myrlin-workbook 0.9.27 → 0.9.28

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/logs/server.pid CHANGED
@@ -1 +1 @@
1
- 44496
1
+ 55976
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myrlin-workbook",
3
- "version": "0.9.27",
3
+ "version": "0.9.28",
4
4
  "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -82,7 +82,8 @@ async function listIssues(cwd, filters = {}, binary = DEFAULT_TD_BINARY) {
82
82
  if (filters.status) args.push('--status', filters.status);
83
83
  const { stdout } = await runTd(binary, args, cwd);
84
84
  const parsed = JSON.parse(stdout);
85
- // td --json returns either an array or { issues: [...] }
85
+ // td --json returns either an array, { issues: [...] }, or null (empty repo)
86
+ if (!parsed) return [];
86
87
  return Array.isArray(parsed) ? parsed : (parsed.issues || []);
87
88
  }
88
89
 
@@ -4763,13 +4763,83 @@ class CWMApp {
4763
4763
  if (name === 'files') this.renderTasksFilesPanel();
4764
4764
  }
4765
4765
 
4766
- async renderTasksTdPanel() {
4767
- const panel = document.getElementById('tasks-td-panel');
4766
+ /**
4767
+ * Render the project switcher toolbar inside a td panel toolbar element.
4768
+ * Populates a <select> from this._tdProjects (loaded async).
4769
+ * @param {HTMLElement} toolbar
4770
+ * @param {string} currentDir - the currently shown repo dir
4771
+ */
4772
+ _renderTdToolbar(toolbar, currentDir) {
4773
+ toolbar.textContent = '';
4774
+ const label = document.createElement('span');
4775
+ label.className = 'tasks-td-toolbar-label';
4776
+ label.textContent = 'Project';
4777
+ toolbar.appendChild(label);
4778
+
4779
+ const sel = document.createElement('select');
4780
+ sel.className = 'tasks-td-project-select';
4781
+
4782
+ const projects = this._tdProjects || [];
4783
+
4784
+ // Always include the current dir even if not yet in projects list
4785
+ const allDirs = new Map();
4786
+ allDirs.set(currentDir, this._tdProjectName(currentDir));
4787
+ for (const p of projects) allDirs.set(p.repoDir, p.name || this._tdProjectName(p.repoDir));
4788
+
4789
+ for (const [dir, name] of allDirs) {
4790
+ const opt = document.createElement('option');
4791
+ opt.value = dir;
4792
+ opt.textContent = name;
4793
+ opt.selected = dir === currentDir;
4794
+ sel.appendChild(opt);
4795
+ }
4796
+
4797
+ sel.addEventListener('change', () => {
4798
+ this._tdPanelDir = sel.value;
4799
+ // Mark as manually pinned so pane focus changes don't override the selection
4800
+ this._tdPanelDirPinned = (sel.value !== this._getTdPanelDir());
4801
+ this.renderTasksTdPanel();
4802
+ });
4803
+
4804
+ toolbar.appendChild(sel);
4805
+
4806
+ // Refresh button
4807
+ const refreshBtn = document.createElement('button');
4808
+ refreshBtn.className = 'btn btn-ghost btn-icon btn-sm tasks-td-refresh';
4809
+ refreshBtn.title = 'Refresh';
4810
+ refreshBtn.innerHTML = '&#8635;';
4811
+ refreshBtn.addEventListener('click', () => this.renderTasksTdPanel());
4812
+ toolbar.appendChild(refreshBtn);
4813
+ }
4814
+
4815
+ /** Extract a short project name from a directory path */
4816
+ _tdProjectName(dir) {
4817
+ if (!dir) return '(unknown)';
4818
+ return dir.split('/').filter(Boolean).pop() || dir;
4819
+ }
4820
+
4821
+ /**
4822
+ * Resolve the td repo dir for the currently focused terminal pane.
4823
+ * Falls back to the active workspace's resolved dir.
4824
+ * Returns null if nothing useful found.
4825
+ */
4826
+ _getTdPanelDir() {
4827
+ // 1. Prefer the focused terminal pane's working dir
4828
+ const slot = this._activeTerminalSlot;
4829
+ const tp = slot !== null ? this.terminalPanes[slot] : null;
4830
+ if (tp && tp.spawnOpts && tp.spawnOpts.cwd) return tp.spawnOpts.cwd;
4831
+ // 2. Fall back to any open pane's cwd
4832
+ for (const p of this.terminalPanes) {
4833
+ if (p && p.spawnOpts && p.spawnOpts.cwd) return p.spawnOpts.cwd;
4834
+ }
4835
+ return null;
4836
+ }
4837
+
4838
+ async renderTasksTdPanel(container = null) {
4839
+ const panel = container || document.getElementById('tasks-td-panel');
4768
4840
  if (!panel) return;
4769
4841
  if (!this.getSetting('enableTd')) return;
4770
4842
 
4771
- const ws = this.state.activeWorkspace;
4772
-
4773
4843
  const showPlaceholder = (msg, isError) => {
4774
4844
  panel.textContent = '';
4775
4845
  const el = document.createElement('div');
@@ -4778,24 +4848,44 @@ class CWMApp {
4778
4848
  panel.appendChild(el);
4779
4849
  };
4780
4850
 
4781
- if (!ws) {
4782
- showPlaceholder('No active project selected', false);
4851
+ // Determine which dir to show: manually selected > active pane > nothing
4852
+ const autoDir = this._getTdPanelDir();
4853
+ if (!this._tdPanelDir && autoDir) this._tdPanelDir = autoDir;
4854
+ const dir = this._tdPanelDir;
4855
+
4856
+ if (!dir) {
4857
+ showPlaceholder('Open a project in a terminal pane to see its td issues', false);
4783
4858
  return;
4784
4859
  }
4785
4860
 
4786
4861
  showPlaceholder('Loading td issues\u2026', false);
4787
4862
 
4863
+ // Load projects list for dropdown (non-blocking, updates after issues load)
4864
+ this.api('GET', '/api/td/projects').then(projectsData => {
4865
+ this._tdProjects = projectsData.projects || [];
4866
+ // Re-render toolbar if panel is still showing the td view
4867
+ const toolbar = panel.querySelector('.tasks-td-toolbar');
4868
+ if (toolbar) this._renderTdToolbar(toolbar, dir);
4869
+ }).catch(() => {});
4870
+
4788
4871
  try {
4789
- const data = await this.api('GET', `/api/workspaces/${ws.id}/td/issues`);
4872
+ const data = await this.api('GET', `/api/td/issues?dir=${encodeURIComponent(dir)}`);
4790
4873
  const issues = data.issues || [];
4791
4874
 
4875
+ panel.textContent = '';
4876
+ const toolbar = document.createElement('div');
4877
+ toolbar.className = 'tasks-td-toolbar';
4878
+ this._renderTdToolbar(toolbar, dir);
4879
+ panel.appendChild(toolbar);
4880
+
4792
4881
  if (issues.length === 0) {
4793
- showPlaceholder('No open td issues for this project', false);
4882
+ const empty = document.createElement('div');
4883
+ empty.className = 'tasks-placeholder';
4884
+ empty.textContent = 'No open td issues for this project';
4885
+ panel.appendChild(empty);
4794
4886
  return;
4795
4887
  }
4796
4888
 
4797
- panel.textContent = '';
4798
-
4799
4889
  // Group by status in display order
4800
4890
  const STATUS_ORDER = ['in_progress', 'in_review', 'blocked', 'open'];
4801
4891
  const STATUS_LABELS = {
@@ -4869,6 +4959,10 @@ class CWMApp {
4869
4959
  if (msg.includes('not initialized')) {
4870
4960
  // td isn't initialized — show a helpful prompt rather than a raw error
4871
4961
  panel.textContent = '';
4962
+ const toolbar = document.createElement('div');
4963
+ toolbar.className = 'tasks-td-toolbar';
4964
+ this._renderTdToolbar(toolbar, dir);
4965
+ panel.appendChild(toolbar);
4872
4966
  const wrap = document.createElement('div');
4873
4967
  wrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:12px;padding:32px 24px;';
4874
4968
  const info = document.createElement('div');
@@ -4877,7 +4971,7 @@ class CWMApp {
4877
4971
  info.textContent = 'td is not initialized for this project.';
4878
4972
  const hint = document.createElement('div');
4879
4973
  hint.style.cssText = 'font-size:12px;color:var(--subtext0);text-align:center;';
4880
- hint.textContent = 'Run td init in the project root to enable task tracking. If this repo uses a git worktree, td should be initialized in the main repo.';
4974
+ hint.textContent = 'Run td init in the project root to enable task tracking.';
4881
4975
  const initBtn = document.createElement('button');
4882
4976
  initBtn.className = 'btn btn-primary btn-sm';
4883
4977
  initBtn.textContent = 'Run td init';
@@ -4885,7 +4979,14 @@ class CWMApp {
4885
4979
  initBtn.disabled = true;
4886
4980
  initBtn.textContent = 'Initializing\u2026';
4887
4981
  try {
4888
- await this.api('POST', `/api/workspaces/${ws.id}/td/init`, {});
4982
+ // Use the workspace-scoped init if we can find the ws, otherwise use the dir directly
4983
+ const ws = this.state.activeWorkspace;
4984
+ if (ws) {
4985
+ await this.api('POST', `/api/workspaces/${ws.id}/td/init`, { repoDir: dir });
4986
+ } else {
4987
+ // Fallback: run td init via a generic endpoint if workspace isn't known
4988
+ throw new Error('No active workspace to run td init against. Run `td init` manually in ' + dir);
4989
+ }
4889
4990
  this.renderTasksTdPanel();
4890
4991
  } catch (e) {
4891
4992
  initBtn.disabled = false;
@@ -9978,6 +10079,24 @@ class CWMApp {
9978
10079
  });
9979
10080
  }
9980
10081
 
10082
+ // Pinned notes (bookmark) button — shows modal of all pinned notes for this pane's session
10083
+ const pinDocBtn = pane.querySelector('.terminal-pane-pinnedoc');
10084
+ if (pinDocBtn) {
10085
+ pinDocBtn.addEventListener('click', (e) => {
10086
+ e.stopPropagation();
10087
+ this._showPinnedNotesModal(slotIdx);
10088
+ });
10089
+ }
10090
+
10091
+ // Pane view back button — restores terminal after a non-terminal view (E003)
10092
+ const backBtn = pane.querySelector('.pane-view-back');
10093
+ if (backBtn) {
10094
+ backBtn.addEventListener('click', (e) => {
10095
+ e.stopPropagation();
10096
+ if (this.restoreTerminalInPane) this.restoreTerminalInPane(slotIdx);
10097
+ });
10098
+ }
10099
+
9981
10100
  // Drag-to-reposition: make pane header draggable to swap panes
9982
10101
  const header = pane.querySelector('.terminal-pane-header');
9983
10102
  if (header) {
@@ -10166,6 +10285,9 @@ class CWMApp {
10166
10285
  if (this.state.settings.paneColorHighlights) {
10167
10286
  this.renderWorkspaces();
10168
10287
  }
10288
+
10289
+ // Refresh pinned-notes badge for this pane now that a session is loaded
10290
+ this._refreshPanePin(slotIdx);
10169
10291
  }
10170
10292
 
10171
10293
  /**
@@ -10206,6 +10328,31 @@ class CWMApp {
10206
10328
  el.innerHTML = `<span class="activity-dot ${dotClass}"></span>${label}${detail}`;
10207
10329
  }
10208
10330
 
10331
+ /**
10332
+ * Show a modal listing all pinned notes for the session currently open in the given pane slot.
10333
+ * Fetches notes from the backend and displays them with timestamps.
10334
+ * If there are no pinned notes, shows an info toast instead.
10335
+ * @param {number} slotIdx - The terminal pane slot index
10336
+ */
10337
+ async _showPinnedNotesModal(slotIdx) {
10338
+ const tp = this.terminalPanes[slotIdx];
10339
+ if (!tp || !tp.sessionId) return;
10340
+ const ws = this.state.activeWorkspace;
10341
+ if (!ws) return;
10342
+ const data = await this.api('GET', `/api/workspaces/${ws.id}/pinned-notes/${tp.sessionId}`);
10343
+ const notes = data.notes || [];
10344
+ if (notes.length === 0) {
10345
+ this.showToast('No pinned notes for this session', 'info');
10346
+ return;
10347
+ }
10348
+ const body = notes.map(n => `[${n.timestamp}]\n${n.text}`).join('\n\n---\n\n');
10349
+ await this.showPromptModal({
10350
+ title: 'Pinned Notes',
10351
+ fields: [{ key: 'body', type: 'textarea', value: body }],
10352
+ confirmText: 'Close',
10353
+ });
10354
+ }
10355
+
10209
10356
  showTerminalContextMenu(slotIdx, x, y) {
10210
10357
  const tp = this.terminalPanes[slotIdx];
10211
10358
  if (!tp) return;
@@ -10227,6 +10374,26 @@ class CWMApp {
10227
10374
  });
10228
10375
  }
10229
10376
 
10377
+ // Save to Notes (only show when there's a selection)
10378
+ if (tp.term && tp.term.hasSelection()) {
10379
+ items.push({
10380
+ label: 'Save to Notes', icon: '&#128221;', action: async () => {
10381
+ const selected = tp.term.getSelection();
10382
+ const ws = this.state.activeWorkspace;
10383
+ if (!ws) { this.showToast('No active workspace', 'error'); return; }
10384
+ const result = await this.showPromptModal({
10385
+ title: 'Save to Notes',
10386
+ fields: [{ key: 'text', type: 'textarea', value: selected }],
10387
+ confirmText: 'Save Note'
10388
+ });
10389
+ if (!result) return;
10390
+ await this.api('POST', '/api/workspaces/' + ws.id + '/docs/notes', { text: result.text.trim() });
10391
+ this.loadDocs();
10392
+ this.showToast('Saved to Notes', 'success');
10393
+ },
10394
+ });
10395
+ }
10396
+
10230
10397
  // Paste from clipboard
10231
10398
  items.push({
10232
10399
  label: 'Paste', icon: '&#128203;', action: () => {
@@ -10992,6 +11159,15 @@ class CWMApp {
10992
11159
  tp.setFocused(true);
10993
11160
  tp.focus();
10994
11161
  }
11162
+
11163
+ // If Tasks > td tab is visible and not manually pinned, update to this pane's project
11164
+ if (this._activeTasksTab === 'td' && !this._tdPanelDirPinned) {
11165
+ const newDir = tp && tp.spawnOpts && tp.spawnOpts.cwd ? tp.spawnOpts.cwd : null;
11166
+ if (newDir && newDir !== this._tdPanelDir) {
11167
+ this._tdPanelDir = newDir;
11168
+ this.renderTasksTdPanel();
11169
+ }
11170
+ }
10995
11171
  }
10996
11172
 
10997
11173
  /**
@@ -11810,16 +11986,54 @@ class CWMApp {
11810
11986
  if (this.els.docsRoadmapCount) this.els.docsRoadmapCount.textContent = (docs.roadmap || []).length;
11811
11987
  if (this.els.docsRulesCount) this.els.docsRulesCount.textContent = (docs.rules || []).length;
11812
11988
 
11813
- // Notes
11989
+ // Notes — built with DOM APIs to support pin buttons safely (no user HTML injected)
11814
11990
  if (this.els.docsNotesList) {
11815
- this.els.docsNotesList.innerHTML = (docs.notes || []).length > 0
11816
- ? (docs.notes || []).map((n, i) => `
11817
- <div class="docs-item" data-index="${i}">
11818
- <span class="docs-note-time">${this.escapeHtml(n.timestamp || '')}</span>
11819
- <span class="docs-note-text">${this.escapeHtml(n.text)}</span>
11820
- <button class="docs-item-delete btn btn-ghost btn-icon btn-sm" data-section="notes" data-index="${i}" title="Remove">&times;</button>
11821
- </div>`).join('')
11822
- : '<div class="docs-empty">No notes yet. Click + to add one.</div>';
11991
+ const notes = docs.notes || [];
11992
+ while (this.els.docsNotesList.firstChild) {
11993
+ this.els.docsNotesList.removeChild(this.els.docsNotesList.firstChild);
11994
+ }
11995
+ if (notes.length === 0) {
11996
+ const empty = document.createElement('div');
11997
+ empty.className = 'docs-empty';
11998
+ empty.textContent = 'No notes yet. Click + to add one.';
11999
+ this.els.docsNotesList.appendChild(empty);
12000
+ } else {
12001
+ notes.forEach((n, noteIndex) => {
12002
+ const noteRow = document.createElement('div');
12003
+ noteRow.className = 'docs-item';
12004
+ noteRow.dataset.index = noteIndex;
12005
+
12006
+ const timeSpan = document.createElement('span');
12007
+ timeSpan.className = 'docs-note-time';
12008
+ timeSpan.textContent = n.timestamp || '';
12009
+ noteRow.appendChild(timeSpan);
12010
+
12011
+ const textSpan = document.createElement('span');
12012
+ textSpan.className = 'docs-note-text';
12013
+ textSpan.textContent = n.text;
12014
+ noteRow.appendChild(textSpan);
12015
+
12016
+ const pinBtn = document.createElement('button');
12017
+ pinBtn.className = 'doc-pin-btn btn btn-ghost btn-icon btn-sm';
12018
+ pinBtn.textContent = '📌';
12019
+ pinBtn.title = 'Pin to focused terminal session';
12020
+ pinBtn.addEventListener('click', (e) => {
12021
+ e.stopPropagation();
12022
+ this._toggleNotePin(noteIndex, pinBtn);
12023
+ });
12024
+ noteRow.appendChild(pinBtn);
12025
+
12026
+ const delBtn = document.createElement('button');
12027
+ delBtn.className = 'docs-item-delete btn btn-ghost btn-icon btn-sm';
12028
+ delBtn.dataset.section = 'notes';
12029
+ delBtn.dataset.index = noteIndex;
12030
+ delBtn.title = 'Remove';
12031
+ delBtn.textContent = '×';
12032
+ noteRow.appendChild(delBtn);
12033
+
12034
+ this.els.docsNotesList.appendChild(noteRow);
12035
+ });
12036
+ }
11823
12037
  }
11824
12038
 
11825
12039
  // Goals
@@ -11971,6 +12185,52 @@ class CWMApp {
11971
12185
  }
11972
12186
  }
11973
12187
 
12188
+ /**
12189
+ * Toggle a pinned note on the currently focused terminal pane session.
12190
+ * @param {number} noteIndex - 0-based index of the note in the workspace docs.notes array
12191
+ * @param {HTMLButtonElement} buttonEl - The pin button element to update visually
12192
+ */
12193
+ async _toggleNotePin(noteIndex, buttonEl) {
12194
+ const slot = this._activeTerminalSlot;
12195
+ const tp = (slot !== null && slot !== undefined) ? this.terminalPanes[slot] : null;
12196
+ if (!tp || !tp.sessionId) {
12197
+ this.showToast('Focus a terminal pane first', 'error');
12198
+ return;
12199
+ }
12200
+ const ws = this.state.activeWorkspace;
12201
+ if (!ws) return;
12202
+ const isPinned = buttonEl.classList.contains('pinned');
12203
+ const action = isPinned ? 'unpin' : 'pin';
12204
+ await this.api('POST', `/api/workspaces/${ws.id}/pinned-notes`, {
12205
+ sessionId: tp.sessionId,
12206
+ noteIndex,
12207
+ action
12208
+ });
12209
+ buttonEl.classList.toggle('pinned', !isPinned);
12210
+ await this._refreshPanePin(slot);
12211
+ }
12212
+
12213
+ /**
12214
+ * Refresh the pinned-notes badge on a terminal pane header.
12215
+ * Shows/hides the badge button and updates the count label.
12216
+ * @param {number} slotIdx - Terminal pane slot index (0-based)
12217
+ */
12218
+ async _refreshPanePin(slotIdx) {
12219
+ const tp = this.terminalPanes[slotIdx];
12220
+ if (!tp || !tp.sessionId) return;
12221
+ const ws = this.state.activeWorkspace;
12222
+ if (!ws) return;
12223
+ const data = await this.api('GET', `/api/workspaces/${ws.id}/pinned-notes`);
12224
+ const pins = data[tp.sessionId] || [];
12225
+ const paneEl = document.getElementById(`term-pane-${slotIdx}`);
12226
+ if (!paneEl) return;
12227
+ const pinDocBtn = paneEl.querySelector('.terminal-pane-pinnedoc');
12228
+ if (!pinDocBtn) return;
12229
+ pinDocBtn.hidden = pins.length === 0;
12230
+ const countEl = pinDocBtn.querySelector('.pane-pin-count');
12231
+ if (countEl) countEl.textContent = pins.length > 0 ? pins.length : '';
12232
+ }
12233
+
11974
12234
 
11975
12235
  /* ═══════════════════════════════════════════════════════════
11976
12236
  TD ISSUES — docs panel integration
@@ -558,6 +558,7 @@
558
558
  <path d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z"/>
559
559
  </svg>
560
560
  </button>
561
+ <button class="terminal-pane-pinnedoc btn btn-ghost btn-icon btn-sm" title="Pinned notes" hidden>📌<span class="pane-pin-count"></span></button>
561
562
  <button class="terminal-pane-close btn btn-ghost btn-icon btn-sm" hidden>
562
563
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/></svg>
563
564
  </button>
@@ -604,6 +605,7 @@
604
605
  <path d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z"/>
605
606
  </svg>
606
607
  </button>
608
+ <button class="terminal-pane-pinnedoc btn btn-ghost btn-icon btn-sm" title="Pinned notes" hidden>📌<span class="pane-pin-count"></span></button>
607
609
  <button class="terminal-pane-close btn btn-ghost btn-icon btn-sm" hidden>
608
610
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/></svg>
609
611
  </button>
@@ -650,6 +652,7 @@
650
652
  <path d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z"/>
651
653
  </svg>
652
654
  </button>
655
+ <button class="terminal-pane-pinnedoc btn btn-ghost btn-icon btn-sm" title="Pinned notes" hidden>📌<span class="pane-pin-count"></span></button>
653
656
  <button class="terminal-pane-close btn btn-ghost btn-icon btn-sm" hidden>
654
657
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/></svg>
655
658
  </button>
@@ -696,6 +699,7 @@
696
699
  <path d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z"/>
697
700
  </svg>
698
701
  </button>
702
+ <button class="terminal-pane-pinnedoc btn btn-ghost btn-icon btn-sm" title="Pinned notes" hidden>📌<span class="pane-pin-count"></span></button>
699
703
  <button class="terminal-pane-close btn btn-ghost btn-icon btn-sm" hidden>
700
704
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/></svg>
701
705
  </button>
@@ -742,6 +746,7 @@
742
746
  <path d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z"/>
743
747
  </svg>
744
748
  </button>
749
+ <button class="terminal-pane-pinnedoc btn btn-ghost btn-icon btn-sm" title="Pinned notes" hidden>📌<span class="pane-pin-count"></span></button>
745
750
  <button class="terminal-pane-close btn btn-ghost btn-icon btn-sm" hidden>
746
751
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/></svg>
747
752
  </button>
@@ -788,6 +793,7 @@
788
793
  <path d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z"/>
789
794
  </svg>
790
795
  </button>
796
+ <button class="terminal-pane-pinnedoc btn btn-ghost btn-icon btn-sm" title="Pinned notes" hidden>📌<span class="pane-pin-count"></span></button>
791
797
  <button class="terminal-pane-close btn btn-ghost btn-icon btn-sm" hidden>
792
798
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/></svg>
793
799
  </button>
@@ -4313,6 +4313,19 @@ textarea.input {
4313
4313
  color: var(--red);
4314
4314
  }
4315
4315
 
4316
+ .doc-pin-btn {
4317
+ opacity: 0.4;
4318
+ transition: opacity 0.15s;
4319
+ flex-shrink: 0;
4320
+ cursor: pointer;
4321
+ }
4322
+
4323
+ .doc-pin-btn:hover,
4324
+ .doc-pin-btn.pinned {
4325
+ opacity: 1;
4326
+ color: var(--mauve);
4327
+ }
4328
+
4316
4329
  /* Roadmap items */
4317
4330
  .roadmap-status-dot {
4318
4331
  background: none;
@@ -5031,6 +5044,15 @@ textarea.input {
5031
5044
  @media (prefers-reduced-motion: reduce) {
5032
5045
  .terminal-pane-mic.mic-active { animation: none; }
5033
5046
  }
5047
+ .pane-pin-count {
5048
+ font-size: 10px;
5049
+ background: var(--mauve);
5050
+ color: var(--base);
5051
+ border-radius: 8px;
5052
+ padding: 0 4px;
5053
+ margin-left: 2px;
5054
+ font-weight: 600;
5055
+ }
5034
5056
  /* Voice interim transcript overlay - shown at bottom of terminal pane during recording */
5035
5057
  .voice-interim-overlay {
5036
5058
  position: absolute;
@@ -6321,6 +6343,20 @@ html.activity-indicators-disabled .terminal-pane-activity {
6321
6343
  flex: 1;
6322
6344
  overflow-y: auto;
6323
6345
  min-height: 0;
6346
+ display: flex;
6347
+ flex-direction: column;
6348
+ }
6349
+ /* td panel needs its toolbar fixed at top, list scrolls below */
6350
+ #tasks-td-panel {
6351
+ display: flex;
6352
+ flex-direction: column;
6353
+ overflow: hidden;
6354
+ }
6355
+ #tasks-td-panel .tasks-td-toolbar { flex-shrink: 0; }
6356
+ #tasks-td-panel .tasks-td-group-header,
6357
+ #tasks-td-panel .tasks-td-row,
6358
+ #tasks-td-panel .tasks-placeholder {
6359
+ flex-shrink: 0;
6324
6360
  }
6325
6361
 
6326
6362
  /* ── Placeholder / empty states ──────────────────────── */
@@ -6394,6 +6430,40 @@ html.activity-indicators-disabled .terminal-pane-activity {
6394
6430
  .td-priority-badge.priority-p2 { background: var(--yellow); color: var(--base); }
6395
6431
  .td-priority-badge.priority-p3 { background: var(--surface2); color: var(--text); }
6396
6432
 
6433
+ /* ── td panel project switcher toolbar ───────────────── */
6434
+ .tasks-td-toolbar {
6435
+ display: flex;
6436
+ align-items: center;
6437
+ gap: 8px;
6438
+ padding: 6px 12px;
6439
+ border-bottom: 1px solid var(--surface1);
6440
+ background: var(--mantle);
6441
+ flex-shrink: 0;
6442
+ }
6443
+ .tasks-td-toolbar-label {
6444
+ font-size: 11px;
6445
+ color: var(--subtext0);
6446
+ font-weight: 600;
6447
+ white-space: nowrap;
6448
+ }
6449
+ .tasks-td-project-select {
6450
+ flex: 1;
6451
+ font-size: 12px;
6452
+ background: var(--surface0);
6453
+ color: var(--text);
6454
+ border: 1px solid var(--surface2);
6455
+ border-radius: 4px;
6456
+ padding: 2px 6px;
6457
+ min-width: 0;
6458
+ cursor: pointer;
6459
+ }
6460
+ .tasks-td-project-select:focus { outline: none; border-color: var(--mauve); }
6461
+ .tasks-td-refresh {
6462
+ font-size: 14px;
6463
+ padding: 2px 6px;
6464
+ flex-shrink: 0;
6465
+ }
6466
+
6397
6467
  /* ─── New Task Dialog form elements ──────────────────── */
6398
6468
  .form-group {
6399
6469
  margin-bottom: 14px;
package/src/web/server.js CHANGED
@@ -750,6 +750,151 @@ app.delete('/api/workspaces/:id/docs/:section/:index', requireAuth, (req, res) =
750
750
  return res.json({ success: true });
751
751
  });
752
752
 
753
+ // ──────────────────────────────────────────────────────────
754
+ // PINNED NOTES
755
+ // Per-session pinned note indices, persisted as JSON files
756
+ // under ~/.myrlin/pinned-notes/{workspaceId}.json
757
+ // Format: { [sessionId]: [noteIndex, ...] }
758
+ // ──────────────────────────────────────────────────────────
759
+
760
+ /**
761
+ * Return the directory used to store pinned-note files.
762
+ * Creates it if it does not already exist.
763
+ * @returns {string}
764
+ */
765
+ function getPinnedNotesDir() {
766
+ const dir = path.join(getDataDir(), 'pinned-notes');
767
+ require('fs').mkdirSync(dir, { recursive: true });
768
+ return dir;
769
+ }
770
+
771
+ /**
772
+ * Read the pinned-notes map for a workspace from disk.
773
+ * Returns {} if the file does not exist or cannot be parsed.
774
+ * @param {string} workspaceId
775
+ * @returns {{ [sessionId: string]: number[] }}
776
+ */
777
+ function getPinnedNotes(workspaceId) {
778
+ const file = path.join(getPinnedNotesDir(), `${workspaceId}.json`);
779
+ try {
780
+ const raw = require('fs').readFileSync(file, 'utf-8');
781
+ return JSON.parse(raw);
782
+ } catch (_) {
783
+ return {};
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Atomically write the pinned-notes map for a workspace.
789
+ * Writes to a temp file then renames to ensure no partial reads.
790
+ * @param {string} workspaceId
791
+ * @param {{ [sessionId: string]: number[] }} data
792
+ */
793
+ function savePinnedNotes(workspaceId, data) {
794
+ const fs = require('fs');
795
+ const dir = getPinnedNotesDir();
796
+ const file = path.join(dir, `${workspaceId}.json`);
797
+ const tmp = path.join(dir, `.tmp.${Date.now()}.${workspaceId}.json`);
798
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
799
+ fs.renameSync(tmp, file);
800
+ }
801
+
802
+ /**
803
+ * Add noteIndex to the pinned list for sessionId, deduplicating.
804
+ * @param {string} wsId
805
+ * @param {string} sessionId
806
+ * @param {number} noteIndex
807
+ */
808
+ function pin(wsId, sessionId, noteIndex) {
809
+ const data = getPinnedNotes(wsId);
810
+ const existing = data[sessionId] || [];
811
+ if (!existing.includes(noteIndex)) {
812
+ existing.push(noteIndex);
813
+ }
814
+ data[sessionId] = existing;
815
+ savePinnedNotes(wsId, data);
816
+ }
817
+
818
+ /**
819
+ * Remove noteIndex from the pinned list for sessionId.
820
+ * @param {string} wsId
821
+ * @param {string} sessionId
822
+ * @param {number} noteIndex
823
+ */
824
+ function unpin(wsId, sessionId, noteIndex) {
825
+ const data = getPinnedNotes(wsId);
826
+ if (!data[sessionId]) return;
827
+ data[sessionId] = data[sessionId].filter(i => i !== noteIndex);
828
+ savePinnedNotes(wsId, data);
829
+ }
830
+
831
+ /**
832
+ * GET /api/workspaces/:id/pinned-notes
833
+ * Returns the full pinned-notes map: { [sessionId]: [noteIndex, ...] }
834
+ */
835
+ app.get('/api/workspaces/:id/pinned-notes', requireAuth, (req, res) => {
836
+ const store = getStore();
837
+ const ws = store.getWorkspace(req.params.id);
838
+ if (!ws) return res.status(404).json({ error: 'Workspace not found.' });
839
+
840
+ const data = getPinnedNotes(req.params.id);
841
+ return res.json(data);
842
+ });
843
+
844
+ /**
845
+ * POST /api/workspaces/:id/pinned-notes
846
+ * Body: { sessionId, noteIndex, action: 'pin' | 'unpin' }
847
+ * Pins or unpins a note index for a session.
848
+ */
849
+ app.post('/api/workspaces/:id/pinned-notes', requireAuth, (req, res) => {
850
+ const store = getStore();
851
+ const ws = store.getWorkspace(req.params.id);
852
+ if (!ws) return res.status(404).json({ error: 'Workspace not found.' });
853
+
854
+ const { sessionId, noteIndex, action } = req.body || {};
855
+ if (!sessionId || noteIndex === undefined || noteIndex === null) {
856
+ return res.status(400).json({ error: 'sessionId and noteIndex are required.' });
857
+ }
858
+ const idx = parseInt(noteIndex, 10);
859
+ if (isNaN(idx) || idx < 0) {
860
+ return res.status(400).json({ error: 'noteIndex must be a non-negative integer.' });
861
+ }
862
+
863
+ if (action === 'unpin') {
864
+ unpin(req.params.id, sessionId, idx);
865
+ } else {
866
+ pin(req.params.id, sessionId, idx);
867
+ }
868
+ return res.json({ success: true });
869
+ });
870
+
871
+ /**
872
+ * GET /api/workspaces/:id/pinned-notes/:sessionId
873
+ * Returns resolved note objects for the session's pinned indices.
874
+ * Response: { notes: [{ text, timestamp }, ...] }
875
+ * Returns empty array (not 404) if the session has no pins.
876
+ */
877
+ app.get('/api/workspaces/:id/pinned-notes/:sessionId', requireAuth, (req, res) => {
878
+ const store = getStore();
879
+ const ws = store.getWorkspace(req.params.id);
880
+ if (!ws) return res.status(404).json({ error: 'Workspace not found.' });
881
+
882
+ const data = getPinnedNotes(req.params.id);
883
+ const indices = data[req.params.sessionId] || [];
884
+
885
+ if (indices.length === 0) {
886
+ return res.json({ notes: [] });
887
+ }
888
+
889
+ const docs = store.getWorkspaceDocs(req.params.id);
890
+ const allNotes = (docs && docs.notes) ? docs.notes : [];
891
+ const notes = indices
892
+ .filter(i => i >= 0 && i < allNotes.length)
893
+ .map(i => ({ text: allNotes[i].text, timestamp: allNotes[i].timestamp }));
894
+
895
+ return res.json({ notes });
896
+ });
897
+
753
898
  // ──────────────────────────────────────────────────────────
754
899
  // TD TASK INTEGRATION
755
900
  // These endpoints bridge myrlin's docs panel to the `td` CLI
@@ -858,6 +1003,39 @@ app.put('/api/workspaces/:id/td/repodir', requireAuth, (req, res) => {
858
1003
  return res.json({ success: true, repoDir });
859
1004
  });
860
1005
 
1006
+ /**
1007
+ * GET /api/td/projects
1008
+ * Return all workspaces that have td initialized, with their resolved repo dirs.
1009
+ * Used by the Tasks > td panel dropdown.
1010
+ */
1011
+ app.get('/api/td/projects', requireAuth, (req, res) => {
1012
+ const store = getStore();
1013
+ const allWorkspaces = store.getAllWorkspacesList();
1014
+ const projects = [];
1015
+ for (const ws of allWorkspaces) {
1016
+ const repoDir = resolveTdRepoDir(store, ws.id);
1017
+ if (repoDir && td.isInitialized(repoDir)) {
1018
+ projects.push({ name: ws.name, repoDir, workspaceId: ws.id });
1019
+ }
1020
+ }
1021
+ return res.json({ projects });
1022
+ });
1023
+
1024
+ /**
1025
+ * GET /api/td/issues?dir=<path>
1026
+ * List td issues for an explicit repo directory (not workspace-scoped).
1027
+ * Allows the Tasks panel to show issues for whatever dir is focused.
1028
+ * Query: ?dir=<absolute-path>, ?status=open|in_progress|...
1029
+ */
1030
+ app.get('/api/td/issues', requireAuth, async (req, res) => {
1031
+ const dir = req.query.dir ? decodeURIComponent(req.query.dir) : null;
1032
+ if (!dir) return res.status(400).json({ error: 'dir query parameter required.' });
1033
+ if (!td.isInitialized(dir)) return res.status(400).json({ error: 'td is not initialized in this directory.' });
1034
+ const filters = req.query.status ? { status: req.query.status } : {};
1035
+ const issues = await td.listIssues(dir, filters, getTdBinary());
1036
+ return res.json({ issues, repoDir: dir });
1037
+ });
1038
+
861
1039
  /**
862
1040
  * GET /api/workspaces/:id/td/issues
863
1041
  * List td issues for this workspace's repo.