claudeck 1.0.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/cli.js +2 -0
  4. package/config/agent-chains.json +16 -0
  5. package/config/agent-dags.json +16 -0
  6. package/config/agents.json +46 -0
  7. package/config/bot-prompt.json +3 -0
  8. package/config/folders.json +66 -0
  9. package/config/prompts.json +92 -0
  10. package/config/repos.json +86 -0
  11. package/config/telegram-config.json +17 -0
  12. package/config/workflows.json +90 -0
  13. package/db.js +1198 -0
  14. package/package.json +55 -0
  15. package/plugins/claude-editor/client.css +171 -0
  16. package/plugins/claude-editor/client.js +183 -0
  17. package/plugins/event-stream/client.css +207 -0
  18. package/plugins/event-stream/client.js +271 -0
  19. package/plugins/linear/client.css +345 -0
  20. package/plugins/linear/client.js +380 -0
  21. package/plugins/linear/config.json +5 -0
  22. package/plugins/linear/server.js +312 -0
  23. package/plugins/repos/client.css +549 -0
  24. package/plugins/repos/client.js +663 -0
  25. package/plugins/repos/server.js +232 -0
  26. package/plugins/sudoku/client.css +196 -0
  27. package/plugins/sudoku/client.js +329 -0
  28. package/plugins/tasks/client.css +414 -0
  29. package/plugins/tasks/client.js +394 -0
  30. package/plugins/tasks/server.js +116 -0
  31. package/plugins/tic-tac-toe/client.css +167 -0
  32. package/plugins/tic-tac-toe/client.js +241 -0
  33. package/public/css/core/components.css +232 -0
  34. package/public/css/core/layout.css +330 -0
  35. package/public/css/core/print.css +18 -0
  36. package/public/css/core/reset.css +36 -0
  37. package/public/css/core/responsive.css +378 -0
  38. package/public/css/core/theme.css +116 -0
  39. package/public/css/core/variables.css +93 -0
  40. package/public/css/features/agent-monitor.css +297 -0
  41. package/public/css/features/agent-sidebar.css +525 -0
  42. package/public/css/features/agents.css +996 -0
  43. package/public/css/features/analytics.css +181 -0
  44. package/public/css/features/background-sessions.css +321 -0
  45. package/public/css/features/cost-dashboard.css +168 -0
  46. package/public/css/features/home.css +313 -0
  47. package/public/css/features/retro-terminal.css +88 -0
  48. package/public/css/features/telegram.css +127 -0
  49. package/public/css/features/tour.css +148 -0
  50. package/public/css/features/voice-input.css +60 -0
  51. package/public/css/features/welcome.css +241 -0
  52. package/public/css/panels/assistant-bot.css +442 -0
  53. package/public/css/panels/dev-docs.css +292 -0
  54. package/public/css/panels/file-explorer.css +322 -0
  55. package/public/css/panels/git-panel.css +221 -0
  56. package/public/css/panels/mcp-manager.css +199 -0
  57. package/public/css/panels/tips-feed.css +353 -0
  58. package/public/css/ui/commands.css +273 -0
  59. package/public/css/ui/context-gauge.css +76 -0
  60. package/public/css/ui/file-picker.css +69 -0
  61. package/public/css/ui/image-attachments.css +106 -0
  62. package/public/css/ui/messages.css +884 -0
  63. package/public/css/ui/modals.css +122 -0
  64. package/public/css/ui/parallel.css +217 -0
  65. package/public/css/ui/permissions.css +110 -0
  66. package/public/css/ui/right-panel.css +481 -0
  67. package/public/css/ui/sessions.css +689 -0
  68. package/public/css/ui/status-bar.css +425 -0
  69. package/public/css/ui/toolbox.css +206 -0
  70. package/public/data/tips.json +218 -0
  71. package/public/icons/favicon.png +0 -0
  72. package/public/icons/icon-192.png +0 -0
  73. package/public/icons/icon-512.png +0 -0
  74. package/public/icons/whaly.png +0 -0
  75. package/public/index.html +1140 -0
  76. package/public/js/core/api.js +591 -0
  77. package/public/js/core/constants.js +3 -0
  78. package/public/js/core/dom.js +270 -0
  79. package/public/js/core/events.js +10 -0
  80. package/public/js/core/plugin-loader.js +153 -0
  81. package/public/js/core/store.js +39 -0
  82. package/public/js/core/utils.js +25 -0
  83. package/public/js/core/ws.js +64 -0
  84. package/public/js/features/agent-monitor.js +222 -0
  85. package/public/js/features/agents.js +1209 -0
  86. package/public/js/features/analytics.js +397 -0
  87. package/public/js/features/attachments.js +251 -0
  88. package/public/js/features/background-sessions.js +475 -0
  89. package/public/js/features/chat.js +589 -0
  90. package/public/js/features/cost-dashboard.js +152 -0
  91. package/public/js/features/dag-editor.js +399 -0
  92. package/public/js/features/easter-egg.js +46 -0
  93. package/public/js/features/home.js +270 -0
  94. package/public/js/features/projects.js +372 -0
  95. package/public/js/features/prompts.js +228 -0
  96. package/public/js/features/sessions.js +332 -0
  97. package/public/js/features/telegram.js +131 -0
  98. package/public/js/features/tour.js +210 -0
  99. package/public/js/features/voice-input.js +185 -0
  100. package/public/js/features/welcome.js +43 -0
  101. package/public/js/features/workflows.js +277 -0
  102. package/public/js/main.js +51 -0
  103. package/public/js/panels/assistant-bot.js +445 -0
  104. package/public/js/panels/dev-docs.js +380 -0
  105. package/public/js/panels/file-explorer.js +486 -0
  106. package/public/js/panels/git-panel.js +285 -0
  107. package/public/js/panels/mcp-manager.js +311 -0
  108. package/public/js/panels/tips-feed.js +303 -0
  109. package/public/js/ui/commands.js +114 -0
  110. package/public/js/ui/context-gauge.js +100 -0
  111. package/public/js/ui/diff.js +124 -0
  112. package/public/js/ui/disabled-tools.js +36 -0
  113. package/public/js/ui/export.js +74 -0
  114. package/public/js/ui/formatting.js +206 -0
  115. package/public/js/ui/header-dropdowns.js +72 -0
  116. package/public/js/ui/input-meta.js +71 -0
  117. package/public/js/ui/max-turns.js +21 -0
  118. package/public/js/ui/messages.js +387 -0
  119. package/public/js/ui/model-selector.js +20 -0
  120. package/public/js/ui/notifications.js +232 -0
  121. package/public/js/ui/parallel.js +176 -0
  122. package/public/js/ui/permissions.js +168 -0
  123. package/public/js/ui/right-panel.js +173 -0
  124. package/public/js/ui/shortcuts.js +143 -0
  125. package/public/js/ui/sidebar-toggle.js +29 -0
  126. package/public/js/ui/status-bar.js +172 -0
  127. package/public/js/ui/tab-sdk.js +623 -0
  128. package/public/js/ui/theme.js +38 -0
  129. package/public/manifest.json +13 -0
  130. package/public/offline.html +190 -0
  131. package/public/style.css +42 -0
  132. package/public/sw.js +91 -0
  133. package/server/agent-loop.js +385 -0
  134. package/server/dag-executor.js +265 -0
  135. package/server/orchestrator.js +514 -0
  136. package/server/paths.js +61 -0
  137. package/server/plugin-mount.js +56 -0
  138. package/server/push-sender.js +31 -0
  139. package/server/routes/agents.js +294 -0
  140. package/server/routes/bot.js +45 -0
  141. package/server/routes/exec.js +35 -0
  142. package/server/routes/files.js +218 -0
  143. package/server/routes/mcp.js +82 -0
  144. package/server/routes/messages.js +36 -0
  145. package/server/routes/notifications.js +37 -0
  146. package/server/routes/projects.js +207 -0
  147. package/server/routes/prompts.js +53 -0
  148. package/server/routes/sessions.js +103 -0
  149. package/server/routes/stats.js +143 -0
  150. package/server/routes/telegram.js +71 -0
  151. package/server/routes/tips.js +135 -0
  152. package/server/routes/workflows.js +81 -0
  153. package/server/summarizer.js +55 -0
  154. package/server/telegram-poller.js +205 -0
  155. package/server/telegram-sender.js +304 -0
  156. package/server/ws-handler.js +926 -0
  157. package/server.js +179 -0
