openclacky 1.5.21 โ†’ 1.5.22

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/lib/clacky/billing/platform_billing.rb +3 -0
  4. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  5. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  6. data/lib/clacky/providers.rb +43 -17
  7. data/lib/clacky/search_config.rb +33 -11
  8. data/lib/clacky/server/git_panel.rb +360 -34
  9. data/lib/clacky/server/http_server.rb +145 -47
  10. data/lib/clacky/server/preview.rb +118 -17
  11. data/lib/clacky/server/scheduler.rb +81 -2
  12. data/lib/clacky/server/web_ui_controller.rb +35 -27
  13. data/lib/clacky/session_manager.rb +52 -5
  14. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  15. data/lib/clacky/utils/model_pricing.rb +60 -2
  16. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  17. data/lib/clacky/version.rb +1 -1
  18. data/lib/clacky/web/app.css +650 -96
  19. data/lib/clacky/web/app.js +59 -6
  20. data/lib/clacky/web/auth.js +1 -1
  21. data/lib/clacky/web/components/composer.js +107 -8
  22. data/lib/clacky/web/components/image-zoom.js +165 -0
  23. data/lib/clacky/web/components/model-picker.js +6 -4
  24. data/lib/clacky/web/components/notify.js +2 -2
  25. data/lib/clacky/web/core/aside.js +60 -3
  26. data/lib/clacky/web/features/billing/view.js +2 -2
  27. data/lib/clacky/web/features/channels/view.js +5 -2
  28. data/lib/clacky/web/features/extensions/view.js +2 -2
  29. data/lib/clacky/web/features/new-session/store.js +79 -2
  30. data/lib/clacky/web/features/new-session/view.js +496 -58
  31. data/lib/clacky/web/features/skills/store.js +1 -1
  32. data/lib/clacky/web/features/tasks/view.js +162 -51
  33. data/lib/clacky/web/features/workspace/store.js +13 -6
  34. data/lib/clacky/web/features/workspace/view.js +153 -163
  35. data/lib/clacky/web/i18n.js +238 -56
  36. data/lib/clacky/web/index.html +173 -157
  37. data/lib/clacky/web/projects.js +37 -4
  38. data/lib/clacky/web/sessions.js +181 -105
  39. data/lib/clacky/web/settings.js +26 -8
  40. data/lib/clacky/web/skills.js +2 -2
  41. data/lib/clacky/web/theme.js +17 -2
  42. data/lib/clacky/web/utils.js +17 -2
  43. data/lib/clacky/web/weixin-qr.html +2 -1
  44. data/lib/clacky/web/ws-dispatcher.js +8 -29
  45. metadata +2 -1
