mulmoclaude 0.3.0 → 0.5.0

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 (312) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-DiKaqnKs.js +5 -0
  3. package/client/assets/{index-eHWB79u5.js → index-C94GcmNa.js} +203 -198
  4. package/client/assets/index-CY-WpQUm.css +2 -0
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-5ipqh8Pe.js} +5 -5
  6. package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
  7. package/client/index.html +2 -4
  8. package/package.json +17 -15
  9. package/server/agent/attachmentConverter.ts +2 -2
  10. package/server/agent/backend/claude-code.ts +170 -0
  11. package/server/agent/backend/index.ts +14 -0
  12. package/server/agent/backend/types.ts +65 -0
  13. package/server/agent/index.ts +31 -159
  14. package/server/agent/mcp-server.ts +88 -10
  15. package/server/agent/mcp-tools/index.ts +8 -7
  16. package/server/agent/mcp-tools/notify.ts +76 -0
  17. package/server/agent/mcp-tools/x.ts +12 -2
  18. package/server/agent/plugin-names.ts +10 -4
  19. package/server/agent/prompt.ts +187 -26
  20. package/server/agent/resumeFailover.ts +5 -5
  21. package/server/agent/sandboxMounts.ts +3 -3
  22. package/server/api/auth/bearerAuth.ts +3 -3
  23. package/server/api/auth/token.ts +2 -2
  24. package/server/api/routes/agent.ts +99 -4
  25. package/server/api/routes/chart.ts +13 -0
  26. package/server/api/routes/chat-index.ts +2 -1
  27. package/server/api/routes/config.ts +35 -8
  28. package/server/api/routes/files.ts +75 -24
  29. package/server/api/routes/html.ts +15 -2
  30. package/server/api/routes/image.ts +75 -20
  31. package/server/api/routes/mulmo-script.ts +33 -31
  32. package/server/api/routes/news.ts +146 -0
  33. package/server/api/routes/notifications.ts +58 -2
  34. package/server/api/routes/pdf.ts +2 -2
  35. package/server/api/routes/plugins.ts +73 -91
  36. package/server/api/routes/presentHtml.ts +9 -0
  37. package/server/api/routes/roles.ts +12 -2
  38. package/server/api/routes/scheduler.ts +20 -11
  39. package/server/api/routes/schedulerTasks.ts +58 -21
  40. package/server/api/routes/sessions.ts +15 -4
  41. package/server/api/routes/sessionsCursor.ts +4 -4
  42. package/server/api/routes/skills.ts +26 -5
  43. package/server/api/routes/sources.ts +8 -7
  44. package/server/api/routes/todos.ts +30 -0
  45. package/server/api/routes/todosColumnsHandlers.ts +13 -27
  46. package/server/api/routes/todosHandlers.ts +1 -1
  47. package/server/api/routes/todosItemsHandlers.ts +14 -14
  48. package/server/api/routes/wiki/frontmatter.ts +86 -0
  49. package/server/api/routes/wiki.ts +335 -75
  50. package/server/api/sandboxStatus.ts +1 -1
  51. package/server/events/notifications.ts +32 -8
  52. package/server/events/pub-sub/index.ts +3 -3
  53. package/server/events/relay-client.ts +26 -16
  54. package/server/events/resolveRelayBridgeOptions.ts +125 -0
  55. package/server/index.ts +72 -49
  56. package/server/system/config.ts +5 -5
  57. package/server/system/credentials.ts +7 -5
  58. package/server/system/env.ts +15 -5
  59. package/server/system/macosNotify.ts +152 -0
  60. package/server/utils/errors.ts +11 -2
  61. package/server/utils/fetch.ts +54 -0
  62. package/server/utils/files/atomic.ts +18 -17
  63. package/server/utils/files/image-store.ts +19 -13
  64. package/server/utils/files/journal-io.ts +2 -2
  65. package/server/utils/files/json.ts +5 -5
  66. package/server/utils/files/markdown-image-fill.ts +131 -0
  67. package/server/utils/files/markdown-store.ts +22 -6
  68. package/server/utils/files/naming.ts +20 -10
  69. package/server/utils/files/reference-dirs-io.ts +3 -3
  70. package/server/utils/files/roles-io.ts +4 -4
  71. package/server/utils/files/safe.ts +14 -14
  72. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  73. package/server/utils/files/spreadsheet-store.ts +15 -10
  74. package/server/utils/files/workspace-io.ts +12 -12
  75. package/server/utils/gemini.ts +30 -4
  76. package/server/utils/gitignore.ts +9 -9
  77. package/server/utils/id.ts +40 -8
  78. package/server/utils/json.ts +5 -5
  79. package/server/utils/logBackgroundError.ts +12 -3
  80. package/server/utils/logPreview.ts +24 -0
  81. package/server/utils/markdown.ts +5 -5
  82. package/server/utils/port.d.mts +6 -0
  83. package/server/utils/port.mjs +48 -0
  84. package/server/utils/promptMeta.ts +32 -0
  85. package/server/utils/request.ts +12 -6
  86. package/server/utils/slug.ts +65 -4
  87. package/server/utils/spawn.ts +1 -1
  88. package/server/utils/types.ts +2 -2
  89. package/server/workspace/chat-index/index.ts +1 -1
  90. package/server/workspace/chat-index/summarizer.ts +5 -5
  91. package/server/workspace/custom-dirs.ts +5 -5
  92. package/server/workspace/helps/gemini.md +57 -0
  93. package/server/workspace/helps/index.md +2 -1
  94. package/server/workspace/helps/sources.md +42 -0
  95. package/server/workspace/helps/wiki.md +40 -5
  96. package/server/workspace/journal/archivist-cli.ts +121 -0
  97. package/server/workspace/journal/{archivist.ts → archivist-schemas.ts} +12 -120
  98. package/server/workspace/journal/dailyPass.ts +78 -38
  99. package/server/workspace/journal/diff.ts +2 -2
  100. package/server/workspace/journal/index.ts +56 -5
  101. package/server/workspace/journal/memoryExtractor.ts +1 -1
  102. package/server/workspace/journal/optimizationPass.ts +4 -5
  103. package/server/workspace/journal/paths.ts +8 -24
  104. package/server/workspace/journal/state.ts +18 -8
  105. package/server/workspace/news/reader.ts +248 -0
  106. package/server/workspace/paths.ts +4 -3
  107. package/server/workspace/reference-dirs.ts +3 -3
  108. package/server/workspace/skills/parser.ts +6 -6
  109. package/server/workspace/skills/scheduler.ts +5 -4
  110. package/server/workspace/skills/user-tasks.ts +3 -2
  111. package/server/workspace/skills/writer.ts +3 -3
  112. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  113. package/server/workspace/sources/classifier.ts +1 -1
  114. package/server/workspace/sources/fetchers/rss.ts +5 -5
  115. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  116. package/server/workspace/sources/interests.ts +3 -3
  117. package/server/workspace/sources/paths.ts +6 -6
  118. package/server/workspace/sources/pipeline/fetch.ts +59 -13
  119. package/server/workspace/sources/pipeline/index.ts +59 -7
  120. package/server/workspace/sources/pipeline/notify.ts +13 -5
  121. package/server/workspace/sources/pipeline/plan.ts +11 -9
  122. package/server/workspace/sources/pipeline/summarize.ts +1 -1
  123. package/server/workspace/sources/pipeline/write.ts +5 -5
  124. package/server/workspace/sources/rateLimiter.ts +1 -1
  125. package/server/workspace/sources/sourceState.ts +9 -4
  126. package/server/workspace/sources/types.ts +9 -0
  127. package/server/workspace/sources/urls.ts +1 -1
  128. package/server/workspace/tool-trace/classify.ts +4 -4
  129. package/server/workspace/workspace.ts +7 -7
  130. package/src/App.vue +477 -251
  131. package/src/components/CanvasViewToggle.vue +12 -10
  132. package/src/components/ChatInput.vue +112 -105
  133. package/src/components/FileContentHeader.vue +10 -7
  134. package/src/components/FileContentRenderer.vue +37 -10
  135. package/src/components/FileTree.vue +34 -4
  136. package/src/components/FileTreePane.vue +32 -27
  137. package/src/components/FilesView.vue +5 -3
  138. package/src/components/FilterChip.vue +22 -0
  139. package/src/components/LockStatusPopup.vue +19 -13
  140. package/src/components/NewsView.vue +252 -0
  141. package/src/components/NotificationBell.vue +35 -9
  142. package/src/components/NotificationToast.vue +4 -1
  143. package/src/components/PageChatComposer.vue +101 -0
  144. package/src/components/PluginLauncher.vue +36 -62
  145. package/src/components/RightSidebar.vue +13 -10
  146. package/src/components/RoleSelector.vue +3 -2
  147. package/src/components/SessionHeaderControls.vue +63 -0
  148. package/src/components/SessionHistoryExpandButton.vue +30 -0
  149. package/src/components/SessionHistoryPanel.vue +64 -93
  150. package/src/components/SessionHistoryToggleButton.vue +40 -0
  151. package/src/components/SessionRoleIcon.vue +72 -0
  152. package/src/components/SessionSidebar.vue +96 -0
  153. package/src/components/SessionTabBar.vue +44 -51
  154. package/src/components/SettingsMcpTab.vue +361 -52
  155. package/src/components/SettingsModal.vue +203 -72
  156. package/src/components/SettingsReferenceDirsTab.vue +72 -51
  157. package/src/components/SettingsWorkspaceDirsTab.vue +74 -51
  158. package/src/components/SidebarHeader.vue +50 -16
  159. package/src/components/SourcesManager.vue +900 -0
  160. package/src/components/SourcesView.vue +45 -0
  161. package/src/components/StackView.vue +84 -48
  162. package/src/components/SuggestionsPanel.vue +25 -36
  163. package/src/components/SystemFileBanner.vue +106 -0
  164. package/src/components/ThinkingIndicator.vue +41 -0
  165. package/src/components/TodoExplorer.vue +72 -22
  166. package/src/components/todo/TodoAddDialog.vue +17 -12
  167. package/src/components/todo/TodoEditDialog.vue +7 -2
  168. package/src/components/todo/TodoEditPanel.vue +15 -10
  169. package/src/components/todo/TodoKanbanView.vue +16 -6
  170. package/src/components/todo/TodoListView.vue +14 -3
  171. package/src/components/todo/TodoTableView.vue +36 -5
  172. package/src/composables/favicon/conditions.ts +76 -0
  173. package/src/composables/favicon/resolveColor.ts +93 -0
  174. package/src/composables/favicon/types.ts +61 -0
  175. package/src/composables/useAppApi.ts +23 -0
  176. package/src/composables/useChatScroll.ts +5 -5
  177. package/src/composables/useCurrentRole.ts +32 -0
  178. package/src/composables/useDynamicFavicon.ts +174 -58
  179. package/src/composables/useEventListeners.ts +7 -12
  180. package/src/composables/useFaviconState.ts +93 -12
  181. package/src/composables/useFileSelection.ts +25 -6
  182. package/src/composables/useHealth.ts +76 -7
  183. package/src/composables/useLayoutMode.ts +27 -0
  184. package/src/composables/useNewsItems.ts +38 -0
  185. package/src/composables/useNewsReadState.ts +75 -0
  186. package/src/composables/useNotifications.ts +76 -13
  187. package/src/composables/usePendingCalls.ts +11 -1
  188. package/src/composables/useRoles.ts +6 -10
  189. package/src/composables/useRunElapsed.ts +80 -0
  190. package/src/composables/useSessionDerived.ts +21 -5
  191. package/src/composables/useSessionHistory.ts +7 -17
  192. package/src/composables/useSidePanelVisible.ts +25 -0
  193. package/src/composables/useViewLayout.ts +16 -37
  194. package/src/config/apiRoutes.ts +19 -6
  195. package/src/config/historyFilters.ts +30 -0
  196. package/src/config/mcpCatalog.ts +285 -0
  197. package/src/config/mcpTypes.ts +26 -0
  198. package/src/config/roles.ts +19 -51
  199. package/src/config/systemFileDescriptors.ts +170 -0
  200. package/src/config/toolNames.ts +6 -1
  201. package/src/config/workspacePaths.ts +1 -0
  202. package/src/index.css +14 -0
  203. package/src/lang/de.ts +706 -0
  204. package/src/lang/en.ts +726 -0
  205. package/src/lang/es.ts +712 -0
  206. package/src/lang/fr.ts +704 -0
  207. package/src/lang/ja.ts +707 -0
  208. package/src/lang/ko.ts +709 -0
  209. package/src/lang/pt-BR.ts +702 -0
  210. package/src/lang/zh.ts +705 -0
  211. package/src/lib/vue-i18n.ts +97 -0
  212. package/src/main.ts +3 -0
  213. package/src/plugins/canvas/View.vue +104 -186
  214. package/src/plugins/canvas/definition.ts +0 -8
  215. package/src/plugins/canvas/index.ts +3 -2
  216. package/src/plugins/chart/Preview.vue +1 -1
  217. package/src/plugins/chart/View.vue +9 -4
  218. package/src/plugins/chart/index.ts +3 -2
  219. package/src/plugins/editImage/index.ts +3 -2
  220. package/src/plugins/generateImage/index.ts +3 -2
  221. package/src/plugins/manageRoles/Preview.vue +4 -1
  222. package/src/plugins/manageRoles/View.vue +67 -46
  223. package/src/plugins/manageRoles/index.ts +3 -2
  224. package/src/plugins/manageSkills/Preview.vue +8 -3
  225. package/src/plugins/manageSkills/View.vue +39 -34
  226. package/src/plugins/manageSkills/index.ts +3 -2
  227. package/src/plugins/manageSource/Preview.vue +1 -1
  228. package/src/plugins/manageSource/View.vue +3 -687
  229. package/src/plugins/manageSource/index.ts +3 -2
  230. package/src/plugins/markdown/Preview.vue +1 -1
  231. package/src/plugins/markdown/View.vue +164 -73
  232. package/src/plugins/markdown/definition.ts +6 -4
  233. package/src/plugins/markdown/index.ts +3 -2
  234. package/src/plugins/presentForm/Preview.vue +99 -0
  235. package/src/plugins/presentForm/View.vue +675 -0
  236. package/src/plugins/presentForm/definition.ts +127 -0
  237. package/src/plugins/presentForm/index.ts +18 -0
  238. package/src/plugins/presentForm/plugin.ts +94 -0
  239. package/src/plugins/presentForm/types.ts +109 -0
  240. package/src/plugins/presentHtml/Preview.vue +1 -1
  241. package/src/plugins/presentHtml/View.vue +7 -4
  242. package/src/plugins/presentHtml/index.ts +3 -2
  243. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  244. package/src/plugins/presentMulmoScript/View.vue +36 -26
  245. package/src/plugins/presentMulmoScript/index.ts +3 -2
  246. package/src/plugins/scheduler/AutomationsPreview.vue +37 -0
  247. package/src/plugins/scheduler/AutomationsView.vue +23 -0
  248. package/src/plugins/scheduler/CalendarView.vue +23 -0
  249. package/src/plugins/scheduler/LegacySchedulerView.vue +32 -0
  250. package/src/plugins/scheduler/Preview.vue +7 -4
  251. package/src/plugins/scheduler/TasksTab.vue +119 -28
  252. package/src/plugins/scheduler/View.vue +75 -32
  253. package/src/plugins/scheduler/automationsDefinition.ts +58 -0
  254. package/src/plugins/scheduler/calendarDefinition.ts +46 -0
  255. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  256. package/src/plugins/scheduler/index.ts +68 -14
  257. package/src/plugins/scheduler/legacyShape.ts +34 -0
  258. package/src/plugins/spreadsheet/Preview.vue +9 -5
  259. package/src/plugins/spreadsheet/View.vue +43 -57
  260. package/src/plugins/spreadsheet/engine/responseDecoder.ts +2 -1
  261. package/src/plugins/spreadsheet/index.ts +3 -2
  262. package/src/plugins/textResponse/Preview.vue +15 -58
  263. package/src/plugins/textResponse/View.vue +42 -45
  264. package/src/plugins/textResponse/utils.ts +25 -0
  265. package/src/plugins/todo/Preview.vue +11 -6
  266. package/src/plugins/todo/View.vue +27 -13
  267. package/src/plugins/todo/composables/useTodos.ts +3 -1
  268. package/src/plugins/todo/index.ts +3 -2
  269. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  270. package/src/plugins/ui-image/ImageView.vue +7 -4
  271. package/src/plugins/wiki/Preview.vue +5 -2
  272. package/src/plugins/wiki/View.vue +539 -92
  273. package/src/plugins/wiki/index.ts +5 -2
  274. package/src/plugins/wiki/route.ts +121 -0
  275. package/src/router/guards.ts +43 -24
  276. package/src/router/index.ts +53 -26
  277. package/src/router/pageRoutes.ts +23 -0
  278. package/src/tools/index.ts +12 -5
  279. package/src/tools/legacyPluginNames.ts +13 -0
  280. package/src/types/notification.ts +31 -6
  281. package/src/types/vue-i18n.d.ts +20 -0
  282. package/src/utils/agent/eventDispatch.ts +3 -6
  283. package/src/utils/agent/formatElapsed.ts +37 -0
  284. package/src/utils/agent/request.ts +22 -1
  285. package/src/utils/canvas/layoutMode.ts +26 -0
  286. package/src/utils/canvas/sidePanelVisible.ts +19 -0
  287. package/src/utils/dom/scrollIntoViewByTestId.ts +38 -0
  288. package/src/utils/errors.ts +9 -2
  289. package/src/utils/files/filename.ts +24 -0
  290. package/src/utils/filesPreview/schedulerPreview.ts +9 -3
  291. package/src/utils/id.ts +18 -0
  292. package/src/utils/image/cacheBust.ts +16 -0
  293. package/src/utils/image/resolve.ts +16 -0
  294. package/src/utils/markdown/taskList.ts +175 -0
  295. package/src/utils/mcp/interpolateSpec.ts +97 -0
  296. package/src/utils/notification/dispatch.ts +51 -15
  297. package/src/utils/path/workspaceLinkRouter.ts +99 -0
  298. package/src/utils/session/mergeSessions.ts +5 -0
  299. package/src/utils/sources/filter.ts +69 -0
  300. package/src/vite-env.d.ts +9 -0
  301. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  302. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  303. package/client/assets/index-Bm70FDU2.css +0 -1
  304. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  305. package/server/workspace/journal/linkRewrite.ts +0 -4
  306. package/src/components/ToolResultsPanel.vue +0 -77
  307. package/src/composables/useCanvasViewMode.ts +0 -121
  308. package/src/plugins/scheduler/definition.ts +0 -57
  309. package/src/utils/canvas/viewMode.ts +0 -46
  310. package/src/utils/role/plugins.ts +0 -12
  311. package/src/utils/session/seedRoleDefault.ts +0 -35
  312. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -0,0 +1,900 @@
