@artkit-ai/react 0.0.0-stage → 0.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 (118) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +145 -2
  3. package/dist/cache.d.ts +35 -0
  4. package/dist/cache.d.ts.map +1 -0
  5. package/dist/cache.js +116 -0
  6. package/dist/cache.js.map +1 -0
  7. package/dist/client.d.ts +79 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +260 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/components/approval.d.ts +34 -0
  12. package/dist/components/approval.d.ts.map +1 -0
  13. package/dist/components/approval.js +80 -0
  14. package/dist/components/approval.js.map +1 -0
  15. package/dist/components/card.d.ts +22 -0
  16. package/dist/components/card.d.ts.map +1 -0
  17. package/dist/components/card.js +16 -0
  18. package/dist/components/card.js.map +1 -0
  19. package/dist/components/editor.d.ts +28 -0
  20. package/dist/components/editor.d.ts.map +1 -0
  21. package/dist/components/editor.js +161 -0
  22. package/dist/components/editor.js.map +1 -0
  23. package/dist/components/icons.d.ts +8 -0
  24. package/dist/components/icons.d.ts.map +1 -0
  25. package/dist/components/icons.js +14 -0
  26. package/dist/components/icons.js.map +1 -0
  27. package/dist/components/panel.d.ts +34 -0
  28. package/dist/components/panel.d.ts.map +1 -0
  29. package/dist/components/panel.js +216 -0
  30. package/dist/components/panel.js.map +1 -0
  31. package/dist/components/shared.d.ts +71 -0
  32. package/dist/components/shared.d.ts.map +1 -0
  33. package/dist/components/shared.js +104 -0
  34. package/dist/components/shared.js.map +1 -0
  35. package/dist/components/version-diff.d.ts +35 -0
  36. package/dist/components/version-diff.d.ts.map +1 -0
  37. package/dist/components/version-diff.js +85 -0
  38. package/dist/components/version-diff.js.map +1 -0
  39. package/dist/components/view.d.ts +40 -0
  40. package/dist/components/view.d.ts.map +1 -0
  41. package/dist/components/view.js +75 -0
  42. package/dist/components/view.js.map +1 -0
  43. package/dist/context.d.ts +59 -0
  44. package/dist/context.d.ts.map +1 -0
  45. package/dist/context.js +13 -0
  46. package/dist/context.js.map +1 -0
  47. package/dist/controls.d.ts +145 -0
  48. package/dist/controls.d.ts.map +1 -0
  49. package/dist/controls.js +188 -0
  50. package/dist/controls.js.map +1 -0
  51. package/dist/download.d.ts +4 -0
  52. package/dist/download.d.ts.map +1 -0
  53. package/dist/download.js +20 -0
  54. package/dist/download.js.map +1 -0
  55. package/dist/editor-session.d.ts +184 -0
  56. package/dist/editor-session.d.ts.map +1 -0
  57. package/dist/editor-session.js +614 -0
  58. package/dist/editor-session.js.map +1 -0
  59. package/dist/hooks.d.ts +85 -0
  60. package/dist/hooks.d.ts.map +1 -0
  61. package/dist/hooks.js +297 -0
  62. package/dist/hooks.js.map +1 -0
  63. package/dist/i18n.d.ts +197 -0
  64. package/dist/i18n.d.ts.map +1 -0
  65. package/dist/i18n.js +207 -0
  66. package/dist/i18n.js.map +1 -0
  67. package/dist/index.d.ts +22 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +22 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/kit-css.d.ts +3 -0
  72. package/dist/kit-css.d.ts.map +1 -0
  73. package/dist/kit-css.js +5 -0
  74. package/dist/kit-css.js.map +1 -0
  75. package/dist/line-diff.d.ts +25 -0
  76. package/dist/line-diff.d.ts.map +1 -0
  77. package/dist/line-diff.js +96 -0
  78. package/dist/line-diff.js.map +1 -0
  79. package/dist/partial-json.d.ts +8 -0
  80. package/dist/partial-json.d.ts.map +1 -0
  81. package/dist/partial-json.js +176 -0
  82. package/dist/partial-json.js.map +1 -0
  83. package/dist/provider.d.ts +42 -0
  84. package/dist/provider.d.ts.map +1 -0
  85. package/dist/provider.js +95 -0
  86. package/dist/provider.js.map +1 -0
  87. package/dist/styles.d.ts +25 -0
  88. package/dist/styles.d.ts.map +1 -0
  89. package/dist/styles.js +203 -0
  90. package/dist/styles.js.map +1 -0
  91. package/dist/toolbar.d.ts +90 -0
  92. package/dist/toolbar.d.ts.map +1 -0
  93. package/dist/toolbar.js +233 -0
  94. package/dist/toolbar.js.map +1 -0
  95. package/package.json +50 -3
  96. package/src/cache.ts +132 -0
  97. package/src/client.ts +390 -0
  98. package/src/components/approval.tsx +219 -0
  99. package/src/components/card.tsx +87 -0
  100. package/src/components/editor.tsx +392 -0
  101. package/src/components/icons.tsx +14 -0
  102. package/src/components/panel.tsx +591 -0
  103. package/src/components/shared.tsx +207 -0
  104. package/src/components/version-diff.tsx +266 -0
  105. package/src/components/view.tsx +199 -0
  106. package/src/context.ts +82 -0
  107. package/src/controls.tsx +587 -0
  108. package/src/download.ts +23 -0
  109. package/src/editor-session.ts +738 -0
  110. package/src/hooks.ts +411 -0
  111. package/src/i18n.ts +217 -0
  112. package/src/index.ts +160 -0
  113. package/src/kit-css.ts +5 -0
  114. package/src/line-diff.ts +111 -0
  115. package/src/partial-json.ts +160 -0
  116. package/src/provider.tsx +176 -0
  117. package/src/styles.ts +222 -0
  118. package/src/toolbar.tsx +536 -0
