@signal9/era-ui 2.8.3 → 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 (56) hide show
  1. package/dist/ai/confirmation/approval.svelte.d.ts +42 -0
  2. package/dist/ai/confirmation/approval.svelte.js +122 -0
  3. package/dist/ai/confirmation/confirmation-args.svelte +67 -0
  4. package/dist/ai/confirmation/confirmation-args.svelte.d.ts +8 -0
  5. package/dist/ai/confirmation/index.d.ts +2 -0
  6. package/dist/ai/confirmation/index.js +2 -0
  7. package/dist/ai/conversations/conversations-group.svelte +28 -0
  8. package/dist/ai/conversations/conversations-group.svelte.d.ts +10 -0
  9. package/dist/ai/conversations/conversations-item.svelte +208 -0
  10. package/dist/ai/conversations/conversations-item.svelte.d.ts +28 -0
  11. package/dist/ai/conversations/conversations-root.svelte +27 -0
  12. package/dist/ai/conversations/conversations-root.svelte.d.ts +10 -0
  13. package/dist/ai/conversations/conversations.svelte.d.ts +38 -0
  14. package/dist/ai/conversations/conversations.svelte.js +73 -0
  15. package/dist/ai/conversations/index.d.ts +5 -0
  16. package/dist/ai/conversations/index.js +4 -0
  17. package/dist/ai/error-panel/categorize-error.d.ts +32 -0
  18. package/dist/ai/error-panel/categorize-error.js +106 -0
  19. package/dist/ai/error-panel/error-panel.svelte +130 -0
  20. package/dist/ai/error-panel/error-panel.svelte.d.ts +17 -0
  21. package/dist/ai/error-panel/index.d.ts +2 -0
  22. package/dist/ai/error-panel/index.js +2 -0
  23. package/dist/ai/index.d.ts +11 -0
  24. package/dist/ai/index.js +7 -0
  25. package/dist/ai/message/index.d.ts +2 -0
  26. package/dist/ai/message/index.js +1 -0
  27. package/dist/ai/message/message-usage.svelte +126 -0
  28. package/dist/ai/message/message-usage.svelte.d.ts +22 -0
  29. package/dist/ai/project.d.ts +64 -0
  30. package/dist/ai/project.js +70 -0
  31. package/dist/ai/run-status/index.d.ts +2 -0
  32. package/dist/ai/run-status/index.js +1 -0
  33. package/dist/ai/run-status/run-status.svelte +132 -0
  34. package/dist/ai/run-status/run-status.svelte.d.ts +26 -0
  35. package/dist/ai/runtime-feed/index.d.ts +2 -0
  36. package/dist/ai/runtime-feed/index.js +2 -0
  37. package/dist/ai/runtime-feed/runtime-feed-item.svelte +64 -0
  38. package/dist/ai/runtime-feed/runtime-feed-item.svelte.d.ts +27 -0
  39. package/dist/ai/runtime-feed/runtime-feed-root.svelte +35 -0
  40. package/dist/ai/runtime-feed/runtime-feed-root.svelte.d.ts +13 -0
  41. package/dist/ai/structured-value/index.d.ts +1 -0
  42. package/dist/ai/structured-value/index.js +1 -0
  43. package/dist/ai/structured-value/structured-value.svelte +130 -0
  44. package/dist/ai/structured-value/structured-value.svelte.d.ts +10 -0
  45. package/dist/ai/tool/index.d.ts +3 -0
  46. package/dist/ai/tool/index.js +3 -0
  47. package/dist/ai/tool/tool-chip.svelte +66 -0
  48. package/dist/ai/tool/tool-chip.svelte.d.ts +17 -0
  49. package/dist/ai/tool/tool-chips.svelte +56 -0
  50. package/dist/ai/tool/tool-chips.svelte.d.ts +13 -0
  51. package/dist/ai/tool/tool-exec.svelte +49 -0
  52. package/dist/ai/tool/tool-exec.svelte.d.ts +11 -0
  53. package/dist/era-ui.css +1 -1
  54. package/dist/ui/badge/badge.svelte +3 -2
  55. package/dist/ui/badge/badge.svelte.d.ts +4 -1
  56. package/package.json +1 -1
