@heyramzi/ui 2.7.0 → 2.9.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 (132) hide show
  1. package/COMPONENTS.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +58 -74
  4. package/src/agents/vendor/draw.ts +4 -4
  5. package/src/ai/generation-progress/GenerationProgress.svelte +2 -2
  6. package/src/animations/README.md +1 -1
  7. package/src/animations/card-tilt.css +1 -1
  8. package/src/animations/digit-pop-in.css +1 -1
  9. package/src/animations/input-clear-dissolve.css +2 -2
  10. package/src/animations/learn-more-hover.css +2 -2
  11. package/src/animations/like-button.css +1 -1
  12. package/src/animations/plus-menu-morph.css +1 -1
  13. package/src/animations/success-check.css +5 -5
  14. package/src/canvas/headless/canvas-cleanup.test.ts +1 -1
  15. package/src/canvas/ops/ops.ts +1 -1
  16. package/src/charts/area-chart.svelte +11 -1
  17. package/src/charts/bar-chart.css +2 -1
  18. package/src/charts/bar-chart.svelte +18 -3
  19. package/src/charts/charts.css +2 -1
  20. package/src/charts/line-chart.svelte +11 -1
  21. package/src/components/data-table/data-table.ts +4 -5
  22. package/src/components/data-table/pagination.svelte +5 -3
  23. package/src/components/docs/on-this-page.svelte +82 -5
  24. package/src/components/docs/scroll-spy.svelte.ts +6 -1
  25. package/src/components/list-page/detail-header.svelte +7 -3
  26. package/src/components/list-page/empty-state.svelte +4 -2
  27. package/src/components/list-page/filter-popover.svelte +2 -2
  28. package/src/components/list-page/rail-row.svelte +5 -2
  29. package/src/components/mark/mark.svelte +2 -1
  30. package/src/components/section-card/section-card.svelte +3 -1
  31. package/src/components/section-header/section-header.svelte +5 -5
  32. package/src/components/segmented/segment.svelte +1 -1
  33. package/src/components/table-view/table-view.svelte +6 -1
  34. package/src/components/tags-input/tags-input.svelte +1 -1
  35. package/src/components/toolbar/toolbar.svelte +1 -1
  36. package/src/design/index.css +7 -5
  37. package/src/editor/anchors.ts +28 -0
  38. package/src/editor/doc-blocks.ts +168 -0
  39. package/src/editor/doc-page.css +389 -0
  40. package/src/editor/editor.svelte +47 -5
  41. package/src/editor/extensions.ts +26 -2
  42. package/src/editor/markdown.ts +27 -2
  43. package/src/editor/slash-items.ts +18 -0
  44. package/src/effects/reveal/reveal.css +1 -1
  45. package/src/icons/trades.ts +1 -1
  46. package/src/inbox/README.md +6 -4
  47. package/src/inbox/inbox.test.ts +1 -1
  48. package/src/inbox/index.ts +2 -2
  49. package/src/inbox/logic.ts +29 -5
  50. package/src/inbox/thread-inbox.svelte +219 -92
  51. package/src/inbox/types.ts +27 -1
  52. package/src/lib/initials.test.ts +25 -0
  53. package/src/lib/initials.ts +29 -0
  54. package/src/lib/theme-transition.ts +6 -2
  55. package/src/modules.test.ts +4 -64
  56. package/src/panel/README.md +1 -1
  57. package/src/record/README.md +1 -1
  58. package/src/record/info-list.svelte +5 -4
  59. package/src/record/next-up.svelte +1 -1
  60. package/src/record/project-list.svelte +1 -1
  61. package/src/record/steps-rail.svelte +3 -3
  62. package/src/shadcn/badge/badge.svelte +1 -1
  63. package/src/shadcn/card/card-title.svelte +1 -5
  64. package/src/shadcn/empty/empty-description.svelte +1 -1
  65. package/src/shadcn/progress/progress.svelte +8 -1
  66. package/src/shadcn/table/table-cell.svelte +10 -2
  67. package/src/slides/theme.css +2 -2
  68. package/src/status/README.md +1 -1
  69. package/src/status/status-dot.svelte +3 -3
  70. package/src/thread/README.md +2 -0
  71. package/src/thread/index.ts +9 -1
  72. package/src/thread/logic.ts +33 -3
  73. package/src/thread/thread-body.svelte +37 -0
  74. package/src/thread/thread-events.svelte +30 -0
  75. package/src/thread/thread.svelte +74 -59
  76. package/src/thread/types.ts +21 -0
  77. package/src/tokens/craft.css +7 -5
  78. package/src/{tasks → work}/README.md +11 -8
  79. package/src/{tasks → work}/detail/task-thread.svelte +8 -15
  80. package/src/work/docs/doc-editor-page.svelte +194 -0
  81. package/src/work/docs/doc-menu.svelte +333 -0
  82. package/src/work/docs/docs-demo.svelte +227 -0
  83. package/src/work/docs/edited-chip.svelte +125 -0
  84. package/src/work/docs/edited.ts +54 -0
  85. package/src/work/docs/index.ts +38 -0
  86. package/src/work/docs/labels.ts +70 -0
  87. package/src/work/docs/menu.ts +127 -0
  88. package/src/work/docs/outline.ts +60 -0
  89. package/src/{tasks → work}/gantt/gantt-grid.svelte +2 -2
  90. package/src/{tasks → work}/gantt/gantt-group-row.svelte +1 -1
  91. package/src/{tasks → work}/gantt/gantt.css +1 -1
  92. package/src/{tasks → work}/inbox/inbox-card.svelte +113 -50
  93. package/src/work/inbox/inbox-logic.ts +98 -0
  94. package/src/work/inbox/inbox-trace.svelte +58 -0
  95. package/src/{tasks → work}/inbox/inbox-types.ts +2 -0
  96. package/src/{tasks → work}/index.ts +0 -10
  97. package/src/{tasks → work}/kanban-board.svelte +1 -1
  98. package/src/{tasks → work}/kanban-column.svelte +1 -1
  99. package/src/{tasks → work}/task-list.svelte +2 -2
  100. package/src/{tasks → work}/time/time-ring.svelte +1 -1
  101. package/src/{tasks → work}/time/time-tracker.svelte +3 -6
  102. package/src/tasks/inbox/inbox-logic.ts +0 -21
  103. package/src/tasks/inbox/inbox-trace.svelte +0 -51
  104. /package/src/{tasks → work}/ambient.d.ts +0 -0
  105. /package/src/{tasks → work}/board-order.ts +0 -0
  106. /package/src/{tasks → work}/dates.ts +0 -0
  107. /package/src/{tasks → work}/demo.svelte +0 -0
  108. /package/src/{tasks → work}/detail/detail-demo.svelte +0 -0
  109. /package/src/{tasks → work}/detail/detail-types.ts +0 -0
  110. /package/src/{tasks → work}/detail/task-chip.svelte +0 -0
  111. /package/src/{tasks → work}/detail/task-detail.svelte +0 -0
  112. /package/src/{tasks → work}/detail/task-property.svelte +0 -0
  113. /package/src/{tasks → work}/gantt/gantt-bar.svelte +0 -0
  114. /package/src/{tasks → work}/gantt/gantt-header.svelte +0 -0
  115. /package/src/{tasks → work}/gantt/gantt-task-row.svelte +0 -0
  116. /package/src/{tasks → work}/gantt/gantt.test.ts +0 -0
  117. /package/src/{tasks → work}/gantt/logic.test.ts +0 -0
  118. /package/src/{tasks → work}/gantt/logic.ts +0 -0
  119. /package/src/{tasks → work}/inbox/inbox-draft.svelte +0 -0
  120. /package/src/{tasks → work}/inbox/inbox-statuses.ts +0 -0
  121. /package/src/{tasks → work}/inbox/index.ts +0 -0
  122. /package/src/{tasks → work}/kanban-drag.ts +0 -0
  123. /package/src/{tasks → work}/kanban-types.ts +0 -0
  124. /package/src/{tasks → work}/presets/sales.ts +0 -0
  125. /package/src/{tasks → work}/reorder.ts +0 -0
  126. /package/src/{tasks → work}/task-gantt.svelte +0 -0
  127. /package/src/{tasks → work}/task-types.ts +0 -0
  128. /package/src/{tasks → work}/time/duration.ts +0 -0
  129. /package/src/{tasks → work}/time/time-chip.svelte +0 -0
  130. /package/src/{tasks → work}/time/time-dot.svelte +0 -0
  131. /package/src/{tasks → work}/time/time-types.ts +0 -0
  132. /package/src/{tasks → work}/use-kanban-dnd.svelte.ts +0 -0
