claudeck 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +30 -4
  2. package/config/skillsmp-config.json +5 -0
  3. package/db.js +248 -0
  4. package/package.json +11 -2
  5. package/public/css/panels/git-panel.css +220 -0
  6. package/public/css/panels/skills-manager.css +975 -0
  7. package/public/css/ui/input-history.css +109 -0
  8. package/public/css/ui/messages.css +51 -0
  9. package/public/css/ui/notification-bell.css +421 -0
  10. package/public/css/ui/sessions.css +41 -0
  11. package/public/css/ui/worktree.css +442 -0
  12. package/public/index.html +43 -10
  13. package/public/js/core/api.js +83 -0
  14. package/public/js/core/dom.js +15 -0
  15. package/public/js/features/background-sessions.js +11 -0
  16. package/public/js/features/chat.js +501 -3
  17. package/public/js/features/input-history.js +122 -0
  18. package/public/js/features/projects.js +16 -1
  19. package/public/js/features/sessions.js +77 -30
  20. package/public/js/main.js +3 -0
  21. package/public/js/panels/git-panel.js +385 -6
  22. package/public/js/panels/skills-manager.js +1005 -0
  23. package/public/js/ui/messages.js +58 -0
  24. package/public/js/ui/notification-bell.js +240 -0
  25. package/public/js/ui/notification-history.js +210 -0
  26. package/public/js/ui/parallel.js +11 -0
  27. package/public/js/ui/tab-sdk.js +1 -1
  28. package/public/style.css +4 -0
  29. package/server/agent-loop.js +13 -0
  30. package/server/notification-logger.js +27 -0
  31. package/server/routes/notifications.js +57 -1
  32. package/server/routes/sessions.js +41 -0
  33. package/server/routes/skills.js +454 -0
  34. package/server/routes/worktrees.js +93 -0
  35. package/server/utils/git-worktree.js +297 -0
  36. package/server/ws-handler.js +708 -629
  37. package/server.js +17 -1
@@ -297,6 +297,15 @@ export function addStatus(text, isError, pane) {
297
297
  scrollToBottom(pane);
298
298
  }
299
299
 
