@heyramzi/ui 2.0.0 → 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 (71) hide show
  1. package/package.json +59 -34
  2. package/scripts/sync-shadcn.mjs +2 -0
  3. package/src/canvas/README.md +9 -12
  4. package/src/charts/dashboard/data-panel.svelte +1 -1
  5. package/src/components/list-page/detail-panel.svelte +1 -1
  6. package/src/components/list-page/filter-popover.svelte +1 -1
  7. package/src/components/list-page/index.ts +3 -3
  8. package/src/components/list-page/rail-head.svelte +1 -1
  9. package/src/components/section-header/index.ts +1 -0
  10. package/src/components/section-header/section-header.svelte +1 -1
  11. package/src/components/toolbar/index.ts +2 -0
  12. package/src/components/toolbar/toolbar.svelte +2 -2
  13. package/src/editor/editor.svelte +8 -1
  14. package/src/gallery.test.ts +20 -1
  15. package/src/inbox/README.md +29 -0
  16. package/src/inbox/inbox.test.ts +171 -0
  17. package/src/inbox/index.ts +31 -0
  18. package/src/inbox/logic.ts +166 -0
  19. package/src/inbox/thread-inbox.svelte +363 -0
  20. package/src/inbox/types.ts +139 -0
  21. package/src/modules.test.ts +95 -0
  22. package/src/panel/README.md +5 -0
  23. package/src/panel/index.ts +2 -0
  24. package/src/{tasks → panel}/side-panel.svelte +1 -1
  25. package/src/record/README.md +3 -0
  26. package/src/{tasks/record → record}/field-section.svelte +1 -1
  27. package/src/{tasks/record → record}/info-list.svelte +9 -9
  28. package/src/{tasks/record → record}/next-up.svelte +4 -4
  29. package/src/{tasks/record → record}/project-list.svelte +5 -5
  30. package/src/{tasks/record → record}/record-demo.svelte +4 -4
  31. package/src/{tasks/record → record}/record-header.svelte +3 -3
  32. package/src/{tasks/record → record}/record-shell.svelte +4 -4
  33. package/src/{tasks/record → record}/record-stats.svelte +2 -2
  34. package/src/{tasks/record → record}/record-timeline.svelte +6 -6
  35. package/src/{tasks/record → record}/steps-rail.svelte +7 -7
  36. package/src/{tasks/record → record}/types.ts +2 -2
  37. package/src/{tasks/record → record}/where-we-are.svelte +4 -4
  38. package/src/status/README.md +18 -0
  39. package/src/status/index.ts +11 -0
  40. package/src/tasks/README.md +1 -1
  41. package/src/tasks/demo.svelte +3 -3
  42. package/src/tasks/detail/detail-demo.svelte +2 -2
  43. package/src/tasks/gantt/gantt.test.ts +1 -1
  44. package/src/tasks/inbox/inbox-card.svelte +2 -2
  45. package/src/tasks/inbox/inbox-statuses.ts +1 -1
  46. package/src/tasks/inbox/inbox.svelte +1 -1
  47. package/src/tasks/index.ts +9 -7
  48. package/src/tasks/presets/sales.ts +1 -1
  49. package/src/thread/README.md +31 -0
  50. package/src/thread/index.ts +12 -0
  51. package/src/thread/logic.ts +58 -0
  52. package/src/thread/thread.svelte +366 -0
  53. package/src/thread/thread.test.ts +62 -0
  54. package/src/thread/types.ts +93 -0
  55. /package/src/components/{list-page → section-header}/section-label.svelte +0 -0
  56. /package/src/components/{list-page → toolbar}/toolbar-search.svelte +0 -0
  57. /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
  58. /package/src/{tasks → panel}/side-panel-types.ts +0 -0
  59. /package/src/{tasks/record → record}/demo-data.ts +0 -0
  60. /package/src/{tasks/record → record}/fields.ts +0 -0
  61. /package/src/{tasks/record → record}/format.ts +0 -0
  62. /package/src/{tasks/record → record}/gutter.ts +0 -0
  63. /package/src/{tasks/record → record}/index.ts +0 -0
  64. /package/src/{tasks/record → record}/kinds.ts +0 -0
  65. /package/src/{tasks/record → record}/logic.ts +0 -0
  66. /package/src/{tasks/record → record}/record.test.ts +0 -0
  67. /package/src/{tasks → status}/define-statuses.ts +0 -0
  68. /package/src/{tasks → status}/status-badge.svelte +0 -0
  69. /package/src/{tasks → status}/status-dot.svelte +0 -0
  70. /package/src/{tasks → status}/status-icon.svelte +0 -0
  71. /package/src/{tasks → status}/status-types.ts +0 -0