@@ -0,0 +1,54 @@
1
+ /** The "Edited Oct 23" chip: how a stamp reads, and what to show when it can't be read. */
2
+
3
+ export type DateInput = Date | string | number | null | undefined;
4
+
5
+ export type StampOptions = {
6
+ /** The clock, so a test doesn't depend on today. */
7
+ now?: Date;
8
+ locale?: string;
9
+ timeZone?: string;
10
+ };
11
+
12
+ export function parseStamp(value: DateInput): Date | null {
13
+ if (value === null || value === undefined || value === "") return null;
14
+ const date = value instanceof Date ? value : new Date(value);
15
+ return Number.isNaN(date.getTime()) ? null : date;
16
+ }
17
+
18
+ function yearOf(date: Date, { locale = "en-US", timeZone }: StampOptions) {
19
+ return new Intl.DateTimeFormat(locale, { year: "numeric", timeZone }).format(date);
20
+ }
21
+
22
+ /** "Oct 23", or "Oct 23, 2025" when it isn't this year. Empty when the date can't be read. */
23
+ export function shortDate(value: DateInput, options: StampOptions = {}): string {
24
+ const date = parseStamp(value);
25
+ if (!date) return "";
26
+ const { locale = "en-US", timeZone, now = new Date() } = options;
27
+ const sameYear = yearOf(date, options) === yearOf(now, options);
28
+ return new Intl.DateTimeFormat(locale, {
29
+ month: "short",
30
+ day: "numeric",
31
+ year: sameYear ? undefined : "numeric",
32
+ timeZone,
33
+ }).format(date);
34
+ }
35
+
36
+ /** "Oct 23, 14:44" for the last-edit row. Empty when the date can't be read. */
37
+ export function editStamp(value: DateInput, options: StampOptions = {}): string {
38
+ const date = parseStamp(value);
39
+ if (!date) return "";
40
+ const { locale = "en-US", timeZone } = options;
41
+ const time = new Intl.DateTimeFormat(locale, {
42
+ hour: "2-digit",
43
+ minute: "2-digit",
44
+ hour12: false,
45
+ timeZone,
46
+ }).format(date);
47
+ return `${shortDate(date, options)}, ${time}`;
48
+ }
49
+
50
+ /** The chip's text. `label` is the word ("Edited"); a bad date leaves only the word. */
51
+ export function editedLabel(value: DateInput, label = "Edited", options: StampOptions = {}) {
52
+ const day = shortDate(value, options);
53
+ return day ? `${label} ${day}` : label;
54
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The document surface: a page to write one, the "Edited" chip, the `…` menu and the outline
3
+ * logic behind the edge ticks. The app supplies the data and every handler.
4
+ */
5
+ export { default as DocEditorPage } from "./doc-editor-page.svelte";
6
+ export { default as DocMenu } from "./doc-menu.svelte";
7
+ export { default as EditedChip } from "./edited-chip.svelte";
8
+ export { DEFAULT_DOC_LABELS, docLabels, type DocLabels } from "./labels.js";
9
+ export {
10
+ anchorIds,
11
+ headingsOf,
12
+ outlineOf,
13
+ outlineOfDoc,
14
+ slugify,
15
+ type FoundHeading,
16
+ type OutlineHeading,
17
+ type OutlineLevel,
18
+ } from "./outline.js";
19
+ export {
20
+ editStamp,
21
+ editedLabel,
22
+ parseStamp,
23
+ shortDate,
24
+ type DateInput,
25
+ type StampOptions,
26
+ } from "./edited.js";
27
+ export {
28
+ copyOptions,
29
+ filterMenu,
30
+ menuItems,
31
+ shortcutLabel,
32
+ shortcutTarget,
33
+ type CopyKind,
34
+ type CopyOption,
35
+ type MenuId,
36
+ type MenuItem,
37
+ type Shortcut,
38
+ } from "./menu.js";
@@ -0,0 +1,70 @@
1
+ /** Every word the docs surface prints. The app passes a partial to localise it. */
2
+ export type DocLabels = {
3
+ titlePlaceholder: string;
4
+ untitled: string;
5
+ edited: string;
6
+ showAuthorNames: string;
7
+ ownedBy: string;
8
+ lastEditBy: string;
9
+ showHistory: string;
10
+ menu: string;
11
+ filter: string;
12
+ noResults: string;
13
+ moveTo: string;
14
+ duplicate: string;
15
+ rename: string;
16
+ template: string;
17
+ favorite: string;
18
+ unfavorite: string;
19
+ copy: string;
20
+ copyUrl: string;
21
+ copyTitle: string;
22
+ copyTitleAsLink: string;
23
+ copyMarkdown: string;
24
+ remind: string;
25
+ history: string;
26
+ delete: string;
27
+ deleteTitle: (title: string) => string;
28
+ deleteDetail: string;
29
+ cancel: string;
30
+ back: string;
31
+ outline: string;
32
+ copyLink: string;
33
+ };
34
+
35
+ export const DEFAULT_DOC_LABELS: DocLabels = {
36
+ titlePlaceholder: "New document",
37
+ untitled: "Untitled",
38
+ edited: "Edited",
39
+ showAuthorNames: "Show author names",
40
+ ownedBy: "Owned by",
41
+ lastEditBy: "Last edit by",
42
+ showHistory: "Show document history",
43
+ menu: "Document actions",
44
+ filter: "Filter…",
45
+ noResults: "Nothing matches",
46
+ moveTo: "Move to",
47
+ duplicate: "Duplicate",
48
+ rename: "Rename",
49
+ template: "New template from document",
50
+ favorite: "Favorite",
51
+ unfavorite: "Remove from favorites",
52
+ copy: "Copy",
53
+ copyUrl: "Copy URL",
54
+ copyTitle: "Copy title",
55
+ copyTitleAsLink: "Copy title as link",
56
+ copyMarkdown: "Copy content as Markdown",
57
+ remind: "Remind me",
58
+ history: "Show document history",
59
+ delete: "Delete",
60
+ deleteTitle: (title) => `Delete "${title}"`,
61
+ deleteDetail: "This can't be undone.",
62
+ cancel: "Cancel",
63
+ back: "Back",
64
+ outline: "Outline",
65
+ copyLink: "Copy link",
66
+ };
67
+
68
+ export function docLabels(partial?: Partial<DocLabels>): DocLabels {
69
+ return { ...DEFAULT_DOC_LABELS, ...partial };
70
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The `…` menu as data: which items exist, which show, how a filter reads them and
3
+ * which keys fire them. The component only draws what this returns.
4
+ */
5
+ import { tiptapToMarkdown } from "../../editor/markdown.js";
6
+ import type { DocLabels } from "./labels.js";
7
+
8
+ export type MenuId =
9
+ | "move"
10
+ | "duplicate"
11
+ | "rename"
12
+ | "template"
13
+ | "favorite"
14
+ | "copy"
15
+ | "remind"
16
+ | "history"
17
+ | "delete";
18
+
19
+ export type Shortcut = { key: string; shift?: boolean; alt?: boolean };
20
+
21
+ export type MenuItem = {
22
+ id: MenuId;
23
+ label: string;
24
+ shortcut?: Shortcut;
25
+ /** Opens a second list instead of firing. */
26
+ submenu?: boolean;
27
+ danger?: boolean;
28
+ /** Items in one group sit together; a hairline separates groups. */
29
+ group: number;
30
+ };
31
+
32
+ export const SHORTCUTS: Partial<Record<MenuId, Shortcut>> = {
33
+ move: { key: "P", shift: true },
34
+ rename: { key: "R", shift: true },
35
+ favorite: { key: "F", alt: true },
36
+ remind: { key: "H", shift: true },
37
+ };
38
+
39
+ /** "⇧P", "⌥F". */
40
+ export function shortcutLabel(shortcut: Shortcut): string {
41
+ return `${shortcut.shift ? "⇧" : ""}${shortcut.alt ? "⌥" : ""}${shortcut.key.toUpperCase()}`;
42
+ }
43
+
44
+ /** Which callbacks the host passed. An item with no callback never shows. */
45
+ export type MenuAvailable = Partial<Record<MenuId, boolean>>;
46
+
47
+ export function menuItems(
48
+ available: MenuAvailable,
49
+ labels: DocLabels,
50
+ options: { favorited?: boolean } = {},
51
+ ): MenuItem[] {
52
+ const all: MenuItem[] = [
53
+ { id: "move", label: labels.moveTo, group: 0 },
54
+ { id: "duplicate", label: labels.duplicate, group: 0 },
55
+ { id: "rename", label: labels.rename, group: 0 },
56
+ { id: "template", label: labels.template, group: 0 },
57
+ { id: "favorite", label: options.favorited ? labels.unfavorite : labels.favorite, group: 1 },
58
+ { id: "copy", label: labels.copy, submenu: true, group: 1 },
59
+ { id: "remind", label: labels.remind, group: 1 },
60
+ { id: "history", label: labels.history, group: 2 },
61
+ { id: "delete", label: labels.delete, danger: true, group: 2 },
62
+ ];
63
+ return all
64
+ .filter((item) => available[item.id])
65
+ .map((item) => Object.assign(item, { shortcut: SHORTCUTS[item.id] }));
66
+ }
67
+
68
+ /** Case-insensitive, every typed word must appear. An empty query keeps everything. */
69
+ export function filterMenu<T extends { label: string }>(items: T[], query: string): T[] {
70
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
71
+ if (words.length === 0) return items;
72
+ return items.filter((item) => {
73
+ const label = item.label.toLowerCase();
74
+ return words.every((word) => label.includes(word));
75
+ });
76
+ }
77
+
78
+ /** The item a key press fires, or null. `code` is the physical key, since Option+F types "ƒ". */
79
+ export function shortcutTarget(
80
+ event: { code: string; shiftKey: boolean; altKey: boolean; metaKey: boolean; ctrlKey: boolean },
81
+ items: MenuItem[],
82
+ ): MenuId | null {
83
+ if (event.metaKey || event.ctrlKey) return null;
84
+ const hit = items.find(
85
+ (item) =>
86
+ item.shortcut &&
87
+ event.code === `Key${item.shortcut.key.toUpperCase()}` &&
88
+ !!item.shortcut.shift === event.shiftKey &&
89
+ !!item.shortcut.alt === event.altKey,
90
+ );
91
+ return hit?.id ?? null;
92
+ }
93
+
94
+ export type CopyKind = "url" | "title" | "link" | "markdown";
95
+
96
+ export type CopyOption = { kind: CopyKind; label: string; text: string };
97
+
98
+ /** The Copy submenu. A kind with nothing to copy is left out: no URL, no content. */
99
+ export function copyOptions(
100
+ source: { title: string; url?: string; content?: unknown },
101
+ labels: DocLabels,
102
+ ): CopyOption[] {
103
+ const title = source.title.trim() || labels.untitled;
104
+ const options: CopyOption[] = [];
105
+ if (source.url) options.push({ kind: "url", label: labels.copyUrl, text: source.url });
106
+ options.push({ kind: "title", label: labels.copyTitle, text: title });
107
+ if (source.url) {
108
+ options.push({
109
+ kind: "link",
110
+ label: labels.copyTitleAsLink,
111
+ text: `[${title.replace(/([[\]\\])/g, "\\$1")}](${source.url})`,
112
+ });
113
+ }
114
+ if (source.content) {
115
+ options.push({
116
+ kind: "markdown",
117
+ label: labels.copyMarkdown,
118
+ text: tiptapToMarkdown(source.content),
119
+ });
120
+ }
121
+ return options;
122
+ }
123
+
124
+ /** What the delete confirm calls the doc. */
125
+ export function deleteName(title: string, labels: DocLabels): string {
126
+ return title.trim() || labels.untitled;
127
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The outline of a document: which headings it lists, and the anchor each one gets.
3
+ * Pure, so the editor's anchors and the outline's ids can never disagree.
4
+ */
5
+
6
+ import { anchorIds } from "../../editor/anchors.js";
7
+
8
+ export { anchorIds, slugify } from "../../editor/anchors.js";
9
+
10
+ export type OutlineLevel = 2 | 3;
11
+
12
+ export type OutlineHeading = { id: string; label: string; level: OutlineLevel };
13
+
14
+ type DocNode = {
15
+ type?: string;
16
+ attrs?: Record<string, unknown>;
17
+ content?: DocNode[];
18
+ text?: string;
19
+ };
20
+
21
+ function textOf(node: DocNode): string {
22
+ if (node.type === "text") return node.text ?? "";
23
+ return (node.content ?? []).map(textOf).join("");
24
+ }
25
+
26
+ export type FoundHeading = { id: string; label: string; level: number };
27
+
28
+ /** Every heading in document order, at any depth, with its anchor. */
29
+ export function headingsOf(doc: unknown): FoundHeading[] {
30
+ const found: { label: string; level: number }[] = [];
31
+ const walk = (node: DocNode) => {
32
+ if (node.type === "heading") {
33
+ const level = typeof node.attrs?.level === "number" ? node.attrs.level : 1;
34
+ found.push({ label: textOf(node).trim(), level });
35
+ return;
36
+ }
37
+ for (const child of node.content ?? []) walk(child);
38
+ };
39
+ if (doc && typeof doc === "object") walk(doc as DocNode);
40
+ const ids = anchorIds(found.map((heading) => heading.label));
41
+ return found.map((heading, i) => ({ id: ids[i], label: heading.label, level: heading.level }));
42
+ }
43
+
44
+ /**
45
+ * What the outline lists: H2 and H3 with a label. H1 and H4 are ignored, and a
46
+ * document with fewer than two entries gets no outline at all.
47
+ */
48
+ export function outlineOf(headings: FoundHeading[]): OutlineHeading[] {
49
+ const items = headings.filter(
50
+ (heading): heading is FoundHeading & { level: OutlineLevel } =>
51
+ (heading.level === 2 || heading.level === 3) && heading.label !== "",
52
+ );
53
+ if (items.length < 2) return [];
54
+ return items.map(({ id, label, level }) => ({ id, label, level }));
55
+ }
56
+
57
+ /** The outline of a Tiptap document. */
58
+ export function outlineOfDoc(doc: unknown): OutlineHeading[] {
59
+ return outlineOf(headingsOf(doc));
60
+ }
@@ -73,7 +73,7 @@
73
73
  <style>
74
74
  .g-today {
75
75
  transform-origin: top;
76
- animation: g-today-in var(--duration-very-slow, 500ms)
76
+ animation: g-today-in var(--duration-slow, 300ms)
77
77
  var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
78
78
  }
79
79
 
@@ -89,7 +89,7 @@
89
89
  .g-arrow-line {
90
90
  stroke-dasharray: 1;
91
91
  stroke-dashoffset: 0;
92
- animation: g-draw var(--duration-very-slow, 500ms)
92
+ animation: g-draw var(--duration-slow, 300ms)
93
93
  var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) 300ms backwards;
94
94
  }
95
95
 
@@ -28,7 +28,7 @@
28
28
  <span
29
29
  class="grid h-5 min-w-5 place-items-center rounded-sm bg-foreground/5 px-1 text-xs font-medium text-muted-foreground tabular-nums"
30
30
  >
31
- {row.count}
31
+ {row.count.toLocaleString()}
32
32
  </span>
33
33
  </div>
34
34
  </div>
@@ -1,6 +1,6 @@
1
1
  /* Shared by the bar, the derived bar and the group summary: they all slide in the same way. */
2
2
  .g-enter {
3
- animation: g-bar-in var(--duration-slow, 400ms)
3
+ animation: g-bar-in var(--duration-slow, 300ms)
4
4
  var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
5
5
  animation-delay: calc(var(--g-i, 0) * var(--duration-stagger, 40ms));
6
6
  }
@@ -9,7 +9,14 @@
9
9
  import StatusDot from "../../status/status-dot.svelte";
10
10
  import { TONE_TEXT } from "../../lib/tone.js";
11
11
  import InboxDraftEditor from "./inbox-draft.svelte";
12
- import { canUndo, isOpen, startDraft } from "./inbox-logic.js";
12
+ import {
13
+ canUndo,
14
+ cardFacts,
15
+ freshReasoning,
16
+ isOpen,
17
+ startDraft,
18
+ whenOf,
19
+ } from "./inbox-logic.js";
13
20
  import {
14
21
  SOURCE_ICON,
15
22
  SOURCE_LABEL,
@@ -29,15 +36,38 @@
29
36
  onverdict: (verdict: InboxVerdict) => void;
30
37
  /** Replaces the summary lines, for a host that renders its own proposal. */
31
38
  renderProposal?: Snippet<[InboxItem]>;
39
+ /** How the source time reads. Defaults to a relative time, never a raw ISO string. */
40
+ when?: (at: string) => string;
32
41
  }
33
42
 
34
- let { item, selected = false, onselect, onverdict, renderProposal }: Props = $props();
43
+ const FACT =
44
+ "relative inline-flex items-center ps-3 before:absolute before:start-1 before:opacity-40 before:content-['·']";
45
+
46
+ let {
47
+ item,
48
+ selected = false,
49
+ onselect,
50
+ onverdict,
51
+ renderProposal,
52
+ when = whenOf,
53
+ }: Props = $props();
35
54
 
36
55
  let drafts = $derived(item.proposal.drafts ?? []);
37
56
  let open = $derived(isOpen(item));
38
57
  let level = $derived(confidenceLevel(item.confidence.score));
39
58
  let tone = $derived(inboxConfidence.tone(level));
40
59
  let SourceIcon = $derived(SOURCE_ICON[item.trace.source.type]);
60
+ let facts = $derived(cardFacts(item));
61
+ let time = $derived(when(item.trace.source.at));
62
+ let why = $derived(freshReasoning(item, facts));
63
+ // WHY not the source link alone: a trace holding only "Open source" was a disclosure to open
64
+ // for a link the host already shows.
65
+ let hasTrace = $derived(item.trace.evidence.length > 0 || !!why);
66
+ let expanded = $state(false);
67
+ let longCommand = $derived(
68
+ !!facts.command &&
69
+ (facts.command.text.length > 240 || facts.command.text.split("\n").length > 3),
70
+ );
41
71
 
42
72
  let pickedDraft = $state<string>();
43
73
  let edits = $state<InboxDraftEdits>({});
@@ -167,64 +197,99 @@
167
197
  <header class="flex items-start gap-2.5">
168
198
  <StatusDot statuses={inboxStates} status={item.state} class="mt-0.5" />
169
199
  <span class="sr-only">{inboxStates.label(item.state)}.</span>
170
- <div class="flex min-w-0 flex-1 flex-col gap-1">
200
+ <div class="flex min-w-0 flex-1 flex-col gap-2">
171
201
  <div class="flex flex-wrap items-center gap-x-2 gap-y-1">
172
202
  <h3 class="text-sm leading-5 font-medium text-balance">{item.title}</h3>
173
- <StatusBadge statuses={inboxKinds} status={item.kind} />
203
+ {#if item.kind !== "action"}
204
+ <StatusBadge statuses={inboxKinds} status={item.kind} />
205
+ {/if}
174
206
  {#if !open}
175
207
  <StatusBadge statuses={inboxStates} status={item.state} />
176
208
  {/if}
177
209
  </div>
210
+
178
211
  {#if renderProposal}
179
212
  {@render renderProposal(item)}
180
213
  {:else}
181
- <p class="text-muted-foreground text-sm text-pretty">{item.proposal.summary}</p>
182
- {#if item.proposal.detail}
183
- <p class="text-muted-foreground text-sm text-pretty">{item.proposal.detail}</p>
214
+ {#if facts.command}
215
+ <div class="flex flex-col items-start gap-1">
216
+ <!-- The padding sits on a wrapper: on the clamped box itself, the 4th line showed through it. -->
217
+ <div class="bg-muted/50 w-full rounded-md px-2.5 py-2">
218
+ <pre
219
+ class={cn(
220
+ "text-foreground font-mono text-xs leading-5 break-words whitespace-pre-wrap",
221
+ !expanded && "line-clamp-3",
222
+ )}><span class="text-muted-foreground select-none"
223
+ >{facts.command.tool} </span>{facts.command.text}</pre>
224
+ </div>
225
+ {#if longCommand}
226
+ <button
227
+ type="button"
228
+ class="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
229
+ aria-expanded={expanded}
230
+ onclick={() => (expanded = !expanded)}
231
+ >
232
+ {expanded ? "Show less" : "Show all"}
233
+ </button>
234
+ {/if}
235
+ </div>
184
236
  {/if}
237
+ {#each facts.lines as line, i (i)}
238
+ <p class="text-muted-foreground text-sm text-pretty">{line}</p>
239
+ {/each}
185
240
  {/if}
186
- </div>
187
- </header>
188
241
 
189
- <div class="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 pl-6 text-xs">
190
- <span class="inline-flex items-center gap-1.5">
191
- <SourceIcon size={14} aria-hidden="true" />
192
- {SOURCE_LABEL[item.trace.source.type]}, {item.trace.source.at}
193
- </span>
194
- <span class={cn("inline-flex items-center gap-1.5 tabular-nums", TONE_TEXT[tone])}>
195
- <StatusDot
196
- statuses={inboxConfidence}
197
- status={level}
198
- progress={item.confidence.score}
199
- size={12}
200
- animate={false}
201
- />
202
- {Math.round(item.confidence.score * 100)}% sure
203
- </span>
204
- {#if item.agent}
205
- <span class="inline-flex items-center gap-1.5">
206
- <AgentAvatar
207
- name={item.agent.name}
208
- shape={item.agent.shape}
209
- color={item.agent.color}
210
- state={item.state === "working" ? "working" : "default"}
211
- size={16}
212
- paused={item.state !== "working"}
213
- interactive={false}
214
- aria-hidden
215
- />
216
- {item.agent.name}
217
- </span>
218
- {/if}
219
- </div>
242
+ <!-- Each dot hangs off the fact it leads, in a gutter the wrapper clips, so a wrapped line
243
+ never starts with a dot. The same pattern as DetailHeader's meta line. -->
244
+ <div class="overflow-hidden">
245
+ <p class="text-muted-foreground -ms-3 flex flex-wrap items-center gap-y-1 text-xs">
246
+ {#if item.agent}
247
+ <span class={cn(FACT, "gap-1.5")}>
248
+ <AgentAvatar
249
+ name={item.agent.name}
250
+ shape={item.agent.shape}
251
+ color={item.agent.color}
252
+ state={item.state === "working" ? "working" : "default"}
253
+ size={16}
254
+ paused={item.state !== "working"}
255
+ interactive={false}
256
+ aria-hidden
257
+ />
258
+ {item.agent.name}
259
+ </span>
260
+ {:else}
261
+ <span class={cn(FACT, "gap-1.5")}>
262
+ <SourceIcon size={14} aria-hidden="true" />
263
+ {SOURCE_LABEL[item.trace.source.type]}
264
+ </span>
265
+ {/if}
266
+ {#if time}
267
+ <span class={cn(FACT, "tabular-nums")}>{time}</span>
268
+ {/if}
269
+ {#if level !== "high"}
270
+ <span class={cn(FACT, "gap-1.5 tabular-nums", TONE_TEXT[tone])}>
271
+ <StatusDot
272
+ statuses={inboxConfidence}
273
+ status={level}
274
+ progress={item.confidence.score}
275
+ size={12}
276
+ animate={false}
277
+ />
278
+ {Math.round(item.confidence.score * 100)}% sure
279
+ </span>
280
+ {/if}
281
+ </p>
282
+ </div>
220
283
 
221
- {#if item.confidence.missing && level !== "high" && open}
222
- <p class={cn("pl-6 text-sm", TONE_TEXT[tone])}>Missing: {item.confidence.missing}</p>
223
- {/if}
284
+ {#if item.confidence.missing && level !== "high" && open}
285
+ <p class={cn("text-sm", TONE_TEXT[tone])}>Missing: {item.confidence.missing}</p>
286
+ {/if}
224
287
 
225
- <div class="pl-6">
226
- <InboxTraceView trace={item.trace} />
227
- </div>
288
+ {#if hasTrace}
289
+ <InboxTraceView trace={item.trace} reasoning={why} agent={item.agent?.name} />
290
+ {/if}
291
+ </div>
292
+ </header>
228
293
 
229
294
  {#if item.state === "working"}
230
295
  <div class="flex flex-col gap-1.5 pl-6">
@@ -272,11 +337,9 @@
272
337
  <div class="flex flex-wrap items-center gap-2 pl-6">
273
338
  <Button size="sm" onclick={approve} aria-keyshortcuts="a">{sendLabel}</Button>
274
339
  {#if !drafts.some((d) => d.outcome === "decline")}
275
- <Button size="sm" variant="destructive" onclick={decline} aria-keyshortcuts="d"
276
- >Decline</Button
277
- >
340
+ <Button size="sm" variant="ghost" onclick={decline} aria-keyshortcuts="d">Decline</Button>
278
341
  {/if}
279
- <Button size="sm" variant="outline" onclick={steer} aria-keyshortcuts="s">Steer</Button>
342
+ <Button size="sm" variant="ghost" onclick={steer} aria-keyshortcuts="s">Steer</Button>
280
343
  {#if drafts.length}
281
344
  <Button size="sm" variant="ghost" onclick={copy} class="ml-auto" aria-live="polite">
282
345
  {copied ? "Copied" : "Copy"}