@protoyard/ai-ui 0.0.0-stage → 1.1.1

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -2
  3. package/dist/MessageList.svelte +113 -0
  4. package/dist/MessageList.svelte.d.ts +13 -0
  5. package/dist/approvals/AuditTrail.svelte +72 -0
  6. package/dist/approvals/AuditTrail.svelte.d.ts +12 -0
  7. package/dist/approvals/AutonomyLevelPicker.svelte +100 -0
  8. package/dist/approvals/AutonomyLevelPicker.svelte.d.ts +13 -0
  9. package/dist/approvals/ChatApprovalCard.svelte +118 -0
  10. package/dist/approvals/ChatApprovalCard.svelte.d.ts +12 -0
  11. package/dist/approvals/SuggestionCard.svelte +248 -0
  12. package/dist/approvals/SuggestionCard.svelte.d.ts +18 -0
  13. package/dist/approvals/index.d.ts +9 -0
  14. package/dist/approvals/index.js +9 -0
  15. package/dist/approvals/labels.d.ts +12 -0
  16. package/dist/approvals/labels.js +53 -0
  17. package/dist/components/AssistantComposer.svelte +172 -0
  18. package/dist/components/AssistantComposer.svelte.d.ts +21 -0
  19. package/dist/components/AssistantWorkspace.svelte +335 -0
  20. package/dist/components/AssistantWorkspace.svelte.d.ts +30 -0
  21. package/dist/components/CanvasPanel.svelte +62 -0
  22. package/dist/components/CanvasPanel.svelte.d.ts +9 -0
  23. package/dist/components/ChartAnswer.svelte +121 -0
  24. package/dist/components/ChartAnswer.svelte.d.ts +6 -0
  25. package/dist/components/RichAnswer.svelte +129 -0
  26. package/dist/components/RichAnswer.svelte.d.ts +7 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.js +5 -0
  29. package/dist/messages/ChatMessageBlocks.svelte +73 -0
  30. package/dist/messages/ChatMessageBlocks.svelte.d.ts +13 -0
  31. package/dist/messages/MessageAttachments.svelte +61 -0
  32. package/dist/messages/MessageAttachments.svelte.d.ts +8 -0
  33. package/dist/messages/MessageButtons.svelte +162 -0
  34. package/dist/messages/MessageButtons.svelte.d.ts +11 -0
  35. package/dist/messages/MessageForm.svelte +224 -0
  36. package/dist/messages/MessageForm.svelte.d.ts +11 -0
  37. package/dist/messages/MessageLinkPreview.svelte +42 -0
  38. package/dist/messages/MessageLinkPreview.svelte.d.ts +7 -0
  39. package/dist/messages/MessageSelect.svelte +118 -0
  40. package/dist/messages/MessageSelect.svelte.d.ts +11 -0
  41. package/dist/messages/MessageTable.svelte +48 -0
  42. package/dist/messages/MessageTable.svelte.d.ts +7 -0
  43. package/dist/messages/format.d.ts +15 -0
  44. package/dist/messages/format.js +46 -0
  45. package/dist/messages/index.d.ts +10 -0
  46. package/dist/messages/index.js +9 -0
  47. package/dist/messages/types.d.ts +33 -0
  48. package/dist/messages/types.js +11 -0
  49. package/dist/scroll-anchor.d.ts +13 -0
  50. package/dist/scroll-anchor.js +35 -0
  51. package/dist/styles.css +5 -0
  52. package/dist/styles.css.d.ts +1 -0
  53. package/dist/types.d.ts +25 -0
  54. package/dist/types.js +1 -0
  55. package/dist/voice/VoiceMode.svelte +230 -0
  56. package/dist/voice/VoiceMode.svelte.d.ts +13 -0
  57. package/dist/voice/index.d.ts +1 -0
  58. package/dist/voice/index.js +1 -0
  59. package/dist/widget/AssistantWidget.svelte +167 -0
  60. package/dist/widget/AssistantWidget.svelte.d.ts +5 -0
  61. package/dist/widget/AssistantWidgetPanel.svelte +11 -0
  62. package/dist/widget/AssistantWidgetPanel.svelte.d.ts +4 -0
  63. package/dist/widget/index.d.ts +2 -0
  64. package/dist/widget/index.js +1 -0
  65. package/dist/widget/types.d.ts +7 -0
  66. package/dist/widget/types.js +1 -0
  67. package/package.json +97 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dailybot, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,153 @@
