mulmoclaude 0.9.3 → 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 (45) hide show
  1. package/client/assets/{index-40ErrJ4a.js → index-B3NxFcEH.js} +36 -36
  2. package/client/assets/{marp-Dh7C24F1.js → marp-C9QDHFAJ.js} +1 -1
  3. package/client/index.html +1 -1
  4. package/package.json +6 -6
  5. package/server/api/routes/agent.ts +5 -1
  6. package/server/api/routes/collections.ts +16 -1
  7. package/server/api/routes/config.ts +12 -0
  8. package/server/remoteHost/firebase.ts +6 -0
  9. package/server/remoteHost/handlers/index.ts +4 -0
  10. package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
  11. package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
  12. package/server/remoteHost/handlers/listCollections.ts +6 -1
  13. package/server/remoteHost/handlers/listSkills.ts +39 -0
  14. package/server/remoteHost/handlers/startChat.ts +103 -39
  15. package/server/system/config.ts +101 -4
  16. package/server/system/logs/aaa +737 -0
  17. package/server/system/logs/bb +446 -0
  18. package/server/workspace/chat-index/index.ts +21 -2
  19. package/server/workspace/chat-index/indexer.ts +57 -14
  20. package/server/workspace/chat-index/summarizer.ts +18 -10
  21. package/server/workspace/collections/index.ts +1 -1
  22. package/server/workspace/journal/archivist-cli.ts +17 -3
  23. package/server/workspace/journal/dailyPass.ts +52 -2
  24. package/server/workspace/journal/index.ts +20 -4
  25. package/src/App.vue +5 -0
  26. package/src/components/FileTree.vue +21 -1
  27. package/src/components/FileTreePane.vue +41 -25
  28. package/src/components/FilesView.vue +4 -0
  29. package/src/components/RemoteHostControl.vue +18 -0
  30. package/src/components/SessionHistoryPanel.vue +24 -11
  31. package/src/components/SettingsChatIndexTab.vue +119 -0
  32. package/src/components/SettingsJournalTab.vue +117 -0
  33. package/src/components/SettingsModal.vue +12 -2
  34. package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
  35. package/src/composables/useShowHiddenSystemFiles.ts +23 -0
  36. package/src/config/visibleWorkspaceDirs.ts +21 -0
  37. package/src/lang/de.ts +45 -0
  38. package/src/lang/en.ts +43 -0
  39. package/src/lang/es.ts +44 -0
  40. package/src/lang/fr.ts +45 -0
  41. package/src/lang/ja.ts +44 -0
  42. package/src/lang/ko.ts +43 -0
  43. package/src/lang/pt-BR.ts +44 -0
  44. package/src/lang/zh.ts +43 -0
  45. package/src/utils/session/sessionPreview.ts +29 -0
@@ -39,11 +39,18 @@ const SUMMARY_SCHEMA = {
39
39
  required: ["title", "summary", "keywords"],
40
40
  };
41
41
 
42
- // Model used for summarization. Sonnet, not haiku: the title is the
43
- // primary way a user finds a past chat, so a weak title makes the
44
- // history list unusable. One cheap structured call per session is
45
- // negligible next to the agent turns it summarizes.
46
- const SUMMARY_MODEL = "sonnet";
42
+ // Default model used when a caller doesn't specify one. Sonnet, not
43
+ // haiku: the title is the primary way a user finds a past chat, so a
44
+ // weak title makes the history list unusable. Since #1944 the model
45
+ // is user-selectable from Settings Chat index — "off" disables the
46
+ // indexer, "haiku" / "sonnet" pick the model here — so this constant
47
+ // is only the internal fallback when the indexer runs without a
48
+ // model override (e.g. a manual rebuild triggered before the setting
49
+ // resolves).
50
+ const DEFAULT_SUMMARY_MODEL = "sonnet";
51
+ // Explicit union so a bad string from the settings layer can't reach
52
+ // the CLI unchecked. Mirror in `AppSettings.chatIndex` (server/system/config.ts).
53
+ export type SummaryModel = "haiku" | "sonnet";
47
54
 
48
55
  // Prompt-building constants. Sized for Sonnet's large context: the
49
56
  // window is wide enough to carry a long, topic-shifting session's
@@ -68,7 +75,7 @@ const MAX_BUDGET_USD = 0.4;
68
75
  // Any module that wants to drive the summarizer — including the
69
76
  // indexer — takes a SummarizeFn so tests can supply a deterministic
70
77
  // fake. Production path is `defaultSummarize` below.
