@agent-native/core 0.161.20 → 0.161.23

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 (57) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/actions/finalize-meeting.ts +57 -21
  3. package/corpus/templates/clips/actions/get-meeting.ts +11 -3
  4. package/corpus/templates/clips/actions/request-transcript.ts +183 -8
  5. package/corpus/templates/clips/actions/update-meeting.ts +66 -14
  6. package/corpus/templates/clips/app/components/meetings/canvas-editor.tsx +80 -135
  7. package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
  8. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +470 -35
  9. package/corpus/templates/clips/changelog/2026-08-18-meeting-notes-support-rich-editing-for-personal-notes-summar.md +6 -0
  10. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +13 -3
  11. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +13 -7
  12. package/corpus/templates/design/app/hooks/use-navigation-state.ts +18 -21
  13. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +2 -5
  14. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +4 -12
  15. package/corpus/templates/design/app/pages/design-editor/types.ts +38 -1
  16. package/corpus/templates/design/changelog/2026-08-18-advanced-design-editor-panels-hidden-by-default.md +6 -0
  17. package/corpus/templates/design/playwright.config.ts +19 -2
  18. package/corpus/templates/mail/app/components/email/EmailList.tsx +74 -22
  19. package/corpus/templates/mail/app/components/email/infinite-pagination.ts +82 -0
  20. package/corpus/templates/mail/app/hooks/use-emails.ts +1 -0
  21. package/corpus/templates/mail/app/pages/InboxPage.tsx +15 -10
  22. package/corpus/templates/mail/app/pages/inbox-zero.ts +35 -0
  23. package/corpus/templates/mail/changelog/2026-08-18-pinned-labels-load-matches.md +6 -0
  24. package/corpus/templates/slides/actions/get-deck.ts +5 -1
  25. package/corpus/templates/slides/actions/list-decks.ts +29 -8
  26. package/corpus/templates/slides/actions/view-screen.ts +7 -3
  27. package/corpus/templates/slides/changelog/2026-08-18-decks-appear-under-mine.md +6 -0
  28. package/corpus/templates/slides/shared/ownership.ts +6 -0
  29. package/dist/agent/engine/builder-engine.js +15 -8
  30. package/dist/agent/production-agent.d.ts +15 -0
  31. package/dist/agent/production-agent.js +32 -9
  32. package/dist/app-config/schema.d.ts +2 -0
  33. package/dist/app-config/workspace.d.ts +2 -0
  34. package/dist/app-config/workspace.js +18 -0
  35. package/dist/catalog.json +23 -23
  36. package/dist/client/analytics.d.ts +1 -0
  37. package/dist/client/api-path.js +7 -3
  38. package/dist/client/frame.js +20 -6
  39. package/dist/client/org/workspace-app-links.js +10 -1
  40. package/dist/collab/awareness.d.ts +2 -2
  41. package/dist/collab/routes.d.ts +1 -1
  42. package/dist/deploy/build.js +26 -2
  43. package/dist/observability/routes.d.ts +2 -2
  44. package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
  45. package/dist/provider-api/actions/provider-api.d.ts +15 -15
  46. package/dist/server/app-origin-config.d.ts +1 -0
  47. package/dist/server/app-origin-config.js +3 -0
  48. package/dist/server/auth.js +2 -2
  49. package/dist/server/cookie-namespace.js +17 -2
  50. package/dist/server/credential-provider.js +5 -2
  51. package/dist/server/derived-secret.js +3 -2
  52. package/dist/server/onboarding-html.js +34 -2
  53. package/dist/server/workspace-oauth.js +11 -6
  54. package/dist/shared/runtime-config.d.ts +2 -0
  55. package/dist/shared/runtime-config.js +12 -3
  56. package/dist/vite/client.js +5 -2
  57. package/package.json +2 -2
