openclacky 1.3.3 → 1.3.5
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 +53 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +5 -3
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +77 -11
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +343 -0
- data/lib/clacky/api_extension_loader.rb +168 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +47 -25
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/json_ui_controller.rb +1 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/media/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +291 -109
- data/lib/clacky/server/session_registry.rb +5 -3
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +147 -31
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +201 -81
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +19 -16
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +36 -23
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +48 -13
- data/lib/clacky/web/index.html +24 -17
- data/lib/clacky/web/sessions.js +427 -79
- data/lib/clacky/web/settings.js +143 -113
- data/lib/clacky/web/ws-dispatcher.js +18 -6
- data/lib/clacky.rb +27 -5
- metadata +45 -2
- data/lib/clacky/media/output_dir.rb +0 -43
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -124,6 +124,16 @@ const Sessions = (() => {
|
|
|
124
124
|
|
|
125
125
|
let html;
|
|
126
126
|
if (typeof marked !== "undefined") {
|
|
127
|
+
// Restore KTX placeholders that ended up inside code regions back to
|
|
128
|
+
// their original literal — those weren't math, just text that happened
|
|
129
|
+
// to look like math (C-5635). Marked alone decides what's a code region.
|
|
130
|
+
const restoreMathInCode = (s) =>
|
|
131
|
+
s.replace(/\u0000KTX(\d+)\u0000/g, (_, i) => {
|
|
132
|
+
const m = math[+i];
|
|
133
|
+
if (m) m.disabled = true; // suppress later KaTeX render
|
|
134
|
+
return m ? m.raw : "";
|
|
135
|
+
});
|
|
136
|
+
|
|
127
137
|
const renderer = new marked.Renderer();
|
|
128
138
|
renderer.link = function({ href, title, text }) {
|
|
129
139
|
const titleAttr = title ? ` title="${title}"` : "";
|
|
@@ -132,6 +142,7 @@ const Sessions = (() => {
|
|
|
132
142
|
// Override code block rendering: apply syntax highlighting + header with
|
|
133
143
|
// language label and copy button.
|
|
134
144
|
renderer.code = function({ text: code, lang }) {
|
|
145
|
+
code = restoreMathInCode(code);
|
|
135
146
|
const language = (lang || "").split(/\s+/)[0]; // strip extra info after lang
|
|
136
147
|
const highlighted = _highlightCode(code, language);
|
|
137
148
|
const displayLang = language || "text";
|
|
@@ -152,6 +163,10 @@ const Sessions = (() => {
|
|
|
152
163
|
`</div>`
|
|
153
164
|
);
|
|
154
165
|
};
|
|
166
|
+
// Inline code: same restoration, then plain <code> with HTML escaping.
|
|
167
|
+
renderer.codespan = function({ text }) {
|
|
168
|
+
return `<code>${escapeHtml(restoreMathInCode(text))}</code>`;
|
|
169
|
+
};
|
|
155
170
|
try {
|
|
156
171
|
html = marked.parse(prepared, { breaks: true, gfm: true, renderer });
|
|
157
172
|
} catch (_) {
|
|
@@ -163,7 +178,11 @@ const Sessions = (() => {
|
|
|
163
178
|
}
|
|
164
179
|
|
|
165
180
|
if (math.length) {
|
|
166
|
-
html = html.replace(/\u0000KTX(\d+)\u0000/g, (_, i) =>
|
|
181
|
+
html = html.replace(/\u0000KTX(\d+)\u0000/g, (_, i) => {
|
|
182
|
+
const m = math[+i];
|
|
183
|
+
if (!m || m.disabled) return ""; // already restored by renderer
|
|
184
|
+
return _renderMath(m);
|
|
185
|
+
});
|
|
167
186
|
}
|
|
168
187
|
return html;
|
|
169
188
|
}
|
|
@@ -187,22 +206,30 @@ const Sessions = (() => {
|
|
|
187
206
|
|
|
188
207
|
// Pull $$...$$, \[...\], $...$, \(...\) out of `text` and replace each with a
|
|
189
208
|
// sentinel placeholder so marked won't mangle the LaTeX source. The matched
|
|
190
|
-
// segments are pushed
|
|
209
|
+
// segments are pushed onto `out` as { body, display, raw } for later KaTeX
|
|
210
|
+
// rendering or — if the placeholder ends up landing inside a code block /
|
|
211
|
+
// code span — restoration to the original literal by the renderer (C-5635).
|
|
212
|
+
//
|
|
213
|
+
// Why no code-block detection here: we don't want a second, parallel notion
|
|
214
|
+
// of "what counts as code" living next to marked's. Instead we extract math
|
|
215
|
+
// unconditionally, then let marked be the single arbiter — its
|
|
216
|
+
// renderer.code / renderer.codespan hooks restore any placeholder that
|
|
217
|
+
// turns out to be inside a code region (see _markedParse).
|
|
191
218
|
function _extractMath(text, out, placeholder) {
|
|
192
219
|
// Order matters: longest/most-specific delimiters first.
|
|
193
220
|
const patterns = [
|
|
194
|
-
{ re: /\$\$([\s\S]+?)\$\$/g,
|
|
195
|
-
{ re: /\\\[([\s\S]+?)\\\]/g,
|
|
196
|
-
{ re: /\\\(([\s\S]+?)\\\)/g,
|
|
221
|
+
{ re: /\$\$([\s\S]+?)\$\$/g, display: true, wrap: (b) => `$$${b}$$` },
|
|
222
|
+
{ re: /\\\[([\s\S]+?)\\\]/g, display: true, wrap: (b) => `\\[${b}\\]` },
|
|
223
|
+
{ re: /\\\(([\s\S]+?)\\\)/g, display: false, wrap: (b) => `\\(${b}\\)` },
|
|
197
224
|
// Inline $...$: avoid $$, escaped \$, and prevent crossing newlines/blanks.
|
|
198
|
-
{ re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true },
|
|
225
|
+
{ re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true, wrap: (b) => `$${b}$` },
|
|
199
226
|
];
|
|
200
227
|
let result = text;
|
|
201
|
-
for (const { re, display, hasPrefix } of patterns) {
|
|
228
|
+
for (const { re, display, hasPrefix, wrap } of patterns) {
|
|
202
229
|
result = result.replace(re, (m, a, b) => {
|
|
203
230
|
const body = hasPrefix ? b : a;
|
|
204
231
|
const idx = out.length;
|
|
205
|
-
out.push({ body, display });
|
|
232
|
+
out.push({ body, display, raw: wrap(body) });
|
|
206
233
|
return (hasPrefix ? a : "") + placeholder(idx);
|
|
207
234
|
});
|
|
208
235
|
}
|
|
@@ -384,42 +411,27 @@ const Sessions = (() => {
|
|
|
384
411
|
_hideNewMessageBanner();
|
|
385
412
|
});
|
|
386
413
|
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
messages.addEventListener("keydown", detectUserScroll);
|
|
403
|
-
|
|
404
|
-
// For touch devices: touchmove doesn't tell us direction, so check in scroll event
|
|
405
|
-
let touchStartY = 0;
|
|
406
|
-
messages.addEventListener("touchstart", (e) => {
|
|
407
|
-
touchStartY = e.touches[0].clientY;
|
|
408
|
-
}, { passive: true });
|
|
409
|
-
|
|
410
|
-
messages.addEventListener("touchmove", (e) => {
|
|
411
|
-
const touchDeltaY = e.touches[0].clientY - touchStartY;
|
|
412
|
-
// touchDeltaY > 0 means finger moved down = content scrolls up
|
|
413
|
-
if (touchDeltaY > 5) {
|
|
414
|
-
_userScrolledUp = true;
|
|
415
|
-
}
|
|
416
|
-
}, { passive: true });
|
|
417
|
-
|
|
418
|
-
// Monitor scroll position: clear flag when user reaches bottom
|
|
414
|
+
// Single source of truth for "is the user browsing history?": the scroll
|
|
415
|
+
// position itself. Every scrolling method — mouse wheel, dragging the
|
|
416
|
+
// scrollbar, keyboard (Up/PageUp/Home/Space), touch swipe and momentum
|
|
417
|
+
// scrolling — funnels through the `scroll` event, so reading the position
|
|
418
|
+
// here covers them all with no blind spots.
|
|
419
|
+
//
|
|
420
|
+
// The previous approach instead listened to specific input events
|
|
421
|
+
// (wheel/keydown/touchmove) to *infer* intent. Dragging the scrollbar
|
|
422
|
+
// fires `scroll` but none of those, so it was never detected and AI
|
|
423
|
+
// messages kept yanking the view back to the bottom — see C-5629.
|
|
424
|
+
//
|
|
425
|
+
// Streaming-append safety: appending content grows scrollHeight but leaves
|
|
426
|
+
// scrollTop untouched (verified in-browser, even with overflow-anchor:auto),
|
|
427
|
+
// so a content update never moves us "away from bottom" and never trips a
|
|
428
|
+
// false positive here. The 150px threshold in _isAtBottom is extra slack.
|
|
419
429
|
messages.addEventListener("scroll", () => {
|
|
420
430
|
if (_isAtBottom(messages)) {
|
|
421
431
|
_userScrolledUp = false;
|
|
422
432
|
_hideNewMessageBanner();
|
|
433
|
+
} else {
|
|
434
|
+
_userScrolledUp = true;
|
|
423
435
|
}
|
|
424
436
|
});
|
|
425
437
|
}
|
|
@@ -441,30 +453,26 @@ const Sessions = (() => {
|
|
|
441
453
|
document.getElementById("btn-new-session-inline")
|
|
442
454
|
.addEventListener("click", () => Sessions.create("general"));
|
|
443
455
|
|
|
444
|
-
// Split button: arrow
|
|
445
|
-
document.getElementById("btn-new-session-arrow")
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const dd = document.getElementById("new-session-dropdown");
|
|
449
|
-
dd.hidden = !dd.hidden;
|
|
450
|
-
});
|
|
456
|
+
// Split button: arrow — show dropdown on hover, hide when leaving the whole wrap
|
|
457
|
+
const arrow = document.getElementById("btn-new-session-arrow");
|
|
458
|
+
const wrap = arrow.closest(".btn-split-wrap");
|
|
459
|
+
const dd = document.getElementById("new-session-dropdown");
|
|
451
460
|
|
|
452
|
-
|
|
453
|
-
|
|
461
|
+
arrow.addEventListener("mouseenter", () => { dd.hidden = false; });
|
|
462
|
+
dd.addEventListener("mouseenter", () => { dd.hidden = false; });
|
|
463
|
+
wrap.addEventListener("mouseleave", () => { dd.hidden = true; });
|
|
464
|
+
dd.addEventListener("mouseleave", () => { dd.hidden = true; });
|
|
465
|
+
|
|
466
|
+
// Dropdown item "Advanced Options…"
|
|
454
467
|
document.addEventListener("click", (e) => {
|
|
455
468
|
if (e.target && e.target.id === "btn-new-session-modal") {
|
|
456
|
-
|
|
457
|
-
document.getElementById("new-session-dropdown").hidden = true;
|
|
469
|
+
dd.hidden = true;
|
|
458
470
|
Sessions.openNewSessionModal();
|
|
471
|
+
} else if (!wrap.contains(e.target)) {
|
|
472
|
+
dd.hidden = true;
|
|
459
473
|
}
|
|
460
474
|
});
|
|
461
475
|
|
|
462
|
-
// Close dropdown when clicking anywhere else
|
|
463
|
-
document.addEventListener("click", () => {
|
|
464
|
-
const dd = document.getElementById("new-session-dropdown");
|
|
465
|
-
if (dd && !dd.hidden) dd.hidden = true;
|
|
466
|
-
});
|
|
467
|
-
|
|
468
476
|
// Welcome screen "+ New Session" button
|
|
469
477
|
document.getElementById("btn-welcome-new")
|
|
470
478
|
.addEventListener("click", () => Sessions.create("general"));
|
|
@@ -827,7 +835,7 @@ const Sessions = (() => {
|
|
|
827
835
|
_imageSeq = 0;
|
|
828
836
|
_renderAttachmentPreviews();
|
|
829
837
|
|
|
830
|
-
WS.send({ type: "message", session_id: Sessions.activeId, content, files });
|
|
838
|
+
WS.send({ type: "message", session_id: Sessions.activeId, content, files, lang: I18n.lang() });
|
|
831
839
|
|
|
832
840
|
// Disable any pending feedback cards — user has replied (either by clicking
|
|
833
841
|
// an option button or by typing directly). The backend has already consumed
|
|
@@ -1360,11 +1368,11 @@ const Sessions = (() => {
|
|
|
1360
1368
|
bubbleHtml += escapeHtml(ev.content || "");
|
|
1361
1369
|
el.innerHTML = bubbleHtml;
|
|
1362
1370
|
if (ev.created_at) el.dataset.createdAt = ev.created_at;
|
|
1363
|
-
_appendMsgTime(el, ev.created_at);
|
|
1364
1371
|
const wrap = document.createElement("div");
|
|
1365
1372
|
wrap.className = "msg-user-wrap";
|
|
1366
1373
|
wrap.appendChild(el);
|
|
1367
1374
|
_appendUserActionBar(el, wrap);
|
|
1375
|
+
_appendMsgTime(wrap, ev.created_at);
|
|
1368
1376
|
container.appendChild(wrap);
|
|
1369
1377
|
break;
|
|
1370
1378
|
}
|
|
@@ -1377,6 +1385,7 @@ const Sessions = (() => {
|
|
|
1377
1385
|
el.dataset.raw = ev.content || "";
|
|
1378
1386
|
el.innerHTML = _renderMarkdown(ev.content || "");
|
|
1379
1387
|
_appendCopyButton(el);
|
|
1388
|
+
_enhanceTaskItems(el, ev.content || "");
|
|
1380
1389
|
container.appendChild(el);
|
|
1381
1390
|
break;
|
|
1382
1391
|
}
|
|
@@ -1616,6 +1625,7 @@ const Sessions = (() => {
|
|
|
1616
1625
|
// If no more history remains, insert a "beginning of conversation" marker at the top.
|
|
1617
1626
|
// Remove any existing marker first to avoid duplicates.
|
|
1618
1627
|
messages.querySelector(".history-start-marker")?.remove();
|
|
1628
|
+
_refreshEditButtons(messages);
|
|
1619
1629
|
if (!state.hasMore) {
|
|
1620
1630
|
const marker = document.createElement("div");
|
|
1621
1631
|
marker.className = "history-start-marker";
|
|
@@ -1639,6 +1649,7 @@ const Sessions = (() => {
|
|
|
1639
1649
|
code: session.error_code,
|
|
1640
1650
|
message: session.error,
|
|
1641
1651
|
top_up_url: session.top_up_url,
|
|
1652
|
+
raw_message: session.raw_message,
|
|
1642
1653
|
});
|
|
1643
1654
|
} else {
|
|
1644
1655
|
Sessions.appendMsg("error", session.error);
|
|
@@ -1663,9 +1674,11 @@ const Sessions = (() => {
|
|
|
1663
1674
|
if (!createdAt) return I18n.t("sessions.untitled") || "Untitled";
|
|
1664
1675
|
const d = new Date(createdAt);
|
|
1665
1676
|
const now = new Date();
|
|
1666
|
-
const diffDays = Math.floor((now - d) / 86400000);
|
|
1667
1677
|
const pad = n => String(n).padStart(2, "0");
|
|
1668
1678
|
const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1679
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
1680
|
+
const dDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
1681
|
+
const diffDays = Math.round((today - dDay) / 86400000);
|
|
1669
1682
|
if (diffDays === 0) return `Today ${hhmm}`;
|
|
1670
1683
|
if (diffDays === 1) return `Yesterday ${hhmm}`;
|
|
1671
1684
|
return `${d.getMonth() + 1}/${d.getDate()} ${hhmm}`;
|
|
@@ -1738,12 +1751,18 @@ const Sessions = (() => {
|
|
|
1738
1751
|
|
|
1739
1752
|
const editBtn = document.createElement("button");
|
|
1740
1753
|
editBtn.type = "button";
|
|
1741
|
-
editBtn.className = "msg-user-action-btn";
|
|
1754
|
+
editBtn.className = "msg-user-action-btn msg-edit-btn";
|
|
1742
1755
|
editBtn.setAttribute("aria-label", I18n.t("chat.edit"));
|
|
1743
1756
|
editBtn.title = I18n.t("chat.edit");
|
|
1744
1757
|
editBtn.innerHTML = EDIT_SVG;
|
|
1745
|
-
editBtn.addEventListener("click", (e) => {
|
|
1758
|
+
editBtn.addEventListener("click", async (e) => {
|
|
1746
1759
|
e.stopPropagation();
|
|
1760
|
+
const ok = await Modal.confirmOnce(
|
|
1761
|
+
"clacky-edit-warn-dismissed",
|
|
1762
|
+
I18n.t("chat.edit.warn"),
|
|
1763
|
+
I18n.t("chat.edit.warnSkip")
|
|
1764
|
+
);
|
|
1765
|
+
if (!ok) return;
|
|
1747
1766
|
_enterEditMode(el);
|
|
1748
1767
|
});
|
|
1749
1768
|
|
|
@@ -1752,6 +1771,13 @@ const Sessions = (() => {
|
|
|
1752
1771
|
wrap.appendChild(bar);
|
|
1753
1772
|
}
|
|
1754
1773
|
|
|
1774
|
+
function _refreshEditButtons(container) {
|
|
1775
|
+
const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
|
|
1776
|
+
btns.forEach((btn, i) => {
|
|
1777
|
+
btn.style.display = i === btns.length - 1 ? "" : "none";
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1755
1781
|
function _extractUserBubbleText(el) {
|
|
1756
1782
|
const clone = el.cloneNode(true);
|
|
1757
1783
|
clone.querySelectorAll(".msg-user-actions, .msg-time").forEach(n => n.remove());
|
|
@@ -1821,9 +1847,14 @@ const Sessions = (() => {
|
|
|
1821
1847
|
});
|
|
1822
1848
|
}
|
|
1823
1849
|
|
|
1824
|
-
function _exitEditMode(el) {
|
|
1850
|
+
function _exitEditMode(el, newContent) {
|
|
1825
1851
|
el.classList.remove("editing");
|
|
1826
|
-
|
|
1852
|
+
if (newContent) {
|
|
1853
|
+
el.dataset.originalHtml = escapeHtml(newContent);
|
|
1854
|
+
el.innerHTML = escapeHtml(newContent);
|
|
1855
|
+
} else {
|
|
1856
|
+
el.innerHTML = el.dataset.originalHtml || "";
|
|
1857
|
+
}
|
|
1827
1858
|
}
|
|
1828
1859
|
|
|
1829
1860
|
function _submitEdit(el, newContent) {
|
|
@@ -1843,7 +1874,7 @@ const Sessions = (() => {
|
|
|
1843
1874
|
}
|
|
1844
1875
|
}
|
|
1845
1876
|
|
|
1846
|
-
_exitEditMode(el);
|
|
1877
|
+
_exitEditMode(el, newContent);
|
|
1847
1878
|
|
|
1848
1879
|
WS.send({ type: "edit_message", session_id: Sessions.activeId, content: newContent, created_at: createdAt });
|
|
1849
1880
|
|
|
@@ -1877,6 +1908,78 @@ const Sessions = (() => {
|
|
|
1877
1908
|
_ensureCopyDelegation();
|
|
1878
1909
|
}
|
|
1879
1910
|
|
|
1911
|
+
// System feature: any assistant message containing GFM task-list items
|
|
1912
|
+
// (`- [ ] ...`) gets a "spawn" button on each UNCHECKED item, turning a
|
|
1913
|
+
// todo into its own session. The new session's first prompt is the item
|
|
1914
|
+
// text plus the full message for context.
|
|
1915
|
+
function _stripThink(text) {
|
|
1916
|
+
if (!text) return "";
|
|
1917
|
+
return text.replace(/<think>[\s\S]*?<\/think>/g, "").replace(/^\s+/, "");
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function _enhanceTaskItems(el, rawText) {
|
|
1921
|
+
const seen = new Set();
|
|
1922
|
+
const context = _stripThink(rawText);
|
|
1923
|
+
el.querySelectorAll("li").forEach((li) => {
|
|
1924
|
+
const box = li.querySelector('input[type="checkbox"]');
|
|
1925
|
+
if (!box || box.checked) return; // only unchecked todos
|
|
1926
|
+
if (seen.has(li)) return;
|
|
1927
|
+
seen.add(li);
|
|
1928
|
+
|
|
1929
|
+
const text = li.textContent.trim();
|
|
1930
|
+
if (!text) return;
|
|
1931
|
+
|
|
1932
|
+
const btn = document.createElement("button");
|
|
1933
|
+
btn.type = "button";
|
|
1934
|
+
btn.className = "msg-todo-spawn";
|
|
1935
|
+
btn.title = I18n.t("chat.todo.spawn");
|
|
1936
|
+
btn.textContent = I18n.t("chat.todo.spawn");
|
|
1937
|
+
btn.dataset.todoText = text;
|
|
1938
|
+
btn.dataset.todoContext = context;
|
|
1939
|
+
li.appendChild(btn);
|
|
1940
|
+
});
|
|
1941
|
+
_ensureTodoSpawnDelegation();
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
let _todoSpawnDelegationInstalled = false;
|
|
1945
|
+
function _ensureTodoSpawnDelegation() {
|
|
1946
|
+
if (_todoSpawnDelegationInstalled) return;
|
|
1947
|
+
const messages = RenderTarget.outer();
|
|
1948
|
+
if (!messages) return;
|
|
1949
|
+
_todoSpawnDelegationInstalled = true;
|
|
1950
|
+
messages.addEventListener("click", (e) => {
|
|
1951
|
+
const btn = e.target.closest(".msg-todo-spawn");
|
|
1952
|
+
if (!btn) return;
|
|
1953
|
+
e.preventDefault();
|
|
1954
|
+
e.stopPropagation();
|
|
1955
|
+
_spawnFromTodo(btn);
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
async function _spawnFromTodo(btn) {
|
|
1960
|
+
if (btn.disabled) return;
|
|
1961
|
+
btn.disabled = true;
|
|
1962
|
+
const original = btn.textContent;
|
|
1963
|
+
btn.textContent = I18n.t("chat.todo.spawning");
|
|
1964
|
+
|
|
1965
|
+
const todoText = btn.dataset.todoText || "";
|
|
1966
|
+
const context = btn.dataset.todoContext || "";
|
|
1967
|
+
const prompt =
|
|
1968
|
+
`Task:\n${todoText}\n\n` +
|
|
1969
|
+
`--- Context (the message this task came from) ---\n${context}`;
|
|
1970
|
+
|
|
1971
|
+
try {
|
|
1972
|
+
await Sessions.startWith(prompt, {
|
|
1973
|
+
name: todoText.slice(0, 60),
|
|
1974
|
+
display: `📋 ${todoText}`,
|
|
1975
|
+
});
|
|
1976
|
+
} catch (err) {
|
|
1977
|
+
btn.disabled = false;
|
|
1978
|
+
btn.textContent = original;
|
|
1979
|
+
alert(I18n.t("chat.todo.spawnFailed", { msg: err.message }));
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1880
1983
|
// Install the click-delegation listener on #messages exactly once.
|
|
1881
1984
|
// Handles copy clicks for all current AND future assistant bubbles
|
|
1882
1985
|
// AND code block copy buttons.
|
|
@@ -2290,6 +2393,36 @@ const Sessions = (() => {
|
|
|
2290
2393
|
}
|
|
2291
2394
|
},
|
|
2292
2395
|
|
|
2396
|
+
// Create a session, queue a command to run once subscribed, and navigate to
|
|
2397
|
+
// it. The user bubble is rendered locally on the "subscribed" event (see
|
|
2398
|
+
// ws-dispatcher), so callers never touch history or WS timing.
|
|
2399
|
+
// Returns the created session.
|
|
2400
|
+
async startWith(command, { name, source = "setup", display = null } = {}) {
|
|
2401
|
+
if (!name) {
|
|
2402
|
+
const maxN = _sessions.reduce((max, s) => {
|
|
2403
|
+
const m = s.name && s.name.match(/^Session (\d+)$/);
|
|
2404
|
+
return m ? Math.max(max, parseInt(m[1], 10)) : max;
|
|
2405
|
+
}, 0);
|
|
2406
|
+
name = "Session " + (maxN + 1);
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
const res = await fetch("/api/sessions", {
|
|
2410
|
+
method: "POST",
|
|
2411
|
+
headers: { "Content-Type": "application/json" },
|
|
2412
|
+
body: JSON.stringify({ name, source }),
|
|
2413
|
+
});
|
|
2414
|
+
const data = await res.json();
|
|
2415
|
+
if (!res.ok) throw new Error(data.error || "failed to create session");
|
|
2416
|
+
const session = data.session;
|
|
2417
|
+
if (!session) throw new Error("no session returned");
|
|
2418
|
+
|
|
2419
|
+
Sessions.add(session);
|
|
2420
|
+
Sessions.renderList();
|
|
2421
|
+
Sessions.setPendingMessage(session.id, command, display);
|
|
2422
|
+
Sessions.select(session.id);
|
|
2423
|
+
return session;
|
|
2424
|
+
},
|
|
2425
|
+
|
|
2293
2426
|
/** Patch a single session's fields (from session_update event).
|
|
2294
2427
|
* If the session is not in the list yet (e.g. just created by another tab),
|
|
2295
2428
|
* prepend it so the sidebar shows it immediately. */
|
|
@@ -3428,6 +3561,16 @@ const Sessions = (() => {
|
|
|
3428
3561
|
}
|
|
3429
3562
|
},
|
|
3430
3563
|
|
|
3564
|
+
stampLastUserBubble(createdAt) {
|
|
3565
|
+
const messages = RenderTarget.outer();
|
|
3566
|
+
const wraps = messages.querySelectorAll(".msg-user-wrap");
|
|
3567
|
+
if (!wraps.length) return;
|
|
3568
|
+
const el = wraps[wraps.length - 1].querySelector(".msg-user");
|
|
3569
|
+
if (el) el.dataset.createdAt = createdAt;
|
|
3570
|
+
const dedup = _renderedCreatedAt[_activeId] || (_renderedCreatedAt[_activeId] = new Set());
|
|
3571
|
+
dedup.add(createdAt);
|
|
3572
|
+
},
|
|
3573
|
+
|
|
3431
3574
|
appendMsg(type, html, { time } = {}) {
|
|
3432
3575
|
// Starting a new assistant/user/info message: close any open tool group
|
|
3433
3576
|
if (type !== "tool") Sessions.collapseToolGroup();
|
|
@@ -3450,17 +3593,19 @@ const Sessions = (() => {
|
|
|
3450
3593
|
el.dataset.raw = html || "";
|
|
3451
3594
|
el.innerHTML = _renderMarkdown(html);
|
|
3452
3595
|
_appendCopyButton(el);
|
|
3596
|
+
_enhanceTaskItems(el, html || "");
|
|
3453
3597
|
} else {
|
|
3454
3598
|
el.innerHTML = html;
|
|
3455
3599
|
}
|
|
3456
|
-
if (type === "user" && time) _appendMsgTime(el, time);
|
|
3457
3600
|
|
|
3458
3601
|
if (type === "user") {
|
|
3459
3602
|
const wrap = document.createElement("div");
|
|
3460
3603
|
wrap.className = "msg-user-wrap";
|
|
3461
3604
|
wrap.appendChild(el);
|
|
3462
3605
|
_appendUserActionBar(el, wrap);
|
|
3606
|
+
if (time) _appendMsgTime(wrap, time);
|
|
3463
3607
|
messages.appendChild(wrap);
|
|
3608
|
+
_refreshEditButtons(messages);
|
|
3464
3609
|
} else {
|
|
3465
3610
|
// For error messages, add a retry button
|
|
3466
3611
|
if (type === "error") {
|
|
@@ -3476,7 +3621,11 @@ const Sessions = (() => {
|
|
|
3476
3621
|
});
|
|
3477
3622
|
retryBtn.disabled = true;
|
|
3478
3623
|
};
|
|
3624
|
+
// Move any .error-raw-detail to after the retry button
|
|
3625
|
+
const rawDetail = el.querySelector(".error-raw-detail");
|
|
3626
|
+
if (rawDetail) el.removeChild(rawDetail);
|
|
3479
3627
|
el.appendChild(retryBtn);
|
|
3628
|
+
if (rawDetail) el.appendChild(rawDetail);
|
|
3480
3629
|
}
|
|
3481
3630
|
messages.appendChild(el);
|
|
3482
3631
|
}
|
|
@@ -3869,8 +4018,8 @@ const Sessions = (() => {
|
|
|
3869
4018
|
},
|
|
3870
4019
|
|
|
3871
4020
|
/** Register a slash-command message to send after subscribe is confirmed. */
|
|
3872
|
-
setPendingMessage(sessionId, content) {
|
|
3873
|
-
_pendingMessage = { session_id: sessionId, content };
|
|
4021
|
+
setPendingMessage(sessionId, content, display = null) {
|
|
4022
|
+
_pendingMessage = { session_id: sessionId, content, display };
|
|
3874
4023
|
},
|
|
3875
4024
|
|
|
3876
4025
|
/** Consume and return the pending message (clears it). */
|
|
@@ -4613,7 +4762,7 @@ const Sessions = (() => {
|
|
|
4613
4762
|
// ── Tree-based directory picker ─────────────────────────────────────────
|
|
4614
4763
|
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>';
|
|
4615
4764
|
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>';
|
|
4616
|
-
function showDirectoryPicker(currentDir, sessionId
|
|
4765
|
+
function showDirectoryPicker(currentDir, sessionId) {
|
|
4617
4766
|
return new Promise((resolve) => {
|
|
4618
4767
|
const t = (key, fallback) => {
|
|
4619
4768
|
const s = I18n.t(key);
|
|
@@ -4626,7 +4775,8 @@ const Sessions = (() => {
|
|
|
4626
4775
|
|
|
4627
4776
|
let selectedPath = currentDir;
|
|
4628
4777
|
let rootDir = ""; // absolute path of the session's working directory
|
|
4629
|
-
let homeDir = ""; // user home, used as
|
|
4778
|
+
let homeDir = ""; // user home, used as a quick preset
|
|
4779
|
+
let defaultDir = ""; // default workspace from agent_config (or fallback)
|
|
4630
4780
|
let showHidden = false;
|
|
4631
4781
|
|
|
4632
4782
|
// Fetch directory entries from API, returns dirs with absolute paths
|
|
@@ -4640,6 +4790,7 @@ const Sessions = (() => {
|
|
|
4640
4790
|
const data = await resp.json();
|
|
4641
4791
|
rootDir = data.root || rootDir;
|
|
4642
4792
|
homeDir = data.home || homeDir;
|
|
4793
|
+
defaultDir = data.default || defaultDir;
|
|
4643
4794
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
4644
4795
|
dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
|
|
4645
4796
|
return dirs;
|
|
@@ -4652,6 +4803,8 @@ const Sessions = (() => {
|
|
|
4652
4803
|
const data = await resp.json();
|
|
4653
4804
|
// Only update rootDir in relative mode; absolute mode would overwrite it with "/"
|
|
4654
4805
|
if (!absolute) rootDir = data.root || rootDir;
|
|
4806
|
+
homeDir = data.home || homeDir;
|
|
4807
|
+
defaultDir = data.default || defaultDir;
|
|
4655
4808
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
4656
4809
|
// Convert relative paths to absolute
|
|
4657
4810
|
dirs.forEach(d => {
|
|
@@ -4717,6 +4870,12 @@ const Sessions = (() => {
|
|
|
4717
4870
|
// Enter this directory - reload tree with this as root
|
|
4718
4871
|
loadTreeForPath(entry.absPath, entry.absolute);
|
|
4719
4872
|
});
|
|
4873
|
+
|
|
4874
|
+
// Make the row addressable for inline-edit invoked elsewhere
|
|
4875
|
+
// (e.g. from the "+ New folder" button placing its placeholder).
|
|
4876
|
+
node._dpEntry = entry;
|
|
4877
|
+
node._dpName = name;
|
|
4878
|
+
node._dpRow = row;
|
|
4720
4879
|
return node;
|
|
4721
4880
|
}
|
|
4722
4881
|
|
|
@@ -4765,10 +4924,9 @@ const Sessions = (() => {
|
|
|
4765
4924
|
// Title
|
|
4766
4925
|
const title = document.createElement("div");
|
|
4767
4926
|
title.className = "modal-title";
|
|
4768
|
-
title.textContent =
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
: t("sib.dir.changePrompt", "切换工作目录"));
|
|
4927
|
+
title.textContent = sessionLess
|
|
4928
|
+
? t("sessions.modal.dirpicker.title", "选择工作目录")
|
|
4929
|
+
: t("sib.dir.changePrompt", "切换工作目录");
|
|
4772
4930
|
modal.appendChild(title);
|
|
4773
4931
|
|
|
4774
4932
|
// Modal body
|
|
@@ -4809,18 +4967,206 @@ const Sessions = (() => {
|
|
|
4809
4967
|
loadTreeForPath(parent, true);
|
|
4810
4968
|
});
|
|
4811
4969
|
|
|
4970
|
+
// ── "+ New folder" ────────────────────────────────────────────────
|
|
4971
|
+
// Drops an editable placeholder row at the top of the current view
|
|
4972
|
+
// and POSTs to the backend on commit. Confirm = Enter / blur, cancel
|
|
4973
|
+
// = Escape. The placeholder is purely visual; nothing is created on
|
|
4974
|
+
// disk until the user commits a non-empty name.
|
|
4975
|
+
const newFolderBtn = document.createElement("button");
|
|
4976
|
+
newFolderBtn.className = "btn btn-secondary btn-sm dp-newfolder-btn";
|
|
4977
|
+
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>`;
|
|
4978
|
+
newFolderBtn.addEventListener("click", () => beginCreateFolder());
|
|
4979
|
+
|
|
4980
|
+
function beginCreateFolder() {
|
|
4981
|
+
const parentDir = (pathInput.value || "").trim();
|
|
4982
|
+
if (!parentDir.startsWith("/")) {
|
|
4983
|
+
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
|
|
4984
|
+
return;
|
|
4985
|
+
}
|
|
4986
|
+
|
|
4987
|
+
// Locate the parent node in the current tree, if it's rendered.
|
|
4988
|
+
// The path input may point at any expanded descendant of the
|
|
4989
|
+
// tree root, so we can't assume parentDir == tree root.
|
|
4990
|
+
// Walk every rendered .dp-node and match by entry.absPath.
|
|
4991
|
+
let parentNode = null;
|
|
4992
|
+
treeContainer.querySelectorAll(".dp-node").forEach(n => {
|
|
4993
|
+
if (parentNode) return;
|
|
4994
|
+
if (n._dpEntry && n._dpEntry.absPath === parentDir) parentNode = n;
|
|
4995
|
+
});
|
|
4996
|
+
|
|
4997
|
+
// Pick where to insert + what depth the placeholder lives at.
|
|
4998
|
+
// - Tree root (parentDir is the root) → depth 0, host = treeContainer
|
|
4999
|
+
// - Rendered subtree → depth = parent.depth + 1,
|
|
5000
|
+
// host = parent's .dp-children
|
|
5001
|
+
let host, depth, parentRow, parentCaret, parentChildren;
|
|
5002
|
+
if (parentNode) {
|
|
5003
|
+
parentRow = parentNode._dpRow;
|
|
5004
|
+
parentCaret = parentRow.querySelector(".dp-caret");
|
|
5005
|
+
parentChildren = parentNode.querySelector(":scope > .dp-children");
|
|
5006
|
+
depth = (parseInt(parentNode.dataset.depth, 10) || 0) + 1;
|
|
5007
|
+
host = parentChildren;
|
|
5008
|
+
} else {
|
|
5009
|
+
depth = 0;
|
|
5010
|
+
host = treeContainer;
|
|
5011
|
+
}
|
|
5012
|
+
|
|
5013
|
+
// Build a placeholder node and seed it with an inline editor.
|
|
5014
|
+
const placeholder = document.createElement("div");
|
|
5015
|
+
placeholder.className = "dp-node dp-node-pending";
|
|
5016
|
+
placeholder.dataset.depth = depth;
|
|
5017
|
+
|
|
5018
|
+
const row = document.createElement("div");
|
|
5019
|
+
row.className = "dp-row dp-row-editing";
|
|
5020
|
+
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
|
5021
|
+
|
|
5022
|
+
const caret = document.createElement("span");
|
|
5023
|
+
caret.className = "dp-caret";
|
|
5024
|
+
caret.innerHTML = ICON_CARET_SVG;
|
|
5025
|
+
|
|
5026
|
+
const icon = document.createElement("span");
|
|
5027
|
+
icon.className = "dp-icon";
|
|
5028
|
+
icon.innerHTML = ICON_FOLDER_SVG;
|
|
5029
|
+
|
|
5030
|
+
const nameSpan = document.createElement("span");
|
|
5031
|
+
nameSpan.className = "dp-name";
|
|
5032
|
+
|
|
5033
|
+
row.appendChild(caret);
|
|
5034
|
+
row.appendChild(icon);
|
|
5035
|
+
row.appendChild(nameSpan);
|
|
5036
|
+
placeholder.appendChild(row);
|
|
5037
|
+
|
|
5038
|
+
// If we're inserting under a parent node, make sure the parent is
|
|
5039
|
+
// visually expanded so the user sees the placeholder. Mark the
|
|
5040
|
+
// children container as loaded so we don't trigger a refetch on
|
|
5041
|
+
// first toggleExpand (we'll keep our placeholder + inserted node).
|
|
5042
|
+
if (parentNode) {
|
|
5043
|
+
parentCaret.classList.add("open");
|
|
5044
|
+
parentChildren.style.display = "flex";
|
|
5045
|
+
// Drop "loading" / "empty" / "error" stub if present.
|
|
5046
|
+
const stub = parentChildren.querySelector(".dp-empty, .dp-loading, .dp-error");
|
|
5047
|
+
if (stub) stub.remove();
|
|
5048
|
+
parentChildren.dataset.loaded = "1";
|
|
5049
|
+
parentChildren.insertBefore(placeholder, parentChildren.firstChild);
|
|
5050
|
+
} else {
|
|
5051
|
+
// Root level: drop the empty/loading/error stub, then prepend.
|
|
5052
|
+
const emptyEl = treeContainer.querySelector(":scope > .dp-empty, :scope > .dp-loading, :scope > .dp-error");
|
|
5053
|
+
if (emptyEl) emptyEl.remove();
|
|
5054
|
+
treeContainer.insertBefore(placeholder, treeContainer.firstChild);
|
|
5055
|
+
}
|
|
5056
|
+
|
|
5057
|
+
startInlineEdit(nameSpan, t("sib.dir.newFolderDefault", "New Folder"), {
|
|
5058
|
+
onCommit: async (newName) => {
|
|
5059
|
+
const trimmed = (newName || "").trim();
|
|
5060
|
+
if (!trimmed) { placeholder.remove(); return; }
|
|
5061
|
+
try {
|
|
5062
|
+
const resp = await fetch("/api/dirs/mkdir", {
|
|
5063
|
+
method: "POST",
|
|
5064
|
+
headers: { "Content-Type": "application/json" },
|
|
5065
|
+
body: JSON.stringify({ parent: parentDir, name: trimmed })
|
|
5066
|
+
});
|
|
5067
|
+
const data = await resp.json().catch(() => ({}));
|
|
5068
|
+
if (!resp.ok || !data.ok) {
|
|
5069
|
+
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}")
|
|
5070
|
+
.replace("{{msg}}", data.error || `HTTP ${resp.status}`));
|
|
5071
|
+
placeholder.remove();
|
|
5072
|
+
return;
|
|
5073
|
+
}
|
|
5074
|
+
// Replace placeholder with a real, fully-wired node so it can
|
|
5075
|
+
// be expanded / right-clicked exactly like any sibling.
|
|
5076
|
+
const realEntry = {
|
|
5077
|
+
name: data.name,
|
|
5078
|
+
path: data.path,
|
|
5079
|
+
absPath: data.path,
|
|
5080
|
+
absolute: true,
|
|
5081
|
+
type: "dir"
|
|
5082
|
+
};
|
|
5083
|
+
const realNode = buildDirNode(realEntry, depth);
|
|
5084
|
+
placeholder.replaceWith(realNode);
|
|
5085
|
+
// Auto-select the new folder so the path input updates.
|
|
5086
|
+
modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
5087
|
+
realNode._dpRow.classList.add("selected");
|
|
5088
|
+
selectedPath = data.path;
|
|
5089
|
+
pathInput.value = data.path;
|
|
5090
|
+
refreshUpBtn();
|
|
5091
|
+
} catch (err) {
|
|
5092
|
+
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
|
|
5093
|
+
placeholder.remove();
|
|
5094
|
+
}
|
|
5095
|
+
},
|
|
5096
|
+
onCancel: () => placeholder.remove()
|
|
5097
|
+
});
|
|
5098
|
+
}
|
|
5099
|
+
|
|
5100
|
+
// ── Inline rename editor (shared by mkdir + rename) ───────────────
|
|
5101
|
+
// Replaces a name span with an <input> seeded by `initialValue`,
|
|
5102
|
+
// commits on Enter/blur, cancels on Escape. The caller wires the
|
|
5103
|
+
// backend round-trip via onCommit (it receives the new name string).
|
|
5104
|
+
function startInlineEdit(nameSpan, initialValue, { onCommit, onCancel }) {
|
|
5105
|
+
const input = document.createElement("input");
|
|
5106
|
+
input.type = "text";
|
|
5107
|
+
input.className = "dp-name-input";
|
|
5108
|
+
input.value = initialValue;
|
|
5109
|
+
input.spellcheck = false;
|
|
5110
|
+
input.autocomplete = "off";
|
|
5111
|
+
input.setAttribute("autocapitalize", "off");
|
|
5112
|
+
|
|
5113
|
+
// Stop click-through so editing doesn't trigger row selection /
|
|
5114
|
+
// expand / collapse.
|
|
5115
|
+
["click", "dblclick", "mousedown"].forEach(ev =>
|
|
5116
|
+
input.addEventListener(ev, (e) => e.stopPropagation())
|
|
5117
|
+
);
|
|
5118
|
+
|
|
5119
|
+
// Replace span with input. Track via parent so we can swap back.
|
|
5120
|
+
const parent = nameSpan.parentNode;
|
|
5121
|
+
parent.replaceChild(input, nameSpan);
|
|
5122
|
+
// Defer focus until the next tick so the caller's surrounding DOM
|
|
5123
|
+
// mutations (e.g. inserting a placeholder row) finish first.
|
|
5124
|
+
setTimeout(() => {
|
|
5125
|
+
input.focus();
|
|
5126
|
+
input.select();
|
|
5127
|
+
}, 0);
|
|
5128
|
+
|
|
5129
|
+
let finished = false;
|
|
5130
|
+
const finish = (commit) => {
|
|
5131
|
+
if (finished) return;
|
|
5132
|
+
finished = true;
|
|
5133
|
+
const value = input.value;
|
|
5134
|
+
if (input.parentNode === parent) parent.replaceChild(nameSpan, input);
|
|
5135
|
+
if (commit) {
|
|
5136
|
+
try { onCommit && onCommit(value); } catch (e) { console.error(e); }
|
|
5137
|
+
} else {
|
|
5138
|
+
try { onCancel && onCancel(); } catch (e) { console.error(e); }
|
|
5139
|
+
}
|
|
5140
|
+
};
|
|
5141
|
+
|
|
5142
|
+
// Enter commits; Escape cancels. Use IME.bindEnter so that the
|
|
5143
|
+
// Enter that confirms a Chinese / Japanese / Korean IME candidate
|
|
5144
|
+
// does NOT commit the rename. Mirrors Sessions._startRename.
|
|
5145
|
+
IME.bindEnter(input, () => finish(true));
|
|
5146
|
+
input.addEventListener("keydown", (e) => {
|
|
5147
|
+
if (e.key === "Escape") { e.preventDefault(); finish(false); }
|
|
5148
|
+
});
|
|
5149
|
+
input.addEventListener("blur", () => finish(true));
|
|
5150
|
+
}
|
|
5151
|
+
|
|
4812
5152
|
function setupPresets() {
|
|
4813
5153
|
presets.innerHTML = "";
|
|
4814
5154
|
presets.appendChild(upBtn);
|
|
4815
5155
|
const presetDirs = sessionLess
|
|
4816
5156
|
? [
|
|
4817
|
-
{ value:
|
|
4818
|
-
{ value:
|
|
5157
|
+
{ value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
|
|
5158
|
+
{ value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
|
|
5159
|
+
{ value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
|
|
4819
5160
|
]
|
|
4820
5161
|
: [
|
|
4821
|
-
{ value:
|
|
4822
|
-
{ value: "/",
|
|
4823
|
-
];
|
|
5162
|
+
{ value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
|
|
5163
|
+
{ value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
|
|
5164
|
+
];
|
|
5165
|
+
presetDirs.forEach(p => {
|
|
5166
|
+
// Skip preset whose absolute path isn't known yet (defensive: the
|
|
5167
|
+
// backend always returns it, but a malformed deploy shouldn't render
|
|
5168
|
+
// a button that navigates to "").
|
|
5169
|
+
if (!p.value) return;
|
|
4824
5170
|
const btn = document.createElement("button");
|
|
4825
5171
|
btn.className = "btn btn-secondary btn-sm";
|
|
4826
5172
|
btn.textContent = p.text;
|
|
@@ -4832,6 +5178,8 @@ const Sessions = (() => {
|
|
|
4832
5178
|
});
|
|
4833
5179
|
presets.appendChild(btn);
|
|
4834
5180
|
});
|
|
5181
|
+
// "+ New folder" trailing action on the same toolbar.
|
|
5182
|
+
presets.appendChild(newFolderBtn);
|
|
4835
5183
|
}
|
|
4836
5184
|
|
|
4837
5185
|
// Path input
|