openclacky 1.0.0 → 1.0.2

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. data/lib/clacky/deploy_api_client.rb +0 -484
@@ -45,6 +45,8 @@ const PANELS = [
45
45
  "task-detail-panel",
46
46
  "skills-panel",
47
47
  "channels-panel",
48
+ "trash-panel",
49
+ "profile-panel",
48
50
  "settings-panel",
49
51
  "creator-panel",
50
52
  ];
@@ -75,6 +77,11 @@ const Router = (() => {
75
77
  if (h === "tasks") return { view: "tasks", params: {} };
76
78
  if (h === "skills") return { view: "skills", params: {} };
77
79
  if (h === "channels") return { view: "channels", params: {} };
80
+ if (h === "trash") return { view: "trash", params: {} };
81
+ if (h === "profile") return { view: "profile", params: {} };
82
+ // Legacy: #memories redirects to #profile (memories are now merged into
83
+ // the profile panel). Kept so bookmarks / external links don't 404.
84
+ if (h === "memories") return { view: "profile", params: {} };
78
85
  if (h === "settings") return { view: "settings", params: {} };
79
86
  if (h === "creator") return { view: "creator", params: {} };
80
87
  const m = h.match(/^session\/(.+)$/);
@@ -89,6 +96,8 @@ const Router = (() => {
89
96
  tasks: "tasks-sidebar-item",
90
97
  skills: "skills-sidebar-item",
91
98
  channels: "channels-sidebar-item",
99
+ trash: "trash-sidebar-item",
100
+ profile: "profile-sidebar-item",
92
101
  creator: "creator-sidebar-item",
93
102
  };
94
103
 
@@ -189,6 +198,20 @@ const Router = (() => {
189
198
  Sessions.renderList();
190
199
  break;
191
200
 
201
+ case "trash":
202
+ _setHash("trash");
203
+ $("trash-panel").style.display = "flex";
204
+ Trash.onPanelShow();
205
+ Sessions.renderList();
206
+ break;
207
+
208
+ case "profile":
209
+ _setHash("profile");
210
+ $("profile-panel").style.display = "flex";
211
+ Profile.onPanelShow();
212
+ Sessions.renderList();
213
+ break;
214
+
192
215
  case "creator":
193
216
  _setHash("creator");
194
217
  $("creator-panel").style.display = "flex";
@@ -323,566 +346,14 @@ function showConfirmModal(confId, message) {
323
346
  $("modal-no").onclick = () => answer("no");
324
347
  }
325
348
 
326
- // ── WS event dispatcher ───────────────────────────────────────────────────
327
- // Guard: restore hash routing only once after initial session_list arrives.
328
- let _initialRestoreDone = false;
329
-
330
- WS.onEvent(ev => {
331
- console.log("[DEBUG] WS event received:", ev.type, ev);
332
- switch (ev.type) {
333
-
334
- // ── Internal WS lifecycle ──────────────────────────────────────────
335
- case "_ws_connected": {
336
- const banner = document.getElementById("offline-banner");
337
- if (banner) banner.style.display = "none";
338
- const hint = $("ws-disconnect-hint");
339
- if (hint) hint.style.display = "none";
340
- break;
341
- }
342
-
343
- case "_ws_disconnected": {
344
- const banner = document.getElementById("offline-banner");
345
- if (banner) {
346
- banner.textContent = I18n.t("offline.banner");
347
- banner.style.display = "block";
348
- }
349
- Sessions.clearAllProgress();
350
- Sessions.updateStatusBar("idle");
351
- break;
352
- }
353
-
354
- // ── Session list ───────────────────────────────────────────────────
355
- case "session_list": {
356
- Sessions.setAll(ev.sessions || [], !!ev.has_more);
357
- Sessions.renderList();
358
-
359
- // Restore URL hash once on initial connect; ignore subsequent session_list events.
360
- // Skip if we are already on a session view (e.g. onboard flow navigated there
361
- // before WS connected) — restoreFromHash would wrongly redirect to "welcome"
362
- // because there is no hash set during onboarding.
363
- if (!_initialRestoreDone) {
364
- _initialRestoreDone = true;
365
- if (Router.current !== "session") {
366
- Router.restoreFromHash();
367
- }
368
- } else {
369
- // If active session was deleted, go to welcome
370
- if (Sessions.activeId && !Sessions.find(Sessions.activeId)) {
371
- Router.navigate("welcome");
372
- }
373
- }
374
- break;
375
- }
376
-
377
- // ── Session lifecycle ──────────────────────────────────────────────
378
- case "subscribed": {
379
- // Re-enable send button now that the server has confirmed the subscription.
380
- $("btn-send").disabled = false;
381
- $("user-input").focus();
382
- // If this session was created by Tasks.run(), fire the agent now that
383
- // we're guaranteed to receive its broadcasts.
384
- const pendingId = Sessions.takePendingRunTask();
385
- if (pendingId && pendingId === ev.session_id) {
386
- WS.send({ type: "run_task", session_id: pendingId });
387
- }
388
- // If a slash-command was queued (e.g. /onboard from first-boot flow),
389
- // send it now — after restoreFromHash has settled — so appendMsg won't be wiped.
390
- const pendingMsg = Sessions.takePendingMessage();
391
- if (pendingMsg && pendingMsg.session_id === ev.session_id) {
392
- Sessions.appendMsg("user", escapeHtml(pendingMsg.content), { time: new Date() });
393
- WS.send({ type: "message", session_id: pendingMsg.session_id, content: pendingMsg.content });
394
- }
395
- break;
396
- }
397
349
 
398
- case "session_update": {
399
- // Two shapes arrive under this type:
400
- // (1) Full session object from http_server broadcast_session_update:
401
- // { type, session: { id, name, status, total_cost, total_tasks, ... } }
402
- // (2) Partial real-time update from web_ui_controller (cost/tasks/status):
403
- // { type, session_id, cost?, tasks?, status? }
404
- let sid, patch;
405
- if (ev.session) {
406
- // Shape (1): full session — use as-is
407
- sid = ev.session.id;
408
- patch = ev.session;
409
- } else {
410
- // Shape (2): partial update — build patch from top-level fields
411
- sid = ev.session_id;
412
- patch = {};
413
- if (ev.cost !== undefined) patch.total_cost = ev.cost;
414
- if (ev.tasks !== undefined) patch.total_tasks = ev.tasks;
415
- if (ev.status !== undefined) patch.status = ev.status;
416
- // Latency pushed by Agent after each LLM call (see update_sessionbar).
417
- // Stored under latest_latency — same field name the HTTP /api/sessions
418
- // list returns, so updateInfoBar doesn't need to branch on the source.
419
- if (ev.latency !== undefined) patch.latest_latency = ev.latency;
420
- }
421
- if (!sid) break;
422
- Sessions.patch(sid, patch);
423
- Sessions.renderList();
424
- if (sid === Sessions.activeId) {
425
- const current = Sessions.find(sid);
426
- if (patch.status !== undefined) Sessions.updateStatusBar(patch.status);
427
- Sessions.updateInfoBar(current);
428
- // Update chat title/subtitle in case session was renamed or working_dir changed
429
- Sessions.updateChatHeader(current);
430
- }
431
- // When a session finishes, refresh tasks and skills, and clear any progress state
432
- if (patch.status === "idle") {
433
- Tasks.load();
434
- Skills.load();
435
- // Clear progress state for this session (even if not currently active)
436
- Sessions.clearProgress(sid);
437
- }
438
- break;
439
- }
440
-
441
- case "session_renamed": {
442
- Sessions.patch(ev.session_id, { name: ev.name });
443
- Sessions.renderList();
444
- // Title is now shown only in the sidebar; chat-header element was removed.
445
- break;
446
- }
447
-
448
- case "session_deleted":
449
- Sessions.remove(ev.session_id);
450
- if (ev.session_id === Sessions.activeId) Router.navigate("welcome");
451
- Sessions.renderList();
452
- break;
453
-
454
- // ── Chat messages ──────────────────────────────────────────────────
455
- case "history_user_message":
456
- // Emitted only during history replay — never from live WS.
457
- // Rendered by Sessions._fetchHistory; nothing to do here.
458
- break;
459
-
460
- case "assistant_message":
461
- if (ev.session_id !== Sessions.activeId) break;
462
- Sessions.clearProgress();
463
- Sessions.appendMsg("assistant", ev.content);
464
- break;
465
-
466
- case "tool_call":
467
- if (ev.session_id !== Sessions.activeId) break;
468
- Sessions.clearProgress();
469
- Sessions.appendToolCall(ev.name, ev.args, ev.summary);
470
- break;
471
-
472
- case "tool_result":
473
- if (ev.session_id !== Sessions.activeId) break;
474
- Sessions.appendToolResult(ev.result);
475
- break;
476
-
477
- case "tool_stdout":
478
- if (ev.session_id !== Sessions.activeId) break;
479
- Sessions.appendToolStdout(ev.lines);
480
- break;
481
-
482
- case "tool_error":
483
- if (ev.session_id !== Sessions.activeId) break;
484
- Sessions.appendMsg("info", `⚠ Tool error: ${escapeHtml(ev.error)}`);
485
- break;
486
-
487
- case "token_usage":
488
- if (ev.session_id !== Sessions.activeId) break;
489
- Sessions.appendTokenUsage(ev);
490
- break;
491
-
492
- case "progress":
493
- console.log("[DEBUG] progress event:", ev);
494
- if (ev.session_id !== Sessions.activeId) break;
495
- if (ev.phase === "active" || ev.status === "start") {
496
- const progress_type = ev.progress_type || "thinking";
497
- const metadata = ev.metadata || {};
498
- console.log("[DEBUG] calling showProgress:", { message: ev.message, progress_type, metadata, started_at: ev.started_at });
499
- Sessions.showProgress(ev.message, progress_type, metadata, ev.started_at || null);
500
- } else {
501
- console.log("[DEBUG] calling clearProgress:", ev.message);
502
- Sessions.clearProgress(ev.message);
503
- }
504
- break;
505
-
506
- case "complete":
507
- if (ev.session_id !== Sessions.activeId) break;
508
- Sessions.clearProgress();
509
- Sessions.collapseToolGroup();
510
- {
511
- const costSource = ev.cost_source;
512
- const costDisplay = (!costSource || costSource === "estimated")
513
- ? "N/A"
514
- : `$${(ev.cost || 0).toFixed(4)}`;
515
- Sessions.appendInfo(`✓ ${I18n.t("chat.done", { n: ev.iterations, cost: costDisplay })}`);
516
- }
517
- break;
518
-
519
- case "request_feedback":
520
- if (ev.session_id !== Sessions.activeId) break;
521
- Sessions.showFeedbackRequest(ev.question, ev.context, ev.options);
522
- break;
523
-
524
- case "request_confirmation":
525
- if (ev.session_id !== Sessions.activeId) break;
526
- showConfirmModal(ev.id, ev.message);
527
- break;
528
-
529
- case "interrupted":
530
- if (ev.session_id !== Sessions.activeId) break;
531
- Sessions.clearProgress();
532
- Sessions.collapseToolGroup();
533
- Sessions.appendInfo(I18n.t("chat.interrupted"));
534
- break;
535
-
536
- // ── Info / errors ──────────────────────────────────────────────────
537
- case "info":
538
- Sessions.appendInfo(ev.message);
539
- break;
540
-
541
- case "warning":
542
- // Optimize retry messages for better UX
543
- const friendlyWarning = _transformRetryWarning(ev.message);
544
- if (friendlyWarning) {
545
- Sessions.appendInfo(friendlyWarning);
546
- }
547
- break;
548
-
549
- case "success":
550
- Sessions.appendMsg("success", "✓ " + escapeHtml(ev.message));
551
- break;
552
-
553
- case "error":
554
- if (!ev.session_id || ev.session_id === Sessions.activeId)
555
- Sessions.appendMsg("error", escapeHtml(ev.message));
556
- break;
557
- }
558
- });
350
+ // ── WS event dispatcher ───────────────────────────────────────────────────
351
+ // Moved to ws-dispatcher.js.
559
352
 
560
353
  // ── Image & file attachments ──────────────────────────────────────────────
561
- const _pendingImages = [];
562
- const _pendingFiles = [];
563
- const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB hard reject before compression
564
- const MAX_IMAGE_BYTES_SEND = 512 * 1024; // 512 KB — target after compression
565
- const MAX_IMAGE_LONG_EDGE = 1920; // px — scale down if larger
566
- const MAX_FILE_BYTES = 32 * 1024 * 1024; // 32 MB
567
- const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
568
- const ACCEPTED_DOC_TYPES = [
569
- "application/pdf",
570
- "application/zip",
571
- "application/x-zip-compressed",
572
- "application/gzip",
573
- "application/x-gzip",
574
- "application/x-tar",
575
- "application/x-compressed-tar",
576
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
577
- "application/msword", // .doc
578
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
579
- "application/vnd.ms-excel", // .xls
580
- "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
581
- "application/vnd.ms-powerpoint", // .ppt
582
- "text/csv", // .csv
583
- "application/csv", // .csv (some browsers)
584
- "text/markdown", // .md
585
- "text/x-markdown", // .md (some browsers)
586
- "text/plain", // .md / .txt (many browsers report this)
587
- ];
588
-
589
- // Extension-based fallback for files whose MIME type is missing or unreliable.
590
- // Browsers frequently report "" or "application/octet-stream" for .md / .tar.gz.
591
- const ACCEPTED_DOC_EXTENSIONS = [
592
- ".pdf", ".zip",
593
- ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
594
- ".csv",
595
- ".md", ".markdown", ".txt", ".log",
596
- ".tar", ".gz", ".tgz", ".tar.gz", ".rar", ".7z"
597
- ];
598
-
599
- function _hasAcceptedDocExt(filename) {
600
- const lower = (filename || "").toLowerCase();
601
- return ACCEPTED_DOC_EXTENSIONS.some(ext => lower.endsWith(ext));
602
- }
603
-
604
- function _isAcceptedDoc(file) {
605
- if (!file) return false;
606
- if (file.type && ACCEPTED_DOC_TYPES.includes(file.type)) return true;
607
- return _hasAcceptedDocExt(file.name);
608
- }
609
-
610
- function _isAcceptedImage(file) {
611
- if (!file) return false;
612
- return ACCEPTED_IMAGE_TYPES.includes(file.type);
613
- }
614
-
615
- function _isAcceptedFile(file) {
616
- return _isAcceptedImage(file) || _isAcceptedDoc(file);
617
- }
618
-
619
- function _docTypeIcon(mimeType, filename) {
620
- const lower = (filename || "").toLowerCase();
621
- if (mimeType === "application/pdf" || lower.endsWith(".pdf")) return "📄";
622
- if (mimeType === "application/zip" || mimeType === "application/x-zip-compressed" || lower.endsWith(".zip")) return "🗜️";
623
- if (mimeType === "application/gzip" || mimeType === "application/x-gzip" ||
624
- mimeType === "application/x-tar" || mimeType === "application/x-compressed-tar" ||
625
- lower.endsWith(".tar") || lower.endsWith(".gz") || lower.endsWith(".tgz") || lower.endsWith(".tar.gz") ||
626
- lower.endsWith(".rar") || lower.endsWith(".7z")) return "🗜️";
627
- if ((mimeType && mimeType.includes("wordprocessingml")) || mimeType === "application/msword" ||
628
- lower.endsWith(".doc") || lower.endsWith(".docx")) return "📝";
629
- if ((mimeType && mimeType.includes("spreadsheetml")) || mimeType === "application/vnd.ms-excel" ||
630
- lower.endsWith(".xls") || lower.endsWith(".xlsx")) return "📊";
631
- if ((mimeType && mimeType.includes("presentationml")) || mimeType === "application/vnd.ms-powerpoint" ||
632
- lower.endsWith(".ppt") || lower.endsWith(".pptx")) return "📋";
633
- if (mimeType === "text/csv" || mimeType === "application/csv" || lower.endsWith(".csv")) return "📊";
634
- if (mimeType === "text/markdown" || mimeType === "text/x-markdown" ||
635
- lower.endsWith(".md") || lower.endsWith(".markdown")) return "📝";
636
- if (mimeType === "text/plain" || lower.endsWith(".txt") || lower.endsWith(".log")) return "📄";
637
- return "📎";
638
- }
639
-
640
- // Compress an image File/Blob to a data URL within MAX_IMAGE_BYTES_SEND.
641
- // Strategy: scale down to MAX_IMAGE_LONG_EDGE, then reduce JPEG quality until small enough.
642
- // GIF is not compressible via Canvas — returned as-is if within limit.
643
- function _compressImage(file) {
644
- return new Promise((resolve, reject) => {
645
- const reader = new FileReader();
646
- reader.onerror = () => reject(new Error("Failed to read image"));
647
- reader.onload = e => {
648
- const img = new Image();
649
- img.onerror = () => reject(new Error("Failed to decode image"));
650
- img.onload = () => {
651
- // Scale down if needed
652
- let { width, height } = img;
653
- if (width > MAX_IMAGE_LONG_EDGE || height > MAX_IMAGE_LONG_EDGE) {
654
- const ratio = Math.min(MAX_IMAGE_LONG_EDGE / width, MAX_IMAGE_LONG_EDGE / height);
655
- width = Math.round(width * ratio);
656
- height = Math.round(height * ratio);
657
- }
658
-
659
- const canvas = document.createElement("canvas");
660
- canvas.width = width;
661
- canvas.height = height;
662
- const ctx = canvas.getContext("2d");
663
- ctx.drawImage(img, 0, 0, width, height);
664
-
665
- // GIF: LLMs only see the first frame anyway — render via Canvas and compress as JPEG
666
-
667
- // Try decreasing quality until under limit
668
- let quality = 0.85;
669
- let dataUrl = canvas.toDataURL("image/jpeg", quality);
670
- while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && quality > 0.2) {
671
- quality -= 0.1;
672
- dataUrl = canvas.toDataURL("image/jpeg", quality);
673
- }
674
- resolve(dataUrl);
675
- };
676
- img.src = e.target.result;
677
- };
678
- reader.readAsDataURL(file);
679
- });
680
- }
681
-
682
- function _addImageFile(file) {
683
- if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
684
- alert(`Unsupported image type: ${file.type}\nSupported: PNG, JPEG, GIF, WEBP`);
685
- return;
686
- }
687
- if (file.size > MAX_IMAGE_SIZE) {
688
- alert(`Image too large: ${file.name} (max 5 MB)`);
689
- return;
690
- }
691
- _compressImage(file)
692
- .then(dataUrl => {
693
- _pendingImages.push({ dataUrl, name: file.name, mimeType: "image/jpeg" });
694
- _renderAttachmentPreviews();
695
- })
696
- .catch(err => alert(`Image processing failed: ${err.message}`));
697
- }
698
-
699
- function _addGenericFile(file) {
700
- if (file.size > MAX_FILE_BYTES) {
701
- alert(`File too large: ${file.name} (max 32 MB)`);
702
- return;
703
- }
704
- // Upload file to server via HTTP — only the path is returned, no base64 in memory
705
- const formData = new FormData();
706
- formData.append("file", file);
707
- fetch("/api/upload", { method: "POST", body: formData })
708
- .then(r => r.json())
709
- .then(data => {
710
- if (!data.ok) { alert(`Upload failed: ${data.error}`); return; }
711
- _pendingFiles.push({
712
- name: data.name,
713
- path: data.path,
714
- mime_type: file.type
715
- });
716
- _renderAttachmentPreviews();
717
- setTimeout(() => $("user-input").focus(), 100);
718
- })
719
- .catch(err => alert(`Upload error: ${err.message}`));
720
- }
721
-
722
- function _addAttachmentFile(file) {
723
- // Route by content category. Images must match known image MIME types
724
- // (MIME is reliable for images). Documents fall back to extension-based
725
- // detection because browsers frequently report "" or "application/octet-stream"
726
- // for .md / .tar.gz files.
727
- if (_isAcceptedImage(file)) {
728
- _addImageFile(file);
729
- } else if (_isAcceptedDoc(file)) {
730
- _addGenericFile(file);
731
- } else {
732
- // Unknown type — surface a helpful error instead of silently rejecting.
733
- alert(`Unsupported file: ${file.name}\nSupported: images (PNG/JPG/GIF/WEBP), PDF, Office (DOC/XLS/PPT), ZIP, TAR, TAR.GZ, MD, TXT, CSV`);
734
- }
735
- }
736
-
737
- function _renderAttachmentPreviews() {
738
- const strip = $("image-preview-strip");
739
- strip.innerHTML = "";
740
- const hasContent = _pendingImages.length > 0 || _pendingFiles.length > 0;
741
- if (!hasContent) {
742
- strip.style.display = "none";
743
- return;
744
- }
745
- strip.style.display = "flex";
746
-
747
- // Render image thumbnails
748
- _pendingImages.forEach((img, idx) => {
749
- const item = document.createElement("div");
750
- item.className = "img-preview-item";
751
- item.title = img.name;
752
- const thumbnail = document.createElement("img");
753
- thumbnail.src = img.dataUrl;
754
- thumbnail.alt = img.name;
755
- const removeBtn = document.createElement("button");
756
- removeBtn.className = "img-preview-remove";
757
- removeBtn.textContent = "✕";
758
- removeBtn.title = "Remove";
759
- removeBtn.addEventListener("click", () => {
760
- _pendingImages.splice(idx, 1);
761
- _renderAttachmentPreviews();
762
- });
763
- item.appendChild(thumbnail);
764
- item.appendChild(removeBtn);
765
- strip.appendChild(item);
766
- });
767
-
768
- // Render file cards (PDF, ZIP, DOC, XLS, PPT, etc.)
769
- _pendingFiles.forEach((f, idx) => {
770
- const item = document.createElement("div");
771
- item.className = "pdf-preview-item";
772
- item.title = f.name;
773
-
774
- const icon = document.createElement("div");
775
- icon.className = "pdf-preview-icon";
776
- icon.textContent = _docTypeIcon(f.mime_type, f.name);
777
-
778
- const info = document.createElement("div");
779
- info.className = "pdf-preview-info";
780
-
781
- const name = document.createElement("div");
782
- name.className = "pdf-preview-name";
783
- name.textContent = f.name;
784
-
785
- const typeLabel = document.createElement("div");
786
- typeLabel.className = "pdf-preview-type";
787
- const _lowerName = (f.name || "").toLowerCase();
788
- typeLabel.textContent = _lowerName.endsWith(".tar.gz")
789
- ? "TAR.GZ"
790
- : (f.name.split(".").pop() || "file").toUpperCase();
791
-
792
- info.appendChild(name);
793
- info.appendChild(typeLabel);
794
-
795
- const removeBtn = document.createElement("button");
796
- removeBtn.className = "pdf-preview-remove";
797
- removeBtn.textContent = "✕";
798
- removeBtn.title = "Remove";
799
- removeBtn.addEventListener("click", () => {
800
- _pendingFiles.splice(idx, 1);
801
- _renderAttachmentPreviews();
802
- });
803
-
804
- item.appendChild(icon);
805
- item.appendChild(info);
806
- item.appendChild(removeBtn);
807
- strip.appendChild(item);
808
- });
809
- }
810
-
811
- // Keep backward-compat alias (used in drag-drop / paste handlers below)
812
- function _renderImagePreviews() { _renderAttachmentPreviews(); }
813
-
814
- // ── Send message ──────────────────────────────────────────────────────────
815
- let _sending = false;
816
-
817
- function sendMessage() {
818
- if (_sending) return;
819
- const input = $("user-input");
820
- const content = input.value.trim();
821
- if (!content && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
822
- if (!Sessions.activeId) return;
823
-
824
- if (!WS.ready) {
825
- const hint = $("ws-disconnect-hint");
826
- if (hint) {
827
- hint.textContent = I18n.t("chat.disconnected.hint");
828
- hint.style.display = "block";
829
- hint.style.opacity = "1";
830
- clearTimeout(hint._hideTimer);
831
- hint._hideTimer = setTimeout(() => {
832
- hint.style.opacity = "0";
833
- setTimeout(() => { hint.style.display = "none"; }, 400);
834
- }, 2000);
835
- }
836
- return;
837
- }
838
-
839
- _sending = true;
840
-
841
- let bubbleHtml = content ? escapeHtml(content) : "";
842
- if (_pendingImages.length > 0) {
843
- const thumbs = _pendingImages
844
- .map(img => `<img src="${img.dataUrl}" alt="${escapeHtml(img.name)}" class="msg-image-thumb">`)
845
- .join("");
846
- bubbleHtml = thumbs + (bubbleHtml ? "<br>" + bubbleHtml : "");
847
- }
848
- if (_pendingFiles.length > 0) {
849
- const badges = _pendingFiles.map(f => {
850
- const icon = _docTypeIcon(f.mime_type);
851
- const ext = (f.name.split(".").pop() || "file").toUpperCase();
852
- return `<span class="msg-pdf-badge">` +
853
- `<span class="msg-pdf-badge-icon">${icon}</span>` +
854
- `<span class="msg-pdf-badge-info">` +
855
- `<span class="msg-pdf-badge-name">${escapeHtml(f.name)}</span>` +
856
- `<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span>` +
857
- `</span>` +
858
- `</span>`;
859
- }).join(" ");
860
- bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
861
- }
862
- Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
863
-
864
- // Merge images and files into unified files array
865
- const files = [
866
- ..._pendingImages.map(img => ({
867
- name: img.name,
868
- mime_type: img.mimeType || "image/jpeg",
869
- data_url: img.dataUrl
870
- })),
871
- ..._pendingFiles.map(f => ({
872
- name: f.name,
873
- path: f.path,
874
- mime_type: f.mime_type
875
- }))
876
- ];
877
- _pendingImages.length = 0;
878
- _pendingFiles.length = 0;
879
- _renderAttachmentPreviews();
880
-
881
- WS.send({ type: "message", session_id: Sessions.activeId, content, files });
882
- input.value = "";
883
- input.style.height = "auto";
884
- setTimeout(() => { _sending = false; }, 300);
885
- }
354
+ // Moved to sessions.js (Composer section — _initComposer() in Sessions.init()).
355
+ // All state (_pendingImages/_pendingFiles), helpers (_addAttachmentFile/etc.),
356
+ // preview rendering, and sendMessage() now live there as private members.
886
357
 
887
358
  // ── DOM event listeners ───────────────────────────────────────────────────
888
359
  // Sidebar toggle (with mobile overlay support)
@@ -928,625 +399,31 @@ function _mobileCloseSidebar() {
928
399
  if (_isMobile()) _closeSidebar();
929
400
  }
930
401
 
931
- // ── New session split button [+ ▾] ────────────────────────────────────────
932
- // Main button: quick create (like before)
933
- // Arrow button: show dropdown with "Advanced Options..." to open modal
934
- if ($("btn-new-session-inline")) {
935
- $("btn-new-session-inline").addEventListener("click", () => Sessions.create("general"));
936
- }
937
- if ($("btn-new-session-arrow")) {
938
- $("btn-new-session-arrow").addEventListener("click", (e) => {
939
- e.stopPropagation();
940
- const dd = $("new-session-dropdown");
941
- if (dd) dd.hidden = !dd.hidden;
942
- });
943
- }
944
- // Dropdown item: Advanced Options → open modal
945
- document.addEventListener("click", (e) => {
946
- if (e.target && e.target.id === "btn-new-session-modal") {
947
- e.stopPropagation();
948
- $("new-session-dropdown").hidden = true;
949
- Sessions.openNewSessionModal();
950
- }
951
- });
952
- // Close dropdown when clicking elsewhere
953
- document.addEventListener("click", () => {
954
- const dd = $("new-session-dropdown");
955
- if (dd && !dd.hidden) dd.hidden = true;
956
- });
957
-
958
- $("btn-welcome-new").addEventListener("click", () => Sessions.create("general"));
959
-
960
- // ── New Session Modal event handlers ───────────────────────────────────────
961
- if ($("new-session-modal-close")) {
962
- $("new-session-modal-close").addEventListener("click", () => Sessions.closeNewSessionModal());
963
- }
964
- if ($("new-session-cancel")) {
965
- $("new-session-cancel").addEventListener("click", () => Sessions.closeNewSessionModal());
966
- }
967
- if ($("new-session-create")) {
968
- $("new-session-create").addEventListener("click", () => Sessions.createFromModal());
969
- }
970
- // Close modal when clicking overlay
971
- if ($("new-session-modal")) {
972
- $("new-session-modal").addEventListener("click", (e) => {
973
- if (e.target.id === "new-session-modal") {
974
- Sessions.closeNewSessionModal();
975
- }
976
- });
977
- }
978
- // Browse button (placeholder - actual file browsing would need native integration)
979
- if ($("new-session-browse-btn")) {
980
- $("new-session-browse-btn").addEventListener("click", () => {
981
- alert("Tip: Enter your desired path directly, e.g., ~/clacky_workspace/my-project");
982
- });
983
- }
984
-
985
- // Load-more sessions button (dynamic — rendered by Sessions.renderList, use delegation)
986
- document.addEventListener("click", e => {
987
- if (e.target && e.target.id === "btn-load-more-sessions") {
988
- Sessions.loadMore();
989
- }
990
- });
402
+ // ── New session controls ───────────────────────────────────────────────────
403
+ // Moved to sessions.js (_initNewSessionControls, called from Sessions.init()).
991
404
 
992
405
  // ── Session search bar ─────────────────────────────────────────────────────
993
- // Magnifier toggle button
994
- document.addEventListener("click", e => {
995
- if (e.target && e.target.closest("#btn-session-search-toggle")) {
996
- Sessions.toggleSearch();
997
- }
998
- });
406
+ // Moved to sessions.js (_initSearch in Sessions.init()).
999
407
 
1000
- // Close button inside panel
1001
- document.addEventListener("click", e => {
1002
- if (e.target && e.target.id === "btn-session-search-close") {
1003
- if (Sessions.searchOpen) Sessions.toggleSearch();
1004
- }
1005
- });
1006
-
1007
- // Enter key → commitSearch
1008
- document.addEventListener("keydown", e => {
1009
- if (e.key === "Enter" && e.target && e.target.id === "session-search-q") {
1010
- e.preventDefault();
1011
- Sessions.commitSearch();
1012
- }
1013
- });
1014
-
1015
- // Inline ✕ button — clear the q input and re-fetch
1016
- document.addEventListener("click", e => {
1017
- if (e.target && e.target.id === "btn-search-q-clear") {
1018
- const qEl = document.getElementById("session-search-q");
1019
- if (qEl) qEl.value = "";
1020
- Sessions.clearFilter("q");
1021
- }
1022
- });
1023
-
1024
- // Clear active filters (type + date) and re-fetch once
1025
- document.addEventListener("click", e => {
1026
- if (e.target && e.target.id === "btn-search-clear-all") {
1027
- const typeEl = document.getElementById("session-search-type");
1028
- const dateEl = document.getElementById("session-search-date");
1029
- if (typeEl) typeEl.value = "";
1030
- if (dateEl) dateEl.value = "";
1031
- Sessions.commitSearch();
1032
- }
1033
- });
1034
-
1035
- // Show/hide inline ✕ as user types in search input
1036
- document.addEventListener("input", e => {
1037
- if (e.target && e.target.id === "session-search-q") {
1038
- const btn = document.getElementById("btn-search-q-clear");
1039
- if (btn) btn.hidden = !e.target.value;
1040
- }
1041
- });
1042
-
1043
- // Type select and date change → immediate commit (they're explicit choices, not typing)
1044
- document.addEventListener("change", e => {
1045
- if (e.target && (e.target.id === "session-search-type" || e.target.id === "session-search-date")) {
1046
- Sessions.commitSearch();
1047
- }
1048
- });
408
+ // ── Theme / session-scoped message panel bindings ──────────────────────────
1049
409
 
1050
410
  // Theme toggle in header
1051
411
  if ($("theme-toggle-header")) {
1052
412
  $("theme-toggle-header").addEventListener("click", () => Theme.toggle());
1053
413
  }
1054
- // btn-delete-session was removed with chat-header; deletion is now triggered from
1055
- // the bottom session-info-bar actions dropdown (see Sessions._showActionsMenu).
1056
- const _btnDeleteSession = $("btn-delete-session");
1057
- if (_btnDeleteSession) {
1058
- _btnDeleteSession.addEventListener("click", () => {
1059
- if (Sessions.activeId) Sessions.deleteSession(Sessions.activeId);
1060
- });
1061
- }
414
+ // btn-delete-session, #messages scroll-to-top (load history), and btn-interrupt
415
+ // moved to sessions.js (_initMessageHistory in Sessions.init()).
1062
416
 
1063
- // Load older history when the user scrolls to the top of the message list
1064
- $("messages").addEventListener("scroll", () => {
1065
- const messages = $("messages");
1066
- if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
1067
- Sessions.loadMoreHistory(Sessions.activeId);
1068
- }
1069
- });
1070
- $("btn-send").addEventListener("click", sendMessage);
1071
- $("btn-interrupt").addEventListener("click", () =>
1072
- WS.send({ type: "interrupt", session_id: Sessions.activeId })
1073
- );
1074
-
1075
- $("btn-attach").addEventListener("click", () => $("image-file-input").click());
1076
-
1077
- // / button: set input to "/" and open skill autocomplete
1078
- // mousedown + preventDefault prevents the textarea from losing focus (which would
1079
- // trigger the blur→hide timer and immediately close the dropdown we're about to open).
1080
- $("btn-slash").addEventListener("mousedown", e => {
1081
- e.preventDefault(); // keep focus on user-input
1082
- });
1083
- $("btn-slash").addEventListener("click", () => {
1084
- const input = $("user-input");
1085
- if (input.value === "" || input.value === "/") {
1086
- input.value = "/";
1087
- input.style.height = "auto";
1088
- input.style.height = Math.min(input.scrollHeight, 200) + "px";
1089
- }
1090
- SkillAC.toggle(); // Toggle dropdown instead of always opening
1091
- if (SkillAC.visible) {
1092
- $("btn-slash").classList.add("active");
1093
- }
1094
- input.focus();
1095
- });
1096
- $("image-file-input").addEventListener("change", e => {
1097
- Array.from(e.target.files).forEach(_addAttachmentFile);
1098
- e.target.value = "";
1099
- });
1100
-
1101
- const inputArea = document.getElementById("input-area");
1102
- inputArea.addEventListener("dragover", e => {
1103
- e.preventDefault();
1104
- inputArea.classList.add("drag-over");
1105
- });
1106
- inputArea.addEventListener("dragleave", e => {
1107
- if (!inputArea.contains(e.relatedTarget)) inputArea.classList.remove("drag-over");
1108
- });
1109
- inputArea.addEventListener("drop", e => {
1110
- e.preventDefault();
1111
- inputArea.classList.remove("drag-over");
1112
- const files = Array.from(e.dataTransfer.files).filter(_isAcceptedFile);
1113
- if (files.length === 0) return;
1114
- files.forEach(_addAttachmentFile);
1115
- });
1116
-
1117
- $("user-input").addEventListener("paste", e => {
1118
- const items = Array.from(e.clipboardData?.items || []);
1119
- // Paste filter: any file-kind item that's an image, or a document whose
1120
- // type/name passes our doc filter. Must check name via getAsFile() for
1121
- // markdown/tar.gz (browsers often leave item.type empty for these).
1122
- const attachItems = items.filter(it => {
1123
- if (it.kind !== "file") return false;
1124
- if (ACCEPTED_IMAGE_TYPES.includes(it.type)) return true;
1125
- if (ACCEPTED_DOC_TYPES.includes(it.type)) return true;
1126
- // Last resort: check filename extension on the actual File object.
1127
- const f = it.getAsFile && it.getAsFile();
1128
- return f ? _hasAcceptedDocExt(f.name) : false;
1129
- });
1130
- if (attachItems.length === 0) return;
1131
- e.preventDefault();
1132
- attachItems.forEach(it => _addAttachmentFile(it.getAsFile()));
1133
- });
1134
-
1135
- // Cross-browser IME composition fix:
1136
- // Safari fires compositionend BEFORE keydown (violating W3C spec), and the
1137
- // gap between compositionend and keydown is ~5ms on Safari. We record the
1138
- // timestamp of compositionend and treat any Enter keydown within 20ms as
1139
- // still-composing. Chrome is unaffected because e.isComposing is still true.
1140
- // Reference: https://bugs.webkit.org/show_bug.cgi?id=165004
1141
- let _lastCompositionEndTime = -Infinity;
1142
- $("user-input").addEventListener("compositionend", () => {
1143
- _lastCompositionEndTime = Date.now();
1144
- });
1145
-
1146
- // ── Skill autocomplete ────────────────────────────────────────────────────
1147
- const SkillAC = (() => {
1148
- let _visible = false;
1149
- let _activeIndex = -1;
1150
- let _items = []; // filtered [{ name, description, encrypted, source }]
1151
- let _currentSession = null; // track active session id for live fetch
1152
-
1153
- // Load from localStorage, default to false (hide system skills)
1154
- let _showSystemSkills = localStorage.getItem("skill-ac-show-system") === "true";
1155
-
1156
- /** Called whenever the active session changes — just store the id, no prefetch. */
1157
- function _loadForSession(sessionId) {
1158
- _currentSession = sessionId || null;
1159
- }
1160
-
1161
- /** Fetch live skill list from server for the current session. */
1162
- async function _fetchSkills() {
1163
- if (!_currentSession) return [];
1164
- try {
1165
- const res = await fetch(`/api/sessions/${_currentSession}/skills`);
1166
- const data = await res.json();
1167
- return data.skills || [];
1168
- } catch (e) {
1169
- console.error("[SkillAC] fetchSkills failed", e);
1170
- return [];
1171
- }
1172
- }
1173
-
1174
- /** Return the /xxx prefix if the entire input is a slash command, else null. */
1175
- function _getSlashQuery(value) {
1176
- // Full-width slash / dunhao are already replaced in the input event handler,
1177
- // but guard here too in case value is passed programmatically.
1178
- let trimmed = value.replace(/^[/、]/, "/");
1179
-
1180
- // Only activate when the whole input starts with / (no leading space)
1181
- if (!trimmed.startsWith("/")) return null;
1182
- // Only single-word slash token — no spaces allowed after /
1183
- if (/^\/\S*$/.test(trimmed)) return trimmed.slice(1).toLowerCase();
1184
- return null;
1185
- }
1186
-
1187
- /**
1188
- * Score how well a skill matches the query string.
1189
- * Only matches against name and name_zh — description is intentionally excluded.
1190
- * All matches are contiguous substring matches (no fuzzy/subsequence).
1191
- * Returns 0 if no match (should be filtered out).
1192
- *
1193
- * Scoring tiers:
1194
- * 100 — name or name_zh exact match
1195
- * 80 — name or name_zh starts-with
1196
- * 60 — name or name_zh contains
1197
- * 0 — no match
1198
- */
1199
- function _scoreMatch(skill, query) {
1200
- if (!query) return 50; // empty query → show all with neutral score
1201
-
1202
- const q = query.toLowerCase();
1203
- const name = (skill.name || "").toLowerCase();
1204
- const zh = (skill.name_zh || "").toLowerCase();
1205
-
1206
- // Exact match
1207
- if (name === q || zh === q) return 100;
1208
-
1209
- // Prefix match
1210
- if (name.startsWith(q) || zh.startsWith(q)) return 80;
1211
-
1212
- // Contains match (contiguous substring)
1213
- if (name.includes(q) || zh.includes(q)) return 60;
1214
-
1215
- return 0;
1216
- }
1217
-
1218
- /**
1219
- * Wrap the matching substring in <mark> for highlighting.
1220
- * Returns an array of DOM nodes (text + mark nodes).
1221
- */
1222
- function _highlight(text, query) {
1223
- if (!query) return [document.createTextNode(text)];
1224
- const idx = text.toLowerCase().indexOf(query.toLowerCase());
1225
- if (idx === -1) return [document.createTextNode(text)];
1226
-
1227
- const nodes = [];
1228
- if (idx > 0) nodes.push(document.createTextNode(text.slice(0, idx)));
1229
- const mark = document.createElement("span");
1230
- mark.className = "skill-ac-highlight";
1231
- mark.textContent = text.slice(idx, idx + query.length);
1232
- nodes.push(mark);
1233
- if (idx + query.length < text.length) {
1234
- nodes.push(document.createTextNode(text.slice(idx + query.length)));
1235
- }
1236
- return nodes;
1237
- }
1238
-
1239
- async function _render(query) {
1240
- const all = await _fetchSkills();
1241
-
1242
- // Score and filter
1243
- let scored = all
1244
- .map(s => ({ skill: s, score: _scoreMatch(s, query) }))
1245
- .filter(({ score }) => score > 0);
1246
-
1247
- if (!_showSystemSkills) {
1248
- scored = scored.filter(({ skill }) => skill.source_type !== "default");
1249
- }
417
+ // btn-send, btn-attach, image-file-input change, input-area drag/drop, and
418
+ // user-input paste handlers moved to sessions.js (_initComposer).
1250
419
 
1251
- // Sort by score descending, stable secondary sort by name
1252
- scored.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
1253
420
 
1254
- _items = scored.map(({ skill }) => skill);
421
+ // ── Skill autocomplete + composer bindings ───────────────────────────────
422
+ // Moved to skills.js (SkillAC IIFE, initialized from SkillAC.init()).
1255
423
 
1256
- const list = $("skill-autocomplete-list");
1257
- list.innerHTML = "";
1258
-
1259
- if (_items.length === 0) {
1260
- // Show empty state instead of hiding the dropdown
1261
- const emptyEl = document.createElement("div");
1262
- emptyEl.className = "skill-ac-empty";
1263
- emptyEl.textContent = I18n.t("skills.ac.empty");
1264
- list.appendChild(emptyEl);
1265
- $("skill-autocomplete").style.display = "";
1266
- _visible = true;
1267
- _createOverlay();
1268
- return;
1269
- }
1270
-
1271
- _items.forEach((skill, idx) => {
1272
- const item = document.createElement("div");
1273
- item.className = "skill-ac-item" + (idx === _activeIndex ? " active" : "");
1274
- item.setAttribute("role", "option");
1275
- item.setAttribute("data-idx", idx);
1276
-
1277
- const nameEl = document.createElement("span");
1278
- nameEl.className = "skill-ac-name";
1279
-
1280
- const currentLangForName = I18n.lang();
1281
- const showZhFirst = currentLangForName === "zh" && skill.name_zh;
1282
-
1283
- if (showZhFirst) {
1284
- // Chinese UI: /中文名 first (with slash), then english id (no slash) after
1285
- const zhEl = document.createElement("span");
1286
- zhEl.className = "skill-ac-name-zh";
1287
- zhEl.appendChild(document.createTextNode("/"));
1288
- _highlight(skill.name_zh, query).forEach(function(n) { zhEl.appendChild(n); });
1289
- nameEl.appendChild(zhEl);
1290
-
1291
- const nameTextEl = document.createElement("span");
1292
- nameTextEl.className = "skill-ac-name-id";
1293
- _highlight(skill.name, query).forEach(function(n) { nameTextEl.appendChild(n); });
1294
- nameEl.appendChild(nameTextEl);
1295
- } else {
1296
- // English UI (or no zh name): show /id only, no zh name
1297
- const nameTextEl = document.createElement("span");
1298
- nameTextEl.appendChild(document.createTextNode("/"));
1299
- _highlight(skill.name, query).forEach(function(n) { nameTextEl.appendChild(n); });
1300
- nameEl.appendChild(nameTextEl);
1301
- }
1302
-
1303
- // meta: encrypted badge + source type label (subtle)
1304
- const metaEl = document.createElement("span");
1305
- metaEl.className = "skill-ac-meta";
1306
- if (skill.encrypted) {
1307
- const encBadge = document.createElement("span");
1308
- encBadge.className = "skill-ac-enc";
1309
- encBadge.textContent = "🔒";
1310
- metaEl.appendChild(encBadge);
1311
- }
1312
- const sourceLabel = {
1313
- "default": "built-in",
1314
- "global_clacky": "user",
1315
- "global_claude": "user",
1316
- "project_clacky": "project",
1317
- "project_claude": "project",
1318
- "brand": "brand",
1319
- }[skill.source_type];
1320
- if (sourceLabel) {
1321
- const srcEl = document.createElement("span");
1322
- srcEl.className = "skill-ac-src";
1323
- srcEl.textContent = sourceLabel;
1324
- metaEl.appendChild(srcEl);
1325
- }
1326
-
1327
- const descEl = document.createElement("span");
1328
- descEl.className = "skill-ac-desc";
1329
- // Choose description based on current language
1330
- const description = (currentLangForName === "zh" && skill.description_zh)
1331
- ? skill.description_zh
1332
- : skill.description || "";
1333
- descEl.textContent = description;
1334
-
1335
- item.appendChild(nameEl);
1336
- item.appendChild(metaEl);
1337
- item.appendChild(descEl);
1338
-
1339
- item.addEventListener("mousedown", e => {
1340
- // mousedown fires before blur — prevent input losing focus
1341
- e.preventDefault();
1342
- _select(idx);
1343
- });
1344
-
1345
- list.appendChild(item);
1346
- });
1347
-
1348
- $("skill-autocomplete").style.display = "";
1349
- _visible = true;
1350
- _createOverlay();
1351
- }
1352
-
1353
- function _hide() {
1354
- $("skill-autocomplete").style.display = "none";
1355
- _visible = false;
1356
- _activeIndex = -1;
1357
- _items = [];
1358
- $("btn-slash")?.classList.remove("active");
1359
- _removeOverlay();
1360
- }
1361
-
1362
- function _createOverlay() {
1363
- // Remove existing overlay if any
1364
- _removeOverlay();
1365
-
1366
- const overlay = document.createElement("div");
1367
- overlay.id = "skill-ac-overlay";
1368
- overlay.style.cssText = "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999; background: transparent;";
1369
-
1370
- // Click overlay to close dropdown
1371
- overlay.addEventListener("click", () => {
1372
- _hide();
1373
- });
1374
-
1375
- document.body.appendChild(overlay);
1376
- }
1377
-
1378
- function _removeOverlay() {
1379
- const overlay = document.getElementById("skill-ac-overlay");
1380
- if (overlay) overlay.remove();
1381
- }
1382
-
1383
- function _select(idx) {
1384
- const skill = _items[idx];
1385
- if (!skill) return;
1386
- const input = $("user-input");
1387
- input.value = "/" + skill.name + " ";
1388
- input.style.height = "auto";
1389
- input.style.height = Math.min(input.scrollHeight, 200) + "px";
1390
- _hide();
1391
- input.focus();
1392
- }
1393
-
1394
- function _moveActive(delta) {
1395
- if (!_visible || _items.length === 0) return;
1396
- _activeIndex = (_activeIndex + delta + _items.length) % _items.length;
1397
- // Re-render to apply active class
1398
- const list = $("skill-autocomplete-list");
1399
- list.querySelectorAll(".skill-ac-item").forEach((el, i) => {
1400
- el.classList.toggle("active", i === _activeIndex);
1401
- if (i === _activeIndex) el.scrollIntoView({ block: "nearest" });
1402
- });
1403
- }
1404
-
1405
- /** Open the dropdown showing all skills, used by the / button. */
1406
- async function _openAll() {
1407
- _activeIndex = 0; // Default to first item
1408
- await _render("");
1409
- $("user-input").focus();
1410
- }
1411
-
1412
- /** Toggle the dropdown (open if hidden, close if visible). */
1413
- async function _toggle() {
1414
- if (_visible) {
1415
- _hide();
1416
- } else {
1417
- await _openAll();
1418
- }
1419
- }
1420
-
1421
- return {
1422
- get visible() { return _visible; },
1423
- get activeIndex() { return _activeIndex; },
1424
-
1425
- /** Initialize event listeners (call once on page load). */
1426
- init() {
1427
- const chk = $("chk-ac-show-system-skills");
1428
-
1429
- if (chk) {
1430
- // Restore state from localStorage
1431
- chk.checked = _showSystemSkills;
1432
-
1433
- chk.addEventListener("change", async () => {
1434
- _showSystemSkills = chk.checked;
1435
- // Persist to localStorage
1436
- localStorage.setItem("skill-ac-show-system", _showSystemSkills ? "true" : "false");
1437
-
1438
- // If dropdown is visible, re-fetch and re-render
1439
- if (_visible) {
1440
- const input = $("user-input");
1441
- const query = _getSlashQuery(input.value);
1442
- if (query !== null) {
1443
- await _render(query);
1444
- }
1445
- }
1446
- });
1447
- }
1448
- },
1449
-
1450
- /** Called on every `input` event — decide whether to show/hide/update. */
1451
- update(value) {
1452
- const query = _getSlashQuery(value);
1453
- if (query === null) { _hide(); return; }
1454
- _activeIndex = 0; // Always highlight the first match
1455
- _render(query); // async, fire-and-forget
1456
- },
1457
-
1458
- /** Open dropdown with all skills (triggered by / button). */
1459
- openAll: _openAll,
1460
-
1461
- /** Toggle dropdown visibility (used by / button). */
1462
- toggle: _toggle,
1463
-
1464
- /** Hide the dropdown. */
1465
- hide: _hide,
1466
-
1467
- /** Reload session-scoped skill list when the active session changes. */
1468
- loadForSession: _loadForSession,
1469
-
1470
- /** Handle keyboard nav inside the dropdown. Returns true if event was consumed. */
1471
- handleKey(e) {
1472
- if (!_visible) return false;
1473
- if (e.key === "ArrowDown") { e.preventDefault(); _moveActive(1); return true; }
1474
- if (e.key === "ArrowUp") { e.preventDefault(); _moveActive(-1); return true; }
1475
- if (e.key === "Escape") { e.preventDefault(); _hide(); return true; }
1476
- if (e.key === "Tab") {
1477
- // Tab: select active item if one is highlighted, otherwise select first item
1478
- e.preventDefault();
1479
- const targetIdx = _activeIndex >= 0 ? _activeIndex : 0;
1480
- _select(targetIdx);
1481
- return true;
1482
- }
1483
- if (e.key === "Enter" && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
1484
- if (_activeIndex >= 0) {
1485
- e.preventDefault();
1486
- _select(_activeIndex);
1487
- return true;
1488
- }
1489
- // No item highlighted — select first item if available
1490
- if (_items.length > 0) {
1491
- e.preventDefault();
1492
- _select(0);
1493
- return true;
1494
- }
1495
- // No items — let Enter fall through to sendMessage
1496
- _hide();
1497
- return false;
1498
- }
1499
- return false;
1500
- },
1501
-
1502
- hide: _hide,
1503
- };
1504
- })();
1505
-
1506
- $("user-input").addEventListener("keydown", e => {
1507
- // Let skill autocomplete consume arrow/enter/escape first
1508
- if (SkillAC.handleKey(e)) return;
1509
-
1510
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
1511
- e.preventDefault();
1512
- sendMessage();
1513
- }
1514
- });
1515
-
1516
- $("user-input").addEventListener("input", () => {
1517
- const el = $("user-input");
1518
- el.style.height = "auto";
1519
- el.style.height = Math.min(el.scrollHeight, 200) + "px";
1520
-
1521
- // Replace full-width slash / or Chinese dunhao 、 with ASCII / in-place
1522
- if (/^[/、]/.test(el.value)) {
1523
- const pos = el.selectionStart;
1524
- el.value = el.value.replace(/^[/、]/, "/");
1525
- el.setSelectionRange(pos, pos);
1526
- }
1527
-
1528
- // Trigger skill autocomplete
1529
- SkillAC.update(el.value);
1530
- });
1531
-
1532
- $("btn-settings").addEventListener("click", () => {
1533
- if (Router.current === "settings") {
1534
- Router.navigate("welcome");
1535
- } else {
1536
- Router.navigate("settings");
1537
- }
1538
- });
1539
-
1540
- $("tasks-sidebar-item").addEventListener("click", () => Router.navigate("tasks"));
1541
- $("skills-sidebar-item").addEventListener("click", () => Router.navigate("skills"));
1542
- $("channels-sidebar-item").addEventListener("click", () => Router.navigate("channels"));
1543
- // creator-sidebar-item is only present when user_licensed — guard with ?
1544
- document.getElementById("creator-sidebar-item")?.addEventListener("click", () => Router.navigate("creator"));
1545
-
1546
- $("btn-create-skill").addEventListener("click", () => Skills.createInSession());
1547
- $("btn-import-skill").addEventListener("click", () => Skills.toggleImportBar());
1548
424
 
1549
425
  // ── Boot ──────────────────────────────────────────────────────────────────
426
+ Sidebar.init();
1550
427
  Settings.init();
1551
428
  Channels.init();
1552
429
  Sessions.init();
@@ -1638,450 +515,5 @@ window.bootAfterBrand = async function() {
1638
515
  });
1639
516
  })();
