mulmoclaude 0.9.2 → 0.9.3

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 (40) hide show
  1. package/bin/mulmoclaude.js +2 -1
  2. package/client/assets/{index-Dc0R-HW5.js → index-40ErrJ4a.js} +23 -23
  3. package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
  4. package/client/assets/{marp-CSq0PPfK.js → marp-Dh7C24F1.js} +1 -1
  5. package/client/index.html +2 -2
  6. package/package.json +8 -7
  7. package/server/api/routes/chat-index.ts +5 -1
  8. package/server/api/routes/collections.ts +136 -0
  9. package/server/index.ts +14 -2
  10. package/server/prompts/system/system.md +7 -1
  11. package/server/remoteHost/handlers/collectionPage.ts +15 -17
  12. package/server/remoteHost/handlers/getCollection.ts +2 -2
  13. package/server/remoteHost/handlers/getFeed.ts +2 -2
  14. package/server/remoteHost/handlers/getRemoteView.ts +37 -0
  15. package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
  16. package/server/remoteHost/handlers/index.ts +6 -0
  17. package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
  18. package/server/utils/files/thumbnail-store.ts +97 -0
  19. package/server/workspace/chat-index/index.ts +8 -1
  20. package/server/workspace/chat-index/indexer.ts +142 -27
  21. package/server/workspace/collections/index.ts +1 -0
  22. package/server/workspace/collections/remoteView.ts +290 -0
  23. package/src/App.vue +57 -3
  24. package/src/composables/collections/uiHost.ts +15 -0
  25. package/src/composables/usePubSub.ts +40 -2
  26. package/src/composables/useSessionSync.ts +9 -0
  27. package/src/config/apiRoutes.ts +20 -0
  28. package/src/config/roles.ts +2 -2
  29. package/src/lang/de.ts +2 -0
  30. package/src/lang/en.ts +2 -0
  31. package/src/lang/es.ts +2 -0
  32. package/src/lang/fr.ts +2 -0
  33. package/src/lang/ja.ts +2 -0
  34. package/src/lang/ko.ts +2 -0
  35. package/src/lang/pt-BR.ts +2 -0
  36. package/src/lang/zh.ts +1 -0
  37. package/src/plugins/textResponse/View.vue +49 -14
  38. package/src/plugins/textResponse/utils.ts +37 -0
  39. package/src/utils/html/previewCsp.ts +7 -16
  40. package/src/utils/role/icon.ts +9 -2
@@ -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
+ }
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";
@@ -425,7 +425,7 @@ const currentSessionId = ref("");
425
425
  // --- Debug beat (pub/sub) ---
426
426
  const { debugTitleStyle } = useDebugBeat();
427
427
 
428
- const { subscribe: pubsubSubscribe } = usePubSub();
428
+ const { subscribe: pubsubSubscribe, onReconnect: pubsubOnReconnect } = usePubSub();
429
429
 
430
430
  // --- Routing ---
431
431
  const route = useRoute();
@@ -482,7 +482,7 @@ const pastedFiles = ref<PastedFile[]>([]);
482
482
  const activePane = ref<"sidebar" | "main">("sidebar");
483
483
 
484
484
  const { sessions, historyError, fetchSessions, setBookmark, deleteSession: deleteSessionFromHistory } = useSessionHistory();
