mulmoclaude 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/client/assets/{PluginScopedRoot-B53YSjaC.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
  3. package/client/assets/index-7QnbsZZr.css +2 -0
  4. package/client/assets/{index-DUdqhWg6.js → index-C3SRVT2p.js} +71 -71
  5. package/client/assets/{marp-CyWxL3bc.js → marp-DxctLEy1.js} +1 -1
  6. package/client/index.html +3 -3
  7. package/package.json +5 -5
  8. package/server/agent/mcp-server.ts +27 -8
  9. package/server/api/auth/viewToken.ts +7 -1
  10. package/server/api/routes/agent.ts +14 -1
  11. package/server/api/routes/collections.ts +127 -9
  12. package/server/build/dispatcher.mjs +2 -2
  13. package/server/events/collection-change.ts +1 -0
  14. package/server/events/pub-sub/index.ts +15 -2
  15. package/server/events/relay-client.ts +6 -1
  16. package/server/events/session-store/index.ts +11 -2
  17. package/server/index.ts +26 -2
  18. package/server/prompts/system/system.md +0 -1
  19. package/server/remoteHost/handlers/getFeed.ts +10 -5
  20. package/server/services/translation/index.ts +10 -3
  21. package/server/utils/errors.ts +7 -21
  22. package/server/utils/text.ts +7 -23
  23. package/server/workspace/collections/index.ts +5 -4
  24. package/server/workspace/collections/remoteView.ts +21 -21
  25. package/server/workspace/collections/watcher.ts +1 -0
  26. package/server/workspace/custom-dirs.ts +16 -6
  27. package/server/workspace/reference-dirs.ts +8 -3
  28. package/src/components/SettingsGoogleTab.vue +4 -1
  29. package/src/components/StackView.vue +12 -1
  30. package/src/composables/collections/uiHost.ts +2 -1
  31. package/src/composables/useChatScroll.ts +18 -6
  32. package/src/composables/useFileSelection.ts +4 -1
  33. package/src/composables/useStickToBottom.ts +51 -0
  34. package/src/config/apiRoutes.ts +14 -0
  35. package/src/utils/dom/scrollable.ts +15 -0
  36. package/src/utils/errors.ts +7 -38
  37. package/client/assets/index-D3czxq1I.css +0 -2
@@ -23,17 +23,14 @@ import {
23
23
  } from "@mulmoclaude/core/remote-view";
24
24
  import { enrichItems } from "@mulmoclaude/core/collection/server";
25
25
  import {
26
- collectionWritable,
27
- deleteItem,
28
26
  readCustomViewHtml,
29
27
  readCustomViewI18n,
30
- readItem,
31
28
  safeRecordId,
32
29
  storeFor,
33
- writeItem,
34
30
  type CollectionCustomView,
35
31
  type CollectionItem,
36
32
  type CollectionSchema,
33
+ type CollectionStore,
37
34
  type LoadedCollection,
38
35
  } from "./index.js";
39
36
  import { resolveThumbnail } from "../../utils/files/thumbnail-store.js";
@@ -118,9 +115,7 @@ export type MutateRemoteViewResult =
118
115
  | { kind: "path-escape" };
119
116
 
120
117
  export interface MutateRemoteViewDeps {
121
- readItem: typeof readItem;
122
- writeItem: typeof writeItem;
123
- deleteItem: typeof deleteItem;
118
+ storeFor: (collection: LoadedCollection) => CollectionStore;
124
119
  enrichItems: typeof enrichItems;
125
120
  resolveThumbnail: typeof resolveThumbnail;
126
121
  }
@@ -132,15 +127,17 @@ export const createMutateRemoteView =
132
127
  if (!view) return { kind: "view-not-found", viewId };
133
128
  if (view.target !== "mobile") return { kind: "not-mobile", viewId };
134
129
  // A dataSource collection is read-only regardless of what write surface
135
- // the view declares — the collection-level rule outranks the view's.
136
- if (!collectionWritable(collection)) return { kind: "read-only-collection" };
130
+ // the view declares — the collection-level rule outranks the view's. The
131
+ // store encodes it as absent write/delete methods.
132
+ const store = deps.storeFor(collection);
133
+ if (!store.write || !store.delete) return { kind: "read-only-collection" };
137
134
  if (!isWritableView(view)) return { kind: "not-writable", viewId };
138
- return request.op === "delete" ? deleteViaView(deps, collection, view.allowDelete === true, request.id) : updateViaView(deps, collection, view, request);
135
+ return request.op === "delete" ? deleteViaView(store.delete, view.allowDelete === true, request.id) : updateViaView(deps, store, collection, view, request);
139
136
  };
