openclacky 1.5.13 → 1.5.15
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +53 -98
- data/lib/clacky/agent.rb +477 -297
- data/lib/clacky/agent_config.rb +5 -3
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +7 -0
- data/lib/clacky/brand_config.rb +27 -19
- data/lib/clacky/cli.rb +92 -12
- data/lib/clacky/cli_guidance.rb +48 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +18 -6
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/json_ui_controller.rb +4 -2
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/message_format/open_ai.rb +22 -5
- data/lib/clacky/message_format/open_ai_responses.rb +7 -3
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +44 -13
- data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +25 -7
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/dir_picker.rb +67 -4
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +391 -75
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/session_registry.rb +4 -0
- data/lib/clacky/server/web_ui_controller.rb +10 -5
- data/lib/clacky/session_manager.rb +52 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +91 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
- data/lib/clacky/ui2/components/input_area.rb +37 -4
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/screen_buffer.rb +1 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -12
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +118 -68
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +998 -168
- data/lib/clacky/web/app.js +19 -0
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/composer.js +3 -1
- data/lib/clacky/web/components/datepicker.js +19 -19
- data/lib/clacky/web/components/mentions.js +11 -14
- data/lib/clacky/web/components/model-picker.js +66 -23
- data/lib/clacky/web/components/quote-select.js +341 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/extensions/store.js +4 -1
- data/lib/clacky/web/features/model-tester/store.js +37 -4
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +474 -43
- data/lib/clacky/web/i18n.js +158 -23
- data/lib/clacky/web/index.html +57 -29
- data/lib/clacky/web/sessions.js +517 -127
- data/lib/clacky/web/settings.js +155 -6
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +161 -4
- data/lib/clacky.rb +2 -0
- metadata +13 -1
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
|
|
18
18
|
const Sessions = (() => {
|
|
19
19
|
const _sessions = []; // [{ id, name, status, total_tasks, total_cost }]
|
|
20
|
-
const _historyState = {}; //
|
|
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: [...] } }
|
|
24
|
+
const _quoteDrafts = new Map(); // { [session_id]: staged quote cards (quote-select.js) }
|
|
24
25
|
let _activeId = null;
|
|
25
26
|
let _hasMore = false; // unified pagination: are there older sessions to load?
|
|
26
27
|
let _loadingMore = false;
|
|
@@ -37,6 +38,10 @@ const Sessions = (() => {
|
|
|
37
38
|
// `findOrFetch`. Excluded from sidebar render and from the loadMore cursor
|
|
38
39
|
// so the pagination of `_sessions` stays correct.
|
|
39
40
|
const _extraSessions = [];
|
|
41
|
+
// Unknown session_update rows can affect folded-group counts before their
|
|
42
|
+
// corresponding creation update arrives. Track those adjustments separately
|
|
43
|
+
// so promoting a cached row into `_sessions` stays idempotent.
|
|
44
|
+
const _countedExtraIds = new Set();
|
|
40
45
|
// Sessions that finished while the user was looking elsewhere. Holds a
|
|
41
46
|
// transient "done" dot until the session is opened or DONE_DOT_TTL elapses,
|
|
42
47
|
// so a completed background task is noticeable without a permanent marker.
|
|
@@ -347,11 +352,8 @@ const Sessions = (() => {
|
|
|
347
352
|
// Clear the pane and dedup state; history will be re-fetched from API.
|
|
348
353
|
RenderTarget.outer().innerHTML = "";
|
|
349
354
|
delete _renderedCreatedAt[id];
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
_historyState[id].hasMore = true;
|
|
353
|
-
_historyState[id].loading = false; // reset so next fetch is not skipped
|
|
354
|
-
}
|
|
355
|
+
_historyState[id]?.controller?.abort();
|
|
356
|
+
delete _historyState[id];
|
|
355
357
|
// Reset scroll tracking when switching sessions
|
|
356
358
|
_userScrolledUp = false;
|
|
357
359
|
}
|
|
@@ -412,6 +414,12 @@ const Sessions = (() => {
|
|
|
412
414
|
function _showNewMessageBanner() {
|
|
413
415
|
const banner = $("new-message-banner");
|
|
414
416
|
if (!banner) return;
|
|
417
|
+
const label = banner.querySelector("span");
|
|
418
|
+
if (label) {
|
|
419
|
+
const key = Sessions.isHistoricalWindow() ? "chat.nav.latest" : "chat.newMessageHint";
|
|
420
|
+
label.dataset.i18n = key;
|
|
421
|
+
label.textContent = I18n.t(key);
|
|
422
|
+
}
|
|
415
423
|
banner.style.display = "block";
|
|
416
424
|
}
|
|
417
425
|
|
|
@@ -548,6 +556,10 @@ const Sessions = (() => {
|
|
|
548
556
|
|
|
549
557
|
// Click to scroll to bottom
|
|
550
558
|
banner.addEventListener("click", () => {
|
|
559
|
+
if (Sessions.isHistoricalWindow()) {
|
|
560
|
+
Sessions.loadLatestHistory();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
551
563
|
messages.scrollTop = messages.scrollHeight;
|
|
552
564
|
_userScrolledUp = false;
|
|
553
565
|
_hideNewMessageBanner();
|
|
@@ -569,7 +581,7 @@ const Sessions = (() => {
|
|
|
569
581
|
// so a content update never moves us "away from bottom" and never trips a
|
|
570
582
|
// false positive here. The 150px threshold in _isAtBottom is extra slack.
|
|
571
583
|
messages.addEventListener("scroll", () => {
|
|
572
|
-
if (_isAtBottom(messages)) {
|
|
584
|
+
if (_isAtBottom(messages) && !Sessions.isHistoricalWindow()) {
|
|
573
585
|
_userScrolledUp = false;
|
|
574
586
|
_hideNewMessageBanner();
|
|
575
587
|
} else {
|
|
@@ -1112,8 +1124,18 @@ const Sessions = (() => {
|
|
|
1112
1124
|
if (submit) submit.onclick = trySubmit;
|
|
1113
1125
|
}
|
|
1114
1126
|
|
|
1115
|
-
// Render a
|
|
1127
|
+
// Render a reference chip (file/directory/session/quote) as a bubble badge.
|
|
1128
|
+
// Quote references carry a verbatim excerpt instead of a name: the badge shows
|
|
1129
|
+
// it truncated to one line (.msg-quote-badge), the tooltip carries the whole
|
|
1130
|
+
// thing plus the provenance line.
|
|
1116
1131
|
function _renderMentionBadge(chip) {
|
|
1132
|
+
if (chip.type === "quote") {
|
|
1133
|
+
const title = chip.label ? chip.label + "\n" + chip.text : chip.text;
|
|
1134
|
+
return `<span class="msg-mention-badge msg-quote-badge" title="${escapeHtml(title)}">` +
|
|
1135
|
+
`<span class="msg-mention-badge-icon">${QuoteSelect.icon(13)}</span>` +
|
|
1136
|
+
`<span class="msg-mention-badge-name">${escapeHtml(chip.text)}</span>` +
|
|
1137
|
+
`</span>`;
|
|
1138
|
+
}
|
|
1117
1139
|
const path = chip.type === "session"
|
|
1118
1140
|
? MentionAC.icons.message
|
|
1119
1141
|
: (chip.type === "directory" ? MentionAC.icons.folder : MentionAC.icons.file);
|
|
@@ -1123,12 +1145,37 @@ const Sessions = (() => {
|
|
|
1123
1145
|
`</span>`;
|
|
1124
1146
|
}
|
|
1125
1147
|
|
|
1126
|
-
function
|
|
1148
|
+
function _closeInputBehaviorMenu(restoreFocus = false) {
|
|
1149
|
+
const menu = $("input-behavior-menu");
|
|
1150
|
+
const toggle = $("input-behavior-toggle");
|
|
1151
|
+
if (menu) menu.hidden = true;
|
|
1152
|
+
toggle?.setAttribute("aria-expanded", "false");
|
|
1153
|
+
if (restoreFocus) toggle?.focus();
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function _updateInputBehavior(status = Sessions.find(Sessions.activeId)?.status) {
|
|
1157
|
+
const running = status === "running";
|
|
1158
|
+
const control = $("input-behavior-control");
|
|
1159
|
+
const mode = $("input-behavior")?.value || "queue";
|
|
1160
|
+
if (control) control.hidden = !running;
|
|
1161
|
+
if (!running) _closeInputBehaviorMenu();
|
|
1162
|
+
document.querySelectorAll("[data-input-behavior]").forEach(button => {
|
|
1163
|
+
button.setAttribute("aria-checked", String(button.dataset.inputBehavior === mode));
|
|
1164
|
+
});
|
|
1165
|
+
const label = I18n.t(`chat.input.${mode}Mode`);
|
|
1166
|
+
$("input-behavior-toggle")?.setAttribute("aria-label", I18n.t("chat.input.behaviorTitle") + ": " + label);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function _sendMessage() {
|
|
1127
1170
|
if (_sending) return;
|
|
1128
1171
|
const input = $("user-input");
|
|
1129
|
-
const
|
|
1172
|
+
const typed = Composer.text(input).trim();
|
|
1173
|
+
const quotes = (typeof QuoteSelect !== "undefined") ? QuoteSelect.toReferences() : [];
|
|
1130
1174
|
const mentionChips = Composer.chips(input);
|
|
1131
|
-
|
|
1175
|
+
// Quotes travel as references, not as text in the body — the chip the user
|
|
1176
|
+
// staged is a UI affordance, and the sent bubble re-renders it as a badge.
|
|
1177
|
+
const content = typed;
|
|
1178
|
+
if (!content && quotes.length === 0 && mentionChips.length === 0 && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
|
|
1132
1179
|
if (!Sessions.activeId) return;
|
|
1133
1180
|
|
|
1134
1181
|
if (!WS.ready) {
|
|
@@ -1147,10 +1194,16 @@ const Sessions = (() => {
|
|
|
1147
1194
|
}
|
|
1148
1195
|
|
|
1149
1196
|
_sending = true;
|
|
1197
|
+
if (Sessions.isHistoricalWindow()) {
|
|
1198
|
+
const id = Sessions.activeId;
|
|
1199
|
+
const loaded = await Sessions.loadLatestHistory();
|
|
1200
|
+
if (!loaded || Sessions.activeId !== id) { _sending = false; return; }
|
|
1201
|
+
}
|
|
1150
1202
|
|
|
1151
1203
|
let bubbleHtml = content ? SkillAC.renderUserMessageHtml(content) : "";
|
|
1152
|
-
|
|
1153
|
-
|
|
1204
|
+
const badgeChips = quotes.concat(mentionChips);
|
|
1205
|
+
if (badgeChips.length > 0) {
|
|
1206
|
+
const chipBadges = badgeChips.map(_renderMentionBadge).join(" ");
|
|
1154
1207
|
bubbleHtml = chipBadges + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
1155
1208
|
}
|
|
1156
1209
|
if (_pendingImages.length > 0) {
|
|
@@ -1173,10 +1226,12 @@ const Sessions = (() => {
|
|
|
1173
1226
|
}).join(" ");
|
|
1174
1227
|
bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
1175
1228
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1229
|
+
const inputBehavior = $("input-behavior")?.value || "queue";
|
|
1230
|
+
const queuing = inputBehavior === "steer" || inputBehavior === "queue";
|
|
1231
|
+
if (!queuing) {
|
|
1232
|
+
if (typeof window._closeAllPhases === "function") window._closeAllPhases("incomplete");
|
|
1233
|
+
Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
|
|
1178
1234
|
}
|
|
1179
|
-
Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
|
|
1180
1235
|
|
|
1181
1236
|
// Merge images and files into unified files array for WS payload.
|
|
1182
1237
|
_pendingImages.sort((a, b) => a.seq - b.seq);
|
|
@@ -1196,12 +1251,12 @@ const Sessions = (() => {
|
|
|
1196
1251
|
.filter(c => c.type === "file" || c.type === "directory")
|
|
1197
1252
|
.map(c => ({ name: c.name, path: c.path, reference: true }))
|
|
1198
1253
|
];
|
|
1199
|
-
const references = mentionChips.map(c => {
|
|
1254
|
+
const references = quotes.concat(mentionChips.map(c => {
|
|
1200
1255
|
const ref = { type: c.type, name: c.name };
|
|
1201
1256
|
if (c.type === "session") ref.session_id = c.session_id;
|
|
1202
1257
|
else ref.path = c.path;
|
|
1203
1258
|
return ref;
|
|
1204
|
-
});
|
|
1259
|
+
}));
|
|
1205
1260
|
_pendingImages.length = 0;
|
|
1206
1261
|
_pendingFiles.length = 0;
|
|
1207
1262
|
_imageSeq = 0;
|
|
@@ -1219,8 +1274,10 @@ const Sessions = (() => {
|
|
|
1219
1274
|
});
|
|
1220
1275
|
|
|
1221
1276
|
Composer.clear(input);
|
|
1277
|
+
if (typeof QuoteSelect !== "undefined") QuoteSelect.clear();
|
|
1222
1278
|
_drafts.delete(Sessions.activeId);
|
|
1223
1279
|
_attachmentDrafts.delete(Sessions.activeId);
|
|
1280
|
+
_quoteDrafts.delete(Sessions.activeId);
|
|
1224
1281
|
setTimeout(() => { _sending = false; }, 300);
|
|
1225
1282
|
}
|
|
1226
1283
|
|
|
@@ -1245,6 +1302,40 @@ const Sessions = (() => {
|
|
|
1245
1302
|
Composer.init($("user-input"));
|
|
1246
1303
|
|
|
1247
1304
|
// Send & attach buttons
|
|
1305
|
+
const behaviorMenu = $("input-behavior-menu");
|
|
1306
|
+
const behaviorToggle = $("input-behavior-toggle");
|
|
1307
|
+
behaviorToggle?.addEventListener("click", () => {
|
|
1308
|
+
const open = behaviorMenu.hidden;
|
|
1309
|
+
behaviorMenu.hidden = !open;
|
|
1310
|
+
behaviorToggle.setAttribute("aria-expanded", String(open));
|
|
1311
|
+
if (open) behaviorMenu.querySelector('[aria-checked="true"]')?.focus();
|
|
1312
|
+
});
|
|
1313
|
+
behaviorMenu?.addEventListener("click", event => {
|
|
1314
|
+
const option = event.target.closest("[data-input-behavior]");
|
|
1315
|
+
if (!option || !WS.ready) return;
|
|
1316
|
+
$("input-behavior").value = option.dataset.inputBehavior;
|
|
1317
|
+
WS.send({ type: "input_behavior", value: option.dataset.inputBehavior });
|
|
1318
|
+
_updateInputBehavior();
|
|
1319
|
+
_closeInputBehaviorMenu(true);
|
|
1320
|
+
});
|
|
1321
|
+
behaviorMenu?.addEventListener("keydown", event => {
|
|
1322
|
+
const options = [...behaviorMenu.querySelectorAll("[data-input-behavior]")];
|
|
1323
|
+
const index = options.indexOf(document.activeElement);
|
|
1324
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
1325
|
+
event.preventDefault();
|
|
1326
|
+
options[(index + (event.key === "ArrowDown" ? 1 : options.length - 1)) % options.length]?.focus();
|
|
1327
|
+
} else if (event.key === "Escape") {
|
|
1328
|
+
event.preventDefault();
|
|
1329
|
+
event.stopPropagation();
|
|
1330
|
+
_closeInputBehaviorMenu(true);
|
|
1331
|
+
} else if (event.key === "Tab") {
|
|
1332
|
+
_closeInputBehaviorMenu();
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
document.addEventListener("pointerdown", event => {
|
|
1336
|
+
if (!event.target.closest("#input-behavior-control")) _closeInputBehaviorMenu();
|
|
1337
|
+
});
|
|
1338
|
+
_updateInputBehavior();
|
|
1248
1339
|
document.getElementById("btn-send").addEventListener("click", _sendMessage);
|
|
1249
1340
|
document.getElementById("btn-attach")
|
|
1250
1341
|
.addEventListener("click", () => document.getElementById("image-file-input").click());
|
|
@@ -1407,6 +1498,8 @@ const Sessions = (() => {
|
|
|
1407
1498
|
const messages = e.currentTarget;
|
|
1408
1499
|
if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
|
|
1409
1500
|
Sessions.loadMoreHistory(Sessions.activeId);
|
|
1501
|
+
} else if (_isAtBottom(messages) && Sessions.isHistoricalWindow()) {
|
|
1502
|
+
Sessions.loadNewerHistory();
|
|
1410
1503
|
}
|
|
1411
1504
|
});
|
|
1412
1505
|
|
|
@@ -1609,6 +1702,22 @@ const Sessions = (() => {
|
|
|
1609
1702
|
return result;
|
|
1610
1703
|
}
|
|
1611
1704
|
|
|
1705
|
+
function _renderWebSearchResults(payload) {
|
|
1706
|
+
const escape = (value) => String(value == null ? "" : value)
|
|
1707
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """);
|
|
1708
|
+
if (payload.error) return `<div class="search-error">${escape(payload.error)}</div>`;
|
|
1709
|
+
const meta = [payload.count != null ? `${payload.count} results` : "", payload.provider ? `via ${payload.provider}` : ""]
|
|
1710
|
+
.filter(Boolean).join(" · ");
|
|
1711
|
+
const items = Array.isArray(payload.results) ? payload.results : [];
|
|
1712
|
+
return `<div class="search-results">${meta ? `<div class="search-results-meta">${escape(meta)}</div>` : ""}` +
|
|
1713
|
+
items.map((item, index) => {
|
|
1714
|
+
const title = item.title || item.url || `Result ${index + 1}`;
|
|
1715
|
+
const url = item.url || "";
|
|
1716
|
+
const snippet = item.snippet ? `<div class="search-result-snippet">${escape(item.snippet)}</div>` : "";
|
|
1717
|
+
return `<article class="search-result"><span class="search-result-index">${index + 1}</span><div><a href="${escape(url)}" target="_blank" rel="noopener noreferrer">${escape(title)}</a><div class="search-result-url">${escape(url)}</div>${snippet}</div></article>`;
|
|
1718
|
+
}).join("") + "</div>";
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1612
1721
|
// Produce a short one-line summary of tool arguments for the compact view.
|
|
1613
1722
|
function _summariseArgs(toolName, args) {
|
|
1614
1723
|
if (!args || typeof args !== "object") return String(args || "");
|
|
@@ -1671,7 +1780,7 @@ const Sessions = (() => {
|
|
|
1671
1780
|
}
|
|
1672
1781
|
|
|
1673
1782
|
// Mark a specific tool-item element as done (update status + render result).
|
|
1674
|
-
function _completeToolItem(last, result, { collapsed = false } = {}) {
|
|
1783
|
+
function _completeToolItem(last, result, { collapsed = false, ui = null } = {}) {
|
|
1675
1784
|
if (!last) return;
|
|
1676
1785
|
const status = last.querySelector(".tool-item-status");
|
|
1677
1786
|
if (status) {
|
|
@@ -1687,8 +1796,11 @@ const Sessions = (() => {
|
|
|
1687
1796
|
const stdout = last.querySelector(".tool-item-stdout");
|
|
1688
1797
|
if (stdout) {
|
|
1689
1798
|
const existing = stdout.textContent.trim();
|
|
1799
|
+
const isWebSearch = ui && ui.type === "web_search";
|
|
1690
1800
|
const resultStr = (result == null) ? "" : String(result).trim();
|
|
1691
|
-
if (!existing &&
|
|
1801
|
+
if (!existing && isWebSearch) {
|
|
1802
|
+
stdout.innerHTML = _renderWebSearchResults(ui);
|
|
1803
|
+
} else if (!existing && resultStr) {
|
|
1692
1804
|
stdout.innerHTML = _ansiToHtml(resultStr);
|
|
1693
1805
|
}
|
|
1694
1806
|
const hasContent = !!stdout.textContent.trim();
|
|
@@ -1804,6 +1916,7 @@ const Sessions = (() => {
|
|
|
1804
1916
|
bubbleHtml += SkillAC.renderUserMessageHtml(ev.content || "", ev.skill_command, ev.skill_command_display);
|
|
1805
1917
|
el.innerHTML = bubbleHtml;
|
|
1806
1918
|
if (ev.created_at) el.dataset.createdAt = ev.created_at;
|
|
1919
|
+
if (ev.round_id) el.dataset.roundId = ev.round_id;
|
|
1807
1920
|
// Messages archived into a compressed chunk can't be edited (the backend
|
|
1808
1921
|
// truncate keys off the active in-memory history). Flag them so the edit
|
|
1809
1922
|
// affordance stays hidden.
|
|
@@ -1852,14 +1965,17 @@ const Sessions = (() => {
|
|
|
1852
1965
|
}
|
|
1853
1966
|
const stdout = historyCtx.lastItem.querySelector(".tool-item-stdout");
|
|
1854
1967
|
if (stdout) {
|
|
1968
|
+
const ui = ev.ui;
|
|
1969
|
+
const isWebSearch = ui && ui.type === "web_search";
|
|
1855
1970
|
const resultStr = (ev.result == null) ? "" : String(ev.result).trim();
|
|
1856
|
-
if (
|
|
1971
|
+
if (isWebSearch) {
|
|
1972
|
+
stdout.innerHTML = _renderWebSearchResults(ui);
|
|
1973
|
+
const header = historyCtx.lastItem.querySelector(".tool-item-header");
|
|
1974
|
+
if (header && !header.classList.contains("tool-item-expandable")) header.classList.add("tool-item-expandable");
|
|
1975
|
+
} else if (resultStr && !stdout.textContent.trim()) {
|
|
1857
1976
|
stdout.innerHTML = _ansiToHtml(resultStr);
|
|
1858
|
-
// Collapsed by default in history; click header to expand
|
|
1859
1977
|
const header = historyCtx.lastItem.querySelector(".tool-item-header");
|
|
1860
|
-
if (header && !header.classList.contains("tool-item-expandable"))
|
|
1861
|
-
header.classList.add("tool-item-expandable");
|
|
1862
|
-
}
|
|
1978
|
+
if (header && !header.classList.contains("tool-item-expandable")) header.classList.add("tool-item-expandable");
|
|
1863
1979
|
} else if (!resultStr && !stdout.textContent.trim()) {
|
|
1864
1980
|
stdout.style.display = "none";
|
|
1865
1981
|
}
|
|
@@ -1987,40 +2103,56 @@ const Sessions = (() => {
|
|
|
1987
2103
|
|
|
1988
2104
|
// Fetch one page of history and insert into #messages or cache.
|
|
1989
2105
|
// 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,
|
|
1992
|
-
if (state.loading) return;
|
|
2106
|
+
async function _fetchHistory(id, before = null, prepend = false, options = {}) {
|
|
2107
|
+
const state = _historyState[id] || (_historyState[id] = { hasMore: true, loading: false });
|
|
2108
|
+
if (state.loading && !options.replace) return false;
|
|
2109
|
+
state.controller?.abort();
|
|
2110
|
+
const controller = new AbortController();
|
|
2111
|
+
state.controller = controller;
|
|
2112
|
+
const wasHistorical = !!state.hasAfter;
|
|
2113
|
+
const liveRevision = state.liveRevision || 0;
|
|
2114
|
+
if (options.around) state.hasAfter = true;
|
|
2115
|
+
if (options.replace) state.replacing = true;
|
|
1993
2116
|
state.loading = true;
|
|
2117
|
+
let succeeded = false;
|
|
1994
2118
|
|
|
1995
2119
|
try {
|
|
1996
|
-
const params = new URLSearchParams({ limit: 30 });
|
|
1997
|
-
if (before) params.set("
|
|
2120
|
+
const params = new URLSearchParams({ limit: 30, window: 1 });
|
|
2121
|
+
if (before) params.set("before_id", before);
|
|
2122
|
+
if (options.around) params.set("around", options.around);
|
|
2123
|
+
if (options.after) params.set("after_id", options.after);
|
|
1998
2124
|
|
|
1999
|
-
const res = await fetch(`/api/sessions/${id}/messages?${params}
|
|
2125
|
+
const res = await fetch(`/api/sessions/${id}/messages?${params}`, { signal: controller.signal });
|
|
2126
|
+
if (id !== _activeId || _historyState[id] !== state || state.controller !== controller) return false;
|
|
2000
2127
|
if (!res.ok) {
|
|
2128
|
+
if (res.status === 409) window.ChatNavigator?.refresh();
|
|
2001
2129
|
if (id === _activeId) {
|
|
2002
2130
|
let reason = "";
|
|
2003
2131
|
try { const d = await res.json(); reason = d.error || ""; } catch {}
|
|
2004
2132
|
const suffix = reason ? `: ${reason}` : "";
|
|
2005
2133
|
Sessions.appendMsg("info", `${I18n.t("chat.history_load_failed")} (${res.status}${suffix})`);
|
|
2006
2134
|
}
|
|
2007
|
-
return;
|
|
2135
|
+
return false;
|
|
2008
2136
|
}
|
|
2009
2137
|
const data = await res.json();
|
|
2138
|
+
if (id !== _activeId || _historyState[id] !== state || state.controller !== controller) return false;
|
|
2010
2139
|
|
|
2011
|
-
|
|
2140
|
+
if (!options.after) {
|
|
2141
|
+
state.hasMore = !!data.has_more;
|
|
2142
|
+
state.beforeCursor = data.before_cursor;
|
|
2143
|
+
}
|
|
2144
|
+
if (!prepend) {
|
|
2145
|
+
state.hasAfter = !!data.has_after;
|
|
2146
|
+
state.afterCursor = data.after_cursor;
|
|
2147
|
+
}
|
|
2148
|
+
if (options.replace) {
|
|
2149
|
+
window._closeAllPhases?.("incomplete");
|
|
2150
|
+
Sessions.collapseToolGroup();
|
|
2151
|
+
RenderTarget.outer().replaceChildren();
|
|
2152
|
+
delete _renderedCreatedAt[id];
|
|
2153
|
+
}
|
|
2012
2154
|
|
|
2013
2155
|
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
2156
|
|
|
2025
2157
|
// Pre-scan: mark each request_feedback as answered.
|
|
2026
2158
|
// A feedback card is disabled as soon as any subsequent history_user_message appears
|
|
@@ -2028,7 +2160,7 @@ const Sessions = (() => {
|
|
|
2028
2160
|
{
|
|
2029
2161
|
let lastFeedbackIdx = -1;
|
|
2030
2162
|
events.forEach((ev, i) => {
|
|
2031
|
-
if (ev.type === "request_feedback") { ev._answered =
|
|
2163
|
+
if (ev.type === "request_feedback") { ev._answered = !!data.has_after; lastFeedbackIdx = i; }
|
|
2032
2164
|
if (ev.type === "history_user_message" && lastFeedbackIdx >= 0 && i > lastFeedbackIdx) {
|
|
2033
2165
|
events[lastFeedbackIdx]._answered = true;
|
|
2034
2166
|
lastFeedbackIdx = -1;
|
|
@@ -2047,8 +2179,8 @@ const Sessions = (() => {
|
|
|
2047
2179
|
|
|
2048
2180
|
events.forEach(ev => {
|
|
2049
2181
|
if (ev.type === "history_user_message") {
|
|
2050
|
-
currentCreatedAt = ev.created_at;
|
|
2051
|
-
skipRound
|
|
2182
|
+
currentCreatedAt = ev.round_id || ev.created_at;
|
|
2183
|
+
skipRound = currentCreatedAt && (dedup.has(currentCreatedAt) || (ev.editable !== false && dedup.has(ev.created_at)));
|
|
2052
2184
|
if (!skipRound && currentCreatedAt) dedup.add(currentCreatedAt);
|
|
2053
2185
|
}
|
|
2054
2186
|
if (!skipRound) _renderHistoryEvent(ev, frag, historyCtx);
|
|
@@ -2075,10 +2207,16 @@ const Sessions = (() => {
|
|
|
2075
2207
|
} else {
|
|
2076
2208
|
messages.appendChild(frag);
|
|
2077
2209
|
}
|
|
2078
|
-
|
|
2210
|
+
if (options.around) {
|
|
2211
|
+
messages.scrollTop = 0;
|
|
2212
|
+
_userScrolledUp = true;
|
|
2213
|
+
} else if (!options.after) {
|
|
2214
|
+
messages.scrollTop = messages.scrollHeight;
|
|
2215
|
+
_userScrolledUp = false;
|
|
2216
|
+
}
|
|
2079
2217
|
// Flush any tool_stdout lines that arrived via WS before this history
|
|
2080
2218
|
// fetch completed (race condition on session switch).
|
|
2081
|
-
if (!prepend) _flushPendingStdout();
|
|
2219
|
+
if (!prepend && !state.hasAfter) _flushPendingStdout();
|
|
2082
2220
|
}
|
|
2083
2221
|
|
|
2084
2222
|
// If no more history remains, insert a "beginning of conversation" marker at the top.
|
|
@@ -2094,7 +2232,7 @@ const Sessions = (() => {
|
|
|
2094
2232
|
|
|
2095
2233
|
// Restore transient UI state based on session status after initial load
|
|
2096
2234
|
// (not prepend, which is scroll-up pagination — no need to re-restore then)
|
|
2097
|
-
if (!prepend) {
|
|
2235
|
+
if (!prepend && !state.hasAfter) {
|
|
2098
2236
|
const session = _sessions.find(s => s.id === id);
|
|
2099
2237
|
if (session) {
|
|
2100
2238
|
if (session.status === "running") {
|
|
@@ -2116,13 +2254,35 @@ const Sessions = (() => {
|
|
|
2116
2254
|
}
|
|
2117
2255
|
}
|
|
2118
2256
|
}
|
|
2257
|
+
if (state.hasAfter) _showNewMessageBanner();
|
|
2258
|
+
else _hideNewMessageBanner();
|
|
2259
|
+
window.ChatNavigator?.syncMessages();
|
|
2119
2260
|
}
|
|
2261
|
+
succeeded = true;
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
if (error.name !== "AbortError" && id === _activeId && state.controller === controller) {
|
|
2264
|
+
Sessions.appendMsg("info", escapeHtml(I18n.t("chat.history_load_failed")));
|
|
2265
|
+
}
|
|
2266
|
+
return false;
|
|
2120
2267
|
} finally {
|
|
2121
|
-
state.
|
|
2268
|
+
if (state.controller === controller) {
|
|
2269
|
+
state.loading = false;
|
|
2270
|
+
state.replacing = false;
|
|
2271
|
+
if (!succeeded) state.hasAfter = wasHistorical;
|
|
2272
|
+
}
|
|
2122
2273
|
// After loading finishes, re-evaluate the empty-state hint in case
|
|
2123
2274
|
// the session is genuinely empty (no events + no existing DOM content).
|
|
2124
|
-
if (id === _activeId)
|
|
2275
|
+
if (id === _activeId) {
|
|
2276
|
+
_updateEmptyHint();
|
|
2277
|
+
_refreshEditButtons(RenderTarget.outer());
|
|
2278
|
+
}
|
|
2125
2279
|
}
|
|
2280
|
+
// A live message may have arrived after the server took its snapshot while
|
|
2281
|
+
// this detached window was loading. Reconcile before resuming live output.
|
|
2282
|
+
if (succeeded && !state.hasAfter && (state.liveRevision || 0) !== liveRevision && id === _activeId) {
|
|
2283
|
+
return _fetchHistory(id, null, false, { replace: true });
|
|
2284
|
+
}
|
|
2285
|
+
return succeeded;
|
|
2126
2286
|
}
|
|
2127
2287
|
|
|
2128
2288
|
// ── Private helpers ───────────────────────────────────────────────────
|
|
@@ -2238,7 +2398,7 @@ const Sessions = (() => {
|
|
|
2238
2398
|
}
|
|
2239
2399
|
|
|
2240
2400
|
function _refreshEditButtons(container, status) {
|
|
2241
|
-
const running = (status || Sessions.find(Sessions.activeId)?.status) === "running";
|
|
2401
|
+
const running = (status || Sessions.find(Sessions.activeId)?.status) === "running" || Sessions.isHistoricalWindow();
|
|
2242
2402
|
const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
|
|
2243
2403
|
btns.forEach((btn, i) => {
|
|
2244
2404
|
btn.style.display = !running && i === btns.length - 1 ? "" : "none";
|
|
@@ -2256,6 +2416,7 @@ const Sessions = (() => {
|
|
|
2256
2416
|
}
|
|
2257
2417
|
|
|
2258
2418
|
function _enterEditMode(el) {
|
|
2419
|
+
if (Sessions.isHistoricalWindow()) return;
|
|
2259
2420
|
if (_activeSessionIsRunning() || el.classList.contains("editing")) return;
|
|
2260
2421
|
el.classList.add("editing");
|
|
2261
2422
|
|
|
@@ -2330,6 +2491,7 @@ const Sessions = (() => {
|
|
|
2330
2491
|
}
|
|
2331
2492
|
|
|
2332
2493
|
function _submitEdit(el, newContent) {
|
|
2494
|
+
if (Sessions.isHistoricalWindow()) return;
|
|
2333
2495
|
if (!newContent) return;
|
|
2334
2496
|
if (!Sessions.activeId) return;
|
|
2335
2497
|
if (_activeSessionIsRunning()) {
|
|
@@ -2785,6 +2947,40 @@ const Sessions = (() => {
|
|
|
2785
2947
|
|
|
2786
2948
|
|
|
2787
2949
|
|
|
2950
|
+
// Compact a working directory for the status bar: keep a short leading
|
|
2951
|
+
// anchor (up to two segments, e.g. "/Users/leo") plus as many trailing
|
|
2952
|
+
// segments as fit, ellipsising the middle so the current directory name
|
|
2953
|
+
// stays visible. The full path lives in title/dataset, not here.
|
|
2954
|
+
function _compactWorkingDir(p, maxLen = 40) {
|
|
2955
|
+
if (!p) return p;
|
|
2956
|
+
if (p.length <= maxLen) return p;
|
|
2957
|
+
|
|
2958
|
+
const abs = /^[\\/]/.test(p);
|
|
2959
|
+
const segs = p.split(/[\\/]+/).filter((s) => s !== "");
|
|
2960
|
+
if (!segs.length) return p;
|
|
2961
|
+
|
|
2962
|
+
// The final segment (current directory name) must always stay intact;
|
|
2963
|
+
// shrink the leading anchor first when a longer tail won't fit.
|
|
2964
|
+
for (let headCount = Math.min(2, segs.length - 1); headCount >= 0; headCount--) {
|
|
2965
|
+
const headStr = headCount > 0
|
|
2966
|
+
? (abs ? "/" : "") + segs.slice(0, headCount).join("/")
|
|
2967
|
+
: "";
|
|
2968
|
+
let budget = maxLen - headStr.length - 3; // reserve "/…/"
|
|
2969
|
+
const tail = [];
|
|
2970
|
+
for (let i = segs.length - 1; i >= headCount; i--) {
|
|
2971
|
+
const need = segs[i].length + (tail.length ? 1 : 0);
|
|
2972
|
+
if (need > budget) break;
|
|
2973
|
+
tail.unshift(segs[i]);
|
|
2974
|
+
budget -= need;
|
|
2975
|
+
}
|
|
2976
|
+
if (tail.length) {
|
|
2977
|
+
return (headStr ? headStr + "/…/" : "…/") + tail.join("/");
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
// Final segment alone exceeds maxLen — cut it (practically never).
|
|
2981
|
+
return "…/" + segs[segs.length - 1].slice(-Math.max(1, maxLen - 2));
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2788
2984
|
// ── Public API ─────────────────────────────────────────────────────────
|
|
2789
2985
|
return {
|
|
2790
2986
|
get all() { return _sessions; },
|
|
@@ -2850,6 +3046,10 @@ const Sessions = (() => {
|
|
|
2850
3046
|
_initComposer();
|
|
2851
3047
|
_initSearch();
|
|
2852
3048
|
_initMessageHistory();
|
|
3049
|
+
// Quote-on-selection: floating quote button over message selections.
|
|
3050
|
+
// quote-select.js is loaded after sessions.js; it's available at init
|
|
3051
|
+
// time because app.js (which calls Sessions.init()) loads last.
|
|
3052
|
+
if (typeof QuoteSelect !== "undefined") QuoteSelect.init();
|
|
2853
3053
|
_ensureAgentsLoaded().then(() => Sessions.renderList());
|
|
2854
3054
|
// Re-render session list (badges/labels) when the user switches language
|
|
2855
3055
|
document.addEventListener("langchange", () => Sessions.renderList());
|
|
@@ -2866,6 +3066,21 @@ const Sessions = (() => {
|
|
|
2866
3066
|
// Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
|
|
2867
3067
|
// OS default handler. Remote deployments: download the file.
|
|
2868
3068
|
document.addEventListener("click", async (e) => {
|
|
3069
|
+
// Localhost dev-server URLs open in the preview panel instead of a new
|
|
3070
|
+
// tab (local deployments only — the proxy can't reach a remote user's
|
|
3071
|
+
// localhost).
|
|
3072
|
+
const localLink = e.target.closest("a[href]");
|
|
3073
|
+
if (localLink) {
|
|
3074
|
+
const href = localLink.getAttribute("href") || "";
|
|
3075
|
+
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?([/?#]|$)/i.test(href) &&
|
|
3076
|
+
["localhost", "127.0.0.1", "::1"].includes(window.location.hostname) &&
|
|
3077
|
+
window.Clacky && Clacky.Preview) {
|
|
3078
|
+
e.preventDefault();
|
|
3079
|
+
Clacky.Preview.open(href);
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
|
|
2869
3084
|
const link = e.target.closest("a[href^='file://']");
|
|
2870
3085
|
if (!link) return;
|
|
2871
3086
|
e.preventDefault();
|
|
@@ -2878,6 +3093,12 @@ const Sessions = (() => {
|
|
|
2878
3093
|
|
|
2879
3094
|
const hostname = window.location.hostname;
|
|
2880
3095
|
const isLocal = ["localhost", "127.0.0.1", "::1"].includes(hostname);
|
|
3096
|
+
|
|
3097
|
+
// Local deployments: open the file in the Files tab of the aside
|
|
3098
|
+
// panel (inline preview; binary formats show an "open with app"
|
|
3099
|
+
// fallback page). Fall back to the OS default handler when the
|
|
3100
|
+
// viewer is unavailable. Remote deployments: download the file.
|
|
3101
|
+
if (isLocal && window.Clacky && Clacky.WorkspaceView && Clacky.WorkspaceView.openFile(filePath)) return;
|
|
2881
3102
|
const action = isLocal ? "open" : "download";
|
|
2882
3103
|
|
|
2883
3104
|
try {
|
|
@@ -2915,12 +3136,44 @@ const Sessions = (() => {
|
|
|
2915
3136
|
_applyGroupStats(groups);
|
|
2916
3137
|
},
|
|
2917
3138
|
|
|
2918
|
-
/** Insert a newly created session
|
|
3139
|
+
/** Insert or refresh a newly created session in the local list. */
|
|
2919
3140
|
add(session) {
|
|
2920
|
-
if (!
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
3141
|
+
if (!session || !session.id) return;
|
|
3142
|
+
|
|
3143
|
+
const existing = _sessions.find(s => s.id === session.id);
|
|
3144
|
+
const extraIdx = _extraSessions.findIndex(s => s.id === session.id);
|
|
3145
|
+
const extra = extraIdx === -1 ? null : _extraSessions[extraIdx];
|
|
3146
|
+
const extraWasCounted = _countedExtraIds.delete(session.id);
|
|
3147
|
+
|
|
3148
|
+
if (existing) {
|
|
3149
|
+
const previousGroup = _groupOf(existing);
|
|
3150
|
+
if (extra) {
|
|
3151
|
+
if (extraWasCounted) {
|
|
3152
|
+
const extraGroup = _groupOf(extra);
|
|
3153
|
+
if (extraGroup) extraGroup.count = Math.max(0, extraGroup.count - 1);
|
|
3154
|
+
}
|
|
3155
|
+
Object.assign(existing, extra);
|
|
3156
|
+
_extraSessions.splice(extraIdx, 1);
|
|
3157
|
+
}
|
|
3158
|
+
Object.assign(existing, session);
|
|
3159
|
+
|
|
3160
|
+
const nextGroup = _groupOf(existing);
|
|
3161
|
+
if (previousGroup !== nextGroup) {
|
|
3162
|
+
if (previousGroup) previousGroup.count = Math.max(0, previousGroup.count - 1);
|
|
3163
|
+
if (nextGroup) nextGroup.count++;
|
|
3164
|
+
}
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
const inserted = extra ? { ...extra, ...session } : session;
|
|
3169
|
+
const previousGroup = extra && extraWasCounted ? _groupOf(extra) : null;
|
|
3170
|
+
if (extra) _extraSessions.splice(extraIdx, 1);
|
|
3171
|
+
_sessions.push(inserted);
|
|
3172
|
+
|
|
3173
|
+
const nextGroup = _groupOf(inserted);
|
|
3174
|
+
if (previousGroup !== nextGroup) {
|
|
3175
|
+
if (previousGroup) previousGroup.count = Math.max(0, previousGroup.count - 1);
|
|
3176
|
+
if (nextGroup) nextGroup.count++;
|
|
2924
3177
|
}
|
|
2925
3178
|
},
|
|
2926
3179
|
|
|
@@ -2951,8 +3204,8 @@ const Sessions = (() => {
|
|
|
2951
3204
|
},
|
|
2952
3205
|
|
|
2953
3206
|
/** Patch a single session's fields (from session_update event).
|
|
2954
|
-
*
|
|
2955
|
-
*
|
|
3207
|
+
* Unknown sessions stay in the extra cache until an explicit creation
|
|
3208
|
+
* update promotes them, preserving the paged sidebar boundary. */
|
|
2956
3209
|
patch(id, fields) {
|
|
2957
3210
|
const s = _sessions.find(s => s.id === id);
|
|
2958
3211
|
const extra = _extraSessions.find(e => e.id === id);
|
|
@@ -2985,6 +3238,13 @@ const Sessions = (() => {
|
|
|
2985
3238
|
isGroup.latestUpdatedAt = t;
|
|
2986
3239
|
}
|
|
2987
3240
|
}
|
|
3241
|
+
|
|
3242
|
+
if (!s) {
|
|
3243
|
+
if (wasGroup !== isGroup) {
|
|
3244
|
+
if (isGroup) _countedExtraIds.add(id);
|
|
3245
|
+
else _countedExtraIds.delete(id);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
2988
3248
|
},
|
|
2989
3249
|
|
|
2990
3250
|
/** Flag a session as just-finished so the sidebar shows a transient dot.
|
|
@@ -3016,6 +3276,7 @@ const Sessions = (() => {
|
|
|
3016
3276
|
// Clean up per-session progress state (timer + DOM + logical state)
|
|
3017
3277
|
Sessions._deleteProgressState(id);
|
|
3018
3278
|
_drafts.delete(id);
|
|
3279
|
+
_quoteDrafts.delete(id);
|
|
3019
3280
|
},
|
|
3020
3281
|
|
|
3021
3282
|
/** Clear project_id from all sessions that belong to a deleted project. */
|
|
@@ -3269,7 +3530,8 @@ const Sessions = (() => {
|
|
|
3269
3530
|
contentRows.forEach(s => _renderSessionItem(box, s));
|
|
3270
3531
|
}
|
|
3271
3532
|
}
|
|
3272
|
-
|
|
3533
|
+
// Only when the content search never ran — it renders its own empty state.
|
|
3534
|
+
if (!contentLoaded && nameRows.length === 0 && contentRows.length === 0) {
|
|
3273
3535
|
const empty = document.createElement("div");
|
|
3274
3536
|
empty.className = "session-empty";
|
|
3275
3537
|
empty.textContent = I18n.t("sessions.search.contentEmpty");
|
|
@@ -3437,6 +3699,7 @@ const Sessions = (() => {
|
|
|
3437
3699
|
_pendingFiles.length = 0;
|
|
3438
3700
|
}
|
|
3439
3701
|
_renderAttachmentPreviews();
|
|
3702
|
+
if (typeof QuoteSelect !== "undefined") QuoteSelect.restore(_quoteDrafts.get(id) || []);
|
|
3440
3703
|
// Reconcile todos against the authoritative server snapshot. WS
|
|
3441
3704
|
// todo_update is fire-and-forget — a broadcast lost mid-complete
|
|
3442
3705
|
// (e.g. a WS hiccup while completing the last task) would otherwise
|
|
@@ -3459,6 +3722,7 @@ const Sessions = (() => {
|
|
|
3459
3722
|
const input = $("user-input");
|
|
3460
3723
|
if (input) _drafts.set(_activeId, Composer.text(input));
|
|
3461
3724
|
_attachmentDrafts.set(_activeId, { images: _pendingImages.splice(0), files: _pendingFiles.splice(0) });
|
|
3725
|
+
if (typeof QuoteSelect !== "undefined") _quoteDrafts.set(_activeId, QuoteSelect.list());
|
|
3462
3726
|
_renderAttachmentPreviews();
|
|
3463
3727
|
Sessions._detachProgressUI(_activeId);
|
|
3464
3728
|
}
|
|
@@ -3910,7 +4174,10 @@ const Sessions = (() => {
|
|
|
3910
4174
|
}
|
|
3911
4175
|
},
|
|
3912
4176
|
|
|
4177
|
+
updateInputBehavior: _updateInputBehavior,
|
|
4178
|
+
|
|
3913
4179
|
updateStatusBar(status) {
|
|
4180
|
+
_updateInputBehavior(status);
|
|
3914
4181
|
// chat-header was removed; status text is now shown in the bottom session-info-bar (#sib-status).
|
|
3915
4182
|
// Here we update controls whose availability follows the active session status.
|
|
3916
4183
|
const interrupt = $("btn-interrupt");
|
|
@@ -3945,8 +4212,12 @@ const Sessions = (() => {
|
|
|
3945
4212
|
this._lastSession = s;
|
|
3946
4213
|
if (Clacky.Workspace) Clacky.Workspace.onSession(s);
|
|
3947
4214
|
if (!s) {
|
|
3948
|
-
// Hide all spans when no session
|
|
3949
|
-
|
|
4215
|
+
// Hide all spans when no session. Fields that own a static caret (dir,
|
|
4216
|
+
// reasoning) keep their markup and only blank the text node.
|
|
4217
|
+
["sib-id", "sib-status", "sib-mode", "sib-model", "sib-tasks", "sib-cost"].forEach(id => {
|
|
4218
|
+
const el = $(id); if (el) el.textContent = "";
|
|
4219
|
+
});
|
|
4220
|
+
["sib-dir-text", "sib-reasoning-text"].forEach(id => {
|
|
3950
4221
|
const el = $(id); if (el) el.textContent = "";
|
|
3951
4222
|
});
|
|
3952
4223
|
const sibIdEl = $("sib-id");
|
|
@@ -3987,13 +4258,20 @@ const Sessions = (() => {
|
|
|
3987
4258
|
}
|
|
3988
4259
|
}
|
|
3989
4260
|
|
|
3990
|
-
// Working dir — show full path
|
|
4261
|
+
// Working dir — show a compacted path (middle-ellipsised), full path in title.
|
|
4262
|
+
// The caret lives in the markup, so only the inner text node is rewritten.
|
|
3991
4263
|
const sibDir = $("sib-dir");
|
|
3992
|
-
|
|
3993
|
-
|
|
4264
|
+
const sibDirText = $("sib-dir-text");
|
|
4265
|
+
if (sibDir && sibDirText) {
|
|
4266
|
+
sibDirText.textContent = s.working_dir ? _compactWorkingDir(s.working_dir) : "";
|
|
3994
4267
|
sibDir.title = `${s.working_dir} (${I18n.t("sib.dir.tooltip")})`;
|
|
3995
|
-
sibDir.
|
|
3996
|
-
|
|
4268
|
+
sibDir.hidden = !s.working_dir;
|
|
4269
|
+
if (s.working_dir) {
|
|
4270
|
+
sibDir.dataset.workingDir = s.working_dir;
|
|
4271
|
+
sibDir.dataset.sessionId = s.id;
|
|
4272
|
+
} else {
|
|
4273
|
+
delete sibDir.dataset.workingDir;
|
|
4274
|
+
}
|
|
3997
4275
|
}
|
|
3998
4276
|
|
|
3999
4277
|
// Permission mode — hide element and its separator if empty
|
|
@@ -4017,7 +4295,14 @@ const Sessions = (() => {
|
|
|
4017
4295
|
const display = subModel
|
|
4018
4296
|
? `${subModel}`
|
|
4019
4297
|
: (s.model || "");
|
|
4020
|
-
|
|
4298
|
+
// Same vendor badge as the dropdown rows, so the bar reads as the
|
|
4299
|
+
// same object the user just picked from.
|
|
4300
|
+
sibModel.textContent = "";
|
|
4301
|
+
if (display) {
|
|
4302
|
+
sibModel.appendChild(ModelPicker.vendorBadge(display));
|
|
4303
|
+
sibModel.appendChild(document.createTextNode(display));
|
|
4304
|
+
sibModel.appendChild(ModelPicker.caret());
|
|
4305
|
+
}
|
|
4021
4306
|
sibModel.dataset.sessionId = s.id;
|
|
4022
4307
|
if (s.model_id) {
|
|
4023
4308
|
sibModel.dataset.modelId = s.model_id;
|
|
@@ -4040,7 +4325,8 @@ const Sessions = (() => {
|
|
|
4040
4325
|
const sibSepAfterReasoning = document.querySelector(".sib-sep-after-reasoning");
|
|
4041
4326
|
if (sibReasoning) {
|
|
4042
4327
|
const eff = (s.reasoning_effort || "off").toLowerCase();
|
|
4043
|
-
|
|
4328
|
+
const sibReasoningText = $("sib-reasoning-text");
|
|
4329
|
+
if (sibReasoningText) sibReasoningText.textContent = I18n.t(`sib.reasoning.${eff}`);
|
|
4044
4330
|
sibReasoning.dataset.sessionId = s.id;
|
|
4045
4331
|
sibReasoning.dataset.reasoningEffort = eff;
|
|
4046
4332
|
}
|
|
@@ -4240,18 +4526,18 @@ const Sessions = (() => {
|
|
|
4240
4526
|
// If the tool group was collapsed by an intervening info message (e.g.
|
|
4241
4527
|
// "Subagent start/completed" during invoke_skill), fall back to the last
|
|
4242
4528
|
// still-running .tool-item in the DOM.
|
|
4243
|
-
appendToolResult(result) {
|
|
4529
|
+
appendToolResult(result, ui = null) {
|
|
4244
4530
|
const messages = RenderTarget.current();
|
|
4245
4531
|
const ctx = Sessions._toolGroupCtx(messages);
|
|
4246
4532
|
if (ctx.group && ctx.item) {
|
|
4247
|
-
_completeLastToolItem(ctx.group, result);
|
|
4533
|
+
_completeLastToolItem(ctx.group, result, { ui });
|
|
4248
4534
|
return;
|
|
4249
4535
|
}
|
|
4250
4536
|
if (messages) {
|
|
4251
4537
|
const running = messages.querySelectorAll(".tool-item-status.running");
|
|
4252
4538
|
if (running.length > 0) {
|
|
4253
4539
|
const item = running[running.length - 1].closest(".tool-item");
|
|
4254
|
-
if (item) { _completeToolItem(item, result); return; }
|
|
4540
|
+
if (item) { _completeToolItem(item, result, { ui }); return; }
|
|
4255
4541
|
}
|
|
4256
4542
|
}
|
|
4257
4543
|
},
|
|
@@ -4896,6 +5182,7 @@ const Sessions = (() => {
|
|
|
4896
5182
|
|
|
4897
5183
|
/** Load the most recent page of history for a session (called on first visit). */
|
|
4898
5184
|
loadHistory(id) {
|
|
5185
|
+
window.ChatNavigator?.setSession(id);
|
|
4899
5186
|
return _fetchHistory(id, null, false);
|
|
4900
5187
|
},
|
|
4901
5188
|
|
|
@@ -4903,7 +5190,41 @@ const Sessions = (() => {
|
|
|
4903
5190
|
loadMoreHistory(id) {
|
|
4904
5191
|
const state = _historyState[id];
|
|
4905
5192
|
if (!state || !state.hasMore) return;
|
|
4906
|
-
return _fetchHistory(id, state.
|
|
5193
|
+
return _fetchHistory(id, state.beforeCursor, true);
|
|
5194
|
+
},
|
|
5195
|
+
|
|
5196
|
+
isHistoricalWindow() {
|
|
5197
|
+
const state = _historyState[_activeId];
|
|
5198
|
+
return !!(state?.hasAfter || state?.replacing);
|
|
5199
|
+
},
|
|
5200
|
+
|
|
5201
|
+
loadNewerHistory() {
|
|
5202
|
+
const state = _historyState[_activeId];
|
|
5203
|
+
if (!state?.hasAfter || !state.afterCursor) return;
|
|
5204
|
+
return _fetchHistory(_activeId, null, false, { after: state.afterCursor });
|
|
5205
|
+
},
|
|
5206
|
+
|
|
5207
|
+
jumpToHistory(roundId) {
|
|
5208
|
+
return _fetchHistory(_activeId, null, false, { around: roundId, replace: true });
|
|
5209
|
+
},
|
|
5210
|
+
|
|
5211
|
+
async loadLatestHistory() {
|
|
5212
|
+
const id = _activeId;
|
|
5213
|
+
const loaded = await _fetchHistory(id, null, false, { replace: true });
|
|
5214
|
+
if (loaded && id === _activeId && Sessions.find(id)?.status === "running") {
|
|
5215
|
+
WS.send({ type: "subscribe", session_id: id });
|
|
5216
|
+
}
|
|
5217
|
+
return loaded;
|
|
5218
|
+
},
|
|
5219
|
+
|
|
5220
|
+
deferLiveHistory(event) {
|
|
5221
|
+
if (!Sessions.isHistoricalWindow()) return false;
|
|
5222
|
+
if (["history_user_message", "assistant_message", "tool_call", "tool_result"].includes(event.type)) {
|
|
5223
|
+
const state = _historyState[_activeId];
|
|
5224
|
+
state.liveRevision = (state.liveRevision || 0) + 1;
|
|
5225
|
+
}
|
|
5226
|
+
_showNewMessageBanner();
|
|
5227
|
+
return true;
|
|
4907
5228
|
},
|
|
4908
5229
|
|
|
4909
5230
|
/** Check if there is more history to load for a session. */
|
|
@@ -4960,50 +5281,74 @@ const Sessions = (() => {
|
|
|
4960
5281
|
(function() {
|
|
4961
5282
|
let _isOpen = false;
|
|
4962
5283
|
|
|
4963
|
-
|
|
5284
|
+
function _closeModelDropdown() {
|
|
5285
|
+
const dropdown = $("sib-model-dropdown");
|
|
5286
|
+
if (dropdown) dropdown.style.display = "none";
|
|
5287
|
+
_isOpen = false;
|
|
5288
|
+
const sibModel = $("sib-model");
|
|
5289
|
+
if (sibModel) sibModel.classList.remove("is-open");
|
|
5290
|
+
ModelPicker.closeSubmodelPanel();
|
|
5291
|
+
}
|
|
5292
|
+
|
|
5293
|
+
// Toggle model dropdown when clicking on model name. The caret beside it is a
|
|
5294
|
+
// click target of its own: it opens the quick-switch panel — the same one the
|
|
5295
|
+
// dropdown row exposes — without going through the full catalog first.
|
|
4964
5296
|
document.addEventListener("click", async (e) => {
|
|
4965
5297
|
const modelEl = e.target.closest("#sib-model");
|
|
5298
|
+
const caretEl = e.target.closest("#sib-model .sib-caret");
|
|
4966
5299
|
if (modelEl) {
|
|
4967
5300
|
e.stopPropagation();
|
|
4968
5301
|
if (modelEl.classList.contains("sib-model-disabled")) return;
|
|
5302
|
+
|
|
5303
|
+
if (_isOpen) {
|
|
5304
|
+
_closeModelDropdown();
|
|
5305
|
+
return;
|
|
5306
|
+
}
|
|
5307
|
+
|
|
5308
|
+
if (caretEl) {
|
|
5309
|
+
const opened = ModelPicker.toggleSubmodelPanel(modelEl, _sibSubInfo(modelEl), (name, displayName) => {
|
|
5310
|
+
_switchSubModel(modelEl.dataset.sessionId, name, displayName);
|
|
5311
|
+
});
|
|
5312
|
+
if (opened) {
|
|
5313
|
+
_isOpen = true;
|
|
5314
|
+
modelEl.classList.add("is-open");
|
|
5315
|
+
}
|
|
5316
|
+
return;
|
|
5317
|
+
}
|
|
5318
|
+
|
|
4969
5319
|
const dropdown = $("sib-model-dropdown");
|
|
4970
5320
|
if (!dropdown) return;
|
|
4971
5321
|
|
|
4972
|
-
|
|
4973
|
-
dropdown.style.display = "none";
|
|
4974
|
-
_isOpen = false;
|
|
4975
|
-
ModelPicker.closeSubmodelPanel();
|
|
4976
|
-
} else {
|
|
4977
|
-
let subOptions = [];
|
|
4978
|
-
try { subOptions = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
|
|
4979
|
-
const subInfo = {
|
|
4980
|
-
options: Array.isArray(subOptions) ? subOptions : [],
|
|
4981
|
-
current: modelEl.dataset.subModel || null,
|
|
4982
|
-
cardModel: modelEl.dataset.cardModel || null
|
|
4983
|
-
};
|
|
4984
|
-
await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, subInfo);
|
|
5322
|
+
await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, _sibSubInfo(modelEl));
|
|
4985
5323
|
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
5324
|
+
// Calculate position relative to the model element (fixed positioning)
|
|
5325
|
+
const rect = modelEl.getBoundingClientRect();
|
|
5326
|
+
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
|
5327
|
+
dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
|
|
5328
|
+
dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
|
|
4991
5329
|
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
5330
|
+
dropdown.style.display = "block";
|
|
5331
|
+
_isOpen = true;
|
|
5332
|
+
modelEl.classList.add("is-open");
|
|
4995
5333
|
return;
|
|
4996
5334
|
}
|
|
4997
5335
|
|
|
4998
5336
|
// Close dropdown when clicking outside
|
|
4999
5337
|
if (_isOpen && !e.target.closest(".sib-model-dropdown") && !e.target.closest(".sib-submodel-panel")) {
|
|
5000
|
-
|
|
5001
|
-
if (dropdown) dropdown.style.display = "none";
|
|
5002
|
-
_isOpen = false;
|
|
5003
|
-
ModelPicker.closeSubmodelPanel();
|
|
5338
|
+
_closeModelDropdown();
|
|
5004
5339
|
}
|
|
5005
5340
|
});
|
|
5006
5341
|
|
|
5342
|
+
function _sibSubInfo(modelEl) {
|
|
5343
|
+
let options = [];
|
|
5344
|
+
try { options = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
|
|
5345
|
+
return {
|
|
5346
|
+
options: Array.isArray(options) ? options : [],
|
|
5347
|
+
current: modelEl.dataset.subModel || null,
|
|
5348
|
+
cardModel: modelEl.dataset.cardModel || null
|
|
5349
|
+
};
|
|
5350
|
+
}
|
|
5351
|
+
|
|
5007
5352
|
// Populate dropdown with available models
|
|
5008
5353
|
async function _populateModelDropdown(sessionId, currentModelId, subInfo) {
|
|
5009
5354
|
subInfo = subInfo || { options: [], current: null, cardModel: null };
|
|
@@ -5049,12 +5394,7 @@ const Sessions = (() => {
|
|
|
5049
5394
|
// Switch the session's current card. modelId is the stable runtime id,
|
|
5050
5395
|
// modelName is for optimistic display.
|
|
5051
5396
|
async function _switchModel(sessionId, modelId, modelName) {
|
|
5052
|
-
|
|
5053
|
-
if (dropdown) {
|
|
5054
|
-
dropdown.style.display = "none";
|
|
5055
|
-
_isOpen = false;
|
|
5056
|
-
}
|
|
5057
|
-
ModelPicker.closeSubmodelPanel();
|
|
5397
|
+
_closeModelDropdown();
|
|
5058
5398
|
|
|
5059
5399
|
try {
|
|
5060
5400
|
const res = await fetch(`/api/sessions/${sessionId}/model`, {
|
|
@@ -5085,12 +5425,7 @@ const Sessions = (() => {
|
|
|
5085
5425
|
// Pin (or clear) the session's sub-model. Pass modelName=null to clear.
|
|
5086
5426
|
// displayName is what we optimistically show in the status bar.
|
|
5087
5427
|
async function _switchSubModel(sessionId, modelName, displayName) {
|
|
5088
|
-
|
|
5089
|
-
if (dropdown) {
|
|
5090
|
-
dropdown.style.display = "none";
|
|
5091
|
-
_isOpen = false;
|
|
5092
|
-
}
|
|
5093
|
-
ModelPicker.closeSubmodelPanel();
|
|
5428
|
+
_closeModelDropdown();
|
|
5094
5429
|
|
|
5095
5430
|
try {
|
|
5096
5431
|
const res = await fetch(`/api/sessions/${sessionId}/submodel`, {
|
|
@@ -5111,10 +5446,7 @@ const Sessions = (() => {
|
|
|
5111
5446
|
|
|
5112
5447
|
// Navigate to media config.
|
|
5113
5448
|
function _goConfigureMedia() {
|
|
5114
|
-
|
|
5115
|
-
if (dropdown) dropdown.style.display = "none";
|
|
5116
|
-
_isOpen = false;
|
|
5117
|
-
ModelPicker.closeSubmodelPanel();
|
|
5449
|
+
_closeModelDropdown();
|
|
5118
5450
|
if (typeof Router !== "undefined") Router.navigate("settings");
|
|
5119
5451
|
setTimeout(() => {
|
|
5120
5452
|
const sec = document.getElementById("media-section");
|
|
@@ -5163,6 +5495,7 @@ const Sessions = (() => {
|
|
|
5163
5495
|
// When no session exists yet (e.g. the New Session modal), browse the
|
|
5164
5496
|
// real filesystem via /api/dirs instead of the session-scoped files API.
|
|
5165
5497
|
const sessionLess = !sessionId;
|
|
5498
|
+
const mobileLayout = window.matchMedia("(max-width: 768px)");
|
|
5166
5499
|
|
|
5167
5500
|
let currentPath = currentDir;
|
|
5168
5501
|
let selectedPath = currentDir;
|
|
@@ -5243,11 +5576,16 @@ const Sessions = (() => {
|
|
|
5243
5576
|
row.appendChild(name);
|
|
5244
5577
|
node.appendChild(row);
|
|
5245
5578
|
|
|
5246
|
-
//
|
|
5579
|
+
// Mobile uses the conventional file-picker behavior: tap to enter.
|
|
5580
|
+
// Desktop keeps single-click selection and double-click navigation.
|
|
5247
5581
|
let clickTimer = null;
|
|
5248
5582
|
row.addEventListener("click", (e) => {
|
|
5249
5583
|
e.stopPropagation();
|
|
5250
5584
|
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
|
5585
|
+
if (mobileLayout.matches) {
|
|
5586
|
+
navigateTo(entry.absPath, true);
|
|
5587
|
+
return;
|
|
5588
|
+
}
|
|
5251
5589
|
clickTimer = setTimeout(() => {
|
|
5252
5590
|
clickTimer = null;
|
|
5253
5591
|
listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
@@ -5259,6 +5597,7 @@ const Sessions = (() => {
|
|
|
5259
5597
|
// Double-click: enter directory (navigate into it)
|
|
5260
5598
|
row.addEventListener("dblclick", (e) => {
|
|
5261
5599
|
e.stopPropagation();
|
|
5600
|
+
if (mobileLayout.matches) return;
|
|
5262
5601
|
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
|
5263
5602
|
navigateTo(entry.absPath, true);
|
|
5264
5603
|
});
|
|
@@ -5271,12 +5610,36 @@ const Sessions = (() => {
|
|
|
5271
5610
|
|
|
5272
5611
|
// Create modal overlay
|
|
5273
5612
|
const overlay = document.createElement("div");
|
|
5274
|
-
overlay.className = "modal-overlay";
|
|
5613
|
+
overlay.className = "modal-overlay dp-overlay";
|
|
5614
|
+
|
|
5615
|
+
const cancelPicker = () => {
|
|
5616
|
+
overlay.remove();
|
|
5617
|
+
resolve(null);
|
|
5618
|
+
};
|
|
5275
5619
|
|
|
5276
5620
|
// Create modal content — Finder-style layout
|
|
5277
5621
|
const modal = document.createElement("div");
|
|
5278
5622
|
modal.className = "modal-content dp-modal";
|
|
5279
5623
|
|
|
5624
|
+
// Mobile-only header — the desktop picker keeps its Finder-style layout.
|
|
5625
|
+
const mobileHeader = document.createElement("div");
|
|
5626
|
+
mobileHeader.className = "dp-mobile-header";
|
|
5627
|
+
|
|
5628
|
+
const mobileTitle = document.createElement("span");
|
|
5629
|
+
mobileTitle.className = "dp-mobile-title";
|
|
5630
|
+
mobileTitle.textContent = t("sessions.modal.dirpicker.title", "Select Working Directory");
|
|
5631
|
+
|
|
5632
|
+
const mobileCloseButton = document.createElement("button");
|
|
5633
|
+
mobileCloseButton.type = "button";
|
|
5634
|
+
mobileCloseButton.className = "dp-nav-btn dp-mobile-close";
|
|
5635
|
+
mobileCloseButton.title = t("sib.dir.cancel", "Cancel");
|
|
5636
|
+
mobileCloseButton.setAttribute("aria-label", mobileCloseButton.title);
|
|
5637
|
+
mobileCloseButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
|
5638
|
+
mobileCloseButton.addEventListener("click", cancelPicker);
|
|
5639
|
+
|
|
5640
|
+
mobileHeader.appendChild(mobileTitle);
|
|
5641
|
+
mobileHeader.appendChild(mobileCloseButton);
|
|
5642
|
+
|
|
5280
5643
|
// Left sidebar — quick-access places (favorites + locations)
|
|
5281
5644
|
const sidebar = document.createElement("div");
|
|
5282
5645
|
sidebar.className = "dp-sidebar";
|
|
@@ -5368,7 +5731,7 @@ const Sessions = (() => {
|
|
|
5368
5731
|
const cancelButton = document.createElement("button");
|
|
5369
5732
|
cancelButton.className = "btn btn-secondary";
|
|
5370
5733
|
cancelButton.textContent = t("sib.dir.cancel", "取消");
|
|
5371
|
-
cancelButton.onclick =
|
|
5734
|
+
cancelButton.onclick = cancelPicker;
|
|
5372
5735
|
|
|
5373
5736
|
const confirmButton = document.createElement("button");
|
|
5374
5737
|
confirmButton.className = "btn btn-primary";
|
|
@@ -5381,6 +5744,7 @@ const Sessions = (() => {
|
|
|
5381
5744
|
footer.appendChild(footerActions);
|
|
5382
5745
|
main.appendChild(footer);
|
|
5383
5746
|
|
|
5747
|
+
modal.appendChild(mobileHeader);
|
|
5384
5748
|
modal.appendChild(sidebar);
|
|
5385
5749
|
modal.appendChild(main);
|
|
5386
5750
|
|
|
@@ -5416,14 +5780,29 @@ const Sessions = (() => {
|
|
|
5416
5780
|
};
|
|
5417
5781
|
|
|
5418
5782
|
addSection("sib.dir.favorites", "Quick access", quickAccess);
|
|
5419
|
-
|
|
5783
|
+
// Favorites pointing at a quick-access place would render the same folder twice
|
|
5784
|
+
const placePaths = new Set(sidebarPlaces.map(place => comparablePath(place.path)));
|
|
5785
|
+
const userFavorites = directoryFavorites.filter(path => !placePaths.has(comparablePath(path)));
|
|
5786
|
+
if (userFavorites.length) {
|
|
5420
5787
|
const label = document.createElement("div");
|
|
5421
5788
|
label.className = "dp-sidebar-label";
|
|
5422
5789
|
label.textContent = t("sib.dir.userFavorites", "My favorites");
|
|
5423
5790
|
sidebar.appendChild(label);
|
|
5424
|
-
|
|
5791
|
+
userFavorites.forEach(path => sidebar.appendChild(buildFavoriteSidebarItem(path)));
|
|
5425
5792
|
}
|
|
5426
5793
|
addSection("sib.dir.drives", "Drives", drives);
|
|
5794
|
+
refreshSidebarActive();
|
|
5795
|
+
}
|
|
5796
|
+
|
|
5797
|
+
// Highlight the sidebar entry that matches the directory being browsed
|
|
5798
|
+
function refreshSidebarActive() {
|
|
5799
|
+
const active = currentPathLoaded ? comparablePath(currentPath) : "";
|
|
5800
|
+
sidebar.querySelectorAll("[data-path]").forEach(el => {
|
|
5801
|
+
const isActive = el.dataset.path === active;
|
|
5802
|
+
el.classList.toggle("active", isActive);
|
|
5803
|
+
if (isActive) el.setAttribute("aria-current", "true");
|
|
5804
|
+
else el.removeAttribute("aria-current");
|
|
5805
|
+
});
|
|
5427
5806
|
}
|
|
5428
5807
|
|
|
5429
5808
|
function buildSidebarItem(place) {
|
|
@@ -5431,6 +5810,7 @@ const Sessions = (() => {
|
|
|
5431
5810
|
item.type = "button";
|
|
5432
5811
|
item.className = "dp-sidebar-item";
|
|
5433
5812
|
item.title = place.path;
|
|
5813
|
+
item.dataset.path = comparablePath(place.path);
|
|
5434
5814
|
|
|
5435
5815
|
const icon = document.createElement("span");
|
|
5436
5816
|
icon.className = "dp-sidebar-icon";
|
|
@@ -5460,6 +5840,7 @@ const Sessions = (() => {
|
|
|
5460
5840
|
const openButton = document.createElement("button");
|
|
5461
5841
|
openButton.type = "button";
|
|
5462
5842
|
openButton.className = "dp-sidebar-item dp-sidebar-favorite-open";
|
|
5843
|
+
openButton.dataset.path = comparablePath(path);
|
|
5463
5844
|
|
|
5464
5845
|
const icon = document.createElement("span");
|
|
5465
5846
|
icon.className = "dp-sidebar-icon";
|
|
@@ -5743,6 +6124,7 @@ const Sessions = (() => {
|
|
|
5743
6124
|
currentPathLoaded = true;
|
|
5744
6125
|
refreshBreadcrumb(effectivePath);
|
|
5745
6126
|
refreshFavoriteButton();
|
|
6127
|
+
refreshSidebarActive();
|
|
5746
6128
|
listContainer.innerHTML = "";
|
|
5747
6129
|
if (dirs.length === 0) {
|
|
5748
6130
|
listContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
|
|
@@ -5948,7 +6330,9 @@ const Sessions = (() => {
|
|
|
5948
6330
|
const sessionId = dirEl.dataset.sessionId;
|
|
5949
6331
|
const currentDir = dirEl.dataset.workingDir || dirEl.textContent;
|
|
5950
6332
|
|
|
6333
|
+
dirEl.classList.add("is-open");
|
|
5951
6334
|
const newDir = await showDirectoryPicker(currentDir, sessionId);
|
|
6335
|
+
dirEl.classList.remove("is-open");
|
|
5952
6336
|
if (newDir && newDir !== currentDir) {
|
|
5953
6337
|
_changeWorkingDirectory(sessionId, newDir);
|
|
5954
6338
|
}
|
|
@@ -6108,8 +6492,9 @@ const Sessions = (() => {
|
|
|
6108
6492
|
|
|
6109
6493
|
// Update UI optimistically (will be confirmed by session_update broadcast)
|
|
6110
6494
|
const sibDir = $("sib-dir");
|
|
6111
|
-
|
|
6112
|
-
|
|
6495
|
+
const sibDirText = $("sib-dir-text");
|
|
6496
|
+
if (sibDir && sibDirText) {
|
|
6497
|
+
sibDirText.textContent = newDir;
|
|
6113
6498
|
sibDir.title = `${newDir} (${I18n.t("sib.dir.tooltip")})`;
|
|
6114
6499
|
sibDir.dataset.workingDir = newDir;
|
|
6115
6500
|
}
|
|
@@ -6143,6 +6528,7 @@ const Sessions = (() => {
|
|
|
6143
6528
|
if (_isOpen) {
|
|
6144
6529
|
dropdown.style.display = "none";
|
|
6145
6530
|
_isOpen = false;
|
|
6531
|
+
el.classList.remove("is-open");
|
|
6146
6532
|
return;
|
|
6147
6533
|
}
|
|
6148
6534
|
|
|
@@ -6154,12 +6540,15 @@ const Sessions = (() => {
|
|
|
6154
6540
|
dropdown.style.transform = "translate(-50%, -100%)";
|
|
6155
6541
|
dropdown.style.display = "block";
|
|
6156
6542
|
_isOpen = true;
|
|
6543
|
+
el.classList.add("is-open");
|
|
6157
6544
|
return;
|
|
6158
6545
|
}
|
|
6159
6546
|
|
|
6160
6547
|
if (_isOpen && !e.target.closest("#sib-reasoning-dropdown")) {
|
|
6161
6548
|
const dropdown = $("sib-reasoning-dropdown");
|
|
6162
6549
|
if (dropdown) dropdown.style.display = "none";
|
|
6550
|
+
const el = $("sib-reasoning");
|
|
6551
|
+
if (el) el.classList.remove("is-open");
|
|
6163
6552
|
_isOpen = false;
|
|
6164
6553
|
}
|
|
6165
6554
|
});
|
|
@@ -6200,6 +6589,8 @@ const Sessions = (() => {
|
|
|
6200
6589
|
dropdown.style.display = "none";
|
|
6201
6590
|
_isOpen = false;
|
|
6202
6591
|
}
|
|
6592
|
+
const sibReasoningEl = $("sib-reasoning");
|
|
6593
|
+
if (sibReasoningEl) sibReasoningEl.classList.remove("is-open");
|
|
6203
6594
|
|
|
6204
6595
|
try {
|
|
6205
6596
|
const res = await fetch(`/api/sessions/${sessionId}/reasoning_effort`, {
|
|
@@ -6210,11 +6601,10 @@ const Sessions = (() => {
|
|
|
6210
6601
|
const data = await res.json();
|
|
6211
6602
|
if (!res.ok) throw new Error(data.error || "Unknown error");
|
|
6212
6603
|
|
|
6213
|
-
const
|
|
6214
|
-
if (
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
}
|
|
6604
|
+
const textEl = $("sib-reasoning-text");
|
|
6605
|
+
if (textEl) textEl.textContent = I18n.t(`sib.reasoning.${level}`);
|
|
6606
|
+
const sibReasoningEl = $("sib-reasoning");
|
|
6607
|
+
if (sibReasoningEl) sibReasoningEl.dataset.reasoningEffort = level;
|
|
6218
6608
|
} catch (e) {
|
|
6219
6609
|
console.error("Failed to switch reasoning effort:", e);
|
|
6220
6610
|
alert("Failed to switch reasoning effort: " + e.message);
|