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
@@ -4,8 +4,8 @@ import { getState, setState } from '../core/store.js';
4
4
  import { CHAT_IDS, BOT_CHAT_ID } from '../core/constants.js';
5
5
  import { on } from '../core/events.js';
6
6
  import { commandRegistry, dismissAutocomplete, handleAutocompleteKeydown, handleSlashAutocomplete, registerCommand } from '../ui/commands.js';
7
- import { addUserMessage, appendAssistantText, appendToolIndicator, appendToolResult, showThinking, removeThinking, addResultSummary, addStatus, showWhalyPlaceholder } from '../ui/messages.js';
8
- import { getPane, panes, _setChatFns } from '../ui/parallel.js';
7
+ import { addUserMessage, appendAssistantText, appendToolIndicator, appendToolResult, showThinking, removeThinking, addResultSummary, addStatus, showWhalyPlaceholder, addSkillUsedMessage } from '../ui/messages.js';
8
+ import { getPane, panes, _setChatFns, _setInputHistoryGetter } from '../ui/parallel.js';
9
9
  import { loadSessions } from './sessions.js';
10
10
  import { loadStats, loadAccountInfo } from './cost-dashboard.js';
11
11
  import { loadProjects } from './projects.js';
@@ -24,10 +24,41 @@ import { getSelectedModel } from '../ui/model-selector.js';
24
24
  import { getMaxTurns } from '../ui/max-turns.js';
25
25
  import { getDisabledTools } from '../ui/disabled-tools.js';
26
26
  import { updateContextGauge, resetContextGauge, loadContextGauge } from '../ui/context-gauge.js';
27
+ import { InputHistory, handleHistoryKeydown } from './input-history.js';
27
28
 
28
29
  // ── "Waiting for input" indicator ──
29
30
  const inputWaitingEl = document.getElementById("input-waiting");
30
31
 