@@ -0,0 +1,42 @@
1
+ /** How a single argument field is rendered and coerced. */
2
+ export type FieldKind = 'string' | 'number' | 'boolean' | 'json';
3
+ /**
4
+ * Drives editable approval of a tool call's arguments. Holds the original
5
+ * args, the user's live edits, which keys are dirty, whether every JSON field
6
+ * still parses, and a {@link diff} of only the changed keys so a consumer can
7
+ * send overrides only when something actually changed.
8
+ */
9
+ export declare class ApprovalController {
10
+ #private;
11
+ /** The arguments as the tool proposed them — never mutated. */
12
+ readonly original: Record<string, unknown>;
13
+ /** Live edited values, keyed like {@link original}. */
14
+ edits: Record<string, unknown>;
15
+ constructor(args?: Record<string, unknown>);
16
+ /** Original entries in insertion order — the stable render list. */
17
+ get entries(): [string, unknown][];
18
+ /** Per-key dirty flags (edited value differs from the original). */
19
+ dirty: Record<string, boolean>;
20
+ /** True when every JSON field currently parses. */
21
+ valid: boolean;
22
+ /** How to render/coerce the field for `key`. */
23
+ kindOf(key: string): FieldKind;
24
+ isDirty(key: string): boolean;
25
+ isValid(key: string): boolean;
26
+ /** Pretty JSON for object/array fields' textarea seed content. */
27
+ json(key: string): string;
28
+ /** Display string for a primitive field. */
29
+ text(key: string): string;
30
+ /** Set a boolean field directly (from a switch). */
31
+ setBoolean(key: string, value: boolean): void;
32
+ /** Set a string/number field from raw input, coercing to the original type. */
33
+ setPrimitive(key: string, raw: string): void;
34
+ /** Parse a JSON field; on failure keep the last valid value and flag invalid. */
35
+ setJson(key: string, raw: string): void;
36
+ /** Restore a single field (or all fields) to its original value. */
37
+ reset(key?: string): void;
38
+ /** Only the changed keys and their edited values — send these as overrides. */
39
+ diff(): Record<string, unknown>;
40
+ }
41
+ export declare function setApprovalContext(ctrl: ApprovalController): ApprovalController;
42
+ export declare function getApprovalContext(): ApprovalController;
@@ -0,0 +1,122 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ function kindOf(value) {
3
+ if (typeof value === 'boolean')
4
+ return 'boolean';
5
+ if (typeof value === 'number' && Number.isFinite(value))
6
+ return 'number';
7
+ if (value !== null && typeof value === 'object')
8
+ return 'json';
9
+ return 'string';
10
+ }
11
+ /** Structural equality good enough for JSON-shaped tool arguments. */
12
+ function equal(a, b) {
13
+ return JSON.stringify(a) === JSON.stringify(b);
14
+ }
15
+ /**
16
+ * Drives editable approval of a tool call's arguments. Holds the original
17
+ * args, the user's live edits, which keys are dirty, whether every JSON field
18
+ * still parses, and a {@link diff} of only the changed keys so a consumer can
19
+ * send overrides only when something actually changed.
20
+ */
21
+ export class ApprovalController {
22
+ /** The arguments as the tool proposed them — never mutated. */
23
+ original;
24
+ /** Live edited values, keyed like {@link original}. */
25
+ edits = $state({});
26
+ /** Per-key JSON parse validity; primitives are always valid. */
27
+ #valid = $state({});
28
+ constructor(args = {}) {
29
+ this.original = { ...args };
30
+ for (const [key, value] of Object.entries(args)) {
31
+ this.edits[key] = structuredClone(value);
32
+ this.#valid[key] = true;
33
+ }
34
+ }
35
+ /** Original entries in insertion order — the stable render list. */
36
+ get entries() {
37
+ return Object.entries(this.original);
38
+ }
39
+ /** Per-key dirty flags (edited value differs from the original). */
40
+ dirty = $derived.by(() => {
41
+ const out = {};
42
+ for (const key of Object.keys(this.original)) {
43
+ out[key] = !equal(this.original[key], this.edits[key]);
44
+ }
45
+ return out;
46
+ });
47
+ /** True when every JSON field currently parses. */
48
+ valid = $derived.by(() => Object.values(this.#valid).every(Boolean));
49
+ /** How to render/coerce the field for `key`. */
50
+ kindOf(key) {
51
+ return kindOf(this.original[key]);
52
+ }
53
+ isDirty(key) {
54
+ return this.dirty[key] ?? false;
55
+ }
56
+ isValid(key) {
57
+ return this.#valid[key] ?? true;
58
+ }
59
+ /** Pretty JSON for object/array fields' textarea seed content. */
60
+ json(key) {
61
+ return JSON.stringify(this.edits[key] ?? this.original[key], null, 2);
62
+ }
63
+ /** Display string for a primitive field. */
64
+ text(key) {
65
+ const value = this.edits[key];
66
+ if (value === null)
67
+ return 'null';
68
+ return String(value ?? '');
69
+ }
70
+ /** Set a boolean field directly (from a switch). */
71
+ setBoolean(key, value) {
72
+ this.edits[key] = value;
73
+ }
74
+ /** Set a string/number field from raw input, coercing to the original type. */
75
+ setPrimitive(key, raw) {
76
+ if (this.kindOf(key) === 'number') {
77
+ const n = Number(raw);
78
+ this.edits[key] = raw.trim() !== '' && Number.isFinite(n) ? n : raw;
79
+ }
80
+ else {
81
+ this.edits[key] = raw;
82
+ }
83
+ }
84
+ /** Parse a JSON field; on failure keep the last valid value and flag invalid. */
85
+ setJson(key, raw) {
86
+ try {
87
+ this.edits[key] = JSON.parse(raw);
88
+ this.#valid[key] = true;
89
+ }
90
+ catch {
91
+ this.#valid[key] = false;
92
+ }
93
+ }
94
+ /** Restore a single field (or all fields) to its original value. */
95
+ reset(key) {
96
+ const keys = key === undefined ? Object.keys(this.original) : [key];
97
+ for (const k of keys) {
98
+ this.edits[k] = structuredClone(this.original[k]);
99
+ this.#valid[k] = true;
100
+ }
101
+ }
102
+ /** Only the changed keys and their edited values — send these as overrides. */
103
+ diff() {
104
+ const out = {};
105
+ for (const key of Object.keys(this.original)) {
106
+ if (this.dirty[key])
107
+ out[key] = this.edits[key];
108
+ }
109
+ return out;
110
+ }
111
+ }
112
+ const KEY = Symbol('era-ai-approval');
113
+ export function setApprovalContext(ctrl) {
114
+ setContext(KEY, ctrl);
115
+ return ctrl;
116
+ }
117
+ export function getApprovalContext() {
118
+ const ctx = getContext(KEY);
119
+ if (!ctx)
120
+ throw new Error('Approval parts must be used inside a Confirmation with a controller.');
121
+ return ctx;
122
+ }
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../utils/index.js';
3
+ import { Input, Textarea } from '../../ui/input/index.js';
4
+ import { Switch } from '../../ui/switch/index.js';
5
+ import { ApprovalController, getApprovalContext } from './approval.svelte.js';
6
+
7
+ let {
8
+ controller,
9
+ class: className
10
+ }: { controller?: ApprovalController; class?: string } = $props();
11
+
12
+ const ctrl = $derived(controller ?? getApprovalContext());
13
+
14
+ // JSON fields are edited as free text: we keep the raw string locally so the
15
+ // textarea keeps showing what the user typed even while it fails to parse
16
+ // (the controller only adopts the value once it parses again). Entries seed
17
+ // lazily from `ctrl.json(key)` until the field is first edited.
18
+ const jsonText = $state<Record<string, string>>({});
19
+ </script>
20
+
21
+ <div class={cn('flex min-w-0 flex-col gap-(--era-gap)', className)}>
22
+ {#if ctrl.entries.length === 0}
23
+ <span class="text-body text-muted">No arguments</span>
24
+ {:else}
25
+ {#each ctrl.entries as [key] (key)}
26
+ {@const kind = ctrl.kindOf(key)}
27
+ <div
28
+ class={cn(
29
+ 'flex min-w-0 flex-col gap-(--era-gap)',
30
+ ctrl.isDirty(key) && 'border-s-2 border-warning ps-(--era-pad-sm)'
31
+ )}
32
+ >
33
+ <span class="text-[0.625rem] leading-none tracking-[0.15em] text-muted uppercase">{key}</span>
34
+
35
+ {#if kind === 'boolean'}
36
+ <Switch
37
+ checked={ctrl.edits[key] === true}
38
+ onCheckedChange={(value) => ctrl.setBoolean(key, value)}
39
+ />
40
+ {:else if kind === 'json'}
41
+ <Textarea
42
+ value={jsonText[key] ?? ctrl.json(key)}
43
+ oninput={(event) => {
44
+ jsonText[key] = event.currentTarget.value;
45
+ ctrl.setJson(key, event.currentTarget.value);
46
+ }}
47
+ spellcheck={false}
48
+ class={cn(
49
+ 'min-h-(--era-h-lg) font-mono text-body',
50
+ !ctrl.isValid(key) && 'border border-destructive'
51
+ )}
52
+ />
53
+ {#if !ctrl.isValid(key)}
54
+ <span class="text-body text-destructive era-text-trim">Invalid JSON</span>
55
+ {/if}
56
+ {:else}
57
+ <Input
58
+ value={ctrl.text(key)}
59
+ oninput={(event) => ctrl.setPrimitive(key, event.currentTarget.value)}
60
+ inputmode={kind === 'number' ? 'decimal' : undefined}
61
+ spellcheck={false}
62
+ />
63
+ {/if}
64
+ </div>
65
+ {/each}
66
+ {/if}
67
+ </div>
@@ -0,0 +1,8 @@
1
+ import { ApprovalController } from './approval.svelte.js';
2
+ type $$ComponentProps = {
3
+ controller?: ApprovalController;
4
+ class?: string;
5
+ };
6
+ declare const ConfirmationArgs: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type ConfirmationArgs = ReturnType<typeof ConfirmationArgs>;
8
+ export default ConfirmationArgs;
@@ -1,2 +1,4 @@
1
1
  export { default as Root, type ApprovalState } from './confirmation.svelte';
2
2
  export { default as Actions } from './confirmation-actions.svelte';
3
+ export { default as Args } from './confirmation-args.svelte';
4
+ export { ApprovalController, setApprovalContext, getApprovalContext, type FieldKind } from './approval.svelte.js';
@@ -1,2 +1,4 @@
1
1
  export { default as Root } from './confirmation.svelte';
2
2
  export { default as Actions } from './confirmation-actions.svelte';
3
+ export { default as Args } from './confirmation-args.svelte';
4
+ export { ApprovalController, setApprovalContext, getApprovalContext } from './approval.svelte.js';
@@ -0,0 +1,28 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { cn } from '../../utils/index.js';
5
+
6
+ let {
7
+ label,
8
+ children,
9
+ class: className,
10
+ ...restProps
11
+ }: HTMLAttributes<HTMLDivElement> & {
12
+ /** Tiny uppercase bucket header — Today / Yesterday / Starred / … */
13
+ label?: string;
14
+ children?: Snippet;
15
+ } = $props();
16
+ </script>
17
+
18
+ <!-- A time-bucket section: the step-section label idiom over its stack of rows. -->
19
+ <div class={cn('flex min-w-0 flex-col gap-(--era-gap)', className)} {...restProps}>
20
+ {#if label}
21
+ <span
22
+ class="px-(--era-inset-md) text-[0.625rem] leading-none tracking-[0.15em] text-muted uppercase era-text-trim-caps"
23
+ >
24
+ {label}
25
+ </span>
26
+ {/if}
27
+ {@render children?.()}
28
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = HTMLAttributes<HTMLDivElement> & {
4
+ /** Tiny uppercase bucket header — Today / Yesterday / Starred / … */
5
+ label?: string;
6
+ children?: Snippet;
7
+ };
8
+ declare const ConversationsGroup: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type ConversationsGroup = ReturnType<typeof ConversationsGroup>;
10
+ export default ConversationsGroup;
@@ -0,0 +1,208 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import MessageSquare from '@lucide/svelte/icons/message-square';
5
+ import Star from '@lucide/svelte/icons/star';
6
+ import X from '@lucide/svelte/icons/x';
7
+ import Trash2 from '@lucide/svelte/icons/trash-2';
8
+ import Check from '@lucide/svelte/icons/check';
9
+ import { Button } from '../../ui/button/index.js';
10
+ import { cn } from '../../utils/index.js';
11
+ import type { ConversationStatus } from './conversations.svelte.js';
12
+
13
+ let {
14
+ title = $bindable(''),
15
+ starred = $bindable(false),
16
+ status,
17
+ active = false,
18
+ leading,
19
+ onSelect,
20
+ onRename,
21
+ onStar,
22
+ onCancel,
23
+ onDelete,
24
+ class: className,
25
+ ...restProps
26
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
27
+ /** Conversation label; double-click to rename in place. Bindable. */
28
+ title?: string;
29
+ /** Bindable star state; the rail's star button toggles it. */
30
+ starred?: boolean;
31
+ /** Drives the status dot tone; omit for no dot. */
32
+ status?: ConversationStatus;
33
+ /** Highlighted as the open conversation. */
34
+ active?: boolean;
35
+ /** Overrides the leading icon (e.g. an expand chevron). */
36
+ leading?: Snippet;
37
+ /** Row activated (click / Enter on the title). */
38
+ onSelect?: () => void;
39
+ /** Fired after a non-empty rename commits, with the new title. */
40
+ onRename?: (title: string) => void;
41
+ /** Fired when the star toggles, with the new starred state. */
42
+ onStar?: (starred: boolean) => void;
43
+ /** Fired by the cancel button (shown while queued / running). */
44
+ onCancel?: () => void;
45
+ /** Fired on the confirming second delete click. */
46
+ onDelete?: () => void;
47
+ } = $props();
48
+
49
+ // The status dot: tone token per state; `running` breathes via animate-pulse.
50
+ const DOT: Record<ConversationStatus, { class: string; label: string }> = {
51
+ idle: { class: 'bg-muted', label: 'Idle' },
52
+ queued: { class: 'bg-warning', label: 'Queued' },
53
+ running: { class: 'bg-primary animate-pulse', label: 'Running' },
54
+ done: { class: 'bg-success', label: 'Done' },
55
+ error: { class: 'bg-destructive', label: 'Error' }
56
+ };
57
+
58
+ const showCancel = $derived(status === 'queued' || status === 'running');
59
+
60
+ // Inline rename. `draft` mirrors the title while editing; the bindable
61
+ // `title` only changes when a non-empty edit commits.
62
+ let editing = $state(false);
63
+ let draft = $state('');
64
+ let input = $state<HTMLInputElement | null>(null);
65
+
66
+ $effect(() => {
67
+ if (editing && input) {
68
+ input.focus();
69
+ input.select();
70
+ }
71
+ });
72
+
73
+ function startEditing() {
74
+ draft = title;
75
+ editing = true;
76
+ }
77
+
78
+ function commitRename() {
79
+ if (!editing) return;
80
+ const next = draft.trim();
81
+ editing = false;
82
+ if (next.length === 0 || next === title) return; // empty rejected; no-op unchanged
83
+ title = next;
84
+ onRename?.(next);
85
+ }
86
+
87
+ function cancelRename() {
88
+ editing = false;
89
+ }
90
+
91
+ function onTitleKeydown(event: KeyboardEvent) {
92
+ if (event.key === 'Enter') {
93
+ event.preventDefault();
94
+ commitRename();
95
+ } else if (event.key === 'Escape') {
96
+ event.preventDefault();
97
+ cancelRename();
98
+ }
99
+ }
100
+
101
+ function toggleStar() {
102
+ starred = !starred;
103
+ onStar?.(starred);
104
+ }
105
+
106
+ // Two-click delete: the first click arms (Trash → Check), the second within
107
+ // the window confirms. An optional timer and pointer-leave both disarm.
108
+ let armed = $state(false);
109
+ let disarmTimer: ReturnType<typeof setTimeout> | undefined;
110
+
111
+ function disarm() {
112
+ armed = false;
113
+ clearTimeout(disarmTimer);
114
+ }
115
+
116
+ function onDeleteClick() {
117
+ if (armed) {
118
+ disarm();
119
+ onDelete?.();
120
+ return;
121
+ }
122
+ armed = true;
123
+ clearTimeout(disarmTimer);
124
+ disarmTimer = setTimeout(() => (armed = false), 3000);
125
+ }
126
+ </script>
127
+
128
+ <div
129
+ data-active={active || undefined}
130
+ class={cn(
131
+ 'group/row relative flex h-(--era-h-md) items-center gap-(--era-gap) rounded-(--era-rd-md) px-(--era-inset-md) text-body transition-colors duration-(--era-duration) ease-(--era-ease)',
132
+ active ? 'bg-hover text-bright' : 'text-fg hover:bg-(--era-highlight)',
133
+ className
134
+ )}
135
+ onmouseleave={disarm}
136
+ {...restProps}
137
+ >
138
+ <!-- Leading: a chevron/expander slot, else the default conversation glyph. -->
139
+ {#if leading}
140
+ {@render leading()}
141
+ {:else}
142
+ <MessageSquare class="size-(--era-h-xs) shrink-0 text-muted" aria-hidden="true" />
143
+ {/if}
144
+
145
+ {#if status}
146
+ <span
147
+ class={cn('size-2 shrink-0 rounded-full', DOT[status].class)}
148
+ title={DOT[status].label}
149
+ aria-hidden="true"
150
+ ></span>
151
+ {/if}
152
+
153
+ {#if editing}
154
+ <input
155
+ bind:this={input}
156
+ bind:value={draft}
157
+ onkeydown={onTitleKeydown}
158
+ onblur={commitRename}
159
+ aria-label="Rename conversation"
160
+ class="min-w-0 flex-1 border-b border-primary bg-transparent text-body text-bright outline-none era-text-trim"
161
+ />
162
+ {:else}
163
+ <button
164
+ type="button"
165
+ onclick={onSelect}
166
+ ondblclick={startEditing}
167
+ class="min-w-0 flex-1 cursor-pointer truncate text-left outline-none era-text-trim"
168
+ >
169
+ {title}
170
+ </button>
171
+ {/if}
172
+
173
+ <!-- Action rail: hidden at rest, revealed on row hover or keyboard focus. -->
174
+ <div
175
+ class="flex shrink-0 items-center gap-(--era-gap) opacity-0 transition-opacity duration-(--era-duration) ease-(--era-ease) group-hover/row:opacity-100 focus-within:opacity-100"
176
+ >
177
+ <Button
178
+ icon
179
+ size="icon"
180
+ aria-label={starred ? 'Unstar conversation' : 'Star conversation'}
181
+ aria-pressed={starred}
182
+ onclick={toggleStar}
183
+ >
184
+ <Star class={cn('size-(--era-h-xs)', starred && 'fill-warning text-warning')} />
185
+ </Button>
186
+
187
+ {#if showCancel}
188
+ <Button icon size="icon" aria-label="Cancel generation" onclick={() => onCancel?.()}>
189
+ <X class="size-(--era-h-xs)" />
190
+ </Button>
191
+ {/if}
192
+
193
+ <Button
194
+ icon
195
+ size="icon"
196
+ tone="destructive"
197
+ active={armed}
198
+ aria-label={armed ? 'Confirm delete conversation' : 'Delete conversation'}
199
+ onclick={onDeleteClick}
200
+ >
201
+ {#if armed}
202
+ <Check class="size-(--era-h-xs)" />
203
+ {:else}
204
+ <Trash2 class="size-(--era-h-xs)" />
205
+ {/if}
206
+ </Button>
207
+ </div>
208
+ </div>
@@ -0,0 +1,28 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { ConversationStatus } from './conversations.svelte.js';
4
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
5
+ /** Conversation label; double-click to rename in place. Bindable. */
6
+ title?: string;
7
+ /** Bindable star state; the rail's star button toggles it. */
8
+ starred?: boolean;
9
+ /** Drives the status dot tone; omit for no dot. */
10
+ status?: ConversationStatus;
11
+ /** Highlighted as the open conversation. */
12
+ active?: boolean;
13
+ /** Overrides the leading icon (e.g. an expand chevron). */
14
+ leading?: Snippet;
15
+ /** Row activated (click / Enter on the title). */
16
+ onSelect?: () => void;
17
+ /** Fired after a non-empty rename commits, with the new title. */
18
+ onRename?: (title: string) => void;
19
+ /** Fired when the star toggles, with the new starred state. */
20
+ onStar?: (starred: boolean) => void;
21
+ /** Fired by the cancel button (shown while queued / running). */
22
+ onCancel?: () => void;
23
+ /** Fired on the confirming second delete click. */
24
+ onDelete?: () => void;
25
+ };
26
+ declare const ConversationsItem: import("svelte").Component<$$ComponentProps, {}, "title" | "starred">;
27
+ type ConversationsItem = ReturnType<typeof ConversationsItem>;
28
+ export default ConversationsItem;
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { cn } from '../../utils/index.js';
5
+
6
+ let {
7
+ header,
8
+ children,
9
+ class: className,
10
+ ...restProps
11
+ }: HTMLAttributes<HTMLDivElement> & {
12
+ /** Pinned above the scroll area — a "New chat" button, a filter, etc. */
13
+ header?: Snippet;
14
+ children?: Snippet;
15
+ } = $props();
16
+ </script>
17
+
18
+ <!-- The rail: a sticky header slot over a chromeless scroll column. min-h-0 lets
19
+ the list shrink inside a flex parent so only the list scrolls, not the page. -->
20
+ <div class={cn('flex min-h-0 flex-col gap-(--era-gap)', className)} {...restProps}>
21
+ {#if header}
22
+ <div class="shrink-0">{@render header()}</div>
23
+ {/if}
24
+ <div class="scrollbar-none flex min-h-0 flex-1 flex-col gap-(--era-gap) overflow-y-auto">
25
+ {@render children?.()}
26
+ </div>
27
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = HTMLAttributes<HTMLDivElement> & {
4
+ /** Pinned above the scroll area — a "New chat" button, a filter, etc. */
5
+ header?: Snippet;
6
+ children?: Snippet;
7
+ };
8
+ declare const ConversationsRoot: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type ConversationsRoot = ReturnType<typeof ConversationsRoot>;
10
+ export default ConversationsRoot;
@@ -0,0 +1,38 @@
1
+ /** Execution state of a conversation, drives the row's status dot tone. */
2
+ export type ConversationStatus = 'idle' | 'queued' | 'running' | 'done' | 'error';
3
+ /** Ordered time buckets. `starred` floats to the top, ahead of any age band. */
4
+ export type TimeBucket = 'starred' | 'today' | 'yesterday' | 'last7days' | 'older';
5
+ /** One conversation, as fed to the bucketer. `updatedAt` is epoch ms. */
6
+ export interface ConversationEntry {
7
+ id: string;
8
+ title: string;
9
+ /** Epoch ms of last activity — both bucketed and sorted (newest-first) by this. */
10
+ updatedAt: number;
11
+ starred?: boolean;
12
+ status?: ConversationStatus;
13
+ }
14
+ /** A rendered section: a bucket, its display label, and its member rows. */
15
+ export interface ConversationGroup {
16
+ bucket: TimeBucket;
17
+ label: string;
18
+ items: ConversationEntry[];
19
+ }
20
+ /**
21
+ * Group conversations for the rail: a leading `Starred` section (any age),
22
+ * then the non-empty age bands in order. Items within a group are newest-first.
23
+ * Pure — the `now` epoch (ms) is the only clock input.
24
+ */
25
+ export declare function bucketConversations(items: ConversationEntry[], now: number): ConversationGroup[];
26
+ /**
27
+ * Optional runes-class helper mirroring `BranchController`: holds the raw item
28
+ * list and a `now` epoch, and exposes reactively-bucketed `groups`. Feeding
29
+ * `now` from a consumer-owned `$state` re-buckets as the clock advances. Using
30
+ * the class is never required — `bucketConversations` covers the stateless case.
31
+ */
32
+ export declare class ConversationsController {
33
+ items: ConversationEntry[];
34
+ /** Epoch ms; caller-supplied so the class never touches the clock. */
35
+ now: number;
36
+ constructor(items?: ConversationEntry[], now?: number);
37
+ readonly groups: ConversationGroup[];
38
+ }
@@ -0,0 +1,73 @@
1
+ // Headless bucketing for the conversation history rail. The library never
2
+ // reads the clock itself — the caller passes a `now` epoch (ms) so the same
3
+ // input always produces the same grouping (testable, SSR-safe). Consumers own
4
+ // `Date.now()`, we own the relative-age math.
5
+ const BUCKET_LABELS = {
6
+ starred: 'Starred',
7
+ today: 'Today',
8
+ yesterday: 'Yesterday',
9
+ last7days: 'Last 7 Days',
10
+ older: 'Older'
11
+ };
12
+ const AGE_ORDER = ['today', 'yesterday', 'last7days', 'older'];
13
+ const DAY_MS = 86_400_000;
14
+ /** Which age band a timestamp falls in, measured from the local start-of-today. */
15
+ function ageBucket(updatedAt, now) {
16
+ // Local calendar start-of-today, built without mutating a Date instance.
17
+ const d = new Date(now);
18
+ const startOfToday = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
19
+ if (updatedAt >= startOfToday)
20
+ return 'today';
21
+ if (updatedAt >= startOfToday - DAY_MS)
22
+ return 'yesterday';
23
+ if (updatedAt >= startOfToday - 7 * DAY_MS)
24
+ return 'last7days';
25
+ return 'older';
26
+ }
27
+ /**
28
+ * Group conversations for the rail: a leading `Starred` section (any age),
29
+ * then the non-empty age bands in order. Items within a group are newest-first.
30
+ * Pure — the `now` epoch (ms) is the only clock input.
31
+ */
32
+ export function bucketConversations(items, now) {
33
+ const starred = [];
34
+ const byAge = {
35
+ today: [],
36
+ yesterday: [],
37
+ last7days: [],
38
+ older: []
39
+ };
40
+ for (const item of items) {
41
+ if (item.starred)
42
+ starred.push(item);
43
+ else
44
+ byAge[ageBucket(item.updatedAt, now)].push(item);
45
+ }
46
+ const newestFirst = (a, b) => b.updatedAt - a.updatedAt;
47
+ const groups = [];
48
+ if (starred.length > 0) {
49
+ groups.push({ bucket: 'starred', label: BUCKET_LABELS.starred, items: starred.sort(newestFirst) });
50
+ }
51
+ for (const bucket of AGE_ORDER) {
52
+ if (byAge[bucket].length > 0) {
53
+ groups.push({ bucket, label: BUCKET_LABELS[bucket], items: byAge[bucket].sort(newestFirst) });
54
+ }
55
+ }
56
+ return groups;
57
+ }
58
+ /**
59
+ * Optional runes-class helper mirroring `BranchController`: holds the raw item
60
+ * list and a `now` epoch, and exposes reactively-bucketed `groups`. Feeding
61
+ * `now` from a consumer-owned `$state` re-buckets as the clock advances. Using
62
+ * the class is never required — `bucketConversations` covers the stateless case.
63
+ */
64
+ export class ConversationsController {
65
+ items = $state([]);
66
+ /** Epoch ms; caller-supplied so the class never touches the clock. */
67
+ now = $state(0);
68
+ constructor(items = [], now = 0) {
69
+ this.items = items;
70
+ this.now = now;
71
+ }
72
+ groups = $derived.by(() => bucketConversations(this.items, this.now));
73
+ }