@agent-native/core-corpus 0.211.0-nightly-20261020183616 → 0.211.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/templates/analytics/CHANGELOG.md +18 -0
  3. package/corpus/templates/clips/CHANGELOG.md +11 -24
  4. package/corpus/templates/clips/actions/create-recording-upload.ts +5 -1
  5. package/corpus/templates/clips/changelog/2026-10-16-agent-uploads-can-use-free-clips-storage.md +6 -0
  6. package/corpus/templates/clips/server/lib/recording-upload-ticket.ts +8 -0
  7. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +2 -0
  8. package/corpus/templates/content/CHANGELOG.md +12 -0
  9. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +4 -0
  10. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +56 -2
  11. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -46
  12. package/corpus/templates/content/app/components/editor/database/ContentTable.tsx +2 -1
  13. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +28 -13
  14. package/corpus/templates/content/app/components/editor/database/database-cell-comments.tsx +515 -0
  15. package/corpus/templates/content/app/components/editor/database/database-item-comments.tsx +5 -43
  16. package/corpus/templates/content/app/components/editor/property-comments.tsx +35 -4
  17. package/corpus/templates/content/app/components/editor/property-date-format.ts +45 -0
  18. package/corpus/templates/content/app/global.css +23 -0
  19. package/corpus/templates/content/app/i18n-data.ts +1 -0
  20. package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +5 -1
  21. package/corpus/templates/design/CHANGELOG.md +2 -0
  22. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  23. package/corpus/templates/mail/CHANGELOG.md +14 -0
  24. package/corpus/templates/plan/CHANGELOG.md +4 -0
  25. package/package.json +1 -1
  26. package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  27. package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  28. package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  29. package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  30. package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  31. package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  32. package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  33. package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  34. package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  35. package/corpus/templates/design/.generated/bridge/native-builtin-metadata.generated.ts +0 -301
  36. package/corpus/templates/design/.generated/bridge/native-worker-source.generated.ts +0 -6
  37. package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  38. package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  39. package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  40. package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  41. package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