@@ -17,6 +17,7 @@ import {
17
17
  IconExternalLink,
18
18
  IconLoader2,
19
19
  IconNotes,
20
+ IconPlus,
20
21
  IconPlayerStop,
21
22
  IconRefresh,
22
23
  IconTrash,
@@ -61,6 +62,7 @@ import {
61
62
  DropdownMenuItem,
62
63
  DropdownMenuTrigger,
63
64
  } from "@/components/ui/dropdown-menu";
65
+ import { Input } from "@/components/ui/input";
64
66
  import { Skeleton } from "@/components/ui/skeleton";
65
67
  import {
66
68
  Tooltip,
@@ -95,6 +97,7 @@ interface Meeting {
95
97
  ownerEmail?: string | null;
96
98
  scheduledStart: string;
97
99
  scheduledEnd?: string | null;
100
+ updatedAt?: string | null;
98
101
  actualStart?: string | null;
99
102
  actualEnd?: string | null;
100
103
  platform?: string;
@@ -217,31 +220,41 @@ function TitleEditor({
217
220
  function ActionItemsByPerson({
218
221
  items,
219
222
  onToggle,
223
+ onChange,
224
+ onRemove,
225
+ onAdd,
226
+ emptyLabel,
220
227
  readOnly = false,
221
228
  }: {
222
229
  items: ActionItem[];
223
230
  onToggle: (index: number, completed: boolean) => void;
231
+ onChange: (index: number, text: string) => void;
232
+ onRemove: (index: number) => void;
233
+ onAdd: (text: string) => void;
234
+ emptyLabel: string;
224
235
  readOnly?: boolean;
225
236
  }) {
237
+ const t = useT();
238
+ const [adding, setAdding] = useState(false);
239
+
226
240
  // Preserve original index for toggle callback while grouping.
227
241
  const grouped = useMemo(() => {
228
242
  const map = new Map<string, Array<{ item: ActionItem; index: number }>>();
229
243
  items.forEach((it, index) => {
230
- const key = it.assigneeEmail || "Unassigned";
244
+ const key = it.assigneeEmail || "";
231
245
  const arr = map.get(key) ?? [];
232
246
  arr.push({ item: it, index });
233
247
  map.set(key, arr);
234
248
  });
235
249
  const entries = Array.from(map.entries());
236
250
  entries.sort(([a], [b]) => {
237
- if (a === "Unassigned") return 1;
238
- if (b === "Unassigned") return -1;
251
+ if (!a) return 1;
252
+ if (!b) return -1;
239
253
  return a.localeCompare(b);
240
254
  });
241
255
  return entries;
242
256
  }, [items]);
243
257
 
244
- if (items.length === 0) return null;
245
258
  return (
246
259
  <div className="space-y-3">
247
260
  {grouped.map(([who, list]) => (
@@ -250,10 +263,12 @@ function ActionItemsByPerson({
250
263
  <Avatar className="h-5 w-5">
251
264
  <AvatarImage alt={who} />
252
265
  <AvatarFallback className="text-[9px]">
253
- {attendeeInitials(who)}
266
+ {attendeeInitials(who || t("meetingDetail.unassigned"))}
254
267
  </AvatarFallback>
255
268
  </Avatar>
256
- <span className="text-xs font-medium">{who}</span>
269
+ <span className="text-xs font-medium">
270
+ {who || t("meetingDetail.unassigned")}
271
+ </span>
257
272
  <span className="text-[10px] text-muted-foreground">
258
273
  {list.filter((x) => x.item.completedAt).length}/{list.length}
259
274
  </span>
@@ -290,24 +305,153 @@ function ActionItemsByPerson({
290
305
  <IconCheck className="h-2.5 w-2.5 text-background" />
291
306
  )}
292
307
  </button>
293
- <span
294
- className={cn(
295
- "flex-1",
296
- done && "line-through text-muted-foreground",
297
- )}
298
- >
299
- {it.text}
300
- </span>
308
+ {readOnly ? (
309
+ <span
310
+ className={cn(
311
+ "flex-1",
312
+ done && "line-through text-muted-foreground",
313
+ )}
314
+ >
315
+ {it.text}
316
+ </span>
317
+ ) : (
318
+ <ActionItemTextEditor
319
+ value={it.text}
320
+ done={done}
321
+ placeholder={t("meetingDetail.actionItemPlaceholder")}
322
+ onCommit={(text) => onChange(index, text)}
323
+ onCancel={() => {}}
324
+ />
325
+ )}
326
+ {!readOnly && (
327
+ <Button
328
+ type="button"
329
+ size="icon"
330
+ variant="ghost"
331
+ className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
332
+ aria-label={t("meetingDetail.removeActionItem")}
333
+ onClick={() => onRemove(index)}
334
+ >
335
+ <IconTrash className="h-3.5 w-3.5" />
336
+ </Button>
337
+ )}
301
338
  </li>
302
339
  );
303
340
  })}
304
341
  </ul>
305
342
  </div>
306
343
  ))}
344
+
345
+ {grouped.length === 0 && (
346
+ <p className="text-sm leading-relaxed text-muted-foreground/50 italic">
347
+ {emptyLabel}
348
+ </p>
349
+ )}
350
+
351
+ {adding && (
352
+ <div className="flex items-start gap-2 pl-7 text-xs leading-relaxed">
353
+ <span className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border border-border" />
354
+ <ActionItemTextEditor
355
+ value=""
356
+ isNew
357
+ autoFocus
358
+ placeholder={t("meetingDetail.actionItemPlaceholder")}
359
+ onCommit={(text) => {
360
+ onAdd(text);
361
+ setAdding(false);
362
+ }}
363
+ onCancel={() => setAdding(false)}
364
+ />
365
+ </div>
366
+ )}
367
+
368
+ {!readOnly && (
369
+ <Button
370
+ type="button"
371
+ variant="ghost"
372
+ size="sm"
373
+ className="ml-7 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground"
374
+ disabled={adding}
375
+ onClick={() => setAdding(true)}
376
+ >
377
+ <IconPlus className="mr-1.5 h-3.5 w-3.5" />
378
+ {t("meetingDetail.addActionItem")}
379
+ </Button>
380
+ )}
307
381
  </div>
308
382
  );
309
383
  }
310
384
 
385
+ function ActionItemTextEditor({
386
+ value,
387
+ done = false,
388
+ isNew = false,
389
+ autoFocus = false,
390
+ placeholder,
391
+ onCommit,
392
+ onCancel,
393
+ }: {
394
+ value: string;
395
+ done?: boolean;
396
+ isNew?: boolean;
397
+ autoFocus?: boolean;
398
+ placeholder: string;
399
+ onCommit: (text: string) => void;
400
+ onCancel: () => void;
401
+ }) {
402
+ const [draft, setDraft] = useState(value);
403
+ const inputRef = useRef<HTMLInputElement>(null);
404
+ const committedRef = useRef(false);
405
+
406
+ useEffect(() => {
407
+ setDraft(value);
408
+ committedRef.current = false;
409
+ }, [value]);
410
+
411
+ useEffect(() => {
412
+ if (autoFocus) inputRef.current?.focus();
413
+ }, [autoFocus]);
414
+
415
+ const commit = () => {
416
+ if (committedRef.current) return;
417
+ const next = draft.trim();
418
+ if (!next) {
419
+ setDraft(value);
420
+ onCancel();
421
+ return;
422
+ }
423
+ if (isNew || next !== value) {
424
+ committedRef.current = true;
425
+ onCommit(next);
426
+ }
427
+ };
428
+
429
+ return (
430
+ <Input
431
+ ref={inputRef}
432
+ value={draft}
433
+ placeholder={placeholder}
434
+ onChange={(event) => setDraft(event.target.value)}
435
+ onBlur={commit}
436
+ onKeyDown={(event) => {
437
+ if (event.key === "Enter") {
438
+ event.preventDefault();
439
+ commit();
440
+ }
441
+ if (event.key === "Escape") {
442
+ event.preventDefault();
443
+ setDraft(value);
444
+ onCancel();
445
+ }
446
+ }}
447
+ className={cn(
448
+ "h-auto min-h-0 flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0",
449
+ done && "line-through text-muted-foreground",
450
+ )}
451
+ />
452
+ );
453
+ }
454
+
311
455
  export default function MeetingDetailRoute() {
312
456
  const t = useT();
313
457
  const { meetingId } = useParams<{ meetingId: string }>();
@@ -326,7 +470,12 @@ export default function MeetingDetailRoute() {
326
470
  role?: "owner" | "admin" | "editor" | "commenter" | "viewer";
327
471
  };
328
472
 
329
- const { data, isLoading, isError } = useActionQuery<GetMeetingResp>(
473
+ const {
474
+ data,
475
+ isLoading,
476
+ isError,
477
+ refetch: refetchMeeting,
478
+ } = useActionQuery<GetMeetingResp>(
330
479
  "get-meeting",
331
480
  { id: meetingId },
332
481
  {
@@ -357,6 +506,31 @@ export default function MeetingDetailRoute() {
357
506
  const [transcriptCopied, setTranscriptCopied] = useState(false);
358
507
  const previousHasNotesRef = useRef(false);
359
508
  const autoFinalizedRef = useRef(false);
509
+ const actionItemsDraftRef = useRef<{
510
+ meetingId: string;
511
+ items: ActionItem[];
512
+ } | null>(null);
513
+ const actionItemsAuthoritativeRef = useRef<{
514
+ meetingId: string;
515
+ items: ActionItem[];
516
+ } | null>(null);
517
+ const actionItemsSaveRevisionRef = useRef(0);
518
+ const pendingActionItemsSavesRef = useRef(0);
519
+ const meetingContentSaveQueueRef = useRef<Promise<unknown>>(
520
+ Promise.resolve(),
521
+ );
522
+ const meetingTitleSaveRevisionRef = useRef(0);
523
+ const meetingTitleDraftRef = useRef<{
524
+ meetingId: string;
525
+ value: string;
526
+ } | null>(null);
527
+ const richNotePendingRef = useRef<{
528
+ meetingId: string;
529
+ patch: { summaryMd?: string; userNotesMd?: string };
530
+ label: string;
531
+ } | null>(null);
532
+ const richNoteSaveActiveRef = useRef(false);
533
+ const meetingUpdatedAtRef = useRef<string | null>(null);
360
534
 
361
535
  // Imperative scroll-to handle wired by TranscriptBubbles
362
536
  const transcriptScrollToRef = useRef<((index: number) => void) | null>(null);
@@ -405,6 +579,32 @@ export default function MeetingDetailRoute() {
405
579
  (meeting?.bulletsJson?.length ?? 0) > 0 ||
406
580
  (meeting?.actionItemsJson?.length ?? 0) > 0;
407
581
 
582
+ useEffect(() => {
583
+ if (!meeting) {
584
+ actionItemsDraftRef.current = null;
585
+ actionItemsAuthoritativeRef.current = null;
586
+ return;
587
+ }
588
+ if (
589
+ actionItemsDraftRef.current?.meetingId !== meeting.id ||
590
+ pendingActionItemsSavesRef.current === 0
591
+ ) {
592
+ const serverItems = meeting.actionItemsJson ?? [];
593
+ actionItemsAuthoritativeRef.current = {
594
+ meetingId: meeting.id,
595
+ items: serverItems,
596
+ };
597
+ actionItemsDraftRef.current = {
598
+ meetingId: meeting.id,
599
+ items: serverItems,
600
+ };
601
+ }
602
+ }, [meeting?.id, meeting?.actionItemsJson]);
603
+
604
+ useEffect(() => {
605
+ meetingUpdatedAtRef.current = meeting?.updatedAt ?? null;
606
+ }, [meeting?.id, meeting?.updatedAt]);
607
+
408
608
  // Recording is a native Clips desktop-app gesture (Granola-style), not an
409
609
  // in-browser capture. For an un-recorded, not-yet-past meeting we surface a
410
610
  // handoff to the desktop app. While the desktop records, this web view polls
@@ -469,31 +669,243 @@ export default function MeetingDetailRoute() {
469
669
  );
470
670
  };
471
671
 
672
+ const recordMeetingSaveResult = (result: unknown) => {
673
+ const updatedAt = (
674
+ result as { meeting?: { updatedAt?: unknown } } | null | undefined
675
+ )?.meeting?.updatedAt;
676
+ if (typeof updatedAt !== "string") return;
677
+ meetingUpdatedAtRef.current = updatedAt;
678
+ patchCachedMeeting({ updatedAt });
679
+ };
680
+
681
+ const refetchMeetingAfterSaveFailure = async () => {
682
+ try {
683
+ const refreshed = await refetchMeeting();
684
+ const updatedAt = refreshed.data?.meeting?.updatedAt;
685
+ if (typeof updatedAt === "string") {
686
+ meetingUpdatedAtRef.current = updatedAt;
687
+ }
688
+ return refreshed.data;
689
+ } catch (error) {
690
+ console.error("[clips] meeting refetch after save failed", error);
691
+ return undefined;
692
+ }
693
+ };
694
+
472
695
  const handleTitleChange = (next: string) => {
473
696
  if (!meeting) return;
697
+ const revision = ++meetingTitleSaveRevisionRef.current;
698
+ meetingTitleDraftRef.current = { meetingId: meeting.id, value: next };
474
699
  patchCachedMeeting({ title: next });
475
- updateMeeting.mutate({ id: meeting.id, title: next });
700
+ const save = meetingContentSaveQueueRef.current
701
+ .catch(() => undefined)
702
+ .then(async () => {
703
+ const expectedUpdatedAt = meetingUpdatedAtRef.current;
704
+ const result = await updateMeeting.mutateAsync({
705
+ id: meeting.id,
706
+ title: next,
707
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
708
+ });
709
+ recordMeetingSaveResult(result);
710
+ return result;
711
+ });
712
+ meetingContentSaveQueueRef.current = save.catch((error) => {
713
+ return (async () => {
714
+ console.error("[clips] meeting title save failed", error);
715
+ const hasNewerDraft = meetingTitleSaveRevisionRef.current !== revision;
716
+ await refetchMeetingAfterSaveFailure();
717
+ if (
718
+ hasNewerDraft &&
719
+ meetingTitleDraftRef.current?.meetingId === meeting.id
720
+ ) {
721
+ patchCachedMeeting({ title: meetingTitleDraftRef.current.value });
722
+ }
723
+ toast.error(t("transcriptPanel.saveFailed", { status: "title" }));
724
+ })();
725
+ });
726
+ };
727
+
728
+ const enqueueRichNoteSave = (
729
+ meetingId: string,
730
+ patch: { summaryMd?: string; userNotesMd?: string },
731
+ label: string,
732
+ ) => {
733
+ const pending = richNotePendingRef.current;
734
+ richNotePendingRef.current = {
735
+ meetingId,
736
+ patch: {
737
+ ...(pending?.meetingId === meetingId ? pending.patch : {}),
738
+ ...patch,
739
+ },
740
+ label,
741
+ };
742
+ if (richNoteSaveActiveRef.current) return;
743
+
744
+ richNoteSaveActiveRef.current = true;
745
+ const drain = meetingContentSaveQueueRef.current
746
+ .catch(() => undefined)
747
+ .then(async () => {
748
+ while (richNotePendingRef.current) {
749
+ const current = richNotePendingRef.current;
750
+ richNotePendingRef.current = null;
751
+ try {
752
+ const expectedUpdatedAt = meetingUpdatedAtRef.current;
753
+ const result = await updateMeeting.mutateAsync({
754
+ id: current.meetingId,
755
+ ...current.patch,
756
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
757
+ });
758
+ recordMeetingSaveResult(result);
759
+ } catch (error) {
760
+ console.error("[clips] rich note save failed", error);
761
+ await refetchMeetingAfterSaveFailure();
762
+ // A newer blur may have arrived while the failed request was in
763
+ // flight. Refetch to refresh the CAS token, then put that latest
764
+ // draft back into the optimistic cache before retrying it.
765
+ const nextPending = richNotePendingRef.current as {
766
+ meetingId: string;
767
+ patch: { summaryMd?: string; userNotesMd?: string };
768
+ label: string;
769
+ } | null;
770
+ if (nextPending) {
771
+ patchCachedMeeting(nextPending.patch);
772
+ continue;
773
+ }
774
+ toast.error(
775
+ t("transcriptPanel.saveFailed", { status: current.label }),
776
+ );
777
+ }
778
+ }
779
+ })
780
+ .finally(() => {
781
+ richNoteSaveActiveRef.current = false;
782
+ });
783
+ meetingContentSaveQueueRef.current = drain.catch((error) => {
784
+ console.error("[clips] rich note save drain failed", error);
785
+ });
476
786
  };
477
787
 
478
788
  const handleSummaryChange = (next: string) => {
479
789
  if (!meeting) return;
480
790
  patchCachedMeeting({ summaryMd: next });
481
- updateMeeting.mutate({ id: meeting.id, summaryMd: next });
791
+ enqueueRichNoteSave(
792
+ meeting.id,
793
+ { summaryMd: next },
794
+ t("meetingDetail.summary"),
795
+ );
796
+ };
797
+
798
+ const handleUserNotesChange = (next: string) => {
799
+ if (!meeting) return;
800
+ patchCachedMeeting({ userNotesMd: next });
801
+ enqueueRichNoteSave(
802
+ meeting.id,
803
+ { userNotesMd: next },
804
+ t("meetingDetail.myNotes"),
805
+ );
806
+ };
807
+
808
+ const persistActionItems = (next: ActionItem[]) => {
809
+ if (!meeting) return;
810
+ const meetingId = meeting.id;
811
+ const revision = ++actionItemsSaveRevisionRef.current;
812
+ actionItemsDraftRef.current = { meetingId, items: next };
813
+ patchCachedMeeting({ actionItemsJson: next });
814
+ pendingActionItemsSavesRef.current += 1;
815
+ const save = meetingContentSaveQueueRef.current
816
+ .catch(() => undefined)
817
+ .then(async () => {
818
+ const expectedUpdatedAt = meetingUpdatedAtRef.current;
819
+ const result = await updateMeeting.mutateAsync({
820
+ id: meetingId,
821
+ actionItems: next,
822
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
823
+ });
824
+ recordMeetingSaveResult(result);
825
+ actionItemsAuthoritativeRef.current = { meetingId, items: next };
826
+ return result;
827
+ });
828
+ meetingContentSaveQueueRef.current = save
829
+ .catch(async (error) => {
830
+ console.error("[clips] action-item save failed", error);
831
+ toast.error(
832
+ t("transcriptPanel.saveFailed", {
833
+ status: t("meetingDetail.actionItems"),
834
+ }),
835
+ );
836
+ const hasNewerDraft = actionItemsSaveRevisionRef.current !== revision;
837
+ const refreshed = await refetchMeetingAfterSaveFailure();
838
+ if (hasNewerDraft) {
839
+ const latestDraft = actionItemsDraftRef.current;
840
+ if (latestDraft?.meetingId === meetingId) {
841
+ patchCachedMeeting({ actionItemsJson: latestDraft.items });
842
+ }
843
+ return;
844
+ }
845
+
846
+ const rollbackItems = Array.isArray(refreshed?.actionItems)
847
+ ? refreshed.actionItems
848
+ : actionItemsAuthoritativeRef.current?.meetingId === meetingId
849
+ ? actionItemsAuthoritativeRef.current.items
850
+ : [];
851
+ actionItemsAuthoritativeRef.current = {
852
+ meetingId,
853
+ items: rollbackItems,
854
+ };
855
+ actionItemsDraftRef.current = { meetingId, items: rollbackItems };
856
+ patchCachedMeeting({ actionItemsJson: rollbackItems });
857
+ })
858
+ .finally(() => {
859
+ pendingActionItemsSavesRef.current -= 1;
860
+ });
861
+ };
862
+
863
+ const currentActionItems = () => {
864
+ const draft = actionItemsDraftRef.current;
865
+ if (meeting && draft && draft.meetingId === meeting.id) {
866
+ return draft.items;
867
+ }
868
+ return meeting?.actionItemsJson ?? [];
482
869
  };
483
870
 
484
871
  const handleToggleActionItem = (index: number, completed: boolean) => {
485
872
  if (!meeting) return;
486
- const items = meeting.actionItemsJson ?? [];
873
+ const items = currentActionItems();
487
874
  const next = items.map((it, i) =>
488
875
  i === index
489
876
  ? { ...it, completedAt: completed ? new Date().toISOString() : null }
490
877
  : it,
491
878
  );
492
- patchCachedMeeting({ actionItemsJson: next });
493
- updateMeeting.mutate({
494
- id: meeting.id,
495
- actionItemsJson: JSON.stringify(next),
496
- });
879
+ persistActionItems(next);
880
+ };
881
+
882
+ const handleActionItemChange = (index: number, text: string) => {
883
+ if (!meeting) return;
884
+ const items = currentActionItems();
885
+ persistActionItems(
886
+ items.map((item, itemIndex) =>
887
+ itemIndex === index ? { ...item, text } : item,
888
+ ),
889
+ );
890
+ };
891
+
892
+ const handleActionItemRemove = (index: number) => {
893
+ if (!meeting) return;
894
+ const items = currentActionItems();
895
+ persistActionItems(items.filter((_, itemIndex) => itemIndex !== index));
896
+ };
897
+
898
+ const handleActionItemAdd = (text: string) => {
899
+ if (!meeting) return;
900
+ persistActionItems([
901
+ ...currentActionItems(),
902
+ {
903
+ text,
904
+ assigneeEmail: null,
905
+ dueDate: null,
906
+ completedAt: null,
907
+ },
908
+ ]);
497
909
  };
498
910
 
499
911
  const handleJumpToSegment = (segmentIndex: number) => {
@@ -642,7 +1054,13 @@ export default function MeetingDetailRoute() {
642
1054
  if (!segments.length) return;
643
1055
  const text = segments
644
1056
  .map((s) => {
645
- const label = s.speaker || (s.source === "system" ? "Them" : "Me");
1057
+ const label =
1058
+ s.speaker?.trim() ||
1059
+ (s.source === "mic"
1060
+ ? t("transcriptBubbles.me")
1061
+ : s.source === "system"
1062
+ ? t("transcriptBubbles.them")
1063
+ : t("transcriptBubbles.unknownSpeaker"));
646
1064
  return `${label}: ${s.text}`;
647
1065
  })
648
1066
  .join("\n");
@@ -909,12 +1327,31 @@ export default function MeetingDetailRoute() {
909
1327
  </div>
910
1328
 
911
1329
  <div className="min-h-0 flex-1 overflow-y-auto">
1330
+ <div className="max-w-2xl px-6 pt-5">
1331
+ <div className="mb-2 text-xs font-medium">
1332
+ {t("meetingDetail.myNotes")}
1333
+ </div>
1334
+ <CanvasEditor
1335
+ view="user"
1336
+ userNotesMd={meeting.userNotesMd ?? ""}
1337
+ onUserNotesChange={handleUserNotesChange}
1338
+ readOnly={!canEdit}
1339
+ className="max-w-none px-0 py-0"
1340
+ />
1341
+ </div>
1342
+
1343
+ <div className="max-w-2xl px-6 pt-5">
1344
+ <div className="mb-2 text-xs font-medium">
1345
+ {t("meetingDetail.aiNotes")}
1346
+ </div>
1347
+ </div>
912
1348
  <CanvasEditor
913
1349
  view="ai"
914
1350
  summaryMd={meeting.summaryMd ?? ""}
915
1351
  bullets={bullets.map((b) => b.text)}
916
1352
  onSummaryChange={handleSummaryChange}
917
1353
  readOnly={!canEdit}
1354
+ className="max-w-2xl px-6 pt-0 pb-0"
918
1355
  renderBullet={(b) => (
919
1356
  <BulletLink
920
1357
  bullet={b}
@@ -933,17 +1370,15 @@ export default function MeetingDetailRoute() {
933
1370
  <div className="mb-3 text-xs font-medium">
934
1371
  {t("meetingDetail.actionItems")}
935
1372
  </div>
936
- {actionItems.length > 0 ? (
937
- <ActionItemsByPerson
938
- items={actionItems}
939
- onToggle={handleToggleActionItem}
940
- readOnly={!canEdit}
941
- />
942
- ) : (
943
- <p className="text-sm leading-relaxed text-muted-foreground/50 italic">
944
- {t("meetingDetail.noActionItems")}
945
- </p>
946
- )}
1373
+ <ActionItemsByPerson
1374
+ items={actionItems}
1375
+ onToggle={handleToggleActionItem}
1376
+ onChange={handleActionItemChange}
1377
+ onRemove={handleActionItemRemove}
1378
+ onAdd={handleActionItemAdd}
1379
+ emptyLabel={t("meetingDetail.noActionItems")}
1380
+ readOnly={!canEdit}
1381
+ />
947
1382
  </div>
948
1383
  </div>
949
1384
  </div>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-18
4
+ ---
5
+
6
+ Meeting notes support rich editing for personal notes, summaries, and action items.
@@ -954,6 +954,7 @@ mod macos {
954
954
  "thank you very much",
955
955
  "thanks for watching",
956
956
  "thank you for watching",
957
+ "ご視聴ありがとうございました",
957
958
  "please subscribe",
958
959
  ];
959
960
  if HALLUCINATIONS.contains(&normalized.as_str()) {
@@ -1407,9 +1408,9 @@ mod macos {
1407
1408
  use std::time::{Duration, Instant};
1408
1409
 
1409
1410
  use super::{
1410
- buffer_start_after_drain, partial_inference_due, partial_inference_timestamp,
1411
- resample_to_16k, should_use_combined_sck_capture, split_mic_capture_options,
1412
- utterance_finalize_due, IncrementalResample, SessionOwner,
1411
+ buffer_start_after_drain, clean_transcript, partial_inference_due,
1412
+ partial_inference_timestamp, resample_to_16k, should_use_combined_sck_capture,
1413
+ split_mic_capture_options, utterance_finalize_due, IncrementalResample, SessionOwner,
1413
1414
  };
1414
1415
  use crate::native_speech::macos::MicVoiceProcessingMode;
1415
1416
 
@@ -1566,5 +1567,14 @@ mod macos {
1566
1567
  }
1567
1568
  );
1568
1569
  }
1570
+
1571
+ #[test]
1572
+ fn filters_multilingual_caption_hallucinations() {
1573
+ assert_eq!(clean_transcript("ご視聴ありがとうございました"), None);
1574
+ assert_eq!(
1575
+ clean_transcript("Thanks for the update"),
1576
+ Some("Thanks for the update".to_string())
1577
+ );
1578
+ }
1569
1579
  }
1570
1580
  }