@agent-native/core 0.81.3 → 0.82.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 (41) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/file-upload/builder.ts +199 -37
  5. package/corpus/core/src/file-upload/index.ts +2 -0
  6. package/corpus/core/src/file-upload/types.ts +38 -0
  7. package/corpus/templates/clips/actions/create-recording.ts +44 -0
  8. package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
  9. package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
  10. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
  11. package/corpus/templates/clips/app/routes/record.tsx +12 -1
  12. package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
  13. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
  14. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
  15. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
  16. package/corpus/templates/clips/shared/recording-core.ts +5 -0
  17. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +63 -16
  18. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
  19. package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
  20. package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
  21. package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
  22. package/dist/collab/awareness.d.ts +2 -2
  23. package/dist/collab/awareness.d.ts.map +1 -1
  24. package/dist/collab/routes.d.ts +1 -1
  25. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  26. package/dist/file-upload/builder.d.ts.map +1 -1
  27. package/dist/file-upload/builder.js +137 -25
  28. package/dist/file-upload/builder.js.map +1 -1
  29. package/dist/file-upload/index.d.ts +1 -1
  30. package/dist/file-upload/index.d.ts.map +1 -1
  31. package/dist/file-upload/index.js.map +1 -1
  32. package/dist/file-upload/types.d.ts +26 -0
  33. package/dist/file-upload/types.d.ts.map +1 -1
  34. package/dist/file-upload/types.js.map +1 -1
  35. package/dist/notifications/routes.d.ts +3 -3
  36. package/dist/observability/routes.d.ts +8 -8
  37. package/dist/progress/routes.d.ts +1 -1
  38. package/dist/resources/handlers.d.ts +3 -3
  39. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  40. package/dist/server/transcribe-voice.d.ts +1 -1
  41. package/package.json +1 -1
@@ -17,6 +17,7 @@ import {
17
17
  readAppState,
18
18
  writeAppState,
19
19
  } from "@agent-native/core/application-state";
20
+ import { getActiveFileUploadProvider } from "@agent-native/core/file-upload";
20
21
  import { runWithRequestContext } from "@agent-native/core/server";
21
22
  import { normalizeChunkUploadNumber } from "@shared/recording-core.js";
22
23
  import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
@@ -40,12 +41,23 @@ import {
40
41
  getEventOwnerContext,
41
42
  ownerEmailMatches,
42
43
  } from "../../../../lib/recordings.js";
44
+ import {
45
+ getResumableSession,
46
+ setResumableSession,
47
+ type StoredResumableSession,
48
+ } from "../../../../lib/resumable-session.js";
43
49
  import {
44
50
  shouldRejectVideoUploadWithoutStorage,
45
51
  STORAGE_SETUP_REQUIRED_REASON,
46
52
  } from "../../../../lib/video-storage.js";
53
+
47
54
  const RECORDING_TOO_LARGE_REASON = `Recording exceeds the ${Math.round(MAX_RECORDING_UPLOAD_BYTES / (1024 * 1024))} MB size limit. Please record a shorter clip.`;
48
55
 
