myrlin-workbook 0.9.26 → 0.9.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myrlin-workbook",
3
- "version": "0.9.26",
3
+ "version": "0.9.27",
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": {
@@ -20,7 +20,8 @@
20
20
  "mcp:visual-qa": "node src/mcp/visual-qa.js",
21
21
  "gui:cdp": "node src/supervisor.js --cdp",
22
22
  "postinstall": "node scripts/postinstall.js",
23
- "restart": "bash scripts/restart-gui.sh"
23
+ "restart": "bash scripts/restart-gui.sh",
24
+ "build:icons": "node scripts/build-lucide-bundle.mjs && node scripts/build-material-bundle.mjs"
24
25
  },
25
26
  "repository": {
26
27
  "type": "git",
@@ -46,17 +47,20 @@
46
47
  "author": "Arthur",
47
48
  "license": "AGPL-3.0-only",
48
49
  "dependencies": {
50
+ "@lucide/lab": "^0.1.2",
49
51
  "blessed": "^0.1.81",
50
52
  "blessed-contrib": "^4.11.0",
51
53
  "chalk": "^5.6.2",
52
54
  "chrome-remote-interface": "^0.34.0",
53
55
  "express": "^5.2.1",
56
+ "lucide": "^1.8.0",
54
57
  "node-pty": "^1.1.0",
55
58
  "qrcode": "^1.5.4",
56
59
  "simple-git": "^3.33.0",
57
60
  "ws": "^8.19.0"
58
61
  },
59
62
  "devDependencies": {
63
+ "@material-icons/svg": "^1.0.33",
60
64
  "@playwright/test": "^1.58.2",
61
65
  "@xterm/addon-fit": "^0.11.0",
62
66
  "@xterm/addon-web-links": "^0.12.0",
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build script: generates public/vendor/lucide.bundle.js
4
+ * Imports curated icons from `lucide` and `@lucide/lab`, converts to SVG strings,
5
+ * and outputs a plain JS bundle that sets window.__lucideIcons.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, resolve } from 'path';
11
+ import vm from 'vm';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const ROOT = resolve(__dirname, '..');
15
+ const OUT = resolve(ROOT, 'src/web/public/vendor/lucide.bundle.js');
16
+
17
+ // ── Curated icon list ────────────────────────────────────────────────────────
18
+ // Format: { category: [[kebab-name, 'lucide' | 'lab'], ...], ... }
19
+ const ICON_CATEGORIES = {
20
+ 'Folders & Files': [
21
+ ['folder', 'lucide'],
22
+ ['folder-open', 'lucide'],
23
+ ['folder-code', 'lucide'],
24
+ ['folder-git-2', 'lucide'],
25
+ ['folder-sync', 'lucide'],
26
+ ['folder-key', 'lucide'],
27
+ ['folder-search', 'lucide'],
28
+ ['folders', 'lucide'],
29
+ ['file', 'lucide'],
30
+ ['file-code', 'lucide'],
31
+ ['file-text', 'lucide'],
32
+ ['files', 'lucide'],
33
+ ['notebook', 'lucide'],
34
+ ['notebook-pen', 'lucide'],
35
+ ],
36
+ 'Code & Dev': [
37
+ ['code', 'lucide'],
38
+ ['code-xml', 'lucide'],
39
+ ['square-terminal', 'lucide'],
40
+ ['terminal', 'lucide'],
41
+ ['git-branch', 'lucide'],
42
+ ['git-merge', 'lucide'],
43
+ ['git-commit-vertical', 'lucide'],
44
+ ['git-fork', 'lucide'],
45
+ ['braces', 'lucide'],
46
+ ['brackets', 'lucide'],
47
+ ['hash', 'lucide'],
48
+ ['variable', 'lucide'],
49
+ ],
50
+ 'Infrastructure': [
51
+ ['database', 'lucide'],
52
+ ['database-zap', 'lucide'],
53
+ ['server', 'lucide'],
54
+ ['server-cog', 'lucide'],
55
+ ['cpu', 'lucide'],
56
+ ['hard-drive', 'lucide'],
57
+ ['network', 'lucide'],
58
+ ['wifi', 'lucide'],
59
+ ['cloud', 'lucide'],
60
+ ['cloud-upload', 'lucide'],
61
+ ['globe', 'lucide'],
62
+ ['layers', 'lucide'],
63
+ ],
64
+ 'Apps & Tools': [
65
+ ['box', 'lucide'],
66
+ ['package', 'lucide'],
67
+ ['package-2', 'lucide'],
68
+ ['workflow', 'lucide'],
69
+ ['settings', 'lucide'],
70
+ ['settings-2', 'lucide'],
71
+ ['wrench', 'lucide'],
72
+ ['hammer', 'lucide'],
73
+ ['cog', 'lucide'],
74
+ ['tool-case', 'lucide'],
75
+ ['puzzle', 'lucide'],
76
+ ['blocks', 'lucide'],
77
+ ['plug', 'lucide'],
78
+ ['cpu', 'lucide'],
79
+ ],
80
+ 'Design & Art': [
81
+ ['palette', 'lucide'],
82
+ ['brush', 'lucide'],
83
+ ['paintbrush', 'lucide'],
84
+ ['wand', 'lucide'],
85
+ ['wand-sparkles', 'lucide'],
86
+ ['sparkles', 'lucide'],
87
+ ['pen-tool', 'lucide'],
88
+ ['pencil', 'lucide'],
89
+ ['scissors', 'lucide'],
90
+ ['crop', 'lucide'],
91
+ ['image', 'lucide'],
92
+ ['camera', 'lucide'],
93
+ ],
94
+ 'Learning & Science': [
95
+ ['book-open', 'lucide'],
96
+ ['book', 'lucide'],
97
+ ['scroll', 'lucide'],
98
+ ['flask-conical', 'lucide'],
99
+ ['flask-round', 'lucide'],
100
+ ['microscope', 'lucide'],
101
+ ['test-tube', 'lucide'],
102
+ ['brain', 'lucide'],
103
+ ['brain-circuit', 'lucide'],
104
+ ['atom', 'lucide'],
105
+ ['dna', 'lucide'],
106
+ ['telescope', 'lucide'],
107
+ ],
108
+ 'People & Status': [
109
+ ['user', 'lucide'],
110
+ ['users', 'lucide'],
111
+ ['user-round', 'lucide'],
112
+ ['bot', 'lucide'],
113
+ ['rocket', 'lucide'],
114
+ ['star', 'lucide'],
115
+ ['heart', 'lucide'],
116
+ ['zap', 'lucide'],
117
+ ['shield', 'lucide'],
118
+ ['lock', 'lucide'],
119
+ ['key', 'lucide'],
120
+ ['flag', 'lucide'],
121
+ ['trophy', 'lucide'],
122
+ ['medal', 'lucide'],
123
+ ['crown', 'lucide'],
124
+ ],
125
+ 'Home & Work': [
126
+ ['house', 'lucide'],
127
+ ['briefcase', 'lucide'],
128
+ ['building', 'lucide'],
129
+ ['building-2', 'lucide'],
130
+ ['store', 'lucide'],
131
+ ['landmark', 'lucide'],
132
+ ['map-pin', 'lucide'],
133
+ ['compass', 'lucide'],
134
+ ['map', 'lucide'],
135
+ ['globe-2', 'lucide'],
136
+ ['mountain', 'lucide'],
137
+ ],
138
+ 'Media & Comms': [
139
+ ['monitor', 'lucide'],
140
+ ['laptop', 'lucide'],
141
+ ['smartphone', 'lucide'],
142
+ ['mail', 'lucide'],
143
+ ['message-circle', 'lucide'],
144
+ ['bell', 'lucide'],
145
+ ['phone', 'lucide'],
146
+ ['mic', 'lucide'],
147
+ ['music', 'lucide'],
148
+ ['headphones', 'lucide'],
149
+ ['video', 'lucide'],
150
+ ['radio', 'lucide'],
151
+ ],
152
+ 'Nature & Fun': [
153
+ ['sun', 'lucide'],
154
+ ['moon', 'lucide'],
155
+ ['cloud-sun', 'lucide'],
156
+ ['flame', 'lucide'],
157
+ ['waves', 'lucide'],
158
+ ['coffee', 'lucide'],
159
+ ['leaf', 'lucide'],
160
+ ['flower', 'lucide'],
161
+ ['flower-2', 'lucide'],
162
+ ['tree-pine', 'lucide'],
163
+ ['trees', 'lucide'],
164
+ ['snail', 'lucide'],
165
+ ['fish', 'lucide'],
166
+ ['rabbit', 'lucide'],
167
+ ['turtle', 'lucide'],
168
+ ['bird', 'lucide'],
169
+ ['dog', 'lucide'],
170
+ ['cat', 'lucide'],
171
+ ],
172
+ 'Lab Extras': [
173
+ ['owl', 'lab'],
174
+ ['planet', 'lab'],
175
+ ['bee', 'lab'],
176
+ ['venn', 'lab'],
177
+ ['copy-code', 'lab'],
178
+ ['grid-lines', 'lab'],
179
+ ['farm', 'lab'],
180
+ ['toolbox', 'lab'],
181
+ ['toolbox-2', 'lab'],
182
+ ['snowman', 'lab'],
183
+ ['mountain-snow', 'lab'],
184
+ ],
185
+ };
186
+
187
+ // ── SVG serializer ────────────────────────────────────────────────────────────
188
+ function iconDataToSvg(iconData) {
189
+ const children = iconData.map(([tag, attrs]) => {
190
+ // Filter out non-SVG metadata attrs ('key' is a React/lucide internal)
191
+ const entries = Object.entries(attrs).filter(([k]) => k !== 'key');
192
+ const attrStr = entries.map(([k, v]) => `${k}="${v}"`).join(' ');
193
+ return `<${tag} ${attrStr}/>`;
194
+ }).join('');
195
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${children}</svg>`;
196
+ }
197
+
198
+ // ── Main ──────────────────────────────────────────────────────────────────────
199
+ const icons = {}; // { "folder": "<svg>...</svg>", ... }
200
+ const categories = {}; // { "Folders & Files": ["folder", "folder-open", ...], ... }
201
+ const skipped = [];
202
+
203
+ for (const [category, entries] of Object.entries(ICON_CATEGORIES)) {
204
+ const catIcons = [];
205
+ for (const [name, pkg] of entries) {
206
+ // De-dup (e.g. 'cpu' appears twice in the list above)
207
+ if (icons[name]) { catIcons.push(name); continue; }
208
+
209
+ const iconDir = pkg === 'lab'
210
+ ? resolve(ROOT, `node_modules/@lucide/lab/dist/esm/icons/${name}.js`)
211
+ : resolve(ROOT, `node_modules/lucide/dist/esm/icons/${name}.js`);
212
+
213
+ try {
214
+ const src = readFileSync(iconDir, 'utf8');
215
+ // Extract: const <Name> = <array>;\nexport { <Name> as default }
216
+ // Use greedy match up to the last ";" before the export statement
217
+ const match = src.match(/const \w+ = ([\s\S]+?);\s*\nexport/);
218
+ if (!match) { skipped.push(`${name} (parse fail)`); continue; }
219
+ // Evaluate JS object literal (has unquoted keys, not valid JSON)
220
+ const iconData = vm.runInNewContext(`(${match[1]})`);
221
+ icons[name] = iconDataToSvg(iconData);
222
+ catIcons.push(name);
223
+ } catch {
224
+ skipped.push(`${name} (${pkg})`);
225
+ }
226
+ }
227
+ if (catIcons.length > 0) categories[category] = catIcons;
228
+ }
229
+
230
+ // ── Output ────────────────────────────────────────────────────────────────────
231
+ mkdirSync(resolve(ROOT, 'src/web/public/vendor'), { recursive: true });
232
+
233
+ const output = `/* lucide icons bundle — generated by scripts/build-lucide-bundle.mjs */
234
+ /* Includes ${Object.keys(icons).length} icons from lucide + @lucide/lab */
235
+ (function() {
236
+ window.__lucideIcons = ${JSON.stringify(icons)};
237
+ window.__lucideIconCategories = ${JSON.stringify(categories)};
238
+ document.dispatchEvent(new Event('lucide-ready'));
239
+ })();
240
+ `;
241
+
242
+ writeFileSync(OUT, output);
243
+ console.log(`✓ Built ${Object.keys(icons).length} icons → src/web/public/vendor/lucide.bundle.js`);
244
+ if (skipped.length) console.log(` Skipped (not found): ${skipped.join(', ')}`);
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build script: generates public/vendor/material-icons.bundle.js
4
+ * Includes ALL ~2191 icons from @material-icons/svg (baseline/filled variant),
5
+ * organized by their official categories from data.json.
6
+ *
7
+ * Icon names are stored with a "mi/" prefix when used in ws.icon so they
8
+ * can coexist with Lucide icon names without collision.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
12
+ import { fileURLToPath } from 'url';
13
+ import { dirname, resolve } from 'path';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const ROOT = resolve(__dirname, '..');
17
+ const ICONS_DIR = resolve(ROOT, 'node_modules/@material-icons/svg/svg');
18
+ const DATA_JSON = resolve(ROOT, 'node_modules/@material-icons/svg/data.json');
19
+ const OUT = resolve(ROOT, 'src/web/public/vendor/material-icons.bundle.js');
20
+
21
+ // Official category short-name → display name
22
+ const CATEGORY_LABELS = {
23
+ action: 'Action',
24
+ alert: 'Alert',
25
+ av: 'Audio & Video',
26
+ communication:'Communication',
27
+ content: 'Content',
28
+ device: 'Device',
29
+ editor: 'Editor',
30
+ file: 'File',
31
+ hardware: 'Hardware',
32
+ home: 'Home',
33
+ image: 'Image',
34
+ maps: 'Maps',
35
+ math: 'Math',
36
+ navigation: 'Navigation',
37
+ notification: 'Notification',
38
+ places: 'Places',
39
+ search: 'Search',
40
+ shopping: 'Shopping',
41
+ social: 'Social',
42
+ toggle: 'Toggle',
43
+ };
44
+
45
+ // ── SVG processor ─────────────────────────────────────────────────────────────
46
+ function processSvg(raw) {
47
+ return raw
48
+ .replace(/width="24"/, 'width="14"')
49
+ .replace(/height="24"/, 'height="14"')
50
+ // Make color inherit from CSS (without this, icons always render black)
51
+ .replace('<svg ', '<svg fill="currentColor" ');
52
+ }
53
+
54
+ // ── Load icon metadata ────────────────────────────────────────────────────────
55
+ const data = JSON.parse(readFileSync(DATA_JSON, 'utf8'));
56
+ const iconMeta = data.icons; // [{ name, categories: [string], tags: [string] }]
57
+
58
+ // Build category → [name] map using official categories
59
+ const categoryMap = {}; // { 'Audio & Video': ['10k', ...], ... }
60
+ for (const icon of iconMeta) {
61
+ for (const cat of (icon.categories || [])) {
62
+ const label = CATEGORY_LABELS[cat] || cat;
63
+ if (!categoryMap[label]) categoryMap[label] = [];
64
+ categoryMap[label].push(icon.name);
65
+ }
66
+ }
67
+
68
+ // ── Process all icons ─────────────────────────────────────────────────────────
69
+ const icons = {}; // { name: '<svg>...' }
70
+ const categories = {}; // { 'Audio & Video': ['10k', ...] }
71
+ const skipped = [];
72
+
73
+ for (const icon of iconMeta) {
74
+ const svgPath = resolve(ICONS_DIR, icon.name, 'baseline.svg');
75
+ if (!existsSync(svgPath)) {
76
+ skipped.push(icon.name);
77
+ continue;
78
+ }
79
+ try {
80
+ const raw = readFileSync(svgPath, 'utf8').trim();
81
+ icons[icon.name] = processSvg(raw);
82
+ } catch {
83
+ skipped.push(icon.name);
84
+ }
85
+ }
86
+
87
+ // Build final categories (only include icons that were successfully loaded)
88
+ for (const [label, names] of Object.entries(categoryMap)) {
89
+ const valid = names.filter(n => icons[n]);
90
+ if (valid.length > 0) categories[label] = valid;
91
+ }
92
+
93
+ // ── Output ────────────────────────────────────────────────────────────────────
94
+ mkdirSync(resolve(ROOT, 'src/web/public/vendor'), { recursive: true });
95
+
96
+ const output = `/* Material Icons bundle — generated by scripts/build-material-bundle.mjs */
97
+ /* Includes ${Object.keys(icons).length} icons from @material-icons/svg (baseline variant) */
98
+ /* Icon names are stored as "mi/<name>" in workspace records to avoid Lucide collisions */
99
+ (function() {
100
+ window.__materialIcons = ${JSON.stringify(icons)};
101
+ window.__materialIconCategories = ${JSON.stringify(categories)};
102
+ document.dispatchEvent(new Event('material-icons-ready'));
103
+ })();
104
+ `;
105
+
106
+ writeFileSync(OUT, output);
107
+ console.log(`✓ Built ${Object.keys(icons).length} Material icons across ${Object.keys(categories).length} categories → src/web/public/vendor/material-icons.bundle.js`);
108
+ if (skipped.length) console.log(` Skipped (not found): ${skipped.join(', ')}`);
@@ -452,7 +452,7 @@ class Store extends EventEmitter {
452
452
 
453
453
  // ─── Workspace CRUD ──────────────────────────────────────
454
454
 
455
- createWorkspace({ name, description = '', color = 'cyan' }) {
455
+ createWorkspace({ name, description = '', color = 'cyan', icon = null }) {
456
456
  const id = crypto.randomUUID();
457
457
  const now = new Date().toISOString();
458
458
  const workspace = {
@@ -460,6 +460,7 @@ class Store extends EventEmitter {
460
460
  name,
461
461
  description,
462
462
  color,
463
+ icon,
463
464
  sessions: [],
464
465
  createdAt: now,
465
466
  lastActive: now,
@@ -1369,11 +1369,17 @@ class CWMApp {
1369
1369
  const newTaskBtn = e.target.closest('.ws-new-task-btn');
1370
1370
  if (newTaskBtn) { e.stopPropagation(); this.openNewTaskDialog(newTaskBtn.dataset.wsId); return; }
1371
1371
 
1372
- const renameBtn = e.target.closest('.ws-rename-btn');
1373
- if (renameBtn) { e.stopPropagation(); this.renameWorkspace(renameBtn.dataset.id); return; }
1374
-
1375
- const deleteBtn = e.target.closest('.ws-delete-btn');
1376
- if (deleteBtn) { e.stopPropagation(); this.deleteWorkspace(deleteBtn.dataset.id); return; }
1372
+ const moreBtn = e.target.closest('.ws-more-btn');
1373
+ if (moreBtn) {
1374
+ e.stopPropagation();
1375
+ const wsId = moreBtn.dataset.id;
1376
+ const rect = moreBtn.getBoundingClientRect();
1377
+ this._renderContextItems('Workspace', [
1378
+ { icon: '✏️', label: 'Rename', action: () => this.renameWorkspace(wsId) },
1379
+ { icon: '🗑️', label: 'Delete', danger: true, action: () => this.deleteWorkspace(wsId) },
1380
+ ], rect.right, rect.bottom);
1381
+ return;
1382
+ }
1377
1383
 
1378
1384
  const wsSessionItem = e.target.closest('.ws-session-item');
1379
1385
  if (wsSessionItem) {
@@ -2193,6 +2199,7 @@ class CWMApp {
2193
2199
  { key: 'name', label: 'Name', placeholder: 'my-project', required: true },
2194
2200
  { key: 'description', label: 'Description', placeholder: 'What is this project for?', type: 'textarea' },
2195
2201
  { key: 'color', label: 'Color', type: 'color' },
2202
+ { key: 'icon', label: 'Icon', type: 'icon' },
2196
2203
  ],
2197
2204
  confirmText: 'Create',
2198
2205
  confirmClass: 'btn-primary',
@@ -2220,6 +2227,7 @@ class CWMApp {
2220
2227
  { key: 'name', label: 'Name', value: ws.name, required: true },
2221
2228
  { key: 'description', label: 'Description', value: ws.description || '', type: 'textarea' },
2222
2229
  { key: 'color', label: 'Color', type: 'color', value: ws.color },
2230
+ { key: 'icon', label: 'Icon', type: 'icon', value: ws.icon || '' },
2223
2231
  ],
2224
2232
  confirmText: 'Save',
2225
2233
  confirmClass: 'btn-primary',
@@ -7884,6 +7892,53 @@ class CWMApp {
7884
7892
  </div>`;
7885
7893
  return;
7886
7894
  }
7895
+ if (f.type === 'icon') {
7896
+ // Merge Lucide icons (bare name) and Material icons (mi/ prefix)
7897
+ const lucideIcons = window.__lucideIcons || {};
7898
+ const lucideCats = window.__lucideIconCategories || {};
7899
+ const materialIcons = window.__materialIcons || {};
7900
+ const materialCats = window.__materialIconCategories || {};
7901
+
7902
+ const selectedIcon = f.value || '';
7903
+ let gridHtml = `<div class="icon-swatch icon-swatch-none${!selectedIcon ? ' selected' : ''}" data-icon="" title="No icon">
7904
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4" stroke-dasharray="3 2"/></svg>
7905
+ </div>`;
7906
+
7907
+ // Lucide section
7908
+ if (Object.keys(lucideCats).length > 0) {
7909
+ gridHtml += `<span class="icon-picker-set-sep" data-set="lucide">Lucide</span>`;
7910
+ for (const [cat, names] of Object.entries(lucideCats)) {
7911
+ gridHtml += `<span class="icon-picker-cat-sep" data-cat="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</span>`;
7912
+ for (const name of names) {
7913
+ if (!lucideIcons[name]) continue;
7914
+ gridHtml += `<div class="icon-swatch${selectedIcon === name ? ' selected' : ''}" data-icon="${this.escapeHtml(name)}" title="${this.escapeHtml(name)}">${lucideIcons[name]}</div>`;
7915
+ }
7916
+ }
7917
+ }
7918
+
7919
+ // Material section
7920
+ if (Object.keys(materialCats).length > 0) {
7921
+ gridHtml += `<span class="icon-picker-set-sep" data-set="material">Material</span>`;
7922
+ for (const [cat, names] of Object.entries(materialCats)) {
7923
+ gridHtml += `<span class="icon-picker-cat-sep" data-cat="${this.escapeHtml('Material — ' + cat)}">${this.escapeHtml(cat)}</span>`;
7924
+ for (const name of names) {
7925
+ if (!materialIcons[name]) continue;
7926
+ const storedName = 'mi/' + name;
7927
+ gridHtml += `<div class="icon-swatch${selectedIcon === storedName ? ' selected' : ''}" data-icon="${this.escapeHtml(storedName)}" title="${this.escapeHtml(name)}">${materialIcons[name]}</div>`;
7928
+ }
7929
+ }
7930
+ }
7931
+
7932
+ bodyHtml += `
7933
+ <div class="input-group">
7934
+ <label class="input-label">${f.label} <span class="field-optional">optional</span></label>
7935
+ <div class="icon-picker" id="modal-field-${f.key}">
7936
+ <input type="text" class="input icon-picker-search" placeholder="Search icons..." autocomplete="off" spellcheck="false">
7937
+ <div class="icon-picker-grid">${gridHtml}</div>
7938
+ </div>
7939
+ </div>`;
7940
+ return;
7941
+ }
7887
7942
  if (f.type === 'checkbox') {
7888
7943
  const checked = f.value ? 'checked' : '';
7889
7944
  bodyHtml += `
@@ -7939,6 +7994,44 @@ class CWMApp {
7939
7994
  });
7940
7995
  });
7941
7996
 
7997
+ // Icon picker behavior
7998
+ const iconPickers = this.els.modalBody.querySelectorAll('.icon-picker');
7999
+ iconPickers.forEach(picker => {
8000
+ const grid = picker.querySelector('.icon-picker-grid');
8001
+ const search = picker.querySelector('.icon-picker-search');
8002
+ // Selection
8003
+ grid.addEventListener('click', (e) => {
8004
+ const swatch = e.target.closest('.icon-swatch');
8005
+ if (!swatch) return;
8006
+ grid.querySelectorAll('.icon-swatch').forEach(s => s.classList.remove('selected'));
8007
+ swatch.classList.add('selected');
8008
+ });
8009
+ // Search filter
8010
+ if (search) {
8011
+ search.addEventListener('input', () => {
8012
+ const q = search.value.toLowerCase().trim();
8013
+ grid.querySelectorAll('.icon-swatch').forEach(swatch => {
8014
+ if (!swatch.dataset.icon) return; // "none" always visible
8015
+ // Match against stored name (mi/folder → "folder" for search)
8016
+ const searchable = swatch.dataset.icon.replace(/^mi\//, '').replace(/_/g, ' ');
8017
+ swatch.hidden = !!q && !searchable.includes(q);
8018
+ });
8019
+ // Hide category separators when their section is empty
8020
+ grid.querySelectorAll('.icon-picker-cat-sep, .icon-picker-set-sep').forEach(sep => {
8021
+ if (!q) { sep.hidden = false; return; }
8022
+ let next = sep.nextElementSibling;
8023
+ let hasVisible = false;
8024
+ while (next && !next.classList.contains('icon-picker-cat-sep') && !next.classList.contains('icon-picker-set-sep')) {
8025
+ if (!next.hidden) { hasVisible = true; break; }
8026
+ next = next.nextElementSibling;
8027
+ }
8028
+ sep.hidden = !hasVisible;
8029
+ });
8030
+ });
8031
+ search.addEventListener('keydown', e => e.stopPropagation());
8032
+ }
8033
+ });
8034
+
7942
8035
  // Re-enable confirm button (may have been disabled by previous modal interaction)
7943
8036
  this.els.modalConfirmBtn.disabled = false;
7944
8037
 
@@ -7951,6 +8044,9 @@ class CWMApp {
7951
8044
  if (f.type === 'color') {
7952
8045
  const selected = this.els.modalBody.querySelector(`#modal-field-${f.key} .color-swatch.selected`);
7953
8046
  result[f.key] = selected ? selected.dataset.color : 'mauve';
8047
+ } else if (f.type === 'icon') {
8048
+ const selected = this.els.modalBody.querySelector(`#modal-field-${f.key} .icon-swatch.selected`);
8049
+ result[f.key] = (selected && selected.dataset.icon) ? selected.dataset.icon : null;
7954
8050
  } else if (f.type === 'checkbox') {
7955
8051
  const el = document.getElementById(`modal-field-${f.key}`);
7956
8052
  if (el) result[f.key] = el.checked;
@@ -8547,12 +8643,13 @@ class CWMApp {
8547
8643
  : '';
8548
8644
 
8549
8645
  // Build meta row (badges + size + time) — only if there's something to show
8550
- const metaParts = [badges, sizeStr ? `<span class="ws-session-size">${sizeStr}</span>` : '', timeStr ? `<span class="ws-session-time">${timeStr}</span>` : ''].filter(Boolean).join('');
8646
+ const metaParts = [badges, sizeStr ? `<span class="ws-session-size">${sizeStr}</span>` : ''].filter(Boolean).join('');
8551
8647
  const metaRow = metaParts ? `<div class="ws-session-meta-row">${metaParts}</div>` : '';
8648
+ const timeEl = timeStr ? `<span class="ws-session-time">${timeStr}</span>` : '';
8552
8649
 
8553
8650
  return `<div class="ws-session-item${isHidden ? ' ws-session-hidden' : ''}" data-session-id="${s.id}" draggable="true" title="${this.escapeHtml(s.workingDir || '')}">
8554
8651
  <span class="ws-session-dot${tristateAttr}" style="background: ${statusDot}"></span>${pip}
8555
- <span class="ws-session-name">${this.escapeHtml(name)}</span>
8652
+ <span class="ws-session-name">${this.escapeHtml(name)}</span>${timeEl}
8556
8653
  ${metaRow}
8557
8654
  </div>`;
8558
8655
  };
@@ -8595,25 +8692,24 @@ class CWMApp {
8595
8692
 
8596
8693
  return `
8597
8694
  <div class="workspace-accordion${isWsHidden ? ' hidden-item' : ''}" data-id="${ws.id}">
8598
- <div class="workspace-item${isActive ? ' active' : ''}" data-id="${ws.id}" draggable="true">
8695
+ <div class="workspace-item${isActive ? ' active' : ''}" data-id="${ws.id}" draggable="true" style="--ws-color: ${color}">
8599
8696
  <span class="ws-chevron${showBody ? ' open' : ''}">&#9654;</span>
8600
- <div class="workspace-color-dot" style="background: ${color}"></div>
8697
+ ${(() => {
8698
+ const iconSvg = ws.icon
8699
+ ? (ws.icon.startsWith('mi/')
8700
+ ? window.__materialIcons?.[ws.icon.slice(3)]
8701
+ : window.__lucideIcons?.[ws.icon])
8702
+ : null;
8703
+ return iconSvg
8704
+ ? `<span class="workspace-icon" style="color: ${color}">${iconSvg}</span>`
8705
+ : `<div class="workspace-color-dot" style="background: ${color}"></div>`;
8706
+ })()}
8601
8707
  <div class="workspace-info">
8602
- <div class="workspace-name">${this.escapeHtml(ws.name)}</div>
8603
- <div class="workspace-session-count">${sessionCount} session${sessionCount !== 1 ? 's' : ''}</div>
8708
+ <div class="workspace-name">${this.escapeHtml(ws.name)}<span class="ws-count-badge">${sessionCount}</span></div>
8604
8709
  </div>
8605
8710
  <div class="workspace-actions">
8606
8711
  ${this.state.settings.enableWorktreeTasks ? `<button class="btn btn-ghost btn-icon btn-sm ws-new-task-btn" data-ws-id="${ws.id}" title="New Task">+</button>` : ''}
8607
- <button class="btn btn-ghost btn-icon btn-sm ws-rename-btn" data-id="${ws.id}" title="Edit">
8608
- <svg width="12" height="12" viewBox="0 0 14 14" fill="none">
8609
- <path d="M8.5 2.5l3 3M2 9.5V12h2.5L11 5.5l-3-3L2 9.5z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
8610
- </svg>
8611
- </button>
8612
- <button class="btn btn-ghost btn-icon btn-sm btn-danger-hover ws-delete-btn" data-id="${ws.id}" title="Delete">
8613
- <svg width="12" height="12" viewBox="0 0 14 14" fill="none">
8614
- <path d="M2.5 4h9M5 4V2.5h4V4M3.5 4v7.5a1 1 0 001 1h5a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
8615
- </svg>
8616
- </button>
8712
+ <button class="btn btn-ghost btn-icon btn-sm ws-more-btn" data-id="${ws.id}" title="More actions">&#8230;</button>
8617
8713
  </div>
8618
8714
  </div>
8619
8715
  <div class="workspace-accordion-body"${showBody ? '' : ' hidden'}>
@@ -1656,6 +1656,8 @@
1656
1656
  <!-- Hidden file input for image uploads (reused by all terminal panes) -->
1657
1657
  <input type="file" id="image-upload-input" accept="image/*" hidden>
1658
1658
 
1659
+ <script src="vendor/lucide.bundle.js"></script>
1660
+ <script src="vendor/material-icons.bundle.js"></script>
1659
1661
  <script src="vendor/qrcode.min.js"></script>
1660
1662
  <script src="vendor/xterm/xterm.min.js"></script>
1661
1663
  <script src="vendor/xterm-addon-fit/xterm-addon-fit.min.js"></script>