32
+ // ── Input history (message recall) ──
33
+ function historyKey() {
34
+ return "claudeck-input-history-" + ($.projectSelect?.value || "default");
35
+ }
36
+ let inputHistory = new InputHistory(historyKey());
37
+
38
+ $.projectSelect?.addEventListener("change", () => {
39
+ inputHistory = new InputHistory(historyKey());
40
+ // Defer visibility update — updateHistoryButtonVisibility is defined later in this file
41
+ queueMicrotask(() => updateHistoryButtonVisibility());
42
+ });
43
+
44
+ export function getInputHistory() {
45
+ // Re-sync key if it drifted (e.g. project loaded after module init)
46
+ const expected = historyKey();
47
+ if (inputHistory.storageKey !== expected) {
48
+ inputHistory = new InputHistory(expected);
49
+ }
50
+ return inputHistory;
51
+ }
52
+
53
+ // ── Worktree mode toggle ──
54
+ let worktreeMode = false;
55
+ if ($.worktreeBtn) {
56
+ $.worktreeBtn.addEventListener("click", () => {
57
+ worktreeMode = !worktreeMode;
58
+ $.worktreeBtn.classList.toggle("active", worktreeMode);
59
+ });
60
+ }
61
+
31
62
  function isQuestionText(text) {
32
63
  if (!text) return false;
33
64
  // Get the last meaningful line (skip empty lines, code blocks, lists)
@@ -63,6 +94,9 @@ function hideWaitingForInput(pane) {
63
94
 
64
95
  export function sendMessage(pane) {
65
96
  pane = pane || getPane(null);
97
+ // Re-sync history key in case project loaded after module init
98
+ const ek = historyKey();
99
+ if (inputHistory.storageKey !== ek) inputHistory = new InputHistory(ek);
66
100
  const text = pane.messageInput.value.trim();
67
101
  const cwd = $.projectSelect.value;
68
102
 
@@ -76,6 +110,9 @@ export function sendMessage(pane) {
76
110
  if (cmdName === "run" && !cwd) {
77
111
  // /run needs a project, fall through
78
112
  } else {
113
+ inputHistory.add(text);
114
+ inputHistory.reset();
115
+ updateHistoryButtonVisibility();
79
116
  pane.messageInput.value = "";
80
117
  pane.messageInput.style.height = "auto";
81
118
  dismissAutocomplete(pane);
@@ -107,6 +144,8 @@ export function sendMessage(pane) {
107
144
  const [, cmdName, args] = match;
108
145
  const cmd = commandRegistry[cmdName];
109
146
  if (cmd) {
147
+ inputHistory.add(text);
148
+ inputHistory.reset();
110
149
  pane.messageInput.value = "";
111
150
  pane.messageInput.style.height = "auto";
112
151
  dismissAutocomplete(pane);
@@ -129,6 +168,9 @@ export function sendMessage(pane) {
129
168
  const images = getImageAttachments();
130
169
  const filePaths = attachedFiles.map(f => f.path);
131
170
  addUserMessage(text, pane, images, filePaths);
171
+ inputHistory.add(text);
172
+ inputHistory.reset();
173
+ updateHistoryButtonVisibility();
132
174
  pane.messageInput.value = "";
133
175
  pane.messageInput.style.height = "auto";
134
176
  setState("streamingCharCount", 0);
@@ -179,6 +221,14 @@ export function sendMessage(pane) {
179
221
  payload.chatId = pane.chatId;
180
222
  }
181
223
 
224
+ // Worktree confirmation: show approve/reject before sending
225
+ if (worktreeMode) {
226
+ worktreeMode = false;
227
+ $.worktreeBtn?.classList.remove("active");
228
+ showWorktreeConfirmation(ws, payload, pane);
229
+ return;
230
+ }
231
+
182
232
  ws.send(JSON.stringify(payload));
183
233
  showThinking("Connecting to Claude...", pane);
184
234
  }
@@ -220,10 +270,17 @@ export function finishStreamingHandler(pane) {
220
270
  $.sendBtn.disabled = false;
221
271
  $.messageInput.focus();
222
272
  }
273
+
274
+ // Re-render messages from DB so fork buttons appear on the completed turn
275
+ const sid = getState("sessionId");
276
+ if (sid) {
277
+ import('./sessions.js').then(({ loadMessages }) => loadMessages(sid));
278
+ }
223
279
  }
224
280
 
225
281
  // Register the chat functions with parallel.js to break circular dependency
226
282
  _setChatFns({ sendMessage, stopGeneration });
283
+ _setInputHistoryGetter(() => inputHistory);
227
284
 
228
285
  // Render a collapsible memory indicator in the chat
229
286
  function appendMemoryIndicator(memories, pane) {
@@ -347,6 +404,13 @@ function handleServerMessage(msg) {
347
404
  break;
348
405
 
349
406
  case "tool":
407
+ // Detect model-invoked skill usage
408
+ if (msg.name === "Skill" && msg.input?.skill) {
409
+ import('./projects.js').then(({ skillLookup }) => {
410
+ const info = skillLookup.get(msg.input.skill);
411
+ addSkillUsedMessage(msg.input.skill, info?.description || "", pane);
412
+ });
413
+ }
350
414
  appendToolIndicator(msg.name, msg.input, pane, msg.id);
351
415
  showThinking(`Running ${msg.name}...`, pane);
352
416
  break;
@@ -456,9 +520,293 @@ function handleServerMessage(msg) {
456
520
  case "memory_saved":
457
521
  // /remember command response — already handled by "text" message
458
522
  break;
523
+
524
+ case "worktree_created":
525
+ showWorktreeBanner(msg.branchName, msg.baseBranch, pane);
526
+ showThinking(`Working in worktree: ${msg.branchName}...`, pane);
527
+ break;
528
+
529
+ case "worktree_completed":
530
+ removeThinking(pane);
531
+ showWorktreeActions(msg.worktreeId, msg.branchName, msg.stats, pane);
532
+ break;
533
+
534
+ case "worktree_error":
535
+ addStatus(`Worktree failed: ${msg.error} — running on current branch instead`, true, pane);
536
+ break;
537
+ }
538
+ }
539
+
540
+ // ── Worktree UI functions ──────────────────────────────────────────────────
541
+
542
+ const BRANCH_SVG = `<svg width="16" height="16" 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>`;
543
+
544
+ /**
545
+ * Show a persistent banner indicating the agent is working in a worktree.
546
+ * This is clearly visible so the user knows they're NOT on the main branch.
547
+ */
548
+ function showWorktreeBanner(branchName, baseBranch, pane) {
549
+ const container = pane.messagesDiv || $.messagesDiv;
550
+ const banner = document.createElement("div");
551
+ banner.className = "worktree-banner";
552
+ banner.innerHTML = `
553
+ <div class="worktree-banner-content">
554
+ ${BRANCH_SVG}
555
+ <span class="worktree-banner-label">Worktree active</span>
556
+ <code class="worktree-banner-branch">${escapeHtml(branchName)}</code>
557
+ <span class="worktree-banner-base">branched from <strong>${escapeHtml(baseBranch)}</strong></span>
558
+ </div>
559
+ `;
560
+ container.appendChild(banner);
561
+ banner.scrollIntoView({ behavior: "smooth" });
562
+ }
563
+
564
+ /**
565
+ * Show inline confirmation card: "Run in Worktree" or "Use Current Branch".
566
+ * The user's message is already displayed — this card appears below it.
567
+ */
568
+ function showWorktreeConfirmation(ws, payload, pane) {
569
+ const container = pane.messagesDiv || $.messagesDiv;
570
+ const card = document.createElement("div");
571
+ card.className = "worktree-confirm-card";
572
+ card.innerHTML = `
573
+ <div class="wt-confirm-header">
574
+ ${BRANCH_SVG}
575
+ <span>Run this task in an isolated worktree?</span>
576
+ </div>
577
+ <div class="wt-confirm-desc">
578
+ Your working branch stays untouched. You can merge or discard the result after completion.
579
+ </div>
580
+ <div class="wt-confirm-btns">
581
+ <button class="wt-btn-worktree">Run in Worktree</button>
582
+ <button class="wt-btn-current">Use Current Branch</button>
583
+ </div>
584
+ `;
585
+
586
+ function send(useWorktree) {
587
+ if (useWorktree) payload.worktree = true;
588
+ console.log("[worktree] Sending payload with worktree:", payload.worktree, "cwd:", payload.cwd);
589
+ card.remove();
590
+ ws.send(JSON.stringify(payload));
591
+ showThinking(useWorktree ? "Creating worktree..." : "Connecting to Claude...", pane);
592
+ }
593
+
594
+ card.querySelector(".wt-btn-worktree").addEventListener("click", () => send(true));
595
+ card.querySelector(".wt-btn-current").addEventListener("click", () => send(false));
596
+
597
+ container.appendChild(card);
598
+ card.scrollIntoView({ behavior: "smooth" });
599
+ }
600
+
601
+ /**
602
+ * Show inline action card after worktree completes: View Diff / Merge / Discard.
603
+ */
604
+ function showWorktreeActions(worktreeId, branchName, stats, pane) {
605
+ const container = pane.messagesDiv || $.messagesDiv;
606
+ const card = document.createElement("div");
607
+ card.className = "worktree-action-card";
608
+ const statsText = stats ? `+${stats.insertions} -${stats.deletions} in ${stats.files} file(s)` : "";
609
+ card.innerHTML = `
610
+ <div class="worktree-action-header">
611
+ ${BRANCH_SVG}
612
+ <span>Branch: <strong>${escapeHtml(branchName)}</strong></span>
613
+ <span class="worktree-stats">${statsText}</span>
614
+ </div>
615
+ <div class="worktree-action-btns">
616
+ <button class="wt-diff-btn">View Diff</button>
617
+ <button class="wt-merge-btn">Squash Merge</button>
618
+ <button class="wt-discard-btn">Discard</button>
619
+ </div>
620
+ `;
621
+
622
+ const diffBtn = card.querySelector(".wt-diff-btn");
623
+ const mergeBtn = card.querySelector(".wt-merge-btn");
624
+ const discardBtn = card.querySelector(".wt-discard-btn");
625
+
626
+ diffBtn.addEventListener("click", async () => {
627
+ diffBtn.disabled = true;
628
+ diffBtn.textContent = "Loading...";
629
+ try {
630
+ const res = await fetch(`/api/worktrees/${encodeURIComponent(worktreeId)}/diff`);
631
+ const data = await res.json();
632
+ if (data.error) throw new Error(data.error);
633
+ showWorktreeDiffModal(data.diff, branchName);
634
+ } catch (err) {
635
+ addStatus("Diff error: " + err.message, true, pane);
636
+ } finally {
637
+ diffBtn.disabled = false;
638
+ diffBtn.textContent = "View Diff";
639
+ }
640
+ });
641
+
642
+ mergeBtn.addEventListener("click", async () => {
643
+ mergeBtn.disabled = true;
644
+ mergeBtn.textContent = "Merging...";
645
+ discardBtn.disabled = true;
646
+ try {
647
+ const res = await fetch(`/api/worktrees/${encodeURIComponent(worktreeId)}/merge`, {
648
+ method: "POST",
649
+ headers: { "Content-Type": "application/json" },
650
+ body: JSON.stringify({}),
651
+ });
652
+ const data = await res.json();
653
+ if (data.error) throw new Error(data.error);
654
+ card.innerHTML = `<div class="worktree-action-header">${BRANCH_SVG}<span>Merged <strong>${escapeHtml(branchName)}</strong> into current branch</span></div>`;
655
+ card.classList.add("worktree-merged");
656
+ } catch (err) {
657
+ addStatus("Merge error: " + err.message, true, pane);
658
+ mergeBtn.disabled = false;
659
+ mergeBtn.textContent = "Squash Merge";
660
+ discardBtn.disabled = false;
661
+ }
662
+ });
663
+
664
+ discardBtn.addEventListener("click", () => {
665
+ // Replace buttons with confirmation
666
+ const btnsDiv = card.querySelector(".worktree-action-btns");
667
+ btnsDiv.innerHTML = `
668
+ <span class="wt-confirm-label">Discard this worktree? This cannot be undone.</span>
669
+ <button class="wt-confirm-yes">Yes, Discard</button>
670
+ <button class="wt-confirm-no">Cancel</button>
671
+ `;
672
+
673
+ btnsDiv.querySelector(".wt-confirm-no").addEventListener("click", () => {
674
+ btnsDiv.innerHTML = "";
675
+ btnsDiv.appendChild(diffBtn);
676
+ btnsDiv.appendChild(mergeBtn);
677
+ btnsDiv.appendChild(discardBtn);
678
+ });
679
+
680
+ btnsDiv.querySelector(".wt-confirm-yes").addEventListener("click", async () => {
681
+ const yesBtn = btnsDiv.querySelector(".wt-confirm-yes");
682
+ yesBtn.disabled = true;
683
+ yesBtn.textContent = "Discarding...";
684
+ btnsDiv.querySelector(".wt-confirm-no").disabled = true;
685
+ try {
686
+ const res = await fetch(`/api/worktrees/${encodeURIComponent(worktreeId)}`, { method: "DELETE" });
687
+ const data = await res.json();
688
+ if (data.error) throw new Error(data.error);
689
+ card.innerHTML = `<div class="worktree-action-header">${BRANCH_SVG}<span>Discarded <strong>${escapeHtml(branchName)}</strong></span></div>`;
690
+ card.classList.add("worktree-discarded");
691
+ } catch (err) {
692
+ addStatus("Discard error: " + err.message, true, pane);
693
+ btnsDiv.innerHTML = "";
694
+ btnsDiv.appendChild(diffBtn);
695
+ btnsDiv.appendChild(mergeBtn);
696
+ btnsDiv.appendChild(discardBtn);
697
+ }
698
+ });
699
+ });
700
+
701
+ container.appendChild(card);
702
+ card.scrollIntoView({ behavior: "smooth" });
703
+ }
704
+
705
+ /**
706
+ * Show a modal with the raw unified diff.
707
+ */
708
+ function showWorktreeDiffModal(diffText, branchName) {
709
+ const overlay = document.createElement("div");
710
+ overlay.className = "modal-overlay";
711
+ overlay.innerHTML = `
712
+ <div class="modal git-diff-modal">
713
+ <div class="modal-header">
714
+ <h3>Diff: ${escapeHtml(branchName)}</h3>
715
+ <button class="modal-close">&times;</button>
716
+ </div>
717
+ <div class="git-diff-body"></div>
718
+ </div>
719
+ `;
720
+
721
+ const body = overlay.querySelector(".git-diff-body");
722
+
723
+ if (!diffText || !diffText.trim()) {
724
+ body.innerHTML = '<div class="git-diff-empty">(no changes)</div>';
725
+ } else {
726
+ // Parse into per-file sections
727
+ const sections = [];
728
+ let current = null;
729
+ for (const line of diffText.split("\n")) {
730
+ if (line.startsWith("diff --git ")) {
731
+ if (current) sections.push(current);
732
+ const match = line.match(/diff --git a\/(.+?) b\/(.+)/);
733
+ current = { fileName: match ? match[2] : line, lines: [] };
734
+ } else if (current) {
735
+ current.lines.push(line);
736
+ } else {
737
+ if (!current) current = { fileName: "", lines: [] };
738
+ current.lines.push(line);
739
+ }
740
+ }
741
+ if (current) sections.push(current);
742
+
743
+ if (sections.length <= 1 && (!sections[0]?.fileName || sections[0].fileName === "")) {
744
+ // Single file — plain view
745
+ const pre = document.createElement("pre");
746
+ pre.className = "git-diff-content";
747
+ renderColoredDiff(pre, sections[0]?.lines || diffText.split("\n"));
748
+ body.appendChild(pre);
749
+ } else {
750
+ // Multi-file — per-file collapsible sections
751
+ for (const section of sections) {
752
+ let add = 0, del = 0;
753
+ for (const l of section.lines) {
754
+ if (l.startsWith("+") && !l.startsWith("+++")) add++;
755
+ else if (l.startsWith("-") && !l.startsWith("---")) del++;
756
+ }
757
+
758
+ const fileDiv = document.createElement("div");
759
+ fileDiv.className = "git-diff-file";
760
+
761
+ const header = document.createElement("div");
762
+ header.className = "git-diff-file-header";
763
+ header.innerHTML = `
764
+ <svg class="git-diff-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
765
+ <span class="git-diff-file-name">${escapeHtml(section.fileName)}</span>
766
+ <span class="git-diff-file-stats">
767
+ ${add ? `<span class="diff-stat-add">+${add}</span>` : ""}
768
+ ${del ? `<span class="diff-stat-del">-${del}</span>` : ""}
769
+ </span>
770
+ `;
771
+ header.addEventListener("click", () => fileDiv.classList.toggle("collapsed"));
772
+
773
+ const content = document.createElement("pre");
774
+ content.className = "git-diff-content git-diff-file-content";
775
+ renderColoredDiff(content, section.lines);
776
+
777
+ fileDiv.appendChild(header);
778
+ fileDiv.appendChild(content);
779
+ body.appendChild(fileDiv);
780
+ }
781
+ }
782
+ }
783
+
784
+ overlay.querySelector(".modal-close").addEventListener("click", () => overlay.remove());
785
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
786
+ document.addEventListener("keydown", function esc(e) {
787
+ if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", esc); }
788
+ });
789
+ document.body.appendChild(overlay);
790
+ }
791
+
792
+ function renderColoredDiff(container, lines) {
793
+ for (const line of lines) {
794
+ const span = document.createElement("span");
795
+ span.textContent = line + "\n";
796
+ if (line.startsWith("+++") || line.startsWith("---")) span.className = "diff-line-meta";
797
+ else if (line.startsWith("+")) span.className = "diff-line-added";
798
+ else if (line.startsWith("-")) span.className = "diff-line-removed";
799
+ else if (line.startsWith("@@")) span.className = "diff-line-hunk";
800
+ container.appendChild(span);
459
801
  }
460
802
  }
461
803
 
804
+ function escapeHtml(str) {
805
+ const d = document.createElement("div");
806
+ d.textContent = str;
807
+ return d.innerHTML;
808
+ }
809
+
462
810
  // Listen for WebSocket messages via event bus
463
811
  on("ws:message", handleServerMessage);
464
812
 
@@ -618,6 +966,7 @@ $.stopBtn.addEventListener("click", () => stopGeneration(getPane(null)));
618
966
 
619
967
  $.messageInput.addEventListener("keydown", (e) => {
620
968
  if (handleAutocompleteKeydown(e, getPane(null))) return;
969
+ if (handleHistoryKeydown(e, getPane(null), inputHistory)) return;
621
970
  if (e.key === "Enter" && !e.shiftKey) {
622
971
  e.preventDefault();
623
972
  sendMessage(getPane(null));
@@ -625,19 +974,168 @@ $.messageInput.addEventListener("keydown", (e) => {
625
974
  });
626
975
 
627
976
  $.messageInput.addEventListener("input", () => {
977
+ if (inputHistory.isNavigating) inputHistory.reset();
628
978
  $.messageInput.style.height = "auto";
629
979
  $.messageInput.style.height = Math.min($.messageInput.scrollHeight, 200) + "px";
630
980
  handleSlashAutocomplete(getPane(null));
631
981
  });
632
982
 
983
+ // ── History button + popover ──
984
+ export function updateHistoryButtonVisibility() {
985
+ // Re-sync key in case project loaded after module init
986
+ const ek = historyKey();
987
+ if (inputHistory.storageKey !== ek) inputHistory = new InputHistory(ek);
988
+ if ($.historyBtn) {
989
+ $.historyBtn.classList.toggle("hidden", inputHistory.entries.length === 0);
990
+ }
991
+ }
992
+
993
+ function renderHistoryPopover() {
994
+ const el = $.historyPopover;
995
+ if (!el) return;
996
+ const entries = inputHistory.getAll();
997
+
998
+ if (entries.length === 0) {
999
+ el.innerHTML = '<div class="history-popover-empty">No messages yet</div>';
1000
+ return;
1001
+ }
1002
+
1003
+ el.innerHTML = `<div class="history-popover-header"><span>Recent messages</span><button class="history-popover-clear">Clear</button></div>`;
1004
+ const clearBtn = el.querySelector(".history-popover-clear");
1005
+ clearBtn.addEventListener("click", (e) => {
1006
+ e.stopPropagation();
1007
+ inputHistory.entries.length = 0;
1008
+ inputHistory._save();
1009
+ closeHistoryPopover();
1010
+ updateHistoryButtonVisibility();
1011
+ });
1012
+
1013
+ entries.forEach((text) => {
1014
+ const item = document.createElement("div");
1015
+ item.className = "history-popover-item";
1016
+ const truncated = text.length > 80 ? text.slice(0, 80) + "\u2026" : text;
1017
+ const span = document.createElement("span");
1018
+ span.className = "history-popover-item-text" + (text.startsWith("/") ? " is-slash" : "");
1019
+ span.textContent = truncated;
1020
+ item.appendChild(span);
1021
+ item.addEventListener("click", () => {
1022
+ const pane = getPane(null);
1023
+ pane.messageInput.value = text;
1024
+ pane.messageInput.style.height = "auto";
1025
+ pane.messageInput.style.height = Math.min(pane.messageInput.scrollHeight, 200) + "px";
1026
+ closeHistoryPopover();
1027
+ pane.messageInput.focus();
1028
+ });
1029
+ el.appendChild(item);
1030
+ });
1031
+ }
1032
+
1033
+ function closeHistoryPopover() {
1034
+ if ($.historyPopover) $.historyPopover.classList.add("hidden");
1035
+ }
1036
+
1037
+ if ($.historyBtn) {
1038
+ $.historyBtn.addEventListener("click", (e) => {
1039
+ e.stopPropagation();
1040
+ const el = $.historyPopover;
1041
+ if (!el) return;
1042
+ if (el.classList.contains("hidden")) {
1043
+ renderHistoryPopover();
1044
+ el.classList.remove("hidden");
1045
+ } else {
1046
+ closeHistoryPopover();
1047
+ }
1048
+ });
1049
+ }
1050
+
1051
+ document.addEventListener("click", (e) => {
1052
+ if ($.historyPopover && !$.historyPopover.classList.contains("hidden")) {
1053
+ if (!$.historyPopover.contains(e.target) && e.target !== $.historyBtn) {
1054
+ closeHistoryPopover();
1055
+ }
1056
+ }
1057
+ });
1058
+
633
1059
  // Initialize mermaid
634
1060
  if (typeof mermaid !== "undefined") {
635
1061
  mermaid.initialize({ startOnLoad: false, theme: "dark" });
636
1062
  }
637
1063
 
1064
+ // ── Fork button handler (delegated) ──
1065
+ $.messagesDiv.addEventListener("click", async (e) => {
1066
+ const forkBtn = e.target.closest(".fork-btn");
1067
+ if (!forkBtn) return;
1068
+
1069
+ // Block fork during active streaming
1070
+ const currentPane = getPane(null);
1071
+ if (currentPane && currentPane.isStreaming) return;
1072
+
1073
+ const messageId = Number(forkBtn.dataset.messageId);
1074
+ const sessionId = getState("sessionId");
1075
+ if (!sessionId || !messageId) return;
1076
+
1077
+ forkBtn.disabled = true;
1078
+ forkBtn.classList.add("fork-loading");
1079
+ try {
1080
+ const forked = await api.forkSession(sessionId, messageId);
1081
+ // Switch to the forked session
1082
+ setState("sessionId", forked.id);
1083
+ $.messagesDiv.innerHTML = "";
1084
+ const { loadMessages } = await import('./sessions.js');
1085
+ await loadMessages(forked.id);
1086
+ await loadSessions();
1087
+ $.messageInput.focus();
1088
+ // Show toast
1089
+ showForkToast(forked.title || "Forked session");
1090
+ } catch (err) {
1091
+ console.error("Fork failed:", err);
1092
+ } finally {
1093
+ forkBtn.disabled = false;
1094
+ forkBtn.classList.remove("fork-loading");
1095
+ }
1096
+ });
1097
+
1098
+ function showForkToast(title) {
1099
+ const container = document.getElementById("toast-container");
1100
+ if (!container) return;
1101
+ const toast = document.createElement("div");
1102
+ toast.className = "bg-toast";
1103
+ const dot = document.createElement("span");
1104
+ dot.className = "bg-toast-dot";
1105
+ const body = document.createElement("div");
1106
+ body.className = "bg-toast-body";
1107
+ const label = document.createElement("div");
1108
+ label.className = "bg-toast-label";
1109
+ label.textContent = "Session forked";
1110
+ const titleEl = document.createElement("div");
1111
+ titleEl.className = "bg-toast-title";
1112
+ titleEl.textContent = title;
1113
+ body.appendChild(label);
1114
+ body.appendChild(titleEl);
1115
+ const closeBtn = document.createElement("button");
1116
+ closeBtn.className = "bg-toast-close";
1117
+ closeBtn.title = "Dismiss";
1118
+ closeBtn.innerHTML = "&times;";
1119
+ toast.appendChild(dot);
1120
+ toast.appendChild(body);
1121
+ toast.appendChild(closeBtn);
1122
+ const dismiss = () => {
1123
+ toast.classList.add("toast-exit");
1124
+ toast.addEventListener("animationend", () => toast.remove());
1125
+ };
1126
+ closeBtn.addEventListener("click", dismiss);
1127
+ container.appendChild(toast);
1128
+ setTimeout(() => { if (toast.parentNode) dismiss(); }, 3000);
1129
+ }
1130
+
638
1131
  // ── Boot sequence ──
639
1132
  showWhalyPlaceholder();
640
- loadProjects(); // loadSessions() is called inside loadProjects() after dropdown is populated
1133
+ updateHistoryButtonVisibility();
1134
+ loadProjects().then(() => {
1135
+ // Re-sync history after projects load (programmatic .value= doesn't fire change event)
1136
+ inputHistory = new InputHistory(historyKey());
1137
+ updateHistoryButtonVisibility();
1138
+ }); // loadSessions() is called inside loadProjects() after dropdown is populated
641
1139
  loadAccountInfo();
642
1140
  loadStats();
643
1141
  loadPrompts();