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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/lib/clacky/billing/platform_billing.rb +3 -0
  4. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  5. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  6. data/lib/clacky/providers.rb +43 -17
  7. data/lib/clacky/search_config.rb +33 -11
  8. data/lib/clacky/server/git_panel.rb +360 -34
  9. data/lib/clacky/server/http_server.rb +145 -47
  10. data/lib/clacky/server/preview.rb +118 -17
  11. data/lib/clacky/server/scheduler.rb +81 -2
  12. data/lib/clacky/server/web_ui_controller.rb +35 -27
  13. data/lib/clacky/session_manager.rb +52 -5
  14. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  15. data/lib/clacky/utils/model_pricing.rb +60 -2
  16. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  17. data/lib/clacky/version.rb +1 -1
  18. data/lib/clacky/web/app.css +650 -96
  19. data/lib/clacky/web/app.js +59 -6
  20. data/lib/clacky/web/auth.js +1 -1
  21. data/lib/clacky/web/components/composer.js +107 -8
  22. data/lib/clacky/web/components/image-zoom.js +165 -0
  23. data/lib/clacky/web/components/model-picker.js +6 -4
  24. data/lib/clacky/web/components/notify.js +2 -2
  25. data/lib/clacky/web/core/aside.js +60 -3
  26. data/lib/clacky/web/features/billing/view.js +2 -2
  27. data/lib/clacky/web/features/channels/view.js +5 -2
  28. data/lib/clacky/web/features/extensions/view.js +2 -2
  29. data/lib/clacky/web/features/new-session/store.js +79 -2
  30. data/lib/clacky/web/features/new-session/view.js +496 -58
  31. data/lib/clacky/web/features/skills/store.js +1 -1
  32. data/lib/clacky/web/features/tasks/view.js +162 -51
  33. data/lib/clacky/web/features/workspace/store.js +13 -6
  34. data/lib/clacky/web/features/workspace/view.js +153 -163
  35. data/lib/clacky/web/i18n.js +238 -56
  36. data/lib/clacky/web/index.html +173 -157
  37. data/lib/clacky/web/projects.js +37 -4
  38. data/lib/clacky/web/sessions.js +181 -105
  39. data/lib/clacky/web/settings.js +26 -8
  40. data/lib/clacky/web/skills.js +2 -2
  41. data/lib/clacky/web/theme.js +17 -2
  42. data/lib/clacky/web/utils.js +17 -2
  43. data/lib/clacky/web/weixin-qr.html +2 -1
  44. data/lib/clacky/web/ws-dispatcher.js +8 -29
  45. metadata +2 -1
@@ -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 = "clacky_projects_sort_mode";
23
- const _LS_COLLAPSED_KEY = "clacky_projects_collapsed";
24
- const _LS_SECTION_COLLAPSE_KEY = "clacky_projects_section_collapsed";
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,
@@ -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]: timeoutId }
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 = "clacky_chat_section_collapsed";
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
- // Line-art glyphs for file chips. Emoji render differently on every platform (and
679
- // badly in dark themes), so chips draw a stroked SVG that inherits currentColor.
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
- return `<span class="msg-pdf-badge">` +
755
- `<span class="msg-pdf-badge-icon">${_docTypeIcon(mimeType, fname, 18)}</span>` +
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
- `</span>`;
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 = "Remove";
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 file cards (PDF, ZIP, DOC, XLS, PPT, etc.)
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 = _docTypeIcon(f.mime_type, f.name, 22);
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 = "Remove";
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
- item.appendChild(icon);
976
- item.appendChild(info);
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 ? "chat.feedback_answered" : "chat.feedback_waiting"))}</span>`;
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 badges = _pendingFiles.map(f => _attachmentBadge(f.name, f.mime_type)).join(" ");
1354
- bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
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
- Composer.init($("user-input"));
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 full chat panel accepts session references and file attachments.
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-panel"),
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 === true));
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: mark each request_feedback as answered.
2334
- // A feedback card is disabled as soon as any subsequent history_user_message appears
2335
- // (user either clicked an option or typed a reply).
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 === "request_feedback") { ev._answered = !!data.has_after; lastFeedbackIdx = i; }
2342
- if (ev.type === "history_user_message" && lastFeedbackIdx >= 0 && i > lastFeedbackIdx) {
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
- if (!_recentlyDone.has(id)) return false;
3619
- clearTimeout(_recentlyDone.get(id));
3663
+ const done = _recentlyDone.get(id);
3664
+ if (!done) return null;
3665
+ clearTimeout(done.timer);
3620
3666
  _recentlyDone.delete(id);
3621
- return true;
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.title = I18n.t("sib.mode.tooltip");
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.title = busy
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