mulmoclaude 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bin/mulmoclaude.js +2 -1
  2. package/client/assets/{index-Dc0R-HW5.js → index-B3NxFcEH.js} +46 -46
  3. package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
  4. package/client/assets/{marp-CSq0PPfK.js → marp-C9QDHFAJ.js} +1 -1
  5. package/client/index.html +2 -2
  6. package/package.json +11 -10
  7. package/server/api/routes/agent.ts +5 -1
  8. package/server/api/routes/chat-index.ts +5 -1
  9. package/server/api/routes/collections.ts +152 -1
  10. package/server/api/routes/config.ts +12 -0
  11. package/server/index.ts +14 -2
  12. package/server/prompts/system/system.md +7 -1
  13. package/server/remoteHost/firebase.ts +6 -0
  14. package/server/remoteHost/handlers/collectionPage.ts +15 -17
  15. package/server/remoteHost/handlers/getCollection.ts +2 -2
  16. package/server/remoteHost/handlers/getFeed.ts +2 -2
  17. package/server/remoteHost/handlers/getRemoteView.ts +37 -0
  18. package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
  19. package/server/remoteHost/handlers/index.ts +10 -0
  20. package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
  21. package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
  22. package/server/remoteHost/handlers/listCollections.ts +6 -1
  23. package/server/remoteHost/handlers/listSkills.ts +39 -0
  24. package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
  25. package/server/remoteHost/handlers/startChat.ts +103 -39
  26. package/server/system/config.ts +101 -4
  27. package/server/system/logs/aaa +737 -0
  28. package/server/system/logs/bb +446 -0
  29. package/server/utils/files/thumbnail-store.ts +97 -0
  30. package/server/workspace/chat-index/index.ts +29 -3
  31. package/server/workspace/chat-index/indexer.ts +187 -29
  32. package/server/workspace/chat-index/summarizer.ts +18 -10
  33. package/server/workspace/collections/index.ts +2 -1
  34. package/server/workspace/collections/remoteView.ts +290 -0
  35. package/server/workspace/journal/archivist-cli.ts +17 -3
  36. package/server/workspace/journal/dailyPass.ts +52 -2
  37. package/server/workspace/journal/index.ts +20 -4
  38. package/src/App.vue +62 -3
  39. package/src/components/FileTree.vue +21 -1
  40. package/src/components/FileTreePane.vue +41 -25
  41. package/src/components/FilesView.vue +4 -0
  42. package/src/components/RemoteHostControl.vue +18 -0
  43. package/src/components/SessionHistoryPanel.vue +24 -11
  44. package/src/components/SettingsChatIndexTab.vue +119 -0
  45. package/src/components/SettingsJournalTab.vue +117 -0
  46. package/src/components/SettingsModal.vue +12 -2
  47. package/src/composables/collections/uiHost.ts +15 -0
  48. package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
  49. package/src/composables/usePubSub.ts +40 -2
  50. package/src/composables/useSessionSync.ts +9 -0
  51. package/src/composables/useShowHiddenSystemFiles.ts +23 -0
  52. package/src/config/apiRoutes.ts +20 -0
  53. package/src/config/roles.ts +2 -2
  54. package/src/config/visibleWorkspaceDirs.ts +21 -0
  55. package/src/lang/de.ts +47 -0
  56. package/src/lang/en.ts +45 -0
  57. package/src/lang/es.ts +46 -0
  58. package/src/lang/fr.ts +47 -0
  59. package/src/lang/ja.ts +46 -0
  60. package/src/lang/ko.ts +45 -0
  61. package/src/lang/pt-BR.ts +46 -0
  62. package/src/lang/zh.ts +44 -0
  63. package/src/plugins/textResponse/View.vue +49 -14
  64. package/src/plugins/textResponse/utils.ts +37 -0
  65. package/src/utils/html/previewCsp.ts +7 -16
  66. package/src/utils/role/icon.ts +9 -2
  67. package/src/utils/session/sessionPreview.ts +29 -0