1640
517
 
1641
- // ── Session Info Bar Model Switcher ───────────────────────────────────────
1642
- (function() {
1643
- let _isOpen = false;
1644
- // Cache of the most recent benchmark results, keyed by model_id. Kept at
1645
- // closure scope so the numbers survive closing & reopening the dropdown —
1646
- // the user shouldn't have to re-run the test just to peek at results. We
1647
- // intentionally do NOT persist this to disk: latency is a point-in-time
1648
- // measurement, and yesterday's numbers are misleading.
1649
- let _benchCache = {}; // { [model_id]: { ttft_ms, ok, error, ts } }
1650
- let _benchInFlight = false; // prevent double-click spam
1651
-
1652
- // Toggle model dropdown when clicking on model name
1653
- document.addEventListener("click", async (e) => {
1654
- const modelEl = e.target.closest("#sib-model");
1655
- if (modelEl) {
1656
- e.stopPropagation();
1657
- const dropdown = $("sib-model-dropdown");
1658
- if (!dropdown) return;
1659
-
1660
- if (_isOpen) {
1661
- dropdown.style.display = "none";
1662
- _isOpen = false;
1663
- } else {
1664
- await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.textContent.trim());
1665
-
1666
- // Calculate position relative to the model element (fixed positioning)
1667
- const rect = modelEl.getBoundingClientRect();
1668
- dropdown.style.left = `${rect.left + rect.width / 2}px`;
1669
- dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
1670
- dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
1671
-
1672
- dropdown.style.display = "block";
1673
- _isOpen = true;
1674
- }
1675
- return;
1676
- }
1677
-
1678
- // Close dropdown when clicking outside
1679
- if (_isOpen && !e.target.closest(".sib-model-dropdown")) {
1680
- const dropdown = $("sib-model-dropdown");
1681
- if (dropdown) dropdown.style.display = "none";
1682
- _isOpen = false;
1683
- }
1684
- });
1685
-
1686
- // Populate dropdown with available models
1687
- async function _populateModelDropdown(sessionId, currentModel) {
1688
- const dropdown = $("sib-model-dropdown");
1689
- if (!dropdown) return;
1690
-
1691
- try {
1692
- console.log("[Model Switcher] Fetching /api/config...");
1693
- const res = await fetch("/api/config");
1694
- const data = await res.json();
1695
- console.log("[Model Switcher] Received data:", data);
1696
- const models = data.models || [];
1697
- console.log("[Model Switcher] Models count:", models.length);
1698
-
1699
- if (models.length === 0) {
1700
- dropdown.innerHTML = '<div style="padding:12px;text-align:center;color:var(--color-text-secondary);font-size:11px;">No models configured</div>';
1701
- return;
1702
- }
1703
-
1704
- dropdown.innerHTML = "";
1705
-
1706
- // ── Benchmark floating button (top-right of dropdown) ──────────────
1707
- // Tiny ⚡ button pinned to the dropdown's top-right corner. Runs one
1708
- // concurrent request per model and back-fills each row's latency cell.
1709
- // We deliberately avoid a full-width banner — it ate visual space that
1710
- // the model list needs, and most users open the dropdown to SWITCH,
1711
- // not to benchmark. The floating button is discoverable but unobtrusive.
1712
- const bench = document.createElement("div");
1713
- bench.className = "sib-model-bench";
1714
- const btnLabel = (typeof I18n !== "undefined") ? I18n.t("sib.bench.btn") : "Benchmark";
1715
- const btnTooltip = (typeof I18n !== "undefined") ? I18n.t("sib.bench.tooltip") : "Test response latency for every configured model";
1716
- bench.innerHTML = `
1717
- <button type="button" class="sib-bench-btn" title="${btnTooltip}">⚡ <span class="sib-bench-label">${btnLabel}</span></button>
1718
- <span class="sib-bench-hint"></span>
1719
- `;
1720
- dropdown.appendChild(bench);
1721
-
1722
- const benchBtn = bench.querySelector(".sib-bench-btn");
1723
- const benchLabel = bench.querySelector(".sib-bench-label");
1724
- const benchHint = bench.querySelector(".sib-bench-hint");
1725
- benchBtn.addEventListener("click", (ev) => {
1726
- ev.stopPropagation();
1727
- _runBenchmark(sessionId, dropdown, benchBtn, benchLabel, benchHint);
1728
- });
1729
-
1730
- // ── Model rows ─────────────────────────────────────────────────────
1731
- models.forEach(m => {
1732
- console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModel);
1733
- const opt = document.createElement("div");
1734
- opt.className = "sib-model-option";
1735
- opt.dataset.modelId = m.id;
1736
- if (m.model === currentModel) opt.classList.add("current");
1737
-
1738
- const left = document.createElement("span");
1739
- left.className = "sib-model-name";
1740
- left.textContent = m.model;
1741
- opt.appendChild(left);
1742
-
1743
- const right = document.createElement("span");
1744
- right.className = "sib-model-right";
1745
-
1746
- if (m.type === "default") {
1747
- const badge = document.createElement("span");
1748
- badge.className = `model-badge ${m.type}`;
1749
- badge.textContent = m.type;
1750
- right.appendChild(badge);
1751
- }
1752
-
1753
- // Latency cell — populated from _benchCache on open, updated live
1754
- // when a benchmark run completes. Empty slot keeps row heights stable
1755
- // so the list doesn't visually jump mid-benchmark.
1756
- const lat = document.createElement("span");
1757
- lat.className = "sib-model-latency";
1758
- _fillLatencyCell(lat, _benchCache[m.id]);
1759
- right.appendChild(lat);
1760
-
1761
- opt.appendChild(right);
1762
-
1763
- // Switch by id (stable across reorders/edits). Keep model name for UI update.
1764
- opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
1765
- dropdown.appendChild(opt);
1766
- });
1767
- console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
1768
- } catch (e) {
1769
- console.error("Failed to load models:", e);
1770
- dropdown.innerHTML = '<div style="padding:12px;text-align:center;color:var(--color-error);font-size:11px;">Error loading models</div>';
1771
- }
1772
- }
1773
-
1774
- // Render one latency cell based on a cached result.
1775
- // undefined → empty slot (never tested / in-flight starts from here)
1776
- // { ok:true } → "812ms" in green/amber/red per threshold
1777
- // { ok:false } → "✕" with error in tooltip
1778
- // { pending:true } → "…" spinner-ish marker
1779
- function _fillLatencyCell(el, entry) {
1780
- el.className = "sib-model-latency";
1781
- el.textContent = "";
1782
- el.removeAttribute("title");
1783
- if (!entry) return;
1784
- if (entry.pending) {
1785
- el.textContent = "…";
1786
- el.classList.add("is-pending");
1787
- return;
1788
- }
1789
- if (!entry.ok) {
1790
- el.textContent = "✕";
1791
- el.classList.add("is-err");
1792
- el.title = entry.error || "failed";
1793
- return;
1794
- }
1795
- const ms = entry.ttft_ms;
1796
- // Same thresholds as the sib-signal status bar — keep them aligned so
1797
- // "3 bars in the status bar" ≈ "green number in the picker".
1798
- // We measure full non-streaming response time (not real TTFT), so ≤60s is
1799
- // normal, ≤120s is slow, beyond is bad. ≤2s still gets the "feels instant"
1800
- // green treatment like the 4-bar signal.
1801
- let cls = "is-bad";
1802
- if (ms <= 2000) cls = "is-ok";
1803
- else if (ms <= 60000) cls = "is-ok";
1804
- else if (ms <= 120000) cls = "is-warn";
1805
- el.classList.add(cls);
1806
- el.textContent = ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
1807
- if (typeof I18n !== "undefined") {
1808
- el.title = I18n.t("sib.bench.latencyTooltip", {
1809
- ttft: el.textContent,
1810
- time: new Date(entry.ts).toLocaleTimeString(),
1811
- });
1812
- } else {
1813
- el.title = `TTFT ${el.textContent} · tested ${new Date(entry.ts).toLocaleTimeString()}`;
1814
- }
1815
- }
1816
-
1817
- async function _runBenchmark(sessionId, dropdown, btn, label, hint) {
1818
- if (_benchInFlight) return;
1819
- _benchInFlight = true;
1820
- btn.disabled = true;
1821
- const origLabel = label.textContent;
1822
- const _t = (key, vars) => (typeof I18n !== "undefined") ? I18n.t(key, vars) : key;
1823
- label.textContent = _t("sib.bench.running");
1824
- hint.textContent = "";
1825
-
1826
- // Mark every row as pending so the user sees instant feedback instead of
1827
- // a silent button. _fillLatencyCell handles the visual treatment.
1828
- dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
1829
- const id = opt.dataset.modelId;
1830
- if (!id) return;
1831
- _benchCache[id] = { pending: true };
1832
- _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[id]);
1833
- });
1834
-
1835
- const t0 = performance.now();
1836
- try {
1837
- const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
1838
- const data = await res.json();
1839
- if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
1840
-
1841
- const now = Date.now();
1842
- (data.results || []).forEach(r => {
1843
- _benchCache[r.model_id] = {
1844
- ok: !!r.ok,
1845
- ttft_ms: r.ttft_ms,
1846
- error: r.error,
1847
- ts: now,
1848
- };
1849
- const opt = dropdown.querySelector(`.sib-model-option[data-model-id="${CSS.escape(r.model_id)}"]`);
1850
- if (opt) _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[r.model_id]);
1851
- });
1852
-
1853
- const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
1854
- hint.textContent = _t("sib.bench.done", { t: elapsed });
1855
- } catch (e) {
1856
- console.error("Benchmark failed:", e);
1857
- hint.textContent = _t("sib.bench.failed", { msg: e.message });
1858
- // Clear pending markers so rows don't stay stuck on "…"
1859
- dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
1860
- const id = opt.dataset.modelId;
1861
- if (id && _benchCache[id]?.pending) {
1862
- _benchCache[id] = undefined;
1863
- _fillLatencyCell(opt.querySelector(".sib-model-latency"), undefined);
1864
- }
1865
- });
1866
- } finally {
1867
- _benchInFlight = false;
1868
- btn.disabled = false;
1869
- label.textContent = origLabel;
1870
- }
1871
- }
1872
-
1873
- // Switch session model via API
1874
- // modelId — stable runtime id (required by backend)
1875
- // modelName — display name, used for optimistic UI update
1876
- async function _switchModel(sessionId, modelId, modelName) {
1877
- const dropdown = $("sib-model-dropdown");
1878
- if (dropdown) {
1879
- dropdown.style.display = "none";
1880
- _isOpen = false;
1881
- }
1882
-
1883
- try {
1884
- const res = await fetch(`/api/sessions/${sessionId}/model`, {
1885
- method: "PATCH",
1886
- headers: { "Content-Type": "application/json" },
1887
- body: JSON.stringify({ model_id: modelId })
1888
- });
1889
-
1890
- const data = await res.json();
1891
-
1892
- if (!res.ok) {
1893
- throw new Error(data.error || "Unknown error");
1894
- }
1895
-
1896
- // Update UI optimistically (will be confirmed by session_update broadcast)
1897
- const sibModel = $("sib-model");
1898
- if (sibModel) sibModel.textContent = modelName;
1899
-
1900
- console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
1901
- } catch (e) {
1902
- console.error("Failed to switch model:", e);
1903
- alert("Failed to switch model: " + e.message);
1904
- }
1905
- }
1906
- })();
1907
-
1908
- // ── Session Info Bar Working Directory Switcher ───────────────────────────
1909
- (function() {
1910
- // Handle click on working directory
1911
- document.addEventListener("click", async (e) => {
1912
- const dirEl = e.target.closest("#sib-dir");
1913
- if (dirEl) {
1914
- e.stopPropagation();
1915
- const sessionId = dirEl.dataset.sessionId;
1916
- const currentDir = dirEl.title.replace(" (click to change)", "");
1917
-
1918
- const newDir = await Modal.prompt("Change working directory:", currentDir);
1919
- if (newDir && newDir !== currentDir) {
1920
- _changeWorkingDirectory(sessionId, newDir);
1921
- }
1922
- }
1923
-
1924
- // Handle click on session ID — toggles a small actions dropdown with
1925
- // items like "Download session files (for debugging)". Designed to be
1926
- // extensible (more session-level actions can be added here later).
1927
- const sibIdEl = e.target.closest("#sib-id");
1928
- if (sibIdEl) {
1929
- e.stopPropagation();
1930
- const sessionId = sibIdEl.dataset.sessionId;
1931
- if (!sessionId) return;
1932
- _toggleSessionActionsDropdown(sibIdEl, sessionId);
1933
- return;
1934
- }
1935
-
1936
- // Handle click on an item inside the actions dropdown.
1937
- const actionItem = e.target.closest(".sib-actions-item");
1938
- if (actionItem) {
1939
- e.stopPropagation();
1940
- const action = actionItem.dataset.action;
1941
- const sessionId = actionItem.dataset.sessionId;
1942
- _closeSessionActionsDropdown();
1943
- if (action === "download" && sessionId) {
1944
- _downloadSessionBundle(sessionId, actionItem);
1945
- }
1946
- return;
1947
- }
1948
-
1949
- // Click outside — close the actions dropdown if open.
1950
- if (!e.target.closest("#sib-actions-dropdown")) {
1951
- _closeSessionActionsDropdown();
1952
- }
1953
- });
1954
-
1955
- // Close dropdown on Escape.
1956
- document.addEventListener("keydown", (e) => {
1957
- if (e.key === "Escape") _closeSessionActionsDropdown();
1958
- });
1959
-
1960
- function _closeSessionActionsDropdown() {
1961
- const dd = $("sib-actions-dropdown");
1962
- if (dd && dd.style.display !== "none") dd.style.display = "none";
1963
- }
518
+ // Session Info Bar (model switcher + working-directory switcher) moved to sessions.js
1964
519
 
1965
- function _toggleSessionActionsDropdown(anchorEl, sessionId) {
1966
- const dd = $("sib-actions-dropdown");
1967
- if (!dd) return;
1968
-
1969
- // If already open for this session, close it (toggle behaviour).
1970
- if (dd.style.display !== "none" && dd.dataset.sessionId === sessionId) {
1971
- dd.style.display = "none";
1972
- return;
1973
- }
1974
-
1975
- _populateSessionActionsDropdown(dd, sessionId);
1976
- dd.dataset.sessionId = sessionId;
1977
-
1978
- // Position the dropdown above the session ID element (same pattern as
1979
- // the model switcher — fixed positioning, centered horizontally).
1980
- const rect = anchorEl.getBoundingClientRect();
1981
- dd.style.left = `${rect.left + rect.width / 2}px`;
1982
- dd.style.top = `${rect.top - 6}px`;
1983
- dd.style.transform = "translate(-50%, -100%)";
1984
- dd.style.display = "block";
1985
- }
1986
-
1987
- function _populateSessionActionsDropdown(dd, sessionId) {
1988
- const t = (key, fallback) => (window.I18n && I18n.t(key)) || fallback;
1989
- dd.innerHTML = "";
1990
-
1991
- // Download item
1992
- const item = document.createElement("div");
1993
- item.className = "sib-actions-item";
1994
- item.setAttribute("role", "menuitem");
1995
- item.dataset.action = "download";
1996
- item.dataset.sessionId = sessionId;
1997
-
1998
- const icon = document.createElement("span");
1999
- icon.className = "sib-actions-icon";
2000
- icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
2001
-
2002
- const label = document.createElement("span");
2003
- label.className = "sib-actions-label";
2004
- label.textContent = t("sessions.actions.download", "Download session files");
2005
-
2006
- const hint = document.createElement("span");
2007
- hint.className = "sib-actions-hint";
2008
- hint.textContent = t("sessions.actions.downloadHint", "for debugging");
2009
-
2010
- item.appendChild(icon);
2011
- item.appendChild(label);
2012
- item.appendChild(hint);
2013
- dd.appendChild(item);
2014
- }
2015
-
2016
- async function _downloadSessionBundle(sessionId, btnEl) {
2017
- // btnEl may be a <button> (legacy) or a menu item <div> — guard accordingly.
2018
- const wasDisabled = btnEl && btnEl.disabled;
2019
- if (btnEl) {
2020
- try { btnEl.disabled = true; } catch (_) {}
2021
- btnEl.classList && btnEl.classList.add("is-loading");
2022
- }
2023
- try {
2024
- const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/export`);
2025
- if (!res.ok) {
2026
- let msg = `HTTP ${res.status}`;
2027
- try { const data = await res.json(); if (data.error) msg = data.error; } catch (_) {}
2028
- alert((window.I18n && I18n.t("sessions.export.failed")) || "Failed to download session: " + msg);
2029
- return;
2030
- }
2031
- const blob = await res.blob();
2032
-
2033
- // Derive filename from Content-Disposition header, fall back to short id.
2034
- let filename = `clacky-session-${sessionId.slice(0, 8)}.zip`;
2035
- const cd = res.headers.get("Content-Disposition") || "";
2036
- const m = cd.match(/filename="?([^"]+)"?/i);
2037
- if (m) filename = m[1];
2038
-
2039
- const url = URL.createObjectURL(blob);
2040
- const a = document.createElement("a");
2041
- a.href = url;
2042
- a.download = filename;
2043
- document.body.appendChild(a);
2044
- a.click();
2045
- a.remove();
2046
- // Revoke on next tick so the browser has a chance to start the download.
2047
- setTimeout(() => URL.revokeObjectURL(url), 1000);
2048
- } catch (err) {
2049
- console.error("Session export failed:", err);
2050
- alert(((window.I18n && I18n.t("sessions.export.failed")) || "Failed to download session") + ": " + err.message);
2051
- } finally {
2052
- if (btnEl) {
2053
- try { btnEl.disabled = wasDisabled; } catch (_) {}
2054
- btnEl.classList && btnEl.classList.remove("is-loading");
2055
- }
2056
- }
2057
- }
2058
-
2059
- // Change working directory via backend API
2060
- async function _changeWorkingDirectory(sessionId, newDir) {
2061
- try {
2062
- const res = await fetch(`/api/sessions/${sessionId}/working_dir`, {
2063
- method: "PATCH",
2064
- headers: { "Content-Type": "application/json" },
2065
- body: JSON.stringify({ working_dir: newDir })
2066
- });
2067
-
2068
- const data = await res.json();
2069
-
2070
- if (!res.ok) {
2071
- throw new Error(data.error || "Unknown error");
2072
- }
2073
-
2074
- // Update UI optimistically (will be confirmed by session_update broadcast)
2075
- const sibDir = $("sib-dir");
2076
- if (sibDir) {
2077
- sibDir.textContent = newDir;
2078
- sibDir.title = newDir + " (click to change)";
2079
- }
2080
-
2081
- console.log(`Changed session ${sessionId} directory to ${newDir}`);
2082
- } catch (e) {
2083
- console.error("Failed to change directory:", e);
2084
- alert("Failed to change directory: " + e.message);
2085
- }
2086
- }
2087
- })();