package/src/client.ts ADDED
@@ -0,0 +1,390 @@
1
+ import {
2
+ type Artifact,
3
+ ArtifactError,
4
+ type ArtifactFilter,
5
+ type ArtifactService,
6
+ type ArtifactSummary,
7
+ type BatchResult,
8
+ type CommitResult,
9
+ type CreateRequest,
10
+ ERROR_CODES,
11
+ type ErrorCode,
12
+ type ErrorIssue,
13
+ isArtifactError,
14
+ type Lock,
15
+ type Page,
16
+ type PageRequest,
17
+ type ProjectionMode,
18
+ type ReadResult,
19
+ type Scope,
20
+ type VersionEntry,
21
+ } from '@artkit-ai/core';
22
+
23
+ /** Lifecycle actions accepted by `POST {base}/{id}/status` (spec §15.3). */
24
+ export type StatusAction = 'submit' | 'approve' | 'reject' | 'publish' | 'archive';
25
+
26
+ export const STATUS_ACTIONS: readonly StatusAction[] = ['submit', 'approve', 'reject', 'publish', 'archive'];
27
+
28
+ export interface RequestOptions {
29
+ signal?: AbortSignal;
30
+ }
31
+
32
+ export interface ReadOptions extends RequestOptions {
33
+ mode?: ProjectionMode;
34
+ range?: string;
35
+ maxTokens?: number;
36
+ cursor?: string;
37
+ version?: number;
38
+ }
39
+
40
+ export interface ApplyInput {
41
+ baseVersion: number;
42
+ operations: unknown[];
43
+ idempotencyKey?: string;
44
+ message?: string;
45
+ }
46
+
47
+ export interface ExportInput extends RequestOptions {
48
+ format: string;
49
+ version?: number;
50
+ options?: Record<string, unknown>;
51
+ }
52
+
53
+ export interface ClientExportResult {
54
+ bytes: Uint8Array;
55
+ mimeType: string;
56
+ filename: string;
57
+ warnings: string[];
58
+ }
59
+
60
+ /**
61
+ * What the React layer talks to (spec §13.3). It never touches a store: credentials and authorization
62
+ * stay on the server (HTTP client) or in the scope given to an in-process service (service client).
63
+ */
64
+ export interface ArtifactClient {
65
+ list(filter?: ArtifactFilter, page?: PageRequest, options?: RequestOptions): Promise<Page<ArtifactSummary>>;
66
+ create(request: CreateRequest, options?: RequestOptions): Promise<CommitResult>;
67
+ get(id: string, options?: RequestOptions & { version?: number }): Promise<Artifact>;
68
+ read(id: string, options?: ReadOptions): Promise<ReadResult>;
69
+ apply(id: string, input: ApplyInput, options?: RequestOptions): Promise<CommitResult>;
70
+ /** Dry run on the current version: diff and lint without committing. */
71
+ preview(id: string, input: ApplyInput, options?: RequestOptions): Promise<BatchResult>;
72
+ history(id: string, page?: PageRequest, options?: RequestOptions): Promise<Page<VersionEntry>>;
73
+ restore(id: string, version: number, message?: string, options?: RequestOptions): Promise<CommitResult>;
74
+ setStatus(id: string, action: StatusAction, message?: string, options?: RequestOptions): Promise<Artifact>;
75
+ lock(id: string, options?: RequestOptions & { ttlMs?: number }): Promise<Lock>;
76
+ unlock(id: string, lock: Lock, options?: RequestOptions): Promise<void>;
77
+ /** Current lock, or null when nobody holds one. */
78
+ getLock(id: string, options?: RequestOptions): Promise<Lock | null>;
79
+ export(id: string, input: ExportInput): Promise<ClientExportResult>;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------------------------------
83
+ // HTTP client
84
+ // ---------------------------------------------------------------------------------------------------
85
+
86
+ type HeadersSource = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
87
+
88
+ export interface HttpClientOptions {
89
+ /** Base path of the `@artkit-ai/node` routes (default `/api/artifacts`). */
90
+ baseUrl?: string;
91
+ /** Custom fetch (auth wrappers, tests, SSR). Defaults to the global `fetch`. */
92
+ fetch?: typeof fetch;
93
+ /** Extra headers, or a function returning them per request (e.g. a fresh bearer token). */
94
+ headers?: HeadersSource;
95
+ credentials?: RequestCredentials;
96
+ }
97
+
98
+ const STATUS_TO_CODE: Record<number, ErrorCode> = {
99
+ 404: 'NOT_FOUND',
100
+ 403: 'PERMISSION_DENIED',
101
+ 409: 'VERSION_CONFLICT',
102
+ 423: 'LOCKED',
103
+ 413: 'LIMIT_EXCEEDED',
104
+ 422: 'INVALID_CONTENT',
105
+ };
106
+
107
+ function isErrorCode(value: unknown): value is ErrorCode {
108
+ return typeof value === 'string' && (ERROR_CODES as readonly string[]).includes(value);
109
+ }
110
+
111
+ function toIssues(value: unknown): ErrorIssue[] | undefined {
112
+ if (!Array.isArray(value)) return undefined;
113
+ const issues: ErrorIssue[] = [];
114
+ for (const item of value) {
115
+ if (item === null || typeof item !== 'object') continue;
116
+ const { path, message, hint } = item as Record<string, unknown>;
117
+ if (typeof message !== 'string') continue;
118
+ issues.push({
119
+ path: typeof path === 'string' ? path : '',
120
+ message,
121
+ ...(typeof hint === 'string' ? { hint } : {}),
122
+ });
123
+ }
124
+ return issues;
125
+ }
126
+
127
+ /** Rebuilds an `ArtifactError` from an HTTP error response (`ModelErrorPayload` body, spec CONVENTIONS). */
128
+ export async function errorFromResponse(response: Response): Promise<ArtifactError> {
129
+ let body: unknown;
130
+ try {
131
+ body = await response.json();
132
+ } catch {
133
+ body = undefined;
134
+ }
135
+ const error = (body as { error?: Record<string, unknown> } | undefined)?.error;
136
+ const fallbackCode: ErrorCode = STATUS_TO_CODE[response.status] ?? 'INTERNAL_ERROR';
137
+ if (error && typeof error === 'object' && typeof error.message === 'string') {
138
+ const issues = toIssues(error.issues);
139
+ const details =
140
+ error.details && typeof error.details === 'object' && !Array.isArray(error.details)
141
+ ? (error.details as Record<string, unknown>)
142
+ : undefined;
143
+ return new ArtifactError(isErrorCode(error.code) ? error.code : fallbackCode, error.message, {
144
+ ...(issues ? { issues } : {}),
145
+ details: { ...details, status: response.status },
146
+ });
147
+ }
148
+ return new ArtifactError(fallbackCode, `Request failed with status ${response.status}.`, {
149
+ details: { status: response.status },
150
+ });
151
+ }
152
+
153
+ /** Parses `Content-Disposition`, preferring the RFC 5987 `filename*` form. */
154
+ export function filenameFromDisposition(header: string | null): string | undefined {
155
+ if (!header) return undefined;
156
+ const extended = /filename\*\s*=\s*(?:UTF-8|utf-8)?''([^;]+)/.exec(header);
157
+ if (extended?.[1]) {
158
+ try {
159
+ return sanitizeFilename(decodeURIComponent(extended[1].trim().replace(/^"|"$/g, '')));
160
+ } catch {
161
+ // Fall through to the plain form when the encoded name is malformed.
162
+ }
163
+ }
164
+ const plain = /filename\s*=\s*("([^"]*)"|[^;]+)/.exec(header);
165
+ const value = plain?.[2] ?? plain?.[1];
166
+ return value ? sanitizeFilename(value.trim()) : undefined;
167
+ }
168
+
169
+ // The name ends up in a download attribute: strip any path a hostile server could smuggle in.
170
+ function sanitizeFilename(name: string): string | undefined {
171
+ const base = name.split(/[\\/]/).pop()?.trim();
172
+ return base ? base : undefined;
173
+ }
174
+
175
+ function parseWarnings(header: string | null): string[] {
176
+ if (!header) return [];
177
+ try {
178
+ const parsed: unknown = JSON.parse(decodeURIComponent(header));
179
+ return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
180
+ } catch {
181
+ return [];
182
+ }
183
+ }
184
+
185
+ function isAbortError(error: unknown): boolean {
186
+ return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'AbortError';
187
+ }
188
+
189
+ /** Reference client for the `@artkit-ai/node` routes (HTTP contract in docs/CONVENTIONS.md). */
190
+ export function createHttpClient(options: HttpClientOptions = {}): ArtifactClient {
191
+ const base = (options.baseUrl ?? '/api/artifacts').replace(/\/+$/, '');
192
+ const doFetch =
193
+ options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init));
194
+
195
+ const path = (id?: string, suffix?: string) =>
196
+ `${base}${id === undefined ? '' : `/${encodeURIComponent(id)}`}${suffix ? `/${suffix}` : ''}`;
197
+
198
+ function withQuery(url: string, query: Record<string, string | number | undefined>): string {
199
+ const params = new URLSearchParams();
200
+ for (const [key, value] of Object.entries(query))
201
+ if (value !== undefined && value !== '') params.set(key, String(value));
202
+ const text = params.toString();
203
+ return text ? `${url}?${text}` : url;
204
+ }
205
+
206
+ async function send(method: string, url: string, body?: unknown, signal?: AbortSignal): Promise<Response> {
207
+ const headers = new Headers(
208
+ typeof options.headers === 'function' ? await options.headers() : options.headers,
209
+ );
210
+ if (body !== undefined) headers.set('Content-Type', 'application/json');
211
+ if (!headers.has('Accept')) headers.set('Accept', 'application/json');
212
+ let response: Response;
213
+ try {
214
+ response = await doFetch(url, {
215
+ method,
216
+ headers,
217
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
218
+ ...(signal ? { signal } : {}),
219
+ ...(options.credentials ? { credentials: options.credentials } : {}),
220
+ });
221
+ } catch (cause) {
222
+ if (isAbortError(cause)) throw cause;
223
+ throw new ArtifactError('INTERNAL_ERROR', 'Network request failed.', {
224
+ cause,
225
+ details: { network: true },
226
+ });
227
+ }
228
+ if (!response.ok) throw await errorFromResponse(response);
229
+ return response;
230
+ }
231
+
232
+ async function json<T>(method: string, url: string, body?: unknown, signal?: AbortSignal): Promise<T> {
233
+ const response = await send(method, url, body, signal);
234
+ if (response.status === 204) return undefined as T;
235
+ try {
236
+ return (await response.json()) as T;
237
+ } catch (cause) {
238
+ throw new ArtifactError('INTERNAL_ERROR', 'Invalid JSON response.', {
239
+ cause,
240
+ details: { status: response.status },
241
+ });
242
+ }
243
+ }
244
+
245
+ return {
246
+ list: (filter = {}, page = {}, opts) =>
247
+ json(
248
+ 'GET',
249
+ withQuery(path(), { ...filter, limit: page.limit, cursor: page.cursor }),
250
+ undefined,
251
+ opts?.signal,
252
+ ),
253
+ create: (request, opts) => json('POST', path(), request, opts?.signal),
254
+ get: (id, opts) => json('GET', withQuery(path(id), { version: opts?.version }), undefined, opts?.signal),
255
+ read: (id, opts = {}) =>
256
+ json(
257
+ 'GET',
258
+ withQuery(path(id, 'read'), {
259
+ mode: opts.mode,
260
+ range: opts.range,
261
+ maxTokens: opts.maxTokens,
262
+ cursor: opts.cursor,
263
+ version: opts.version,
264
+ }),
265
+ undefined,
266
+ opts.signal,
267
+ ),
268
+ apply: (id, input, opts) => json('POST', path(id, 'apply'), input, opts?.signal),
269
+ preview: (id, input, opts) => json('POST', path(id, 'preview'), input, opts?.signal),
270
+ history: (id, page = {}, opts) =>
271
+ json(
272
+ 'GET',
273
+ withQuery(path(id, 'history'), { limit: page.limit, cursor: page.cursor }),
274
+ undefined,
275
+ opts?.signal,
276
+ ),
277
+ restore: (id, version, message, opts) =>
278
+ json('POST', path(id, 'restore'), { version, ...(message ? { message } : {}) }, opts?.signal),
279
+ setStatus: (id, action, message, opts) =>
280
+ json('POST', path(id, 'status'), { action, ...(message ? { message } : {}) }, opts?.signal),
281
+ lock: (id, opts) =>
282
+ json('POST', path(id, 'lock'), opts?.ttlMs === undefined ? {} : { ttlMs: opts.ttlMs }, opts?.signal),
283
+ async unlock(id, lock, opts) {
284
+ await send('DELETE', path(id, 'lock'), lock, opts?.signal);
285
+ },
286
+ getLock: (id, opts) => json('GET', path(id, 'lock'), undefined, opts?.signal),
287
+ async export(id, input) {
288
+ const response = await send(
289
+ 'POST',
290
+ path(id, 'export'),
291
+ {
292
+ format: input.format,
293
+ ...(input.version === undefined ? {} : { version: input.version }),
294
+ ...(input.options ? { options: input.options } : {}),
295
+ },
296
+ input.signal,
297
+ );
298
+ const bytes = new Uint8Array(await response.arrayBuffer());
299
+ const mimeType =
300
+ response.headers.get('Content-Type')?.split(';')[0]?.trim() || 'application/octet-stream';
301
+ return {
302
+ bytes,
303
+ mimeType,
304
+ filename:
305
+ filenameFromDisposition(response.headers.get('Content-Disposition')) ?? `${id}.${input.format}`,
306
+ warnings: parseWarnings(response.headers.get('X-Artkit-Warnings')),
307
+ };
308
+ },
309
+ };
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------------------------------
313
+ // In-process client
314
+ // ---------------------------------------------------------------------------------------------------
315
+
316
+ function normalizeError(error: unknown): unknown {
317
+ if (isArtifactError(error) || isAbortError(error)) return error;
318
+ return new ArtifactError('INTERNAL_ERROR', 'Internal error while processing the request.', {
319
+ cause: error,
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Wraps a core `ArtifactService` in-process (playground, tests, local apps). The scope is fixed by the
325
+ * application, never by the UI; pass a function to resolve it per call (e.g. after a login change).
326
+ */
327
+ export function createServiceClient(service: ArtifactService, scope: Scope | (() => Scope)): ArtifactClient {
328
+ const current = () => (typeof scope === 'function' ? scope() : scope);
329
+ const run = async <T>(fn: (s: Scope) => Promise<T>): Promise<T> => {
330
+ try {
331
+ return await fn(current());
332
+ } catch (error) {
333
+ throw normalizeError(error);
334
+ }
335
+ };
336
+
337
+ return {
338
+ list: (filter, page) => run((s) => service.list(s, filter, page)),
339
+ create: (request) => run((s) => service.create(s, request)),
340
+ get: (id, opts) =>
341
+ run((s) => service.get(s, id, opts?.version === undefined ? undefined : { version: opts.version })),
342
+ read: (id, opts = {}) =>
343
+ run((s) => {
344
+ const { signal: _signal, ...rest } = opts;
345
+ return service.read(s, id, rest);
346
+ }),
347
+ apply: (id, input) => run((s) => service.apply(s, { artifactId: id, ...input })),
348
+ preview: (id, input) => run((s) => service.preview(s, { artifactId: id, ...input })),
349
+ history: (id, page) => run((s) => service.history(s, id, page)),
350
+ restore: (id, version, message) => run((s) => service.restore(s, id, version, message)),
351
+ setStatus: (id, action, message) =>
352
+ run((s) => {
353
+ switch (action) {
354
+ case 'submit':
355
+ return service.submit(s, id, message);
356
+ case 'approve':
357
+ return service.approve(s, id, message);
358
+ case 'reject':
359
+ return service.reject(s, id, message);
360
+ case 'publish':
361
+ return service.publish(s, id);
362
+ case 'archive':
363
+ return service.archive(s, id);
364
+ default:
365
+ throw new ArtifactError('INVALID_OPERATION', `Unknown status action "${String(action)}".`, {
366
+ issues: [{ path: 'action', message: `Use one of: ${STATUS_ACTIONS.join(', ')}.` }],
367
+ });
368
+ }
369
+ }),
370
+ lock: (id, opts) => run((s) => service.lock(s, id, opts?.ttlMs)),
371
+ unlock: (_id, lock) => run((s) => service.unlock(s, lock)),
372
+ getLock: (id) => run((s) => service.getLock(s, id)),
373
+ export: (id, input) =>
374
+ run(async (s) => {
375
+ const result = await service.export(s, {
376
+ artifactId: id,
377
+ format: input.format,
378
+ ...(input.version === undefined ? {} : { version: input.version }),
379
+ ...(input.options ? { options: input.options } : {}),
380
+ ...(input.signal ? { signal: input.signal } : {}),
381
+ });
382
+ return {
383
+ bytes: result.bytes,
384
+ mimeType: result.mimeType,
385
+ filename: result.filename,
386
+ warnings: result.warnings,
387
+ };
388
+ }),
389
+ };
390
+ }
@@ -0,0 +1,219 @@
1
+ import type { Artifact } from '@artkit-ai/core';
2
+ import { Check, X } from 'lucide-react';
3
+ import { type CSSProperties, useId, useState } from 'react';
4
+ import { useArtkit } from '../context.js';
5
+ import { useArtifact } from '../hooks.js';
6
+ import {
7
+ cx,
8
+ ErrorMessage,
9
+ Loading,
10
+ rootAttributes,
11
+ type Slots,
12
+ StatusBadge,
13
+ slot,
14
+ useAnnouncer,
15
+ } from './shared.js';
16
+ import { ArtifactVersionDiff } from './version-diff.js';
17
+
18
+ export type ArtifactApprovalPart = 'header' | 'diff' | 'actions' | 'reason';
19
+
20
+ export interface ArtifactApprovalProps {
21
+ id?: string;
22
+ artifact?: Artifact;
23
+ /** Require a reason to reject (default true). */
24
+ requireReason?: boolean;
25
+ onApproved?(artifact: Artifact): void;
26
+ onRejected?(artifact: Artifact, reason: string): void;
27
+ className?: string;
28
+ slots?: Slots<ArtifactApprovalPart>;
29
+ }
30
+
31
+ /**
32
+ * Review of the version waiting for approval (spec §13.1, §15.3): diff against the last published
33
+ * version (`meta.publishedVersion`) or the previous one, plus approve and reject with a reason.
34
+ */
35
+ export function ArtifactApproval(props: ArtifactApprovalProps) {
36
+ const ctx = useArtkit();
37
+ const { t, client, cache } = ctx;
38
+ const id = props.id ?? props.artifact?.id;
39
+ const query = useArtifact(id, props.artifact ? { initial: props.artifact } : {});
40
+ const artifact = query.artifact;
41
+ const [busy, setBusy] = useState<'approve' | 'reject' | undefined>(undefined);
42
+ const [error, setError] = useState<unknown>(undefined);
43
+ const [rejecting, setRejecting] = useState(false);
44
+ const [reason, setReason] = useState('');
45
+ const [live, announce] = useAnnouncer();
46
+ const requireReason = props.requireReason ?? true;
47
+
48
+ if (!artifact) {
49
+ return (
50
+ <div className={cx('ak-root ak-approval', props.className)} {...rootAttributes(ctx)}>
51
+ {query.error ? <ErrorMessage error={query.error} /> : <Loading />}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ const baseVersion =
57
+ artifact.meta.publishedVersion !== undefined && artifact.meta.publishedVersion < artifact.version
58
+ ? artifact.meta.publishedVersion
59
+ : artifact.version > 1
60
+ ? artifact.version - 1
61
+ : undefined;
62
+ const pending = artifact.status === 'pending_approval';
63
+
64
+ async function decide(action: 'approve' | 'reject') {
65
+ if (!artifact) return;
66
+ const trimmed = reason.trim();
67
+ if (action === 'reject' && requireReason && !trimmed) return;
68
+ setBusy(action);
69
+ setError(undefined);
70
+ try {
71
+ const updated = await client.setStatus(
72
+ artifact.id,
73
+ action,
74
+ action === 'reject' ? trimmed || undefined : undefined,
75
+ );
76
+ cache.put(updated);
77
+ if (action === 'approve') {
78
+ announce(t('approved', { version: artifact.version }));
79
+ props.onApproved?.(updated);
80
+ } else {
81
+ announce(t('rejected', { version: artifact.version }));
82
+ setRejecting(false);
83
+ setReason('');
84
+ props.onRejected?.(updated, trimmed);
85
+ }
86
+ } catch (caught) {
87
+ setError(caught);
88
+ } finally {
89
+ setBusy(undefined);
90
+ }
91
+ }
92
+
93
+ return (
94
+ <section
95
+ className={cx('ak-root ak-approval', props.className)}
96
+ {...rootAttributes(ctx, artifact.themeId)}
97
+ aria-label={t('approvalTitle', { version: artifact.version })}
98
+ >
99
+ {live}
100
+ <header {...slot(props.slots, 'header', 'ak-panel-header')}>
101
+ <h2 className="ak-panel-title">{artifact.title}</h2>
102
+ <StatusBadge status={artifact.status} />
103
+ <span className="ak-history-meta">
104
+ {t('approvalTitle', { version: artifact.version })}
105
+ {' · '}
106
+ {baseVersion === undefined
107
+ ? t('approvalFirstVersion')
108
+ : t('approvalComparedTo', { version: baseVersion })}
109
+ </span>
110
+ </header>
111
+ <div {...slot(props.slots, 'diff', '')}>
112
+ {baseVersion === undefined ? (
113
+ <ArtifactVersionDiff after={artifact} showLines={false} />
114
+ ) : (
115
+ <ArtifactVersionDiff id={artifact.id} from={baseVersion} after={artifact} />
116
+ )}
117
+ </div>
118
+ {error ? <ErrorMessage error={error} onDismiss={() => setError(undefined)} /> : null}
119
+ {pending ? (
120
+ <div {...slot(props.slots, 'actions', 'ak-approval-actions')}>
121
+ {rejecting ? (
122
+ <RejectForm
123
+ busy={busy !== undefined}
124
+ requireReason={requireReason}
125
+ reason={reason}
126
+ onReasonChange={setReason}
127
+ onSubmit={() => void decide('reject')}
128
+ onCancel={() => setRejecting(false)}
129
+ {...(props.slots?.reason ? { textareaProps: props.slots.reason } : {})}
130
+ />
131
+ ) : (
132
+ <>
133
+ <button
134
+ type="button"
135
+ className="ak-button"
136
+ data-variant="primary"
137
+ disabled={busy !== undefined}
138
+ onClick={() => void decide('approve')}
139
+ >
140
+ <Check aria-hidden="true" />
141
+ {t('action_approve')}
142
+ </button>
143
+ <button
144
+ type="button"
145
+ className="ak-button"
146
+ data-variant="danger"
147
+ disabled={busy !== undefined}
148
+ onClick={() => setRejecting(true)}
149
+ >
150
+ <X aria-hidden="true" />
151
+ {t('action_reject')}
152
+ </button>
153
+ </>
154
+ )}
155
+ </div>
156
+ ) : (
157
+ <p className="ak-notice">{t('notPending')}</p>
158
+ )}
159
+ </section>
160
+ );
161
+ }
162
+
163
+ export interface RejectFormProps {
164
+ busy: boolean;
165
+ requireReason: boolean;
166
+ reason: string;
167
+ onReasonChange(reason: string): void;
168
+ onSubmit(): void;
169
+ onCancel(): void;
170
+ textareaProps?: { className?: string; style?: CSSProperties };
171
+ }
172
+
173
+ /** Inline rejection form shared by the approval view and the panel. Escape cancels. */
174
+ export function RejectForm(props: RejectFormProps) {
175
+ const { t } = useArtkit();
176
+ const reasonId = useId();
177
+ return (
178
+ <form
179
+ className="ak-form"
180
+ onSubmit={(event) => {
181
+ event.preventDefault();
182
+ props.onSubmit();
183
+ }}
184
+ >
185
+ <label htmlFor={reasonId}>{t('rejectReason')}</label>
186
+ <textarea
187
+ id={reasonId}
188
+ className={cx('ak-textarea', props.textareaProps?.className)}
189
+ style={props.textareaProps?.style}
190
+ value={props.reason}
191
+ required={props.requireReason}
192
+ aria-describedby={`${reasonId}-help`}
193
+ onChange={(event) => props.onReasonChange(event.target.value)}
194
+ onKeyDown={(event) => {
195
+ if (event.key === 'Escape') props.onCancel();
196
+ }}
197
+ // biome-ignore lint/a11y/noAutofocus: the reason field is the only purpose of the revealed form.
198
+ autoFocus
199
+ />
200
+ <span id={`${reasonId}-help`} className="ak-history-meta">
201
+ {t('rejectReasonRequired')}
202
+ </span>
203
+ <div className="ak-approval-actions">
204
+ <button
205
+ type="submit"
206
+ className="ak-button"
207
+ data-variant="danger"
208
+ disabled={props.busy || (props.requireReason && !props.reason.trim())}
209
+ >
210
+ <X aria-hidden="true" />
211
+ {t('confirmReject')}
212
+ </button>
213
+ <button type="button" className="ak-button" onClick={props.onCancel}>
214
+ {t('cancel')}
215
+ </button>
216
+ </div>
217
+ </form>
218
+ );
219
+ }
@@ -0,0 +1,87 @@
1
+ import type { ArtifactSummary } from '@artkit-ai/core';
2
+ import { ArrowUpRight } from 'lucide-react';
3
+ import { type ReactNode, useId } from 'react';
4
+ import { useArtkit } from '../context.js';
5
+ import { KindIcon } from './icons.js';
6
+ import { cx, kindLabel, rootAttributes, type Slots, StatusBadge, slot } from './shared.js';
7
+
8
+ export type ArtifactCardPart = 'body' | 'title' | 'meta' | 'open';
9
+
10
+ export interface ArtifactCardProps {
11
+ /** Summary or full artifact (only the summary fields are used). */
12
+ artifact: Pick<ArtifactSummary, 'id' | 'kind' | 'title' | 'version' | 'status'>;
13
+ onOpen?(artifact: ArtifactCardProps['artifact']): void;
14
+ /** Render the open action as a link instead of a button. */
15
+ href?: string;
16
+ /** Shows a "generating" badge while the agent is still streaming the artifact. */
17
+ streaming?: boolean;
18
+ /** Extra actions rendered after the open action. */
19
+ actions?: ReactNode;
20
+ /** Icon or thumbnail rendered before the text. */
21
+ icon?: ReactNode;
22
+ className?: string;
23
+ slots?: Slots<ArtifactCardPart>;
24
+ }
25
+
26
+ /** Compact card for chat messages: title, kind, version, status and an open action (spec §13.1). */
27
+ export function ArtifactCard(props: ArtifactCardProps) {
28
+ const ctx = useArtkit();
29
+ const { t } = ctx;
30
+ const { artifact } = props;
31
+ const openLabel = t('openTitle', { title: artifact.title });
32
+ const titleId = useId();
33
+ return (
34
+ <article
35
+ className={cx('ak-root ak-card', props.className)}
36
+ {...rootAttributes(ctx)}
37
+ aria-labelledby={titleId}
38
+ data-kind={artifact.kind}
39
+ >
40
+ {props.icon ?? (
41
+ <span className="ak-card-icon">
42
+ <KindIcon kind={artifact.kind} />
43
+ </span>
44
+ )}
45
+ <div {...slot(props.slots, 'body', 'ak-card-body')}>
46
+ <h3 id={titleId} {...slot(props.slots, 'title', 'ak-card-title')}>
47
+ {artifact.title}
48
+ </h3>
49
+ <div {...slot(props.slots, 'meta', 'ak-card-meta')}>
50
+ <span>{kindLabel(ctx, artifact.kind)}</span>
51
+ <span>
52
+ <span className="ak-sr-only">{`${t('version')} ${artifact.version}`}</span>
53
+ <span aria-hidden="true">{t('versionShort', { version: artifact.version })}</span>
54
+ </span>
55
+ <StatusBadge status={artifact.status} />
56
+ {props.streaming ? (
57
+ <span className="ak-badge" data-variant="provisional" role="status">
58
+ {t('generating')}
59
+ </span>
60
+ ) : null}
61
+ </div>
62
+ </div>
63
+ {props.href ? (
64
+ <a
65
+ {...slot(props.slots, 'open', 'ak-button')}
66
+ href={props.href}
67
+ aria-label={openLabel}
68
+ onClick={props.onOpen ? () => props.onOpen?.(artifact) : undefined}
69
+ >
70
+ {t('open')}
71
+ <ArrowUpRight aria-hidden="true" />
72
+ </a>
73
+ ) : props.onOpen ? (
74
+ <button
75
+ type="button"
76
+ {...slot(props.slots, 'open', 'ak-button')}
77
+ aria-label={openLabel}
78
+ onClick={() => props.onOpen?.(artifact)}
79
+ >
80
+ {t('open')}
81
+ <ArrowUpRight aria-hidden="true" />
82
+ </button>
83
+ ) : null}
84
+ {props.actions}
85
+ </article>
86
+ );
87
+ }