mulmoclaude 0.9.2 → 0.9.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.
Files changed (67) hide show
  1. package/bin/mulmoclaude.js +2 -1
  2. package/client/assets/{index-Dc0R-HW5.js → index-B3NxFcEH.js} +46 -46
  3. package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
  4. package/client/assets/{marp-CSq0PPfK.js → marp-C9QDHFAJ.js} +1 -1
  5. package/client/index.html +2 -2
  6. package/package.json +11 -10
  7. package/server/api/routes/agent.ts +5 -1
  8. package/server/api/routes/chat-index.ts +5 -1
  9. package/server/api/routes/collections.ts +152 -1
  10. package/server/api/routes/config.ts +12 -0
  11. package/server/index.ts +14 -2
  12. package/server/prompts/system/system.md +7 -1
  13. package/server/remoteHost/firebase.ts +6 -0
  14. package/server/remoteHost/handlers/collectionPage.ts +15 -17
  15. package/server/remoteHost/handlers/getCollection.ts +2 -2
  16. package/server/remoteHost/handlers/getFeed.ts +2 -2
  17. package/server/remoteHost/handlers/getRemoteView.ts +37 -0
  18. package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
  19. package/server/remoteHost/handlers/index.ts +10 -0
  20. package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
  21. package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
  22. package/server/remoteHost/handlers/listCollections.ts +6 -1
  23. package/server/remoteHost/handlers/listSkills.ts +39 -0
  24. package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
  25. package/server/remoteHost/handlers/startChat.ts +103 -39
  26. package/server/system/config.ts +101 -4
  27. package/server/system/logs/aaa +737 -0
  28. package/server/system/logs/bb +446 -0
  29. package/server/utils/files/thumbnail-store.ts +97 -0
  30. package/server/workspace/chat-index/index.ts +29 -3
  31. package/server/workspace/chat-index/indexer.ts +187 -29
  32. package/server/workspace/chat-index/summarizer.ts +18 -10
  33. package/server/workspace/collections/index.ts +2 -1
  34. package/server/workspace/collections/remoteView.ts +290 -0
  35. package/server/workspace/journal/archivist-cli.ts +17 -3
  36. package/server/workspace/journal/dailyPass.ts +52 -2
  37. package/server/workspace/journal/index.ts +20 -4
  38. package/src/App.vue +62 -3
  39. package/src/components/FileTree.vue +21 -1
  40. package/src/components/FileTreePane.vue +41 -25
  41. package/src/components/FilesView.vue +4 -0
  42. package/src/components/RemoteHostControl.vue +18 -0
  43. package/src/components/SessionHistoryPanel.vue +24 -11
  44. package/src/components/SettingsChatIndexTab.vue +119 -0
  45. package/src/components/SettingsJournalTab.vue +117 -0
  46. package/src/components/SettingsModal.vue +12 -2
  47. package/src/composables/collections/uiHost.ts +15 -0
  48. package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
  49. package/src/composables/usePubSub.ts +40 -2
  50. package/src/composables/useSessionSync.ts +9 -0
  51. package/src/composables/useShowHiddenSystemFiles.ts +23 -0
  52. package/src/config/apiRoutes.ts +20 -0
  53. package/src/config/roles.ts +2 -2
  54. package/src/config/visibleWorkspaceDirs.ts +21 -0
  55. package/src/lang/de.ts +47 -0
  56. package/src/lang/en.ts +45 -0
  57. package/src/lang/es.ts +46 -0
  58. package/src/lang/fr.ts +47 -0
  59. package/src/lang/ja.ts +46 -0
  60. package/src/lang/ko.ts +45 -0
  61. package/src/lang/pt-BR.ts +46 -0
  62. package/src/lang/zh.ts +44 -0
  63. package/src/plugins/textResponse/View.vue +49 -14
  64. package/src/plugins/textResponse/utils.ts +37 -0
  65. package/src/utils/html/previewCsp.ts +7 -16
  66. package/src/utils/role/icon.ts +9 -2
  67. package/src/utils/session/sessionPreview.ts +29 -0
