mulmoclaude 0.1.2 → 0.4.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 (251) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-D8rhwXLq.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/config.ts +12 -12
  10. package/server/agent/index.ts +9 -3
  11. package/server/agent/mcp-server.ts +19 -19
  12. package/server/agent/mcp-tools/index.ts +6 -6
  13. package/server/agent/mcp-tools/x.ts +7 -6
  14. package/server/agent/prompt.ts +195 -29
  15. package/server/agent/resumeFailover.ts +5 -5
  16. package/server/agent/sandboxMounts.ts +10 -10
  17. package/server/agent/stream.ts +4 -4
  18. package/server/api/auth/bearerAuth.ts +3 -3
  19. package/server/api/auth/token.ts +2 -2
  20. package/server/api/routes/agent.ts +21 -3
  21. package/server/api/routes/config.ts +1 -1
  22. package/server/api/routes/files.ts +22 -21
  23. package/server/api/routes/html.ts +2 -2
  24. package/server/api/routes/image.ts +7 -7
  25. package/server/api/routes/mulmo-script.ts +33 -31
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/plugins.ts +16 -6
  28. package/server/api/routes/roles.ts +2 -2
  29. package/server/api/routes/scheduler.ts +14 -12
  30. package/server/api/routes/schedulerHandlers.ts +12 -12
  31. package/server/api/routes/schedulerTasks.ts +19 -17
  32. package/server/api/routes/sessions.ts +26 -26
  33. package/server/api/routes/sessionsCursor.ts +4 -4
  34. package/server/api/routes/skills.ts +5 -5
  35. package/server/api/routes/sources.ts +3 -3
  36. package/server/api/routes/todosColumnsHandlers.ts +30 -30
  37. package/server/api/routes/todosHandlers.ts +1 -1
  38. package/server/api/routes/todosItemsHandlers.ts +14 -14
  39. package/server/api/routes/wiki.ts +36 -22
  40. package/server/api/sandboxStatus.ts +1 -1
  41. package/server/events/notifications.ts +6 -6
  42. package/server/events/pub-sub/index.ts +3 -3
  43. package/server/events/relay-client.ts +17 -16
  44. package/server/events/scheduler-adapter.ts +20 -20
  45. package/server/events/session-store/index.ts +10 -10
  46. package/server/events/task-manager/index.ts +7 -7
  47. package/server/index.ts +59 -65
  48. package/server/system/config.ts +5 -5
  49. package/server/system/credentials.ts +7 -5
  50. package/server/system/env.ts +5 -5
  51. package/server/utils/date.ts +18 -18
  52. package/server/utils/files/atomic.ts +16 -16
  53. package/server/utils/files/html-io.ts +5 -5
  54. package/server/utils/files/image-store.ts +19 -8
  55. package/server/utils/files/journal-io.ts +4 -4
  56. package/server/utils/files/json.ts +5 -5
  57. package/server/utils/files/markdown-store.ts +4 -4
  58. package/server/utils/files/naming.ts +2 -2
  59. package/server/utils/files/reference-dirs-io.ts +3 -3
  60. package/server/utils/files/roles-io.ts +12 -12
  61. package/server/utils/files/safe.ts +14 -14
  62. package/server/utils/files/scheduler-io.ts +5 -5
  63. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  64. package/server/utils/files/session-io.ts +35 -35
  65. package/server/utils/files/spreadsheet-store.ts +7 -7
  66. package/server/utils/files/todos-io.ts +9 -9
  67. package/server/utils/files/user-tasks-io.ts +5 -5
  68. package/server/utils/files/workspace-io.ts +12 -12
  69. package/server/utils/gemini.ts +2 -2
  70. package/server/utils/gitignore.ts +9 -9
  71. package/server/utils/json.ts +5 -5
  72. package/server/utils/logBackgroundError.ts +12 -3
  73. package/server/utils/markdown.ts +5 -5
  74. package/server/utils/port.d.mts +6 -0
  75. package/server/utils/port.mjs +48 -0
  76. package/server/utils/request.ts +12 -6
  77. package/server/utils/spawn.ts +1 -1
  78. package/server/utils/types.ts +2 -2
  79. package/server/workspace/chat-index/indexer.ts +15 -15
  80. package/server/workspace/chat-index/summarizer.ts +4 -4
  81. package/server/workspace/custom-dirs.ts +16 -16
  82. package/server/workspace/journal/archivist.ts +35 -35
  83. package/server/workspace/journal/dailyPass.ts +31 -28
  84. package/server/workspace/journal/diff.ts +2 -2
  85. package/server/workspace/journal/index.ts +4 -4
  86. package/server/workspace/journal/indexFile.ts +29 -25
  87. package/server/workspace/journal/optimizationPass.ts +2 -2
  88. package/server/workspace/journal/state.ts +6 -6
  89. package/server/workspace/paths.ts +3 -3
  90. package/server/workspace/reference-dirs.ts +20 -20
  91. package/server/workspace/roles.ts +6 -6
  92. package/server/workspace/skills/discovery.ts +4 -4
  93. package/server/workspace/skills/parser.ts +6 -6
  94. package/server/workspace/skills/scheduler.ts +3 -3
  95. package/server/workspace/skills/user-tasks.ts +34 -34
  96. package/server/workspace/skills/writer.ts +3 -3
  97. package/server/workspace/sources/arxivDiscovery.ts +10 -10
  98. package/server/workspace/sources/classifier.ts +7 -7
  99. package/server/workspace/sources/fetchers/arxiv.ts +7 -7
  100. package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
  101. package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
  102. package/server/workspace/sources/fetchers/rss.ts +5 -5
  103. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  104. package/server/workspace/sources/interests.ts +12 -12
  105. package/server/workspace/sources/paths.ts +6 -6
  106. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  107. package/server/workspace/sources/pipeline/index.ts +8 -13
  108. package/server/workspace/sources/pipeline/notify.ts +3 -3
  109. package/server/workspace/sources/pipeline/plan.ts +15 -13
  110. package/server/workspace/sources/pipeline/write.ts +5 -5
  111. package/server/workspace/sources/rateLimiter.ts +1 -1
  112. package/server/workspace/sources/registry.ts +16 -16
  113. package/server/workspace/sources/robots.ts +14 -14
  114. package/server/workspace/sources/sourceState.ts +17 -10
  115. package/server/workspace/sources/types.ts +9 -0
  116. package/server/workspace/sources/urls.ts +1 -1
  117. package/server/workspace/tool-trace/classify.ts +4 -4
  118. package/server/workspace/tool-trace/index.ts +1 -1
  119. package/server/workspace/tool-trace/writeSearch.ts +26 -16
  120. package/server/workspace/wiki-backlinks/index.ts +8 -8
  121. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
  122. package/server/workspace/workspace.ts +7 -7
  123. package/src/App.vue +315 -141
  124. package/src/components/CanvasViewToggle.vue +10 -7
  125. package/src/components/ChatInput.vue +67 -33
  126. package/src/components/FileContentHeader.vue +7 -4
  127. package/src/components/FileContentRenderer.vue +20 -6
  128. package/src/components/FileTree.vue +6 -3
  129. package/src/components/FileTreePane.vue +11 -8
  130. package/src/components/FilesView.vue +5 -3
  131. package/src/components/LockStatusPopup.vue +17 -14
  132. package/src/components/NotificationBell.vue +14 -5
  133. package/src/components/NotificationToast.vue +6 -3
  134. package/src/components/PluginLauncher.vue +19 -56
  135. package/src/components/RightSidebar.vue +13 -10
  136. package/src/components/RoleSelector.vue +2 -2
  137. package/src/components/SessionHistoryPanel.vue +38 -34
  138. package/src/components/SessionTabBar.vue +8 -10
  139. package/src/components/SettingsMcpTab.vue +49 -36
  140. package/src/components/SettingsModal.vue +24 -22
  141. package/src/components/SettingsReferenceDirsTab.vue +39 -34
  142. package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
  143. package/src/components/SidebarHeader.vue +25 -4
  144. package/src/components/StackView.vue +4 -1
  145. package/src/components/SuggestionsPanel.vue +7 -4
  146. package/src/components/TodoExplorer.vue +26 -15
  147. package/src/components/ToolResultsPanel.vue +27 -13
  148. package/src/components/todo/TodoAddDialog.vue +19 -14
  149. package/src/components/todo/TodoEditDialog.vue +7 -2
  150. package/src/components/todo/TodoEditPanel.vue +17 -12
  151. package/src/components/todo/TodoKanbanView.vue +10 -5
  152. package/src/components/todo/TodoListView.vue +10 -7
  153. package/src/components/todo/TodoTableView.vue +5 -2
  154. package/src/composables/useAppApi.ts +9 -0
  155. package/src/composables/useClickOutside.ts +2 -2
  156. package/src/composables/useDynamicFavicon.ts +172 -37
  157. package/src/composables/useEventListeners.ts +7 -8
  158. package/src/composables/useFaviconState.ts +13 -2
  159. package/src/composables/useFileSelection.ts +24 -6
  160. package/src/composables/useFreshPluginData.ts +3 -3
  161. package/src/composables/useKeyNavigation.ts +11 -11
  162. package/src/composables/useLayoutMode.ts +32 -0
  163. package/src/composables/useMcpTools.ts +2 -2
  164. package/src/composables/useNotifications.ts +3 -3
  165. package/src/composables/usePdfDownload.ts +4 -4
  166. package/src/composables/usePendingCalls.ts +1 -1
  167. package/src/composables/usePubSub.ts +10 -10
  168. package/src/composables/useRoles.ts +1 -1
  169. package/src/composables/useSandboxStatus.ts +1 -1
  170. package/src/composables/useSessionDerived.ts +3 -3
  171. package/src/composables/useSessionHistory.ts +7 -17
  172. package/src/composables/useSessionSync.ts +8 -8
  173. package/src/composables/useViewLayout.ts +20 -34
  174. package/src/config/roles.ts +2 -2
  175. package/src/lang/de.ts +536 -0
  176. package/src/lang/en.ts +558 -0
  177. package/src/lang/es.ts +543 -0
  178. package/src/lang/fr.ts +536 -0
  179. package/src/lang/ja.ts +536 -0
  180. package/src/lang/ko.ts +540 -0
  181. package/src/lang/pt-BR.ts +534 -0
  182. package/src/lang/zh.ts +537 -0
  183. package/src/lib/vue-i18n.ts +97 -0
  184. package/src/main.ts +2 -0
  185. package/src/plugins/canvas/View.vue +102 -186
  186. package/src/plugins/canvas/definition.ts +0 -8
  187. package/src/plugins/chart/Preview.vue +5 -5
  188. package/src/plugins/chart/View.vue +9 -4
  189. package/src/plugins/manageRoles/Preview.vue +4 -1
  190. package/src/plugins/manageRoles/View.vue +59 -43
  191. package/src/plugins/manageSkills/Preview.vue +8 -3
  192. package/src/plugins/manageSkills/View.vue +29 -25
  193. package/src/plugins/manageSource/Preview.vue +2 -2
  194. package/src/plugins/manageSource/View.vue +73 -52
  195. package/src/plugins/markdown/Preview.vue +1 -1
  196. package/src/plugins/markdown/View.vue +26 -36
  197. package/src/plugins/presentHtml/Preview.vue +1 -1
  198. package/src/plugins/presentHtml/View.vue +7 -4
  199. package/src/plugins/presentHtml/helpers.ts +8 -8
  200. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  201. package/src/plugins/presentMulmoScript/View.vue +40 -30
  202. package/src/plugins/presentMulmoScript/helpers.ts +1 -1
  203. package/src/plugins/scheduler/Preview.vue +13 -10
  204. package/src/plugins/scheduler/TasksTab.vue +57 -28
  205. package/src/plugins/scheduler/View.vue +28 -19
  206. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  207. package/src/plugins/spreadsheet/Preview.vue +8 -3
  208. package/src/plugins/spreadsheet/View.vue +21 -12
  209. package/src/plugins/textResponse/Preview.vue +15 -58
  210. package/src/plugins/textResponse/View.vue +29 -9
  211. package/src/plugins/todo/Preview.vue +13 -8
  212. package/src/plugins/todo/View.vue +38 -24
  213. package/src/plugins/todo/composables/useTodos.ts +5 -5
  214. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  215. package/src/plugins/ui-image/ImageView.vue +7 -4
  216. package/src/plugins/wiki/Preview.vue +10 -7
  217. package/src/plugins/wiki/View.vue +202 -81
  218. package/src/plugins/wiki/helpers.ts +4 -4
  219. package/src/plugins/wiki/route.ts +112 -0
  220. package/src/router/guards.ts +46 -28
  221. package/src/router/index.ts +41 -26
  222. package/src/types/session.ts +4 -3
  223. package/src/types/vue-i18n.d.ts +20 -0
  224. package/src/utils/agent/request.ts +22 -3
  225. package/src/utils/canvas/layoutMode.ts +26 -0
  226. package/src/utils/dom/scrollable.ts +2 -2
  227. package/src/utils/files/expandedDirs.ts +1 -1
  228. package/src/utils/files/sortChildren.ts +6 -6
  229. package/src/utils/format/frontmatter.ts +6 -6
  230. package/src/utils/image/cacheBust.ts +16 -0
  231. package/src/utils/image/resolve.ts +16 -0
  232. package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
  233. package/src/utils/markdown/extractFirstH1.ts +2 -2
  234. package/src/utils/path/relativeLink.ts +15 -15
  235. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  236. package/src/utils/role/icon.ts +2 -2
  237. package/src/utils/role/merge.ts +2 -2
  238. package/src/utils/role/plugins.ts +1 -1
  239. package/src/utils/session/sessionFactory.ts +2 -2
  240. package/src/utils/session/sessionHelpers.ts +2 -2
  241. package/src/utils/tools/dedup.ts +4 -4
  242. package/src/utils/tools/result.ts +3 -3
  243. package/src/utils/types.ts +2 -2
  244. package/src/vite-env.d.ts +9 -0
  245. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  246. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  247. package/client/assets/index-KNLBjwuh.css +0 -1
  248. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  249. package/src/composables/useCanvasViewMode.ts +0 -121
  250. package/src/utils/canvas/viewMode.ts +0 -46
  251. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -2,10 +2,10 @@