@@ -52,7 +52,7 @@ const NewSessionView = (() => {
52
52
  return a.description || "";
53
53
  }
54
54
 
55
- const LS_SEEN_AGENTS_KEY = "openclacky.newSession.seenAgents";
55
+ const LS_SEEN_AGENTS_KEY = "clacky-new-session-seen-agents";
56
56
 
57
57
  function _seenAgents() {
58
58
  try {
@@ -356,20 +356,6 @@ const NewSessionView = (() => {
356
356
  const MAX_FILE_BYTES = 300 * 1024 * 1024;
357
357
  const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
358
358
 
359
- function _docTypeIcon(mimeType, filename) {
360
- const lower = (filename || "").toLowerCase();
361
- if (mimeType === "application/pdf" || lower.endsWith(".pdf")) return "๐Ÿ“„";
362
- if (mimeType === "application/zip" || lower.endsWith(".zip")) return "๐Ÿ—œ๏ธ";
363
- if (lower.endsWith(".tar") || lower.endsWith(".gz") || lower.endsWith(".tgz") ||
364
- lower.endsWith(".tar.gz") || lower.endsWith(".rar") || lower.endsWith(".7z")) return "๐Ÿ—œ๏ธ";
365
- if ((mimeType && mimeType.includes("wordprocessingml")) || lower.endsWith(".doc") || lower.endsWith(".docx")) return "๐Ÿ“";
366
- if ((mimeType && mimeType.includes("spreadsheetml")) || lower.endsWith(".xls") || lower.endsWith(".xlsx")) return "๐Ÿ“Š";
367
- if ((mimeType && mimeType.includes("presentationml")) || lower.endsWith(".ppt") || lower.endsWith(".pptx")) return "๐Ÿ“‹";
368
- if (mimeType === "text/csv" || lower.endsWith(".csv")) return "๐Ÿ“Š";
369
- if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "๐Ÿ“";
370
- return "๐Ÿ“Ž";
371
- }
372
-
373
359
  function _compressImage(file) {
374
360
  return new Promise((resolve, reject) => {
375
361
  const reader = new FileReader();
@@ -477,7 +463,7 @@ const NewSessionView = (() => {
477
463
  const removeBtn = document.createElement("button");
478
464
  removeBtn.className = "img-preview-remove";
479
465
  removeBtn.textContent = "โœ•";
480
- removeBtn.title = "Remove";
466
+ removeBtn.title = I18n.t("tooltip.remove");
481
467
  removeBtn.addEventListener("click", () => { _pendingImages.splice(idx, 1); _renderAttachmentPreviews(); });
482
468
  item.appendChild(thumbnail);
483
469
  item.appendChild(removeBtn);
@@ -490,7 +476,7 @@ const NewSessionView = (() => {
490
476
  item.title = f.name;
491
477
  const icon = document.createElement("div");
492
478
  icon.className = "pdf-preview-icon";
493
- icon.textContent = _docTypeIcon(f.mime_type, f.name);
479
+ icon.innerHTML = Clacky.WorkspaceView.fileIcon(f.name);
494
480
  const info = document.createElement("div");
495
481
  info.className = "pdf-preview-info";
496
482
  const name = document.createElement("div");
@@ -504,7 +490,7 @@ const NewSessionView = (() => {
504
490
  const removeBtn = document.createElement("button");
505
491
  removeBtn.className = "pdf-preview-remove";
506
492
  removeBtn.textContent = "โœ•";
507
- removeBtn.title = "Remove";
493
+ removeBtn.title = I18n.t("tooltip.remove");
508
494
  removeBtn.addEventListener("click", () => { _pendingFiles.splice(idx, 1); _renderAttachmentPreviews(); });
509
495
  item.appendChild(icon);
510
496
  item.appendChild(info);
@@ -541,11 +527,15 @@ const NewSessionView = (() => {
541
527
  }
542
528
  if (_pendingFiles.length > 0) {
543
529
  const badges = _pendingFiles.map((f) => {
544
- const icon = _docTypeIcon(f.mime_type, f.name);
530
+ const icon = Clacky.WorkspaceView.fileIcon(f.name);
545
531
  const ext = (f.name.split(".").pop() || "file").toUpperCase();
546
- return `<span class="msg-pdf-badge"><span class="msg-pdf-badge-icon">${icon}</span>` +
532
+ const tag = f.path ? "button" : "span";
533
+ const attrs = f.path
534
+ ? ` type="button" class="msg-pdf-badge msg-file-open" data-file-path="${encodeURIComponent(f.path)}"`
535
+ : ` class="msg-pdf-badge"`;
536
+ return `<${tag}${attrs}><span class="msg-pdf-badge-icon">${icon}</span>` +
547
537
  `<span class="msg-pdf-badge-info"><span class="msg-pdf-badge-name">${escapeHtml(f.name)}</span>` +
548
- `<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span></span></span>`;
538
+ `<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span></span></${tag}>`;
549
539
  }).join(" ");
550
540
  html = badges + (html ? "<br>" + html : "");
551
541
  }
@@ -604,6 +594,11 @@ const NewSessionView = (() => {
604
594
  if (popRect.bottom > window.innerHeight - 8) {
605
595
  pop.style.top = `${rect.top - popRect.height - 6}px`;
606
596
  }
597
+ // Keep the popover inside the viewport when the anchor sits near the right
598
+ // edge (narrow windows), instead of letting it run off-screen.
599
+ if (popRect.right > window.innerWidth - 8) {
600
+ pop.style.left = `${Math.max(8, window.innerWidth - popRect.width - 8)}px`;
601
+ }
607
602
 
608
603
  const dismiss = (e) => {
609
604
  if (!pop.contains(e.target) && e.target !== anchor) {
@@ -799,36 +794,482 @@ const NewSessionView = (() => {
799
794
  _updateSendButton();
800
795
  }
801
796
 
797
+ // โ”€โ”€ Branch chip popover โ€” search, switch, create โ”€โ”€
798
+ // Branches live in the project's work tree, not in the session, so switching
799
+ // here really runs `git checkout` on the shared directory. Local branches
800
+ // switch directly; remote ones become a tracking branch of the same name.
801
+ function _openBranchPopover(anchor) {
802
+ const zh = _isZh();
803
+ const projectId = NewSessionStore.state.advanced.projectId;
804
+ if (!projectId) return;
805
+
806
+ let busy = false;
807
+
808
+ _showCtxPopover(anchor, (pop) => {
809
+ pop.classList.add("ns-branch-pop");
810
+
811
+ const header = document.createElement("div");
812
+ header.className = "ns-branch-search";
813
+ header.innerHTML =
814
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
815
+ 'stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>';
816
+ const input = document.createElement("input");
817
+ input.type = "text";
818
+ input.className = "ns-branch-search-input";
819
+ input.placeholder = zh ? "ๆœ็ดขๅˆ†ๆ”ฏ" : "Search branches";
820
+ input.autocomplete = "off";
821
+ input.spellcheck = false;
822
+ header.appendChild(input);
823
+ pop.appendChild(header);
824
+
825
+ const list = document.createElement("div");
826
+ list.className = "ns-branch-list";
827
+ pop.appendChild(list);
828
+
829
+ const foot = document.createElement("div");
830
+ foot.className = "ns-branch-foot";
831
+ pop.appendChild(foot);
832
+
833
+ const _showError = (msg) => {
834
+ Clacky.Modal.toast(msg, "error");
835
+ };
836
+
837
+ const _checkout = async (branch, opts = {}) => {
838
+ if (busy) return;
839
+ busy = true;
840
+ list.classList.add("is-busy");
841
+ try {
842
+ const data = await Projects.checkoutBranch(projectId, {
843
+ branch,
844
+ dir: NewSessionStore.state.advanced.workingDir || "",
845
+ ...opts,
846
+ });
847
+ NewSessionStore.applyGitState(data);
848
+ _dismissCtxPopover();
849
+ } catch (e) {
850
+ busy = false;
851
+ list.classList.remove("is-busy");
852
+ _showError(_branchErrorText(e.message, zh, branch));
853
+ }
854
+ };
855
+
856
+ const _branchItem = (b, label, onClick) => {
857
+ const item = document.createElement("button");
858
+ item.type = "button";
859
+ item.className = "ns-ctx-popover-item ns-branch-item" + (b.current ? " active" : "");
860
+ const icon =
861
+ '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
862
+ 'stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/>' +
863
+ '<circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 3-2.5 4.5-6 5"/></svg>';
864
+ const check = b.current
865
+ ? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" ' +
866
+ 'stroke-linecap="round" stroke-linejoin="round"><path d="m20 6-11 11-5-5"/></svg>'
867
+ : "";
868
+ item.innerHTML =
869
+ `<span class="ns-branch-item-icon">${icon}</span>` +
870
+ `<span class="ns-branch-item-label">${_escHtml(label)}</span>` +
871
+ `<span class="ns-branch-item-check">${check}</span>`;
872
+ item.title = label;
873
+ item.addEventListener("click", onClick);
874
+ return item;
875
+ };
876
+
877
+ const _localName = (ref) => ref.replace(/^[^/]+\//, "");
878
+
879
+ const renderList = () => {
880
+ const q = input.value.trim().toLowerCase();
881
+ const branches = NewSessionStore.state.git.branches || [];
882
+ const local = branches.filter(b => !b.remote && b.name.toLowerCase().includes(q));
883
+ const remote = branches.filter(b => b.remote && b.name.toLowerCase().includes(q));
884
+ const currentRef = NewSessionStore.state.git.branch;
885
+ const localNames = new Set(branches.filter(b => !b.remote).map(b => b.name));
886
+
887
+ list.innerHTML = "";
888
+ list.scrollTop = 0;
889
+
890
+ if (!local.length && !remote.length) {
891
+ const empty = document.createElement("div");
892
+ empty.className = "ns-ctx-popover-empty";
893
+ empty.textContent = q
894
+ ? (zh ? "ๆฒกๆœ‰ๅŒน้…็š„ๅˆ†ๆ”ฏ" : "No matching branches")
895
+ : (zh ? "ๆš‚ๆ— ๅˆ†ๆ”ฏ" : "No branches");
896
+ list.appendChild(empty);
897
+ return;
898
+ }
899
+
900
+ const addGroup = (title, items, toItem) => {
901
+ if (!items.length) return;
902
+ const label = document.createElement("div");
903
+ label.className = "ns-branch-group";
904
+ label.textContent = title;
905
+ list.appendChild(label);
906
+ items.forEach(b => list.appendChild(toItem(b)));
907
+ };
908
+
909
+ addGroup(zh ? "ๅˆ†ๆ”ฏ" : "Branches", local, (b) => {
910
+ const isCurrent = b.name === currentRef || b.current;
911
+ return _branchItem({ current: isCurrent }, b.name, () => {
912
+ if (isCurrent) { _dismissCtxPopover(); return; }
913
+ _checkout(b.name);
914
+ });
915
+ });
916
+
917
+ addGroup(zh ? "่ฟœ็จ‹" : "Remote", remote, (b) => {
918
+ // A remote ref is not a thing HEAD can point at, so picking one
919
+ // materialises a local branch of the same name (or reuses it).
920
+ const short = _localName(b.name);
921
+ const existsLocally = localNames.has(short);
922
+ const item = _branchItem({ current: false }, b.name, () => {
923
+ if (existsLocally) _checkout(short);
924
+ else _checkout(short, { create: true, from: b.name });
925
+ });
926
+ item.classList.add("is-remote");
927
+ return item;
928
+ });
929
+ };
930
+
931
+ // Naming a new branch needs room for the prefix rule and validation
932
+ // messages, so it opens in a modal instead of an inline field here.
933
+ const createBtn = document.createElement("button");
934
+ createBtn.type = "button";
935
+ createBtn.className = "ns-ctx-popover-item ns-branch-create";
936
+ createBtn.innerHTML =
937
+ '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
938
+ 'stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>' +
939
+ `<span>${zh ? "ๅˆ›ๅปบๅนถๆฃ€ๅ‡บๅˆ†ๆ”ฏโ€ฆ" : "Create and check outโ€ฆ"}</span>`;
940
+ createBtn.addEventListener("click", () => {
941
+ _dismissCtxPopover();
942
+ _openCreateBranchModal();
943
+ });
944
+ foot.appendChild(createBtn);
945
+
946
+ input.addEventListener("input", renderList);
947
+ input.addEventListener("keydown", (e) => {
948
+ if (e.key === "Escape") { e.preventDefault(); _dismissCtxPopover(); }
949
+ });
950
+
951
+ renderList();
952
+ setTimeout(() => input.focus(), 30);
953
+ });
954
+ }
955
+
956
+ // โ”€โ”€ Create-branch modal โ”€โ”€
957
+ // Mirrors `git check-ref-format --branch` locally so most mistakes are caught
958
+ // before the round trip; git's own message is shown for anything missed.
959
+ function _branchNameError(name, zh) {
960
+ if (!name) return zh ? "่ฏท่พ“ๅ…ฅๅˆ†ๆ”ฏๅ็งฐใ€‚" : "Please enter a branch name.";
961
+ if (name.endsWith("/")) return zh ? 'ๅˆ†ๆ”ฏๅไธ่ƒฝไปฅ"/"็ป“ๅฐพใ€‚' : 'Branch name cannot end with "/".';
962
+ if (name.startsWith("/")) return zh ? 'ๅˆ†ๆ”ฏๅไธ่ƒฝไปฅ"/"ๅผ€ๅคดใ€‚' : 'Branch name cannot start with "/".';
963
+ if (name.startsWith("-")) return zh ? 'ๅˆ†ๆ”ฏๅไธ่ƒฝไปฅ"-"ๅผ€ๅคดใ€‚' : 'Branch name cannot start with "-".';
964
+ if (name.indexOf("//") !== -1) return zh ? 'ๅˆ†ๆ”ฏๅไธ่ƒฝๅŒ…ๅซ"//"ใ€‚' : 'Branch name cannot contain "//".';
965
+ if (name.indexOf("..") !== -1) return zh ? 'ๅˆ†ๆ”ฏๅไธ่ƒฝๅŒ…ๅซ".."ใ€‚' : 'Branch name cannot contain "..".';
966
+ if (name.endsWith(".")) return zh ? 'ๅˆ†ๆ”ฏๅไธ่ƒฝไปฅ"."็ป“ๅฐพใ€‚' : 'Branch name cannot end with "."';
967
+ if (name.endsWith(".lock")) return zh ? 'ๅˆ†ๆ”ฏๅไธ่ƒฝไปฅ".lock"็ป“ๅฐพใ€‚' : 'Branch name cannot end with ".lock".';
968
+ if (/[\s~^:?*\[\\\x00-\x1f\x7f]/.test(name)) {
969
+ return zh
970
+ ? "ๅˆ†ๆ”ฏๅไธ่ƒฝๅŒ…ๅซ็ฉบๆ ผๆˆ– ~ ^ : ? * [ \\ ็ญ‰ๅญ—็ฌฆใ€‚"
971
+ : "Branch name cannot contain spaces or characters like ~ ^ : ? * [ \\.";
972
+ }
973
+ return "";
974
+ }
975
+
976
+ // git reports checkout failures on stderr in English and with a file list that
977
+ // would swamp the popover, so the common cases are rewritten for the active
978
+ // language; anything unrecognised is passed through untouched.
979
+ function _branchErrorText(raw, zh, branch, verb = "ๅˆ‡ๆขๅˆ†ๆ”ฏ") {
980
+ const msg = String(raw || "").trim();
981
+ if (!zh) return msg || "Failed";
982
+ if (!msg) return `${verb}ๅคฑ่ดฅใ€‚`;
983
+
984
+ if (/would be overwritten by checkout|would be overwritten by merge/i.test(msg)) {
985
+ const files = msg
986
+ .slice(msg.indexOf(":") + 1)
987
+ .split("\n")
988
+ .map(line => line.trim())
989
+ .filter(line => line && !/^(Please|error:|hint:|Aborting)/i.test(line));
990
+ if (!files.length) return "ๆœฌๅœฐๆœ‰ๆœชๆไบค็š„ไฟฎๆ”น๏ผŒๅˆ‡ๆขๅˆ†ๆ”ฏไผš่ขซ่ฆ†็›–ใ€‚่ฏทๅ…ˆๆไบคๆˆ–ๆš‚ๅญ˜ๅŽๅ†่ฏ•ใ€‚";
991
+ const shown = files.slice(0, 5);
992
+ const more = files.length > shown.length ? `\nโ€ฆ๏ผˆๅ…ฑ ${files.length} ไธชๆ–‡ไปถ๏ผ‰` : "";
993
+ return `ๆœฌๅœฐๆœ‰ๆœชๆไบค็š„ไฟฎๆ”น๏ผŒๅˆ‡ๆขๅˆ†ๆ”ฏไผš่ขซ่ฆ†็›–๏ผš\n${shown.join("\n")}${more}\n่ฏทๅ…ˆๆไบคๆˆ–ๆš‚ๅญ˜ๅŽๅ†่ฏ•ใ€‚`;
994
+ }
995
+ if (/untracked working tree files would be overwritten/i.test(msg)) {
996
+ return "ๆœฌๅœฐๆœ‰ๆœช่ทŸ่ธช็š„ๆ–‡ไปถไผš่ขซ่ฆ†็›–๏ผŒ่ฏทๅ…ˆ็งป่ตฐๆˆ–ๆไบคๅŽๅ†่ฏ•ใ€‚";
997
+ }
998
+ if (/a branch named '.+' already exists/i.test(msg)) {
999
+ const found = msg.match(/a branch named '(.+)' already exists/i);
1000
+ return `ๅˆ†ๆ”ฏ ${found ? found[1] : branch || ""} ๅทฒๅญ˜ๅœจใ€‚`;
1001
+ }
1002
+ if (/not a valid branch name/i.test(msg)) return "ๅˆ†ๆ”ฏๅไธๅˆๆณ•ใ€‚";
1003
+ if (/couldn't find remote ref/i.test(msg)) return "่ฟœ็จ‹ๅˆ†ๆ”ฏไธๅญ˜ๅœจใ€‚";
1004
+ if (/did not match any file\(s\) known to git|pathspec .+ did not match/i.test(msg)) {
1005
+ return "ๆ‰พไธๅˆฐ่ฏฅๅˆ†ๆ”ฏใ€‚";
1006
+ }
1007
+ if (/not a git repository/i.test(msg)) return "่ฏฅ็›ฎๅฝ•ไธๆ˜ฏ Git ไป“ๅบ“ใ€‚";
1008
+ if (/you need to resolve your current index first/i.test(msg)) {
1009
+ return "ๅฝ“ๅ‰ๆœ‰ๆœช่งฃๅ†ณ็š„ๅˆๅนถๅ†ฒ็ช๏ผŒ่ฏทๅ…ˆๅค„็†ๅŽๅ†ๅˆ‡ๆขๅˆ†ๆ”ฏใ€‚";
1010
+ }
1011
+ return `${verb}ๅคฑ่ดฅ๏ผš${msg}`;
1012
+ }
1013
+
1014
+ function _openCreateBranchModal() {
1015
+ const zh = _isZh();
1016
+ const projectId = NewSessionStore.state.advanced.projectId;
1017
+ if (!projectId) return;
1018
+
1019
+ let prefix = "";
1020
+ let busy = false;
1021
+
1022
+ const overlay = document.createElement("div");
1023
+ overlay.className = "modal-overlay";
1024
+ overlay.innerHTML =
1025
+ '<div class="modal-box sm ns-branch-modal" role="dialog" aria-modal="true">' +
1026
+ '<div class="modal-header">' +
1027
+ `<h3 class="modal-title">${zh ? "ๅˆ›ๅปบๅนถๆฃ€ๅ‡บๅˆ†ๆ”ฏ" : "Create and check out branch"}</h3>` +
1028
+ '<button type="button" class="modal-close-btn ns-branch-modal-close" aria-label="Close"></button>' +
1029
+ "</div>" +
1030
+ '<div class="modal-body">' +
1031
+ '<div class="modal-field">' +
1032
+ '<div class="ns-branch-field-head">' +
1033
+ `<span class="modal-label">${zh ? "ๅˆ†ๆ”ฏๅ็งฐ" : "Branch name"}</span>` +
1034
+ `<button type="button" class="ns-branch-prefix-btn">${zh ? "่ฎพ็ฝฎๅ‰็ผ€" : "Set prefix"}</button>` +
1035
+ "</div>" +
1036
+ '<div class="ns-branch-name-row">' +
1037
+ '<span class="ns-branch-prefix-chip" hidden></span>' +
1038
+ '<input type="text" class="ns-branch-name-input" autocomplete="off" spellcheck="false"' +
1039
+ ` aria-label="${zh ? "ๅˆ†ๆ”ฏๅ็งฐ" : "Branch name"}"` +
1040
+ ` placeholder="${zh ? "ไพ‹ๅฆ‚ feature/login" : "e.g. feature/login"}">` +
1041
+ "</div>" +
1042
+ '<div class="ns-branch-modal-error" hidden></div>' +
1043
+ "</div>" +
1044
+ "</div>" +
1045
+ '<div class="modal-footer">' +
1046
+ `<button type="button" class="btn-secondary ns-branch-modal-cancel">${zh ? "ๅ…ณ้—ญ" : "Close"}</button>` +
1047
+ `<button type="button" class="btn-primary ns-branch-modal-submit">${zh ? "ๅˆ›ๅปบๅนถๆฃ€ๅ‡บ" : "Create and check out"}</button>` +
1048
+ "</div>" +
1049
+ "</div>";
1050
+ document.body.appendChild(overlay);
1051
+
1052
+ const input = overlay.querySelector(".ns-branch-name-input");
1053
+ const prefixChip = overlay.querySelector(".ns-branch-prefix-chip");
1054
+ const prefixBtn = overlay.querySelector(".ns-branch-prefix-btn");
1055
+ const nameRow = overlay.querySelector(".ns-branch-name-row");
1056
+ const errorEl = overlay.querySelector(".ns-branch-modal-error");
1057
+ const submitBtn = overlay.querySelector(".ns-branch-modal-submit");
1058
+ const cancelBtn = overlay.querySelector(".ns-branch-modal-cancel");
1059
+ const closeBtn = overlay.querySelector(".ns-branch-modal-close");
1060
+
1061
+ const fullName = () => (prefix + input.value.trim()).trim();
1062
+
1063
+ const setError = (msg) => {
1064
+ errorEl.textContent = msg;
1065
+ errorEl.hidden = !msg;
1066
+ nameRow.classList.toggle("is-invalid", !!msg);
1067
+ };
1068
+
1069
+ const sync = () => {
1070
+ const name = input.value.trim();
1071
+ prefixChip.hidden = !prefix;
1072
+ prefixChip.textContent = prefix;
1073
+ prefixBtn.textContent = prefix
1074
+ ? (zh ? "ๅ–ๆถˆๅ‰็ผ€" : "Clear prefix")
1075
+ : (zh ? "่ฎพ็ฝฎๅ‰็ผ€" : "Set prefix");
1076
+ prefixBtn.disabled = busy || (!prefix && !name);
1077
+ submitBtn.disabled = busy || !name;
1078
+ setError(name ? _branchNameError(fullName(), zh) : "");
1079
+ };
1080
+
1081
+ const close = () => {
1082
+ unbindEnter();
1083
+ document.removeEventListener("keydown", onKeydown);
1084
+ overlay.remove();
1085
+ };
1086
+
1087
+ const onKeydown = (e) => {
1088
+ if (e.key === "Escape") { e.preventDefault(); close(); }
1089
+ };
1090
+
1091
+ const submit = async () => {
1092
+ if (busy) return;
1093
+ const branch = fullName();
1094
+ const err = _branchNameError(branch, zh);
1095
+ if (err) { setError(err); input.focus(); return; }
1096
+
1097
+ busy = true;
1098
+ const label = submitBtn.textContent;
1099
+ submitBtn.textContent = zh ? "ๅˆ›ๅปบไธญโ€ฆ" : "Creatingโ€ฆ";
1100
+ sync();
1101
+ try {
1102
+ const data = await Projects.checkoutBranch(projectId, {
1103
+ branch,
1104
+ create: true,
1105
+ dir: NewSessionStore.state.advanced.workingDir || "",
1106
+ });
1107
+ NewSessionStore.applyGitState(data);
1108
+ close();
1109
+ } catch (e) {
1110
+ busy = false;
1111
+ submitBtn.textContent = label;
1112
+ sync();
1113
+ setError(_branchErrorText(e.message, zh, branch, "ๅˆ›ๅปบๅˆ†ๆ”ฏ"));
1114
+ }
1115
+ };
1116
+
1117
+ prefixBtn.addEventListener("click", () => {
1118
+ const name = input.value.trim();
1119
+ if (prefix) {
1120
+ input.value = prefix + name;
1121
+ prefix = "";
1122
+ } else {
1123
+ if (!name) return;
1124
+ const cut = name.lastIndexOf("/");
1125
+ if (cut > 0) {
1126
+ prefix = name.slice(0, cut + 1);
1127
+ input.value = name.slice(cut + 1);
1128
+ } else {
1129
+ prefix = name + "/";
1130
+ input.value = "";
1131
+ }
1132
+ }
1133
+ input.focus();
1134
+ sync();
1135
+ });
1136
+
1137
+ input.addEventListener("input", sync);
1138
+ overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); });
1139
+ submitBtn.addEventListener("click", submit);
1140
+ cancelBtn.addEventListener("click", close);
1141
+ closeBtn.addEventListener("click", close);
1142
+ document.addEventListener("keydown", onKeydown);
1143
+ const unbindEnter = IME.bindEnter(input, submit);
1144
+
1145
+ sync();
1146
+ setTimeout(() => input.focus(), 50);
1147
+ }
1148
+
802
1149
  // โ”€โ”€ Render context bar โ”€โ”€
1150
+ // Chips are created once and updated in place. Rebuilding the row on every
1151
+ // store event made it flash during a project switch: the branch chip dropped
1152
+ // out until git info arrived, which shoved the model and init chips sideways,
1153
+ // and the row was repainted two or three times per switch.
1154
+ const CTX_CHIP_ORDER = ["name", "project", "dir", "branch", "model", "init"];
1155
+
1156
+ const _ctxSvg = (body) =>
1157
+ `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
1158
+
1159
+ const CTX_ICONS = {
1160
+ name: _ctxSvg(`<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>`),
1161
+ project: _ctxSvg(`<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>`),
1162
+ dir: _ctxSvg(`<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m7 11 2-2-2-2"/><path d="M11 13h4"/>`),
1163
+ branch: _ctxSvg(`<circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 3-2.5 4.5-6 5"/>`),
1164
+ model: _ctxSvg(`<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>`),
1165
+ init: _ctxSvg(`<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>`),
1166
+ };
1167
+
1168
+ let _ctxChips = null;
1169
+
1170
+ function _setChipLabel(chip, label) {
1171
+ const text = chip.querySelector(".ns-ctx-chip-label");
1172
+ if (text && text.textContent !== label) text.textContent = label;
1173
+ }
1174
+
1175
+ function _setChipIcon(chip, icon, cacheKey) {
1176
+ if (!icon) return;
1177
+ if (cacheKey && chip.dataset.iconKey === cacheKey) return;
1178
+ const slot = chip.querySelector(".ns-ctx-chip-icon");
1179
+ if (slot) {
1180
+ if (icon instanceof Node) {
1181
+ slot.innerHTML = "";
1182
+ slot.appendChild(icon);
1183
+ slot.classList.add("ns-ctx-chip-icon--badge");
1184
+ } else if (slot.innerHTML !== icon) {
1185
+ slot.innerHTML = icon;
1186
+ slot.classList.remove("ns-ctx-chip-icon--badge");
1187
+ }
1188
+ }
1189
+ if (cacheKey) chip.dataset.iconKey = cacheKey;
1190
+ }
1191
+
1192
+ function _ensureCtxChips(bar) {
1193
+ if (_ctxChips && _ctxChips.name.parentElement === bar) return _ctxChips;
1194
+ bar.innerHTML = "";
1195
+ _ctxChips = {};
1196
+ CTX_CHIP_ORDER.forEach((role) => {
1197
+ const onClick = {
1198
+ name: _openNamePopover,
1199
+ project: _openProjectPopover,
1200
+ dir: _openDirPopover,
1201
+ branch: _openBranchPopover,
1202
+ model: _openModelPopover,
1203
+ init: _toggleInitProject,
1204
+ }[role];
1205
+ const chip = _makeChip(CTX_ICONS[role], "", onClick);
1206
+ chip.dataset.ctxRole = role;
1207
+ _ctxChips[role] = chip;
1208
+ bar.appendChild(chip);
1209
+ });
1210
+ _ctxChips.branch.id = "ns-chip-branch";
1211
+ return _ctxChips;
1212
+ }
1213
+
803
1214
  function _renderContextBar() {
804
1215
  const bar = $("ns-context-bar");
805
1216
  if (!bar) return;
806
- bar.innerHTML = "";
1217
+ const chips = _ensureCtxChips(bar);
807
1218
 
808
1219
  const zh = _isZh();
809
1220
  const agent = NewSessionStore.currentAgent();
810
1221
  const adv = NewSessionStore.state.advanced;
811
1222
 
1223
+ // Session name chip โ€” leads the bar: naming the session is the first thing
1224
+ // people do here, and every other chip is a setting they rarely touch.
1225
+ const nameVal = adv.name || "";
1226
+ _setChipLabel(chips.name, nameVal || (zh ? "ไผš่ฏๅ็งฐ" : "Name"));
1227
+ chips.name.classList.toggle("ns-ctx-chip--muted", !nameVal);
1228
+
812
1229
  // Project chip
813
1230
  const projectId = adv.projectId;
814
1231
  const project = projectId && typeof Projects !== "undefined" && Projects.find
815
1232
  ? Projects.find(projectId)
816
1233
  : null;
817
- const projectLabel = project
818
- ? project.name
819
- : (zh ? "้€‰ๆ‹ฉ้กน็›ฎ" : "Project");
820
- const projectIcon = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
821
- const projectChip = _makeChip(projectIcon, projectLabel, _openProjectPopover);
822
- if (!projectId) projectChip.classList.add("ns-ctx-chip--muted");
823
- bar.appendChild(projectChip);
824
-
825
- // Session name chip (2nd position)
826
- const nameVal = adv.name || "";
827
- const nameLabel = nameVal || (zh ? "ไผš่ฏๅ็งฐ" : "Name");
828
- const nameIcon = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`;
829
- const nameChip = _makeChip(nameIcon, nameLabel, _openNamePopover);
830
- if (!nameVal) nameChip.classList.add("ns-ctx-chip--muted");
831
- bar.appendChild(nameChip);
1234
+ _setChipLabel(chips.project, project ? project.name : (zh ? "้€‰ๆ‹ฉ้กน็›ฎ" : "Project"));
1235
+ chips.project.classList.toggle("ns-ctx-chip--muted", !projectId);
1236
+
1237
+ // Working dir chip โ€” a terminal, not a folder: the folder glyph is already
1238
+ // the project chip's, and two folders in a row read as the same thing.
1239
+ const dirVal = adv.workingDir || "";
1240
+ const dirShort = dirVal
1241
+ ? dirVal.replace(/^.*[\/]([^\/]+[\/][^\/]*)$/, "$1").replace(/^.*[\/]/, "").slice(-32)
1242
+ : "";
1243
+ _setChipLabel(chips.dir, dirShort || (zh ? "ๅทฅไฝœ็›ฎๅฝ•" : "Directory"));
1244
+ chips.dir.classList.toggle("ns-ctx-chip--muted", !dirVal);
1245
+
1246
+ // Branch chip โ€” coding agent on a git-backed project. The work tree's
1247
+ // branch is shared state, and the session's working dir can point at a
1248
+ // different repo than the project's, so the key is project + dir.
1249
+ const showBranch = !!(agent && agent.id === "coding" && projectId);
1250
+ const git = NewSessionStore.state.git || {};
1251
+ const gitKey = showBranch ? NewSessionStore.gitKey(projectId, dirVal) : "";
1252
+ const gitKnown = showBranch && git.loaded && git.key === gitKey;
1253
+
1254
+ if (showBranch && !gitKnown && !git.loading) {
1255
+ NewSessionStore.loadGitInfo(projectId, { dir: dirVal });
1256
+ }
1257
+
1258
+ // Only a confirmed non-repo hides the chip; while loading it stays in place
1259
+ // so the chips after it don't shift.
1260
+ chips.branch.hidden = !showBranch || (gitKnown && !git.repo);
1261
+ if (showBranch) {
1262
+ // Keep the previous branch name while a switch is in flight โ€” swapping in
1263
+ // a placeholder and then the real name reads as two separate flickers.
1264
+ const branchLabel = gitKnown
1265
+ ? (git.branch || (zh ? "ๅˆ†ๆ”ฏ" : "Branch"))
1266
+ : (chips.branch.querySelector(".ns-ctx-chip-label").textContent || (zh ? "ๅˆ†ๆ”ฏ" : "Branch"));
1267
+ _setChipLabel(chips.branch, branchLabel);
1268
+ chips.branch.classList.toggle("ns-ctx-chip--muted", !gitKnown || !git.branch);
1269
+ chips.branch.title = gitKnown
1270
+ ? (zh ? "ๅˆ‡ๆขๆˆ–ๅˆ›ๅปบๅˆ†ๆ”ฏ" : "Switch or create a branch")
1271
+ : (zh ? "ๆญฃๅœจ่ฏปๅ–ๅˆ†ๆ”ฏโ€ฆ" : "Loading branchโ€ฆ");
1272
+ }
832
1273
 
833
1274
  // Model chip โ€” show selected model name from store (strip prefix/suffix for brevity)
834
1275
  let modelLabel = zh ? "ๆจกๅž‹" : "Model";
@@ -842,29 +1283,19 @@ const NewSessionView = (() => {
842
1283
  modelLabel = modelRaw.length > 28 ? modelRaw.slice(0, 26) + "โ€ฆ" : modelRaw;
843
1284
  }
844
1285
  }
845
- const modelIcon = modelRaw && typeof ModelPicker !== "undefined"
846
- ? ModelPicker.vendorBadge(modelRaw)
847
- : `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;
848
- bar.appendChild(_makeChip(modelIcon, modelLabel, _openModelPopover));
849
-
850
- // Working dir chip
851
- const dirVal = adv.workingDir || "";
852
- const dirShort = dirVal
853
- ? dirVal.replace(/^.*[\/]([^\/]+[\/][^\/]*)$/, "$1").replace(/^.*[\/]/, "").slice(-32)
854
- : (zh ? "ๅทฅไฝœ็›ฎๅฝ•" : "Directory");
855
- const dirIcon = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>`;
856
- const dirChip = _makeChip(dirIcon, dirShort || (zh ? "ๅทฅไฝœ็›ฎๅฝ•" : "Directory"), _openDirPopover);
857
- if (!dirVal) dirChip.classList.add("ns-ctx-chip--muted");
858
- bar.appendChild(dirChip);
1286
+ _setChipLabel(chips.model, modelLabel);
1287
+ _setChipIcon(
1288
+ chips.model,
1289
+ modelRaw && typeof ModelPicker !== "undefined" ? ModelPicker.vendorBadge(modelRaw) : CTX_ICONS.model,
1290
+ modelRaw || "default"
1291
+ );
859
1292
 
860
1293
  // Init project chip โ€” only for coding agent
861
- if (agent && agent.id === "coding") {
862
- const active = NewSessionStore.state.advanced.initProject === true;
863
- const initLabel = zh ? "ๅˆๅง‹ๅŒ–้กน็›ฎ" : "Init project";
864
- const initIcon = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`;
865
- const initChip = _makeChip(initIcon, initLabel, _toggleInitProject);
866
- if (active) initChip.classList.add("ns-ctx-chip--active");
867
- bar.appendChild(initChip);
1294
+ const showInit = !!(agent && agent.id === "coding");
1295
+ chips.init.hidden = !showInit;
1296
+ if (showInit) {
1297
+ _setChipLabel(chips.init, zh ? "ๅˆๅง‹ๅŒ–้กน็›ฎ" : "Init project");
1298
+ chips.init.classList.toggle("ns-ctx-chip--active", adv.initProject === true);
868
1299
  }
869
1300
  }
870
1301
 
@@ -943,6 +1374,7 @@ const NewSessionView = (() => {
943
1374
  _renderContextBar();
944
1375
  });
945
1376
  NewSessionStore.on("newSession:creating", _updateSendButton);
1377
+ NewSessionStore.on("newSession:git-changed", _renderContextBar);
946
1378
 
947
1379
  const input = $("new-session-input");
948
1380
  if (input) {
@@ -1031,6 +1463,12 @@ const NewSessionView = (() => {
1031
1463
  // projectId from localStorage, which decides whether the working dir is
1032
1464
  // prefilled from the project or from the global default.
1033
1465
  await _prefillDefaultDir();
1466
+ // Another session may have switched the work tree's branch while this page
1467
+ // was hidden, so the cached value is refreshed on every show.
1468
+ await NewSessionStore.loadGitInfo(NewSessionStore.state.advanced.projectId, {
1469
+ dir: NewSessionStore.state.advanced.workingDir || "",
1470
+ force: true,
1471
+ });
1034
1472
  _renderContextBar();
1035
1473
  _updateSendButton();
1036
1474
  const input = $("new-session-input");
@@ -23,7 +23,7 @@
23
23
  // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
24
24
 
25
25
  const SkillsStore = (() => {
26
- const SKILL_AC_SHOW_SYSTEM_KEY = "skill-ac-show-system";
26
+ const SKILL_AC_SHOW_SYSTEM_KEY = "clacky-skill-ac-show-system";
27
27
 
28
28
  // โ”€โ”€ State (single source of truth) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
29
29
  let _skills = []; // [{ name, description, source, enabled }]