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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
64
examples/desktop/src/renderer/nav-config-model.js
Normal file
64
examples/desktop/src/renderer/nav-config-model.js
Normal 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
|
||||
|
||||
})();
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user