300
+ export function addSkillUsedMessage(skillName, skillDescription, pane) {
301
+ pane = pane || getPane(null);
302
+ const div = document.createElement("div");
303
+ div.className = "skill-used-message";
304
+ div.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg><span><span class="skill-used-name">Skill used: ${escapeHtml(skillName)}</span>${skillDescription ? `<span class="skill-used-desc"> — ${escapeHtml(skillDescription)}</span>` : ""}</span>`;
305
+ pane.messagesDiv.appendChild(div);
306
+ scrollToBottom(pane);
307
+ }
308
+
300
309
  export function appendCliOutput(data, pane) {
301
310
  pane = pane || getPane(null);
302
311
  const div = document.createElement("div");
@@ -334,10 +343,20 @@ export function renderMessagesIntoPane(messages, pane) {
334
343
  showWhalyPlaceholder(pane);
335
344
  return;
336
345
  }
346
+ // Track last assistant message ID for fork button placement
347
+ let lastAssistantMsgEl = null;
348
+ let lastAssistantMsgId = null;
349
+
337
350
  for (const msg of messages) {
338
351
  const data = JSON.parse(msg.content);
339
352
  switch (msg.role) {
340
353
  case "user": {
354
+ // Finalize previous assistant block with fork button
355
+ if (lastAssistantMsgEl && lastAssistantMsgId) {
356
+ addForkButton(lastAssistantMsgEl, lastAssistantMsgId);
357
+ lastAssistantMsgEl = null;
358
+ lastAssistantMsgId = null;
359
+ }
341
360
  // Extract file paths from saved <file path="..."> blocks
342
361
  const filePathMatches = (data.text || "").match(/<file path="([^"]+)">/g);
343
362
  const savedFilePaths = filePathMatches
@@ -352,15 +371,35 @@ export function renderMessagesIntoPane(messages, pane) {
352
371
  }
353
372
  case "assistant":
354
373
  appendAssistantText(data.text, pane);
374
+ // Track this assistant message element for fork button
375
+ if (pane.currentAssistantMsg) {
376
+ lastAssistantMsgEl = pane.currentAssistantMsg.closest(".msg-assistant");
377
+ lastAssistantMsgId = msg.id;
378
+ }
355
379
  break;
356
380
  case "tool":
381
+ // Render "Skill used" indicator for Skill tool_use messages
382
+ if (data.name === "Skill" && data.input?.skill) {
383
+ addSkillUsedMessage(data.input.skill, data.input.description || "", pane);
384
+ }
357
385
  appendToolIndicator(data.name, data.input, pane, data.id, false);
386
+ // Tools are part of assistant turn — update the tracking ID
387
+ if (!lastAssistantMsgEl) lastAssistantMsgEl = pane.messagesDiv.lastElementChild;
388
+ lastAssistantMsgId = msg.id;
358
389
  break;
359
390
  case "tool_result":
360
391
  appendToolResult(data.toolUseId, data.content, data.isError, pane);
392
+ if (!lastAssistantMsgEl) lastAssistantMsgEl = pane.messagesDiv.lastElementChild;
393
+ lastAssistantMsgId = msg.id;
361
394
  break;
362
395
  case "result":
363
396
  addResultSummary(data, pane);
397
+ // Result marks end of an assistant turn — add fork button on the assistant msg element
398
+ if (lastAssistantMsgEl && lastAssistantMsgId) {
399
+ addForkButton(lastAssistantMsgEl, lastAssistantMsgId);
400
+ }
401
+ lastAssistantMsgEl = null;
402
+ lastAssistantMsgId = null;
364
403
  break;
365
404
  case "error": {
366
405
  const errorParts = [];
@@ -371,11 +410,20 @@ export function renderMessagesIntoPane(messages, pane) {
371
410
  addStatus(errorParts.join(" \u00b7 ") || "Error", true, pane);
372
411
  break;
373
412
  }
413
+ case "skill":
414
+ addSkillUsedMessage(data.skill || data.name || "", data.description || "", pane);
415
+ break;
374
416
  case "aborted":
375
417
  addStatus("Aborted", true, pane);
376
418
  break;
377
419
  }
378
420
  }
421
+
422
+ // Add fork button to last assistant message if conversation ends with one
423
+ if (lastAssistantMsgEl && lastAssistantMsgId) {
424
+ addForkButton(lastAssistantMsgEl, lastAssistantMsgId);
425
+ }
426
+
379
427
  pane.currentAssistantMsg = null;
380
428
  // Hide token counter and reset — loading saved messages shouldn't show streaming stats
381
429
  setState("streamingCharCount", 0);
@@ -385,3 +433,13 @@ export function renderMessagesIntoPane(messages, pane) {
385
433
  addCopyButtons(pane.messagesDiv);
386
434
  renderMermaidBlocks(pane.messagesDiv);
387
435
  }
436
+
437
+ function addForkButton(msgEl, messageId) {
438
+ if (!msgEl || msgEl.querySelector(".fork-btn")) return;
439
+ const btn = document.createElement("button");
440
+ btn.className = "fork-btn";
441
+ btn.dataset.messageId = messageId;
442
+ btn.title = "Fork conversation from here";
443
+ btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>`;
444
+ msgEl.appendChild(btn);
445
+ }
@@ -0,0 +1,240 @@
1
+ // Notification bell — badge, dropdown, read/unread management
2
+ import { on, emit } from '../core/events.js';
3
+
4
+ const TYPE_ICONS = {
5
+ session: '\u{1F4AC}',
6
+ agent: '\u{1F916}',
7
+ workflow: '\u2699\uFE0F',
8
+ chain: '\u{1F517}',
9
+ dag: '\u{1F310}',
10
+ error: '\u26A0\uFE0F',
11
+ approval: '\u{1F512}',
12
+ };
13
+
14
+ let unreadCount = 0;
15
+ let notifications = [];
16
+ let dropdownOpen = false;
17
+ let autoReadTimer = null;
18
+
19
+ const bellBtn = document.getElementById('notif-bell-btn');
20
+ const badge = document.getElementById('notif-badge');
21
+ const dropdown = document.getElementById('notif-dropdown');
22
+
23
+ function init() {
24
+ if (!bellBtn) return;
25
+ fetchUnreadCount();
26
+ on('ws:message', handleWsMessage);
27
+ on('ws:reconnected', fetchUnreadCount);
28
+ bellBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleDropdown(); });
29
+ document.addEventListener('click', handleOutsideClick);
30
+ }
31
+
32
+ async function fetchUnreadCount() {
33
+ try {
34
+ const res = await fetch('/api/notifications/unread-count');
35
+ if (!res.ok) return;
36
+ const data = await res.json();
37
+ updateBadge(data.count);
38
+ } catch { /* network error */ }
39
+ }
40
+
41
+ function toggleDropdown() {
42
+ dropdownOpen = !dropdownOpen;
43
+ if (dropdownOpen) {
44
+ fetchAndRender();
45
+ dropdown.classList.remove('hidden');
46
+ } else {
47
+ dropdown.classList.add('hidden');
48
+ clearAutoRead();
49
+ }
50
+ }
51
+
52
+ function closeDropdown() {
53
+ dropdownOpen = false;
54
+ dropdown.classList.add('hidden');
55
+ clearAutoRead();
56
+ }
57
+
58
+ async function fetchAndRender() {
59
+ try {
60
+ const res = await fetch('/api/notifications/history?limit=15');
61
+ if (!res.ok) return;
62
+ notifications = await res.json();
63
+ renderDropdown();
64
+ startAutoRead();
65
+ } catch { /* network error */ }
66
+ }
67
+
68
+ function renderDropdown() {
69
+ if (!notifications.length) {
70
+ dropdown.innerHTML = `
71
+ <div class="notif-dropdown-header">Notifications</div>
72
+ <div class="notif-empty">
73
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
74
+ No notifications yet
75
+ </div>`;
76
+ return;
77
+ }
78
+
79
+ const countLabel = unreadCount > 0 ? `<span>${unreadCount} unread</span>` : '';
80
+ let html = `<div class="notif-dropdown-header">Notifications ${countLabel}</div>`;
81
+ html += '<div class="notif-list">';
82
+ for (const n of notifications) {
83
+ html += renderItem(n);
84
+ }
85
+ html += '</div>';
86
+ html += `<div class="notif-footer">
87
+ <button class="notif-footer-btn" data-action="mark-all-read">Mark all read</button>
88
+ <button class="notif-footer-btn" data-action="view-all">View All</button>
89
+ </div>`;
90
+
91
+ dropdown.innerHTML = html;
92
+
93
+ // Wire events
94
+ dropdown.querySelector('[data-action="mark-all-read"]')?.addEventListener('click', (e) => {
95
+ e.stopPropagation();
96
+ markAllRead();
97
+ });
98
+ dropdown.querySelector('[data-action="view-all"]')?.addEventListener('click', (e) => {
99
+ e.stopPropagation();
100
+ closeDropdown();
101
+ emit('notification:show-history');
102
+ });
103
+
104
+ // Wire individual items
105
+ for (const el of dropdown.querySelectorAll('.notif-item')) {
106
+ el.addEventListener('click', () => onNotifClick(el.dataset.id));
107
+ }
108
+ for (const el of dropdown.querySelectorAll('.notif-dot')) {
109
+ el.addEventListener('click', (e) => {
110
+ e.stopPropagation();
111
+ const id = parseInt(e.target.closest('.notif-item').dataset.id);
112
+ if (e.target.classList.contains('unread')) markAsRead([id]);
113
+ });
114
+ }
115
+ }
116
+
117
+ function renderItem(n) {
118
+ const isUnread = !n.read_at;
119
+ const icon = TYPE_ICONS[n.type] || '\u{1F514}';
120
+ return `<div class="notif-item ${isUnread ? 'unread' : ''}" data-id="${n.id}" data-session="${n.source_session_id || ''}">
121
+ <span class="notif-dot ${isUnread ? 'unread' : 'read'}"></span>
122
+ <span class="notif-icon">${icon}</span>
123
+ <div class="notif-content">
124
+ <div class="notif-title">${escapeHtml(n.title)}</div>
125
+ ${n.body ? `<div class="notif-body">${escapeHtml(n.body)}</div>` : ''}
126
+ </div>
127
+ <span class="notif-time">${timeAgo(n.created_at)}</span>
128
+ </div>`;
129
+ }
130
+
131
+ function updateBadge(count) {
132
+ unreadCount = count;
133
+ if (count > 0) {
134
+ badge.textContent = count > 99 ? '99+' : count;
135
+ badge.classList.remove('hidden');
136
+ bellBtn.classList.add('has-unread');
137
+ } else {
138
+ badge.classList.add('hidden');
139
+ bellBtn.classList.remove('has-unread');
140
+ }
141
+ }
142
+
143
+ function handleWsMessage(msg) {
144
+ if (msg.type === 'notification:new') {
145
+ updateBadge(msg.unreadCount);
146
+ if (dropdownOpen) {
147
+ notifications.unshift(msg.notification);
148
+ if (notifications.length > 15) notifications.pop();
149
+ renderDropdown();
150
+ }
151
+ } else if (msg.type === 'notification:read') {
152
+ updateBadge(msg.unreadCount);
153
+ if (dropdownOpen) {
154
+ for (const n of notifications) {
155
+ if (msg.ids.length === 0 || msg.ids.includes(n.id)) {
156
+ n.read_at = Math.floor(Date.now() / 1000);
157
+ }
158
+ }
159
+ renderDropdown();
160
+ }
161
+ }
162
+ }
163
+
164
+ function handleOutsideClick(e) {
165
+ if (dropdownOpen && !e.target.closest('.notif-bell')) {
166
+ closeDropdown();
167
+ }
168
+ }
169
+
170
+ // ── Read strategies ──────────────────────────────────────
171
+ async function markAsRead(ids) {
172
+ try {
173
+ const res = await fetch('/api/notifications/read', {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify({ ids }),
177
+ });
178
+ if (!res.ok) return;
179
+ const data = await res.json();
180
+ updateBadge(data.unreadCount);
181
+ for (const n of notifications) {
182
+ if (ids.includes(n.id)) n.read_at = Math.floor(Date.now() / 1000);
183
+ }
184
+ renderDropdown();
185
+ } catch { /* network error */ }
186
+ }
187
+
188
+ async function markAllRead() {
189
+ try {
190
+ const res = await fetch('/api/notifications/read', {
191
+ method: 'POST',
192
+ headers: { 'Content-Type': 'application/json' },
193
+ body: JSON.stringify({ all: true }),
194
+ });
195
+ if (!res.ok) return;
196
+ const data = await res.json();
197
+ updateBadge(data.unreadCount);
198
+ for (const n of notifications) n.read_at = Math.floor(Date.now() / 1000);
199
+ renderDropdown();
200
+ } catch { /* network error */ }
201
+ }
202
+
203
+ function startAutoRead() {
204
+ clearAutoRead();
205
+ const unreadIds = notifications.filter(n => !n.read_at).map(n => n.id);
206
+ if (unreadIds.length === 0) return;
207
+ autoReadTimer = setTimeout(() => markAsRead(unreadIds), 1500);
208
+ }
209
+
210
+ function clearAutoRead() {
211
+ if (autoReadTimer) { clearTimeout(autoReadTimer); autoReadTimer = null; }
212
+ }
213
+
214
+ function onNotifClick(idStr) {
215
+ const id = parseInt(idStr);
216
+ const n = notifications.find(n => n.id === id);
217
+ if (!n) return;
218
+ if (!n.read_at) markAsRead([id]);
219
+ if (n.source_session_id) {
220
+ closeDropdown();
221
+ emit('session:switch', n.source_session_id);
222
+ }
223
+ }
224
+
225
+ // ── Helpers ──────────────────────────────────────────────
226
+ function timeAgo(unixTs) {
227
+ const diff = Math.floor(Date.now() / 1000) - unixTs;
228
+ if (diff < 60) return 'now';
229
+ if (diff < 3600) return `${Math.floor(diff / 60)}m`;
230
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
231
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
232
+ return new Date(unixTs * 1000).toLocaleDateString();
233
+ }
234
+
235
+ function escapeHtml(str) {
236
+ if (!str) return '';
237
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
238
+ }
239
+
240
+ init();
@@ -0,0 +1,210 @@
1
+ // Notification history modal — full paginated view with filters
2
+ import { on } from '../core/events.js';
3
+
4
+ const TYPE_ICONS = {
5
+ session: '\u{1F4AC}',
6
+ agent: '\u{1F916}',
7
+ workflow: '\u2699\uFE0F',
8
+ chain: '\u{1F517}',
9
+ dag: '\u{1F310}',
10
+ error: '\u26A0\uFE0F',
11
+ approval: '\u{1F512}',
12
+ };
13
+
14
+ let overlay = null;
15
+ let items = [];
16
+ let offset = 0;
17
+ let hasMore = true;
18
+ let filterType = '';
19
+ let filterStatus = '';
20
+ let selectedIds = new Set();
21
+
22
+ function init() {
23
+ on('notification:show-history', openModal);
24
+ }
25
+
26
+ function openModal() {
27
+ if (overlay) return;
28
+ items = [];
29
+ offset = 0;
30
+ hasMore = true;
31
+ filterType = '';
32
+ filterStatus = '';
33
+ selectedIds.clear();
34
+
35
+ overlay = document.createElement('div');
36
+ overlay.className = 'notif-history-overlay';
37
+ overlay.innerHTML = `
38
+ <div class="notif-history-modal">
39
+ <div class="notif-history-header">
40
+ <h2>Notification History</h2>
41
+ <button class="notif-history-close">&times;</button>
42
+ </div>
43
+ <div class="notif-history-filters">
44
+ <select class="notif-filter-select" id="notif-filter-type">
45
+ <option value="">All Types</option>
46
+ <option value="agent">Agent</option>
47
+ <option value="error">Error</option>
48
+ <option value="workflow">Workflow</option>
49
+ <option value="chain">Chain</option>
50
+ <option value="dag">DAG</option>
51
+ <option value="session">Session</option>
52
+ <option value="approval">Approval</option>
53
+ </select>
54
+ <select class="notif-filter-select" id="notif-filter-status">
55
+ <option value="">All</option>
56
+ <option value="unread">Unread Only</option>
57
+ <option value="read">Read Only</option>
58
+ </select>
59
+ <div class="notif-bulk-actions">
60
+ <button class="notif-bulk-btn" id="notif-bulk-read">Mark Selected Read</button>
61
+ <button class="notif-bulk-btn danger" id="notif-bulk-purge">Purge Old</button>
62
+ </div>
63
+ </div>
64
+ <div class="notif-history-list" id="notif-history-list"></div>
65
+ <div class="notif-load-more" id="notif-load-more" style="display:none">
66
+ <button class="notif-load-more-btn">Load More</button>
67
+ </div>
68
+ </div>`;
69
+
70
+ document.body.appendChild(overlay);
71
+
72
+ // Wire events
73
+ overlay.querySelector('.notif-history-close').addEventListener('click', closeModal);
74
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); });
75
+ overlay.querySelector('#notif-filter-type').addEventListener('change', (e) => { filterType = e.target.value; resetAndFetch(); });
76
+ overlay.querySelector('#notif-filter-status').addEventListener('change', (e) => { filterStatus = e.target.value; resetAndFetch(); });
77
+ overlay.querySelector('#notif-bulk-read').addEventListener('click', bulkMarkRead);
78
+ overlay.querySelector('#notif-bulk-purge').addEventListener('click', bulkPurge);
79
+ overlay.querySelector('#notif-load-more .notif-load-more-btn').addEventListener('click', fetchMore);
80
+
81
+ // Keyboard
82
+ const onKey = (e) => { if (e.key === 'Escape') closeModal(); };
83
+ document.addEventListener('keydown', onKey);
84
+ overlay._keyHandler = onKey;
85
+
86
+ fetchMore();
87
+ }
88
+
89
+ function closeModal() {
90
+ if (!overlay) return;
91
+ document.removeEventListener('keydown', overlay._keyHandler);
92
+ overlay.remove();
93
+ overlay = null;
94
+ }
95
+
96
+ function resetAndFetch() {
97
+ items = [];
98
+ offset = 0;
99
+ hasMore = true;
100
+ selectedIds.clear();
101
+ const list = overlay?.querySelector('#notif-history-list');
102
+ if (list) list.innerHTML = '';
103
+ fetchMore();
104
+ }
105
+
106
+ async function fetchMore() {
107
+ if (!hasMore) return;
108
+ const params = new URLSearchParams({ limit: '30', offset: String(offset) });
109
+ if (filterType) params.set('type', filterType);
110
+ if (filterStatus === 'unread') params.set('unread_only', 'true');
111
+
112
+ try {
113
+ const res = await fetch(`/api/notifications/history?${params}`);
114
+ if (!res.ok) return;
115
+ let batch = await res.json();
116
+
117
+ // Client-side filter for "read only"
118
+ if (filterStatus === 'read') {
119
+ batch = batch.filter(n => n.read_at);
120
+ }
121
+
122
+ if (batch.length < 30) hasMore = false;
123
+ offset += batch.length;
124
+ items.push(...batch);
125
+ renderList();
126
+ } catch { /* network error */ }
127
+ }
128
+
129
+ function renderList() {
130
+ const list = overlay?.querySelector('#notif-history-list');
131
+ if (!list) return;
132
+
133
+ if (items.length === 0) {
134
+ list.innerHTML = '<div class="notif-empty" style="padding:40px">No notifications found</div>';
135
+ toggleLoadMore(false);
136
+ return;
137
+ }
138
+
139
+ let html = '';
140
+ for (const n of items) {
141
+ const isUnread = !n.read_at;
142
+ const checked = selectedIds.has(n.id) ? 'checked' : '';
143
+ const icon = TYPE_ICONS[n.type] || '\u{1F514}';
144
+ html += `<div class="notif-history-item ${isUnread ? 'unread' : ''}" data-id="${n.id}">
145
+ <input type="checkbox" ${checked} data-id="${n.id}">
146
+ <span class="notif-icon">${icon}</span>
147
+ <div class="notif-content">
148
+ <div class="notif-title">${escapeHtml(n.title)}</div>
149
+ ${n.body ? `<div class="notif-body">${escapeHtml(n.body)}</div>` : ''}
150
+ </div>
151
+ <span class="notif-time">${formatTime(n.created_at)}</span>
152
+ </div>`;
153
+ }
154
+ list.innerHTML = html;
155
+
156
+ // Wire checkboxes
157
+ for (const cb of list.querySelectorAll('input[type="checkbox"]')) {
158
+ cb.addEventListener('change', (e) => {
159
+ const id = parseInt(e.target.dataset.id);
160
+ if (e.target.checked) selectedIds.add(id); else selectedIds.delete(id);
161
+ });
162
+ }
163
+
164
+ toggleLoadMore(hasMore);
165
+ }
166
+
167
+ function toggleLoadMore(show) {
168
+ const el = overlay?.querySelector('#notif-load-more');
169
+ if (el) el.style.display = show ? 'flex' : 'none';
170
+ }
171
+
172
+ async function bulkMarkRead() {
173
+ if (selectedIds.size === 0) return;
174
+ const ids = [...selectedIds];
175
+ try {
176
+ await fetch('/api/notifications/read', {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({ ids }),
180
+ });
181
+ for (const n of items) {
182
+ if (ids.includes(n.id)) n.read_at = Math.floor(Date.now() / 1000);
183
+ }
184
+ selectedIds.clear();
185
+ renderList();
186
+ } catch { /* network error */ }
187
+ }
188
+
189
+ async function bulkPurge() {
190
+ try {
191
+ await fetch('/api/notifications/old', { method: 'DELETE' });
192
+ resetAndFetch();
193
+ } catch { /* network error */ }
194
+ }
195
+
196
+ function formatTime(unixTs) {
197
+ const d = new Date(unixTs * 1000);
198
+ const diff = Math.floor(Date.now() / 1000) - unixTs;
199
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
200
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
201
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +
202
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
203
+ }
204
+
205
+ function escapeHtml(str) {
206
+ if (!str) return '';
207
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
208
+ }
209
+
210
+ init();
@@ -3,6 +3,7 @@ import { $ } from '../core/dom.js';
3
3
  import { getState, setState } from '../core/store.js';
