From 8319c6fc0d1d70d56012099d3cefc2ed23cf39a5 Mon Sep 17 00:00:00 2001 From: agrechenkov Date: Thu, 9 Jul 2026 16:03:26 -0400 Subject: [PATCH] Add history visibility controls --- CHANGELOG.md | 4 + README.md | 3 +- SPEC.md | 4 + docs/history-visibility-plan.md | 220 +++++++++++++++++++++++++ scripts/ffp_daemon.py | 2 +- scripts/ffp_telemetry.py | 13 +- scripts/ui/web/app.js | 172 +++++++++++++++++-- scripts/ui/web/index.html | 22 ++- scripts/ui/web/styles.css | 104 ++++++++++++ tests/test_dashboard_history_static.py | 39 +++++ tests/test_ffp_daemon.py | 7 +- tests/test_grammar_fix.py | 23 +++ 12 files changed, 587 insertions(+), 26 deletions(-) create mode 100644 docs/history-visibility-plan.md create mode 100644 tests/test_dashboard_history_static.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b2bd4..c6ad742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- **History visibility controls.** The History tab now defaults to a privacy-safe Telemetry view, adds an Exposed view for request/result text that was actually stored, and surfaces the redacted/visible capture setting in context. Rows captured while redacted remain text-free. + ## 2.2.0 **Prompt builder controls.** Flowkey's `prompt:` mode can now target Claude Code or generic chat tools without exposing raw built-in system-prompt editing. diff --git a/README.md b/README.md index 6b96a08..c6e986c 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,8 @@ The dashboard is a web page served by the local daemon — open it from the tray - **Prompt builder:** Config → Prompt builder controls the built-in `prompt:` output style without allowing raw edits to the locked system prompt. - **Models:** pull models with live progress — pick a suggestion or type any name (on Ollama, anything from the [library](https://ollama.com/library) works); set active, remove. Suggestions are hardware-aware: detected RAM/VRAM caps the model size (e.g. 32 GB RAM → ~4B on the NPU; 8 GB VRAM → ~9B on the GPU), oversized models are hidden, and free-typing one asks before pulling. - **Benchmark:** works on both providers — `flm bench` on FastFlowLM (~10–20 min, NPU), timed generations with native metrics on Ollama (~1–3 min, server keeps running). -- **Notes:** browse or search your vault; History shows recent runs (text is stored only if history storage is enabled). +- **History:** Telemetry view shows what ran and how fast; Exposed view shows stored request/result text only for rows captured while history storage was visible. The History tab includes the same redacted/visible storage toggle as Config. +- **Notes:** browse or search your vault. - **Meetings:** connect the local [Quill](https://quillapp.com) app to search meetings, read AI digests (pre-computed after-hours), review action items (accept / reject), and generate a weekly review. Off by default — enable in Config → Meetings. - **Notifications:** per-event toggles, dedupe window, Do-Not-Disturb, and quiet hours; every toast (shown or muted) is logged to the Telemetry feed. diff --git a/SPEC.md b/SPEC.md index 967551c..c2ea6d2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -25,6 +25,7 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - api: `POST /action/` ! header `X-FFP-API: 1` → 200 `{ok,result,error,elapsed_ms}` - api: `GET /` → dashboard; `GET /healthz` → `{ok,version,api,actions}` - action: `config_snapshot` → full cfg; `apply_config_patch {patch}` → merge (whitelist `filter_config_patch`) +- action: `recent_history {limit?}` → newest history rows; `input_text`/`output_text` iff stored @ write-time - action: `prompt_builder_preview {settings?,sample?}` → deterministic local preview (`⊥` LLM call) - action: `notify_gate {title,message}` → `{show,reason,category}` (logs); `notifications_log {limit}` → rows - action: `quill_status` → `{reachable,enabled,server,server_version}` @@ -70,6 +71,8 @@ Caveman-encoded (compression, not amputation). Paths / ids / action names / numb - V22: `sync.ps1` ∃ uncommitted tracked changes → skip pull (⊥ clobber un-pushed WIP) - V23: meeting `NoContentError` & age ≥ 2d → skip-marker (`meeting_skips.jsonl`) ∴ ⊥ re-queue ever; age < 2d → retry (Quill transcript may still sync); non-content errors ⊥ skip-mark. batch errors → `daemon.log` only (⊥ UI panel, per user) - V24: prompt_builder default cfg ⇒ `CLAUDE_PROMPT_SYSTEM_PROMPT` identity; non-default prompt validation target-aware (XML only when effective structure=xml); built-in `modes.prompt.system_prompt` still locked +- V25: History view toggle = display-only; ⊥ mutate `history_store_text`; ⊥ reveal text absent from jsonl row +- V26: History tab load default view = Telemetry (text hidden) even when storage visible ## §T tasks @@ -96,6 +99,7 @@ T18|.|[DOCS] provider roadmap marks selector/status UX incomplete → update to T19|x|[DOCS] first-run wizard text "chat popup" → "Open chat" + hotkey now `^!c` (2.1.1)|B5 T20|x|[DOCS] daemon log location — audited 2.1.1: no stale ref in README/docs; nothing to change|— T21|x|prompt_builder cfg + claude_code identity + generic_chat adapter + dashboard controls/preview|V17,V24 +T22|x|History Telemetry/Exposed views + inline redacted/visible storage control/help|V5,V6,V25,V26 ``` ## §B bugs diff --git a/docs/history-visibility-plan.md b/docs/history-visibility-plan.md new file mode 100644 index 0000000..79c3b3a --- /dev/null +++ b/docs/history-visibility-plan.md @@ -0,0 +1,220 @@ +# Dashboard History Visibility Plan (hidden vs exposed views) + +Date: 2026-07-09 +Target release: **Unreleased** after `v2.2.0` +Status: IMPLEMENTED + VALIDATED locally — pending commit + +## Goal + +Give the History tab clear visibility with two explicit view modes: + +- **Telemetry view** (default, privacy-safe): what/when/how-fast, no captured text. +- **Exposed view**: reveals captured request/result text for entries that + actually stored it. + +Plus surface the text-storage setting *inside* the History tab so users +understand why text is or isn't present, and can change it in context. + +## Implementation status (2026-07-09) + +Implemented: + +- `recent_history` passes through `input_text` and `output_text` only when those + fields exist on the jsonl row. +- History tab defaults to **Telemetry** on every load. +- History tab has an **Exposed** view for stored request/result text and shows + "not stored" for rows captured while redacted. +- History tab surfaces the redacted/visible write-time capture policy with an + inline toggle using existing daemon actions. +- Config tab storage checkbox has a reusable keyboard-accessible help marker. +- `SPEC.md`, README, CHANGELOG, and tests updated. + +Verified: + +- `python -m py_compile scripts\ffp_telemetry.py scripts\ffp_daemon.py` +- `node --check scripts\ui\web\app.js` +- `python -m ruff check scripts tests` → all checks passed +- `python -m pytest tests -q` → 347 passed +- `AutoHotkey64.exe /ErrorStdOut tests\test_parse_mode.ahk` +- `AutoHotkey64.exe /ErrorStdOut tests\test_classify_clipboard.ahk` + +## Current state (verified in code, 2026-07-09) + +- **Setting: `history_store_text`** (boolean, default `false`), a WRITE-time flag. + - Config UI: `#cfg-store-text` checkbox "Store selected text (off = redacted)" + (`scripts/ui/web/index.html:338`), saved via `apply_config_patch` + (`app.js:861`); whitelisted in `filter_config_patch` (`ffp_config.py:504`). + - Tray menu: "🙈 Redacted" / visible (`tray.ahk`), daemon actions + `set_history_visible` / `set_history_redacted` / `toggle_history_text` / + `history_text_status`. + - Overview shows read-only state (`app.js:249`). +- **Write behavior** (`grammar_fix.py` ~1048-1057): every run always stores + telemetry (`mode`, `input_chars`, `output_chars`, `elapsed_seconds`, + `tok_per_sec`, `completion_tokens`, `timestamp`). Only when + `HISTORY_STORE_TEXT` is true does it also store `input_text` and + `output_text`. +- **History tab** (`app.js` `loadHistory` → `recent_history` action): + renders a Telemetry table by default and an Exposed table on request. Stored + `input_text`/`output_text` is visible only in Exposed view; missing fields show + "not stored". +- Privacy invariant **V6**: history text redacted by default + (`history_store_text` false). + +**Net:** the storage toggle is now surfaced where its effect is visible, and +the view toggle remains independent from the write-time storage policy. + +## Key design constraint (must be honest about this) + +Redaction is decided at WRITE time. Therefore: + +- The **exposed view can only reveal text that was already stored.** Rows + captured while redacted have no text on disk — toggling the view (or the + setting) later does **not** retroactively reveal them. +- The view toggle is a **display preference**; the storage setting is a + **capture policy**. They are independent and must never be conflated: + switching to exposed view must not change storage; changing storage must not + alter existing rows. + +So each entry falls into one of three display states: +1. **Telemetry-only** (captured while redacted) → exposed view shows "— (not + stored)". +2. **Text stored** (captured while visible) → exposed view shows the text. +3. Future: **result stored** (if output-text storage is added) → exposed view + shows request + result. + +## Proposed UX + +History tab gains: + +1. **A view switch** (segmented control / two tabs): **Telemetry** (default) and + **Exposed**. Default is always Telemetry on load — privacy-first display. +2. **A storage-state banner** at the top of the tab: + - Redacted: "Text storage: **Redacted** — new runs store telemetry only. + [Change in Config]" (or an inline toggle). + - Visible: "Text storage: **Visible** — new runs store your captured text. + [Change]". + This is where the answer to "where is the setting?" lives — brought to the + tab where its effect is felt. +3. **Exposed view rendering:** each row expands (or adds columns) to show the + captured request text; rows without stored text show a muted "— (not + stored — captured while redacted)". If output-text storage is added, show the + result too. +4. **Exposed-view affordance:** because it renders potentially sensitive text on + screen, gate the first switch with a one-line in-page notice (not a blocking + confirm) — "Showing stored request text." No native confirm (V5). + +Telemetry view = today's 7-column table, unchanged. + +## Inline setting help (hover "read me") + +Problem: the Config label **"Store selected text (off = redacted)"** is easy to +miss and doesn't explain what it does — a user can lose track of what it means +or where it is. + +Add a small, reusable **help affordance** next to settings: an unobtrusive info +marker (e.g. `ⓘ`) after the label that reveals a short explanation on +**hover and keyboard focus**. Ship it on the storage checkbox first; make the +pattern reusable so other cryptic Config settings can adopt it later. + +Design (CSP- and V5-safe): + +- A `` + followed by a ``, both built via + `createElement` + `textContent` (no innerHTML; content is static copy). +- Reveal purely with CSS on `:hover` and `:focus-within` (keyboard-accessible), + positioned as a small popover; no JS needed to show/hide. Link with + `aria-describedby` for screen readers. +- Keep a plain `title` attribute as a no-CSS fallback, but the styled popover is + the primary affordance (native `title` is slow and unstyled). +- No `alert`/`confirm`/`prompt`; no external assets (CSP `default-src 'self'`). + +Copy for the storage setting (exact text to render): + +> **Store selected text.** Controls whether the exact text you send to a hotkey +> is saved in History. **Off (default, "redacted"):** only telemetry is kept — +> mode, character counts, timing, tokens — your text is never written to disk. +> **On ("visible"):** the captured request text and generated result text are +> also saved so you can re-read them in History's Exposed view. This is a capture +> policy for *new* runs; it never reveals or hides text already recorded. +> Everything stays on your machine. + +This help marker is the Config-tab half; the History-tab storage banner (above) +is the same explanation surfaced where the effect is felt. Both should read +consistently. + +## Backend changes (implemented) + +- `recent_history` passes through `input_text` and `output_text` when those + fields are present on a row. No redaction is re-applied at read time because + redaction already happened at write time. +- A per-entry `has_text` / display flag was not added; the frontend checks field + presence. +- Generated output text is stored under the same `HISTORY_STORE_TEXT` guard as + input text. +- All hotkey modes write to the same history file; the `mode` column keeps them + distinguishable. + +## Frontend changes + +- `index.html`: History tab gains the view switch, the storage-state banner, and + an exposed-view container. All DOM via createElement/textContent (V5); no + innerHTML. +- `index.html` + `styles.css`: add a **reusable help marker** (`ⓘ` + tooltip + popover, `.help` / `.help-text`, CSS `:hover`/`:focus-within` reveal) and apply + it to the Config **"Store selected text"** checkbox first. Component is generic + so other settings can adopt it later. +- `app.js`: + - `loadHistory` fetches once; render function takes a `view` arg + (telemetry|exposed). + - Telemetry render = current table. + - Exposed render = table/rows with request (and result) text, "— (not stored)" + for telemetry-only rows. + - Banner reads `history_store_text` from `config_snapshot` (or + `history_text_status`) and offers an inline change (calls + `set_history_visible` / `set_history_redacted`, then refreshes the banner and + Overview). + - Default view = telemetry on every tab load. + +## Privacy invariants to add (SPEC §V) + +- V(new): History **view** toggle is display-only — never changes + `history_store_text` and never reveals text that was not stored at write time. +- V(new): default History **view** is telemetry (text hidden) on load, even when + storage is visible. +- Keep V6 (redacted by default) unchanged. + +## Tests + +- Backend: `recent_history` returns `input_text` for rows written under visible + mode and omits it for rows written under redacted mode (no read-time reveal). +- If output-text storage is added: stored under visible mode, absent under + redacted. +- Frontend (`node --check` + a small DOM-logic unit if feasible): exposed render + shows text when present, "not stored" marker when absent; view toggle does not + call any storage-mutating action; default view is telemetry. +- Help marker: content is set via `textContent` (no innerHTML), reveals on hover + AND focus (keyboard-accessible), and is linked via `aria-describedby`; the copy + matches the History banner wording. +- Config: `history_store_text` still whitelisted, still defaults false. + +## Resolved implementation choices + +1. **Store output text too:** yes, under the existing `history_store_text` guard. + No separate output-text flag was added. +2. **Row-level vs global reveal:** global Exposed view with Telemetry as the + default on every load. +3. **Retention/limit:** unchanged. This batch does not add clear-history or + max-row controls. +4. **Version:** tracked as Unreleased after `v2.2.0`. + +## Rollout order + +1. Backend: `recent_history` text passthrough; `output_text` already stored + under the visible flag; tests. +2. Frontend: view switch + storage banner + exposed render; default telemetry; + reusable help marker (`.help`/`.help-text` in `styles.css`) on the Config + "Store selected text" checkbox. +3. Gates: `node --check` app.js, pytest, ruff. +4. SPEC: add the two view invariants; note the History tab now surfaces the + storage setting. +5. Docs: README History bullet — mention the two views and where storage is set. diff --git a/scripts/ffp_daemon.py b/scripts/ffp_daemon.py index 41ab9af..1e0be81 100644 --- a/scripts/ffp_daemon.py +++ b/scripts/ffp_daemon.py @@ -411,7 +411,7 @@ def _act_mode_ids(_args: dict) -> str: def _act_recent_history(args: dict) -> list[dict]: - """Last N history entries (summary fields only — never stored text).""" + """Last N history entries (telemetry plus stored text when present).""" try: limit = int(args.get("limit") or 50) except (TypeError, ValueError): diff --git a/scripts/ffp_telemetry.py b/scripts/ffp_telemetry.py index fd6a52c..d337f6c 100644 --- a/scripts/ffp_telemetry.py +++ b/scripts/ffp_telemetry.py @@ -18,18 +18,19 @@ def append_history(history_path: Path, entry: dict) -> None: log.warning("append_history failed (%s): %s", history_path, exc) -# Summary fields exposed to dashboard clients. input_text / output_text are -# deliberately excluded so stored selection text can never leak into a client -# that only needs telemetry (e.g. the web dashboard's History tab). -_HISTORY_SUMMARY_KEYS = ( +# Fields exposed to dashboard clients. input_text / output_text are included +# only when they were written under history_store_text=True; rows captured while +# redacted have no text to reveal at read time. +_HISTORY_DISPLAY_KEYS = ( "timestamp", "ts", "status", "mode", "source", "model", "strategy", "input_chars", "output_chars", "elapsed_seconds", "api_time", "prompt_tokens", "completion_tokens", "tok_per_sec", + "input_text", "output_text", ) def recent_history(history_path: Path, limit: int = 50) -> list[dict]: - """Last `limit` history entries, newest first, summary fields only.""" + """Last `limit` history entries, newest first, with stored text if present.""" entries: list[dict] = [] if not history_path.exists(): return entries @@ -47,7 +48,7 @@ def recent_history(history_path: Path, limit: int = 50) -> list[dict]: row = json.loads(raw) except Exception: continue - entries.append({key: row[key] for key in _HISTORY_SUMMARY_KEYS if key in row}) + entries.append({key: row[key] for key in _HISTORY_DISPLAY_KEYS if key in row}) if len(entries) >= limit: break return entries diff --git a/scripts/ui/web/app.js b/scripts/ui/web/app.js index 177d1ff..8534aa9 100644 --- a/scripts/ui/web/app.js +++ b/scripts/ui/web/app.js @@ -30,6 +30,26 @@ function setStatus(id, message, ok = true) { el.className = ok ? "ok" : "bad"; } +function attachHelpMarker(containerId, text) { + const wrap = $(containerId); + if (!wrap || wrap.childElementCount > 0) return; + const tipId = `${containerId}-tooltip`; + const marker = document.createElement("span"); + marker.className = "help"; + marker.tabIndex = 0; + marker.setAttribute("role", "img"); + marker.setAttribute("aria-label", "What is this?"); + marker.setAttribute("aria-describedby", tipId); + marker.title = text; + marker.textContent = "i"; + const tip = document.createElement("span"); + tip.className = "help-text"; + tip.id = tipId; + tip.setAttribute("role", "tooltip"); + tip.textContent = text; + wrap.append(marker, tip); +} + // In-page confirmation modal. We never use native confirm()/alert()/prompt() — // they break the dashboard's look and feel. Returns a Promise. All DOM // via createElement/textContent (no innerHTML; CSP-safe). @@ -104,6 +124,7 @@ const PROMPT_BUILDER_DEFAULTS = { allow_user_suffix: true, user_suffix: "", }; +const HISTORY_STORE_HELP_TEXT = "Store selected text. Controls whether the exact text you send to a hotkey is saved in History. Off (default, redacted): only telemetry is kept - mode, character counts, timing, tokens - your text is never written to disk. On (visible): the captured request and generated result text are also saved so you can re-read them in History's Exposed view. This is a capture policy for new runs; it never reveals or hides text already recorded. Everything stays on your machine."; // ---- LLM providers ----------------------------------------------------------- // The daemon resolves the *effective* provider (configured one, with fallback @@ -384,22 +405,145 @@ function renderHours(buckets) { // ---- History --------------------------------------------------------------- +const HISTORY_TELEMETRY_HEADERS = ["When", "Mode", "In", "Out", "Latency", "tok/s", "Tokens"]; +const HISTORY_EXPOSED_HEADERS = ["When", "Mode", "Request", "Result", "Latency"]; +let historyEntries = []; +let historyView = "telemetry"; +let historyStoreText = false; + +function historyTime(e) { + return String(e.timestamp || e.ts || "-").slice(0, 19).replace("T", " "); +} + +function historyLatency(e) { + const value = e.elapsed_seconds ?? e.api_time; + return value === undefined || value === null || value === "" ? "-" : `${value}s`; +} + +function setHistoryColumns(headers) { + const head = $("history-head"); + head.replaceChildren(); + const tr = document.createElement("tr"); + for (const label of headers) { + const th = document.createElement("th"); + th.textContent = label; + tr.append(th); + } + head.append(tr); +} + +function renderHistoryTable(headers, rows, textColumns = []) { + setHistoryColumns(headers); + const body = $("history-body"); + body.replaceChildren(); + for (const cells of rows) { + const tr = document.createElement("tr"); + cells.forEach((cell, index) => { + const td = document.createElement("td"); + td.textContent = cell; + if (textColumns.includes(index)) td.classList.add("history-text-cell"); + tr.append(td); + }); + body.append(tr); + } + $("history-empty").hidden = rows.length > 0; +} + +function storedHistoryText(e, key) { + if (Object.prototype.hasOwnProperty.call(e, key)) { + const text = String(e[key] ?? ""); + if (text.trim()) return text; + } + return "- (not stored - captured while redacted)"; +} + +function updateHistoryViewButtons() { + const telemetry = historyView === "telemetry"; + $("history-view-telemetry").classList.toggle("active", telemetry); + $("history-view-exposed").classList.toggle("active", !telemetry); + $("history-view-telemetry").setAttribute("aria-pressed", telemetry ? "true" : "false"); + $("history-view-exposed").setAttribute("aria-pressed", telemetry ? "false" : "true"); + $("history-exposed-note").hidden = telemetry; + $("history-table").classList.toggle("history-exposed", !telemetry); +} + +function renderHistoryStorageBanner() { + const copy = $("history-storage-copy"); + const button = $("history-storage-action"); + if (historyStoreText) { + copy.textContent = "Text storage: Visible - new runs store captured request and result text."; + button.textContent = "Switch to redacted"; + button.dataset.target = "redacted"; + } else { + copy.textContent = "Text storage: Redacted - new runs store telemetry only."; + button.textContent = "Store new text"; + button.dataset.target = "visible"; + } +} + +function renderHistory() { + updateHistoryViewButtons(); + if (historyView === "exposed") { + const rows = historyEntries.map((e) => [ + historyTime(e), + e.mode || "?", + storedHistoryText(e, "input_text"), + storedHistoryText(e, "output_text"), + historyLatency(e), + ]); + renderHistoryTable(HISTORY_EXPOSED_HEADERS, rows, [2, 3]); + return; + } + const rows = historyEntries.map((e) => [ + historyTime(e), + e.mode || "?", + e.input_chars ?? "?", + e.output_chars ?? "?", + historyLatency(e), + e.tok_per_sec ?? "-", + e.completion_tokens ?? "-", + ]); + renderHistoryTable(HISTORY_TELEMETRY_HEADERS, rows); +} + +function setHistoryView(view) { + historyView = view === "exposed" ? "exposed" : "telemetry"; + renderHistory(); +} + +async function setHistoryStorageFromBanner() { + const target = $("history-storage-action").dataset.target === "visible" ? "visible" : "redacted"; + $("history-storage-action").disabled = true; + try { + await action(target === "visible" ? "set_history_visible" : "set_history_redacted"); + historyStoreText = target === "visible"; + $("cfg-store-text").checked = historyStoreText; + renderHistoryStorageBanner(); + loadOverview(); + } catch (e) { + $("history-storage-copy").textContent = `Text storage change failed: ${e.message}`; + } finally { + $("history-storage-action").disabled = false; + } +} + async function loadHistory() { + historyView = "telemetry"; try { - const entries = await action("recent_history", { limit: 50 }); - const rows = entries.map((e) => [ - String(e.timestamp || e.ts || "-").slice(0, 19).replace("T", " "), - e.mode || "?", - e.input_chars ?? "?", - e.output_chars ?? "?", - `${e.elapsed_seconds ?? e.api_time ?? "-"}s`, - e.tok_per_sec ?? "-", - e.completion_tokens ?? "-", + const [entries, cfg] = await Promise.all([ + action("recent_history", { limit: 50 }), + action("config_snapshot"), ]); - const n = fillTable("history-body", rows); - $("history-empty").hidden = n > 0; + historyEntries = Array.isArray(entries) ? entries : []; + historyStoreText = !!cfg.history_store_text; + renderHistoryStorageBanner(); + renderHistory(); } catch (e) { - fillTable("history-body", [[`History unavailable: ${e.message}`, "", "", "", "", "", ""]]); + historyEntries = []; + renderHistoryStorageBanner(); + updateHistoryViewButtons(); + renderHistoryTable(HISTORY_TELEMETRY_HEADERS, [[`History unavailable: ${e.message}`, "", "", "", "", "", ""]]); + $("history-empty").hidden = true; } } @@ -1630,6 +1774,7 @@ function tabFromHash() { } document.addEventListener("DOMContentLoaded", () => { + attachHelpMarker("history-store-help", HISTORY_STORE_HELP_TEXT); $("tabs").addEventListener("click", (e) => { const btn = e.target.closest(".tab"); if (btn) { location.hash = btn.dataset.tab; switchTab(btn.dataset.tab); } @@ -1655,6 +1800,9 @@ document.addEventListener("DOMContentLoaded", () => { $("nr-move").addEventListener("click", moveNoteToBucket); $("nr-delete").addEventListener("click", deleteCurrentNote); $("nr-close").addEventListener("click", () => { $("note-reader").hidden = true; }); + $("history-view-telemetry").addEventListener("click", () => setHistoryView("telemetry")); + $("history-view-exposed").addEventListener("click", () => setHistoryView("exposed")); + $("history-storage-action").addEventListener("click", setHistoryStorageFromBanner); $("mtg-search-btn").addEventListener("click", searchMeetings); $("mtg-query").addEventListener("keydown", (e) => { if (e.key === "Enter") searchMeetings(); }); $("mtg-load-more").addEventListener("click", loadMoreMeetings); diff --git a/scripts/ui/web/index.html b/scripts/ui/web/index.html index 8afc3ef..3a25941 100644 --- a/scripts/ui/web/index.html +++ b/scripts/ui/web/index.html @@ -130,9 +130,20 @@

