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
@@ -54,7 +54,7 @@ import FileTreePane from "./FileTreePane.vue";
54
54
  import FileContentHeader from "./FileContentHeader.vue";
55
55
  import FileContentRenderer from "./FileContentRenderer.vue";
56
56
  import { useFileTree } from "../composables/useFileTree";
57
- import { useFileSelection, isValidFilePath } from "../composables/useFileSelection";
57
+ import { useFileSelection, isValidFilePath, readPathMatch } from "../composables/useFileSelection";
58
58
  import { useMarkdownMode } from "../composables/useMarkdownMode";
59
59
  import { useFileSortMode } from "../composables/useFileSortMode";
60
60
  import { useContentDisplay } from "../composables/useContentDisplay";
@@ -161,9 +161,11 @@ const { handleMarkdownLinkClick } = useMarkdownLinkHandler(selectedPath, {
161
161
  onLoadSession: (sessionId) => emit("loadSession", sessionId),
162
162
  });
163
163
 
164
- // External URL changes (back/forward) → update selectedPath.
164
+ // External URL changes (back/forward) → update selectedPath. Reading
165
+ // from `route.params.pathMatch` after the query→params migration;
166
+ // see plans/done/feat-files-path-url.md.
165
167
  watch(
166
- () => route.query.path,
168
+ () => readPathMatch(route.params.pathMatch),
167
169
  (newPath) => {
168
170
  if (!isValidFilePath(newPath)) {
169
171
  if (selectedPath.value !== null) {
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ active: boolean;
4
+ label: string;
5
+ count?: number;
6
+ }>();
7
+ defineEmits<{ click: [] }>();
8
+ </script>
9
+
10
+ <template>
11
+ <button
12
+ type="button"
13
+ :aria-pressed="active"
14
+ :class="[
15
+ 'inline-flex items-center px-2 py-0.5 text-xs leading-4 rounded-full border transition-colors cursor-pointer',
16
+ active ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-200',
17
+ ]"
18
+ @click="$emit('click')"
19
+ >
20
+ {{ label }}<template v-if="count !== undefined"> ({{ count }})</template>
21
+ </button>
22
+ </template>
@@ -3,8 +3,11 @@
3
3
  <button
4
4
  ref="button"
5
5
  data-testid="sandbox-lock-button"
6
- :class="sandboxEnabled ? 'text-gray-400 hover:text-gray-700' : 'text-amber-400 hover:text-amber-500'"
7
- :title="sandboxEnabled ? 'Sandbox enabled (Docker)' : 'No sandbox (Docker not found)'"
6
+ :class="[
7
+ 'h-8 w-8 flex items-center justify-center rounded',
8
+ sandboxEnabled ? 'text-gray-400 hover:text-gray-700' : 'text-amber-400 hover:text-amber-500',
9
+ ]"
10
+ :title="sandboxEnabled ? t('lockStatusPopup.sandboxEnabledTooltip') : t('lockStatusPopup.noSandboxTooltip')"
8
11
  @click="emit('update:open', !open)"
9
12
  >
10
13
  <span class="material-icons">{{ sandboxEnabled ? "lock" : "lock_open" }}</span>
@@ -13,36 +16,36 @@
13
16
  <p class="mb-2" :class="sandboxEnabled ? 'text-green-800' : 'text-amber-500'">
14
17
  <template v-if="sandboxEnabled">
15
18
  <span class="material-icons text-xs align-middle mr-1">lock</span>
16
- <strong>Sandbox enabled:</strong> Docker is running. Filesystem access is isolated.
19
+ <strong>{{ t("lockStatusPopup.sandboxEnabledLabel") }}</strong> {{ t("lockStatusPopup.sandboxEnabledBody") }}
17
20
  </template>
18
21
  <template v-else>
19
22
  <span class="material-icons text-xs align-middle mr-1">warning</span>
20
- <strong>No sandbox:</strong> Claude can access all files on your machine. Install
21
- <a href="https://www.docker.com/products/docker-desktop/" target="_blank" class="underline">Docker Desktop</a>
22
- to enable filesystem isolation.
23
+ <strong>{{ t("lockStatusPopup.noSandboxLabel") }}</strong> {{ t("lockStatusPopup.noSandboxBodyPrefix") }}
24
+ <a href="https://www.docker.com/products/docker-desktop/" target="_blank" class="underline">{{ t("lockStatusPopup.dockerDesktop") }}</a>
25
+ {{ t("lockStatusPopup.noSandboxBodySuffix") }}
23
26
  </template>
24
27
  </p>
25
28
  <div v-if="sandboxEnabled" data-testid="sandbox-credentials-block" class="mb-2 border-t border-gray-100 pt-2">
26
- <p class="text-gray-400 mb-1">Host credentials attached:</p>
27
- <p v-if="sandboxStatus === null" class="text-gray-400 italic" data-testid="sandbox-credentials-loading">loading…</p>
29
+ <p class="text-gray-400 mb-1">{{ t("lockStatusPopup.hostCredentials") }}</p>
30
+ <p v-if="sandboxStatus === null" class="text-gray-400 italic" data-testid="sandbox-credentials-loading">{{ t("lockStatusPopup.credsLoading") }}</p>
28
31
  <template v-else>
29
32
  <p data-testid="sandbox-credentials-ssh">
30
33
  <span class="mr-1">🔑</span>
31
- <span class="text-gray-500">SSH agent:</span>
34
+ <span class="text-gray-500">{{ t("lockStatusPopup.sshAgent") }}</span>
32
35
  <span :class="sandboxStatus.sshAgent ? 'text-green-700' : 'text-gray-400'" class="ml-1">
33
- {{ sandboxStatus.sshAgent ? "forwarded" : "not forwarded" }}
36
+ {{ sandboxStatus.sshAgent ? t("lockStatusPopup.forwarded") : t("lockStatusPopup.notForwarded") }}
34
37
  </span>
35
38
  </p>
36
39
  <p data-testid="sandbox-credentials-mounts">
37
40
  <span class="mr-1">📁</span>
38
- <span class="text-gray-500">Mounted configs:</span>
41
+ <span class="text-gray-500">{{ t("lockStatusPopup.mountedConfigs") }}</span>
39
42
  <span :class="sandboxStatus.mounts.length > 0 ? 'text-green-700' : 'text-gray-400'" class="ml-1">
40
- {{ sandboxStatus.mounts.length > 0 ? sandboxStatus.mounts.join(", ") : "none" }}
43
+ {{ sandboxStatus.mounts.length > 0 ? sandboxStatus.mounts.join(", ") : t("lockStatusPopup.none") }}
41
44
  </span>
42
45
  </p>
43
46
  </template>
44
47
  </div>
45
- <p class="text-gray-400 mb-1">Test sandbox isolation:</p>
48
+ <p class="text-gray-400 mb-1">{{ t("lockStatusPopup.testIsolation") }}</p>
46
49
  <div class="flex flex-col gap-1">
47
50
  <button
48
51
  v-for="q in SANDBOX_TEST_QUERIES"
@@ -60,8 +63,11 @@
60
63
 
61
64
  <script setup lang="ts">
62
65
  import { ref, watch } from "vue";
66
+ import { useI18n } from "vue-i18n";
63
67
  import { useSandboxStatus } from "../composables/useSandboxStatus";
64
68
 
69
+ const { t } = useI18n();
70
+
65
71
  const props = defineProps<{
66
72
  sandboxEnabled: boolean;
67
73
  open: boolean;
@@ -0,0 +1,252 @@
1
+ <template>
2
+ <div class="h-full flex flex-col bg-white" data-testid="news-view">
3
+ <!-- Header: title + filter chips + actions. -->
4
+ <div class="px-3 py-2 border-b border-gray-200 flex flex-wrap items-center gap-2 shrink-0">
5
+ <h1 class="text-base font-semibold text-gray-900 mr-3">{{ t("pluginNews.title") }}</h1>
6
+ <span class="text-xs text-gray-500" data-testid="news-counts">{{
7
+ t("pluginNews.itemCount", {
8
+ unread: unreadCount,
9
+ total: items.length,
10
+ })
11
+ }}</span>
12
+ <div class="ml-auto flex items-center gap-2">
13
+ <div class="flex border border-gray-300 rounded overflow-hidden" role="tablist">
14
+ <button
15
+ v-for="filter in readFilterChoices"
16
+ :key="filter.value"
17
+ :class="[
18
+ 'h-8 px-2.5 flex items-center gap-1 text-sm transition-colors',
19
+ readFilter === filter.value ? 'bg-blue-50 text-blue-600 font-medium' : 'bg-white text-gray-600 hover:bg-gray-50',
20
+ ]"
21
+ :data-testid="`news-filter-${filter.value}`"
22
+ :aria-pressed="readFilter === filter.value"
23
+ @click="readFilter = filter.value"
24
+ >
25
+ {{ filter.label }}
26
+ </button>
27
+ </div>
28
+ <button
29
+ 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 disabled:cursor-not-allowed"
30
+ :disabled="unreadCount === 0"
31
+ data-testid="news-mark-all-read"
32
+ @click="markAllReadNow"
33
+ >
34
+ {{ t("pluginNews.markAllRead") }}
35
+ </button>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Source filter chip row (only sources with items). -->
40
+ <div v-if="sourceChoices.length > 1" class="px-3 py-2 border-b border-gray-100 flex flex-wrap items-center gap-1 shrink-0">
41
+ <FilterChip
42
+ v-for="choice in sourceChoices"
43
+ :key="choice.slug"
44
+ :active="sourceFilter === choice.slug"
45
+ :label="choice.label"
46
+ :count="choice.count"
47
+ :data-testid="`news-source-${choice.slug}`"
48
+ @click="sourceFilter = choice.slug"
49
+ />
50
+ </div>
51
+
52
+ <!-- Body: list (left) + detail (right). -->
53
+ <div class="flex-1 min-h-0 flex">
54
+ <!-- List pane -->
55
+ <div class="w-80 shrink-0 border-r border-gray-200 overflow-y-auto" data-testid="news-list">
56
+ <div v-if="loading" class="p-4 text-sm text-gray-400">{{ t("common.loading") }}</div>
57
+ <div v-else-if="error" class="p-4 text-sm text-red-600 bg-red-50" role="alert">
58
+ {{ t("pluginNews.loadError", { error }) }}
59
+ </div>
60
+ <div v-else-if="visibleItems.length === 0" class="p-4 text-sm text-gray-400">{{ t("pluginNews.empty") }}</div>
61
+ <ul v-else class="divide-y divide-gray-100">
62
+ <li
63
+ v-for="item in visibleItems"
64
+ :key="item.id"
65
+ :class="['px-3 py-2 cursor-pointer', selectedId === item.id ? 'bg-blue-50' : 'hover:bg-gray-50']"
66
+ :data-testid="`news-item-${item.id}`"
67
+ @click="selectItem(item.id)"
68
+ >
69
+ <div class="flex items-start gap-2">
70
+ <span
71
+ v-if="!isRead(item.id)"
72
+ class="mt-1 w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0"
73
+ :title="t('pluginNews.unread')"
74
+ :aria-label="t('pluginNews.unread')"
75
+ />
76
+ <div class="min-w-0 flex-1">
77
+ <div :class="['text-sm leading-snug', isRead(item.id) ? 'text-gray-500' : 'text-gray-900 font-medium']">
78
+ {{ item.title }}
79
+ </div>
80
+ <div class="mt-0.5 flex items-center gap-2 text-[11px] text-gray-500">
81
+ <span class="truncate">{{ item.sourceSlug }}</span>
82
+ <span>{{ formatSmartTime(item.publishedAt) }}</span>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </li>
87
+ </ul>
88
+ </div>
89
+
90
+ <!-- Detail pane -->
91
+ <div class="flex-1 min-w-0 flex flex-col" data-testid="news-detail">
92
+ <div v-if="!selected" class="flex-1 flex items-center justify-center text-sm text-gray-400">
93
+ {{ t("pluginNews.selectPrompt") }}
94
+ </div>
95
+ <template v-else>
96
+ <div class="flex-1 min-h-0 overflow-y-auto">
97
+ <div class="px-6 py-4 max-w-3xl">
98
+ <h2 class="text-xl font-semibold text-gray-900 leading-snug">{{ selected.title }}</h2>
99
+ <div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
100
+ <span>{{ selected.sourceSlug }}</span>
101
+ <span>{{ formatSmartTime(selected.publishedAt) }}</span>
102
+ <span v-for="cat in selected.categories" :key="cat" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">
103
+ {{ cat }}
104
+ </span>
105
+ </div>
106
+ <a
107
+ :href="selected.url"
108
+ target="_blank"
109
+ rel="noopener noreferrer"
110
+ class="mt-3 inline-flex items-center gap-1 text-sm text-blue-600 hover:underline"
111
+ data-testid="news-open-original"
112
+ >
113
+ <span class="material-icons text-sm">open_in_new</span>
114
+ {{ t("pluginNews.openOriginal") }}
115
+ </a>
116
+ <div class="mt-4">
117
+ <div v-if="bodyLoading" class="text-sm text-gray-400">{{ t("common.loading") }}</div>
118
+ <div v-else-if="bodyError" class="text-sm text-red-600">{{ t("pluginNews.bodyError", { error: bodyError }) }}</div>
119
+ <div v-else-if="!body" class="text-sm text-gray-400 italic">{{ t("pluginNews.noBody") }}</div>
120
+ <div v-else class="markdown-content prose prose-slate max-w-none" v-html="renderedBody"></div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ <PageChatComposer
125
+ :key="selected.id"
126
+ :placeholder="t('pluginNews.chatPlaceholder')"
127
+ :prepend-text="`Read this article. ${selected.url}`"
128
+ :allow-empty="true"
129
+ test-id-prefix="news-page-chat"
130
+ />
131
+ </template>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </template>
136
+
137
+ <script setup lang="ts">
138
+ import { computed, onMounted, ref, watch } from "vue";
139
+ import { useI18n } from "vue-i18n";
140
+ import { marked } from "marked";
141
+ import { useRoute } from "vue-router";
142
+ import { API_ROUTES } from "../config/apiRoutes";
143
+ import { apiGet } from "../utils/api";
144
+ import { formatSmartTime } from "../utils/format/date";
145
+ import { useNewsItems } from "../composables/useNewsItems";
146
+ import { useNewsReadState } from "../composables/useNewsReadState";
147
+ import FilterChip from "./FilterChip.vue";
148
+ import PageChatComposer from "./PageChatComposer.vue";
149
+
150
+ const { t } = useI18n();
151
+ const route = useRoute();
152
+
153
+ const { items, loading, error, load: loadItems } = useNewsItems();
154
+ const { isRead, markRead, markAllRead, load: loadReadState } = useNewsReadState();
155
+
156
+ type ReadFilter = "all" | "unread";
157
+ const readFilter = ref<ReadFilter>("unread");
158
+ const sourceFilter = ref<string>("all");
159
+ const selectedId = ref<string | null>(null);
160
+ const body = ref<string | null>(null);
161
+ const bodyLoading = ref(false);
162
+ const bodyError = ref<string | null>(null);
163
+
164
+ const readFilterChoices = computed<{ value: ReadFilter; label: string }[]>(() => [
165
+ { value: "unread", label: t("pluginNews.filterUnread") },
166
+ { value: "all", label: t("pluginNews.filterAll") },
167
+ ]);
168
+
169
+ const visibleItems = computed(() =>
170
+ items.value.filter((item) => {
171
+ if (readFilter.value === "unread" && isRead(item.id)) return false;
172
+ if (sourceFilter.value !== "all" && item.sourceSlug !== sourceFilter.value) return false;
173
+ return true;
174
+ }),
175
+ );
176
+
177
+ // Source chips: derived from the current items list, sorted by
178
+ // per-source count desc so the busiest source surfaces first.
179
+ const sourceChoices = computed<{ slug: string; label: string; count: number }[]>(() => {
180
+ const counts = new Map<string, number>();
181
+ for (const item of items.value) {
182
+ counts.set(item.sourceSlug, (counts.get(item.sourceSlug) ?? 0) + 1);
183
+ }
184
+ const sorted = Array.from(counts.entries())
185
+ .sort(([, leftCount], [, rightCount]) => rightCount - leftCount)
186
+ .map(([slug, count]) => ({ slug, label: slug, count }));
187
+ return [{ slug: "all", label: t("pluginNews.allSources"), count: items.value.length }, ...sorted];
188
+ });
189
+
190
+ const unreadCount = computed(() => items.value.filter((item) => !isRead(item.id)).length);
191
+
192
+ const selected = computed(() => items.value.find((item) => item.id === selectedId.value) ?? null);
193
+
194
+ const renderedBody = computed(() => (body.value ? marked(body.value, { breaks: true, gfm: true }) : ""));
195
+
196
+ function selectItem(itemId: string): void {
197
+ selectedId.value = itemId;
198
+ // Auto mark-as-read on selection. Defer slightly so a rapid arrow-
199
+ // key scroll doesn't burn through the unread queue accidentally —
200
+ // we only mark when the user dwells on a card.
201
+ setTimeout(() => {
202
+ if (selectedId.value === itemId) markRead(itemId);
203
+ }, 250);
204
+ }
205
+
206
+ function markAllReadNow(): void {
207
+ markAllRead(items.value.map((item) => item.id));
208
+ }
209
+
210
+ // Body fetch fires whenever the selection changes. Cancellation via
211
+ // a token: a stale response just no-ops if the user moved on.
212
+ let bodyToken = 0;
213
+ watch(
214
+ () => selectedId.value,
215
+ async (itemId) => {
216
+ body.value = null;
217
+ bodyError.value = null;
218
+ if (!itemId) return;
219
+ bodyLoading.value = true;
220
+ const token = ++bodyToken;
221
+ const url = API_ROUTES.news.itemBody.replace(":id", encodeURIComponent(itemId));
222
+ const result = await apiGet<{ body: string | null }>(url);
223
+ if (token !== bodyToken) return;
224
+ bodyLoading.value = false;
225
+ if (!result.ok) {
226
+ bodyError.value = result.error;
227
+ return;
228
+ }
229
+ body.value = result.data.body;
230
+ },
231
+ );
232
+
233
+ // Apply `?source=<slug>` deep link from the Sources page once items
234
+ // land — the sourceFilter only takes effect if the slug is one of
235
+ // the registered sources in the current items list.
236
+ function applyRouteSourceFilter(): void {
237
+ const querySource = route.query.source;
238
+ if (typeof querySource === "string" && querySource.length > 0) {
239
+ sourceFilter.value = querySource;
240
+ }
241
+ }
242
+
243
+ onMounted(async () => {
244
+ applyRouteSourceFilter();
245
+ await Promise.all([loadItems(), loadReadState()]);
246
+ });
247
+
248
+ watch(
249
+ () => route.query.source,
250
+ () => applyRouteSourceFilter(),
251
+ );
252
+ </script>
@@ -1,11 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, watch, onMounted, onUnmounted } from "vue";
3
+ import { useI18n } from "vue-i18n";
3
4
  import { useNotifications } from "../composables/useNotifications";