2
2
  <button
3
3
  class="flex items-center justify-center w-8 h-8 rounded transition-colors hover:bg-gray-100"
4
4
  :class="isStack ? 'text-blue-500' : 'text-gray-400 hover:text-gray-700'"
5
- :title="isStack ? 'Stack view · click to switch to Single (⌘1)' : 'Single view · click to switch to Stack (⌘2)'"
6
- :aria-label="isStack ? 'Switch to Single view' : 'Switch to Stack view'"
5
+ :title="isStack ? t('canvasViewToggle.stackViewTooltip') : t('canvasViewToggle.singleViewTooltip')"
6
+ :aria-label="isStack ? t('canvasViewToggle.switchToSingle') : t('canvasViewToggle.switchToStack')"
7
7
  :data-testid="`canvas-view-toggle-${modelValue}`"
8
- @click="emit('update:modelValue', isStack ? CANVAS_VIEW.single : CANVAS_VIEW.stack)"
8
+ @click="emit('update:modelValue', isStack ? LAYOUT_MODES.single : LAYOUT_MODES.stack)"
9
9
  >
10
10
  <span class="material-icons text-lg">view_agenda</span>
11
11
  </button>
@@ -13,15 +13,18 @@
13
13
 
14
14
  <script setup lang="ts">
15
15
  import { computed } from "vue";