Notifications (last 50 — shown & muted; conf
-

Recent activity (last 50, newest first — summary fields only)

- - +

Recent activity (last 50, newest first)

+
+
+ + +
+
+ Text storage: checking... + +
+
+ +
WhenModeInOutLatencytok/sTokens
+
WhenModeInOutLatencytok/sTokens
@@ -335,7 +346,10 @@

Performance & history

- +
+ + +

Tone preset (tone: prefix)

diff --git a/scripts/ui/web/styles.css b/scripts/ui/web/styles.css index 899d336..fa501bd 100644 --- a/scripts/ui/web/styles.css +++ b/scripts/ui/web/styles.css @@ -418,6 +418,53 @@ kbd { } .data-table tr:last-child td { border-bottom: none; } .data-table tbody tr:hover td { background: color-mix(in srgb, var(--accent) 5%, transparent); } +.data-table.history-exposed td { vertical-align: top; } +.data-table td.history-text-cell { + white-space: pre-wrap; + overflow: visible; + text-overflow: clip; + max-width: 520px; + line-height: 1.35; +} + +.history-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: space-between; + margin: 8px 0 10px; +} +.segmented { + display: inline-flex; + padding: 2px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} +.seg-btn { + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + padding: 5px 11px; + cursor: pointer; +} +.seg-btn.active { + background: var(--accent); + color: #fff; +} +.history-storage-banner { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 8px; + background: color-mix(in srgb, var(--accent) 5%, transparent); +} +.history-notice { margin: 6px 0 10px; } /* ---- Forms (Notes, Config) ---- */ input[type="text"], input[type="number"], textarea, select { @@ -455,6 +502,63 @@ input[type="number"] { max-width: 120px; } cursor: pointer; } .check-row input { width: auto; margin-right: 8px; accent-color: var(--accent); } +.check-help-row { + display: flex; + align-items: center; + gap: 4px; + margin: 8px 0; +} +.check-help-row .check-row { margin: 0; } +.help-wrap { + position: relative; + display: inline-flex; + align-items: center; +} +.help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 17px; + height: 17px; + border: 1px solid var(--border-strong); + border-radius: 50%; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + line-height: 1; + cursor: help; +} +.help:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--ring); +} +.help-text { + position: absolute; + left: 0; + top: calc(100% + 7px); + z-index: 20; + width: min(520px, calc(100vw - 48px)); + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); + color: var(--text); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18); + font-size: 12px; + line-height: 1.45; + white-space: normal; + opacity: 0; + visibility: hidden; + transform: translateY(-2px); + transition: opacity 0.12s, transform 0.12s, visibility 0.12s; +} +.help-wrap:hover .help-text, +.help-wrap:focus-within .help-text { + opacity: 1; + visibility: visible; + transform: translateY(0); +} /* Scrollable list container (meetings results, etc.) */ .table-scroll { max-height: 520px; overflow-y: auto; } diff --git a/tests/test_dashboard_history_static.py b/tests/test_dashboard_history_static.py new file mode 100644 index 0000000..f73f7bc --- /dev/null +++ b/tests/test_dashboard_history_static.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WEB = ROOT / "scripts" / "ui" / "web" + + +def test_history_visibility_dashboard_contract(): + index = (WEB / "index.html").read_text(encoding="utf-8") + app = (WEB / "app.js").read_text(encoding="utf-8") + styles = (WEB / "styles.css").read_text(encoding="utf-8") + + for needle in ( + 'id="history-view-telemetry"', + 'id="history-view-exposed"', + 'id="history-storage-copy"', + 'id="history-storage-action"', + 'id="history-exposed-note"', + 'id="history-head"', + 'id="history-store-help"', + ): + assert needle in index + + assert "HISTORY_STORE_HELP_TEXT" in app + assert 'attachHelpMarker("history-store-help", HISTORY_STORE_HELP_TEXT)' in app + assert "tip.textContent = text" in app + assert "set_history_visible" in app + assert "set_history_redacted" in app + assert 'historyView = "telemetry";' in app + assert ".innerHTML" not in app + assert "window.confirm" not in app + assert ".help-wrap:hover .help-text" in styles + assert ".help-wrap:focus-within .help-text" in styles + + view_fn = re.search(r"function setHistoryView\(view\) \{(?P.*?)\n\}", app, flags=re.DOTALL) + assert view_fn + assert "set_history_" not in view_fn.group("body") diff --git a/tests/test_ffp_daemon.py b/tests/test_ffp_daemon.py index 380083f..5078ab9 100644 --- a/tests/test_ffp_daemon.py +++ b/tests/test_ffp_daemon.py @@ -320,7 +320,7 @@ def test_post_provider_status_returns_capabilities(daemon_server): assert set(payload["result"]["providers"]) >= {"fastflowlm", "ollama"} -def test_recent_history_returns_summary_without_text(daemon_server): +def test_recent_history_returns_stored_text_when_present(daemon_server): daemon_module, base_url = daemon_server history_path = daemon_module.grammar_fix.HISTORY_PATH history_path.parent.mkdir(parents=True, exist_ok=True) @@ -338,7 +338,10 @@ def test_recent_history_returns_summary_without_text(daemon_server): assert status == 200 entries = payload["result"] assert [e["mode"] for e in entries] == ["prompt", "grammar"] # newest first - assert all("input_text" not in e and "output_text" not in e for e in entries) + assert "input_text" not in entries[0] + assert "output_text" not in entries[0] + assert entries[1]["input_text"] == "SECRET-IN" + assert entries[1]["output_text"] == "SECRET-OUT" assert entries[1]["input_chars"] == 10 diff --git a/tests/test_grammar_fix.py b/tests/test_grammar_fix.py index 015f7c6..96569d7 100644 --- a/tests/test_grammar_fix.py +++ b/tests/test_grammar_fix.py @@ -263,6 +263,29 @@ def test_append_history_writes_jsonl_line(fresh_modules): assert json.loads(rows[0]) == entry +def test_main_stores_history_text_only_when_visible(fresh_modules, monkeypatch): + grammar_fix = fresh_modules("grammar_fix") + entries = [] + outputs = [] + monkeypatch.setattr(sys, "argv", ["grammar_fix.py"]) + monkeypatch.setattr(grammar_fix, "_read_input_text", lambda: "source text") + monkeypatch.setattr(grammar_fix, "_write_output_text", outputs.append) + monkeypatch.setattr(grammar_fix, "append_history", entries.append) + monkeypatch.setattr(grammar_fix, "_snapshot_usage_acc", lambda: {"prompt_tokens": 4, "completion_tokens": 5}) + monkeypatch.setattr(grammar_fix, "call_flm", lambda mode, text: ("fixed text", 2.0, "model", "strategy")) + + grammar_fix.HISTORY_STORE_TEXT = True + grammar_fix.main() + assert entries[-1]["input_text"] == "source text" + assert entries[-1]["output_text"] == "fixed text" + + grammar_fix.HISTORY_STORE_TEXT = False + grammar_fix.main() + assert "input_text" not in entries[-1] + assert "output_text" not in entries[-1] + assert outputs == ["fixed text", "fixed text"] + + def test_split_chunks_returns_single_chunk_when_short(fresh_modules): grammar_fix = fresh_modules("grammar_fix")