openclacky 1.5.19 → 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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +19 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/lib/clacky/agent/chunk_index.rb +8 -1
  7. data/lib/clacky/agent/feedback.rb +30 -0
  8. data/lib/clacky/agent/history_navigation.rb +17 -3
  9. data/lib/clacky/agent/hook_manager.rb +6 -1
  10. data/lib/clacky/agent/llm_caller.rb +17 -3
  11. data/lib/clacky/agent/message_compressor_helper.rb +36 -2
  12. data/lib/clacky/agent/session_serializer.rb +45 -4
  13. data/lib/clacky/agent.rb +135 -55
  14. data/lib/clacky/agent_config.rb +82 -4
  15. data/lib/clacky/billing/platform_billing.rb +10 -5
  16. data/lib/clacky/brand_config.rb +1 -1
  17. data/lib/clacky/cli.rb +5 -4
  18. data/lib/clacky/client.rb +12 -5
  19. data/lib/clacky/deepseek_web_tools.rb +156 -0
  20. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  21. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
  22. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  23. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  24. data/lib/clacky/extension/loader.rb +5 -3
  25. data/lib/clacky/locales/en.rb +5 -5
  26. data/lib/clacky/locales/zh.rb +5 -5
  27. data/lib/clacky/message_format/anthropic.rb +20 -0
  28. data/lib/clacky/message_format/open_ai.rb +31 -14
  29. data/lib/clacky/message_history.rb +45 -4
  30. data/lib/clacky/platform_http_client.rb +6 -2
  31. data/lib/clacky/providers.rb +38 -138
  32. data/lib/clacky/search_config.rb +47 -13
  33. data/lib/clacky/server/browser_manager.rb +35 -5
  34. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  35. data/lib/clacky/server/discover.rb +5 -4
  36. data/lib/clacky/server/http_server.rb +285 -53
  37. data/lib/clacky/server/model_prices.rb +10 -5
  38. data/lib/clacky/server/pid_files.rb +25 -0
  39. data/lib/clacky/server/server_master.rb +9 -3
  40. data/lib/clacky/server/session_registry.rb +37 -7
  41. data/lib/clacky/server/web_ui_controller.rb +72 -0
  42. data/lib/clacky/session_manager.rb +28 -6
  43. data/lib/clacky/skill.rb +1 -1
  44. data/lib/clacky/tools/ask_user.rb +4 -1
  45. data/lib/clacky/tools/base.rb +3 -2
  46. data/lib/clacky/tools/browser.rb +18 -2
  47. data/lib/clacky/tools/file_reader.rb +1 -1
  48. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  49. data/lib/clacky/tools/terminal.rb +65 -23
  50. data/lib/clacky/tools/trash_manager.rb +5 -3
  51. data/lib/clacky/tools/web_fetch.rb +81 -22
  52. data/lib/clacky/tools/web_search.rb +45 -4
  53. data/lib/clacky/ui2/ui_controller.rb +1 -1
  54. data/lib/clacky/ui_interface.rb +4 -3
  55. data/lib/clacky/utils/file_processor.rb +51 -0
  56. data/lib/clacky/utils/model_pricing.rb +242 -39
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +1158 -249
  59. data/lib/clacky/web/app.js +82 -13
  60. data/lib/clacky/web/components/chat-navigator.js +8 -1
  61. data/lib/clacky/web/components/code-editor.js +6 -2
  62. data/lib/clacky/web/components/mentions.js +2 -13
  63. data/lib/clacky/web/components/model-picker.js +92 -97
  64. data/lib/clacky/web/components/onboard.js +13 -10
  65. data/lib/clacky/web/components/sidebar.js +6 -2
  66. data/lib/clacky/web/features/archive/store.js +196 -0
  67. data/lib/clacky/web/features/archive/view.js +409 -0
  68. data/lib/clacky/web/features/backup/view.js +17 -7
  69. data/lib/clacky/web/features/billing/view.js +84 -10
  70. data/lib/clacky/web/features/channels/view.js +75 -29
  71. data/lib/clacky/web/features/new-session/store.js +3 -3
  72. data/lib/clacky/web/features/new-session/view.js +1 -0
  73. data/lib/clacky/web/features/trash/store.js +14 -11
  74. data/lib/clacky/web/features/trash/view.js +66 -13
  75. data/lib/clacky/web/features/workspace/view.js +421 -16
  76. data/lib/clacky/web/i18n.js +242 -70
  77. data/lib/clacky/web/index.html +270 -117
  78. data/lib/clacky/web/projects.js +56 -1
  79. data/lib/clacky/web/sessions.js +609 -267
  80. data/lib/clacky/web/settings.js +186 -32
  81. data/lib/clacky/web/ws-dispatcher.js +33 -21
  82. data/lib/clacky.rb +4 -2
  83. metadata +6 -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)) {
@@ -918,6 +920,7 @@ const Sessions = (() => {
918
920
  item.className = "img-preview-item";
919
921
  item.title = img.name;
920
922
  const thumbnail = document.createElement("img");
923
+ thumbnail.className = "img-preview-thumb";
921
924
  thumbnail.src = img.dataUrl;
922
925
  thumbnail.alt = img.name;
923
926
  const removeBtn = document.createElement("button");
@@ -979,6 +982,41 @@ const Sessions = (() => {
979
982
  // ── sendMessage ────────────────────────────────────────────────────────
980
983
  let _sending = false;
981
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
+
982
1020
  // ── ask_user feedback card ────────────────────────────────────────────
983
1021
  //
984
1022
  // The backend always sends a normalized `questions` array, but older stored
@@ -1011,31 +1049,7 @@ const Sessions = (() => {
1011
1049
  }];
1012
1050
  }
1013
1051
 
1014
- function _feedbackNeedsCard(list) {
1015
- return list.some(q => q.options.length > 0);
1016
- }
1017
-
1018
- function _feedbackPlainText(list, context) {
1019
- // Normalize bullet symbols to markdown list format so marked renders them as <ul>
1020
- const normalizeBullets = (t) => t ? t.replace(/^[•·‣▸▪\-–]\s*/gm, "- ") : t;
1021
- const parts = [];
1022
- if (context && context.trim()) parts.push(context.trim());
1023
- list.forEach(q => {
1024
- parts.push(q.question);
1025
- if (q.description) parts.push(q.description);
1026
- });
1027
- return parts.map(normalizeBullets).join("\n\n");
1028
- }
1029
-
1030
- // A single-choice question with no free-text escape submits on click, which
1031
- // keeps the one-question case at one tap. Anything else needs an explicit
1032
- // submit so partial answers aren't sent.
1033
- function _feedbackIsInstant(list) {
1034
- return list.length === 1 && !list[0].multi && !list[0].allow_free_text;
1035
- }
1036
-
1037
1052
  function _buildFeedbackCard(list, context, answered) {
1038
- const instant = _feedbackIsInstant(list);
1039
1053
  // Multiple open questions are walked one at a time. A submitted card is a
1040
1054
  // read-only record, so it stacks instead — hiding past questions there
1041
1055
  // would lose what was actually asked.
@@ -1070,7 +1084,7 @@ const Sessions = (() => {
1070
1084
  }
1071
1085
  html += `<div class="feedback-options">`;
1072
1086
  q.options.forEach((opt, oi) => {
1073
- const selected = !instant && q.recommended === oi ? " is-selected" : "";
1087
+ const selected = q.recommended === oi ? " is-selected" : "";
1074
1088
  const rec = q.recommended === oi
1075
1089
  ? `<span class="feedback-rec">${escapeHtml(I18n.t("chat.feedback_recommended"))}</span>` : "";
1076
1090
  html += `<button class="feedback-option-btn${selected}" data-question-index="${qi}" data-option-index="${oi}"` +
@@ -1084,22 +1098,19 @@ const Sessions = (() => {
1084
1098
  html += `</div>`;
1085
1099
  });
1086
1100
 
1087
- if (!instant) {
1088
- html += `<div class="feedback-actions">`;
1089
- if (stepped) {
1090
- html += `<button class="feedback-nav-btn feedback-back-btn" disabled>` +
1091
- `${escapeHtml(I18n.t("chat.feedback_back"))}</button>` +
1092
- `<button class="feedback-nav-btn feedback-next-btn">` +
1093
- `${escapeHtml(I18n.t("chat.feedback_next"))}</button>`;
1094
- }
1095
- html += `<button class="btn-primary feedback-submit-btn"${answered ? " disabled" : ""}` +
1096
- `${stepped ? " hidden" : ""}>${escapeHtml(I18n.t("chat.feedback_submit"))}</button>`;
1097
- 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>`;
1098
1107
  }
1099
- 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>`;
1100
1111
 
1101
1112
  card.innerHTML = html;
1102
- if (!answered) _wireFeedbackCard(card, list, instant, stepped);
1113
+ if (!answered) _wireFeedbackCard(card, list, stepped);
1103
1114
  return card;
1104
1115
  }
1105
1116
 
@@ -1128,6 +1139,7 @@ const Sessions = (() => {
1128
1139
  card.querySelectorAll(".feedback-option-btn, .feedback-submit-btn, .feedback-free-text, .feedback-nav-btn")
1129
1140
  .forEach(el => { el.disabled = true; });
1130
1141
  card.classList.add("feedback-card--submitted");
1142
+ card.querySelector(".feedback-status").textContent = I18n.t("chat.feedback_answered");
1131
1143
 
1132
1144
  const input = $("user-input");
1133
1145
  if (input) Composer.setText(input, lines.join("\n"));
@@ -1135,7 +1147,7 @@ const Sessions = (() => {
1135
1147
  return true;
1136
1148
  }
1137
1149
 
1138
- function _wireFeedbackCard(card, list, instant, stepped) {
1150
+ function _wireFeedbackCard(card, list, stepped) {
1139
1151
  let step = 0;
1140
1152
 
1141
1153
  const showStep = (target) => {
@@ -1176,15 +1188,6 @@ const Sessions = (() => {
1176
1188
  btn.onclick = () => {
1177
1189
  const qi = Number(btn.dataset.questionIndex);
1178
1190
 
1179
- if (instant) {
1180
- card.querySelectorAll(".feedback-option-btn").forEach(b => { b.disabled = true; });
1181
- card.classList.add("feedback-card--submitted");
1182
- const input = $("user-input");
1183
- if (input) Composer.setText(input, list[qi].options[Number(btn.dataset.optionIndex)]);
1184
- _sendMessage();
1185
- return;
1186
- }
1187
-
1188
1191
  if (!list[qi].multi) {
1189
1192
  card.querySelectorAll(`.feedback-option-btn[data-question-index="${qi}"]`)
1190
1193
  .forEach(b => b.classList.remove("is-selected"));
@@ -1381,16 +1384,19 @@ const Sessions = (() => {
1381
1384
  _imageSeq = 0;
1382
1385
  _renderAttachmentPreviews();
1383
1386
 
1387
+ _interveneFeedbackCountdown();
1384
1388
  WS.send({ type: "message", session_id: Sessions.activeId, content, files, references, lang: I18n.lang() });
1385
1389
 
1386
1390
  // Disable any pending feedback cards — user has replied (either by clicking
1387
1391
  // an option button or by typing directly). The backend has already consumed
1388
1392
  // the feedback; make the frontend reflect that immediately.
1389
1393
  document.querySelectorAll(".feedback-card:not(.feedback-card--submitted)").forEach(card => {
1390
- card.querySelectorAll(".feedback-option-btn, .feedback-submit-btn, .feedback-free-text")
1394
+ card.querySelectorAll("button, input")
1391
1395
  .forEach(el => { el.disabled = true; });
1392
1396
  card.classList.add("feedback-card--submitted");
1397
+ card.querySelector(".feedback-status").textContent = I18n.t("chat.feedback_answered");
1393
1398
  });
1399
+ _refreshFeedbackStatus();
1394
1400
 
1395
1401
  Composer.clear(input);
1396
1402
  if (typeof QuoteSelect !== "undefined") QuoteSelect.clear();
@@ -1419,6 +1425,64 @@ const Sessions = (() => {
1419
1425
  }
1420
1426
  // contenteditable composer: chip deletion, plain-text paste, Shift+Enter.
1421
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
+ });
1422
1486
 
1423
1487
  // Send & attach buttons
1424
1488
  const behaviorMenu = $("input-behavior-menu");
@@ -2015,6 +2079,11 @@ const Sessions = (() => {
2015
2079
  }
2016
2080
 
2017
2081
  switch (ev.type) {
2082
+ case "error": {
2083
+ if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
2084
+ window.renderErrorEvent(ev, container);
2085
+ break;
2086
+ }
2018
2087
  case "history_user_message": {
2019
2088
  // Collapse any open tool group from the previous round
2020
2089
  if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
@@ -2027,6 +2096,7 @@ const Sessions = (() => {
2027
2096
  el.innerHTML = _buildUserBubbleHtml(ev);
2028
2097
  if (ev.created_at) el.dataset.createdAt = ev.created_at;
2029
2098
  if (ev.round_id) el.dataset.roundId = ev.round_id;
2099
+ if (ev.task_id) el.dataset.taskId = ev.task_id;
2030
2100
  // Messages archived into a compressed chunk can't be edited (the backend
2031
2101
  // truncate keys off the active in-memory history). Flag them so the edit
2032
2102
  // affordance stays hidden.
@@ -2034,8 +2104,7 @@ const Sessions = (() => {
2034
2104
  const wrap = document.createElement("div");
2035
2105
  wrap.className = "msg-user-wrap";
2036
2106
  wrap.appendChild(el);
2037
- _appendUserActionBar(el, wrap);
2038
- _appendMsgTime(wrap, ev.created_at);
2107
+ _appendMsgTime(_appendUserActionBar(el, wrap), ev.created_at);
2039
2108
  outerContainer.appendChild(wrap);
2040
2109
  break;
2041
2110
  }
@@ -2065,15 +2134,18 @@ const Sessions = (() => {
2065
2134
 
2066
2135
  case "tool_result": {
2067
2136
  if (historyCtx.group && historyCtx.lastItem) {
2068
- const status = historyCtx.lastItem.querySelector(".tool-item-status");
2137
+ // Replay emits all calls of a round before their results, so pair results in call order.
2138
+ const running = historyCtx.group.querySelector(".tool-item-status.running");
2139
+ const item = (running && running.closest(".tool-item")) || historyCtx.lastItem;
2140
+ const status = item.querySelector(".tool-item-status");
2069
2141
  if (status) { status.className = "tool-item-status ok"; status.textContent = "✓"; }
2070
- const toolName = historyCtx.lastItem.dataset.toolName || "";
2142
+ const toolName = item.dataset.toolName || "";
2071
2143
  if (toolName === "edit" || toolName === "write") {
2072
2144
  let parsedArgs = null;
2073
- try { parsedArgs = JSON.parse(historyCtx.lastItem.dataset.argsJson || "null"); } catch (_) {}
2074
- if (parsedArgs) _renderEditWriteDiff(historyCtx.lastItem, toolName, parsedArgs);
2145
+ try { parsedArgs = JSON.parse(item.dataset.argsJson || "null"); } catch (_) {}
2146
+ if (parsedArgs) _renderEditWriteDiff(item, toolName, parsedArgs);
2075
2147
  }
2076
- const stdout = historyCtx.lastItem.querySelector(".tool-item-stdout");
2148
+ const stdout = item.querySelector(".tool-item-stdout");
2077
2149
  if (stdout) {
2078
2150
  const ui = ev.ui;
2079
2151
  const promoted = ui && ui.type === "web_search";
@@ -2082,7 +2154,7 @@ const Sessions = (() => {
2082
2154
  stdout.style.display = "none";
2083
2155
  } else if (resultStr && !stdout.textContent.trim()) {
2084
2156
  stdout.innerHTML = _ansiToHtml(resultStr);
2085
- const header = historyCtx.lastItem.querySelector(".tool-item-header");
2157
+ const header = item.querySelector(".tool-item-header");
2086
2158
  if (header && !header.classList.contains("tool-item-expandable")) header.classList.add("tool-item-expandable");
2087
2159
  } else if (!resultStr && !stdout.textContent.trim()) {
2088
2160
  stdout.style.display = "none";
@@ -2163,18 +2235,6 @@ const Sessions = (() => {
2163
2235
  const rfList = _normalizeFeedbackQuestions(ev.questions, ev.question, ev.options);
2164
2236
  if (rfList.length === 0) break;
2165
2237
 
2166
- if (!_feedbackNeedsCard(rfList)) {
2167
- // Nothing selectable — render as plain assistant bubble
2168
- const rfText = _feedbackPlainText(rfList, rfContext);
2169
- const rfEl = document.createElement("div");
2170
- rfEl.className = "msg msg-assistant";
2171
- rfEl.dataset.raw = rfText;
2172
- rfEl.innerHTML = _renderMarkdown(rfText);
2173
- _appendCopyButton(rfEl);
2174
- container.appendChild(rfEl);
2175
- break;
2176
- }
2177
-
2178
2238
  container.appendChild(_buildFeedbackCard(rfList, rfContext, ev._answered === true));
2179
2239
  break;
2180
2240
  }
@@ -2268,6 +2328,7 @@ const Sessions = (() => {
2268
2328
  }
2269
2329
 
2270
2330
  const events = data.events || [];
2331
+ let lastRoundError = null;
2271
2332
 
2272
2333
  // Pre-scan: mark each request_feedback as answered.
2273
2334
  // A feedback card is disabled as soon as any subsequent history_user_message appears
@@ -2275,6 +2336,8 @@ const Sessions = (() => {
2275
2336
  {
2276
2337
  let lastFeedbackIdx = -1;
2277
2338
  events.forEach((ev, i) => {
2339
+ if (ev.type === "history_user_message") lastRoundError = null;
2340
+ if (ev.type === "error") lastRoundError = ev;
2278
2341
  if (ev.type === "request_feedback") { ev._answered = !!data.has_after; lastFeedbackIdx = i; }
2279
2342
  if (ev.type === "history_user_message" && lastFeedbackIdx >= 0 && i > lastFeedbackIdx) {
2280
2343
  events[lastFeedbackIdx]._answered = true;
@@ -2286,6 +2349,8 @@ const Sessions = (() => {
2286
2349
  // Dedup by created_at: skip rounds already rendered (e.g. arrived via live WS)
2287
2350
  const dedup = _renderedCreatedAt[id] || (_renderedCreatedAt[id] = new Set());
2288
2351
  const frag = document.createDocumentFragment();
2352
+ // The unresolved latest error uses the live card (with actions) below.
2353
+ const currentError = !prepend && !data.has_after && _sessions.find(s => s.id === id)?.status === "error" ? lastRoundError : null;
2289
2354
 
2290
2355
  let currentCreatedAt = null;
2291
2356
  let skipRound = false;
@@ -2298,7 +2363,7 @@ const Sessions = (() => {
2298
2363
  skipRound = currentCreatedAt && (dedup.has(currentCreatedAt) || (ev.editable !== false && dedup.has(ev.created_at)));
2299
2364
  if (!skipRound && currentCreatedAt) dedup.add(currentCreatedAt);
2300
2365
  }
2301
- if (!skipRound) _renderHistoryEvent(ev, frag, historyCtx);
2366
+ if (!skipRound && ev !== currentError) _renderHistoryEvent(ev, frag, historyCtx);
2302
2367
  });
2303
2368
 
2304
2369
  // Collapse any tool group still open at end of page
@@ -2334,6 +2399,8 @@ const Sessions = (() => {
2334
2399
  if (!prepend && !state.hasAfter) _flushPendingStdout();
2335
2400
  }
2336
2401
 
2402
+ _refreshFeedbackStatus();
2403
+
2337
2404
  // If no more history remains, insert a "beginning of conversation" marker at the top.
2338
2405
  // Remove any existing marker first to avoid duplicates.
2339
2406
  messages.querySelector(".history-start-marker")?.remove();
@@ -2436,10 +2503,13 @@ const Sessions = (() => {
2436
2503
  const now = new Date();
2437
2504
  const pad = n => String(n).padStart(2, "0");
2438
2505
  const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
2439
- const sameDay = d.getFullYear() === now.getFullYear() &&
2440
- d.getMonth() === now.getMonth() &&
2441
- d.getDate() === now.getDate();
2442
- 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}`;
2443
2513
  }
2444
2514
 
2445
2515
  // Append a .msg-time span to a message element.
@@ -2454,15 +2524,15 @@ const Sessions = (() => {
2454
2524
 
2455
2525
  // ── User message action bar (copy + edit) ───────────────────────────────
2456
2526
 
2457
- const COPY_SVG = `<svg class="msg-user-copy-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
2458
- `<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"/>` +
2459
2529
  `</svg>` +
2460
- `<svg class="msg-user-copy-icon-check" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
2461
- `<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"/>` +
2462
2532
  `</svg>`;
2463
2533
 
2464
- const EDIT_SVG = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
2465
- `<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"/>` +
2466
2536
  `</svg>`;
2467
2537
 
2468
2538
  function _appendUserActionBar(el, wrap) {
@@ -2506,6 +2576,7 @@ const Sessions = (() => {
2506
2576
  // chunk — editing them would silently no-op on the backend.
2507
2577
  if (el.dataset.editable !== "false") bar.appendChild(editBtn);
2508
2578
  wrap.appendChild(bar);
2579
+ return bar;
2509
2580
  }
2510
2581
 
2511
2582
  function _activeSessionIsRunning() {
@@ -2882,22 +2953,65 @@ const Sessions = (() => {
2882
2953
  // visible in a single-line ellipsis layout. Backend already gives us a
2883
2954
  // ±N byte window, but for ASCII-heavy lines that's still wider than the
2884
2955
  // sidebar can render on one line. We trim the head when the match sits
2885
- // too far to the right, keeping ~`headRoom` chars before it.
2886
- function _centerSnippet(text, query, headRoom = 16) {
2887
- 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();
2888
2960
  const q = (query || "").trim();
2889
2961
  if (!q) return t;
2890
2962
  const idx = t.toLowerCase().indexOf(q.toLowerCase());
2891
2963
  if (idx <= headRoom) return t;
2892
- 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*/, "");
2893
2968
  }
2894
2969
 
2895
- // Reveal an overflowing session title on hover without moving its badges or
2896
- // action button. Measurement waits for the hover action-button transition so
2897
- // the distance reflects the title's final available width.
2898
- function _setupSessionNameOverflow(item) {
2899
- const viewport = item.querySelector(".session-name__text");
2900
- const content = viewport?.querySelector(".session-name__content");
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();
2999
+ }
3000
+
3001
+ // Reveal a single-line overflowing label on hover. Callers provide their
3002
+ // own viewport/content selectors and CSS properties so the measurement and
3003
+ // motion behaviour stays consistent across compact UI surfaces.
3004
+ function _setupHorizontalTextOverflow(item, {
3005
+ viewportSelector,
3006
+ contentSelector,
3007
+ activeClass,
3008
+ distanceProperty,
3009
+ durationProperty,
3010
+ revealPadding = 16,
3011
+ measureDelay = 170,
3012
+ }) {
3013
+ const viewport = item.querySelector(viewportSelector);
3014
+ const content = viewport?.querySelector(contentSelector);
2901
3015
  if (!viewport || !content) return;
2902
3016
 
2903
3017
  let measureTimer = null;
@@ -2909,25 +3023,55 @@ const Sessions = (() => {
2909
3023
 
2910
3024
  // Move the final characters just inside the fade instead of leaving
2911
3025
  // them permanently translucent at the viewport edge.
2912
- const distance = overflow + 16;
3026
+ const distance = overflow + revealPadding;
2913
3027
  const duration = Math.min(8, Math.max(1.4, distance / 45));
2914
- item.style.setProperty("--session-name-scroll-distance", `${distance}px`);
2915
- item.style.setProperty("--session-name-scroll-duration", `${duration}s`);
2916
- item.classList.add("session-name-scrolling");
2917
- }, 170);
3028
+ item.style.setProperty(distanceProperty, `${distance}px`);
3029
+ item.style.setProperty(durationProperty, `${duration}s`);
3030
+ item.classList.add(activeClass);
3031
+ }, measureDelay);
2918
3032
  });
2919
3033
  item.addEventListener("pointerleave", () => {
2920
3034
  clearTimeout(measureTimer);
2921
3035
  measureTimer = null;
2922
- item.classList.remove("session-name-scrolling");
2923
- item.style.removeProperty("--session-name-scroll-distance");
2924
- item.style.removeProperty("--session-name-scroll-duration");
3036
+ item.classList.remove(activeClass);
3037
+ item.style.removeProperty(distanceProperty);
3038
+ item.style.removeProperty(durationProperty);
3039
+ });
3040
+ }
3041
+
3042
+ // Reveal an overflowing session title without moving its badges or action
3043
+ // button. The delay lets the hover action-button transition settle before
3044
+ // the available title width is measured.
3045
+ function _setupSessionNameOverflow(item) {
3046
+ _setupHorizontalTextOverflow(item, {
3047
+ viewportSelector: ".session-name__text",
3048
+ contentSelector: ".session-name__content",
3049
+ activeClass: "session-name-scrolling",
3050
+ distanceProperty: "--session-name-scroll-distance",
3051
+ durationProperty: "--session-name-scroll-duration",
2925
3052
  });
2926
3053
  }
2927
3054
 
2928
3055
  //
2929
3056
  // Build and append a single session-item <div> into `container`.
2930
3057
  // Used by both the general list and the coding section.
3058
+ // Session display title, shared by the sidebar rows and the header title:
3059
+ // cron sessions embed a glyph prefix, and built-in setup sessions ship fixed
3060
+ // English names that must follow the UI language.
3061
+ const CRON_NAME_PREFIXES = ["\u23F0 ", "\u25B6 "];
3062
+ const BUILTIN_SETUP_NAMES = {
3063
+ "Onboard": "sessions.setup.name",
3064
+ "🌐 Browser Setup": "sessions.setup.browser.name",
3065
+ };
3066
+
3067
+ function _sessionDisplayName(s) {
3068
+ const cronPrefix = CRON_NAME_PREFIXES.find(p => s.name && s.name.startsWith(p)) || "";
3069
+ const storedName = (cronPrefix ? s.name.slice(cronPrefix.length) : s.name)
3070
+ || _relativeTime(s.created_at);
3071
+ const nameKey = s.source === "setup" ? BUILTIN_SETUP_NAMES[storedName] : null;
3072
+ return { cronPrefix, nameKey, text: nameKey ? I18n.t(nameKey) : storedName };
3073
+ }
3074
+
2931
3075
  function _renderSessionItem(container, s) {
2932
3076
  const el = document.createElement("div");
2933
3077
  el.className = "session-item" + (s.id === _activeId ? " active" : "");
@@ -2938,18 +3082,7 @@ const Sessions = (() => {
2938
3082
  // Cron sessions embed a "⏰ " (scheduled) or "▶ " (manually run) prefix in
2939
3083
  // their name — render either as the lucide alarm-clock-check icon instead
2940
3084
  // of the raw glyph.
2941
- const CRON_NAME_PREFIXES = ["\u23F0 ", "\u25B6 "];
2942
- const cronPrefix = CRON_NAME_PREFIXES.find(p => s.name && s.name.startsWith(p)) || "";
2943
- const storedName = (cronPrefix ? s.name.slice(cronPrefix.length) : s.name)
2944
- || _relativeTime(s.created_at);
2945
- // Built-in setup sessions ship with fixed English names; localise their
2946
- // titles so they follow the UI language (existing sessions included).
2947
- const BUILTIN_SETUP_NAMES = {
2948
- "Onboard": "sessions.setup.name",
2949
- "🌐 Browser Setup": "sessions.setup.browser.name",
2950
- };
2951
- const nameKey = s.source === "setup" ? BUILTIN_SETUP_NAMES[storedName] : null;
2952
- const displayName = nameKey ? I18n.t(nameKey) : storedName;
3085
+ const { cronPrefix, nameKey, text: displayName } = _sessionDisplayName(s);
2953
3086
  const cronIcon = cronPrefix
2954
3087
  ? `<svg class="session-cron-icon" 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"><circle cx="12" cy="13" r="8"/><path d="M5 3 2 6"/><path d="m22 6-3-3"/><path d="M6.38 18.7 4 21"/><path d="M17.64 18.67 20 21"/><path d="m9 13 2 2 4-4"/></svg>`
2955
3088
  : (nameKey === "sessions.setup.browser.name"
@@ -3012,7 +3145,7 @@ const Sessions = (() => {
3012
3145
  : "";
3013
3146
 
3014
3147
  const snippetHtml = (s._matchVia === "content" && s.search_snippet)
3015
- ? `<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>`
3016
3149
  : "";
3017
3150
 
3018
3151
  el.innerHTML = `
@@ -3252,6 +3385,7 @@ const Sessions = (() => {
3252
3385
  _initComposer();
3253
3386
  _initSearch();
3254
3387
  _initMessageHistory();
3388
+ Sessions._initTodoPanel();
3255
3389
  // Quote-on-selection: floating quote button over message selections.
3256
3390
  // quote-select.js is loaded after sessions.js; it's available at init
3257
3391
  // time because app.js (which calls Sessions.init()) loads last.
@@ -3274,9 +3408,10 @@ const Sessions = (() => {
3274
3408
  document.addEventListener("click", async (e) => {
3275
3409
  // Localhost dev-server URLs open in the preview panel instead of a new
3276
3410
  // tab (local deployments only — the proxy can't reach a remote user's
3277
- // 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.
3278
3413
  const localLink = e.target.closest("a[href]");
3279
- if (localLink) {
3414
+ if (localLink && !localLink.hasAttribute("data-open-external")) {
3280
3415
  const href = localLink.getAttribute("href") || "";
3281
3416
  if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?([/?#]|$)/i.test(href) &&
3282
3417
  ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname) &&
@@ -3334,14 +3469,27 @@ const Sessions = (() => {
3334
3469
  // ── List management ───────────────────────────────────────────────────
3335
3470
 
3336
3471
  /** Populate list from initial session_list WS event (connect only). */
3337
- setAll(list, hasMore = false, groups = {}) {
3472
+ setAll(list, hasMore = false, groups = {}, nextCursor = null) {
3338
3473
  _sessions.length = 0;
3339
3474
  _sessions.push(...list);
3340
3475
  _hasMore = !!hasMore;
3476
+ _cursor = nextCursor;
3341
3477
  _groups.forEach(g => { g.count = 0; g.latestUpdatedAt = null; });
3342
3478
  _applyGroupStats(groups);
3343
3479
  },
3344
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
+
3345
3493
  /** Insert or refresh a newly created session in the local list. */
3346
3494
  add(session) {
3347
3495
  if (!session || !session.id) return;
@@ -3413,6 +3561,7 @@ const Sessions = (() => {
3413
3561
  * Unknown sessions stay in the extra cache until an explicit creation
3414
3562
  * update promotes them, preserving the paged sidebar boundary. */
3415
3563
  patch(id, fields) {
3564
+ if (id === _activeId && fields.status && fields.status !== "error") Sessions.archiveErrors();
3416
3565
  const s = _sessions.find(s => s.id === id);
3417
3566
  const extra = _extraSessions.find(e => e.id === id);
3418
3567
  const before = s || extra;
@@ -3432,6 +3581,7 @@ const Sessions = (() => {
3432
3581
  }
3433
3582
 
3434
3583
  const updated = s || _extraSessions.find(e => e.id === id);
3584
+ if (id === _activeId && "name" in fields) Sessions.updateChatHeader(updated);
3435
3585
  const isGroup = _groupOf(updated);
3436
3586
  if (wasGroup !== isGroup) {
3437
3587
  if (wasGroup) wasGroup.count = Math.max(0, wasGroup.count - 1);
@@ -3524,19 +3674,8 @@ const Sessions = (() => {
3524
3674
  Sessions.renderList();
3525
3675
 
3526
3676
  try {
3527
- // Cursor: oldest activity time of non-pinned, non-grouped, non-project
3528
- // sessions. Project sessions sit in _sessions but render in their own
3529
- // section; counting them here would drag the cursor back to a stale
3530
- // timestamp and skip a whole page of regular sessions.
3531
- const oldest = _sessions.reduce((min, s) => {
3532
- if (s.pinned || s.project_id || _groupOf(s)) return min;
3533
- const t = s.updated_at || s.created_at;
3534
- if (!t) return min;
3535
- return (!min || t < min) ? t : min;
3536
- }, null);
3537
-
3538
3677
  const params = new URLSearchParams({ limit: "10", exclude_type: GROUP_SOURCES.join(",") });
3539
- if (oldest) params.set("before", oldest);
3678
+ if (_cursor) { params.set("before", _cursor.before); params.set("before_id", _cursor.before_id); }
3540
3679
  if (_filter.q) params.set("q", _filter.q);
3541
3680
  if (_filter.date) params.set("date", _filter.date);
3542
3681
  if (_filter.type) params.set("type", _filter.type);
@@ -3549,6 +3688,7 @@ const Sessions = (() => {
3549
3688
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
3550
3689
  });
3551
3690
  _hasMore = !!data.has_more;
3691
+ if (data.next_cursor) _cursor = data.next_cursor;
3552
3692
  _applyGroupStats(data.groups);
3553
3693
  } catch (e) {
3554
3694
  console.error("loadMore error:", e);
@@ -3580,7 +3720,7 @@ const Sessions = (() => {
3580
3720
 
3581
3721
  try {
3582
3722
  const params = new URLSearchParams({ limit: "10", type: source });
3583
- 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); }
3584
3724
 
3585
3725
  const res = await fetch(`/api/sessions?${params}`);
3586
3726
  if (!res.ok) return;
@@ -3590,16 +3730,7 @@ const Sessions = (() => {
3590
3730
  rows.forEach(s => {
3591
3731
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
3592
3732
  });
3593
- // Advance cursor to the oldest activity time in THIS batch
3594
- // (exclude pinned — backend returns all pinned on the first page,
3595
- // bypassing pagination, so their time must not drag the cursor back).
3596
- const oldest = rows.reduce((min, s) => {
3597
- if (s.pinned) return min;
3598
- const t = s.updated_at || s.created_at;
3599
- if (!t) return min;
3600
- return (!min || t < min) ? t : min;
3601
- }, null);
3602
- if (oldest) group.before = oldest;
3733
+ if (data.next_cursor) group.before = data.next_cursor;
3603
3734
  group.hasMore = !!data.has_more;
3604
3735
  _applyGroupStats(data.groups);
3605
3736
  } catch (e) {
@@ -3963,7 +4094,8 @@ const Sessions = (() => {
3963
4094
  // Within same pinned status, sort by last activity (newest first)
3964
4095
  const ta = a.updated_at || a.created_at;
3965
4096
  const tb = b.updated_at || b.created_at;
3966
- 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);
3967
4099
  };
3968
4100
 
3969
4101
  // ── Sidebar list always shows the full session set, sorted ───────
@@ -4085,12 +4217,23 @@ const Sessions = (() => {
4085
4217
  }
4086
4218
  },
4087
4219
 
4220
+ /** Archive-box icon (shared by both session menus). */
4221
+ _iconArchive() {
4222
+ return `<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"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/><line x1="10" y1="14" x2="14" y2="14"/></svg>`;
4223
+ },
4224
+
4225
+ /** Unarchive icon — the same box with an up arrow. */
4226
+ _iconUnarchive() {
4227
+ return `<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"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/><polyline points="9 15 12 12 15 15"/><line x1="12" y1="12" x2="12" y2="18"/></svg>`;
4228
+ },
4229
+
4088
4230
  /** Show right-click context menu for a session item. */
4089
4231
  _showContextMenu(e, session) {
4090
4232
  Sessions._closeContextMenu();
4091
4233
  Sessions._closeActionsMenu();
4092
4234
 
4093
4235
  const iconFork = `<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"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>`;
4236
+ const archived = !!session.archived_at;
4094
4237
 
4095
4238
  const menu = document.createElement("div");
4096
4239
  menu.className = "session-context-menu";
@@ -4099,6 +4242,10 @@ const Sessions = (() => {
4099
4242
  <span class="session-actions-menu-icon">${iconFork}</span>
4100
4243
  <span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.fork"))}</span>
4101
4244
  </div>
4245
+ <div class="session-actions-menu-item" data-action="${archived ? "unarchive" : "archive"}">
4246
+ <span class="session-actions-menu-icon">${archived ? Sessions._iconUnarchive() : Sessions._iconArchive()}</span>
4247
+ <span class="session-actions-menu-label">${escapeHtml(I18n.t(archived ? "sessions.actions.unarchive" : "sessions.actions.archive"))}</span>
4248
+ </div>
4102
4249
  `;
4103
4250
 
4104
4251
  document.body.appendChild(menu);
@@ -4119,6 +4266,10 @@ const Sessions = (() => {
4119
4266
  Sessions._closeContextMenu();
4120
4267
  if (action === "fork") {
4121
4268
  await Sessions.fork(session.id);
4269
+ } else if (action === "archive") {
4270
+ await Sessions.setArchived(session.id, true);
4271
+ } else if (action === "unarchive") {
4272
+ await Sessions.setArchived(session.id, false);
4122
4273
  }
4123
4274
  });
4124
4275
 
@@ -4154,6 +4305,13 @@ const Sessions = (() => {
4154
4305
  <span class="session-actions-menu-label">${escapeHtml(pinLabel)}</span>
4155
4306
  </div>`;
4156
4307
 
4308
+ const isArchived = !!session.archived_at;
4309
+ const archiveItemHtml = `
4310
+ <div class="session-actions-menu-item" data-action="${isArchived ? "unarchive" : "archive"}">
4311
+ <span class="session-actions-menu-icon">${isArchived ? Sessions._iconUnarchive() : Sessions._iconArchive()}</span>
4312
+ <span class="session-actions-menu-label">${escapeHtml(I18n.t(isArchived ? "sessions.actions.unarchive" : "sessions.actions.archive"))}</span>
4313
+ </div>`;
4314
+
4157
4315
  const removeFromProjectItemHtml = session.project_id ? `
4158
4316
  <div class="session-actions-menu-item" data-action="removeFromProject">
4159
4317
  <span class="session-actions-menu-icon">${iconRemoveFromProject}</span>
@@ -4209,6 +4367,8 @@ const Sessions = (() => {
4209
4367
  <span class="session-actions-submenu-label">${escapeHtml(I18n.t("sessions.moveToProject.newProject"))}</span>
4210
4368
  </div>
4211
4369
  </div>
4370
+ <div class="session-actions-menu-divider"></div>
4371
+ ${archiveItemHtml}
4212
4372
  <div class="session-actions-menu-item session-actions-menu-item--danger" data-action="delete">
4213
4373
  <span class="session-actions-menu-icon">${iconTrash}</span>
4214
4374
  <span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.delete"))}</span>
@@ -4324,6 +4484,10 @@ const Sessions = (() => {
4324
4484
  } else if (action === "delete") {
4325
4485
  window.mobileCloseSidebar?.();
4326
4486
  await Sessions.deleteSession(session.id);
4487
+ } else if (action === "archive") {
4488
+ await Sessions.setArchived(session.id, true);
4489
+ } else if (action === "unarchive") {
4490
+ await Sessions.setArchived(session.id, false);
4327
4491
  }
4328
4492
  });
4329
4493
 
@@ -4383,6 +4547,7 @@ const Sessions = (() => {
4383
4547
  Sessions.remove(sessionId);
4384
4548
  Sessions.renderList();
4385
4549
  Projects.renderSection();
4550
+ Trash.refreshIfShown();
4386
4551
  // If deleted session was active, switch to welcome
4387
4552
  if (sessionId === _activeId) {
4388
4553
  Router.navigate("welcome");
@@ -4395,6 +4560,60 @@ const Sessions = (() => {
4395
4560
  }
4396
4561
  },
4397
4562
 
4563
+ /**
4564
+ * Archive (`archived === true`) or unarchive a session. Archived chats leave
4565
+ * the sidebar and every project list, and land on the Archived chats page.
4566
+ */
4567
+ async setArchived(sessionId, archived) {
4568
+ const session = _sessions.find(s => s.id === sessionId) || { id: sessionId };
4569
+ const name = session.name || String(sessionId).slice(0, 8);
4570
+
4571
+ if (archived) {
4572
+ const confirmed = await Modal.confirm(I18n.t("archive.confirmArchive", { name }));
4573
+ if (!confirmed) return false;
4574
+ }
4575
+
4576
+ const res = archived
4577
+ ? await Archive.archiveSession(session)
4578
+ : await Archive.unarchive(session);
4579
+
4580
+ if (!res || !res.ok) {
4581
+ Modal.toast(I18n.t("archive.actionFail", { msg: (res && res.error) || "" }), "error");
4582
+ return false;
4583
+ }
4584
+
4585
+ if (archived) {
4586
+ // An archived chat can no longer be the active one.
4587
+ if (sessionId === _activeId) Router.navigate("welcome");
4588
+ Modal.toast(I18n.t("archive.archived", { name }), "success", {
4589
+ action: {
4590
+ label: I18n.t("archive.undo"),
4591
+ onClick: async () => {
4592
+ const back = await Archive.unarchive(session);
4593
+ if (back && back.ok) {
4594
+ Modal.toast(I18n.t("archive.unarchived", { name }), "success", {
4595
+ action: {
4596
+ label: I18n.t("archive.unarchivedAction"),
4597
+ onClick: () => Sessions.select(sessionId),
4598
+ },
4599
+ });
4600
+ } else {
4601
+ Modal.toast(I18n.t("archive.actionFail", { msg: "" }), "error");
4602
+ }
4603
+ },
4604
+ },
4605
+ });
4606
+ } else {
4607
+ Modal.toast(I18n.t("archive.unarchived", { name }), "success", {
4608
+ action: {
4609
+ label: I18n.t("archive.unarchivedAction"),
4610
+ onClick: () => Sessions.select(sessionId),
4611
+ },
4612
+ });
4613
+ }
4614
+ return true;
4615
+ },
4616
+
4398
4617
  updateInputBehavior: _updateInputBehavior,
4399
4618
 
4400
4619
  updateStatusBar(status) {
@@ -4410,7 +4629,7 @@ const Sessions = (() => {
4410
4629
  const inp = $("user-input");
4411
4630
  if (inp) {
4412
4631
  const mobile = window.innerWidth <= 768;
4413
- const key = status === "running"
4632
+ const key = _hasPendingFeedback() ? "chat.input.placeholderFeedback" : status === "running"
4414
4633
  ? (mobile ? "chat.input.placeholderRunningMobile" : "chat.input.placeholderRunning")
4415
4634
  : (mobile ? "chat.input.placeholderMobile" : "chat.input.placeholder");
4416
4635
  inp.setAttribute("data-i18n-placeholder", key);
@@ -4419,13 +4638,18 @@ const Sessions = (() => {
4419
4638
  },
4420
4639
 
4421
4640
  /**
4422
- * No-op: the chat header element (#chat-header) was removed. All session
4423
- * metadata (title, source, working dir, status) is now shown in the
4424
- * sidebar and the bottom #session-info-bar. Kept as a stub so existing
4425
- * call sites don't need to be updated.
4641
+ * Render the active session's title in the top header, right of the
4642
+ * sidebar divider (#header-session-title). Called on every session switch
4643
+ * and on session_update so renames and freshly generated titles land
4644
+ * immediately. Pass null to clear it — the header is shared by all views.
4426
4645
  */
4427
- updateChatHeader(_s) {
4428
- // intentionally empty
4646
+ updateChatHeader(s) {
4647
+ const el = $("header-session-title");
4648
+ if (!el) return;
4649
+ const title = s ? _sessionDisplayName(s).text : "";
4650
+ el.textContent = title;
4651
+ el.title = title;
4652
+ el.hidden = !title;
4429
4653
  },
4430
4654
 
4431
4655
  /** Update the session info bar below the chat header with current session metadata. */
@@ -4435,10 +4659,10 @@ const Sessions = (() => {
4435
4659
  if (!s) {
4436
4660
  // Hide all spans when no session. Fields that own a static caret (dir,
4437
4661
  // reasoning) keep their markup and only blank the text node.
4438
- ["sib-id", "sib-status", "sib-mode", "sib-model", "sib-tasks", "sib-cost"].forEach(id => {
4662
+ ["sib-id", "sib-status", "sib-model", "sib-cost"].forEach(id => {
4439
4663
  const el = $(id); if (el) el.textContent = "";
4440
4664
  });
4441
- ["sib-dir-text", "sib-reasoning-text"].forEach(id => {
4665
+ ["sib-dir-text", "sib-reasoning-text", "sib-mode-text"].forEach(id => {
4442
4666
  const el = $(id); if (el) el.textContent = "";
4443
4667
  });
4444
4668
  const sibIdEl = $("sib-id");
@@ -4453,7 +4677,7 @@ const Sessions = (() => {
4453
4677
  // Status dot + text — first
4454
4678
  const sibStatus = $("sib-status");
4455
4679
  if (sibStatus) {
4456
- const st = s.status || "idle";
4680
+ const st = _hasPendingFeedback() ? "awaiting_feedback" : (s.status || "idle");
4457
4681
  const labelKey = `sib.status.${st}`;
4458
4682
  const label = I18n.t(labelKey);
4459
4683
  sibStatus.innerHTML = `<span class="sib-dot"></span>${escapeHtml(label === labelKey ? st : label)}`;
@@ -4495,16 +4719,28 @@ const Sessions = (() => {
4495
4719
  }
4496
4720
  }
4497
4721
 
4498
- // Permission mode — hide element and its separator if empty
4722
+ // Permission mode — ordinary Web sessions default to confirm_all.
4499
4723
  const sibMode = $("sib-mode");
4724
+ const sibModeText = $("sib-mode-text");
4500
4725
  const sibSepAfterMode = document.querySelector(".sib-sep-after-mode");
4501
- if (sibMode) {
4502
- sibMode.textContent = s.permission_mode || "";
4503
- sibMode.title = s.permission_mode ? I18n.t("sib.mode.tooltip") : "";
4504
- 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 = "";
4505
4741
  }
4506
4742
  if (sibSepAfterMode) {
4507
- sibSepAfterMode.style.display = s.permission_mode ? "" : "none";
4743
+ sibSepAfterMode.style.display = "";
4508
4744
  }
4509
4745
 
4510
4746
  // Model — hide wrap entirely if empty
@@ -4544,7 +4780,6 @@ const Sessions = (() => {
4544
4780
 
4545
4781
  const sibReasoning = $("sib-reasoning");
4546
4782
  const sibReasoningWrap = $("sib-reasoning-wrap");
4547
- const sibSepAfterReasoning = document.querySelector(".sib-sep-after-reasoning");
4548
4783
  if (sibReasoning) {
4549
4784
  const eff = (s.reasoning_effort || "off").toLowerCase();
4550
4785
  const sibReasoningText = $("sib-reasoning-text");
@@ -4553,7 +4788,6 @@ const Sessions = (() => {
4553
4788
  sibReasoning.dataset.reasoningEffort = eff;
4554
4789
  }
4555
4790
  if (sibReasoningWrap) sibReasoningWrap.style.display = "";
4556
- if (sibSepAfterReasoning) sibSepAfterReasoning.style.display = "";
4557
4791
 
4558
4792
  // Latency signal — read from s.latest_latency (populated by:
4559
4793
  // - HTTP /api/sessions → session_registry#list (from agent.latest_latency)
@@ -4562,13 +4796,6 @@ const Sessions = (() => {
4562
4796
  // pre-feature sessions that have never made an LLM call this run).
4563
4797
  this._renderSignal(s.latest_latency);
4564
4798
 
4565
- // Tasks
4566
- const sibTasks = $("sib-tasks");
4567
- if (sibTasks) {
4568
- sibTasks.textContent = I18n.t("sessions.metaTasks", { n: s.total_tasks || 0 });
4569
- sibTasks.title = I18n.t("sib.tasks.tooltip");
4570
- }
4571
-
4572
4799
  // Cost — show N/A when pricing is unknown (estimated)
4573
4800
  const sibCost = $("sib-cost");
4574
4801
  if (sibCost) {
@@ -4919,7 +5146,17 @@ const Sessions = (() => {
4919
5146
  }
4920
5147
  },
4921
5148
 
5149
+ archiveErrors() {
5150
+ RenderTarget.outer()?.querySelectorAll(".msg-error:not(.msg-history-error)").forEach(el => {
5151
+ el.classList.add("msg-history-error");
5152
+ el.querySelectorAll(".msg-error-actions, .retry-btn, .error-action-btn").forEach(action => action.remove());
5153
+ const credit = el.querySelector(".error-credit-message");
5154
+ if (credit) credit.textContent = I18n.t("error.insufficient_credit.history");
5155
+ });
5156
+ },
5157
+
4922
5158
  appendMsg(type, html, { time } = {}) {
5159
+ if (type === "user") Sessions.archiveErrors();
4923
5160
  // Starting a new assistant/user/info message: close any open tool group
4924
5161
  if (type !== "tool") Sessions.collapseToolGroup();
4925
5162
 
@@ -4927,7 +5164,7 @@ const Sessions = (() => {
4927
5164
 
4928
5165
  // For error messages: remove any existing error messages first to avoid duplicates
4929
5166
  if (type === "error") {
4930
- messages.querySelectorAll(".msg-error").forEach(el => el.remove());
5167
+ messages.querySelectorAll(".msg-error:not(.msg-history-error)").forEach(el => el.remove());
4931
5168
  }
4932
5169
 
4933
5170
  const el = document.createElement("div");
@@ -4950,8 +5187,8 @@ const Sessions = (() => {
4950
5187
  const wrap = document.createElement("div");
4951
5188
  wrap.className = "msg-user-wrap";
4952
5189
  wrap.appendChild(el);
4953
- _appendUserActionBar(el, wrap);
4954
- if (time) _appendMsgTime(wrap, time);
5190
+ const bar = _appendUserActionBar(el, wrap);
5191
+ if (time) _appendMsgTime(bar, time);
4955
5192
  messages.appendChild(wrap);
4956
5193
  _refreshEditButtons(messages);
4957
5194
  } else {
@@ -5005,25 +5242,38 @@ const Sessions = (() => {
5005
5242
  _scrollToBottomIfNeeded(messages);
5006
5243
  },
5007
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
+
5008
5266
  // Display an ask_user UI card with clickable options.
5009
- // Called when the agent needs user input to continue.
5010
5267
  showFeedbackRequest(question, context, options, questions) {
5011
5268
  Sessions.collapseToolGroup();
5012
5269
  const messages = RenderTarget.current();
5013
5270
  const list = _normalizeFeedbackQuestions(questions, question, options);
5014
5271
 
5015
- // Nothing selectable → plain assistant bubble (card UI adds no value without choices)
5016
- if (!_feedbackNeedsCard(list)) {
5017
- const text = _feedbackPlainText(list, context);
5018
- if (!text) return;
5019
- // Pass raw markdown; appendMsg renders it via _renderMarkdown and
5020
- // also stashes it on dataset.raw for the copy button.
5021
- Sessions.appendMsg("assistant", text);
5022
- return;
5023
- }
5272
+ if (list.length === 0) return;
5024
5273
 
5025
5274
  const card = _buildFeedbackCard(list, context, false);
5026
5275
  messages.appendChild(card);
5276
+ _refreshFeedbackStatus();
5027
5277
  _scrollToBottomIfNeeded(messages);
5028
5278
  },
5029
5279
 
@@ -5042,8 +5292,6 @@ const Sessions = (() => {
5042
5292
  _sessionProgress: {},
5043
5293
  _sessionTodos: {},
5044
5294
  _todoClearTimer: null,
5045
- // Collapsed state of the todo panel — persisted across reloads via localStorage.
5046
- _todoCollapsed: (typeof localStorage !== "undefined" && localStorage.getItem("clacky-todo-collapsed")) === "1",
5047
5295
 
5048
5296
  _getProgressState(id) {
5049
5297
  if (!id) return null;
@@ -5286,14 +5534,9 @@ const Sessions = (() => {
5286
5534
  },
5287
5535
 
5288
5536
  // updateTodos(todos) — called from ws-dispatcher on "todo_update".
5289
- // Stores the latest todo snapshot per session and re-renders the panel
5290
- // for the active session. Mirrors the CLI TodoArea behaviour.
5291
- //
5292
- // UX detail: if the panel was previously collapsed by the user but the
5293
- // new snapshot contains pending (incomplete) todos, we auto-expand so
5294
- // the user actually notices the new work. We do NOT touch localStorage
5295
- // here — the user's explicit collapse choice is still remembered and
5296
- // will be re-applied the next time they collapse the panel manually.
5537
+ // Stores the latest todo snapshot per session and re-renders the compact
5538
+ // session-bar entry for the active session. Mirrors the CLI TodoArea
5539
+ // behaviour while keeping the conversation height unchanged.
5297
5540
  updateTodos(todos) {
5298
5541
  const sid = _activeId;
5299
5542
  if (!sid) return;
@@ -5329,72 +5572,134 @@ const Sessions = (() => {
5329
5572
  }
5330
5573
 
5331
5574
  Sessions._sessionTodos[sid] = next;
5332
-
5333
- // Auto-expand when there is new pending work that the user hasn't seen.
5334
- // Trigger only when transitioning from "no pending" → "has pending"
5335
- // so we don't fight the user if they intentionally collapsed mid-task.
5336
- const prevHasPending = prev.some(t => t && t.status !== "completed");
5337
- const nextHasPending = next.some(t => t && t.status !== "completed");
5338
- if (!prevHasPending && nextHasPending && Sessions._todoCollapsed) {
5339
- Sessions._todoCollapsed = false;
5340
- // Persist so a page reload doesn't bring the collapsed state back
5341
- // and hide the new tasks again.
5342
- try { localStorage.setItem("clacky-todo-collapsed", "0"); } catch (e) {}
5343
- }
5344
-
5345
5575
  Sessions._renderTodoPanel();
5346
5576
  },
5347
5577
 
5348
- // Render (or hide) the #todo-panel based on the active session's todos.
5349
- // Pure DOM rebuild on each update — todo lists are tiny so this is cheap
5350
- // and avoids diffing complexity.
5578
+ // Render the stable session-bar todo entry. The icon always remains in
5579
+ // place; progress details expand only while the active session has todos.
5351
5580
  _renderTodoPanel() {
5352
5581
  const panel = document.getElementById("todo-panel");
5353
- if (!panel) return;
5582
+ const trigger = document.getElementById("todo-trigger");
5583
+ const close = document.getElementById("todo-close");
5584
+ const countEl = document.getElementById("todo-count");
5585
+ const summaryEl = document.getElementById("todo-summary");
5586
+ const listEl = document.getElementById("todo-list");
5587
+ if (!panel || !trigger || !close || !countEl || !summaryEl || !listEl) return;
5588
+
5354
5589
  const sid = _activeId;
5355
5590
  const todos = (sid && Sessions._sessionTodos[sid]) || [];
5591
+ const hasTodos = !!sid && todos.length > 0;
5592
+ const completed = todos.filter(t => t && t.status === "completed").length;
5593
+ const total = todos.length;
5356
5594
 
5357
- // No active session, no todos, or single todo → hide.
5358
- if (!sid || todos.length <= 1) {
5359
- panel.style.display = "none";
5360
- panel.innerHTML = "";
5595
+ panel.classList.toggle("has-todos", hasTodos);
5596
+ trigger.disabled = !hasTodos;
5597
+ close.setAttribute("aria-label", I18n.t("chat.todo.close"));
5598
+
5599
+ if (!hasTodos) {
5600
+ countEl.textContent = "";
5601
+ summaryEl.textContent = "";
5602
+ listEl.textContent = "";
5603
+ trigger.style.width = "";
5604
+ const emptyLabel = I18n.t("chat.todo.empty");
5605
+ trigger.title = emptyLabel;
5606
+ trigger.setAttribute("aria-label", emptyLabel);
5607
+ Sessions._setTodoPopoverOpen(false);
5361
5608
  return;
5362
5609
  }
5363
5610
 
5364
- const completed = todos.filter(t => t && t.status === "completed").length;
5365
- const total = todos.length;
5366
- const title = (typeof I18n !== "undefined" && I18n.t)
5367
- ? (I18n.t("chat.todo.title") || "Tasks")
5368
- : "Tasks";
5611
+ const count = `${completed}/${total}`;
5612
+ const progress = I18n.t("chat.todo.progress", { completed, total });
5613
+ countEl.textContent = count;
5614
+ summaryEl.textContent = progress;
5615
+ trigger.title = `${I18n.t("chat.todo.open")} · ${progress}`;
5616
+ trigger.setAttribute("aria-label", trigger.title);
5617
+ // Keep the empty slot genuinely compact, then animate to the exact
5618
+ // width needed by the current count instead of reserving a wide box.
5619
+ trigger.style.width = `${Math.min(84, 40 + count.length * 7)}px`;
5369
5620
 
5370
- const items = todos.map(t => {
5621
+ listEl.innerHTML = todos.map(t => {
5371
5622
  const done = t && t.status === "completed";
5372
5623
  const cls = done ? "todo-item done" : "todo-item";
5373
5624
  const text = escapeHtml(String((t && t.task) || ""));
5374
5625
  const icon = done
5375
5626
  ? `<svg class="todo-icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" fill="currentColor" stroke="currentColor" stroke-width="1"/><path d="M5 8.2l2 2 4-4.4" stroke="var(--color-text-inverse, #fff)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`
5376
5627
  : `<svg class="todo-icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/></svg>`;
5377
- return `<div class="${cls}">${icon}<span class="todo-text">${text}</span></div>`;
5628
+ return `<div class="${cls}">${icon}<span class="todo-text"><span class="todo-text__content">${text}</span></span></div>`;
5378
5629
  }).join("");
5379
-
5380
- const collapsedCls = Sessions._todoCollapsed ? " collapsed" : "";
5381
- panel.innerHTML =
5382
- `<div class="todo-header"><span>${title}</span><span class="todo-count">${completed}/${total}</span><svg class="todo-toggle" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg></div>` +
5383
- `<div class="todo-list">${items}</div>`;
5384
- panel.className = "todo-panel" + collapsedCls;
5385
- panel.style.display = "block";
5386
-
5387
- const header = panel.querySelector(".todo-header");
5388
- if (header) {
5389
- header.title = Sessions._todoCollapsed ? "Click to expand" : "Click to collapse";
5390
- header.addEventListener("click", () => {
5391
- Sessions._todoCollapsed = !Sessions._todoCollapsed;
5392
- try { localStorage.setItem("clacky-todo-collapsed", Sessions._todoCollapsed ? "1" : "0"); } catch (e) {}
5393
- Sessions._renderTodoPanel();
5630
+ listEl.querySelectorAll(".todo-item").forEach(item => {
5631
+ _setupHorizontalTextOverflow(item, {
5632
+ viewportSelector: ".todo-text",
5633
+ contentSelector: ".todo-text__content",
5634
+ activeClass: "todo-text-scrolling",
5635
+ distanceProperty: "--todo-text-scroll-distance",
5636
+ durationProperty: "--todo-text-scroll-duration",
5637
+ revealPadding: 12,
5638
+ measureDelay: 120,
5394
5639
  });
5640
+ });
5641
+ if (trigger.getAttribute("aria-expanded") === "true") {
5642
+ Sessions._positionTodoPopover();
5395
5643
  }
5396
5644
  },
5397
5645
 
5646
+ _positionTodoPopover() {
5647
+ const trigger = document.getElementById("todo-trigger");
5648
+ const popover = document.getElementById("todo-popover");
5649
+ if (!trigger || !popover || popover.hidden) return;
5650
+
5651
+ const triggerRect = trigger.getBoundingClientRect();
5652
+ const popoverRect = popover.getBoundingClientRect();
5653
+ const margin = 12;
5654
+ const idealLeft = triggerRect.left + triggerRect.width / 2;
5655
+ const minLeft = margin + popoverRect.width / 2;
5656
+ const maxLeft = window.innerWidth - margin - popoverRect.width / 2;
5657
+ popover.style.left = `${Math.max(minLeft, Math.min(idealLeft, maxLeft))}px`;
5658
+ popover.style.top = `${triggerRect.top - 6}px`;
5659
+ },
5660
+
5661
+ _setTodoPopoverOpen(open) {
5662
+ const panel = document.getElementById("todo-panel");
5663
+ const trigger = document.getElementById("todo-trigger");
5664
+ const popover = document.getElementById("todo-popover");
5665
+ if (!panel || !trigger || !popover) return;
5666
+
5667
+ const shouldOpen = !!open && panel.classList.contains("has-todos");
5668
+ popover.hidden = !shouldOpen;
5669
+ panel.classList.toggle("is-open", shouldOpen);
5670
+ trigger.setAttribute("aria-expanded", String(shouldOpen));
5671
+ if (shouldOpen) Sessions._positionTodoPopover();
5672
+ },
5673
+
5674
+ _initTodoPanel() {
5675
+ const panel = document.getElementById("todo-panel");
5676
+ const trigger = document.getElementById("todo-trigger");
5677
+ const close = document.getElementById("todo-close");
5678
+ const bar = document.getElementById("session-info-bar");
5679
+ if (!panel || !trigger || !close) return;
5680
+
5681
+ trigger.addEventListener("click", () => {
5682
+ if (trigger.disabled) return;
5683
+ Sessions._setTodoPopoverOpen(trigger.getAttribute("aria-expanded") !== "true");
5684
+ });
5685
+ close.addEventListener("click", () => {
5686
+ Sessions._setTodoPopoverOpen(false);
5687
+ trigger.focus();
5688
+ });
5689
+ document.addEventListener("click", e => {
5690
+ if (!panel.contains(e.target)) Sessions._setTodoPopoverOpen(false);
5691
+ });
5692
+ document.addEventListener("keydown", e => {
5693
+ if (e.key !== "Escape" || trigger.getAttribute("aria-expanded") !== "true") return;
5694
+ Sessions._setTodoPopoverOpen(false);
5695
+ trigger.focus();
5696
+ });
5697
+ window.addEventListener("resize", () => Sessions._positionTodoPopover());
5698
+ if (bar) bar.addEventListener("scroll", () => Sessions._positionTodoPopover(), { passive: true });
5699
+
5700
+ Sessions._renderTodoPanel();
5701
+ },
5702
+
5398
5703
  // Delete all progress state for a session (used when session is removed).
5399
5704
  _deleteProgressState(id) {
5400
5705
  Sessions._detachProgressUI(id);
@@ -5641,6 +5946,7 @@ const Sessions = (() => {
5641
5946
  subInfo,
5642
5947
  onSelect: (m) => _switchModel(sessionId, m.id, m.model),
5643
5948
  onSwitchSubModel: (name, displayName) => _switchSubModel(sessionId, name, displayName),
5949
+ onSwitchRowSubModel: (m, name, displayName) => _switchModelAndSubModel(sessionId, m, name, displayName),
5644
5950
  onBenchmark: () => _benchmarkSession(sessionId),
5645
5951
  onAddModel: _goAddModel,
5646
5952
  onConfigureMedia: _goConfigureMedia,
@@ -5713,21 +6019,42 @@ const Sessions = (() => {
5713
6019
  }
5714
6020
  }
5715
6021
 
6022
+ // A variant picked on a row that is not the session's current model: the
6023
+ // sub-model is pinned under the card, and the backend validates the name
6024
+ // against the card's provider, so the session has to run on that model first.
6025
+ async function _switchModelAndSubModel(sessionId, m, modelName, displayName) {
6026
+ _closeModelDropdown();
6027
+
6028
+ try {
6029
+ const res = await fetch(`/api/sessions/${sessionId}/model`, {
6030
+ method: "PATCH",
6031
+ headers: { "Content-Type": "application/json" },
6032
+ body: JSON.stringify({ model_id: m.id })
6033
+ });
6034
+ const data = await res.json();
6035
+ if (!res.ok) throw new Error(data.error || "Unknown error");
6036
+ } catch (e) {
6037
+ console.error("Failed to switch model and sub-model:", e);
6038
+ alert("Failed to switch model: " + e.message);
6039
+ return;
6040
+ }
6041
+
6042
+ await _switchSubModel(sessionId, modelName, displayName);
6043
+ }
6044
+
5716
6045
  // The picker only lists models that are already configured, so this is the
5717
6046
  // way out when the wanted one is missing.
5718
6047
  function _goAddModel() {
5719
6048
  _closeModelDropdown();
5720
- if (typeof Router !== "undefined") Router.navigate("settings");
6049
+ if (typeof Router !== "undefined") Router.navigate("settings", { tab: "models" });
5721
6050
  }
5722
6051
 
5723
6052
  // Navigate to media config.
5724
6053
  function _goConfigureMedia() {
5725
6054
  _closeModelDropdown();
5726
- if (typeof Router !== "undefined") Router.navigate("settings");
5727
- setTimeout(() => {
5728
- const sec = document.getElementById("media-section");
5729
- if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
5730
- }, 200);
6055
+ if (typeof Router !== "undefined") {
6056
+ Router.navigate("settings", { tab: "models", section: "media-section" });
6057
+ }
5731
6058
  }
5732
6059
  })();
5733
6060
 
@@ -5768,13 +6095,15 @@ const Sessions = (() => {
5768
6095
  return (s && s !== key) ? s : fallback;
5769
6096
  };
5770
6097
 
5771
- // When no session exists yet (e.g. the New Session modal), browse the
5772
- // real filesystem via /api/dirs instead of the session-scoped files API.
6098
+ // Absolute browsing belongs to the session-independent directory API:
6099
+ // it remains usable even when a session's bound working directory was
6100
+ // deleted. Relative browsing can still use the session-scoped files API.
5773
6101
  const sessionLess = !sessionId;
5774
6102
  const mobileLayout = window.matchMedia("(max-width: 768px)");
5775
6103
 
5776
6104
  let currentPath = currentDir;
5777
- let selectedPath = currentDir;
6105
+ let selectedPath = null;
6106
+ let currentPathExists = false;
5778
6107
  let currentHasChildDirs = false;
5779
6108
  let rootDir = ""; // absolute path of the session's working directory
5780
6109
  let homeDir = ""; // user home, used as a quick preset
@@ -5787,7 +6116,7 @@ const Sessions = (() => {
5787
6116
 
5788
6117
  // Fetch directory entries from API, returns dirs with absolute paths
5789
6118
  async function fetchDirs(relPath, absolute = false) {
5790
- if (sessionLess) {
6119
+ if (sessionLess || absolute) {
5791
6120
  // /api/dirs already returns absolute paths and operates in absolute mode.
5792
6121
  let url = `/api/dirs${relPath ? `?path=${encodeURIComponent(relPath)}` : ""}`;
5793
6122
  if (showHidden) url += `${url.includes("?") ? "&" : "?"}show_hidden=true`;
@@ -5866,7 +6195,7 @@ const Sessions = (() => {
5866
6195
  clickTimer = null;
5867
6196
  listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5868
6197
  row.classList.add("selected");
5869
- selectedPath = entry.absPath;
6198
+ setSelectedPath(entry.absPath);
5870
6199
  }, 200);
5871
6200
  });
5872
6201
 
@@ -6000,6 +6329,7 @@ const Sessions = (() => {
6000
6329
  const newFolderBtn = document.createElement("button");
6001
6330
  newFolderBtn.className = "btn btn-secondary btn-sm dp-newfolder-btn";
6002
6331
  newFolderBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span>${t("sib.dir.newFolder", "新建文件夹")}</span>`;
6332
+ newFolderBtn.disabled = true;
6003
6333
 
6004
6334
  const footerActions = document.createElement("div");
6005
6335
  footerActions.className = "dp-footer-actions";
@@ -6012,7 +6342,12 @@ const Sessions = (() => {
6012
6342
  const confirmButton = document.createElement("button");
6013
6343
  confirmButton.className = "btn btn-primary";
6014
6344
  confirmButton.textContent = t("sib.dir.confirm", "确认");
6015
- confirmButton.onclick = () => { overlay.remove(); resolve(selectedPath || null); };
6345
+ confirmButton.disabled = true;
6346
+ confirmButton.onclick = () => {
6347
+ if (!selectedPath) return;
6348
+ overlay.remove();
6349
+ resolve(selectedPath);
6350
+ };
6016
6351
 
6017
6352
  footerActions.appendChild(cancelButton);
6018
6353
  footerActions.appendChild(confirmButton);
@@ -6034,6 +6369,11 @@ const Sessions = (() => {
6034
6369
  let breadcrumbMenuAnchor = null;
6035
6370
  let breadcrumbMenuRequest = 0;
6036
6371
 
6372
+ function setSelectedPath(path) {
6373
+ selectedPath = path || null;
6374
+ confirmButton.disabled = !selectedPath;
6375
+ }
6376
+
6037
6377
  function refreshNavBtns() {
6038
6378
  backBtn.disabled = historyIndex <= 0;
6039
6379
  fwdBtn.disabled = historyIndex >= history.length - 1;
@@ -6152,7 +6492,7 @@ const Sessions = (() => {
6152
6492
  function refreshFavoriteButton() {
6153
6493
  const path = normalizeFavoritePath(currentPath);
6154
6494
  const isFavorite = directoryFavorites.includes(path);
6155
- favoriteBtn.disabled = !currentPathLoaded;
6495
+ favoriteBtn.disabled = !currentPathLoaded || !currentPathExists;
6156
6496
  favoriteBtn.classList.toggle("active", isFavorite);
6157
6497
  favoriteBtn.setAttribute("aria-pressed", isFavorite ? "true" : "false");
6158
6498
  favoriteBtn.title = t(
@@ -6355,9 +6695,12 @@ const Sessions = (() => {
6355
6695
  closeBreadcrumbMenu();
6356
6696
  if (pushHistory && historyIndex >= 0
6357
6697
  && comparablePath(currentPath) === comparablePath(dirPath)) {
6698
+ if (currentPathExists) setSelectedPath(currentPath);
6358
6699
  return true;
6359
6700
  }
6360
6701
 
6702
+ setSelectedPath(null);
6703
+ newFolderBtn.disabled = true;
6361
6704
  favoriteBtn.disabled = true;
6362
6705
  listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
6363
6706
  listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
@@ -6385,24 +6728,33 @@ const Sessions = (() => {
6385
6728
  : rootDir;
6386
6729
  }
6387
6730
 
6731
+ // A missing requested path may be resolved internally by /api/dirs so
6732
+ // it can still return quick-access places. Keep showing the path that
6733
+ // belongs to the session instead of silently replacing it with that
6734
+ // internal fallback directory.
6735
+ const displayPath = exactMatch ? effectivePath : dirPath;
6388
6736
  const isNewHistoryEntry = historyIndex < 0
6389
- || comparablePath(history[historyIndex]) !== comparablePath(effectivePath);
6737
+ || comparablePath(history[historyIndex]) !== comparablePath(displayPath);
6390
6738
  if (pushHistory && isNewHistoryEntry) {
6391
6739
  history.splice(historyIndex + 1);
6392
- history.push(effectivePath);
6740
+ history.push(displayPath);
6393
6741
  historyIndex = history.length - 1;
6394
6742
  }
6395
6743
  refreshNavBtns();
6396
6744
 
6397
- currentPath = effectivePath;
6398
- selectedPath = effectivePath;
6399
- currentHasChildDirs = dirs.length > 0;
6745
+ currentPath = displayPath;
6746
+ currentPathExists = exactMatch;
6747
+ setSelectedPath(exactMatch ? effectivePath : null);
6748
+ currentHasChildDirs = exactMatch && dirs.length > 0;
6400
6749
  currentPathLoaded = true;
6401
- refreshBreadcrumb(effectivePath);
6750
+ newFolderBtn.disabled = !exactMatch;
6751
+ refreshBreadcrumb(displayPath);
6402
6752
  refreshFavoriteButton();
6403
6753
  refreshSidebarActive();
6404
6754
  listContainer.innerHTML = "";
6405
- if (dirs.length === 0) {
6755
+ if (!exactMatch) {
6756
+ listContainer.innerHTML = `<div class="dp-empty dp-missing">${t("sib.dir.missing", "This folder no longer exists. Please select a new working directory.")}</div>`;
6757
+ } else if (dirs.length === 0) {
6406
6758
  listContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
6407
6759
  } else {
6408
6760
  const frag = document.createDocumentFragment();
@@ -6521,7 +6873,7 @@ const Sessions = (() => {
6521
6873
  placeholder.replaceWith(realNode);
6522
6874
  listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
6523
6875
  realNode._dpRow.classList.add("selected");
6524
- selectedPath = data.path;
6876
+ setSelectedPath(data.path);
6525
6877
  currentHasChildDirs = true;
6526
6878
  refreshBreadcrumb(currentPath);
6527
6879
  } catch (err) {
@@ -6565,21 +6917,10 @@ const Sessions = (() => {
6565
6917
  }
6566
6918
 
6567
6919
  // ── Initial load ───────────────────────────────────────────────────
6568
- // session-less: browse absolute paths from currentDir (or "/" fallback)
6569
- // sessionful: load relative root first so rootDir is populated, then
6570
- // navigate to currentDir if it's provided.
6571
- if (sessionLess) {
6572
- const startDir = currentDir || "/";
6573
- navigateTo(startDir, true);
6574
- } else {
6575
- // Load the relative root first so rootDir is known, then navigate to a
6576
- // deeper current directory without racing two initial requests.
6577
- navigateTo("", false).then((loaded) => {
6578
- if (loaded && currentDir && currentDir !== rootDir) {
6579
- navigateTo(currentDir, currentDir.startsWith("/"));
6580
- }
6581
- });
6582
- }
6920
+ // Open the requested path directly. For a missing path, /api/dirs still
6921
+ // supplies quick-access places while navigateTo keeps the original path
6922
+ // visible and waits for the user to choose a replacement.
6923
+ navigateTo(currentDir || "/", true);
6583
6924
 
6584
6925
  overlay.addEventListener("keydown", (e) => {
6585
6926
  if (e.key !== "Escape") return;
@@ -6890,6 +7231,7 @@ const Sessions = (() => {
6890
7231
 
6891
7232
  document.addEventListener("langchange", () => {
6892
7233
  if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
7234
+ Sessions._renderTodoPanel();
6893
7235
  });
6894
7236
 
6895
7237
  document.addEventListener("currencychange", () => {