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.
- package/client/assets/{index-40ErrJ4a.js → index-B3NxFcEH.js} +36 -36
- package/client/assets/{marp-Dh7C24F1.js → marp-C9QDHFAJ.js} +1 -1
- package/client/index.html +1 -1
- package/package.json +6 -6
- package/server/api/routes/agent.ts +5 -1
- package/server/api/routes/collections.ts +16 -1
- package/server/api/routes/config.ts +12 -0
- package/server/remoteHost/firebase.ts +6 -0
- package/server/remoteHost/handlers/index.ts +4 -0
- package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
- package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
- package/server/remoteHost/handlers/listCollections.ts +6 -1
- package/server/remoteHost/handlers/listSkills.ts +39 -0
- package/server/remoteHost/handlers/startChat.ts +103 -39
- package/server/system/config.ts +101 -4
- package/server/system/logs/aaa +737 -0
- package/server/system/logs/bb +446 -0
- package/server/workspace/chat-index/index.ts +21 -2
- package/server/workspace/chat-index/indexer.ts +57 -14
- package/server/workspace/chat-index/summarizer.ts +18 -10
- package/server/workspace/collections/index.ts +1 -1
- package/server/workspace/journal/archivist-cli.ts +17 -3
- package/server/workspace/journal/dailyPass.ts +52 -2
- package/server/workspace/journal/index.ts +20 -4
- package/src/App.vue +5 -0
- package/src/components/FileTree.vue +21 -1
- package/src/components/FileTreePane.vue +41 -25
- package/src/components/FilesView.vue +4 -0
- package/src/components/RemoteHostControl.vue +18 -0
- package/src/components/SessionHistoryPanel.vue +24 -11
- package/src/components/SettingsChatIndexTab.vue +119 -0
- package/src/components/SettingsJournalTab.vue +117 -0
- package/src/components/SettingsModal.vue +12 -2
- package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
- package/src/composables/useShowHiddenSystemFiles.ts +23 -0
- package/src/config/visibleWorkspaceDirs.ts +21 -0
- package/src/lang/de.ts +45 -0
- package/src/lang/en.ts +43 -0
- package/src/lang/es.ts +44 -0
- package/src/lang/fr.ts +45 -0
- package/src/lang/ja.ts +44 -0
- package/src/lang/ko.ts +43 -0
- package/src/lang/pt-BR.ts +44 -0
- package/src/lang/zh.ts +43 -0
- 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
|
-
//
|
|
43
|
-
// primary way a user finds a past chat, so a
|
|
44
|
-
// history list unusable.
|
|
45
|
-
//
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
256
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|