openclacky 1.5.21 → 1.5.22
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 +61 -0
- data/lib/clacky/billing/platform_billing.rb +3 -0
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- data/lib/clacky/providers.rb +43 -17
- data/lib/clacky/search_config.rb +33 -11
- data/lib/clacky/server/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +145 -47
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/web_ui_controller.rb +35 -27
- data/lib/clacky/session_manager.rb +52 -5
- data/lib/clacky/utils/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +60 -2
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +650 -96
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/model-picker.js +6 -4
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +79 -2
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +238 -56
- data/lib/clacky/web/index.html +173 -157
- data/lib/clacky/web/projects.js +37 -4
- data/lib/clacky/web/sessions.js +181 -105
- data/lib/clacky/web/settings.js +26 -8
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +8 -29
- metadata +2 -1
data/lib/clacky/web/projects.js
CHANGED
|
@@ -19,9 +19,9 @@ const Projects = (() => {
|
|
|
19
19
|
|
|
20
20
|
// "recent" (default) — sort by updated_at desc
|
|
21
21
|
// "priority" — oldest-created project first
|
|
22
|
-
const _LS_SORT_KEY = "
|
|
23
|
-
const _LS_COLLAPSED_KEY = "
|
|
24
|
-
const _LS_SECTION_COLLAPSE_KEY = "
|
|
22
|
+
const _LS_SORT_KEY = "clacky-projects-sort-mode";
|
|
23
|
+
const _LS_COLLAPSED_KEY = "clacky-projects-collapsed";
|
|
24
|
+
const _LS_SECTION_COLLAPSE_KEY = "clacky-projects-section-collapsed";
|
|
25
25
|
let _sortMode = localStorage.getItem(_LS_SORT_KEY) || "recent";
|
|
26
26
|
let _sectionCollapsed = localStorage.getItem(_LS_SECTION_COLLAPSE_KEY) === "1";
|
|
27
27
|
let _draggedSession = null; // { id, projectId } during an internal session drag
|
|
@@ -29,7 +29,7 @@ const Projects = (() => {
|
|
|
29
29
|
|
|
30
30
|
// Track which project groups are collapsed: Set of project IDs (persisted to localStorage)
|
|
31
31
|
const _collapsed = new Set(
|
|
32
|
-
JSON.parse(localStorage.getItem(_LS_COLLAPSED_KEY)
|
|
32
|
+
JSON.parse(localStorage.getItem(_LS_COLLAPSED_KEY) ?? "[]")
|
|
33
33
|
);
|
|
34
34
|
|
|
35
35
|
function _saveCollapsed() {
|
|
@@ -161,6 +161,37 @@ const Projects = (() => {
|
|
|
161
161
|
Trash.refreshIfShown();
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* GET /api/projects/:id/git — branch state of a work tree. `dir` overrides
|
|
166
|
+
* the project's own working dir, since a session may run in another repo.
|
|
167
|
+
* Returns { repo: false } for paths that are not a git work tree.
|
|
168
|
+
*/
|
|
169
|
+
async function gitInfo(id, dir) {
|
|
170
|
+
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
171
|
+
const res = await fetch(`/api/projects/${id}/git${qs}`);
|
|
172
|
+
const data = await res.json();
|
|
173
|
+
if (!res.ok) throw new Error(data.error || "Failed to load git state");
|
|
174
|
+
return data;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* POST /api/projects/:id/git/checkout — switch the work tree to a branch.
|
|
179
|
+
* `create` branches off `from` (defaults to HEAD); pass a remote ref as
|
|
180
|
+
* `from` to get a tracking branch. `dir` targets a work tree other than the
|
|
181
|
+
* project's own. Throws with git's own message on failure (e.g. uncommitted
|
|
182
|
+
* changes blocking the switch).
|
|
183
|
+
*/
|
|
184
|
+
async function checkoutBranch(id, { branch, create = false, from = null, dir = "" } = {}) {
|
|
185
|
+
const res = await fetch(`/api/projects/${id}/git/checkout`, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "Content-Type": "application/json" },
|
|
188
|
+
body: JSON.stringify({ branch, create, from, dir }),
|
|
189
|
+
});
|
|
190
|
+
const data = await res.json();
|
|
191
|
+
if (!res.ok) throw new Error(data.error || "Failed to switch branch");
|
|
192
|
+
return data;
|
|
193
|
+
}
|
|
194
|
+
|
|
164
195
|
/**
|
|
165
196
|
* PATCH /api/sessions/:sessionId/project — assign or remove a session's project.
|
|
166
197
|
* Pass projectId=null to remove from project.
|
|
@@ -1182,6 +1213,8 @@ const Projects = (() => {
|
|
|
1182
1213
|
update,
|
|
1183
1214
|
remove,
|
|
1184
1215
|
moveSession,
|
|
1216
|
+
gitInfo,
|
|
1217
|
+
checkoutBranch,
|
|
1185
1218
|
promptCreate,
|
|
1186
1219
|
promptEdit,
|
|
1187
1220
|
promptDelete,
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -45,8 +45,10 @@ const Sessions = (() => {
|
|
|
45
45
|
// Sessions that finished while the user was looking elsewhere. Holds a
|
|
46
46
|
// transient "done" dot until the session is opened or DONE_DOT_TTL elapses,
|
|
47
47
|
// so a completed background task is noticeable without a permanent marker.
|
|
48
|
-
const _recentlyDone = new Map(); // { [session_id]:
|
|
48
|
+
const _recentlyDone = new Map(); // { [session_id]: { timer, summary } }
|
|
49
49
|
const DONE_DOT_TTL = 5 * 60 * 1000;
|
|
50
|
+
// Summary taken from _recentlyDone on open, appended once that history loads.
|
|
51
|
+
let _unseenSummary = null; // { id, ev } | null
|
|
50
52
|
// Active search result split when _filter.q is non-empty:
|
|
51
53
|
// { nameIds: Set<id>, contentIds: Set<id>, contentLoaded: bool }
|
|
52
54
|
let _searchSplit = null;
|
|
@@ -109,7 +111,7 @@ const Sessions = (() => {
|
|
|
109
111
|
let _pendingStdoutLines = null; // string[] | null
|
|
110
112
|
|
|
111
113
|
// ── Chat section collapse ──────────────────────────────────────────────────
|
|
112
|
-
const _CHAT_COLLAPSE_KEY = "
|
|
114
|
+
const _CHAT_COLLAPSE_KEY = "clacky-chat-section-collapsed";
|
|
113
115
|
let _chatCollapsed = localStorage.getItem(_CHAT_COLLAPSE_KEY) === "1";
|
|
114
116
|
|
|
115
117
|
// ── Markdown renderer ──────────────────────────────────────────────────
|
|
@@ -675,89 +677,30 @@ const Sessions = (() => {
|
|
|
675
677
|
return ACCEPTED_IMAGE_TYPES.includes(file.type);
|
|
676
678
|
}
|
|
677
679
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
const DOC_ICON_PATHS = {
|
|
681
|
-
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
|
|
682
|
-
doc: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M9 13h6"/><path d="M9 17h5"/>',
|
|
683
|
-
sheet: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h3"/><path d="M13 13h3"/><path d="M8 17h3"/><path d="M13 17h3"/>',
|
|
684
|
-
slide: '<path d="M2 3h20"/><path d="M20 3v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/>',
|
|
685
|
-
code: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/>',
|
|
686
|
-
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>',
|
|
687
|
-
archive: '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
|
|
688
|
-
video: '<circle cx="12" cy="12" r="9"/><path d="m10 8.5 5.5 3.5L10 15.5z"/>',
|
|
689
|
-
audio: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
|
|
690
|
-
};
|
|
691
|
-
|
|
692
|
-
function _docTypeIcon(mimeType, filename, size) {
|
|
693
|
-
const lower = (filename || "").toLowerCase();
|
|
694
|
-
const mime = mimeType || "";
|
|
695
|
-
const px = size || 20;
|
|
696
|
-
let key = "file";
|
|
697
|
-
if (mime.startsWith("image/") ||
|
|
698
|
-
lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") ||
|
|
699
|
-
lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp") ||
|
|
700
|
-
lower.endsWith(".svg") || lower.endsWith(".ico") || lower.endsWith(".avif") ||
|
|
701
|
-
lower.endsWith(".heic") || lower.endsWith(".tif") || lower.endsWith(".tiff")) key = "image";
|
|
702
|
-
else if (mime === "application/zip" || mime === "application/x-zip-compressed" ||
|
|
703
|
-
mime === "application/gzip" || mime === "application/x-gzip" ||
|
|
704
|
-
mime === "application/x-tar" || mime === "application/x-compressed-tar" ||
|
|
705
|
-
mime.includes("7z") || mime.includes("rar") ||
|
|
706
|
-
lower.endsWith(".zip") || lower.endsWith(".tar") || lower.endsWith(".gz") ||
|
|
707
|
-
lower.endsWith(".tgz") || lower.endsWith(".tar.gz") || lower.endsWith(".rar") ||
|
|
708
|
-
lower.endsWith(".7z")) key = "archive";
|
|
709
|
-
else if (mime.includes("spreadsheetml") || mime === "application/vnd.ms-excel" ||
|
|
710
|
-
mime === "text/csv" || mime === "application/csv" ||
|
|
711
|
-
lower.endsWith(".xls") || lower.endsWith(".xlsx") || lower.endsWith(".et") ||
|
|
712
|
-
lower.endsWith(".ods") || lower.endsWith(".csv")) key = "sheet";
|
|
713
|
-
else if (mime.includes("presentationml") || mime === "application/vnd.ms-powerpoint" ||
|
|
714
|
-
lower.endsWith(".ppt") || lower.endsWith(".pptx") || lower.endsWith(".dps") ||
|
|
715
|
-
lower.endsWith(".odp") || lower.endsWith(".key")) key = "slide";
|
|
716
|
-
else if (mime === "application/pdf" || lower.endsWith(".pdf") ||
|
|
717
|
-
mime.includes("wordprocessingml") || mime === "application/msword" ||
|
|
718
|
-
mime === "text/markdown" || mime === "text/x-markdown" || mime === "text/plain" ||
|
|
719
|
-
lower.endsWith(".doc") || lower.endsWith(".docx") || lower.endsWith(".wps") ||
|
|
720
|
-
lower.endsWith(".odt") || lower.endsWith(".rtf") || lower.endsWith(".md") ||
|
|
721
|
-
lower.endsWith(".markdown") || lower.endsWith(".txt") || lower.endsWith(".log")) key = "doc";
|
|
722
|
-
else if (mime.startsWith("video/") ||
|
|
723
|
-
lower.endsWith(".mp4") || lower.endsWith(".webm") || lower.endsWith(".mov") ||
|
|
724
|
-
lower.endsWith(".mkv") || lower.endsWith(".avi") || lower.endsWith(".m4v")) key = "video";
|
|
725
|
-
else if (mime.startsWith("audio/") ||
|
|
726
|
-
lower.endsWith(".mp3") || lower.endsWith(".wav") || lower.endsWith(".m4a") ||
|
|
727
|
-
lower.endsWith(".ogg") || lower.endsWith(".flac") || lower.endsWith(".aac")) key = "audio";
|
|
728
|
-
else if (lower.endsWith(".js") || lower.endsWith(".jsx") || lower.endsWith(".ts") ||
|
|
729
|
-
lower.endsWith(".tsx") || lower.endsWith(".py") || lower.endsWith(".rb") ||
|
|
730
|
-
lower.endsWith(".go") || lower.endsWith(".rs") || lower.endsWith(".java") ||
|
|
731
|
-
lower.endsWith(".c") || lower.endsWith(".h") || lower.endsWith(".cpp") ||
|
|
732
|
-
lower.endsWith(".hpp") || lower.endsWith(".cs") || lower.endsWith(".php") ||
|
|
733
|
-
lower.endsWith(".swift") || lower.endsWith(".kt") || lower.endsWith(".sh") ||
|
|
734
|
-
lower.endsWith(".bash") || lower.endsWith(".zsh") || lower.endsWith(".sql") ||
|
|
735
|
-
lower.endsWith(".json") || lower.endsWith(".yml") || lower.endsWith(".yaml") ||
|
|
736
|
-
lower.endsWith(".toml") || lower.endsWith(".xml") || lower.endsWith(".html") ||
|
|
737
|
-
lower.endsWith(".css") || lower.endsWith(".scss") || lower.endsWith(".vue") ||
|
|
738
|
-
lower.endsWith(".svelte") || lower.endsWith(".lua") || lower.endsWith(".pl") ||
|
|
739
|
-
lower.endsWith(".r") || lower.endsWith(".dart")) key = "code";
|
|
740
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 24 24" ` +
|
|
741
|
-
`fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" ` +
|
|
742
|
-
`stroke-linejoin="round" aria-hidden="true">${DOC_ICON_PATHS[key]}</svg>`;
|
|
680
|
+
function _fileIcon(name) {
|
|
681
|
+
return Clacky.WorkspaceView.fileIcon(name || "");
|
|
743
682
|
}
|
|
744
683
|
|
|
745
684
|
// Single source of truth for the file-attachment chip in a user bubble.
|
|
746
685
|
// Both the optimistic send path and the history/live replay paths render
|
|
747
686
|
// through here, so a badge never renders two different ways for one file.
|
|
748
|
-
function _attachmentBadge(name, mimeType) {
|
|
687
|
+
function _attachmentBadge(name, mimeType, filePath) {
|
|
749
688
|
const fname = name || "file";
|
|
750
689
|
const lower = fname.toLowerCase();
|
|
751
690
|
const ext = (fname.split(".").pop() || "file").toUpperCase();
|
|
752
691
|
// ".tar.gz" would otherwise show as just GZ
|
|
753
692
|
const displayExt = lower.endsWith(".tar.gz") ? "TAR.GZ" : ext;
|
|
754
|
-
|
|
755
|
-
|
|
693
|
+
const tag = filePath ? "button" : "span";
|
|
694
|
+
const attrs = filePath
|
|
695
|
+
? ` type="button" class="msg-pdf-badge msg-file-open" data-file-path="${encodeURIComponent(filePath)}"`
|
|
696
|
+
: ` class="msg-pdf-badge"`;
|
|
697
|
+
return `<${tag}${attrs}>` +
|
|
698
|
+
`<span class="msg-pdf-badge-icon">${_fileIcon(fname)}</span>` +
|
|
756
699
|
`<span class="msg-pdf-badge-info">` +
|
|
757
700
|
`<span class="msg-pdf-badge-name">${escapeHtml(fname)}</span>` +
|
|
758
701
|
`<span class="msg-pdf-badge-type">${escapeHtml(displayExt)}</span>` +
|
|
759
702
|
`</span>` +
|
|
760
|
-
|
|
703
|
+
`</${tag}>`;
|
|
761
704
|
}
|
|
762
705
|
|
|
763
706
|
// Compress an image File/Blob to a data URL within MAX_IMAGE_BYTES_SEND.
|
|
@@ -884,6 +827,28 @@ const Sessions = (() => {
|
|
|
884
827
|
return IMAGE_MIME_BY_EXT[ext] || "";
|
|
885
828
|
}
|
|
886
829
|
|
|
830
|
+
const IMAGE_FILE_EXTENSIONS = new Set([
|
|
831
|
+
"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "avif", "heic", "tif", "tiff"
|
|
832
|
+
]);
|
|
833
|
+
|
|
834
|
+
function _isImageAttachment(file) {
|
|
835
|
+
const mime = String(file?.mime_type || file?.type || "").toLowerCase();
|
|
836
|
+
const ext = String(file?.name || "").split(".").pop().toLowerCase();
|
|
837
|
+
return mime.startsWith("image/") || IMAGE_FILE_EXTENSIONS.has(ext);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function _attachmentImageUrl(filePath) {
|
|
841
|
+
if (!filePath) return "";
|
|
842
|
+
const path = window.Clacky?.Workspace?.absPath
|
|
843
|
+
? Clacky.Workspace.absPath(filePath)
|
|
844
|
+
: filePath;
|
|
845
|
+
return `/api/local-image?path=${encodeURIComponent(path)}`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function _openAttachment(filePath) {
|
|
849
|
+
return !!(filePath && window.Clacky?.WorkspaceView?.openFile(filePath));
|
|
850
|
+
}
|
|
851
|
+
|
|
887
852
|
// Stage a file the caller only knows by path — the Files tab hands over the
|
|
888
853
|
// entries listed by /api/sessions/:id/files. The path itself travels with the
|
|
889
854
|
// message (no /api/upload copy), so the agent parses the file where it lives.
|
|
@@ -926,7 +891,7 @@ const Sessions = (() => {
|
|
|
926
891
|
const removeBtn = document.createElement("button");
|
|
927
892
|
removeBtn.className = "img-preview-remove";
|
|
928
893
|
removeBtn.textContent = "✕";
|
|
929
|
-
removeBtn.title = "
|
|
894
|
+
removeBtn.title = I18n.t("tooltip.remove");
|
|
930
895
|
removeBtn.addEventListener("click", () => {
|
|
931
896
|
_pendingImages.splice(idx, 1);
|
|
932
897
|
_renderAttachmentPreviews();
|
|
@@ -936,15 +901,44 @@ const Sessions = (() => {
|
|
|
936
901
|
strip.appendChild(item);
|
|
937
902
|
});
|
|
938
903
|
|
|
939
|
-
// Render
|
|
904
|
+
// Render path-backed images as thumbnails, matching uploaded images while
|
|
905
|
+
// preserving their original attachment path in the outgoing payload.
|
|
940
906
|
_pendingFiles.forEach((f, idx) => {
|
|
907
|
+
if (_isImageAttachment(f)) {
|
|
908
|
+
const item = document.createElement("div");
|
|
909
|
+
item.className = "img-preview-item";
|
|
910
|
+
item.title = f.name;
|
|
911
|
+
const thumbnail = document.createElement("img");
|
|
912
|
+
thumbnail.className = "img-preview-thumb";
|
|
913
|
+
thumbnail.src = _attachmentImageUrl(f.path);
|
|
914
|
+
thumbnail.alt = f.name;
|
|
915
|
+
const removeBtn = document.createElement("button");
|
|
916
|
+
removeBtn.className = "img-preview-remove";
|
|
917
|
+
removeBtn.textContent = "✕";
|
|
918
|
+
removeBtn.title = I18n.t("tooltip.remove");
|
|
919
|
+
removeBtn.addEventListener("click", () => {
|
|
920
|
+
_pendingFiles.splice(idx, 1);
|
|
921
|
+
_renderAttachmentPreviews();
|
|
922
|
+
});
|
|
923
|
+
item.appendChild(thumbnail);
|
|
924
|
+
item.appendChild(removeBtn);
|
|
925
|
+
strip.appendChild(item);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// PDF, ZIP, DOC, XLS, PPT, etc. open through the existing Files viewer.
|
|
941
930
|
const item = document.createElement("div");
|
|
942
|
-
item.className = "pdf-preview-item";
|
|
931
|
+
item.className = "pdf-preview-item pdf-preview-item--clickable";
|
|
943
932
|
item.title = f.name;
|
|
944
933
|
|
|
934
|
+
const openBtn = document.createElement("button");
|
|
935
|
+
openBtn.type = "button";
|
|
936
|
+
openBtn.className = "pdf-preview-open";
|
|
937
|
+
openBtn.addEventListener("click", () => _openAttachment(f.path));
|
|
938
|
+
|
|
945
939
|
const icon = document.createElement("div");
|
|
946
940
|
icon.className = "pdf-preview-icon";
|
|
947
|
-
icon.innerHTML =
|
|
941
|
+
icon.innerHTML = _fileIcon(f.name);
|
|
948
942
|
|
|
949
943
|
const info = document.createElement("div");
|
|
950
944
|
info.className = "pdf-preview-info";
|
|
@@ -966,14 +960,15 @@ const Sessions = (() => {
|
|
|
966
960
|
const removeBtn = document.createElement("button");
|
|
967
961
|
removeBtn.className = "pdf-preview-remove";
|
|
968
962
|
removeBtn.textContent = "✕";
|
|
969
|
-
removeBtn.title = "
|
|
963
|
+
removeBtn.title = I18n.t("tooltip.remove");
|
|
970
964
|
removeBtn.addEventListener("click", () => {
|
|
971
965
|
_pendingFiles.splice(idx, 1);
|
|
972
966
|
_renderAttachmentPreviews();
|
|
973
967
|
});
|
|
974
968
|
|
|
975
|
-
|
|
976
|
-
|
|
969
|
+
openBtn.appendChild(icon);
|
|
970
|
+
openBtn.appendChild(info);
|
|
971
|
+
item.appendChild(openBtn);
|
|
977
972
|
item.appendChild(removeBtn);
|
|
978
973
|
strip.appendChild(item);
|
|
979
974
|
});
|
|
@@ -1049,6 +1044,7 @@ const Sessions = (() => {
|
|
|
1049
1044
|
}];
|
|
1050
1045
|
}
|
|
1051
1046
|
|
|
1047
|
+
// answered: i18n status key of a closed card, or falsy while it awaits input.
|
|
1052
1048
|
function _buildFeedbackCard(list, context, answered) {
|
|
1053
1049
|
// Multiple open questions are walked one at a time. A submitted card is a
|
|
1054
1050
|
// read-only record, so it stacks instead — hiding past questions there
|
|
@@ -1098,7 +1094,7 @@ const Sessions = (() => {
|
|
|
1098
1094
|
html += `</div>`;
|
|
1099
1095
|
});
|
|
1100
1096
|
|
|
1101
|
-
html += `<div class="feedback-actions"><span class="feedback-status">${escapeHtml(I18n.t(answered
|
|
1097
|
+
html += `<div class="feedback-actions"><span class="feedback-status">${escapeHtml(I18n.t(answered || "chat.feedback_waiting"))}</span>`;
|
|
1102
1098
|
if (stepped) {
|
|
1103
1099
|
html += `<button class="feedback-nav-btn feedback-back-btn" disabled>` +
|
|
1104
1100
|
`${escapeHtml(I18n.t("chat.feedback_back"))}</button>` +
|
|
@@ -1258,9 +1254,10 @@ const Sessions = (() => {
|
|
|
1258
1254
|
function _buildUserBubbleHtml(ev) {
|
|
1259
1255
|
let html = "";
|
|
1260
1256
|
const images = Array.isArray(ev.images) ? ev.images : [];
|
|
1257
|
+
const attachments = Array.isArray(ev.attachments) ? ev.attachments : [];
|
|
1261
1258
|
if (Array.isArray(ev.references) && ev.references.length > 0) {
|
|
1262
1259
|
html += ev.references.map(_renderMentionBadge).join(" ");
|
|
1263
|
-
if (ev.content || images.length > 0) html += "<br>";
|
|
1260
|
+
if (ev.content || images.length > 0 || attachments.length > 0) html += "<br>";
|
|
1264
1261
|
}
|
|
1265
1262
|
if (images.length > 0) {
|
|
1266
1263
|
html += images.map(src => {
|
|
@@ -1277,6 +1274,11 @@ const Sessions = (() => {
|
|
|
1277
1274
|
}
|
|
1278
1275
|
return `<img src="${escapeHtml(src)}" alt="image" class="msg-image-thumb">`;
|
|
1279
1276
|
}).join("");
|
|
1277
|
+
if (attachments.length > 0) html += " ";
|
|
1278
|
+
else if (ev.content) html += "<br>";
|
|
1279
|
+
}
|
|
1280
|
+
if (attachments.length > 0) {
|
|
1281
|
+
html += attachments.map(file => _attachmentBadge(file.name, file.type, file.path)).join(" ");
|
|
1280
1282
|
if (ev.content) html += "<br>";
|
|
1281
1283
|
}
|
|
1282
1284
|
return html + SkillAC.renderUserMessageHtml(ev.content || "", ev.skill_command, ev.skill_command_display);
|
|
@@ -1350,8 +1352,11 @@ const Sessions = (() => {
|
|
|
1350
1352
|
bubbleHtml = thumbs + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
1351
1353
|
}
|
|
1352
1354
|
if (_pendingFiles.length > 0) {
|
|
1353
|
-
const
|
|
1354
|
-
|
|
1355
|
+
const attachments = _pendingFiles.map(f => _isImageAttachment(f)
|
|
1356
|
+
? `<img src="${_attachmentImageUrl(f.path)}" alt="${escapeHtml(f.name)}" class="msg-image-thumb">`
|
|
1357
|
+
: _attachmentBadge(f.name, f.mime_type, f.path)
|
|
1358
|
+
).join(" ");
|
|
1359
|
+
bubbleHtml = attachments + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
1355
1360
|
}
|
|
1356
1361
|
Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
|
|
1357
1362
|
|
|
@@ -1423,8 +1428,14 @@ const Sessions = (() => {
|
|
|
1423
1428
|
},
|
|
1424
1429
|
});
|
|
1425
1430
|
}
|
|
1426
|
-
// contenteditable composer: chip deletion, plain-text paste, Shift+Enter
|
|
1427
|
-
|
|
1431
|
+
// contenteditable composer: chip deletion, plain-text paste, Shift+Enter,
|
|
1432
|
+
// and ArrowUp/Down recall of this session's sent messages.
|
|
1433
|
+
Composer.init($("user-input"), {
|
|
1434
|
+
getHistory: () => Array.from(
|
|
1435
|
+
RenderTarget.outer().querySelectorAll(".msg-user-wrap .msg-user"),
|
|
1436
|
+
el => _extractUserBubbleText(el)
|
|
1437
|
+
).filter(Boolean),
|
|
1438
|
+
});
|
|
1428
1439
|
setInterval(_renderFeedbackCountdown, 250);
|
|
1429
1440
|
document.addEventListener("input", (e) => {
|
|
1430
1441
|
if (e.target.closest?.("#user-input, .feedback-free-text")) _interveneFeedbackCountdown();
|
|
@@ -1529,11 +1540,14 @@ const Sessions = (() => {
|
|
|
1529
1540
|
e.target.value = "";
|
|
1530
1541
|
});
|
|
1531
1542
|
|
|
1532
|
-
// The
|
|
1543
|
+
// The chat column accepts session references and file attachments. The
|
|
1544
|
+
// session aside is deliberately excluded so workspace drops keep their
|
|
1545
|
+
// own behaviour.
|
|
1533
1546
|
Composer.bindDropZone({
|
|
1534
|
-
zone: document.getElementById("chat-
|
|
1547
|
+
zone: document.getElementById("chat-main"),
|
|
1535
1548
|
input: $("user-input"),
|
|
1536
1549
|
onFiles: files => files.forEach(_addAttachmentFile),
|
|
1550
|
+
onWorkspaceFile: attachWorkspaceFile,
|
|
1537
1551
|
});
|
|
1538
1552
|
|
|
1539
1553
|
document.getElementById("user-input").addEventListener("paste", (e) => {
|
|
@@ -2235,7 +2249,7 @@ const Sessions = (() => {
|
|
|
2235
2249
|
const rfList = _normalizeFeedbackQuestions(ev.questions, ev.question, ev.options);
|
|
2236
2250
|
if (rfList.length === 0) break;
|
|
2237
2251
|
|
|
2238
|
-
container.appendChild(_buildFeedbackCard(rfList, rfContext, ev._answered
|
|
2252
|
+
container.appendChild(_buildFeedbackCard(rfList, rfContext, ev._answered || null));
|
|
2239
2253
|
break;
|
|
2240
2254
|
}
|
|
2241
2255
|
|
|
@@ -2330,19 +2344,20 @@ const Sessions = (() => {
|
|
|
2330
2344
|
const events = data.events || [];
|
|
2331
2345
|
let lastRoundError = null;
|
|
2332
2346
|
|
|
2333
|
-
// Pre-scan:
|
|
2334
|
-
// A
|
|
2335
|
-
//
|
|
2347
|
+
// Pre-scan: a feedback card stays open only while nothing follows it.
|
|
2348
|
+
// A user message means it was answered; any later agent activity means
|
|
2349
|
+
// the agent decided on its own (auto-approve timeout, interrupt, error).
|
|
2336
2350
|
{
|
|
2351
|
+
const agentActs = ["assistant_message", "tool_call", "request_feedback", "subagent_start", "error"];
|
|
2337
2352
|
let lastFeedbackIdx = -1;
|
|
2338
2353
|
events.forEach((ev, i) => {
|
|
2339
2354
|
if (ev.type === "history_user_message") lastRoundError = null;
|
|
2340
2355
|
if (ev.type === "error") lastRoundError = ev;
|
|
2341
|
-
if (ev.type === "
|
|
2342
|
-
|
|
2343
|
-
events[lastFeedbackIdx]._answered = true;
|
|
2356
|
+
if (lastFeedbackIdx >= 0 && (ev.type === "history_user_message" || agentActs.includes(ev.type))) {
|
|
2357
|
+
events[lastFeedbackIdx]._answered = ev.type === "history_user_message" ? "chat.feedback_answered" : "chat.feedback_timeout";
|
|
2344
2358
|
lastFeedbackIdx = -1;
|
|
2345
2359
|
}
|
|
2360
|
+
if (ev.type === "request_feedback") { ev._answered = data.has_after ? "chat.feedback_answered" : null; lastFeedbackIdx = i; }
|
|
2346
2361
|
});
|
|
2347
2362
|
}
|
|
2348
2363
|
|
|
@@ -2415,6 +2430,10 @@ const Sessions = (() => {
|
|
|
2415
2430
|
// Restore transient UI state based on session status after initial load
|
|
2416
2431
|
// (not prepend, which is scroll-up pagination — no need to re-restore then)
|
|
2417
2432
|
if (!prepend && !state.hasAfter) {
|
|
2433
|
+
if (_unseenSummary?.id === id) {
|
|
2434
|
+
Sessions.appendCompleteSummary(_unseenSummary.ev);
|
|
2435
|
+
_unseenSummary = null;
|
|
2436
|
+
}
|
|
2418
2437
|
const session = _sessions.find(s => s.id === id);
|
|
2419
2438
|
if (session) {
|
|
2420
2439
|
if (session.status === "running") {
|
|
@@ -3007,6 +3026,8 @@ const Sessions = (() => {
|
|
|
3007
3026
|
activeClass,
|
|
3008
3027
|
distanceProperty,
|
|
3009
3028
|
durationProperty,
|
|
3029
|
+
fadeClass = null,
|
|
3030
|
+
fadeDelay = 300,
|
|
3010
3031
|
revealPadding = 16,
|
|
3011
3032
|
measureDelay = 170,
|
|
3012
3033
|
}) {
|
|
@@ -3015,8 +3036,10 @@ const Sessions = (() => {
|
|
|
3015
3036
|
if (!viewport || !content) return;
|
|
3016
3037
|
|
|
3017
3038
|
let measureTimer = null;
|
|
3039
|
+
let fadeTimer = null;
|
|
3018
3040
|
item.addEventListener("pointerenter", () => {
|
|
3019
3041
|
clearTimeout(measureTimer);
|
|
3042
|
+
clearTimeout(fadeTimer);
|
|
3020
3043
|
measureTimer = setTimeout(() => {
|
|
3021
3044
|
const overflow = content.scrollWidth - viewport.clientWidth;
|
|
3022
3045
|
if (overflow <= 1) return;
|
|
@@ -3028,12 +3051,17 @@ const Sessions = (() => {
|
|
|
3028
3051
|
item.style.setProperty(distanceProperty, `${distance}px`);
|
|
3029
3052
|
item.style.setProperty(durationProperty, `${duration}s`);
|
|
3030
3053
|
item.classList.add(activeClass);
|
|
3054
|
+
// Mirrors the CSS transition delay: a leading fade that shows up first
|
|
3055
|
+
// would dim the opening glyphs before they start moving.
|
|
3056
|
+
if (fadeClass) fadeTimer = setTimeout(() => item.classList.add(fadeClass), fadeDelay);
|
|
3031
3057
|
}, measureDelay);
|
|
3032
3058
|
});
|
|
3033
3059
|
item.addEventListener("pointerleave", () => {
|
|
3034
3060
|
clearTimeout(measureTimer);
|
|
3061
|
+
clearTimeout(fadeTimer);
|
|
3035
3062
|
measureTimer = null;
|
|
3036
3063
|
item.classList.remove(activeClass);
|
|
3064
|
+
if (fadeClass) item.classList.remove(fadeClass);
|
|
3037
3065
|
item.style.removeProperty(distanceProperty);
|
|
3038
3066
|
item.style.removeProperty(durationProperty);
|
|
3039
3067
|
});
|
|
@@ -3047,6 +3075,7 @@ const Sessions = (() => {
|
|
|
3047
3075
|
viewportSelector: ".session-name__text",
|
|
3048
3076
|
contentSelector: ".session-name__content",
|
|
3049
3077
|
activeClass: "session-name-scrolling",
|
|
3078
|
+
fadeClass: "session-name-leading-fade",
|
|
3050
3079
|
distanceProperty: "--session-name-scroll-distance",
|
|
3051
3080
|
durationProperty: "--session-name-scroll-duration",
|
|
3052
3081
|
});
|
|
@@ -3406,6 +3435,15 @@ const Sessions = (() => {
|
|
|
3406
3435
|
// Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
|
|
3407
3436
|
// OS default handler. Remote deployments: download the file.
|
|
3408
3437
|
document.addEventListener("click", async (e) => {
|
|
3438
|
+
const attachment = e.target.closest?.(".msg-file-open[data-file-path]");
|
|
3439
|
+
if (attachment) {
|
|
3440
|
+
e.preventDefault();
|
|
3441
|
+
let filePath = attachment.dataset.filePath || "";
|
|
3442
|
+
try { filePath = decodeURIComponent(filePath); } catch (_) { /* use raw path */ }
|
|
3443
|
+
_openAttachment(filePath);
|
|
3444
|
+
return;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3409
3447
|
// Localhost dev-server URLs open in the preview panel instead of a new
|
|
3410
3448
|
// tab (local deployments only — the proxy can't reach a remote user's
|
|
3411
3449
|
// localhost). Links that belong to the platform (not a dev server) opt
|
|
@@ -3607,18 +3645,26 @@ const Sessions = (() => {
|
|
|
3607
3645
|
* No-op for the session the user is already looking at. */
|
|
3608
3646
|
markDone(id) {
|
|
3609
3647
|
if (!id || id === _activeId) return;
|
|
3610
|
-
clearTimeout(_recentlyDone.get(id));
|
|
3611
|
-
_recentlyDone.set(id, setTimeout(() => {
|
|
3648
|
+
clearTimeout(_recentlyDone.get(id)?.timer);
|
|
3649
|
+
_recentlyDone.set(id, { summary: null, timer: setTimeout(() => {
|
|
3612
3650
|
_recentlyDone.delete(id);
|
|
3613
3651
|
Sessions.renderList();
|
|
3614
|
-
}, DONE_DOT_TTL));
|
|
3652
|
+
}, DONE_DOT_TTL) });
|
|
3653
|
+
},
|
|
3654
|
+
|
|
3655
|
+
/** Keep the turn's "complete" payload with its done dot until the session is opened. */
|
|
3656
|
+
markDoneSummary(id, ev) {
|
|
3657
|
+
const done = _recentlyDone.get(id);
|
|
3658
|
+
if (done) done.summary = ev;
|
|
3615
3659
|
},
|
|
3616
3660
|
|
|
3661
|
+
/** Drop the done dot; returns its unseen summary (or null). */
|
|
3617
3662
|
clearDone(id) {
|
|
3618
|
-
|
|
3619
|
-
|
|
3663
|
+
const done = _recentlyDone.get(id);
|
|
3664
|
+
if (!done) return null;
|
|
3665
|
+
clearTimeout(done.timer);
|
|
3620
3666
|
_recentlyDone.delete(id);
|
|
3621
|
-
return
|
|
3667
|
+
return done.summary;
|
|
3622
3668
|
},
|
|
3623
3669
|
|
|
3624
3670
|
/** Remove a session from the list (from session_deleted event). */
|
|
@@ -4025,7 +4071,8 @@ const Sessions = (() => {
|
|
|
4025
4071
|
_setActiveId(id) {
|
|
4026
4072
|
const oldId = _activeId;
|
|
4027
4073
|
_activeId = id;
|
|
4028
|
-
Sessions.clearDone(id);
|
|
4074
|
+
const summary = Sessions.clearDone(id);
|
|
4075
|
+
_unseenSummary = summary ? { id, ev: summary } : null;
|
|
4029
4076
|
// Cancel any pending "all done" hide timer from the previous session —
|
|
4030
4077
|
// it shouldn't fire on the new one.
|
|
4031
4078
|
if (Sessions._todoClearTimer) {
|
|
@@ -4709,7 +4756,6 @@ const Sessions = (() => {
|
|
|
4709
4756
|
const sibDirText = $("sib-dir-text");
|
|
4710
4757
|
if (sibDir && sibDirText) {
|
|
4711
4758
|
sibDirText.textContent = s.working_dir ? _compactWorkingDir(s.working_dir) : "";
|
|
4712
|
-
sibDir.title = `${s.working_dir} (${I18n.t("sib.dir.tooltip")})`;
|
|
4713
4759
|
sibDir.hidden = !s.working_dir;
|
|
4714
4760
|
if (s.working_dir) {
|
|
4715
4761
|
sibDir.dataset.workingDir = s.working_dir;
|
|
@@ -4736,7 +4782,7 @@ const Sessions = (() => {
|
|
|
4736
4782
|
}
|
|
4737
4783
|
sibMode.dataset.sessionId = s.id;
|
|
4738
4784
|
sibMode.disabled = false;
|
|
4739
|
-
sibMode.
|
|
4785
|
+
sibMode.setAttribute("data-tooltip", I18n.t("sib.mode.tooltip"));
|
|
4740
4786
|
sibMode.style.display = "";
|
|
4741
4787
|
}
|
|
4742
4788
|
if (sibSepAfterMode) {
|
|
@@ -4772,9 +4818,9 @@ const Sessions = (() => {
|
|
|
4772
4818
|
sibModel.classList.toggle("sib-model-no-subs", (s.sub_model_options || []).length <= 1);
|
|
4773
4819
|
const busy = s.status === "running";
|
|
4774
4820
|
sibModel.classList.toggle("sib-model-disabled", busy);
|
|
4775
|
-
sibModel.
|
|
4821
|
+
sibModel.setAttribute("data-tooltip", busy
|
|
4776
4822
|
? I18n.t("sib.model.tooltip.busy")
|
|
4777
|
-
: I18n.t("sib.model.tooltip");
|
|
4823
|
+
: I18n.t("sib.model.tooltip"));
|
|
4778
4824
|
}
|
|
4779
4825
|
if (sibModelWrap) sibModelWrap.style.display = s.model ? "" : "none";
|
|
4780
4826
|
|
|
@@ -5226,6 +5272,36 @@ const Sessions = (() => {
|
|
|
5226
5272
|
}
|
|
5227
5273
|
},
|
|
5228
5274
|
|
|
5275
|
+
/** Render the end-of-turn "✓ Done" summary from a `complete` payload. */
|
|
5276
|
+
appendCompleteSummary(ev) {
|
|
5277
|
+
const costSource = ev.cost_source;
|
|
5278
|
+
const symbol = typeof Billing !== "undefined" ? Billing.getCurrencySymbol() : "$";
|
|
5279
|
+
const rawCost = ev.cost || 0;
|
|
5280
|
+
const cost = typeof Billing !== "undefined" ? Billing.convertCost(rawCost) : rawCost;
|
|
5281
|
+
const costDisplay = (!costSource || costSource === "estimated")
|
|
5282
|
+
? "N/A"
|
|
5283
|
+
: `${symbol}${cost.toFixed(4)}`;
|
|
5284
|
+
let mainLine = I18n.t("chat.done", { n: ev.iterations, cost: costDisplay });
|
|
5285
|
+
if (typeof ev.duration === "number" && ev.duration > 0) {
|
|
5286
|
+
mainLine += I18n.t("chat.done.duration", { duration: ev.duration.toFixed(1) });
|
|
5287
|
+
}
|
|
5288
|
+
let cacheLine = null;
|
|
5289
|
+
const cs = ev.cache_stats;
|
|
5290
|
+
const total = cs && (cs.total_requests || cs["total_requests"]);
|
|
5291
|
+
const hits = cs && (cs.cache_hit_requests || cs["cache_hit_requests"]);
|
|
5292
|
+
const cachedTokens = cs && (cs.cache_read_input_tokens || cs["cache_read_input_tokens"]);
|
|
5293
|
+
if (total && total > 0 && cachedTokens && cachedTokens > 0) {
|
|
5294
|
+
const rate = ((hits / total) * 100).toFixed(1);
|
|
5295
|
+
const tokensFmt = cachedTokens >= 1000
|
|
5296
|
+
? `${(cachedTokens / 1000).toFixed(1)}k`
|
|
5297
|
+
: `${cachedTokens}`;
|
|
5298
|
+
cacheLine = I18n.t("chat.done.cache", {
|
|
5299
|
+
rate, hits, total: total, tokens: tokensFmt
|
|
5300
|
+
});
|
|
5301
|
+
}
|
|
5302
|
+
Sessions.appendInfo(`✓ ${mainLine}`, cacheLine);
|
|
5303
|
+
},
|
|
5304
|
+
|
|
5229
5305
|
appendInfo(text, subline) {
|
|
5230
5306
|
Sessions.collapseToolGroup();
|
|
5231
5307
|
const messages = RenderTarget.current();
|
|
@@ -5632,6 +5708,7 @@ const Sessions = (() => {
|
|
|
5632
5708
|
viewportSelector: ".todo-text",
|
|
5633
5709
|
contentSelector: ".todo-text__content",
|
|
5634
5710
|
activeClass: "todo-text-scrolling",
|
|
5711
|
+
fadeClass: "todo-text-leading-fade",
|
|
5635
5712
|
distanceProperty: "--todo-text-scroll-distance",
|
|
5636
5713
|
durationProperty: "--todo-text-scroll-duration",
|
|
5637
5714
|
revealPadding: 12,
|
|
@@ -7112,7 +7189,6 @@ const Sessions = (() => {
|
|
|
7112
7189
|
const sibDirText = $("sib-dir-text");
|
|
7113
7190
|
if (sibDir && sibDirText) {
|
|
7114
7191
|
sibDirText.textContent = newDir;
|
|
7115
|
-
sibDir.title = `${newDir} (${I18n.t("sib.dir.tooltip")})`;
|
|
7116
7192
|
sibDir.dataset.workingDir = newDir;
|
|
7117
7193
|
}
|
|
7118
7194
|
|