@@ -1,30 +1,42 @@
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
- <div class="flex justify-end items-center gap-2 pb-1 text-xs">
4
- <span class="text-gray-400">{{ t("fileTreePane.sort") }}</span>
5
- <div class="flex border border-gray-300 rounded overflow-hidden">
6
- <button
7
- type="button"
8
- class="h-8 px-2.5 flex items-center gap-1 transition-colors"
9
- :class="sortMode === 'name' ? 'bg-blue-50 text-blue-700 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'"
10
- :aria-pressed="sortMode === 'name'"
11
- :title="t('fileTreePane.sortByName')"
12
- data-testid="file-sort-name"
13
- @click="emit('update:sortMode', 'name')"
14
- >
15
- {{ t("fileTreePane.name") }}
16
- </button>
17
- <button
18
- type="button"
19
- class="h-8 px-2.5 flex items-center gap-1 transition-colors"
20
- :class="sortMode === 'recent' ? 'bg-blue-50 text-blue-700 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'"
21
- :aria-pressed="sortMode === 'recent'"
22
- :title="t('fileTreePane.sortByRecent')"
23
- data-testid="file-sort-recent"
24
- @click="emit('update:sortMode', 'recent')"
25
- >
26
- {{ t("fileTreePane.recent") }}
27
- </button>
3
+ <div class="flex flex-wrap justify-end items-center gap-x-3 gap-y-1 pb-1 text-xs">
4
+ <label class="flex items-center gap-1 text-gray-500 cursor-pointer select-none" :title="t('fileTreePane.showSystemFilesTitle')">
5
+ <input
6
+ type="checkbox"
7
+ class="h-3.5 w-3.5"
8
+ data-testid="file-tree-show-system-toggle"
9
+ :checked="showHiddenSystem"
10
+ @change="(e) => emit('update:showHiddenSystem', (e.target as HTMLInputElement).checked)"
11
+ />
12
+ {{ t("fileTreePane.showSystemFiles") }}
13
+ </label>
14
+ <div class="flex items-center gap-2">
15
+ <span class="text-gray-400">{{ t("fileTreePane.sort") }}</span>
16
+ <div class="flex border border-gray-300 rounded overflow-hidden">
17
+ <button
18
+ type="button"
19
+ class="h-8 px-2.5 flex items-center gap-1 transition-colors"
20
+ :class="sortMode === 'name' ? 'bg-blue-50 text-blue-700 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'"
21
+ :aria-pressed="sortMode === 'name'"
22
+ :title="t('fileTreePane.sortByName')"
23
+ data-testid="file-sort-name"
24
+ @click="emit('update:sortMode', 'name')"
25
+ >
26
+ {{ t("fileTreePane.name") }}
27
+ </button>
28
+ <button
29
+ type="button"
30
+ class="h-8 px-2.5 flex items-center gap-1 transition-colors"
31
+ :class="sortMode === 'recent' ? 'bg-blue-50 text-blue-700 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'"
32
+ :aria-pressed="sortMode === 'recent'"
33
+ :title="t('fileTreePane.sortByRecent')"
34
+ data-testid="file-sort-recent"
35
+ @click="emit('update:sortMode', 'recent')"
36
+ >
37
+ {{ t("fileTreePane.recent") }}
38
+ </button>
39
+ </div>
28
40
  </div>
29
41
  </div>
30
42
  <div v-if="treeError" class="p-2 text-xs text-red-600">
@@ -38,6 +50,7 @@
38
50
  :recent-paths="recentPaths"
39
51
  :children-by-path="childrenByPath"
40
52
  :sort-mode="sortMode"
53
+ :show-hidden-system="showHiddenSystem"
41
54
  @select="emit('select', $event)"
42
55
  @load-children="emit('loadChildren', $event)"
43
56
  @create-file="emit('createFile', $event)"
@@ -55,6 +68,7 @@
55
68
  :recent-paths="emptySet"
56
69
  :children-by-path="childrenByPath"
57
70
  :sort-mode="sortMode"
71
+ show-hidden-system
58
72
  @select="emit('select', $event)"
59
73
  @load-children="emit('loadChildren', $event)"
60
74
  />
@@ -78,12 +92,14 @@ defineProps<{
78
92
  selectedPath: string | null;
79
93
  recentPaths: Set<string>;
80
94
  sortMode: FileSortMode;
95
+ showHiddenSystem: boolean;
81
96
  }>();
82
97
 
83
98
  const emit = defineEmits<{
84
99
  select: [path: string];
85
100
  loadChildren: [path: string];
86
101
  "update:sortMode": [mode: FileSortMode];
102
+ "update:showHiddenSystem": [next: boolean];
87
103
  createFile: [args: { folder: string; filename: string; resolve: (ok: boolean, error?: string) => void }];
88
104
  }>();
89
105
 
@@ -9,9 +9,11 @@
9
9
  :selected-path="selectedPath"
10
10
  :recent-paths="recentPaths"
11
11
  :sort-mode="sortMode"
12
+ :show-hidden-system="showHiddenSystem"
12
13
  @select="selectFile"
13
14
  @load-children="loadDirChildren"
14
15
  @update:sort-mode="setSortMode"
16
+ @update:show-hidden-system="setShowHiddenSystem"
15
17
  @create-file="handleCreateFile"
16
18
  />
17
19
  <!-- Content pane -->
@@ -72,6 +74,7 @@ import { useFileTree } from "../composables/useFileTree";
72
74
  import { useFileSelection, isValidFilePath, readPathMatch } from "../composables/useFileSelection";
