@heyramzi/ui 2.0.1 → 2.1.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 (68) hide show
  1. package/package.json +42 -17
  2. package/src/canvas/README.md +9 -12
  3. package/src/charts/dashboard/data-panel.svelte +1 -1
  4. package/src/components/list-page/detail-panel.svelte +1 -1
  5. package/src/components/list-page/filter-popover.svelte +1 -1
  6. package/src/components/list-page/index.ts +3 -3
  7. package/src/components/list-page/rail-head.svelte +1 -1
  8. package/src/components/section-header/index.ts +1 -0
  9. package/src/components/section-header/section-header.svelte +1 -1
  10. package/src/components/toolbar/index.ts +2 -0
  11. package/src/components/toolbar/toolbar.svelte +2 -2
  12. package/src/inbox/README.md +29 -0
  13. package/src/inbox/inbox.test.ts +171 -0
  14. package/src/inbox/index.ts +31 -0
  15. package/src/inbox/logic.ts +166 -0
  16. package/src/inbox/thread-inbox.svelte +363 -0
  17. package/src/inbox/types.ts +139 -0
  18. package/src/modules.test.ts +95 -0
  19. package/src/panel/README.md +5 -0
  20. package/src/panel/index.ts +2 -0
  21. package/src/{tasks → panel}/side-panel.svelte +1 -1
  22. package/src/record/README.md +3 -0
  23. package/src/{tasks/record → record}/field-section.svelte +1 -1
  24. package/src/{tasks/record → record}/info-list.svelte +9 -9
  25. package/src/{tasks/record → record}/next-up.svelte +4 -4
  26. package/src/{tasks/record → record}/project-list.svelte +5 -5
  27. package/src/{tasks/record → record}/record-demo.svelte +4 -4
  28. package/src/{tasks/record → record}/record-header.svelte +3 -3
  29. package/src/{tasks/record → record}/record-shell.svelte +4 -4
  30. package/src/{tasks/record → record}/record-stats.svelte +2 -2
  31. package/src/{tasks/record → record}/record-timeline.svelte +6 -6
  32. package/src/{tasks/record → record}/steps-rail.svelte +7 -7
  33. package/src/{tasks/record → record}/types.ts +2 -2
  34. package/src/{tasks/record → record}/where-we-are.svelte +4 -4
  35. package/src/status/README.md +18 -0
  36. package/src/status/index.ts +11 -0
  37. package/src/tasks/README.md +1 -1
  38. package/src/tasks/demo.svelte +3 -3
  39. package/src/tasks/detail/detail-demo.svelte +2 -2
  40. package/src/tasks/gantt/gantt.test.ts +1 -1
  41. package/src/tasks/inbox/inbox-card.svelte +2 -2
  42. package/src/tasks/inbox/inbox-statuses.ts +1 -1
  43. package/src/tasks/inbox/inbox.svelte +1 -1
  44. package/src/tasks/index.ts +9 -7
  45. package/src/tasks/presets/sales.ts +1 -1
  46. package/src/thread/README.md +31 -0
  47. package/src/thread/index.ts +12 -0
  48. package/src/thread/logic.ts +58 -0
  49. package/src/thread/thread.svelte +366 -0
  50. package/src/thread/thread.test.ts +62 -0
  51. package/src/thread/types.ts +93 -0
  52. /package/src/components/{list-page → section-header}/section-label.svelte +0 -0
  53. /package/src/components/{list-page → toolbar}/toolbar-search.svelte +0 -0
  54. /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
  55. /package/src/{tasks → panel}/side-panel-types.ts +0 -0
  56. /package/src/{tasks/record → record}/demo-data.ts +0 -0
  57. /package/src/{tasks/record → record}/fields.ts +0 -0
  58. /package/src/{tasks/record → record}/format.ts +0 -0
  59. /package/src/{tasks/record → record}/gutter.ts +0 -0
  60. /package/src/{tasks/record → record}/index.ts +0 -0
  61. /package/src/{tasks/record → record}/kinds.ts +0 -0
  62. /package/src/{tasks/record → record}/logic.ts +0 -0
  63. /package/src/{tasks/record → record}/record.test.ts +0 -0
  64. /package/src/{tasks → status}/define-statuses.ts +0 -0
  65. /package/src/{tasks → status}/status-badge.svelte +0 -0
  66. /package/src/{tasks → status}/status-dot.svelte +0 -0
  67. /package/src/{tasks → status}/status-icon.svelte +0 -0
  68. /package/src/{tasks → status}/status-types.ts +0 -0
