Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions internal/server/dashboard_csp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ func TestDashboardCSP_JsdelivrNpmPathScoped(t *testing.T) {
// handler surface in static/dashboard.html (R236-SEC-02 / #479, also tracked
// as #922). The dashboard CSP still ships `script-src 'unsafe-inline'`
// because the static HTML contains a fixed set of `onclick=` attributes on
// header buttons (sidebar search, history, new session, cron panel,
// sidebar-search-clear, sidebar-toggle resizer). Migrating to
// header buttons (history, new session, cron panel,
// sidebar-toggle resizer). Migrating to
// hash/nonce CSP requires moving those handlers into dashboard.js as
// addEventListener bindings.
//
Expand All @@ -276,8 +276,8 @@ func TestDashboardCSP_InlineHandlerSurfaceDoesNotGrow(t *testing.T) {
html := string(body)

// Cap on `onclick=` attributes. R249-SEC-9 (#922) migration: the static
// HTML's header/sidebar handlers (sidebar-search, history, new-session,
// cron, sidebar-search-clear, sidebar-toggle) plus the
// HTML's header/sidebar handlers (history, new-session, cron,
// sidebar-toggle) plus the
// quick-ask form's onsubmit were moved into dashboard.js as
// addEventListener binds (DOMContentLoaded header binder + wireQuickAskInput
// for the repaint-prone quick-ask form), driving the static surface to 0.
Expand Down Expand Up @@ -365,10 +365,8 @@ func TestDashboardCSP_StaticHandlersWiredInJS(t *testing.T) {
id string
handler string
}{
{"btn-sidebar-search", "toggleSidebarSearch"},
{"btn-history", "toggleHistory"},
{"btn-new-session", "createNewSession"},
{"sidebar-search-clear", "closeSidebarSearch"},
{"btn-sidebar-toggle", "toggleSidebarCollapsed"},
{"quick-ask-form", "submitQuickAsk"},
}
Expand Down
27 changes: 0 additions & 27 deletions internal/server/static/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -1292,27 +1292,8 @@
.hdr-btns{display:flex;gap:4px;align-items:center}
.hdr-btn{background:none;border:1px solid var(--nz-border);border-radius:var(--nz-radius-ms);color:var(--nz-text-mute);cursor:pointer;padding:0;position:relative;transition:all .15s;line-height:1;white-space:nowrap;width:32px;height:32px;display:flex;align-items:center;justify-content:center}
.hdr-btn:hover{background:var(--nz-bg-2);color:var(--nz-text);border-color:var(--nz-text-faint)}
/* Active state indicates the toggleable pane (currently sidebar search) is
open. Matches the accent color used on focus rings across the dashboard
so a keyboard user can tell which toggle the button drives. */
.hdr-btn.active{background:var(--nz-bg-2);color:var(--nz-accent);border-color:var(--nz-accent)}
.hdr-btn svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
/* UX-P3 sidebar search: toggle-revealed input row under the header-row.
Lives inside .sidebar-header (outside #session-list) so sessions_update
repaints don't clobber the input value/focus — the renderer reads the
input's current value on each repaint and filters accordingly. */
.sidebar-search{display:flex;gap:6px;align-items:center;margin-top:8px;padding:0 4px}
.sidebar-search-input{flex:1;padding:6px 10px;background:var(--nz-bg-1);border:1px solid var(--nz-border);border-radius:var(--nz-radius-ms);color:var(--nz-text);font-size:var(--nz-fs-sm2);font-family:inherit;outline:none;transition:border-color .1s}
.sidebar-search-input:focus{border-color:var(--nz-accent)}
.sidebar-search-input::placeholder{color:var(--nz-text-faint)}
.sidebar-search-clear{background:transparent;border:1px solid transparent;color:var(--nz-text-mute);font-size:var(--nz-fs-md);line-height:1;padding:4px 8px;border-radius:var(--nz-radius-sm);cursor:pointer;transition:all .1s}
.sidebar-search-clear:hover{background:var(--nz-bg-2);color:var(--nz-text);border-color:var(--nz-border)}
/* When the filter is active AND empty (no matches), paint a friendly hint
instead of the legacy "no sessions" CTA which would mislead operators
into thinking they have zero sessions. .session-list-filter-empty is
emitted by the renderer's filter branch. */
.session-list-filter-empty{padding:24px 16px;text-align:center;color:var(--nz-text-dim);font-size:var(--nz-fs-sm)}
.session-list-filter-empty .slfe-hint{display:block;margin-top:6px;color:var(--nz-text-faint);font-size:var(--nz-fs-xs)}
/* Header badges (history count, cron attention count) are neutral by
default — red is opt-in via .is-alert (see Track D variants below).
Before Round 127 this rule hard-coded --nz-red, then a later Track D
Expand Down Expand Up @@ -2837,18 +2818,10 @@
<div class="header-row">
<h1>naozhi</h1>
<div class="hdr-btns">
<button type="button" class="hdr-btn" id="btn-sidebar-search" title="搜索会话 (按 /)" aria-label="搜索会话" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></button>
<button type="button" class="hdr-btn" id="btn-history" title="历史会话" aria-label="查看会话历史" aria-haspopup="dialog"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg><span class="hdr-badge" id="history-badge" style="display:none" aria-label="历史记录数"></span></button>
<button type="button" class="hdr-btn" id="btn-new-session" title="New Session" aria-label="Create new session"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
</div>
<!-- UX-P3 sidebar search: default-hidden, toggled via btn-sidebar-search
or the `/` shortcut. Kept outside #session-list so sessions_update
re-renders don't blow away the input value or its focus. -->
<div class="sidebar-search" id="sidebar-search" style="display:none">
<input type="text" id="sidebar-search-input" class="sidebar-search-input" placeholder="搜索会话、项目或 CLI 名..." autocomplete="off" spellcheck="false" aria-label="搜索会话" />
<button type="button" class="sidebar-search-clear btn-close" id="sidebar-search-clear" title="关闭搜索" aria-label="关闭搜索">&times;</button>
</div>
</div>
<!-- Node/connection picker moved into the New Session modal (renderNodePicker).
The sidebar no longer filters by node; every connected node's sessions
Expand Down
179 changes: 9 additions & 170 deletions internal/server/static/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,9 @@ let nodesData = {};
let lastVersion = 0;
let lastNodesJSON = '';
let lastHistoryJSON = '';
// _lastSidebarData caches the most recent /api/sessions payload so UX-P3
// sidebar search can re-render locally on each keystroke without
// re-hitting the server. Set by fetchSessions after a successful render;
// consumed by the sidebar-search input oninput handler.
// _lastSidebarData caches the most recent /api/sessions payload so the
// sidebar can re-render locally without re-hitting the server. Set by
// fetchSessions after a successful render.
let _lastSidebarData = null;
// _lastSidebarHtml caches the last fully-built sidebar HTML string so
// renderSidebar can skip the (expensive) `list.innerHTML = html` write
Expand Down Expand Up @@ -363,10 +362,8 @@ document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById(id);
if (el) el.addEventListener('click', fn);
};
bindClick('btn-sidebar-search', function () { toggleSidebarSearch(); });
bindClick('btn-history', function () { toggleHistory(); });
bindClick('btn-new-session', function () { createNewSession(); });
bindClick('sidebar-search-clear', function () { closeSidebarSearch(); });
bindClick('btn-sidebar-toggle', function () { toggleSidebarCollapsed(); });
// NOTE: the quick-ask form submit is NOT bound here. That form lives in
// `#main`, which is repainted via innerHTML (mainEmptyHtml()), so its submit
Expand Down Expand Up @@ -720,28 +717,11 @@ function renderSidebar(data) {
// cron-panel-consolidation RFC §4.2: cron stubs are filtered server-side
// (internal/server/dashboard_session.go) so allItems never contains cron
// keys here. The previous `cronVisibleKeys` whitelist + per-render filter
// are gone — both branches below (search-filter / project-grouping) walk
// allItems directly.
// are gone — the project-grouping branch below walks allItems directly.
const visibleItems = allItems;

// UX-P3 sidebar search: if the filter input is visible and non-empty,
// skip the project grouping entirely and render the filtered set as a
// flat list. Grouping under a filter scatters matches across day headers
// and loses the "search" affordance. Reading the input here (not in a
// separate oninput handler) means every sessions_update re-evaluates the
// filter — same query, fresh data — without flickering the input.
const filterQuery = readSidebarSearchQuery();
const filterActive = !!filterQuery;
let listHtml = '';
if (filterActive) {
const matched = filterSessionsByQuery(visibleItems, filterQuery);
listHtml = matched.length === 0
? '<div class="session-list-filter-empty">没有匹配的会话<span class="slfe-hint">试试项目名、CLI 名或 prompt 片段</span></div>'
: matched.map(sessionCardHtml).join('');
}

let html = listHtml;
if (!filterActive) {
let html = '';
{
// Project lookup by (node,name) so we can reach favorite/github flags.
const projIndex = {};
projectsData.forEach(p => {
Expand Down Expand Up @@ -875,9 +855,8 @@ function renderSidebar(data) {
// R110-P2 empty-state CTA: keep the legacy "no sessions" text (E2E asserts
// it via toContain) but add a visible call-to-action so first-time users
// aren't left staring at a dead sidebar. createNewSession is the same handler
// the header `+` button invokes. NOT emitted in filter mode — the
// filter-specific empty state ('没有匹配的会话') already covers that path.
if (!html && !filterActive) html = '<div class="no-sessions">no sessions<br><button type="button" class="no-sessions-cta" onclick="createNewSession()">+ 开启你的第一个会话</button></div>';
// the header `+` button invokes.
if (!html) html = '<div class="no-sessions">no sessions<br><button type="button" class="no-sessions-cta" onclick="createNewSession()">+ 开启你的第一个会话</button></div>';
// R33-UX1: skip the innerHTML write (and its full sidebar reflow) when
// the produced markup is byte-identical to what is already mounted.
// 20 sessions × 1 Hz polling cycle rebuilds the same string every tick
Expand Down Expand Up @@ -921,144 +900,6 @@ function renderSidebar(data) {
renderRecentSessionsPanel();
}

// --- UX-P3 sidebar search helpers ---
//
// readSidebarSearchQuery is called at the top of renderSidebar so every
// sessions_update repaint re-applies the current filter without a separate
// oninput handler firing a second render. Returns '' when the search pane
// is hidden or the input is empty, both of which are "no filter" states.
function readSidebarSearchQuery() {
const pane = document.getElementById('sidebar-search');
if (!pane || pane.style.display === 'none') return '';
const input = document.getElementById('sidebar-search-input');
if (!input) return '';
return input.value.trim();
}

// filterSessionsByQuery is the pure match step — extracted so unit tests
// can exercise it without driving the DOM. Match surface: user_label,
// summary, last_prompt, project, cli_name, key (all substring,
// case-insensitive). session_id is NOT in the surface because it's a
// long opaque hash no operator types; matching on key is enough when
// someone wants to paste a slice of it.
function filterSessionsByQuery(items, query) {
const q = (query || '').trim().toLowerCase();
if (!q) return items;
return items.filter(s => {
if (!s) return false;
const fields = [
s.user_label, s.summary, s.last_prompt,
s.project, s.cli_name, s.key,
];
for (const f of fields) {
if (typeof f === 'string' && f.toLowerCase().indexOf(q) !== -1) return true;
}
return false;
});
}

// toggleSidebarSearch flips the search pane's visibility. Entering toggle
// auto-focuses the input; exiting clears it (so re-opening starts clean
// and a stale filter doesn't silently hide sessions). Mirrors the header
// button's aria-expanded so screen readers track state.
function toggleSidebarSearch() {
const pane = document.getElementById('sidebar-search');
const btn = document.getElementById('btn-sidebar-search');
if (!pane || !btn) return;
const opening = pane.style.display === 'none';
pane.style.display = opening ? 'flex' : 'none';
btn.classList.toggle('active', opening);
btn.setAttribute('aria-expanded', opening ? 'true' : 'false');
if (opening) {
const input = document.getElementById('sidebar-search-input');
if (input) {
// defer focus so the display:flex paint lands first (Safari refuses
// focus() on a still-hidden element, then silently drops it).
setTimeout(() => { input.focus(); input.select(); }, 30);
}
} else {
// Closing clears the query so the next open starts fresh and the
// sidebar immediately re-renders without a lingering filter. Render
// locally against the cached payload (if any) to avoid an extra
// /api/sessions round-trip — the data is already authoritative.
// #1772: cancel any pending debounced keystroke render first, so a timer
// queued just before close can't fire after we've already cleared and
// re-rendered (which, when _lastSidebarData is null, would also trigger a
// spurious debouncedFetchSessions()).
if (_sidebarSearchDebounce) { clearTimeout(_sidebarSearchDebounce); _sidebarSearchDebounce = null; }
const input = document.getElementById('sidebar-search-input');
if (input) input.value = '';
if (_lastSidebarData) {
renderSidebar(_lastSidebarData);
} else {
debouncedFetchSessions();
}
}
}

// closeSidebarSearch is the explicit "close" path used by the × button
// and the Esc key — same semantics as toggleSidebarSearch's close arm.
function closeSidebarSearch() {
const pane = document.getElementById('sidebar-search');
if (pane && pane.style.display !== 'none') toggleSidebarSearch();
}

// initSidebarSearch wires the input handler + the `/` and Esc keyboard
// shortcuts. Call once at startup. The input's oninput handler triggers
// a debounced sidebar re-fetch so each keystroke re-applies the filter
// against the canonical sessions data — no client-side cache desync.
let _sidebarSearchDebounce = null;
function initSidebarSearch() {
const input = document.getElementById('sidebar-search-input');
if (input) {
input.addEventListener('input', () => {
// Re-render locally against the cached /api/sessions payload so
// rapid typing doesn't DoS the server with per-keystroke requests.
// When no data has landed yet (first load), fall through to a
// debounced fetch as a degraded bootstrap.
//
// #1772: debounce the local re-render too. renderSidebar does a full
// sort + filter + sessionCardHtml map+join over every session on each
// keystroke; the _lastSidebarHtml guard skips the DOM write only when
// the output is byte-identical, which is rare while a filter narrows.
// 120ms collapses a typing burst into one render. The periodic
// sessions_update repaint reuses the current query via
// readSidebarSearchQuery, so debouncing the keystroke render loses no
// filter state.
if (_sidebarSearchDebounce) clearTimeout(_sidebarSearchDebounce);
_sidebarSearchDebounce = setTimeout(() => {
_sidebarSearchDebounce = null;
if (_lastSidebarData) {
renderSidebar(_lastSidebarData);
} else {
debouncedFetchSessions();
}
}, 120);
});
input.addEventListener('keydown', e => {
if (e.key === 'Escape') { e.preventDefault(); closeSidebarSearch(); }
});
}
// Global `/` shortcut: open sidebar search unless the user is already
// typing into some other input/textarea/contenteditable. Mirrors the
// `?` help shortcut's skip rule so developer muscle memory works.
document.addEventListener('keydown', e => {
if (e.key !== '/') return;
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
const tgt = e.target;
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return;
if (document.querySelector('.modal-overlay, .cmd-palette-overlay')) return;
e.preventDefault();
const pane = document.getElementById('sidebar-search');
if (pane && pane.style.display === 'none') {
toggleSidebarSearch();
} else {
const inp = document.getElementById('sidebar-search-input');
if (inp) inp.focus();
}
});
}

// projectDisplayLabel returns the operator-facing name for a project,
// preferring the explicit ProjectConfig.display_name override (R110-P2 /
// #448) and falling back to the directory-derived `p.name` so projects
Expand Down Expand Up @@ -12461,8 +12302,7 @@ document.addEventListener('keydown', function(e) {
// `[` toggles collapse on PC. Skip when typing into an input/textarea/
// contenteditable, when any modifier is held, while an IME composition is
// active (CJK input fires `[` for fullwidth bracket), or while a modal/
// palette is open. Mirrors the skip logic the `/` shortcut uses for
// sidebar search.
// palette is open.
if (e.key !== '[') return;
if (e.isComposing) return;
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
Expand Down Expand Up @@ -12659,7 +12499,6 @@ initViewportTracking();
initSwipeDelete();
initSidebarProjectActions();
initSwipeBack();
initSidebarSearch();
(function(){
var ov=document.createElement('div');ov.className='lightbox-overlay';
ov.setAttribute('role','dialog');ov.setAttribute('aria-modal','true');ov.setAttribute('aria-label','Image preview');
Expand Down
Loading