package/src/lang/ko.ts CHANGED
@@ -141,6 +141,9 @@ const koMessages = {
141
141
  disconnectFailed: "연결 해제 실패",
142
142
  signInFailed: "Google 로그인 실패",
143
143
  statusFailed: "상태를 불러오지 못했습니다",
144
+ description: "원격 액세스를 사용하면 모바일 기기에서 이 MulmoClaude의 컬렉션과 피드에 연결할 수 있습니다.",
145
+ howTo: "휴대폰에서 {url} 을(를) 열고 같은 Google 계정으로 로그인하세요.",
146
+ customViewHint: "모바일에 최적화된 뷰가 필요하다면, 일반 custom view가 아닌 {keyword} 를(을) 만들어 달라고 Claude에게 요청하세요.",
144
147
  },
145
148
  sidebarHeader: {
146
149
  home: "최신 채팅으로 이동",
@@ -184,6 +187,8 @@ const koMessages = {
184
187
  reference: "참조",
185
188
  // "RO" = Read-Only. 간결한 배지로 표시하기 위해 영문 약어 유지.
186
189
  readOnlyBadge: "RO",
190
+ showSystemFiles: "시스템 파일 표시",
191
+ showSystemFilesTitle: "사용자 콘텐츠(data/, artifacts/, config/)에 더해 에이전트 내부 최상위 디렉터리(conversations/, feeds/ 등)까지 표시합니다.",
187
192
  },
188
193
  fileTree: {
189
194
  workspace: "(워크스페이스)",
@@ -235,6 +240,8 @@ const koMessages = {
235
240
  photos: "사진",
236
241
  model: "모델",
237
242
  voice: "음성",
243
+ chatIndex: "채팅 인덱스",
244
+ journal: "저널",
238
245
  skills: "스킬",
239
246
  roles: "역할",
240
247
  },
@@ -292,6 +299,42 @@ const koMessages = {
292
299
  loadError: "설정을 불러오지 못했습니다",
293
300
  saveError: "저장에 실패했습니다",
294
301
  },
302
+ chatIndexTab: {
303
+ description:
304
+ "채팅 기록의 AI 제목/요약 자동 생성을 설정합니다. 기본값은 off 이며, 자동화 계열 세션(scheduler / 시스템 워커)은 on 상태여도 항상 제외됩니다. 사람 세션만 턴 종료 시 요약이 한 번 실행됩니다.",
305
+ modeLabel: "채팅 인덱스 모델",
306
+ helperText: "Haiku 가 더 저렴하고, Sonnet 은 길고 주제가 바뀌는 세션에서 더 정확한 제목을 만듭니다.",
307
+ mode: {
308
+ off: "꺼짐",
309
+ haiku: "Haiku",
310
+ sonnet: "Sonnet",
311
+ },
312
+ status: {
313
+ off: "인덱싱: 꺼짐",
314
+ haiku: "Haiku 로 인덱싱 중",
315
+ sonnet: "Sonnet 으로 인덱싱 중",
316
+ },
317
+ loadError: "설정을 불러오지 못했습니다",
318
+ saveError: "저장에 실패했습니다",
319
+ },
320
+ journalTab: {
321
+ description:
322
+ "일일 저널 자동 생성 설정입니다. 최근 채팅 세션을 journal/*.md 로 요약하고 지속 메모(memory.md)를 추출합니다. 기본값은 off. 자동화 세션(scheduler / 시스템 워커)은 이 설정과 무관하게 항상 제외됩니다.",
323
+ modeLabel: "저널 모델",
324
+ helperText: "Haiku 가 더 저렴하고, Sonnet 은 일일/주제 요약 품질이 더 좋습니다. 매시 실행은 이 설정이 켜져 있을 때만 동작합니다.",
325
+ mode: {
326
+ off: "꺼짐",
327
+ haiku: "Haiku",
328
+ sonnet: "Sonnet",
329
+ },
330
+ status: {
331
+ off: "저널: 꺼짐",
332
+ haiku: "Haiku 로 저널 실행 중",
333
+ sonnet: "Sonnet 으로 저널 실행 중",
334
+ },
335
+ loadError: "설정을 불러오지 못했습니다",
336
+ saveError: "저장에 실패했습니다",
337
+ },
295
338
  // `<i18n-t>` 슬롯 — `envKey` / `envFile` 은 SettingsModal.vue 에서
296
339
  // 인라인 `<code>` 로 렌더링되므로 변수명·파일명은 번역하지 않고
297
340
  // 남깁니다.
@@ -935,6 +978,8 @@ const koMessages = {
935
978
  cancel: "취소",
936
979
  seededByPlugin: "{pkg}에서",
937
980
  seededByPluginTooltip: "이 메시지는 사용자가 보낸 것이 아니라 {pkg} 플러그인에서 작성한 것입니다.",
981
+ truncatedForRender:
982
+ "이 메시지는 매우 깁니다(총 {total}자). 탭이 멈추지 않도록 앞부분만 표시됩니다 — {omitted}자 숨김. 전체 원문은 복사 버튼으로 가져올 수 있습니다.",
938
983
  },
939
984
  pluginSkill: {
940
985
  noDescription: "(설명 없음)",
package/src/lang/pt-BR.ts CHANGED
@@ -134,6 +134,9 @@ const ptBRMessages = {
134
134
  disconnectFailed: "Falha ao desconectar",
135
135
  signInFailed: "Falha ao entrar com o Google",
136
136
  statusFailed: "Falha ao carregar o status",
137
+ description: "O acesso remoto permite que um dispositivo móvel se conecte às coleções e feeds deste MulmoClaude.",
138
+ howTo: "No seu telefone, abra {url} e entre com a mesma conta do Google.",
139
+ customViewHint: "Para uma visualização adaptada ao celular, peça ao Claude para criar uma {keyword} (não uma custom view comum).",
137
140
  },
138
141
  sidebarHeader: {
139
142
  home: "Ir para o chat mais recente",
@@ -178,6 +181,9 @@ const ptBRMessages = {
178
181
  // "RO" = Read-Only. Abreviação em inglês mantida para caber como
179
182
  // badge compacto ao lado do rótulo Referência.
180
183
  readOnlyBadge: "RO",
184
+ showSystemFiles: "Exibir arquivos do sistema",
185
+ showSystemFilesTitle:
186
+ "Mostra os diretórios raiz internos do agente (conversations/, feeds/ etc.) além do conteúdo do usuário (data/, artifacts/, config/).",
181
187
  },
182
188
  fileTree: {
183
189
  workspace: "(workspace)",
@@ -229,6 +235,8 @@ const ptBRMessages = {
229
235
  photos: "Fotos",
230
236
  model: "Modelo",
231
237
  voice: "Voz",
238
+ chatIndex: "Índice de chat",
239
+ journal: "Diário",
232
240
  skills: "Skills",
233
241
  roles: "Papéis",
234
242
  },
@@ -288,6 +296,42 @@ const ptBRMessages = {
288
296
  loadError: "Falha ao carregar as configurações",
289
297
  saveError: "Falha ao salvar",
290
298
  },
299
+ chatIndexTab: {
300
+ description:
301
+ "Títulos e resumos gerados por IA para o histórico do chat. Sai desativado por padrão — sessões de automação (scheduler / workers do sistema) sempre são ignoradas mesmo quando ativado; sessões humanas pagam apenas uma chamada ao sumarizador ao terminar cada turno.",
302
+ modeLabel: "Modelo do índice de chat",
303
+ helperText: "Haiku é mais barato; Sonnet dá títulos mais precisos em sessões longas que mudam de assunto.",
304
+ mode: {
305
+ off: "Desativado",
306
+ haiku: "Haiku",
307
+ sonnet: "Sonnet",
308
+ },
309
+ status: {
310
+ off: "Indexação DESATIVADA",
311
+ haiku: "Indexando com Haiku",
312
+ sonnet: "Indexando com Sonnet",
313
+ },
314
+ loadError: "Falha ao carregar as configurações",
315
+ saveError: "Falha ao salvar",
316
+ },
317
+ journalTab: {
318
+ description:
319
+ "Diário diário automatizado — resume sessões recentes de chat em journal/*.md e extrai notas de memória duradoura. Sai desativado por padrão. Sessões de automação (scheduler / workers do sistema) são sempre excluídas, independentemente desta configuração.",
320
+ modeLabel: "Modelo do diário",
321
+ helperText: "Haiku é mais barato; Sonnet produz resumos diários / por tópico mais ricos. A passagem horária só é executada quando isto está ativado.",
322
+ mode: {
323
+ off: "Desativado",
324
+ haiku: "Haiku",
325
+ sonnet: "Sonnet",
326
+ },
327
+ status: {
328
+ off: "Diário DESATIVADO",
329
+ haiku: "Diário em execução com Haiku",
330
+ sonnet: "Diário em execução com Sonnet",
331
+ },
332
+ loadError: "Falha ao carregar as configurações",
333
+ saveError: "Falha ao salvar",
334
+ },
291
335
  // Slots `<i18n-t>` — os nomes `envKey` / `envFile` renderizam como
292
336
  // `<code>` inline no SettingsModal.vue, então os literais de
293
337
  // variável e nome de arquivo permanecem não traduzidos.
@@ -936,6 +980,8 @@ const ptBRMessages = {
936
980
  cancel: "Cancelar",
937
981
  seededByPlugin: "de {pkg}",
938
982
  seededByPluginTooltip: "Esta mensagem foi gerada pelo plugin {pkg}, não foi enviada por você.",
983
+ truncatedForRender:
984
+ "Esta mensagem é excepcionalmente longa ({total} caracteres no total). Apenas a primeira parte é renderizada — {omitted} caracteres ocultos para manter a aba responsiva. Use Copiar para obter o texto completo.",
939
985
  },
940
986
  pluginSkill: {
941
987
  noDescription: "(sem descrição)",
package/src/lang/zh.ts CHANGED
@@ -136,6 +136,9 @@ const zhMessages = {
136
136
  disconnectFailed: "断开连接失败",
137
137
  signInFailed: "Google 登录失败",
138
138
  statusFailed: "加载状态失败",
139
+ description: "远程访问允许移动设备连接到此 MulmoClaude 的收藏与消息流。",
140
+ howTo: "在手机上打开 {url},用同一个 Google 账号登录。",
141
+ customViewHint: "如需移动端优化的视图,请让 Claude 创建 {keyword}(而不是普通的 custom view)。",
139
142
  },
140
143
  sidebarHeader: {
141
144
  home: "前往最新对话",
@@ -179,6 +182,8 @@ const zhMessages = {
179
182
  reference: "引用",
180
183
  // "RO" = Read-Only. 保留英文缩写作为紧凑徽章。
181
184
  readOnlyBadge: "RO",
185
+ showSystemFiles: "显示系统文件",
186
+ showSystemFilesTitle: "在用户内容(data/、artifacts/、config/)之外同时显示代理内部的顶层目录(conversations/、feeds/ 等)。",
182
187
  },
183
188
  fileTree: {
184
189
  workspace: "(工作区)",
@@ -230,6 +235,8 @@ const zhMessages = {
230
235
  photos: "照片",
231
236
  model: "模型",
232
237
  voice: "语音",
238
+ chatIndex: "聊天索引",
239
+ journal: "日志",
233
240
  skills: "技能",
234
241
  roles: "角色",
235
242
  },
@@ -286,6 +293,42 @@ const zhMessages = {
286
293
  loadError: "加载设置失败",
287
294
  saveError: "保存失败",
288
295
  },
296
+ chatIndexTab: {
297
+ description:
298
+ "为聊天历史自动生成 AI 标题/摘要。默认关闭。开启后自动化会话(scheduler / 系统 worker)仍会始终跳过;只有人类会话在每次轮次结束时才会调用一次摘要。",
299
+ modeLabel: "聊天索引模型",
300
+ helperText: "Haiku 更便宜;Sonnet 在长且话题多变的会话中标题更精准。",
301
+ mode: {
302
+ off: "关闭",
303
+ haiku: "Haiku",
304
+ sonnet: "Sonnet",
305
+ },
306
+ status: {
307
+ off: "索引已关闭",
308
+ haiku: "使用 Haiku 建立索引中",
309
+ sonnet: "使用 Sonnet 建立索引中",
310
+ },
311
+ loadError: "加载设置失败",
312
+ saveError: "保存失败",
313
+ },
314
+ journalTab: {
315
+ description:
316
+ "自动化每日日志 — 将近期聊天会话摘要为 journal/*.md,并抽取持久化记忆笔记。默认关闭。自动化会话(scheduler / 系统 worker)无论此设置如何都会始终排除。",
317
+ modeLabel: "日志模型",
318
+ helperText: "Haiku 更便宜;Sonnet 生成的每日/主题摘要更丰富。仅在此设置开启时每小时的轮次才会运行。",
319
+ mode: {
320
+ off: "关闭",
321
+ haiku: "Haiku",
322
+ sonnet: "Sonnet",
323
+ },
324
+ status: {
325
+ off: "日志已关闭",
326
+ haiku: "使用 Haiku 运行日志中",
327
+ sonnet: "使用 Sonnet 运行日志中",
328
+ },
329
+ loadError: "加载设置失败",
330
+ saveError: "保存失败",
331
+ },
289
332
  // `<i18n-t>` 插槽 — 命名为 `envKey` / `envFile`,在 SettingsModal.vue
290
333
  // 中作为行内 `<code>` 渲染,因此字面的变量名和文件名保持不翻译。
291
334
  geminiRequired: "图像生成需要 {envKey}。请将它加入 {envFile} 并重启应用。",
@@ -925,6 +968,7 @@ const zhMessages = {
925
968
  cancel: "取消",
926
969
  seededByPlugin: "来自 {pkg}",
927
970
  seededByPluginTooltip: "此消息由 {pkg} 插件生成,并非您发送。",
971
+ truncatedForRender: "该消息异常长(共 {total} 个字符)。为保持标签页响应,仅渲染开头部分 — 隐藏了 {omitted} 个字符。使用复制按钮获取完整原文。",
928
972
  },
929
973
  pluginSkill: {
930
974
  noDescription: "(无描述)",
@@ -18,6 +18,18 @@
18
18
  <span class="material-icons text-gray-400 text-base shrink-0 group-open:rotate-180 transition-transform">expand_more</span>
19
19
  </summary>
20
20
  <div class="border-t border-purple-200 p-4 bg-white rounded-b-lg">
21
+ <div
22
+ v-if="truncationInfo.wasTruncated"
23
+ class="mb-3 p-3 rounded border border-amber-300 bg-amber-50 text-amber-900 text-sm"
24
+ data-testid="text-response-seeded-truncation-banner"
25
+ >
26
+ {{
27
+ t("pluginTextResponse.truncatedForRender", {
28
+ omitted: truncationInfo.omittedChars.toLocaleString(locale),
29
+ total: truncationInfo.originalChars.toLocaleString(locale),
30
+ })
31
+ }}
32
+ </div>
21
33
  <!-- eslint-disable vue/no-v-html -- marked.parse output of the plugin-seeded prompt; trusted in-process render matching the standard textResponse path. Multi-line element so disable/enable pair (CLAUDE.md UI rule). -->
22
34
  <div ref="markdownContainerRef" class="markdown-content prose prose-slate max-w-none" @click="openLinksInNewTab" v-html="renderedHtml"></div>
23
35
  <!-- eslint-enable vue/no-v-html -->
@@ -62,6 +74,18 @@
62
74
  <span class="font-medium text-gray-700">{{ speakerLabel }}</span>
63
75
  <span v-if="transportKind" class="italic">{{ transportKind }}</span>
64
76
  </div>
77
+ <div
78
+ v-if="truncationInfo.wasTruncated"
79
+ class="mb-3 p-3 rounded border border-amber-300 bg-amber-50 text-amber-900 text-sm"
80
+ data-testid="text-response-truncation-banner"
81
+ >
82
+ {{
83
+ t("pluginTextResponse.truncatedForRender", {
84
+ omitted: truncationInfo.omittedChars.toLocaleString(locale),
85
+ total: truncationInfo.originalChars.toLocaleString(locale),
86
+ })
87
+ }}
88
+ </div>
65
89
  <!-- eslint-disable vue/no-v-html -- marked.parse output of app-owned assistant response text; trusted in-process render. Multi-line element so disable/enable pair (CLAUDE.md UI rule) instead of -next-line. -->
66
90
  <div
67
91
  ref="markdownContainerRef"
@@ -110,9 +134,9 @@ import { usePdfDownload } from "../../composables/usePdfDownload";
110
134
  import { useMarkdownZip } from "../../composables/useMarkdownZip";
111
135
  import { useClipboardCopy } from "../../composables/useClipboardCopy";
112
136
  import { buildPdfFilename } from "../../utils/files/filename";
113
- import { extractTextResponseTitle } from "./utils";
137
+ import { extractTextResponseTitle, truncateForRender } from "./utils";
114
138
 
115
- const { t } = useI18n();
139
+ const { t, locale } = useI18n();
116
140
  const appApi = useAppApi();
117
141
 
118
142
  const props = withDefaults(
@@ -162,19 +186,30 @@ const seededByPlugin = computed<string>(() => props.selectedResult.data?.seededB
162
186
  // inherently scoped to the opening message.
163
187
  const isSeededUserTurn = computed(() => Boolean(seededByPlugin.value) && messageRole.value === "user");
164
188
 
189
+ // Truncation summary surfaced to the template — the banner renders when
190
+ // `wasTruncated` is true, telling the user the visible content is a preview
191
+ // and the raw text is available via Copy. See #1863 for the pathological
192
+ // input this defends against (Opus 4.8 degenerate repetition freezing Safari).
193
+ const truncationInfo = computed(() => truncateForRender(messageText.value ?? ""));
194
+
165
195
  const renderedHtml = computed(() => {
166
- if (!messageText.value) return "";
167
-
168
- let processedText = messageText.value;
169
-
170
- // Detect and wrap JSON content in code fences
171
- const trimmedText = processedText.trim();
172
- if ((trimmedText.startsWith("{") && trimmedText.endsWith("}")) || (trimmedText.startsWith("[") && trimmedText.endsWith("]"))) {
173
- try {
174
- JSON.parse(trimmedText);
175
- processedText = `\`\`\`json\n${trimmedText}\n\`\`\``;
176
- } catch {
177
- // Not valid JSON, continue with original text
196
+ const { displayText } = truncationInfo.value;
197
+ if (!displayText) return "";
198
+
199
+ let processedText = displayText;
200
+
201
+ // Detect and wrap JSON content in code fences (skip for truncated views —
202
+ // JSON.parse of a truncated tail would throw anyway, and a partial JSON
203
+ // dump is more informative rendered as markdown).
204
+ if (!truncationInfo.value.wasTruncated) {
205
+ const trimmedText = processedText.trim();
206
+ if ((trimmedText.startsWith("{") && trimmedText.endsWith("}")) || (trimmedText.startsWith("[") && trimmedText.endsWith("]"))) {
207
+ try {
208
+ JSON.parse(trimmedText);
209
+ processedText = `\`\`\`json\n${trimmedText}\n\`\`\``;
210
+ } catch {
211
+ // Not valid JSON, continue with original text
212
+ }
178
213
  }
179
214
  }
180
215
 
@@ -4,6 +4,43 @@
4
4
 
5
5
  const MAX_TITLE_CHARS = 50;
6
6
 
7
+ // Cap on how much of an assistant message we feed into marked() at once.
8
+ // The Opus 4.8 "degenerate repetition" bug can generate hundreds of
9
+ // thousands of chars of blank-line-separated single words; marked itself
10
+ // parses that fine (~120ms), but Safari's layout/paint on ~30k <p>
11
+ // elements freezes the tab for minutes (#1863). 100_000 is comfortably
12
+ // larger than any real assistant reply (Claude's ~200k token ceiling is
13
+ // well below that in bytes) and small enough that pathological input
14
+ // bounces off it well before the render blows up.
15
+ export const RENDER_TRUNCATE_CHARS = 100_000;
16
+ // The preview slice we DO render when a message trips the cap. Small
17
+ // enough that even a fully-blank-line-separated payload stays under a
18
+ // few thousand block elements. Users get "Copy" for the full raw text.
19
+ export const RENDER_TRUNCATE_PREVIEW_CHARS = 20_000;
20
+
21
+ export interface TruncationResult {
22
+ displayText: string;
23
+ wasTruncated: boolean;
24
+ originalChars: number;
25
+ omittedChars: number;
26
+ }
27
+
28
+ // Truncate an assistant message so pathological model output can't
29
+ // freeze the render path. Pure so it can be unit-tested from
30
+ // node:test without a Vue runtime.
31
+ export function truncateForRender(text: string): TruncationResult {
32
+ const originalChars = text.length;
33
+ if (originalChars <= RENDER_TRUNCATE_CHARS) {
34
+ return { displayText: text, wasTruncated: false, originalChars, omittedChars: 0 };
35
+ }
36
+ return {
37
+ displayText: text.slice(0, RENDER_TRUNCATE_PREVIEW_CHARS),
38
+ wasTruncated: true,
39
+ originalChars,
40
+ omittedChars: originalChars - RENDER_TRUNCATE_PREVIEW_CHARS,
41
+ };
42
+ }
43
+
7
44
  // Pull a short, human-meaningful title out of a chat reply for use as
8
45
  // a download filename. Priority:
9
46
  // 1. First markdown H1 ("# ...") — the model often opens a long
@@ -5,23 +5,14 @@
5
5
  // random `https://` origins, phone-home `fetch()` calls, etc. —
6
6
  // is rejected.
7
7
  //
8
- // Widen by editing `HTML_PREVIEW_CSP_ALLOWED_CDNS` below. Keep the
9
- // list audited every entry is a potential supply-chain surface.
8
+ // The list itself lives in `@mulmoclaude/core/remote-view`
9
+ // (SANDBOXED_VIEW_CDN_ALLOWLIST) so the remote-view CSP and these
10
+ // desktop policies can't drift — widen it THERE, and keep it
11
+ // audited: every entry is a potential supply-chain surface.
10
12
 
11
- export const HTML_PREVIEW_CSP_ALLOWED_CDNS: readonly string[] = [
12
- "https://cdn.jsdelivr.net",
13
- "https://unpkg.com",
14
- "https://cdnjs.cloudflare.com",
15
- "https://fonts.googleapis.com",
16
- "https://fonts.gstatic.com",
17
- // Plotly's official CDN. The LLM defaults to this URL when it
18
- // includes a Sankey or other Plotly chart in presentHtml output —
19
- // Plotly's docs recommend it, so unconditioned LLM output ends up
20
- // pointing here. Also reachable through jsdelivr, but adding the
21
- // first-party CDN keeps historical artifacts (where the URL is
22
- // already baked into the file on disk) rendering correctly.
23
- "https://cdn.plot.ly",
24
- ];
13
+ import { SANDBOXED_VIEW_CDN_ALLOWLIST } from "@mulmoclaude/core/remote-view";
14
+
15
+ export const HTML_PREVIEW_CSP_ALLOWED_CDNS: readonly string[] = SANDBOXED_VIEW_CDN_ALLOWLIST;
25
16
 
26
17
  /**
27
18
  * Build the CSP string. Split from the wrapper so tests can exercise
@@ -10,9 +10,16 @@ import type { Role } from "../../config/roles";
10
10
  // don't render the literal text inside a Material Icons span.
11
11
  const MATERIAL_ICON_RE = /^[a-z_]+$/;
12
12
 
13
+ // `smart_toy` (robot glyph) is used for both fallback cases —
14
+ // "role not found" and "role icon isn't a valid Material Icon name".
15
+ // Reserved specifically to avoid collision with `star`, which is the
16
+ // PinToggle glyph for collection shortcuts; using `star` here would
17
+ // make an unknown role look identical to a pinned collection (#1684).
18
+ const FALLBACK_ICON = "smart_toy";
19
+
13
20
  export function roleIcon(roles: Role[], roleId: string): string {
14
- const icon = roles.find((role) => role.id === roleId)?.icon ?? "star";
15
- return MATERIAL_ICON_RE.test(icon) ? icon : "smart_toy";
21
+ const icon = roles.find((role) => role.id === roleId)?.icon ?? FALLBACK_ICON;
22
+ return MATERIAL_ICON_RE.test(icon) ? icon : FALLBACK_ICON;
16
23
  }
17
24
 
18
25
  export function roleName(roles: Role[], roleId: string): string {
@@ -0,0 +1,29 @@
1
+ // Selection helpers for the session-history sidebar row.
2
+ //
3
+ // The AI summary (chat indexer) is preferred over the raw first user
4
+ // message when it's meaningful. "Meaningful" excludes undefined / empty
5
+ // / whitespace-only strings — otherwise a summarizer that returns " "
6
+ // would blank out the row (no visible text, no tooltip, no aria label).
7
+
8
+ export interface SessionPreviewInput {
9
+ summary?: string;
10
+ preview: string;
11
+ }
12
+
13
+ export function resolveSessionSummary(summary: string | undefined): string | null {
14
+ const trimmed = summary?.trim();
15
+ return trimmed || null;
16
+ }
17
+
18
+ // `null` means "nothing to show" — the template turns that into the
19
+ // localised `noMessages` placeholder. Not baked in here to keep this
20
+ // pure (no i18n dependency).
21
+ export function resolveSessionPrimaryText(session: SessionPreviewInput): string | null {
22
+ const summary = resolveSessionSummary(session.summary);
23
+ if (summary) return summary;
24
+ return session.preview || null;
25
+ }
26
+
27
+ export function sessionHasVisibleSummary(session: SessionPreviewInput): boolean {
28
+ return resolveSessionSummary(session.summary) !== null;
29
+ }