@@ -0,0 +1,366 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import { ArrowBendDownRight, Check, MapPin, PencilSimple, Trash } from "phosphor-svelte";
4
+ import { cn } from "../lib/utils.js";
5
+ import { AgentAvatar } from "../agents/index.js";
6
+ import { TONE_CLASS } from "../lib/tone.js";
7
+ import RichRenderer from "../editor/renderer.svelte";
8
+ import { Skeleton } from "../shadcn/skeleton/index.js";
9
+ import { buildThread, fillCount, resolveThreadLabels, resolvedCount } from "./logic.js";
10
+ import type { ThreadComment, ThreadLabels, ThreadPost } from "./types.js";
11
+
12
+ interface Props {
13
+ comments: ThreadComment[];
14
+ /** Rows still on their way. Draws skeleton rows in place of the thread. */
15
+ loading?: boolean;
16
+ labels?: Partial<ThreadLabels>;
17
+ /** Replaces the default avatar for a person or an agent, drawn in a 24px slot. */
18
+ avatar?: Snippet<[ThreadComment]>;
19
+ /** Content under a comment's body, such as files. */
20
+ attachments?: Snippet<[ThreadComment]>;
21
+ /** Replaces the default "Pinned" chip on a comment that has an anchor. */
22
+ anchorChip?: Snippet<[ThreadComment]>;
23
+ /** How a timestamp reads. Defaults to a short date. */
24
+ when?: (comment: ThreadComment) => string;
25
+ /** Returns false to keep the draft. Without it no composer shows. */
26
+ onPost?: (post: ThreadPost) => void | boolean | Promise<void | boolean>;
27
+ onEdit?: (comment: ThreadComment, body: string) => void | Promise<void>;
28
+ onDelete?: (comment: ThreadComment) => void | Promise<void>;
29
+ /** Without it a root has no resolve button. */
30
+ onResolve?: (comment: ThreadComment, resolved: boolean) => void | Promise<void>;
31
+ /** A click on a pinned comment's chip, to move a canvas to its node. */
32
+ onAnchor?: (comment: ThreadComment) => void;
33
+ /** Whose comments carry the edit and delete buttons. A rich comment can't be edited here. */
34
+ canEdit?: (comment: ThreadComment) => boolean;
35
+ class?: string;
36
+ }
37
+
38
+ let {
39
+ comments,
40
+ loading = false,
41
+ labels: labelOverrides,
42
+ avatar,
43
+ attachments,
44
+ anchorChip,
45
+ when = (comment) => new Date(comment.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
46
+ onPost,
47
+ onEdit,
48
+ onDelete,
49
+ onResolve,
50
+ onAnchor,
51
+ canEdit = () => false,
52
+ class: className,
53
+ }: Props = $props();
54
+
55
+ let labels = $derived(resolveThreadLabels(labelOverrides));
56
+ let nodes = $derived(buildThread(comments));
57
+ let resolved = $derived(resolvedCount(nodes));
58
+ let showResolved = $state(false);
59
+ let shown = $derived(showResolved ? nodes : nodes.filter((node) => !node.comment.resolved));
60
+
61
+ let draft = $state("");
62
+ let replyTo = $state<string | null>(null);
63
+ let replyDraft = $state("");
64
+ let sending = $state(false);
65
+ let editingId = $state<string | null>(null);
66
+ let editDraft = $state("");
67
+
68
+ async function post(body: string, parentId: string | null): Promise<boolean> {
69
+ const text = body.trim();
70
+ if (!text || sending || !onPost) return false;
71
+ sending = true;
72
+ try {
73
+ return (await onPost({ body: text, parentId })) !== false;
74
+ } finally {
75
+ sending = false;
76
+ }
77
+ }
78
+
79
+ async function sendRoot(): Promise<void> {
80
+ if (await post(draft, null)) draft = "";
81
+ }
82
+
83
+ async function sendReply(): Promise<void> {
84
+ if (await post(replyDraft, replyTo)) {
85
+ replyDraft = "";
86
+ replyTo = null;
87
+ }
88
+ }
89
+
90
+ function submitOnModEnter(event: KeyboardEvent, send: () => Promise<void>): void {
91
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
92
+ event.preventDefault();
93
+ void send();
94
+ }
95
+ }
96
+
97
+ async function saveEdit(comment: ThreadComment): Promise<void> {
98
+ const body = editDraft.trim();
99
+ editingId = null;
100
+ if (!body || body === comment.body) return;
101
+ await onEdit?.(comment, body);
102
+ }
103
+
104
+ function initial(name: string): string {
105
+ return name.trim().charAt(0).toLocaleUpperCase() || "?";
106
+ }
107
+ </script>
108
+
109
+ {#snippet composer(value: string, placeholder: string, onSend: () => Promise<void>, set: (next: string) => void, isReply: boolean)}
110
+ <div class="rounded-lg border border-input transition-shadow focus-within:ring-3 focus-within:ring-ring/50">
111
+ <!-- svelte-ignore a11y_autofocus -->
112
+ <textarea
113
+ {value}
114
+ oninput={(event) => set(event.currentTarget.value)}
115
+ onkeydown={(event) => submitOnModEnter(event, onSend)}
116
+ rows={2}
117
+ {placeholder}
118
+ aria-label={placeholder}
119
+ autofocus={isReply}
120
+ class="block max-h-60 min-h-16 w-full resize-none border-0 bg-transparent px-3 py-2 text-sm leading-relaxed outline-none [field-sizing:content] placeholder:text-muted-foreground"
121
+ ></textarea>
122
+ <div class="flex justify-end px-2 pb-2">
123
+ <button type="button" class="thread-button thread-primary" onclick={onSend} disabled={!value.trim() || sending}>
124
+ {sending ? labels.sending : isReply ? labels.reply : labels.send}
125
+ </button>
126
+ </div>
127
+ </div>
128
+ {/snippet}
129
+
130
+ {#snippet row(comment: ThreadComment, isRoot: boolean)}
131
+ <div class="group/row relative flex gap-3">
132
+ <span class="relative size-6 shrink-0">
133
+ {#if avatar}
134
+ {@render avatar(comment)}
135
+ {:else if comment.author.kind === "agent"}
136
+ <AgentAvatar name={comment.author.name} size={24} paused interactive={false} />
137
+ {:else if comment.author.avatar}
138
+ <img src={comment.author.avatar} alt="" class="size-6 rounded-full object-cover" />
139
+ {:else}
140
+ <span class="grid size-6 place-items-center rounded-full bg-muted text-xs font-medium text-muted-foreground" aria-hidden="true">
141
+ {initial(comment.author.name)}
142
+ </span>
143
+ {/if}
144
+ </span>
145
+
146
+ <div class="min-w-0 flex-1">
147
+ <div class="flex min-h-6 flex-wrap items-center gap-x-2">
148
+ <span class="truncate text-[13px] font-medium">{comment.author.name}</span>
149
+ {#if comment.author.kind === "agent"}
150
+ <span class={cn("inline-flex h-5 items-center rounded-sm border px-1.5 text-xs font-medium", TONE_CLASS.accent)}>
151
+ {labels.agent}
152
+ </span>
153
+ {/if}
154
+ <span class="text-xs text-muted-foreground tabular-nums">{when(comment)}</span>
155
+ {#if comment.edited}
156
+ <span class="text-xs text-muted-foreground">{labels.edited}</span>
157
+ {/if}
158
+ {#if comment.anchor}
159
+ {#if anchorChip}
160
+ {@render anchorChip(comment)}
161
+ {:else if onAnchor}
162
+ <button type="button" class="thread-chip" onclick={() => onAnchor(comment)}>
163
+ <MapPin class="size-3" aria-hidden="true" />{labels.pinned}
164
+ </button>
165
+ {:else}
166
+ <span class="thread-chip"><MapPin class="size-3" aria-hidden="true" />{labels.pinned}</span>
167
+ {/if}
168
+ {/if}
169
+ {#if editingId !== comment.id}
170
+ <span class="ms-auto flex gap-0.5 opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
171
+ {#if isRoot && onResolve}
172
+ <button
173
+ type="button"
174
+ class="thread-icon"
175
+ aria-label={comment.resolved ? labels.unresolve : labels.resolve}
176
+ aria-pressed={comment.resolved ?? false}
177
+ onclick={() => onResolve(comment, !comment.resolved)}
178
+ >
179
+ <Check class="size-3.5" aria-hidden="true" />
180
+ </button>
181
+ {/if}
182
+ {#if canEdit(comment)}
183
+ {#if onEdit && !comment.content}
184
+ <button type="button" class="thread-icon" aria-label={labels.edit} onclick={() => { editingId = comment.id; editDraft = comment.body; }}>
185
+ <PencilSimple class="size-3.5" aria-hidden="true" />
186
+ </button>
187
+ {/if}
188
+ {#if onDelete}
189
+ <button type="button" class="thread-icon" aria-label={labels.delete} onclick={() => onDelete?.(comment)}>
190
+ <Trash class="size-3.5" aria-hidden="true" />
191
+ </button>
192
+ {/if}
193
+ {/if}
194
+ </span>
195
+ {/if}
196
+ </div>
197
+
198
+ {#if editingId === comment.id}
199
+ <textarea
200
+ bind:value={editDraft}
201
+ rows={3}
202
+ aria-label={labels.edit}
203
+ class="mt-1 w-full resize-y rounded-md border border-input bg-transparent px-2.5 py-2 text-sm leading-relaxed outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
204
+ ></textarea>
205
+ <div class="mt-2 flex justify-end gap-1.5">
206
+ <button type="button" class="thread-button" onclick={() => (editingId = null)}>{labels.cancel}</button>
207
+ <button type="button" class="thread-button thread-primary" onclick={() => saveEdit(comment)}>{labels.save}</button>
208
+ </div>
209
+ {:else if comment.content}
210
+ <div class="text-sm leading-relaxed"><RichRenderer content={comment.content} /></div>
211
+ {:else}
212
+ <p class="text-sm leading-relaxed whitespace-pre-wrap text-pretty">{comment.body}</p>
213
+ {/if}
214
+
215
+ {@render attachments?.(comment)}
216
+ </div>
217
+ </div>
218
+ {/snippet}
219
+
220
+ <section aria-label={labels.title} class={cn("flex flex-col gap-3", className)}>
221
+ <div class="flex h-7 items-center justify-between">
222
+ <h2 class="text-xs font-medium text-muted-foreground">{labels.title}</h2>
223
+ {#if resolved > 0}
224
+ <button type="button" class="thread-button" aria-expanded={showResolved} onclick={() => (showResolved = !showResolved)}>
225
+ {showResolved ? labels.hideResolved : fillCount(labels.showResolved, resolved)}
226
+ </button>
227
+ {/if}
228
+ </div>
229
+
230
+ {#if loading}
231
+ <ol class="flex flex-col gap-4" aria-busy="true">
232
+ {#each [0, 1] as placeholder (placeholder)}
233
+ <li class="flex gap-3">
234
+ <Skeleton class="size-6 shrink-0 rounded-full" />
235
+ <div class="flex flex-1 flex-col gap-2">
236
+ <Skeleton class="h-3.5 w-28" />
237
+ <Skeleton class="h-3.5 w-full" />
238
+ </div>
239
+ </li>
240
+ {/each}
241
+ </ol>
242
+ {:else if shown.length > 0}
243
+ <ol class="flex flex-col gap-4">
244
+ {#each shown as node (node.comment.id)}
245
+ <li class={cn("flex flex-col gap-3", node.comment.resolved && "opacity-60")} data-resolved={node.comment.resolved || undefined}>
246
+ {@render row(node.comment, true)}
247
+ {#if node.comment.resolved}
248
+ <span class="ms-9 text-xs text-muted-foreground">{labels.resolved}</span>
249
+ {/if}
250
+ {#if node.replies.length > 0}
251
+ <ol class="ms-3 flex flex-col gap-3 border-s border-border ps-6">
252
+ {#each node.replies as reply (reply.id)}
253
+ <li>{@render row(reply, false)}</li>
254
+ {/each}
255
+ </ol>
256
+ {/if}
257
+ {#if onPost && !node.comment.resolved}
258
+ {#if replyTo === node.comment.id}
259
+ <div class="ms-9">
260
+ {@render composer(replyDraft, labels.replyPlaceholder, sendReply, (next) => (replyDraft = next), true)}
261
+ </div>
262
+ {:else}
263
+ <button type="button" class="thread-button ms-9 flex w-fit items-center gap-1.5" onclick={() => { replyTo = node.comment.id; replyDraft = ""; }}>
264
+ <ArrowBendDownRight class="size-3.5" aria-hidden="true" />{labels.reply}
265
+ </button>
266
+ {/if}
267
+ {/if}
268
+ </li>
269
+ {/each}
270
+ </ol>
271
+ {:else if !onPost}
272
+ <p class="text-[13px] text-muted-foreground">{labels.empty}</p>
273
+ {/if}
274
+
275
+ {#if onPost}
276
+ {@render composer(draft, labels.placeholder, sendRoot, (next) => (draft = next), false)}
277
+ {/if}
278
+ </section>
279
+
280
+ <style>
281
+ .thread-icon {
282
+ display: grid;
283
+ place-items: center;
284
+ width: 1.75rem;
285
+ height: 1.75rem;
286
+ border-radius: var(--radius-md, 0.5rem);
287
+ color: var(--color-muted-foreground);
288
+ outline: none;
289
+ transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
290
+ }
291
+
292
+ .thread-chip {
293
+ display: inline-flex;
294
+ align-items: center;
295
+ gap: 0.25rem;
296
+ height: 1.25rem;
297
+ padding-inline: 0.375rem;
298
+ border-radius: var(--radius-sm, 0.25rem);
299
+ background: var(--color-muted);
300
+ color: var(--color-muted-foreground);
301
+ font-size: 0.75rem;
302
+ outline: none;
303
+ }
304
+
305
+ button.thread-chip:focus-visible {
306
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-ring) 50%, transparent);
307
+ }
308
+
309
+ @media (hover: hover) and (pointer: fine) {
310
+ .thread-icon:hover {
311
+ background: var(--color-muted);
312
+ color: var(--color-foreground);
313
+ }
314
+ }
315
+
316
+ .thread-icon:focus-visible,
317
+ .thread-button:focus-visible {
318
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-ring) 50%, transparent);
319
+ }
320
+
321
+ .thread-button {
322
+ height: 1.75rem;
323
+ padding-inline: 0.75rem;
324
+ border-radius: var(--radius-md, 0.5rem);
325
+ font-size: 0.8125rem;
326
+ font-weight: 500;
327
+ outline: none;
328
+ transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
329
+ }
330
+
331
+ @media (hover: hover) and (pointer: fine) {
332
+ .thread-button:hover:not(:disabled) {
333
+ background: var(--color-muted);
334
+ }
335
+ }
336
+
337
+ .thread-button:disabled {
338
+ opacity: 0.5;
339
+ pointer-events: none;
340
+ }
341
+
342
+ .thread-primary {
343
+ background: var(--color-primary);
344
+ color: var(--color-primary-foreground);
345
+ }
346
+
347
+ /* An empty draft reads as an outlined button, not a faded fill. */
348
+ .thread-primary:disabled {
349
+ opacity: 1;
350
+ background: transparent;
351
+ box-shadow: inset 0 0 0 1px var(--color-border);
352
+ color: var(--color-muted-foreground);
353
+ }
354
+
355
+ @media (hover: hover) and (pointer: fine) {
356
+ .thread-primary:hover:not(:disabled) {
357
+ background: color-mix(in oklab, var(--color-primary) 90%, transparent);
358
+ }
359
+ }
360
+
361
+ @media (any-pointer: coarse) {
362
+ .thread-button {
363
+ height: 2.5rem;
364
+ }
365
+ }
366
+ </style>
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { buildThread, fillCount, resolveThreadLabels, resolvedCount } from "./logic.js";
3
+ import { DEFAULT_THREAD_LABELS, type ThreadComment } from "./types.js";
4
+
5
+ const ada = { kind: "user", name: "Ada" } as const;
6
+ const bot = { kind: "agent", name: "Scribe" } as const;
7
+
8
+ function comment(id: string, at: string, extra: Partial<ThreadComment> = {}): ThreadComment {
9
+ return { id, author: ada, body: id, createdAt: at, ...extra };
10
+ }
11
+
12
+ describe("buildThread", () => {
13
+ it("puts replies under their root in time order", () => {
14
+ const nodes = buildThread([
15
+ comment("r2", "2026-10-22T10:05:00Z", { parentId: "a" }),
16
+ comment("a", "2026-10-22T10:00:00Z"),
17
+ comment("b", "2026-10-22T10:02:00Z", { author: bot }),
18
+ comment("r1", "2026-10-22T10:03:00Z", { parentId: "a" }),
19
+ ]);
20
+ expect(nodes.map((n) => n.comment.id)).toEqual(["a", "b"]);
21
+ expect(nodes[0].replies.map((r) => r.id)).toEqual(["r1", "r2"]);
22
+ });
23
+
24
+ it("keeps a reply to a reply under the root", () => {
25
+ const nodes = buildThread([
26
+ comment("a", "2026-10-22T10:00:00Z"),
27
+ comment("r1", "2026-10-22T10:01:00Z", { parentId: "a" }),
28
+ comment("r2", "2026-10-22T10:02:00Z", { parentId: "r1" }),
29
+ ]);
30
+ expect(nodes).toHaveLength(1);
31
+ expect(nodes[0].replies.map((r) => r.id)).toEqual(["r1", "r2"]);
32
+ });
33
+
34
+ it("turns an orphan reply into a root and survives a parent loop", () => {
35
+ const nodes = buildThread([
36
+ comment("x", "2026-10-22T10:00:00Z", { parentId: "gone" }),
37
+ comment("p", "2026-10-22T10:01:00Z", { parentId: "q" }),
38
+ comment("q", "2026-10-22T10:02:00Z", { parentId: "p" }),
39
+ ]);
40
+ expect(nodes.map((n) => n.comment.id)).toContain("x");
41
+ expect(nodes.flatMap((n) => [n.comment.id, ...n.replies.map((r) => r.id)]).sort()).toEqual(["p", "q", "x"]);
42
+ });
43
+
44
+ it("sorts a malformed timestamp last", () => {
45
+ const nodes = buildThread([comment("bad", "soon"), comment("ok", "2026-10-22T10:00:00Z")]);
46
+ expect(nodes.map((n) => n.comment.id)).toEqual(["ok", "bad"]);
47
+ });
48
+
49
+ it("counts resolved roots", () => {
50
+ const nodes = buildThread([comment("a", "2026-10-22T10:00:00Z", { resolved: true }), comment("b", "2026-10-22T10:01:00Z")]);
51
+ expect(resolvedCount(nodes)).toBe(1);
52
+ });
53
+ });
54
+
55
+ describe("labels", () => {
56
+ it("lets a host write French over the English defaults", () => {
57
+ const fr = resolveThreadLabels({ title: "Activité", send: "Commenter", showResolved: "Afficher {count} résolus" });
58
+ expect(fr.title).toBe("Activité");
59
+ expect(fr.cancel).toBe(DEFAULT_THREAD_LABELS.cancel);
60
+ expect(fillCount(fr.showResolved, 3)).toBe("Afficher 3 résolus");
61
+ });
62
+ });
@@ -0,0 +1,93 @@
1
+ export type ThreadAuthorKind = "user" | "agent";
2
+
3
+ export type ThreadAuthor = {
4
+ kind: ThreadAuthorKind;
5
+ name: string;
6
+ /** Image URL for a person. The host's own portrait goes through the `avatar` snippet. */
7
+ avatar?: string;
8
+ };
9
+
10
+ /** Where on a canvas a comment is pinned: a node and an offset from it, or a point. */
11
+ export type ThreadAnchor = {
12
+ nodeId?: string;
13
+ dx?: number;
14
+ dy?: number;
15
+ x?: number;
16
+ y?: number;
17
+ };
18
+
19
+ /** One comment on any subject. The host resolves the author's name. */
20
+ export type ThreadComment = {
21
+ id: string;
22
+ author: ThreadAuthor;
23
+ /** Plain text, shown as written. Also the fallback when `content` is missing. */
24
+ body: string;
25
+ /** Tiptap JSON. When set, the thread draws it through the editor's renderer instead of `body`. */
26
+ content?: Record<string, unknown>;
27
+ /** ISO timestamp. */
28
+ createdAt: string;
29
+ /** Set when the body changed after posting. */
30
+ edited?: boolean;
31
+ /** The comment this one answers. A reply to a reply sits under the same root. */
32
+ parentId?: string | null;
33
+ /** Only a root carries it: the whole conversation is resolved. */
34
+ resolved?: boolean;
35
+ anchor?: ThreadAnchor | null;
36
+ };
37
+
38
+ export type ThreadPost = {
39
+ body: string;
40
+ parentId: string | null;
41
+ anchor?: ThreadAnchor | null;
42
+ };
43
+
44
+ /** A root comment with its replies in time order. */
45
+ export type ThreadNode = {
46
+ comment: ThreadComment;
47
+ replies: ThreadComment[];
48
+ };
49
+
50
+ export type ThreadLabels = {
51
+ title: string;
52
+ empty: string;
53
+ placeholder: string;
54
+ replyPlaceholder: string;
55
+ send: string;
56
+ sending: string;
57
+ reply: string;
58
+ edit: string;
59
+ delete: string;
60
+ edited: string;
61
+ save: string;
62
+ cancel: string;
63
+ resolve: string;
64
+ unresolve: string;
65
+ resolved: string;
66
+ /** Shows `{count}` resolved conversations. */
67
+ showResolved: string;
68
+ hideResolved: string;
69
+ agent: string;
70
+ pinned: string;
71
+ };
72
+
73
+ export const DEFAULT_THREAD_LABELS: ThreadLabels = {
74
+ title: "Activity",
75
+ empty: "No comments yet",
76
+ placeholder: "Leave a comment",
77
+ replyPlaceholder: "Reply",
78
+ send: "Comment",
79
+ sending: "Sending",
80
+ reply: "Reply",
81
+ edit: "Edit comment",
82
+ delete: "Delete comment",
83
+ edited: "edited",
84
+ save: "Save",
85
+ cancel: "Cancel",
86
+ resolve: "Resolve",
87
+ unresolve: "Reopen",
88
+ resolved: "Resolved",
89
+ showResolved: "Show {count} resolved",
90
+ hideResolved: "Hide resolved",
91
+ agent: "AI",
92
+ pinned: "Pinned",
93
+ };
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes