claude-code-kanban 4.16.0 → 4.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.16.0",
3
+ "version": "4.17.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -1,13 +1,17 @@
1
1
  //#region STATE
2
+ // The baseline "nothing is filtered" state: what resetState() returns to, what updateUrl() omits
3
+ // from the query string, and what decides whether a control tints ember in the header summary.
4
+ const FILTER_DEFAULTS = { project: '__recent__', session: 'active', limit: '20' };
5
+
2
6
  let sessions = [];
3
7
  let currentSessionId = null;
4
8
  let currentTasks = [];
5
9
  let viewMode = 'session';
6
- let sessionFilter = 'active';
10
+ let sessionFilter = FILTER_DEFAULTS.session;
7
11
  // Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
8
12
  const activityFilter = new Set(); // kinds: 'waiting' | 'active'
9
- let sessionLimit = '20';
10
- let filterProject = '__recent__'; // null = all, '__recent__' = last 24h, or project path
13
+ let sessionLimit = FILTER_DEFAULTS.limit;
14
+ let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
11
15
  let recentProjects = new Set();
12
16
  let projectsCacheDirty = true;
13
17
  const collapsedProjectGroups = new Set();
@@ -75,9 +79,9 @@ function updateUrl() {
75
79
  if (viewMode === 'all') params.set('view', 'all');
76
80
  if (viewMode === 'project' && currentProjectPath) params.set('projectView', btoa(currentProjectPath));
77
81
  if (currentSessionId) params.set('session', currentSessionId);
78
- if (sessionFilter !== 'active') params.set('filter', sessionFilter);
79
- if (sessionLimit !== '20') params.set('limit', sessionLimit);
80
- if (filterProject && filterProject !== '__recent__') params.set('project', filterProject);
82
+ if (sessionFilter !== FILTER_DEFAULTS.session) params.set('filter', sessionFilter);
83
+ if (sessionLimit !== FILTER_DEFAULTS.limit) params.set('limit', sessionLimit);
84
+ if (filterProject && filterProject !== FILTER_DEFAULTS.project) params.set('project', filterProject);
81
85
  if (ownerFilter) params.set('owner', ownerFilter);
82
86
  if (searchQuery) params.set('search', searchQuery);
83
87
  if (messagePanelOpen) params.set('messages', '1');
@@ -94,6 +98,9 @@ function persistLastView() {
94
98
  view: viewMode,
95
99
  session: currentSessionId,
96
100
  projectPath: viewMode === 'project' ? currentProjectPath : null,
101
+ // The project filter also lives in the URL, but the hub recreates each iframe at the app's
102
+ // base URL on reload, so the query string alone doesn't survive a hub refresh.
103
+ project: filterProject,
97
104
  };
98
105
  localStorage.setItem(LAST_VIEW_KEY, JSON.stringify(data));
99
106
  } catch (_) {}
