openclacky 0.9.7 → 0.9.9

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. data/scripts/install_agent_browser.sh +0 -67
@@ -27,7 +27,7 @@
27
27
  <path d="M9 3v18"/>
28
28
  </svg>
29
29
  </button>
30
- <div id="header-brand">
30
+ <div id="header-brand" style="cursor:pointer" onclick="Router.navigate('chat')">
31
31
  <img class="header-logo-img" id="header-logo-img" src="" alt="" style="display:none">
32
32
  <span class="header-logo-divider"></span>
33
33
  <span class="header-logo" id="header-logo">{{BRAND_NAME}}</span>
@@ -42,6 +42,9 @@
42
42
  </div>
43
43
  </header>
44
44
 
45
+ <!-- ── Sidebar overlay (mobile only) ───────────────────────────────────── -->
46
+ <div id="sidebar-overlay"></div>
47
+
45
48
  <!-- ── CONTENT ROW (Sidebar + Main) ────────────────────────────────────── -->
46
49
  <div id="content-row">
47
50
 
@@ -51,16 +54,37 @@
51
54
  <div id="sidebar-list">
52
55
  <!-- Chat Group -->
53
56
  <div id="chat-section">
57
+ <!-- Header: "Sessions" label + [+ ▾] split button -->
54
58
  <div class="sidebar-divider">
55
- <span data-i18n="sidebar.chat">Chat</span>
56
- <button id="btn-new-session-inline" class="btn-new-inline" title="New Session">
57
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
58
- <path d="M5 12h14"/>
59
- <path d="M12 5v14"/>
60
- </svg>
61
- </button>
59
+ <span data-i18n="sidebar.chat">Sessions</span>
60
+ <div class="btn-split-wrap">
61
+ <button id="btn-new-session-inline" class="btn-split-main" title="New Session" data-i18n="sessions.newSession">+ New Session</button>
62
+ <button id="btn-new-session-arrow" class="btn-split-arrow" title="Choose agent">▾</button>
63
+ <!-- Dropdown -->
64
+ <div id="new-session-dropdown" class="new-session-dropdown" hidden>
65
+ <div class="dropdown-item" data-profile="general">✦ General</div>
66
+ <div class="dropdown-item" data-profile="coding">👨‍💻 Coding</div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- Source tabs: Manual / Scheduled / Channel -->
72
+ <div id="session-tabs" class="session-tabs">
73
+ <button class="session-tab active" data-tab="manual" data-i18n="sessions.tab.manual">Manual</button>
74
+ <button class="session-tab" data-tab="cron" data-i18n="sessions.tab.scheduled">Scheduled</button>
75
+ <button class="session-tab" data-tab="channel" data-i18n="sessions.tab.channel">Channel</button>
76
+ <button class="session-tab" data-tab="setup" data-i18n="sessions.tab.setup" id="tab-setup" style="display:none">Setup</button>
62
77
  </div>
78
+
79
+ <!-- General sessions list (filtered by active tab) -->
63
80
  <div id="session-list"></div>
81
+
82
+ <!-- Coding agent fixed section -->
83
+ <div id="coding-section" style="display:none">
84
+ <div class="coding-section-divider">👨‍💻 Coding</div>
85
+ <div id="coding-session-list"></div>
86
+ </div>
87
+ <!-- Load more buttons are rendered dynamically inside each section by Sessions.renderList() -->
64
88
  </div>
65
89
 
66
90
  <!-- Config Group -->
@@ -161,6 +185,12 @@
161
185
  <!-- Chat panel -->
162
186
  <div id="chat-panel" style="display:none">
163
187
  <header id="chat-header">
188
+ <!-- Mobile-only back button: shown inside a session -->
189
+ <button id="btn-back-mobile" class="chat-back-btn" title="Back">
190
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
191
+ <path d="M15 18l-6-6 6-6"/>
192
+ </svg>
193
+ </button>
164
194
  <span id="chat-title">Session</span>
165
195
  <span id="chat-status" class="status-idle">idle</span>
166
196
  <button id="btn-delete-session" class="btn-delete-session" title="Delete session">
@@ -174,7 +204,7 @@
174
204
  <div id="session-info-bar" style="display:none">