16
- import { CANVAS_VIEW, type CanvasViewMode } from "../utils/canvas/viewMode";
16
+ import { useI18n } from "vue-i18n";
17
+ import { LAYOUT_MODES, type LayoutMode } from "../utils/canvas/layoutMode";
18
+
19
+ const { t } = useI18n();
17
20
 
18
21
  const props = defineProps<{
19
- modelValue: CanvasViewMode;
22
+ modelValue: LayoutMode;
20
23
  }>();
21
24
 
22
25
  const emit = defineEmits<{
23
- "update:modelValue": [mode: CanvasViewMode];
26
+ "update:modelValue": [mode: LayoutMode];
24
27
  }>();
25
28
 
26
- const isStack = computed(() => props.modelValue === CANVAS_VIEW.stack);
29
+ const isStack = computed(() => props.modelValue === LAYOUT_MODES.stack);
27
30
  </script>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="p-4 border-t border-gray-200" @dragover.prevent @drop="onDropFile">
2
+ <div class="p-2 border-t border-gray-200" @dragover.prevent @drop="onDropFile">
3
3
  <div v-if="fileError" class="mb-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-1.5" data-testid="file-error">
4
4
  {{ fileError }}
5
5
  </div>
@@ -15,43 +15,55 @@
15
15
  ref="textarea"
16
16
  :value="modelValue"
17
17
  data-testid="user-input"
18
- placeholder="Type a task..."
19
- :rows="inputFocused ? 8 : 2"
20
- class="flex-1 bg-white border border-gray-300 rounded px-3 py-2 text-sm text-gray-900 placeholder-gray-400 disabled:opacity-50 disabled:cursor-not-allowed resize-none transition-all duration-200"
21
- :class="inputFocused ? 'ring-2 ring-blue-300' : ''"
18
+ :placeholder="t('chatInput.placeholder')"
19
+ rows="2"
20
+ class="flex-1 bg-white border border-gray-300 rounded px-3 py-2 text-sm text-gray-900 placeholder-gray-400 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
22
21
  :disabled="isRunning"
