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.
- package/README.md +1 -1
- package/client/assets/{PluginScopedRoot-B53YSjaC.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
- package/client/assets/index-7QnbsZZr.css +2 -0
- package/client/assets/{index-DUdqhWg6.js → index-C3SRVT2p.js} +71 -71
- package/client/assets/{marp-CyWxL3bc.js → marp-DxctLEy1.js} +1 -1
- package/client/index.html +3 -3
- package/package.json +5 -5
- package/server/agent/mcp-server.ts +27 -8
- package/server/api/auth/viewToken.ts +7 -1
- package/server/api/routes/agent.ts +14 -1
- package/server/api/routes/collections.ts +127 -9
- package/server/build/dispatcher.mjs +2 -2
- package/server/events/collection-change.ts +1 -0
- package/server/events/pub-sub/index.ts +15 -2
- package/server/events/relay-client.ts +6 -1
- package/server/events/session-store/index.ts +11 -2
- package/server/index.ts +26 -2
- package/server/prompts/system/system.md +0 -1
- package/server/remoteHost/handlers/getFeed.ts +10 -5
- package/server/services/translation/index.ts +10 -3
- package/server/utils/errors.ts +7 -21
- package/server/utils/text.ts +7 -23
- package/server/workspace/collections/index.ts +5 -4
- package/server/workspace/collections/remoteView.ts +21 -21
- package/server/workspace/collections/watcher.ts +1 -0
- package/server/workspace/custom-dirs.ts +16 -6
- package/server/workspace/reference-dirs.ts +8 -3
- package/src/components/SettingsGoogleTab.vue +4 -1
- package/src/components/StackView.vue +12 -1
- package/src/composables/collections/uiHost.ts +2 -1
- package/src/composables/useChatScroll.ts +18 -6
- package/src/composables/useFileSelection.ts +4 -1
- package/src/composables/useStickToBottom.ts +51 -0
- package/src/config/apiRoutes.ts +14 -0
- package/src/utils/dom/scrollable.ts +15 -0
- package/src/utils/errors.ts +7 -38
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
165
|
-
// path-escape, AND a genuinely-missing record alike — so update reports
|
|
166
|
-
// same explicit `invalid-id` the delete path does (via
|
|
167
|
-
// masking it as a 404. (A valid id whose
|
|
168
|
-
// hold no record, so it still resolves to item-not-found; a
|
|
169
|
-
// additionally refused by
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
@@ -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
|
-
|
|
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(
|
|
95
|
+
const structure = isValidStructure(raw.structure) ? raw.structure : DIR_STRUCTURES.flat;
|
|
91
96
|
|
|
92
97
|
return {
|
|
93
98
|
path: validPath,
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/config/apiRoutes.ts
CHANGED
|
@@ -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
|
package/src/utils/errors.ts
CHANGED
|
@@ -1,40 +1,9 @@
|
|
|
1
|
-
// Shared error helpers for the Vue side.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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)
|
|
7
|
-
|
|
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";
|