4
4
  import { CHAT_IDS } from '../core/constants.js';
5
5
  import { handleAutocompleteKeydown, handleSlashAutocomplete } from './commands.js';
6
+ import { handleHistoryKeydown } from '../features/input-history.js';
6
7
 
7
8
  // Panes map — chatId -> pane state object
8
9
  export const panes = new Map();
@@ -94,6 +95,9 @@ export function createChatPane(chatId, index) {
94
95
 
95
96
  textarea.addEventListener("keydown", (e) => {
96
97
  if (handleAutocompleteKeydown(e, state)) return;
98
+ // Lazy import to avoid circular dependency — getInputHistory is set by chat.js
99
+ const history = _getInputHistory();
100
+ if (history && handleHistoryKeydown(e, state, history)) return;
97
101
  if (e.key === "Enter" && !e.shiftKey) {
98
102
  e.preventDefault();
99
103
  sendMessage(state);
@@ -101,6 +105,8 @@ export function createChatPane(chatId, index) {
101
105
  });
102
106
 
103
107
  textarea.addEventListener("input", () => {
108
+ const history = _getInputHistory();
109
+ if (history && history.isNavigating) history.reset();
104
110
  textarea.style.height = "auto";
105
111
  textarea.style.height = Math.min(textarea.scrollHeight, 80) + "px";
106
112
  handleSlashAutocomplete(state);
@@ -161,6 +167,11 @@ export function exitParallelMode() {
161
167
  }
162
168
  }
163
169
 
170
+ // Lazy getter for input history to avoid circular dependency
171
+ let _inputHistoryGetter = null;
172
+ export function _setInputHistoryGetter(fn) { _inputHistoryGetter = fn; }
173
+ function _getInputHistory() { return _inputHistoryGetter ? _inputHistoryGetter() : null; }
174
+
164
175
  // Lazy getter for chat.js functions to avoid circular dependency
165
176
  let _chatFns = null;
166
177
  function _getLazyChatFns() {
@@ -651,7 +651,7 @@ function reorderPluginTabs(enabledNames) {
651
651
  }
652
652
 
653
653
  /** Built-in (hardcoded) tab IDs that are never managed by the marketplace */
654
- const BUILTIN_TABS = new Set(['files', 'git', 'memory', 'mcp', 'tips', 'assistant', 'tab-sdk', 'architecture', 'adding-features']);
654
+ const BUILTIN_TABS = new Set(['files', 'git', 'memory', 'mcp', 'tips', 'assistant', 'skills', 'tab-sdk', 'architecture', 'adding-features']);
655
655
 
656
656
  function isPluginTab(tabId) {
657
657
  return !BUILTIN_TABS.has(tabId);
package/public/style.css CHANGED
@@ -33,8 +33,12 @@
33
33
  @import url("css/features/agent-monitor.css");
34
34
  @import url("css/features/agent-sidebar.css");
35
35
  @import url("css/ui/status-bar.css");
36
+ @import url("css/ui/notification-bell.css");
37
+ @import url("css/ui/input-history.css");
38
+ @import url("css/ui/worktree.css");
36
39
  @import url("css/panels/memory.css");
37
40
  @import url("css/panels/dev-docs.css");
41
+ @import url("css/panels/skills-manager.css");
38
42
  @import url("css/features/telegram.css");
39
43
  @import url("css/features/voice-input.css");
40
44
  @import url("css/features/retro-terminal.css");
@@ -34,6 +34,7 @@ import { sendTelegramNotification } from "./telegram-sender.js";
34
34
  import { buildAgentMemoryPrompt } from "./memory-injector.js";
35
35
  import { captureMemories } from "./memory-extractor.js";
36
36
  import { saveExplicitMemories } from "./memory-injector.js";
37
+ import { logNotification } from "./notification-logger.js";
37
38
 
38
39
  /**
39
40
  * Build the agent system prompt that instructs Claude to work autonomously
@@ -302,6 +303,12 @@ export async function runAgent({
302
303
  recordAgentRunComplete(monitorRunId, agentId, 'completed', numTurns, costUsd, durationMs, inputTokens, outputTokens);
303
304
  } catch (e) { /* ignore */ }
304
305
 
306
+ // Log notification
307
+ logNotification('agent', `Agent "${agentDef.title}" completed`,
308
+ `${numTurns} turns · $${costUsd.toFixed(4)} · ${(durationMs / 1000).toFixed(1)}s`,
309
+ JSON.stringify({ costUsd, durationMs, inputTokens, outputTokens, turns: numTurns }),
310
+ resolvedSid, agentId);
311
+
305
312
  // Store agent output as shared context for downstream agents
306
313
  if (runId && lastAssistantText) {
307
314
  const summary = lastAssistantText.length > 4000
@@ -339,6 +346,12 @@ export async function runAgent({
339
346
  try {
340
347
  recordAgentRunComplete(monitorRunId, agentId, 'error', numTurns, costUsd, durationMs, inputTokens, outputTokens, errMsg);
341
348
  } catch (e) { /* ignore */ }
349
+
350
+ // Log error notification
351
+ logNotification('error', `Agent "${agentDef.title}" failed`,
352
+ errMsg.slice(0, 200),
353
+ JSON.stringify({ costUsd, durationMs, error: errMsg }),
354
+ resolvedSid, agentId);
342
355
  }
343
356
  continue;
344
357
  }
@@ -0,0 +1,27 @@
1
+ import { createNotification, getUnreadNotificationCount } from "../db.js";
2
+
3
+ let wss = null;
4
+
5
+ export function setWss(wssInstance) {
6
+ wss = wssInstance;
7
+ }
8
+
9
+ function broadcast(payload) {
10
+ if (!wss) return;
11
+ const data = JSON.stringify(payload);
12
+ for (const client of wss.clients) {
13
+ if (client.readyState === 1) client.send(data);
14
+ }
15
+ }
16
+
17
+ export function logNotification(type, title, body = null, metadata = null, sourceSessionId = null, sourceAgentId = null) {
18
+ const notification = createNotification(type, title, body, metadata, sourceSessionId, sourceAgentId);
19
+ const unreadCount = getUnreadNotificationCount();
20
+ broadcast({ type: "notification:new", notification, unreadCount });
21
+ return notification;
22
+ }
23
+
24
+ export function broadcastReadUpdate(ids) {
25
+ const unreadCount = getUnreadNotificationCount();
26
+ broadcast({ type: "notification:read", ids, unreadCount });
27
+ }