openclacky 1.5.20 → 1.5.21

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +18 -0
  4. data/lib/clacky/agent/chunk_index.rb +8 -1
  5. data/lib/clacky/agent/feedback.rb +30 -0
  6. data/lib/clacky/agent/history_navigation.rb +17 -3
  7. data/lib/clacky/agent/hook_manager.rb +6 -1
  8. data/lib/clacky/agent/llm_caller.rb +1 -0
  9. data/lib/clacky/agent/message_compressor_helper.rb +28 -2
  10. data/lib/clacky/agent/session_serializer.rb +20 -2
  11. data/lib/clacky/agent.rb +56 -43
  12. data/lib/clacky/agent_config.rb +82 -4
  13. data/lib/clacky/billing/platform_billing.rb +5 -1
  14. data/lib/clacky/brand_config.rb +1 -1
  15. data/lib/clacky/cli.rb +3 -2
  16. data/lib/clacky/client.rb +5 -5
  17. data/lib/clacky/deepseek_web_tools.rb +156 -0
  18. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
  20. data/lib/clacky/extension/loader.rb +5 -3
  21. data/lib/clacky/locales/en.rb +5 -5
  22. data/lib/clacky/locales/zh.rb +5 -5
  23. data/lib/clacky/message_format/anthropic.rb +20 -0
  24. data/lib/clacky/message_format/open_ai.rb +31 -14
  25. data/lib/clacky/message_history.rb +21 -3
  26. data/lib/clacky/platform_http_client.rb +6 -2
  27. data/lib/clacky/providers.rb +23 -14
  28. data/lib/clacky/search_config.rb +47 -13
  29. data/lib/clacky/server/browser_manager.rb +35 -5
  30. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  31. data/lib/clacky/server/discover.rb +5 -4
  32. data/lib/clacky/server/http_server.rb +194 -47
  33. data/lib/clacky/server/model_prices.rb +5 -3
  34. data/lib/clacky/server/pid_files.rb +25 -0
  35. data/lib/clacky/server/server_master.rb +9 -3
  36. data/lib/clacky/server/session_registry.rb +19 -7
  37. data/lib/clacky/server/web_ui_controller.rb +61 -38
  38. data/lib/clacky/session_manager.rb +7 -1
  39. data/lib/clacky/skill.rb +1 -1
  40. data/lib/clacky/tools/ask_user.rb +4 -1
  41. data/lib/clacky/tools/base.rb +3 -2
  42. data/lib/clacky/tools/browser.rb +18 -2
  43. data/lib/clacky/tools/file_reader.rb +1 -1
  44. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  45. data/lib/clacky/tools/terminal.rb +65 -23
  46. data/lib/clacky/tools/trash_manager.rb +5 -3
  47. data/lib/clacky/tools/web_fetch.rb +81 -22
  48. data/lib/clacky/tools/web_search.rb +45 -4
  49. data/lib/clacky/ui2/ui_controller.rb +1 -1
  50. data/lib/clacky/ui_interface.rb +4 -3
  51. data/lib/clacky/utils/file_processor.rb +51 -0
  52. data/lib/clacky/utils/model_pricing.rb +132 -13
  53. data/lib/clacky/version.rb +1 -1
  54. data/lib/clacky/web/app.css +366 -105
  55. data/lib/clacky/web/components/chat-navigator.js +8 -1
  56. data/lib/clacky/web/components/mentions.js +2 -13
  57. data/lib/clacky/web/components/model-picker.js +3 -1
  58. data/lib/clacky/web/components/onboard.js +13 -10
  59. data/lib/clacky/web/features/archive/store.js +30 -16
  60. data/lib/clacky/web/features/archive/view.js +20 -10
  61. data/lib/clacky/web/features/backup/view.js +5 -5
  62. data/lib/clacky/web/features/new-session/store.js +3 -3
  63. data/lib/clacky/web/features/trash/store.js +10 -7
  64. data/lib/clacky/web/features/trash/view.js +9 -4
  65. data/lib/clacky/web/i18n.js +106 -30
  66. data/lib/clacky/web/index.html +90 -60
  67. data/lib/clacky/web/projects.js +56 -1
  68. data/lib/clacky/web/sessions.js +244 -131
  69. data/lib/clacky/web/settings.js +143 -8
  70. data/lib/clacky/web/ws-dispatcher.js +9 -13
  71. data/lib/clacky.rb +1 -0
  72. metadata +4 -1