1
- # Temporary Holding Version
1
+ # @protoyard/ai-ui
2
+
3
+ Svelte 5 components for prototypes that simulate AI assistants. The package renders conversations and interaction controls; it does not call a model provider, create database rows, or implement sharing itself. Mutations are callback props so the prototype can route them through its own services. Model labels and UI copy keep the simulated boundary visible.
4
+
5
+ **Platform guide:** [docs/AI_KITS.md](../../docs/AI_KITS.md) (workspace, virtual threads, widget launcher→sidebar→fullscreen, voice panel). **Measured bundles and long threads:** [docs/PERFORMANCE.md → AI kits](../../docs/PERFORMANCE.md#ai-kits).
6
+
7
+ ## Install in a workspace prototype
8
+
9
+ Add `"@protoyard/ai-ui": "workspace:*"` to the prototype dependencies. Import the styles after Tailwind and the theme/UI styles:
10
+
11
+ ```css
12
+ @import 'tailwindcss';
13
+ @import '@protoyard/themes/base.css';
14
+ @import '@protoyard/ui/styles.css';
15
+ @import '@protoyard/ai-ui/styles.css';
16
+ ```
17
+
18
+ ## Assistant workspace
19
+
20
+ `AssistantWorkspace` accepts conversation summaries and the active branch's messages. Send, stop, rename, pin, folder, feedback, branch, regenerate, edit and share operations are callbacks. The component does not persist changes. `onShare` must return a URL created by the consuming prototype; the UI then offers a copy action.
21
+
22
+ The workspace renders its conversation through `MessageList`, which is `@texuli/chat`'s `MessageList`: it virtualizes long threads, keeps the visible message anchored as streamed content changes and offers "Jump to latest". ai-ui restores the first message in view and its offset from session storage when the same conversation is reopened in the tab (and reopens at the latest message when the reader left it at the bottom). A reopened position is held until it settles, even when late content (a chart answer, an image) resizes the thread. Editing a message calls `onEditMessage` only when its text changed: "Save and resend" with the same text closes the editor (`@texuli/chat`'s inline edit), and Regenerate is the way to ask again for the last reply. The saved position is presentation state; conversation messages remain owned by the prototype.
23
+
24
+ ```svelte
25
+ <script lang="ts">
26
+ import { AssistantWorkspace, type AssistantConversation, type AssistantMessage, type AssistantSendInput } from '@protoyard/ai-ui';
27
+
28
+ let conversations: AssistantConversation[] = $state([]);
29
+ let messages: AssistantMessage[] = $state([]);
30
+ let streaming = $state(false);
31
+
32
+ async function send(conversationId: string, input: AssistantSendInput) {
33
+ // A real prototype calls its own service and persists its own attachment metadata.
34
+ await assistantService.send(conversationId, input);
35
+ }
36
+ </script>
37
+
38
+ <AssistantWorkspace
39
+ {conversations}
40
+ {messages}
41
+ {streaming}
42
+ onSend={send}
43
+ onStop={() => assistantService.stop()}
44
+ onRenameConversation={(id, title) => assistantService.rename(id, title)}
45
+ onTogglePin={(id) => assistantService.togglePin(id)}
46
+ />
47
+ ```
48
+
49
+ The composer supports Enter to send, Shift+Enter for a newline, Escape to stop a stream, model selection, file selection, slash-command suggestions, and @mention suggestions. The parent owns streaming state and should update `messages` as deterministic chunks arrive. `RichAnswer` renders the plain-data AST from `parseMarkdown` using text nodes and Svelte elements; it never uses `{@html}`. `CanvasPanel` displays code or editable document text in a responsive side panel.
50
+
51
+ **Scrollable code regions.** Overflowing `<pre>` blocks in `RichAnswer` (fenced code), `ChartAnswer` (invalid chart data), and `CanvasPanel` (code kind) use `tabindex="0"` and an `aria-label`, matching the ui-kit `CodeBlock` pattern, so keyboard users can reach them (axe `scrollable-region-focusable` / WCAG 2.1.1). (Its unit test was removed in PLAN_009.)
52
+
53
+ **Chart answers.** `RichAnswer` parses with `{ charts: true }`.
54
+ - A ```` ```chart ```` fence (see `@protoyard/ai/chart`) shows "Preparing
55
+ chart…" while it streams.
56
+ - When the fence closes, `ChartAnswer` loads on demand. It validates the spec,
57
+ shows a table of the values at once, then the matching `@protoyard/charts-ui`
58
+ chart (bar, line, area, donut, pie or funnel), which brings keyboard
59
+ navigation, tooltips and its own table toggle.
60
+ - An invalid spec shows a note with the reason and the raw data as text.
61
+ - The charts kit and the zod schema are lazy chunks; only the small parser and
62
+ renderer branch ship with `RichAnswer`. In nova-assistant-demo initial JS
63
+ went from 169.40 KB to 169.93 KB gzip.
64
+ - `styles.css` registers the charts-ui class sources, so prototypes need no
65
+ extra import.
66
+
67
+ Conversation folder controls require `folders`, `onCreateFolder`, and `onMoveToFolder` callbacks. Features without a callback are disabled. The caller supplies `onRegenerate`, `onEditMessage`, `onBranchChange`, `onFeedback`, and `onShare` only when those operations are implemented.
68
+
69
+ All components use Svelte 5 runes and theme token utility classes. Import `styles.css` to register this package's class sources with Tailwind.
70
+
71
+ ### Built on Texuli
72
+
73
+ The list is `@texuli/chat`'s `MessageList` (per-message actions snippet, inline edit, stream follow) and the composer is its `Composer` (stop and Escape while streaming, keydown hook, attachments and actions snippets), and every control is a `texuli` component: `Button`/`IconButton`, `CopyButton`, `Textarea`/`Input` (the attachment picker is a file `Input`), `Select`, `Checkbox`/`RadioGroup`, `Field`, `Card`, `Badge`/`Tag`, `Table`, `Code`, `Kbd`, `DropdownMenu` and `EmptyState`. Lucide glyphs render through `LucideIcon` from `texuli/icons/lucide` (or `Icon` for Texuli's built-in names). ai-ui keeps what is assistant-specific: slash and mention suggestions, the model choice, feedback, branches and regenerate, and the reading position per conversation; the public props are unchanged. Rich answers are still parsed by `@protoyard/ai` (`parseMarkdown`) and render as text, never HTML. Components accept data-only messages from `@protoyard/ai`; no credentials, provider SDKs, generated models or external network calls are included.
74
+
75
+ ## Simulated voice mode
76
+
77
+ Load the voice panel from its lazy subpath when the user opens voice mode:
78
+
79
+ ```ts
80
+ const { VoiceMode } = await import('@protoyard/ai-ui/voice');
81
+ ```
82
+
83
+ Pass the injected `AiClock`, scripted transcript options, the latest simulated assistant reply and an `onTranscript` handler. Captions are always visible. Browser `speechSynthesis` speaks replies locally when available; otherwise output stays muted and the reply caption remains visible. The panel does not request a microphone by default. A consumer may opt into showing the local mic level meter; enabling it asks for microphone permission and reads amplitude only with Web Audio, without recording, storing or uploading audio. Stop the meter to release its stream; component teardown also stops it.
84
+
85
+ ## Widget modes
86
+
87
+ ```ts
88
+ const { AssistantWidget } = await import('@protoyard/ai-ui/widget');
89
+ ```
90
+
91
+ `AssistantWidget` hosts the same workspace callbacks inside **launcher → sidebar → fullscreen** chrome. The panel module loads on first open. Mode is stored per `prototypeId` in `localStorage` when the browser allows it. Escape collapses one level. No model or network call is introduced by the widget shell.
92
+
93
+ ## Package output
94
+
95
+ The repository keeps source exports for workspace development. `pnpm build:packages --filter svelte` creates the compiled ESM, declarations and Svelte components under `dist/` that the packed manifest exposes.
96
+
97
+
98
+ ## Suggestion approvals (lazy)
99
+
100
+ Import from `@protoyard/ai-ui/approvals` so the approval surface stays out of the
101
+ assistant boot chunk. Pair with `@protoyard/ai/approvals` pure helpers.
102
+
103
+ | Export | Purpose |
104
+ | --- | --- |
105
+ | `SuggestionCard` | Waiting or decided suggestion with evidence and decision buttons |
106
+ | `ChatApprovalCard` | Compact chat-row approval |
107
+ | `AutonomyLevelPicker` | Per-area autonomy radios (`setLevel` via `onChange`) |
108
+ | `AuditTrail` | Newest-first audit table |
109
+
110
+ Callbacks are required for every decision. Show pending state is built in.
111
+ Label the UI as simulated — these controls are prototype UX, not security.
112
+
113
+ ```ts
114
+ import {
115
+ AuditTrail,
116
+ AutonomyLevelPicker,
117
+ ChatApprovalCard,
118
+ SuggestionCard,
119
+ } from '@protoyard/ai-ui/approvals';
120
+ ```
121
+
122
+ Bundle note: the approvals subpath is optional; assistant workspace entry graphs
123
+ do not import it until a screen does.
124
+
125
+ ## Chat message blocks (lazy)
126
+
127
+ Import `ChatMessageBlocks` from `@protoyard/ai-ui/messages` to render
128
+ `protoyard.message@1` messages from `@protoyard/ai/messages`. It renders text
129
+ (plain or safe markdown), buttons with inline confirmations, selects, forms,
130
+ tables, link previews, attachments and the fallback for unknown blocks.
131
+
132
+ ```svelte
133
+ <script lang="ts">
134
+ import { ChatMessageBlocks, type MessageActionEvent } from '@protoyard/ai-ui/messages';
135
+
136
+ let { message, services } = $props();
137
+ const onAction = (event: MessageActionEvent) => services.chat.respond(event);
138
+ </script>
139
+
140
+ <ChatMessageBlocks {message} {onAction} onOpenAttachment={(file) => services.files.open(file.id)} />
141
+ ```
142
+
143
+ - **Validation:** input is always validated with `parseChatMessage`. An invalid
144
+ message shows a short note and none of its content.
145
+ - **Actions:** buttons, selects and forms report one typed `MessageActionEvent`
146
+ (`kind: "button" | "select" | "form"`) through `onAction`. The returned
147
+ promise drives pending, error and done feedback. Without `onAction` the
148
+ controls are disabled with a note.
149
+ - **No data writes:** the component never writes data. Route every action
150
+ through a service (`session.assert` → zod → `environment.run`).
151
+ - **Size:** about 13.6 KB gzip over `RichAnswer` (zod external), and only for
152
+ screens that import the subpath.
2
153
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
@@ -0,0 +1,113 @@
1
+ <script lang="ts">
2
+ import { ArrowLeft, ArrowRight, ChevronUp, RefreshCw, ThumbsDown, ThumbsUp } from '@lucide/svelte';
3
+ import { type ChatMessage, MessageList as TexuliMessageList } from '@texuli/chat';
4
+ import { Badge } from 'texuli/badge';
5
+ import { Button, IconButton } from 'texuli/button';
6
+ import { LucideIcon } from 'texuli/icons/lucide';
7
+ import RichAnswer from './components/RichAnswer.svelte';
8
+ import { loadReadingAnchor, readingAnchorKey, saveReadingAnchor } from './scroll-anchor.js';
9
+ import type { AssistantMessage, FeedbackRating } from './types.js';
10
+
11
+ interface Props {
12
+ messages: readonly AssistantMessage[];
13
+ conversationId: string;
14
+ streaming?: boolean;
15
+ onRegenerate?: () => void;
16
+ onEditMessage?: (messageId: string, content: string) => void;
17
+ onBranchChange?: (messageId: string, direction: -1 | 1) => void;
18
+ onFeedback?: (messageId: string, rating: FeedbackRating) => void;
19
+ }
20
+
21
+ let {
22
+ messages,
23
+ conversationId,
24
+ streaming = false,
25
+ onRegenerate,
26
+ onEditMessage,
27
+ onBranchChange,
28
+ onFeedback,
29
+ }: Props = $props();
30
+
31
+ const USER = { id: 'user', name: 'You' };
32
+ const ASSISTANT = { id: 'assistant', name: 'Assistant' };
33
+
34
+ const lastAssistantId = $derived(
35
+ [...messages].reverse().find((message) => message.role === 'assistant')?.id
36
+ );
37
+ const byId = $derived(new Map(messages.map((message) => [message.id, message])));
38
+ const chatMessages = $derived(
39
+ messages.map(
40
+ (message): ChatMessage => ({
41
+ id: message.id,
42
+ author: message.role === 'user' ? USER : ASSISTANT,
43
+ body: message.content,
44
+ at: message.createdAt,
45
+ streaming: streaming && message.id === lastAssistantId,
46
+ })
47
+ )
48
+ );
49
+ const storageKey = $derived(readingAnchorKey(conversationId));
50
+ </script>
51
+
52
+ <!--
53
+ @texuli/chat's MessageList: virtual window, stream follow, jump to latest,
54
+ per-message actions, inline edit, full-width day separators (G70) and the
55
+ reading position (G71). ai-ui keeps its names ("Conversation messages",
56
+ "Your message", "Assistant message") that hosts and E2E address, the rich
57
+ answer body, and where each conversation's position is stored: the list is
58
+ keyed by conversation, so switching remounts it with that conversation's
59
+ saved anchor, and Texuli restores it.
60
+ -->
61
+ <div class="@container flex min-h-0 flex-1 flex-col overflow-hidden">
62
+ {#key conversationId}
63
+ <TexuliMessageList
64
+ messages={chatMessages}
65
+ currentUserId={USER.id}
66
+ label="Conversation messages"
67
+ emptyText="No messages yet. Ask the simulated assistant anything."
68
+ labels={{ editInput: 'Edit your message', saveEdit: 'Save and resend' }}
69
+ onEdit={onEditMessage ? (message, body) => onEditMessage(message.id, body) : undefined}
70
+ anchor={loadReadingAnchor(storageKey)}
71
+ onAnchorChange={(next) => saveReadingAnchor(storageKey, next)}
72
+ class="px-4 py-5 @xl:px-8"
73
+ >
74
+ {#snippet body(chat)}
75
+ {@const message = byId.get(chat.id)}
76
+ {#if message?.role === 'assistant'}
77
+ <article class="min-w-0 whitespace-normal" aria-label="Assistant message" data-message-role="assistant" data-py-message-id={chat.id}>
78
+ <Badge variant="outline" label="Simulated" class="mb-1" />
79
+ <RichAnswer content={chat.body} />
80
+ {#if chat.streaming}<p class="mt-2 text-xs text-muted-foreground">Writing…</p>{/if}
81
+ </article>
82
+ {:else}
83
+ <article class="min-w-0" aria-label="Your message" data-message-role="user" data-py-message-id={chat.id}>{chat.body}</article>
84
+ {/if}
85
+ {/snippet}
86
+ {#snippet actions(chat, controls)}
87
+ {@const message = byId.get(chat.id)}
88
+ {#if message?.role === 'user'}
89
+ {#if controls.canEdit}
90
+ <Button variant="ghost" size="sm" class="text-xs" aria-label="Edit your message" onclick={controls.edit}><LucideIcon icon={ChevronUp} size="xs" /> Edit</Button>
91
+ {/if}
92
+ {:else if message}
93
+ <div class="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
94
+ {#if onFeedback}
95
+ <Button variant="ghost" size="sm" class={message.feedback === 'up' ? 'text-primary' : ''} aria-label="Helpful response" aria-pressed={message.feedback === 'up'} onclick={() => onFeedback?.(message.id, 'up')}><LucideIcon icon={ThumbsUp} size="xs" />Helpful</Button>
96
+ <Button variant="ghost" size="sm" class={message.feedback === 'down' ? 'text-primary' : ''} aria-label="Not helpful response" aria-pressed={message.feedback === 'down'} onclick={() => onFeedback?.(message.id, 'down')}><LucideIcon icon={ThumbsDown} size="xs" />Not helpful</Button>
97
+ {/if}
98
+ {#if message.branchCount && message.branchCount > 1}
99
+ <span class="ml-1 inline-flex items-center gap-1">
100
+ <IconButton variant="ghost" size="sm" label="Previous response version" disabled={message.branchIndex === 0 || !onBranchChange} onclick={() => onBranchChange?.(message.id, -1)}><LucideIcon icon={ArrowLeft} size="xs" /></IconButton>
101
+ <span>{(message.branchIndex ?? 0) + 1} / {message.branchCount}</span>
102
+ <IconButton variant="ghost" size="sm" label="Next response version" disabled={(message.branchIndex ?? 0) >= message.branchCount - 1 || !onBranchChange} onclick={() => onBranchChange?.(message.id, 1)}><LucideIcon icon={ArrowRight} size="xs" /></IconButton>
103
+ </span>
104
+ {/if}
105
+ {#if lastAssistantId === message.id && onRegenerate}
106
+ <Button variant="ghost" size="sm" disabled={streaming} aria-label="Regenerate response" onclick={onRegenerate}><LucideIcon icon={RefreshCw} size="xs" /> Regenerate</Button>
107
+ {/if}
108
+ </div>
109
+ {/if}
110
+ {/snippet}
111
+ </TexuliMessageList>
112
+ {/key}
113
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { AssistantMessage, FeedbackRating } from './types.ts';
2
+ interface Props {
3
+ messages: readonly AssistantMessage[];
4
+ conversationId: string;
5
+ streaming?: boolean;
6
+ onRegenerate?: () => void;
7
+ onEditMessage?: (messageId: string, content: string) => void;
8
+ onBranchChange?: (messageId: string, direction: -1 | 1) => void;
9
+ onFeedback?: (messageId: string, rating: FeedbackRating) => void;
10
+ }
11
+ declare const MessageList: import("svelte").Component<Props, {}, "">;
12
+ type MessageList = ReturnType<typeof MessageList>;
13
+ export default MessageList;
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ import { type AuditEntry, sortAudit } from '@protoyard/ai';
3
+ import { EmptyState } from 'texuli/empty';
4
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'texuli/table';
5
+ import { AUDIT_ACTION_LABELS } from './labels.js';
6
+
7
+ /**
8
+ * Newest-first audit table for suggestion decisions, on Texuli's table and
9
+ * empty state. Pure presentation of engine AuditEntry rows.
10
+ */
11
+ let {
12
+ entries,
13
+ emptyTitle = 'No audit entries yet',
14
+ emptyDescription = 'When the simulated agent suggests something and people decide, rows appear here.',
15
+ actorName = (entry: AuditEntry) =>
16
+ entry.actor === 'agent' ? 'Simulated agent' : (entry.actorId ?? 'Person'),
17
+ formatWhen = (iso: string) => {
18
+ try {
19
+ return new Intl.DateTimeFormat(undefined, {
20
+ month: 'short',
21
+ day: 'numeric',
22
+ hour: 'numeric',
23
+ minute: '2-digit',
24
+ }).format(new Date(iso));
25
+ } catch {
26
+ return iso;
27
+ }
28
+ },
29
+ class: className = '',
30
+ }: {
31
+ entries: readonly AuditEntry[];
32
+ emptyTitle?: string;
33
+ emptyDescription?: string;
34
+ actorName?: (entry: AuditEntry) => string;
35
+ formatWhen?: (iso: string) => string;
36
+ class?: string;
37
+ } = $props();
38
+
39
+ const rows = $derived(sortAudit(entries));
40
+ </script>
41
+
42
+ <section class="grid gap-3 {className}" data-py-audit-trail aria-label="Approval audit trail">
43
+ <p class="m-0 text-xs text-muted-foreground">
44
+ Simulated audit log. Not a compliance or security record.
45
+ </p>
46
+ {#if rows.length === 0}
47
+ <EmptyState title={emptyTitle} description={emptyDescription} size="sm" />
48
+ {:else}
49
+ <Table density="compact" stack="sm" aria-label="Approval audit entries">
50
+ <TableHeader>
51
+ <TableRow>
52
+ <TableHead scope="col">When</TableHead>
53
+ <TableHead scope="col">Who</TableHead>
54
+ <TableHead scope="col">What</TableHead>
55
+ <TableHead scope="col">Detail</TableHead>
56
+ </TableRow>
57
+ </TableHeader>
58
+ <TableBody>
59
+ {#each rows as entry (entry.id)}
60
+ <TableRow data-py-audit-row={entry.id} data-action={entry.action}>
61
+ <TableCell class="whitespace-nowrap text-muted-foreground">
62
+ <time datetime={entry.at}>{formatWhen(entry.at)}</time>
63
+ </TableCell>
64
+ <TableCell>{actorName(entry)}</TableCell>
65
+ <TableCell>{AUDIT_ACTION_LABELS[entry.action]}</TableCell>
66
+ <TableCell class="text-muted-foreground">{entry.detail}</TableCell>
67
+ </TableRow>
68
+ {/each}
69
+ </TableBody>
70
+ </Table>
71
+ {/if}
72
+ </section>
@@ -0,0 +1,12 @@
1
+ import { type AuditEntry } from '@protoyard/ai';
2
+ type $$ComponentProps = {
3
+ entries: readonly AuditEntry[];
4
+ emptyTitle?: string;
5
+ emptyDescription?: string;
6
+ actorName?: (entry: AuditEntry) => string;
7
+ formatWhen?: (iso: string) => string;
8
+ class?: string;
9
+ };
10
+ declare const AuditTrail: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type AuditTrail = ReturnType<typeof AuditTrail>;
12
+ export default AuditTrail;
@@ -0,0 +1,100 @@
1
+ <script lang="ts">
2
+ import {
3
+ AUTONOMY_LEVELS,
4
+ type AutonomyConfig,
5
+ type AutonomyLevel,
6
+ CAP_REASON,
7
+ setLevel,
8
+ } from '@protoyard/ai';
9
+ import { Radio, RadioGroup } from 'texuli/choice';
10
+ import { AUTONOMY_LEVEL_LABELS, areaLabel } from './labels.js';
11
+
12
+ /**
13
+ * Per-area autonomy level radios (Texuli's card radio group). Calls `onChange`
14
+ * with the next config from `setLevel` (pure). Caps disable higher options.
15
+ * Simulated product settings.
16
+ */
17
+ let {
18
+ config,
19
+ area,
20
+ legend = 'Autonomy level',
21
+ disabled = false,
22
+ onChange,
23
+ class: className = '',
24
+ }: {
25
+ config: AutonomyConfig;
26
+ area: string;
27
+ legend?: string;
28
+ disabled?: boolean;
29
+ /** Required: receive the next AutonomyConfig after a level pick. */
30
+ onChange: (next: AutonomyConfig, level: AutonomyLevel) => unknown | Promise<unknown>;
31
+ class?: string;
32
+ } = $props();
33
+
34
+ let pending = $state(false);
35
+ let error = $state('');
36
+ const current = $derived(config.levels[area] ?? 1);
37
+ const cap = $derived(config.caps[area]);
38
+
39
+ async function pick(level: AutonomyLevel): Promise<void> {
40
+ if (disabled || pending || level === current) return;
41
+ pending = true;
42
+ error = '';
43
+ try {
44
+ const next = setLevel(config, area, level);
45
+ await Promise.resolve(onChange(next, level));
46
+ } catch (err) {
47
+ error = err instanceof Error ? err.message : 'Could not update autonomy.';
48
+ } finally {
49
+ pending = false;
50
+ }
51
+ }
52
+ </script>
53
+
54
+ <fieldset
55
+ class="m-0 grid gap-2 border-0 p-0 {className}"
56
+ data-py-autonomy-picker
57
+ data-area={area}
58
+ disabled={disabled || pending}
59
+ >
60
+ <legend class="mb-1 px-0 text-sm font-semibold text-foreground">
61
+ {legend}
62
+ <span class="font-normal text-muted-foreground"> · {areaLabel(area)}</span>
63
+ </legend>
64
+ <p class="m-0 text-xs text-muted-foreground">
65
+ Simulated settings. Levels are not security boundaries.
66
+ </p>
67
+ <RadioGroup
68
+ class="grid gap-2 sm:grid-cols-2"
69
+ variant="card"
70
+ name="autonomy-{area}"
71
+ value={String(current)}
72
+ disabled={disabled || pending}
73
+ aria-label="{legend} for {areaLabel(area)}"
74
+ onValueChange={(value) => void pick(Number(value) as AutonomyLevel)}
75
+ >
76
+ {#each AUTONOMY_LEVELS as level (level)}
77
+ {@const meta = AUTONOMY_LEVEL_LABELS[level]}
78
+ {@const capped = cap !== undefined && level > cap}
79
+ <Radio
80
+ card
81
+ value={String(level)}
82
+ label={`Level ${level}: ${meta.name}`}
83
+ description={meta.definition}
84
+ disabled={capped || disabled || pending}
85
+ >
86
+ {#if capped}
87
+ <span class="text-xs text-muted-foreground"
88
+ >{area === 'external' ? CAP_REASON : `Capped at level ${String(cap)}.`}</span
89
+ >
90
+ {/if}
91
+ </Radio>
92
+ {/each}
93
+ </RadioGroup>
94
+ {#if pending}
95
+ <p class="m-0 text-xs text-muted-foreground" role="status" aria-live="polite">Saving…</p>
96
+ {/if}
97
+ {#if error}
98
+ <p class="m-0 text-sm text-destructive" role="alert">{error}</p>
99
+ {/if}
100
+ </fieldset>
@@ -0,0 +1,13 @@
1
+ import { type AutonomyConfig, type AutonomyLevel } from '@protoyard/ai';
2
+ type $$ComponentProps = {
3
+ config: AutonomyConfig;
4
+ area: string;
5
+ legend?: string;
6
+ disabled?: boolean;
7
+ /** Required: receive the next AutonomyConfig after a level pick. */
8
+ onChange: (next: AutonomyConfig, level: AutonomyLevel) => unknown | Promise<unknown>;
9
+ class?: string;
10
+ };
11
+ declare const AutonomyLevelPicker: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type AutonomyLevelPicker = ReturnType<typeof AutonomyLevelPicker>;
13
+ export default AutonomyLevelPicker;
@@ -0,0 +1,118 @@
1
+ <script lang="ts">
2
+ import { type ApprovalActor, mayDecide, type Suggestion } from '@protoyard/ai';
3
+ import { Button } from 'texuli/button';
4
+ import { Card } from 'texuli/card';
5
+ import { modeNeedsApproval } from './labels.js';
6
+
7
+ /**
8
+ * Compact chat-row approval for a pending suggestion. Same decision callbacks
9
+ * as SuggestionCard; designed to sit inside a message list. Texuli card and
10
+ * buttons; a decision shows its pending state on its own button.
11
+ */
12
+ let {
13
+ suggestion,
14
+ viewer,
15
+ onConfirm,
16
+ onApprove,
17
+ onDismiss,
18
+ class: className = '',
19
+ }: {
20
+ suggestion: Suggestion;
21
+ viewer: ApprovalActor;
22
+ onConfirm?: (suggestion: Suggestion) => unknown | Promise<unknown>;
23
+ onApprove?: (suggestion: Suggestion) => unknown | Promise<unknown>;
24
+ onDismiss?: (suggestion: Suggestion) => unknown | Promise<unknown>;
25
+ class?: string;
26
+ } = $props();
27
+
28
+ let pending = $state<'confirm' | 'approve' | 'dismiss' | null>(null);
29
+ let error = $state('');
30
+
31
+ const needsApproval = $derived(modeNeedsApproval(suggestion.mode));
32
+ const waiting = $derived(suggestion.status === 'pending');
33
+
34
+ async function run(
35
+ kind: 'confirm' | 'approve' | 'dismiss',
36
+ work: () => unknown | Promise<unknown>
37
+ ): Promise<void> {
38
+ if (pending) return;
39
+ pending = kind;
40
+ error = '';
41
+ try {
42
+ await Promise.resolve(work());
43
+ } catch (err) {
44
+ error = err instanceof Error ? err.message : 'The action could not be completed.';
45
+ } finally {
46
+ pending = null;
47
+ }
48
+ }
49
+ </script>
50
+
51
+ <Card
52
+ class="grid gap-2 p-3 text-sm {className}"
53
+ data-py-chat-approval
54
+ data-status={suggestion.status}
55
+ role="group"
56
+ aria-label="Simulated approval request"
57
+ >
58
+ <p class="m-0 font-medium text-foreground">{suggestion.summary}</p>
59
+ {#if suggestion.preparedApplySummary}
60
+ <p class="m-0 text-xs text-muted-foreground">
61
+ Prepared: {suggestion.preparedApplySummary}
62
+ </p>
63
+ {/if}
64
+ <p class="m-0 text-[0.7rem] text-muted-foreground">Simulated approval · not security</p>
65
+
66
+ {#if waiting}
67
+ <div class="flex flex-wrap gap-2" aria-label="Approval actions">
68
+ {#if needsApproval && mayDecide(viewer, suggestion, 'approve') && onApprove}
69
+ <Button
70
+ size="sm"
71
+ variant="default"
72
+ autoLoading={false}
73
+ loading={pending === 'approve'}
74
+ disabled={pending !== null}
75
+ onclick={() => run('approve', () => onApprove(suggestion))}
76
+ >
77
+ Approve
78
+ </Button>
79
+ {:else if !needsApproval && mayDecide(viewer, suggestion, 'confirm') && onConfirm}
80
+ <Button
81
+ size="sm"
82
+ variant="default"
83
+ autoLoading={false}
84
+ loading={pending === 'confirm'}
85
+ disabled={pending !== null}
86
+ onclick={() => run('confirm', () => onConfirm(suggestion))}
87
+ >
88
+ Confirm
89
+ </Button>
90
+ {:else if needsApproval && !viewer.canApprove}
91
+ <span class="text-xs text-muted-foreground" role="status">Waiting for an approver</span>
92
+ {/if}
93
+ {#if mayDecide(viewer, suggestion, 'dismiss') && onDismiss}
94
+ <Button
95
+ size="sm"
96
+ variant="outline"
97
+ autoLoading={false}
98
+ loading={pending === 'dismiss'}
99
+ disabled={pending !== null}
100
+ onclick={() => run('dismiss', () => onDismiss(suggestion))}
101
+ >
102
+ Dismiss
103
+ </Button>
104
+ {/if}
105
+ </div>
106
+ {:else}
107
+ <p class="m-0 text-xs text-muted-foreground" role="status">
108
+ {suggestion.status === 'confirmed' || suggestion.status === 'auto'
109
+ ? 'Decided'
110
+ : suggestion.status === 'dismissed'
111
+ ? 'Dismissed'
112
+ : suggestion.status}
113
+ </p>
114
+ {/if}
115
+ {#if error}
116
+ <p class="m-0 text-xs text-destructive" role="alert">{error}</p>
117
+ {/if}
118
+ </Card>
@@ -0,0 +1,12 @@
1
+ import { type ApprovalActor, type Suggestion } from '@protoyard/ai';
2
+ type $$ComponentProps = {
3
+ suggestion: Suggestion;
4
+ viewer: ApprovalActor;
5
+ onConfirm?: (suggestion: Suggestion) => unknown | Promise<unknown>;
6
+ onApprove?: (suggestion: Suggestion) => unknown | Promise<unknown>;
7
+ onDismiss?: (suggestion: Suggestion) => unknown | Promise<unknown>;
8
+ class?: string;
9
+ };
10
+ declare const ChatApprovalCard: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type ChatApprovalCard = ReturnType<typeof ChatApprovalCard>;
12
+ export default ChatApprovalCard;