@@ -0,0 +1,387 @@
1
+ // Message rendering
2
+ import { escapeHtml, getToolDetail, scrollToBottom } from '../core/utils.js';
3
+ import { renderMarkdown, highlightCodeBlocks, addCopyButtons, renderMermaidBlocks } from './formatting.js';
4
+ import { renderDiffView, renderAdditionsView } from './diff.js';
5
+ import { getState, setState } from '../core/store.js';
6
+ import { $ } from '../core/dom.js';
7
+ import { getPane } from './parallel.js';
8
+
9
+ export function showWhalyPlaceholder(pane) {
10
+ pane = pane || getPane(null);
11
+ removeWhalyPlaceholder(pane);
12
+ const el = document.createElement("div");
13
+ el.className = "whaly-placeholder";
14
+ el.innerHTML = `<img src="/icons/whaly.png" alt="Whaly" draggable="false"><div class="whaly-text">~ start chatting with claude ~</div><div class="whaly-hint">Type a message or select a prompt template</div>`;
15
+ pane.messagesDiv.appendChild(el);
16
+ }
17
+
18
+ export function removeWhalyPlaceholder(pane) {
19
+ pane = pane || getPane(null);
20
+ const existing = pane.messagesDiv.querySelector(".whaly-placeholder");
21
+ if (existing) existing.remove();
22
+ }
23
+
24
+ export function addUserMessage(text, pane, images = [], filePaths = []) {
25
+ pane = pane || getPane(null);
26
+ removeWhalyPlaceholder(pane);
27
+ pane.currentAssistantMsg = null;
28
+ const div = document.createElement("div");
29
+ div.className = "msg msg-user";
30
+
31
+ const label = document.createElement("span");
32
+ label.className = "msg-user-label";
33
+ label.textContent = "YOU";
34
+
35
+ div.appendChild(label);
36
+
37
+ if (filePaths && filePaths.length > 0) {
38
+ const filesDiv = document.createElement("div");
39
+ filesDiv.className = "msg-user-files";
40
+ for (const fp of filePaths) {
41
+ const fileTag = document.createElement("span");
42
+ fileTag.className = "msg-user-file-tag";
43
+ fileTag.textContent = fp;
44
+ fileTag.title = fp;
45
+ filesDiv.appendChild(fileTag);
46
+ }
47
+ div.appendChild(filesDiv);
48
+ }
49
+
50
+ const body = document.createElement("span");
51
+ body.className = "msg-user-body";
52
+ body.textContent = text;
53
+
54
+ div.appendChild(body);
55
+
56
+ if (images && images.length > 0) {
57
+ renderChatImages(images, div);
58
+ }
59
+
60
+ pane.messagesDiv.appendChild(div);
61
+ scrollToBottom(pane);
62
+ }
63
+
64
+ function renderChatImages(images, container) {
65
+ const strip = document.createElement("div");
66
+ strip.className = "chat-image-strip";
67
+
68
+ for (const img of images) {
69
+ const imgEl = document.createElement("img");
70
+ imgEl.className = "chat-image-thumb";
71
+ imgEl.src = `data:${img.mimeType};base64,${img.data}`;
72
+ imgEl.alt = img.name || "attached image";
73
+ imgEl.title = img.name || "attached image";
74
+ imgEl.addEventListener("click", () => {
75
+ const overlay = document.createElement("div");
76
+ overlay.className = "chat-image-overlay";
77
+ const fullImg = document.createElement("img");
78
+ fullImg.src = imgEl.src;
79
+ overlay.appendChild(fullImg);
80
+ overlay.addEventListener("click", () => overlay.remove());
81
+ document.body.appendChild(overlay);
82
+ });
83
+ strip.appendChild(imgEl);
84
+ }
85
+
86
+ container.appendChild(strip);
87
+ }
88
+
89
+ export function appendAssistantText(text, pane) {
90
+ pane = pane || getPane(null);
91
+ if (!pane.currentAssistantMsg) {
92
+ const div = document.createElement("div");
93
+ div.className = "msg msg-assistant";
94
+ const content = document.createElement("div");
95
+ content.className = "text-content";
96
+ div.appendChild(content);
97
+ pane.messagesDiv.appendChild(div);
98
+ pane.currentAssistantMsg = content;
99
+ }
100
+ pane.currentAssistantMsg.innerHTML = renderMarkdown(
101
+ (pane.currentAssistantMsg.dataset.raw || "") + text
102
+ );
103
+ pane.currentAssistantMsg.dataset.raw =
104
+ (pane.currentAssistantMsg.dataset.raw || "") + text;
105
+ highlightCodeBlocks(pane.currentAssistantMsg);
106
+ addCopyButtons(pane.currentAssistantMsg);
107
+ renderMermaidBlocks(pane.currentAssistantMsg);
108
+ scrollToBottom(pane);
109
+
110
+ // Update streaming token counter
111
+ let count = getState("streamingCharCount") + text.length;
112
+ setState("streamingCharCount", count);
113
+ const tokenEst = Math.round(count / 4);
114
+ if ($.streamingTokens) {
115
+ if ($.streamingTokensValue) $.streamingTokensValue.textContent = `~${tokenEst} tokens`;
116
+ $.streamingTokens.classList.remove("hidden");
117
+ if ($.streamingTokensSep) $.streamingTokensSep.classList.remove("hidden");
118
+ }
119
+ }
120
+
121
+ export function appendToolIndicator(name, input, pane, toolId, isLive = true) {
122
+ pane = pane || getPane(null);
123
+ const div = document.createElement("div");
124
+ div.className = "msg";
125
+
126
+ // Diff view for Edit tool
127
+ if (name === "Edit" && input && input.old_string != null && input.new_string != null) {
128
+ const diffEl = renderDiffView(input.old_string, input.new_string, input.file_path);
129
+ if (diffEl) {
130
+ div.appendChild(diffEl);
131
+ pane.messagesDiv.appendChild(div);
132
+ pane.currentAssistantMsg = null;
133
+ scrollToBottom(pane);
134
+ return;
135
+ }
136
+ }
137
+
138
+ // Additions view for Write tool
139
+ if (name === "Write" && input && input.content != null) {
140
+ const addEl = renderAdditionsView(input.content, input.file_path);
141
+ if (addEl) {
142
+ div.appendChild(addEl);
143
+ pane.messagesDiv.appendChild(div);
144
+ pane.currentAssistantMsg = null;
145
+ scrollToBottom(pane);
146
+ return;
147
+ }
148
+ }
149
+
150
+ // Default tool indicator — show spinner only for live streaming tools
151
+ const indicator = document.createElement("div");
152
+ indicator.className = isLive ? "tool-indicator tool-running" : "tool-indicator";
153
+ if (toolId) indicator.dataset.toolId = toolId;
154
+ indicator.innerHTML = `
155
+ <span class="tool-spinner" ${!isLive ? 'style="display:none;"' : ""}></span>
156
+ <span class="tool-status-icon" style="display:none;"></span>
157
+ <span class="tool-name">${escapeHtml(name)}</span>
158
+ <span class="tool-detail">${getToolDetail(name, input)}</span>
159
+ <div class="tool-body">${escapeHtml(JSON.stringify(input, null, 2))}</div>
160
+ <div class="tool-result-preview" style="display:none;"></div>
161
+ `;
162
+ indicator.addEventListener("click", () => {
163
+ indicator.classList.toggle("expanded");
164
+ });
165
+
166
+ div.appendChild(indicator);
167
+ pane.messagesDiv.appendChild(div);
168
+ pane.currentAssistantMsg = null;
169
+ scrollToBottom(pane);
170
+ }
171
+
172
+ export function appendToolResult(toolUseId, content, isError, pane) {
173
+ pane = pane || getPane(null);
174
+
175
+ // Try to find the matching tool indicator and update it in-place
176
+ const existing = toolUseId
177
+ ? pane.messagesDiv.querySelector(`.tool-indicator[data-tool-id="${toolUseId}"]`)
178
+ : null;
179
+
180
+ if (existing) {
181
+ // Update the existing indicator: stop spinner, show status icon + result
182
+ existing.classList.remove("tool-running");
183
+ existing.classList.add(isError ? "tool-error" : "tool-done");
184
+
185
+ const spinner = existing.querySelector(".tool-spinner");
186
+ if (spinner) spinner.style.display = "none";
187
+
188
+ const statusIcon = existing.querySelector(".tool-status-icon");
189
+ if (statusIcon) {
190
+ statusIcon.style.display = "";
191
+ statusIcon.style.color = isError ? "var(--error)" : "var(--success)";
192
+ statusIcon.innerHTML = isError ? "&#10007;" : "&#10003;";
193
+ }
194
+
195
+ // Show result preview inline
196
+ const resultPreview = existing.querySelector(".tool-result-preview");
197
+ if (resultPreview && content) {
198
+ const preview = typeof content === "string" ? content.slice(0, 150) : "";
199
+ resultPreview.textContent = preview;
200
+ resultPreview.style.display = "";
201
+ resultPreview.className = "tool-result-preview" + (isError ? " error" : "");
202
+ }
203
+
204
+ // Append full result to tool-body
205
+ const body = existing.querySelector(".tool-body");
206
+ if (body && content) {
207
+ body.innerHTML += "\n\n─── Result ───\n" + escapeHtml(content || "");
208
+ }
209
+
210
+ scrollToBottom(pane);
211
+ return;
212
+ }
213
+
214
+ // Fallback: create a standalone result element (for old messages without tool IDs)
215
+ const div = document.createElement("div");
216
+ div.className = "msg";
217
+
218
+ const indicator = document.createElement("div");
219
+ indicator.className = "tool-indicator " + (isError ? "tool-error" : "tool-done");
220
+ const preview = typeof content === "string" ? content.slice(0, 120) : "";
221
+ const iconColor = isError ? "var(--error)" : "var(--success)";
222
+ const icon = isError ? "&#10007;" : "&#10003;";
223
+ indicator.innerHTML = `
224
+ <span class="tool-status-icon" style="color: ${iconColor};">${icon}</span>
225
+ <span class="tool-name">${isError ? "Error" : "Result"}</span>
226
+ <span class="tool-detail">${escapeHtml(preview)}</span>
227
+ <div class="tool-body">${escapeHtml(content || "")}</div>
228
+ `;
229
+ indicator.addEventListener("click", () => {
230
+ indicator.classList.toggle("expanded");
231
+ });
232
+
233
+ div.appendChild(indicator);
234
+ pane.messagesDiv.appendChild(div);
235
+ pane.currentAssistantMsg = null;
236
+ scrollToBottom(pane);
237
+ }
238
+
239
+ export function showThinking(label, pane) {
240
+ pane = pane || getPane(null);
241
+ removeThinking(pane);
242
+ const div = document.createElement("div");
243
+ div.className = "thinking-bar";
244
+ div.dataset.thinkingBar = "true";
245
+ div.innerHTML = `
246
+ <div class="thinking-dot-container">
247
+ <span class="thinking-dot"></span>
248
+ <span class="thinking-dot"></span>
249
+ <span class="thinking-dot"></span>
250
+ </div>
251
+ <span class="thinking-label">${escapeHtml(label)}</span>
252
+ `;
253
+ pane.messagesDiv.appendChild(div);
254
+ if (pane.statusEl) {
255
+ pane.statusEl.textContent = "streaming";
256
+ pane.statusEl.className = "chat-pane-status streaming";
257
+ }
258
+ scrollToBottom(pane);
259
+ }
260
+
261
+ export function removeThinking(pane) {
262
+ pane = pane || getPane(null);
263
+ const el = pane.messagesDiv.querySelector('[data-thinking-bar="true"]');
264
+ if (el) el.remove();
265
+ }
266
+
267
+ export function addResultSummary(msg, pane) {
268
+ pane = pane || getPane(null);
269
+ const parts = [];
270
+ if (msg.model) parts.push(msg.model);
271
+ if (msg.num_turns != null) parts.push(`${msg.num_turns} turn${msg.num_turns !== 1 ? "s" : ""}`);
272
+ if (msg.duration_ms != null) {
273
+ const secs = (msg.duration_ms / 1000).toFixed(1);
274
+ parts.push(`${secs}s`);
275
+ }
276
+ if (msg.cost_usd != null) parts.push(`$${msg.cost_usd.toFixed(4)}`);
277
+ const inTok = msg.input_tokens || 0;
278
+ const outTok = msg.output_tokens || 0;
279
+ if (inTok > 0 || outTok > 0) {
280
+ const fmtTok = (n) => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
281
+ parts.push(`${fmtTok(inTok)} in / ${fmtTok(outTok)} out`);
282
+ }
283
+ if (msg.stop_reason && msg.stop_reason !== "success") {
284
+ parts.push(`[${msg.stop_reason}]`);
285
+ }
286
+ if (parts.length > 0) {
287
+ addStatus(parts.join(" \u00b7 "), false, pane);
288
+ }
289
+ }
290
+
291
+ export function addStatus(text, isError, pane) {
292
+ pane = pane || getPane(null);
293
+ const div = document.createElement("div");
294
+ div.className = "status" + (isError ? " error" : "");
295
+ div.textContent = text;
296
+ pane.messagesDiv.appendChild(div);
297
+ scrollToBottom(pane);
298
+ }
299
+
300
+ export function appendCliOutput(data, pane) {
301
+ pane = pane || getPane(null);
302
+ const div = document.createElement("div");
303
+ div.className = "msg";
304
+
305
+ const block = document.createElement("div");
306
+ block.className = "cli-output";
307
+
308
+ const isOk = data.exitCode === 0;
309
+ block.innerHTML = `
310
+ <div class="cli-output-header">
311
+ <span class="cli-icon ${isOk ? "success" : "error"}">${isOk ? "&#10003;" : "&#10007;"}</span>
312
+ <span class="cli-cmd">${escapeHtml(data.command)}</span>
313
+ <span class="cli-exit">exit ${data.exitCode}</span>
314
+ </div>
315
+ <div class="cli-output-body">
316
+ ${data.stdout ? `<pre>${escapeHtml(data.stdout)}</pre>` : ""}
317
+ ${data.stderr ? `<pre class="cli-output-stderr">${escapeHtml(data.stderr)}</pre>` : ""}
318
+ ${!data.stdout && !data.stderr ? `<pre>(no output)</pre>` : ""}
319
+ </div>
320
+ `;
321
+
322
+ div.appendChild(block);
323
+ pane.messagesDiv.appendChild(div);
324
+ pane.currentAssistantMsg = null;
325
+ scrollToBottom(pane);
326
+ }
327
+
328
+ export function renderMessagesIntoPane(messages, pane) {
329
+ pane.messagesDiv.innerHTML = "";
330
+ pane.currentAssistantMsg = null;
331
+ // Reset streaming counter — we're loading saved messages, not streaming
332
+ setState("streamingCharCount", 0);
333
+ if (!messages || messages.length === 0) {
334
+ showWhalyPlaceholder(pane);
335
+ return;
336
+ }
337
+ for (const msg of messages) {
338
+ const data = JSON.parse(msg.content);
339
+ switch (msg.role) {
340
+ case "user": {
341
+ // Extract file paths from saved <file path="..."> blocks
342
+ const filePathMatches = (data.text || "").match(/<file path="([^"]+)">/g);
343
+ const savedFilePaths = filePathMatches
344
+ ? filePathMatches.map(m => m.match(/<file path="([^"]+)">/)[1])
345
+ : [];
346
+ // Show only the user's actual text, not the file content blocks
347
+ const cleanText = savedFilePaths.length > 0
348
+ ? (data.text || "").replace(/<file path="[^"]*">[\s\S]*?<\/file>\s*/g, "").trim()
349
+ : (data.text || "");
350
+ addUserMessage(cleanText, pane, data.images || [], savedFilePaths);
351
+ break;
352
+ }
353
+ case "assistant":
354
+ appendAssistantText(data.text, pane);
355
+ break;
356
+ case "tool":
357
+ appendToolIndicator(data.name, data.input, pane, data.id, false);
358
+ break;
359
+ case "tool_result":
360
+ appendToolResult(data.toolUseId, data.content, data.isError, pane);
361
+ break;
362
+ case "result":
363
+ addResultSummary(data, pane);
364
+ break;
365
+ case "error": {
366
+ const errorParts = [];
367
+ if (data.subtype) errorParts.push(`[${data.subtype}]`);
368
+ if (data.error) errorParts.push(data.error);
369
+ if (data.cost_usd != null) errorParts.push(`$${data.cost_usd.toFixed(4)}`);
370
+ if (data.model) errorParts.push(data.model);
371
+ addStatus(errorParts.join(" \u00b7 ") || "Error", true, pane);
372
+ break;
373
+ }
374
+ case "aborted":
375
+ addStatus("Aborted", true, pane);
376
+ break;
377
+ }
378
+ }
379
+ pane.currentAssistantMsg = null;
380
+ // Hide token counter and reset — loading saved messages shouldn't show streaming stats
381
+ setState("streamingCharCount", 0);
382
+ if ($.streamingTokens) $.streamingTokens.classList.add("hidden");
383
+ if ($.streamingTokensSep) $.streamingTokensSep.classList.add("hidden");
384
+ highlightCodeBlocks(pane.messagesDiv);
385
+ addCopyButtons(pane.messagesDiv);
386
+ renderMermaidBlocks(pane.messagesDiv);
387
+ }
@@ -0,0 +1,20 @@
1
+ // Model selector — localStorage persistence + getter
2
+ import { $ } from '../core/dom.js';
3
+
4
+ const STORAGE_KEY = 'claudeck-model';
5
+
6
+ export function getSelectedModel() {
7
+ return $.modelSelect?.value || '';
8
+ }
9
+
10
+ function init() {
11
+ const saved = localStorage.getItem(STORAGE_KEY);
12
+ if (saved && $.modelSelect) {
13
+ $.modelSelect.value = saved;
14
+ }
15
+ $.modelSelect?.addEventListener('change', () => {
16
+ localStorage.setItem(STORAGE_KEY, $.modelSelect.value);
17
+ });
18
+ }
19
+
20
+ init();
@@ -0,0 +1,232 @@
1
+ // Browser push notifications
2
+ import { getState, setState } from '../core/store.js';
3
+ import { registerCommand } from './commands.js';
4
+
5
+ const STORAGE_KEY = 'claudeck-notifications';
6
+ const SOUND_KEY = 'claudeck-notifications-sound';
7
+
8
+ // ── Audio ──
9
+ let audioCtx = null;
10
+ function getAudioCtx() {
11
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
12
+ if (audioCtx.state === 'suspended') audioCtx.resume();
13
+ return audioCtx;
14
+ }
15
+ // Unlock AudioContext on first user interaction (browser autoplay policy)
16
+ document.addEventListener('click', () => getAudioCtx(), { once: true });
17
+ document.addEventListener('keydown', () => getAudioCtx(), { once: true });
18
+
19
+ function playNotificationSound() {
20
+ try {
21
+ const ctx = getAudioCtx();
22
+ const now = ctx.currentTime;
23
+ // Two-tone chime: C5 → E5
24
+ [[523, 0], [659, 0.15]].forEach(([freq, offset]) => {
25
+ const osc = ctx.createOscillator();
26
+ const gain = ctx.createGain();
27
+ osc.connect(gain);
28
+ gain.connect(ctx.destination);
29
+ osc.frequency.value = freq;
30
+ osc.type = 'sine';
31
+ gain.gain.setValueAtTime(0.25, now + offset);
32
+ gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.3);
33
+ osc.start(now + offset);
34
+ osc.stop(now + offset + 0.3);
35
+ });
36
+ } catch { /* audio unavailable */ }
37
+ }
38
+
39
+ export function isNotificationSoundEnabled() {
40
+ return localStorage.getItem(SOUND_KEY) !== '0';
41
+ }
42
+
43
+ /**
44
+ * Request notification permission from the browser.
45
+ * Returns true if granted.
46
+ */
47
+ export async function requestNotificationPermission() {
48
+ if (!('Notification' in window)) return false;
49
+ if (Notification.permission === 'granted') return true;
50
+ if (Notification.permission === 'denied') return false;
51
+ const result = await Notification.requestPermission();
52
+ return result === 'granted';
53
+ }
54
+
55
+ /**
56
+ * Check if notifications are supported and enabled by user preference.
57
+ */
58
+ export function isNotificationsEnabled() {
59
+ if (!('Notification' in window)) return false;
60
+ if (Notification.permission !== 'granted') return false;
61
+ return getState('notificationsEnabled');
62
+ }
63
+
64
+ /**
65
+ * Send a browser notification (only when tab/app is not focused).
66
+ * Uses ServiceWorker.showNotification() for PWA support — works in
67
+ * standalone mode, background tabs, and regular browser tabs.
68
+ * Falls back to `new Notification()` if SW is unavailable.
69
+ * @param {string} title
70
+ * @param {string} body
71
+ * @param {string} [tag] - dedup tag (same tag replaces previous)
72
+ */
73
+ export async function sendNotification(title, body, tag) {
74
+ if (!isNotificationsEnabled()) return;
75
+ if (document.hasFocus()) return;
76
+
77
+ // Play sound (works even when tab is unfocused, as long as page is loaded)
78
+ if (isNotificationSoundEnabled()) playNotificationSound();
79
+
80
+ const opts = {
81
+ body,
82
+ tag: tag || undefined,
83
+ icon: '/icons/icon-192.png',
84
+ silent: true, // suppress OS sound — we play our own
85
+ };
86
+
87
+ // Prefer SW-based notification (works in PWA standalone + background)
88
+ if ('serviceWorker' in navigator) {
89
+ try {
90
+ const reg = await navigator.serviceWorker.ready;
91
+ await reg.showNotification(title, opts);
92
+ return;
93
+ } catch { /* fall through to basic Notification */ }
94
+ }
95
+
96
+ // Fallback for browsers without active SW
97
+ const notification = new Notification(title, opts);
98
+ notification.addEventListener('click', () => {
99
+ window.focus();
100
+ notification.close();
101
+ });
102
+ }
103
+
104
+ // ── Web Push helpers ──
105
+
106
+ function urlBase64ToUint8Array(base64String) {
107
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
108
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
109
+ const raw = atob(base64);
110
+ const arr = new Uint8Array(raw.length);
111
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
112
+ return arr;
113
+ }
114
+
115
+ async function subscribeToPush() {
116
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
117
+ try {
118
+ const res = await fetch('/api/notifications/vapid-public-key');
119
+ if (!res.ok) return;
120
+ const { key } = await res.json();
121
+
122
+ const reg = await navigator.serviceWorker.ready;
123
+ const subscription = await reg.pushManager.subscribe({
124
+ userVisibleOnly: true,
125
+ applicationServerKey: urlBase64ToUint8Array(key),
126
+ });
127
+
128
+ const sub = subscription.toJSON();
129
+ await fetch('/api/notifications/subscribe', {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({ endpoint: sub.endpoint, keys: sub.keys }),
133
+ });
134
+ } catch (err) {
135
+ console.warn('Push subscription failed:', err);
136
+ }
137
+ }
138
+
139
+ async function unsubscribeFromPush() {
140
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
141
+ try {
142
+ const reg = await navigator.serviceWorker.ready;
143
+ const subscription = await reg.pushManager.getSubscription();
144
+ if (!subscription) return;
145
+
146
+ const endpoint = subscription.endpoint;
147
+ await subscription.unsubscribe();
148
+
149
+ await fetch('/api/notifications/unsubscribe', {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify({ endpoint }),
153
+ });
154
+ } catch (err) {
155
+ console.warn('Push unsubscribe failed:', err);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Toggle notifications on/off. Requests permission if enabling for the first time.
161
+ */
162
+ export async function toggleNotifications() {
163
+ const current = getState('notificationsEnabled');
164
+ if (!current) {
165
+ const granted = await requestNotificationPermission();
166
+ if (!granted) return false;
167
+ setState('notificationsEnabled', true);
168
+ localStorage.setItem(STORAGE_KEY, '1');
169
+ await subscribeToPush();
170
+ return true;
171
+ } else {
172
+ setState('notificationsEnabled', false);
173
+ localStorage.setItem(STORAGE_KEY, '0');
174
+ await unsubscribeFromPush();
175
+ return false;
176
+ }
177
+ }
178
+
179
+ function updateLabel() {
180
+ const label = document.getElementById('notifications-label');
181
+ if (label) {
182
+ label.textContent = getState('notificationsEnabled') ? 'Notifications (on)' : 'Notifications';
183
+ }
184
+ }
185
+
186
+ // ── Listen for SW messages (push-triggered sound) ──
187
+ if ('serviceWorker' in navigator) {
188
+ navigator.serviceWorker.addEventListener('message', (event) => {
189
+ if (event.data?.type === 'play-notification-sound' && isNotificationSoundEnabled()) {
190
+ playNotificationSound();
191
+ }
192
+ });
193
+ }
194
+
195
+ // ── Init ──
196
+ function init() {
197
+ // Restore preference
198
+ const saved = localStorage.getItem(STORAGE_KEY);
199
+ if (saved === '1' && Notification.permission === 'granted') {
200
+ setState('notificationsEnabled', true);
201
+ // Re-subscribe to push on load (ensures server has current subscription)
202
+ subscribeToPush();
203
+ }
204
+
205
+ // Wire up toggle button
206
+ const btn = document.getElementById('notifications-toggle-btn');
207
+ if (btn) {
208
+ btn.addEventListener('click', async () => {
209
+ await toggleNotifications();
210
+ updateLabel();
211
+ });
212
+ }
213
+
214
+ updateLabel();
215
+ }
216
+
217
+ // ── Commands ──
218
+ registerCommand('notifications', {
219
+ category: 'app',
220
+ description: 'Toggle browser notifications',
221
+ async execute(args, pane) {
222
+ const { addStatus } = await import('./messages.js');
223
+ const enabled = await toggleNotifications();
224
+ addStatus(
225
+ enabled ? 'Notifications enabled' : 'Notifications disabled',
226
+ false,
227
+ pane
228
+ );
229
+ },
230
+ });
231
+
232
+ init();