71
- export type SummarizeFn = (input: string) => Promise<SummaryResult>;
78
+ export type SummarizeFn = (input: string, opts?: { model?: SummaryModel }) => Promise<SummaryResult>;
72
79
 
73
80
  interface JsonlEntry {
74
81
  source?: string;
@@ -184,7 +191,7 @@ export async function loadJsonlInput(jsonlPath: string): Promise<string> {
184
191
 
185
192
  // --- spawn layer ----------------------------------------------------
186
193
 
187
- function spawnClaudeSummarize(input: string, timeoutMs: number): Promise<string> {
194
+ function spawnClaudeSummarize(input: string, timeoutMs: number, model: SummaryModel): Promise<string> {
188
195
  return new Promise((resolve, reject) => {
189
196
  const args = [
190
197
  "--print",
@@ -192,7 +199,7 @@ function spawnClaudeSummarize(input: string, timeoutMs: number): Promise<string>
192
199
  "--output-format",
193
200
  "json",
194
201
  "--model",
195
- SUMMARY_MODEL,
202
+ model,
196
203
  "--max-budget-usd",
197
204
  String(MAX_BUDGET_USD),
198
205
  "--json-schema",
@@ -252,7 +259,8 @@ function spawnClaudeSummarize(input: string, timeoutMs: number): Promise<string>
252
259
  // Production SummarizeFn: prepare the input from a jsonl path and
253
260
  // drive the CLI. Tests inject their own SummarizeFn that bypasses
254
261
  // the CLI entirely.
255
- export const defaultSummarize: SummarizeFn = async (input: string) => {
256
- const stdout = await spawnClaudeSummarize(input, DEFAULT_TIMEOUT_MS);
262
+ export const defaultSummarize: SummarizeFn = async (input, opts) => {
263
+ const model: SummaryModel = opts?.model ?? DEFAULT_SUMMARY_MODEL;
264
+ const stdout = await spawnClaudeSummarize(input, DEFAULT_TIMEOUT_MS, model);
257
265
  return parseClaudeJsonResult(stdout);
258
266
  };
@@ -8,7 +8,7 @@ export {
8
8
  type LoadedCollection,
9
9
  } from "@mulmoclaude/core/collection/server";
10
10
  export { validateCollectionRecords, validateRecordObject, COMPUTED_TYPES, type RecordIssue } from "@mulmoclaude/core/collection/server";
11
- export { enrichItems } from "@mulmoclaude/core/collection/server";
11
+ export { enrichItems, computeCollectionIcon } from "@mulmoclaude/core/collection/server";
12
12
  export { deleteCollection, deleteCollectionRefusalMessage, type DeleteCollectionResult } from "@mulmoclaude/core/collection/server";
13
13
  export { deleteCustomView, type DeleteViewResult } from "@mulmoclaude/core/collection/server";
14
14
  export {
@@ -4,7 +4,12 @@ import { spawn } from "node:child_process";
4
4
  import { CLI_SUBPROCESS_TIMEOUT_MS } from "../../utils/time.js";
5
5
  import { claudeBinPath, ClaudeCliNotFoundError } from "../../utils/claudeBin.js";
6
6
 
7
- export type Summarize = (systemPrompt: string, userPrompt: string) => Promise<string>;
7
+ // User-selectable model for the archivist CLI call. `journalMode`
8
+ // widens to `"off"` too; the entry-point (`maybeRunJournal`) filters
9
+ // that out before we get here — this union is intentionally narrow so
10
+ // a bad string can't reach `--model`.
11
+ export type JournalSummaryModel = "haiku" | "sonnet";
12
+ export type Summarize = (systemPrompt: string, userPrompt: string, opts?: { model?: JournalSummaryModel }) => Promise<string>;
8
13
 
9
14
  const CLI_TIMEOUT_MS = CLI_SUBPROCESS_TIMEOUT_MS;
10
15
 
@@ -28,9 +33,18 @@ export class ClaudeCliFailedError extends Error {
28
33
  }
29
34
 
30
35
  // Pipe the combined prompt via stdin to dodge shell-argv limits for large day excerpts.
31
- export const runClaudeCli: Summarize = async (systemPrompt, userPrompt) =>
36
+ export const runClaudeCli: Summarize = async (systemPrompt, userPrompt, opts) =>
32
37
  new Promise((resolve, reject) => {
33
- const child = spawn(claudeBinPath(), ["-p", "--output-format", "text"], {
38
+ // `opts?.model` is threaded from `Settings Journal` via
39
+ // maybeRunJournal so the user's model choice reaches the CLI.
40
+ // When undefined we omit the flag entirely and let the CLI use
41
+ // its own default — preserves the pre-#1944 archivist behaviour
42
+ // for direct-CLI callers that don't specify a model.
43
+ const args = ["-p", "--output-format", "text"];
44
+ if (opts?.model) {
45
+ args.push("--model", opts.model);
46
+ }
47
+ const child = spawn(claudeBinPath(), args, {
34
48
  stdio: ["pipe", "pipe", "pipe"],
35
49
  });
36
50
 
@@ -74,17 +74,24 @@ export async function buildDailyPassPlan(state: JournalState, deps: DailyPassDep
74
74
  const { dirty } = findDirtySessions(eligible, state.processedSessions);
75
75
  if (dirty.length === 0) return null;
76
76
 
77
- const perSessionExcerpts = await loadDirtySessionExcerpts(chatDir, dirty, workspaceRoot);
77
+ const dirtyMetaById = new Map(eligible.map((sessionMeta) => [sessionMeta.id, sessionMeta]));
78
+ const { journalDirty, silentlyProcessed } = await partitionDirtyByOrigin(dirty, dirtyMetaById, workspaceRoot);
79
+
80
+ const perSessionExcerpts = await loadDirtySessionExcerpts(chatDir, journalDirty, workspaceRoot);
78
81
  const { dayBuckets, sessionToDays } = buildDayBuckets(perSessionExcerpts);
79
82
 
80
83
  const existingTopics = await readAllTopics(workspaceRoot);
81
84
  const newTopicsSeen = new Set<string>(state.knownTopics);
82
85
  // Do NOT bump lastDailyRunAt here — outer runner does it after optimization, so partial progress isn't a complete pass.
86
+ // Silently mark origin-filtered dirty sessions as processed at their current
87
+ // mtime so they don't reappear as dirty on every pass. Without this, a
88
+ // workspace with many automation sessions pays an O(N) meta re-read on
89
+ // every hourly run.
83
90
  const initialNextState: JournalState = {
84
91
  ...state,
92
+ processedSessions: applyProcessed(state.processedSessions, silentlyProcessed),
85
93
  knownTopics: [...newTopicsSeen].sort(),
86
94
  };
87
- const dirtyMetaById = new Map(eligible.map((sessionMeta) => [sessionMeta.id, sessionMeta]));
88
95
  const orderedDays = [...dayBuckets.keys()].sort();
89
96
 
90
97
  return {
@@ -422,6 +429,49 @@ export function advanceJournalState(prev: JournalState, justCompleted: readonly
422
429
  }
423
430
 
424
431
  // Malformed sessions are logged and skipped so one bad jsonl can't crash the pass.
432
+ // Origins the journal pass intentionally skips (mirror of the chat-index
433
+ // filter added in #1944). `system` sessions are hidden host workers
434
+ // (thumbnail generation, background exports); `scheduler` sessions are
435
+ // automation-driven with prompts the user did not author. Neither
436
+ // surfaces content worth summarising into a personal daily journal.
437
+ const NON_INDEXED_ORIGINS: ReadonlySet<string> = new Set(["system", "scheduler"]);
438
+
439
+ async function isEligibleForJournalByOrigin(sessionId: string, workspaceRoot: string): Promise<boolean> {
440
+ try {
441
+ const meta = await readSessionMetaIO(sessionId, workspaceRoot);
442
+ if (meta && typeof meta.origin === "string" && NON_INDEXED_ORIGINS.has(meta.origin)) return false;
443
+ } catch {
444
+ // meta unreadable → treat as eligible; the excerpt loader will
445
+ // still bail if the jsonl itself is malformed.
446
+ }
447
+ return true;
448
+ }
449
+
450
+ interface DirtyPartition {
451
+ journalDirty: string[];
452
+ // origin-filtered dirty sessions — marked processed at their current mtime
453
+ // so the next pass doesn't re-inspect them.
454
+ silentlyProcessed: SessionFileMeta[];
455
+ }
456
+
457
+ async function partitionDirtyByOrigin(
458
+ dirty: readonly string[],
459
+ dirtyMetaById: ReadonlyMap<string, SessionFileMeta>,
460
+ workspaceRoot: string,
461
+ ): Promise<DirtyPartition> {
462
+ const journalDirty: string[] = [];
463
+ const silentlyProcessed: SessionFileMeta[] = [];
464
+ for (const sessionId of dirty) {
465
+ if (await isEligibleForJournalByOrigin(sessionId, workspaceRoot)) {
466
+ journalDirty.push(sessionId);
467
+ continue;
468
+ }
469
+ const meta = dirtyMetaById.get(sessionId);
470
+ if (meta) silentlyProcessed.push(meta);
471
+ }
472
+ return { journalDirty, silentlyProcessed };
473
+ }
474
+
425
475
  async function loadDirtySessionExcerpts(chatDir: string, dirty: readonly string[], workspaceRoot: string): Promise<Map<string, Map<string, SessionExcerpt>>> {
426
476
  const perSession = new Map<string, Map<string, SessionExcerpt>>();
427
477
  for (const sessionId of dirty) {
@@ -15,9 +15,10 @@ import { readState, writeState, isDailyDue, isOptimizationDue } from "./state.js
15
15
  import { runDailyPass } from "./dailyPass.js";
16
16
  import { runOptimizationPass } from "./optimizationPass.js";
17
17
  import { buildIndexMarkdown, type IndexTopicEntry, type IndexDailyEntry } from "./indexFile.js";
18
- import { runClaudeCli, ClaudeCliNotFoundError, type Summarize } from "./archivist-cli.js";
18
+ import { runClaudeCli, ClaudeCliNotFoundError, type Summarize, type JournalSummaryModel } from "./archivist-cli.js";
19
19
  import { extractFirstH1 } from "../../../src/utils/markdown/extractFirstH1.js";
20
20
  import { log } from "../../system/logger/index.js";
21
+ import { journalMode as resolveJournalMode, loadSettings, type JournalMode } from "../../system/config.js";
21
22
 
22
23
  export { extractFirstH1 };
23
24
 
@@ -39,14 +40,24 @@ export interface MaybeRunJournalOptions {
39
40
  activeSessionIds?: ReadonlySet<string>;
40
41
  // Bypass the interval gate; the disable flags (CLI missing, in-process lock) still apply.
41
42
  force?: boolean;
43
+ // Injectable journal mode — defaults to `journalMode(loadSettings())`
44
+ // when omitted. "off" short-circuits before any lock / state read;
45
+ // "haiku" / "sonnet" pick the model the archivist CLI spawns. Tests
46
+ // and the force-run switch inject this directly; production callers
47
+ // (turn-end hook, scheduled task) let the resolver pick it up.
48
+ mode?: JournalMode;
42
49
  }
43
50
 
44
51
  export async function maybeRunJournal(opts: MaybeRunJournalOptions = {}): Promise<void> {
45
52
  if (disabled) return;
46
53
  if (running) return;
54
+ // Config-driven kill switch. Resolve at entry time so a settings edit
55
+ // takes effect on the very next turn without a server restart.
56
+ const mode = opts.mode ?? resolveJournalMode(loadSettings());
57
+ if (mode === "off") return;
47
58
  running = true;
48
59
  try {
49
- await runJournalPass(opts);
60
+ await runJournalPass(opts, mode);
50
61
  } catch (err) {
51
62
  if (err instanceof ClaudeCliNotFoundError) {
52
63
  disabled = true;
@@ -61,9 +72,14 @@ export async function maybeRunJournal(opts: MaybeRunJournalOptions = {}): Promis
61
72
  }
62
73
  }
63
74
 
64
- async function runJournalPass(opts: MaybeRunJournalOptions): Promise<void> {
75
+ async function runJournalPass(opts: MaybeRunJournalOptions, model: JournalSummaryModel): Promise<void> {
65
76
  const workspaceRoot = opts.workspaceRoot ?? defaultWorkspacePath;
66
- const summarize = opts.summarize ?? runClaudeCli;
77
+ // Pre-bind the model into the summarize callable so every layer
78
+ // downstream (dailyPass / optimizationPass / memoryExtractor) picks
79
+ // the user-selected model without threading the parameter through
80
+ // half a dozen call sites.
81
+ const rawSummarize = opts.summarize ?? runClaudeCli;
82
+ const summarize: Summarize = (sys, user) => rawSummarize(sys, user, { model });
67
83
  const activeSessionIds = opts.activeSessionIds ?? new Set<string>();
68
84
 
69
85
  const state = await readState(workspaceRoot);
package/src/App.vue CHANGED
@@ -386,6 +386,7 @@ import ConfirmModal from "./components/ConfirmModal.vue";
386
386
  import { useNotifications } from "./composables/useNotifications";
387
387
  import { collectionNotifiedSeverities } from "./utils/collections/notifiedItems";
388
388
  import { installCollectionAppBindings } from "./composables/collections/uiHost";
389
+ import { useDynamicShortcutIcons } from "./composables/collections/useDynamicShortcutIcons";
389
390
  import type { CollectionsListResponse } from "@mulmoclaude/core/collection";
390
391
  import { useHealth } from "./composables/useHealth";
391
392
  import { useSessionHistory } from "./composables/useSessionHistory";
@@ -1261,6 +1262,10 @@ installCollectionAppBindings({
1261
1262
  startNewChatDraft: (prompt: string, role?: string) => startNewChatDraft(prompt, role),
1262
1263
  notifiedSeverities: (slug: string) => collectionNotifiedSeverities(notifierEntries.value, slug),
1263
1264
  });
1265
+ // Keep pinned collection-launcher icons that declare `dynamicIcon` live —
1266
+ // mounted here (not inside CollectionsIndexView) so it runs regardless of
1267
+ // which page is open.
1268
+ useDynamicShortcutIcons();
1264
1269
  // Plugin Views that need to tag background work with the current
1265
1270
  // session (e.g. MulmoScript generations) inject this.
1266
1271
  provideActiveSession(activeSession);
@@ -51,13 +51,14 @@
51
51
  not as a global overlay. -->
52
52
  <div v-if="loadingChildren" class="px-2 py-1 text-xs text-gray-400">{{ t("common.loading") }}</div>
53
53
  <FileTree
54
- v-for="child in loadedChildren"
54
+ v-for="child in visibleChildren"
55
55
  :key="child.path"
56
56
  :node="child"
57
57
  :selected-path="selectedPath"
58
58
  :recent-paths="recentPaths"
59
59
  :children-by-path="childrenByPath"
60
60
  :sort-mode="sortMode"
61
+ :show-hidden-system="showHiddenSystem"
61
62
  @select="(p) => emit('select', p)"
62
63
  @load-children="(p) => emit('loadChildren', p)"
63
64
  @create-file="(args) => emit('createFile', args)"
@@ -95,6 +96,7 @@ import { useI18n } from "vue-i18n";
95
96
  import { useExpandedDirs } from "../composables/useExpandedDirs";
96
97
  import { sortChildren } from "../utils/files/sortChildren";
97
98
  import { descriptorForPath, EDIT_POLICY_ICON_COLOR } from "../config/systemFileDescriptors";
99
+ import { isVisibleTopLevel } from "../config/visibleWorkspaceDirs";
98
100
  import { normaliseNewFileSlug, policyForFolder } from "../config/createFilePolicy";
99
101
  import type { FileSortMode } from "../composables/useFileSortMode";
100
102
  import type { TreeNode } from "../types/fileTree";
@@ -118,6 +120,14 @@ const props = defineProps<{
118
120
  // show spinner. Array = loaded.
119
121
  childrenByPath: Map<string, TreeNode[] | null>;
120
122
  sortMode: FileSortMode;
123
+ // When false, top-level "system" dirs (`conversations/`, `feeds/`,
124
+ // `.git/`, ad-hoc automation buckets) are filtered out — only
125
+ // recognised user-content buckets (data / artifacts / config) stay
126
+ // visible at the workspace root. Filter only fires when this
127
+ // component IS the root (`node.path === ""`); nested instances
128
+ // still receive the prop so the recursion carries it, but they
129
+ // don't apply the filter themselves.
130
+ showHiddenSystem: boolean;
121
131
  }>();
122
132
 
123
133
  const emit = defineEmits<{
@@ -143,6 +153,16 @@ const cached = computed(() => props.childrenByPath.get(props.node.path));
143
153
  const loadingChildren = computed(() => cached.value === null);
144
154
  const loadedChildren = computed(() => (Array.isArray(cached.value) ? sortChildren(cached.value, props.sortMode) : []));
145
155
 
156
+ // Root-only filter: hide non-whitelisted top-level dirs unless the
157
+ // user has toggled "show system files" on. Any depth other than
158
+ // the workspace root is a pass-through.
159
+ const isWorkspaceRoot = computed(() => props.node.path === "");
160
+ const visibleChildren = computed(() => {
161
+ if (!isWorkspaceRoot.value) return loadedChildren.value;
162
+ if (props.showHiddenSystem) return loadedChildren.value;
163
+ return loadedChildren.value.filter((child) => child.type === "dir" && isVisibleTopLevel(child.name));
164
+ });
165
+
146
166
  // Kick off a fetch if the dir is expanded but its children haven't
147
167
  // been requested yet. Covers two scenarios:
148
168
  // 1. User just toggled open → onToggle already emits, but watching
@@ -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