From cb39f828e021f60ead0d5d635efbf4748b4d1ab5 Mon Sep 17 00:00:00 2001 From: evmcod <214792911+EVMCOD@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:54:18 +0100 Subject: [PATCH] fix: what the product screenshots showed (dark theme, no-key answers, note actions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking screenshots for the website and Product Hunt from a fictional demo vault showed: - the ◐ toggle did nothing on the dashboard: app.css resolved its colour aliases on :root, so body.dark changing --cl-* never reached them (checked on main: body stays rgb(255,255,255) with body.dark set); now on body too; - "What did we decide about pricing?" without a key quoted a daily note and a link line, not the decision: words of one family now match (rough stem to four letters), a line inherits the words of its heading and shows it, the title adds a quarter point, and notes are ordered by their best line; - the note view's Key points / Summarize / Action items / Expand were fixed to Spanish for every user: they follow the browser language now, and the API and note_ai default to English; - search results on the notes home use content_snip (words around the match) instead of the few-word excerpt. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++ ops_dashboard/brain.py | 58 +++++++++++++++++++++++-------- ops_dashboard/indexers/note_ai.py | 4 +-- ops_dashboard/server.py | 2 +- ops_dashboard/web/app.css | 12 ++++--- ops_dashboard/web/app.js | 3 +- ops_dashboard/web/note.html | 28 +++++++++------ tests/test_brain_no_key.py | 30 ++++++++++++++++ tests/test_notes_home_static.py | 15 ++++++++ 9 files changed, 122 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b4701..5fbe1a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ User-facing changes to ConductLoop (the desktop app and its local server), newes ## Unreleased +- **The dark theme works on the dashboard.** The ◐ toggle added the `dark` class but the dashboard stayed white: its colour aliases were resolved on `:root` with the light palette. They are now declared on `body` too. +- **Better answers without an AI key.** Words of one family match ("decide" and "decision", "pricing" and "price"), a line under a matching heading counts it and shows it ("Decision (2026-10-21) — Keep both plans…"), a note's title adds a little, and the note with the best line comes first. Asked "What did we decide about pricing?", it now quotes the decision instead of a daily note that mentions pricing. +- **Note actions in your language.** Key points, Summarize, Action items and Expand answered in Spanish for everyone; they now follow the browser's language (Spanish for es-*, English otherwise), and the API defaults to English. The Translate buttons keep their own language. +- Search results on the notes home show the words around the match instead of a few words. - **Copy that is true on every system.** The welcome page said the AI key "is stored in the Keychain of this Mac", also on Linux and Windows (seen in the first Linux and Windows smoke tests). It now says the system keychain (the Keychain on a Mac) or an encrypted file. The same for the other "this Mac" / "este Mac" in the interface (Connectors, Learning, the feedback buttons, Servers, Media): "this computer" / "este ordenador". Nothing changes on the Mac except the wording. - CI only: the "Windows bundles" workflow now runs `scripts/windows-smoke.ps1` on the NSIS installer: it installs it silently, starts the app as a new user, and checks the server, that `/api/platform` says Windows with the in-app browser and code execution off, the first run and welcome page, the window (with a screenshot), that the app and its server are still running 20 s later, that closing the window stops the server, and that **uninstalling keeps the user's data** (it lives in `%LOCALAPPDATA%\ConductLoop\data`, inside the install folder). Still informational (a runner has no real desktop session), but each failed check is now an error annotation on the run page, and the screenshot and log are kept for 3 days. - CI only: the Linux bundles workflow now **starts the AppImage and the .deb** it builds, on Ubuntu 22.04 (the build image) and on ubuntu-latest, on a virtual display with an empty HOME and no keyring. `scripts/linux-smoke.sh` checks that the local server answers, that `/api/platform` says Linux with the in-app browser off, that it is a first run with the welcome page, that a ConductLoop window is on screen, and that closing the app stops the server; screenshots and logs are kept for 3 days. Until now the Linux packages were only built, never run. The script also works by hand on a Linux machine. diff --git a/ops_dashboard/brain.py b/ops_dashboard/brain.py index 56b78f1..fc75d60 100644 --- a/ops_dashboard/brain.py +++ b/ops_dashboard/brain.py @@ -423,9 +423,21 @@ def ask(self, system: str, messages: list[dict], **kwargs) -> str: ) +_SUFFIXES = ("ations", "ation", "ings", "ing", "ions", "ion", "ers", "er", "ed", "es", "s", "e") + + +def _key(word: str) -> str: + """A rough stem to match words of one family: "decide", "decided" and + "decision" give "deci"; "pricing", "price" and "prices" give "pric".""" + for suffix in _SUFFIXES: + if word.endswith(suffix) and len(word) - len(suffix) >= 4: + word = word[: -len(suffix)] + break + return word[:4] + + def _terms(text: str) -> set[str]: - words = re.findall(r"[^\W_]{3,}", text.lower()) - return {w[:-1] if len(w) > 4 and w.endswith("s") else w for w in words if w not in _STOPWORDS} + return {_key(w) for w in re.findall(r"[^\W_]{3,}", text.lower()) if w not in _STOPWORDS} def _matching_lines(system: str, question: str, *, docs: int = 3, per_doc: int = 2) -> list[str]: @@ -461,30 +473,46 @@ def _matching_lines(system: str, question: str, *, docs: int = 3, per_doc: int = if body and body[0].strip() == "---": end = next((k for k in range(1, len(body)) if body[k].strip() == "---"), None) body = body[end + 1:] if end is not None else body + # Each line counts the question's words it has, plus those of the + # heading it sits under ("## Decision" then "Keep both plans…"; half for + # the lines after the first), plus a quarter point for each one in the + # note's title. Headings are context, not answers: a line is only a + # candidate if it or its heading matches. scored = [] + heading, heading_terms, under = "", set(), 0 for k, raw in enumerate(body): - text = re.sub(r"^#+\s*", "", raw.strip()) + stripped = raw.strip() + text = re.sub(r"^#+\s*", "", stripped) if not text or text.startswith(("<<<", "url: ")): continue if text.startswith("title: "): b["title"] = text[7:].strip() or b["title"] continue - if text.lower() == b["title"].lower(): # the note's own heading + if stripped.startswith("#"): + same = text.lower() == b["title"].lower() # the note's own heading + heading, heading_terms = ("", set()) if same else (text, _terms(text) & terms) + under = 0 + continue + under += 1 + own = _terms(text) & terms + context = heading_terms - own + if not own and not context: continue - score = sum(1 for t in terms if t in text.lower()) - if score: - scored.append((score, k, text if len(text) <= 240 else text[:239] + "…")) + title = (_terms(b["title"]) & terms) - own - context + shown = f"{heading} — {text}" if context else text + score = len(own) + len(context) * (1 if under == 1 else 0.5) + 0.25 * len(title) + scored.append((score, k, shown if len(shown) <= 240 else shown[:239] + "…")) b["scored"] = scored - # A line has to share at least half as many words with the question as the - # best line does: one common word ("need") is not a match next to four. - top = max((s[0] for b in blocks for s in b["scored"]), default=0) - floor = max(1, (top + 1) // 2) - for b in blocks: - scored = [s for s in b["scored"] if s[0] >= floor] + b["best"] = max((x[0] for x in scored), default=0) + # A line has to score at least half as much as the best line: one common + # word ("need") is not a match next to four. Notes go best line first. + top = max((b["best"] for b in blocks), default=0) + for b in sorted(blocks, key=lambda b: (-b["best"], int(b["n"]))): + scored = [x for x in b["scored"] if x[0] >= top / 2] if not scored: continue - best = sorted(sorted(scored, key=lambda s: (-s[0], s[1]))[:per_doc], key=lambda s: s[1]) - out.append(f"**{b['title']}** [{b['n']}]\n" + "\n".join(f"> {s[2]}" for s in best)) + best = sorted(sorted(scored, key=lambda x: (-x[0], x[1]))[:per_doc], key=lambda x: x[1]) + out.append(f"**{b['title']}** [{b['n']}]\n" + "\n".join(f"> {x[2]}" for x in best)) if len(out) >= docs: break return out diff --git a/ops_dashboard/indexers/note_ai.py b/ops_dashboard/indexers/note_ai.py index 88ccaf4..63c9fb1 100644 --- a/ops_dashboard/indexers/note_ai.py +++ b/ops_dashboard/indexers/note_ai.py @@ -7,7 +7,7 @@ POST /api/notes//ai-action {action: 'summarize'|'expand'|'translate'|'key_points'|'action_items', target_lang?: 'es'|'en'|...} -For 'translate', target_lang defaults to 'es'. +For 'translate', target_lang defaults to 'en'. The result is returned as JSON and can be applied to a new note or appended to the existing one (caller's choice). @@ -54,7 +54,7 @@ ), } -DEFAULT_LANG = "es" +DEFAULT_LANG = "en" def get_note_content(doc_id: int) -> tuple[dict | None, str | None]: diff --git a/ops_dashboard/server.py b/ops_dashboard/server.py index 28c388f..0e5ba51 100644 --- a/ops_dashboard/server.py +++ b/ops_dashboard/server.py @@ -4873,7 +4873,7 @@ def _notes_ai_action(self, doc_id: int) -> None: try: data = self._read_json() except Exception: data = {} action = data.get("action") or "" - target_lang = data.get("lang") or data.get("target_lang") or "es" + target_lang = data.get("lang") or data.get("target_lang") or "en" provider = data.get("provider") or None from .indexers import note_ai result = note_ai.run_action(doc_id, action, target_lang=target_lang, provider_name=provider) diff --git a/ops_dashboard/web/app.css b/ops_dashboard/web/app.css index 7f53f39..7f3e62e 100644 --- a/ops_dashboard/web/app.css +++ b/ops_dashboard/web/app.css @@ -8,8 +8,11 @@ /* Design system (2026-10-23). The legacy token names below are aliases of the shared system in /theme.css, so this page adopts one palette without - touching the component CSS. New code should use the --cl-* names. */ - :root { + touching the component CSS. New code should use the --cl-* names. + Declared on body too: an alias is resolved where it is declared, so on + :root alone it kept the light palette when body.dark changed --cl-* + (the dark toggle did nothing on the dashboard). */ + :root, body { --bg: var(--cl-bg); --bg-soft: var(--cl-bg-soft); --bg-2: var(--cl-bg-3); @@ -27,9 +30,8 @@ --red: var(--cl-danger); --yellow: var(--cl-warn); } - /* Dark mode now comes from the shared system (/theme.css -> body.dark), - so there is one palette instead of two that drift apart. */ - body.dark { } + /* Dark mode comes from the shared system (/theme.css -> body.dark), so + there is one palette instead of two that drift apart. */ * { box-sizing: border-box; } html, body { margin: 0; padding: 0; height: 100%; } body { diff --git a/ops_dashboard/web/app.js b/ops_dashboard/web/app.js index 3d6365f..9ebe432 100644 --- a/ops_dashboard/web/app.js +++ b/ops_dashboard/web/app.js @@ -538,7 +538,8 @@ function renderNotesHome() { var li = el('li'); var a = link('/note/' + encodeURIComponent(r.id), r.title || String(r.path || '').split('/').pop()); li.appendChild(a); - var excerpt = String(r.excerpt || '').replace(/\s+/g, ' ').trim(); + // content_snip has the words around the match; excerpt is a few words only. + var excerpt = String(r.content_snip || r.excerpt || '').replace(/\s+/g, ' ').trim(); if (excerpt) li.appendChild(el('span', 'nh-excerpt', excerpt.slice(0, 160))); list.appendChild(li); }); diff --git a/ops_dashboard/web/note.html b/ops_dashboard/web/note.html index 22e2cd4..ce66def 100644 --- a/ops_dashboard/web/note.html +++ b/ops_dashboard/web/note.html @@ -73,20 +73,20 @@

Loading…