@@ -67,7 +67,7 @@ const Sessions = (() => {
67
67
  const _groups = new Map(GROUP_SOURCES.map(source => [source, {
68
68
  count: 0, // total sessions in this group (server-side, unpaginated)
69
69
  latestUpdatedAt: null, // newest activity — decides the virtual entry's sort position
70
- before: null, // cursor: oldest activity time loaded into the sub-view
70
+ before: null, // cursor: server-issued sort key of the last row loaded into the sub-view
71
71
  hasMore: false,
72
72
  loadingMore: false,
73
73
  }]));
@@ -87,6 +87,9 @@ const Sessions = (() => {
87
87
  // Project-assigned sessions always render in the project section.
88
88
  const _groupOf = s => (s && !s.project_id && _groups.get(s.source)) || null;
89
89
 
90
+ // Server-issued sort key of the last loaded main-list row — the loadMore cursor.
91
+ let _cursor = null;
92
+
90
93
  // Merge server-reported group stats (REST/WS `groups` payload) into local state.
91
94
  function _applyGroupStats(stats) {
92
95
  if (!stats) return;
@@ -619,7 +622,6 @@ const Sessions = (() => {
619
622
  try {
620
623
  const session = await NewSessionStore.createSession({ existingSessions: _sessions, useDefaults: true });
621
624
  if (!session) return;
622
- NewSessionStore.reset();
623
625
  // Add to local list immediately so renderList shows it and findOrFetch
624
626
  // can locate it synchronously (without a round-trip to the API).
625
627
  if (!_sessions.find(s => s.id === session.id)) {
@@ -980,6 +982,41 @@ const Sessions = (() => {
980
982
  // ── sendMessage ────────────────────────────────────────────────────────
981
983
  let _sending = false;
982
984
 
985
+ // Countdown state is per session; only the backend decides when to continue.
986
+ const _feedbackCountdowns = new Map();
987
+ function _renderFeedbackCountdown() {
988
+ const state = _feedbackCountdowns.get(Sessions.activeId);
989
+ document.querySelectorAll(".feedback-card:not(.feedback-card--submitted) .feedback-status").forEach(el => {
990
+ el.textContent = state?.deadline_ms != null
991
+ ? I18n.t("chat.feedback_countdown", {
992
+ seconds: Math.max(0, Math.ceil((state.deadline_ms - Date.now()) / 1000))
993
+ })
994
+ : I18n.t("chat.feedback_waiting");
995
+ });
996
+ }
997
+
998
+ function _hasPendingFeedback() {
999
+ return !!document.querySelector(".feedback-card:not(.feedback-card--submitted)");
1000
+ }
1001
+
1002
+ function _refreshFeedbackStatus() {
1003
+ _renderFeedbackCountdown();
1004
+ const session = Sessions._lastSession;
1005
+ if (session && session.id === Sessions.activeId) {
1006
+ Sessions.updateStatusBar(session.status);
1007
+ Sessions.updateInfoBar(session);
1008
+ }
1009
+ }
1010
+
1011
+ function _interveneFeedbackCountdown() {
1012
+ const state = _feedbackCountdowns.get(Sessions.activeId);
1013
+ if (!state || state.intervened) return;
1014
+ state.intervened = true;
1015
+ state.deadline_ms = null;
1016
+ WS.send({ type: "feedback_intervene", session_id: Sessions.activeId, request_id: state.request_id });
1017
+ _renderFeedbackCountdown();
1018
+ }
1019
+
983
1020
  // ── ask_user feedback card ────────────────────────────────────────────
984
1021
  //
985
1022
  // The backend always sends a normalized `questions` array, but older stored
@@ -1012,31 +1049,7 @@ const Sessions = (() => {
1012
1049
  }];
1013
1050
  }
1014
1051
 
1015
- function _feedbackNeedsCard(list) {
1016
- return list.some(q => q.options.length > 0);
1017
- }
1018
-
1019
- function _feedbackPlainText(list, context) {
1020
- // Normalize bullet symbols to markdown list format so marked renders them as <ul>
1021
- const normalizeBullets = (t) => t ? t.replace(/^[•·‣▸▪\-–]\s*/gm, "- ") : t;
1022
- const parts = [];
1023
- if (context && context.trim()) parts.push(context.trim());
1024
- list.forEach(q => {
1025
- parts.push(q.question);
1026
- if (q.description) parts.push(q.description);
1027
- });
1028
- return parts.map(normalizeBullets).join("\n\n");
1029
- }
1030
-
1031
- // A single-choice question with no free-text escape submits on click, which
1032
- // keeps the one-question case at one tap. Anything else needs an explicit
1033
- // submit so partial answers aren't sent.
1034
- function _feedbackIsInstant(list) {
1035
- return list.length === 1 && !list[0].multi && !list[0].allow_free_text;
1036
- }
1037
-
1038
1052
  function _buildFeedbackCard(list, context, answered) {
1039
- const instant = _feedbackIsInstant(list);
1040
1053
  // Multiple open questions are walked one at a time. A submitted card is a
1041
1054
  // read-only record, so it stacks instead — hiding past questions there
1042
1055
  // would lose what was actually asked.
@@ -1071,7 +1084,7 @@ const Sessions = (() => {
1071
1084
  }
1072
1085
  html += `<div class="feedback-options">`;
1073
1086
  q.options.forEach((opt, oi) => {
1074
- const selected = !instant && q.recommended === oi ? " is-selected" : "";
1087
+ const selected = q.recommended === oi ? " is-selected" : "";
1075
1088
  const rec = q.recommended === oi
1076
1089
  ? `<span class="feedback-rec">${escapeHtml(I18n.t("chat.feedback_recommended"))}</span>` : "";
1077
1090
  html += `<button class="feedback-option-btn${selected}" data-question-index="${qi}" data-option-index="${oi}"` +
@@ -1085,22 +1098,19 @@ const Sessions = (() => {
1085
1098
  html += `</div>`;
1086
1099
  });
1087
1100
 
1088
- if (!instant) {
1089
- html += `<div class="feedback-actions">`;
1090
- if (stepped) {
1091
- html += `<button class="feedback-nav-btn feedback-back-btn" disabled>` +
1092
- `${escapeHtml(I18n.t("chat.feedback_back"))}</button>` +
1093
- `<button class="feedback-nav-btn feedback-next-btn">` +
1094
- `${escapeHtml(I18n.t("chat.feedback_next"))}</button>`;
1095
- }
1096
- html += `<button class="btn-primary feedback-submit-btn"${answered ? " disabled" : ""}` +
1097
- `${stepped ? " hidden" : ""}>${escapeHtml(I18n.t("chat.feedback_submit"))}</button>`;
1098
- html += `</div>`;
1101
+ html += `<div class="feedback-actions"><span class="feedback-status">${escapeHtml(I18n.t(answered ? "chat.feedback_answered" : "chat.feedback_waiting"))}</span>`;
1102
+ if (stepped) {
1103
+ html += `<button class="feedback-nav-btn feedback-back-btn" disabled>` +
1104
+ `${escapeHtml(I18n.t("chat.feedback_back"))}</button>` +
1105
+ `<button class="feedback-nav-btn feedback-next-btn">` +
1106
+ `${escapeHtml(I18n.t("chat.feedback_next"))}</button>`;
1099
1107
  }
1100
- html += `<div class="feedback-hint">${I18n.t("chat.feedback_hint")}</div>`;
1108
+ html += `<button class="btn-primary feedback-submit-btn"${answered ? " disabled" : ""}` +
1109
+ `${stepped ? " hidden" : ""}>${escapeHtml(I18n.t("chat.feedback_submit"))}</button>`;
1110
+ html += `</div>`;
1101
1111
 
1102
1112
  card.innerHTML = html;
1103
- if (!answered) _wireFeedbackCard(card, list, instant, stepped);
1113
+ if (!answered) _wireFeedbackCard(card, list, stepped);
1104
1114
  return card;
1105
1115
  }
1106
1116
 
@@ -1129,6 +1139,7 @@ const Sessions = (() => {
1129
1139
  card.querySelectorAll(".feedback-option-btn, .feedback-submit-btn, .feedback-free-text, .feedback-nav-btn")
1130
1140
  .forEach(el => { el.disabled = true; });
1131
1141
  card.classList.add("feedback-card--submitted");
1142
+ card.querySelector(".feedback-status").textContent = I18n.t("chat.feedback_answered");
1132
1143
 
1133
1144
  const input = $("user-input");
1134
1145
  if (input) Composer.setText(input, lines.join("\n"));
@@ -1136,7 +1147,7 @@ const Sessions = (() => {
1136
1147
  return true;
1137
1148
  }
1138
1149
 
1139
- function _wireFeedbackCard(card, list, instant, stepped) {
1150
+ function _wireFeedbackCard(card, list, stepped) {
1140
1151
  let step = 0;
1141
1152
 
1142
1153
  const showStep = (target) => {
@@ -1177,15 +1188,6 @@ const Sessions = (() => {
1177
1188
  btn.onclick = () => {
1178
1189
  const qi = Number(btn.dataset.questionIndex);
1179
1190
 
1180
- if (instant) {
1181
- card.querySelectorAll(".feedback-option-btn").forEach(b => { b.disabled = true; });
1182
- card.classList.add("feedback-card--submitted");
1183
- const input = $("user-input");
1184
- if (input) Composer.setText(input, list[qi].options[Number(btn.dataset.optionIndex)]);
1185
- _sendMessage();
1186
- return;
1187
- }
1188
-
1189
1191
  if (!list[qi].multi) {
1190
1192
  card.querySelectorAll(`.feedback-option-btn[data-question-index="${qi}"]`)
1191
1193
  .forEach(b => b.classList.remove("is-selected"));
@@ -1382,16 +1384,19 @@ const Sessions = (() => {
1382
1384
  _imageSeq = 0;
1383
1385
  _renderAttachmentPreviews();
1384
1386
 
1387
+ _interveneFeedbackCountdown();
1385
1388
  WS.send({ type: "message", session_id: Sessions.activeId, content, files, references, lang: I18n.lang() });
1386
1389
 
1387
1390
  // Disable any pending feedback cards — user has replied (either by clicking
1388
1391
  // an option button or by typing directly). The backend has already consumed
1389
1392
  // the feedback; make the frontend reflect that immediately.
1390
1393
  document.querySelectorAll(".feedback-card:not(.feedback-card--submitted)").forEach(card => {
1391
- card.querySelectorAll(".feedback-option-btn, .feedback-submit-btn, .feedback-free-text")
1394
+ card.querySelectorAll("button, input")
1392
1395
  .forEach(el => { el.disabled = true; });
1393
1396
  card.classList.add("feedback-card--submitted");
1397
+ card.querySelector(".feedback-status").textContent = I18n.t("chat.feedback_answered");
1394
1398
  });
1399
+ _refreshFeedbackStatus();
1395
1400
 
1396
1401
  Composer.clear(input);
1397
1402
  if (typeof QuoteSelect !== "undefined") QuoteSelect.clear();
@@ -1420,6 +1425,64 @@ const Sessions = (() => {
1420
1425
  }
1421
1426
  // contenteditable composer: chip deletion, plain-text paste, Shift+Enter.
1422
1427
  Composer.init($("user-input"));
1428
+ setInterval(_renderFeedbackCountdown, 250);
1429
+ document.addEventListener("input", (e) => {
1430
+ if (e.target.closest?.("#user-input, .feedback-free-text")) _interveneFeedbackCountdown();
1431
+ });
1432
+ document.addEventListener("click", (e) => {
1433
+ if (e.target.closest?.(".feedback-option-btn, .feedback-nav-btn, .feedback-dot")) {
1434
+ _interveneFeedbackCountdown();
1435
+ }
1436
+ }, true);
1437
+
1438
+ const modeButton = $("sib-mode");
1439
+ const modeMenu = $("sib-mode-menu");
1440
+ const closeModeMenu = () => {
1441
+ modeMenu.hidden = true;
1442
+ modeButton.setAttribute("aria-expanded", "false");
1443
+ modeButton.classList.remove("is-open");
1444
+ };
1445
+ modeButton?.addEventListener("click", () => {
1446
+ const open = modeMenu.hidden;
1447
+ modeMenu.hidden = !open;
1448
+ modeButton.setAttribute("aria-expanded", String(open));
1449
+ modeButton.classList.toggle("is-open", open);
1450
+ if (open) {
1451
+ const rect = modeButton.getBoundingClientRect();
1452
+ modeMenu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 248))}px`;
1453
+ modeMenu.style.bottom = `${window.innerHeight - rect.top + 6}px`;
1454
+ modeMenu.querySelector('[aria-checked="true"]')?.focus();
1455
+ }
1456
+ });
1457
+ document.addEventListener("click", (event) => {
1458
+ if (!event.target.closest("#sib-mode, #sib-mode-menu")) closeModeMenu();
1459
+ });
1460
+ modeMenu?.addEventListener("keydown", (event) => {
1461
+ if (event.key === "Escape") { closeModeMenu(); modeButton.focus(); }
1462
+ });
1463
+ modeMenu?.addEventListener("click", async (event) => {
1464
+ const option = event.target.closest("[data-permission-mode]");
1465
+ if (!option) return;
1466
+ const sessionId = Sessions.activeId;
1467
+ const mode = option.dataset.permissionMode;
1468
+ closeModeMenu();
1469
+ modeButton.disabled = true;
1470
+ try {
1471
+ const response = await fetch(`/api/sessions/${sessionId}/permission_mode`, {
1472
+ method: "PATCH",
1473
+ headers: { "Content-Type": "application/json" },
1474
+ body: JSON.stringify({ permission_mode: mode })
1475
+ });
1476
+ const data = await response.json();
1477
+ if (!response.ok) throw new Error(data.error || "Failed to switch permission mode");
1478
+ const session = Sessions.find(sessionId);
1479
+ if (session) session.permission_mode = data.permission_mode;
1480
+ } catch (error) {
1481
+ Modal.toast(error.message);
1482
+ } finally {
1483
+ Sessions.updateInfoBar(Sessions.find(Sessions.activeId));
1484
+ }
1485
+ });
1423
1486
 
1424
1487
  // Send & attach buttons
1425
1488
  const behaviorMenu = $("input-behavior-menu");
@@ -2033,6 +2096,7 @@ const Sessions = (() => {
2033
2096
  el.innerHTML = _buildUserBubbleHtml(ev);
2034
2097
  if (ev.created_at) el.dataset.createdAt = ev.created_at;
2035
2098
  if (ev.round_id) el.dataset.roundId = ev.round_id;
2099
+ if (ev.task_id) el.dataset.taskId = ev.task_id;
2036
2100
  // Messages archived into a compressed chunk can't be edited (the backend
2037
2101
  // truncate keys off the active in-memory history). Flag them so the edit
2038
2102
  // affordance stays hidden.
@@ -2040,8 +2104,7 @@ const Sessions = (() => {
2040
2104
  const wrap = document.createElement("div");
2041
2105
  wrap.className = "msg-user-wrap";
2042
2106
  wrap.appendChild(el);
2043
- _appendUserActionBar(el, wrap);
2044
- _appendMsgTime(wrap, ev.created_at);
2107
+ _appendMsgTime(_appendUserActionBar(el, wrap), ev.created_at);
2045
2108
  outerContainer.appendChild(wrap);
2046
2109
  break;
2047
2110
  }
@@ -2172,18 +2235,6 @@ const Sessions = (() => {
2172
2235
  const rfList = _normalizeFeedbackQuestions(ev.questions, ev.question, ev.options);
2173
2236
  if (rfList.length === 0) break;
2174
2237
 
2175
- if (!_feedbackNeedsCard(rfList)) {
2176
- // Nothing selectable — render as plain assistant bubble
2177
- const rfText = _feedbackPlainText(rfList, rfContext);
2178
- const rfEl = document.createElement("div");
2179
- rfEl.className = "msg msg-assistant";
2180
- rfEl.dataset.raw = rfText;
2181
- rfEl.innerHTML = _renderMarkdown(rfText);
2182
- _appendCopyButton(rfEl);
2183
- container.appendChild(rfEl);
2184
- break;
2185
- }
2186
-
2187
2238
  container.appendChild(_buildFeedbackCard(rfList, rfContext, ev._answered === true));
2188
2239
  break;
2189
2240
  }
@@ -2348,6 +2399,8 @@ const Sessions = (() => {
2348
2399
  if (!prepend && !state.hasAfter) _flushPendingStdout();
2349
2400
  }
2350
2401
 
2402
+ _refreshFeedbackStatus();
2403
+
2351
2404
  // If no more history remains, insert a "beginning of conversation" marker at the top.
2352
2405
  // Remove any existing marker first to avoid duplicates.
2353
2406
  messages.querySelector(".history-start-marker")?.remove();
@@ -2450,10 +2503,13 @@ const Sessions = (() => {
2450
2503
  const now = new Date();
2451
2504
  const pad = n => String(n).padStart(2, "0");
2452
2505
  const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
2453
- const sameDay = d.getFullYear() === now.getFullYear() &&
2454
- d.getMonth() === now.getMonth() &&
2455
- d.getDate() === now.getDate();
2456
- return sameDay ? hhmm : `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`;
2506
+ const sameYear = d.getFullYear() === now.getFullYear();
2507
+ const sameDay = sameYear &&
2508
+ d.getMonth() === now.getMonth() &&
2509
+ d.getDate() === now.getDate();
2510
+ if (sameDay) return hhmm;
2511
+ const md = `${d.getMonth() + 1}月${d.getDate()}日`;
2512
+ return sameYear ? `${md} ${hhmm}` : `${d.getFullYear()}年${md} ${hhmm}`;
2457
2513
  }
2458
2514
 
2459
2515
  // Append a .msg-time span to a message element.
@@ -2468,15 +2524,15 @@ const Sessions = (() => {
2468
2524
 
2469
2525
  // ── User message action bar (copy + edit) ───────────────────────────────
2470
2526
 
2471
- const COPY_SVG = `<svg class="msg-user-copy-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
2472
- `<path fill="currentColor" d="M10 1H4a2 2 0 0 0-2 2v8h1.5V3a.5.5 0 0 1 .5-.5h6V1zm3 3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm.5 10a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v8z"/>` +
2527
+ const COPY_SVG = `<svg class="msg-user-copy-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
2528
+ `<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>` +
2473
2529
  `</svg>` +
2474
- `<svg class="msg-user-copy-icon-check" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
2475
- `<path fill="currentColor" d="M13.5 3.5 6 11 2.5 7.5 1 9l5 5 9-9z"/>` +
2530
+ `<svg class="msg-user-copy-icon-check" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
2531
+ `<path d="M20 6 9 17l-5-5"/>` +
2476
2532
  `</svg>`;
2477
2533
 
2478
- const EDIT_SVG = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
2479
- `<path fill="currentColor" d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354l-1.086-1.086zM11.189 6.25 9.75 4.81l-6.286 6.287a.25.25 0 0 0-.064.108l-.558 1.953 1.953-.558a.249.249 0 0 0 .108-.064l6.286-6.286z"/>` +
2534
+ const EDIT_SVG = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
2535
+ `<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4z"/>` +
2480
2536
  `</svg>`;
2481
2537
 
2482
2538
  function _appendUserActionBar(el, wrap) {
@@ -2520,6 +2576,7 @@ const Sessions = (() => {
2520
2576
  // chunk — editing them would silently no-op on the backend.
2521
2577
  if (el.dataset.editable !== "false") bar.appendChild(editBtn);
2522
2578
  wrap.appendChild(bar);
2579
+ return bar;
2523
2580
  }
2524
2581
 
2525
2582
  function _activeSessionIsRunning() {
@@ -2896,14 +2953,49 @@ const Sessions = (() => {
2896
2953
  // visible in a single-line ellipsis layout. Backend already gives us a
2897
2954
  // ±N byte window, but for ASCII-heavy lines that's still wider than the
2898
2955
  // sidebar can render on one line. We trim the head when the match sits
2899
- // too far to the right, keeping ~`headRoom` chars before it.
2900
- function _centerSnippet(text, query, headRoom = 16) {
2901
- const t = text || "";
2956
+ // too far to the right, keeping ~`headRoom` chars before it and snapping
2957
+ // forward to a word boundary so the head never starts mid-token.
2958
+ function _centerSnippet(text, query, headRoom = 28) {
2959
+ const t = (text || "").trim();
2902
2960
  const q = (query || "").trim();
2903
2961
  if (!q) return t;
2904
2962
  const idx = t.toLowerCase().indexOf(q.toLowerCase());
2905
2963
  if (idx <= headRoom) return t;
2906
- return "…" + t.slice(idx - headRoom);
2964
+ let from = idx - headRoom;
2965
+ const space = t.indexOf(" ", from);
2966
+ if (space !== -1 && space - from <= 12) from = space + 1;
2967
+ return "…" + t.slice(from).replace(/^…\s*/, "");
2968
+ }
2969
+
2970
+ // Snippets are raw grep hits from session JSON / chunk markdown, so drop
2971
+ // JSON escapes plus the markdown syntax that reads as noise in a one-line
2972
+ // teaser (**bold**, `code`, [label](url), list markers, stray ellipses).
2973
+ function _cleanSnippet(text) {
2974
+ return (text || "")
2975
+ .replace(/```[\s\S]*?```/g, " ")
2976
+ .replace(/\\[nrt]/g, " ")
2977
+ .replace(/\\(["\\/])/g, "$1")
2978
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
2979
+ .replace(/(^|\s)[-*+>]\s+/g, "$1")
2980
+ .replace(/(^|\s)#{1,6}\s+/g, "$1")
2981
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
2982
+ .replace(/__([^_]+)__/g, "$1")
2983
+ .replace(/~~([^~]+)~~/g, "$1")
2984
+ // Orphan markers left behind when the grep window cuts a pair in half.
2985
+ .replace(/(?<![\w*])\*{2,}(?![\w*])/g, "")
2986
+ .replace(/`/g, "")
2987
+ .replace(/<\/?[a-zA-Z][^>]{0,40}>/g, " ")
2988
+ // Multi-byte chars that grep cut in half surface as consecutive "?".
2989
+ .replace(/\?{2,}/g, "…")
2990
+ // JSON key/value remnants when the hit lands inside a serialized field.
2991
+ .replace(/^…?\s*"?[^"]{0,24}"\s*:\s*/, "…")
2992
+ .replace(/…\s*["',,、。;;::\s]+/g, "…")
2993
+ .replace(/([\u4e00-\u9fff))】」])\?+(?=…|\.\.\.)/g, "$1…")
2994
+ .replace(/^[":;,\s]+|[";,\s]+$/g, "")
2995
+ .replace(/(?:…|\.{3,})+/g, "…")
2996
+ .replace(/([,。;:、!?)】」])\s+/g, "$1")
2997
+ .replace(/\s+/g, " ")
2998
+ .trim();
2907
2999
  }
2908
3000
 
2909
3001
  // Reveal a single-line overflowing label on hover. Callers provide their
@@ -3053,7 +3145,7 @@ const Sessions = (() => {
3053
3145
  : "";
3054
3146
 
3055
3147
  const snippetHtml = (s._matchVia === "content" && s.search_snippet)
3056
- ? `<div class="session-snippet">${_highlightSnippet(_centerSnippet(s.search_snippet, _filter.q), _filter.q)}</div>`
3148
+ ? `<div class="session-snippet">${_highlightSnippet(_centerSnippet(_cleanSnippet(s.search_snippet), _filter.q), _filter.q)}</div>`
3057
3149
  : "";
3058
3150
 
3059
3151
  el.innerHTML = `
@@ -3316,9 +3408,10 @@ const Sessions = (() => {
3316
3408
  document.addEventListener("click", async (e) => {
3317
3409
  // Localhost dev-server URLs open in the preview panel instead of a new
3318
3410
  // tab (local deployments only — the proxy can't reach a remote user's
3319
- // localhost).
3411
+ // localhost). Links that belong to the platform (not a dev server) opt
3412
+ // out with data-open-external, so they still get a real browser tab.
3320
3413
  const localLink = e.target.closest("a[href]");
3321
- if (localLink) {
3414
+ if (localLink && !localLink.hasAttribute("data-open-external")) {
3322
3415
  const href = localLink.getAttribute("href") || "";
3323
3416
  if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?([/?#]|$)/i.test(href) &&
3324
3417
  ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname) &&
@@ -3376,14 +3469,27 @@ const Sessions = (() => {
3376
3469
  // ── List management ───────────────────────────────────────────────────
3377
3470
 
3378
3471
  /** Populate list from initial session_list WS event (connect only). */
3379
- setAll(list, hasMore = false, groups = {}) {
3472
+ setAll(list, hasMore = false, groups = {}, nextCursor = null) {
3380
3473
  _sessions.length = 0;
3381
3474
  _sessions.push(...list);
3382
3475
  _hasMore = !!hasMore;
3476
+ _cursor = nextCursor;
3383
3477
  _groups.forEach(g => { g.count = 0; g.latestUpdatedAt = null; });
3384
3478
  _applyGroupStats(groups);
3385
3479
  },
3386
3480
 
3481
+ /** Whether a full snapshot sorts inside the loaded pages of the main
3482
+ * list. Grouped sessions render behind their group entry instead. */
3483
+ belongsToLoadedPage(session) {
3484
+ if (_groupOf(session) || session.archived_at) return false;
3485
+ if (session.pinned || !_hasMore || !_cursor) return true;
3486
+ const t = session.updated_at || session.created_at;
3487
+ if (!t) return false;
3488
+ // Same-second ties fall back to the server's id order, like the cursor itself.
3489
+ const diff = new Date(t) - new Date(_cursor.before);
3490
+ return diff > 0 || (diff === 0 && session.id >= _cursor.before_id);
3491
+ },
3492
+
3387
3493
  /** Insert or refresh a newly created session in the local list. */
3388
3494
  add(session) {
3389
3495
  if (!session || !session.id) return;
@@ -3475,6 +3581,7 @@ const Sessions = (() => {
3475
3581
  }
3476
3582
 
3477
3583
  const updated = s || _extraSessions.find(e => e.id === id);
3584
+ if (id === _activeId && "name" in fields) Sessions.updateChatHeader(updated);
3478
3585
  const isGroup = _groupOf(updated);
3479
3586
  if (wasGroup !== isGroup) {
3480
3587
  if (wasGroup) wasGroup.count = Math.max(0, wasGroup.count - 1);
@@ -3567,19 +3674,8 @@ const Sessions = (() => {
3567
3674
  Sessions.renderList();
3568
3675
 
3569
3676
  try {
3570
- // Cursor: oldest activity time of non-pinned, non-grouped, non-project
3571
- // sessions. Project sessions sit in _sessions but render in their own
3572
- // section; counting them here would drag the cursor back to a stale
3573
- // timestamp and skip a whole page of regular sessions.
3574
- const oldest = _sessions.reduce((min, s) => {
3575
- if (s.pinned || s.project_id || _groupOf(s)) return min;
3576
- const t = s.updated_at || s.created_at;
3577
- if (!t) return min;
3578
- return (!min || t < min) ? t : min;
3579
- }, null);
3580
-
3581
3677
  const params = new URLSearchParams({ limit: "10", exclude_type: GROUP_SOURCES.join(",") });
3582
- if (oldest) params.set("before", oldest);
3678
+ if (_cursor) { params.set("before", _cursor.before); params.set("before_id", _cursor.before_id); }
3583
3679
  if (_filter.q) params.set("q", _filter.q);
3584
3680
  if (_filter.date) params.set("date", _filter.date);
3585
3681
  if (_filter.type) params.set("type", _filter.type);
@@ -3592,6 +3688,7 @@ const Sessions = (() => {
3592
3688
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
3593
3689
  });
3594
3690
  _hasMore = !!data.has_more;
3691
+ if (data.next_cursor) _cursor = data.next_cursor;
3595
3692
  _applyGroupStats(data.groups);
3596
3693
  } catch (e) {
3597
3694
  console.error("loadMore error:", e);
@@ -3623,7 +3720,7 @@ const Sessions = (() => {
3623
3720
 
3624
3721
  try {
3625
3722
  const params = new URLSearchParams({ limit: "10", type: source });
3626
- if (group.before) params.set("before", group.before);
3723
+ if (group.before) { params.set("before", group.before.before); params.set("before_id", group.before.before_id); }
3627
3724
 
3628
3725
  const res = await fetch(`/api/sessions?${params}`);
3629
3726
  if (!res.ok) return;
@@ -3633,16 +3730,7 @@ const Sessions = (() => {
3633
3730
  rows.forEach(s => {
3634
3731
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
3635
3732
  });
3636
- // Advance cursor to the oldest activity time in THIS batch
3637
- // (exclude pinned — backend returns all pinned on the first page,
3638
- // bypassing pagination, so their time must not drag the cursor back).
3639
- const oldest = rows.reduce((min, s) => {
3640
- if (s.pinned) return min;
3641
- const t = s.updated_at || s.created_at;
3642
- if (!t) return min;
3643
- return (!min || t < min) ? t : min;
3644
- }, null);
3645
- if (oldest) group.before = oldest;
3733
+ if (data.next_cursor) group.before = data.next_cursor;
3646
3734
  group.hasMore = !!data.has_more;
3647
3735
  _applyGroupStats(data.groups);
3648
3736
  } catch (e) {
@@ -4006,7 +4094,8 @@ const Sessions = (() => {
4006
4094
  // Within same pinned status, sort by last activity (newest first)
4007
4095
  const ta = a.updated_at || a.created_at;
4008
4096
  const tb = b.updated_at || b.created_at;
4009
- return new Date(tb || 0) - new Date(ta || 0);
4097
+ // Same-second ties follow the server's id order so arrival order can't reshuffle rows.
4098
+ return (new Date(tb || 0) - new Date(ta || 0)) || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0);
4010
4099
  };
4011
4100
 
4012
4101
  // ── Sidebar list always shows the full session set, sorted ───────
@@ -4458,6 +4547,7 @@ const Sessions = (() => {
4458
4547
  Sessions.remove(sessionId);
4459
4548
  Sessions.renderList();
4460
4549
  Projects.renderSection();
4550
+ Trash.refreshIfShown();
4461
4551
  // If deleted session was active, switch to welcome
4462
4552
  if (sessionId === _activeId) {
4463
4553
  Router.navigate("welcome");
@@ -4539,7 +4629,7 @@ const Sessions = (() => {
4539
4629
  const inp = $("user-input");
4540
4630
  if (inp) {
4541
4631
  const mobile = window.innerWidth <= 768;
4542
- const key = status === "running"
4632
+ const key = _hasPendingFeedback() ? "chat.input.placeholderFeedback" : status === "running"
4543
4633
  ? (mobile ? "chat.input.placeholderRunningMobile" : "chat.input.placeholderRunning")
4544
4634
  : (mobile ? "chat.input.placeholderMobile" : "chat.input.placeholder");
4545
4635
  inp.setAttribute("data-i18n-placeholder", key);
@@ -4569,10 +4659,10 @@ const Sessions = (() => {
4569
4659
  if (!s) {
4570
4660
  // Hide all spans when no session. Fields that own a static caret (dir,
4571
4661
  // reasoning) keep their markup and only blank the text node.
4572
- ["sib-id", "sib-status", "sib-mode", "sib-model", "sib-cost"].forEach(id => {
4662
+ ["sib-id", "sib-status", "sib-model", "sib-cost"].forEach(id => {
4573
4663
  const el = $(id); if (el) el.textContent = "";
4574
4664
  });
4575
- ["sib-dir-text", "sib-reasoning-text"].forEach(id => {
4665
+ ["sib-dir-text", "sib-reasoning-text", "sib-mode-text"].forEach(id => {
4576
4666
  const el = $(id); if (el) el.textContent = "";
4577
4667
  });
4578
4668
  const sibIdEl = $("sib-id");
@@ -4587,7 +4677,7 @@ const Sessions = (() => {
4587
4677
  // Status dot + text — first
4588
4678
  const sibStatus = $("sib-status");
4589
4679
  if (sibStatus) {
4590
- const st = s.status || "idle";
4680
+ const st = _hasPendingFeedback() ? "awaiting_feedback" : (s.status || "idle");
4591
4681
  const labelKey = `sib.status.${st}`;
4592
4682
  const label = I18n.t(labelKey);
4593
4683
  sibStatus.innerHTML = `<span class="sib-dot"></span>${escapeHtml(label === labelKey ? st : label)}`;
@@ -4629,16 +4719,28 @@ const Sessions = (() => {
4629
4719
  }
4630
4720
  }
4631
4721
 
4632
- // Permission mode — hide element and its separator if empty
4722
+ // Permission mode — ordinary Web sessions default to confirm_all.
4633
4723
  const sibMode = $("sib-mode");
4724
+ const sibModeText = $("sib-mode-text");
4634
4725
  const sibSepAfterMode = document.querySelector(".sib-sep-after-mode");
4635
- if (sibMode) {
4636
- sibMode.textContent = s.permission_mode || "";
4637
- sibMode.title = s.permission_mode ? I18n.t("sib.mode.tooltip") : "";
4638
- sibMode.style.display = s.permission_mode ? "" : "none";
4726
+ if (sibMode && sibModeText) {
4727
+ const mode = s.permission_mode || "confirm_all";
4728
+ sibModeText.textContent = I18n.t(`sib.mode.${mode}`);
4729
+ document.querySelectorAll("[data-permission-mode]").forEach(option => {
4730
+ option.setAttribute("aria-checked", String(option.dataset.permissionMode === mode));
4731
+ });
4732
+ if (sibMode.dataset.sessionId !== s.id) {
4733
+ $("sib-mode-menu").hidden = true;
4734
+ sibMode.classList.remove("is-open");
4735
+ sibMode.setAttribute("aria-expanded", "false");
4736
+ }
4737
+ sibMode.dataset.sessionId = s.id;
4738
+ sibMode.disabled = false;
4739
+ sibMode.title = I18n.t("sib.mode.tooltip");
4740
+ sibMode.style.display = "";
4639
4741
  }
4640
4742
  if (sibSepAfterMode) {
4641
- sibSepAfterMode.style.display = s.permission_mode ? "" : "none";
4743
+ sibSepAfterMode.style.display = "";
4642
4744
  }
4643
4745
 
4644
4746
  // Model — hide wrap entirely if empty
@@ -4678,7 +4780,6 @@ const Sessions = (() => {
4678
4780
 
4679
4781
  const sibReasoning = $("sib-reasoning");
4680
4782
  const sibReasoningWrap = $("sib-reasoning-wrap");
4681
- const sibSepAfterReasoning = document.querySelector(".sib-sep-after-reasoning");
4682
4783
  if (sibReasoning) {
4683
4784
  const eff = (s.reasoning_effort || "off").toLowerCase();
4684
4785
  const sibReasoningText = $("sib-reasoning-text");
@@ -4687,7 +4788,6 @@ const Sessions = (() => {
4687
4788
  sibReasoning.dataset.reasoningEffort = eff;
4688
4789
  }
4689
4790
  if (sibReasoningWrap) sibReasoningWrap.style.display = "";
4690
- if (sibSepAfterReasoning) sibSepAfterReasoning.style.display = "";
4691
4791
 
4692
4792
  // Latency signal — read from s.latest_latency (populated by:
4693
4793
  // - HTTP /api/sessions → session_registry#list (from agent.latest_latency)
@@ -5087,8 +5187,8 @@ const Sessions = (() => {
5087
5187
  const wrap = document.createElement("div");
5088
5188
  wrap.className = "msg-user-wrap";
5089
5189
  wrap.appendChild(el);
5090
- _appendUserActionBar(el, wrap);
5091
- if (time) _appendMsgTime(wrap, time);
5190
+ const bar = _appendUserActionBar(el, wrap);
5191
+ if (time) _appendMsgTime(bar, time);
5092
5192
  messages.appendChild(wrap);
5093
5193
  _refreshEditButtons(messages);
5094
5194
  } else {
@@ -5142,25 +5242,38 @@ const Sessions = (() => {
5142
5242
  _scrollToBottomIfNeeded(messages);
5143
5243
  },
5144
5244
 
5245
+ updateFeedbackCountdown(ev) {
5246
+ const previous = _feedbackCountdowns.get(ev.session_id);
5247
+ if (ev.resolved) {
5248
+ if (ev.request_id && previous?.request_id !== ev.request_id) return;
5249
+ _feedbackCountdowns.delete(ev.session_id);
5250
+ if (ev.session_id === Sessions.activeId && ev.resolution === "timeout") {
5251
+ document.querySelectorAll(".feedback-card:not(.feedback-card--submitted)").forEach(card => {
5252
+ card.classList.add("feedback-card--submitted");
5253
+ card.querySelector(".feedback-status").textContent = I18n.t("chat.feedback_timeout");
5254
+ card.querySelectorAll("button, input").forEach(el => { el.disabled = true; });
5255
+ });
5256
+ }
5257
+ } else {
5258
+ _feedbackCountdowns.set(ev.session_id, ev);
5259
+ if (ev.session_id === Sessions.activeId && Composer.text($("user-input")).trim()) {
5260
+ _interveneFeedbackCountdown();
5261
+ }
5262
+ }
5263
+ _refreshFeedbackStatus();
5264
+ },
5265
+
5145
5266
  // Display an ask_user UI card with clickable options.
5146
- // Called when the agent needs user input to continue.
5147
5267
  showFeedbackRequest(question, context, options, questions) {
5148
5268
  Sessions.collapseToolGroup();
5149
5269
  const messages = RenderTarget.current();
5150
5270
  const list = _normalizeFeedbackQuestions(questions, question, options);
5151
5271
 
5152
- // Nothing selectable → plain assistant bubble (card UI adds no value without choices)
5153
- if (!_feedbackNeedsCard(list)) {
5154
- const text = _feedbackPlainText(list, context);
5155
- if (!text) return;
5156
- // Pass raw markdown; appendMsg renders it via _renderMarkdown and
5157
- // also stashes it on dataset.raw for the copy button.
5158
- Sessions.appendMsg("assistant", text);
5159
- return;
5160
- }
5272
+ if (list.length === 0) return;
5161
5273
 
5162
5274
  const card = _buildFeedbackCard(list, context, false);
5163
5275
  messages.appendChild(card);
5276
+ _refreshFeedbackStatus();
5164
5277
  _scrollToBottomIfNeeded(messages);
5165
5278
  },
5166
5279