diff --git a/examples/desktop/docs/qa-nav-optional/01-default-hidden.png b/examples/desktop/docs/qa-nav-optional/01-default-hidden.png new file mode 100644 index 0000000000..545b4a403d Binary files /dev/null and b/examples/desktop/docs/qa-nav-optional/01-default-hidden.png differ diff --git a/examples/desktop/docs/qa-nav-optional/02-empty-array.png b/examples/desktop/docs/qa-nav-optional/02-empty-array.png new file mode 100644 index 0000000000..5bea104f4a Binary files /dev/null and b/examples/desktop/docs/qa-nav-optional/02-empty-array.png differ diff --git a/examples/desktop/docs/qa-nav-optional/03-custom-list.png b/examples/desktop/docs/qa-nav-optional/03-custom-list.png new file mode 100644 index 0000000000..420e049a09 Binary files /dev/null and b/examples/desktop/docs/qa-nav-optional/03-custom-list.png differ diff --git a/examples/desktop/scripts/qa-cdp-shoot-nav-optional.mjs b/examples/desktop/scripts/qa-cdp-shoot-nav-optional.mjs new file mode 100644 index 0000000000..1b96f1c7ff --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-shoot-nav-optional.mjs @@ -0,0 +1,255 @@ +// QA verification script for lane-nav-optional. Boots an isolated +// Electron on CDP :9272, rewrites the shell config for three fixture +// cases (missing hiddenPages / empty [] / custom list), and takes one +// sidebar screenshot for each case into docs/qa-nav-optional/. +// +// Isolation follows the 2026-07-18 postmortem in +// scripts/qa-cdp-shoot-affordance.mjs: +// 1. --user-data-dir= isolates Chromium userdata. +// 2. DSH_DESKTOP_HOME= isolates main-process config root so we +// never write into ~/.dsh-desktop. +// +// Why three passes not one boot with hot reloads: the hidden-page filter +// reads config.json through IPC at renderer init; we could plumb a +// window.__dshNavFilter.apply() to re-read after we mutate the file, +// but the point of the shoot is to prove the boot-time filter honors +// the file as-shipped. So we relaunch three times with different +// on-disk configs — a clean-room reproduction of the three cases a +// researcher would actually hit. + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { resolve, join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { tmpdir } from 'node:os' + +const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd()) +const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo') +const ELECTRON = join(PARENT, 'node_modules/.bin/electron') +// One CDP port per case — Electron helper processes can linger past the +// main process death and hold the debug port bound, which quietly forces +// case-2/3 to attach to case-1's DevTools endpoint. Isolating ports side- +// steps the whole race. +const CDP_PORT_BASE = Number(process.env.DSH_NAV_OPTIONAL_PORT || 9272) +const OUTDIR = join(WORKTREE, 'docs/qa-nav-optional') + +if (!existsSync(ELECTRON)) { + console.error(`electron binary not found at ${ELECTRON}`) + process.exit(2) +} +mkdirSync(OUTDIR, { recursive: true }) + +// Three fixture cases per task spec: +// • case-1: config.json missing hiddenPages → default (playground+mission hidden) +// • case-2: hiddenPages = [] → all pages visible +// • case-3: hiddenPages = ["prs","growth"] → PRs + Growth hidden +const CASES = [ + { + name: '01-default-hidden', + label: 'missing hiddenPages → default (playground+mission hidden)', + config: { role: 'coding', approvalMode: 'never' }, + }, + { + name: '02-empty-array', + label: 'hiddenPages=[] → everything visible', + config: { role: 'coding', approvalMode: 'never', hiddenPages: [] }, + }, + { + name: '03-custom-list', + label: 'hiddenPages=["prs","growth"] → PRs+Growth hidden', + config: { role: 'coding', approvalMode: 'never', hiddenPages: ['prs', 'growth'] }, + }, +] + +function seedHome(dshHome, cfg) { + // Every fresh boot writes a minimal overlay + config.json + .onboarded + // sentinel so the first-run modal doesn't fire and rewrite our seed. + const seedOverlay = [ + '# QA nav-optional-shoot seed overlay (tmp, per-run).', + 'plugins:', + ` - "@cordisjs/plugin-include":`, + ` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`, + '', + ].join('\n') + writeFileSync(join(dshHome, 'user-overlay.cordis.yml'), seedOverlay) + writeFileSync(join(dshHome, 'config.json'), JSON.stringify(cfg, null, 2)) + writeFileSync(join(dshHome, '.onboarded'), new Date().toISOString()) +} + +async function bootElectron(dshHome, userData, port) { + const child = spawn(ELECTRON, [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${userData}`, + '--disable-gpu', + '--no-sandbox', + '.', + ], { + cwd: WORKTREE, + env: { + ...process.env, + DSH_DESKTOP_HOME: dshHome, + DSH_MAXIMIZE: '1', + // Deliberately NOT setting DSH_QA=1: qa-harness.js §11 clicks every + // onboarding button on boot including the skip path, which triggers + // onboarding:apply and rewrites config.json without our seeded + // hiddenPages. See main.js:763 — `writeShellConfig({role,approvalMode,createdAt})` + // drops any other fields on the floor. + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const logs = [] + child.stdout.on('data', d => { const s = String(d); logs.push(s); if (s.includes('[nav:')) process.stdout.write('MAIN_STDOUT: ' + s) }) + child.stderr.on('data', d => { const s = String(d); logs.push(s); if (s.includes('[nav:')) process.stdout.write('MAIN_STDERR: ' + s) }) + for (let i = 0; i < 40; i++) { + await sleep(500) + try { + const r = await fetch(`http://localhost:${port}/json/list`) + if (r.ok) return { child, logs } + } catch {} + } + child.kill('SIGKILL') + console.error('electron CDP did not come up in 20s. logs:\n' + logs.join('')) + process.exit(3) +} + +async function newCdp(port) { + const targets = await (await fetch(`http://localhost:${port}/json/list`)).json() + const target = targets.find(t => t.type === 'page') + if (!target) throw new Error('no page target on port ' + port) + const ws = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) }) + let id = 1 + const pending = new Map() + const chunks = [] + ws.onmessage = ev => { + const data = typeof ev.data === 'string' ? ev.data : String(ev.data) + let msg + try { msg = JSON.parse(data) } catch { chunks.push(data); return } + if (msg.id != null && pending.has(msg.id)) { + const [ok, err] = pending.get(msg.id); pending.delete(msg.id) + if (msg.error) err(new Error(msg.error.message)); else ok(msg.result) + } + } + const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => { + const _id = id++ + const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms) + pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }]) + ws.send(JSON.stringify({ id: _id, method: m, params: p })) + }) + const evj = async expr => { + const r = await call('Runtime.evaluate', { + expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`, + returnByValue: true, awaitPromise: true, + }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text) + return r.result?.value + } + return { ws, call, evj } +} + +async function runCase(kase, idx) { + const port = CDP_PORT_BASE + idx + const dshHome = join(tmpdir(), `dsh-nav-optional-home-${kase.name}`) + const userData = join(tmpdir(), `dsh-nav-optional-userdata-${kase.name}`) + for (const dir of [dshHome, userData]) { + try { rmSync(dir, { recursive: true, force: true }) } catch {} + mkdirSync(dir, { recursive: true }) + } + seedHome(dshHome, kase.config) + console.log(`[${kase.name}] booting on port ${port}: ${kase.label}`) + const { child } = await bootElectron(dshHome, userData, port) + try { + // Give the renderer a beat to run applyNavHiddenPages. + await sleep(1500) + const { call, evj } = await newCdp(port) + await call('Page.enable') + + // Diagnostics before assertion: prove the IPC path is reachable and + // the model resolves the fixture case we expect. Without this, a + // race between the applyNavHiddenPages await and the shot capture + // leaves us reading DOM that hasn't been filtered yet. + const diag = await evj(` + (async () => { + const M = window.__dshNavConfigModel || null + const raw = window.dsh && window.dsh.nav && typeof window.dsh.nav.getHiddenPages === 'function' + ? await window.dsh.nav.getHiddenPages() + : '' + const resolved = M ? M.resolveHiddenPages(raw || {}) : '' + // Force-run the filter now so the DOM is definitely up-to-date + // before we assert. This is what the settings-page.js checkbox + // path calls on toggle too. + if (window.__dshNavFilter && typeof window.__dshNavFilter.apply === 'function') { + await window.__dshNavFilter.apply() + } + return { hasModel: !!M, ipcResult: raw, resolved } + })() + `) + console.log(` diag :`, JSON.stringify(diag)) + + // Assert the DOM matches expectation before we capture — cheap + // sanity so a broken build fails loud instead of shipping a bad shot. + const visible = await evj(` + Array.from(document.querySelectorAll('.sidebar-nav .tab-btn')) + .filter(b => !b.classList.contains('nav-item--hidden')) + .map(b => b.dataset.tab) + `) + const hidden = await evj(` + Array.from(document.querySelectorAll('.sidebar-nav .tab-btn')) + .filter(b => b.classList.contains('nav-item--hidden')) + .map(b => b.dataset.tab) + `) + console.log(` visible:`, visible) + console.log(` hidden :`, hidden) + + // Screenshot: clip to the left sidebar so we get a tight frame of + // the nav filter effect (not a giant chat pane full of empty state). + const clip = await evj(` + (() => { + const s = document.querySelector('.sidebar') + if (!s) return null + const r = s.getBoundingClientRect() + return { x: r.x, y: r.y, width: r.width, height: r.height, scale: 1 } + })() + `) + const shotArgs = { format: 'png', captureBeyondViewport: true } + if (clip) shotArgs.clip = clip + const shot = await call('Page.captureScreenshot', shotArgs, 30000) + if (!shot || !shot.data) throw new Error('captureScreenshot returned no data') + const outPath = join(OUTDIR, `${kase.name}.png`) + writeFileSync(outPath, Buffer.from(shot.data, 'base64')) + console.log(` wrote ${outPath}`) + return { visible, hidden, path: outPath } + } finally { + try { child.kill('SIGKILL') } catch {} + // Aggressively wait for the CDP port to actually free — Electron + // spawns a helper process (GPU / renderer) that can linger after the + // main process death, keeping the port bound. Poll until /json/list + // stops answering (or 10s cap). + for (let i = 0; i < 20; i++) { + await sleep(500) + try { + await fetch(`http://localhost:${port}/json/list`) + } catch { + break // port is free + } + } + } +} + +async function main() { + const results = [] + for (let i = 0; i < CASES.length; i++) { + const kase = CASES[i] + const r = await runCase(kase, i) + results.push({ name: kase.name, label: kase.label, ...r }) + } + console.log('\n--- SUMMARY ---') + for (const r of results) { + console.log(`${r.name}: ${r.label}`) + console.log(` visible: ${JSON.stringify(r.visible)}`) + console.log(` hidden : ${JSON.stringify(r.hidden)}`) + console.log(` shot : ${r.path}`) + } +} + +main().catch(err => { console.error(err); process.exit(1) }) diff --git a/examples/desktop/src/main/main.js b/examples/desktop/src/main/main.js index 25130fc684..07786e5be0 100644 --- a/examples/desktop/src/main/main.js +++ b/examples/desktop/src/main/main.js @@ -784,6 +784,30 @@ app.whenReady().then(async () => { return { ok: true } }) + // -- left-nav hidden-pages config (lane-nav-optional) --------------------- + // Reads/writes the `hiddenPages` array on ~/.dsh-desktop/config.json so a + // researcher can hide any left-nav page by id (matches the `data-tab` + // attribute in index.html). Missing field falls through to the two-page + // default (Playground shim + Missions) so fresh installs surface fewer + // demo-tier pages up front. Renderer honors the returned list on init + + // whenever the Settings > Optional pages checkboxes flip. + ipcMain.handle('nav:getHiddenPages', () => { + const cfg = P.readShellConfig() || {} + return { hiddenPages: cfg.hiddenPages } + }) + ipcMain.handle('nav:setHiddenPages', (_e, { hiddenPages } = {}) => { + if (!Array.isArray(hiddenPages)) { + return { ok: false, reason: 'hiddenPages must be an array of pageId strings' } + } + // Defensive filter — the caller should already have cleaned this, but + // dropping non-strings here means an accidental `null` in the payload + // can't corrupt the on-disk config for the next boot. + const cleaned = hiddenPages.filter((x) => typeof x === 'string' && x.length > 0) + const existing = P.readShellConfig() || {} + P.writeShellConfig({ ...existing, hiddenPages: cleaned }) + return { ok: true, hiddenPages: cleaned } + }) + // Growth log — the append-only jsonl of runtime-shaping events that the // Growth page reads. Returns entries + a hint on the installedAt anchor // (config.json.createdAt, when onboarding wrote it) so the renderer can diff --git a/examples/desktop/src/preload/preload.js b/examples/desktop/src/preload/preload.js index b5c541e3f6..8b485e487c 100644 --- a/examples/desktop/src/preload/preload.js +++ b/examples/desktop/src/preload/preload.js @@ -142,6 +142,15 @@ contextBridge.exposeInMainWorld('dsh', { apply: (role, approvalMode) => ipcRenderer.invoke('onboarding:apply', { role, approvalMode }), reset: () => ipcRenderer.invoke('onboarding:reset'), }, + // -- left-nav hidden-pages config (lane-nav-optional) --------------------- + // Read + write the `hiddenPages` array in ~/.dsh-desktop/config.json. + // Returned value follows the same three-state semantic the renderer + // filter honors: `undefined` = default (playground+mission hidden), + // `[]` = show everything, non-empty array = honored as-is. + nav: { + getHiddenPages: () => ipcRenderer.invoke('nav:getHiddenPages'), + setHiddenPages: (hiddenPages) => ipcRenderer.invoke('nav:setHiddenPages', { hiddenPages }), + }, // -- growth log (self-evolution audit trail) ------------------------------- // The main process appends jsonl entries at ~/.dsh-desktop/growth-log.jsonl // on runtime-shaping events (plugin add/toggle, overlay apply, vibe diff --git a/examples/desktop/src/renderer/index.html b/examples/desktop/src/renderer/index.html index a011a6a3ce..381845d2cf 100644 --- a/examples/desktop/src/renderer/index.html +++ b/examples/desktop/src/renderer/index.html @@ -1116,6 +1116,27 @@ + +
+
+

