feat(desktop): nav hiddenPages config + optional pages opt-in

hiddenPages three-state config (undefined default / [] show-all / [list] custom) with
Playground and Missions default-hidden. Nav is now opt-in via Settings > Optional pages
section with immediate toggle rendering (no restart).

Change surface: nav-config-model.js (new, 64 lines), main.js (+24), preload.js (+9),
renderer.js (+41), index.html (+27/-1), settings-page.js (+87), style.css (+49), and
test/nav-config-model.test.js (new 22 tests, +291). docs/qa-nav-optional/*.png × 3
capture the three states.

12 files, +846/-1.

Test count on mainline: 1806 total / 1806 pass / 0 fail.
This commit is contained in:
ZiyaZhang
2026-07-19 05:39:04 -07:00
parent 767d65465d
commit 52a2493495
12 changed files with 846 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -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=<tmp> isolates Chromium userdata.
// 2. DSH_DESKTOP_HOME=<tmp> 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()
: '<no-ipc>'
const resolved = M ? M.resolveHiddenPages(raw || {}) : '<no-model>'
// 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) })

View File

@@ -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

View File

@@ -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

View File

@@ -1116,6 +1116,27 @@
<tbody data-settings-keys-tbody></tbody>
</table>
</section>
<!-- Optional pages (lane-nav-optional). Playground + Missions
ship hidden out of the box to cut first-run cognitive load;
a researcher opts them back in here. Any custom
`hiddenPages` entries the user hand-edits into
~/.dsh-desktop/config.json are honored by the renderer
filter but do not surface as checkboxes — this section
only lists the two demo-tier pages we hide by default. -->
<section class="settings-section" data-settings-optional-pages>
<div class="settings-section-head">
<h3 class="settings-section-title">Optional pages</h3>
<div class="settings-section-sub muted">
Enable extra pages in the left sidebar. Hidden by default
to reduce the first-run surface; toggles persist to
<code>~/.dsh-desktop/config.json</code> under
<code>hiddenPages</code>. Advanced users can hide any page
by adding its id to that array manually (e.g.
<code>["prs","growth"]</code>).
</div>
</div>
<ul class="settings-optional-list" data-settings-optional-list aria-label="Optional pages"></ul>
</section>
</section>
</section>
@@ -1418,7 +1439,11 @@
<script src="./pr-page.js"></script>
<!-- Lane-nav (task #189/#193): runtimes page + settings page + pure
settings-model helpers. settings-model must load BEFORE
settings-page so the DOM controller finds the pure module. -->
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. -->
<script src="./nav-config-model.js"></script>
<script src="./settings-model.js"></script>
<script src="./runtimes-page.js"></script>
<script src="./settings-page.js"></script>

View File

@@ -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
})();

View File

@@ -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).

View File

@@ -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() {

View File

@@ -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);
}

View File

@@ -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(/<script[^>]*src="\.\/nav-config-model\.js"/)
const settingsMatch = HTML.match(/<script[^>]*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'))
})