23
22
  @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
24
- @focus="inputFocused = true"
25
23
  @compositionstart="imeEnter.onCompositionStart"
26
24
  @compositionend="imeEnter.onCompositionEnd"
27
25
  @keydown="imeEnter.onKeydown"
28
- @blur="onInputBlur"
26
+ @blur="imeEnter.onBlur"
29
27
  @paste="onPasteFile"
30
28
  />
31
29
  <div class="flex flex-col gap-1">
32
30
  <button
33
31
  data-testid="send-btn"
34
- class="bg-blue-600 hover:bg-blue-700 text-white rounded px-3 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
32
+ 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"
35
33
  :disabled="isRunning"
36
34
  @click="emit('send')"
37
35
  >
38
- <span class="material-icons text-base">send</span>
36
+ <span class="material-icons text-base leading-none">send</span>
37
+ </button>
38
+ <button
39
+ data-testid="attach-file-btn"
40
+ class="text-gray-400 hover:text-gray-600 rounded w-8 h-8 flex items-center justify-center"
41
+ :title="t('chatInput.attachFile')"
42
+ @click="openFilePicker"
43
+ >
44
+ <span class="material-icons text-base leading-none">attach_file</span>
39
45
  </button>
40
46
  <button
41
47
  data-testid="expand-input-btn"
42
- class="text-gray-400 hover:text-gray-600 rounded px-3 py-1 text-sm"
43
- title="Expand editor"
48
+ class="text-gray-400 hover:text-gray-600 rounded w-8 h-8 flex items-center justify-center"
49
+ :title="t('chatInput.expandEditor')"
44
50
  @click="openExpandedEditor"
45
51
  >
46
- <span class="material-icons text-base">open_in_full</span>
52
+ <span class="material-icons text-base leading-none">open_in_full</span>
47
53
  </button>
48
54
  </div>
49
55
  </div>
50
56
 
57
+ <!-- Hidden file input driven by the attach button. The `accept`
58
+ filter matches ACCEPTED_MIME_PREFIXES/_EXACT below; the change
59
+ handler routes through the same readAttachmentFile() used by
60
+ drop + paste, so all three paths behave identically. -->
61
+ <input ref="fileInput" type="file" class="hidden" :accept="fileInputAccept" data-testid="file-input" @change="onFilePicked" />
62
+
51
63
  <div v-if="expandedEditorOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="closeExpandedEditor">
52
64
  <div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 flex flex-col" style="max-height: 80vh">
53
65
  <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
54
- <h3 class="text-sm font-semibold text-gray-700">Compose message</h3>
66
+ <h3 class="text-sm font-semibold text-gray-700">{{ t("chatInput.composeMessage") }}</h3>
55
67
  <button class="text-gray-400 hover:text-gray-600" @click="closeExpandedEditor">
56
68
  <span class="material-icons text-base">close</span>
57
69
  </button>
@@ -60,7 +72,7 @@
60
72
  ref="expandedTextarea"
61
73
  :value="modelValue"
62
74
  data-testid="expanded-input"
63
- placeholder="Type a task..."
75
+ :placeholder="t('chatInput.placeholder')"
64
76
  class="flex-1 px-4 py-3 text-sm text-gray-900 placeholder-gray-400 resize-none focus:outline-none"
65
77
  style="min-height: 300px"
66
78
  @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@@ -68,16 +80,18 @@
68
80
  @keydown.ctrl.enter="sendFromExpanded"
69
81
  ></textarea>
70
82
  <div class="flex items-center justify-between px-4 py-3 border-t border-gray-200">
71
- <p class="text-xs text-gray-400">Cmd+Enter to send</p>
83
+ <p class="text-xs text-gray-400">{{ t("chatInput.sendHint") }}</p>
72
84
  <div class="flex gap-2">
73
- <button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="closeExpandedEditor">Cancel</button>
85
+ <button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="closeExpandedEditor">
86
+ {{ t("common.cancel") }}
87
+ </button>
74
88
  <button
75
89
  class="px-3 py-1.5 text-sm rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40"
76
90
  :disabled="isRunning"
77
91
  data-testid="expanded-send-btn"
78
92
  @click="sendFromExpanded"
79
93
  >
80
- Send
94
+ {{ t("chatInput.send") }}
81
95
  </button>
82
96
  </div>
83
97
  </div>
@@ -88,9 +102,12 @@
88
102
 
89
103
  <script setup lang="ts">
90
104
  import { nextTick, ref } from "vue";
105
+ import { useI18n } from "vue-i18n";
91
106
  import ChatAttachmentPreview from "./ChatAttachmentPreview.vue";
92
107
  import { useImeAwareEnter } from "../composables/useImeAwareEnter";
93
108
 