175
205
  <!-- Always visible: status | id | model | cost -->
176
206
  <span id="sib-status"></span>
177
- <span class="sib-sep">│</span>
207
+ <span class="sib-sep sib-sep-before-id">│</span>
178
208
  <span id="sib-id"></span>
179
209
  <span id="sib-model-wrap"><span class="sib-sep">│</span><span id="sib-model"></span></span>
180
210
  <span class="sib-sep">│</span>
@@ -184,7 +214,7 @@
184
214
  <span class="sib-sep">│</span>
185
215
  <span id="sib-dir"></span>
186
216
  <span id="sib-mode-wrap"><span class="sib-sep">│</span><span id="sib-mode"></span></span>
187
- <span class="sib-sep">│</span>
217
+ <span class="sib-sep sib-sep-before-tasks">│</span>
188
218
  <span id="sib-tasks"></span>
189
219
  </span>
190
220
  </div>
@@ -342,6 +372,23 @@
342
372
  </div>
343
373
  </section>
344
374
 
375
+ <!-- Browser section -->
376
+ <section class="settings-section">
377
+ <div class="settings-section-title">
378
+ <span data-i18n="settings.browser.title">Browser</span>
379
+ </div>
380
+ <div class="settings-personalize">
381
+ <p class="settings-personalize-desc" id="browser-status-desc" data-i18n="settings.browser.desc">Connect your browser to enable browser automation.</p>
382
+ <div class="settings-browser-actions">
383
+ <label class="toggle-switch" id="browser-toggle-wrap" style="display:none">
384
+ <input type="checkbox" id="browser-toggle-input">
385
+ <span class="toggle-slider"></span>
386
+ </label>
387
+ <button id="btn-browser-setup" class="btn-settings-action" data-i18n="settings.browser.btn">🌐 Configure Browser</button>
388
+ </div>
389
+ </div>
390
+ </section>
391
+
345
392
  <!-- Language section -->
346
393
  <section class="settings-section" id="language-section">
347
394
  <div class="settings-section-title">
@@ -375,10 +422,20 @@
375
422
  <span class="brand-status-label" data-i18n="settings.brand.label.expires">Expires</span>
376
423
  <span class="brand-status-value" id="brand-status-expires">—</span>
377
424
  </div>
425
+ <div class="brand-status-row" id="brand-status-homepage-row" style="display:none">
426
+ <span class="brand-status-label" data-i18n="settings.brand.label.homepage">Homepage</span>
427
+ <a id="brand-status-homepage" class="brand-status-value brand-status-link" href="#" target="_blank" rel="noopener noreferrer">—</a>
428
+ </div>
378
429
  <div class="brand-status-actions">
379
430
  <button id="btn-rebind-license" class="btn-settings-action" data-i18n="settings.brand.btn.change">Change License Key</button>
380
431
  </div>
381
432
  </div>
433
+ <!-- Support contact (shown when support_contact is available) -->
434
+ <div id="brand-support-contact" class="brand-support-contact" style="display:none">
435
+ <span class="brand-support-contact-label" data-i18n="settings.brand.label.supportContact">Support</span>
436
+ <a id="brand-support-contact-link" class="brand-support-contact-value" href="#" target="_blank" rel="noopener noreferrer"></a>
437
+ </div>
438
+
382
439
  <!-- Support QR code (shown when support_qr_url is available) -->
383
440
  <div id="brand-support-qr" class="brand-support-qr" style="display:none">
384
441
  <button class="brand-support-qr-btn" id="brand-support-qr-btn" title="Click to enlarge" aria-label="View QR code larger">
