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.
- package/bin/mulmoclaude.js +7 -24
- package/client/assets/html2canvas-Cx501zZr-DiKaqnKs.js +5 -0
- package/client/assets/{index-eHWB79u5.js → index-C94GcmNa.js} +203 -198
- package/client/assets/index-CY-WpQUm.css +2 -0
- package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-5ipqh8Pe.js} +5 -5
- package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
- package/client/index.html +2 -4
- package/package.json +17 -15
- package/server/agent/attachmentConverter.ts +2 -2
- package/server/agent/backend/claude-code.ts +170 -0
- package/server/agent/backend/index.ts +14 -0
- package/server/agent/backend/types.ts +65 -0
- package/server/agent/index.ts +31 -159
- package/server/agent/mcp-server.ts +88 -10
- package/server/agent/mcp-tools/index.ts +8 -7
- package/server/agent/mcp-tools/notify.ts +76 -0
- package/server/agent/mcp-tools/x.ts +12 -2
- package/server/agent/plugin-names.ts +10 -4
- package/server/agent/prompt.ts +187 -26
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +3 -3
- package/server/api/auth/bearerAuth.ts +3 -3
- package/server/api/auth/token.ts +2 -2
- package/server/api/routes/agent.ts +99 -4
- package/server/api/routes/chart.ts +13 -0
- package/server/api/routes/chat-index.ts +2 -1
- package/server/api/routes/config.ts +35 -8
- package/server/api/routes/files.ts +75 -24
- package/server/api/routes/html.ts +15 -2
- package/server/api/routes/image.ts +75 -20
- package/server/api/routes/mulmo-script.ts +33 -31
- package/server/api/routes/news.ts +146 -0
- package/server/api/routes/notifications.ts +58 -2
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/plugins.ts +73 -91
- package/server/api/routes/presentHtml.ts +9 -0
- package/server/api/routes/roles.ts +12 -2
- package/server/api/routes/scheduler.ts +20 -11
- package/server/api/routes/schedulerTasks.ts +58 -21
- package/server/api/routes/sessions.ts +15 -4
- package/server/api/routes/sessionsCursor.ts +4 -4
- package/server/api/routes/skills.ts +26 -5
- package/server/api/routes/sources.ts +8 -7
- package/server/api/routes/todos.ts +30 -0
- package/server/api/routes/todosColumnsHandlers.ts +13 -27
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki/frontmatter.ts +86 -0
- package/server/api/routes/wiki.ts +335 -75
- package/server/api/sandboxStatus.ts +1 -1
- package/server/events/notifications.ts +32 -8
- package/server/events/pub-sub/index.ts +3 -3
- package/server/events/relay-client.ts +26 -16
- package/server/events/resolveRelayBridgeOptions.ts +125 -0
- package/server/index.ts +72 -49
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +15 -5
- package/server/system/macosNotify.ts +152 -0
- package/server/utils/errors.ts +11 -2
- package/server/utils/fetch.ts +54 -0
- package/server/utils/files/atomic.ts +18 -17
- package/server/utils/files/image-store.ts +19 -13
- package/server/utils/files/journal-io.ts +2 -2
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-image-fill.ts +131 -0
- package/server/utils/files/markdown-store.ts +22 -6
- package/server/utils/files/naming.ts +20 -10
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +4 -4
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/spreadsheet-store.ts +15 -10
- package/server/utils/files/workspace-io.ts +12 -12
- package/server/utils/gemini.ts +30 -4
- package/server/utils/gitignore.ts +9 -9
- package/server/utils/id.ts +40 -8
- package/server/utils/json.ts +5 -5
- package/server/utils/logBackgroundError.ts +12 -3
- package/server/utils/logPreview.ts +24 -0
- package/server/utils/markdown.ts +5 -5
- package/server/utils/port.d.mts +6 -0
- package/server/utils/port.mjs +48 -0
- package/server/utils/promptMeta.ts +32 -0
- package/server/utils/request.ts +12 -6
- package/server/utils/slug.ts +65 -4
- package/server/utils/spawn.ts +1 -1
- package/server/utils/types.ts +2 -2
- package/server/workspace/chat-index/index.ts +1 -1
- package/server/workspace/chat-index/summarizer.ts +5 -5
- package/server/workspace/custom-dirs.ts +5 -5
- package/server/workspace/helps/gemini.md +57 -0
- package/server/workspace/helps/index.md +2 -1
- package/server/workspace/helps/sources.md +42 -0
- package/server/workspace/helps/wiki.md +40 -5
- package/server/workspace/journal/archivist-cli.ts +121 -0
- package/server/workspace/journal/{archivist.ts → archivist-schemas.ts} +12 -120
- package/server/workspace/journal/dailyPass.ts +78 -38
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +56 -5
- package/server/workspace/journal/memoryExtractor.ts +1 -1
- package/server/workspace/journal/optimizationPass.ts +4 -5
- package/server/workspace/journal/paths.ts +8 -24
- package/server/workspace/journal/state.ts +18 -8
- package/server/workspace/news/reader.ts +248 -0
- package/server/workspace/paths.ts +4 -3
- package/server/workspace/reference-dirs.ts +3 -3
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +5 -4
- package/server/workspace/skills/user-tasks.ts +3 -2
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +2 -2
- package/server/workspace/sources/classifier.ts +1 -1
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +3 -3
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +59 -13
- package/server/workspace/sources/pipeline/index.ts +59 -7
- package/server/workspace/sources/pipeline/notify.ts +13 -5
- package/server/workspace/sources/pipeline/plan.ts +11 -9
- package/server/workspace/sources/pipeline/summarize.ts +1 -1
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/sourceState.ts +9 -4
- package/server/workspace/sources/types.ts +9 -0
- package/server/workspace/sources/urls.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +4 -4
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +477 -251
- package/src/components/CanvasViewToggle.vue +12 -10
- package/src/components/ChatInput.vue +112 -105
- package/src/components/FileContentHeader.vue +10 -7
- package/src/components/FileContentRenderer.vue +37 -10
- package/src/components/FileTree.vue +34 -4
- package/src/components/FileTreePane.vue +32 -27
- package/src/components/FilesView.vue +5 -3
- package/src/components/FilterChip.vue +22 -0
- package/src/components/LockStatusPopup.vue +19 -13
- package/src/components/NewsView.vue +252 -0
- package/src/components/NotificationBell.vue +35 -9
- package/src/components/NotificationToast.vue +4 -1
- package/src/components/PageChatComposer.vue +101 -0
- package/src/components/PluginLauncher.vue +36 -62
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/RoleSelector.vue +3 -2
- package/src/components/SessionHeaderControls.vue +63 -0
- package/src/components/SessionHistoryExpandButton.vue +30 -0
- package/src/components/SessionHistoryPanel.vue +64 -93
- package/src/components/SessionHistoryToggleButton.vue +40 -0
- package/src/components/SessionRoleIcon.vue +72 -0
- package/src/components/SessionSidebar.vue +96 -0
- package/src/components/SessionTabBar.vue +44 -51
- package/src/components/SettingsMcpTab.vue +361 -52
- package/src/components/SettingsModal.vue +203 -72
- package/src/components/SettingsReferenceDirsTab.vue +72 -51
- package/src/components/SettingsWorkspaceDirsTab.vue +74 -51
- package/src/components/SidebarHeader.vue +50 -16
- package/src/components/SourcesManager.vue +900 -0
- package/src/components/SourcesView.vue +45 -0
- package/src/components/StackView.vue +84 -48
- package/src/components/SuggestionsPanel.vue +25 -36
- package/src/components/SystemFileBanner.vue +106 -0
- package/src/components/ThinkingIndicator.vue +41 -0
- package/src/components/TodoExplorer.vue +72 -22
- package/src/components/todo/TodoAddDialog.vue +17 -12
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +15 -10
- package/src/components/todo/TodoKanbanView.vue +16 -6
- package/src/components/todo/TodoListView.vue +14 -3
- package/src/components/todo/TodoTableView.vue +36 -5
- package/src/composables/favicon/conditions.ts +76 -0
- package/src/composables/favicon/resolveColor.ts +93 -0
- package/src/composables/favicon/types.ts +61 -0
- package/src/composables/useAppApi.ts +23 -0
- package/src/composables/useChatScroll.ts +5 -5
- package/src/composables/useCurrentRole.ts +32 -0
- package/src/composables/useDynamicFavicon.ts +174 -58
- package/src/composables/useEventListeners.ts +7 -12
- package/src/composables/useFaviconState.ts +93 -12
- package/src/composables/useFileSelection.ts +25 -6
- package/src/composables/useHealth.ts +76 -7
- package/src/composables/useLayoutMode.ts +27 -0
- package/src/composables/useNewsItems.ts +38 -0
- package/src/composables/useNewsReadState.ts +75 -0
- package/src/composables/useNotifications.ts +76 -13
- package/src/composables/usePendingCalls.ts +11 -1
- package/src/composables/useRoles.ts +6 -10
- package/src/composables/useRunElapsed.ts +80 -0
- package/src/composables/useSessionDerived.ts +21 -5
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useSidePanelVisible.ts +25 -0
- package/src/composables/useViewLayout.ts +16 -37
- package/src/config/apiRoutes.ts +19 -6
- package/src/config/historyFilters.ts +30 -0
- package/src/config/mcpCatalog.ts +285 -0
- package/src/config/mcpTypes.ts +26 -0
- package/src/config/roles.ts +19 -51
- package/src/config/systemFileDescriptors.ts +170 -0
- package/src/config/toolNames.ts +6 -1
- package/src/config/workspacePaths.ts +1 -0
- package/src/index.css +14 -0
- package/src/lang/de.ts +706 -0
- package/src/lang/en.ts +726 -0
- package/src/lang/es.ts +712 -0
- package/src/lang/fr.ts +704 -0
- package/src/lang/ja.ts +707 -0
- package/src/lang/ko.ts +709 -0
- package/src/lang/pt-BR.ts +702 -0
- package/src/lang/zh.ts +705 -0
- package/src/lib/vue-i18n.ts +97 -0
- package/src/main.ts +3 -0
- package/src/plugins/canvas/View.vue +104 -186
- package/src/plugins/canvas/definition.ts +0 -8
- package/src/plugins/canvas/index.ts +3 -2
- package/src/plugins/chart/Preview.vue +1 -1
- package/src/plugins/chart/View.vue +9 -4
- package/src/plugins/chart/index.ts +3 -2
- package/src/plugins/editImage/index.ts +3 -2
- package/src/plugins/generateImage/index.ts +3 -2
- package/src/plugins/manageRoles/Preview.vue +4 -1
- package/src/plugins/manageRoles/View.vue +67 -46
- package/src/plugins/manageRoles/index.ts +3 -2
- package/src/plugins/manageSkills/Preview.vue +8 -3
- package/src/plugins/manageSkills/View.vue +39 -34
- package/src/plugins/manageSkills/index.ts +3 -2
- package/src/plugins/manageSource/Preview.vue +1 -1
- package/src/plugins/manageSource/View.vue +3 -687
- package/src/plugins/manageSource/index.ts +3 -2
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +164 -73
- package/src/plugins/markdown/definition.ts +6 -4
- package/src/plugins/markdown/index.ts +3 -2
- package/src/plugins/presentForm/Preview.vue +99 -0
- package/src/plugins/presentForm/View.vue +675 -0
- package/src/plugins/presentForm/definition.ts +127 -0
- package/src/plugins/presentForm/index.ts +18 -0
- package/src/plugins/presentForm/plugin.ts +94 -0
- package/src/plugins/presentForm/types.ts +109 -0
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentHtml/index.ts +3 -2
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +36 -26
- package/src/plugins/presentMulmoScript/index.ts +3 -2
- package/src/plugins/scheduler/AutomationsPreview.vue +37 -0
- package/src/plugins/scheduler/AutomationsView.vue +23 -0
- package/src/plugins/scheduler/CalendarView.vue +23 -0
- package/src/plugins/scheduler/LegacySchedulerView.vue +32 -0
- package/src/plugins/scheduler/Preview.vue +7 -4
- package/src/plugins/scheduler/TasksTab.vue +119 -28
- package/src/plugins/scheduler/View.vue +75 -32
- package/src/plugins/scheduler/automationsDefinition.ts +58 -0
- package/src/plugins/scheduler/calendarDefinition.ts +46 -0
- package/src/plugins/scheduler/formatSchedule.ts +93 -0
- package/src/plugins/scheduler/index.ts +68 -14
- package/src/plugins/scheduler/legacyShape.ts +34 -0
- package/src/plugins/spreadsheet/Preview.vue +9 -5
- package/src/plugins/spreadsheet/View.vue +43 -57
- package/src/plugins/spreadsheet/engine/responseDecoder.ts +2 -1
- package/src/plugins/spreadsheet/index.ts +3 -2
- package/src/plugins/textResponse/Preview.vue +15 -58
- package/src/plugins/textResponse/View.vue +42 -45
- package/src/plugins/textResponse/utils.ts +25 -0
- package/src/plugins/todo/Preview.vue +11 -6
- package/src/plugins/todo/View.vue +27 -13
- package/src/plugins/todo/composables/useTodos.ts +3 -1
- package/src/plugins/todo/index.ts +3 -2
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +5 -2
- package/src/plugins/wiki/View.vue +539 -92
- package/src/plugins/wiki/index.ts +5 -2
- package/src/plugins/wiki/route.ts +121 -0
- package/src/router/guards.ts +43 -24
- package/src/router/index.ts +53 -26
- package/src/router/pageRoutes.ts +23 -0
- package/src/tools/index.ts +12 -5
- package/src/tools/legacyPluginNames.ts +13 -0
- package/src/types/notification.ts +31 -6
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/eventDispatch.ts +3 -6
- package/src/utils/agent/formatElapsed.ts +37 -0
- package/src/utils/agent/request.ts +22 -1
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/canvas/sidePanelVisible.ts +19 -0
- package/src/utils/dom/scrollIntoViewByTestId.ts +38 -0
- package/src/utils/errors.ts +9 -2
- package/src/utils/files/filename.ts +24 -0
- package/src/utils/filesPreview/schedulerPreview.ts +9 -3
- package/src/utils/id.ts +18 -0
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/markdown/taskList.ts +175 -0
- package/src/utils/mcp/interpolateSpec.ts +97 -0
- package/src/utils/notification/dispatch.ts +51 -15
- package/src/utils/path/workspaceLinkRouter.ts +99 -0
- package/src/utils/session/mergeSessions.ts +5 -0
- package/src/utils/sources/filter.ts +69 -0
- package/src/vite-env.d.ts +9 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
- package/client/assets/index-Bm70FDU2.css +0 -1
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
- package/server/workspace/journal/linkRewrite.ts +0 -4
- package/src/components/ToolResultsPanel.vue +0 -77
- package/src/composables/useCanvasViewMode.ts +0 -121
- package/src/plugins/scheduler/definition.ts +0 -57
- package/src/utils/canvas/viewMode.ts +0 -46
- package/src/utils/role/plugins.ts +0 -12
- package/src/utils/session/seedRoleDefault.ts +0 -35
- /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.
|
|
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="
|
|
7
|
-
|
|
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>
|
|
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>
|
|
21
|
-
<a href="https://www.docker.com/products/docker-desktop/" target="_blank" class="underline">
|
|
22
|
-
|
|
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">
|
|
27
|
-
<p v-if="sandboxStatus === null" class="text-gray-400 italic" data-testid="sandbox-credentials-loading">
|
|
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">
|
|
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" : "
|
|
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">
|
|
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">
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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">
|
|
93
|
-
<button class="text-xs text-blue-500 hover:text-blue-700" data-testid="notification-mark-all-read" @click="markAllRead">
|
|
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">
|
|
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-
|
|
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="
|
|
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="
|
|
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>
|