@@ -0,0 +1,166 @@
1
+ import { isOpen, isTyping } from "../tasks/inbox/inbox-logic.js";
2
+ import {
3
+ ALL,
4
+ DEFAULT_INBOX_LABELS,
5
+ type InboxEntry,
6
+ type InboxFilter,
7
+ type InboxKey,
8
+ type InboxLabels,
9
+ type InboxSubject,
10
+ type InboxThread,
11
+ } from "./types.js";
12
+
13
+ /** A timestamp as a number, with NaN for one that doesn't parse. */
14
+ function ms(at: string): number {
15
+ return Date.parse(at);
16
+ }
17
+
18
+ function latestOf(thread: InboxThread): number {
19
+ const time = ms(thread.latestAt);
20
+ return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
21
+ }
22
+
23
+ export type ThreadsOptions = {
24
+ /** Threads with unread entries come before read ones. */
25
+ unreadFirst?: boolean;
26
+ };
27
+
28
+ /** True while a proposal entry still waits for a verdict. */
29
+ export function isPending(entry: InboxEntry): boolean {
30
+ return entry.kind === "proposal" && !!entry.proposal && isOpen(entry.proposal);
31
+ }
32
+
33
+ /**
34
+ * Group entries into one thread per subject and sort by the newest entry. An entry with no subject id
35
+ * is dropped. A malformed timestamp sorts last, among threads and inside one.
36
+ */
37
+ export function threadsOf(entries: InboxEntry[], options: ThreadsOptions = {}): InboxThread[] {
38
+ type Row = { entry: InboxEntry; subject: InboxSubject; index: number; time: number };
39
+ const groups = new Map<string, Row[]>();
40
+ entries.forEach((entry, index) => {
41
+ const subject = entry.subject;
42
+ if (!subject?.id) return;
43
+ const key = `${subject.type}:${subject.id}`;
44
+ groups.set(key, [...(groups.get(key) ?? []), { entry, subject, index, time: ms(entry.at) }]);
45
+ });
46
+
47
+ const threads: InboxThread[] = [];
48
+ for (const [key, rows] of groups) {
49
+ const ordered = rows.toSorted((a, b) => {
50
+ const aBad = Number.isNaN(a.time);
51
+ const bBad = Number.isNaN(b.time);
52
+ if (aBad !== bBad) return aBad ? 1 : -1;
53
+ return (aBad ? 0 : a.time - b.time) || a.index - b.index;
54
+ });
55
+ const newest = ordered.findLast((row) => !Number.isNaN(row.time)) ?? ordered[0];
56
+ const list = ordered.map((row) => row.entry);
57
+ threads.push({
58
+ key,
59
+ // The newest entry carries the freshest title and status.
60
+ subject: newest.subject,
61
+ entries: list,
62
+ unread: list.filter((entry) => !entry.read).length,
63
+ latestAt: newest.entry.at,
64
+ pending: list.filter(isPending).toReversed(),
65
+ });
66
+ }
67
+
68
+ return threads
69
+ .map((thread, index) => ({ thread, index }))
70
+ .toSorted((a, b) => {
71
+ if (options.unreadFirst) {
72
+ const unread = Number(b.thread.unread > 0) - Number(a.thread.unread > 0);
73
+ if (unread) return unread;
74
+ }
75
+ const aTime = latestOf(a.thread);
76
+ const bTime = latestOf(b.thread);
77
+ if (aTime === bTime) return a.index - b.index;
78
+ return bTime - aTime;
79
+ })
80
+ .map((row) => row.thread);
81
+ }
82
+
83
+ export const NO_FILTER: InboxFilter = { type: ALL, who: ALL, needsYou: false };
84
+
85
+ /** Keep a thread when it matches the type, holds an entry from the person or agent, and has a pending proposal if asked. */
86
+ export function filterThreads(threads: InboxThread[], filter: InboxFilter): InboxThread[] {
87
+ return threads.filter(
88
+ (thread) =>
89
+ (filter.type === ALL || thread.subject.type === filter.type) &&
90
+ (filter.who === ALL || thread.entries.some((entry) => entry.actor.name === filter.who)) &&
91
+ (!filter.needsYou || thread.pending.length > 0),
92
+ );
93
+ }
94
+
95
+ export function subjectTypes(threads: InboxThread[]): string[] {
96
+ return [...new Set(threads.map((thread) => thread.subject.type))];
97
+ }
98
+
99
+ export function actorNames(threads: InboxThread[]): string[] {
100
+ return [...new Set(threads.flatMap((thread) => thread.entries.map((entry) => entry.actor.name)))];
101
+ }
102
+
103
+ type KeyEventLike = Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "altKey" | "defaultPrevented" | "target">;
104
+
105
+ const KEYS: Record<string, InboxKey> = {
106
+ j: "next",
107
+ k: "previous",
108
+ u: "toggleRead",
109
+ h: "snooze",
110
+ a: "approve",
111
+ d: "decline",
112
+ s: "steer",
113
+ e: "edit",
114
+ };
115
+
116
+ /** What a key press asks for, or null when it isn't ours: a modifier is held, or focus is in a text field. */
117
+ export function inboxKeyOf(event: KeyEventLike): InboxKey | null {
118
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return null;
119
+ if (isTyping(event.target)) return null;
120
+ return KEYS[event.key] ?? null;
121
+ }
122
+
123
+ export type SnoozeChoice = { id: "later" | "tomorrow" | "nextWeek"; until: Date };
124
+
125
+ /** The three times a snooze offers: 3 hours on, tomorrow at 9, next Monday at 9. */
126
+ export function snoozeChoices(now: Date): SnoozeChoice[] {
127
+ const later = new Date(now.getTime() + 3 * 60 * 60 * 1000);
128
+ const tomorrow = new Date(now);
129
+ tomorrow.setDate(tomorrow.getDate() + 1);
130
+ tomorrow.setHours(9, 0, 0, 0);
131
+ const nextWeek = new Date(now);
132
+ nextWeek.setDate(nextWeek.getDate() + (((8 - nextWeek.getDay()) % 7) || 7));
133
+ nextWeek.setHours(9, 0, 0, 0);
134
+ return [
135
+ { id: "later", until: later },
136
+ { id: "tomorrow", until: tomorrow },
137
+ { id: "nextWeek", until: nextWeek },
138
+ ];
139
+ }
140
+
141
+ export function resolveInboxLabels(overrides?: Partial<InboxLabels>): InboxLabels {
142
+ return { ...DEFAULT_INBOX_LABELS, ...overrides };
143
+ }
144
+
145
+ /** One line for an entry, in the host's words. `{from}`, `{to}` and `{assignee}` are filled. */
146
+ export function entryLine(entry: InboxEntry, labels: InboxLabels): string {
147
+ const fill = (text: string) =>
148
+ text
149
+ .replaceAll("{from}", entry.status?.from ?? "")
150
+ .replaceAll("{to}", entry.status?.to ?? "")
151
+ .replaceAll("{assignee}", entry.assignee ?? "");
152
+ switch (entry.kind) {
153
+ case "status":
154
+ return fill(entry.status?.from ? labels.statusMoved : labels.statusSet);
155
+ case "assigned":
156
+ return fill(labels.assigned);
157
+ case "mention":
158
+ return labels.mentioned;
159
+ case "agent_response":
160
+ return labels.responded;
161
+ case "proposal":
162
+ return labels.proposed;
163
+ default:
164
+ return labels.commented;
165
+ }
166
+ }
@@ -0,0 +1,363 @@
1
+ <script lang="ts">
2
+ import { tick, type Snippet } from "svelte";
3
+ import { ArrowLeft, BellSlash, Circle, Envelope, EnvelopeOpen } from "phosphor-svelte";
4
+ import { AgentAvatar } from "../agents/index.js";
5
+ import { Badge } from "../shadcn/badge/index.js";
6
+ import { Button } from "../shadcn/button/index.js";
7
+ import * as Empty from "../shadcn/empty/index.js";
8
+ import { Kbd, KbdGroup } from "../shadcn/kbd/index.js";
9
+ import { ToggleGroup, ToggleGroupItem } from "../shadcn/toggle-group/index.js";
10
+ import { cn } from "../lib/utils.js";
11
+ import { TONE_CLASS } from "../lib/tone.js";
12
+ import InboxCard from "../tasks/inbox/inbox-card.svelte";
13
+ import type { InboxItem, InboxVerdict } from "../tasks/inbox/inbox-types.js";
14
+ import {
15
+ NO_FILTER,
16
+ actorNames,
17
+ entryLine,
18
+ filterThreads,
19
+ inboxKeyOf,
20
+ resolveInboxLabels,
21
+ snoozeChoices,
22
+ subjectTypes,
23
+ threadsOf,
24
+ } from "./logic.js";
25
+ import {
26
+ ALL,
27
+ type InboxActor,
28
+ type InboxEntry,
29
+ type InboxFilter,
30
+ type InboxLabels,
31
+ type InboxSnooze,
32
+ type InboxSubject,
33
+ type InboxThread,
34
+ } from "./types.js";
35
+
36
+ interface Props {
37
+ entries: InboxEntry[];
38
+ /** The key of the open thread. Without a match the first thread opens. */
39
+ selected?: string;
40
+ /** Threads with unread entries come first. */
41
+ unreadFirst?: boolean;
42
+ labels?: Partial<InboxLabels>;
43
+ /** Display names for subject types, such as `{ task: "Tasks" }`. A missing type shows as written. */
44
+ typeLabels?: Record<string, string>;
45
+ /** Replaces the default avatar for a person or an agent, drawn in a 24px slot. */
46
+ avatar?: Snippet<[InboxActor]>;
47
+ /** A badge for the subject's status, drawn by the host with its own status map. */
48
+ status?: Snippet<[InboxSubject]>;
49
+ /** Replaces a pinned proposal's summary lines. */
50
+ renderProposal?: Snippet<[InboxItem]>;
51
+ /** How a timestamp reads. Defaults to a short date and time. */
52
+ when?: (at: string) => string;
53
+ onSelect?: (thread: InboxThread) => void;
54
+ /** `read` is true when the thread had unread entries, so the host marks them all read. */
55
+ onRead?: (thread: InboxThread, read: boolean) => void;
56
+ onSnooze?: (request: InboxSnooze) => void;
57
+ onDecide?: (verdict: InboxVerdict, thread: InboxThread) => void;
58
+ class?: string;
59
+ }
60
+
61
+ let {
62
+ entries,
63
+ selected = $bindable(),
64
+ unreadFirst = false,
65
+ labels: labelOverrides,
66
+ typeLabels = {},
67
+ avatar,
68
+ status,
69
+ renderProposal,
70
+ when = (at) => {
71
+ const date = new Date(at);
72
+ return Number.isNaN(date.getTime())
73
+ ? ""
74
+ : date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
75
+ },
76
+ onSelect,
77
+ onRead,
78
+ onSnooze,
79
+ onDecide,
80
+ class: className,
81
+ }: Props = $props();
82
+
83
+ let labels = $derived(resolveInboxLabels(labelOverrides));
84
+ let filter = $state<InboxFilter>({ ...NO_FILTER });
85
+ let snoozing = $state(false);
86
+ let detailOpen = $state(false);
87
+ let cards = $state<Record<string, ReturnType<typeof InboxCard> | null>>({});
88
+
89
+ let all = $derived(threadsOf(entries, { unreadFirst }));
90
+ let visible = $derived(filterThreads(all, filter));
91
+ let types = $derived(subjectTypes(all));
92
+ let people = $derived(actorNames(all));
93
+ let current = $derived(visible.find((thread) => thread.key === selected) ?? visible[0]);
94
+ let needsYouCount = $derived(all.filter((thread) => thread.pending.length > 0).length);
95
+ let filtered = $derived(filter.type !== ALL || filter.who !== ALL || filter.needsYou);
96
+ let pinned = $derived(current?.pending[0]);
97
+ let timeline = $derived(current?.entries.filter((entry) => !current.pending.includes(entry)) ?? []);
98
+
99
+ function open(thread: InboxThread): void {
100
+ selected = thread.key;
101
+ snoozing = false;
102
+ detailOpen = true;
103
+ onSelect?.(thread);
104
+ }
105
+
106
+ async function move(step: number): Promise<void> {
107
+ if (!visible.length) return;
108
+ const from = current ? visible.indexOf(current) : -1;
109
+ const next = visible[Math.max(0, Math.min(visible.length - 1, from + step))];
110
+ open(next);
111
+ detailOpen = false;
112
+ await tick();
113
+ document.querySelector<HTMLElement>(`[data-thread-key="${CSS.escape(next.key)}"]`)?.focus();
114
+ }
115
+
116
+ function toggleRead(): void {
117
+ if (current) onRead?.(current, current.unread > 0);
118
+ }
119
+
120
+ function snooze(until: Date): void {
121
+ if (current) onSnooze?.({ thread: current, until });
122
+ snoozing = false;
123
+ }
124
+
125
+ function onkeydown(event: KeyboardEvent): void {
126
+ if (snoozing && !event.defaultPrevented && !event.metaKey && !event.ctrlKey && !event.altKey) {
127
+ const choice = snoozeChoices(new Date())[Number(event.key) - 1];
128
+ if (choice) {
129
+ event.preventDefault();
130
+ snooze(choice.until);
131
+ return;
132
+ }
133
+ if (event.key === "Escape") {
134
+ snoozing = false;
135
+ return;
136
+ }
137
+ }
138
+ const key = inboxKeyOf(event);
139
+ if (!key) return;
140
+ const card = pinned ? cards[pinned.id] : null;
141
+ const actions = {
142
+ next: () => move(1),
143
+ previous: () => move(-1),
144
+ toggleRead,
145
+ snooze: () => (snoozing = !snoozing),
146
+ approve: () => card?.approve(),
147
+ decline: () => card?.decline(),
148
+ steer: () => card?.steer(),
149
+ edit: () => card?.edit(),
150
+ };
151
+ event.preventDefault();
152
+ actions[key]();
153
+ }
154
+
155
+ function setType(next: string | undefined): void {
156
+ filter = { ...filter, type: next || ALL };
157
+ }
158
+
159
+ function snoozeLabel(id: string): string {
160
+ return id === "later" ? labels.snoozeLater : id === "tomorrow" ? labels.snoozeTomorrow : labels.snoozeNextWeek;
161
+ }
162
+
163
+ function initial(name: string): string {
164
+ return name.trim().charAt(0).toLocaleUpperCase() || "?";
165
+ }
166
+
167
+ function lastEntry(thread: InboxThread): InboxEntry {
168
+ return thread.entries[thread.entries.length - 1];
169
+ }
170
+ </script>
171
+
172
+ {#snippet face(actor: InboxActor)}
173
+ <span class="relative size-6 shrink-0">
174
+ {#if avatar}
175
+ {@render avatar(actor)}
176
+ {:else if actor.kind === "agent"}
177
+ <AgentAvatar name={actor.name} size={24} paused interactive={false} />
178
+ {:else if actor.avatar}
179
+ <img src={actor.avatar} alt="" class="size-6 rounded-full object-cover" />
180
+ {:else}
181
+ <span class="grid size-6 place-items-center rounded-full bg-muted text-xs font-medium text-muted-foreground" aria-hidden="true">
182
+ {initial(actor.name)}
183
+ </span>
184
+ {/if}
185
+ </span>
186
+ {/snippet}
187
+
188
+ <!-- WHY the key handler sits on the region: the shortcuts work wherever focus is inside the inbox and nowhere else on the page. -->
189
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
190
+ <div role="region" aria-label={labels.title} class={cn("flex h-full min-h-0 flex-col gap-3", className)} {onkeydown}>
191
+ <div class="flex flex-wrap items-center gap-2">
192
+ <ToggleGroup type="single" variant="outline" size="sm" value={filter.type} onValueChange={setType} aria-label={labels.filterType}>
193
+ <ToggleGroupItem value={ALL}>{labels.all}</ToggleGroupItem>
194
+ {#each types as type (type)}
195
+ <ToggleGroupItem value={type}>{typeLabels[type] ?? type}</ToggleGroupItem>
196
+ {/each}
197
+ </ToggleGroup>
198
+ <select
199
+ aria-label={labels.filterWho}
200
+ value={filter.who}
201
+ onchange={(event) => (filter = { ...filter, who: event.currentTarget.value })}
202
+ class="h-7 rounded-lg border border-border bg-background px-2 text-[0.8rem] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 any-pointer-coarse:h-10"
203
+ >
204
+ <option value={ALL}>{labels.anyone}</option>
205
+ {#each people as name (name)}
206
+ <option value={name}>{name}</option>
207
+ {/each}
208
+ </select>
209
+ <Button
210
+ size="sm"
211
+ variant={filter.needsYou ? "default" : "outline"}
212
+ aria-pressed={filter.needsYou}
213
+ onclick={() => (filter = { ...filter, needsYou: !filter.needsYou })}
214
+ >
215
+ {labels.needsYou}
216
+ <span class="tabular-nums opacity-70">{needsYouCount}</span>
217
+ </Button>
218
+ </div>
219
+
220
+ <div class="grid min-h-0 flex-1 gap-3 md:grid-cols-[20rem_minmax(0,1fr)]">
221
+ <ol class={cn("-mx-1 min-h-0 flex-col gap-1 overflow-y-auto px-1 pb-1", detailOpen ? "max-md:hidden md:flex" : "flex")} aria-label={labels.title}>
222
+ {#each visible as thread (thread.key)}
223
+ {@const last = lastEntry(thread)}
224
+ <li>
225
+ <button
226
+ type="button"
227
+ data-thread-key={thread.key}
228
+ aria-current={current?.key === thread.key}
229
+ onclick={() => open(thread)}
230
+ class={cn(
231
+ "flex w-full flex-col gap-1 rounded-lg border border-transparent p-2.5 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
232
+ current?.key === thread.key ? "border-border bg-muted" : "hover:bg-muted/50",
233
+ )}
234
+ >
235
+ <span class="flex items-center gap-2">
236
+ {#if thread.unread > 0}
237
+ <Circle weight="fill" class="size-2 shrink-0 text-primary" aria-hidden="true" />
238
+ {/if}
239
+ {#if thread.subject.ref}
240
+ <span class="text-xs text-muted-foreground tabular-nums">{thread.subject.ref}</span>
241
+ {/if}
242
+ <span class={cn("truncate text-sm", thread.unread > 0 ? "font-medium" : "")}>{thread.subject.title}</span>
243
+ <span class="ms-auto shrink-0 text-xs text-muted-foreground tabular-nums">{when(thread.latestAt)}</span>
244
+ </span>
245
+ <span class="flex items-center gap-2 ps-4 text-xs text-muted-foreground">
246
+ <span class="truncate">{last.actor.name} {entryLine(last, labels)}</span>
247
+ {#if thread.pending.length > 0}
248
+ <span class={cn("ms-auto inline-flex h-5 shrink-0 items-center rounded-sm border px-1.5 font-medium", TONE_CLASS.warning)}>
249
+ {labels.needsYou}
250
+ </span>
251
+ {:else if thread.unread > 0}
252
+ <Badge variant="outline" class="ms-auto tabular-nums" aria-label="{thread.unread} {labels.unread}">{thread.unread}</Badge>
253
+ {/if}
254
+ </span>
255
+ </button>
256
+ </li>
257
+ {:else}
258
+ <li class="h-full">
259
+ <Empty.Root class="h-full">
260
+ <Empty.Header>
261
+ <Empty.Title>
262
+ {filtered ? (filter.needsYou ? labels.emptyNeedsYou : labels.emptyFiltered) : labels.empty}
263
+ </Empty.Title>
264
+ </Empty.Header>
265
+ {#if filtered}
266
+ <Empty.Content>
267
+ <Button size="sm" variant="outline" onclick={() => (filter = { ...NO_FILTER })}>{labels.clearFilters}</Button>
268
+ </Empty.Content>
269
+ {/if}
270
+ </Empty.Root>
271
+ </li>
272
+ {/each}
273
+ </ol>
274
+
275
+ <section
276
+ class={cn("min-h-0 flex-col gap-4 overflow-y-auto rounded-xl border border-border p-4", detailOpen ? "flex" : "max-md:hidden md:flex")}
277
+ aria-label={current?.subject.title ?? labels.selectThread}
278
+ >
279
+ {#if current}
280
+ <header class="flex flex-wrap items-center gap-2">
281
+ <Button size="sm" variant="ghost" class="md:hidden" onclick={() => (detailOpen = false)} aria-label={labels.title}>
282
+ <ArrowLeft aria-hidden="true" />
283
+ </Button>
284
+ {#if current.subject.ref}
285
+ <span class="text-xs text-muted-foreground tabular-nums">{current.subject.ref}</span>
286
+ {/if}
287
+ <h2 class="min-w-0 truncate text-sm font-medium">
288
+ {#if current.subject.href}
289
+ <a href={current.subject.href} class="hover:underline">{current.subject.title}</a>
290
+ {:else}
291
+ {current.subject.title}
292
+ {/if}
293
+ </h2>
294
+ {@render status?.(current.subject)}
295
+ <span class="ms-auto flex items-center gap-1">
296
+ <Button size="sm" variant="ghost" onclick={toggleRead} aria-keyshortcuts="u">
297
+ {#if current.unread > 0}
298
+ <EnvelopeOpen aria-hidden="true" />{labels.markRead}
299
+ {:else}
300
+ <Envelope aria-hidden="true" />{labels.markUnread}
301
+ {/if}
302
+ </Button>
303
+ <Button size="sm" variant="ghost" onclick={() => (snoozing = !snoozing)} aria-expanded={snoozing} aria-keyshortcuts="h">
304
+ <BellSlash aria-hidden="true" />{labels.snooze}
305
+ </Button>
306
+ </span>
307
+ </header>
308
+
309
+ {#if snoozing}
310
+ <div class="flex flex-wrap gap-2" role="group" aria-label={labels.snooze}>
311
+ {#each snoozeChoices(new Date()) as choice, index (choice.id)}
312
+ <Button size="sm" variant="outline" onclick={() => snooze(choice.until)}>
313
+ <Kbd>{index + 1}</Kbd>{snoozeLabel(choice.id)}
314
+ </Button>
315
+ {/each}
316
+ </div>
317
+ {/if}
318
+
319
+ {#each current.pending as entry (entry.id)}
320
+ <InboxCard
321
+ bind:this={cards[entry.id]}
322
+ item={entry.proposal!}
323
+ selected={entry === pinned}
324
+ onverdict={(verdict) => onDecide?.(verdict, current)}
325
+ {renderProposal}
326
+ />
327
+ {/each}
328
+
329
+ <ol class="flex flex-col gap-4">
330
+ {#each timeline as entry (entry.id)}
331
+ <li class={cn("flex gap-3", !entry.read && "font-medium")}>
332
+ {@render face(entry.actor)}
333
+ <div class="min-w-0 flex-1">
334
+ <div class="flex flex-wrap items-center gap-x-2 text-[13px]">
335
+ <span class="truncate font-medium">{entry.actor.name}</span>
336
+ {#if entry.actor.kind === "agent"}
337
+ <span class={cn("inline-flex h-5 items-center rounded-sm border px-1.5 text-xs font-medium", TONE_CLASS.accent)}>
338
+ {labels.agent}
339
+ </span>
340
+ {/if}
341
+ <span class="font-normal text-muted-foreground">{entryLine(entry, labels)}</span>
342
+ <span class="ms-auto text-xs font-normal text-muted-foreground tabular-nums">{when(entry.at)}</span>
343
+ </div>
344
+ {#if entry.body}
345
+ <p class="mt-1 text-sm leading-relaxed font-normal whitespace-pre-wrap text-pretty">{entry.body}</p>
346
+ {/if}
347
+ </div>
348
+ </li>
349
+ {/each}
350
+ </ol>
351
+ {:else}
352
+ <p class="text-sm text-muted-foreground">{labels.selectThread}</p>
353
+ {/if}
354
+ </section>
355
+ </div>
356
+
357
+ <footer class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground max-sm:hidden">
358
+ <span class="inline-flex items-center gap-1.5"><KbdGroup><Kbd>j</Kbd><Kbd>k</Kbd></KbdGroup> {labels.hintMove}</span>
359
+ <span class="inline-flex items-center gap-1.5"><Kbd>u</Kbd> {labels.hintRead}</span>
360
+ <span class="inline-flex items-center gap-1.5"><Kbd>h</Kbd> {labels.hintSnooze}</span>
361
+ <span class="inline-flex items-center gap-1.5"><KbdGroup><Kbd>a</Kbd><Kbd>d</Kbd><Kbd>s</Kbd><Kbd>e</Kbd></KbdGroup> {labels.hintAct}</span>
362
+ </footer>
363
+ </div>
@@ -0,0 +1,139 @@
1
+ import type { InboxItem } from "../tasks/inbox/inbox-types.js";
2
+
3
+ export type InboxActorKind = "user" | "agent";
4
+
5
+ export type InboxActor = {
6
+ kind: InboxActorKind;
7
+ name: string;
8
+ /** Image URL for a person. The host's own portrait goes through the `avatar` snippet. */
9
+ avatar?: string;
10
+ };
11
+
12
+ export type InboxEntryKind = "comment" | "status" | "assigned" | "mention" | "agent_response" | "proposal";
13
+
14
+ /** What the thread is about: one task, project or doc. Entries with the same type and id share a thread. */
15
+ export type InboxSubject = {
16
+ /** "task", "project", "doc" or whatever the host calls it. */
17
+ type: string;
18
+ id: string;
19
+ /** The short reference, such as "TSK-7". */
20
+ ref?: string;
21
+ title: string;
22
+ /** A status id the host draws itself, for a badge in the thread header. */
23
+ status?: string;
24
+ href?: string;
25
+ };
26
+
27
+ export type InboxEntry = {
28
+ id: string;
29
+ kind: InboxEntryKind;
30
+ /** An entry with no subject id belongs to no thread and is dropped. */
31
+ subject?: InboxSubject;
32
+ actor: InboxActor;
33
+ /** ISO timestamp. A malformed one sorts last. */
34
+ at: string;
35
+ read?: boolean;
36
+ /** Text of a comment, a mention or an agent response. */
37
+ body?: string;
38
+ /** A `status` entry: where the subject moved. */
39
+ status?: { from?: string; to: string };
40
+ /** An `assigned` entry: who got the work. */
41
+ assignee?: string;
42
+ /** A `proposal` entry: the agent's ask, drawn as an InboxCard while it waits. */
43
+ proposal?: InboxItem;
44
+ };
45
+
46
+ export type InboxThread = {
47
+ /** `type:id`, unique across subjects. */
48
+ key: string;
49
+ subject: InboxSubject;
50
+ /** Oldest first. */
51
+ entries: InboxEntry[];
52
+ unread: number;
53
+ /** The newest valid timestamp, as written. The raw first one when none parses. */
54
+ latestAt: string;
55
+ /** Proposals still waiting for a verdict, newest first. */
56
+ pending: InboxEntry[];
57
+ };
58
+
59
+ export type InboxFilter = {
60
+ /** A subject type, or "all". */
61
+ type: string;
62
+ /** An actor name, or "all". */
63
+ who: string;
64
+ /** Only threads holding a pending proposal. */
65
+ needsYou: boolean;
66
+ };
67
+
68
+ export const ALL = "all";
69
+
70
+ export type InboxKey = "next" | "previous" | "toggleRead" | "snooze" | "approve" | "decline" | "steer" | "edit";
71
+
72
+ export type InboxSnooze = { thread: InboxThread; until: Date };
73
+
74
+ export type InboxLabels = {
75
+ title: string;
76
+ all: string;
77
+ anyone: string;
78
+ needsYou: string;
79
+ empty: string;
80
+ emptyNeedsYou: string;
81
+ emptyFiltered: string;
82
+ clearFilters: string;
83
+ unread: string;
84
+ markRead: string;
85
+ markUnread: string;
86
+ snooze: string;
87
+ snoozeLater: string;
88
+ snoozeTomorrow: string;
89
+ snoozeNextWeek: string;
90
+ selectThread: string;
91
+ filterType: string;
92
+ filterWho: string;
93
+ agent: string;
94
+ statusMoved: string;
95
+ statusSet: string;
96
+ assigned: string;
97
+ mentioned: string;
98
+ commented: string;
99
+ responded: string;
100
+ proposed: string;
101
+ /** Keyboard hint words. */
102
+ hintMove: string;
103
+ hintRead: string;
104
+ hintSnooze: string;
105
+ hintAct: string;
106
+ };
107
+
108
+ export const DEFAULT_INBOX_LABELS: InboxLabels = {
109
+ title: "Inbox",
110
+ all: "All",
111
+ anyone: "Anyone",
112
+ needsYou: "Needs you",
113
+ empty: "Nothing here",
114
+ emptyNeedsYou: "Nothing is waiting on you",
115
+ emptyFiltered: "No thread matches these filters",
116
+ clearFilters: "Clear filters",
117
+ unread: "unread",
118
+ markRead: "Mark read",
119
+ markUnread: "Mark unread",
120
+ snooze: "Snooze",
121
+ snoozeLater: "Later today",
122
+ snoozeTomorrow: "Tomorrow",
123
+ snoozeNextWeek: "Next week",
124
+ selectThread: "Select a thread",
125
+ filterType: "Filter by type",
126
+ filterWho: "Filter by person or agent",
127
+ agent: "AI",
128
+ statusMoved: "moved this from {from} to {to}",
129
+ statusSet: "set this to {to}",
130
+ assigned: "assigned this to {assignee}",
131
+ mentioned: "mentioned you",
132
+ commented: "commented",
133
+ responded: "replied",
134
+ proposed: "asks for your call",
135
+ hintMove: "move",
136
+ hintRead: "read",
137
+ hintSnooze: "snooze",
138
+ hintAct: "approve, decline, steer, edit",
139
+ };