4
5
  import { formatRelativeTime } from "../utils/format/date";
5
6
  import { NOTIFICATION_ICONS, NOTIFICATION_ACTION_TYPES, NOTIFICATION_PRIORITIES } from "../types/notification";
6
7
  import type { NotificationPayload } from "../types/notification";
7
8
 
8
- const { notifications, unreadCount, markAllRead, dismiss } = useNotifications();
9
+ const { t } = useI18n();
10
+ const { notifications, unreadCount, isRead, markRead, markAllRead, dismiss } = useNotifications();
9
11
  const open = ref(false);
10
12
  const rootRef = ref<HTMLElement | null>(null);
11
13
 
@@ -37,7 +39,10 @@ watch(
37
39
 
38
40
  function toggle(): void {
39
41
  open.value = !open.value;
40
- if (open.value) markAllRead();
42
+ // Opening the panel does NOT auto-mark items as read — the user
43
+ // has to click an item (markRead via handleClick) or dismiss it
44
+ // (× button) for the unread badge to drop. The "Mark all read"
45
+ // button in the panel header still bulk-clears.
41
46
  emit("update:open", open.value);
42
47
  }
43
48
 
@@ -55,6 +60,10 @@ function formatTime(iso: string): string {
55
60
  }
56
61
 
57
62
  function handleClick(notification: NotificationPayload): void {
63
+ // Mark this single item read regardless of whether it has a
64
+ // navigate action — the user has explicitly engaged with it,
65
+ // which is the unambiguous "I've seen this" signal.
66
+ markRead(notification.id);
58
67
  if (notification.action.type === NOTIFICATION_ACTION_TYPES.navigate) {
59
68
  emit("navigate", notification.action);
60
69
  close();
@@ -63,6 +72,10 @@ function handleClick(notification: NotificationPayload): void {
63
72
 
64
73
  function handleDismiss(event: Event, notificationId: string): void {
65
74
  event.stopPropagation();
75
+ // dismiss removes the entry from the list, so its contribution
76
+ // to unreadCount drops automatically. No separate markRead call
77
+ // needed — and we don't want the entry to linger as "read" if
78
+ // the user explicitly chose to remove it.
66
79
  dismiss(notificationId);
67
80
  }
68
81
  </script>
@@ -70,7 +83,12 @@ function handleDismiss(event: Event, notificationId: string): void {
70
83
  <template>
71
84
  <div ref="rootRef" class="relative">
72
85
  <!-- Bell button -->
73
- <button class="relative text-gray-400 hover:text-gray-700" data-testid="notification-bell" aria-label="Notifications" @click="toggle">
86
+ <button
87
+ class="relative h-8 w-8 flex items-center justify-center rounded text-gray-400 hover:text-gray-700"
88
+ data-testid="notification-bell"
89
+ :aria-label="t('notificationBell.notifications')"
90
+ @click="toggle"
91
+ >
74
92
  <span class="material-icons">notifications</span>
75
93
  <span
76
94
  v-if="unreadCount > 0"
@@ -89,12 +107,14 @@ function handleDismiss(event: Event, notificationId: string): void {
89
107
  >
90
108
  <!-- Header -->
91
109
  <div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
92
- <span class="text-sm font-semibold text-gray-700">Notifications</span>
93
- <button class="text-xs text-blue-500 hover:text-blue-700" data-testid="notification-mark-all-read" @click="markAllRead">Mark all read</button>
110
+ <span class="text-sm font-semibold text-gray-700">{{ t("notificationBell.notifications") }}</span>
111
+ <button class="text-xs text-blue-500 hover:text-blue-700" data-testid="notification-mark-all-read" @click="markAllRead">
112
+ {{ t("notificationBell.markAllRead") }}
113
+ </button>
94
114
  </div>
95
115
 
96
116
  <!-- Empty state -->
97
- <div v-if="notifications.length === 0" class="py-8 text-center text-sm text-gray-400">No notifications</div>
117
+ <div v-if="notifications.length === 0" class="py-8 text-center text-sm text-gray-400">{{ t("notificationBell.noNotifications") }}</div>
98
118
 
99
119
  <!-- Items -->
100
120
  <div v-else>
@@ -104,16 +124,22 @@ function handleDismiss(event: Event, notificationId: string): void {
104
124
  role="button"
105
125
  tabindex="0"
106
126
  class="flex items-start gap-3 px-4 py-3 border-b border-gray-50 hover:bg-gray-50 focus:bg-gray-100 cursor-pointer outline-none"
127
+ :class="isRead(n.id) ? 'bg-white' : 'bg-blue-50/40'"
107
128
  :data-testid="`notification-item-${n.id}`"
129
+ :data-unread="isRead(n.id) ? 'false' : 'true'"
108
130
  :aria-label="n.title"
109
131
  @click="handleClick(n)"
110
- @keydown.enter="handleClick(n)"
132
+ @keydown.enter.prevent.self="(e) => !e.repeat && handleClick(n)"
133
+ @keydown.space.prevent.self="(e) => !e.repeat && handleClick(n)"
111
134
  >
135
+ <!-- Unread dot — small leading marker so the user can scan
136
+ the panel for what's new. Hidden once `markRead` fires. -->
137
+ <span class="w-2 h-2 mt-2 shrink-0 rounded-full" :class="isRead(n.id) ? 'bg-transparent' : 'bg-blue-500'" aria-hidden="true" />
112
138
  <span class="material-icons text-lg mt-0.5 shrink-0" :class="n.priority === NOTIFICATION_PRIORITIES.high ? 'text-red-500' : 'text-gray-400'">
113
139
  {{ iconName(n) }}
114
140
  </span>
115
141
  <div class="flex-1 min-w-0">
116
- <p class="text-sm text-gray-800 truncate">{{ n.title }}</p>
142
+ <p class="text-sm truncate" :class="isRead(n.id) ? 'text-gray-600 font-normal' : 'text-gray-900 font-semibold'">{{ n.title }}</p>
117
143
  <p v-if="n.body" class="text-xs text-gray-500 truncate mt-0.5">
118
144
  {{ n.body }}
119
145
  </p>
@@ -121,7 +147,7 @@ function handleDismiss(event: Event, notificationId: string): void {
121
147
  {{ formatTime(n.firedAt) }}
122
148
  </p>
123
149
  </div>
124
- <button class="text-gray-300 hover:text-gray-500 shrink-0 mt-0.5" aria-label="Dismiss" @click="handleDismiss($event, n.id)">
150
+ <button class="text-gray-300 hover:text-gray-500 shrink-0 mt-0.5" :aria-label="t('notificationBell.dismiss')" @click="handleDismiss($event, n.id)">
125
151
  <span class="material-icons text-sm">close</span>
126
152
  </button>
127
153
  </div>
@@ -1,6 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, watch } from "vue";
3
+ import { useI18n } from "vue-i18n";
3
4
  import { useNotifications } from "../composables/useNotifications";
5
+
6
+ const { t } = useI18n();
4
7
  import { NOTIFICATION_ICONS } from "../types/notification";
5
8
  import type { NotificationPayload } from "../types/notification";
6
9
  import { ONE_SECOND_MS } from "../../server/utils/time";
@@ -52,7 +55,7 @@ function iconName(notif: NotificationPayload): string {
52
55
  {{ formatSmartTime(visible.firedAt) }}
53
56
  </p>
54
57
  </div>
55
- <button type="button" class="text-slate-400 hover:text-white" aria-label="Dismiss" @click="dismiss">
58
+ <button type="button" class="text-slate-400 hover:text-white" :aria-label="t('common.dismiss')" @click="dismiss">
56
59
  <span class="material-icons text-base">close</span>
57
60
  </button>
58
61
  </div>
@@ -0,0 +1,101 @@
1
+ <template>
2
+ <div class="border-t border-gray-200 shrink-0 bg-gray-50">
3
+ <SuggestionsPanel
4
+ v-if="suggestions && suggestions.length > 0"
5
+ v-model:expanded="suggestionsExpanded"
6
+ :queries="suggestions"
7
+ @send="onSuggestionSend"
8
+ @edit="onSuggestionEdit"
9
+ />
10
+ <div class="px-4 py-3 flex gap-2">
11
+ <textarea
12
+ ref="textareaRef"
13
+ v-model="draft"
14
+ :data-testid="`${testIdPrefix}-input`"
15
+ :placeholder="placeholder"
16
+ rows="2"
17
+ class="flex-1 bg-white border border-gray-300 rounded px-3 py-2 text-sm text-gray-900 placeholder-gray-400 resize-none"
18
+ @compositionstart="imeEnter.onCompositionStart"
19
+ @compositionend="imeEnter.onCompositionEnd"
20
+ @keydown="imeEnter.onKeydown"
21
+ @blur="imeEnter.onBlur"
22
+ />
23
+ <div class="flex flex-col gap-1 shrink-0">
24
+ <button
25
+ v-if="suggestions && suggestions.length > 0"
26
+ :data-testid="`${testIdPrefix}-suggestions`"
27
+ class="rounded w-8 h-8 flex items-center justify-center"
28
+ :class="suggestionsExpanded ? 'text-blue-600 bg-blue-50' : 'text-gray-400 hover:text-gray-600'"
29
+ :title="t('suggestionsPanel.suggestions')"
30
+ :aria-label="t('suggestionsPanel.suggestions')"
31
+ @click="suggestionsExpanded = !suggestionsExpanded"
32
+ >
33
+ <span class="material-icons text-base leading-none">lightbulb</span>
34
+ </button>
35
+ <button
36
+ :data-testid="`${testIdPrefix}-send`"
37
+ class="bg-blue-600 hover:bg-blue-700 text-white rounded w-8 h-8 flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
38
+ :title="t('common.sendChat')"
39
+ :aria-label="t('common.sendChat')"
40
+ :disabled="!canSend"
41
+ @click="submit"
42
+ >
43
+ <span class="material-icons text-base leading-none">send</span>
44
+ </button>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import { computed, nextTick, ref } from "vue";
52
+ import { useI18n } from "vue-i18n";
53
+ import { useAppApi } from "../composables/useAppApi";
54
+ import { useImeAwareEnter } from "../composables/useImeAwareEnter";
55
+ import SuggestionsPanel from "./SuggestionsPanel.vue";
56
+
57
+ const props = withDefaults(
58
+ defineProps<{
59
+ placeholder: string;
60
+ prependText: string;
61
+ disabled?: boolean;
62
+ testIdPrefix?: string;
63
+ allowEmpty?: boolean;
64
+ suggestions?: string[];
65
+ }>(),
66
+ { disabled: false, testIdPrefix: "page-chat", allowEmpty: false, suggestions: () => [] },
67
+ );
68
+
69
+ const { t } = useI18n();
70
+ const appApi = useAppApi();
71
+ const draft = ref("");
72
+ const textareaRef = ref<HTMLTextAreaElement | null>(null);
73
+ const suggestionsExpanded = ref(false);
74
+
75
+ const canSend = computed(() => !props.disabled && (props.allowEmpty || draft.value.trim().length > 0));
76
+
77
+ function submitText(text: string) {
78
+ const trimmed = text.trim();
79
+ if (!trimmed && !props.allowEmpty) return;
80
+ const prompt = trimmed ? `${props.prependText}\n\n${trimmed}` : props.prependText;
81
+ draft.value = "";
82
+ appApi.startNewChat(prompt);
83
+ }
84
+
85
+ function submit() {
86
+ if (props.disabled) return;
87
+ submitText(draft.value);
88
+ }
89
+
90
+ function onSuggestionSend(query: string) {
91
+ if (props.disabled) return;
92
+ submitText(query);
93
+ }
94
+
95
+ function onSuggestionEdit(query: string) {
96
+ draft.value = query;
97
+ nextTick(() => textareaRef.value?.focus());
98
+ }
99
+
100
+ const imeEnter = useImeAwareEnter(submit);
101
+ </script>