140
137
 
141
- async function deleteViaView(deps: MutateRemoteViewDeps, collection: LoadedCollection, allowDelete: boolean, itemId: string): Promise<MutateRemoteViewResult> {
138
+ async function deleteViaView(remove: NonNullable<CollectionStore["delete"]>, allowDelete: boolean, itemId: string): Promise<MutateRemoteViewResult> {
142
139
  if (!allowDelete) return { kind: "delete-not-allowed" };
143
- const result = await deps.deleteItem(collection.dataDir, itemId, { slug: collection.slug });
140
+ const result = await remove(itemId);
144
141
  if (result.kind === "invalid-id") return { kind: "invalid-id", id: result.itemId };
145
142
  if (result.kind === "path-escape") return { kind: "path-escape" };
146
143
  if (result.kind === "not-found") return { kind: "item-not-found", id: result.itemId };
@@ -149,10 +146,13 @@ async function deleteViaView(deps: MutateRemoteViewDeps, collection: LoadedColle
149
146
 
150
147
  async function updateViaView(
151
148
  deps: MutateRemoteViewDeps,
149
+ store: CollectionStore,
152
150
  collection: LoadedCollection,
153
151
  view: CollectionCustomView,
154
152
  request: Extract<RemoteViewMutateRequest, { op: "update" }>,
155
153
  ): Promise<MutateRemoteViewResult> {
154
+ const { write } = store;
155
+ if (!write) return { kind: "read-only-collection" }; // unreachable: caller guards presence
156
156
  const { primaryKey } = collection.schema;
157
157
  const patchKeys = Object.keys(request.patch);
158
158
  if (patchKeys.length === 0) return { kind: "invalid-patch" };
@@ -161,17 +161,17 @@ async function updateViaView(
161
161
  // desync the file name from the record) even if an author listed it.
162
162
  const offending = patchKeys.find((key) => key === primaryKey || !allowed.has(key));
163
163
  if (offending) return { kind: "field-not-editable", field: offending };
164
- // Classify a bad id BEFORE readItem — which returns null for an unsafe id, a
165
- // path-escape, AND a genuinely-missing record alike — so update reports the
166
- // same explicit `invalid-id` the delete path does (via deleteItem) instead of
167
- // masking it as a 404. (A valid id whose dataDir escapes the workspace can
168
- // hold no record, so it still resolves to item-not-found; a real write is
169
- // additionally refused by writeItem's own containment guard below.)
164
+ // Classify a bad id BEFORE the store read — which returns null for an unsafe
165
+ // id, a path-escape, AND a genuinely-missing record alike — so update reports
166
+ // the same explicit `invalid-id` the delete path does (via `store.delete`)
167
+ // instead of masking it as a 404. (A valid id whose data location escapes the
168
+ // workspace can hold no record, so it still resolves to item-not-found; a
169
+ // real write is additionally refused by the store's own containment guard.)
170
170
  if (safeRecordId(request.id) === null) return { kind: "invalid-id", id: request.id };
171
- const existing = await deps.readItem(collection.dataDir, request.id, { slug: collection.slug });
171
+ const existing = await store.read(request.id);
172
172
  if (!existing) return { kind: "item-not-found", id: request.id };
173
173
  const merged: CollectionItem = { ...existing, ...request.patch, [primaryKey]: request.id };
174
- const result = await deps.writeItem(collection.dataDir, request.id, merged, { slug: collection.slug });
174
+ const result = await write(request.id, merged);
175
175
  if (result.kind === "invalid-id") return { kind: "invalid-id", id: result.itemId };
176
176
  if (result.kind === "path-escape") return { kind: "path-escape" };
177
177
  if (result.kind === "conflict") return { kind: "item-not-found", id: result.itemId }; // unreachable: refuseOverwrite is false
@@ -201,7 +201,7 @@ async function updateViaView(
201
201
  return { kind: "ok", op: "update", item: item as CollectionItem };
202
202
  }
203
203
 
204
- export const mutateRemoteView = createMutateRemoteView({ readItem, writeItem, deleteItem, enrichItems, resolveThumbnail });
204
+ export const mutateRemoteView = createMutateRemoteView({ storeFor, enrichItems, resolveThumbnail });
205
205
 
206
206
  // ── Item pages with inlined image thumbnails (phase 5 — plans/feat-remote-view-images.md) ──
207
207
  // A mobile view's `getItems`, view-aware so it can inline the `imageFields` its
@@ -15,5 +15,6 @@ export {
15
15
  _syncWatchersForTesting,
16
16
  _tickTimeTriggersForTesting,
17
17
  _scheduleItemReconcileForTesting,
18
+ _scheduleStorageReconcileForTesting,
18
19
  type CollectionWatcherOptions,
19
20
  } from "@mulmoclaude/core/collection-watchers";
@@ -9,7 +9,7 @@ import path from "path";
9
9
  import { workspacePath, WORKSPACE_DIRS } from "./paths.js";
10
10
  import { log } from "../system/logger/index.js";
11
11
  import { writeJsonAtomicSync } from "../utils/files/json.js";
12
- import { isRecord } from "../utils/types.js";
12
+ import { hasStringProp, isRecord } from "../utils/types.js";
13
13
 
14
14
  // ── Types ───────────────────────────────────────────────────────
15
15
 
@@ -82,16 +82,23 @@ function sanitizeDescription(raw: string): string {
82
82
 
83
83
  function validateEntry(raw: unknown): CustomDirEntry | null {
84
84
  if (!isRecord(raw)) return null;
85
- const obj = raw as Record<string, unknown>;
86
85
 
87
- const validPath = validatePath(String(obj.path ?? ""));
86
+ // Type-check the field rather than `String(...)`-ing it. This file is fed by a
87
+ // hand-edited config, so `path` can be an array or object; stringifying one
88
+ // handed `validatePath` the literal "[object Object]" to evaluate as a path.
89
+ // (`validatePath`'s own `typeof !== "string"` check could never fire behind a
90
+ // `String()` call.)
91
+ if (!hasStringProp(raw, "path")) return null;
92
+ const validPath = validatePath(raw.path);
88
93
  if (!validPath) return null;
89
94
 
90
- const structure = isValidStructure(obj.structure) ? obj.structure : DIR_STRUCTURES.flat;
95
+ const structure = isValidStructure(raw.structure) ? raw.structure : DIR_STRUCTURES.flat;
91
96
 
92
97
  return {
93
98
  path: validPath,
94
- description: sanitizeDescription(String(obj.description ?? "")),
99
+ // A non-string description is dropped rather than described as
100
+ // "[object Object]" — it is optional, so absent is the honest reading.
101
+ description: sanitizeDescription(hasStringProp(raw, "description") ? raw.description : ""),
95
102
  structure,
96
103
  };
97
104
  }
@@ -153,7 +160,10 @@ export function validateCustomDirs(raw: unknown): { entries: CustomDirEntry[] }
153
160
  if (entry) {
154
161
  entries.push(entry);
155
162
  } else {
156
- const itemPath = isRecord(item) ? String((item as Record<string, unknown>).path ?? "") : "";
163
+ // Only echo a genuine string back in the error; a non-string `path` is
164
+ // exactly the case where "[object Object]" would mislead the reader about
165
+ // what their config actually says.
166
+ const itemPath = hasStringProp(item, "path") ? item.path : "";
157
167
  errors.push(`entry ${i}: invalid path "${itemPath}"`);
158
168
  }
159
169
  });
@@ -11,7 +11,7 @@ import path from "path";
11
11
  import { homedir } from "os";
12
12
  import { log } from "../system/logger/index.js";
13
13
  import { readReferenceDirsJson, writeReferenceDirsJson, isExistingDirectory } from "../utils/files/reference-dirs-io.js";
14
- import { isRecord } from "../utils/types.js";
14
+ import { hasStringProp, isRecord } from "../utils/types.js";
15
15
 
16
16
  // ── Types ───────────────────────────────────────────────────────
17
17
 
@@ -104,7 +104,10 @@ function validateEntry(raw: unknown): ReferenceDirEntry | null {
104
104
  return null;
105
105
  }
106
106
 
107
- const label = sanitizeLabel(String(obj.label ?? path.basename(absPath)));
107
+ // Type-check like `hostPath` above rather than `String(...)`-ing: a
108
+ // non-string `label` in the hand-edited config falls back to the basename
109
+ // instead of labelling the directory "[object Object]".
110
+ const label = sanitizeLabel(hasStringProp(raw, "label") ? raw.label : path.basename(absPath));
108
111
 
109
112
  return { hostPath: absPath, label };
110
113
  }
@@ -155,7 +158,9 @@ export function validateReferenceDirs(raw: unknown): { entries: ReferenceDirEntr
155
158
  if (entry) {
156
159
  entries.push(entry);
157
160
  } else {
158
- const hostPath = isRecord(item) ? String((item as Record<string, unknown>).hostPath ?? "") : "";
161
+ // Echo the path back only when it really is one a non-string entry is
162
+ // precisely where "[object Object]" would misreport the user's config.
163
+ const hostPath = hasStringProp(item, "hostPath") ? item.hostPath : "";
159
164
  errors.push(`entry ${i}: invalid or blocked path "${hostPath}"`);
160
165
  }
161
166
  });
@@ -11,10 +11,13 @@
11
11
 
12
12
  <div v-if="loaded" class="flex items-center gap-3">
13
13
  <span class="text-sm" :class="statusColour" data-testid="settings-google-status">{{ statusText }}</span>
14
+ <!-- Stays clickable while `pending`: a user who abandoned the browser
15
+ consent can restart the link instead of waiting out the server-side
16
+ timeout (the click aborts the stale flow and opens a fresh one). -->
14
17
  <button
15
18
  v-if="!linked"
16
19
  class="px-2 py-1 text-xs rounded bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
17
- :disabled="busy || pending || clientSecret === 'ambiguous'"
20
+ :disabled="busy || clientSecret === 'ambiguous'"
18
21
  data-testid="settings-google-connect-btn"
19
22
  @click="connect"
20
23
  >
@@ -128,6 +128,7 @@ import type { ToolResultComplete } from "gui-chat-protocol/vue";
128
128
  import { View as TextResponseOriginalView } from "../plugins/textResponse/index";
129
129
  import { handleExternalLinkClick } from "../utils/dom/externalLink";
130
130
  import { clampIframeHeight } from "../utils/dom/iframeHeightClamp";
131
+ import { isNearBottom } from "../utils/dom/scrollable";
131
132
  import type { TextResponseData } from "../plugins/textResponse/types";
132
133
  import { formatSmartTime } from "../utils/format/date";
133
134
  import { isRecord } from "../utils/types";
@@ -231,6 +232,9 @@ const emit = defineEmits<{
231
232
  }>();
232
233
 
233
234
  const containerRef = ref<HTMLDivElement | null>(null);
235
+ // Auto-follow gate: true while the reader is at the bottom. Updated from the
236
+ // container's scroll handler (#2179).
237
+ const stickToBottom = ref(true);
234
238
  const itemRefs = new Map<string, HTMLElement>();
235
239
  const naturalWrapperRefs = new Map<string, HTMLElement>();
236
240
 
@@ -445,6 +449,11 @@ function computeActiveUuidFromScroll(): string | null {
445
449
 
446
450
  function onContainerScroll(): void {
447
451
  if (suppressScrollSync) return;
452
+ // Only a genuine user scroll moves the auto-follow gate. Programmatic
453
+ // scrolls are suppressed above — otherwise following would cancel itself:
454
+ // the newest-card jump lands away from the bottom, which would read as
455
+ // "the user scrolled up" and stop the next chunk from arriving.
456
+ if (containerRef.value) stickToBottom.value = isNearBottom(containerRef.value);
448
457
  if (scrollSpyRafId !== null) return;
449
458
  scrollSpyRafId = requestAnimationFrame(() => {
450
459
  scrollSpyRafId = null;
@@ -490,7 +499,9 @@ const latestResultScrollKey = computed(() => {
490
499
 
491
500
  watch(latestResultScrollKey, () => {
492
501
  nextTick(() => {
493
- if (containerRef.value) {
502
+ // Streaming fires this on every chunk — only follow while the reader is
503
+ // still parked at the bottom, so scrolling up to read stays put (#2179).
504
+ if (containerRef.value && stickToBottom.value) {
494
505
  beginSuppressScrollSync();
495
506
  const newest = props.toolResults[props.toolResults.length - 1];
496
507
  const target = resolveLatestScrollTarget(displayItems.value, newest, (result) => result.uuid);
@@ -43,7 +43,7 @@ import { useConfirm } from "../useConfirm";
43
43
  import { useShortcuts } from "../useShortcuts";
44
44
  import PinToggle from "../../components/PinToggle.vue";
45
45
  import type { NotifierSeverity } from "../../utils/collections/notifiedItems";
46
- import type { CollectionsListResponse, FeedsListResponse } from "@mulmoclaude/core/collection";
46
+ import type { CollectionsListResponse, CollectionOntologyResponse, FeedsListResponse } from "@mulmoclaude/core/collection";
47
47
  import type { TranslateResponse } from "@mulmoclaude/core/translation/client";
48
48
  import type { CollectionDetailResponse, ItemMutationResponse } from "../../components/collectionTypes";
49
49
 
@@ -167,6 +167,7 @@ configureCollectionUi({
167
167
  // index pages
168
168
  listCollections: () => apiGet<CollectionsListResponse>(API_ROUTES.collections.list),
169
169
  listFeeds: () => apiGet<FeedsListResponse>(API_ROUTES.feeds.list),
170
+ fetchOntology: () => apiGet<CollectionOntologyResponse>(API_ROUTES.collections.ontology),
170
171
  listRegistry: () => apiGet<RegistryListResponse>(API_ROUTES.collectionsRegistry.list),
171
172
  importRegistry: (author, slug, registry) => apiPost<RegistryImportResponse>(API_ROUTES.collectionsRegistry.import, { author, slug, registry }),
172
173
  reconcileShortcuts: (kind, live) => useShortcuts().reconcile(kind, live),
@@ -1,9 +1,16 @@
1
1
  // Auto-scroll the sidebar chat list to the bottom when new results
2
2
  // arrive or a run starts. Also re-focuses the chat input when a run
3
3
  // finishes.
4
+ //
5
+ // Following is gated on the reader still being at the bottom: streaming
6
+ // appends fire this watch on every chunk, and forcing the scroll each
7
+ // time dragged the view out from under anyone who had scrolled up to
8
+ // read (#2179). A run starting is treated as an explicit user action
9
+ // (they just sent something), so that one re-arms and jumps.
4
10
 
5
11
  import { computed, nextTick, watch, type ComputedRef, type Ref } from "vue";
6
12
  import type { ToolResultComplete } from "gui-chat-protocol/vue";
13
+ import { useStickToBottom } from "./useStickToBottom";
7
14
 
8
15
  export function useChatScroll<T extends { focus: () => void }>(opts: {
9
16
  sessionSidebarRef: Ref<{ root: HTMLDivElement | null } | null>;
@@ -14,6 +21,7 @@ export function useChatScroll<T extends { focus: () => void }>(opts: {
14
21
  const { sessionSidebarRef, toolResults, isRunning, chatInputRef } = opts;
15
22
 
16
23
  const chatListRef = computed(() => sessionSidebarRef.value?.root ?? null);
24
+ const { stuck, resume } = useStickToBottom(chatListRef);
17
25
  // Key that changes both on new results AND on streaming updates to
18
26
  // the last text card (which appends in place, leaving length stable).
19
27
  const latestResultScrollKey = computed(() => {
@@ -22,8 +30,11 @@ export function useChatScroll<T extends { focus: () => void }>(opts: {
22
30
  return `${list.length}:${last?.uuid ?? ""}:${last?.message?.length ?? 0}`;
23
31
  });
24
32
 
25
- function scrollChatToBottom(): void {
26
- nextTick(() => {
33
+ function scrollChatToBottom(options: { force?: boolean } = {}): void {
34
+ if (!options.force && !stuck.value) return;
35
+ // Scrolling after the DOM settles is the whole point; callers are sync and
36
+ // have nothing to do with the tick's completion.
37
+ void nextTick(() => {
27
38
  if (chatListRef.value) {
28
39
  chatListRef.value.scrollTop = chatListRef.value.scrollHeight;
29
40
  }
@@ -34,14 +45,15 @@ export function useChatScroll<T extends { focus: () => void }>(opts: {
34
45
  chatInputRef.value?.focus();
35
46
  }
36
47
 
37
- watch(latestResultScrollKey, scrollChatToBottom);
48
+ watch(latestResultScrollKey, () => scrollChatToBottom());
38
49
  watch(isRunning, (running) => {
39
50
  if (running) {
40
- scrollChatToBottom();
51
+ resume();
52
+ scrollChatToBottom({ force: true });
41
53
  } else {
42
- nextTick(() => focusChatInput());
54
+ void nextTick(() => focusChatInput());
43
55
  }
44
56
  });
45
57
 
46
- return { scrollChatToBottom, focusChatInput };
58
+ return { scrollChatToBottom, focusChatInput, stuckToBottom: stuck };
47
59
  }
@@ -41,7 +41,10 @@ export function useFileSelection() {
41
41
 
42
42
  function selectFile(filePath: string): void {
43
43
  selectedPath.value = filePath;
44
- loadContent(filePath);
44
+ // Fire-and-forget: selection must not block on I/O, and `loadContent`
45
+ // catches internally so this cannot reject — its outcome is read off
46
+ // `contentLoading` / `contentError`.
47
+ void loadContent(filePath);
45
48
  // Pass segments as an array so Vue Router encodes each segment
46
49
  // independently (spaces / multi-byte / `?#%` get UTF-8 percent-
47
50
  // encoding), while slashes stay as path separators. Passing the
@@ -0,0 +1,51 @@
1
+ // Tracks whether a scroll container is parked at (or just above) its bottom,
2
+ // so auto-follow callers can stop yanking the view away from a reader who
3
+ // scrolled up mid-stream (#2179).
4
+ //
5
+ // Starts stuck: a fresh list follows new output until the reader deliberately
6
+ // scrolls away, and re-arms the moment they scroll back down — the Slack /
7
+ // Discord behaviour. Only real scroll events move the flag, so a container
8
+ // that simply grows taller below the viewport keeps following.
9
+
10
+ import { getCurrentInstance, onBeforeUnmount, ref, watch, type Ref } from "vue";
11
+ import { isNearBottom, NEAR_BOTTOM_THRESHOLD_PX } from "../utils/dom/scrollable";
12
+
13
+ export function useStickToBottom(elementRef: Ref<HTMLElement | null>, thresholdPx: number = NEAR_BOTTOM_THRESHOLD_PX) {
14
+ const stuck = ref(true);
15
+ let attached: HTMLElement | null = null;
16
+
17
+ const sync = (): void => {
18
+ if (attached) stuck.value = isNearBottom(attached, thresholdPx);
19
+ };
20
+
21
+ const detach = (): void => {
22
+ attached?.removeEventListener("scroll", sync);
23
+ attached = null;
24
+ };
25
+
26
+ watch(
27
+ elementRef,
28
+ (element) => {
29
+ detach();
30
+ if (!element) return;
31
+ attached = element;
32
+ // A freshly mounted container follows again — otherwise a reader who
33
+ // had scrolled away in a previous list would find the new one silently
34
+ // not following.
35
+ stuck.value = true;
36
+ element.addEventListener("scroll", sync, { passive: true });
37
+ },
38
+ { immediate: true },
39
+ );
40
+
41
+ // Guarded so the composable can also be driven directly from a test, where
42
+ // there is no component instance to own the hook.
43
+ if (getCurrentInstance()) onBeforeUnmount(detach);
44
+
45
+ /** Re-arm following after an explicit jump-to-bottom (sending a message). */
46
+ const resume = (): void => {
47
+ stuck.value = true;
48
+ };
49
+
50
+ return { stuck, resume };
51
+ }
@@ -300,6 +300,12 @@ const HOST_API_ROUTES = {
300
300
  // host renders its records via `<CollectionView>`.
301
301
  collections: {
302
302
  list: "/api/collections",
303
+ /** GET → { entries: CollectionOntologyEntry[] } — the raw workspace
304
+ * ontology; the /collections Map tab builds its graph client-side
305
+ * from these via the shared `buildOntologyGraph` (server/client
306
+ * parity). Must be registered BEFORE `detail` so "ontology" is
307
+ * never matched as a `:slug`. */
308
+ ontology: "/api/collections/ontology",
303
309
  /** GET → { collection, items } */
304
310
  detail: "/api/collections/:slug",
305
311
  /** POST → create one record (auto-id when primaryKey value omitted) */
@@ -363,6 +369,14 @@ const HOST_API_ROUTES = {
363
369
  * read-only by construction (see core/queryZ.ts); 400 on a file-backed
364
370
  * collection (no query engine). Backs chart-drawing custom views. */
365
371
  viewDataQuery: "/api/collections/:slug/view-data/query",
372
+ /** GET → resolve one record-referenced `image` field value (a workspace
373
+ * path, `?path=…`) into a downscaled thumbnail → { path, dataUrl }.
374
+ * Requires only the `read` capability; the path must be a CURRENT value
375
+ * of one of the schema's image-type fields (no arbitrary file reads).
376
+ * Optional `?maxEdge=` (clamped 64–1024, default 512). This is how a
377
+ * desktop custom view displays workspace image files — sibling of the
378
+ * remote view's `imageFields` inlining. */
379
+ viewDataImage: "/api/collections/:slug/view-data/image",
366
380
  /** DELETE → remove one custom view: drop it from schema.json `views[]` and
367
381
  * unlink its `views/<file>.html` (global-bearer auth) → { deleted, viewId }.
368
382
  * Source-aware; refuses user-scope + preset collections. */
@@ -1,5 +1,20 @@
1
1
  // Small DOM helpers shared across components.
2
2
 
3
+ /** Tolerance band for {@link isNearBottom}: a reader sitting within this many
4
+ * pixels of the end still counts as "at the bottom", so auto-follow survives
5
+ * the small drift of reading the last line. */
6
+ export const NEAR_BOTTOM_THRESHOLD_PX = 80;
7
+
8
+ // Whether a scroll container is parked at (or just above) its bottom.
9
+ // Auto-follow readers gate on this so streaming output stops yanking the
10
+ // view away from someone who scrolled up to read (#2179).
11
+ export function isNearBottom(
12
+ element: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">,
13
+ thresholdPx: number = NEAR_BOTTOM_THRESHOLD_PX,
14
+ ): boolean {
15
+ return element.scrollHeight - element.scrollTop - element.clientHeight <= thresholdPx;
16
+ }
17
+
3
18
  // Walk a container's descendants and return the first one that
4
19
  // has both more vertical content than its visible height AND a
5
20
  // CSS overflow that allows scrolling. Used so canvas-level arrow
@@ -1,40 +1,9 @@
1
- // Shared error helpers for the Vue side. Mirrors the server-side
2
- // `server/utils/errors.ts` so the same helper is available wherever
3
- // we handle caught exceptions.
1
+ // Shared error helpers for the Vue side. Same implementation as the server
2
+ // both re-export `@mulmoclaude/core/utils`, whose helpers are browser-safe
3
+ // (pure string work, no node imports). Keeping one implementation is what
4
+ // stops the gRPC `{ code, details, metadata }` case from printing as
5
+ // `[object Object]` on one side and "quota exceeded" on the other (#2217).
4
6
  //
5
7
  // Use `errorMessage(err)` instead of inlining
6
- // `err instanceof Error ? err.message : String(err)` — searching for
7
- // one canonical helper is easier than grepping for the inline form.
8
- //
9
- // Non-Error objects with a `details` (gRPC convention) or `message`
10
- // string field have that field surfaced — without this, gRPC errors
11
- // like `{ code, details, metadata }` show up as `[object Object]`.
12
- //
13
- // The optional `fallback` covers the common idiom of surfacing a
14
- // descriptive message ("Invalid JSON", "Connection error.") when a
15
- // throw turns out to be a non-Error value.
16
-
17
- export function errorMessage(err: unknown, fallback?: string): string {
18
- if (err instanceof Error) return err.message;
19
- if (err !== null && typeof err === "object") {
20
- const obj = err as { details?: unknown; message?: unknown };
21
- if (typeof obj.details === "string" && obj.details) return obj.details;
22
- if (typeof obj.message === "string" && obj.message) return obj.message;
23
- }
24
- if (fallback !== undefined) return fallback;
25
- return String(err);
26
- }
27
-
28
- // Coerce an unknown caught value into an Error, preserving the
29
- // original if it already was one. Use in error boundaries / Promise
30
- // rejections / event-handler onerror callbacks where the downstream
31
- // API wants an Error object (not just its message string).
32
- //
33
- // `fallback` is the message used when coercing a non-Error value —
34
- // pass a descriptive string for cases where `String(err)` would just
35
- // produce noise (e.g. `<img>.onerror` hands you an Event, not the
36
- // underlying load failure).
37
- export function toError(err: unknown, fallback?: string): Error {
38
- if (err instanceof Error) return err;
39
- return new Error(fallback ?? errorMessage(err));
40
- }
8
+ // `err instanceof Error ? err.message : String(err)`.
9
+ export { errorMessage, toError } from "@mulmoclaude/core/utils";