openclacky 1.5.13 → 1.5.14

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -1
@@ -17,7 +17,7 @@
17
17
 
18
18
  const Sessions = (() => {
19
19
  const _sessions = []; // [{ id, name, status, total_tasks, total_cost }]
20
- const _historyState = {}; // { [session_id]: { hasMore, oldestCreatedAt, loading, loaded } }
20
+ const _historyState = {}; // Per-session history window, cursors and in-flight request.
21
21
  const _renderedCreatedAt = {}; // { [session_id]: Set<number> } — dedup by created_at
22
22
  const _drafts = new Map(); // { [session_id]: composer textarea draft }
23
23
  const _attachmentDrafts = new Map(); // { [session_id]: { images: [...], files: [...] } }
@@ -347,11 +347,8 @@ const Sessions = (() => {
347
347
  // Clear the pane and dedup state; history will be re-fetched from API.
348
348
  RenderTarget.outer().innerHTML = "";
349
349
  delete _renderedCreatedAt[id];
350
- if (_historyState[id]) {
351
- _historyState[id].oldestCreatedAt = null;
352
- _historyState[id].hasMore = true;
353
- _historyState[id].loading = false; // reset so next fetch is not skipped
354
- }
350
+ _historyState[id]?.controller?.abort();
351
+ delete _historyState[id];
355
352
  // Reset scroll tracking when switching sessions
356
353
  _userScrolledUp = false;
357
354
  }
@@ -412,6 +409,12 @@ const Sessions = (() => {
412
409
  function _showNewMessageBanner() {
413
410
  const banner = $("new-message-banner");
414
411
  if (!banner) return;
412
+ const label = banner.querySelector("span");
413
+ if (label) {
414
+ const key = Sessions.isHistoricalWindow() ? "chat.nav.latest" : "chat.newMessageHint";
415
+ label.dataset.i18n = key;
416
+ label.textContent = I18n.t(key);
417
+ }
415
418
  banner.style.display = "block";
416
419
  }
417
420
 
@@ -548,6 +551,10 @@ const Sessions = (() => {
548
551
 
549
552
  // Click to scroll to bottom
550
553
  banner.addEventListener("click", () => {
554
+ if (Sessions.isHistoricalWindow()) {
555
+ Sessions.loadLatestHistory();
556
+ return;
557
+ }
551
558
  messages.scrollTop = messages.scrollHeight;
552
559
  _userScrolledUp = false;
553
560
  _hideNewMessageBanner();
@@ -569,7 +576,7 @@ const Sessions = (() => {
569
576
  // so a content update never moves us "away from bottom" and never trips a
570
577
  // false positive here. The 150px threshold in _isAtBottom is extra slack.
571
578
  messages.addEventListener("scroll", () => {
572
- if (_isAtBottom(messages)) {
579
+ if (_isAtBottom(messages) && !Sessions.isHistoricalWindow()) {
573
580
  _userScrolledUp = false;
574
581
  _hideNewMessageBanner();
575
582
  } else {
@@ -1123,7 +1130,7 @@ const Sessions = (() => {
1123
1130
  `</span>`;
1124
1131
  }
1125
1132
 
1126
- function _sendMessage() {
1133
+ async function _sendMessage() {
1127
1134
  if (_sending) return;
1128
1135
  const input = $("user-input");
1129
1136
  const content = Composer.text(input).trim();
@@ -1147,6 +1154,11 @@ const Sessions = (() => {
1147
1154
  }
1148
1155
 
1149
1156
  _sending = true;
1157
+ if (Sessions.isHistoricalWindow()) {
1158
+ const id = Sessions.activeId;
1159
+ const loaded = await Sessions.loadLatestHistory();
1160
+ if (!loaded || Sessions.activeId !== id) { _sending = false; return; }
1161
+ }
1150
1162
 
1151
1163
  let bubbleHtml = content ? SkillAC.renderUserMessageHtml(content) : "";
1152
1164
  if (mentionChips.length > 0) {
@@ -1407,6 +1419,8 @@ const Sessions = (() => {
1407
1419
  const messages = e.currentTarget;
1408
1420
  if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
1409
1421
  Sessions.loadMoreHistory(Sessions.activeId);
1422
+ } else if (_isAtBottom(messages) && Sessions.isHistoricalWindow()) {
1423
+ Sessions.loadNewerHistory();
1410
1424
  }
1411
1425
  });
1412
1426
 
@@ -1804,6 +1818,7 @@ const Sessions = (() => {
1804
1818
  bubbleHtml += SkillAC.renderUserMessageHtml(ev.content || "", ev.skill_command, ev.skill_command_display);
1805
1819
  el.innerHTML = bubbleHtml;
1806
1820
  if (ev.created_at) el.dataset.createdAt = ev.created_at;
1821
+ if (ev.round_id) el.dataset.roundId = ev.round_id;
1807
1822
  // Messages archived into a compressed chunk can't be edited (the backend
1808
1823
  // truncate keys off the active in-memory history). Flag them so the edit
1809
1824
  // affordance stays hidden.
@@ -1987,40 +2002,56 @@ const Sessions = (() => {
1987
2002
 
1988
2003
  // Fetch one page of history and insert into #messages or cache.
1989
2004
  // before=null means most recent page; prepend=true for scroll-up load.
1990
- async function _fetchHistory(id, before = null, prepend = false) {
1991
- const state = _historyState[id] || (_historyState[id] = { hasMore: true, oldestCreatedAt: null, loading: false });
1992
- if (state.loading) return;
2005
+ async function _fetchHistory(id, before = null, prepend = false, options = {}) {
2006
+ const state = _historyState[id] || (_historyState[id] = { hasMore: true, loading: false });
2007
+ if (state.loading && !options.replace) return false;
2008
+ state.controller?.abort();
2009
+ const controller = new AbortController();
2010
+ state.controller = controller;
2011
+ const wasHistorical = !!state.hasAfter;
2012
+ const liveRevision = state.liveRevision || 0;
2013
+ if (options.around) state.hasAfter = true;
2014
+ if (options.replace) state.replacing = true;
1993
2015
  state.loading = true;
2016
+ let succeeded = false;
1994
2017
 
1995
2018
  try {
1996
- const params = new URLSearchParams({ limit: 30 });
1997
- if (before) params.set("before", before);
2019
+ const params = new URLSearchParams({ limit: 30, window: 1 });
2020
+ if (before) params.set("before_id", before);
2021
+ if (options.around) params.set("around", options.around);
2022
+ if (options.after) params.set("after_id", options.after);
1998
2023
 
1999
- const res = await fetch(`/api/sessions/${id}/messages?${params}`);
2024
+ const res = await fetch(`/api/sessions/${id}/messages?${params}`, { signal: controller.signal });
2025
+ if (id !== _activeId || _historyState[id] !== state || state.controller !== controller) return false;
2000
2026
  if (!res.ok) {
2027
+ if (res.status === 409) window.ChatNavigator?.refresh();
2001
2028
  if (id === _activeId) {
2002
2029
  let reason = "";
2003
2030
  try { const d = await res.json(); reason = d.error || ""; } catch {}
2004
2031
  const suffix = reason ? `: ${reason}` : "";
2005
2032
  Sessions.appendMsg("info", `${I18n.t("chat.history_load_failed")} (${res.status}${suffix})`);
2006
2033
  }
2007
- return;
2034
+ return false;
2008
2035
  }
2009
2036
  const data = await res.json();
2037
+ if (id !== _activeId || _historyState[id] !== state || state.controller !== controller) return false;
2010
2038
 
2011
- state.hasMore = !!data.has_more;
2039
+ if (!options.after) {
2040
+ state.hasMore = !!data.has_more;
2041
+ state.beforeCursor = data.before_cursor;
2042
+ }
2043
+ if (!prepend) {
2044
+ state.hasAfter = !!data.has_after;
2045
+ state.afterCursor = data.after_cursor;
2046
+ }
2047
+ if (options.replace) {
2048
+ window._closeAllPhases?.("incomplete");
2049
+ Sessions.collapseToolGroup();
2050
+ RenderTarget.outer().replaceChildren();
2051
+ delete _renderedCreatedAt[id];
2052
+ }
2012
2053
 
2013
2054
  const events = data.events || [];
2014
- if (events.length === 0) return;
2015
-
2016
- // Track oldest created_at for next cursor (scroll-up pagination)
2017
- events.forEach(ev => {
2018
- if (ev.type === "history_user_message" && ev.created_at) {
2019
- if (state.oldestCreatedAt === null || ev.created_at < state.oldestCreatedAt) {
2020
- state.oldestCreatedAt = ev.created_at;
2021
- }
2022
- }
2023
- });
2024
2055
 
2025
2056
  // Pre-scan: mark each request_feedback as answered.
2026
2057
  // A feedback card is disabled as soon as any subsequent history_user_message appears
@@ -2028,7 +2059,7 @@ const Sessions = (() => {
2028
2059
  {
2029
2060
  let lastFeedbackIdx = -1;
2030
2061
  events.forEach((ev, i) => {
2031
- if (ev.type === "request_feedback") { ev._answered = false; lastFeedbackIdx = i; }
2062
+ if (ev.type === "request_feedback") { ev._answered = !!data.has_after; lastFeedbackIdx = i; }
2032
2063
  if (ev.type === "history_user_message" && lastFeedbackIdx >= 0 && i > lastFeedbackIdx) {
2033
2064
  events[lastFeedbackIdx]._answered = true;
2034
2065
  lastFeedbackIdx = -1;
@@ -2047,8 +2078,8 @@ const Sessions = (() => {
2047
2078
 
2048
2079
  events.forEach(ev => {
2049
2080
  if (ev.type === "history_user_message") {
2050
- currentCreatedAt = ev.created_at;
2051
- skipRound = currentCreatedAt && dedup.has(currentCreatedAt);
2081
+ currentCreatedAt = ev.round_id || ev.created_at;
2082
+ skipRound = currentCreatedAt && (dedup.has(currentCreatedAt) || (ev.editable !== false && dedup.has(ev.created_at)));
2052
2083
  if (!skipRound && currentCreatedAt) dedup.add(currentCreatedAt);
2053
2084
  }
2054
2085
  if (!skipRound) _renderHistoryEvent(ev, frag, historyCtx);
@@ -2075,10 +2106,16 @@ const Sessions = (() => {
2075
2106
  } else {
2076
2107
  messages.appendChild(frag);
2077
2108
  }
2078
- messages.scrollTop = messages.scrollHeight;
2109
+ if (options.around) {
2110
+ messages.scrollTop = 0;
2111
+ _userScrolledUp = true;
2112
+ } else if (!options.after) {
2113
+ messages.scrollTop = messages.scrollHeight;
2114
+ _userScrolledUp = false;
2115
+ }
2079
2116
  // Flush any tool_stdout lines that arrived via WS before this history
2080
2117
  // fetch completed (race condition on session switch).
2081
- if (!prepend) _flushPendingStdout();
2118
+ if (!prepend && !state.hasAfter) _flushPendingStdout();
2082
2119
  }
2083
2120
 
2084
2121
  // If no more history remains, insert a "beginning of conversation" marker at the top.
@@ -2094,7 +2131,7 @@ const Sessions = (() => {
2094
2131
 
2095
2132
  // Restore transient UI state based on session status after initial load
2096
2133
  // (not prepend, which is scroll-up pagination — no need to re-restore then)
2097
- if (!prepend) {
2134
+ if (!prepend && !state.hasAfter) {
2098
2135
  const session = _sessions.find(s => s.id === id);
2099
2136
  if (session) {
2100
2137
  if (session.status === "running") {
@@ -2116,13 +2153,35 @@ const Sessions = (() => {
2116
2153
  }
2117
2154
  }
2118
2155
  }
2156
+ if (state.hasAfter) _showNewMessageBanner();
2157
+ else _hideNewMessageBanner();
2158
+ window.ChatNavigator?.syncMessages();
2159
+ }
2160
+ succeeded = true;
2161
+ } catch (error) {
2162
+ if (error.name !== "AbortError" && id === _activeId && state.controller === controller) {
2163
+ Sessions.appendMsg("info", escapeHtml(I18n.t("chat.history_load_failed")));
2119
2164
  }
2165
+ return false;
2120
2166
  } finally {
2121
- state.loading = false;
2167
+ if (state.controller === controller) {
2168
+ state.loading = false;
2169
+ state.replacing = false;
2170
+ if (!succeeded) state.hasAfter = wasHistorical;
2171
+ }
2122
2172
  // After loading finishes, re-evaluate the empty-state hint in case
2123
2173
  // the session is genuinely empty (no events + no existing DOM content).
2124
- if (id === _activeId) _updateEmptyHint();
2174
+ if (id === _activeId) {
2175
+ _updateEmptyHint();
2176
+ _refreshEditButtons(RenderTarget.outer());
2177
+ }
2178
+ }
2179
+ // A live message may have arrived after the server took its snapshot while
2180
+ // this detached window was loading. Reconcile before resuming live output.
2181
+ if (succeeded && !state.hasAfter && (state.liveRevision || 0) !== liveRevision && id === _activeId) {
2182
+ return _fetchHistory(id, null, false, { replace: true });
2125
2183
  }
2184
+ return succeeded;
2126
2185
  }
2127
2186
 
2128
2187
  // ── Private helpers ───────────────────────────────────────────────────
@@ -2238,7 +2297,7 @@ const Sessions = (() => {
2238
2297
  }
2239
2298
 
2240
2299
  function _refreshEditButtons(container, status) {
2241
- const running = (status || Sessions.find(Sessions.activeId)?.status) === "running";
2300
+ const running = (status || Sessions.find(Sessions.activeId)?.status) === "running" || Sessions.isHistoricalWindow();
2242
2301
  const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
2243
2302
  btns.forEach((btn, i) => {
2244
2303
  btn.style.display = !running && i === btns.length - 1 ? "" : "none";
@@ -2256,6 +2315,7 @@ const Sessions = (() => {
2256
2315
  }
2257
2316
 
2258
2317
  function _enterEditMode(el) {
2318
+ if (Sessions.isHistoricalWindow()) return;
2259
2319
  if (_activeSessionIsRunning() || el.classList.contains("editing")) return;
2260
2320
  el.classList.add("editing");
2261
2321
 
@@ -2330,6 +2390,7 @@ const Sessions = (() => {
2330
2390
  }
2331
2391
 
2332
2392
  function _submitEdit(el, newContent) {
2393
+ if (Sessions.isHistoricalWindow()) return;
2333
2394
  if (!newContent) return;
2334
2395
  if (!Sessions.activeId) return;
2335
2396
  if (_activeSessionIsRunning()) {
@@ -2785,6 +2846,40 @@ const Sessions = (() => {
2785
2846
 
2786
2847
 
2787
2848
 
2849
+ // Compact a working directory for the status bar: keep a short leading
2850
+ // anchor (up to two segments, e.g. "/Users/leo") plus as many trailing
2851
+ // segments as fit, ellipsising the middle so the current directory name
2852
+ // stays visible. The full path lives in title/dataset, not here.
2853
+ function _compactWorkingDir(p, maxLen = 40) {
2854
+ if (!p) return p;
2855
+ if (p.length <= maxLen) return p;
2856
+
2857
+ const abs = /^[\\/]/.test(p);
2858
+ const segs = p.split(/[\\/]+/).filter((s) => s !== "");
2859
+ if (!segs.length) return p;
2860
+
2861
+ // The final segment (current directory name) must always stay intact;
2862
+ // shrink the leading anchor first when a longer tail won't fit.
2863
+ for (let headCount = Math.min(2, segs.length - 1); headCount >= 0; headCount--) {
2864
+ const headStr = headCount > 0
2865
+ ? (abs ? "/" : "") + segs.slice(0, headCount).join("/")
2866
+ : "";
2867
+ let budget = maxLen - headStr.length - 3; // reserve "/…/"
2868
+ const tail = [];
2869
+ for (let i = segs.length - 1; i >= headCount; i--) {
2870
+ const need = segs[i].length + (tail.length ? 1 : 0);
2871
+ if (need > budget) break;
2872
+ tail.unshift(segs[i]);
2873
+ budget -= need;
2874
+ }
2875
+ if (tail.length) {
2876
+ return (headStr ? headStr + "/…/" : "…/") + tail.join("/");
2877
+ }
2878
+ }
2879
+ // Final segment alone exceeds maxLen — cut it (practically never).
2880
+ return "…/" + segs[segs.length - 1].slice(-Math.max(1, maxLen - 2));
2881
+ }
2882
+
2788
2883
  // ── Public API ─────────────────────────────────────────────────────────
2789
2884
  return {
2790
2885
  get all() { return _sessions; },
@@ -2850,6 +2945,10 @@ const Sessions = (() => {
2850
2945
  _initComposer();
2851
2946
  _initSearch();
2852
2947
  _initMessageHistory();
2948
+ // Quote-on-selection: floating quote button over message selections.
2949
+ // quote-select.js is loaded after sessions.js; it's available at init
2950
+ // time because app.js (which calls Sessions.init()) loads last.
2951
+ if (typeof QuoteSelect !== "undefined") QuoteSelect.init();
2853
2952
  _ensureAgentsLoaded().then(() => Sessions.renderList());
2854
2953
  // Re-render session list (badges/labels) when the user switches language
2855
2954
  document.addEventListener("langchange", () => Sessions.renderList());
@@ -2866,6 +2965,21 @@ const Sessions = (() => {
2866
2965
  // Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
2867
2966
  // OS default handler. Remote deployments: download the file.
2868
2967
  document.addEventListener("click", async (e) => {
2968
+ // Localhost dev-server URLs open in the preview panel instead of a new
2969
+ // tab (local deployments only — the proxy can't reach a remote user's
2970
+ // localhost).
2971
+ const localLink = e.target.closest("a[href]");
2972
+ if (localLink) {
2973
+ const href = localLink.getAttribute("href") || "";
2974
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?([/?#]|$)/i.test(href) &&
2975
+ ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname) &&
2976
+ window.Clacky && Clacky.Preview) {
2977
+ e.preventDefault();
2978
+ Clacky.Preview.open(href);
2979
+ return;
2980
+ }
2981
+ }
2982
+
2869
2983
  const link = e.target.closest("a[href^='file://']");
2870
2984
  if (!link) return;
2871
2985
  e.preventDefault();
@@ -2878,6 +2992,12 @@ const Sessions = (() => {
2878
2992
 
2879
2993
  const hostname = window.location.hostname;
2880
2994
  const isLocal = ["localhost", "127.0.0.1", "::1"].includes(hostname);
2995
+
2996
+ // Local deployments: open the file in the Files tab of the aside
2997
+ // panel (inline preview; binary formats show an "open with app"
2998
+ // fallback page). Fall back to the OS default handler when the
2999
+ // viewer is unavailable. Remote deployments: download the file.
3000
+ if (isLocal && window.Clacky && Clacky.WorkspaceView && Clacky.WorkspaceView.openFile(filePath)) return;
2881
3001
  const action = isLocal ? "open" : "download";
2882
3002
 
2883
3003
  try {
@@ -3987,10 +4107,10 @@ const Sessions = (() => {
3987
4107
  }
3988
4108
  }
3989
4109
 
3990
- // Working dir — show full path
4110
+ // Working dir — show a compacted path (middle-ellipsised), full path in title.
3991
4111
  const sibDir = $("sib-dir");
3992
4112
  if (sibDir && s.working_dir) {
3993
- sibDir.textContent = s.working_dir;
4113
+ sibDir.textContent = _compactWorkingDir(s.working_dir);
3994
4114
  sibDir.title = `${s.working_dir} (${I18n.t("sib.dir.tooltip")})`;
3995
4115
  sibDir.dataset.workingDir = s.working_dir;
3996
4116
  sibDir.dataset.sessionId = s.id;
@@ -4896,6 +5016,7 @@ const Sessions = (() => {
4896
5016
 
4897
5017
  /** Load the most recent page of history for a session (called on first visit). */
4898
5018
  loadHistory(id) {
5019
+ window.ChatNavigator?.setSession(id);
4899
5020
  return _fetchHistory(id, null, false);
4900
5021
  },
4901
5022
 
@@ -4903,7 +5024,41 @@ const Sessions = (() => {
4903
5024
  loadMoreHistory(id) {
4904
5025
  const state = _historyState[id];
4905
5026
  if (!state || !state.hasMore) return;
4906
- return _fetchHistory(id, state.oldestCreatedAt, true);
5027
+ return _fetchHistory(id, state.beforeCursor, true);
5028
+ },
5029
+
5030
+ isHistoricalWindow() {
5031
+ const state = _historyState[_activeId];
5032
+ return !!(state?.hasAfter || state?.replacing);
5033
+ },
5034
+
5035
+ loadNewerHistory() {
5036
+ const state = _historyState[_activeId];
5037
+ if (!state?.hasAfter || !state.afterCursor) return;
5038
+ return _fetchHistory(_activeId, null, false, { after: state.afterCursor });
5039
+ },
5040
+
5041
+ jumpToHistory(roundId) {
5042
+ return _fetchHistory(_activeId, null, false, { around: roundId, replace: true });
5043
+ },
5044
+
5045
+ async loadLatestHistory() {
5046
+ const id = _activeId;
5047
+ const loaded = await _fetchHistory(id, null, false, { replace: true });
5048
+ if (loaded && id === _activeId && Sessions.find(id)?.status === "running") {
5049
+ WS.send({ type: "subscribe", session_id: id });
5050
+ }
5051
+ return loaded;
5052
+ },
5053
+
5054
+ deferLiveHistory(event) {
5055
+ if (!Sessions.isHistoricalWindow()) return false;
5056
+ if (["history_user_message", "assistant_message", "tool_call", "tool_result"].includes(event.type)) {
5057
+ const state = _historyState[_activeId];
5058
+ state.liveRevision = (state.liveRevision || 0) + 1;
5059
+ }
5060
+ _showNewMessageBanner();
5061
+ return true;
4907
5062
  },
4908
5063
 
4909
5064
  /** Check if there is more history to load for a session. */
@@ -56,6 +56,40 @@ const IME = (() => {
56
56
  return { track, bindEnter };
57
57
  })();
58
58
 
59
+ // Compact, non-interactive Markdown for short previews. Only inline formatting
60
+ // is emitted; raw HTML and images cannot introduce active content.
61
+ const MarkdownPreview = (() => {
62
+ function render(text) {
63
+ if (!text) return "";
64
+ if (typeof marked === "undefined") return escapeHtml(text);
65
+
66
+ const renderer = new marked.Renderer();
67
+ renderer.html = renderer.image = renderer.checkbox = () => "";
68
+ renderer.hr = renderer.br = () => " ";
69
+ renderer.link = function({ tokens }) { return this.parser.parseInline(tokens); };
70
+ renderer.paragraph = renderer.heading = function({ tokens }) {
71
+ return this.parser.parseInline(tokens) + " ";
72
+ };
73
+ renderer.blockquote = renderer.listitem = function({ tokens }) {
74
+ return this.parser.parse(tokens).trim() + " ";
75
+ };
76
+ renderer.list = function({ items }) {
77
+ return items.map(item => this.listitem(item)).join("");
78
+ };
79
+ renderer.table = function({ header, rows }) {
80
+ return [header, ...rows].map(row => row.map(cell => this.parser.parseInline(cell.tokens)).join(" ")).join(" ") + " ";
81
+ };
82
+ renderer.code = function({ text }) { return `<code>${escapeHtml(text)}</code> `; };
83
+ try {
84
+ return marked.parse(text, { gfm: true, breaks: false, renderer }).trim();
85
+ } catch (_) {
86
+ return escapeHtml(text);
87
+ }
88
+ }
89
+
90
+ return { render };
91
+ })();
92
+
59
93
  const Tooltip = (() => {
60
94
  const GAP = 8;
61
95
  let el = null;