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

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