56
+ // Netlify functions have a 6 MB buffered request cap, but binary requests
57
+ // are base64 encoded by the gateway and effectively cap out around 4.5 MB.
58
+ // Keep our own cap lower so dev/local failures match production.
59
+ const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
60
+
49
61
  const ALLOWED_RECORDING_MIME_TYPES = new Set([
50
62
  "video/webm",
51
63
  "video/mp4",
@@ -115,10 +127,6 @@ export default defineEventHandler(async (event: H3Event) => {
115
127
  throw createError({ statusCode: 400, message: "Invalid chunk index" });
116
128
  }
117
129
 
118
- // Netlify functions have a 6 MB buffered request cap, but binary requests
119
- // are base64 encoded by the gateway and effectively cap out around 4.5 MB.
120
- // Keep our own cap lower so dev/local failures match production.
121
- const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
122
130
  const contentLength = Number(getHeader(event, "content-length") || 0);
123
131
  if (contentLength > MAX_CHUNK_BYTES) {
124
132
  setResponseStatus(event, 413);
@@ -164,6 +172,20 @@ export default defineEventHandler(async (event: H3Event) => {
164
172
  throw createError({ statusCode: 404, message: "Recording not found" });
165
173
  }
166
174
 
175
+ // Resumable streaming path — forward chunks directly to the provider.
176
+ const resumableSession = await getResumableSession(recordingId);
177
+ if (resumableSession) {
178
+ return handleResumableChunk(
179
+ event,
180
+ resumableSession,
181
+ recordingId,
182
+ index,
183
+ isFinal,
184
+ mimeType,
185
+ query,
186
+ );
187
+ }
188
+
167
189
  const failedUploadResponse = (reason: string, bytes?: number) => {
168
190
  setResponseStatus(
169
191
  event,
@@ -183,6 +205,13 @@ export default defineEventHandler(async (event: H3Event) => {
183
205
  );
184
206
  }
185
207
 
208
+ // Already finalized — retried final chunk after session was deleted. Skip
209
+ // buffered path writes so recording-upload-* state stays correct.
210
+ if (existing.status === "ready") {
211
+ return { ok: true, finalized: true };
212
+ }
213
+
214
+ // Store chunks in application_state, assemble on finalize.
186
215
  if (await shouldRejectVideoUploadWithoutStorage()) {
187
216
  const now = new Date().toISOString();
188
217
  await db
@@ -362,10 +391,7 @@ export default defineEventHandler(async (event: H3Event) => {
362
391
 
363
392
  await db
364
393
  .update(schema.recordings)
365
- .set({
366
- uploadProgress: progress,
367
- updatedAt: new Date().toISOString(),
368
- })
394
+ .set({ uploadProgress: progress, updatedAt: new Date().toISOString() })
369
395
  .where(
370
396
  and(
371
397
  eq(schema.recordings.id, recordingId),
@@ -399,21 +425,9 @@ export default defineEventHandler(async (event: H3Event) => {
399
425
  if (failedResponse) return failedResponse;
400
426
  debugLog("[chunk] isFinal — invoking finalize", { recordingId });
401
427
  try {
402
- const result = await finalizeRecording.run({
403
- id: recordingId,
404
- durationMs: normalizeChunkUploadNumber(query.durationMs),
405
- width: normalizeChunkUploadNumber(query.width),
406
- height: normalizeChunkUploadNumber(query.height),
407
- hasAudio:
408
- query.hasAudio === undefined
409
- ? undefined
410
- : query.hasAudio === "1" || query.hasAudio === "true",
411
- hasCamera:
412
- query.hasCamera === undefined
413
- ? undefined
414
- : query.hasCamera === "1" || query.hasCamera === "true",
415
- mimeType,
416
- });
428
+ const result = await finalizeRecording.run(
429
+ buildFinalizeArgs(recordingId, mimeType, query),
430
+ );
417
431
  debugLog("[chunk] finalize ok", {
418
432
  recordingId,
419
433
  videoUrl: (result as any)?.videoUrl,
@@ -510,11 +524,154 @@ export default defineEventHandler(async (event: H3Event) => {
510
524
  }
511
525
  }
512
526
 
513
- return {
514
- ok: true,
515
- finalized: false,
516
- index,
517
- bytes: bytes.byteLength,
518
- };
527
+ return { ok: true, finalized: false, index, bytes: bytes.byteLength };
519
528
  });
520
529
  });
530
+
531
+ function buildFinalizeArgs(
532
+ recordingId: string,
533
+ mimeType: string,
534
+ query: Record<string, unknown>,
535
+ ) {
536
+ return {
537
+ id: recordingId,
538
+ durationMs: normalizeChunkUploadNumber(query.durationMs),
539
+ width: normalizeChunkUploadNumber(query.width),
540
+ height: normalizeChunkUploadNumber(query.height),
541
+ hasAudio:
542
+ query.hasAudio === undefined
543
+ ? undefined
544
+ : query.hasAudio === "1" || query.hasAudio === "true",
545
+ hasCamera:
546
+ query.hasCamera === undefined
547
+ ? undefined
548
+ : query.hasCamera === "1" || query.hasCamera === "true",
549
+ mimeType,
550
+ };
551
+ }
552
+
553
+ // Resumable streaming path: each chunk is forwarded directly to the upload
554
+ // provider. Always returns a response — never falls through to the buffered path.
555
+ async function handleResumableChunk(
556
+ event: H3Event,
557
+ session: StoredResumableSession,
558
+ recordingId: string,
559
+ index: number,
560
+ isFinal: boolean,
561
+ mimeType: string,
562
+ query: Record<string, unknown>,
563
+ ) {
564
+ const uploadProvider = getActiveFileUploadProvider();
565
+ console.log(
566
+ `[resumable-chunk-${recordingId}] resumable session exists - bytesUploaded=${session.bytesUploaded} index=${index} isFinal=${isFinal}`,
567
+ );
568
+
569
+ const raw = await readRawBody(event, false);
570
+ const bytes: Uint8Array = raw ?? new Uint8Array(0);
571
+
572
+ if (!isFinal && bytes.byteLength === 0) {
573
+ throw createError({ statusCode: 400, message: "Empty chunk body" });
574
+ }
575
+
576
+ if (isFinal && bytes.byteLength === 0) {
577
+ // 0-byte sentinel from the recorder after stop(). All data chunks have
578
+ // already been PUT to the provider; send Content-Range: bytes */<total>
579
+ // to close the session before handing off to finalize-recording.
580
+ const closeRes = await uploadProvider!.resumable!.relayChunk(
581
+ { sessionId: session.sessionId, meta: session.meta },
582
+ `bytes */${session.bytesUploaded}`,
583
+ new Uint8Array(0),
584
+ );
585
+ if (!closeRes.ok) {
586
+ console.error(
587
+ `[resumable-chunk-${recordingId}] session close failed (${closeRes.status})`,
588
+ );
589
+ setResponseStatus(event, 502);
590
+ return {
591
+ ok: false,
592
+ error: `Resumable session close failed (${closeRes.status})`,
593
+ };
594
+ }
595
+ } else {
596
+ // Idempotent replay guard: a client retry (after a lost response) can
597
+ // re-send a chunk we already committed. Re-PUTing it at the new offset
598
+ // would corrupt the file — detect the duplicate by index and skip the PUT.
599
+ // Chunks are strictly sequential, so any index <= last committed is a replay.
600
+ // A replayed non-final is acked here; a replayed final falls through to
601
+ // finalize, which is idempotent.
602
+ const isReplay = index <= (session.lastCommittedIndex ?? -1);
603
+ if (isReplay) {
604
+ console.warn(
605
+ `[resumable-chunk-${recordingId}] duplicate chunk ${index}, acking without re-upload`,
606
+ );
607
+ if (!isFinal) {
608
+ return {
609
+ ok: true,
610
+ finalized: false,
611
+ index,
612
+ bytes: bytes.byteLength,
613
+ duplicate: true,
614
+ };
615
+ }
616
+ } else {
617
+ // Forward the data chunk to the provider and advance offsets only after
618
+ // the provider confirms receipt (308 Resume Incomplete for non-final, 2xx for final).
619
+ const start = session.bytesUploaded;
620
+ const end = start + bytes.byteLength - 1;
621
+ const contentRange = isFinal
622
+ ? `bytes ${start}-${end}/${start + bytes.byteLength}`
623
+ : `bytes ${start}-${end}/*`;
624
+
625
+ const putT0 = Date.now();
626
+ const putResult = await uploadProvider!.resumable!.relayChunk(
627
+ { sessionId: session.sessionId, meta: session.meta },
628
+ contentRange,
629
+ bytes,
630
+ { mimeType: mimeType.split(";")[0].trim() },
631
+ );
632
+ console.log(
633
+ `[resumable-chunk-${recordingId}] PUT ${Date.now() - putT0}ms status=${putResult.status} range="${contentRange}"`,
634
+ );
635
+
636
+ const resultOk = isFinal
637
+ ? putResult.ok && putResult.status !== 308
638
+ : putResult.ok;
639
+ if (!resultOk) {
640
+ setResponseStatus(event, 502);
641
+ return {
642
+ ok: false,
643
+ error: `Chunk upload failed (${putResult.status})`,
644
+ };
645
+ }
646
+
647
+ await setResumableSession(recordingId, {
648
+ ...session,
649
+ ...(putResult.updatedMeta
650
+ ? { meta: { ...session.meta, ...putResult.updatedMeta } }
651
+ : {}),
652
+ bytesUploaded: start + bytes.byteLength,
653
+ lastCommittedIndex: index,
654
+ });
655
+
656
+ if (!isFinal) {
657
+ return { ok: true, finalized: false, index, bytes: bytes.byteLength };
658
+ }
659
+ }
660
+ }
661
+
662
+ // isFinal — delegate to finalize-recording, which reads the resumable
663
+ // session and calls provider.resumable.completeSession.
664
+ try {
665
+ const result = await finalizeRecording.run(
666
+ buildFinalizeArgs(recordingId, mimeType, query),
667
+ );
668
+ return { ok: true, finalized: true, ...result };
669
+ } catch (err) {
670
+ console.error(`[resumable-chunk-${recordingId}] finalize failed:`, err);
671
+ setResponseStatus(event, 500);
672
+ return {
673
+ ok: false,
674
+ error: err instanceof Error ? err.message : "Finalize failed",
675
+ };
676
+ }
677
+ }
@@ -44,6 +44,7 @@ import {
44
44
  getEventOwnerContext,
45
45
  ownerEmailMatches,
46
46
  } from "../../../../lib/recordings.js";
47
+ import { deleteResumableSession } from "../../../../lib/resumable-session.js";
47
48
 
48
49
  interface CompressionMeta {
49
50
  originalBytes?: number;
@@ -112,6 +113,9 @@ export default defineEventHandler(async (event: H3Event) => {
112
113
  const cleared = await deleteAppStateByPrefix(
113
114
  `recording-chunks-${recordingId}-`,
114
115
  );
116
+ // Clear any stale resumable session so a buffered retry does not
117
+ // accidentally route through handleResumableChunk with stale offsets.
118
+ await deleteResumableSession(recordingId).catch(() => {});
115
119
 
116
120
  // Reset the per-recording upload progress so the UI poller sees the
117
121
  // re-upload restart from 0 and doesn't briefly show "100% then
@@ -46,6 +46,11 @@ export function pickMimeType(): string {
46
46
  return "";
47
47
  }
48
48
 
49
+ /** How the client delivers recorded data to the server.
50
+ * - `"streaming"` — chunks are flushed to GCS during recording via a resumable session
51
+ * - `"buffered"` — full blob assembled after stop() and uploaded in slices */
52
+ export type UploadMode = "streaming" | "buffered";
53
+
49
54
  /** Query params understood by the chunk-upload route
50
55
  * (`/api/uploads/:id/chunk`). This is the on-the-wire contract — the route in
51
56
  * `server/routes/api/uploads/[recordingId]/chunk.post.ts` reads exactly these. */
@@ -22,7 +22,7 @@ import {
22
22
  useRef,
23
23
  useState,
24
24
  } from "react";
25
- import type { ClipboardEvent } from "react";
25
+ import type { ClipboardEvent, MutableRefObject } from "react";
26
26
  import { useNavigate } from "react-router";
27
27
  import { toast } from "sonner";
28
28
 
@@ -69,6 +69,50 @@ interface DocumentEditorProps {
69
69
  documentId: string;
70
70
  }
71
71
 
72
+ type FieldSaveWatermark = { title: string; updatedAt: string | null };
73
+ type ContentSaveWatermark = { content: string; updatedAt: string | null };
74
+
75
+ function adoptConfirmedSaveWatermarks({
76
+ saved,
77
+ savedAt,
78
+ title,
79
+ content,
80
+ updates,
81
+ lastSavedTitleRef,
82
+ lastSavedContentRef,
83
+ }: {
84
+ saved: Document | undefined;
85
+ savedAt: string;
86
+ title: string;
87
+ content: string;
88
+ updates: Record<string, string>;
89
+ lastSavedTitleRef: MutableRefObject<FieldSaveWatermark>;
90
+ lastSavedContentRef: MutableRefObject<ContentSaveWatermark>;
91
+ }) {
92
+ if (updates.title !== undefined) {
93
+ lastSavedTitleRef.current = { title, updatedAt: savedAt };
94
+ } else if (
95
+ updates.content !== undefined &&
96
+ saved?.title === lastSavedTitleRef.current.title
97
+ ) {
98
+ lastSavedTitleRef.current = {
99
+ ...lastSavedTitleRef.current,
100
+ updatedAt: savedAt,
101
+ };
102
+ }
103
+ if (updates.content !== undefined) {
104
+ lastSavedContentRef.current = { content, updatedAt: savedAt };
105
+ } else if (
106
+ updates.title !== undefined &&
107
+ saved?.content === lastSavedContentRef.current.content
108
+ ) {
109
+ lastSavedContentRef.current = {
110
+ ...lastSavedContentRef.current,
111
+ updatedAt: savedAt,
112
+ };
113
+ }
114
+ }
115
+
72
116
  function DocumentEditorSkeleton() {
73
117
  return (
74
118
  <div className="flex min-h-0 flex-1 flex-col bg-background">
@@ -686,12 +730,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
686
730
  const saved = await persistDocumentUpdates(updates, options);
687
731
  // Adopt the server updatedAt per saved field.
688
732
  const savedAt = saved?.updatedAt ?? new Date().toISOString();
689
- if (updates.title !== undefined) {
690
- lastSavedTitleRef.current = { title, updatedAt: savedAt };
691
- }
692
- if (updates.content !== undefined) {
693
- lastSavedContentRef.current = { content, updatedAt: savedAt };
694
- }
733
+ adoptConfirmedSaveWatermarks({
734
+ saved,
735
+ savedAt,
736
+ title,
737
+ content,
738
+ updates,
739
+ lastSavedTitleRef,
740
+ lastSavedContentRef,
741
+ });
695
742
 
696
743
  // Push-on-save: when auto-sync is on, trigger a Notion push
697
744
  // immediately after the save lands in SQL. This eliminates the
@@ -818,15 +865,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
818
865
  if (Object.keys(updates).length > 0) {
819
866
  const saved = await persistDocumentUpdates(updates);
820
867
  const savedAt = saved?.updatedAt ?? new Date().toISOString();
821
- if (updates.title !== undefined) {
822
- lastSavedTitleRef.current = { title, updatedAt: savedAt };
823
- }
824
- if (updates.content !== undefined) {
825
- lastSavedContentRef.current = {
826
- content,
827
- updatedAt: savedAt,
828
- };
829
- }
868
+ adoptConfirmedSaveWatermarks({
869
+ saved,
870
+ savedAt,
871
+ title,
872
+ content,
873
+ updates,
874
+ lastSavedTitleRef,
875
+ lastSavedContentRef,
876
+ });
830
877
  }
831
878
  } finally {
832
879
  // Acknowledge the flush even if nothing changed — the SQL row is
@@ -59,6 +59,10 @@ import {
59
59
  } from "@/hooks/use-documents";
60
60
  import { cn } from "@/lib/utils";
61
61
 
62
+ import {
63
+ getDocumentSidebarSections,
64
+ isDirectLocalDocument,
65
+ } from "./document-sidebar-sections";
62
66
  import { DocumentSidebarIcon, DocumentTreeItem } from "./DocumentTreeItem";
63
67
  import { NotionButton } from "./NotionButton";
64
68
 
@@ -114,22 +118,6 @@ function collectDocumentSubtreeIds(documents: Document[], rootId: string) {
114
118
  return deletedIds;
115
119
  }
116
120
 
117
- function isDirectLocalDocument(document: Pick<Document, "id" | "source">) {
118
- return (
119
- document.source?.mode === "local-files" &&
120
- (document.id.startsWith("local-file:") ||
121
- document.id.startsWith("local-folder:"))
122
- );
123
- }
124
-
125
- function isImportedLocalSourceDocument(
126
- document: Pick<Document, "id" | "source">,
127
- ) {
128
- return (
129
- document.source?.mode === "local-files" && !isDirectLocalDocument(document)
130
- );
131
- }
132
-
133
121
  type SidebarSectionId =
134
122
  | "local-files"
135
123
  | "shared-copies"
@@ -210,24 +198,23 @@ export function DocumentSidebar({
210
198
  );
211
199
 
212
200
  const treeDocuments = filterDocumentTreeDocuments(documents);
213
- const localFileMode = documents.some(isDirectLocalDocument);
214
- const localSourceDocuments = localFileMode
215
- ? treeDocuments.filter(isDirectLocalDocument)
216
- : treeDocuments.filter(isImportedLocalSourceDocument);
217
- const databaseDocuments = localFileMode
218
- ? treeDocuments.filter((document) => !isDirectLocalDocument(document))
219
- : treeDocuments.filter(
220
- (document) => !isImportedLocalSourceDocument(document),
221
- );
201
+ const {
202
+ localFileMode,
203
+ localSourceDocuments,
204
+ databaseDocuments,
205
+ favorites,
206
+ showFavorites,
207
+ } = getDocumentSidebarSections(documents, treeDocuments);
222
208
  const localFileTree = buildDocumentTree(localSourceDocuments);
223
209
  const databaseTree = buildDocumentTree(databaseDocuments);
224
210
  const privateTree = databaseTree.filter((node) => node.visibility !== "org");
225
211
  const organizationTree = databaseTree.filter(
226
212
  (node) => node.visibility === "org",
227
213
  );
228
- const favorites = documents.filter(
229
- (d) => d.isFavorite && (localFileMode || !isImportedLocalSourceDocument(d)),
230
- );
214
+ // Match the tree rows' right-side inset so favorite titles clip inside the
215
+ // visible sidebar instead of widening the scroll surface.
216
+ const favoriteRowWidth =
217
+ width === undefined ? undefined : Math.max(224, width - 8);
231
218
  const activeDocument = activeDocumentId
232
219
  ? documents.find((doc) => doc.id === activeDocumentId)
233
220
  : null;
@@ -878,21 +865,29 @@ export function DocumentSidebar({
878
865
  ) : (
879
866
  <>
880
867
  {/* Favorites */}
881
- {!localFileMode && favorites.length > 0 && (
882
- <div className="mb-2">
883
- <div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1">
868
+ {showFavorites && (
869
+ <div className="mb-2 min-w-0">
870
+ <div className="flex min-w-0 items-center gap-1 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
884
871
  <IconStar size={10} />
885
- {t("sidebar.favorites")}
872
+ <span className="min-w-0 flex-1 truncate">
873
+ {t("sidebar.favorites")}
874
+ </span>
886
875
  </div>
887
876
  {favorites.map((doc) => (
888
877
  <button
889
878
  key={doc.id}
890
879
  className={cn(
891
- "w-full flex items-center gap-2 px-4 py-[5px] text-sm text-start rounded-md",
880
+ "flex w-full min-w-0 items-center gap-2 rounded-md px-4 py-[5px] text-start text-sm",
892
881
  doc.id === activeDocumentId
893
882
  ? "bg-accent text-accent-foreground"
894
883
  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
895
884
  )}
885
+ style={{
886
+ width:
887
+ favoriteRowWidth === undefined
888
+ ? undefined
889
+ : `${favoriteRowWidth}px`,
890
+ }}
896
891
  onClick={() => {
897
892
  navigateToDocument(doc.id);
898
893
  onNavigate?.();
@@ -0,0 +1,47 @@
1
+ import type { Document } from "@shared/api";
2
+
3
+ export function isDirectLocalDocument(
4
+ document: Pick<Document, "id" | "source">,
5
+ ) {
6
+ return (
7
+ document.source?.mode === "local-files" &&
8
+ (document.id.startsWith("local-file:") ||
9
+ document.id.startsWith("local-folder:"))
10
+ );
11
+ }
12
+
13
+ export function isImportedLocalSourceDocument(
14
+ document: Pick<Document, "id" | "source">,
15
+ ) {
16
+ return (
17
+ document.source?.mode === "local-files" && !isDirectLocalDocument(document)
18
+ );
19
+ }
20
+
21
+ export function getDocumentSidebarSections(
22
+ documents: Document[],
23
+ treeDocuments: Document[] = documents,
24
+ ) {
25
+ const localFileMode = documents.some(isDirectLocalDocument);
26
+ const localSourceDocuments = localFileMode
27
+ ? treeDocuments.filter(isDirectLocalDocument)
28
+ : treeDocuments.filter(isImportedLocalSourceDocument);
29
+ const databaseDocuments = localFileMode
30
+ ? treeDocuments.filter((document) => !isDirectLocalDocument(document))
31
+ : treeDocuments.filter(
32
+ (document) => !isImportedLocalSourceDocument(document),
33
+ );
34
+ const favorites = documents.filter(
35
+ (document) =>
36
+ document.isFavorite &&
37
+ (localFileMode || !isImportedLocalSourceDocument(document)),
38
+ );
39
+
40
+ return {
41
+ localFileMode,
42
+ localSourceDocuments,
43
+ databaseDocuments,
44
+ favorites,
45
+ showFavorites: favorites.length > 0,
46
+ };
47
+ }
@@ -12,6 +12,36 @@ import { toast } from "sonner";
12
12
 
13
13
  import { useRestoreContentDatabase } from "./use-content-database";
14
14
 
15
+ const LIST_DOCUMENTS_QUERY_KEY = ["action", "list-documents", undefined];
16
+
17
+ export function mergeDocumentIntoDocumentCache(
18
+ old: unknown,
19
+ document: Document,
20
+ ) {
21
+ return old && typeof old === "object" ? { ...old, ...document } : document;
22
+ }
23
+
24
+ export function mergeDocumentIntoListDocumentsCache(
25
+ old: unknown,
26
+ document: Document,
27
+ ) {
28
+ if (Array.isArray(old)) {
29
+ return old.map((item: Document) =>
30
+ item.id === document.id ? { ...item, ...document } : item,
31
+ );
32
+ }
33
+
34
+ if (!old || typeof old !== "object") return old;
35
+ const cached = old as { documents?: unknown };
36
+ if (!Array.isArray(cached.documents)) return old;
37
+
38
+ const nextDocuments = cached.documents.map((item: Document) =>
39
+ item.id === document.id ? { ...item, ...document } : item,
40
+ );
41
+
42
+ return { ...(old as object), documents: nextDocuments };
43
+ }
44
+
15
45
  export function useDocuments() {
16
46
  return useActionQuery<Document[]>("list-documents", undefined, {
17
47
  select: (data: any) => {
@@ -42,6 +72,13 @@ export function useUpdateDocument() {
42
72
  DocumentUpdateRequest & { id: string }
43
73
  >("update-document", {
44
74
  onSuccess: (data, variables) => {
75
+ queryClient.setQueryData(
76
+ ["action", "get-document", { id: variables.id }],
77
+ (old: unknown) => mergeDocumentIntoDocumentCache(old, data),
78
+ );
79
+ queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) =>
80
+ mergeDocumentIntoListDocumentsCache(old, data),
81
+ );
45
82
  queryClient.invalidateQueries({
46
83
  queryKey: ["action", "get-document", { id: variables.id }],
47
84
  });
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-30
4
+ ---
5
+
6
+ Sidebar favorites now keep long titles tidy and stay in sync with saved page titles.
@@ -49,11 +49,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
49
49
  error: string;
50
50
  states?: undefined;
51
51
  } | {
52
- error?: undefined;
53
52
  states: {
54
53
  clientId: number;
55
54
  state: string;
56
55
  }[];
56
+ error?: undefined;
57
57
  }>>;
58
58
  /**
59
59
  * GET /_agent-native/collab/:docId/users
@@ -64,10 +64,10 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
64
64
  error: string;
65
65
  users?: undefined;
66
66
  } | {
67
- error?: undefined;
68
67
  users: {
69
68
  clientId: number;
70
69
  lastSeen: number;
71
70
  }[];
71
+ error?: undefined;
72
72
  }>>;
73
73
  //# sourceMappingURL=awareness.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"awareness.d.ts","sourceRoot":"","sources":["../../src/collab/awareness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAS3C,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB;AAOD,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,WAAW,CAAC;IACpB,IAAI,EAAE,kBAAkB,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,MAAM,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAKD,wBAAgB,mBAAmB,IAAI,YAAY,CAElD;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,EAClD,KAAK,CAAC,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,MAAM,GACb,IAAI,CAUN;AAKD,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAO1E;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,IAAI,CAOnE;AAUD;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;kBAuCa,MAAM;eAAS,MAAM;;GAa1D,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;;;;kBAWM,MAAM;kBAAY,MAAM;;GAMvD,CAAC"}
1
+ {"version":3,"file":"awareness.d.ts","sourceRoot":"","sources":["../../src/collab/awareness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAS3C,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB;AAOD,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,WAAW,CAAC;IACpB,IAAI,EAAE,kBAAkB,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,MAAM,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAKD,wBAAgB,mBAAmB,IAAI,YAAY,CAElD;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,EAClD,KAAK,CAAC,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,MAAM,GACb,IAAI,CAUN;AAKD,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAO1E;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,IAAI,CAOnE;AAUD;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;kBAuCa,MAAM;eAAS,MAAM;;;GAa1D,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;;;kBAWM,MAAM;kBAAY,MAAM;;;GAMvD,CAAC"}
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- error: string;
30
29
  ok?: undefined;
30
+ error: string;
31
31
  } | {
32
32
  error?: undefined;
33
33
  ok: boolean;