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