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.
- package/bin/mulmoclaude.js +2 -1
- package/client/assets/{index-Dc0R-HW5.js → index-B3NxFcEH.js} +46 -46
- package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
- package/client/assets/{marp-CSq0PPfK.js → marp-C9QDHFAJ.js} +1 -1
- package/client/index.html +2 -2
- package/package.json +11 -10
- package/server/api/routes/agent.ts +5 -1
- package/server/api/routes/chat-index.ts +5 -1
- package/server/api/routes/collections.ts +152 -1
- package/server/api/routes/config.ts +12 -0
- package/server/index.ts +14 -2
- package/server/prompts/system/system.md +7 -1
- package/server/remoteHost/firebase.ts +6 -0
- package/server/remoteHost/handlers/collectionPage.ts +15 -17
- package/server/remoteHost/handlers/getCollection.ts +2 -2
- package/server/remoteHost/handlers/getFeed.ts +2 -2
- package/server/remoteHost/handlers/getRemoteView.ts +37 -0
- package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
- package/server/remoteHost/handlers/index.ts +10 -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/mutateRemoteView.ts +40 -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/utils/files/thumbnail-store.ts +97 -0
- package/server/workspace/chat-index/index.ts +29 -3
- package/server/workspace/chat-index/indexer.ts +187 -29
- package/server/workspace/chat-index/summarizer.ts +18 -10
- package/server/workspace/collections/index.ts +2 -1
- package/server/workspace/collections/remoteView.ts +290 -0
- 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 +62 -3
- 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/uiHost.ts +15 -0
- package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
- package/src/composables/usePubSub.ts +40 -2
- package/src/composables/useSessionSync.ts +9 -0
- package/src/composables/useShowHiddenSystemFiles.ts +23 -0
- package/src/config/apiRoutes.ts +20 -0
- package/src/config/roles.ts +2 -2
- package/src/config/visibleWorkspaceDirs.ts +21 -0
- package/src/lang/de.ts +47 -0
- package/src/lang/en.ts +45 -0
- package/src/lang/es.ts +46 -0
- package/src/lang/fr.ts +47 -0
- package/src/lang/ja.ts +46 -0
- package/src/lang/ko.ts +45 -0
- package/src/lang/pt-BR.ts +46 -0
- package/src/lang/zh.ts +44 -0
- package/src/plugins/textResponse/View.vue +49 -14
- package/src/plugins/textResponse/utils.ts +37 -0
- package/src/utils/html/previewCsp.ts +7 -16
- package/src/utils/role/icon.ts +9 -2
- package/src/utils/session/sessionPreview.ts +29 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Assemble one mobile (`target: "mobile"`) custom view for the remote client:
|
|
2
|
+
// find the view entry, read its HTML (source-aware staging read), pick its
|
|
3
|
+
// i18n dict for the requested locale, wrap it into the sandboxed srcdoc
|
|
4
|
+
// (CSP + postMessage bootstrap — @mulmoclaude/core/remote-view), and enforce
|
|
5
|
+
// the 1 MiB command-document budget. Shared by the `getRemoteView` channel
|
|
6
|
+
// handler and the desktop preview's HTTP route so both serve the IDENTICAL
|
|
7
|
+
// artifact (plans/feat-remote-custom-view.md, decision 2).
|
|
8
|
+
//
|
|
9
|
+
// Discriminated result (not throw) so the HTTP route can map each failure to
|
|
10
|
+
// its status; the channel handler converts non-ok to a thrown error via
|
|
11
|
+
// `remoteViewFailureMessage`. Factory keeps the mapping unit-testable with the
|
|
12
|
+
// engine stubbed.
|
|
13
|
+
import {
|
|
14
|
+
buildRemoteViewSrcdoc,
|
|
15
|
+
clampImageMaxEdge,
|
|
16
|
+
pageFromItems,
|
|
17
|
+
REMOTE_VIEW_ITEMS_MAX_BYTES,
|
|
18
|
+
REMOTE_VIEW_MAX_BYTES,
|
|
19
|
+
type RemoteViewItem,
|
|
20
|
+
type RemoteViewMutateRequest,
|
|
21
|
+
type RemoteViewPage,
|
|
22
|
+
type RemoteViewPageRequest,
|
|
23
|
+
} from "@mulmoclaude/core/remote-view";
|
|
24
|
+
import { deriveAll } from "@mulmoclaude/core/collection";
|
|
25
|
+
import {
|
|
26
|
+
deleteItem,
|
|
27
|
+
listItems,
|
|
28
|
+
readCustomViewHtml,
|
|
29
|
+
readCustomViewI18n,
|
|
30
|
+
readItem,
|
|
31
|
+
safeRecordId,
|
|
32
|
+
writeItem,
|
|
33
|
+
type CollectionCustomView,
|
|
34
|
+
type CollectionItem,
|
|
35
|
+
type CollectionSchema,
|
|
36
|
+
type LoadedCollection,
|
|
37
|
+
} from "./index.js";
|
|
38
|
+
import { resolveThumbnail } from "../../utils/files/thumbnail-store.js";
|
|
39
|
+
|
|
40
|
+
export interface RemoteViewInfo {
|
|
41
|
+
id: string;
|
|
42
|
+
label: string;
|
|
43
|
+
icon?: string;
|
|
44
|
+
target: "mobile";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type RemoteViewBuildResult =
|
|
48
|
+
| { kind: "ok"; view: RemoteViewInfo; srcdoc: string; bytes: number }
|
|
49
|
+
| { kind: "view-not-found"; viewId: string }
|
|
50
|
+
| { kind: "not-mobile"; viewId: string }
|
|
51
|
+
| { kind: "file-missing"; file: string }
|
|
52
|
+
| { kind: "too-large"; bytes: number };
|
|
53
|
+
|
|
54
|
+
export interface BuildRemoteViewDeps {
|
|
55
|
+
readCustomViewHtml: typeof readCustomViewHtml;
|
|
56
|
+
readCustomViewI18n: typeof readCustomViewI18n;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const createBuildRemoteView =
|
|
60
|
+
(deps: BuildRemoteViewDeps) =>
|
|
61
|
+
async (collection: LoadedCollection, viewId: string, locale: string): Promise<RemoteViewBuildResult> => {
|
|
62
|
+
const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
|
|
63
|
+
if (!view) return { kind: "view-not-found", viewId };
|
|
64
|
+
// A desktop view's HTML assumes the token/dataUrl contract and would just
|
|
65
|
+
// break on the phone — refuse it instead of serving a broken page.
|
|
66
|
+
if (view.target !== "mobile") return { kind: "not-mobile", viewId };
|
|
67
|
+
const html = await deps.readCustomViewHtml(collection, view.file);
|
|
68
|
+
if (html === null) return { kind: "file-missing", file: view.file };
|
|
69
|
+
const i18n = view.i18n ? await deps.readCustomViewI18n(collection, view.i18n, locale) : { locale: "", dict: {} };
|
|
70
|
+
// `writable` gates the client-side updateItem/deleteItem install; the host
|
|
71
|
+
// re-derives + enforces the actual policy on every mutate (createMutateRemoteView).
|
|
72
|
+
const writable = isWritableView(view);
|
|
73
|
+
const srcdoc = buildRemoteViewSrcdoc(html, { slug: collection.slug, locale: i18n.locale, dict: i18n.dict, writable });
|
|
74
|
+
const bytes = Buffer.byteLength(srcdoc, "utf8");
|
|
75
|
+
if (bytes > REMOTE_VIEW_MAX_BYTES) return { kind: "too-large", bytes };
|
|
76
|
+
return { kind: "ok", view: { id: view.id, label: view.label, ...(view.icon ? { icon: view.icon } : {}), target: "mobile" }, srcdoc, bytes };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const buildRemoteView = createBuildRemoteView({ readCustomViewHtml, readCustomViewI18n });
|
|
80
|
+
|
|
81
|
+
/** One message per failure kind, shared by the channel handler (throws it) and
|
|
82
|
+
* the HTTP route (sends it with the matching status). */
|
|
83
|
+
export function remoteViewFailureMessage(result: Exclude<RemoteViewBuildResult, { kind: "ok" }>, slug: string): string {
|
|
84
|
+
if (result.kind === "view-not-found") return `custom view '${result.viewId}' not found on collection '${slug}'`;
|
|
85
|
+
if (result.kind === "not-mobile") return `custom view '${result.viewId}' is not a mobile view — declare target: "mobile" in its views[] entry`;
|
|
86
|
+
if (result.kind === "file-missing") return `view file '${result.file}' not found — author it at data/skills/${slug}/${result.file}`;
|
|
87
|
+
return `mobile view srcdoc is ${result.bytes} bytes — over the ${REMOTE_VIEW_MAX_BYTES}-byte command-channel budget; slim the HTML`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Mutate (phase 4 — plans/feat-remote-writable-view.md) ──
|
|
91
|
+
// A `target: "mobile"` view's update/delete, authorized by its OWN declared
|
|
92
|
+
// surface (editableFields / allowDelete) and enforced HOST-side — the client is
|
|
93
|
+
// never trusted. Shared by the `mutateRemoteViewItem` channel handler (phone)
|
|
94
|
+
// and the `…/remote-view/:viewId/mutate` HTTP route (desktop preview), so both
|
|
95
|
+
// transports apply identical policy. Discriminated result (not throw) mirrors
|
|
96
|
+
// the build result above.
|
|
97
|
+
|
|
98
|
+
/** True when a mobile view declared ANY write surface. Also gates the srcdoc's
|
|
99
|
+
* `writable` boot flag so the client only exposes methods the host will honor. */
|
|
100
|
+
function isWritableView(view: CollectionCustomView): boolean {
|
|
101
|
+
return (view.editableFields?.length ?? 0) > 0 || view.allowDelete === true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type MutateRemoteViewResult =
|
|
105
|
+
| { kind: "ok"; op: "update"; item: CollectionItem }
|
|
106
|
+
| { kind: "ok"; op: "delete"; id: string }
|
|
107
|
+
| { kind: "view-not-found"; viewId: string }
|
|
108
|
+
| { kind: "not-mobile"; viewId: string }
|
|
109
|
+
| { kind: "not-writable"; viewId: string }
|
|
110
|
+
| { kind: "field-not-editable"; field: string }
|
|
111
|
+
| { kind: "delete-not-allowed" }
|
|
112
|
+
| { kind: "invalid-patch" }
|
|
113
|
+
| { kind: "item-not-found"; id: string }
|
|
114
|
+
| { kind: "invalid-id"; id: string }
|
|
115
|
+
| { kind: "path-escape" };
|
|
116
|
+
|
|
117
|
+
export interface MutateRemoteViewDeps {
|
|
118
|
+
readItem: typeof readItem;
|
|
119
|
+
writeItem: typeof writeItem;
|
|
120
|
+
deleteItem: typeof deleteItem;
|
|
121
|
+
resolveThumbnail: typeof resolveThumbnail;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const createMutateRemoteView =
|
|
125
|
+
(deps: MutateRemoteViewDeps) =>
|
|
126
|
+
async (collection: LoadedCollection, viewId: string, request: RemoteViewMutateRequest): Promise<MutateRemoteViewResult> => {
|
|
127
|
+
const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
|
|
128
|
+
if (!view) return { kind: "view-not-found", viewId };
|
|
129
|
+
if (view.target !== "mobile") return { kind: "not-mobile", viewId };
|
|
130
|
+
if (!isWritableView(view)) return { kind: "not-writable", viewId };
|
|
131
|
+
return request.op === "delete" ? deleteViaView(deps, collection, view.allowDelete === true, request.id) : updateViaView(deps, collection, view, request);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
async function deleteViaView(deps: MutateRemoteViewDeps, collection: LoadedCollection, allowDelete: boolean, itemId: string): Promise<MutateRemoteViewResult> {
|
|
135
|
+
if (!allowDelete) return { kind: "delete-not-allowed" };
|
|
136
|
+
const result = await deps.deleteItem(collection.dataDir, itemId, { slug: collection.slug });
|
|
137
|
+
if (result.kind === "invalid-id") return { kind: "invalid-id", id: result.itemId };
|
|
138
|
+
if (result.kind === "path-escape") return { kind: "path-escape" };
|
|
139
|
+
if (result.kind === "not-found") return { kind: "item-not-found", id: result.itemId };
|
|
140
|
+
return { kind: "ok", op: "delete", id: result.itemId };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function updateViaView(
|
|
144
|
+
deps: MutateRemoteViewDeps,
|
|
145
|
+
collection: LoadedCollection,
|
|
146
|
+
view: CollectionCustomView,
|
|
147
|
+
request: Extract<RemoteViewMutateRequest, { op: "update" }>,
|
|
148
|
+
): Promise<MutateRemoteViewResult> {
|
|
149
|
+
const { primaryKey } = collection.schema;
|
|
150
|
+
const patchKeys = Object.keys(request.patch);
|
|
151
|
+
if (patchKeys.length === 0) return { kind: "invalid-patch" };
|
|
152
|
+
const allowed = new Set(view.editableFields ?? []);
|
|
153
|
+
// The primary key is never patchable (it is the record id — renaming it would
|
|
154
|
+
// desync the file name from the record) even if an author listed it.
|
|
155
|
+
const offending = patchKeys.find((key) => key === primaryKey || !allowed.has(key));
|
|
156
|
+
if (offending) return { kind: "field-not-editable", field: offending };
|
|
157
|
+
// Classify a bad id BEFORE readItem — which returns null for an unsafe id, a
|
|
158
|
+
// path-escape, AND a genuinely-missing record alike — so update reports the
|
|
159
|
+
// same explicit `invalid-id` the delete path does (via deleteItem) instead of
|
|
160
|
+
// masking it as a 404. (A valid id whose dataDir escapes the workspace can
|
|
161
|
+
// hold no record, so it still resolves to item-not-found; a real write is
|
|
162
|
+
// additionally refused by writeItem's own containment guard below.)
|
|
163
|
+
if (safeRecordId(request.id) === null) return { kind: "invalid-id", id: request.id };
|
|
164
|
+
const existing = await deps.readItem(collection.dataDir, request.id, { slug: collection.slug });
|
|
165
|
+
if (!existing) return { kind: "item-not-found", id: request.id };
|
|
166
|
+
const merged: CollectionItem = { ...existing, ...request.patch, [primaryKey]: request.id };
|
|
167
|
+
const result = await deps.writeItem(collection.dataDir, request.id, merged, { slug: collection.slug });
|
|
168
|
+
if (result.kind === "invalid-id") return { kind: "invalid-id", id: result.itemId };
|
|
169
|
+
if (result.kind === "path-escape") return { kind: "path-escape" };
|
|
170
|
+
if (result.kind === "conflict") return { kind: "item-not-found", id: result.itemId }; // unreachable: refuseOverwrite is false
|
|
171
|
+
// The returned item must be shaped like a `getItems` item — with the view's
|
|
172
|
+
// declared image fields inlined as `data:` URLs — so a view that merges the
|
|
173
|
+
// result (as the help file recommends) doesn't clobber a good thumbnail with a
|
|
174
|
+
// bare path. No projection here (the whole record is returned), so inline every
|
|
175
|
+
// declared image-type field. Budget the thumbnail against the serialized item
|
|
176
|
+
// (same as the page builder) so a record with large text/markdown fields can't
|
|
177
|
+
// push the mutate result over the command-document cap — over budget, the field
|
|
178
|
+
// stays a path (placeholder), never a doc-write failure.
|
|
179
|
+
const item = result.item as RemoteViewItem;
|
|
180
|
+
const imageFields = inlineFields(view, collection.schema, undefined);
|
|
181
|
+
if (imageFields.length > 0) {
|
|
182
|
+
const budget = REMOTE_VIEW_ITEMS_MAX_BYTES - Buffer.byteLength(JSON.stringify(item), "utf8");
|
|
183
|
+
await inlineImages([item], imageFields, clampImageMaxEdge(view.imageMaxEdge), deps.resolveThumbnail, Math.max(0, budget));
|
|
184
|
+
}
|
|
185
|
+
return { kind: "ok", op: "update", item: item as CollectionItem };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const mutateRemoteView = createMutateRemoteView({ readItem, writeItem, deleteItem, resolveThumbnail });
|
|
189
|
+
|
|
190
|
+
// ── Item pages with inlined image thumbnails (phase 5 — plans/feat-remote-view-images.md) ──
|
|
191
|
+
// A mobile view's `getItems`, view-aware so it can inline the `imageFields` its
|
|
192
|
+
// declaration whitelists: derive computed fields → slice/project (the phase-2
|
|
193
|
+
// page semantics) → replace each declared image-type field's workspace path with
|
|
194
|
+
// a downscaled `data:` URL thumbnail, within a per-page byte budget so the 1 MiB
|
|
195
|
+
// command doc is never risked. Shared by the `getRemoteViewItems` channel handler
|
|
196
|
+
// (phone) and the `…/remote-view/:viewId/items` HTTP route (desktop preview).
|
|
197
|
+
|
|
198
|
+
export type RemoteViewItemsResult =
|
|
199
|
+
{ kind: "ok"; page: RemoteViewPage; inlined: number; omitted: number } | { kind: "view-not-found"; viewId: string } | { kind: "not-mobile"; viewId: string };
|
|
200
|
+
|
|
201
|
+
export interface RemoteViewItemsDeps {
|
|
202
|
+
listItems: typeof listItems;
|
|
203
|
+
resolveThumbnail: typeof resolveThumbnail;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The declared image fields that are actually inlineable this page: image-type
|
|
207
|
+
* in the schema AND kept by the request's `fields` projection (a projection
|
|
208
|
+
* that dropped the column ships no image bytes). A declared non-image field is
|
|
209
|
+
* ignored, not an error. */
|
|
210
|
+
function inlineFields(view: CollectionCustomView, schema: CollectionSchema, requested: string[] | undefined): string[] {
|
|
211
|
+
const declared = view.imageFields ?? [];
|
|
212
|
+
if (declared.length === 0) return [];
|
|
213
|
+
const kept = requested ? new Set([schema.primaryKey, ...requested]) : null;
|
|
214
|
+
return declared.filter((name) => schema.fields[name]?.type === "image" && (kept === null || kept.has(name)));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Replace declared image paths with thumbnail `data:` URLs in place, stopping
|
|
218
|
+
* once the accumulated thumbnail bytes would exceed `budget` — a field left as
|
|
219
|
+
* its path (over budget, unresolvable, or already inlined) counts as `omitted`
|
|
220
|
+
* and the view renders it as a placeholder. */
|
|
221
|
+
async function inlineImages(
|
|
222
|
+
items: RemoteViewItem[],
|
|
223
|
+
fields: string[],
|
|
224
|
+
maxEdge: number,
|
|
225
|
+
resolve: typeof resolveThumbnail,
|
|
226
|
+
budget: number,
|
|
227
|
+
): Promise<{ inlined: number; omitted: number }> {
|
|
228
|
+
let used = 0;
|
|
229
|
+
let inlined = 0;
|
|
230
|
+
let omitted = 0;
|
|
231
|
+
for (const item of items) {
|
|
232
|
+
for (const field of fields) {
|
|
233
|
+
const value = item[field];
|
|
234
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("data:")) continue;
|
|
235
|
+
const dataUrl = used < budget ? await resolve(value, maxEdge) : null;
|
|
236
|
+
if (dataUrl && used + dataUrl.length <= budget) {
|
|
237
|
+
item[field] = dataUrl;
|
|
238
|
+
used += dataUrl.length;
|
|
239
|
+
inlined += 1;
|
|
240
|
+
} else {
|
|
241
|
+
omitted += 1;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { inlined, omitted };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const createRemoteViewItems =
|
|
249
|
+
(deps: RemoteViewItemsDeps) =>
|
|
250
|
+
async (collection: LoadedCollection, viewId: string, request: RemoteViewPageRequest): Promise<RemoteViewItemsResult> => {
|
|
251
|
+
const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
|
|
252
|
+
if (!view) return { kind: "view-not-found", viewId };
|
|
253
|
+
if (view.target !== "mobile") return { kind: "not-mobile", viewId };
|
|
254
|
+
// Derive record-local formulas with an empty ref cache — same as the phase-2
|
|
255
|
+
// channel handlers and the preview, so `getItems` numbers match the desktop.
|
|
256
|
+
const derived = (await deps.listItems(collection.dataDir)).map((item) => deriveAll(collection.schema, item, {}) as RemoteViewItem);
|
|
257
|
+
const page = pageFromItems(derived, request, collection.schema.primaryKey);
|
|
258
|
+
const fields = inlineFields(view, collection.schema, request.fields);
|
|
259
|
+
if (fields.length === 0) return { kind: "ok", page, inlined: 0, omitted: 0 };
|
|
260
|
+
// Budget the thumbnails against what's left of the doc after the (tiny,
|
|
261
|
+
// path-only) base JSON, so the serialized page stays under the cap.
|
|
262
|
+
const budget = REMOTE_VIEW_ITEMS_MAX_BYTES - Buffer.byteLength(JSON.stringify(page), "utf8");
|
|
263
|
+
const { inlined, omitted } = await inlineImages(page.items, fields, clampImageMaxEdge(view.imageMaxEdge), deps.resolveThumbnail, Math.max(0, budget));
|
|
264
|
+
return { kind: "ok", page, inlined, omitted };
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
export const remoteViewItems = createRemoteViewItems({ listItems, resolveThumbnail });
|
|
268
|
+
|
|
269
|
+
/** Message per non-ok item-page kind — shared by the channel handler (throws)
|
|
270
|
+
* and the HTTP route (sends with the matching status). */
|
|
271
|
+
export function remoteViewItemsFailureMessage(result: Exclude<RemoteViewItemsResult, { kind: "ok" }>, slug: string): string {
|
|
272
|
+
if (result.kind === "not-mobile") return `custom view '${result.viewId}' is not a mobile view — declare target: "mobile" in its views[] entry`;
|
|
273
|
+
return `custom view '${result.viewId}' not found on collection '${slug}'`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Message per non-ok mutate kind — shared by the channel handler (throws) and
|
|
277
|
+
* the HTTP route (sends with the matching status). */
|
|
278
|
+
export function mutateRemoteViewFailureMessage(result: Exclude<MutateRemoteViewResult, { kind: "ok" }>, slug: string): string {
|
|
279
|
+
if (result.kind === "view-not-found") return `custom view '${result.viewId}' not found on collection '${slug}'`;
|
|
280
|
+
if (result.kind === "not-mobile") return `custom view '${result.viewId}' is not a mobile view — declare target: "mobile" in its views[] entry`;
|
|
281
|
+
if (result.kind === "not-writable")
|
|
282
|
+
return `mobile view '${result.viewId}' is read-only — declare editableFields and/or allowDelete in its views[] entry to allow writes`;
|
|
283
|
+
if (result.kind === "field-not-editable")
|
|
284
|
+
return `field '${result.field}' is not editable from this view — add it to the view's editableFields (the primary key is never editable)`;
|
|
285
|
+
if (result.kind === "delete-not-allowed") return `this view may not delete records — set allowDelete: true in its views[] entry`;
|
|
286
|
+
if (result.kind === "invalid-patch") return `update patch must be a non-empty object of field changes`;
|
|
287
|
+
if (result.kind === "item-not-found") return `item '${result.id}' not found in collection '${slug}'`;
|
|
288
|
+
if (result.kind === "invalid-id") return `invalid item id: ${result.id}`;
|
|
289
|
+
return `data directory for collection '${slug}' escapes the workspace`;
|
|
290
|
+
}
|
|
@@ -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
|
@@ -320,7 +320,7 @@
|
|
|
320
320
|
</template>
|
|
321
321
|
|
|
322
322
|
<script setup lang="ts">
|
|
323
|
-
import { ref, computed, watch, nextTick, onMounted, reactive } from "vue";
|
|
323
|
+
import { ref, computed, watch, nextTick, onMounted, onScopeDispose, reactive } from "vue";
|
|
324
324
|
import { useI18n } from "vue-i18n";
|
|
325
325
|
import { v4 as uuidv4 } from "uuid";
|
|
326
326
|
import { getPlugin } from "./tools";
|
|
@@ -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";
|
|
@@ -425,7 +426,7 @@ const currentSessionId = ref("");
|
|
|
425
426
|
// --- Debug beat (pub/sub) ---
|
|
426
427
|
const { debugTitleStyle } = useDebugBeat();
|
|
427
428
|
|
|
428
|
-
const { subscribe: pubsubSubscribe } = usePubSub();
|
|
429
|
+
const { subscribe: pubsubSubscribe, onReconnect: pubsubOnReconnect } = usePubSub();
|
|
429
430
|
|
|
430
431
|
// --- Routing ---
|
|
431
432
|
const route = useRoute();
|
|
@@ -482,7 +483,7 @@ const pastedFiles = ref<PastedFile[]>([]);
|
|
|
482
483
|
const activePane = ref<"sidebar" | "main">("sidebar");
|
|
483
484
|
|
|
484
485
|
const { sessions, historyError, fetchSessions, setBookmark, deleteSession: deleteSessionFromHistory } = useSessionHistory();
|
|
485
|
-
const { markSessionRead } = useSessionSync({
|
|
486
|
+
const { markSessionRead, refreshSessionStates } = useSessionSync({
|
|
486
487
|
sessionMap,
|
|
487
488
|
currentSessionId,
|
|
488
489
|
fetchSessions,
|
|
@@ -991,6 +992,16 @@ function hasPendingGenerations(sessionId: string): boolean {
|
|
|
991
992
|
}
|
|
992
993
|
|
|
993
994
|
function handleSessionFinished(sessionId: string): void {
|
|
995
|
+
// Trust the definitive server signal and flip the local indicator
|
|
996
|
+
// immediately (#1915 Fix A). Without this, the "thinking" spinner stays
|
|
997
|
+
// stuck until the `sessions` channel notification arrives via a separate
|
|
998
|
+
// socket.io frame — which can go missing on a network hiccup or on
|
|
999
|
+
// Safari's tab-throttling flow while the sessionChannel frame did land.
|
|
1000
|
+
const session = sessionMap.get(sessionId);
|
|
1001
|
+
if (session) {
|
|
1002
|
+
session.isRunning = false;
|
|
1003
|
+
session.statusMessage = "";
|
|
1004
|
+
}
|
|
994
1005
|
refreshSessionTranscript(sessionId).catch((err) => {
|
|
995
1006
|
console.error("[handleSessionFinished] refresh failed:", err);
|
|
996
1007
|
});
|
|
@@ -1001,6 +1012,50 @@ function handleSessionFinished(sessionId: string): void {
|
|
|
1001
1012
|
}
|
|
1002
1013
|
}
|
|
1003
1014
|
|
|
1015
|
+
// After the client silently loses events, this pulls fresh state from the
|
|
1016
|
+
// server so the UI recovers without a page reload (#1915). Two trigger
|
|
1017
|
+
// surfaces:
|
|
1018
|
+
// - socket.io reconnect (network hiccup, WS bounce)
|
|
1019
|
+
// - document visibility flips to `visible` (Safari's silent tab
|
|
1020
|
+
// throttling — WS keeps `connected` on the server but delivery stops
|
|
1021
|
+
// while the tab is backgrounded, and there's no `disconnect` event to
|
|
1022
|
+
// hook on reconnect)
|
|
1023
|
+
// refreshSessionStates() carries its own sequence guard inside
|
|
1024
|
+
// useSessionSync so concurrent catch-ups can't overwrite newer live state
|
|
1025
|
+
// with an older-but-slower response. refreshSessionTranscript() only
|
|
1026
|
+
// upgrades toolResults when the server view is strictly larger, so it's
|
|
1027
|
+
// already idempotent against interleaving.
|
|
1028
|
+
function catchUpMissedEvents(reason: "reconnect" | "visibility"): void {
|
|
1029
|
+
console.info(`[chat-ui] catching up after ${reason}`);
|
|
1030
|
+
refreshSessionStates().catch((err) => {
|
|
1031
|
+
console.warn("[chat-ui] refreshSessionStates failed:", err);
|
|
1032
|
+
});
|
|
1033
|
+
const currentId = currentSessionId.value;
|
|
1034
|
+
if (currentId) {
|
|
1035
|
+
refreshSessionTranscript(currentId).catch((err) => {
|
|
1036
|
+
console.warn("[chat-ui] refreshSessionTranscript failed:", err);
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Capture the unsubscribe so remount / HMR doesn't accumulate stale
|
|
1042
|
+
// module-level reconnect handlers (Codex review).
|
|
1043
|
+
const unsubReconnect = pubsubOnReconnect(() => catchUpMissedEvents("reconnect"));
|
|
1044
|
+
|
|
1045
|
+
function handleVisibilityChange(): void {
|
|
1046
|
+
if (document.visibilityState === "visible") {
|
|
1047
|
+
catchUpMissedEvents("visibility");
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
onMounted(() => {
|
|
1052
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
1053
|
+
});
|
|
1054
|
+
onScopeDispose(() => {
|
|
1055
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
1056
|
+
unsubReconnect();
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1004
1059
|
function createSessionEventHandler(session: ActiveSession, ctx: AgentEventContext): (data: unknown) => void {
|
|
1005
1060
|
return (data: unknown) => {
|
|
1006
1061
|
const event = data as SseEvent;
|
|
@@ -1207,6 +1262,10 @@ installCollectionAppBindings({
|
|
|
1207
1262
|
startNewChatDraft: (prompt: string, role?: string) => startNewChatDraft(prompt, role),
|
|
1208
1263
|
notifiedSeverities: (slug: string) => collectionNotifiedSeverities(notifierEntries.value, slug),
|
|
1209
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();
|
|
1210
1269
|
// Plugin Views that need to tag background work with the current
|
|
1211
1270
|
// session (e.g. MulmoScript generations) inject this.
|
|
1212
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
|