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.
- package/README.md +30 -4
- package/config/skillsmp-config.json +5 -0
- package/db.js +248 -0
- package/package.json +11 -2
- package/public/css/panels/git-panel.css +220 -0
- package/public/css/panels/skills-manager.css +975 -0
- package/public/css/ui/input-history.css +109 -0
- package/public/css/ui/messages.css +51 -0
- package/public/css/ui/notification-bell.css +421 -0
- package/public/css/ui/sessions.css +41 -0
- package/public/css/ui/worktree.css +442 -0
- package/public/index.html +43 -10
- package/public/js/core/api.js +83 -0
- package/public/js/core/dom.js +15 -0
- package/public/js/features/background-sessions.js +11 -0
- package/public/js/features/chat.js +501 -3
- package/public/js/features/input-history.js +122 -0
- package/public/js/features/projects.js +16 -1
- package/public/js/features/sessions.js +77 -30
- package/public/js/main.js +3 -0
- package/public/js/panels/git-panel.js +385 -6
- package/public/js/panels/skills-manager.js +1005 -0
- package/public/js/ui/messages.js +58 -0
- package/public/js/ui/notification-bell.js +240 -0
- package/public/js/ui/notification-history.js +210 -0
- package/public/js/ui/parallel.js +11 -0
- package/public/js/ui/tab-sdk.js +1 -1
- package/public/style.css +4 -0
- package/server/agent-loop.js +13 -0
- package/server/notification-logger.js +27 -0
- package/server/routes/notifications.js +57 -1
- package/server/routes/sessions.js +41 -0
- package/server/routes/skills.js +454 -0
- package/server/routes/worktrees.js +93 -0
- package/server/utils/git-worktree.js +297 -0
- package/server/ws-handler.js +708 -629
- package/server.js +17 -1
package/public/js/ui/messages.js
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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">×</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
init();
|
package/public/js/ui/parallel.js
CHANGED
|
@@ -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() {
|
package/public/js/ui/tab-sdk.js
CHANGED
|
@@ -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");
|
package/server/agent-loop.js
CHANGED
|
@@ -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
|
+
}
|