@@ -312,13 +312,14 @@ const Onboard = (() => {
312
312
  const res = await fetch("/api/sessions", {
313
313
  method: "POST",
314
314
  headers: { "Content-Type": "application/json" },
315
- body: JSON.stringify({ name: "✨ Onboard" })
315
+ body: JSON.stringify({ name: "✨ Onboard", source: "setup" })
316
316
  });
317
317
  const data = await res.json();
318
318
  const session = data.session;
319
319
  if (!session) throw new Error("No session returned");
320
320
 
321
321
  Sessions.add(session);
322
+ Sessions.setTab("setup");
322
323
  Sessions.renderList();
323
324
  Sessions.setPendingMessage(session.id, `/onboard lang:${_selectedLang}`);
324
325
  Sessions.select(session.id);
@@ -20,6 +20,10 @@ const Sessions = (() => {
20
20
  const _historyState = {}; // { [session_id]: { hasMore, oldestCreatedAt, loading, loaded } }
21
21
  const _renderedCreatedAt = {}; // { [session_id]: Set<number> } — dedup by created_at
22
22
  let _activeId = null;
23
+ let _activeTab = "manual"; // "manual" | "cron" | "channel" | "setup"
24
+ // Per-source pagination state (manual/cron/channel/setup for general tabs; coding for coding section)
25
+ const _hasMoreBySource = { manual: false, cron: false, channel: false, setup: false, coding: false };
26
+ const _loadingMoreSource = { manual: false, cron: false, channel: false, setup: false, coding: false };
23
27
  let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
24
28
  let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
25
29
 
@@ -246,8 +250,20 @@ const Sessions = (() => {
246
250
  if (Array.isArray(ev.images) && ev.images.length > 0) {
247
251
  bubbleHtml += ev.images.map(src => {
248
252
  if (src && src.startsWith("pdf:")) {
249
- // PDF placeholderrender a badge instead of an image
250
- return `<span class="msg-pdf-badge">📄 PDF</span>`;
253
+ // File badgeextract filename and extension from sentinel "pdf:<name>"
254
+ const fname = src.slice(4);
255
+ const ext = (fname.split(".").pop() || "file").toUpperCase();
256
+ const icon = ext === "PDF" ? "📄" : ext === "ZIP" ? "🗜️" :
257
+ (ext === "DOC" || ext === "DOCX") ? "📝" :
258
+ (ext === "XLS" || ext === "XLSX") ? "📊" :
259
+ (ext === "PPT" || ext === "PPTX") ? "📋" : "📎";
260
+ return `<span class="msg-pdf-badge">` +
261
+ `<span class="msg-pdf-badge-icon">${icon}</span>` +
262
+ `<span class="msg-pdf-badge-info">` +
263
+ `<span class="msg-pdf-badge-name">${escapeHtml(fname)}</span>` +
264
+ `<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span>` +
265
+ `</span>` +
266
+ `</span>`;
251
267
  }
252
268
  return `<img src="${escapeHtml(src)}" alt="image" class="msg-image-thumb">`;
253
269
  }).join("");
@@ -390,6 +406,83 @@ const Sessions = (() => {
390
406
  }
391
407
  }
392
408
 
409
+ // ── Private helpers ───────────────────────────────────────────────────
410
+
411
+ // Return a human-readable relative label for a session with no name.
412
+ // e.g. "Today 14:14" / "Yesterday" / "Mar 21"
413
+ function _relativeTime(createdAt) {
414
+ if (!createdAt) return I18n.t("sessions.untitled") || "Untitled";
415
+ const d = new Date(createdAt);
416
+ const now = new Date();
417
+ const diffDays = Math.floor((now - d) / 86400000);
418
+ const pad = n => String(n).padStart(2, "0");
419
+ const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
420
+ if (diffDays === 0) return `Today ${hhmm}`;
421
+ if (diffDays === 1) return `Yesterday ${hhmm}`;
422
+ return `${d.getMonth() + 1}/${d.getDate()} ${hhmm}`;
423
+ }
424
+
425
+ // Build a load-more button for a given source tab.
426
+ function _makeLoadMoreBtn(source) {
427
+ const btn = document.createElement("button");
428
+ btn.className = "btn-load-more-sessions";
429
+ btn.disabled = _loadingMoreSource[source];
430
+ btn.textContent = _loadingMoreSource[source]
431
+ ? I18n.t("sessions.loadingMore")
432
+ : I18n.t("sessions.loadMore");
433
+ btn.onclick = () => Sessions.loadMoreSessions(source);
434
+ return btn;
435
+ }
436
+
437
+ // ── Private render helper ─────────────────────────────────────────────
438
+ //
439
+ // Build and append a single session-item <div> into `container`.
440
+ // Used by both the general list and the coding section.
441
+ function _renderSessionItem(container, s) {
442
+ const el = document.createElement("div");
443
+ el.className = "session-item" + (s.id === _activeId ? " active" : "");
444
+ const displayName = s.name || _relativeTime(s.created_at);
445
+ const metaText = I18n.t("sessions.meta", { tasks: s.total_tasks || 0, cost: (s.total_cost || 0).toFixed(4) });
446
+ el.innerHTML = `
447
+ <span class="session-dot dot-${s.status || "idle"}"></span>
448
+ <div class="session-body">
449
+ <div class="session-name">${escapeHtml(displayName)}</div>
450
+ <div class="session-meta">${metaText}</div>
451
+ </div>
452
+ <button class="session-delete-btn" title="${I18n.t("sessions.deleteTitle")}">×</button>`;
453
+
454
+ // Use a click timer to distinguish single-click (select) from double-click (rename).
455
+ let clickTimer = null;
456
+ el.onclick = () => {
457
+ if (clickTimer) {
458
+ clearTimeout(clickTimer);
459
+ clickTimer = null;
460
+ return;
461
+ }
462
+ clickTimer = setTimeout(() => {
463
+ clickTimer = null;
464
+ Sessions.select(s.id);
465
+ }, 200);
466
+ };
467
+
468
+ // Delete button
469
+ const deleteBtn = el.querySelector(".session-delete-btn");
470
+ deleteBtn.onclick = (e) => {
471
+ e.stopPropagation();
472
+ Sessions.deleteSession(s.id);
473
+ };
474
+
475
+ // Double-click to rename
476
+ const nameDiv = el.querySelector(".session-name");
477
+ nameDiv.ondblclick = (e) => {
478
+ e.stopPropagation();
479
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
480
+ Sessions._startRename(s.id, nameDiv, s.name);
481
+ };
482
+
483
+ container.appendChild(el);
484
+ }
485
+
393
486
  // ── Public API ─────────────────────────────────────────────────────────
394
487
  return {
395
488
  get all() { return _sessions; },
@@ -399,9 +492,13 @@ const Sessions = (() => {
399
492
  // ── List management ───────────────────────────────────────────────────
400
493
 
401
494
  /** Populate list from initial session_list WS event (connect only). */
402
- setAll(list) {
495
+ setAll(list, hasMoreBySource = {}) {
403
496
  _sessions.length = 0;
404
497
  _sessions.push(...list);
498
+ // Accept either new per-source map or legacy scalar boolean
499
+ if (typeof hasMoreBySource === "object" && hasMoreBySource !== null) {
500
+ Object.assign(_hasMoreBySource, hasMoreBySource);
501
+ }
405
502
  },
406
503
 
407
504
  /** Insert a newly created session into the local list. */
@@ -423,6 +520,53 @@ const Sessions = (() => {
423
520
  if (idx !== -1) _sessions.splice(idx, 1);
424
521
  },
425
522
 
523
+ /** Load older sessions for a specific bucket.
524
+ * bucket: "manual" | "cron" | "channel" | "setup" → uses ?source=<bucket>
525
+ * "coding" → uses ?profile=coding
526
+ */
527
+ async loadMoreSessions(source) {
528
+ if (_loadingMoreSource[source] || !_hasMoreBySource[source]) return;
529
+ _loadingMoreSource[source] = true;
530
+ Sessions.renderList(); // re-render to show loading state on button
531
+
532
+ try {
533
+ // Cursor: oldest created_at among sessions in this bucket
534
+ const bucketSessions = source === "coding"
535
+ ? _sessions.filter(s => s.agent_profile === "coding")
536
+ : _sessions.filter(s => s.source === source);
537
+
538
+ const oldest = bucketSessions.reduce((min, s) => {
539
+ if (!s.created_at) return min;
540
+ return (!min || s.created_at < min) ? s.created_at : min;
541
+ }, null);
542
+
543
+ const param = source === "coding" ? "profile=coding" : `source=${encodeURIComponent(source)}&profile=general`;
544
+ let url = `/api/sessions?limit=10&${param}`;
545
+ if (oldest) url += `&before=${encodeURIComponent(oldest)}`;
546
+
547
+ const res = await fetch(url);
548
+ if (!res.ok) return;
549
+ const data = await res.json();
550
+
551
+ let added = 0;
552
+ (data.sessions || []).forEach(s => {
553
+ if (!_sessions.find(x => x.id === s.id)) {
554
+ _sessions.push(s);
555
+ added++;
556
+ }
557
+ });
558
+ _hasMoreBySource[source] = !!data.has_more;
559
+
560
+ if (added > 0) Sessions.renderList();
561
+ else Sessions.renderList(); // update button state even with no new items
562
+ } catch (e) {
563
+ console.error("loadMoreSessions error:", e);
564
+ } finally {
565
+ _loadingMoreSource[source] = false;
566
+ Sessions.renderList();
567
+ }
568
+ },
569
+
426
570
  /** Delete a session via API (called from UI delete button). */
427
571
  async deleteSession(id) {
428
572
  const s = _sessions.find(s => s.id === id);
@@ -500,70 +644,75 @@ const Sessions = (() => {
500
644
 
501
645
  // ── Rendering ─────────────────────────────────────────────────────────
502
646
 
503
- renderList() {
504
- const list = $("session-list");
505
- list.innerHTML = "";
506
-
507
- // Show a ghost placeholder when there are no sessions yet (e.g. during onboarding)
508
- if (_sessions.length === 0) {
509
- list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
510
- return;
511
- }
647
+ setTab(tab) {
648
+ _activeTab = tab;
649
+ // Sync tab button active state
650
+ document.querySelectorAll(".session-tab").forEach(btn => {
651
+ btn.classList.toggle("active", btn.dataset.tab === tab);
652
+ });
653
+ Sessions.renderList();
654
+ },
512
655
 
513
- // Split into manual sessions and scheduled () sessions, each sorted
514
- // newest-first by created_at (falling back to array order).
656
+ renderList() {
657
+ // Sort helper: newest-first by created_at
515
658
  const byTime = (a, b) => {
516
659
  const ta = a.created_at ? new Date(a.created_at) : 0;
517
660
  const tb = b.created_at ? new Date(b.created_at) : 0;
518
661
  return tb - ta;
519
662
  };
520
- const manual = _sessions.filter(s => !s.name.startsWith("⏰")).sort(byTime);
521
- const scheduled = _sessions.filter(s => s.name.startsWith("⏰")).sort(byTime);
522
663
 
523
- [...manual, ...scheduled].forEach(s => {
524
- const el = document.createElement("div");
525
- el.className = "session-item" + (s.id === _activeId ? " active" : "");
526
- el.innerHTML = `
527
- <div class="session-name">
528
- <span class="session-dot dot-${s.status || "idle"}"></span>${escapeHtml(s.name)}
529
- </div>
530
- <div class="session-meta">${I18n.t("sessions.meta", { tasks: s.total_tasks || 0, cost: (s.total_cost || 0).toFixed(4) })}</div>
531
- <button class="session-delete-btn" title="${I18n.t("sessions.deleteTitle")}">×</button>`;
532
- // Use a click timer to distinguish single-click (select) from double-click (rename).
533
- // Without this, the first click of a dblclick fires select() which re-renders the
534
- // list and destroys the nameDiv DOM node before dblclick can fire _startRename.
535
- let clickTimer = null;
536
- el.onclick = () => {
537
- if (clickTimer) {
538
- // Second click arrived quickly — this is a dblclick, cancel the pending select
539
- clearTimeout(clickTimer);
540
- clickTimer = null;
541
- return;
542
- }
543
- clickTimer = setTimeout(() => {
544
- clickTimer = null;
545
- Sessions.select(s.id);
546
- }, 200); // 200 ms threshold to distinguish single-click from double-click
547
- };
548
-
549
- // Delete button: stop propagation so it doesn't trigger session select
550
- const deleteBtn = el.querySelector(".session-delete-btn");
551
- deleteBtn.onclick = (e) => {
552
- e.stopPropagation();
553
- Sessions.deleteSession(s.id);
554
- };
555
-
556
- // Double-click to rename: cancel any pending single-click select, then start rename
557
- const nameDiv = el.querySelector(".session-name");
558
- nameDiv.ondblclick = (e) => {
559
- e.stopPropagation();
560
- // Cancel the pending select() that was scheduled by the first click of this dblclick
561
- if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
562
- Sessions._startRename(s.id, nameDiv, s.name);
563
- };
564
-
565
- list.appendChild(el);
566
- });
664
+ // ── Classify ──────────────────────────────────────────────────────────
665
+ // Two orthogonal dimensions: agent_profile (general vs coding) and source (tab).
666
+ // Coding section: profile === "coding", shown separately at bottom no source filter.
667
+ // General area tabs: all non-coding sessions, filtered by source per tab.
668
+ const codingSessions = _sessions.filter(s => s.agent_profile === "coding").sort(byTime);
669
+ const generalSessions = _sessions.filter(s => s.agent_profile !== "coding").sort(byTime);
670
+
671
+ // ── Tab visibility ────────────────────────────────────────────────────
672
+ const hasCron = generalSessions.some(s => s.source === "cron") || _hasMoreBySource["cron"];
673
+ const hasChannel = generalSessions.some(s => s.source === "channel") || _hasMoreBySource["channel"];
674
+ const hasSetup = generalSessions.some(s => s.source === "setup") || _hasMoreBySource["setup"];
675
+ const hasNonManual = hasCron || hasChannel || hasSetup;
676
+
677
+ const tabBar = $("session-tabs");
678
+ if (tabBar) tabBar.style.display = hasNonManual ? "" : "none";
679
+
680
+ const setupTabBtn = $("tab-setup");
681
+ if (setupTabBtn) setupTabBtn.style.display = hasSetup ? "" : "none";
682
+
683
+ // Use active tab directly — no fallback to manual when content is empty.
684
+ // Tabs may show an empty state (e.g. cron tab before any cron sessions exist).
685
+ const effectiveTab = _activeTab;
686
+
687
+ const tabSessions = generalSessions.filter(s => (s.source || "manual") === effectiveTab);
688
+
689
+ // ── Render general (tab-filtered) sessions ──────────────────────────
690
+ const list = $("session-list");
691
+ list.innerHTML = "";
692
+ if (tabSessions.length === 0) {
693
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
694
+ } else {
695
+ tabSessions.forEach(s => _renderSessionItem(list, s));
696
+ }
697
+ // Per-tab load-more button (inside the tab's section)
698
+ if (_hasMoreBySource[effectiveTab]) {
699
+ list.appendChild(_makeLoadMoreBtn(effectiveTab));
700
+ }
701
+
702
+ // ── Render coding sessions (fixed section) ───────────────────────────
703
+ const codingSection = $("coding-section");
704
+ const codingList = $("coding-session-list");
705
+ if (codingSessions.length === 0) {
706
+ codingSection.style.display = "none";
707
+ } else {
708
+ codingSection.style.display = "";
709
+ codingList.innerHTML = "";
710
+ codingSessions.forEach(s => _renderSessionItem(codingList, s));
711
+ // Per-section load-more button
712
+ if (_hasMoreBySource["coding"]) {
713
+ codingList.appendChild(_makeLoadMoreBtn("coding"));
714
+ }
715
+ }
567
716
  },
568
717
 
569
718
  /** Begin inline rename: replace session-name content with an <input>. */
@@ -571,10 +720,7 @@ const Sessions = (() => {
571
720
  // Prevent starting a second rename while one is already active
572
721
  if (nameDiv.querySelector("input")) return;
573
722
 
574
- const dot = nameDiv.querySelector(".session-dot");
575
- const dotHtml = dot ? dot.outerHTML : "";
576
-
577
- // Replace entire content with just the input (no dot — keeps layout clean)
723
+ // Replace name span content with input (dot lives in session-row, not here)
578
724
  nameDiv.innerHTML = "";
579
725
  nameDiv.classList.add("renaming"); // disable overflow:hidden while editing
580
726
  const input = document.createElement("input");
@@ -597,7 +743,7 @@ const Sessions = (() => {
597
743
 
598
744
  // Restore original display immediately
599
745
  nameDiv.classList.remove("renaming");
600
- nameDiv.innerHTML = dotHtml + escapeHtml(currentName);
746
+ nameDiv.textContent = currentName;
601
747
 
602
748
  if (!newName || newName === currentName) return;
603
749
 
@@ -748,8 +894,18 @@ const Sessions = (() => {
748
894
  }
749
895
 
750
896
  // Cost: 5 decimal places (matches CLI precision)
751
- const cost = ev.cost || 0;
752
- const costStr = `$${cost.toFixed(5)}`;
897
+ // :api => "$0.00123" (exact)
898
+ // :price => "~$0.00123" (estimated from pricing table)
899
+ // :default => "N/A" (model unknown)
900
+ const cost = ev.cost || 0;
901
+ let costStr;
902
+ if (ev.cost_source === "default") {
903
+ costStr = "N/A";
904
+ } else if (ev.cost_source === "price") {
905
+ costStr = `~$${cost.toFixed(5)}`;
906
+ } else {
907
+ costStr = `$${cost.toFixed(5)}`;
908
+ }
753
909
 
754
910
  // Always-visible: label, delta, cache indicator, cost
755
911
  // Detail fields (Input/Output/Total) are hidden until hover
@@ -806,6 +962,32 @@ const Sessions = (() => {
806
962
  messages.scrollTop = messages.scrollHeight;
807
963
  },
808
964
 
965
+ // Two-phase idle compression status — renders as a single line that updates in-place.
966
+ // phase "start": create element with spinner; phase "end": update same element with result.
967
+ updateIdleStatus(phase, text) {
968
+ const messages = $("messages");
969
+ if (phase === "start") {
970
+ Sessions.collapseToolGroup();
971
+ const el = document.createElement("div");
972
+ el.className = "msg msg-info msg-idle-status";
973
+ el.textContent = "⟳ " + text;
974
+ messages.appendChild(el);
975
+ Sessions._idleStatusEl = el;
976
+ } else {
977
+ // phase "end" — update existing element or create a new one if start was missed
978
+ if (Sessions._idleStatusEl) {
979
+ Sessions._idleStatusEl.textContent = "· " + text;
980
+ Sessions._idleStatusEl.classList.add("msg-idle-done");
981
+ Sessions._idleStatusEl = null;
982
+ } else {
983
+ Sessions.appendInfo("· " + text);
984
+ }
985
+ }
986
+ messages.scrollTop = messages.scrollHeight;
987
+ },
988
+
989
+ _idleStatusEl: null,
990
+
809
991
  showProgress(text) {
810
992
  Sessions.clearProgress();
811
993
  const messages = $("messages");
@@ -829,7 +1011,7 @@ const Sessions = (() => {
829
1011
  // ── Create ─────────────────────────────────────────────────────────────
830
1012
 
831
1013
  /** Create a new session and navigate to it. */
832
- async create() {
1014
+ async create(agentProfile = "general") {
833
1015
  const maxN = _sessions.reduce((max, s) => {
834
1016
  const m = s.name.match(/^Session (\d+)$/);
835
1017
  return m ? Math.max(max, parseInt(m[1], 10)) : max;
@@ -839,7 +1021,7 @@ const Sessions = (() => {
839
1021
  const res = await fetch("/api/sessions", {
840
1022
  method: "POST",
841
1023
  headers: { "Content-Type": "application/json" },
842
- body: JSON.stringify({ name })
1024
+ body: JSON.stringify({ name, agent_profile: agentProfile, source: "manual" })
843
1025
  });
844
1026
  const data = await res.json();
845
1027
  if (!res.ok) { alert(I18n.t("sessions.createError") + (data.error || "unknown")); return; }
@@ -848,6 +1030,10 @@ const Sessions = (() => {
848
1030
  if (!session) return;
849
1031
 
850
1032
  Sessions.add(session);
1033
+
1034
+ // If a coding session was created, make sure the coding section is visible.
1035
+ // If a general session was created, switch to the manual tab to show it.
1036
+ if (agentProfile === "general") Sessions.setTab("manual");
851
1037
  Sessions.renderList();
852
1038
  Sessions.select(session.id);
853
1039
  },