@@ -112,9 +119,9 @@ function resetState() {
112
119
  try {
113
120
  localStorage.removeItem(LAST_VIEW_KEY);
114
121
  } catch (_) {}
115
- sessionFilter = 'active';
116
- sessionLimit = '20';
117
- filterProject = '__recent__';
122
+ sessionFilter = FILTER_DEFAULTS.session;
123
+ sessionLimit = FILTER_DEFAULTS.limit;
124
+ filterProject = FILTER_DEFAULTS.project;
118
125
  ownerFilter = '';
119
126
  searchQuery = '';
120
127
  viewMode = 'all';
@@ -126,7 +133,7 @@ function resetState() {
126
133
  const searchInput = document.getElementById('search-input');
127
134
  if (searchInput) searchInput.value = '';
128
135
  document.getElementById('search-clear-btn')?.classList.remove('visible');
129
- loadPreferences();
136
+ renderFilterState();
130
137
  fetchSessions().then(() => showAllTasks());
131
138
  }
132
139
 
@@ -5819,10 +5826,95 @@ function getOwnerColor(name) {
5819
5826
  //#endregion
5820
5827
 
5821
5828
  //#region FILTERS
5829
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5830
+ function toggleFilterMenu(e) {
5831
+ // Stops the click reaching .section-header (which would collapse the section) *and*
5832
+ // document (which would immediately close the menu we are opening).
5833
+ e?.stopPropagation();
5834
+ if (closeFilterMenu()) return;
5835
+ document.getElementById('filter-menu').classList.add('open');
5836
+ document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'true');
5837
+ renderFilterState();
5838
+ document.addEventListener('click', closeFilterMenu, { once: true });
5839
+ // Capture phase: the global keydown handler bails out on SELECT targets, and every
5840
+ // focusable thing in this popover is a select — Escape would never reach it otherwise.
5841
+ document.addEventListener('keydown', filterMenuKeydown, true);
5842
+ document.getElementById('project-filter')?.focus();
5843
+ }
5844
+
5845
+ function filterMenuKeydown(e) {
5846
+ if (e.key !== 'Escape') return;
5847
+ e.stopPropagation();
5848
+ if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
5849
+ }
5850
+
5851
+ function closeFilterMenu() {
5852
+ const menu = document.getElementById('filter-menu');
5853
+ if (!menu?.classList.contains('open')) return false;
5854
+ menu.classList.remove('open');
5855
+ document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'false');
5856
+ document.removeEventListener('keydown', filterMenuKeydown, true);
5857
+ return true;
5858
+ }
5859
+
5860
+ // One row per filter control: the select it mirrors, its value, whether it differs from the
5861
+ // default, and two label forms — terse tokens for the header strip (which has ~15 characters
5862
+ // before the sidebar's width ellipsises it) and long form for its tooltip.
5863
+ const FILTERS = [
5864
+ {
5865
+ id: 'project-filter',
5866
+ value: () => filterProject ?? '',
5867
+ active: () => filterProject !== FILTER_DEFAULTS.project,
5868
+ short: () => (filterProject ? filterProject.split(/[/\\]/).pop() : 'all proj'),
5869
+ long: () => (filterProject ? `project ${filterProject}` : 'all projects'),
5870
+ },
5871
+ {
5872
+ id: 'session-filter',
5873
+ value: () => sessionFilter,
5874
+ active: () => sessionFilter !== FILTER_DEFAULTS.session,
5875
+ short: () => 'inactive',
5876
+ long: () => 'including inactive sessions',
5877
+ },
5878
+ {
5879
+ id: 'session-limit',
5880
+ value: () => sessionLimit,
5881
+ active: () => sessionLimit !== FILTER_DEFAULTS.limit,
5882
+ short: () => (sessionLimit === 'all' ? 'n=∞' : `n=${sessionLimit}`),
5883
+ long: () => (sessionLimit === 'all' ? 'no session limit' : `showing ${sessionLimit}`),
5884
+ },
5885
+ ];
5886
+
5887
+ // Keeps the header (summary + funnel tint) and the popover's selects in sync with state.
5888
+ // Safe to call on every filter change — it only touches classes, values, and text.
5889
+ function renderFilterState() {
5890
+ const short = [];
5891
+ const long = [];
5892
+ for (const f of FILTERS) {
5893
+ const active = f.active();
5894
+ if (active) {
5895
+ short.push(f.short());
5896
+ long.push(f.long());
5897
+ }
5898
+ const el = document.getElementById(f.id);
5899
+ if (!el) continue;
5900
+ const value = String(f.value());
5901
+ if (el.value !== value) el.value = value;
5902
+ el.classList.toggle('non-default', active);
5903
+ }
5904
+
5905
+ const summary = document.getElementById('filter-summary');
5906
+ if (summary) {
5907
+ summary.textContent = short.join(' · ');
5908
+ summary.title = long.length ? `Active filters: ${long.join(', ')}` : '';
5909
+ }
5910
+ document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
5911
+ }
5912
+
5822
5913
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5823
5914
  function filterBySessions(value) {
5824
5915
  sessionFilter = value;
5825
5916
  if (value !== 'active') activityFilter.clear();
5917
+ renderFilterState();
5826
5918
  updateUrl();
5827
5919
  // Instant feedback from cached data, then refetch — the cached list was fetched
5828
5920
  // with the previous filter's server params (e.g. filter=active), so "All Sessions"
@@ -5835,6 +5927,7 @@ function filterBySessions(value) {
5835
5927
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5836
5928
  function changeSessionLimit(value) {
5837
5929
  sessionLimit = value;
5930
+ renderFilterState();
5838
5931
  updateUrl();
5839
5932
  fetchSessions();
5840
5933
  }
@@ -5848,6 +5941,31 @@ function matchesProjectFilter(project) {
5848
5941
  //#endregion
5849
5942
 
5850
5943
  //#region EVENT_DELEGATION
5944
+ // Selecting text inside a dialog and releasing past its edge puts the mouseup on the
5945
+ // backdrop, so the synthesized click targets the overlay and the `onclick="close…()"`
5946
+ // on every .modal-overlay would dismiss it. Only a press that *started* on the
5947
+ // backdrop counts as a dismissal.
5948
+ let _overlayPressTarget = null;
5949
+ document.addEventListener(
5950
+ 'mousedown',
5951
+ (e) => {
5952
+ // Only an overlay is ever compared below, so don't hold a reference to any other node —
5953
+ // that would keep the last-pressed element (and a detached subtree, after a re-render)
5954
+ // alive until the next mousedown anywhere.
5955
+ _overlayPressTarget = e.target.classList?.contains('modal-overlay') ? e.target : null;
5956
+ },
5957
+ true,
5958
+ );
5959
+ document.addEventListener(
5960
+ 'click',
5961
+ (e) => {
5962
+ if (!e.target.classList?.contains('modal-overlay')) return;
5963
+ if (_overlayPressTarget === e.target) return;
5964
+ e.stopPropagation();
5965
+ },
5966
+ true,
5967
+ );
5968
+
5851
5969
  document.addEventListener('click', (e) => {
5852
5970
  const pathToggle = e.target.closest('[data-group-action="toggle-path"]');
5853
5971
  if (pathToggle) {
@@ -5903,6 +6021,7 @@ document.addEventListener('click', (e) => {
5903
6021
  // Used in HTML; also called by the hub project shim, so no biome suppression is needed.
5904
6022
  function filterByProject(project) {
5905
6023
  filterProject = project || null;
6024
+ renderFilterState();
5906
6025
  updateUrl();
5907
6026
  fetchSessions(false);
5908
6027
  showAllTasks();
@@ -5964,6 +6083,8 @@ function renderProjectDropdown(dropdown, projects) {
5964
6083
  return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
5965
6084
  })
5966
6085
  .join('');
6086
+ // The option list just changed under the select — re-assert tint and header summary.
6087
+ renderFilterState();
5967
6088
  }
5968
6089
 
5969
6090
  function updateThemeColor(isLight) {
@@ -6187,14 +6308,6 @@ function loadPanelWidths() {
6187
6308
 
6188
6309
  //#endregion
6189
6310
 
6190
- //#region PREFERENCES
6191
- function loadPreferences() {
6192
- document.getElementById('session-filter').value = sessionFilter;
6193
- document.getElementById('session-limit').value = sessionLimit;
6194
- }
6195
-
6196
- //#endregion
6197
-
6198
6311
  //#region SESSION_INFO
6199
6312
  async function showSessionInfoModal(sessionId) {
6200
6313
  const session = sessions.find((s) => s.id === sessionId);
@@ -7240,13 +7353,16 @@ fetch('/api/version')
7240
7353
  .catch(() => {});
7241
7354
 
7242
7355
  const urlState = getUrlState();
7243
- sessionFilter = urlState.filter || 'active';
7244
- sessionLimit = urlState.limit || '20';
7245
- filterProject = urlState.project || '__recent__';
7356
+ const lastView = loadLastView();
7357
+ sessionFilter = urlState.filter || FILTER_DEFAULTS.session;
7358
+ sessionLimit = urlState.limit || FILTER_DEFAULTS.limit;
7359
+ // The URL wins; the persisted view only fills in when it carries a project key, so 'all' (null)
7360
+ // restores as 'all' and pre-existing blobs without the key still default to '__recent__'.
7361
+ filterProject = urlState.project || (lastView && 'project' in lastView ? lastView.project : FILTER_DEFAULTS.project);
7246
7362
  ownerFilter = urlState.owner || '';
7247
7363
  searchQuery = urlState.search || '';
7248
7364
 
7249
- loadPreferences();
7365
+ renderFilterState();
7250
7366
  pinnedSessionIds = loadPinnedSessions();
7251
7367
  stickySessionIds = loadStickySessions();
7252
7368
  setupEventSource();
@@ -7308,12 +7424,12 @@ Promise.all([
7308
7424
 
7309
7425
  window.addEventListener('popstate', () => {
7310
7426
  const s = getUrlState();
7311
- sessionFilter = s.filter || 'active';
7312
- sessionLimit = s.limit || '20';
7313
- filterProject = s.project || '__recent__';
7427
+ sessionFilter = s.filter || FILTER_DEFAULTS.session;
7428
+ sessionLimit = s.limit || FILTER_DEFAULTS.limit;
7429
+ filterProject = s.project || FILTER_DEFAULTS.project;
7314
7430
  ownerFilter = s.owner || '';
7315
7431
  searchQuery = s.search || '';
7316
- loadPreferences();
7432
+ renderFilterState();
7317
7433
  // fetchSessions derives query params from the globals set above — refetch so
7318
7434
  // back/forward across a filter change doesn't render a stale server-filtered list.
7319
7435
  fetchSessions(false);
package/public/index.html CHANGED
@@ -56,10 +56,58 @@
56
56
  <!-- Tasks -->
57
57
  <div class="sidebar-section flex-1">
58
58
  <div class="section-header" onclick="toggleSection('sessions-filters', 'sessions-chevron')" style="cursor: pointer;">
59
- <span>Sessions</span>
60
- <svg id="sessions-chevron" class="collapse-chevron" viewBox="0 0 24 24">
61
- <path d="M6 9l6 6 6-6"/>
62
- </svg>
59
+ <span class="section-header-label">Sessions</span>
60
+ <span id="filter-summary" class="filter-summary" title=""></span>
61
+ <span class="section-header-actions">
62
+ <button
63
+ id="filter-menu-btn"
64
+ class="icon-btn section-btn"
65
+ onclick="toggleFilterMenu(event)"
66
+ title="Filters"
67
+ aria-label="Filters"
68
+ aria-haspopup="true"
69
+ aria-expanded="false"
70
+ >
71
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
72
+ <path d="M20 4H4l6.5 8v6l3 2v-8L20 4z"/>
73
+ </svg>
74
+ </button>
75
+ <button
76
+ class="icon-btn section-btn"
77
+ onclick="event.stopPropagation(); resetState()"
78
+ title="Reset filters and clear selection"
79
+ aria-label="Reset filters and clear selection"
80
+ >
81
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
82
+ <path d="M3 2v6h6"/>
83
+ <path d="M3.51 15a9 9 0 1 0 2.13-9.36L3 8"/>
84
+ </svg>
85
+ </button>
86
+ <svg id="sessions-chevron" class="collapse-chevron" viewBox="0 0 24 24">
87
+ <path d="M6 9l6 6 6-6"/>
88
+ </svg>
89
+ </span>
90
+ <div id="filter-menu" class="filter-menu" onclick="event.stopPropagation()">
91
+ <div class="filter-row">
92
+ <select id="project-filter" class="filter-dropdown" onchange="filterByProject(this.value)" aria-label="Filter by project">
93
+ <option value="">All Projects</option>
94
+ </select>
95
+ </div>
96
+ <div class="filter-row">
97
+ <select id="session-filter" class="filter-dropdown" onchange="filterBySessions(this.value)" aria-label="Filter by session status">
98
+ <option value="all">All Sessions</option>
99
+ <option value="active">Active Only</option>
100
+ </select>
101
+ </div>
102
+ <div class="filter-row">
103
+ <select id="session-limit" class="filter-dropdown" onchange="changeSessionLimit(this.value)" aria-label="Number of sessions to show">
104
+ <option value="10">Show 10</option>
105
+ <option value="20">Show 20</option>
106
+ <option value="50">Show 50</option>
107
+ <option value="all">Show All</option>
108
+ </select>
109
+ </div>
110
+ </div>
63
111
  </div>
64
112
  <div id="sessions-filters" class="collapsible-section">
65
113
  <div class="search-container">
@@ -76,29 +124,6 @@
76
124
  </svg>
77
125
  </button>
78
126
  </div>
79
- <div class="filter-row">
80
- <select id="project-filter" class="filter-dropdown" onchange="filterByProject(this.value)" aria-label="Filter by project">
81
- <option value="">All Projects</option>
82
- </select>
83
- <select id="session-filter" class="filter-dropdown" onchange="filterBySessions(this.value)" aria-label="Filter by session status">
84
- <option value="all">All Sessions</option>
85
- <option value="active">Active Only</option>
86
- </select>
87
- </div>
88
- <div class="filter-row">
89
- <select id="session-limit" class="filter-dropdown" onchange="changeSessionLimit(this.value)" aria-label="Number of sessions to show">
90
- <option value="10">Show 10</option>
91
- <option value="20">Show 20</option>
92
- <option value="50">Show 50</option>
93
- <option value="all">Show All</option>
94
- </select>
95
- <button class="icon-btn reset-btn" onclick="resetState()" title="Reset all filters" aria-label="Reset all filters">
96
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
97
- <path d="M3 12a9 9 0 1 1 9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
98
- <path d="M3 22v-6h6"/>
99
- </svg>
100
- </button>
101
- </div>
102
127
  </div>
103
128
  <div id="sessions-list" class="sessions-list"></div>
104
129
  </div>
package/public/style.css CHANGED
@@ -353,10 +353,11 @@ body::before {
353
353
  }
354
354
 
355
355
  .section-header {
356
+ position: relative;
356
357
  display: flex;
357
358
  align-items: center;
358
- justify-content: space-between;
359
- padding: 14px 20px 10px;
359
+ gap: 8px;
360
+ padding: 14px 16px 10px 20px;
360
361
  font-size: 11px;
361
362
  font-weight: 500;
362
363
  text-transform: uppercase;
@@ -364,16 +365,95 @@ body::before {
364
365
  color: var(--text-muted);
365
366
  }
366
367
 
368
+ .section-header-label {
369
+ flex: 0 0 auto;
370
+ }
371
+
372
+ /* Non-default filters, spelled out in the header so folding them into the popover
373
+ never hides *that* the list is filtered. Empty (and invisible) at defaults. */
374
+ .filter-summary {
375
+ flex: 1;
376
+ min-width: 0;
377
+ overflow: hidden;
378
+ white-space: nowrap;
379
+ text-overflow: ellipsis;
380
+ font-size: 10px;
381
+ font-weight: 400;
382
+ letter-spacing: 0.02em;
383
+ text-transform: none;
384
+ color: var(--accent-text);
385
+ }
386
+
387
+ .section-header-actions {
388
+ flex: 0 0 auto;
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 2px;
392
+ margin-left: auto;
393
+ }
394
+
395
+ /* Quiet at rest: no box around a header glyph. The hairline arrives on hover.
396
+ Compounded with .icon-btn because that rule is declared later in this file and would
397
+ otherwise win on size, border, and colour. */
398
+ .icon-btn.section-btn {
399
+ width: 26px;
400
+ height: 26px;
401
+ padding: 0;
402
+ background: none;
403
+ border-color: transparent;
404
+ color: var(--text-muted);
405
+ }
406
+
407
+ .icon-btn.section-btn:hover {
408
+ background: var(--bg-hover);
409
+ border-color: var(--border);
410
+ color: var(--text-primary);
411
+ }
412
+
413
+ .icon-btn.section-btn:focus-visible {
414
+ outline: none;
415
+ box-shadow: 0 0 0 2px var(--accent-dim);
416
+ }
417
+
418
+ .icon-btn.section-btn svg {
419
+ width: 15px;
420
+ height: 15px;
421
+ stroke-width: 1.75;
422
+ }
423
+
424
+ /* Ember only while something is actually filtered — at rest the funnel is just chrome. */
425
+ .icon-btn.section-btn.has-filters {
426
+ color: var(--accent-text);
427
+ }
428
+
429
+ .filter-menu {
430
+ display: none;
431
+ position: absolute;
432
+ top: calc(100% - 2px);
433
+ right: 16px;
434
+ left: 16px;
435
+ z-index: 100;
436
+ padding: 8px;
437
+ background: var(--bg-elevated);
438
+ border: 1px solid var(--border);
439
+ border-radius: 8px;
440
+ box-shadow:
441
+ 0 16px 44px rgba(0, 0, 0, 0.38),
442
+ 0 4px 12px rgba(0, 0, 0, 0.2);
443
+ cursor: default;
444
+ }
445
+
446
+ .filter-menu.open {
447
+ display: block;
448
+ }
449
+
367
450
  .filter-row {
368
451
  display: flex;
369
- gap: 6px;
370
- padding: 0 16px 10px;
452
+ gap: 5px;
371
453
  }
372
454
 
373
- .reset-btn {
374
- flex-shrink: 0;
375
- width: 32px;
376
- height: 32px;
455
+ .filter-row + .filter-row {
456
+ margin-top: 5px;
377
457
  }
378
458
 
379
459
  .filter-dropdown {
@@ -399,6 +479,17 @@ body::before {
399
479
  border-color: var(--border);
400
480
  }
401
481
 
482
+ /* A filter holding a non-default value tints toward the ember, so it reads as "active"
483
+ before you read the value. Border and text only — the fill never floods. */
484
+ .filter-dropdown.non-default {
485
+ color: var(--accent-text);
486
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
487
+ }
488
+
489
+ .filter-dropdown.non-default:hover {
490
+ border-color: color-mix(in srgb, var(--accent) 60%, transparent);
491
+ }
492
+
402
493
  .filter-dropdown option {
403
494
  background: var(--bg-surface);
404
495
  color: var(--text-primary);
package/server.js CHANGED
@@ -2659,9 +2659,19 @@ async function readPreviewFile(absPath) {
2659
2659
  return { content: await fs.readFile(absPath, 'utf8'), kind };
2660
2660
  }
2661
2661
 
2662
+ // Paths copied out of a browser or a slide deck carry a URL fragment
2663
+ // ("deck.html#10"). Dropped only when the literal path is missing and the trimmed one
2664
+ // exists, so a filename that genuinely contains '#' still resolves to itself.
2665
+ function dropUrlFragment(abs) {
2666
+ const i = abs.lastIndexOf('#');
2667
+ if (i <= 0 || existsSync(abs)) return abs;
2668
+ const trimmed = abs.slice(0, i);
2669
+ return existsSync(trimmed) ? trimmed : abs;
2670
+ }
2671
+
2662
2672
  function resolvePreviewPath(filePath, base) {
2663
2673
  if (!filePath || typeof filePath !== 'string') return null;
2664
- if (path.isAbsolute(filePath)) return filePath;
2674
+ if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
2665
2675
  if (base && typeof base === 'string' && path.isAbsolute(base)) {
2666
2676
  let baseDir = base;
2667
2677
  try {
@@ -2670,9 +2680,9 @@ function resolvePreviewPath(filePath, base) {
2670
2680
  // base doesn't exist — fall back to dirname if it looks like a file
2671
2681
  if (path.extname(base)) baseDir = path.dirname(base);
2672
2682
  }
2673
- return path.resolve(baseDir, filePath);
2683
+ return dropUrlFragment(path.resolve(baseDir, filePath));
2674
2684
  }
2675
- return path.resolve(filePath);
2685
+ return dropUrlFragment(path.resolve(filePath));
2676
2686
  }
2677
2687
 
2678
2688
  app.post('/api/preview', async (req, res) => {