myrlin-workbook 0.9.27 → 0.9.29

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.29",
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
 
@@ -3109,10 +3109,16 @@ class CWMApp {
3109
3109
  this.showToast('Select or create a project first', 'warning');
3110
3110
  return;
3111
3111
  }
3112
- // Use project folder name as friendly default name instead of raw UUID
3113
- const projectName = projectPath ? projectPath.split('\\').pop() || projectPath.split('/').pop() || sessionName : sessionName;
3114
- const shortId = sessionName.length > 8 ? sessionName.substring(0, 8) : sessionName;
3115
- const friendlyName = projectName + ' (' + shortId + ')';
3112
+ // Prefer custom title from Claude session, fall back to folder name + short UUID
3113
+ const customTitle = this.getProjectSessionTitle(sessionName);
3114
+ let friendlyName;
3115
+ if (customTitle) {
3116
+ friendlyName = customTitle;
3117
+ } else {
3118
+ const projectName = projectPath ? projectPath.split('\\').pop() || projectPath.split('/').pop() || sessionName : sessionName;
3119
+ const shortId = sessionName.length > 8 ? sessionName.substring(0, 8) : sessionName;
3120
+ friendlyName = projectName + ' (' + shortId + ')';
3121
+ }
3116
3122
  this.api('POST', '/api/sessions', {
3117
3123
  name: friendlyName,
3118
3124
  workspaceId: this.state.activeWorkspace.id,
@@ -4763,13 +4769,83 @@ class CWMApp {
4763
4769
  if (name === 'files') this.renderTasksFilesPanel();
4764
4770
  }
4765
4771
 
4766
- async renderTasksTdPanel() {
4767
- const panel = document.getElementById('tasks-td-panel');
4772
+ /**
4773
+ * Render the project switcher toolbar inside a td panel toolbar element.
4774
+ * Populates a <select> from this._tdProjects (loaded async).
4775
+ * @param {HTMLElement} toolbar
4776
+ * @param {string} currentDir - the currently shown repo dir
4777
+ */
4778
+ _renderTdToolbar(toolbar, currentDir) {
4779
+ toolbar.textContent = '';
4780
+ const label = document.createElement('span');
4781
+ label.className = 'tasks-td-toolbar-label';
4782
+ label.textContent = 'Project';
4783
+ toolbar.appendChild(label);
4784
+
4785
+ const sel = document.createElement('select');
4786
+ sel.className = 'tasks-td-project-select';
4787
+
4788
+ const projects = this._tdProjects || [];
4789
+
4790
+ // Always include the current dir even if not yet in projects list
4791
+ const allDirs = new Map();
4792
+ allDirs.set(currentDir, this._tdProjectName(currentDir));
4793
+ for (const p of projects) allDirs.set(p.repoDir, p.name || this._tdProjectName(p.repoDir));
4794
+
4795
+ for (const [dir, name] of allDirs) {
4796
+ const opt = document.createElement('option');
4797
+ opt.value = dir;
4798
+ opt.textContent = name;
4799
+ opt.selected = dir === currentDir;
4800
+ sel.appendChild(opt);
4801
+ }
4802
+
4803
+ sel.addEventListener('change', () => {
4804
+ this._tdPanelDir = sel.value;
4805
+ // Mark as manually pinned so pane focus changes don't override the selection
4806
+ this._tdPanelDirPinned = (sel.value !== this._getTdPanelDir());
4807
+ this.renderTasksTdPanel();
4808
+ });
4809
+
4810
+ toolbar.appendChild(sel);
4811
+
4812
+ // Refresh button
4813
+ const refreshBtn = document.createElement('button');
4814
+ refreshBtn.className = 'btn btn-ghost btn-icon btn-sm tasks-td-refresh';
4815
+ refreshBtn.title = 'Refresh';
4816
+ refreshBtn.innerHTML = '&#8635;';
4817
+ refreshBtn.addEventListener('click', () => this.renderTasksTdPanel());
4818
+ toolbar.appendChild(refreshBtn);
4819
+ }
4820
+
4821
+ /** Extract a short project name from a directory path */
4822
+ _tdProjectName(dir) {
4823
+ if (!dir) return '(unknown)';
4824
+ return dir.split('/').filter(Boolean).pop() || dir;
4825
+ }
4826
+
4827
+ /**
4828
+ * Resolve the td repo dir for the currently focused terminal pane.
4829
+ * Falls back to the active workspace's resolved dir.
4830
+ * Returns null if nothing useful found.
4831
+ */
4832
+ _getTdPanelDir() {
4833
+ // 1. Prefer the focused terminal pane's working dir
4834
+ const slot = this._activeTerminalSlot;
4835
+ const tp = slot !== null ? this.terminalPanes[slot] : null;
4836
+ if (tp && tp.spawnOpts && tp.spawnOpts.cwd) return tp.spawnOpts.cwd;
4837
+ // 2. Fall back to any open pane's cwd
4838
+ for (const p of this.terminalPanes) {
4839
+ if (p && p.spawnOpts && p.spawnOpts.cwd) return p.spawnOpts.cwd;
4840
+ }
4841
+ return null;
4842
+ }
4843
+
4844
+ async renderTasksTdPanel(container = null) {
4845
+ const panel = container || document.getElementById('tasks-td-panel');
4768
4846
  if (!panel) return;
4769
4847
  if (!this.getSetting('enableTd')) return;
4770
4848
 
4771
- const ws = this.state.activeWorkspace;
4772
-
4773
4849
  const showPlaceholder = (msg, isError) => {
4774
4850
  panel.textContent = '';
4775
4851
  const el = document.createElement('div');
@@ -4778,24 +4854,44 @@ class CWMApp {
4778
4854
  panel.appendChild(el);
4779
4855
  };
4780
4856
 
4781
- if (!ws) {
4782
- showPlaceholder('No active project selected', false);
4857
+ // Determine which dir to show: manually selected > active pane > nothing
4858
+ const autoDir = this._getTdPanelDir();
4859
+ if (!this._tdPanelDir && autoDir) this._tdPanelDir = autoDir;
4860
+ const dir = this._tdPanelDir;
4861
+
4862
+ if (!dir) {
4863
+ showPlaceholder('Open a project in a terminal pane to see its td issues', false);
4783
4864
  return;
4784
4865
  }
4785
4866
 
4786
4867
  showPlaceholder('Loading td issues\u2026', false);
4787
4868
 
4869
+ // Load projects list for dropdown (non-blocking, updates after issues load)
4870
+ this.api('GET', '/api/td/projects').then(projectsData => {
4871
+ this._tdProjects = projectsData.projects || [];
4872
+ // Re-render toolbar if panel is still showing the td view
4873
+ const toolbar = panel.querySelector('.tasks-td-toolbar');
4874
+ if (toolbar) this._renderTdToolbar(toolbar, dir);
4875
+ }).catch(() => {});
4876
+
4788
4877
  try {
4789
- const data = await this.api('GET', `/api/workspaces/${ws.id}/td/issues`);
4878
+ const data = await this.api('GET', `/api/td/issues?dir=${encodeURIComponent(dir)}`);
4790
4879
  const issues = data.issues || [];
4791
4880
 
4881
+ panel.textContent = '';
4882
+ const toolbar = document.createElement('div');
4883
+ toolbar.className = 'tasks-td-toolbar';
4884
+ this._renderTdToolbar(toolbar, dir);
4885
+ panel.appendChild(toolbar);
4886
+
4792
4887
  if (issues.length === 0) {
4793
- showPlaceholder('No open td issues for this project', false);
4888
+ const empty = document.createElement('div');
4889
+ empty.className = 'tasks-placeholder';
4890
+ empty.textContent = 'No open td issues for this project';
4891
+ panel.appendChild(empty);
4794
4892
  return;
4795
4893
  }
4796
4894
 
4797
- panel.textContent = '';
4798
-
4799
4895
  // Group by status in display order
4800
4896
  const STATUS_ORDER = ['in_progress', 'in_review', 'blocked', 'open'];
4801
4897
  const STATUS_LABELS = {
@@ -4869,6 +4965,10 @@ class CWMApp {
4869
4965
  if (msg.includes('not initialized')) {
4870
4966
  // td isn't initialized — show a helpful prompt rather than a raw error
4871
4967
  panel.textContent = '';
4968
+ const toolbar = document.createElement('div');
4969
+ toolbar.className = 'tasks-td-toolbar';
4970
+ this._renderTdToolbar(toolbar, dir);
4971
+ panel.appendChild(toolbar);
4872
4972
  const wrap = document.createElement('div');
4873
4973
  wrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:12px;padding:32px 24px;';
4874
4974
  const info = document.createElement('div');
@@ -4877,7 +4977,7 @@ class CWMApp {
4877
4977
  info.textContent = 'td is not initialized for this project.';
4878
4978
  const hint = document.createElement('div');
4879
4979
  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.';
4980
+ hint.textContent = 'Run td init in the project root to enable task tracking.';
4881
4981
  const initBtn = document.createElement('button');
4882
4982
  initBtn.className = 'btn btn-primary btn-sm';
4883
4983
  initBtn.textContent = 'Run td init';
@@ -4885,7 +4985,14 @@ class CWMApp {
4885
4985
  initBtn.disabled = true;
4886
4986
  initBtn.textContent = 'Initializing\u2026';
4887
4987
  try {
4888
- await this.api('POST', `/api/workspaces/${ws.id}/td/init`, {});
4988
+ // Use the workspace-scoped init if we can find the ws, otherwise use the dir directly
4989
+ const ws = this.state.activeWorkspace;
4990
+ if (ws) {
4991
+ await this.api('POST', `/api/workspaces/${ws.id}/td/init`, { repoDir: dir });
4992
+ } else {
4993
+ // Fallback: run td init via a generic endpoint if workspace isn't known
4994
+ throw new Error('No active workspace to run td init against. Run `td init` manually in ' + dir);
4995
+ }
4889
4996
  this.renderTasksTdPanel();
4890
4997
  } catch (e) {
4891
4998
  initBtn.disabled = false;
@@ -7129,8 +7236,10 @@ class CWMApp {
7129
7236
  !p.dirExists ? '<span class="discover-badge discover-badge-missing">missing</span>' : '',
7130
7237
  ].filter(Boolean).join(' ');
7131
7238
 
7132
- const latestSessionId = p.sessions && p.sessions.length > 0 ? p.sessions[0].name : '';
7133
- return `<div class="discover-row" data-path="${this.escapeHtml(p.realPath)}" data-name="${this.escapeHtml(name)}" data-session-id="${this.escapeHtml(latestSessionId)}">
7239
+ const latestSession = p.sessions && p.sessions.length > 0 ? p.sessions[0] : null;
7240
+ const latestSessionId = latestSession ? latestSession.name : '';
7241
+ const latestSessionTitle = latestSession ? (latestSession.title || '') : '';
7242
+ return `<div class="discover-row" data-path="${this.escapeHtml(p.realPath)}" data-name="${this.escapeHtml(name)}" data-session-id="${this.escapeHtml(latestSessionId)}" data-session-title="${this.escapeHtml(latestSessionTitle)}">
7134
7243
  <div class="discover-check">
7135
7244
  <input type="checkbox" class="discover-cb" ${p.dirExists ? 'checked' : ''} ${!p.dirExists ? 'disabled' : ''}>
7136
7245
  </div>
@@ -7192,7 +7301,7 @@ class CWMApp {
7192
7301
  const cb = row.querySelector('.discover-cb');
7193
7302
  if (cb && cb.checked) {
7194
7303
  selected.push({
7195
- name: row.dataset.name,
7304
+ name: row.dataset.sessionTitle || row.dataset.name,
7196
7305
  path: row.dataset.path,
7197
7306
  sessionId: row.dataset.sessionId || '',
7198
7307
  });
@@ -9445,9 +9554,9 @@ class CWMApp {
9445
9554
  const path = p.realPath || '';
9446
9555
  // Match against project name, encoded name, or path
9447
9556
  if (name.toLowerCase().includes(query) || encoded.toLowerCase().includes(query) || path.toLowerCase().includes(query)) return true;
9448
- // Match against any session ID/name within this project
9557
+ // Match against any session ID, title, or Claude custom-title within this project
9449
9558
  const allSessions = p.sessions || [];
9450
- return allSessions.some(s => (s.name || '').toLowerCase().includes(query));
9559
+ return allSessions.some(s => (s.name || '').toLowerCase().includes(query) || (s.title || '').toLowerCase().includes(query));
9451
9560
  });
9452
9561
  }
9453
9562
 
@@ -9478,8 +9587,9 @@ class CWMApp {
9478
9587
  if (!projectMatches) {
9479
9588
  sessions = sessions.filter(s => {
9480
9589
  const sName = (s.name || '').toLowerCase();
9590
+ const sClaudeTitle = (s.title || '').toLowerCase();
9481
9591
  const sTitle = (this.getProjectSessionTitle(s.name) || '').toLowerCase();
9482
- return sName.includes(query) || sTitle.includes(query);
9592
+ return sName.includes(query) || sClaudeTitle.includes(query) || sTitle.includes(query);
9483
9593
  });
9484
9594
  }
9485
9595
  }
@@ -9487,13 +9597,19 @@ class CWMApp {
9487
9597
  // Build session sub-items
9488
9598
  const sessionItems = sessions.map(s => {
9489
9599
  const sessName = s.name || 'unnamed';
9600
+ const claudeTitle = s.title || null;
9601
+ if (claudeTitle && !this.getProjectSessionTitle(sessName)) {
9602
+ const titles = JSON.parse(localStorage.getItem('cwm_projectSessionTitles') || '{}');
9603
+ titles[sessName] = claudeTitle;
9604
+ localStorage.setItem('cwm_projectSessionTitles', JSON.stringify(titles));
9605
+ }
9490
9606
  const storedTitle = this.getProjectSessionTitle(sessName);
9491
- const displayName = storedTitle || (sessName.length > 24 ? sessName.substring(0, 24) + '...' : sessName);
9607
+ const displayName = storedTitle || claudeTitle || (sessName.length > 24 ? sessName.substring(0, 24) + '...' : sessName);
9492
9608
  const sessSize = s.size ? this.formatSize(s.size) : '';
9493
9609
  const sessTime = s.modified ? this.relativeTime(s.modified) : '';
9494
- // Tooltip: show title + session ID so user sees both on hover
9495
- const tooltip = storedTitle
9496
- ? `${storedTitle}\n\nSession: ${sessName}`
9610
+ const effectiveTitle = storedTitle || claudeTitle;
9611
+ const tooltip = effectiveTitle
9612
+ ? `${effectiveTitle}\n\nSession: ${sessName}`
9497
9613
  : sessName;
9498
9614
  return `<div class="project-session-item" draggable="true" data-session-name="${this.escapeHtml(sessName)}" data-project-path="${this.escapeHtml(p.realPath || '')}" data-project-encoded="${this.escapeHtml(encoded)}" title="${this.escapeHtml(tooltip)}">
9499
9615
  <span class="project-session-name">${this.escapeHtml(displayName)}</span>
@@ -9978,6 +10094,24 @@ class CWMApp {
9978
10094
  });
9979
10095
  }
9980
10096
 
10097
+ // Pinned notes (bookmark) button — shows modal of all pinned notes for this pane's session
10098
+ const pinDocBtn = pane.querySelector('.terminal-pane-pinnedoc');
10099
+ if (pinDocBtn) {
10100
+ pinDocBtn.addEventListener('click', (e) => {
10101
+ e.stopPropagation();
10102
+ this._showPinnedNotesModal(slotIdx);
10103
+ });
10104
+ }
10105
+
10106
+ // Pane view back button — restores terminal after a non-terminal view (E003)
10107
+ const backBtn = pane.querySelector('.pane-view-back');
10108
+ if (backBtn) {
10109
+ backBtn.addEventListener('click', (e) => {
10110
+ e.stopPropagation();
10111
+ if (this.restoreTerminalInPane) this.restoreTerminalInPane(slotIdx);
10112
+ });
10113
+ }
10114
+
9981
10115
  // Drag-to-reposition: make pane header draggable to swap panes
9982
10116
  const header = pane.querySelector('.terminal-pane-header');
9983
10117
  if (header) {
@@ -10166,6 +10300,9 @@ class CWMApp {
10166
10300
  if (this.state.settings.paneColorHighlights) {
10167
10301
  this.renderWorkspaces();
10168
10302
  }
10303
+
10304
+ // Refresh pinned-notes badge for this pane now that a session is loaded
10305
+ this._refreshPanePin(slotIdx);
10169
10306
  }
10170
10307
 
10171
10308
  /**
@@ -10206,6 +10343,31 @@ class CWMApp {
10206
10343
  el.innerHTML = `<span class="activity-dot ${dotClass}"></span>${label}${detail}`;
10207
10344
  }
10208
10345
 
10346
+ /**
10347
+ * Show a modal listing all pinned notes for the session currently open in the given pane slot.
10348
+ * Fetches notes from the backend and displays them with timestamps.
10349
+ * If there are no pinned notes, shows an info toast instead.
10350
+ * @param {number} slotIdx - The terminal pane slot index
10351
+ */
10352
+ async _showPinnedNotesModal(slotIdx) {
10353
+ const tp = this.terminalPanes[slotIdx];
10354
+ if (!tp || !tp.sessionId) return;
10355
+ const ws = this.state.activeWorkspace;
10356
+ if (!ws) return;
10357
+ const data = await this.api('GET', `/api/workspaces/${ws.id}/pinned-notes/${tp.sessionId}`);
10358
+ const notes = data.notes || [];
10359
+ if (notes.length === 0) {
10360
+ this.showToast('No pinned notes for this session', 'info');
10361
+ return;
10362
+ }
10363
+ const body = notes.map(n => `[${n.timestamp}]\n${n.text}`).join('\n\n---\n\n');
10364
+ await this.showPromptModal({
10365
+ title: 'Pinned Notes',
10366
+ fields: [{ key: 'body', type: 'textarea', value: body }],
10367
+ confirmText: 'Close',
10368
+ });
10369
+ }
10370
+
10209
10371
  showTerminalContextMenu(slotIdx, x, y) {
10210
10372
  const tp = this.terminalPanes[slotIdx];
10211
10373
  if (!tp) return;
@@ -10227,6 +10389,26 @@ class CWMApp {
10227
10389
  });
10228
10390
  }
10229
10391
 
10392
+ // Save to Notes (only show when there's a selection)
10393
+ if (tp.term && tp.term.hasSelection()) {
10394
+ items.push({
10395
+ label: 'Save to Notes', icon: '&#128221;', action: async () => {
10396
+ const selected = tp.term.getSelection();
10397
+ const ws = this.state.activeWorkspace;
10398
+ if (!ws) { this.showToast('No active workspace', 'error'); return; }
10399
+ const result = await this.showPromptModal({
10400
+ title: 'Save to Notes',
10401
+ fields: [{ key: 'text', type: 'textarea', value: selected }],
10402
+ confirmText: 'Save Note'
10403
+ });
10404
+ if (!result) return;
10405
+ await this.api('POST', '/api/workspaces/' + ws.id + '/docs/notes', { text: result.text.trim() });
10406
+ this.loadDocs();
10407
+ this.showToast('Saved to Notes', 'success');
10408
+ },
10409
+ });
10410
+ }
10411
+
10230
10412
  // Paste from clipboard
10231
10413
  items.push({
10232
10414
  label: 'Paste', icon: '&#128203;', action: () => {
@@ -10992,6 +11174,15 @@ class CWMApp {
10992
11174
  tp.setFocused(true);
10993
11175
  tp.focus();
10994
11176
  }
11177
+
11178
+ // If Tasks > td tab is visible and not manually pinned, update to this pane's project
11179
+ if (this._activeTasksTab === 'td' && !this._tdPanelDirPinned) {
11180
+ const newDir = tp && tp.spawnOpts && tp.spawnOpts.cwd ? tp.spawnOpts.cwd : null;
11181
+ if (newDir && newDir !== this._tdPanelDir) {
11182
+ this._tdPanelDir = newDir;
11183
+ this.renderTasksTdPanel();
11184
+ }
11185
+ }
10995
11186
  }
10996
11187
 
10997
11188
  /**
@@ -11810,16 +12001,54 @@ class CWMApp {
11810
12001
  if (this.els.docsRoadmapCount) this.els.docsRoadmapCount.textContent = (docs.roadmap || []).length;
11811
12002
  if (this.els.docsRulesCount) this.els.docsRulesCount.textContent = (docs.rules || []).length;
11812
12003
 
11813
- // Notes
12004
+ // Notes — built with DOM APIs to support pin buttons safely (no user HTML injected)
11814
12005
  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>';
12006
+ const notes = docs.notes || [];
12007
+ while (this.els.docsNotesList.firstChild) {
12008
+ this.els.docsNotesList.removeChild(this.els.docsNotesList.firstChild);
12009
+ }
12010
+ if (notes.length === 0) {
12011
+ const empty = document.createElement('div');
12012
+ empty.className = 'docs-empty';
12013
+ empty.textContent = 'No notes yet. Click + to add one.';
12014
+ this.els.docsNotesList.appendChild(empty);
12015
+ } else {
12016
+ notes.forEach((n, noteIndex) => {
12017
+ const noteRow = document.createElement('div');
12018
+ noteRow.className = 'docs-item';
12019
+ noteRow.dataset.index = noteIndex;
12020
+
12021
+ const timeSpan = document.createElement('span');
12022
+ timeSpan.className = 'docs-note-time';
12023
+ timeSpan.textContent = n.timestamp || '';
12024
+ noteRow.appendChild(timeSpan);
12025
+
12026
+ const textSpan = document.createElement('span');
12027
+ textSpan.className = 'docs-note-text';
12028
+ textSpan.textContent = n.text;
12029
+ noteRow.appendChild(textSpan);
12030
+
12031
+ const pinBtn = document.createElement('button');
12032
+ pinBtn.className = 'doc-pin-btn btn btn-ghost btn-icon btn-sm';
12033
+ pinBtn.textContent = '📌';
12034
+ pinBtn.title = 'Pin to focused terminal session';
12035
+ pinBtn.addEventListener('click', (e) => {
12036
+ e.stopPropagation();
12037
+ this._toggleNotePin(noteIndex, pinBtn);
12038
+ });
12039
+ noteRow.appendChild(pinBtn);
12040
+
12041
+ const delBtn = document.createElement('button');
12042
+ delBtn.className = 'docs-item-delete btn btn-ghost btn-icon btn-sm';
12043
+ delBtn.dataset.section = 'notes';
12044
+ delBtn.dataset.index = noteIndex;
12045
+ delBtn.title = 'Remove';
12046
+ delBtn.textContent = '×';
12047
+ noteRow.appendChild(delBtn);
12048
+
12049
+ this.els.docsNotesList.appendChild(noteRow);
12050
+ });
12051
+ }
11823
12052
  }
11824
12053
 
11825
12054
  // Goals
@@ -11971,6 +12200,52 @@ class CWMApp {
11971
12200
  }
11972
12201
  }
11973
12202
 
12203
+ /**
12204
+ * Toggle a pinned note on the currently focused terminal pane session.
12205
+ * @param {number} noteIndex - 0-based index of the note in the workspace docs.notes array
12206
+ * @param {HTMLButtonElement} buttonEl - The pin button element to update visually
12207
+ */
12208
+ async _toggleNotePin(noteIndex, buttonEl) {
12209
+ const slot = this._activeTerminalSlot;
12210
+ const tp = (slot !== null && slot !== undefined) ? this.terminalPanes[slot] : null;
12211
+ if (!tp || !tp.sessionId) {
12212
+ this.showToast('Focus a terminal pane first', 'error');
12213
+ return;
12214
+ }
12215
+ const ws = this.state.activeWorkspace;
12216
+ if (!ws) return;
12217
+ const isPinned = buttonEl.classList.contains('pinned');
12218
+ const action = isPinned ? 'unpin' : 'pin';
12219
+ await this.api('POST', `/api/workspaces/${ws.id}/pinned-notes`, {
12220
+ sessionId: tp.sessionId,
12221
+ noteIndex,
12222
+ action
12223
+ });
12224
+ buttonEl.classList.toggle('pinned', !isPinned);
12225
+ await this._refreshPanePin(slot);
12226
+ }
12227
+
12228
+ /**
12229
+ * Refresh the pinned-notes badge on a terminal pane header.
12230
+ * Shows/hides the badge button and updates the count label.
12231
+ * @param {number} slotIdx - Terminal pane slot index (0-based)
12232
+ */
12233
+ async _refreshPanePin(slotIdx) {
12234
+ const tp = this.terminalPanes[slotIdx];
12235
+ if (!tp || !tp.sessionId) return;
12236
+ const ws = this.state.activeWorkspace;
12237
+ if (!ws) return;
12238
+ const data = await this.api('GET', `/api/workspaces/${ws.id}/pinned-notes`);
12239
+ const pins = data[tp.sessionId] || [];
12240
+ const paneEl = document.getElementById(`term-pane-${slotIdx}`);
12241
+ if (!paneEl) return;
12242
+ const pinDocBtn = paneEl.querySelector('.terminal-pane-pinnedoc');
12243
+ if (!pinDocBtn) return;
12244
+ pinDocBtn.hidden = pins.length === 0;
12245
+ const countEl = pinDocBtn.querySelector('.pane-pin-count');
12246
+ if (countEl) countEl.textContent = pins.length > 0 ? pins.length : '';
12247
+ }
12248
+
11974
12249
 
11975
12250
  /* ═══════════════════════════════════════════════════════════
11976
12251
  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.
@@ -1468,6 +1646,13 @@ app.get('/api/discover', requireAuth, (req, res) => {
1468
1646
  // skip if can't read
1469
1647
  }
1470
1648
 
1649
+ // Extract the last custom-title from each JSONL file
1650
+ const sessionTitles = {};
1651
+ for (const sf of sessionFiles) {
1652
+ const title = extractCustomTitle(path.join(projectDir, sf.name + '.jsonl'));
1653
+ if (title) sessionTitles[sf.name] = title;
1654
+ }
1655
+
1471
1656
  // Check for CLAUDE.md
1472
1657
  let hasClaudeMd = false;
1473
1658
  try {
@@ -1494,7 +1679,7 @@ app.get('/api/discover', requireAuth, (req, res) => {
1494
1679
  sessionCount: sessionFiles.length,
1495
1680
  totalSize,
1496
1681
  lastActive: sessionFiles.length > 0 ? sessionFiles[0].modified : null,
1497
- sessions: sessionFiles.map(s => ({ name: s.name, modified: s.modified, size: s.size })),
1682
+ sessions: sessionFiles.map(s => ({ name: s.name, modified: s.modified, size: s.size, title: sessionTitles[s.name] || null })),
1498
1683
  });
1499
1684
  }
1500
1685
 
@@ -1623,48 +1808,34 @@ function greedyFsWalk(root, tokens) {
1623
1808
  */
1624
1809
  function getOriginalPathFromJsonl(projectDir) {
1625
1810
  try {
1626
- const files = fs.readdirSync(projectDir);
1627
- // Find the first .jsonl file (skip subdirectories)
1628
- const jsonlFile = files.find(f => {
1811
+ const jsonlFiles = fs.readdirSync(projectDir).filter(f => {
1629
1812
  if (!f.endsWith('.jsonl')) return false;
1630
- try {
1631
- return !fs.statSync(path.join(projectDir, f)).isDirectory();
1632
- } catch (_) {
1633
- return false;
1634
- }
1813
+ try { return !fs.statSync(path.join(projectDir, f)).isDirectory(); } catch (_) { return false; }
1635
1814
  });
1636
- if (!jsonlFile) return null;
1637
-
1638
- const jsonlPath = path.join(projectDir, jsonlFile);
1639
-
1640
- // Only read first 4KB to find cwd field (avoids loading large files)
1641
- const fd = fs.openSync(jsonlPath, 'r');
1642
- const buffer = Buffer.alloc(4096);
1643
- let content;
1644
- try {
1645
- const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
1646
- content = buffer.toString('utf-8', 0, bytesRead);
1647
- } finally {
1648
- fs.closeSync(fd);
1649
- }
1650
1815
 
1651
- // Parse first 3 lines to find cwd field
1652
- const lines = content.split('\n').slice(0, 3);
1653
- for (const line of lines) {
1654
- if (!line.trim()) continue;
1816
+ // Try each file until one yields a cwd — stub sessions may lack it
1817
+ for (const jsonlFile of jsonlFiles) {
1655
1818
  try {
1656
- const record = JSON.parse(line);
1657
- if (record.cwd && typeof record.cwd === 'string') {
1658
- return record.cwd;
1819
+ const fd = fs.openSync(path.join(projectDir, jsonlFile), 'r');
1820
+ let content;
1821
+ try {
1822
+ const buffer = Buffer.alloc(16384);
1823
+ const bytesRead = fs.readSync(fd, buffer, 0, 16384, 0);
1824
+ content = buffer.toString('utf-8', 0, bytesRead);
1825
+ } finally {
1826
+ fs.closeSync(fd);
1659
1827
  }
1660
- } catch (_) {
1661
- // Skip invalid JSON lines
1662
- continue;
1663
- }
1828
+
1829
+ for (const line of content.split('\n')) {
1830
+ if (!line.includes('"cwd"')) continue;
1831
+ try {
1832
+ const record = JSON.parse(line);
1833
+ if (record.cwd && typeof record.cwd === 'string') return record.cwd;
1834
+ } catch (_) { continue; }
1835
+ }
1836
+ } catch (_) { continue; }
1664
1837
  }
1665
- } catch (_) {
1666
- // Silently fail and return null
1667
- }
1838
+ } catch (_) {}
1668
1839
  return null;
1669
1840
  }
1670
1841
 
@@ -2346,7 +2517,7 @@ app.post('/api/search-conversations', requireAuth, async (req, res) => {
2346
2517
  */
2347
2518
  app.get('/api/keys/anthropic', requireAuth, (req, res) => {
2348
2519
  const store = getStore();
2349
- const key = (store.getState().settings || {}).anthropicApiKey || '';
2520
+ const key = (store.state.settings || {}).anthropicApiKey || '';
2350
2521
  if (!key) return res.json({ configured: false, masked: null });
2351
2522
  const masked = '...' + key.slice(-8);
2352
2523
  return res.json({ configured: true, masked });
@@ -2382,7 +2553,7 @@ app.post('/api/ai/punctuate', requireAuth, async (req, res) => {
2382
2553
  }
2383
2554
 
2384
2555
  const store = getStore();
2385
- const apiKey = (store.getState().settings || {}).anthropicApiKey || '';
2556
+ const apiKey = (store.state.settings || {}).anthropicApiKey || '';
2386
2557
  if (!apiKey) {
2387
2558
  return res.status(400).json({ error: 'No Anthropic API key configured' });
2388
2559
  }
@@ -2434,7 +2605,7 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2434
2605
  }
2435
2606
 
2436
2607
  const store = getStore();
2437
- const apiKey = (store.getState().settings || {}).anthropicApiKey || '';
2608
+ const apiKey = (store.state.settings || {}).anthropicApiKey || '';
2438
2609
 
2439
2610
  // Gather all metadata
2440
2611
  const workspaces = store.getAllWorkspacesList();
@@ -2453,6 +2624,7 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2453
2624
  const name = getProjectDisplayName(d.name, realPath);
2454
2625
  let sessionCount = 0;
2455
2626
  let lastActive = null;
2627
+ const sessionTitles = [];
2456
2628
  try {
2457
2629
  const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
2458
2630
  sessionCount = files.length;
@@ -2461,9 +2633,11 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2461
2633
  const stat = fs.statSync(path.join(projectDir, f));
2462
2634
  if (!lastActive || stat.mtime > new Date(lastActive)) lastActive = stat.mtime;
2463
2635
  } catch (_) {}
2636
+ const title = extractCustomTitle(path.join(projectDir, f));
2637
+ if (title) sessionTitles.push(title);
2464
2638
  }
2465
2639
  } catch (_) {}
2466
- return { encodedName: d.name, name, path: realPath, sessionCount, lastActive };
2640
+ return { encodedName: d.name, name, path: realPath, sessionCount, lastActive, sessionTitles };
2467
2641
  });
2468
2642
  } catch (_) {}
2469
2643
  }
@@ -2480,7 +2654,8 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2480
2654
  })),
2481
2655
  discoveredProjects: discoveredProjects.map(p => ({
2482
2656
  encodedName: p.encodedName, name: p.name, path: p.path,
2483
- sessionCount: p.sessionCount, lastActive: p.lastActive
2657
+ sessionCount: p.sessionCount, lastActive: p.lastActive,
2658
+ sessionTitles: p.sessionTitles
2484
2659
  }))
2485
2660
  };
2486
2661
 
@@ -2544,9 +2719,9 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2544
2719
  if (score > 0.2) scored.push({ type: 'session', id: s.id, confidence: score, summary: 'Matched by name, topic, or path' });
2545
2720
  }
2546
2721
  for (const p of discoveredProjects) {
2547
- const text = `${p.name} ${p.path}`.toLowerCase();
2722
+ const text = `${p.name} ${p.path} ${(p.sessionTitles || []).join(' ')}`.toLowerCase();
2548
2723
  const score = terms.filter(t => text.includes(t)).length / terms.length;
2549
- if (score > 0.2) scored.push({ type: 'project', id: p.encodedName, confidence: score, summary: 'Matched by project name or path' });
2724
+ if (score > 0.2) scored.push({ type: 'project', id: p.encodedName, confidence: score, summary: 'Matched by project name, path, or session title' });
2550
2725
  }
2551
2726
 
2552
2727
  scored.sort((a, b) => b.confidence - a.confidence);
@@ -7065,6 +7240,30 @@ function getSearchableFiles() {
7065
7240
  * @param {string} sessionId - Fallback UUID
7066
7241
  * @returns {string} A human-readable session name
7067
7242
  */
7243
+ function extractCustomTitle(jsonlPath) {
7244
+ try {
7245
+ const fd = fs.openSync(jsonlPath, 'r');
7246
+ try {
7247
+ const size = fs.fstatSync(fd).size;
7248
+ const tailSize = Math.min(131072, size);
7249
+ const buf = Buffer.alloc(tailSize);
7250
+ fs.readSync(fd, buf, 0, tailSize, size - tailSize);
7251
+ const lines = buf.toString('utf8').split('\n');
7252
+ for (let i = lines.length - 1; i >= 0; i--) {
7253
+ if (lines[i].includes('"custom-title"')) {
7254
+ try {
7255
+ const obj = JSON.parse(lines[i]);
7256
+ if (obj.customTitle) return obj.customTitle;
7257
+ } catch (_) {}
7258
+ }
7259
+ }
7260
+ } finally {
7261
+ fs.closeSync(fd);
7262
+ }
7263
+ } catch (_) {}
7264
+ return null;
7265
+ }
7266
+
7068
7267
  function extractSessionName(filePath, sessionId) {
7069
7268
  try {
7070
7269
  // Read just the first 10KB to find the first meaningful message
@@ -7876,4 +8075,5 @@ module.exports = {
7876
8075
  startServer,
7877
8076
  getPtyManager,
7878
8077
  structuredError,
8078
+ extractCustomTitle,
7879
8079
  };