109
+ const { t } = useI18n();
110
+
94
111
  export interface PastedFile {
95
112
  dataUrl: string;
96
113
  name: string;
@@ -111,9 +128,9 @@ const emit = defineEmits<{
111
128
 
112
129
  const textarea = ref<HTMLTextAreaElement | null>(null);
113
130
  const expandedTextarea = ref<HTMLTextAreaElement | null>(null);
114
- const inputFocused = ref(false);
115
131
  const expandedEditorOpen = ref(false);
116
132
  const fileError = ref<string | null>(null);
133
+ const fileInput = ref<HTMLInputElement | null>(null);
117
134
 
118
135
  const MAX_ATTACH_BYTES = 30 * 1024 * 1024;
119
136
 
@@ -129,16 +146,28 @@ const ACCEPTED_MIME_EXACT = new Set([
129
146
  "application/vnd.openxmlformats-officedocument.presentationml.presentation",
130
147
  ]);
131
148
 
149
+ // `accept` attribute for the hidden <input type="file"> that the
150
+ // paperclip button drives. Prefixes like `image/*` and `text/*` are
151
+ // expanded by the browser's native file picker; exact MIME entries
152
+ // are passed through. Drop + paste still accept the same set via the
153
+ // isAcceptedType() check below, so all three entry points stay in sync.
154
+ const fileInputAccept = [...ACCEPTED_MIME_PREFIXES.map((prefix) => `${prefix}*`), ...ACCEPTED_MIME_EXACT].join(",");
155
+
132
156
  function isAcceptedType(mime: string): boolean {
133
- return ACCEPTED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || ACCEPTED_MIME_EXACT.has(mime);
157
+ return ACCEPTED_MIME_PREFIXES.some((prefix) => mime.startsWith(prefix)) || ACCEPTED_MIME_EXACT.has(mime);
134
158
  }
135
159
 
136
160
  function readAttachmentFile(file: File): void {
137
161
  fileError.value = null;
138
- if (!isAcceptedType(file.type)) return;
162
+ if (!isAcceptedType(file.type)) {
163
+ // Previously returned silently. That left the user wondering whether
164
+ // the drop/paste registered at all — #499.
165
+ fileError.value = t("chatInput.unsupportedFileType");
166
+ return;
167
+ }
139
168
  if (file.size > MAX_ATTACH_BYTES) {
140
169
  const sizeMB = (file.size / 1024 / 1024).toFixed(1);
141
- fileError.value = `File too large (${sizeMB} MB). Maximum is 30 MB.`;
170
+ fileError.value = t("chatInput.fileTooLarge", { sizeMB });
142
171
  return;
143
172
  }
144
173
  const reader = new FileReader();
@@ -154,14 +183,14 @@ function readAttachmentFile(file: File): void {
154
183
  reader.readAsDataURL(file);
155
184
  }
156
185
 
157
- function onPasteFile(e: ClipboardEvent): void {
158
- const items = e.clipboardData?.items;
186
+ function onPasteFile(event: ClipboardEvent): void {
187
+ const items = event.clipboardData?.items;
159
188
  if (!items) return;
160
189
  for (const item of items) {
161
190
  if (isAcceptedType(item.type)) {
162
191
  const file = item.getAsFile();
163
192
  if (file) {
164
- e.preventDefault();
193
+ event.preventDefault();
165
194
  readAttachmentFile(file);
166
195
  return;
167
196
  }
@@ -169,21 +198,26 @@ function onPasteFile(e: ClipboardEvent): void {
169
198
  }
170
199
  }
171
200
 
172
- function onDropFile(e: DragEvent): void {
173
- e.preventDefault();
174
- const file = e.dataTransfer?.files[0];
201
+ function onDropFile(event: DragEvent): void {
202
+ event.preventDefault();
203
+ const file = event.dataTransfer?.files[0];
175
204
  if (file) readAttachmentFile(file);
176
205
  }
177
206
 
178
- const imeEnter = useImeAwareEnter(() => emit("send"));
207
+ function openFilePicker(): void {
208
+ fileInput.value?.click();
209
+ }
179
210
 
180
- function onInputBlur(): void {
181
- imeEnter.onBlur();
182
- setTimeout(() => {
183
- inputFocused.value = false;
184
- }, 150);
211
+ function onFilePicked(event: Event): void {
212
+ const input = event.target as HTMLInputElement;
213
+ const file = input.files?.[0];
214
+ if (file) readAttachmentFile(file);
215
+ // Reset so selecting the same file twice in a row still fires @change.
216
+ input.value = "";
185
217
  }
186
218
 
219
+ const imeEnter = useImeAwareEnter(() => emit("send"));
220
+
187
221
  function openExpandedEditor(): void {
188
222
  expandedEditorOpen.value = true;
189
223
  nextTick(() => expandedTextarea.value?.focus());
@@ -6,17 +6,17 @@
6
6
  <button
7
7
  v-if="isMarkdown"
8
8
  class="ml-auto shrink-0 px-2 py-0.5 rounded border border-gray-200 text-gray-600 hover:bg-gray-100 font-sans"
9
- :title="mdRawMode ? 'Show rendered Markdown' : 'Show raw source'"
9
+ :title="mdRawMode ? t('fileContentHeader.showRendered') : t('fileContentHeader.showRaw')"
10
10
  @click="emit('toggleMdRaw')"
11
11
  >
12
- {{ mdRawMode ? "Rendered" : "Raw" }}
12
+ {{ mdRawMode ? t("fileContentHeader.rendered") : t("fileContentHeader.raw") }}
13
13
  </button>
14
14
  <button
15
15
  type="button"
16
16
  class="shrink-0 px-1 py-0.5 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100"
17
17
  :class="{ 'ml-auto': !isMarkdown }"
18
- title="Close file"
19
- aria-label="Close file"
18
+ :title="t('fileContentHeader.closeFile')"
19
+ :aria-label="t('fileContentHeader.closeFile')"
20
20
  data-testid="close-file-btn"
21
21
  @click="emit('deselect')"
22
22
  >
@@ -26,8 +26,11 @@
26
26
  </template>
27
27
 
28
28
  <script setup lang="ts">
29
+ import { useI18n } from "vue-i18n";
29
30
  import { formatDateTime } from "../utils/format/date";
30
31
 
32
+ const { t } = useI18n();
33
+
31
34
  defineProps<{
32
35
  selectedPath: string | null;
33
36
  size: number | null;
@@ -1,10 +1,10 @@
1
1
  <template>
2
2
  <div class="flex-1 overflow-auto min-h-0">
3
- <div v-if="!selectedPath" class="h-full flex items-center justify-center text-gray-400 text-sm">Select a file</div>
3
+ <div v-if="!selectedPath" class="h-full flex items-center justify-center text-gray-400 text-sm">{{ t("fileContentRenderer.selectFile") }}</div>
4
4
  <div v-else-if="contentError" class="p-4 text-sm text-red-600">
5
5
  {{ contentError }}
6
6
  </div>
7
- <div v-else-if="contentLoading" class="p-4 text-sm text-gray-400">Loading...</div>
7
+ <div v-else-if="contentLoading" class="p-4 text-sm text-gray-400">{{ t("common.loading") }}</div>
8
8
  <template v-else-if="content">
9
9
  <template v-if="content.kind === 'text'">
10
10
  <!-- Scheduler items.json: render with the scheduler plugin's
@@ -55,7 +55,13 @@
55
55
  to restrict script loads to a vetted CDN whitelist +
56
56
  inline; connect-src is `'none'` so the page can't
57
57
  phone home. See src/utils/html/previewCsp.ts. -->
58
- <iframe v-else-if="isHtml" :srcdoc="sandboxedHtml" class="w-full h-full border-0" sandbox="allow-scripts" title="HTML preview" />
58
+ <iframe
59
+ v-else-if="isHtml"
60
+ :srcdoc="sandboxedHtml"
61
+ class="w-full h-full border-0"
62
+ sandbox="allow-scripts"
63
+ :title="t('fileContentRenderer.htmlPreview')"
64
+ />
59
65
  <!-- JSON: pretty-printed with simple syntax coloring. Fall
60
66
  back to raw content if the file is malformed. -->
61
67
  <pre v-else-if="isJson" class="p-4 text-xs whitespace-pre-wrap font-mono text-gray-800"><span
@@ -66,7 +72,7 @@
66
72
  <!-- JSONL / NDJSON: one pretty-printed + colored record per line -->
67
73
  <div v-else-if="isJsonl" class="p-4 space-y-2">
68
74
  <div v-for="(line, i) in jsonlLines" :key="i" class="rounded border bg-gray-50 p-3" :class="line.parseError ? 'border-red-300' : 'border-gray-200'">
69
- <div v-if="line.parseError" class="text-xs text-red-600 mb-1 font-sans">parse error</div>
75
+ <div v-if="line.parseError" class="text-xs text-red-600 mb-1 font-sans">{{ t("fileContentRenderer.parseError") }}</div>
70
76
  <pre class="text-xs font-mono text-gray-800 whitespace-pre-wrap"><span
71
77
  v-for="(tok, j) in line.tokens"
72
78
  :key="j"
@@ -82,7 +88,12 @@
82
88
  <img :src="rawUrl(selectedPath)" :alt="selectedPath" class="max-w-full max-h-full object-contain" />
83
89
  </div>
84
90
  <!-- PDF -->
85
- <iframe v-else-if="content.kind === 'pdf' && selectedPath" :src="rawUrl(selectedPath)" class="w-full h-full border-0" title="PDF preview" />
91
+ <iframe
92
+ v-else-if="content.kind === 'pdf' && selectedPath"
93
+ :src="rawUrl(selectedPath)"
94
+ class="w-full h-full border-0"
95
+ :title="t('fileContentRenderer.pdfPreview')"
96
+ />
86
97
  <!-- Audio -->
87
98
  <div v-else-if="content.kind === 'audio' && selectedPath" class="h-full flex items-center justify-center p-4">
88
99
  <audio :key="selectedPath" :src="rawUrl(selectedPath)" controls preload="metadata" class="w-full max-w-2xl" />
@@ -93,13 +104,14 @@
93
104
  </div>
94
105
  <!-- Binary or too-large -->
95
106
  <div v-else class="p-4 text-sm text-gray-500">
96
- {{ "message" in content ? content.message : "" }}
107
+ <template v-if="'message' in content">{{ content.message }}</template>
97
108
  </div>
98
109
  </template>
99
110
  </div>
100
111
  </template>
101
112
 
102
113
  <script setup lang="ts">
114
+ import { useI18n } from "vue-i18n";
103
115
  import TextResponseView from "../plugins/textResponse/View.vue";
104
116
  import SchedulerView from "../plugins/scheduler/View.vue";
105
117
  import TodoExplorer from "./TodoExplorer.vue";
@@ -114,6 +126,8 @@ import type { Frontmatter } from "../utils/format/frontmatter";
114
126
  import { rewriteMarkdownImageRefs } from "../utils/image/rewriteMarkdownImageRefs";
115
127
  import { API_ROUTES } from "../config/apiRoutes";
116
128
 
129
+ const { t } = useI18n();
130
+
117
131
  const props = defineProps<{
118
132
  selectedPath: string | null;
119
133
  content: FileContent | null;
@@ -7,7 +7,7 @@
7
7
  @click="onToggle"
8
8
  >
9
9
  <span class="material-icons text-sm text-gray-400 shrink-0">{{ expanded ? "folder_open" : "folder" }}</span>
10
- <span class="text-gray-700 truncate">{{ node.name || "(workspace)" }}</span>
10
+ <span class="text-gray-700 truncate">{{ node.name || t("fileTree.workspace") }}</span>
11
11
  </button>
12
12
  <button
13
13
  v-else
@@ -19,13 +19,13 @@
19
19
  >
20
20
  <span class="material-icons text-sm text-gray-400 shrink-0">description</span>
21
21
  <span class="truncate">{{ node.name }}</span>
22
- <span v-if="isRecent" class="ml-auto w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" title="Recently changed" />
22
+ <span v-if="isRecent" class="ml-auto w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" :title="t('fileTree.recentlyChanged')" />
23
23
  </button>
24
24
  <div v-if="node.type === 'dir' && expanded" class="pl-4">
25
25
  <!-- Loading state: children not in the cache yet. Rendered
26
26
  once per dir so a slow network shows where the wait is,
27
27
  not as a global overlay. -->
28
- <div v-if="loadingChildren" class="px-2 py-1 text-xs text-gray-400">Loading...</div>
28
+ <div v-if="loadingChildren" class="px-2 py-1 text-xs text-gray-400">{{ t("common.loading") }}</div>
29
29
  <FileTree
30
30
  v-for="child in loadedChildren"
31
31
  :key="child.path"
@@ -43,10 +43,13 @@
43
43
 
44
44
  <script setup lang="ts">
45
45
  import { computed, watch } from "vue";
46
+ import { useI18n } from "vue-i18n";
46
47
  import { useExpandedDirs } from "../composables/useExpandedDirs";
47
48
  import { sortChildren } from "../utils/files/sortChildren";
48
49
  import type { FileSortMode } from "../composables/useFileSortMode";
49
50
 
51
+ const { t } = useI18n();
52
+
50
53
  // TreeNode lives in src/types/fileTree.ts so .ts composables can
51
54
  // import it without depending on a .vue module. Re-export here so
52
55
  // existing `import { TreeNode } from "./FileTree.vue"` keeps working.
@@ -1,34 +1,34 @@
1
1
  <template>
2
2
  <div class="w-72 flex-shrink-0 border-r border-gray-200 overflow-y-auto p-2 bg-gray-50">
3
3
  <div class="flex justify-end items-center gap-2 px-1 pb-1 text-xs">
4
- <span class="text-gray-400">Sort:</span>
4
+ <span class="text-gray-400">{{ t("fileTreePane.sort") }}</span>
5
5
  <button
6
6
  type="button"
7
7
  class="px-2 py-0.5 rounded transition-colors"
8
8
  :class="sortMode === 'name' ? 'bg-blue-100 text-blue-700 font-medium' : 'text-gray-500 hover:bg-gray-200'"
9
9
  :aria-pressed="sortMode === 'name'"
10
- title="Sort by name"
10
+ :title="t('fileTreePane.sortByName')"
11
11
  data-testid="file-sort-name"
12
12
  @click="emit('update:sortMode', 'name')"
13
13
  >
14
- Name
14
+ {{ t("fileTreePane.name") }}
15
15
  </button>
16
16
  <button
17
17
  type="button"
18
18
  class="px-2 py-0.5 rounded transition-colors"
19
19
  :class="sortMode === 'recent' ? 'bg-blue-100 text-blue-700 font-medium' : 'text-gray-500 hover:bg-gray-200'"
20
20
  :aria-pressed="sortMode === 'recent'"
21
- title="Sort by modified date (newest first)"
21
+ :title="t('fileTreePane.sortByRecent')"
22
22
  data-testid="file-sort-recent"
23
23
  @click="emit('update:sortMode', 'recent')"
24
24
  >
25
- Recent
25
+ {{ t("fileTreePane.recent") }}
26
26
  </button>
27
27
  </div>
28
28
  <div v-if="treeError" class="p-2 text-xs text-red-600">
29
29
  {{ treeError }}
30
30
  </div>
31
- <div v-else-if="!rootNode" class="p-2 text-xs text-gray-400">Loading...</div>
31
+ <div v-else-if="!rootNode" class="p-2 text-xs text-gray-400">{{ t("common.loading") }}</div>
32
32
  <FileTree
33
33
  v-else
34
34
  :node="rootNode"
@@ -41,8 +41,8 @@
41
41
  />
42
42
  <template v-if="refRoots.length > 0">
43
43
  <div class="mt-2 pt-2 border-t border-gray-200 px-1 mb-1 flex items-center gap-1">
44
- <span class="text-[10px] font-semibold text-gray-400 uppercase">Reference</span>
45
- <span class="text-[9px] px-1 py-0.5 rounded bg-blue-100 text-blue-600">RO</span>
44
+ <span class="text-[10px] font-semibold text-gray-400 uppercase">{{ t("fileTreePane.reference") }}</span>
45
+ <span class="text-[9px] px-1 py-0.5 rounded bg-blue-100 text-blue-600">{{ t("fileTreePane.readOnlyBadge") }}</span>
46
46
  </div>
47
47
  <FileTree
48
48
  v-for="refNode in refRoots"
@@ -60,10 +60,13 @@
60
60
  </template>
61
61
 
62
62
  <script setup lang="ts">
63
+ import { useI18n } from "vue-i18n";
63
64
  import FileTree from "./FileTree.vue";
64
65
  import type { TreeNode } from "../types/fileTree";
65
66
  import type { FileSortMode } from "../composables/useFileSortMode";
66
67
 
68
+ const { t } = useI18n();
69
+
67
70
  defineProps<{
68
71
  rootNode: TreeNode | null;
69
72
  refRoots: TreeNode[];
@@ -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/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) {
@@ -4,7 +4,7 @@
4
4
  ref="button"
5
5
  data-testid="sandbox-lock-button"
6
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)'"
7
+ :title="sandboxEnabled ? t('lockStatusPopup.sandboxEnabledTooltip') : t('lockStatusPopup.noSandboxTooltip')"
8
8
  @click="emit('update:open', !open)"
9
9
  >
10
10
  <span class="material-icons">{{ sandboxEnabled ? "lock" : "lock_open" }}</span>
@@ -13,36 +13,36 @@
13
13
  <p class="mb-2" :class="sandboxEnabled ? 'text-green-800' : 'text-amber-500'">
14
14
  <template v-if="sandboxEnabled">
15
15
  <span class="material-icons text-xs align-middle mr-1">lock</span>
16
- <strong>Sandbox enabled:</strong> Docker is running. Filesystem access is isolated.
16
+ <strong>{{ t("lockStatusPopup.sandboxEnabledLabel") }}</strong> {{ t("lockStatusPopup.sandboxEnabledBody") }}
17
17
  </template>
18
18
  <template v-else>
19
19
  <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.
20
+ <strong>{{ t("lockStatusPopup.noSandboxLabel") }}</strong> {{ t("lockStatusPopup.noSandboxBodyPrefix") }}
21
+ <a href="https://www.docker.com/products/docker-desktop/" target="_blank" class="underline">{{ t("lockStatusPopup.dockerDesktop") }}</a>
22
+ {{ t("lockStatusPopup.noSandboxBodySuffix") }}
23
23
  </template>
24
24
  </p>
25
25
  <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>
26
+ <p class="text-gray-400 mb-1">{{ t("lockStatusPopup.hostCredentials") }}</p>
27
+ <p v-if="sandboxStatus === null" class="text-gray-400 italic" data-testid="sandbox-credentials-loading">{{ t("lockStatusPopup.credsLoading") }}</p>
28
28
  <template v-else>
29
29
  <p data-testid="sandbox-credentials-ssh">
30
30
  <span class="mr-1">🔑</span>
31
- <span class="text-gray-500">SSH agent:</span>
31
+ <span class="text-gray-500">{{ t("lockStatusPopup.sshAgent") }}</span>
32
32
  <span :class="sandboxStatus.sshAgent ? 'text-green-700' : 'text-gray-400'" class="ml-1">
33
- {{ sandboxStatus.sshAgent ? "forwarded" : "not forwarded" }}
33
+ {{ sandboxStatus.sshAgent ? t("lockStatusPopup.forwarded") : t("lockStatusPopup.notForwarded") }}
34
34
  </span>
35
35
  </p>
36
36
  <p data-testid="sandbox-credentials-mounts">
37
37
  <span class="mr-1">📁</span>
38
- <span class="text-gray-500">Mounted configs:</span>
38
+ <span class="text-gray-500">{{ t("lockStatusPopup.mountedConfigs") }}</span>
39
39
  <span :class="sandboxStatus.mounts.length > 0 ? 'text-green-700' : 'text-gray-400'" class="ml-1">
40
- {{ sandboxStatus.mounts.length > 0 ? sandboxStatus.mounts.join(", ") : "none" }}
40
+ {{ sandboxStatus.mounts.length > 0 ? sandboxStatus.mounts.join(", ") : t("lockStatusPopup.none") }}
41
41
  </span>
42
42
  </p>
43
43
  </template>
44
44
  </div>
45
- <p class="text-gray-400 mb-1">Test sandbox isolation:</p>
45
+ <p class="text-gray-400 mb-1">{{ t("lockStatusPopup.testIsolation") }}</p>
46
46
  <div class="flex flex-col gap-1">
47
47
  <button
48
48
  v-for="q in SANDBOX_TEST_QUERIES"
@@ -60,8 +60,11 @@
60
60
 
61
61
  <script setup lang="ts">
62
62
  import { ref, watch } from "vue";
63
+ import { useI18n } from "vue-i18n";
63
64
  import { useSandboxStatus } from "../composables/useSandboxStatus";
64
65
 
66
+ const { t } = useI18n();
67
+
65
68
  const props = defineProps<{
66
69
  sandboxEnabled: boolean;
67
70
  open: boolean;
@@ -104,8 +107,8 @@ watch(
104
107
  },
105
108
  );
106
109
 
107
- function onTestQuery(q: string): void {
110
+ function onTestQuery(query: string): void {
108
111
  emit("update:open", false);
109
- emit("testQuery", q);
112
+ emit("testQuery", query);
110
113
  }
111
114
  </script>
@@ -1,10 +1,12 @@
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
 
9
+ const { t } = useI18n();
8
10
  const { notifications, unreadCount, markAllRead, dismiss } = useNotifications();
9
11
  const open = ref(false);
10
12
  const rootRef = ref<HTMLElement | null>(null);
@@ -70,7 +72,12 @@ function handleDismiss(event: Event, notificationId: string): void {
70
72
  <template>
71
73
  <div ref="rootRef" class="relative">
72
74
  <!-- Bell button -->
73
- <button class="relative text-gray-400 hover:text-gray-700" data-testid="notification-bell" aria-label="Notifications" @click="toggle">
75
+ <button
76
+ class="relative text-gray-400 hover:text-gray-700"
77
+ data-testid="notification-bell"
78
+ :aria-label="t('notificationBell.notifications')"
79
+ @click="toggle"
80
+ >
74
81
  <span class="material-icons">notifications</span>
75
82
  <span
76
83
  v-if="unreadCount > 0"
@@ -89,12 +96,14 @@ function handleDismiss(event: Event, notificationId: string): void {
89
96
  >
90
97
  <!-- Header -->
91
98
  <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>
99
+ <span class="text-sm font-semibold text-gray-700">{{ t("notificationBell.notifications") }}</span>
100
+ <button class="text-xs text-blue-500 hover:text-blue-700" data-testid="notification-mark-all-read" @click="markAllRead">
101
+ {{ t("notificationBell.markAllRead") }}
102
+ </button>
94
103
  </div>
95
104
 
96
105
  <!-- Empty state -->
97
- <div v-if="notifications.length === 0" class="py-8 text-center text-sm text-gray-400">No notifications</div>
106
+ <div v-if="notifications.length === 0" class="py-8 text-center text-sm text-gray-400">{{ t("notificationBell.noNotifications") }}</div>
98
107
 
99
108
  <!-- Items -->
100
109
  <div v-else>
@@ -121,7 +130,7 @@ function handleDismiss(event: Event, notificationId: string): void {
121
130
  {{ formatTime(n.firedAt) }}
122
131
  </p>
123
132
  </div>
124
- <button class="text-gray-300 hover:text-gray-500 shrink-0 mt-0.5" aria-label="Dismiss" @click="handleDismiss($event, n.id)">
133
+ <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
134
  <span class="material-icons text-sm">close</span>
126
135
  </button>
127
136
  </div>