73
75
  import { useMarkdownMode } from "../composables/useMarkdownMode";
74
76
  import { useFileSortMode } from "../composables/useFileSortMode";
77
+ import { useShowHiddenSystemFiles } from "../composables/useShowHiddenSystemFiles";
75
78
  import { useContentDisplay } from "../composables/useContentDisplay";
76
79
  import { useMarkdownLinkHandler } from "../composables/useMarkdownLinkHandler";
77
80
  import { apiPost, apiPut } from "../utils/api";
@@ -100,6 +103,7 @@ const { selectedPath, content, contentLoading, contentError, loadContent, select
100
103
  const { mdRawMode, toggleMdRaw } = useMarkdownMode();
101
104
 
102
105
  const { sortMode, setSortMode } = useFileSortMode();
106
+ const { showHiddenSystem, setShowHiddenSystem } = useShowHiddenSystemFiles();
103
107
 
104
108
  const { isMarkdown, isHtml, isJson, isJsonl, sandboxedHtml, htmlPreviewUrl, jsonTokens, jsonlLines, mdFrontmatter } = useContentDisplay(selectedPath, content);
105
109
 
@@ -52,6 +52,20 @@
52
52
  </div>
53
53
 
54
54
  <p v-if="error" class="mt-2 text-red-600 break-words" data-testid="remote-host-error">{{ error }}</p>
55
+
56
+ <div class="mt-3 pt-2 border-t border-gray-100 text-[11px] leading-snug text-gray-600 space-y-2" data-testid="remote-host-help">
57
+ <p>{{ t("remoteHost.description") }}</p>
58
+ <i18n-t keypath="remoteHost.howTo" tag="p" scope="global">
59
+ <template #url>
60
+ <a :href="MOBILE_URL" target="_blank" rel="noopener noreferrer" class="font-mono text-blue-600 hover:underline break-all">{{ MOBILE_URL }}</a>
61
+ </template>
62
+ </i18n-t>
63
+ <i18n-t keypath="remoteHost.customViewHint" tag="p" scope="global">
64
+ <template #keyword>
65
+ <code class="px-1 py-0.5 rounded bg-gray-100 text-gray-800">custom remote view</code>
66
+ </template>
67
+ </i18n-t>
68
+ </div>
55
69
  </div>
56
70
  </div>
57
71
  </template>
@@ -82,6 +96,10 @@ interface StatusResponse {
82
96
 
83
97
  const { t } = useI18n();
84
98
 
99
+ // Mobile companion PWA. Shown in the popover as help text; not fetched from
100
+ // this desktop app, so no runtime env override is needed.
101
+ const MOBILE_URL = "https://mulmoserver.web.app";
102
+
85
103
  const open = ref(false);
86
104
  const busy = ref(false);
87
105
  const error = ref<string | null>(null);
@@ -35,7 +35,8 @@
35
35
  :key="session.id"
36
36
  tabindex="0"
37
37
  role="button"
38
- :aria-label="t('sessionHistoryPanel.openRowAria', { preview: session.preview || t('sessionHistoryPanel.noMessages') })"
38
+ :aria-label="t('sessionHistoryPanel.openRowAria', { preview: primaryText(session) })"
39
+ :title="primaryText(session)"
39
40
  class="relative cursor-pointer rounded p-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
40
41
  :class="rowClasses(session)"
41
42
  :data-testid="`session-item-${session.id}`"
@@ -91,20 +92,22 @@
91
92
  {{ t("sessionHistoryPanel.delete") }}
92
93
  </button>
93
94
  </div>
94
- <div class="flex items-center gap-1.5">
95
- <SessionRoleIcon :session="session" :roles="roles" size="sm" />
96
- <p class="truncate flex-1 min-w-0" :class="previewClasses(session)">
97
- {{ session.preview || t("sessionHistoryPanel.noMessages") }}
95
+ <!-- Primary line prefers the AI-generated summary (chat indexer,
96
+ #123) when present it's typically more informative than
97
+ the first user message. Fall back to the first user message,
98
+ then to a "no messages" placeholder. `line-clamp-2` lets a
99
+ summary wrap so more of it is readable at a glance; the raw
100
+ first-message fallback stays on a single line via `truncate`
101
+ so short prompts don't disturb row heights. -->
102
+ <div class="flex items-start gap-1.5">
103
+ <SessionRoleIcon :session="session" :roles="roles" size="sm" class="flex-shrink-0 mt-0.5" />
104
+ <p class="flex-1 min-w-0" :class="[previewClasses(session), sessionHasVisibleSummary(session) ? 'line-clamp-2' : 'truncate']">
105
+ {{ primaryText(session) }}
98
106
  </p>
99
- <span v-if="isSessionRunning(session)" class="flex-shrink-0 flex items-center" :aria-label="t('sessionHistoryPanel.running')">
107
+ <span v-if="isSessionRunning(session)" class="flex-shrink-0 flex items-center mt-0.5" :aria-label="t('sessionHistoryPanel.running')">
100
108
  <span class="w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
101
109
  </span>
102
110
  </div>
103
- <!-- Optional second line: AI-generated summary of the
104
- session, populated by the chat indexer (#123). -->
105
- <p v-if="session.summary" class="text-xs text-gray-500 truncate mt-0.5">
106
- {{ session.summary }}
107
- </p>
108
111
  </div>
109
112
  </div>
110
113
  </div>
@@ -118,6 +121,7 @@ import type { SessionSummary, SessionOrigin } from "../types/session";
118
121
  import { SESSION_ORIGINS } from "../types/session";
119
122
  import { HISTORY_FILTERS, HISTORY_FILTER_ORDER, type HistoryFilter } from "../config/historyFilters";
120
123
  import { isLongRunningConversation } from "../utils/session/longRunning";
124
+ import { resolveSessionPrimaryText, sessionHasVisibleSummary } from "../utils/session/sessionPreview";
121
125
  import { formatDate } from "../utils/format/date";
122
126
  import SessionRoleIcon from "./SessionRoleIcon.vue";
123
127
  import FilterChip from "./FilterChip.vue";
@@ -199,6 +203,15 @@ function previewClasses(session: SessionSummary): string {
199
203
  return "text-gray-700";
200
204
  }
201
205
 
206
+ // Primary line resolution: prefer the AI summary (trim + whitespace
207
+ // guarded so a summarizer returning " " doesn't blank the row) →
208
+ // first user message → localised placeholder. Same call is used for
209
+ // visible text, aria-label, and the hover title so all three stay in
210
+ // lockstep.
211
+ function primaryText(session: SessionSummary): string {
212
+ return resolveSessionPrimaryText(session) ?? t("sessionHistoryPanel.noMessages");
213
+ }
214
+
202
215
  // ── Row action menu ─────────────────────────────────────────
203
216
  //
204
217
  // Only one popover is open at a time, tracked by session id. A
@@ -0,0 +1,119 @@
1
+ <template>
2
+ <div class="space-y-3" data-testid="settings-chat-index-tab">
3
+ <p class="text-sm text-gray-700">{{ t("settingsModal.chatIndexTab.description") }}</p>
4
+
5
+ <div class="space-y-2">
6
+ <label class="block text-sm font-medium text-gray-800" for="settings-chat-index-mode">{{ t("settingsModal.chatIndexTab.modeLabel") }}</label>
7
+ <select
8
+ id="settings-chat-index-mode"
9
+ v-model="modeDraft"
10
+ class="w-full px-3 py-2 text-sm rounded border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
11
+ data-testid="settings-chat-index-mode-select"
12
+ @change="save"
13
+ >
14
+ <option v-for="mode in CHAT_INDEX_MODES" :key="mode" :value="mode">{{ t(`settingsModal.chatIndexTab.mode.${mode}`) }}</option>
15
+ </select>
16
+ <p class="text-xs text-gray-500">{{ t("settingsModal.chatIndexTab.helperText") }}</p>
17
+ </div>
18
+
19
+ <div v-if="loaded && !errorMessage" class="flex items-center gap-3 text-xs">
20
+ <span :class="statusColour" data-testid="settings-chat-index-status">
21
+ {{ statusText }}
22
+ </span>
23
+ </div>
24
+
25
+ <p v-if="errorMessage" class="text-sm text-red-700" role="alert" data-testid="settings-chat-index-error">{{ errorMessage }}</p>
26
+ </div>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ import { computed, ref, watch } from "vue";
31
+ import { useI18n } from "vue-i18n";
32
+ import { apiGet, apiPut, type ApiResult } from "../utils/api";
33
+ import { API_ROUTES } from "../config/apiRoutes";
34
+
35
+ const CHAT_INDEX_MODES = ["off", "haiku", "sonnet"] as const;
36
+ type ChatIndexMode = (typeof CHAT_INDEX_MODES)[number];
37
+
38
+ const { t } = useI18n();
39
+
40
+ const props = defineProps<{
41
+ reloadToken: number;
42
+ }>();
43
+
44
+ const emit = defineEmits<{
45
+ saved: [];
46
+ }>();
47
+
48
+ interface SettingsResponse {
49
+ settings: { chatIndex?: ChatIndexMode };
50
+ }
51
+
52
+ const modeDraft = ref<ChatIndexMode>("off");
53
+ const storedMode = ref<ChatIndexMode>("off");
54
+ const loaded = ref(false);
55
+ const saving = ref(false);
56
+ const errorMessage = ref("");
57
+
58
+ const statusText = computed(() => {
59
+ if (saving.value) return t("common.saving");
60
+ return t(`settingsModal.chatIndexTab.status.${storedMode.value}`);
61
+ });
62
+
63
+ const statusColour = computed(() => {
64
+ if (saving.value) return "text-gray-500";
65
+ return storedMode.value === "off" ? "text-gray-500" : "text-green-600";
66
+ });
67
+
68
+ async function load(): Promise<void> {
69
+ errorMessage.value = "";
70
+ const response = await apiGet<SettingsResponse>(API_ROUTES.config.base);
71
+ if (!response.ok) {
72
+ errorMessage.value = response.error || t("settingsModal.chatIndexTab.loadError");
73
+ return;
74
+ }
75
+ storedMode.value = response.data.settings.chatIndex ?? "off";
76
+ modeDraft.value = storedMode.value;
77
+ loaded.value = true;
78
+ }
79
+
80
+ async function save(): Promise<void> {
81
+ if (saving.value) return;
82
+ if (modeDraft.value === storedMode.value) return;
83
+ const requested = modeDraft.value;
84
+ saving.value = true;
85
+ errorMessage.value = "";
86
+ // The Settings tab uses the PATCH endpoint (`config.settings`) — the
87
+ // full-shape `config.base` PUT expects `{ settings, mcp }` and would
88
+ // reject a bare `{ chatIndex }` payload. Send null on "off" so the
89
+ // server drops the field and settings.json stays default-clean.
90
+ const payload = { chatIndex: requested === "off" ? null : requested };
91
+ const response = await apiPut<unknown>(API_ROUTES.config.settings, payload);
92
+ saving.value = false;
93
+ applySaveOutcome(response, requested);
94
+ }
95
+
96
+ // On failure with drift, respect the user's newer selection and retry.
97
+ // On failure without drift, revert modeDraft so re-selecting the same
98
+ // option fires @change again — otherwise the select value hasn't
99
+ // changed and the user is stuck with no way to retry.
100
+ function applySaveOutcome(response: ApiResult<unknown>, requested: ChatIndexMode): void {
101
+ if (!response.ok) {
102
+ errorMessage.value = response.error || t("settingsModal.chatIndexTab.saveError");
103
+ if (modeDraft.value !== requested) void save();
104
+ else modeDraft.value = storedMode.value;
105
+ return;
106
+ }
107
+ storedMode.value = requested;
108
+ emit("saved");
109
+ if (modeDraft.value !== requested) void save();
110
+ }
111
+
112
+ watch(
113
+ () => props.reloadToken,
114
+ () => {
115
+ void load();
116
+ },
117
+ { immediate: true },
118
+ );
119
+ </script>
@@ -0,0 +1,117 @@
1
+ <template>
2
+ <div class="space-y-3" data-testid="settings-journal-tab">
3
+ <p class="text-sm text-gray-700">{{ t("settingsModal.journalTab.description") }}</p>
4
+
5
+ <div class="space-y-2">
6
+ <label class="block text-sm font-medium text-gray-800" for="settings-journal-mode">{{ t("settingsModal.journalTab.modeLabel") }}</label>
7
+ <select
8
+ id="settings-journal-mode"
9
+ v-model="modeDraft"
10
+ class="w-full px-3 py-2 text-sm rounded border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
11
+ data-testid="settings-journal-mode-select"
12
+ @change="save"
13
+ >
14
+ <option v-for="mode in JOURNAL_MODES" :key="mode" :value="mode">{{ t(`settingsModal.journalTab.mode.${mode}`) }}</option>
15
+ </select>
16
+ <p class="text-xs text-gray-500">{{ t("settingsModal.journalTab.helperText") }}</p>
17
+ </div>
18
+
19
+ <div v-if="loaded && !errorMessage" class="flex items-center gap-3 text-xs">
20
+ <span :class="statusColour" data-testid="settings-journal-status">
21
+ {{ statusText }}
22
+ </span>
23
+ </div>
24
+
25
+ <p v-if="errorMessage" class="text-sm text-red-700" role="alert" data-testid="settings-journal-error">{{ errorMessage }}</p>
26
+ </div>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ import { computed, ref, watch } from "vue";
31
+ import { useI18n } from "vue-i18n";
32
+ import { apiGet, apiPut, type ApiResult } from "../utils/api";
33
+ import { API_ROUTES } from "../config/apiRoutes";
34
+
35
+ const JOURNAL_MODES = ["off", "haiku", "sonnet"] as const;
36
+ type JournalMode = (typeof JOURNAL_MODES)[number];
37
+
38
+ const { t } = useI18n();
39
+
40
+ const props = defineProps<{
41
+ reloadToken: number;
42
+ }>();
43
+
44
+ const emit = defineEmits<{
45
+ saved: [];
46
+ }>();
47
+
48
+ interface SettingsResponse {
49
+ settings: { journal?: JournalMode };
50
+ }
51
+
52
+ const modeDraft = ref<JournalMode>("off");
53
+ const storedMode = ref<JournalMode>("off");
54
+ const loaded = ref(false);
55
+ const saving = ref(false);
56
+ const errorMessage = ref("");
57
+
58
+ const statusText = computed(() => {
59
+ if (saving.value) return t("common.saving");
60
+ return t(`settingsModal.journalTab.status.${storedMode.value}`);
61
+ });
62
+
63
+ const statusColour = computed(() => {
64
+ if (saving.value) return "text-gray-500";
65
+ return storedMode.value === "off" ? "text-gray-500" : "text-green-600";
66
+ });
67
+
68
+ async function load(): Promise<void> {
69
+ errorMessage.value = "";
70
+ const response = await apiGet<SettingsResponse>(API_ROUTES.config.base);
71
+ if (!response.ok) {
72
+ errorMessage.value = response.error || t("settingsModal.journalTab.loadError");
73
+ return;
74
+ }
75
+ storedMode.value = response.data.settings.journal ?? "off";
76
+ modeDraft.value = storedMode.value;
77
+ loaded.value = true;
78
+ }
79
+
80
+ async function save(): Promise<void> {
81
+ if (saving.value) return;
82
+ if (modeDraft.value === storedMode.value) return;
83
+ const requested = modeDraft.value;
84
+ saving.value = true;
85
+ errorMessage.value = "";
86
+ // PATCH endpoint (not the atomic base) so a bare `{ journal }` body
87
+ // is accepted. Send null on "off" so settings.json stays default-clean.
88
+ const payload = { journal: requested === "off" ? null : requested };
89
+ const response = await apiPut<unknown>(API_ROUTES.config.settings, payload);
90
+ saving.value = false;
91
+ applySaveOutcome(response, requested);
92
+ }
93
+
94
+ // On failure with drift, respect the user's newer selection and retry.
95
+ // On failure without drift, revert modeDraft so re-selecting the same
96
+ // option fires @change again — otherwise the select value hasn't
97
+ // changed and the user is stuck with no way to retry.
98
+ function applySaveOutcome(response: ApiResult<unknown>, requested: JournalMode): void {
99
+ if (!response.ok) {
100
+ errorMessage.value = response.error || t("settingsModal.journalTab.saveError");
101
+ if (modeDraft.value !== requested) void save();
102
+ else modeDraft.value = storedMode.value;
103
+ return;
104
+ }
105
+ storedMode.value = requested;
106
+ emit("saved");
107
+ if (modeDraft.value !== requested) void save();
108
+ }
109
+
110
+ watch(
111
+ () => props.reloadToken,
112
+ () => {
113
+ void load();
114
+ },
115
+ { immediate: true },
116
+ );
117
+ </script>
@@ -167,6 +167,10 @@
167
167
  <SettingsModelTab v-else-if="activeTab === 'model'" :reload-token="modelReloadToken" @saved="emit('saved')" />
168
168
 
169
169
  <SettingsVoiceTab v-else-if="activeTab === 'voice'" :reload-token="voiceReloadToken" />
170
+
171
+ <SettingsChatIndexTab v-else-if="activeTab === 'chatIndex'" :reload-token="chatIndexReloadToken" @saved="emit('saved')" />
172
+
173
+ <SettingsJournalTab v-else-if="activeTab === 'journal'" :reload-token="journalReloadToken" @saved="emit('saved')" />
170
174
  </template>
171
175
  </div>
172
176
  </div>
@@ -197,6 +201,8 @@ import SettingsMapTab from "./SettingsMapTab.vue";
197
201
  import SettingsPhotosTab from "./SettingsPhotosTab.vue";
198
202
  import SettingsModelTab from "./SettingsModelTab.vue";
199
203
  import SettingsVoiceTab from "./SettingsVoiceTab.vue";
204
+ import SettingsChatIndexTab from "./SettingsChatIndexTab.vue";
205
+ import SettingsJournalTab from "./SettingsJournalTab.vue";
200
206
  import SkillsView from "../plugins/manageSkills/View.vue";
201
207
  import RolesView from "./RolesView.vue";
202
208
  import PluginScopedRoot from "./PluginScopedRoot.vue";
@@ -245,7 +251,7 @@ const emit = defineEmits<{
245
251
  // update / remove persist immediately).
246
252
  const mcpTabRef = ref<{ flushDraft: () => boolean; hasPendingDraft: () => boolean } | null>(null);
247
253
 
248
- type TabId = "gemini" | "tools" | "mcp" | "dirs" | "refs" | "map" | "photos" | "model" | "voice" | "skills" | "roles";
254
+ type TabId = "gemini" | "tools" | "mcp" | "dirs" | "refs" | "map" | "photos" | "model" | "voice" | "chatIndex" | "journal" | "skills" | "roles";
249
255
 
250
256
  const activeTab = ref<TabId>("tools");
251
257
 
@@ -261,7 +267,7 @@ const isFullTab = computed(() => FULL_TABS.includes(activeTab.value));
261
267
  // item is filtered out by `visibleGroups` when geminiAvailable === true
262
268
  // (env var present → user has nothing to configure).
263
269
  const GROUPS: readonly { key: string; items: readonly TabId[] }[] = [
264
- { key: "llm", items: ["model", "voice", "tools", "gemini"] },
270
+ { key: "llm", items: ["model", "voice", "chatIndex", "journal", "tools", "gemini"] },
265
271
  { key: "servers", items: ["mcp"] },
266
272
  { key: "workspace", items: ["dirs", "refs"] },
267
273
  { key: "plugins", items: ["map", "photos"] },
@@ -294,6 +300,8 @@ function onMapSaved(): void {
294
300
  const photosReloadToken = ref(0);
295
301
  const modelReloadToken = ref(0);
296
302
  const voiceReloadToken = ref(0);
303
+ const chatIndexReloadToken = ref(0);
304
+ const journalReloadToken = ref(0);
297
305
  const toolsText = ref("");
298
306
  // Server truth for tools — updated on load and on a successful Save
299
307
  // from the Tools tab. `toolsDirty` compares this against `toolsText`
@@ -501,6 +509,8 @@ watch(
501
509
  photosReloadToken.value += 1;
502
510
  modelReloadToken.value += 1;
503
511
  voiceReloadToken.value += 1;
512
+ chatIndexReloadToken.value += 1;
513
+ journalReloadToken.value += 1;
504
514
  statusMessage.value = "";
505
515
  statusError.value = false;
506
516
  }
@@ -12,6 +12,9 @@
12
12
  // `notifiedSeverities` returns an empty map.
13
13
  import {
14
14
  configureCollectionUi,
15
+ type CollectionRemoteViewResult,
16
+ type CollectionRemoteViewMutateResult,
17
+ type CollectionRemoteViewItemsResult,
15
18
  type CollectionViewI18nResult,
16
19
  type CollectionViewToken,
17
20
  type RegistryListResponse,
@@ -57,6 +60,10 @@ const collectionActionUrl = (slug: string, actionId: string): string =>
57
60
  withSlug(API_ROUTES.collections.collectionAction, slug).replace(":actionId", encodeURIComponent(actionId));
58
61
  const viewDeleteUrl = (slug: string, viewId: string): string =>
59
62
  withSlug(API_ROUTES.collections.viewDelete, slug).replace(":viewId", encodeURIComponent(viewId));
63
+ const remoteViewMutateUrl = (slug: string, viewId: string): string =>
64
+ withSlug(API_ROUTES.collections.remoteViewMutate, slug).replace(":viewId", encodeURIComponent(viewId));
65
+ const remoteViewItemsUrl = (slug: string, viewId: string): string =>
66
+ withSlug(API_ROUTES.collections.remoteViewItems, slug).replace(":viewId", encodeURIComponent(viewId));
60
67
 
61
68
  // ── Deferred app bindings (need a component context; set by App.vue setup) ──
62
69
  type StartChat = (prompt: string, role: string) => void;
@@ -98,6 +105,14 @@ configureCollectionUi({
98
105
  }
99
106
  },
100
107
  fetchViewI18n: (slug, viewId, locale) => apiGet<CollectionViewI18nResult>(withSlug(API_ROUTES.collections.viewI18n, slug), { id: viewId, locale }),
108
+ fetchRemoteView: (slug, viewId, locale) => apiGet<CollectionRemoteViewResult>(withSlug(API_ROUTES.collections.remoteView, slug), { id: viewId, locale }),
109
+ mutateRemoteView: (slug, viewId, request) => apiPost<CollectionRemoteViewMutateResult>(remoteViewMutateUrl(slug, viewId), request),
110
+ fetchRemoteViewItems: (slug, viewId, request) =>
111
+ apiGet<CollectionRemoteViewItemsResult>(remoteViewItemsUrl(slug, viewId), {
112
+ offset: request.offset,
113
+ limit: request.limit,
114
+ fields: request.fields?.join(",") || undefined,
115
+ }),
101
116
  buildViewSrcdoc: (html, boot) => buildCustomViewSrcdoc(html, boot),
102
117
 
103
118
  // record CRUD + actions
@@ -0,0 +1,93 @@
1
+ // Keeps pinned launcher shortcuts for collections that declare a
2
+ // `dynamicIcon` (see `CollectionSchema.dynamicIcon`) live: subscribes to
3
+ // each dynamic collection's source collection(s) and re-reconciles the
4
+ // shortcut cache whenever their records change, so the launcher icon
5
+ // tracks the data state live instead of waiting for the next
6
+ // Collections-index visit — `CollectionsIndexView`'s own reconcile-on-mount
7
+ // remains the fallback for a cold boot, before this composable's first
8
+ // fetch resolves.
9
+ //
10
+ // Mounted once at app level (src/App.vue) so it runs regardless of
11
+ // whether the Collections index is open. Module-singleton state, like
12
+ // `useShortcuts` — there's only ever one launcher to keep live.
13
+
14
+ import { onMounted, onUnmounted, ref } from "vue";
15
+ import { collectionUi } from "@mulmoclaude/collection-plugin/vue";
16
+ import { useShortcuts } from "../useShortcuts";
17
+ import type { CollectionSummary } from "@mulmoclaude/core/collection";
18
+
19
+ /** Wait for a burst of source-collection changes to settle (e.g. a feed
20
+ * refresh writing several records) before re-fetching, rather than
21
+ * re-fetching per record. */
22
+ const SOURCE_CHANGE_DEBOUNCE_MS = 300;
23
+
24
+ type Unsubscribe = () => void;
25
+
26
+ const summaries = ref<CollectionSummary[]>([]);
27
+ let subscriptions: Unsubscribe[] = [];
28
+ let debounceHandle: ReturnType<typeof setTimeout> | null = null;
29
+ let refreshSeq = 0;
30
+
31
+ /** Slugs of every source collection a dynamic-icon shortcut watches,
32
+ * deduped (two dynamic collections can share a source). */
33
+ function dynamicSourceSlugs(): string[] {
34
+ const slugs = new Set<string>();
35
+ for (const summary of summaries.value) {
36
+ for (const source of summary.iconSources ?? []) slugs.add(source);
37
+ }
38
+ return [...slugs];
39
+ }
40
+
41
+ function clearSubscriptions(): void {
42
+ subscriptions.forEach((unsubscribe) => unsubscribe());
43
+ subscriptions = [];
44
+ }
45
+
46
+ /** Re-derive the watched source-collection set from the current
47
+ * `summaries` and (re)subscribe — so a newly-added dynamic collection
48
+ * gets watched on the next refresh. A host without `subscribeChanges`
49
+ * (no pub/sub transport) leaves this a no-op; v1's reconcile-on-visit
50
+ * still applies. */
51
+ function resubscribe(): void {
52
+ clearSubscriptions();
53
+ const subscribe = collectionUi().subscribeChanges;
54
+ if (!subscribe) return;
55
+ subscriptions = dynamicSourceSlugs().map((slug) => subscribe(slug, scheduleRefresh));
56
+ }
57
+
58
+ function scheduleRefresh(): void {
59
+ if (debounceHandle) clearTimeout(debounceHandle);
60
+ debounceHandle = setTimeout(() => {
61
+ debounceHandle = null;
62
+ void refresh();
63
+ }, SOURCE_CHANGE_DEBOUNCE_MS);
64
+ }
65
+
66
+ /** Fetch the authoritative collections list, reconcile pinned
67
+ * `"collection"` shortcuts against it, and re-derive which source
68
+ * collections to watch. Feed-source entries are excluded — they're
69
+ * reconciled separately (kind `"feed"`) by `FeedsView`. A failed fetch
70
+ * leaves the prior summaries (and subscriptions) untouched. A monotonic
71
+ * `refreshSeq` drops a superseded fetch so a slow older `listCollections`
72
+ * can't resolve last and overwrite fresher summaries / resubscribe stale. */
73
+ async function refresh(): Promise<void> {
74
+ const seq = ++refreshSeq;
75
+ const result = await collectionUi().listCollections();
76
+ if (seq !== refreshSeq) return;
77
+ if (!result.ok) return;
78
+ summaries.value = result.data.collections.filter((summary) => summary.source !== "feed");
79
+ resubscribe();
80
+ await useShortcuts().reconcile(
81
+ "collection",
82
+ summaries.value.map((summary) => ({ slug: summary.slug, title: summary.title, icon: summary.icon })),
83
+ );
84
+ }
85
+
86
+ /** Mount once at app level. No template — side effects only. */
87
+ export function useDynamicShortcutIcons(): void {
88
+ onMounted(() => void refresh());
89
+ onUnmounted(() => {
90
+ if (debounceHandle) clearTimeout(debounceHandle);
91
+ clearSubscriptions();
92
+ });
93
+ }