claude-relay 2.2.3 → 2.3.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.
@@ -11,10 +11,12 @@ var slashFiltered = [];
11
11
  var isComposing = false;
12
12
  var isRemoteInput = false;
13
13
 
14
- var builtinCommands = [
14
+ export var builtinCommands = [
15
15
  { name: "clear", desc: "Clear conversation" },
16
+ { name: "context", desc: "Context window usage" },
16
17
  { name: "rewind", desc: "Toggle rewind mode" },
17
18
  { name: "usage", desc: "Toggle usage panel" },
19
+ { name: "status", desc: "Process status and resource usage" },
18
20
  ];
19
21
 
20
22
  // --- Send ---
@@ -25,10 +27,12 @@ export function sendMessage() {
25
27
  hideSlashMenu();
26
28
 
27
29
  if (text === "/clear") {
28
- ctx.messagesEl.innerHTML = "";
29
30
  ctx.inputEl.value = "";
30
31
  clearPendingImages();
31
32
  autoResize();
33
+ if (ctx.ws && ctx.connected) {
34
+ ctx.ws.send(JSON.stringify({ type: "new_session" }));
35
+ }
32
36
  return;
33
37
  }
34
38
 
@@ -44,6 +48,14 @@ export function sendMessage() {
44
48
  return;
45
49
  }
46
50
 
51
+ if (text === "/context") {
52
+ ctx.inputEl.value = "";
53
+ clearPendingImages();
54
+ autoResize();
55
+ if (ctx.toggleContextPanel) ctx.toggleContextPanel();
56
+ return;
57
+ }
58
+
47
59
  if (text === "/usage") {
48
60
  ctx.inputEl.value = "";
49
61
  clearPendingImages();
@@ -52,6 +64,14 @@ export function sendMessage() {
52
64
  return;
53
65
  }
54
66
 
67
+ if (text === "/status") {
68
+ ctx.inputEl.value = "";
69
+ clearPendingImages();
70
+ autoResize();
71
+ if (ctx.toggleStatusPanel) ctx.toggleStatusPanel();
72
+ return;
73
+ }
74
+
55
75
  if (!ctx.connected) {
56
76
  ctx.addSystemMessage("Not connected — message not sent.", true);
57
77
  return;
@@ -81,6 +101,67 @@ export function autoResize() {
81
101
  ctx.inputEl.style.height = Math.min(ctx.inputEl.scrollHeight, 120) + "px";
82
102
  }
83
103
 
104
+ // --- File path extraction from clipboard ---
105
+ function extractFilePaths(cd) {
106
+ var paths = [];
107
+
108
+ // 1. Check text/uri-list for file:// URIs (Finder on some browsers)
109
+ var uriList = cd.getData("text/uri-list");
110
+ if (uriList) {
111
+ var lines = uriList.split(/\r?\n/);
112
+ for (var i = 0; i < lines.length; i++) {
113
+ var line = lines[i].trim();
114
+ if (line && !line.startsWith("#") && line.startsWith("file://")) {
115
+ paths.push(decodeURIComponent(line.replace("file://", "")));
116
+ }
117
+ }
118
+ if (paths.length > 0) return paths;
119
+ }
120
+
121
+ // 2. Check if text/plain looks like file path(s) while files are present
122
+ // (Finder Cmd+C puts filename in text/plain, Cmd+Option+C puts full path)
123
+ if (cd.files && cd.files.length > 0) {
124
+ var plainText = cd.getData("text/plain");
125
+ if (plainText) {
126
+ var textLines = plainText.split(/\r?\n/).filter(function (l) { return l.trim(); });
127
+ for (var i = 0; i < textLines.length; i++) {
128
+ var p = textLines[i].trim();
129
+ if (p.startsWith("/") || p.startsWith("~")) {
130
+ paths.push(p);
131
+ }
132
+ }
133
+ if (paths.length > 0) return paths;
134
+ }
135
+ // 3. Fallback: files present but no path in text, use filenames
136
+ for (var i = 0; i < cd.files.length; i++) {
137
+ var f = cd.files[i];
138
+ if (f.name && f.type.indexOf("image/") !== 0) {
139
+ paths.push(f.name);
140
+ }
141
+ }
142
+ }
143
+
144
+ return paths;
145
+ }
146
+
147
+ // --- Insert text at cursor in textarea ---
148
+ function insertTextAtCursor(text) {
149
+ var el = ctx.inputEl;
150
+ el.focus();
151
+ var start = el.selectionStart;
152
+ var end = el.selectionEnd;
153
+ var before = el.value.substring(0, start);
154
+ var after = el.value.substring(end);
155
+ // Add space before if cursor is right after non-space text
156
+ if (before.length > 0 && before[before.length - 1] !== " " && before[before.length - 1] !== "\n") {
157
+ text = " " + text;
158
+ }
159
+ el.value = before + text + after;
160
+ el.selectionStart = el.selectionEnd = start + text.length;
161
+ autoResize();
162
+ sendInputSync();
163
+ }
164
+
84
165
  // --- Image paste ---
85
166
  function addPendingImage(dataUrl) {
86
167
  var commaIdx = dataUrl.indexOf(",");
@@ -125,6 +206,9 @@ function renderInputPreviews() {
125
206
  wrap.className = "image-preview-thumb";
126
207
  var img = document.createElement("img");
127
208
  img.src = "data:" + pendingImages[idx].mediaType + ";base64," + pendingImages[idx].data;
209
+ img.addEventListener("click", function () {
210
+ if (ctx.showImageModal) ctx.showImageModal(this.src);
211
+ });
128
212
  var removeBtn = document.createElement("button");
129
213
  removeBtn.className = "image-preview-remove";
130
214
  removeBtn.innerHTML = iconHtml("x");
@@ -385,6 +469,16 @@ export function initInput(_ctx) {
385
469
  }
386
470
  }
387
471
 
472
+ // File path paste: detect file:// URIs or Finder file references
473
+ if (!found) {
474
+ var filePaths = extractFilePaths(cd);
475
+ if (filePaths.length > 0) {
476
+ e.preventDefault();
477
+ insertTextAtCursor(filePaths.join("\n"));
478
+ found = true;
479
+ }
480
+ }
481
+
388
482
  // Long text paste → pasted chip
389
483
  if (!found) {
390
484
  var pastedText = cd.getData("text/plain");
@@ -137,6 +137,7 @@ export function initNotifications(_ctx) {
137
137
  var footerBtn = $("sidebar-footer-btn");
138
138
  var footerMenu = $("sidebar-footer-menu");
139
139
  var footerUpdateCheck = $("footer-update-check");
140
+ var footerStatus = $("footer-status");
140
141
  if (!footerBtn || !footerMenu) return;
141
142
 
142
143
  footerBtn.addEventListener("click", function (e) {
@@ -195,6 +196,14 @@ export function initNotifications(_ctx) {
195
196
  }, 2000);
196
197
  });
197
198
  }
199
+
200
+ if (footerStatus) {
201
+ footerStatus.addEventListener("click", function (e) {
202
+ e.stopPropagation();
203
+ footerMenu.classList.add("hidden");
204
+ if (ctx.toggleStatusPanel) ctx.toggleStatusPanel();
205
+ });
206
+ }
198
207
  })();
199
208
 
200
209
  // --- Onboarding banner (HTTPS / Push) ---
@@ -371,14 +380,20 @@ export function initNotifications(_ctx) {
371
380
  }
372
381
 
373
382
  function sendPushSubscription(sub) {
383
+ var prevEndpoint = localStorage.getItem("push-endpoint");
374
384
  window._pushSubscription = sub;
385
+ localStorage.setItem("push-endpoint", sub.endpoint);
375
386
  var json = sub.toJSON();
387
+ var payload = { subscription: json };
388
+ if (prevEndpoint && prevEndpoint !== sub.endpoint) {
389
+ payload.replaceEndpoint = prevEndpoint;
390
+ }
376
391
  if (ctx.ws && ctx.ws.readyState === 1) {
377
- ctx.ws.send(JSON.stringify({ type: "push_subscribe", subscription: json }));
392
+ ctx.ws.send(JSON.stringify({ type: "push_subscribe", subscription: json, replaceEndpoint: payload.replaceEndpoint || null }));
378
393
  } else {
379
394
  fetch(basePath + "api/push-subscribe", {
380
395
  method: "POST", headers: { "Content-Type": "application/json" },
381
- credentials: "same-origin", body: JSON.stringify(json),
396
+ credentials: "same-origin", body: JSON.stringify(payload),
382
397
  });
383
398
  }
384
399
  }
@@ -397,6 +412,15 @@ export function initNotifications(_ctx) {
397
412
  }).then(function (sub) {
398
413
  sendPushSubscription(sub);
399
414
  localStorage.setItem("notif-push", "1");
415
+ hideOnboarding();
416
+ localStorage.setItem("onboarding-dismissed", "1");
417
+ // Show a welcome notification so the user knows it works
418
+ navigator.serviceWorker.ready.then(function (reg) {
419
+ reg.showNotification("\ud83c\udf89 Welcome to Claude Relay!", {
420
+ body: "\ud83d\udd14 You\u2019ll be notified when Claude responds.",
421
+ tag: "claude-welcome",
422
+ });
423
+ }).catch(function () {});
400
424
  }).catch(function () {
401
425
  notifTogglePush.checked = false;
402
426
  localStorage.setItem("notif-push", "0");
@@ -454,6 +478,7 @@ export function initNotifications(_ctx) {
454
478
  window._pushSubscription = sub;
455
479
  notifTogglePush.checked = true;
456
480
  sendPushSubscription(sub);
481
+ hideOnboarding();
457
482
  } else if (serverKey && localStorage.getItem("notif-push") === "1") {
458
483
  // Had push enabled but subscription is gone (VAPID key change), re-subscribe
459
484
  var raw = atob(serverKey.replace(/-/g, "+").replace(/_/g, "/"));
@@ -474,7 +499,8 @@ export function initNotifications(_ctx) {
474
499
  // Skip if setup was just completed (setup-done flag)
475
500
  var isStandalone = window.matchMedia("(display-mode:standalone)").matches || navigator.standalone;
476
501
  if (isStandalone && !localStorage.getItem("setup-done")) {
477
- location.href = "/setup";
502
+ var isTailscale = /^100\./.test(location.hostname);
503
+ location.href = "/setup" + (isTailscale ? "" : "?mode=lan");
478
504
  return;
479
505
  }
480
506
  // Browser: show onboarding banner
@@ -1,5 +1,14 @@
1
1
  import { copyToClipboard } from './utils.js';
2
2
 
3
+ function getShareUrl() {
4
+ var url = window.location.href;
5
+ var h = window.location.hostname;
6
+ if ((h === "localhost" || h === "127.0.0.1") && window.__lanHost) {
7
+ url = url.replace(h + ":" + window.location.port, window.__lanHost);
8
+ }
9
+ return url;
10
+ }
11
+
3
12
  export function initQrCode() {
4
13
  var $ = function (id) { return document.getElementById(id); };
5
14
  var qrBtn = $("qr-btn");
@@ -9,7 +18,7 @@ export function initQrCode() {
9
18
 
10
19
  qrBtn.addEventListener("click", function (e) {
11
20
  e.stopPropagation();
12
- var url = window.location.href;
21
+ var url = getShareUrl();
13
22
 
14
23
  // Use Web Share API if available
15
24
  if (navigator.share) {
@@ -30,7 +39,7 @@ export function initQrCode() {
30
39
 
31
40
  // click URL to copy
32
41
  qrUrl.addEventListener("click", function () {
33
- var url = window.location.href;
42
+ var url = getShareUrl();
34
43
  copyToClipboard(url).then(function () {
35
44
  qrUrl.innerHTML = "Copied!";
36
45
  qrUrl.classList.add("copied");
@@ -6,7 +6,8 @@ var rewindMode = false;
6
6
  var pendingRewindUuid = null;
7
7
  var rewindBannerEl = null;
8
8
  var rewindScrollHandler = null;
9
- var rewindModal, rewindSummary, rewindFilesList, rewindConfirmBtn, rewindCancelBtn;
9
+ var rewindModal, rewindSummary, rewindFilesList, rewindConfirmBtn, rewindCancelBtn, rewindModeOptions;
10
+ var cachedPreview = null;
10
11
 
11
12
  export function setRewindMode(on) {
12
13
  rewindMode = on;
@@ -64,12 +65,53 @@ function initiateRewind(uuid) {
64
65
  }
65
66
  }
66
67
 
68
+ function getSelectedMode() {
69
+ if (!rewindModeOptions) return "both";
70
+ var checked = rewindModeOptions.querySelector('input[name="rewind-mode"]:checked');
71
+ return checked ? checked.value : "both";
72
+ }
73
+
74
+ function updateSummaryForMode() {
75
+ if (!cachedPreview) return;
76
+ var mode = getSelectedMode();
77
+ var fileCount = cachedPreview.fileCount;
78
+ var insertions = cachedPreview.insertions;
79
+ var deletions = cachedPreview.deletions;
80
+
81
+ if (mode === "chat") {
82
+ rewindSummary.textContent = "Conversation will be rewound. Files will not be changed.";
83
+ rewindFilesList.style.display = "none";
84
+ } else if (fileCount > 0) {
85
+ var summary = fileCount + " file" + (fileCount !== 1 ? "s" : "") + " will be restored.";
86
+ if (insertions || deletions) summary += " (+" + insertions + " / -" + deletions + " lines)";
87
+ if (mode === "files") summary += " Conversation will not be changed.";
88
+ rewindSummary.textContent = summary;
89
+ rewindFilesList.style.display = "";
90
+ } else {
91
+ if (mode === "files") {
92
+ rewindSummary.textContent = "No file changes to restore.";
93
+ } else {
94
+ rewindSummary.textContent = "No file changes to restore. Conversation will be rewound.";
95
+ }
96
+ rewindFilesList.style.display = "none";
97
+ }
98
+ }
99
+
67
100
  export function showRewindModal(data) {
68
101
  var p = data.preview || data;
69
102
  var filePaths = p.filesChanged || p.filePaths || p.files || [];
70
103
  var fileCount = filePaths.length;
71
104
  var insertions = p.insertions || 0;
72
105
  var deletions = p.deletions || 0;
106
+
107
+ cachedPreview = { fileCount: fileCount, insertions: insertions, deletions: deletions };
108
+
109
+ // Reset radio to default
110
+ if (rewindModeOptions) {
111
+ var defaultRadio = rewindModeOptions.querySelector('input[value="both"]');
112
+ if (defaultRadio) defaultRadio.checked = true;
113
+ }
114
+
73
115
  if (fileCount > 0) {
74
116
  var summary = fileCount + " file" + (fileCount !== 1 ? "s" : "") + " will be restored.";
75
117
  if (insertions || deletions) summary += " (+" + insertions + " / -" + deletions + " lines)";
@@ -264,6 +306,12 @@ export function initRewind(_ctx) {
264
306
  rewindFilesList = ctx.$("rewind-files-list");
265
307
  rewindConfirmBtn = ctx.$("rewind-confirm");
266
308
  rewindCancelBtn = ctx.$("rewind-cancel");
309
+ rewindModeOptions = ctx.$("rewind-mode-options");
310
+
311
+ // Update summary when rewind mode radio changes
312
+ if (rewindModeOptions) {
313
+ rewindModeOptions.addEventListener("change", updateSummaryForMode);
314
+ }
267
315
 
268
316
  // Click on user message bubble to rewind
269
317
  ctx.messagesEl.addEventListener("click", function(e) {
@@ -275,7 +323,8 @@ export function initRewind(_ctx) {
275
323
 
276
324
  rewindConfirmBtn.addEventListener("click", function() {
277
325
  if (pendingRewindUuid && ctx.ws && ctx.connected) {
278
- ctx.ws.send(JSON.stringify({ type: "rewind_execute", uuid: pendingRewindUuid }));
326
+ var mode = getSelectedMode();
327
+ ctx.ws.send(JSON.stringify({ type: "rewind_execute", uuid: pendingRewindUuid, mode: mode }));
279
328
  }
280
329
  hideRewindModal();
281
330
  });
@@ -1,7 +1,8 @@
1
1
  import { escapeHtml } from './utils.js';
2
2
  import { iconHtml, refreshIcons, randomThinkingVerb } from './icons.js';
3
3
  import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks } from './markdown.js';
4
- import { renderDiffPre } from './rewind.js';
4
+ import { renderUnifiedDiff, renderSplitDiff, renderPatchDiff } from './diff.js';
5
+ import { openFile } from './filebrowser.js';
5
6
 
6
7
  var ctx;
7
8
 
@@ -813,7 +814,28 @@ export function updateToolExecuting(id, name, input) {
813
814
  if (!tool) return;
814
815
 
815
816
  tool.input = input;
816
- tool.el.querySelector(".tool-desc").textContent = toolSummary(name, input);
817
+ var descEl = tool.el.querySelector(".tool-desc");
818
+ descEl.textContent = toolSummary(name, input);
819
+
820
+ // Make file path clickable for Read/Edit/Write tools
821
+ var filePath = input && input.file_path;
822
+ if (filePath && (name === "Read" || name === "Edit" || name === "Write")) {
823
+ descEl.classList.add("tool-desc-link");
824
+ descEl.dataset.filePath = filePath;
825
+ descEl.insertAdjacentHTML("beforeend", '<span class="tool-desc-link-icon">' + iconHtml("external-link") + '</span>');
826
+ refreshIcons();
827
+ (function (toolName, toolInput) {
828
+ descEl.onclick = function (e) {
829
+ e.stopPropagation();
830
+ if (toolName === "Edit" && toolInput && (toolInput.old_string || toolInput.new_string)) {
831
+ openFile(filePath, { diff: { oldStr: toolInput.old_string || "", newStr: toolInput.new_string || "" } });
832
+ } else {
833
+ openFile(filePath);
834
+ }
835
+ };
836
+ })(name, input);
837
+ }
838
+
817
839
  ctx.setActivity(toolActivityText(name, input));
818
840
 
819
841
  var subtitleText = tool.el.querySelector(".tool-subtitle-text");
@@ -822,112 +844,26 @@ export function updateToolExecuting(id, name, input) {
822
844
  ctx.scrollToBottom();
823
845
  }
824
846
 
825
- function buildUnifiedDiff(oldLines, newLines) {
826
- var body = document.createElement("div");
827
- body.className = "edit-diff-body";
828
-
829
- var gutter = document.createElement("pre");
830
- gutter.className = "edit-diff-gutter";
831
-
832
- var content = document.createElement("pre");
833
- content.className = "edit-diff-content";
834
-
835
- var gutterLines = [];
836
-
837
- for (var i = 0; i < oldLines.length; i++) {
838
- gutterLines.push(String(i + 1));
839
- var span = document.createElement("span");
840
- span.className = "diff-del";
841
- span.textContent = "- " + oldLines[i];
842
- content.appendChild(span);
843
- content.appendChild(document.createTextNode("\n"));
844
- }
845
- for (var i = 0; i < newLines.length; i++) {
846
- gutterLines.push(String(i + 1));
847
- var span = document.createElement("span");
848
- span.className = "diff-add";
849
- span.textContent = "+ " + newLines[i];
850
- content.appendChild(span);
851
- if (i < newLines.length - 1) content.appendChild(document.createTextNode("\n"));
852
- }
853
-
854
- gutter.textContent = gutterLines.join("\n");
855
- body.appendChild(gutter);
856
- body.appendChild(content);
857
- return body;
858
- }
859
-
860
- function buildSplitDiff(oldLines, newLines) {
861
- var body = document.createElement("div");
862
- body.className = "edit-diff-body edit-diff-split";
863
-
864
- var leftGutter = document.createElement("pre");
865
- leftGutter.className = "edit-diff-gutter";
866
- var leftContent = document.createElement("pre");
867
- leftContent.className = "edit-diff-content edit-diff-side-old";
868
- var rightGutter = document.createElement("pre");
869
- rightGutter.className = "edit-diff-gutter";
870
- var rightContent = document.createElement("pre");
871
- rightContent.className = "edit-diff-content edit-diff-side-new";
872
-
873
- var maxLen = Math.max(oldLines.length, newLines.length);
874
- var leftNums = [];
875
- var rightNums = [];
876
-
877
- for (var i = 0; i < maxLen; i++) {
878
- if (i < oldLines.length) {
879
- leftNums.push(String(i + 1));
880
- var span = document.createElement("span");
881
- span.className = "diff-del";
882
- span.textContent = oldLines[i];
883
- leftContent.appendChild(span);
884
- } else {
885
- leftNums.push("");
886
- leftContent.appendChild(document.createTextNode(""));
887
- }
888
- if (i < oldLines.length - 1 || (i >= oldLines.length && i < maxLen - 1)) {
889
- leftContent.appendChild(document.createTextNode("\n"));
890
- }
891
-
892
- if (i < newLines.length) {
893
- rightNums.push(String(i + 1));
894
- var span = document.createElement("span");
895
- span.className = "diff-add";
896
- span.textContent = newLines[i];
897
- rightContent.appendChild(span);
898
- } else {
899
- rightNums.push("");
900
- rightContent.appendChild(document.createTextNode(""));
901
- }
902
- if (i < newLines.length - 1 || (i >= newLines.length && i < maxLen - 1)) {
903
- rightContent.appendChild(document.createTextNode("\n"));
904
- }
905
- }
906
-
907
- leftGutter.textContent = leftNums.join("\n");
908
- rightGutter.textContent = rightNums.join("\n");
909
-
910
- body.appendChild(leftGutter);
911
- body.appendChild(leftContent);
912
- body.appendChild(rightGutter);
913
- body.appendChild(rightContent);
914
- return body;
915
- }
916
-
917
847
  function renderEditDiff(oldStr, newStr, filePath) {
918
848
  var wrapper = document.createElement("div");
919
849
  wrapper.className = "edit-diff";
920
-
921
- var oldLines = oldStr.split("\n");
922
- var newLines = newStr.split("\n");
850
+ var lang = getLanguageFromPath(filePath);
923
851
 
924
852
  // Header with file path and split toggle (desktop only)
925
853
  var header = document.createElement("div");
926
854
  header.className = "edit-diff-header";
927
855
 
928
856
  var pathSpan = document.createElement("span");
929
- pathSpan.className = "edit-diff-path";
857
+ pathSpan.className = "edit-diff-path edit-diff-path-link";
930
858
  pathSpan.textContent = filePath || "";
859
+ if (filePath) {
860
+ (function (fp, os, ns) {
861
+ pathSpan.addEventListener("click", function (e) {
862
+ e.stopPropagation();
863
+ openFile(fp, { diff: { oldStr: os || "", newStr: ns || "" } });
864
+ });
865
+ })(filePath, oldStr, newStr);
866
+ }
931
867
  header.appendChild(pathSpan);
932
868
 
933
869
  var isMobile = "ontouchstart" in window;
@@ -952,27 +888,29 @@ function renderEditDiff(oldStr, newStr, filePath) {
952
888
 
953
889
  wrapper.appendChild(header);
954
890
 
955
- var currentBody = buildUnifiedDiff(oldLines, newLines);
891
+ var currentBody = renderUnifiedDiff(oldStr, newStr, lang);
956
892
  wrapper.appendChild(currentBody);
957
893
 
958
- unifiedBtn.addEventListener("click", function () {
894
+ unifiedBtn.addEventListener("click", function (e) {
895
+ e.stopPropagation();
959
896
  if (!isSplit) return;
960
897
  isSplit = false;
961
898
  unifiedBtn.classList.add("active");
962
899
  splitBtn.classList.remove("active");
963
900
  wrapper.removeChild(currentBody);
964
- currentBody = buildUnifiedDiff(oldLines, newLines);
901
+ currentBody = renderUnifiedDiff(oldStr, newStr, lang);
965
902
  wrapper.appendChild(currentBody);
966
903
  refreshIcons();
967
904
  });
968
905
 
969
- splitBtn.addEventListener("click", function () {
906
+ splitBtn.addEventListener("click", function (e) {
907
+ e.stopPropagation();
970
908
  if (isSplit) return;
971
909
  isSplit = true;
972
910
  splitBtn.classList.add("active");
973
911
  unifiedBtn.classList.remove("active");
974
912
  wrapper.removeChild(currentBody);
975
- currentBody = buildSplitDiff(oldLines, newLines);
913
+ currentBody = renderSplitDiff(oldStr, newStr, lang);
976
914
  wrapper.appendChild(currentBody);
977
915
  refreshIcons();
978
916
  });
@@ -1075,7 +1013,8 @@ export function updateToolResult(id, content, isError) {
1075
1013
  if (hasEditDiff) {
1076
1014
  resultBlock.appendChild(renderEditDiff(tool.input.old_string, tool.input.new_string, tool.input.file_path));
1077
1015
  } else if (!isError && isDiffContent(displayContent)) {
1078
- resultBlock.appendChild(renderDiffPre(displayContent));
1016
+ var patchLang = tool.input && tool.input.file_path ? getLanguageFromPath(tool.input.file_path) : null;
1017
+ resultBlock.appendChild(renderPatchDiff(displayContent, patchLang));
1079
1018
  } else if (!isError && tool.name === "Read" && tool.input && tool.input.file_path) {
1080
1019
  var parsed = parseLineNumberedContent(displayContent);
1081
1020
  if (parsed) {
@@ -1,13 +1,21 @@
1
- export function showToast(message) {
1
+ export function showToast(message, level, detail) {
2
2
  var el = document.createElement("div");
3
3
  el.className = "toast";
4
+ if (level) el.classList.add("toast-" + level);
4
5
  el.textContent = message;
6
+ if (detail) {
7
+ var detailEl = document.createElement("div");
8
+ detailEl.style.cssText = "font-size:11px;opacity:0.7;margin-top:4px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
9
+ detailEl.textContent = detail.split("\n")[0];
10
+ el.appendChild(detailEl);
11
+ }
5
12
  document.body.appendChild(el);
6
13
  requestAnimationFrame(function () { el.classList.add("visible"); });
14
+ var duration = level === "warn" ? 5000 : 1500;
7
15
  setTimeout(function () {
8
16
  el.classList.remove("visible");
9
17
  setTimeout(function () { el.remove(); }, 300);
10
- }, 1500);
18
+ }, duration);
11
19
  }
12
20
 
13
21
  export function copyToClipboard(text) {
@@ -6,3 +6,4 @@
6
6
  @import url("css/rewind.css");
7
7
  @import url("css/input.css");
8
8
  @import url("css/filebrowser.css");
9
+ @import url("css/diff.css");
package/lib/public/sw.js CHANGED
@@ -10,6 +10,9 @@ self.addEventListener("push", function (event) {
10
10
  var data = {};
11
11
  try { data = event.data.json(); } catch (e) { return; }
12
12
 
13
+ // Silent validation push, do not show notification
14
+ if (data.type === "test") return;
15
+
13
16
  var options = {
14
17
  body: data.body || "",
15
18
  tag: data.tag || "claude-relay",
@@ -31,9 +34,12 @@ self.addEventListener("push", function (event) {
31
34
 
32
35
  event.waitUntil(
33
36
  self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clientList) {
34
- // Skip notification if app is focused (user is already looking at it)
35
- for (var i = 0; i < clientList.length; i++) {
36
- if (clientList[i].focused || clientList[i].visibilityState === "visible") return;
37
+ // Always show permission requests, questions, and errors
38
+ // Only suppress "done" notifications when app is in foreground
39
+ if (data.type !== "permission_request" && data.type !== "ask_user" && data.type !== "error") {
40
+ for (var i = 0; i < clientList.length; i++) {
41
+ if (clientList[i].focused || clientList[i].visibilityState === "visible") return;
42
+ }
37
43
  }
38
44
  return self.registration.showNotification(data.title || "Claude Relay", options);
39
45
  }).catch(function () {})
@@ -44,18 +50,26 @@ self.addEventListener("notificationclick", function (event) {
44
50
  var data = event.notification.data || {};
45
51
  event.notification.close();
46
52
 
47
- // Default click: focus existing window or open new one
48
- // Use the service worker's scope as the base URL for this project
49
- var scopeUrl = self.registration.scope || "/";
53
+ // Build target URL from slug so we open the correct project
54
+ var baseUrl = self.registration.scope || "/";
55
+ var targetUrl = data.slug ? baseUrl + "p/" + data.slug + "/" : baseUrl;
56
+
50
57
  event.waitUntil(
51
58
  self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clientList) {
59
+ // Prefer a client already on the correct project
60
+ for (var i = 0; i < clientList.length; i++) {
61
+ if (clientList[i].url.indexOf(targetUrl) !== -1) {
62
+ return clientList[i].focus();
63
+ }
64
+ }
65
+ // Fall back to any visible client
52
66
  for (var i = 0; i < clientList.length; i++) {
53
67
  if (clientList[i].visibilityState !== "hidden") {
54
68
  return clientList[i].focus();
55
69
  }
56
70
  }
57
71
  if (clientList.length > 0) return clientList[0].focus();
58
- return self.clients.openWindow(scopeUrl);
72
+ return self.clients.openWindow(targetUrl);
59
73
  })
60
74
  );
61
75
  });
package/lib/push.js CHANGED
@@ -75,8 +75,12 @@ function initPush() {
75
75
  })(startupEndpoints[si]);
76
76
  }
77
77
 
78
- function addSubscription(sub) {
78
+ function addSubscription(sub, replaceEndpoint) {
79
79
  if (!sub || !sub.endpoint) return;
80
+ // Remove previous subscription from the same client if endpoint changed
81
+ if (replaceEndpoint && replaceEndpoint !== sub.endpoint) {
82
+ subscriptions.delete(replaceEndpoint);
83
+ }
80
84
  // Store immediately, then validate async. Invalid subs get cleaned on first sendPush.
81
85
  subscriptions.set(sub.endpoint, sub);
82
86
  save();