Optional pages

+
+ Enable extra pages in the left sidebar. Hidden by default + to reduce the first-run surface; toggles persist to + ~/.dsh-desktop/config.json under + hiddenPages. Advanced users can hide any page + by adding its id to that array manually (e.g. + ["prs","growth"]). +
+
+
    +
    @@ -1418,7 +1439,11 @@ + settings-page so the DOM controller finds the pure module. + nav-config-model is loaded here too so settings-page can list + the Optional pages checkboxes and renderer.js can filter the + sidebar on init. --> + diff --git a/examples/desktop/src/renderer/nav-config-model.js b/examples/desktop/src/renderer/nav-config-model.js new file mode 100644 index 0000000000..9b1003c5e7 --- /dev/null +++ b/examples/desktop/src/renderer/nav-config-model.js @@ -0,0 +1,64 @@ +// nav-config-model.js — pure config helpers for the left-nav hidden-page +// filter. Two responsibilities: +// • DEFAULT_HIDDEN — the two demo-tier pages we now hide out of the box +// (Playground shim, Missions) so a fresh install shows fewer surfaces +// up front. Users opt these in from Settings > Optional pages. +// • resolveHiddenPages(cfg) — coerces a shell-config blob into the final +// hidden-page array following these rules: +// - `hiddenPages` missing entirely → default hidden set +// - `hiddenPages` is an empty array → nothing hidden (all pages show) +// - `hiddenPages` is a non-empty array of strings → honored as-is +// - anything else (non-array, garbage) → default hidden set (safe) +// +// The renderer applies the result by iterating `.tab-btn[data-tab=…]` +// and toggling a `.nav-item--hidden` class. Kept as a pure module so the +// three fixture cases (missing / empty / custom) are unit-testable +// without spinning up Electron. Wrapped in an IIFE (like settings-model) +// so no top-level bindings leak into the shared renderer global scope +// (test/renderer-collisions.test.js enforces this). + +'use strict' +;(function () { + +const DEFAULT_HIDDEN = Object.freeze(['playground-shim', 'mission']) + +// Page ids that a user can opt into from the Settings > Optional pages +// section. Kept as a small explicit list rather than "everything in the +// default hidden set" because the Settings section is meant to be a +// curated on/off surface for the demo-tier pages we hide by default — +// arbitrary custom hidden pages (via manual config.json edit) are still +// honored by resolveHiddenPages, they just don't get a Settings toggle. +// Label is what the checkbox shows; the id must match a `data-tab` +// value in index.html so the renderer filter can find the button. +const OPTIONAL_PAGES = Object.freeze([ + Object.freeze({ id: 'playground-shim', label: 'Playground', hint: 'Demo playground shim (opens Plugins tab).' }), + Object.freeze({ id: 'mission', label: 'Missions', hint: 'Mission Control — task graph over sessions.' }), +]) + +function resolveHiddenPages(cfg) { + if (!cfg || typeof cfg !== 'object') return DEFAULT_HIDDEN.slice() + const raw = cfg.hiddenPages + if (raw === undefined) return DEFAULT_HIDDEN.slice() + if (!Array.isArray(raw)) return DEFAULT_HIDDEN.slice() + // Explicit empty array means "show everything" — the user has opted + // every optional page in. Filter out non-strings/blanks defensively so + // a malformed entry can't crash the renderer filter. + const cleaned = raw.filter((x) => typeof x === 'string' && x.length > 0) + return cleaned +} + +// Small helper the Settings page uses to compute the next hiddenPages +// array given a checkbox flip. `enable=true` means "the user wants this +// page visible" so we remove it from hidden; `enable=false` adds it back. +function toggleOptionalPage(current, pageId, enable) { + const set = new Set(Array.isArray(current) ? current : []) + if (enable) set.delete(pageId) + else set.add(pageId) + return Array.from(set) +} + +const navConfigApi = { DEFAULT_HIDDEN, OPTIONAL_PAGES, resolveHiddenPages, toggleOptionalPage } +if (typeof module !== 'undefined' && module.exports) module.exports = navConfigApi +if (typeof window !== 'undefined') window.__dshNavConfigModel = navConfigApi + +})(); diff --git a/examples/desktop/src/renderer/renderer.js b/examples/desktop/src/renderer/renderer.js index 87c24af7ec..873a1e2d8a 100644 --- a/examples/desktop/src/renderer/renderer.js +++ b/examples/desktop/src/renderer/renderer.js @@ -7530,6 +7530,47 @@ async function bootUi() { }) window.__dshTabs = { switchTo } + // Left-nav hidden-pages filter (lane-nav-optional). Reads the shell + // config's `hiddenPages` array through the nav IPC and toggles a + // `nav-item--hidden` class on every `.tab-btn[data-tab=id]` in the + // hidden set. Also hides the enclosing `.nav-group` when every button + // inside it is hidden so an empty group header doesn't linger. If the + // active tab has been hidden by the flip, we fall back to Chat so + // the shell never lands on a pane whose entry point is invisible. + async function applyNavHiddenPages() { + const M = window.__dshNavConfigModel + if (!M) return + let cfg = {} + try { + if (window.dsh && window.dsh.nav && typeof window.dsh.nav.getHiddenPages === 'function') { + cfg = await window.dsh.nav.getHiddenPages() || {} + } + } catch (_) { /* absent IPC (unit test / stripped preload) → fall through to defaults */ } + const hidden = M.resolveHiddenPages(cfg) + const hiddenSet = new Set(hidden) + for (const btn of document.querySelectorAll('.sidebar-nav .tab-btn')) { + const id = btn.dataset.tab + btn.classList.toggle('nav-item--hidden', hiddenSet.has(id)) + } + for (const group of document.querySelectorAll('.sidebar-nav .nav-group')) { + const buttons = group.querySelectorAll('.tab-btn') + if (buttons.length === 0) continue + const everyHidden = Array.from(buttons).every((b) => b.classList.contains('nav-item--hidden')) + group.classList.toggle('nav-group--hidden', everyHidden) + } + // If a hidden tab was the active one, fall back to Chat so the + // main pane isn't left showing a surface whose button is gone. + const activeBtn = document.querySelector('.sidebar-nav .tab-btn.active') + if (activeBtn && activeBtn.classList.contains('nav-item--hidden')) { + switchTo('chat') + } + return hidden + } + window.__dshNavFilter = { apply: applyNavHiddenPages } + // Kick the filter after tab wiring lands so a fresh boot doesn't + // paint Playground/Missions before the hide toggles for one frame. + void applyNavHiddenPages() + // Rate-trajectory button — opens the annotation drawer scoped // to the current chat session. Falls back to a fixture-session picker // when there is no active session (first-run demo shape). diff --git a/examples/desktop/src/renderer/settings-page.js b/examples/desktop/src/renderer/settings-page.js index e845281a45..14a3d3cf97 100644 --- a/examples/desktop/src/renderer/settings-page.js +++ b/examples/desktop/src/renderer/settings-page.js @@ -170,6 +170,62 @@ } } + // Optional pages (lane-nav-optional). Render a checkbox row for each + // page listed in nav-config-model's OPTIONAL_PAGES. `checked=true` + // means the page is CURRENTLY VISIBLE (not in the hiddenPages array). + // On toggle, write the updated array back through window.dsh.nav.set + // and ask the renderer to re-apply the sidebar filter so the change + // takes effect without a reload. + async function readCurrentHidden() { + try { + if (window.dsh && window.dsh.nav && typeof window.dsh.nav.getHiddenPages === 'function') { + const res = await window.dsh.nav.getHiddenPages() + const M = window.__dshNavConfigModel + return M ? M.resolveHiddenPages(res || {}) : (Array.isArray(res && res.hiddenPages) ? res.hiddenPages : []) + } + } catch (_) { /* fall through */ } + const M = window.__dshNavConfigModel + return M ? M.DEFAULT_HIDDEN.slice() : [] + } + + function renderOptionalRow(page, isVisible) { + const li = document.createElement('li') + li.className = 'settings-optional-row' + li.dataset.pageId = page.id + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + checkbox.checked = !!isVisible + checkbox.id = `settings-optional-${page.id}` + checkbox.dataset.pageId = page.id + const label = document.createElement('label') + label.className = 'settings-optional-label' + label.setAttribute('for', checkbox.id) + const name = document.createElement('span') + name.className = 'settings-optional-name' + name.textContent = page.label + const hint = document.createElement('span') + hint.className = 'settings-optional-hint muted' + hint.textContent = page.hint + label.appendChild(name) + label.appendChild(hint) + li.appendChild(checkbox) + li.appendChild(label) + return li + } + + async function renderOptionalPages(container) { + const list = container.querySelector('[data-settings-optional-list]') + if (!list) return + const M = window.__dshNavConfigModel + if (!M) return + const currentHidden = await readCurrentHidden() + const hiddenSet = new Set(currentHidden) + list.innerHTML = '' + for (const page of M.OPTIONAL_PAGES) { + list.appendChild(renderOptionalRow(page, !hiddenSet.has(page.id))) + } + } + async function readKeyPresence() { // Only DEEPSEEK is knowable today because the main process spawns // stdio-deepseek with that key threaded through the profile. Other @@ -202,6 +258,7 @@ if (!root) return const priceContainer = root.querySelector('[data-settings-pricing]') const keysContainer = root.querySelector('[data-settings-keys]') + const optionalContainer = root.querySelector('[data-settings-optional-pages]') const model = window.__dshSettingsModel if (!model) return if (priceContainer) { @@ -221,6 +278,9 @@ const rows = model.classifyKeys(presence) renderKeys(keysContainer, rows) } + if (optionalContainer) { + await renderOptionalPages(optionalContainer) + } } function attachListeners(root) { @@ -260,6 +320,33 @@ void refresh(root) }) } + // Optional pages checkbox listener — one delegated handler for all + // rows. The bound flag guards a double-bind if show() runs twice + // (which happens when the researcher visits Settings after having + // opened it once already, since renderer.js:switchTo('settings') + // calls show() unconditionally). + const optionalList = root.querySelector('[data-settings-optional-list]') + if (optionalList && !optionalList.__dshBound) { + optionalList.__dshBound = true + optionalList.addEventListener('change', async (ev) => { + const t = ev.target + if (!(t instanceof HTMLInputElement) || t.type !== 'checkbox') return + const pageId = t.dataset.pageId + const M = window.__dshNavConfigModel + if (!pageId || !M) return + // Read current, compute next, persist, re-apply filter. + const current = await readCurrentHidden() + const next = M.toggleOptionalPage(current, pageId, t.checked) + try { + if (window.dsh && window.dsh.nav && typeof window.dsh.nav.setHiddenPages === 'function') { + await window.dsh.nav.setHiddenPages(next) + } + } catch (_) { /* IPC absent — the DOM class flip below still updates the sidebar for this session */ } + if (window.__dshNavFilter && typeof window.__dshNavFilter.apply === 'function') { + await window.__dshNavFilter.apply() + } + }) + } } async function show() { diff --git a/examples/desktop/src/renderer/style.css b/examples/desktop/src/renderer/style.css index 318726c5d8..e30fe6a6c2 100644 --- a/examples/desktop/src/renderer/style.css +++ b/examples/desktop/src/renderer/style.css @@ -12700,3 +12700,52 @@ button.artifact-version:hover { } .artifact-timeline-open:hover { color: var(--accent); background: var(--accent-soft); } + +/* ---- lane-nav-optional: hiddenPages filter + Optional pages Settings ---- + `.nav-item--hidden` is added by renderer.js's applyNavHiddenPages() for + every left-nav button whose data-tab id appears in the shell config's + `hiddenPages` array (or the default set: playground-shim + mission). + `.nav-group--hidden` collapses the enclosing group header when every + button inside got hidden — otherwise a lonely "iteration" label would + sit above an empty column. */ +.sidebar .sidebar-nav .nav-item--hidden, +.sidebar .sidebar-nav .nav-group--hidden { + display: none !important; +} + +/* Optional pages checkbox list in Settings > Optional pages. Compact + two-line rows (label + hint) so the section stays skimmable and does + not fight the pricing/keys tables above it. */ +.settings-optional-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.settings-optional-row { + display: flex; + align-items: flex-start; + gap: var(--space-2); + padding: var(--space-2) 0; + border-top: 1px solid var(--border); +} +.settings-optional-row:first-child { border-top: none; } +.settings-optional-row input[type="checkbox"] { + margin-top: 4px; + flex-shrink: 0; +} +.settings-optional-label { + display: flex; + flex-direction: column; + gap: 2px; + cursor: pointer; +} +.settings-optional-name { + font-weight: 600; +} +.settings-optional-hint { + color: var(--text-tertiary); + font-size: var(--fs-small, 12px); +} diff --git a/examples/desktop/test/nav-config-model.test.js b/examples/desktop/test/nav-config-model.test.js new file mode 100644 index 0000000000..b5a346ecd0 --- /dev/null +++ b/examples/desktop/test/nav-config-model.test.js @@ -0,0 +1,291 @@ +// nav-config-model.test.js — pure config helpers + static HTML gates for +// the left-nav hiddenPages filter (lane-nav-optional). +// +// Three fixture cases (task spec): +// • config.json missing `hiddenPages` → default hidden set (Playground + Missions) +// • config.json has empty array → nothing hidden (all pages show) +// • config.json has custom list → honored as-is (e.g. ["prs","growth"]) +// +// Plus DOM-shape assertions on index.html so a future rewrite of the +// sidebar can't silently strip the Optional pages section. + +'use strict' + +const test = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const path = require('node:path') + +const M = require('../src/renderer/nav-config-model.js') + +// -- resolveHiddenPages ---------------------------------------------------- + +test('resolveHiddenPages: no config → default hidden set', () => { + assert.deepStrictEqual(M.resolveHiddenPages(null), ['playground-shim', 'mission']) + assert.deepStrictEqual(M.resolveHiddenPages(undefined), ['playground-shim', 'mission']) + assert.deepStrictEqual(M.resolveHiddenPages({}), ['playground-shim', 'mission']) +}) + +test('resolveHiddenPages: hiddenPages missing on partial config → default set', () => { + const cfg = { role: 'engineer', approvalMode: 'ask', createdAt: 1234 } + assert.deepStrictEqual(M.resolveHiddenPages(cfg), ['playground-shim', 'mission']) +}) + +test('resolveHiddenPages: empty array → show everything', () => { + assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: [] }), []) +}) + +test('resolveHiddenPages: custom list → honored as-is', () => { + assert.deepStrictEqual( + M.resolveHiddenPages({ hiddenPages: ['prs', 'growth'] }), + ['prs', 'growth'] + ) +}) + +test('resolveHiddenPages: non-array garbage → falls back to defaults (safe)', () => { + assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: 'playground' }), ['playground-shim', 'mission']) + assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: 42 }), ['playground-shim', 'mission']) + assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: { pages: [] } }), ['playground-shim', 'mission']) +}) + +test('resolveHiddenPages: filters out non-string / blank entries', () => { + assert.deepStrictEqual( + M.resolveHiddenPages({ hiddenPages: ['prs', '', null, 42, 'growth'] }), + ['prs', 'growth'] + ) +}) + +test('resolveHiddenPages: returns a fresh array (mutation safety)', () => { + const a = M.resolveHiddenPages({}) + a.push('sneaky') + const b = M.resolveHiddenPages({}) + assert.deepStrictEqual(b, ['playground-shim', 'mission'], + 'a subsequent call must not see the mutation of a prior return') +}) + +// -- toggleOptionalPage ----------------------------------------------------- + +test('toggleOptionalPage: enable removes the id from hidden', () => { + assert.deepStrictEqual( + M.toggleOptionalPage(['playground-shim', 'mission'], 'playground-shim', true).sort(), + ['mission'] + ) +}) + +test('toggleOptionalPage: disable adds the id to hidden', () => { + assert.deepStrictEqual( + M.toggleOptionalPage(['mission'], 'playground-shim', false).sort(), + ['mission', 'playground-shim'] + ) +}) + +test('toggleOptionalPage: idempotent — disable an already-hidden page = no change', () => { + assert.deepStrictEqual( + M.toggleOptionalPage(['mission'], 'mission', false).sort(), + ['mission'] + ) +}) + +test('toggleOptionalPage: idempotent — enable an already-visible page = no change', () => { + assert.deepStrictEqual( + M.toggleOptionalPage(['mission'], 'playground-shim', true).sort(), + ['mission'] + ) +}) + +test('toggleOptionalPage: non-array current is tolerated (start fresh)', () => { + assert.deepStrictEqual( + M.toggleOptionalPage(undefined, 'mission', false).sort(), + ['mission'] + ) + assert.deepStrictEqual( + M.toggleOptionalPage(null, 'mission', true).sort(), + [] + ) +}) + +// -- OPTIONAL_PAGES shape --------------------------------------------------- + +test('OPTIONAL_PAGES: matches DEFAULT_HIDDEN 1:1 by id', () => { + const optIds = M.OPTIONAL_PAGES.map((p) => p.id).sort() + const defIds = M.DEFAULT_HIDDEN.slice().sort() + assert.deepStrictEqual(optIds, defIds, + 'every default-hidden page should have a Settings checkbox and vice versa') +}) + +test('OPTIONAL_PAGES: every entry has id/label/hint strings', () => { + for (const page of M.OPTIONAL_PAGES) { + assert.ok(typeof page.id === 'string' && page.id.length > 0, `id: ${JSON.stringify(page)}`) + assert.ok(typeof page.label === 'string' && page.label.length > 0, `label: ${JSON.stringify(page)}`) + assert.ok(typeof page.hint === 'string' && page.hint.length > 0, `hint: ${JSON.stringify(page)}`) + } +}) + +// -- static HTML gate: Optional pages section -------------------------------- + +const HTML = fs.readFileSync( + path.resolve(__dirname, '..', 'src/renderer/index.html'), + 'utf8' +) + +test('Optional pages section is declared in the Settings pane', () => { + assert.match(HTML, /data-settings-optional-pages/, + 'Optional pages section marker present') + assert.match(HTML, /data-settings-optional-list/, + 'Optional pages list hook present') + assert.match(HTML, /Optional pages/, + 'Section title copy present') +}) + +test('Optional pages section lives inside the Settings pane (not orphaned)', () => { + // Grab settings-pane block through its closing tag and assert the + // Optional marker sits inside. Guards against a merge that moves the + // block out of the pane by accident. + const paneMatch = HTML.match(/id="settings-pane"[\s\S]*?<\/section>\s*<\/section>/) + assert.ok(paneMatch, 'settings-pane block found') + assert.match(paneMatch[0], /data-settings-optional-pages/, + 'Optional pages section must live inside #settings-pane') +}) + +test('nav-config-model.js is loaded before settings-page.js', () => { + const navMatch = HTML.match(/]*src="\.\/nav-config-model\.js"/) + const settingsMatch = HTML.match(/]*src="\.\/settings-page\.js"/) + assert.ok(navMatch, 'nav-config-model.js script tag present') + assert.ok(settingsMatch, 'settings-page.js script tag present') + const navIdx = HTML.indexOf(navMatch[0]) + const settingsIdx = HTML.indexOf(settingsMatch[0]) + assert.ok(settingsIdx > navIdx, + 'nav-config-model must load before settings-page so OPTIONAL_PAGES is defined at render time') +}) + +test('Playground-shim + mission buttons expose the data-tab ids the filter matches', () => { + // These two ids are the default-hidden set — if a lane renames either + // without also updating nav-config-model.DEFAULT_HIDDEN, the filter + // would silently no-op and both buttons would show up on fresh installs. + assert.match(HTML, /data-tab="playground-shim"/, + 'playground-shim button id must match DEFAULT_HIDDEN entry') + assert.match(HTML, /data-tab="mission"/, + 'mission button id must match DEFAULT_HIDDEN entry') +}) + +// -- DOM-level filter proof (three fixture cases) -------------------------- +// Mirror the loop inside renderer.js:applyNavHiddenPages() against a tiny +// shim so each fixture case is proven end-to-end: config in → +// hiddenPages resolved → class toggled on matching button. Using a +// hand-rolled shim rather than jsdom (not a dev-dep) or the full +// renderer-harness (evals 8k lines of renderer.js for a one-loop check). + +function makeSidebar() { + const buttons = [] + const groups = [] + function makeBtn(dataTab) { + return { + dataset: { tab: dataTab }, + classList: { + _s: new Set(), + toggle(n, force) { + if (force) this._s.add(n); else this._s.delete(n) + return force + }, + contains(n) { return this._s.has(n) }, + }, + } + } + function makeGroup(btns) { + const g = { + classList: { + _s: new Set(), + toggle(n, force) { + if (force) this._s.add(n); else this._s.delete(n) + return force + }, + contains(n) { return this._s.has(n) }, + }, + _btns: btns, + } + for (const b of btns) buttons.push(b) + groups.push(g) + return g + } + makeGroup([ + makeBtn('chat'), + makeBtn('tree'), + makeBtn('context'), + makeBtn('tracing'), + ]) + // "iteration" group — playground-shim (default hidden) sits here. + makeGroup([ + makeBtn('playground-shim'), + makeBtn('hub'), + makeBtn('bench'), + ]) + // "runtime" group — mission (default hidden) sits here. + makeGroup([ + makeBtn('rubrics'), + makeBtn('plugins'), + makeBtn('runtimes'), + makeBtn('mission'), + makeBtn('growth'), + makeBtn('prs'), + ]) + return { buttons, groups } +} + +function applyFilter(sidebar, cfg) { + const hidden = new Set(M.resolveHiddenPages(cfg)) + for (const b of sidebar.buttons) { + b.classList.toggle('nav-item--hidden', hidden.has(b.dataset.tab)) + } + for (const g of sidebar.groups) { + const allHidden = g._btns.every((b) => b.classList.contains('nav-item--hidden')) + g.classList.toggle('nav-group--hidden', allHidden) + } +} + +test('DOM filter (case 1): missing hiddenPages → playground-shim + mission hidden, others visible', () => { + const sb = makeSidebar() + applyFilter(sb, {}) + const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden')) + assert.deepStrictEqual( + hidden.map((b) => b.dataset.tab).sort(), + ['mission', 'playground-shim'] + ) +}) + +test('DOM filter (case 2): empty array → nothing hidden (all pages show)', () => { + const sb = makeSidebar() + applyFilter(sb, { hiddenPages: [] }) + const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden')) + assert.strictEqual(hidden.length, 0, 'no buttons should be hidden with []') + const groupHidden = sb.groups.filter((g) => g.classList.contains('nav-group--hidden')) + assert.strictEqual(groupHidden.length, 0, 'no groups should be collapsed with []') +}) + +test('DOM filter (case 3): custom list → matching buttons hidden, others untouched', () => { + const sb = makeSidebar() + applyFilter(sb, { hiddenPages: ['prs', 'growth'] }) + const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden')) + assert.deepStrictEqual( + hidden.map((b) => b.dataset.tab).sort(), + ['growth', 'prs'] + ) + // Playground + mission are NOT hidden because the researcher opted them in + // via an explicit list; only the ids in the list are hidden. + const play = sb.buttons.find((b) => b.dataset.tab === 'playground-shim') + const mission = sb.buttons.find((b) => b.dataset.tab === 'mission') + assert.ok(!play.classList.contains('nav-item--hidden'), 'playground-shim visible on custom list') + assert.ok(!mission.classList.contains('nav-item--hidden'), 'mission visible on custom list') +}) + +test('DOM filter: group collapses when every button inside is hidden', () => { + const sb = makeSidebar() + // Hide every button in the iteration group. + applyFilter(sb, { hiddenPages: ['playground-shim', 'hub', 'bench'] }) + const iterGroup = sb.groups[1] // second group is "iteration" + assert.ok(iterGroup.classList.contains('nav-group--hidden'), + 'iteration group should be collapsed when all its buttons are hidden') + // The other groups still have visible members. + assert.ok(!sb.groups[0].classList.contains('nav-group--hidden')) + assert.ok(!sb.groups[2].classList.contains('nav-group--hidden')) +}) +