@@ -0,0 +1,515 @@
1
+ import { useSession } from "@agent-native/core/client/hooks";
2
+ import { useT } from "@agent-native/core/client/i18n";
3
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
4
+ import type { ContentDatabaseItem, DocumentProperty } from "@shared/api";
5
+ import { commentPropertyTarget } from "@shared/comment-property-target";
6
+ import {
7
+ IconLayoutSidebarRight,
8
+ IconMessage,
9
+ IconMessagePlus,
10
+ } from "@tabler/icons-react";
11
+ import { useQueryClient } from "@tanstack/react-query";
12
+ import {
13
+ type MutableRefObject,
14
+ useContext,
15
+ useEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ } from "react";
20
+ import { toast } from "sonner";
21
+
22
+ import {
23
+ Tooltip,
24
+ TooltipContent,
25
+ TooltipTrigger,
26
+ } from "@/components/ui/tooltip";
27
+ import {
28
+ type CommentThread,
29
+ collectionRowsShowingDocumentFilter,
30
+ useComments,
31
+ } from "@/hooks/use-comments";
32
+ import { cn } from "@/lib/utils";
33
+
34
+ import {
35
+ CommentDraftProvider,
36
+ useCommentPanelSession,
37
+ } from "../comment-drafts";
38
+ import { CommentIconButton } from "../CommentRow";
39
+ import {
40
+ CommentsSidebar,
41
+ useCommentReplyDrafts,
42
+ usePendingCommentDraft,
43
+ } from "../CommentsSidebar";
44
+ import { deviceCannotHover } from "./ContentTable";
45
+ import {
46
+ DatabaseItemCommentsContext,
47
+ databaseCommentMarkerClassName,
48
+ } from "./database-item-comments";
49
+
50
+ type CellCommentCard = {
51
+ /** `start` opens on an empty composer, `read` on the cell's open threads. */
52
+ mode: "start" | "read";
53
+ key: number;
54
+ };
55
+
56
+ const NO_THREADS: CommentThread[] = [];
57
+
58
+ // Cards holding unsent text. While one does, no other cell opens a card,
59
+ // which would replace the row's saved draft with an empty one.
60
+ const cardsHoldingText = new Set<MutableRefObject<boolean>>();
61
+
62
+ /** Who can start, and who can resolve, Comments on a row's Page. */
63
+ export function databaseItemCommentAccess(item: ContentDatabaseItem) {
64
+ const { document } = item;
65
+ if (document.source?.mode === "local-files") {
66
+ return { canComment: false, canResolve: false };
67
+ }
68
+ const role = document.accessRole;
69
+ return {
70
+ canComment:
71
+ document.canComment ??
72
+ (role === "owner" ||
73
+ role === "admin" ||
74
+ role === "editor" ||
75
+ role === "commenter"),
76
+ canResolve: document.canEdit === true,
77
+ };
78
+ }
79
+
80
+ function isEditableTarget(target: EventTarget | null) {
81
+ return (
82
+ target instanceof Element &&
83
+ !!target.closest("[contenteditable='true'], input, textarea")
84
+ );
85
+ }
86
+
87
+ /**
88
+ * A table cell's Comments: a marker when its Property has open threads, and a
89
+ * button on hover or focus that starts one. Both open a card on the cell, so
90
+ * reading, replying, and starting a thread happen without leaving the table.
91
+ * Without a provider, cells show no Comment controls.
92
+ */
93
+ export function DatabaseCellComments({
94
+ item,
95
+ property,
96
+ rowSelected = false,
97
+ besideEditButton = false,
98
+ }: {
99
+ item: ContentDatabaseItem;
100
+ property: DocumentProperty;
101
+ /**
102
+ * Touch screens can't hover, so there the Comment button shows on a
103
+ * selected row's cells, like the row's selection controls.
104
+ */
105
+ rowSelected?: boolean;
106
+ /** The cell's value editor keeps its own button at the cell's end. */
107
+ besideEditButton?: boolean;
108
+ }) {
109
+ const t = useT();
110
+ const { session } = useSession();
111
+ const openComments = useContext(DatabaseItemCommentsContext);
112
+ const [card, setCard] = useState<CellCommentCard | null>(null);
113
+ const cardKeyRef = useRef(0);
114
+ const openerRef = useRef<HTMLButtonElement | null>(null);
115
+ const controlsRef = useRef<HTMLSpanElement>(null);
116
+ const startRef = useRef<HTMLButtonElement>(null);
117
+ // Set while the card holds unsent text, so a stray click or Escape keeps it.
118
+ const keepOpenRef = useRef(false);
119
+ // Set when focus belongs elsewhere once the card closes: wherever the user
120
+ // clicked, or the row's peek, which closes if focus returns to the table.
121
+ const skipFocusReturnRef = useRef(false);
122
+ const [layer, setLayer] = useState<HTMLDivElement | null>(null);
123
+ const [overflowing, setOverflowing] = useState(false);
124
+ useEffect(() => {
125
+ if (!layer || typeof ResizeObserver === "undefined") return;
126
+ const measure = () =>
127
+ setOverflowing(layer.scrollHeight > layer.clientHeight + 1);
128
+ const observer = new ResizeObserver(measure);
129
+ observer.observe(layer);
130
+ for (const child of layer.children) observer.observe(child);
131
+ measure();
132
+ return () => observer.disconnect();
133
+ }, [layer, card?.key]);
134
+
135
+ if (!openComments) return null;
136
+ const { canComment, canResolve } = databaseItemCommentAccess(item);
137
+ const propertyId = property.definition.id;
138
+ const name = property.definition.name;
139
+ const summary = item.comments?.properties[propertyId];
140
+ if (!summary && !canComment && !card) return null;
141
+
142
+ const open = (mode: CellCommentCard["mode"], opener: HTMLButtonElement) => {
143
+ // A card holding unsent text stays as it is, and no other card opens.
144
+ if (cardsHoldingText.size > 0) return;
145
+ if (card?.mode === mode) {
146
+ close();
147
+ return;
148
+ }
149
+ // Switching an open card to a new comment remounts only the card, so
150
+ // the composer takes focus once the clicked button lets go of it.
151
+ if (card && mode === "start") opener.blur();
152
+ openerRef.current = opener;
153
+ skipFocusReturnRef.current = false;
154
+ setCard({ mode, key: ++cardKeyRef.current });
155
+ };
156
+ const close = () => setCard(null);
157
+ // Once its last thread is resolved, the marker that opened the card goes
158
+ // away, so focus returns to the cell's Comment button instead.
159
+ const settle = () => {
160
+ const start = startRef.current;
161
+ // A touch screen hides it outside a selected row.
162
+ if (start && (start.checkVisibility?.() ?? true)) {
163
+ openerRef.current = start;
164
+ }
165
+ close();
166
+ };
167
+ const startLabel = t("comments.commentOnProperty", { name });
168
+ const markerLabel = summary
169
+ ? t("comments.openPropertyComments", { count: summary.open, name })
170
+ : "";
171
+
172
+ return (
173
+ <PopoverPrimitive.Root
174
+ open={!!card}
175
+ onOpenChange={(next) => {
176
+ if (!next) close();
177
+ }}
178
+ >
179
+ <PopoverPrimitive.Anchor asChild>
180
+ <span
181
+ className="pointer-events-none absolute inset-0"
182
+ aria-hidden="true"
183
+ />
184
+ </PopoverPrimitive.Anchor>
185
+ <span
186
+ ref={controlsRef}
187
+ className="relative ms-auto flex min-h-6 shrink-0 items-center"
188
+ >
189
+ {canComment ? (
190
+ <Tooltip>
191
+ <TooltipTrigger asChild>
192
+ <button
193
+ ref={startRef}
194
+ type="button"
195
+ className={cn(
196
+ "pointer-events-none absolute end-full top-0 flex size-6 items-center justify-center rounded bg-background text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-focus-within/cell:pointer-events-auto group-focus-within/cell:opacity-100 group-hover/cell:pointer-events-auto group-hover/cell:opacity-100 [@media(any-hover:none)]:hidden",
197
+ besideEditButton ? "me-6" : "me-0.5",
198
+ card?.mode === "start" && "pointer-events-auto opacity-100",
199
+ rowSelected &&
200
+ "[@media(any-hover:none)]:pointer-events-auto [@media(any-hover:none)]:flex [@media(any-hover:none)]:opacity-100",
201
+ )}
202
+ aria-label={startLabel}
203
+ aria-haspopup="dialog"
204
+ aria-expanded={card?.mode === "start"}
205
+ data-database-cell-comment-start
206
+ onClick={(event) => {
207
+ event.stopPropagation();
208
+ // A card is cramped under a phone's keyboard, so touch
209
+ // screens write the comment in the row's peek.
210
+ if (deviceCannotHover()) {
211
+ if (keepOpenRef.current) return;
212
+ // The peek closes if focus returns to the table.
213
+ skipFocusReturnRef.current = true;
214
+ close();
215
+ openComments(item, null, propertyId);
216
+ return;
217
+ }
218
+ open("start", event.currentTarget);
219
+ }}
220
+ >
221
+ <IconMessagePlus className="size-3.5" aria-hidden="true" />
222
+ </button>
223
+ </TooltipTrigger>
224
+ <TooltipContent>{startLabel}</TooltipContent>
225
+ </Tooltip>
226
+ ) : null}
227
+ {summary ? (
228
+ <Tooltip>
229
+ <TooltipTrigger asChild>
230
+ <button
231
+ type="button"
232
+ className={databaseCommentMarkerClassName}
233
+ aria-label={markerLabel}
234
+ aria-haspopup="dialog"
235
+ aria-expanded={card?.mode === "read"}
236
+ data-database-cell-comments={summary.open}
237
+ onClick={(event) => {
238
+ event.stopPropagation();
239
+ open("read", event.currentTarget);
240
+ }}
241
+ >
242
+ <IconMessage className="size-3.5" aria-hidden="true" />
243
+ {summary.open > 1 ? summary.open : null}
244
+ </button>
245
+ </TooltipTrigger>
246
+ <TooltipContent>{markerLabel}</TooltipContent>
247
+ </Tooltip>
248
+ ) : null}
249
+ </span>
250
+ <PopoverPrimitive.Portal>
251
+ <PopoverPrimitive.Content
252
+ ref={setLayer}
253
+ align="end"
254
+ sideOffset={4}
255
+ collisionPadding={12}
256
+ className="database-cell-comment-card"
257
+ data-database-cell-comment-card={card?.mode}
258
+ data-overflowing={overflowing ? "" : undefined}
259
+ onOpenAutoFocus={(event) => {
260
+ if (card?.mode !== "start") return;
261
+ // The composer takes focus once it mounts, but only from the page.
262
+ event.preventDefault();
263
+ if (globalThis.document.activeElement instanceof HTMLElement) {
264
+ globalThis.document.activeElement.blur();
265
+ }
266
+ }}
267
+ onCloseAutoFocus={(event) => {
268
+ event.preventDefault();
269
+ const opener = openerRef.current;
270
+ if (!skipFocusReturnRef.current && opener?.isConnected) {
271
+ opener.focus({ preventScroll: true });
272
+ }
273
+ }}
274
+ onEscapeKeyDown={(event) => {
275
+ // A composer's Escape closes its menus or cancels an empty draft.
276
+ if (keepOpenRef.current || isEditableTarget(event.target)) {
277
+ event.preventDefault();
278
+ }
279
+ }}
280
+ onInteractOutside={(event) => {
281
+ // The cell's own buttons switch or close the card when clicked.
282
+ if (
283
+ keepOpenRef.current ||
284
+ (event.target instanceof Node &&
285
+ controlsRef.current?.contains(event.target))
286
+ ) {
287
+ event.preventDefault();
288
+ } else {
289
+ skipFocusReturnRef.current = true;
290
+ }
291
+ }}
292
+ >
293
+ {card ? (
294
+ <CommentDraftProvider
295
+ documentId={item.document.id}
296
+ currentUserEmail={session?.email}
297
+ currentUserOrgId={session?.orgId}
298
+ >
299
+ <DatabaseCellCommentCard
300
+ key={card.key}
301
+ item={item}
302
+ property={property}
303
+ mode={card.mode}
304
+ canComment={canComment}
305
+ canResolve={canResolve}
306
+ currentUserEmail={session?.email ?? undefined}
307
+ currentUserOrgId={session?.orgId ?? undefined}
308
+ keepOpenRef={keepOpenRef}
309
+ onClose={close}
310
+ onSettled={settle}
311
+ onOpenRow={(threadId) => {
312
+ // The peek returns focus to the cell when it closes.
313
+ skipFocusReturnRef.current = true;
314
+ openerRef.current?.focus({ preventScroll: true });
315
+ close();
316
+ openComments(item, threadId);
317
+ }}
318
+ />
319
+ </CommentDraftProvider>
320
+ ) : null}
321
+ </PopoverPrimitive.Content>
322
+ </PopoverPrimitive.Portal>
323
+ </PopoverPrimitive.Root>
324
+ );
325
+ }
326
+
327
+ function DatabaseCellCommentCard({
328
+ item,
329
+ property,
330
+ mode,
331
+ canComment,
332
+ canResolve,
333
+ currentUserEmail,
334
+ currentUserOrgId,
335
+ keepOpenRef,
336
+ onClose,
337
+ onSettled,
338
+ onOpenRow,
339
+ }: {
340
+ item: ContentDatabaseItem;
341
+ property: DocumentProperty;
342
+ mode: CellCommentCard["mode"];
343
+ canComment: boolean;
344
+ canResolve: boolean;
345
+ currentUserEmail?: string;
346
+ currentUserOrgId?: string;
347
+ keepOpenRef: MutableRefObject<boolean>;
348
+ onClose: () => void;
349
+ onSettled: () => void;
350
+ onOpenRow: (threadId: string) => void;
351
+ }) {
352
+ const t = useT();
353
+ const queryClient = useQueryClient();
354
+ const documentId = item.document.id;
355
+ const propertyId = property.definition.id;
356
+ const { data: threads, isLoading, refetch } = useComments(documentId);
357
+ // The marker can be newer than this row's cached threads, so a card opened
358
+ // from it reads the threads again before deciding the cell has none.
359
+ const [listedSinceOpen, setListedSinceOpen] = useState(mode === "start");
360
+ const replyDrafts = useCommentReplyDrafts(documentId, currentUserEmail);
361
+ const {
362
+ pendingComment,
363
+ setPendingComment,
364
+ changePendingComment,
365
+ completePendingComment,
366
+ } = usePendingCommentDraft(documentId);
367
+ // After posting, the card shows the new thread rather than every thread.
368
+ const [postedThreadId, setPostedThreadId] = useState<string | null>(null);
369
+
370
+ useEffect(() => {
371
+ if (mode === "read") {
372
+ const marked = item.comments?.properties[propertyId]?.open ?? 0;
373
+ void refetch()
374
+ .then(({ data, error }) => {
375
+ // With nothing cached either, the card closes; say why.
376
+ if (!data && error) {
377
+ toast.error(t("empty.genericError"), {
378
+ description: error instanceof Error ? error.message : undefined,
379
+ });
380
+ return;
381
+ }
382
+ const open = data?.filter(
383
+ (thread) =>
384
+ thread.property?.propertyId === propertyId && !thread.resolved,
385
+ ).length;
386
+ // A marker that no longer matches refreshes the row's counts.
387
+ if (open !== undefined && open !== marked) {
388
+ void queryClient.invalidateQueries({
389
+ ...collectionRowsShowingDocumentFilter(documentId),
390
+ refetchType: "active",
391
+ });
392
+ }
393
+ })
394
+ .finally(() => setListedSinceOpen(true));
395
+ return;
396
+ }
397
+ setPendingComment({
398
+ quotedText: "",
399
+ offsetTop: 0,
400
+ property: commentPropertyTarget(property),
401
+ databaseId: item.databaseId,
402
+ });
403
+ // Runs once per card; the card remounts each time it opens.
404
+ // eslint-disable-next-line react-hooks/exhaustive-deps
405
+ }, []);
406
+
407
+ const propertyThreads = useMemo(
408
+ () =>
409
+ threads?.filter((thread) => thread.property?.propertyId === propertyId) ??
410
+ NO_THREADS,
411
+ [threads, propertyId],
412
+ );
413
+ const openThreads = propertyThreads.filter(
414
+ (thread) =>
415
+ !thread.resolved &&
416
+ (!postedThreadId || thread.threadId === postedThreadId),
417
+ );
418
+ const postedThread = postedThreadId
419
+ ? propertyThreads.find((thread) => thread.threadId === postedThreadId)
420
+ : undefined;
421
+ // A thread being resolved stays on screen until the server confirms it,
422
+ // so a failed resolve leaves it in the card with its error.
423
+ const { isResolving } = useCommentPanelSession();
424
+ const resolvingIds = propertyThreads
425
+ .filter((thread) => isResolving(thread.threadId))
426
+ .map((thread) => thread.threadId)
427
+ .join(" ");
428
+ const shownThreads = useMemo(() => {
429
+ if (!resolvingIds) return propertyThreads;
430
+ const resolving = new Set(resolvingIds.split(" "));
431
+ return propertyThreads.map((thread) =>
432
+ thread.resolved && resolving.has(thread.threadId)
433
+ ? { ...thread, resolved: false }
434
+ : thread,
435
+ );
436
+ }, [propertyThreads, resolvingIds]);
437
+ // Resolving the last thread, or the one just posted, closes the card, as
438
+ // does a read that found none or failed.
439
+ const settled =
440
+ !pendingComment &&
441
+ !resolvingIds &&
442
+ (postedThreadId
443
+ ? postedThread?.resolved === true
444
+ : mode === "read" && listedSinceOpen && openThreads.length === 0);
445
+ useEffect(() => {
446
+ if (settled) onSettled();
447
+ }, [settled, onSettled]);
448
+
449
+ const openReplyThreadId = replyDrafts.openReply?.threadId ?? null;
450
+ // Text still being written or sent; a failed send puts it back.
451
+ const unsent =
452
+ !!pendingComment?.submitting ||
453
+ !!pendingComment?.text.trim() ||
454
+ (!!openReplyThreadId && !!replyDrafts.get(openReplyThreadId).text.trim()) ||
455
+ propertyThreads.some((thread) => replyDrafts.isSubmitting(thread.threadId));
456
+ useEffect(() => {
457
+ keepOpenRef.current = unsent;
458
+ if (unsent) cardsHoldingText.add(keepOpenRef);
459
+ return () => {
460
+ keepOpenRef.current = false;
461
+ cardsHoldingText.delete(keepOpenRef);
462
+ };
463
+ }, [keepOpenRef, unsent]);
464
+
465
+ return (
466
+ <CommentsSidebar
467
+ compact
468
+ surface="popover"
469
+ presentation="inline"
470
+ alignToAnchors={false}
471
+ forceVisible
472
+ replyDrafts={replyDrafts}
473
+ documentId={documentId}
474
+ // While composing, the card shows only the new comment.
475
+ threads={pendingComment ? NO_THREADS : shownThreads}
476
+ visibleThreadId={postedThreadId}
477
+ documentProperties={item.properties}
478
+ isLoading={
479
+ !pendingComment &&
480
+ (isLoading || (!listedSinceOpen && openThreads.length === 0))
481
+ }
482
+ pendingComment={pendingComment}
483
+ onPendingChange={changePendingComment}
484
+ onPendingDone={(id, threadId) => {
485
+ if (!completePendingComment(id)) return;
486
+ if (threadId) setPostedThreadId(threadId);
487
+ else onClose();
488
+ }}
489
+ currentUserEmail={currentUserEmail}
490
+ currentUserOrgId={currentUserOrgId}
491
+ canComment={canComment}
492
+ canResolve={canResolve}
493
+ onClose={onClose}
494
+ renderThreadActions={(thread) =>
495
+ thread.threadId.startsWith("optimistic-") ? null : (
496
+ <Tooltip>
497
+ <TooltipTrigger asChild>
498
+ <CommentIconButton
499
+ aria-label={t("comments.openRow")}
500
+ data-database-cell-comment-open-row
501
+ onClick={(event) => {
502
+ event.stopPropagation();
503
+ onOpenRow(thread.threadId);
504
+ }}
505
+ >
506
+ <IconLayoutSidebarRight size={18} />
507
+ </CommentIconButton>
508
+ </TooltipTrigger>
509
+ <TooltipContent>{t("comments.openRow")}</TooltipContent>
510
+ </Tooltip>
511
+ )
512
+ }
513
+ />
514
+ );
515
+ }
@@ -11,17 +11,19 @@ import {
11
11
 
12
12
  /**
13
13
  * Opens a row with its Comments showing: one thread when `threadId` is set,
14
- * otherwise the list. Without a provider, rows show no Comment markers.
14
+ * a new comment on the Property `propertyId` names, otherwise the list.
15
+ * Without a provider, rows show no Comment markers.
15
16
  */
16
17
  export type OpenDatabaseItemComments = (
17
18
  item: ContentDatabaseItem,
18
19
  threadId: string | null,
20
+ propertyId?: string,
19
21
  ) => void;
20
22
 
21
23
  export const DatabaseItemCommentsContext =
22
24
  createContext<OpenDatabaseItemComments | null>(null);
23
25
 
24
- const markerClassName =
26
+ export const databaseCommentMarkerClassName =
25
27
  "flex h-6 min-w-6 shrink-0 items-center justify-center gap-0.5 rounded px-1 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring comment-marker";
26
28
 
27
29
  /** The count of a row's open Comment threads, beside its name. */
@@ -43,7 +45,7 @@ export function DatabaseRowCommentCount({
43
45
  <TooltipTrigger asChild>
44
46
  <button
45
47
  type="button"
46
- className={markerClassName}
48
+ className={databaseCommentMarkerClassName}
47
49
  aria-label={label}
48
50
  data-database-row-comments={count}
49
51
  onClick={(event) => {
@@ -59,43 +61,3 @@ export function DatabaseRowCommentCount({
59
61
  </Tooltip>
60
62
  );
61
63
  }
62
-
63
- /** Marks a cell whose Property has open Comment threads. */
64
- export function DatabaseCellCommentMarker({
65
- item,
66
- propertyId,
67
- propertyName,
68
- }: {
69
- item: ContentDatabaseItem;
70
- propertyId: string;
71
- propertyName: string;
72
- }) {
73
- const t = useT();
74
- const openComments = useContext(DatabaseItemCommentsContext);
75
- const summary = item.comments?.properties[propertyId];
76
- if (!openComments || !summary) return null;
77
- const label = t("comments.openPropertyComments", {
78
- count: summary.open,
79
- name: propertyName,
80
- });
81
- return (
82
- <Tooltip>
83
- <TooltipTrigger asChild>
84
- <button
85
- type="button"
86
- className={markerClassName}
87
- aria-label={label}
88
- data-database-cell-comments={summary.open}
89
- onClick={(event) => {
90
- event.stopPropagation();
91
- openComments(item, summary.latestThreadId);
92
- }}
93
- >
94
- <IconMessage className="size-3.5" aria-hidden="true" />
95
- {summary.open > 1 ? summary.open : null}
96
- </button>
97
- </TooltipTrigger>
98
- <TooltipContent>{label}</TooltipContent>
99
- </Tooltip>
100
- );
101
- }
@@ -12,6 +12,11 @@ import {
12
12
  import type { CommentThread } from "@/hooks/use-comments";
13
13
  import { cn } from "@/lib/utils";
14
14
 
15
+ import {
16
+ formatPropertyDate,
17
+ formatPropertyDateTime,
18
+ } from "./property-date-format";
19
+
15
20
  /**
16
21
  * What a Page editor tells its Properties about their Comment threads, and how
17
22
  * a Property starts or opens one. Without a provider, Properties show no
@@ -60,9 +65,10 @@ export function bodyCommentThreads(
60
65
  export interface PropertyThreadTarget {
61
66
  name: string;
62
67
  /**
63
- * The value when the thread started. Threads that recorded none (one
64
- * still being saved, or a relation, rollup, or formula) show the value
65
- * now, and nothing once the Property is gone.
68
+ * The value when the thread started, with dates read as the table shows
69
+ * them. Threads that recorded none (one still being saved, or a relation,
70
+ * rollup, or formula) show the value now, and nothing once the Property is
71
+ * gone.
66
72
  */
67
73
  value: string | null;
68
74
  /**
@@ -73,6 +79,29 @@ export interface PropertyThreadTarget {
73
79
  state: "current" | "changed" | "removed" | "unknown";
74
80
  }
75
81
 
82
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
83
+ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
84
+
85
+ /** ISO text as Properties show dates; anything else as it was written. */
86
+ function readableDate(text: string) {
87
+ if (ISO_DATE.test(text)) return formatPropertyDate(text);
88
+ if (ISO_DATE_TIME.test(text)) return formatPropertyDateTime(text);
89
+ return text;
90
+ }
91
+
92
+ /**
93
+ * A remembered value as its Property shows values. Comments remember dates
94
+ * as ISO text, and a range as "start - end". A value recorded before the
95
+ * Property became a date keeps its text.
96
+ */
97
+ function readablePropertyValue(value: string, type: string | undefined) {
98
+ if (type === "date") return value.split(" - ").map(readableDate).join(" - ");
99
+ if (type === "created_time" || type === "last_edited_time") {
100
+ return readableDate(value);
101
+ }
102
+ return value;
103
+ }
104
+
76
105
  export function propertyThreadTarget(
77
106
  property: NonNullable<CommentThread["property"]>,
78
107
  properties: readonly DocumentProperty[] | undefined,
@@ -84,7 +113,9 @@ export function propertyThreadTarget(
84
113
  const value = property.propertyValue ?? currentValue;
85
114
  return {
86
115
  name: property.propertyName ?? current?.definition.name ?? "",
87
- value,
116
+ value: value
117
+ ? readablePropertyValue(value, current?.definition.type)
118
+ : value,
88
119
  state: !properties
89
120
  ? "unknown"
90
121
  : !current
@@ -0,0 +1,45 @@
1
+ /** A date as Properties show it, such as "Nov 2, 2026". */
2
+ export function formatPropertyDate(value: string) {
3
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
4
+ const [year, month, day] = value.split("-").map(Number);
5
+ return new Intl.DateTimeFormat(undefined, {
6
+ month: "short",
7
+ day: "numeric",
8
+ year: "numeric",
9
+ }).format(new Date(year, month - 1, day));
10
+ }
11
+
12
+ const date = new Date(value);
13
+ if (Number.isNaN(date.getTime())) return value;
14
+ return new Intl.DateTimeFormat(undefined, {
15
+ month: "short",
16
+ day: "numeric",
17
+ year: "numeric",
18
+ }).format(date);
19
+ }
20
+
21
+ /** A date and time as Properties show it, such as "Nov 2, 2026, 3:30 PM". */
22
+ export function formatPropertyDateTime(value: string) {
23
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
24
+ const [datePart, timePart] = value.split("T");
25
+ const [year, month, day] = datePart.split("-").map(Number);
26
+ const [hour, minute] = timePart.split(":").map(Number);
27
+ return new Intl.DateTimeFormat(undefined, {
28
+ month: "short",
29
+ day: "numeric",
30
+ year: "numeric",
31
+ hour: "numeric",
32
+ minute: "2-digit",
33
+ }).format(new Date(year, month - 1, day, hour, minute));
34
+ }
35
+
36
+ const date = new Date(value);
37
+ if (Number.isNaN(date.getTime())) return value;
38
+ return new Intl.DateTimeFormat(undefined, {
39
+ month: "short",
40
+ day: "numeric",
41
+ year: "numeric",
42
+ hour: "numeric",
43
+ minute: "2-digit",
44
+ }).format(date);
45
+ }