openclacky 1.5.11 → 1.5.13
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 +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- metadata +12 -1
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -26,6 +26,7 @@ const Sessions = (() => {
|
|
|
26
26
|
let _loadingMore = false;
|
|
27
27
|
// Search state
|
|
28
28
|
const _filter = { q: "", date: "", type: "" }; // committed filter (applied to the search overlay)
|
|
29
|
+
let _searchTypeSelect = null;
|
|
29
30
|
let _searchOpen = false; // is the command-palette search overlay visible?
|
|
30
31
|
// Search results live in their own list, rendered into the overlay's
|
|
31
32
|
// #session-search-results — they NEVER replace the sidebar session list.
|
|
@@ -428,12 +429,41 @@ const Sessions = (() => {
|
|
|
428
429
|
|
|
429
430
|
const _EMPTY_HINT_ID = "chat-empty-hint";
|
|
430
431
|
|
|
432
|
+
let _agentName = "";
|
|
433
|
+
let _agentNamePromise = null;
|
|
434
|
+
|
|
435
|
+
function _loadAgentName() {
|
|
436
|
+
if (_agentName) return Promise.resolve(_agentName);
|
|
437
|
+
if (_agentNamePromise) return _agentNamePromise;
|
|
438
|
+
_agentNamePromise = fetch("/api/profile")
|
|
439
|
+
.then(r => (r.ok ? r.json() : {}))
|
|
440
|
+
.then(d => {
|
|
441
|
+
_agentName = (d.soul && d.soul.name) || "";
|
|
442
|
+
return _agentName;
|
|
443
|
+
})
|
|
444
|
+
.catch(() => {
|
|
445
|
+
_agentName = "";
|
|
446
|
+
return "";
|
|
447
|
+
})
|
|
448
|
+
.finally(() => { _agentNamePromise = null; });
|
|
449
|
+
return _agentNamePromise;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function _applyAgentName(name) {
|
|
453
|
+
if (!name) return;
|
|
454
|
+
const el = document.querySelector("#" + _EMPTY_HINT_ID + " .chat-empty-title");
|
|
455
|
+
if (el) el.textContent = I18n.t("chat.empty.title.named", { name });
|
|
456
|
+
}
|
|
457
|
+
|
|
431
458
|
function _buildEmptyHintHtml() {
|
|
432
|
-
const title =
|
|
459
|
+
const title = _agentName
|
|
460
|
+
? I18n.t("chat.empty.title.named", { name: _agentName })
|
|
461
|
+
: I18n.t("chat.empty.title");
|
|
433
462
|
const subtitle = I18n.t("chat.empty.subtitle");
|
|
434
463
|
const tip1 = I18n.t("chat.empty.tip1");
|
|
435
464
|
const tip2 = I18n.t("chat.empty.tip2");
|
|
436
465
|
const tip3 = I18n.t("chat.empty.tip3");
|
|
466
|
+
_loadAgentName().then(_applyAgentName);
|
|
437
467
|
return `
|
|
438
468
|
<div class="chat-empty-icon" aria-hidden="true">
|
|
439
469
|
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -931,7 +961,7 @@ const Sessions = (() => {
|
|
|
931
961
|
`${answered ? " disabled" : ""}>${escapeHtml(opt)}${rec}</button>`;
|
|
932
962
|
});
|
|
933
963
|
html += `</div>`;
|
|
934
|
-
if (q.allow_free_text
|
|
964
|
+
if (q.allow_free_text) {
|
|
935
965
|
html += `<input type="text" class="feedback-free-text" data-question-index="${qi}"` +
|
|
936
966
|
` placeholder="${escapeHtml(I18n.t("chat.feedback_other"))}"${answered ? " disabled" : ""}>`;
|
|
937
967
|
}
|
|
@@ -984,7 +1014,7 @@ const Sessions = (() => {
|
|
|
984
1014
|
card.classList.add("feedback-card--submitted");
|
|
985
1015
|
|
|
986
1016
|
const input = $("user-input");
|
|
987
|
-
if (input) input
|
|
1017
|
+
if (input) Composer.setText(input, lines.join("\n"));
|
|
988
1018
|
_sendMessage();
|
|
989
1019
|
return true;
|
|
990
1020
|
}
|
|
@@ -1016,6 +1046,16 @@ const Sessions = (() => {
|
|
|
1016
1046
|
if (free) free.focus();
|
|
1017
1047
|
};
|
|
1018
1048
|
|
|
1049
|
+
// Enter and the submit button must agree: a stepped card never sends a
|
|
1050
|
+
// partial answer, it jumps back to the first unanswered question instead.
|
|
1051
|
+
const trySubmit = () => {
|
|
1052
|
+
if (stepped) {
|
|
1053
|
+
const blank = list.findIndex((_, i) => _feedbackAnswerFor(card, list, i).length === 0);
|
|
1054
|
+
if (blank !== -1) { showStep(blank); return; }
|
|
1055
|
+
}
|
|
1056
|
+
_submitFeedbackCard(card, list);
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1019
1059
|
card.querySelectorAll(".feedback-option-btn").forEach(btn => {
|
|
1020
1060
|
btn.onclick = () => {
|
|
1021
1061
|
const qi = Number(btn.dataset.questionIndex);
|
|
@@ -1024,7 +1064,7 @@ const Sessions = (() => {
|
|
|
1024
1064
|
card.querySelectorAll(".feedback-option-btn").forEach(b => { b.disabled = true; });
|
|
1025
1065
|
card.classList.add("feedback-card--submitted");
|
|
1026
1066
|
const input = $("user-input");
|
|
1027
|
-
if (input) input
|
|
1067
|
+
if (input) Composer.setText(input, list[qi].options[Number(btn.dataset.optionIndex)]);
|
|
1028
1068
|
_sendMessage();
|
|
1029
1069
|
return;
|
|
1030
1070
|
}
|
|
@@ -1054,11 +1094,13 @@ const Sessions = (() => {
|
|
|
1054
1094
|
dot.onclick = () => showStep(Number(dot.dataset.step));
|
|
1055
1095
|
});
|
|
1056
1096
|
|
|
1097
|
+
// IME.track so confirming a composition candidate is not read as Enter.
|
|
1057
1098
|
card.querySelectorAll(".feedback-free-text").forEach(input => {
|
|
1099
|
+
const ime = IME.track(input);
|
|
1058
1100
|
input.onkeydown = (e) => {
|
|
1059
|
-
if (e.key !== "Enter") return;
|
|
1101
|
+
if (e.key !== "Enter" || ime.isComposing(e)) return;
|
|
1060
1102
|
e.preventDefault();
|
|
1061
|
-
if (step === list.length - 1)
|
|
1103
|
+
if (step === list.length - 1) trySubmit();
|
|
1062
1104
|
else showStep(step + 1);
|
|
1063
1105
|
};
|
|
1064
1106
|
});
|
|
@@ -1067,23 +1109,26 @@ const Sessions = (() => {
|
|
|
1067
1109
|
}
|
|
1068
1110
|
|
|
1069
1111
|
const submit = card.querySelector(".feedback-submit-btn");
|
|
1070
|
-
if (submit)
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1112
|
+
if (submit) submit.onclick = trySubmit;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Render a mention chip (file/directory/session) as a bubble badge.
|
|
1116
|
+
function _renderMentionBadge(chip) {
|
|
1117
|
+
const path = chip.type === "session"
|
|
1118
|
+
? MentionAC.icons.message
|
|
1119
|
+
: (chip.type === "directory" ? MentionAC.icons.folder : MentionAC.icons.file);
|
|
1120
|
+
return `<span class="msg-mention-badge">` +
|
|
1121
|
+
`<span class="msg-mention-badge-icon">${MentionAC.svgHtml(path, 13)}</span>` +
|
|
1122
|
+
`<span class="msg-mention-badge-name">${escapeHtml(chip.name)}</span>` +
|
|
1123
|
+
`</span>`;
|
|
1080
1124
|
}
|
|
1081
1125
|
|
|
1082
1126
|
function _sendMessage() {
|
|
1083
1127
|
if (_sending) return;
|
|
1084
1128
|
const input = $("user-input");
|
|
1085
|
-
const content = input.
|
|
1086
|
-
|
|
1129
|
+
const content = Composer.text(input).trim();
|
|
1130
|
+
const mentionChips = Composer.chips(input);
|
|
1131
|
+
if (!content && mentionChips.length === 0 && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
|
|
1087
1132
|
if (!Sessions.activeId) return;
|
|
1088
1133
|
|
|
1089
1134
|
if (!WS.ready) {
|
|
@@ -1104,6 +1149,10 @@ const Sessions = (() => {
|
|
|
1104
1149
|
_sending = true;
|
|
1105
1150
|
|
|
1106
1151
|
let bubbleHtml = content ? SkillAC.renderUserMessageHtml(content) : "";
|
|
1152
|
+
if (mentionChips.length > 0) {
|
|
1153
|
+
const chipBadges = mentionChips.map(_renderMentionBadge).join(" ");
|
|
1154
|
+
bubbleHtml = chipBadges + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
1155
|
+
}
|
|
1107
1156
|
if (_pendingImages.length > 0) {
|
|
1108
1157
|
const thumbs = _pendingImages
|
|
1109
1158
|
.map(img => `<img src="${img.dataUrl}" alt="${escapeHtml(img.name)}" class="msg-image-thumb">`)
|
|
@@ -1142,14 +1191,23 @@ const Sessions = (() => {
|
|
|
1142
1191
|
name: f.name,
|
|
1143
1192
|
path: f.path,
|
|
1144
1193
|
mime_type: f.mime_type
|
|
1145
|
-
}))
|
|
1194
|
+
})),
|
|
1195
|
+
...mentionChips
|
|
1196
|
+
.filter(c => c.type === "file" || c.type === "directory")
|
|
1197
|
+
.map(c => ({ name: c.name, path: c.path, reference: true }))
|
|
1146
1198
|
];
|
|
1199
|
+
const references = mentionChips.map(c => {
|
|
1200
|
+
const ref = { type: c.type, name: c.name };
|
|
1201
|
+
if (c.type === "session") ref.session_id = c.session_id;
|
|
1202
|
+
else ref.path = c.path;
|
|
1203
|
+
return ref;
|
|
1204
|
+
});
|
|
1147
1205
|
_pendingImages.length = 0;
|
|
1148
1206
|
_pendingFiles.length = 0;
|
|
1149
1207
|
_imageSeq = 0;
|
|
1150
1208
|
_renderAttachmentPreviews();
|
|
1151
1209
|
|
|
1152
|
-
WS.send({ type: "message", session_id: Sessions.activeId, content, files, lang: I18n.lang() });
|
|
1210
|
+
WS.send({ type: "message", session_id: Sessions.activeId, content, files, references, lang: I18n.lang() });
|
|
1153
1211
|
|
|
1154
1212
|
// Disable any pending feedback cards — user has replied (either by clicking
|
|
1155
1213
|
// an option button or by typing directly). The backend has already consumed
|
|
@@ -1160,8 +1218,7 @@ const Sessions = (() => {
|
|
|
1160
1218
|
card.classList.add("feedback-card--submitted");
|
|
1161
1219
|
});
|
|
1162
1220
|
|
|
1163
|
-
input
|
|
1164
|
-
input.style.height = "auto";
|
|
1221
|
+
Composer.clear(input);
|
|
1165
1222
|
_drafts.delete(Sessions.activeId);
|
|
1166
1223
|
_attachmentDrafts.delete(Sessions.activeId);
|
|
1167
1224
|
setTimeout(() => { _sending = false; }, 300);
|
|
@@ -1171,6 +1228,22 @@ const Sessions = (() => {
|
|
|
1171
1228
|
// Wires up the send button, attach button, file picker, drag-drop, paste,
|
|
1172
1229
|
// and IME composition tracking. All targets are static in index.html.
|
|
1173
1230
|
function _initComposer() {
|
|
1231
|
+
// @ mention autocomplete — registered before Composer/SkillAC so its
|
|
1232
|
+
// keydown stopImmediatePropagation wins over chip deletion / Enter-send.
|
|
1233
|
+
if (typeof MentionAC !== "undefined") {
|
|
1234
|
+
MentionAC.attach({
|
|
1235
|
+
input: "user-input",
|
|
1236
|
+
menu: "mention-autocomplete",
|
|
1237
|
+
getSessionId: () => Sessions.activeId,
|
|
1238
|
+
getWorkingDir: () => {
|
|
1239
|
+
const s = Sessions.find(Sessions.activeId);
|
|
1240
|
+
return s ? (s.working_dir || "") : "";
|
|
1241
|
+
},
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
// contenteditable composer: chip deletion, plain-text paste, Shift+Enter.
|
|
1245
|
+
Composer.init($("user-input"));
|
|
1246
|
+
|
|
1174
1247
|
// Send & attach buttons
|
|
1175
1248
|
document.getElementById("btn-send").addEventListener("click", _sendMessage);
|
|
1176
1249
|
document.getElementById("btn-attach")
|
|
@@ -1215,7 +1288,7 @@ const Sessions = (() => {
|
|
|
1215
1288
|
//
|
|
1216
1289
|
// All search-related interactions. The search UI lives in the sessions
|
|
1217
1290
|
// sidebar: a magnifier toggle button, the search panel (q input, type
|
|
1218
|
-
//
|
|
1291
|
+
// dropdown, date <input>), inline ✕ clear, and "clear all filters" button.
|
|
1219
1292
|
//
|
|
1220
1293
|
// Everything uses event delegation because some elements (e.g. the clear
|
|
1221
1294
|
// buttons) are re-rendered as filter state changes.
|
|
@@ -1229,6 +1302,17 @@ const Sessions = (() => {
|
|
|
1229
1302
|
const cmdbarKbd = document.querySelector("#header-cmdbar .cmdbar-kbd");
|
|
1230
1303
|
if (cmdbarKbd) cmdbarKbd.textContent = _sessionSearchShortcutLabel();
|
|
1231
1304
|
|
|
1305
|
+
const typeWrap = document.getElementById("session-search-type");
|
|
1306
|
+
if (typeWrap && window.CustomSelect) {
|
|
1307
|
+
_searchTypeSelect = window.CustomSelect.init({
|
|
1308
|
+
trigger: typeWrap.querySelector(".custom-select-trigger"),
|
|
1309
|
+
dropdown: typeWrap.querySelector(".custom-select-dropdown"),
|
|
1310
|
+
anchor: typeWrap,
|
|
1311
|
+
portal: true,
|
|
1312
|
+
onSelect: () => Sessions.commitSearch()
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1232
1316
|
// Open the palette: top cmdbar button (or the keyboard shortcut, bound below).
|
|
1233
1317
|
document.addEventListener("click", (e) => {
|
|
1234
1318
|
if (e.target && e.target.closest("#header-cmdbar")) {
|
|
@@ -1281,9 +1365,8 @@ const Sessions = (() => {
|
|
|
1281
1365
|
// "Clear all filters" button — resets type + date and re-fetches once
|
|
1282
1366
|
document.addEventListener("click", (e) => {
|
|
1283
1367
|
if (e.target && e.target.id === "btn-search-clear-all") {
|
|
1284
|
-
const typeEl = document.getElementById("session-search-type");
|
|
1285
1368
|
const dateEl = document.getElementById("session-search-date");
|
|
1286
|
-
if (
|
|
1369
|
+
if (_searchTypeSelect) _searchTypeSelect.setValue("");
|
|
1287
1370
|
if (dateEl) DatePicker.clear(dateEl);
|
|
1288
1371
|
Sessions.commitSearch();
|
|
1289
1372
|
}
|
|
@@ -1300,12 +1383,7 @@ const Sessions = (() => {
|
|
|
1300
1383
|
}
|
|
1301
1384
|
});
|
|
1302
1385
|
|
|
1303
|
-
//
|
|
1304
|
-
document.addEventListener("change", (e) => {
|
|
1305
|
-
if (e.target && e.target.id === "session-search-type") {
|
|
1306
|
-
Sessions.commitSearch();
|
|
1307
|
-
}
|
|
1308
|
-
});
|
|
1386
|
+
// Date picker — commit immediately on change
|
|
1309
1387
|
document.addEventListener("datepicker:change", (e) => {
|
|
1310
1388
|
if (e.target && e.target.id === "session-search-date") {
|
|
1311
1389
|
Sessions.commitSearch();
|
|
@@ -1639,20 +1717,11 @@ const Sessions = (() => {
|
|
|
1639
1717
|
if (n > 1) group.classList.remove("expanded");
|
|
1640
1718
|
}
|
|
1641
1719
|
|
|
1642
|
-
// Anchor for a replayed subagent card:
|
|
1643
|
-
//
|
|
1720
|
+
// Anchor for a replayed subagent card: the outer container, mirroring the
|
|
1721
|
+
// live _phaseHost() which now sits phase cards at the top level rather than
|
|
1722
|
+
// nested inside the spawning tool item.
|
|
1644
1723
|
function _historyPhaseHost(historyCtx, container) {
|
|
1645
|
-
|
|
1646
|
-
if (!item) return container;
|
|
1647
|
-
|
|
1648
|
-
let nest = item.querySelector(":scope > .tool-item-phases");
|
|
1649
|
-
if (!nest) {
|
|
1650
|
-
nest = document.createElement("div");
|
|
1651
|
-
nest.className = "tool-item-phases";
|
|
1652
|
-
const stdout = item.querySelector(":scope > .tool-item-stdout");
|
|
1653
|
-
item.insertBefore(nest, stdout || null);
|
|
1654
|
-
}
|
|
1655
|
-
return nest;
|
|
1724
|
+
return container;
|
|
1656
1725
|
}
|
|
1657
1726
|
|
|
1658
1727
|
// Render a single history event into a target container.
|
|
@@ -1688,6 +1757,10 @@ const Sessions = (() => {
|
|
|
1688
1757
|
el.className = "msg msg-user";
|
|
1689
1758
|
// Render image thumbnails and PDF badges (if any) followed by the text content
|
|
1690
1759
|
let bubbleHtml = "";
|
|
1760
|
+
if (Array.isArray(ev.references) && ev.references.length > 0) {
|
|
1761
|
+
bubbleHtml += ev.references.map(_renderMentionBadge).join(" ");
|
|
1762
|
+
if (ev.content || (Array.isArray(ev.images) && ev.images.length > 0)) bubbleHtml += "<br>";
|
|
1763
|
+
}
|
|
1691
1764
|
if (Array.isArray(ev.images) && ev.images.length > 0) {
|
|
1692
1765
|
bubbleHtml += ev.images.map(src => {
|
|
1693
1766
|
if (src && src.startsWith("pdf:")) {
|
|
@@ -2143,6 +2216,7 @@ const Sessions = (() => {
|
|
|
2143
2216
|
editBtn.innerHTML = EDIT_SVG;
|
|
2144
2217
|
editBtn.addEventListener("click", async (e) => {
|
|
2145
2218
|
e.stopPropagation();
|
|
2219
|
+
if (_activeSessionIsRunning()) return;
|
|
2146
2220
|
const ok = await Modal.confirmOnce(
|
|
2147
2221
|
"clacky-edit-warn-dismissed",
|
|
2148
2222
|
I18n.t("chat.edit.warn"),
|
|
@@ -2159,11 +2233,20 @@ const Sessions = (() => {
|
|
|
2159
2233
|
wrap.appendChild(bar);
|
|
2160
2234
|
}
|
|
2161
2235
|
|
|
2162
|
-
function
|
|
2236
|
+
function _activeSessionIsRunning() {
|
|
2237
|
+
return Sessions.find(Sessions.activeId)?.status === "running";
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function _refreshEditButtons(container, status) {
|
|
2241
|
+
const running = (status || Sessions.find(Sessions.activeId)?.status) === "running";
|
|
2163
2242
|
const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
|
|
2164
2243
|
btns.forEach((btn, i) => {
|
|
2165
|
-
btn.style.display = i === btns.length - 1 ? "" : "none";
|
|
2244
|
+
btn.style.display = !running && i === btns.length - 1 ? "" : "none";
|
|
2166
2245
|
});
|
|
2246
|
+
|
|
2247
|
+
if (running) {
|
|
2248
|
+
container.querySelectorAll(".msg-user.editing").forEach(el => _exitEditMode(el));
|
|
2249
|
+
}
|
|
2167
2250
|
}
|
|
2168
2251
|
|
|
2169
2252
|
function _extractUserBubbleText(el) {
|
|
@@ -2173,7 +2256,7 @@ const Sessions = (() => {
|
|
|
2173
2256
|
}
|
|
2174
2257
|
|
|
2175
2258
|
function _enterEditMode(el) {
|
|
2176
|
-
if (el.classList.contains("editing")) return;
|
|
2259
|
+
if (_activeSessionIsRunning() || el.classList.contains("editing")) return;
|
|
2177
2260
|
el.classList.add("editing");
|
|
2178
2261
|
|
|
2179
2262
|
const originalHtml = el.dataset.originalHtml || "";
|
|
@@ -2249,6 +2332,10 @@ const Sessions = (() => {
|
|
|
2249
2332
|
function _submitEdit(el, newContent) {
|
|
2250
2333
|
if (!newContent) return;
|
|
2251
2334
|
if (!Sessions.activeId) return;
|
|
2335
|
+
if (_activeSessionIsRunning()) {
|
|
2336
|
+
_exitEditMode(el);
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2252
2339
|
|
|
2253
2340
|
const createdAt = el.dataset.createdAt || null;
|
|
2254
2341
|
|
|
@@ -2529,8 +2616,16 @@ const Sessions = (() => {
|
|
|
2529
2616
|
// of the raw glyph.
|
|
2530
2617
|
const CRON_NAME_PREFIXES = ["\u23F0 ", "\u25B6 "];
|
|
2531
2618
|
const cronPrefix = CRON_NAME_PREFIXES.find(p => s.name && s.name.startsWith(p)) || "";
|
|
2532
|
-
const
|
|
2619
|
+
const storedName = (cronPrefix ? s.name.slice(cronPrefix.length) : s.name)
|
|
2533
2620
|
|| _relativeTime(s.created_at);
|
|
2621
|
+
// Built-in setup sessions ship with fixed English names; localise their
|
|
2622
|
+
// titles so they follow the UI language (existing sessions included).
|
|
2623
|
+
const BUILTIN_SETUP_NAMES = {
|
|
2624
|
+
"Onboard": "sessions.setup.name",
|
|
2625
|
+
"🌐 Browser Setup": "sessions.setup.browser.name",
|
|
2626
|
+
};
|
|
2627
|
+
const nameKey = s.source === "setup" ? BUILTIN_SETUP_NAMES[storedName] : null;
|
|
2628
|
+
const displayName = nameKey ? I18n.t(nameKey) : storedName;
|
|
2534
2629
|
const cronIcon = cronPrefix
|
|
2535
2630
|
? `<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>`
|
|
2536
2631
|
: "";
|
|
@@ -2958,11 +3053,12 @@ const Sessions = (() => {
|
|
|
2958
3053
|
Sessions.renderList();
|
|
2959
3054
|
|
|
2960
3055
|
try {
|
|
2961
|
-
// Cursor: oldest activity time of non-pinned, non-grouped
|
|
2962
|
-
//
|
|
2963
|
-
//
|
|
3056
|
+
// Cursor: oldest activity time of non-pinned, non-grouped, non-project
|
|
3057
|
+
// sessions. Project sessions sit in _sessions but render in their own
|
|
3058
|
+
// section; counting them here would drag the cursor back to a stale
|
|
3059
|
+
// timestamp and skip a whole page of regular sessions.
|
|
2964
3060
|
const oldest = _sessions.reduce((min, s) => {
|
|
2965
|
-
if (s.pinned || _groupOf(s)) return min;
|
|
3061
|
+
if (s.pinned || s.project_id || _groupOf(s)) return min;
|
|
2966
3062
|
const t = s.updated_at || s.created_at;
|
|
2967
3063
|
if (!t) return min;
|
|
2968
3064
|
return (!min || t < min) ? t : min;
|
|
@@ -3048,10 +3144,9 @@ const Sessions = (() => {
|
|
|
3048
3144
|
* search overlay. Never touches the sidebar session list (_sessions). */
|
|
3049
3145
|
async commitSearch() {
|
|
3050
3146
|
const qEl = document.getElementById("session-search-q");
|
|
3051
|
-
const typeEl = document.getElementById("session-search-type");
|
|
3052
3147
|
const dateEl = document.getElementById("session-search-date");
|
|
3053
3148
|
if (qEl) _filter.q = qEl.value.trim();
|
|
3054
|
-
if (
|
|
3149
|
+
if (_searchTypeSelect) _filter.type = _searchTypeSelect.value;
|
|
3055
3150
|
if (dateEl) _filter.date = dateEl.dataset.value || "";
|
|
3056
3151
|
|
|
3057
3152
|
const token = ++_searchToken;
|
|
@@ -3127,6 +3222,7 @@ const Sessions = (() => {
|
|
|
3127
3222
|
const el = document.getElementById(ids[key]);
|
|
3128
3223
|
if (el) {
|
|
3129
3224
|
if (key === "date") DatePicker.clear(el);
|
|
3225
|
+
else if (key === "type" && _searchTypeSelect) _searchTypeSelect.setValue("");
|
|
3130
3226
|
else el.value = "";
|
|
3131
3227
|
}
|
|
3132
3228
|
await Sessions.commitSearch();
|
|
@@ -3213,10 +3309,9 @@ const Sessions = (() => {
|
|
|
3213
3309
|
// Reset inputs + filter state so the next open starts clean.
|
|
3214
3310
|
const qEl = document.getElementById("session-search-q");
|
|
3215
3311
|
const dEl = document.getElementById("session-search-date");
|
|
3216
|
-
const tEl = document.getElementById("session-search-type");
|
|
3217
3312
|
if (qEl) qEl.value = "";
|
|
3218
3313
|
if (dEl) DatePicker.clear(dEl);
|
|
3219
|
-
if (
|
|
3314
|
+
if (_searchTypeSelect) _searchTypeSelect.setValue("");
|
|
3220
3315
|
const qClear = document.getElementById("btn-search-q-clear");
|
|
3221
3316
|
if (qClear) qClear.hidden = true;
|
|
3222
3317
|
_filter.q = _filter.date = _filter.type = "";
|
|
@@ -3330,9 +3425,7 @@ const Sessions = (() => {
|
|
|
3330
3425
|
}
|
|
3331
3426
|
const input = $("user-input");
|
|
3332
3427
|
if (input) {
|
|
3333
|
-
input
|
|
3334
|
-
input.style.height = "auto";
|
|
3335
|
-
if (input.value) input.style.height = Math.min(input.scrollHeight, 200) + "px";
|
|
3428
|
+
Composer.setText(input, _drafts.get(id) || "");
|
|
3336
3429
|
}
|
|
3337
3430
|
const draft = _attachmentDrafts.get(id);
|
|
3338
3431
|
if (draft) {
|
|
@@ -3364,7 +3457,7 @@ const Sessions = (() => {
|
|
|
3364
3457
|
_cacheActiveMessages();
|
|
3365
3458
|
if (_activeId) {
|
|
3366
3459
|
const input = $("user-input");
|
|
3367
|
-
if (input) _drafts.set(_activeId, input
|
|
3460
|
+
if (input) _drafts.set(_activeId, Composer.text(input));
|
|
3368
3461
|
_attachmentDrafts.set(_activeId, { images: _pendingImages.splice(0), files: _pendingFiles.splice(0) });
|
|
3369
3462
|
_renderAttachmentPreviews();
|
|
3370
3463
|
Sessions._detachProgressUI(_activeId);
|
|
@@ -3427,7 +3520,11 @@ const Sessions = (() => {
|
|
|
3427
3520
|
return !t || t < latest;
|
|
3428
3521
|
};
|
|
3429
3522
|
const renderGroup = source => {
|
|
3430
|
-
|
|
3523
|
+
// Scan _extraSessions too: a session created by the backend (e.g. a
|
|
3524
|
+
// scheduler run) only reaches the client as a session_update, which
|
|
3525
|
+
// patch() stores there — it would otherwise never light the dot.
|
|
3526
|
+
const inGroup = s => s.source === source && !s.project_id;
|
|
3527
|
+
const rows = _sessions.filter(inGroup).concat(_extraSessions.filter(inGroup));
|
|
3431
3528
|
const dotStatus = ["running", "awaiting_feedback"].find(st => rows.some(s => s.status === st))
|
|
3432
3529
|
|| (rows.some(s => _recentlyDone.has(s.id)) ? "done" : null);
|
|
3433
3530
|
_renderGroupItem(list, source, _groups.get(source).count, dotStatus);
|
|
@@ -3562,7 +3659,7 @@ const Sessions = (() => {
|
|
|
3562
3659
|
const iconRename = `<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"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4z"/></svg>`;
|
|
3563
3660
|
const iconTrash = `<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"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`;
|
|
3564
3661
|
const iconMoveToProject = `<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"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M8 13h8"/><path d="m13 10 3 3-3 3"/></svg>`;
|
|
3565
|
-
const iconChevronRight = `<svg viewBox="0 0 24 24" width="
|
|
3662
|
+
const iconChevronRight = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>`;
|
|
3566
3663
|
const iconFolderPlus = `<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"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg>`;
|
|
3567
3664
|
const iconRemoveFromProject = `<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"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M5 13h8"/><path d="m8 10 -3 3 3 3"/></svg>`;
|
|
3568
3665
|
|
|
@@ -3587,8 +3684,9 @@ const Sessions = (() => {
|
|
|
3587
3684
|
submenuItemsHtml = projects.map(p => {
|
|
3588
3685
|
const colorAttr = p.color ? ` style="color:${escapeHtml(p.color)}"` : "";
|
|
3589
3686
|
const projectIcon = Projects.getIconSvg(p.icon || "folder", 14);
|
|
3590
|
-
const
|
|
3591
|
-
|
|
3687
|
+
const isSelected = session.project_id === p.id;
|
|
3688
|
+
const check = isSelected ? `<span class="session-actions-submenu-check">\u2713</span>` : "";
|
|
3689
|
+
return `<div class="session-actions-submenu-item${isSelected ? " selected" : ""}" data-project-id="${escapeHtml(String(p.id))}">
|
|
3592
3690
|
<span class="session-actions-submenu-icon"${colorAttr}>${projectIcon}</span>
|
|
3593
3691
|
<span class="session-actions-submenu-label">${escapeHtml(p.name)}</span>
|
|
3594
3692
|
${check}
|
|
@@ -3814,9 +3912,10 @@ const Sessions = (() => {
|
|
|
3814
3912
|
|
|
3815
3913
|
updateStatusBar(status) {
|
|
3816
3914
|
// chat-header was removed; status text is now shown in the bottom session-info-bar (#sib-status).
|
|
3817
|
-
// Here we
|
|
3915
|
+
// Here we update controls whose availability follows the active session status.
|
|
3818
3916
|
const interrupt = $("btn-interrupt");
|
|
3819
3917
|
if (interrupt) interrupt.style.display = status === "running" ? "" : "none";
|
|
3918
|
+
_refreshEditButtons(RenderTarget.outer(), status);
|
|
3820
3919
|
|
|
3821
3920
|
// Swap input placeholder so the user knows they can still send extra
|
|
3822
3921
|
// info while the agent is working.
|
|
@@ -3827,7 +3926,7 @@ const Sessions = (() => {
|
|
|
3827
3926
|
? (mobile ? "chat.input.placeholderRunningMobile" : "chat.input.placeholderRunning")
|
|
3828
3927
|
: (mobile ? "chat.input.placeholderMobile" : "chat.input.placeholder");
|
|
3829
3928
|
inp.setAttribute("data-i18n-placeholder", key);
|
|
3830
|
-
|
|
3929
|
+
Composer.setPlaceholder(inp, I18n.t(key));
|
|
3831
3930
|
}
|
|
3832
3931
|
},
|
|
3833
3932
|
|
|
@@ -4092,9 +4191,32 @@ const Sessions = (() => {
|
|
|
4092
4191
|
|
|
4093
4192
|
// ── Message helpers ────────────────────────────────────────────────────
|
|
4094
4193
|
|
|
4095
|
-
// Live tool group state
|
|
4096
|
-
|
|
4097
|
-
|
|
4194
|
+
// Live tool group state. For the outer stream this is a single pointer
|
|
4195
|
+
// pair; for a phase body (concurrent fan-out) the pointers live on the
|
|
4196
|
+
// body element itself, so N parallel subagents never share one group.
|
|
4197
|
+
_liveToolGroup: null, // outer stream's open .tool-group DOM element
|
|
4198
|
+
_liveLastToolItem: null, // outer stream's last .tool-item (for tool_result pairing)
|
|
4199
|
+
|
|
4200
|
+
// Resolve the tool-group pointers for a render container. The outer
|
|
4201
|
+
// #messages node keeps its pointers on Sessions; any other container
|
|
4202
|
+
// (a phase body) keeps its own on the element, isolating concurrent runs.
|
|
4203
|
+
_toolGroupCtx(container) {
|
|
4204
|
+
const outer = RenderTarget.outer();
|
|
4205
|
+
if (!container || container === outer) {
|
|
4206
|
+
return {
|
|
4207
|
+
get group() { return Sessions._liveToolGroup; },
|
|
4208
|
+
set group(v) { Sessions._liveToolGroup = v; },
|
|
4209
|
+
get item() { return Sessions._liveLastToolItem; },
|
|
4210
|
+
set item(v) { Sessions._liveLastToolItem = v; },
|
|
4211
|
+
};
|
|
4212
|
+
}
|
|
4213
|
+
return {
|
|
4214
|
+
get group() { return container._liveToolGroup || null; },
|
|
4215
|
+
set group(v) { container._liveToolGroup = v; },
|
|
4216
|
+
get item() { return container._liveLastToolItem || null; },
|
|
4217
|
+
set item(v) { container._liveLastToolItem = v; },
|
|
4218
|
+
};
|
|
4219
|
+
},
|
|
4098
4220
|
|
|
4099
4221
|
// Append a diff block to the message stream (for edit/write previews).
|
|
4100
4222
|
appendDiff(rows, truncated, hiddenLines) {
|
|
@@ -4105,24 +4227,26 @@ const Sessions = (() => {
|
|
|
4105
4227
|
// Creates the group if it doesn't exist yet.
|
|
4106
4228
|
appendToolCall(name, args, summary) {
|
|
4107
4229
|
const messages = RenderTarget.current();
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4230
|
+
const ctx = Sessions._toolGroupCtx(messages);
|
|
4231
|
+
if (!ctx.group) {
|
|
4232
|
+
ctx.group = _makeToolGroup();
|
|
4233
|
+
messages.appendChild(ctx.group);
|
|
4111
4234
|
}
|
|
4112
|
-
|
|
4235
|
+
ctx.item = _addToolCallToGroup(ctx.group, name, args, summary);
|
|
4113
4236
|
_scrollToBottomIfNeeded(messages);
|
|
4114
4237
|
},
|
|
4115
4238
|
|
|
4116
4239
|
// Update the last tool-item with a result status tick.
|
|
4117
|
-
// If
|
|
4240
|
+
// If the tool group was collapsed by an intervening info message (e.g.
|
|
4118
4241
|
// "Subagent start/completed" during invoke_skill), fall back to the last
|
|
4119
4242
|
// still-running .tool-item in the DOM.
|
|
4120
4243
|
appendToolResult(result) {
|
|
4121
|
-
|
|
4122
|
-
|
|
4244
|
+
const messages = RenderTarget.current();
|
|
4245
|
+
const ctx = Sessions._toolGroupCtx(messages);
|
|
4246
|
+
if (ctx.group && ctx.item) {
|
|
4247
|
+
_completeLastToolItem(ctx.group, result);
|
|
4123
4248
|
return;
|
|
4124
4249
|
}
|
|
4125
|
-
const messages = RenderTarget.current();
|
|
4126
4250
|
if (messages) {
|
|
4127
4251
|
const running = messages.querySelectorAll(".tool-item-status.running");
|
|
4128
4252
|
if (running.length > 0) {
|
|
@@ -4136,12 +4260,13 @@ const Sessions = (() => {
|
|
|
4136
4260
|
// Shows the stdout area automatically on first content.
|
|
4137
4261
|
appendToolStdout(lines) {
|
|
4138
4262
|
// Resolve the target tool-item.
|
|
4139
|
-
// After a session switch,
|
|
4140
|
-
// was wiped and re-rendered from history.
|
|
4141
|
-
// .tool-item visible in the DOM —
|
|
4142
|
-
|
|
4263
|
+
// After a session switch, the live pointer is null because the messages
|
|
4264
|
+
// pane was wiped and re-rendered from history. In that case fall back to
|
|
4265
|
+
// the last .tool-item visible in the DOM — the in-flight tool the stdout
|
|
4266
|
+
// belongs to.
|
|
4267
|
+
const messages = RenderTarget.current();
|
|
4268
|
+
let toolItem = Sessions._toolGroupCtx(messages).item;
|
|
4143
4269
|
if (!toolItem) {
|
|
4144
|
-
const messages = RenderTarget.current();
|
|
4145
4270
|
if (messages) {
|
|
4146
4271
|
const items = messages.querySelectorAll(".tool-item");
|
|
4147
4272
|
if (items.length > 0) toolItem = items[items.length - 1];
|
|
@@ -4165,7 +4290,7 @@ const Sessions = (() => {
|
|
|
4165
4290
|
// assistant turn with no tool calls).
|
|
4166
4291
|
appendTokenUsage(ev, container, hostItem) {
|
|
4167
4292
|
const messages = container || RenderTarget.current();
|
|
4168
|
-
const host = hostItem || Sessions.
|
|
4293
|
+
const host = hostItem || Sessions._toolGroupCtx(messages).item || null;
|
|
4169
4294
|
const el = document.createElement("div");
|
|
4170
4295
|
el.className = "token-usage-line";
|
|
4171
4296
|
|
|
@@ -4233,12 +4358,15 @@ const Sessions = (() => {
|
|
|
4233
4358
|
}
|
|
4234
4359
|
},
|
|
4235
4360
|
|
|
4236
|
-
// Collapse the live tool group
|
|
4361
|
+
// Collapse the live tool group for the current render container (call when
|
|
4362
|
+
// AI starts responding or task ends). Inside a pinned phase body this
|
|
4363
|
+
// collapses that body's group; otherwise the outer stream's.
|
|
4237
4364
|
collapseToolGroup() {
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4365
|
+
const ctx = Sessions._toolGroupCtx(RenderTarget.current());
|
|
4366
|
+
if (ctx.group) {
|
|
4367
|
+
_collapseToolGroup(ctx.group);
|
|
4368
|
+
ctx.group = null;
|
|
4369
|
+
ctx.item = null;
|
|
4242
4370
|
}
|
|
4243
4371
|
},
|
|
4244
4372
|
|
|
@@ -4803,8 +4931,8 @@ const Sessions = (() => {
|
|
|
4803
4931
|
},
|
|
4804
4932
|
|
|
4805
4933
|
/** Register a message (optionally with attachments) to send after subscribe is confirmed. */
|
|
4806
|
-
setPendingMessage(sessionId, content, display = null, files = null) {
|
|
4807
|
-
_pendingMessage = { session_id: sessionId, content, display, files };
|
|
4934
|
+
setPendingMessage(sessionId, content, display = null, files = null, references = null) {
|
|
4935
|
+
_pendingMessage = { session_id: sessionId, content, display, files, references };
|
|
4808
4936
|
},
|
|
4809
4937
|
|
|
4810
4938
|
/** Consume and return the pending message (clears it). */
|
|
@@ -4831,42 +4959,6 @@ const Sessions = (() => {
|
|
|
4831
4959
|
// ── Session Info Bar Model Switcher ───────────────────────────────────────
|
|
4832
4960
|
(function() {
|
|
4833
4961
|
let _isOpen = false;
|
|
4834
|
-
// Cache of the most recent benchmark results, keyed by model_id. Kept at
|
|
4835
|
-
// closure scope so the numbers survive closing & reopening the dropdown —
|
|
4836
|
-
// the user shouldn't have to re-run the test just to peek at results. We
|
|
4837
|
-
// intentionally do NOT persist this to disk: latency is a point-in-time
|
|
4838
|
-
// measurement, and yesterday's numbers are misleading.
|
|
4839
|
-
let _benchCache = {}; // { [model_id]: { ttft_ms, ok, error, ts } }
|
|
4840
|
-
let _benchInFlight = false; // prevent double-click spam
|
|
4841
|
-
|
|
4842
|
-
// Price-ratio badges for the submodel rows. Ratios come from
|
|
4843
|
-
// GET /api/model_prices (backed by Clacky::ModelPricing), so the local
|
|
4844
|
-
// pricing table stays the single source of truth - no hardcoded JS prices.
|
|
4845
|
-
const _priceCache = new Map(); // model name -> { in, out, label } | null
|
|
4846
|
-
|
|
4847
|
-
function _fmtRatio(ratio) {
|
|
4848
|
-
if (ratio >= 10) return Math.round(ratio) + "x";
|
|
4849
|
-
const s = ratio >= 1 ? ratio.toFixed(1) : ratio.toFixed(2);
|
|
4850
|
-
return s.replace(/\.?0+$/, "") + "x";
|
|
4851
|
-
}
|
|
4852
|
-
|
|
4853
|
-
// Fetches ratios for names not yet cached. Rows simply stay blank when
|
|
4854
|
-
// the request fails (offline, unknown model).
|
|
4855
|
-
async function _loadPriceRatios(names) {
|
|
4856
|
-
const missing = Array.from(new Set(names.filter(Boolean).filter((n) => !_priceCache.has(n))));
|
|
4857
|
-
if (!missing.length) return;
|
|
4858
|
-
try {
|
|
4859
|
-
const res = await fetch("/api/model_prices?models=" + encodeURIComponent(missing.join(",")));
|
|
4860
|
-
if (!res.ok) return;
|
|
4861
|
-
const data = await res.json();
|
|
4862
|
-
missing.forEach((n) => {
|
|
4863
|
-
const p = data && data.prices && data.prices[n];
|
|
4864
|
-
_priceCache.set(n, p ? { in: p.in, out: p.out, label: _fmtRatio(p.ratio) } : null);
|
|
4865
|
-
});
|
|
4866
|
-
} catch (e) {
|
|
4867
|
-
/* network error - leave prices hidden */
|
|
4868
|
-
}
|
|
4869
|
-
}
|
|
4870
4962
|
|
|
4871
4963
|
// Toggle model dropdown when clicking on model name
|
|
4872
4964
|
document.addEventListener("click", async (e) => {
|
|
@@ -4880,7 +4972,7 @@ const Sessions = (() => {
|
|
|
4880
4972
|
if (_isOpen) {
|
|
4881
4973
|
dropdown.style.display = "none";
|
|
4882
4974
|
_isOpen = false;
|
|
4883
|
-
|
|
4975
|
+
ModelPicker.closeSubmodelPanel();
|
|
4884
4976
|
} else {
|
|
4885
4977
|
let subOptions = [];
|
|
4886
4978
|
try { subOptions = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
|
|
@@ -4890,13 +4982,13 @@ const Sessions = (() => {
|
|
|
4890
4982
|
cardModel: modelEl.dataset.cardModel || null
|
|
4891
4983
|
};
|
|
4892
4984
|
await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, subInfo);
|
|
4893
|
-
|
|
4985
|
+
|
|
4894
4986
|
// Calculate position relative to the model element (fixed positioning)
|
|
4895
4987
|
const rect = modelEl.getBoundingClientRect();
|
|
4896
4988
|
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
|
4897
4989
|
dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
|
|
4898
4990
|
dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
|
|
4899
|
-
|
|
4991
|
+
|
|
4900
4992
|
dropdown.style.display = "block";
|
|
4901
4993
|
_isOpen = true;
|
|
4902
4994
|
}
|
|
@@ -4908,7 +5000,7 @@ const Sessions = (() => {
|
|
|
4908
5000
|
const dropdown = $("sib-model-dropdown");
|
|
4909
5001
|
if (dropdown) dropdown.style.display = "none";
|
|
4910
5002
|
_isOpen = false;
|
|
4911
|
-
|
|
5003
|
+
ModelPicker.closeSubmodelPanel();
|
|
4912
5004
|
}
|
|
4913
5005
|
});
|
|
4914
5006
|
|
|
@@ -4919,306 +5011,41 @@ const Sessions = (() => {
|
|
|
4919
5011
|
if (!dropdown) return;
|
|
4920
5012
|
|
|
4921
5013
|
try {
|
|
4922
|
-
console.log("[Model Switcher] Fetching /api/config...");
|
|
4923
5014
|
const res = await fetch(`/api/config?session_id=${encodeURIComponent(sessionId)}`);
|
|
4924
5015
|
const data = await res.json();
|
|
4925
|
-
console.log("[Model Switcher] Received data:", data);
|
|
4926
5016
|
const models = data.models || [];
|
|
4927
5017
|
const mediaCaps = data.media_capabilities || {};
|
|
4928
|
-
console.log("[Model Switcher] Models count:", models.length);
|
|
4929
5018
|
|
|
4930
5019
|
if (models.length === 0) {
|
|
4931
5020
|
dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-text-secondary);font-size:0.6875rem;">No models configured</div>';
|
|
4932
5021
|
return;
|
|
4933
5022
|
}
|
|
4934
5023
|
|
|
4935
|
-
dropdown
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
bench.className = "sib-model-bench";
|
|
4945
|
-
const btnLabel = (typeof I18n !== "undefined") ? I18n.t("sib.bench.btn") : "Benchmark";
|
|
4946
|
-
const btnTooltip = (typeof I18n !== "undefined") ? I18n.t("sib.bench.tooltip") : "Test response latency for every configured model";
|
|
4947
|
-
bench.innerHTML = `
|
|
4948
|
-
<button type="button" class="sib-bench-btn" title="${btnTooltip}">⚡ <span class="sib-bench-label">${btnLabel}</span></button>
|
|
4949
|
-
<span class="sib-bench-hint"></span>
|
|
4950
|
-
`;
|
|
4951
|
-
dropdown.appendChild(bench);
|
|
4952
|
-
|
|
4953
|
-
const benchBtn = bench.querySelector(".sib-bench-btn");
|
|
4954
|
-
const benchLabel = bench.querySelector(".sib-bench-label");
|
|
4955
|
-
const benchHint = bench.querySelector(".sib-bench-hint");
|
|
4956
|
-
benchBtn.addEventListener("click", (ev) => {
|
|
4957
|
-
ev.stopPropagation();
|
|
4958
|
-
_runBenchmark(sessionId, dropdown, benchBtn, benchLabel, benchHint);
|
|
5024
|
+
ModelPicker.populate(dropdown, {
|
|
5025
|
+
models,
|
|
5026
|
+
currentId: currentModelId,
|
|
5027
|
+
mediaCaps,
|
|
5028
|
+
subInfo,
|
|
5029
|
+
onSelect: (m) => _switchModel(sessionId, m.id, m.model),
|
|
5030
|
+
onSwitchSubModel: (name, displayName) => _switchSubModel(sessionId, name, displayName),
|
|
5031
|
+
onBenchmark: () => _benchmarkSession(sessionId),
|
|
5032
|
+
onConfigureMedia: _goConfigureMedia,
|
|
4959
5033
|
});
|
|
4960
|
-
|
|
4961
|
-
// ── Model rows ─────────────────────────────────────────────────────
|
|
4962
|
-
const _nameCounts = models.reduce((acc, m) => {
|
|
4963
|
-
acc[m.model] = (acc[m.model] || 0) + 1;
|
|
4964
|
-
return acc;
|
|
4965
|
-
}, {});
|
|
4966
|
-
|
|
4967
|
-
models.forEach(m => {
|
|
4968
|
-
console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModelId);
|
|
4969
|
-
const opt = document.createElement("div");
|
|
4970
|
-
opt.className = "sib-model-option";
|
|
4971
|
-
opt.dataset.modelId = m.id;
|
|
4972
|
-
if (m.id === currentModelId) opt.classList.add("current");
|
|
4973
|
-
|
|
4974
|
-
const left = document.createElement("span");
|
|
4975
|
-
left.className = "sib-model-name";
|
|
4976
|
-
|
|
4977
|
-
const nameLine = document.createElement("span");
|
|
4978
|
-
nameLine.className = "sib-model-name-main";
|
|
4979
|
-
// When a non-default quick-switch model is active for the current row,
|
|
4980
|
-
// show only that model's name (avoid the long "main → quick-switch"
|
|
4981
|
-
// string that gets truncated).
|
|
4982
|
-
const hasActiveOverride =
|
|
4983
|
-
m.id === currentModelId &&
|
|
4984
|
-
subInfo.current &&
|
|
4985
|
-
subInfo.current !== subInfo.cardModel;
|
|
4986
|
-
nameLine.textContent = hasActiveOverride ? subInfo.current : m.model;
|
|
4987
|
-
left.appendChild(nameLine);
|
|
4988
|
-
|
|
4989
|
-
// Vision status for the active model only — follows whichever model is
|
|
4990
|
-
// currently in effect (mediaCaps is computed for that same model).
|
|
4991
|
-
if (m.id === currentModelId && mediaCaps.vision) {
|
|
4992
|
-
const ok = !!mediaCaps.vision.configured;
|
|
4993
|
-
const vis = document.createElement("span");
|
|
4994
|
-
vis.className = "sib-model-vision " + (ok ? "is-ok" : "is-missing");
|
|
4995
|
-
vis.textContent = ok ? I18n.t("sib.vision.ok") : I18n.t("sib.vision.missing");
|
|
4996
|
-
vis.title = ok ? I18n.t("sib.vision.okTip") : I18n.t("sib.vision.missingTip");
|
|
4997
|
-
left.appendChild(vis);
|
|
4998
|
-
}
|
|
4999
|
-
|
|
5000
|
-
// Distinguish rows that would otherwise look identical: user-typed
|
|
5001
|
-
// remark first (most explicit), then host/key when names collide.
|
|
5002
|
-
const remark = (m.remark || "").trim();
|
|
5003
|
-
if (remark || _nameCounts[m.model] > 1) {
|
|
5004
|
-
left.classList.add("has-sub");
|
|
5005
|
-
const host = (() => {
|
|
5006
|
-
try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
|
|
5007
|
-
})();
|
|
5008
|
-
const subBits = [remark, host, m.api_key_masked].filter(Boolean);
|
|
5009
|
-
if (subBits.length) {
|
|
5010
|
-
const subLine = document.createElement("span");
|
|
5011
|
-
subLine.className = "sib-model-name-sub";
|
|
5012
|
-
subLine.textContent = subBits.join(" · ");
|
|
5013
|
-
left.appendChild(subLine);
|
|
5014
|
-
opt.title = `${m.model} · ${subBits.join(" · ")}`;
|
|
5015
|
-
}
|
|
5016
|
-
}
|
|
5017
|
-
|
|
5018
|
-
opt.appendChild(left);
|
|
5019
|
-
|
|
5020
|
-
const right = document.createElement("span");
|
|
5021
|
-
right.className = "sib-model-right";
|
|
5022
|
-
|
|
5023
|
-
if (m.type === "default") {
|
|
5024
|
-
const badge = document.createElement("span");
|
|
5025
|
-
badge.className = `model-badge ${m.type}`;
|
|
5026
|
-
badge.textContent = m.type;
|
|
5027
|
-
right.appendChild(badge);
|
|
5028
|
-
}
|
|
5029
|
-
|
|
5030
|
-
const lat = document.createElement("span");
|
|
5031
|
-
lat.className = "sib-model-latency";
|
|
5032
|
-
_fillLatencyCell(lat, _benchCache[m.id]);
|
|
5033
|
-
right.appendChild(lat);
|
|
5034
|
-
|
|
5035
|
-
const hasSubModels =
|
|
5036
|
-
m.id === currentModelId &&
|
|
5037
|
-
subInfo.options &&
|
|
5038
|
-
subInfo.options.length > 1;
|
|
5039
|
-
|
|
5040
|
-
if (hasSubModels) {
|
|
5041
|
-
const toggleBtn = document.createElement("button");
|
|
5042
|
-
toggleBtn.type = "button";
|
|
5043
|
-
toggleBtn.className = "sib-submodel-toggle";
|
|
5044
|
-
toggleBtn.title = I18n.t("sib.variant.header");
|
|
5045
|
-
toggleBtn.setAttribute("aria-expanded", "false");
|
|
5046
|
-
toggleBtn.innerHTML =
|
|
5047
|
-
'<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
|
|
5048
|
-
'<path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
|
|
5049
|
-
'</svg>';
|
|
5050
|
-
right.appendChild(toggleBtn);
|
|
5051
|
-
|
|
5052
|
-
toggleBtn.addEventListener("click", (ev) => {
|
|
5053
|
-
ev.stopPropagation();
|
|
5054
|
-
_toggleSubmodelPanel(opt, toggleBtn, sessionId, subInfo);
|
|
5055
|
-
});
|
|
5056
|
-
}
|
|
5057
|
-
|
|
5058
|
-
opt.appendChild(right);
|
|
5059
|
-
|
|
5060
|
-
opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
|
|
5061
|
-
dropdown.appendChild(opt);
|
|
5062
|
-
});
|
|
5063
|
-
|
|
5064
|
-
_appendGenerationFooter(dropdown, mediaCaps);
|
|
5065
|
-
console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
|
|
5066
5034
|
} catch (e) {
|
|
5067
5035
|
console.error("Failed to load models:", e);
|
|
5068
5036
|
dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-error);font-size:0.6875rem;">Error loading models</div>';
|
|
5069
5037
|
}
|
|
5070
5038
|
}
|
|
5071
5039
|
|
|
5072
|
-
//
|
|
5073
|
-
//
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
footer.className = "sib-gen-footer";
|
|
5080
|
-
|
|
5081
|
-
const list = document.createElement("span");
|
|
5082
|
-
list.className = "sib-gen-list";
|
|
5083
|
-
kinds.forEach(k => {
|
|
5084
|
-
const cap = mediaCaps[k] || {};
|
|
5085
|
-
const ok = !!cap.configured;
|
|
5086
|
-
const chip = document.createElement("span");
|
|
5087
|
-
chip.className = "sib-gen-chip " + (ok ? "is-ok" : "is-off");
|
|
5088
|
-
chip.textContent = (ok ? "✓ " : "") + I18n.t(`sib.gen.kind.${k}`);
|
|
5089
|
-
chip.title = ok
|
|
5090
|
-
? I18n.t("sib.gen.okTip", { model: cap.model || "" })
|
|
5091
|
-
: I18n.t("sib.gen.offTip");
|
|
5092
|
-
list.appendChild(chip);
|
|
5093
|
-
});
|
|
5094
|
-
footer.appendChild(list);
|
|
5095
|
-
|
|
5096
|
-
const configBtn = document.createElement("button");
|
|
5097
|
-
configBtn.type = "button";
|
|
5098
|
-
configBtn.className = "sib-gen-config";
|
|
5099
|
-
configBtn.textContent = I18n.t("sib.gen.config");
|
|
5100
|
-
configBtn.title = I18n.t("sib.gen.offTip");
|
|
5101
|
-
configBtn.addEventListener("click", (ev) => {
|
|
5102
|
-
ev.stopPropagation();
|
|
5103
|
-
_goConfigureMedia();
|
|
5104
|
-
});
|
|
5105
|
-
footer.appendChild(configBtn);
|
|
5106
|
-
|
|
5107
|
-
dropdown.appendChild(footer);
|
|
5108
|
-
}
|
|
5109
|
-
|
|
5110
|
-
function _goConfigureMedia() {
|
|
5111
|
-
const dropdown = $("sib-model-dropdown");
|
|
5112
|
-
if (dropdown) dropdown.style.display = "none";
|
|
5113
|
-
_isOpen = false;
|
|
5114
|
-
_closeSubmodelPanel();
|
|
5115
|
-
if (typeof Router !== "undefined") Router.navigate("settings");
|
|
5116
|
-
setTimeout(() => {
|
|
5117
|
-
const sec = document.getElementById("media-section");
|
|
5118
|
-
if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
5119
|
-
}, 200);
|
|
5120
|
-
}
|
|
5121
|
-
|
|
5122
|
-
// Render one latency cell based on a cached result.
|
|
5123
|
-
// undefined → empty slot (never tested / in-flight starts from here)
|
|
5124
|
-
// { ok:true } → "812ms" in green/amber/red per threshold
|
|
5125
|
-
// { ok:false } → "✕" with error in tooltip
|
|
5126
|
-
// { pending:true } → "…" spinner-ish marker
|
|
5127
|
-
function _fillLatencyCell(el, entry) {
|
|
5128
|
-
el.className = "sib-model-latency";
|
|
5129
|
-
el.textContent = "";
|
|
5130
|
-
el.removeAttribute("title");
|
|
5131
|
-
if (!entry) return;
|
|
5132
|
-
if (entry.pending) {
|
|
5133
|
-
el.textContent = "…";
|
|
5134
|
-
el.classList.add("is-pending");
|
|
5135
|
-
return;
|
|
5136
|
-
}
|
|
5137
|
-
if (!entry.ok) {
|
|
5138
|
-
el.textContent = "✕";
|
|
5139
|
-
el.classList.add("is-err");
|
|
5140
|
-
el.title = entry.error || "failed";
|
|
5141
|
-
return;
|
|
5142
|
-
}
|
|
5143
|
-
const ms = entry.ttft_ms;
|
|
5144
|
-
// Same thresholds as the sib-signal status bar — keep them aligned so
|
|
5145
|
-
// "3 bars in the status bar" ≈ "green number in the picker".
|
|
5146
|
-
// We measure full non-streaming response time (not real TTFT), so ≤60s is
|
|
5147
|
-
// normal, ≤120s is slow, beyond is bad. ≤2s still gets the "feels instant"
|
|
5148
|
-
// green treatment like the 4-bar signal.
|
|
5149
|
-
let cls = "is-bad";
|
|
5150
|
-
if (ms <= 2000) cls = "is-ok";
|
|
5151
|
-
else if (ms <= 60000) cls = "is-ok";
|
|
5152
|
-
else if (ms <= 120000) cls = "is-warn";
|
|
5153
|
-
el.classList.add(cls);
|
|
5154
|
-
el.textContent = ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
|
|
5155
|
-
if (typeof I18n !== "undefined") {
|
|
5156
|
-
el.title = I18n.t("sib.bench.latencyTooltip", {
|
|
5157
|
-
ttft: el.textContent,
|
|
5158
|
-
time: new Date(entry.ts).toLocaleTimeString(),
|
|
5159
|
-
});
|
|
5160
|
-
} else {
|
|
5161
|
-
el.title = `TTFT ${el.textContent} · tested ${new Date(entry.ts).toLocaleTimeString()}`;
|
|
5162
|
-
}
|
|
5163
|
-
}
|
|
5164
|
-
|
|
5165
|
-
async function _runBenchmark(sessionId, dropdown, btn, label, hint) {
|
|
5166
|
-
if (_benchInFlight) return;
|
|
5167
|
-
_benchInFlight = true;
|
|
5168
|
-
btn.disabled = true;
|
|
5169
|
-
const origLabel = label.textContent;
|
|
5170
|
-
const _t = (key, vars) => (typeof I18n !== "undefined") ? I18n.t(key, vars) : key;
|
|
5171
|
-
label.textContent = _t("sib.bench.running");
|
|
5172
|
-
hint.textContent = "";
|
|
5173
|
-
|
|
5174
|
-
// Mark every row as pending so the user sees instant feedback instead of
|
|
5175
|
-
// a silent button. _fillLatencyCell handles the visual treatment.
|
|
5176
|
-
dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
|
|
5177
|
-
const id = opt.dataset.modelId;
|
|
5178
|
-
if (!id) return;
|
|
5179
|
-
_benchCache[id] = { pending: true };
|
|
5180
|
-
_fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[id]);
|
|
5181
|
-
});
|
|
5182
|
-
|
|
5183
|
-
const t0 = performance.now();
|
|
5184
|
-
try {
|
|
5185
|
-
const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
|
|
5186
|
-
const data = await res.json();
|
|
5187
|
-
if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
|
|
5188
|
-
|
|
5189
|
-
const now = Date.now();
|
|
5190
|
-
(data.results || []).forEach(r => {
|
|
5191
|
-
_benchCache[r.model_id] = {
|
|
5192
|
-
ok: !!r.ok,
|
|
5193
|
-
ttft_ms: r.ttft_ms,
|
|
5194
|
-
error: r.error,
|
|
5195
|
-
ts: now,
|
|
5196
|
-
};
|
|
5197
|
-
const opt = dropdown.querySelector(`.sib-model-option[data-model-id="${CSS.escape(r.model_id)}"]`);
|
|
5198
|
-
if (opt) _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[r.model_id]);
|
|
5199
|
-
});
|
|
5200
|
-
|
|
5201
|
-
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
|
5202
|
-
hint.textContent = _t("sib.bench.done", { t: elapsed });
|
|
5203
|
-
} catch (e) {
|
|
5204
|
-
console.error("Benchmark failed:", e);
|
|
5205
|
-
hint.textContent = _t("sib.bench.failed", { msg: e.message });
|
|
5206
|
-
// Clear pending markers so rows don't stay stuck on "…"
|
|
5207
|
-
dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
|
|
5208
|
-
const id = opt.dataset.modelId;
|
|
5209
|
-
if (id && _benchCache[id]?.pending) {
|
|
5210
|
-
_benchCache[id] = undefined;
|
|
5211
|
-
_fillLatencyCell(opt.querySelector(".sib-model-latency"), undefined);
|
|
5212
|
-
}
|
|
5213
|
-
});
|
|
5214
|
-
} finally {
|
|
5215
|
-
_benchInFlight = false;
|
|
5216
|
-
btn.disabled = false;
|
|
5217
|
-
label.textContent = origLabel;
|
|
5218
|
-
}
|
|
5040
|
+
// Fetch benchmark results for a session. The benchmark UI (pending markers,
|
|
5041
|
+
// latency cells) lives in ModelPicker; this only supplies the request.
|
|
5042
|
+
async function _benchmarkSession(sessionId) {
|
|
5043
|
+
const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
|
|
5044
|
+
const data = await res.json();
|
|
5045
|
+
if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
|
|
5046
|
+
return data.results;
|
|
5219
5047
|
}
|
|
5220
5048
|
|
|
5221
|
-
// Switch session model via API
|
|
5222
5049
|
// Switch the session's current card. modelId is the stable runtime id,
|
|
5223
5050
|
// modelName is for optimistic display.
|
|
5224
5051
|
async function _switchModel(sessionId, modelId, modelName) {
|
|
@@ -5227,7 +5054,7 @@ const Sessions = (() => {
|
|
|
5227
5054
|
dropdown.style.display = "none";
|
|
5228
5055
|
_isOpen = false;
|
|
5229
5056
|
}
|
|
5230
|
-
|
|
5057
|
+
ModelPicker.closeSubmodelPanel();
|
|
5231
5058
|
|
|
5232
5059
|
try {
|
|
5233
5060
|
const res = await fetch(`/api/sessions/${sessionId}/model`, {
|
|
@@ -5246,8 +5073,7 @@ const Sessions = (() => {
|
|
|
5246
5073
|
// backend emits inside this same request. Don't touch sibModel here:
|
|
5247
5074
|
// the broadcast typically arrives BEFORE this fetch resolves (WS frame
|
|
5248
5075
|
// vs HTTP response on separate TCP streams), so writing here would
|
|
5249
|
-
// overwrite the already-correct value with an incomplete one
|
|
5250
|
-
// function only knows the card name, not whether a sub-model is pinned).
|
|
5076
|
+
// overwrite the already-correct value with an incomplete one.
|
|
5251
5077
|
|
|
5252
5078
|
console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
|
|
5253
5079
|
} catch (e) {
|
|
@@ -5264,7 +5090,7 @@ const Sessions = (() => {
|
|
|
5264
5090
|
dropdown.style.display = "none";
|
|
5265
5091
|
_isOpen = false;
|
|
5266
5092
|
}
|
|
5267
|
-
|
|
5093
|
+
ModelPicker.closeSubmodelPanel();
|
|
5268
5094
|
|
|
5269
5095
|
try {
|
|
5270
5096
|
const res = await fetch(`/api/sessions/${sessionId}/submodel`, {
|
|
@@ -5276,145 +5102,24 @@ const Sessions = (() => {
|
|
|
5276
5102
|
if (!res.ok) throw new Error(data.error || "Unknown error");
|
|
5277
5103
|
|
|
5278
5104
|
// The status bar is updated by the session_update broadcast that the
|
|
5279
|
-
// backend emits inside this same request. Don't touch sibModel here
|
|
5280
|
-
// the broadcast typically arrives BEFORE this fetch resolves, so any
|
|
5281
|
-
// write here would race with (and often overwrite) the correct value.
|
|
5105
|
+
// backend emits inside this same request. Don't touch sibModel here.
|
|
5282
5106
|
} catch (e) {
|
|
5283
5107
|
console.error("Failed to switch sub-model:", e);
|
|
5284
5108
|
alert("Failed to switch sub-model: " + e.message);
|
|
5285
5109
|
}
|
|
5286
5110
|
}
|
|
5287
5111
|
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
function _closeSubmodelPanel() {
|
|
5291
|
-
const panel = $("sib-submodel-panel");
|
|
5292
|
-
if (panel) panel.style.display = "none";
|
|
5293
|
-
if (_activeSubmodelAnchor) {
|
|
5294
|
-
const btn = _activeSubmodelAnchor.querySelector(".sib-submodel-toggle");
|
|
5295
|
-
if (btn) btn.setAttribute("aria-expanded", "false");
|
|
5296
|
-
_activeSubmodelAnchor.classList.remove("submodel-open");
|
|
5297
|
-
_activeSubmodelAnchor = null;
|
|
5298
|
-
}
|
|
5299
|
-
}
|
|
5300
|
-
|
|
5301
|
-
function _toggleSubmodelPanel(anchorRow, btn, sessionId, subInfo) {
|
|
5302
|
-
const panel = $("sib-submodel-panel");
|
|
5112
|
+
// Navigate to media config.
|
|
5113
|
+
function _goConfigureMedia() {
|
|
5303
5114
|
const dropdown = $("sib-model-dropdown");
|
|
5304
|
-
if (
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
_closeSubmodelPanel();
|
|
5313
|
-
return;
|
|
5314
|
-
}
|
|
5315
|
-
|
|
5316
|
-
_renderSubmodelPanel(panel, sessionId, subInfo);
|
|
5317
|
-
|
|
5318
|
-
// Reset any prior position so measurements are accurate.
|
|
5319
|
-
panel.style.left = "0px";
|
|
5320
|
-
panel.style.top = "0px";
|
|
5321
|
-
panel.style.display = "block";
|
|
5322
|
-
panel.style.visibility = "hidden";
|
|
5323
|
-
|
|
5324
|
-
const dropRect = dropdown.getBoundingClientRect();
|
|
5325
|
-
const btnRect = btn.getBoundingClientRect();
|
|
5326
|
-
const panelRect = panel.getBoundingClientRect();
|
|
5327
|
-
const gap = 6;
|
|
5328
|
-
const margin = 8;
|
|
5329
|
-
const vw = window.innerWidth;
|
|
5330
|
-
const vh = window.innerHeight;
|
|
5331
|
-
|
|
5332
|
-
// Prefer right of dropdown; flip to left if we'd overflow viewport.
|
|
5333
|
-
let left = dropRect.right + gap;
|
|
5334
|
-
if (left + panelRect.width > vw - margin) {
|
|
5335
|
-
left = dropRect.left - panelRect.width - gap;
|
|
5336
|
-
}
|
|
5337
|
-
// If still off-screen on the left, clamp inside viewport.
|
|
5338
|
-
if (left < margin) left = margin;
|
|
5339
|
-
|
|
5340
|
-
// Vertically align to the chevron button, but clamp inside viewport.
|
|
5341
|
-
let top = btnRect.top - 6;
|
|
5342
|
-
if (top + panelRect.height > vh - margin) {
|
|
5343
|
-
top = vh - margin - panelRect.height;
|
|
5344
|
-
}
|
|
5345
|
-
if (top < margin) top = margin;
|
|
5346
|
-
|
|
5347
|
-
panel.style.left = `${left}px`;
|
|
5348
|
-
panel.style.top = `${top}px`;
|
|
5349
|
-
panel.style.visibility = "";
|
|
5350
|
-
|
|
5351
|
-
_activeSubmodelAnchor = anchorRow;
|
|
5352
|
-
anchorRow.classList.add("submodel-open");
|
|
5353
|
-
btn.setAttribute("aria-expanded", "true");
|
|
5354
|
-
}
|
|
5355
|
-
|
|
5356
|
-
function _renderSubmodelPanel(panel, sessionId, subInfo) {
|
|
5357
|
-
panel.innerHTML = "";
|
|
5358
|
-
|
|
5359
|
-
const header = document.createElement("div");
|
|
5360
|
-
header.className = "sib-submodel-panel-header";
|
|
5361
|
-
header.textContent = I18n.t("sib.variant.header");
|
|
5362
|
-
panel.appendChild(header);
|
|
5363
|
-
|
|
5364
|
-
const cardDefault = subInfo.cardModel;
|
|
5365
|
-
subInfo.options.forEach(name => {
|
|
5366
|
-
const row = document.createElement("div");
|
|
5367
|
-
row.className = "sib-submodel-row";
|
|
5368
|
-
row.dataset.subModel = name;
|
|
5369
|
-
|
|
5370
|
-
const isActive = subInfo.current
|
|
5371
|
-
? name === subInfo.current
|
|
5372
|
-
: name === cardDefault;
|
|
5373
|
-
if (isActive) row.classList.add("current");
|
|
5374
|
-
|
|
5375
|
-
const nameEl = document.createElement("span");
|
|
5376
|
-
nameEl.className = "sib-submodel-row-name";
|
|
5377
|
-
nameEl.textContent = name;
|
|
5378
|
-
row.appendChild(nameEl);
|
|
5379
|
-
|
|
5380
|
-
const rightBox = document.createElement("span");
|
|
5381
|
-
rightBox.style.cssText = "display:inline-flex;align-items:center;gap:0.375rem;flex-shrink:0;";
|
|
5382
|
-
|
|
5383
|
-
const priceEl = document.createElement("span");
|
|
5384
|
-
priceEl.className = "sib-model-price";
|
|
5385
|
-
rightBox.appendChild(priceEl);
|
|
5386
|
-
|
|
5387
|
-
if (name === cardDefault) {
|
|
5388
|
-
const tag = document.createElement("span");
|
|
5389
|
-
tag.className = "sib-submodel-default-tag";
|
|
5390
|
-
tag.textContent = I18n.t("sib.variant.default");
|
|
5391
|
-
rightBox.appendChild(tag);
|
|
5392
|
-
}
|
|
5393
|
-
row.appendChild(rightBox);
|
|
5394
|
-
|
|
5395
|
-
row.addEventListener("click", (ev) => {
|
|
5396
|
-
ev.stopPropagation();
|
|
5397
|
-
const passName = (name === cardDefault) ? null : name;
|
|
5398
|
-
_switchSubModel(sessionId, passName, name);
|
|
5399
|
-
});
|
|
5400
|
-
panel.appendChild(row);
|
|
5401
|
-
});
|
|
5402
|
-
|
|
5403
|
-
_fillSubmodelPrices(panel);
|
|
5404
|
-
}
|
|
5405
|
-
|
|
5406
|
-
// Ratio text fills in asynchronously once /api/model_prices responds
|
|
5407
|
-
// (cached afterwards, so re-opening the panel shows prices instantly).
|
|
5408
|
-
async function _fillSubmodelPrices(panel) {
|
|
5409
|
-
const rows = Array.from(panel.querySelectorAll(".sib-submodel-row"));
|
|
5410
|
-
await _loadPriceRatios(rows.map((r) => r.dataset.subModel));
|
|
5411
|
-
rows.forEach((row) => {
|
|
5412
|
-
const price = _priceCache.get(row.dataset.subModel);
|
|
5413
|
-
const el = row.querySelector(".sib-model-price");
|
|
5414
|
-
if (!price || !el || el.textContent) return;
|
|
5415
|
-
el.textContent = price.label;
|
|
5416
|
-
el.title = I18n.t("sib.price.tip", { in: price.in, out: price.out });
|
|
5417
|
-
});
|
|
5115
|
+
if (dropdown) dropdown.style.display = "none";
|
|
5116
|
+
_isOpen = false;
|
|
5117
|
+
ModelPicker.closeSubmodelPanel();
|
|
5118
|
+
if (typeof Router !== "undefined") Router.navigate("settings");
|
|
5119
|
+
setTimeout(() => {
|
|
5120
|
+
const sec = document.getElementById("media-section");
|
|
5121
|
+
if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
5122
|
+
}, 200);
|
|
5418
5123
|
}
|
|
5419
5124
|
})();
|
|
5420
5125
|
|
|
@@ -5424,6 +5129,30 @@ const Sessions = (() => {
|
|
|
5424
5129
|
// ── Tree-based directory picker ─────────────────────────────────────────
|
|
5425
5130
|
const ICON_FOLDER_SVG = '<svg 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"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
|
5426
5131
|
const ICON_CARET_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
|
5132
|
+
const ICON_DRIVE_SVG = '<svg 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"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/></svg>';
|
|
5133
|
+
const ICON_STAR_SVG = '<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" stroke-linejoin="round"><path d="M12 2.6L14.84 8.35L21.18 9.27L16.59 13.74L17.67 20.05L12 17.07L6.33 20.05L7.41 13.74L2.82 9.27L9.16 8.35L12 2.6Z"/></svg>';
|
|
5134
|
+
const DIR_FAVORITES_STORAGE_KEY = "clacky-directory-favorites";
|
|
5135
|
+
|
|
5136
|
+
function normalizeFavoritePath(path) {
|
|
5137
|
+
return (path || "").trim().replaceAll("\\", "/").replace(/\/+$/, "") || "/";
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
function loadDirectoryFavorites() {
|
|
5141
|
+
try {
|
|
5142
|
+
const paths = JSON.parse(localStorage.getItem(DIR_FAVORITES_STORAGE_KEY) || "[]");
|
|
5143
|
+
if (!Array.isArray(paths)) return [];
|
|
5144
|
+
return [...new Set(paths.filter(path => typeof path === "string" && path.trim()).map(normalizeFavoritePath))];
|
|
5145
|
+
} catch (_e) {
|
|
5146
|
+
return [];
|
|
5147
|
+
}
|
|
5148
|
+
}
|
|
5149
|
+
|
|
5150
|
+
function saveDirectoryFavorites(paths) {
|
|
5151
|
+
try {
|
|
5152
|
+
localStorage.setItem(DIR_FAVORITES_STORAGE_KEY, JSON.stringify(paths));
|
|
5153
|
+
} catch (_e) {}
|
|
5154
|
+
}
|
|
5155
|
+
|
|
5427
5156
|
function showDirectoryPicker(currentDir, sessionId) {
|
|
5428
5157
|
return new Promise((resolve) => {
|
|
5429
5158
|
const t = (key, fallback) => {
|
|
@@ -5435,11 +5164,17 @@ const Sessions = (() => {
|
|
|
5435
5164
|
// real filesystem via /api/dirs instead of the session-scoped files API.
|
|
5436
5165
|
const sessionLess = !sessionId;
|
|
5437
5166
|
|
|
5167
|
+
let currentPath = currentDir;
|
|
5438
5168
|
let selectedPath = currentDir;
|
|
5169
|
+
let currentHasChildDirs = false;
|
|
5439
5170
|
let rootDir = ""; // absolute path of the session's working directory
|
|
5440
5171
|
let homeDir = ""; // user home, used as a quick preset
|
|
5441
5172
|
let defaultDir = ""; // default workspace from agent_config (or fallback)
|
|
5442
5173
|
let showHidden = false;
|
|
5174
|
+
let placesLoaded = false;
|
|
5175
|
+
let currentPathLoaded = false;
|
|
5176
|
+
let sidebarPlaces = [];
|
|
5177
|
+
let directoryFavorites = loadDirectoryFavorites();
|
|
5443
5178
|
|
|
5444
5179
|
// Fetch directory entries from API, returns dirs with absolute paths
|
|
5445
5180
|
async function fetchDirs(relPath, absolute = false) {
|
|
@@ -5453,9 +5188,14 @@ const Sessions = (() => {
|
|
|
5453
5188
|
rootDir = data.root || rootDir;
|
|
5454
5189
|
homeDir = data.home || homeDir;
|
|
5455
5190
|
defaultDir = data.default || defaultDir;
|
|
5191
|
+
if (!placesLoaded && data.places) {
|
|
5192
|
+
placesLoaded = true;
|
|
5193
|
+
sidebarPlaces = data.places;
|
|
5194
|
+
renderSidebar();
|
|
5195
|
+
}
|
|
5456
5196
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
5457
5197
|
dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
|
|
5458
|
-
return dirs;
|
|
5198
|
+
return { dirs, resolvedPath: data.path || data.root || relPath, exact: data.exact };
|
|
5459
5199
|
}
|
|
5460
5200
|
let url = `/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
|
|
5461
5201
|
if (absolute) url += "&absolute=true";
|
|
@@ -5467,6 +5207,11 @@ const Sessions = (() => {
|
|
|
5467
5207
|
if (!absolute) rootDir = data.root || rootDir;
|
|
5468
5208
|
homeDir = data.home || homeDir;
|
|
5469
5209
|
defaultDir = data.default || defaultDir;
|
|
5210
|
+
if (!placesLoaded && data.places) {
|
|
5211
|
+
placesLoaded = true;
|
|
5212
|
+
sidebarPlaces = data.places;
|
|
5213
|
+
renderSidebar();
|
|
5214
|
+
}
|
|
5470
5215
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
5471
5216
|
// Convert relative paths to absolute
|
|
5472
5217
|
dirs.forEach(d => {
|
|
@@ -5474,7 +5219,8 @@ const Sessions = (() => {
|
|
|
5474
5219
|
const cleanPath = d.path.replace(/^\/+/, "");
|
|
5475
5220
|
d.absPath = absolute ? ("/" + cleanPath) : (rootDir.replace(/\/+$/, "") + "/" + cleanPath);
|
|
5476
5221
|
d.absolute = absolute; // Store absolute flag for child expansion
|
|
5477
|
-
});
|
|
5222
|
+
});
|
|
5223
|
+
return { dirs, resolvedPath: data.path || data.root || relPath, exact: true };
|
|
5478
5224
|
}
|
|
5479
5225
|
|
|
5480
5226
|
// Build a flat list row for a directory entry (Finder-style)
|
|
@@ -5507,7 +5253,6 @@ const Sessions = (() => {
|
|
|
5507
5253
|
listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
5508
5254
|
row.classList.add("selected");
|
|
5509
5255
|
selectedPath = entry.absPath;
|
|
5510
|
-
refreshBreadcrumb(entry.absPath);
|
|
5511
5256
|
}, 200);
|
|
5512
5257
|
});
|
|
5513
5258
|
|
|
@@ -5532,35 +5277,56 @@ const Sessions = (() => {
|
|
|
5532
5277
|
const modal = document.createElement("div");
|
|
5533
5278
|
modal.className = "modal-content dp-modal";
|
|
5534
5279
|
|
|
5280
|
+
// Left sidebar — quick-access places (favorites + locations)
|
|
5281
|
+
const sidebar = document.createElement("div");
|
|
5282
|
+
sidebar.className = "dp-sidebar";
|
|
5283
|
+
sidebar.innerHTML = `<div class="dp-sidebar-label">${t("sib.dir.favorites", "快捷访问")}</div>`;
|
|
5284
|
+
|
|
5285
|
+
// Main column — toolbar + list + footer
|
|
5286
|
+
const main = document.createElement("div");
|
|
5287
|
+
main.className = "dp-main";
|
|
5288
|
+
|
|
5535
5289
|
// ── Toolbar ──────────────────────────────────────────────────────
|
|
5536
5290
|
const toolbar = document.createElement("div");
|
|
5537
5291
|
toolbar.className = "dp-toolbar";
|
|
5538
5292
|
|
|
5539
|
-
// Back / Forward buttons
|
|
5293
|
+
// Back / Forward / Refresh buttons
|
|
5540
5294
|
const backBtn = document.createElement("button");
|
|
5541
5295
|
backBtn.className = "dp-nav-btn";
|
|
5542
5296
|
backBtn.title = t("sib.dir.back", "后退");
|
|
5543
|
-
backBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
5297
|
+
backBtn.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" stroke-linejoin="round"><path d="M22 12H2"/><path d="m9 4-7 8 7 8"/></svg>`;
|
|
5544
5298
|
backBtn.disabled = true;
|
|
5545
5299
|
|
|
5546
5300
|
const fwdBtn = document.createElement("button");
|
|
5547
5301
|
fwdBtn.className = "dp-nav-btn";
|
|
5548
5302
|
fwdBtn.title = t("sib.dir.forward", "前进");
|
|
5549
|
-
fwdBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
5303
|
+
fwdBtn.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" stroke-linejoin="round"><path d="M2 12h20"/><path d="m15 4 7 8-7 8"/></svg>`;
|
|
5550
5304
|
fwdBtn.disabled = true;
|
|
5551
5305
|
|
|
5306
|
+
const refreshBtn = document.createElement("button");
|
|
5307
|
+
refreshBtn.className = "dp-nav-btn";
|
|
5308
|
+
refreshBtn.title = t("sib.dir.refresh", "刷新");
|
|
5309
|
+
refreshBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path transform="translate(3.5 3.5) scale(0.0166015625)" d="M914.17946 324.34283C854.308387 324.325508 750.895846 324.317788 750.895846 324.317788 732.045471 324.317788 716.764213 339.599801 716.764213 358.451121 716.764213 377.30244 732.045471 392.584453 750.895846 392.584453L955.787864 392.584453C993.448095 392.584453 1024 362.040424 1024 324.368908L1024 119.466667C1024 100.615347 1008.718742 85.333333 989.868367 85.333333 971.017993 85.333333 955.736735 100.615347 955.736735 119.466667L955.736735 256.497996C933.314348 217.628194 905.827487 181.795372 873.995034 149.961328 778.623011 54.584531 649.577119 0 511.974435 0 229.218763 0 0 229.230209 0 512 0 794.769791 229.218763 1024 511.974435 1024 794.730125 1024 1023.948888 794.769791 1023.948888 512 1023.948888 493.148681 1008.66763 477.866667 989.817256 477.866667 970.966881 477.866667 955.685623 493.148681 955.685623 512 955.685623 757.067153 757.029358 955.733333 511.974435 955.733333 266.91953 955.733333 68.263265 757.067153 68.263265 512 68.263265 266.932847 266.91953 68.266667 511.974435 68.266667 631.286484 68.266667 743.028524 115.531923 825.725634 198.233152 862.329644 234.839003 892.298522 277.528256 914.17946 324.34283L914.17946 324.34283Z" stroke="currentColor" stroke-width="35" stroke-linejoin="round"/></svg>`;
|
|
5310
|
+
|
|
5552
5311
|
const navBtns = document.createElement("div");
|
|
5553
5312
|
navBtns.className = "dp-nav-btns";
|
|
5554
5313
|
navBtns.appendChild(backBtn);
|
|
5555
5314
|
navBtns.appendChild(fwdBtn);
|
|
5315
|
+
navBtns.appendChild(refreshBtn);
|
|
5556
5316
|
|
|
5557
5317
|
// Breadcrumb
|
|
5558
5318
|
const breadcrumb = document.createElement("div");
|
|
5559
5319
|
breadcrumb.className = "dp-breadcrumb";
|
|
5560
5320
|
|
|
5321
|
+
const favoriteBtn = document.createElement("button");
|
|
5322
|
+
favoriteBtn.className = "dp-nav-btn dp-favorite-btn";
|
|
5323
|
+
favoriteBtn.innerHTML = ICON_STAR_SVG;
|
|
5324
|
+
favoriteBtn.disabled = true;
|
|
5325
|
+
|
|
5561
5326
|
toolbar.appendChild(navBtns);
|
|
5562
5327
|
toolbar.appendChild(breadcrumb);
|
|
5563
|
-
|
|
5328
|
+
toolbar.appendChild(favoriteBtn);
|
|
5329
|
+
main.appendChild(toolbar);
|
|
5564
5330
|
|
|
5565
5331
|
// ── Column header ─────────────────────────────────────────────────
|
|
5566
5332
|
const colHeader = document.createElement("div");
|
|
@@ -5580,13 +5346,13 @@ const Sessions = (() => {
|
|
|
5580
5346
|
hiddenToggle.appendChild(hiddenLabelText);
|
|
5581
5347
|
colHeader.appendChild(colName);
|
|
5582
5348
|
colHeader.appendChild(hiddenToggle);
|
|
5583
|
-
|
|
5349
|
+
main.appendChild(colHeader);
|
|
5584
5350
|
|
|
5585
5351
|
// ── List container ────────────────────────────────────────────────
|
|
5586
5352
|
const listContainer = document.createElement("div");
|
|
5587
5353
|
listContainer.className = "dp-list";
|
|
5588
5354
|
listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
|
|
5589
|
-
|
|
5355
|
+
main.appendChild(listContainer);
|
|
5590
5356
|
|
|
5591
5357
|
// ── Footer ────────────────────────────────────────────────────────
|
|
5592
5358
|
const footer = document.createElement("div");
|
|
@@ -5613,7 +5379,10 @@ const Sessions = (() => {
|
|
|
5613
5379
|
footerActions.appendChild(confirmButton);
|
|
5614
5380
|
footer.appendChild(newFolderBtn);
|
|
5615
5381
|
footer.appendChild(footerActions);
|
|
5616
|
-
|
|
5382
|
+
main.appendChild(footer);
|
|
5383
|
+
|
|
5384
|
+
modal.appendChild(sidebar);
|
|
5385
|
+
modal.appendChild(main);
|
|
5617
5386
|
|
|
5618
5387
|
overlay.appendChild(modal);
|
|
5619
5388
|
document.body.appendChild(overlay);
|
|
@@ -5621,71 +5390,320 @@ const Sessions = (() => {
|
|
|
5621
5390
|
// ── History stack for back/fwd ────────────────────────────────────
|
|
5622
5391
|
const history = [];
|
|
5623
5392
|
let historyIndex = -1;
|
|
5393
|
+
let breadcrumbMenu = null;
|
|
5394
|
+
let breadcrumbMenuAnchor = null;
|
|
5395
|
+
let breadcrumbMenuRequest = 0;
|
|
5624
5396
|
|
|
5625
5397
|
function refreshNavBtns() {
|
|
5626
5398
|
backBtn.disabled = historyIndex <= 0;
|
|
5627
5399
|
fwdBtn.disabled = historyIndex >= history.length - 1;
|
|
5628
5400
|
}
|
|
5629
5401
|
|
|
5630
|
-
//
|
|
5402
|
+
// Render the sidebar quick-access favorites + drives
|
|
5403
|
+
function renderSidebar() {
|
|
5404
|
+
sidebar.innerHTML = "";
|
|
5405
|
+
|
|
5406
|
+
const quickAccess = sidebarPlaces.filter(p => p.kind !== "drive");
|
|
5407
|
+
const drives = sidebarPlaces.filter(p => p.kind === "drive");
|
|
5408
|
+
|
|
5409
|
+
const addSection = (labelKey, labelFallback, items) => {
|
|
5410
|
+
if (!items.length) return;
|
|
5411
|
+
const label = document.createElement("div");
|
|
5412
|
+
label.className = "dp-sidebar-label";
|
|
5413
|
+
label.textContent = t(labelKey, labelFallback);
|
|
5414
|
+
sidebar.appendChild(label);
|
|
5415
|
+
items.forEach(place => sidebar.appendChild(buildSidebarItem(place)));
|
|
5416
|
+
};
|
|
5417
|
+
|
|
5418
|
+
addSection("sib.dir.favorites", "Quick access", quickAccess);
|
|
5419
|
+
if (directoryFavorites.length) {
|
|
5420
|
+
const label = document.createElement("div");
|
|
5421
|
+
label.className = "dp-sidebar-label";
|
|
5422
|
+
label.textContent = t("sib.dir.userFavorites", "My favorites");
|
|
5423
|
+
sidebar.appendChild(label);
|
|
5424
|
+
directoryFavorites.forEach(path => sidebar.appendChild(buildFavoriteSidebarItem(path)));
|
|
5425
|
+
}
|
|
5426
|
+
addSection("sib.dir.drives", "Drives", drives);
|
|
5427
|
+
}
|
|
5428
|
+
|
|
5429
|
+
function buildSidebarItem(place) {
|
|
5430
|
+
const item = document.createElement("button");
|
|
5431
|
+
item.type = "button";
|
|
5432
|
+
item.className = "dp-sidebar-item";
|
|
5433
|
+
item.title = place.path;
|
|
5434
|
+
|
|
5435
|
+
const icon = document.createElement("span");
|
|
5436
|
+
icon.className = "dp-sidebar-icon";
|
|
5437
|
+
icon.innerHTML = place.kind === "drive" ? ICON_DRIVE_SVG : ICON_FOLDER_SVG;
|
|
5438
|
+
|
|
5439
|
+
const name = document.createElement("span");
|
|
5440
|
+
name.className = "dp-sidebar-name";
|
|
5441
|
+
|
|
5442
|
+
let label = place.label;
|
|
5443
|
+
if (!label && place.kind === "drive" && place.letter) {
|
|
5444
|
+
label = I18n.t("sib.dir.places.drive", { letter: place.letter });
|
|
5445
|
+
}
|
|
5446
|
+
if (!label) label = t("sib.dir.places." + place.id, place.path);
|
|
5447
|
+
name.textContent = label;
|
|
5448
|
+
|
|
5449
|
+
item.appendChild(icon);
|
|
5450
|
+
item.appendChild(name);
|
|
5451
|
+
item.addEventListener("click", () => navigateTo(place.path, true));
|
|
5452
|
+
return item;
|
|
5453
|
+
}
|
|
5454
|
+
|
|
5455
|
+
function buildFavoriteSidebarItem(path) {
|
|
5456
|
+
const item = document.createElement("div");
|
|
5457
|
+
item.className = "dp-sidebar-favorite";
|
|
5458
|
+
item.title = path;
|
|
5459
|
+
|
|
5460
|
+
const openButton = document.createElement("button");
|
|
5461
|
+
openButton.type = "button";
|
|
5462
|
+
openButton.className = "dp-sidebar-item dp-sidebar-favorite-open";
|
|
5463
|
+
|
|
5464
|
+
const icon = document.createElement("span");
|
|
5465
|
+
icon.className = "dp-sidebar-icon";
|
|
5466
|
+
icon.innerHTML = ICON_FOLDER_SVG;
|
|
5467
|
+
|
|
5468
|
+
const name = document.createElement("span");
|
|
5469
|
+
name.className = "dp-sidebar-name";
|
|
5470
|
+
name.textContent = path === "/" ? "/" : path.split("/").filter(Boolean).pop() || path;
|
|
5471
|
+
|
|
5472
|
+
const removeButton = document.createElement("button");
|
|
5473
|
+
removeButton.type = "button";
|
|
5474
|
+
removeButton.className = "dp-sidebar-favorite-remove";
|
|
5475
|
+
removeButton.title = t("sib.dir.removeFavorite", "Remove from favorites");
|
|
5476
|
+
removeButton.setAttribute("aria-label", removeButton.title);
|
|
5477
|
+
removeButton.innerHTML = '<svg 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"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
|
5478
|
+
|
|
5479
|
+
openButton.appendChild(icon);
|
|
5480
|
+
openButton.appendChild(name);
|
|
5481
|
+
openButton.addEventListener("click", () => navigateTo(path, true));
|
|
5482
|
+
removeButton.addEventListener("click", (e) => {
|
|
5483
|
+
e.stopPropagation();
|
|
5484
|
+
directoryFavorites = directoryFavorites.filter(favorite => favorite !== path);
|
|
5485
|
+
saveDirectoryFavorites(directoryFavorites);
|
|
5486
|
+
renderSidebar();
|
|
5487
|
+
refreshFavoriteButton();
|
|
5488
|
+
});
|
|
5489
|
+
|
|
5490
|
+
item.appendChild(openButton);
|
|
5491
|
+
item.appendChild(removeButton);
|
|
5492
|
+
return item;
|
|
5493
|
+
}
|
|
5494
|
+
|
|
5495
|
+
function refreshFavoriteButton() {
|
|
5496
|
+
const path = normalizeFavoritePath(currentPath);
|
|
5497
|
+
const isFavorite = directoryFavorites.includes(path);
|
|
5498
|
+
favoriteBtn.disabled = !currentPathLoaded;
|
|
5499
|
+
favoriteBtn.classList.toggle("active", isFavorite);
|
|
5500
|
+
favoriteBtn.setAttribute("aria-pressed", isFavorite ? "true" : "false");
|
|
5501
|
+
favoriteBtn.title = t(
|
|
5502
|
+
isFavorite ? "sib.dir.removeFavorite" : "sib.dir.addFavorite",
|
|
5503
|
+
isFavorite ? "Remove from favorites" : "Add to favorites"
|
|
5504
|
+
);
|
|
5505
|
+
favoriteBtn.setAttribute("aria-label", favoriteBtn.title);
|
|
5506
|
+
}
|
|
5507
|
+
|
|
5508
|
+
function closeBreadcrumbMenu() {
|
|
5509
|
+
breadcrumbMenuRequest++;
|
|
5510
|
+
breadcrumbMenu?.remove();
|
|
5511
|
+
breadcrumbMenuAnchor?.classList.remove("open");
|
|
5512
|
+
breadcrumbMenu = null;
|
|
5513
|
+
breadcrumbMenuAnchor = null;
|
|
5514
|
+
}
|
|
5515
|
+
|
|
5516
|
+
function positionBreadcrumbMenu(menu, anchor) {
|
|
5517
|
+
const rect = anchor.getBoundingClientRect();
|
|
5518
|
+
const maxLeft = Math.max(8, window.innerWidth - menu.offsetWidth - 8);
|
|
5519
|
+
menu.style.left = `${Math.min(rect.left, maxLeft)}px`;
|
|
5520
|
+
menu.style.top = `${rect.bottom + 4}px`;
|
|
5521
|
+
}
|
|
5522
|
+
|
|
5523
|
+
async function toggleBreadcrumbMenu(anchor, parentPath) {
|
|
5524
|
+
if (breadcrumbMenuAnchor === anchor) {
|
|
5525
|
+
closeBreadcrumbMenu();
|
|
5526
|
+
return;
|
|
5527
|
+
}
|
|
5528
|
+
|
|
5529
|
+
closeBreadcrumbMenu();
|
|
5530
|
+
const requestId = breadcrumbMenuRequest;
|
|
5531
|
+
const menu = document.createElement("div");
|
|
5532
|
+
menu.className = "dp-bc-menu";
|
|
5533
|
+
menu.innerHTML = `<div class="dp-bc-menu-state">${t("sib.dir.loading", "Loading…")}</div>`;
|
|
5534
|
+
overlay.appendChild(menu);
|
|
5535
|
+
breadcrumbMenu = menu;
|
|
5536
|
+
breadcrumbMenuAnchor = anchor;
|
|
5537
|
+
anchor.classList.add("open");
|
|
5538
|
+
positionBreadcrumbMenu(menu, anchor);
|
|
5539
|
+
|
|
5540
|
+
try {
|
|
5541
|
+
const { dirs } = await fetchDirs(parentPath, true);
|
|
5542
|
+
if (requestId !== breadcrumbMenuRequest || breadcrumbMenu !== menu) return;
|
|
5543
|
+
menu.innerHTML = "";
|
|
5544
|
+
if (dirs.length === 0) {
|
|
5545
|
+
menu.innerHTML = `<div class="dp-bc-menu-state">${t("sib.dir.empty", "Empty directory")}</div>`;
|
|
5546
|
+
} else {
|
|
5547
|
+
dirs.forEach(entry => {
|
|
5548
|
+
const item = document.createElement("button");
|
|
5549
|
+
item.type = "button";
|
|
5550
|
+
item.className = "dp-bc-menu-item";
|
|
5551
|
+
item.innerHTML = `<span class="dp-bc-menu-icon">${ICON_FOLDER_SVG}</span>`;
|
|
5552
|
+
const label = document.createElement("span");
|
|
5553
|
+
label.className = "dp-bc-menu-name";
|
|
5554
|
+
label.textContent = entry.name;
|
|
5555
|
+
item.appendChild(label);
|
|
5556
|
+
item.addEventListener("click", (e) => {
|
|
5557
|
+
e.stopPropagation();
|
|
5558
|
+
closeBreadcrumbMenu();
|
|
5559
|
+
navigateTo(entry.absPath, true);
|
|
5560
|
+
});
|
|
5561
|
+
menu.appendChild(item);
|
|
5562
|
+
});
|
|
5563
|
+
}
|
|
5564
|
+
positionBreadcrumbMenu(menu, anchor);
|
|
5565
|
+
} catch (err) {
|
|
5566
|
+
if (requestId !== breadcrumbMenuRequest || breadcrumbMenu !== menu) return;
|
|
5567
|
+
console.error("dir picker breadcrumb load failed:", err);
|
|
5568
|
+
menu.innerHTML = `<div class="dp-bc-menu-state dp-bc-menu-error">${t("sib.dir.loadError", "Failed to load")}</div>`;
|
|
5569
|
+
positionBreadcrumbMenu(menu, anchor);
|
|
5570
|
+
}
|
|
5571
|
+
}
|
|
5572
|
+
|
|
5573
|
+
function appendBreadcrumbPart(label, targetPath, current = false, showArrow = true) {
|
|
5574
|
+
const seg = document.createElement("button");
|
|
5575
|
+
seg.type = "button";
|
|
5576
|
+
seg.className = "dp-bc-seg";
|
|
5577
|
+
if (current) seg.classList.add("dp-bc-current");
|
|
5578
|
+
seg.textContent = label;
|
|
5579
|
+
seg.title = targetPath;
|
|
5580
|
+
seg.addEventListener("click", (e) => {
|
|
5581
|
+
e.stopPropagation();
|
|
5582
|
+
navigateTo(targetPath, true);
|
|
5583
|
+
});
|
|
5584
|
+
|
|
5585
|
+
breadcrumb.appendChild(seg);
|
|
5586
|
+
if (showArrow) {
|
|
5587
|
+
const arrow = document.createElement("button");
|
|
5588
|
+
arrow.type = "button";
|
|
5589
|
+
arrow.className = "dp-bc-sep";
|
|
5590
|
+
arrow.title = targetPath;
|
|
5591
|
+
arrow.innerHTML = ICON_CARET_SVG;
|
|
5592
|
+
arrow.addEventListener("click", (e) => {
|
|
5593
|
+
e.stopPropagation();
|
|
5594
|
+
toggleBreadcrumbMenu(arrow, targetPath);
|
|
5595
|
+
});
|
|
5596
|
+
breadcrumb.appendChild(arrow);
|
|
5597
|
+
}
|
|
5598
|
+
}
|
|
5599
|
+
|
|
5600
|
+
// Build a Windows-style breadcrumb from an absolute path.
|
|
5631
5601
|
function refreshBreadcrumb(absPath) {
|
|
5602
|
+
closeBreadcrumbMenu();
|
|
5603
|
+
breadcrumb.classList.remove("editing");
|
|
5632
5604
|
breadcrumb.innerHTML = "";
|
|
5633
5605
|
if (!absPath) return;
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
parts.
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5606
|
+
|
|
5607
|
+
const normalized = absPath.replaceAll("\\", "/").replace(/\/+$/, "") || "/";
|
|
5608
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
5609
|
+
let accumulated = "";
|
|
5610
|
+
|
|
5611
|
+
if (normalized.startsWith("/")) {
|
|
5612
|
+
const isCurrent = parts.length === 0;
|
|
5613
|
+
appendBreadcrumbPart("/", "/", isCurrent, !isCurrent || currentHasChildDirs);
|
|
5614
|
+
} else if (parts.length > 0) {
|
|
5615
|
+
accumulated = parts.shift();
|
|
5616
|
+
const isCurrent = parts.length === 0;
|
|
5617
|
+
appendBreadcrumbPart(accumulated, `${accumulated}/`, isCurrent, !isCurrent || currentHasChildDirs);
|
|
5618
|
+
}
|
|
5619
|
+
|
|
5620
|
+
parts.forEach((part, index) => {
|
|
5621
|
+
accumulated = normalized.startsWith("/")
|
|
5622
|
+
? `${accumulated}/${part}`
|
|
5623
|
+
: `${accumulated}/${part}`;
|
|
5624
|
+
const targetPath = normalized.startsWith("/") ? accumulated || "/" : accumulated;
|
|
5625
|
+
const isCurrent = index === parts.length - 1;
|
|
5626
|
+
appendBreadcrumbPart(part, targetPath, isCurrent, !isCurrent || currentHasChildDirs);
|
|
5627
|
+
});
|
|
5628
|
+
|
|
5629
|
+
requestAnimationFrame(() => { breadcrumb.scrollLeft = breadcrumb.scrollWidth; });
|
|
5630
|
+
}
|
|
5631
|
+
|
|
5632
|
+
function enterAddressMode(value = currentPath) {
|
|
5633
|
+
closeBreadcrumbMenu();
|
|
5634
|
+
breadcrumb.classList.add("editing");
|
|
5635
|
+
breadcrumb.innerHTML = "";
|
|
5636
|
+
|
|
5637
|
+
const input = document.createElement("input");
|
|
5638
|
+
input.type = "text";
|
|
5639
|
+
input.className = "dp-address-input";
|
|
5640
|
+
input.value = value || "";
|
|
5641
|
+
input.placeholder = t("sib.dir.inputPlaceholder", "Type or select a directory path");
|
|
5642
|
+
input.setAttribute("aria-label", input.placeholder);
|
|
5643
|
+
input.spellcheck = false;
|
|
5644
|
+
input.autocomplete = "off";
|
|
5645
|
+
input.setAttribute("autocapitalize", "off");
|
|
5646
|
+
breadcrumb.appendChild(input);
|
|
5647
|
+
|
|
5648
|
+
let submitting = false;
|
|
5649
|
+
const cancel = () => {
|
|
5650
|
+
if (!breadcrumb.contains(input)) return;
|
|
5651
|
+
refreshBreadcrumb(currentPath);
|
|
5652
|
+
if (listContainer.querySelector(".dp-error")) navigateTo(currentPath, true, false);
|
|
5653
|
+
};
|
|
5654
|
+
IME.bindEnter(input, async () => {
|
|
5655
|
+
const path = input.value.trim();
|
|
5656
|
+
if (!path || submitting) return;
|
|
5657
|
+
submitting = true;
|
|
5658
|
+
input.disabled = true;
|
|
5659
|
+
const navigated = await navigateTo(path, true, true, true);
|
|
5660
|
+
if (!navigated) {
|
|
5661
|
+
enterAddressMode(path);
|
|
5662
|
+
const retryInput = breadcrumb.querySelector(".dp-address-input");
|
|
5663
|
+
retryInput?.classList.add("invalid");
|
|
5645
5664
|
}
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
seg.textContent = part;
|
|
5653
|
-
const targetPath = parts.slice(0, i + 1).join("/") || "/";
|
|
5654
|
-
seg.addEventListener("click", () => navigateTo(targetPath, true));
|
|
5655
|
-
// Last segment = current dir, styled differently
|
|
5656
|
-
if (i === parts.length - 1) {
|
|
5657
|
-
seg.classList.add("dp-bc-current");
|
|
5658
|
-
// Add folder icon before last segment
|
|
5659
|
-
const folderIcon = document.createElement("span");
|
|
5660
|
-
folderIcon.className = "dp-bc-folder-icon";
|
|
5661
|
-
folderIcon.innerHTML = ICON_FOLDER_SVG;
|
|
5662
|
-
breadcrumb.appendChild(sep);
|
|
5663
|
-
breadcrumb.appendChild(folderIcon);
|
|
5664
|
-
} else {
|
|
5665
|
-
breadcrumb.appendChild(sep);
|
|
5665
|
+
});
|
|
5666
|
+
input.addEventListener("keydown", (e) => {
|
|
5667
|
+
e.stopPropagation();
|
|
5668
|
+
if (e.key === "Escape") {
|
|
5669
|
+
e.preventDefault();
|
|
5670
|
+
cancel();
|
|
5666
5671
|
}
|
|
5667
|
-
breadcrumb.appendChild(seg);
|
|
5668
5672
|
});
|
|
5669
|
-
|
|
5670
|
-
|
|
5673
|
+
input.addEventListener("click", (e) => e.stopPropagation());
|
|
5674
|
+
input.addEventListener("blur", () => {
|
|
5675
|
+
if (!submitting) cancel();
|
|
5676
|
+
});
|
|
5677
|
+
|
|
5678
|
+
requestAnimationFrame(() => {
|
|
5679
|
+
input.focus();
|
|
5680
|
+
input.select();
|
|
5681
|
+
});
|
|
5671
5682
|
}
|
|
5672
5683
|
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
5684
|
+
breadcrumb.addEventListener("click", (e) => {
|
|
5685
|
+
if (e.target === breadcrumb) enterAddressMode();
|
|
5686
|
+
});
|
|
5677
5687
|
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
history.push(dirPath);
|
|
5683
|
-
historyIndex = history.length - 1;
|
|
5684
|
-
refreshNavBtns();
|
|
5688
|
+
function comparablePath(path) {
|
|
5689
|
+
let normalized = (path || "").trim().replaceAll("\\", "/");
|
|
5690
|
+
if (normalized === "~" || normalized.startsWith("~/")) {
|
|
5691
|
+
normalized = `${homeDir}${normalized.slice(1)}`;
|
|
5685
5692
|
}
|
|
5693
|
+
return normalized.replace(/\/+$/, "") || "/";
|
|
5694
|
+
}
|
|
5686
5695
|
|
|
5687
|
-
|
|
5688
|
-
|
|
5696
|
+
// Core navigation function
|
|
5697
|
+
async function navigateTo(dirPath, absolute = true, pushHistory = true, requireExact = false) {
|
|
5698
|
+
closeBreadcrumbMenu();
|
|
5699
|
+
if (pushHistory && historyIndex >= 0
|
|
5700
|
+
&& comparablePath(currentPath) === comparablePath(dirPath)) {
|
|
5701
|
+
return true;
|
|
5702
|
+
}
|
|
5703
|
+
|
|
5704
|
+
favoriteBtn.disabled = true;
|
|
5705
|
+
listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
|
|
5706
|
+
listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
5689
5707
|
|
|
5690
5708
|
const useAbsolute = absolute || sessionLess || dirPath.startsWith("/");
|
|
5691
5709
|
let relPath = dirPath;
|
|
@@ -5694,16 +5712,37 @@ const Sessions = (() => {
|
|
|
5694
5712
|
}
|
|
5695
5713
|
|
|
5696
5714
|
try {
|
|
5697
|
-
const dirs = await fetchDirs(relPath, useAbsolute);
|
|
5698
|
-
|
|
5715
|
+
const { dirs, resolvedPath, exact } = await fetchDirs(relPath, useAbsolute);
|
|
5716
|
+
const exactMatch = exact === undefined
|
|
5717
|
+
? comparablePath(resolvedPath) === comparablePath(dirPath)
|
|
5718
|
+
: exact;
|
|
5719
|
+
if (requireExact && !exactMatch) {
|
|
5720
|
+
throw new Error("Directory not found");
|
|
5721
|
+
}
|
|
5722
|
+
|
|
5723
|
+
let effectivePath = useAbsolute && resolvedPath ? resolvedPath : dirPath;
|
|
5724
|
+
// After first relative load, rootDir is now known.
|
|
5699
5725
|
if (rootDir && dirPath === "") {
|
|
5700
|
-
|
|
5726
|
+
effectivePath = currentDir && (currentDir === rootDir || currentDir.startsWith(rootDir + "/"))
|
|
5701
5727
|
? currentDir
|
|
5702
5728
|
: rootDir;
|
|
5703
|
-
selectedPath = effectivePath;
|
|
5704
|
-
refreshBreadcrumb(effectivePath);
|
|
5705
|
-
history[historyIndex] = effectivePath;
|
|
5706
5729
|
}
|
|
5730
|
+
|
|
5731
|
+
const isNewHistoryEntry = historyIndex < 0
|
|
5732
|
+
|| comparablePath(history[historyIndex]) !== comparablePath(effectivePath);
|
|
5733
|
+
if (pushHistory && isNewHistoryEntry) {
|
|
5734
|
+
history.splice(historyIndex + 1);
|
|
5735
|
+
history.push(effectivePath);
|
|
5736
|
+
historyIndex = history.length - 1;
|
|
5737
|
+
}
|
|
5738
|
+
refreshNavBtns();
|
|
5739
|
+
|
|
5740
|
+
currentPath = effectivePath;
|
|
5741
|
+
selectedPath = effectivePath;
|
|
5742
|
+
currentHasChildDirs = dirs.length > 0;
|
|
5743
|
+
currentPathLoaded = true;
|
|
5744
|
+
refreshBreadcrumb(effectivePath);
|
|
5745
|
+
refreshFavoriteButton();
|
|
5707
5746
|
listContainer.innerHTML = "";
|
|
5708
5747
|
if (dirs.length === 0) {
|
|
5709
5748
|
listContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
|
|
@@ -5712,37 +5751,63 @@ const Sessions = (() => {
|
|
|
5712
5751
|
dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
|
|
5713
5752
|
listContainer.appendChild(frag);
|
|
5714
5753
|
}
|
|
5754
|
+
return true;
|
|
5715
5755
|
} catch (err) {
|
|
5716
5756
|
console.error("dir picker load failed:", err);
|
|
5757
|
+
refreshFavoriteButton();
|
|
5717
5758
|
listContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
|
|
5759
|
+
return false;
|
|
5718
5760
|
}
|
|
5719
5761
|
}
|
|
5720
5762
|
|
|
5721
5763
|
// Back / Forward
|
|
5722
|
-
backBtn.addEventListener("click", () => {
|
|
5764
|
+
backBtn.addEventListener("click", async () => {
|
|
5723
5765
|
if (historyIndex <= 0) return;
|
|
5724
|
-
historyIndex
|
|
5725
|
-
|
|
5726
|
-
|
|
5766
|
+
const targetIndex = historyIndex - 1;
|
|
5767
|
+
if (await navigateTo(history[targetIndex], true, false)) {
|
|
5768
|
+
historyIndex = targetIndex;
|
|
5769
|
+
refreshNavBtns();
|
|
5770
|
+
}
|
|
5727
5771
|
});
|
|
5728
|
-
fwdBtn.addEventListener("click", () => {
|
|
5772
|
+
fwdBtn.addEventListener("click", async () => {
|
|
5729
5773
|
if (historyIndex >= history.length - 1) return;
|
|
5730
|
-
historyIndex
|
|
5731
|
-
|
|
5732
|
-
|
|
5774
|
+
const targetIndex = historyIndex + 1;
|
|
5775
|
+
if (await navigateTo(history[targetIndex], true, false)) {
|
|
5776
|
+
historyIndex = targetIndex;
|
|
5777
|
+
refreshNavBtns();
|
|
5778
|
+
}
|
|
5779
|
+
});
|
|
5780
|
+
refreshBtn.addEventListener("click", async () => {
|
|
5781
|
+
refreshBtn.disabled = true;
|
|
5782
|
+
try {
|
|
5783
|
+
await navigateTo(currentPath || currentDir, true, false);
|
|
5784
|
+
} finally {
|
|
5785
|
+
refreshBtn.disabled = false;
|
|
5786
|
+
}
|
|
5787
|
+
});
|
|
5788
|
+
favoriteBtn.addEventListener("click", () => {
|
|
5789
|
+
const path = normalizeFavoritePath(currentPath);
|
|
5790
|
+
if (directoryFavorites.includes(path)) {
|
|
5791
|
+
directoryFavorites = directoryFavorites.filter(favorite => favorite !== path);
|
|
5792
|
+
} else {
|
|
5793
|
+
directoryFavorites.push(path);
|
|
5794
|
+
}
|
|
5795
|
+
saveDirectoryFavorites(directoryFavorites);
|
|
5796
|
+
renderSidebar();
|
|
5797
|
+
refreshFavoriteButton();
|
|
5733
5798
|
});
|
|
5734
5799
|
|
|
5735
5800
|
// Hidden files toggle
|
|
5736
5801
|
hiddenCheckbox.addEventListener("change", () => {
|
|
5737
5802
|
showHidden = hiddenCheckbox.checked;
|
|
5738
|
-
navigateTo(
|
|
5803
|
+
navigateTo(currentPath || currentDir, true, false);
|
|
5739
5804
|
});
|
|
5740
5805
|
|
|
5741
5806
|
// New folder — inserts an editable placeholder row at the top of the list
|
|
5742
5807
|
newFolderBtn.addEventListener("click", () => beginCreateFolder());
|
|
5743
5808
|
|
|
5744
5809
|
function beginCreateFolder() {
|
|
5745
|
-
const parentDir = (
|
|
5810
|
+
const parentDir = (currentPath || "").trim();
|
|
5746
5811
|
if (!parentDir.startsWith("/")) {
|
|
5747
5812
|
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
|
|
5748
5813
|
return;
|
|
@@ -5799,7 +5864,8 @@ const Sessions = (() => {
|
|
|
5799
5864
|
listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
5800
5865
|
realNode._dpRow.classList.add("selected");
|
|
5801
5866
|
selectedPath = data.path;
|
|
5802
|
-
|
|
5867
|
+
currentHasChildDirs = true;
|
|
5868
|
+
refreshBreadcrumb(currentPath);
|
|
5803
5869
|
} catch (err) {
|
|
5804
5870
|
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
|
|
5805
5871
|
placeholder.remove();
|
|
@@ -5848,26 +5914,29 @@ const Sessions = (() => {
|
|
|
5848
5914
|
const startDir = currentDir || "/";
|
|
5849
5915
|
navigateTo(startDir, true);
|
|
5850
5916
|
} else {
|
|
5851
|
-
//
|
|
5852
|
-
//
|
|
5853
|
-
navigateTo("", false).then
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
if (currentDir && currentDir !== rootDir) {
|
|
5859
|
-
navigateTo(currentDir, currentDir.startsWith("/"));
|
|
5860
|
-
}
|
|
5861
|
-
}, 100);
|
|
5862
|
-
}
|
|
5917
|
+
// Load the relative root first so rootDir is known, then navigate to a
|
|
5918
|
+
// deeper current directory without racing two initial requests.
|
|
5919
|
+
navigateTo("", false).then((loaded) => {
|
|
5920
|
+
if (loaded && currentDir && currentDir !== rootDir) {
|
|
5921
|
+
navigateTo(currentDir, currentDir.startsWith("/"));
|
|
5922
|
+
}
|
|
5923
|
+
});
|
|
5863
5924
|
}
|
|
5864
5925
|
|
|
5865
5926
|
overlay.addEventListener("keydown", (e) => {
|
|
5866
|
-
if (e.key
|
|
5927
|
+
if (e.key !== "Escape") return;
|
|
5928
|
+
if (breadcrumbMenu) {
|
|
5929
|
+
e.preventDefault();
|
|
5930
|
+
closeBreadcrumbMenu();
|
|
5931
|
+
return;
|
|
5932
|
+
}
|
|
5933
|
+
cancelButton.click();
|
|
5867
5934
|
});
|
|
5868
5935
|
overlay.addEventListener("click", (e) => {
|
|
5936
|
+
if (breadcrumbMenu && !breadcrumbMenu.contains(e.target)) closeBreadcrumbMenu();
|
|
5869
5937
|
if (e.target === overlay) cancelButton.click();
|
|
5870
5938
|
});
|
|
5939
|
+
breadcrumb.addEventListener("scroll", closeBreadcrumbMenu);
|
|
5871
5940
|
});
|
|
5872
5941
|
}
|
|
5873
5942
|
|