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 +1 -1
- package/package.json +1 -1
- package/src/core/td-adapter.js +2 -1
- package/src/web/public/app.js +310 -35
- package/src/web/public/index.html +6 -0
- package/src/web/public/styles.css +70 -0
- package/src/web/server.js +244 -44
package/logs/server.pid
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
55976
|
package/package.json
CHANGED
package/src/core/td-adapter.js
CHANGED
|
@@ -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
|
|
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
|
|
package/src/web/public/app.js
CHANGED
|
@@ -3109,10 +3109,16 @@ class CWMApp {
|
|
|
3109
3109
|
this.showToast('Select or create a project first', 'warning');
|
|
3110
3110
|
return;
|
|
3111
3111
|
}
|
|
3112
|
-
//
|
|
3113
|
-
const
|
|
3114
|
-
|
|
3115
|
-
|
|
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
|
-
|
|
4767
|
-
|
|
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 = '↻';
|
|
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
|
-
|
|
4782
|
-
|
|
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/
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
7133
|
-
|
|
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
|
|
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
|
-
|
|
9495
|
-
const tooltip =
|
|
9496
|
-
? `${
|
|
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: '📝', 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: '📋', 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
|
-
|
|
11816
|
-
|
|
11817
|
-
|
|
11818
|
-
|
|
11819
|
-
|
|
11820
|
-
|
|
11821
|
-
|
|
11822
|
-
|
|
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
|
|
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
|
-
//
|
|
1652
|
-
const
|
|
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
|
|
1657
|
-
|
|
1658
|
-
|
|
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
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
};
|