1
+ <template>
2
+ <div class="h-full flex flex-col overflow-hidden">
3
+ <div class="px-3 py-2 border-b border-gray-100 shrink-0 flex items-center justify-between gap-2">
4
+ <span class="text-sm font-medium text-gray-700 truncate"> {{ t("pluginManageSource.heading") }} </span>
5
+ <div class="flex items-center gap-2 shrink-0">
6
+ <span class="text-xs text-gray-500"> {{ t("pluginManageSource.sourceCount", sources.length, { named: { count: sources.length } }) }} </span>
7
+ <button
8
+ class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
9
+ :disabled="initialLoading || initialLoadError !== null || adding || busy === 'rebuild'"
10
+ data-testid="sources-add-btn"
11
+ @click="startAdd"
12
+ >
13
+ <span class="material-icons text-sm">add</span>
14
+ {{ t("pluginManageSource.addButton") }}
15
+ </button>
16
+ <button
17
+ class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
18
+ :disabled="initialLoading || initialLoadError !== null || busy === 'rebuild'"
19
+ data-testid="sources-rebuild-btn"
20
+ @click="rebuild"
21
+ >
22
+ <span class="material-icons text-sm">refresh</span>
23
+ {{ busy === "rebuild" ? t("pluginManageSource.rebuilding") : t("pluginManageSource.rebuildNow") }}
24
+ </button>
25
+ </div>
26
+ </div>
27
+
28
+ <div v-if="adding" class="px-4 py-3 border-b border-blue-200 bg-blue-50/50 shrink-0 space-y-2" data-testid="sources-add-form">
29
+ <div class="flex flex-wrap items-center gap-2">
30
+ <label class="text-xs text-gray-700">
31
+ {{ t("pluginManageSource.typeField") }}
32
+ <select v-model="draft.kind" class="ml-1 text-xs border border-gray-300 rounded px-1 py-0.5" data-testid="sources-draft-kind" @change="onKindChange">
33
+ <option value="rss">{{ t("pluginManageSource.kindRss") }}</option>
34
+ <option value="github-releases">{{ t("pluginManageSource.kindGithubReleases") }}</option>
35
+ <option value="github-issues">{{ t("pluginManageSource.kindGithubIssues") }}</option>
36
+ <option value="arxiv">{{ t("pluginManageSource.kindArxiv") }}</option>
37
+ </select>
38
+ </label>
39
+ <input
40
+ v-model="draft.primary"
41
+ class="flex-1 min-w-[12rem] text-xs border border-gray-300 rounded px-2 py-1 font-mono"
42
+ :placeholder="primaryPlaceholder"
43
+ data-testid="sources-draft-primary"
44
+ @keydown.enter="commitAdd"
45
+ />
46
+ <input
47
+ v-model="draft.title"
48
+ class="w-40 text-xs border border-gray-300 rounded px-2 py-1"
49
+ :placeholder="t('pluginManageSource.titlePlaceholder')"
50
+ data-testid="sources-draft-title"
51
+ @keydown.enter="commitAdd"
52
+ />
53
+ </div>
54
+ <div class="flex items-center justify-between text-xs">
55
+ <span class="text-gray-500">
56
+ {{ primaryHint }}
57
+ </span>
58
+ <div class="flex gap-2">
59
+ <button class="px-2 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-50" data-testid="sources-draft-cancel" @click="cancelAdd">
60
+ {{ t("common.cancel") }}
61
+ </button>
62
+ <button
63
+ class="px-2 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
64
+ :disabled="busy === 'add' || !draft.primary.trim()"
65
+ data-testid="sources-draft-add"
66
+ @click="commitAdd"
67
+ >
68
+ {{ busy === "add" ? t("pluginManageSource.addingLabel") : t("pluginManageSource.addAndRebuild") }}
69
+ </button>
70
+ </div>
71
+ </div>
72
+ <div v-if="draftError" class="text-xs text-red-600" data-testid="sources-draft-error">
73
+ {{ draftError }}
74
+ </div>
75
+ </div>
76
+
77
+ <div
78
+ v-if="actionMessage"
79
+ class="px-4 py-2 text-xs border-b shrink-0"
80
+ :class="actionError ? 'bg-red-50 text-red-700 border-red-200' : 'bg-green-50 text-green-700 border-green-200'"
81
+ data-testid="sources-action-message"
82
+ >
83
+ {{ actionMessage }}
84
+ </div>
85
+
86
+ <div class="flex-1 overflow-y-auto">
87
+ <!-- Page-mode gate: hide empty state + preset buttons until the
88
+ initial fetch completes, so users can't register presets
89
+ against a still-empty local list (would re-POST slugs the
90
+ server already has). Failed loads stay gated too — empty
91
+ local state in that case means "unknown", not "zero". -->
92
+ <div v-if="initialLoading" class="flex items-center justify-center h-full p-6" data-testid="sources-initial-loading">
93
+ <span class="text-sm text-gray-500 italic">{{ t("pluginManageSource.initialLoading") }}</span>
94
+ </div>
95
+ <div v-else-if="initialLoadError" class="flex flex-col items-center justify-center h-full p-6 gap-3" data-testid="sources-initial-error">
96
+ <span class="text-sm text-red-600">{{ t("pluginManageSource.initialLoadFailed") }}</span>
97
+ <span class="text-xs text-gray-500 max-w-md text-center">{{ initialLoadError }}</span>
98
+ <button
99
+ class="px-3 py-1 text-xs rounded bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
100
+ :disabled="initialLoading"
101
+ data-testid="sources-initial-retry"
102
+ @click="retryInitialLoad"
103
+ >
104
+ {{ t("pluginManageSource.retryLabel") }}
105
+ </button>
106
+ </div>
107
+ <div v-else-if="sources.length === 0" class="flex flex-col items-center justify-center h-full p-6 gap-4" data-testid="sources-empty">
108
+ <i18n-t keypath="pluginManageSource.emptyPickPack" tag="p" class="text-sm text-gray-500 italic text-center max-w-md">
109
+ <template #addBold>
110
+ <strong>{{ t("pluginManageSource.emptyAddStrong") }}</strong>
111
+ </template>
112
+ </i18n-t>
113
+ <div class="w-full max-w-md space-y-2" data-testid="sources-presets">
114
+ <button
115
+ v-for="preset in PRESETS"
116
+ :key="preset.id"
117
+ class="w-full text-left border border-gray-200 rounded-lg p-3 hover:bg-blue-50 hover:border-blue-300 disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:border-gray-200"
118
+ :disabled="busy === 'preset-' + preset.id"
119
+ :data-testid="`sources-preset-${preset.id}`"
120
+ @click="installPreset(preset)"
121
+ >
122
+ <div class="flex items-baseline justify-between gap-2">
123
+ <span class="text-sm font-medium text-gray-800">
124
+ {{ preset.label }}
125
+ </span>
126
+ <span class="text-[11px] text-gray-500 shrink-0">
127
+ {{ t("pluginManageSource.sourceCount", preset.entries.length, { named: { count: preset.entries.length } }) }}
128
+ </span>
129
+ </div>
130
+ <div class="text-xs text-gray-500 mt-1">
131
+ {{ preset.description }}
132
+ </div>
133
+ <div v-if="busy === 'preset-' + preset.id" class="text-xs text-blue-600 mt-1 italic">{{ t("pluginManageSource.registering") }}</div>
134
+ </button>
135
+ </div>
136
+ </div>
137
+ <template v-else>
138
+ <!-- Filter chip row (#768). Hidden when no sources are
139
+ registered — the empty/preset state above already owns
140
+ the screen. Single-select; clicking a chip replaces the
141
+ active filter rather than toggling. -->
142
+ <div
143
+ v-if="sources.length > 0"
144
+ class="px-4 py-2 border-b border-gray-100 flex flex-wrap items-center gap-1.5 shrink-0"
145
+ data-testid="sources-filter"
146
+ role="toolbar"
147
+ :aria-label="t('pluginManageSource.filter.all')"
148
+ >
149
+ <FilterChip
150
+ v-for="key in visibleFilterKeys"
151
+ :key="key"
152
+ :active="filterKey === key"
153
+ :label="filterChipLabel(key)"
154
+ :count="filterCounts[key]"
155
+ :data-testid="`sources-filter-chip-${key}`"
156
+ @click="selectFilter(key)"
157
+ />
158
+ </div>
159
+
160
+ <!-- Filter-only empty state. Distinct from the no-sources
161
+ empty state above: here the user has registered sources
162
+ but the active chip has zero matches — offer a single
163
+ "Clear filter" affordance. -->
164
+ <div
165
+ v-if="sources.length > 0 && filteredSources.length === 0"
166
+ class="flex flex-col items-center justify-center p-6 gap-3"
167
+ data-testid="sources-filter-empty"
168
+ >
169
+ <span class="text-sm text-gray-500 italic">{{ t("pluginManageSource.filter.noMatching") }}</span>
170
+ <button
171
+ class="text-xs px-3 py-1 rounded border border-gray-300 text-gray-700 hover:bg-gray-50"
172
+ data-testid="sources-filter-clear"
173
+ @click="clearFilter"
174
+ >
175
+ {{ t("pluginManageSource.filter.clearFilter") }}
176
+ </button>
177
+ </div>
178
+
179
+ <ul v-if="filteredSources.length > 0" class="divide-y divide-gray-100 border-b border-gray-100">
180
+ <li
181
+ v-for="source in filteredSources"
182
+ :key="source.slug"
183
+ class="px-4 py-3 flex items-start gap-3"
184
+ :class="{
185
+ 'bg-amber-50': source.slug === highlightSlug,
186
+ }"
187
+ :data-testid="`source-row-${source.slug}`"
188
+ >
189
+ <span class="text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5 mt-0.5 shrink-0" :class="kindBadgeClass(source.fetcherKind)">
190
+ {{ kindLabel(source.fetcherKind) }}
191
+ </span>
192
+ <div class="min-w-0 flex-1">
193
+ <div class="flex items-baseline gap-2">
194
+ <a :href="source.url" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-blue-700 hover:underline truncate">
195
+ {{ source.title }}
196
+ </a>
197
+ <code class="text-[11px] text-gray-400 shrink-0">
198
+ {{ source.slug }}
199
+ </code>
200
+ </div>
201
+ <div class="text-xs text-gray-500 truncate">
202
+ {{ source.url }}
203
+ </div>
204
+ <div v-if="source.categories.length > 0" class="mt-1 flex flex-wrap gap-1">
205
+ <span v-for="cat in source.categories" :key="cat" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">
206
+ {{ cat }}
207
+ </span>
208
+ </div>
209
+ <div v-if="source.notes" class="mt-1 text-xs text-gray-600 italic">
210
+ {{ source.notes }}
211
+ </div>
212
+ </div>
213
+ <button
214
+ class="text-xs text-red-600 hover:text-red-800 shrink-0 disabled:opacity-50"
215
+ :disabled="busy === source.slug"
216
+ :data-testid="`source-remove-${source.slug}`"
217
+ @click="remove(source.slug)"
218
+ >
219
+ {{ busy === source.slug ? t("pluginManageSource.removingLabel") : t("pluginManageSource.removeLabel") }}
220
+ </button>
221
+ </li>
222
+ </ul>
223
+ </template>
224
+
225
+ <!-- Today's brief. Auto-fetched on mount and refreshed after
226
+ every Rebuild. Rendered as markdown so lists / headings
227
+ feel like a document, not a dump. -->
228
+ <div v-if="sources.length > 0 && (briefLoading || briefHtml || briefError)" class="p-4" data-testid="sources-brief">
229
+ <div class="flex items-baseline justify-between mb-2">
230
+ <h3 class="text-sm font-semibold text-gray-800">
231
+ {{ t("pluginManageSource.todaysBrief") }}
232
+ <span v-if="briefDate" class="text-xs text-gray-400 font-normal"> {{ t("pluginManageSource.briefDateLabel", { date: briefDate }) }} </span>
233
+ </h3>
234
+ <button v-if="briefFilePath" class="text-[11px] text-gray-500 hover:text-gray-700" :title="briefFilePath">
235
+ {{ briefFilePath }}
236
+ </button>
237
+ </div>
238
+ <div v-if="briefLoading" class="text-xs text-gray-500 italic">{{ t("pluginManageSource.todaysBriefLoading") }}</div>
239
+ <div v-else-if="briefError" class="text-xs text-gray-500 italic" data-testid="sources-brief-empty">
240
+ {{ briefError }}
241
+ </div>
242
+ <!-- eslint-disable-next-line vue/no-v-html -->
243
+ <div v-else class="markdown-content" v-html="briefHtml" />
244
+ </div>
245
+ </div>
246
+
247
+ <div v-if="lastRebuild" class="px-4 py-2 border-t border-gray-100 shrink-0 text-xs text-gray-600" data-testid="sources-rebuild-summary">
248
+ {{
249
+ t("pluginManageSource.lastRebuildSummary", {
250
+ date: lastRebuild.isoDate,
251
+ itemCount: lastRebuild.itemCount,
252
+ planned: lastRebuild.plannedCount,
253
+ duplicates: lastRebuild.duplicateCount,
254
+ })
255
+ }}
256
+ <span v-if="lastRebuild.archiveErrors.length > 0" class="text-red-600">
257
+ {{ t("pluginManageSource.archiveErrorsSuffix", { count: lastRebuild.archiveErrors.length }) }}
258
+ </span>
259
+ </div>
260
+
261
+ <!-- Per-page chat composer (standalone /sources route only).
262
+ Sending spawns a fresh chat with a prepended pointer to
263
+ config/helps/sources.md so the agent loads source-management
264
+ conventions before answering. Hidden in plugin mode where
265
+ the enclosing chat already has its own composer. -->
266
+ <PageChatComposer
267
+ v-if="mode === 'page'"
268
+ :placeholder="t('pluginManageSource.chatPlaceholder')"
269
+ :prepend-text="`Before answering, read config/helps/sources.md for source-management conventions.`"
270
+ :suggestions="SOURCE_SUGGESTIONS"
271
+ test-id-prefix="sources-page-chat"
272
+ />
273
+ </div>
274
+ </template>
275
+
276
+ <script setup lang="ts">
277
+ import { computed, onMounted, ref, watch } from "vue";
278
+ import { useI18n } from "vue-i18n";
279
+ import { marked } from "marked";
280
+ import DOMPurify from "dompurify";
281
+ import type { ManageSourceData, RebuildSummary, Source } from "../plugins/manageSource/index";
282
+ import { apiGet, apiPost, apiDelete } from "../utils/api";
283
+ import { API_ROUTES } from "../config/apiRoutes";
284
+ import { SOURCE_FILTER_KEYS, countByFilter, matchesSourceFilter, type SourceFilterKey } from "../utils/sources/filter";
285
+ import FilterChip from "./FilterChip.vue";
286
+ import PageChatComposer from "./PageChatComposer.vue";
287
+
288
+ // Starter prompts for the per-page chat composer. Migrated from the
289
+ // retired sourceManager role's `queries` field — kept English-only to
290
+ // match the role-queries convention (the chat agent itself replies
291
+ // in the user's language regardless).
292
+ const SOURCE_SUGGESTIONS = [
293
+ "Show my information sources",
294
+ "Register the Hacker News RSS feed (https://news.ycombinator.com/rss)",
295
+ "Register the anthropics/claude-code GitHub releases",
296
+ "Register an arXiv query for cs.CL new submissions",
297
+ "Rebuild today's brief",
298
+ ];
299
+
300
+ const { t } = useI18n();
301
+
302
+ // Explicit mode — the null-vs-undefined `initialData` heuristic was
303
+ // fragile: a `manageSource` tool result that failed on the server
304
+ // leaves `selectedResult.data === undefined`, which would previously
305
+ // fall through to page-mode and trigger a live refreshList(), hiding
306
+ // the fact that the tool call failed. An explicit prop keeps plugin
307
+ // context (seed-driven, no mount fetch) and page context (mount
308
+ // fetch + loading gate) visibly distinct. See PR #676 review.
309
+ const props = defineProps<{
310
+ mode: "page" | "plugin";
311
+ initialData?: ManageSourceData | null;
312
+ }>();
313
+
314
+ const localSources = ref<Source[] | null>(props.initialData?.sources ?? null);
315
+ const lastRebuild = ref<RebuildSummary | null>(props.initialData?.lastRebuild ?? null);
316
+ const highlightSlugLocal = ref<string | null>(props.initialData?.highlightSlug ?? null);
317
+ const actionMessage = ref("");
318
+ const actionError = ref(false);
319
+ // Tracks the current button-driven request: "rebuild", "add", or a
320
+ // slug (Remove). Used to disable/relabel the matching button.
321
+ const busy = ref<string | null>(null);
322
+ // Page-mode initial-fetch gate. Prevents the user from pressing
323
+ // Add / presets / Rebuild before `GET /api/sources` resolves —
324
+ // otherwise `installPreset()` checks `sources.value` against an
325
+ // empty set and re-POSTs slugs the server already has.
326
+ //
327
+ // `initialLoadError` distinguishes "load finished, list really is
328
+ // empty" from "load failed, list is unknown". Without this split,
329
+ // a 500 on the first GET would release the gate with sources===[],
330
+ // letting the same double-register happen the gate was added to
331
+ // prevent. See PR #676 follow-up review.
332
+ const initialLoading = ref(props.mode === "page");
333
+ const initialLoadError = ref<string | null>(null);
334
+
335
+ // --- Add source form state ---------------------------------------------
336
+
337
+ type DraftKind = "rss" | "github-releases" | "github-issues" | "arxiv";
338
+ interface DraftState {
339
+ kind: DraftKind;
340
+ primary: string; // Feed URL / repo URL / repo slug / arxiv query
341
+ title: string;
342
+ }
343
+
344
+ const adding = ref(false);
345
+ const draft = ref<DraftState>(emptyDraft());
346
+ const draftError = ref("");
347
+
348
+ function emptyDraft(): DraftState {
349
+ return { kind: "rss", primary: "", title: "" };
350
+ }
351
+
352
+ function startAdd(): void {
353
+ draft.value = emptyDraft();
354
+ draftError.value = "";
355
+ adding.value = true;
356
+ }
357
+
358
+ function cancelAdd(): void {
359
+ adding.value = false;
360
+ draftError.value = "";
361
+ }
362
+
363
+ function onKindChange(): void {
364
+ draftError.value = "";
365
+ }
366
+
367
+ const primaryPlaceholder = computed(() => {
368
+ switch (draft.value.kind) {
369
+ case "rss":
370
+ return t("pluginManageSource.primaryRssPlaceholder");
371
+ case "github-releases":
372
+ case "github-issues":
373
+ return t("pluginManageSource.primaryGithubPlaceholder");
374
+ case "arxiv":
375
+ return t("pluginManageSource.primaryArxivPlaceholder");
376
+ }
377
+ return "";
378
+ });
379
+
380
+ const primaryHint = computed(() => {
381
+ switch (draft.value.kind) {
382
+ case "rss":
383
+ return t("pluginManageSource.primaryRssHint");
384
+ case "github-releases":
385
+ return t("pluginManageSource.primaryGithubRelHint");
386
+ case "github-issues":
387
+ return t("pluginManageSource.primaryGithubIssHint");
388
+ case "arxiv":
389
+ return t("pluginManageSource.primaryArxivHint");
390
+ }
391
+ return "";
392
+ });
393
+
394
+ // Extract owner/repo from either a full github.com URL or a bare
395
+ // "owner/repo" string. Returns null when the input doesn't look
396
+ // like a recognisable GitHub repo.
397
+ function parseRepoSlug(input: string): string | null {
398
+ const trimmed = input.trim();
399
+ if (!trimmed) return null;
400
+ const urlMatch = trimmed.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^/\s?#]+)/i);
401
+ if (urlMatch) return `${urlMatch[1]}/${urlMatch[2].replace(/\.git$/, "")}`;
402
+ if (/^[^/\s]+\/[^/\s]+$/.test(trimmed)) return trimmed.replace(/\.git$/, "");
403
+ return null;
404
+ }
405
+
406
+ // Build the /api/sources body from the draft. Returns an error
407
+ // string when the input is invalid for the chosen kind.
408
+ interface RegisterPayload {
409
+ title: string;
410
+ url: string;
411
+ fetcherKind: DraftKind;
412
+ fetcherParams: Record<string, string>;
413
+ }
414
+
415
+ // Error returns are i18n keys (not literal strings) so commitAdd
416
+ // can resolve them with t() at the call site — keeps
417
+ // SourcesManager's validation pure and the error messages
418
+ // translatable across all 8 locales.
419
+ interface BuildError {
420
+ errorKey: string;
421
+ }
422
+
423
+ function buildRegisterPayload(input: DraftState): RegisterPayload | BuildError {
424
+ const primary = input.primary.trim();
425
+ const title = input.title.trim();
426
+ if (!primary) return { errorKey: "pluginManageSource.errPrimaryRequired" };
427
+ switch (input.kind) {
428
+ case "rss": {
429
+ if (!/^https?:\/\//i.test(primary)) {
430
+ return { errorKey: "pluginManageSource.errRssUrlProtocol" };
431
+ }
432
+ let hostname: string;
433
+ try {
434
+ hostname = new URL(primary).hostname;
435
+ } catch {
436
+ return { errorKey: "pluginManageSource.errRssUrlInvalid" };
437
+ }
438
+ if (!hostname) {
439
+ return { errorKey: "pluginManageSource.errRssUrlHost" };
440
+ }
441
+ return {
442
+ title: title || hostname,
443
+ url: primary,
444
+ fetcherKind: "rss",
445
+ fetcherParams: { rss_url: primary },
446
+ };
447
+ }
448
+ case "github-releases":
449
+ case "github-issues": {
450
+ const slug = parseRepoSlug(primary);
451
+ if (!slug) {
452
+ return { errorKey: "pluginManageSource.errGithubInvalid" };
453
+ }
454
+ return {
455
+ title: title || slug,
456
+ url: `https://github.com/${slug}`,
457
+ fetcherKind: input.kind,
458
+ fetcherParams: { github_repo: slug },
459
+ };
460
+ }
461
+ case "arxiv": {
462
+ const query = primary;
463
+ return {
464
+ title: title || `arXiv: ${query}`,
465
+ url: `https://export.arxiv.org/api/query?search_query=${encodeURIComponent(query)}`,
466
+ fetcherKind: "arxiv",
467
+ fetcherParams: { arxiv_query: query },
468
+ };
469
+ }
470
+ }
471
+ return { errorKey: "pluginManageSource.errUnsupportedKind" };
472
+ }
473
+
474
+ async function commitAdd(): Promise<void> {
475
+ const payload = buildRegisterPayload(draft.value);
476
+ if ("errorKey" in payload) {
477
+ draftError.value = t(payload.errorKey);
478
+ return;
479
+ }
480
+ draftError.value = "";
481
+ busy.value = "add";
482
+ const response = await apiPost<unknown>(API_ROUTES.sources.create, payload);
483
+ if (!response.ok) {
484
+ draftError.value = response.error || t("pluginManageSource.flashRegisterFailed");
485
+ busy.value = null;
486
+ return;
487
+ }
488
+ flash(t("pluginManageSource.flashRegistered"));
489
+ adding.value = false;
490
+ await refreshList();
491
+ // C: auto-rebuild so the user sees items without an extra click.
492
+ busy.value = "rebuild";
493
+ await rebuildInline();
494
+ busy.value = null;
495
+ }
496
+
497
+ // --- Starter-pack presets ----------------------------------------------
498
+
499
+ interface PresetEntry {
500
+ slug: string;
501
+ title: string;
502
+ url: string;
503
+ fetcherKind: "rss" | "github-releases" | "github-issues" | "arxiv";
504
+ fetcherParams: Record<string, string>;
505
+ categories?: string[];
506
+ }
507
+
508
+ interface Preset {
509
+ id: string;
510
+ label: string;
511
+ description: string;
512
+ entries: PresetEntry[];
513
+ }
514
+
515
+ const PRESETS: Preset[] = [
516
+ {
517
+ id: "tech-news",
518
+ label: "Tech news",
519
+ description: "Hacker News front page — daily tech headlines.",
520
+ entries: [
521
+ {
522
+ slug: "hacker-news",
523
+ title: "Hacker News",
524
+ url: "https://news.ycombinator.com/rss",
525
+ fetcherKind: "rss",
526
+ fetcherParams: { rss_url: "https://news.ycombinator.com/rss" },
527
+ categories: ["tech-news", "startup"],
528
+ },
529
+ ],
530
+ },
531
+ {
532
+ id: "ai-research",
533
+ label: "AI research",
534
+ description: "Latest arXiv papers in NLP (cs.CL) and machine learning (cs.LG).",
535
+ entries: [
536
+ {
537
+ slug: "arxiv-cs-cl",
538
+ title: "arXiv cs.CL",
539
+ url: "https://export.arxiv.org/api/query?search_query=cat:cs.CL",
540
+ fetcherKind: "arxiv",
541
+ fetcherParams: { arxiv_query: "cat:cs.CL" },
542
+ categories: ["ai", "research"],
543
+ },
544
+ {
545
+ slug: "arxiv-cs-lg",
546
+ title: "arXiv cs.LG",
547
+ url: "https://export.arxiv.org/api/query?search_query=cat:cs.LG",
548
+ fetcherKind: "arxiv",
549
+ fetcherParams: { arxiv_query: "cat:cs.LG" },
550
+ categories: ["ai", "research"],
551
+ },
552
+ ],
553
+ },
554
+ {
555
+ id: "claude-code",
556
+ label: "Claude Code updates",
557
+ description: "New releases of the Claude Code CLI from the anthropics/claude-code repo.",
558
+ entries: [
559
+ {
560
+ slug: "claude-code-releases",
561
+ title: "Claude Code releases",
562
+ url: "https://github.com/anthropics/claude-code",
563
+ fetcherKind: "github-releases",
564
+ fetcherParams: { github_repo: "anthropics/claude-code" },
565
+ categories: ["ai", "tech-news"],
566
+ },
567
+ ],
568
+ },
569
+ ];
570
+
571
+ async function installPreset(preset: Preset): Promise<void> {
572
+ busy.value = `preset-${preset.id}`;
573
+ const alreadyHave = new Set(sources.value.map((source) => source.slug));
574
+ const toRegister = preset.entries.filter((entry) => !alreadyHave.has(entry.slug));
575
+ if (toRegister.length === 0) {
576
+ flash(t("pluginManageSource.flashPresetAlreadyRegistered", { label: preset.label }));
577
+ busy.value = null;
578
+ return;
579
+ }
580
+ const failures: string[] = [];
581
+ for (const entry of toRegister) {
582
+ const response = await apiPost<unknown>(API_ROUTES.sources.create, {
583
+ slug: entry.slug,
584
+ title: entry.title,
585
+ url: entry.url,
586
+ fetcherKind: entry.fetcherKind,
587
+ fetcherParams: entry.fetcherParams,
588
+ // Presets know their categories — skip the classifier
589
+ // CLI call so the first brief is ready sooner.
590
+ categories: entry.categories,
591
+ skipClassify: true,
592
+ });
593
+ if (!response.ok) {
594
+ failures.push(`${entry.slug}: ${response.error}`);
595
+ }
596
+ }
597
+ const okCount = toRegister.length - failures.length;
598
+ if (failures.length > 0) {
599
+ flash(
600
+ t("pluginManageSource.flashPresetPartial", {
601
+ ok: okCount,
602
+ total: toRegister.length,
603
+ errors: failures.join("; "),
604
+ }),
605
+ true,
606
+ );
607
+ } else {
608
+ flash(t("pluginManageSource.flashPresetRegistered", toRegister.length, { named: { count: toRegister.length, label: preset.label } }));
609
+ }
610
+ // Skip the rebuild round-trip when nothing was actually registered
611
+ // (every attempt failed) — refreshList is still useful to pick up
612
+ // any server-side changes from before this click.
613
+ await refreshList();
614
+ if (okCount > 0) {
615
+ await rebuildInline();
616
+ }
617
+ busy.value = null;
618
+ }
619
+
620
+ // Rebuild step extracted so commitAdd can chain it without recursing
621
+ // into rebuild()'s own busy-state machine.
622
+ async function rebuildInline(): Promise<void> {
623
+ const response = await apiPost<RebuildSummary>(API_ROUTES.sources.rebuild);
624
+ if (!response.ok) {
625
+ flash(t("pluginManageSource.flashRegisterSucceededRebuildFailed", { error: response.error }), true);
626
+ return;
627
+ }
628
+ const summary = response.data;
629
+ lastRebuild.value = summary;
630
+ flash(t("pluginManageSource.flashRebuildReady", summary.plannedCount, { named: { itemCount: summary.itemCount, planned: summary.plannedCount } }));
631
+ await loadBrief(summary.isoDate);
632
+ }
633
+
634
+ const sources = computed<Source[]>(() => localSources.value ?? []);
635
+ const highlightSlug = computed(() => highlightSlugLocal.value);
636
+
637
+ // Filter chip state (#768). Single-select. `all` is the implicit
638
+ // default; clicking a chip replaces the active filter rather than
639
+ // toggling — the chip group is mutually exclusive across kind and
640
+ // schedule, so users always see the current bucket without
641
+ // remembering compound state.
642
+ const filterKey = ref<SourceFilterKey>("all");
643
+ const filteredSources = computed<Source[]>(() => sources.value.filter((source) => matchesSourceFilter(source, filterKey.value)));
644
+ const filterCounts = computed(() => countByFilter(sources.value));
645
+ // Hide chips for buckets that match zero sources so the chip row
646
+ // stays compact. `all` is always shown — it's the reset target.
647
+ const visibleFilterKeys = computed<readonly SourceFilterKey[]>(() => SOURCE_FILTER_KEYS.filter((key) => key === "all" || filterCounts.value[key] > 0));
648
+
649
+ function filterChipLabel(key: SourceFilterKey): string {
650
+ switch (key) {
651
+ case "all":
652
+ return t("pluginManageSource.filter.all");
653
+ case "rss":
654
+ return t("pluginManageSource.filter.rss");
655
+ case "github":
656
+ return t("pluginManageSource.filter.github");
657
+ case "arxiv":
658
+ return t("pluginManageSource.filter.arxiv");
659
+ case "schedule:daily":
660
+ return t("pluginManageSource.filter.scheduleDaily");
661
+ case "schedule:weekly":
662
+ return t("pluginManageSource.filter.scheduleWeekly");
663
+ case "schedule:manual":
664
+ return t("pluginManageSource.filter.scheduleManual");
665
+ }
666
+ }
667
+
668
+ function selectFilter(key: SourceFilterKey): void {
669
+ filterKey.value = key;
670
+ }
671
+
672
+ function clearFilter(): void {
673
+ filterKey.value = "all";
674
+ }
675
+
676
+ // Re-seed local state when the plugin caller switches to a different
677
+ // tool result (initialData identity changes). Plugin-only — page mode
678
+ // has no seed and shouldn't react to identity changes on null.
679
+ //
680
+ // A `null` or absent `next` here means "no newer seed" (e.g. the
681
+ // plugin caller passed a failed tool result with no data). We
682
+ // intentionally keep the existing local mirror rather than clearing
683
+ // it — clearing would flash an empty list for a tool result that
684
+ // just happens to lack a `.data` field.
685
+ watch(
686
+ () => props.initialData,
687
+ (next) => {
688
+ if (!next) return;
689
+ localSources.value = next.sources ?? [];
690
+ const nextRebuild = next.lastRebuild;
691
+ if (nextRebuild && (!lastRebuild.value || nextRebuild.isoDate >= lastRebuild.value.isoDate)) {
692
+ lastRebuild.value = nextRebuild;
693
+ }
694
+ highlightSlugLocal.value = next.highlightSlug ?? null;
695
+ },
696
+ );
697
+
698
+ function kindLabel(kind: Source["fetcherKind"]): string {
699
+ switch (kind) {
700
+ case "rss":
701
+ return t("pluginManageSource.kindRss");
702
+ case "github-releases":
703
+ return t("pluginManageSource.kindGithubRel");
704
+ case "github-issues":
705
+ return t("pluginManageSource.kindGithubIss");
706
+ case "arxiv":
707
+ return t("pluginManageSource.kindArxiv");
708
+ }
709
+ }
710
+
711
+ function kindBadgeClass(kind: Source["fetcherKind"]): string {
712
+ switch (kind) {
713
+ case "rss":
714
+ return "bg-orange-100 text-orange-700";
715
+ case "github-releases":
716
+ return "bg-purple-100 text-purple-700";
717
+ case "github-issues":
718
+ return "bg-indigo-100 text-indigo-700";
719
+ case "arxiv":
720
+ return "bg-emerald-100 text-emerald-700";
721
+ }
722
+ }
723
+
724
+ function flash(message: string, isError = false): void {
725
+ actionMessage.value = message;
726
+ actionError.value = isError;
727
+ setTimeout(() => {
728
+ if (actionMessage.value === message) actionMessage.value = "";
729
+ }, 4000);
730
+ }
731
+
732
+ // Returns the server error string on failure so callers (e.g. the
733
+ // initial page-mode load) can keep that error visible in the UI
734
+ // rather than rely on the transient flash toast.
735
+ async function refreshList(): Promise<string | null> {
736
+ const response = await apiGet<{ sources: Source[] }>(API_ROUTES.sources.list);
737
+ if (!response.ok) {
738
+ flash(t("pluginManageSource.flashRefreshListFailed", { error: response.error }), true);
739
+ return response.error || t("pluginManageSource.initialLoadFailed");
740
+ }
741
+ localSources.value = response.data.sources;
742
+ return null;
743
+ }
744
+
745
+ async function remove(slug: string): Promise<void> {
746
+ if (!confirm(t("pluginManageSource.confirmRemove", { slug }))) return;
747
+ busy.value = slug;
748
+ const response = await apiDelete<unknown>(API_ROUTES.sources.remove.replace(":slug", encodeURIComponent(slug)));
749
+ busy.value = null;
750
+ if (!response.ok) {
751
+ flash(t("pluginManageSource.flashRemoveFailed", { error: response.error }), true);
752
+ return;
753
+ }
754
+ flash(t("pluginManageSource.flashRemoved", { slug }));
755
+ await refreshList();
756
+ }
757
+
758
+ async function rebuild(): Promise<void> {
759
+ busy.value = "rebuild";
760
+ const response = await apiPost<RebuildSummary>(API_ROUTES.sources.rebuild);
761
+ if (!response.ok) {
762
+ flash(t("pluginManageSource.flashRebuildFailed", { error: response.error }), true);
763
+ busy.value = null;
764
+ return;
765
+ }
766
+ const summary = response.data;
767
+ lastRebuild.value = summary;
768
+ flash(t("pluginManageSource.flashRebuildComplete", { itemCount: summary.itemCount, planned: summary.plannedCount }));
769
+ await Promise.all([refreshList(), loadBrief(summary.isoDate)]);
770
+ busy.value = null;
771
+ }
772
+
773
+ // --- today's brief -------------------------------------------------------
774
+
775
+ // Fetched markdown (rendered via marked() into briefHtml below). Null
776
+ // while idle; "" after a confirmed empty/404 so the template can show
777
+ // a friendly message instead of a stuck spinner.
778
+ const briefMarkdown = ref<string | null>(null);
779
+ const briefError = ref("");
780
+ const briefLoading = ref(false);
781
+ const briefDate = ref("");
782
+ const briefFilePath = ref("");
783
+
784
+ // Build `news/daily/YYYY/MM/DD.md` from an ISO date. Local-time
785
+ // matches how the pipeline writes the file (see toLocalIsoDate).
786
+ function dailyPathFor(isoDate: string): string {
787
+ const [year, month, day] = isoDate.split("-");
788
+ return `news/daily/${year}/${month}/${day}.md`;
789
+ }
790
+
791
+ function todayIsoDate(): string {
792
+ const now = new Date();
793
+ const year = now.getFullYear();
794
+ const month = String(now.getMonth() + 1).padStart(2, "0");
795
+ const day = String(now.getDate()).padStart(2, "0");
796
+ return `${year}-${month}-${day}`;
797
+ }
798
+
799
+ // Monotonically-increasing token so concurrent loadBrief() calls
800
+ // (mount + rebuild + prop watch racing on slow networks) can drop
801
+ // stale responses that resolve after a newer one has already
802
+ // settled the state. Without this, an older fetch finishing last
803
+ // would clobber the latest brief.
804
+ let briefLoadToken = 0;
805
+
806
+ async function loadBrief(isoDate: string): Promise<void> {
807
+ const token = ++briefLoadToken;
808
+ briefLoading.value = true;
809
+ briefError.value = "";
810
+ briefDate.value = isoDate;
811
+ const relPath = dailyPathFor(isoDate);
812
+ briefFilePath.value = relPath;
813
+ const response = await apiGet<{ content?: string; kind?: string }>(API_ROUTES.files.content, { path: relPath });
814
+ if (token !== briefLoadToken) return;
815
+ if (!response.ok) {
816
+ if (response.status === 404) {
817
+ briefMarkdown.value = "";
818
+ briefError.value = t("pluginManageSource.briefNone");
819
+ } else {
820
+ briefError.value = response.error || t("pluginManageSource.briefLoadFailed");
821
+ }
822
+ briefLoading.value = false;
823
+ return;
824
+ }
825
+ briefMarkdown.value = response.data.content ?? "";
826
+ if (!briefMarkdown.value.trim()) {
827
+ briefError.value = t("pluginManageSource.briefEmpty");
828
+ }
829
+ briefLoading.value = false;
830
+ }
831
+
832
+ // The daily file ends with a trailing ```json block that carries
833
+ // the structured item list for later machine consumption (Q2 of the
834
+ // plan: "Markdown + trailing fenced JSON block"). Strip it for the
835
+ // human-facing render so the UI doesn't dump a 1000-line JSON blob
836
+ // after the brief. The file on disk stays unchanged.
837
+ function stripTrailingJsonBlock(markdown: string): string {
838
+ const marker = "\n```json\n";
839
+ const idx = markdown.lastIndexOf(marker);
840
+ if (idx < 0) return markdown;
841
+ // Only strip if everything after the marker looks like it belongs
842
+ // to that block (i.e. it's the last fenced block in the file).
843
+ const tail = markdown.slice(idx);
844
+ if (!tail.trimEnd().endsWith("```")) return markdown;
845
+ return markdown.slice(0, idx).trimEnd();
846
+ }
847
+
848
+ const briefHtml = computed(() => {
849
+ if (!briefMarkdown.value) return "";
850
+ const body = stripTrailingJsonBlock(briefMarkdown.value);
851
+ // marked() preserves raw HTML embedded in the markdown (RSS
852
+ // content:encoded blocks often carry tracking pixels, iframes,
853
+ // inline <script> from scraped sources). Sanitize before
854
+ // binding to v-html.
855
+ return DOMPurify.sanitize(marked(body) as string);
856
+ });
857
+
858
+ // Load on mount:
859
+ // - page mode: fetch the source list via API first (with loading
860
+ // gate so preset/add can't race an empty local state), then load
861
+ // the brief for today.
862
+ // - plugin mode: skip the list fetch — the tool-result seed (even
863
+ // when .data is absent due to a failure) is authoritative until
864
+ // the user clicks Rebuild/Remove/Add.
865
+ async function doInitialLoad(): Promise<void> {
866
+ initialLoading.value = true;
867
+ initialLoadError.value = null;
868
+ try {
869
+ initialLoadError.value = await refreshList();
870
+ } finally {
871
+ initialLoading.value = false;
872
+ }
873
+ }
874
+
875
+ async function retryInitialLoad(): Promise<void> {
876
+ await doInitialLoad();
877
+ // Re-attempt the brief fetch too — a network blip that killed the
878
+ // list also likely killed the brief, and we only hit this path on
879
+ // the retry button, so users expect a full reload.
880
+ const initial = lastRebuild.value?.isoDate ?? todayIsoDate();
881
+ loadBrief(initial);
882
+ }
883
+
884
+ onMounted(async () => {
885
+ if (props.mode === "page") {
886
+ await doInitialLoad();
887
+ }
888
+ const initial = lastRebuild.value?.isoDate ?? todayIsoDate();
889
+ loadBrief(initial);
890
+ });
891
+
892
+ // Re-fetch when the caller's seed brings a new rebuild summary
893
+ // (e.g. the LLM triggered another rebuild in the plugin context).
894
+ watch(
895
+ () => props.initialData?.lastRebuild?.isoDate,
896
+ (next) => {
897
+ if (next && next !== briefDate.value) loadBrief(next);
898
+ },
899
+ );
900
+ </script>