openclacky 1.3.2 → 1.3.4
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 +54 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +52 -7
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +15 -3
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
- data/lib/clacky/default_agents/coding/profile.yml +3 -0
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
- data/lib/clacky/idle_compression_timer.rb +3 -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/openai_compat.rb +64 -1
- data/lib/clacky/message_history.rb +9 -0
- 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_manager.rb +26 -0
- data/lib/clacky/server/git_panel.rb +115 -0
- data/lib/clacky/server/http_server.rb +547 -15
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +511 -101
- data/lib/clacky/web/app.js +95 -14
- data/lib/clacky/web/components/code-editor.js +197 -0
- data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
- data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
- data/lib/clacky/web/core/aside.js +117 -0
- data/lib/clacky/web/core/ext.js +387 -0
- data/lib/clacky/web/features/backup/store.js +92 -0
- data/lib/clacky/web/features/backup/view.js +94 -0
- data/lib/clacky/web/features/billing/store.js +163 -0
- data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
- data/lib/clacky/web/features/brand/store.js +110 -0
- data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
- data/lib/clacky/web/features/channels/store.js +103 -0
- data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
- data/lib/clacky/web/features/creator/store.js +81 -0
- data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
- data/lib/clacky/web/features/mcp/store.js +158 -0
- data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
- data/lib/clacky/web/features/model-tester/store.js +77 -0
- data/lib/clacky/web/features/model-tester/view.js +7 -0
- data/lib/clacky/web/features/profile/store.js +170 -0
- data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
- data/lib/clacky/web/features/share/store.js +145 -0
- data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
- data/lib/clacky/web/features/skills/store.js +331 -0
- data/lib/clacky/web/features/skills/view.js +581 -0
- data/lib/clacky/web/features/tasks/store.js +135 -0
- data/lib/clacky/web/features/tasks/view.js +241 -0
- data/lib/clacky/web/features/trash/store.js +242 -0
- data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
- data/lib/clacky/web/features/version/store.js +165 -0
- data/lib/clacky/web/features/version/view.js +323 -0
- data/lib/clacky/web/features/workspace/store.js +99 -0
- data/lib/clacky/web/features/workspace/view.js +305 -0
- data/lib/clacky/web/i18n.js +68 -6
- data/lib/clacky/web/index.html +113 -62
- data/lib/clacky/web/sessions.js +493 -39
- data/lib/clacky/web/settings.js +143 -49
- data/lib/clacky/web/skills.js +3 -863
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -1
- metadata +81 -20
- data/lib/clacky/web/backup.js +0 -119
- data/lib/clacky/web/model-tester.js +0 -66
- data/lib/clacky/web/tasks.js +0 -373
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -316
- /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
- /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
- /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
- /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -29,6 +29,12 @@ const Sessions = (() => {
|
|
|
29
29
|
// Search results live in their own list, rendered into the overlay's
|
|
30
30
|
// #session-search-results — they NEVER replace the sidebar session list.
|
|
31
31
|
let _searchResults = [];
|
|
32
|
+
// Sessions resolved by id but not in the paged sidebar list — e.g. landed
|
|
33
|
+
// here via search-result click, URL deep link, share link, browser
|
|
34
|
+
// back/forward, or external notification jump. Acts as a local cache for
|
|
35
|
+
// `findOrFetch`. Excluded from sidebar render and from the loadMore cursor
|
|
36
|
+
// so the pagination of `_sessions` stays correct.
|
|
37
|
+
const _extraSessions = [];
|
|
32
38
|
// Active search result split when _filter.q is non-empty:
|
|
33
39
|
// { nameIds: Set<id>, contentIds: Set<id>, contentLoaded: bool }
|
|
34
40
|
let _searchSplit = null;
|
|
@@ -118,6 +124,16 @@ const Sessions = (() => {
|
|
|
118
124
|
|
|
119
125
|
let html;
|
|
120
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
|
+
|
|
121
137
|
const renderer = new marked.Renderer();
|
|
122
138
|
renderer.link = function({ href, title, text }) {
|
|
123
139
|
const titleAttr = title ? ` title="${title}"` : "";
|
|
@@ -126,6 +142,7 @@ const Sessions = (() => {
|
|
|
126
142
|
// Override code block rendering: apply syntax highlighting + header with
|
|
127
143
|
// language label and copy button.
|
|
128
144
|
renderer.code = function({ text: code, lang }) {
|
|
145
|
+
code = restoreMathInCode(code);
|
|
129
146
|
const language = (lang || "").split(/\s+/)[0]; // strip extra info after lang
|
|
130
147
|
const highlighted = _highlightCode(code, language);
|
|
131
148
|
const displayLang = language || "text";
|
|
@@ -146,6 +163,10 @@ const Sessions = (() => {
|
|
|
146
163
|
`</div>`
|
|
147
164
|
);
|
|
148
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
|
+
};
|
|
149
170
|
try {
|
|
150
171
|
html = marked.parse(prepared, { breaks: true, gfm: true, renderer });
|
|
151
172
|
} catch (_) {
|
|
@@ -157,7 +178,11 @@ const Sessions = (() => {
|
|
|
157
178
|
}
|
|
158
179
|
|
|
159
180
|
if (math.length) {
|
|
160
|
-
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
|
+
});
|
|
161
186
|
}
|
|
162
187
|
return html;
|
|
163
188
|
}
|
|
@@ -181,22 +206,30 @@ const Sessions = (() => {
|
|
|
181
206
|
|
|
182
207
|
// Pull $$...$$, \[...\], $...$, \(...\) out of `text` and replace each with a
|
|
183
208
|
// sentinel placeholder so marked won't mangle the LaTeX source. The matched
|
|
184
|
-
// 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).
|
|
185
218
|
function _extractMath(text, out, placeholder) {
|
|
186
219
|
// Order matters: longest/most-specific delimiters first.
|
|
187
220
|
const patterns = [
|
|
188
|
-
{ re: /\$\$([\s\S]+?)\$\$/g,
|
|
189
|
-
{ re: /\\\[([\s\S]+?)\\\]/g,
|
|
190
|
-
{ 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}\\)` },
|
|
191
224
|
// Inline $...$: avoid $$, escaped \$, and prevent crossing newlines/blanks.
|
|
192
|
-
{ re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true },
|
|
225
|
+
{ re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true, wrap: (b) => `$${b}$` },
|
|
193
226
|
];
|
|
194
227
|
let result = text;
|
|
195
|
-
for (const { re, display, hasPrefix } of patterns) {
|
|
228
|
+
for (const { re, display, hasPrefix, wrap } of patterns) {
|
|
196
229
|
result = result.replace(re, (m, a, b) => {
|
|
197
230
|
const body = hasPrefix ? b : a;
|
|
198
231
|
const idx = out.length;
|
|
199
|
-
out.push({ body, display });
|
|
232
|
+
out.push({ body, display, raw: wrap(body) });
|
|
200
233
|
return (hasPrefix ? a : "") + placeholder(idx);
|
|
201
234
|
});
|
|
202
235
|
}
|
|
@@ -821,7 +854,7 @@ const Sessions = (() => {
|
|
|
821
854
|
_imageSeq = 0;
|
|
822
855
|
_renderAttachmentPreviews();
|
|
823
856
|
|
|
824
|
-
WS.send({ type: "message", session_id: Sessions.activeId, content, files });
|
|
857
|
+
WS.send({ type: "message", session_id: Sessions.activeId, content, files, lang: I18n.lang() });
|
|
825
858
|
|
|
826
859
|
// Disable any pending feedback cards — user has replied (either by clicking
|
|
827
860
|
// an option button or by typing directly). The backend has already consumed
|
|
@@ -1353,8 +1386,13 @@ const Sessions = (() => {
|
|
|
1353
1386
|
}
|
|
1354
1387
|
bubbleHtml += escapeHtml(ev.content || "");
|
|
1355
1388
|
el.innerHTML = bubbleHtml;
|
|
1356
|
-
|
|
1357
|
-
|
|
1389
|
+
if (ev.created_at) el.dataset.createdAt = ev.created_at;
|
|
1390
|
+
const wrap = document.createElement("div");
|
|
1391
|
+
wrap.className = "msg-user-wrap";
|
|
1392
|
+
wrap.appendChild(el);
|
|
1393
|
+
_appendUserActionBar(el, wrap);
|
|
1394
|
+
_appendMsgTime(wrap, ev.created_at);
|
|
1395
|
+
container.appendChild(wrap);
|
|
1358
1396
|
break;
|
|
1359
1397
|
}
|
|
1360
1398
|
|
|
@@ -1605,6 +1643,7 @@ const Sessions = (() => {
|
|
|
1605
1643
|
// If no more history remains, insert a "beginning of conversation" marker at the top.
|
|
1606
1644
|
// Remove any existing marker first to avoid duplicates.
|
|
1607
1645
|
messages.querySelector(".history-start-marker")?.remove();
|
|
1646
|
+
_refreshEditButtons(messages);
|
|
1608
1647
|
if (!state.hasMore) {
|
|
1609
1648
|
const marker = document.createElement("div");
|
|
1610
1649
|
marker.className = "history-start-marker";
|
|
@@ -1652,9 +1691,11 @@ const Sessions = (() => {
|
|
|
1652
1691
|
if (!createdAt) return I18n.t("sessions.untitled") || "Untitled";
|
|
1653
1692
|
const d = new Date(createdAt);
|
|
1654
1693
|
const now = new Date();
|
|
1655
|
-
const diffDays = Math.floor((now - d) / 86400000);
|
|
1656
1694
|
const pad = n => String(n).padStart(2, "0");
|
|
1657
1695
|
const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1696
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
1697
|
+
const dDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
1698
|
+
const diffDays = Math.round((today - dDay) / 86400000);
|
|
1658
1699
|
if (diffDays === 0) return `Today ${hhmm}`;
|
|
1659
1700
|
if (diffDays === 1) return `Yesterday ${hhmm}`;
|
|
1660
1701
|
return `${d.getMonth() + 1}/${d.getDate()} ${hhmm}`;
|
|
@@ -1694,6 +1735,169 @@ const Sessions = (() => {
|
|
|
1694
1735
|
el.appendChild(span);
|
|
1695
1736
|
}
|
|
1696
1737
|
|
|
1738
|
+
// ── User message action bar (copy + edit) ───────────────────────────────
|
|
1739
|
+
|
|
1740
|
+
const COPY_SVG = `<svg class="msg-user-copy-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
|
|
1741
|
+
`<path fill="currentColor" d="M10 1H4a2 2 0 0 0-2 2v8h1.5V3a.5.5 0 0 1 .5-.5h6V1zm3 3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm.5 10a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v8z"/>` +
|
|
1742
|
+
`</svg>` +
|
|
1743
|
+
`<svg class="msg-user-copy-icon-check" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
|
|
1744
|
+
`<path fill="currentColor" d="M13.5 3.5 6 11 2.5 7.5 1 9l5 5 9-9z"/>` +
|
|
1745
|
+
`</svg>`;
|
|
1746
|
+
|
|
1747
|
+
const EDIT_SVG = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
|
|
1748
|
+
`<path fill="currentColor" d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354l-1.086-1.086zM11.189 6.25 9.75 4.81l-6.286 6.287a.25.25 0 0 0-.064.108l-.558 1.953 1.953-.558a.249.249 0 0 0 .108-.064l6.286-6.286z"/>` +
|
|
1749
|
+
`</svg>`;
|
|
1750
|
+
|
|
1751
|
+
function _appendUserActionBar(el, wrap) {
|
|
1752
|
+
el.dataset.originalHtml = el.innerHTML;
|
|
1753
|
+
|
|
1754
|
+
const bar = document.createElement("div");
|
|
1755
|
+
bar.className = "msg-user-actions";
|
|
1756
|
+
|
|
1757
|
+
const copyBtn = document.createElement("button");
|
|
1758
|
+
copyBtn.type = "button";
|
|
1759
|
+
copyBtn.className = "msg-user-action-btn";
|
|
1760
|
+
copyBtn.setAttribute("aria-label", I18n.t("chat.copy"));
|
|
1761
|
+
copyBtn.title = I18n.t("chat.copy");
|
|
1762
|
+
copyBtn.innerHTML = COPY_SVG;
|
|
1763
|
+
copyBtn.addEventListener("click", (e) => {
|
|
1764
|
+
e.stopPropagation();
|
|
1765
|
+
const text = _extractUserBubbleText(el);
|
|
1766
|
+
_copyTextAndFlash(copyBtn, text);
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
const editBtn = document.createElement("button");
|
|
1770
|
+
editBtn.type = "button";
|
|
1771
|
+
editBtn.className = "msg-user-action-btn msg-edit-btn";
|
|
1772
|
+
editBtn.setAttribute("aria-label", I18n.t("chat.edit"));
|
|
1773
|
+
editBtn.title = I18n.t("chat.edit");
|
|
1774
|
+
editBtn.innerHTML = EDIT_SVG;
|
|
1775
|
+
editBtn.addEventListener("click", async (e) => {
|
|
1776
|
+
e.stopPropagation();
|
|
1777
|
+
const ok = await Modal.confirmOnce(
|
|
1778
|
+
"clacky-edit-warn-dismissed",
|
|
1779
|
+
I18n.t("chat.edit.warn"),
|
|
1780
|
+
I18n.t("chat.edit.warnSkip")
|
|
1781
|
+
);
|
|
1782
|
+
if (!ok) return;
|
|
1783
|
+
_enterEditMode(el);
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
bar.appendChild(copyBtn);
|
|
1787
|
+
bar.appendChild(editBtn);
|
|
1788
|
+
wrap.appendChild(bar);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function _refreshEditButtons(container) {
|
|
1792
|
+
const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
|
|
1793
|
+
btns.forEach((btn, i) => {
|
|
1794
|
+
btn.style.display = i === btns.length - 1 ? "" : "none";
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function _extractUserBubbleText(el) {
|
|
1799
|
+
const clone = el.cloneNode(true);
|
|
1800
|
+
clone.querySelectorAll(".msg-user-actions, .msg-time").forEach(n => n.remove());
|
|
1801
|
+
return (clone.textContent || "").trim();
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function _enterEditMode(el) {
|
|
1805
|
+
if (el.classList.contains("editing")) return;
|
|
1806
|
+
el.classList.add("editing");
|
|
1807
|
+
|
|
1808
|
+
const originalHtml = el.dataset.originalHtml || "";
|
|
1809
|
+
const originalText = (() => {
|
|
1810
|
+
const tmp = document.createElement("div");
|
|
1811
|
+
tmp.innerHTML = originalHtml;
|
|
1812
|
+
tmp.querySelectorAll(".msg-user-actions, .msg-time, .msg-pdf-badge, img").forEach(n => n.remove());
|
|
1813
|
+
return (tmp.textContent || "").trim();
|
|
1814
|
+
})();
|
|
1815
|
+
|
|
1816
|
+
el.innerHTML = "";
|
|
1817
|
+
|
|
1818
|
+
const wrap = document.createElement("div");
|
|
1819
|
+
wrap.className = "msg-user-edit-wrap";
|
|
1820
|
+
|
|
1821
|
+
const textarea = document.createElement("textarea");
|
|
1822
|
+
textarea.className = "msg-user-edit-textarea";
|
|
1823
|
+
textarea.value = originalText;
|
|
1824
|
+
textarea.rows = 1;
|
|
1825
|
+
|
|
1826
|
+
const actions = document.createElement("div");
|
|
1827
|
+
actions.className = "msg-user-edit-actions";
|
|
1828
|
+
|
|
1829
|
+
const cancelBtn = document.createElement("button");
|
|
1830
|
+
cancelBtn.type = "button";
|
|
1831
|
+
cancelBtn.className = "msg-user-edit-cancel";
|
|
1832
|
+
cancelBtn.textContent = I18n.t("chat.cancel");
|
|
1833
|
+
cancelBtn.addEventListener("click", () => _exitEditMode(el));
|
|
1834
|
+
|
|
1835
|
+
const sendBtn = document.createElement("button");
|
|
1836
|
+
sendBtn.type = "button";
|
|
1837
|
+
sendBtn.className = "msg-user-edit-send";
|
|
1838
|
+
sendBtn.textContent = I18n.t("chat.send");
|
|
1839
|
+
sendBtn.addEventListener("click", () => _submitEdit(el, textarea.value.trim()));
|
|
1840
|
+
|
|
1841
|
+
textarea.addEventListener("keydown", (e) => {
|
|
1842
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
1843
|
+
e.preventDefault();
|
|
1844
|
+
_submitEdit(el, textarea.value.trim());
|
|
1845
|
+
}
|
|
1846
|
+
if (e.key === "Escape") _exitEditMode(el);
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
textarea.addEventListener("input", () => {
|
|
1850
|
+
textarea.style.height = "auto";
|
|
1851
|
+
textarea.style.height = textarea.scrollHeight + "px";
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
actions.appendChild(cancelBtn);
|
|
1855
|
+
actions.appendChild(sendBtn);
|
|
1856
|
+
wrap.appendChild(textarea);
|
|
1857
|
+
wrap.appendChild(actions);
|
|
1858
|
+
el.appendChild(wrap);
|
|
1859
|
+
|
|
1860
|
+
requestAnimationFrame(() => {
|
|
1861
|
+
textarea.style.height = textarea.scrollHeight + "px";
|
|
1862
|
+
textarea.focus();
|
|
1863
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function _exitEditMode(el, newContent) {
|
|
1868
|
+
el.classList.remove("editing");
|
|
1869
|
+
if (newContent) {
|
|
1870
|
+
el.dataset.originalHtml = escapeHtml(newContent);
|
|
1871
|
+
el.innerHTML = escapeHtml(newContent);
|
|
1872
|
+
} else {
|
|
1873
|
+
el.innerHTML = el.dataset.originalHtml || "";
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function _submitEdit(el, newContent) {
|
|
1878
|
+
if (!newContent) return;
|
|
1879
|
+
if (!Sessions.activeId) return;
|
|
1880
|
+
|
|
1881
|
+
const createdAt = el.dataset.createdAt || null;
|
|
1882
|
+
|
|
1883
|
+
const messages = el.closest("#messages, .messages");
|
|
1884
|
+
if (messages) {
|
|
1885
|
+
const wrap = el.parentElement;
|
|
1886
|
+
let sibling = wrap ? wrap.nextSibling : el.nextSibling;
|
|
1887
|
+
while (sibling) {
|
|
1888
|
+
const next = sibling.nextSibling;
|
|
1889
|
+
sibling.remove();
|
|
1890
|
+
sibling = next;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
_exitEditMode(el, newContent);
|
|
1895
|
+
|
|
1896
|
+
WS.send({ type: "edit_message", session_id: Sessions.activeId, content: newContent, created_at: createdAt });
|
|
1897
|
+
|
|
1898
|
+
if (messages) messages.scrollTop = messages.scrollHeight;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1697
1901
|
// ── Copy button for assistant messages ──────────────────────────────────
|
|
1698
1902
|
//
|
|
1699
1903
|
// Each assistant bubble gets a small copy button in its top-right corner.
|
|
@@ -2017,7 +2221,41 @@ const Sessions = (() => {
|
|
|
2017
2221
|
get all() { return _sessions; },
|
|
2018
2222
|
get activeId() { return _activeId; },
|
|
2019
2223
|
get searchOpen() { return _searchOpen; },
|
|
2020
|
-
find: id => _sessions.find(s => s.id === id)
|
|
2224
|
+
find: id => _sessions.find(s => s.id === id)
|
|
2225
|
+
|| _extraSessions.find(s => s.id === id),
|
|
2226
|
+
|
|
2227
|
+
// Async variant of `find`: when not found in memory, falls back to
|
|
2228
|
+
// GET /api/sessions/:id which returns the on-disk session merged with
|
|
2229
|
+
// any live in-memory state (see SessionRegistry#snapshot in
|
|
2230
|
+
// session_registry.rb). Resolved rows are cached in `_extraSessions`
|
|
2231
|
+
// so subsequent synchronous `find` calls hit too. Returns null on
|
|
2232
|
+
// 404 / network error.
|
|
2233
|
+
//
|
|
2234
|
+
// Use this in code paths where missing-id should NOT silently fail
|
|
2235
|
+
// (Router navigation: search clicks, URL deep links, share links,
|
|
2236
|
+
// browser back/forward, notification jumps). For tight synchronous
|
|
2237
|
+
// paths (WS dispatch, status updates) keep using `find`.
|
|
2238
|
+
async findOrFetch(id) {
|
|
2239
|
+
if (!id) return null;
|
|
2240
|
+
const local = _sessions.find(s => s.id === id)
|
|
2241
|
+
|| _extraSessions.find(s => s.id === id);
|
|
2242
|
+
if (local) return local;
|
|
2243
|
+
try {
|
|
2244
|
+
const resp = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
|
|
2245
|
+
if (!resp.ok) return null;
|
|
2246
|
+
const data = await resp.json();
|
|
2247
|
+
if (!data || !data.session) return null;
|
|
2248
|
+
// Race guard: another caller may have hydrated meanwhile.
|
|
2249
|
+
if (!_sessions.find(s => s.id === id)
|
|
2250
|
+
&& !_extraSessions.find(s => s.id === id)) {
|
|
2251
|
+
_extraSessions.push(data.session);
|
|
2252
|
+
}
|
|
2253
|
+
return data.session;
|
|
2254
|
+
} catch (e) {
|
|
2255
|
+
console.error("Sessions.findOrFetch failed:", e);
|
|
2256
|
+
return null;
|
|
2257
|
+
}
|
|
2258
|
+
},
|
|
2021
2259
|
|
|
2022
2260
|
// Composer entry point — called by Skill autocomplete keydown handler
|
|
2023
2261
|
// (in app.js) when the user presses Enter without an active completion.
|
|
@@ -3238,6 +3476,16 @@ const Sessions = (() => {
|
|
|
3238
3476
|
}
|
|
3239
3477
|
},
|
|
3240
3478
|
|
|
3479
|
+
stampLastUserBubble(createdAt) {
|
|
3480
|
+
const messages = RenderTarget.outer();
|
|
3481
|
+
const wraps = messages.querySelectorAll(".msg-user-wrap");
|
|
3482
|
+
if (!wraps.length) return;
|
|
3483
|
+
const el = wraps[wraps.length - 1].querySelector(".msg-user");
|
|
3484
|
+
if (el) el.dataset.createdAt = createdAt;
|
|
3485
|
+
const dedup = _renderedCreatedAt[_activeId] || (_renderedCreatedAt[_activeId] = new Set());
|
|
3486
|
+
dedup.add(createdAt);
|
|
3487
|
+
},
|
|
3488
|
+
|
|
3241
3489
|
appendMsg(type, html, { time } = {}) {
|
|
3242
3490
|
// Starting a new assistant/user/info message: close any open tool group
|
|
3243
3491
|
if (type !== "tool") Sessions.collapseToolGroup();
|
|
@@ -3263,28 +3511,34 @@ const Sessions = (() => {
|
|
|
3263
3511
|
} else {
|
|
3264
3512
|
el.innerHTML = html;
|
|
3265
3513
|
}
|
|
3266
|
-
if (type === "user" && time) _appendMsgTime(el, time);
|
|
3267
3514
|
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
retryBtn.
|
|
3283
|
-
|
|
3284
|
-
|
|
3515
|
+
if (type === "user") {
|
|
3516
|
+
const wrap = document.createElement("div");
|
|
3517
|
+
wrap.className = "msg-user-wrap";
|
|
3518
|
+
wrap.appendChild(el);
|
|
3519
|
+
_appendUserActionBar(el, wrap);
|
|
3520
|
+
if (time) _appendMsgTime(wrap, time);
|
|
3521
|
+
messages.appendChild(wrap);
|
|
3522
|
+
_refreshEditButtons(messages);
|
|
3523
|
+
} else {
|
|
3524
|
+
// For error messages, add a retry button
|
|
3525
|
+
if (type === "error") {
|
|
3526
|
+
const retryBtn = document.createElement("button");
|
|
3527
|
+
retryBtn.className = "retry-btn";
|
|
3528
|
+
retryBtn.textContent = I18n.t("chat.retry");
|
|
3529
|
+
retryBtn.onclick = () => {
|
|
3530
|
+
if (!_activeId) return;
|
|
3531
|
+
WS.send({
|
|
3532
|
+
type: "message",
|
|
3533
|
+
session_id: _activeId,
|
|
3534
|
+
content: I18n.t("chat.continue")
|
|
3535
|
+
});
|
|
3536
|
+
retryBtn.disabled = true;
|
|
3537
|
+
};
|
|
3538
|
+
el.appendChild(retryBtn);
|
|
3539
|
+
}
|
|
3540
|
+
messages.appendChild(el);
|
|
3285
3541
|
}
|
|
3286
|
-
|
|
3287
|
-
messages.appendChild(el);
|
|
3288
3542
|
// User messages: force scroll to bottom (user just sent a message)
|
|
3289
3543
|
// Assistant/info: conditional scroll (preserve position if user is viewing history)
|
|
3290
3544
|
if (type === "user") {
|
|
@@ -4431,7 +4685,8 @@ const Sessions = (() => {
|
|
|
4431
4685
|
|
|
4432
4686
|
let selectedPath = currentDir;
|
|
4433
4687
|
let rootDir = ""; // absolute path of the session's working directory
|
|
4434
|
-
let homeDir = ""; // user home, used as
|
|
4688
|
+
let homeDir = ""; // user home, used as a quick preset
|
|
4689
|
+
let defaultDir = ""; // default workspace from agent_config (or fallback)
|
|
4435
4690
|
let showHidden = false;
|
|
4436
4691
|
|
|
4437
4692
|
// Fetch directory entries from API, returns dirs with absolute paths
|
|
@@ -4445,6 +4700,7 @@ const Sessions = (() => {
|
|
|
4445
4700
|
const data = await resp.json();
|
|
4446
4701
|
rootDir = data.root || rootDir;
|
|
4447
4702
|
homeDir = data.home || homeDir;
|
|
4703
|
+
defaultDir = data.default || defaultDir;
|
|
4448
4704
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
4449
4705
|
dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
|
|
4450
4706
|
return dirs;
|
|
@@ -4457,6 +4713,8 @@ const Sessions = (() => {
|
|
|
4457
4713
|
const data = await resp.json();
|
|
4458
4714
|
// Only update rootDir in relative mode; absolute mode would overwrite it with "/"
|
|
4459
4715
|
if (!absolute) rootDir = data.root || rootDir;
|
|
4716
|
+
homeDir = data.home || homeDir;
|
|
4717
|
+
defaultDir = data.default || defaultDir;
|
|
4460
4718
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
4461
4719
|
// Convert relative paths to absolute
|
|
4462
4720
|
dirs.forEach(d => {
|
|
@@ -4522,6 +4780,12 @@ const Sessions = (() => {
|
|
|
4522
4780
|
// Enter this directory - reload tree with this as root
|
|
4523
4781
|
loadTreeForPath(entry.absPath, entry.absolute);
|
|
4524
4782
|
});
|
|
4783
|
+
|
|
4784
|
+
// Make the row addressable for inline-edit invoked elsewhere
|
|
4785
|
+
// (e.g. from the "+ New folder" button placing its placeholder).
|
|
4786
|
+
node._dpEntry = entry;
|
|
4787
|
+
node._dpName = name;
|
|
4788
|
+
node._dpRow = row;
|
|
4525
4789
|
return node;
|
|
4526
4790
|
}
|
|
4527
4791
|
|
|
@@ -4613,18 +4877,206 @@ const Sessions = (() => {
|
|
|
4613
4877
|
loadTreeForPath(parent, true);
|
|
4614
4878
|
});
|
|
4615
4879
|
|
|
4880
|
+
// ── "+ New folder" ────────────────────────────────────────────────
|
|
4881
|
+
// Drops an editable placeholder row at the top of the current view
|
|
4882
|
+
// and POSTs to the backend on commit. Confirm = Enter / blur, cancel
|
|
4883
|
+
// = Escape. The placeholder is purely visual; nothing is created on
|
|
4884
|
+
// disk until the user commits a non-empty name.
|
|
4885
|
+
const newFolderBtn = document.createElement("button");
|
|
4886
|
+
newFolderBtn.className = "btn btn-secondary btn-sm dp-newfolder-btn";
|
|
4887
|
+
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>`;
|
|
4888
|
+
newFolderBtn.addEventListener("click", () => beginCreateFolder());
|
|
4889
|
+
|
|
4890
|
+
function beginCreateFolder() {
|
|
4891
|
+
const parentDir = (pathInput.value || "").trim();
|
|
4892
|
+
if (!parentDir.startsWith("/")) {
|
|
4893
|
+
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
|
|
4894
|
+
return;
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
// Locate the parent node in the current tree, if it's rendered.
|
|
4898
|
+
// The path input may point at any expanded descendant of the
|
|
4899
|
+
// tree root, so we can't assume parentDir == tree root.
|
|
4900
|
+
// Walk every rendered .dp-node and match by entry.absPath.
|
|
4901
|
+
let parentNode = null;
|
|
4902
|
+
treeContainer.querySelectorAll(".dp-node").forEach(n => {
|
|
4903
|
+
if (parentNode) return;
|
|
4904
|
+
if (n._dpEntry && n._dpEntry.absPath === parentDir) parentNode = n;
|
|
4905
|
+
});
|
|
4906
|
+
|
|
4907
|
+
// Pick where to insert + what depth the placeholder lives at.
|
|
4908
|
+
// - Tree root (parentDir is the root) → depth 0, host = treeContainer
|
|
4909
|
+
// - Rendered subtree → depth = parent.depth + 1,
|
|
4910
|
+
// host = parent's .dp-children
|
|
4911
|
+
let host, depth, parentRow, parentCaret, parentChildren;
|
|
4912
|
+
if (parentNode) {
|
|
4913
|
+
parentRow = parentNode._dpRow;
|
|
4914
|
+
parentCaret = parentRow.querySelector(".dp-caret");
|
|
4915
|
+
parentChildren = parentNode.querySelector(":scope > .dp-children");
|
|
4916
|
+
depth = (parseInt(parentNode.dataset.depth, 10) || 0) + 1;
|
|
4917
|
+
host = parentChildren;
|
|
4918
|
+
} else {
|
|
4919
|
+
depth = 0;
|
|
4920
|
+
host = treeContainer;
|
|
4921
|
+
}
|
|
4922
|
+
|
|
4923
|
+
// Build a placeholder node and seed it with an inline editor.
|
|
4924
|
+
const placeholder = document.createElement("div");
|
|
4925
|
+
placeholder.className = "dp-node dp-node-pending";
|
|
4926
|
+
placeholder.dataset.depth = depth;
|
|
4927
|
+
|
|
4928
|
+
const row = document.createElement("div");
|
|
4929
|
+
row.className = "dp-row dp-row-editing";
|
|
4930
|
+
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
|
4931
|
+
|
|
4932
|
+
const caret = document.createElement("span");
|
|
4933
|
+
caret.className = "dp-caret";
|
|
4934
|
+
caret.innerHTML = ICON_CARET_SVG;
|
|
4935
|
+
|
|
4936
|
+
const icon = document.createElement("span");
|
|
4937
|
+
icon.className = "dp-icon";
|
|
4938
|
+
icon.innerHTML = ICON_FOLDER_SVG;
|
|
4939
|
+
|
|
4940
|
+
const nameSpan = document.createElement("span");
|
|
4941
|
+
nameSpan.className = "dp-name";
|
|
4942
|
+
|
|
4943
|
+
row.appendChild(caret);
|
|
4944
|
+
row.appendChild(icon);
|
|
4945
|
+
row.appendChild(nameSpan);
|
|
4946
|
+
placeholder.appendChild(row);
|
|
4947
|
+
|
|
4948
|
+
// If we're inserting under a parent node, make sure the parent is
|
|
4949
|
+
// visually expanded so the user sees the placeholder. Mark the
|
|
4950
|
+
// children container as loaded so we don't trigger a refetch on
|
|
4951
|
+
// first toggleExpand (we'll keep our placeholder + inserted node).
|
|
4952
|
+
if (parentNode) {
|
|
4953
|
+
parentCaret.classList.add("open");
|
|
4954
|
+
parentChildren.style.display = "flex";
|
|
4955
|
+
// Drop "loading" / "empty" / "error" stub if present.
|
|
4956
|
+
const stub = parentChildren.querySelector(".dp-empty, .dp-loading, .dp-error");
|
|
4957
|
+
if (stub) stub.remove();
|
|
4958
|
+
parentChildren.dataset.loaded = "1";
|
|
4959
|
+
parentChildren.insertBefore(placeholder, parentChildren.firstChild);
|
|
4960
|
+
} else {
|
|
4961
|
+
// Root level: drop the empty/loading/error stub, then prepend.
|
|
4962
|
+
const emptyEl = treeContainer.querySelector(":scope > .dp-empty, :scope > .dp-loading, :scope > .dp-error");
|
|
4963
|
+
if (emptyEl) emptyEl.remove();
|
|
4964
|
+
treeContainer.insertBefore(placeholder, treeContainer.firstChild);
|
|
4965
|
+
}
|
|
4966
|
+
|
|
4967
|
+
startInlineEdit(nameSpan, t("sib.dir.newFolderDefault", "New Folder"), {
|
|
4968
|
+
onCommit: async (newName) => {
|
|
4969
|
+
const trimmed = (newName || "").trim();
|
|
4970
|
+
if (!trimmed) { placeholder.remove(); return; }
|
|
4971
|
+
try {
|
|
4972
|
+
const resp = await fetch("/api/dirs/mkdir", {
|
|
4973
|
+
method: "POST",
|
|
4974
|
+
headers: { "Content-Type": "application/json" },
|
|
4975
|
+
body: JSON.stringify({ parent: parentDir, name: trimmed })
|
|
4976
|
+
});
|
|
4977
|
+
const data = await resp.json().catch(() => ({}));
|
|
4978
|
+
if (!resp.ok || !data.ok) {
|
|
4979
|
+
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}")
|
|
4980
|
+
.replace("{{msg}}", data.error || `HTTP ${resp.status}`));
|
|
4981
|
+
placeholder.remove();
|
|
4982
|
+
return;
|
|
4983
|
+
}
|
|
4984
|
+
// Replace placeholder with a real, fully-wired node so it can
|
|
4985
|
+
// be expanded / right-clicked exactly like any sibling.
|
|
4986
|
+
const realEntry = {
|
|
4987
|
+
name: data.name,
|
|
4988
|
+
path: data.path,
|
|
4989
|
+
absPath: data.path,
|
|
4990
|
+
absolute: true,
|
|
4991
|
+
type: "dir"
|
|
4992
|
+
};
|
|
4993
|
+
const realNode = buildDirNode(realEntry, depth);
|
|
4994
|
+
placeholder.replaceWith(realNode);
|
|
4995
|
+
// Auto-select the new folder so the path input updates.
|
|
4996
|
+
modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
4997
|
+
realNode._dpRow.classList.add("selected");
|
|
4998
|
+
selectedPath = data.path;
|
|
4999
|
+
pathInput.value = data.path;
|
|
5000
|
+
refreshUpBtn();
|
|
5001
|
+
} catch (err) {
|
|
5002
|
+
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
|
|
5003
|
+
placeholder.remove();
|
|
5004
|
+
}
|
|
5005
|
+
},
|
|
5006
|
+
onCancel: () => placeholder.remove()
|
|
5007
|
+
});
|
|
5008
|
+
}
|
|
5009
|
+
|
|
5010
|
+
// ── Inline rename editor (shared by mkdir + rename) ───────────────
|
|
5011
|
+
// Replaces a name span with an <input> seeded by `initialValue`,
|
|
5012
|
+
// commits on Enter/blur, cancels on Escape. The caller wires the
|
|
5013
|
+
// backend round-trip via onCommit (it receives the new name string).
|
|
5014
|
+
function startInlineEdit(nameSpan, initialValue, { onCommit, onCancel }) {
|
|
5015
|
+
const input = document.createElement("input");
|
|
5016
|
+
input.type = "text";
|
|
5017
|
+
input.className = "dp-name-input";
|
|
5018
|
+
input.value = initialValue;
|
|
5019
|
+
input.spellcheck = false;
|
|
5020
|
+
input.autocomplete = "off";
|
|
5021
|
+
input.setAttribute("autocapitalize", "off");
|
|
5022
|
+
|
|
5023
|
+
// Stop click-through so editing doesn't trigger row selection /
|
|
5024
|
+
// expand / collapse.
|
|
5025
|
+
["click", "dblclick", "mousedown"].forEach(ev =>
|
|
5026
|
+
input.addEventListener(ev, (e) => e.stopPropagation())
|
|
5027
|
+
);
|
|
5028
|
+
|
|
5029
|
+
// Replace span with input. Track via parent so we can swap back.
|
|
5030
|
+
const parent = nameSpan.parentNode;
|
|
5031
|
+
parent.replaceChild(input, nameSpan);
|
|
5032
|
+
// Defer focus until the next tick so the caller's surrounding DOM
|
|
5033
|
+
// mutations (e.g. inserting a placeholder row) finish first.
|
|
5034
|
+
setTimeout(() => {
|
|
5035
|
+
input.focus();
|
|
5036
|
+
input.select();
|
|
5037
|
+
}, 0);
|
|
5038
|
+
|
|
5039
|
+
let finished = false;
|
|
5040
|
+
const finish = (commit) => {
|
|
5041
|
+
if (finished) return;
|
|
5042
|
+
finished = true;
|
|
5043
|
+
const value = input.value;
|
|
5044
|
+
if (input.parentNode === parent) parent.replaceChild(nameSpan, input);
|
|
5045
|
+
if (commit) {
|
|
5046
|
+
try { onCommit && onCommit(value); } catch (e) { console.error(e); }
|
|
5047
|
+
} else {
|
|
5048
|
+
try { onCancel && onCancel(); } catch (e) { console.error(e); }
|
|
5049
|
+
}
|
|
5050
|
+
};
|
|
5051
|
+
|
|
5052
|
+
// Enter commits; Escape cancels. Use IME.bindEnter so that the
|
|
5053
|
+
// Enter that confirms a Chinese / Japanese / Korean IME candidate
|
|
5054
|
+
// does NOT commit the rename. Mirrors Sessions._startRename.
|
|
5055
|
+
IME.bindEnter(input, () => finish(true));
|
|
5056
|
+
input.addEventListener("keydown", (e) => {
|
|
5057
|
+
if (e.key === "Escape") { e.preventDefault(); finish(false); }
|
|
5058
|
+
});
|
|
5059
|
+
input.addEventListener("blur", () => finish(true));
|
|
5060
|
+
}
|
|
5061
|
+
|
|
4616
5062
|
function setupPresets() {
|
|
4617
5063
|
presets.innerHTML = "";
|
|
4618
5064
|
presets.appendChild(upBtn);
|
|
4619
5065
|
const presetDirs = sessionLess
|
|
4620
5066
|
? [
|
|
4621
|
-
{ value:
|
|
4622
|
-
{ value:
|
|
5067
|
+
{ value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
|
|
5068
|
+
{ value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
|
|
5069
|
+
{ value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
|
|
4623
5070
|
]
|
|
4624
5071
|
: [
|
|
4625
|
-
{ value:
|
|
4626
|
-
{ value: "/",
|
|
4627
|
-
];
|
|
5072
|
+
{ value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
|
|
5073
|
+
{ value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
|
|
5074
|
+
];
|
|
5075
|
+
presetDirs.forEach(p => {
|
|
5076
|
+
// Skip preset whose absolute path isn't known yet (defensive: the
|
|
5077
|
+
// backend always returns it, but a malformed deploy shouldn't render
|
|
5078
|
+
// a button that navigates to "").
|
|
5079
|
+
if (!p.value) return;
|
|
4628
5080
|
const btn = document.createElement("button");
|
|
4629
5081
|
btn.className = "btn btn-secondary btn-sm";
|
|
4630
5082
|
btn.textContent = p.text;
|
|
@@ -4636,6 +5088,8 @@ const Sessions = (() => {
|
|
|
4636
5088
|
});
|
|
4637
5089
|
presets.appendChild(btn);
|
|
4638
5090
|
});
|
|
5091
|
+
// "+ New folder" trailing action on the same toolbar.
|
|
5092
|
+
presets.appendChild(newFolderBtn);
|
|
4639
5093
|
}
|
|
4640
5094
|
|
|
4641
5095
|
// Path input
|