485
- const { markSessionRead } = useSessionSync({
485
+ const { markSessionRead, refreshSessionStates } = useSessionSync({
486
486
  sessionMap,
487
487
  currentSessionId,
488
488
  fetchSessions,
@@ -991,6 +991,16 @@ function hasPendingGenerations(sessionId: string): boolean {
991
991
  }
992
992
 
993
993
  function handleSessionFinished(sessionId: string): void {
994
+ // Trust the definitive server signal and flip the local indicator
995
+ // immediately (#1915 Fix A). Without this, the "thinking" spinner stays
996
+ // stuck until the `sessions` channel notification arrives via a separate
997
+ // socket.io frame — which can go missing on a network hiccup or on
998
+ // Safari's tab-throttling flow while the sessionChannel frame did land.
999
+ const session = sessionMap.get(sessionId);
1000
+ if (session) {
1001
+ session.isRunning = false;
1002
+ session.statusMessage = "";
1003
+ }
994
1004
  refreshSessionTranscript(sessionId).catch((err) => {
995
1005
  console.error("[handleSessionFinished] refresh failed:", err);
996
1006
  });
@@ -1001,6 +1011,50 @@ function handleSessionFinished(sessionId: string): void {
1001
1011
  }
1002
1012
  }
1003
1013
 
1014
+ // After the client silently loses events, this pulls fresh state from the
1015
+ // server so the UI recovers without a page reload (#1915). Two trigger
1016
+ // surfaces:
1017
+ // - socket.io reconnect (network hiccup, WS bounce)
1018
+ // - document visibility flips to `visible` (Safari's silent tab
1019
+ // throttling — WS keeps `connected` on the server but delivery stops
1020
+ // while the tab is backgrounded, and there's no `disconnect` event to
1021
+ // hook on reconnect)
1022
+ // refreshSessionStates() carries its own sequence guard inside
1023
+ // useSessionSync so concurrent catch-ups can't overwrite newer live state
1024
+ // with an older-but-slower response. refreshSessionTranscript() only
1025
+ // upgrades toolResults when the server view is strictly larger, so it's
1026
+ // already idempotent against interleaving.
1027
+ function catchUpMissedEvents(reason: "reconnect" | "visibility"): void {
1028
+ console.info(`[chat-ui] catching up after ${reason}`);
1029
+ refreshSessionStates().catch((err) => {
1030
+ console.warn("[chat-ui] refreshSessionStates failed:", err);
1031
+ });
1032
+ const currentId = currentSessionId.value;
1033
+ if (currentId) {
1034
+ refreshSessionTranscript(currentId).catch((err) => {
1035
+ console.warn("[chat-ui] refreshSessionTranscript failed:", err);
1036
+ });
1037
+ }
1038
+ }
1039
+
1040
+ // Capture the unsubscribe so remount / HMR doesn't accumulate stale
1041
+ // module-level reconnect handlers (Codex review).
1042
+ const unsubReconnect = pubsubOnReconnect(() => catchUpMissedEvents("reconnect"));
1043
+
1044
+ function handleVisibilityChange(): void {
1045
+ if (document.visibilityState === "visible") {
1046
+ catchUpMissedEvents("visibility");
1047
+ }
1048
+ }
1049
+
1050
+ onMounted(() => {
1051
+ document.addEventListener("visibilitychange", handleVisibilityChange);
1052
+ });
1053
+ onScopeDispose(() => {
1054
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
1055
+ unsubReconnect();
1056
+ });
1057
+
1004
1058
  function createSessionEventHandler(session: ActiveSession, ctx: AgentEventContext): (data: unknown) => void {
1005
1059
  return (data: unknown) => {
1006
1060
  const event = data as SseEvent;
@@ -12,6 +12,9 @@
12
12
  // `notifiedSeverities` returns an empty map.
13
13
  import {
14
14
  configureCollectionUi,
15
+ type CollectionRemoteViewResult,
16
+ type CollectionRemoteViewMutateResult,
17
+ type CollectionRemoteViewItemsResult,
15
18
  type CollectionViewI18nResult,
16
19
  type CollectionViewToken,
17
20
  type RegistryListResponse,
@@ -57,6 +60,10 @@ const collectionActionUrl = (slug: string, actionId: string): string =>
57
60
  withSlug(API_ROUTES.collections.collectionAction, slug).replace(":actionId", encodeURIComponent(actionId));
58
61
  const viewDeleteUrl = (slug: string, viewId: string): string =>
59
62
  withSlug(API_ROUTES.collections.viewDelete, slug).replace(":viewId", encodeURIComponent(viewId));
63
+ const remoteViewMutateUrl = (slug: string, viewId: string): string =>
64
+ withSlug(API_ROUTES.collections.remoteViewMutate, slug).replace(":viewId", encodeURIComponent(viewId));
65
+ const remoteViewItemsUrl = (slug: string, viewId: string): string =>
66
+ withSlug(API_ROUTES.collections.remoteViewItems, slug).replace(":viewId", encodeURIComponent(viewId));
60
67
 
61
68
  // ── Deferred app bindings (need a component context; set by App.vue setup) ──
62
69
  type StartChat = (prompt: string, role: string) => void;
@@ -98,6 +105,14 @@ configureCollectionUi({
98
105
  }
99
106
  },
100
107
  fetchViewI18n: (slug, viewId, locale) => apiGet<CollectionViewI18nResult>(withSlug(API_ROUTES.collections.viewI18n, slug), { id: viewId, locale }),
108
+ fetchRemoteView: (slug, viewId, locale) => apiGet<CollectionRemoteViewResult>(withSlug(API_ROUTES.collections.remoteView, slug), { id: viewId, locale }),
109
+ mutateRemoteView: (slug, viewId, request) => apiPost<CollectionRemoteViewMutateResult>(remoteViewMutateUrl(slug, viewId), request),
110
+ fetchRemoteViewItems: (slug, viewId, request) =>
111
+ apiGet<CollectionRemoteViewItemsResult>(remoteViewItemsUrl(slug, viewId), {
112
+ offset: request.offset,
113
+ limit: request.limit,
114
+ fields: request.fields?.join(",") || undefined,
115
+ }),
101
116
  buildViewSrcdoc: (html, boot) => buildCustomViewSrcdoc(html, boot),
102
117
 
103
118
  // record CRUD + actions
@@ -7,11 +7,17 @@ interface PubSubMessage {
7
7
 
8
8
  type Callback = (data: unknown) => void;
9
9
  type Unsubscribe = () => void;
10
+ type ReconnectHandler = () => void;
10
11
 
11
12
  // On reconnect we re-emit every live subscription so the rooms list survives the bounce.
12
13
  let socket: Socket | null = null;
13
14
 
14
15
  const listeners = new Map<string, Set<Callback>>();
16
+ const reconnectHandlers = new Set<ReconnectHandler>();
17
+ // First `connect` is the initial establishment; every subsequent one is a
18
+ // reconnect and needs a state catch-up (missed events during the down window
19
+ // are lost because the pub/sub server has no replay buffer). See #1915.
20
+ let hasConnectedOnce = false;
15
21
 
16
22
  function resendSubscriptions(sock: Socket): void {
17
23
  for (const channel of listeners.keys()) {
@@ -19,6 +25,16 @@ function resendSubscriptions(sock: Socket): void {
19
25
  }
20
26
  }
21
27
 
28
+ function fireReconnectHandlers(): void {
29
+ for (const handler of reconnectHandlers) {
30
+ try {
31
+ handler();
32
+ } catch (err) {
33
+ console.error("[usePubSub] reconnect handler threw:", err);
34
+ }
35
+ }
36
+ }
37
+
22
38
  function connect(): Socket {
23
39
  if (socket) return socket;
24
40
 
@@ -28,7 +44,14 @@ function connect(): Socket {
28
44
  transports: ["websocket"],
29
45
  });
30
46
 
31
- sock.on("connect", () => resendSubscriptions(sock));
47
+ sock.on("connect", () => {
48
+ resendSubscriptions(sock);
49
+ if (hasConnectedOnce) {
50
+ fireReconnectHandlers();
51
+ } else {
52
+ hasConnectedOnce = true;
53
+ }
54
+ });
32
55
 
33
56
  sock.on("data", (msg: PubSubMessage) => {
34
57
  const cbs = listeners.get(msg.channel);
@@ -46,6 +69,10 @@ function maybeDisconnect(): void {
46
69
  if (!socket) return;
47
70
  socket.disconnect();
48
71
  socket = null;
72
+ // Reset so the next `connect()` cycle is a true initial connect again —
73
+ // catch-up handlers only make sense against state accumulated on this
74
+ // socket, not across an unsubscribe/resubscribe cycle.
75
+ hasConnectedOnce = false;
49
76
  }
50
77
 
51
78
  export function usePubSub() {
@@ -73,5 +100,16 @@ export function usePubSub() {
73
100
  };
74
101
  }
75
102
 
76
- return { subscribe };
103
+ // Register a handler that fires on every reconnect (not the initial
104
+ // connect). Callers use this to catch up on missed events after the
105
+ // socket bounces — the pub/sub server has no replay buffer, so anything
106
+ // published during the down window is gone. See #1915.
107
+ function onReconnect(handler: ReconnectHandler): Unsubscribe {
108
+ reconnectHandlers.add(handler);
109
+ return () => {
110
+ reconnectHandlers.delete(handler);
111
+ };
112
+ }
113
+
114
+ return { subscribe, onReconnect };
77
115
  }
@@ -30,7 +30,15 @@ export function useSessionSync(opts: {
30
30
  const { sessionMap, currentSessionId, fetchSessions, onCurrentSessionDeleted } = opts;
31
31
  const { subscribe } = usePubSub();
32
32
 
33
+ // Monotonic sequence token — protects sessionMap from stale overwrites when
34
+ // two concurrent refreshes race (e.g. reconnect fires while a
35
+ // visibilitychange is mid-flight). Every call increments the token before
36
+ // awaiting; after the fetch resolves, we mutate only if our token is still
37
+ // the latest, so the older-but-slower response can never regress live
38
+ // state (e.g. re-flip isRunning back to true after session_finished).
39
+ let refreshToken = 0;
33
40
  async function refreshSessionStates(): Promise<void> {
41
+ const myToken = ++refreshToken;
34
42
  let summaries: SessionSummary[];
35
43
  try {
36
44
  summaries = await fetchSessions();
@@ -40,6 +48,7 @@ export function useSessionSync(opts: {
40
48
  console.warn("[session-sync] failed to fetch sessions:", err);
41
49
  return;
42
50
  }
51
+ if (myToken !== refreshToken) return;
43
52
  for (const summary of summaries) {
44
53
  const live = sessionMap.get(summary.id);
45
54
  if (!live) continue;
@@ -293,6 +293,26 @@ const HOST_API_ROUTES = {
293
293
  /** GET ?id=<viewId> → the custom view's HTML file (global-bearer auth),
294
294
  * read from data/skills/:slug/views/. The parent renders it sandboxed. */
295
295
  viewFile: "/api/collections/:slug/view-file",
296
+ /** GET ?id=<viewId>&locale=<tag> → a mobile (`target: "mobile"`) custom
297
+ * view wrapped into its sandboxed srcdoc (global-bearer auth) →
298
+ * { view, srcdoc, bytes }. Same builder as the command channel's
299
+ * `getRemoteView`, so the desktop phone-frame preview renders the exact
300
+ * artifact the phone receives (plans/feat-remote-custom-view.md). */
301
+ remoteView: "/api/collections/:slug/remote-view",
302
+ /** POST { op: "update"|"delete", id, patch? } → apply one mutate on behalf
303
+ * of a `target: "mobile"` view, authorized by that view's declared
304
+ * editableFields / allowDelete and enforced host-side (global-bearer auth).
305
+ * The desktop phone-frame preview's write channel — same builder the
306
+ * command channel's `mutateRemoteViewItem` uses, so preview === phone
307
+ * (plans/feat-remote-writable-view.md). */
308
+ remoteViewMutate: "/api/collections/:slug/remote-view/:viewId/mutate",
309
+ /** GET ?offset&limit&fields=<csv> → one page of a `target: "mobile"` view's
310
+ * records with its declared `imageFields` inlined as `data:` URL thumbnails
311
+ * (global-bearer auth) → { page, inlined, omitted }. Same builder as the
312
+ * command channel's `getRemoteViewItems`, so the desktop phone-frame preview
313
+ * pages the exact data (incl. real thumbnails) the phone will
314
+ * (plans/feat-remote-view-images.md). */
315
+ remoteViewItems: "/api/collections/:slug/remote-view/:viewId/items",
296
316
  /** GET ?id=<viewId>&locale=<tag> → translation dict for one custom view
297
317
  * (global-bearer auth) → { locale, dict }. `dict` is the host-picked
298
318
  * flat map for the requested locale (fallback `"en"`, else `{}`); the
@@ -46,7 +46,7 @@ export const ROLES: Role[] = [
46
46
  {
47
47
  id: "general",
48
48
  name: "General",
49
- icon: "star",
49
+ icon: "auto_awesome",
50
50
  prompt:
51
51
  "You are a helpful assistant with access to the user's workspace. Help with tasks, answer questions, and use available tools when appropriate.\n\n" +
52
52
  "## Asking the user to choose\n\n" +
@@ -372,7 +372,7 @@ export const ROLES: Role[] = [
372
372
  {
373
373
  id: "debug",
374
374
  name: "Debug",
375
- icon: "star",
375
+ icon: "bug_report",
376
376
  prompt:
377
377
  "You are a helpful assistant with access to the user's workspace. Help with tasks, answer questions, and use available tools when appropriate.\n\n" +
378
378
  "## Asking the user to choose\n\n" +
package/src/lang/de.ts CHANGED
@@ -949,6 +949,8 @@ const deMessages = {
949
949
  cancel: "Abbrechen",
950
950
  seededByPlugin: "von {pkg}",
951
951
  seededByPluginTooltip: "Diese Nachricht wurde vom Plugin {pkg} erstellt und nicht von Ihnen gesendet.",
952
+ truncatedForRender:
953
+ "Diese Nachricht ist ungewöhnlich lang (insgesamt {total} Zeichen). Nur der erste Teil wird angezeigt – {omitted} Zeichen ausgeblendet, damit der Tab reaktionsfähig bleibt. Nutze „Kopieren“ für den vollständigen Text.",
952
954
  },
953
955
  pluginSkill: {
954
956
  noDescription: "(keine Beschreibung)",
package/src/lang/en.ts CHANGED
@@ -961,6 +961,8 @@ const enMessages = {
961
961
  cancel: "Cancel",
962
962
  seededByPlugin: "from {pkg}",
963
963
  seededByPluginTooltip: "This message was seeded by the {pkg} plugin, not sent by you.",
964
+ truncatedForRender:
965
+ "This message is unusually long ({total} chars total). Only the first portion is rendered — {omitted} chars hidden to keep the tab responsive. Use Copy for the full raw text.",
964
966
  },
965
967
  pluginSkill: {
966
968
  noDescription: "(no description)",
package/src/lang/es.ts CHANGED
@@ -947,6 +947,8 @@ const esMessages = {
947
947
  cancel: "Cancelar",
948
948
  seededByPlugin: "desde {pkg}",
949
949
  seededByPluginTooltip: "Este mensaje fue generado por el plugin {pkg}, no enviado por ti.",
950
+ truncatedForRender:
951
+ "Este mensaje es inusualmente largo ({total} caracteres en total). Solo se muestra la primera parte — {omitted} caracteres ocultos para mantener la pestaña receptiva. Usa Copiar para obtener el texto completo.",
950
952
  },
951
953
  pluginSkill: {
952
954
  noDescription: "(sin descripción)",
package/src/lang/fr.ts CHANGED
@@ -937,6 +937,8 @@ const frMessages = {
937
937
  cancel: "Annuler",
938
938
  seededByPlugin: "depuis {pkg}",
939
939
  seededByPluginTooltip: "Ce message a été généré par le plugin {pkg}, et non envoyé par vous.",
940
+ truncatedForRender:
941
+ "Ce message est exceptionnellement long ({total} caractères au total). Seule la première partie est affichée — {omitted} caractères masqués pour garder l'onglet réactif. Utilisez « Copier » pour obtenir le texte complet.",
940
942
  },
941
943
  pluginSkill: {
942
944
  noDescription: "(aucune description)",
package/src/lang/ja.ts CHANGED
@@ -935,6 +935,8 @@ const jaMessages = {
935
935
  cancel: "キャンセル",
936
936
  seededByPlugin: "{pkg} から",
937
937
  seededByPluginTooltip: "このメッセージは {pkg} プラグインによって作成されたもので、あなたが送信したものではありません。",
938
+ truncatedForRender:
939
+ "このメッセージは非常に長いため(全 {total} 文字)、描画のフリーズを防ぐため先頭のみ表示しています({omitted} 文字を省略)。全文はコピーボタンから取得できます。",
938
940
  },
939
941
  pluginSkill: {
940
942
  noDescription: "(説明なし)",
package/src/lang/ko.ts CHANGED
@@ -935,6 +935,8 @@ const koMessages = {
935
935
  cancel: "취소",
936
936
  seededByPlugin: "{pkg}에서",
937
937
  seededByPluginTooltip: "이 메시지는 사용자가 보낸 것이 아니라 {pkg} 플러그인에서 작성한 것입니다.",
938
+ truncatedForRender:
939
+ "이 메시지는 매우 깁니다(총 {total}자). 탭이 멈추지 않도록 앞부분만 표시됩니다 — {omitted}자 숨김. 전체 원문은 복사 버튼으로 가져올 수 있습니다.",
938
940
  },
939
941
  pluginSkill: {
940
942
  noDescription: "(설명 없음)",
package/src/lang/pt-BR.ts CHANGED
@@ -936,6 +936,8 @@ const ptBRMessages = {
936
936
  cancel: "Cancelar",
937
937
  seededByPlugin: "de {pkg}",
938
938
  seededByPluginTooltip: "Esta mensagem foi gerada pelo plugin {pkg}, não foi enviada por você.",
939
+ truncatedForRender:
940
+ "Esta mensagem é excepcionalmente longa ({total} caracteres no total). Apenas a primeira parte é renderizada — {omitted} caracteres ocultos para manter a aba responsiva. Use Copiar para obter o texto completo.",
939
941
  },
940
942
  pluginSkill: {
941
943
  noDescription: "(sem descrição)",
package/src/lang/zh.ts CHANGED
@@ -925,6 +925,7 @@ const zhMessages = {
925
925
  cancel: "取消",
926
926
  seededByPlugin: "来自 {pkg}",
927
927
  seededByPluginTooltip: "此消息由 {pkg} 插件生成,并非您发送。",
928
+ truncatedForRender: "该消息异常长(共 {total} 个字符)。为保持标签页响应,仅渲染开头部分 — 隐藏了 {omitted} 个字符。使用复制按钮获取完整原文。",
928
929
  },
929
930
  pluginSkill: {
930
931
  noDescription: "(无描述)",