@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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 (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -1
  3. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
  4. package/dist/ai/AiAssistantLauncher.js +596 -0
  5. package/dist/ai/AiAssistantLauncher.js.map +7 -0
  6. package/dist/ai/AiChat.js +1092 -0
  7. package/dist/ai/AiChat.js.map +7 -0
  8. package/dist/ai/AiChatSessions.js +297 -0
  9. package/dist/ai/AiChatSessions.js.map +7 -0
  10. package/dist/ai/AiDock.js +347 -0
  11. package/dist/ai/AiDock.js.map +7 -0
  12. package/dist/ai/AiMessageContent.js +369 -0
  13. package/dist/ai/AiMessageContent.js.map +7 -0
  14. package/dist/ai/ChatPaneTabs.js +251 -0
  15. package/dist/ai/ChatPaneTabs.js.map +7 -0
  16. package/dist/ai/index.js +115 -0
  17. package/dist/ai/index.js.map +7 -0
  18. package/dist/ai/parts/ConfirmationCard.js +211 -0
  19. package/dist/ai/parts/ConfirmationCard.js.map +7 -0
  20. package/dist/ai/parts/FieldDiffCard.js +119 -0
  21. package/dist/ai/parts/FieldDiffCard.js.map +7 -0
  22. package/dist/ai/parts/MutationPreviewCard.js +224 -0
  23. package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
  24. package/dist/ai/parts/MutationResultCard.js +240 -0
  25. package/dist/ai/parts/MutationResultCard.js.map +7 -0
  26. package/dist/ai/parts/approval-cards-map.js +15 -0
  27. package/dist/ai/parts/approval-cards-map.js.map +7 -0
  28. package/dist/ai/parts/index.js +24 -0
  29. package/dist/ai/parts/index.js.map +7 -0
  30. package/dist/ai/parts/pending-action-api.js +60 -0
  31. package/dist/ai/parts/pending-action-api.js.map +7 -0
  32. package/dist/ai/parts/types.js +1 -0
  33. package/dist/ai/parts/types.js.map +7 -0
  34. package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
  35. package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
  36. package/dist/ai/records/ActivityCard.js +83 -0
  37. package/dist/ai/records/ActivityCard.js.map +7 -0
  38. package/dist/ai/records/CompanyCard.js +81 -0
  39. package/dist/ai/records/CompanyCard.js.map +7 -0
  40. package/dist/ai/records/DealCard.js +76 -0
  41. package/dist/ai/records/DealCard.js.map +7 -0
  42. package/dist/ai/records/PersonCard.js +68 -0
  43. package/dist/ai/records/PersonCard.js.map +7 -0
  44. package/dist/ai/records/ProductCard.js +68 -0
  45. package/dist/ai/records/ProductCard.js.map +7 -0
  46. package/dist/ai/records/RecordCard.js +29 -0
  47. package/dist/ai/records/RecordCard.js.map +7 -0
  48. package/dist/ai/records/RecordCardShell.js +103 -0
  49. package/dist/ai/records/RecordCardShell.js.map +7 -0
  50. package/dist/ai/records/index.js +31 -0
  51. package/dist/ai/records/index.js.map +7 -0
  52. package/dist/ai/records/registry.js +51 -0
  53. package/dist/ai/records/registry.js.map +7 -0
  54. package/dist/ai/records/types.js +1 -0
  55. package/dist/ai/records/types.js.map +7 -0
  56. package/dist/ai/ui-part-registry.js +112 -0
  57. package/dist/ai/ui-part-registry.js.map +7 -0
  58. package/dist/ai/ui-part-slots.js +14 -0
  59. package/dist/ai/ui-part-slots.js.map +7 -0
  60. package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
  61. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
  62. package/dist/ai/upload-adapter.js +256 -0
  63. package/dist/ai/upload-adapter.js.map +7 -0
  64. package/dist/ai/useAiChat.js +549 -0
  65. package/dist/ai/useAiChat.js.map +7 -0
  66. package/dist/ai/useAiChatUpload.js +127 -0
  67. package/dist/ai/useAiChatUpload.js.map +7 -0
  68. package/dist/ai/useAiShortcuts.js +43 -0
  69. package/dist/ai/useAiShortcuts.js.map +7 -0
  70. package/dist/backend/AppShell.js +8 -4
  71. package/dist/backend/AppShell.js.map +2 -2
  72. package/dist/backend/BackendChromeProvider.js +2 -0
  73. package/dist/backend/BackendChromeProvider.js.map +2 -2
  74. package/dist/backend/DataTable.js +19 -2
  75. package/dist/backend/DataTable.js.map +2 -2
  76. package/dist/backend/FilterBar.js +19 -15
  77. package/dist/backend/FilterBar.js.map +2 -2
  78. package/dist/backend/dashboard/DashboardScreen.js +31 -3
  79. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  80. package/dist/backend/injection/spotIds.js +6 -0
  81. package/dist/backend/injection/spotIds.js.map +2 -2
  82. package/dist/backend/notifications/useNotificationEffect.js +38 -2
  83. package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
  84. package/dist/index.js +1 -0
  85. package/dist/index.js.map +2 -2
  86. package/jest.config.cjs +7 -1
  87. package/jest.markdown-mock.tsx +7 -0
  88. package/package.json +10 -4
  89. package/src/ai/AiAssistantLauncher.tsx +805 -0
  90. package/src/ai/AiChat.tsx +1483 -0
  91. package/src/ai/AiChatSessions.tsx +429 -0
  92. package/src/ai/AiDock.tsx +505 -0
  93. package/src/ai/AiMessageContent.tsx +515 -0
  94. package/src/ai/ChatPaneTabs.tsx +310 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +257 -0
  99. package/src/ai/__tests__/AiDock.test.tsx +124 -0
  100. package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
  101. package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
  102. package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
  103. package/src/ai/__tests__/upload-adapter.test.ts +213 -0
  104. package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
  105. package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
  106. package/src/ai/index.ts +125 -0
  107. package/src/ai/parts/ConfirmationCard.tsx +310 -0
  108. package/src/ai/parts/FieldDiffCard.tsx +173 -0
  109. package/src/ai/parts/MutationPreviewCard.tsx +302 -0
  110. package/src/ai/parts/MutationResultCard.tsx +360 -0
  111. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
  112. package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
  113. package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
  114. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
  115. package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
  116. package/src/ai/parts/approval-cards-map.ts +24 -0
  117. package/src/ai/parts/index.ts +27 -0
  118. package/src/ai/parts/pending-action-api.ts +123 -0
  119. package/src/ai/parts/types.ts +84 -0
  120. package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
  121. package/src/ai/records/ActivityCard.tsx +102 -0
  122. package/src/ai/records/CompanyCard.tsx +89 -0
  123. package/src/ai/records/DealCard.tsx +85 -0
  124. package/src/ai/records/PersonCard.tsx +77 -0
  125. package/src/ai/records/ProductCard.tsx +83 -0
  126. package/src/ai/records/RecordCard.tsx +37 -0
  127. package/src/ai/records/RecordCardShell.tsx +169 -0
  128. package/src/ai/records/index.ts +30 -0
  129. package/src/ai/records/registry.tsx +80 -0
  130. package/src/ai/records/types.ts +90 -0
  131. package/src/ai/ui-part-registry.ts +233 -0
  132. package/src/ai/ui-part-slots.ts +32 -0
  133. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
  134. package/src/ai/upload-adapter.ts +421 -0
  135. package/src/ai/useAiChat.ts +865 -0
  136. package/src/ai/useAiChatUpload.ts +180 -0
  137. package/src/ai/useAiShortcuts.ts +79 -0
  138. package/src/backend/AppShell.tsx +12 -5
  139. package/src/backend/BackendChromeProvider.tsx +2 -0
  140. package/src/backend/DataTable.tsx +20 -1
  141. package/src/backend/FilterBar.tsx +26 -13
  142. package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
  143. package/src/backend/dashboard/DashboardScreen.tsx +38 -3
  144. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
  145. package/src/backend/injection/spotIds.ts +6 -0
  146. package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
  147. package/src/backend/notifications/useNotificationEffect.ts +47 -2
  148. package/src/index.ts +1 -0
@@ -0,0 +1,173 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Info } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { Alert, AlertDescription } from '../../primitives/alert'
7
+ import type {
8
+ AiPendingActionCardFieldDiff,
9
+ AiPendingActionCardRecordDiff,
10
+ } from './types'
11
+
12
+ /**
13
+ * Presentational card rendering a `fieldDiff` list in compact three-column
14
+ * form (field | before | after) with DS-compliant semantic-token colors.
15
+ * Accepts either a flat `fieldDiff[]` (single-record preview) or grouped
16
+ * `records[]` (batch preview) — when both are supplied, `records` wins,
17
+ * matching the server-side `AiPendingAction` contract (spec §8 rule 2).
18
+ */
19
+ export interface FieldDiffCardProps {
20
+ fieldDiff?: AiPendingActionCardFieldDiff[] | null
21
+ records?: AiPendingActionCardRecordDiff[] | null
22
+ /** Optional forwarded componentId for the registry renderer. */
23
+ componentId?: string
24
+ }
25
+
26
+ function formatValue(value: unknown): string {
27
+ if (value == null) return ''
28
+ if (typeof value === 'string') return value
29
+ try {
30
+ return JSON.stringify(value)
31
+ } catch {
32
+ return String(value)
33
+ }
34
+ }
35
+
36
+ function DiffRow({ entry }: { entry: AiPendingActionCardFieldDiff }) {
37
+ const before = formatValue(entry.before)
38
+ const after = formatValue(entry.after)
39
+ return (
40
+ <tr className="border-b border-border last:border-b-0" data-ai-field-diff-row>
41
+ <td className="py-1.5 pr-4 text-xs font-mono text-muted-foreground align-top">
42
+ {entry.field}
43
+ </td>
44
+ <td
45
+ className="py-1.5 pr-4 text-sm align-top text-status-warning-text"
46
+ data-ai-field-diff-before
47
+ >
48
+ <span className="line-through break-all">{before || '—'}</span>
49
+ </td>
50
+ <td
51
+ className="py-1.5 text-sm align-top text-status-success-text"
52
+ data-ai-field-diff-after
53
+ >
54
+ <span className="font-medium break-all">{after || '—'}</span>
55
+ </td>
56
+ </tr>
57
+ )
58
+ }
59
+
60
+ function DiffTable({ rows, fieldHeader, beforeHeader, afterHeader }: {
61
+ rows: AiPendingActionCardFieldDiff[]
62
+ fieldHeader: string
63
+ beforeHeader: string
64
+ afterHeader: string
65
+ }) {
66
+ return (
67
+ <table className="w-full" data-ai-field-diff-table>
68
+ <thead>
69
+ <tr className="border-b border-border">
70
+ <th className="py-1 pr-4 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
71
+ {fieldHeader}
72
+ </th>
73
+ <th className="py-1 pr-4 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
74
+ {beforeHeader}
75
+ </th>
76
+ <th className="py-1 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
77
+ {afterHeader}
78
+ </th>
79
+ </tr>
80
+ </thead>
81
+ <tbody>
82
+ {rows.map((entry, idx) => (
83
+ <DiffRow key={`${entry.field}-${idx}`} entry={entry} />
84
+ ))}
85
+ </tbody>
86
+ </table>
87
+ )
88
+ }
89
+
90
+ export function FieldDiffCard({ fieldDiff, records }: FieldDiffCardProps) {
91
+ const t = useT()
92
+ const fieldHeader = t(
93
+ 'ai_assistant.chat.mutation_cards.diff.fieldHeader',
94
+ 'Field',
95
+ )
96
+ const beforeHeader = t(
97
+ 'ai_assistant.chat.mutation_cards.diff.beforeHeader',
98
+ 'Before',
99
+ )
100
+ const afterHeader = t(
101
+ 'ai_assistant.chat.mutation_cards.diff.afterHeader',
102
+ 'After',
103
+ )
104
+
105
+ const batch = Array.isArray(records) && records.length > 0 ? records : null
106
+ const flat = Array.isArray(fieldDiff) ? fieldDiff : []
107
+
108
+ if (batch) {
109
+ return (
110
+ <div className="flex flex-col gap-3" data-ai-field-diff-mode="batch">
111
+ {batch.map((record) => (
112
+ <section
113
+ key={record.recordId}
114
+ className="rounded-md border border-border bg-background p-3"
115
+ data-ai-field-diff-record={record.recordId}
116
+ >
117
+ <header className="mb-2 flex items-baseline justify-between gap-2">
118
+ <h4 className="text-sm font-semibold">{record.label}</h4>
119
+ <span className="text-xs font-mono text-muted-foreground">
120
+ {record.entityType}
121
+ </span>
122
+ </header>
123
+ {record.fieldDiff.length > 0 ? (
124
+ <DiffTable
125
+ rows={record.fieldDiff}
126
+ fieldHeader={fieldHeader}
127
+ beforeHeader={beforeHeader}
128
+ afterHeader={afterHeader}
129
+ />
130
+ ) : (
131
+ <Alert variant="info">
132
+ <Info className="size-4" aria-hidden />
133
+ <AlertDescription>
134
+ {t(
135
+ 'ai_assistant.chat.mutation_cards.diff.empty',
136
+ 'No field changes for this record.',
137
+ )}
138
+ </AlertDescription>
139
+ </Alert>
140
+ )}
141
+ </section>
142
+ ))}
143
+ </div>
144
+ )
145
+ }
146
+
147
+ if (flat.length === 0) {
148
+ return (
149
+ <Alert variant="info" data-ai-field-diff-mode="empty">
150
+ <Info className="size-4" aria-hidden />
151
+ <AlertDescription>
152
+ {t(
153
+ 'ai_assistant.chat.mutation_cards.diff.empty',
154
+ 'No field changes for this record.',
155
+ )}
156
+ </AlertDescription>
157
+ </Alert>
158
+ )
159
+ }
160
+
161
+ return (
162
+ <div data-ai-field-diff-mode="flat">
163
+ <DiffTable
164
+ rows={flat}
165
+ fieldHeader={fieldHeader}
166
+ beforeHeader={beforeHeader}
167
+ afterHeader={afterHeader}
168
+ />
169
+ </div>
170
+ )
171
+ }
172
+
173
+ export default FieldDiffCard
@@ -0,0 +1,302 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { ChevronDown, Eye, ShieldAlert } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { Button } from '../../primitives/button'
7
+ import { useAiShortcuts } from '../useAiShortcuts'
8
+ import type { AiUiPartProps } from '../ui-part-registry'
9
+ import { confirmPendingAction, cancelPendingAction } from './pending-action-api'
10
+ import { useAiPendingActionPolling } from './useAiPendingActionPolling'
11
+ import { FieldDiffCard } from './FieldDiffCard'
12
+ import { ConfirmationCard } from './ConfirmationCard'
13
+ import { MutationResultCard } from './MutationResultCard'
14
+ import type { AiPendingActionCardAction } from './types'
15
+
16
+ /**
17
+ * Mutation-approval preview card. Rendered by the server-emitted
18
+ * `mutation-preview-card` UI part (spec §9.2).
19
+ *
20
+ * Responsibilities:
21
+ * - Fetch the current pending-action row via the shared polling hook so the
22
+ * card recovers state on page reload (reconnect behavior).
23
+ * - Render the top-level `fieldDiff` OR a per-record `records[]` summary
24
+ * with a drill-in link.
25
+ * - Provide `Confirm` / `Cancel` / `Review Details` actions with shared
26
+ * keyboard shortcuts (`Cmd/Ctrl+Enter` → confirm, `Escape` → cancel).
27
+ * - Flip to the {@link ConfirmationCard} once the user confirms, and
28
+ * further to the {@link MutationResultCard} once the row becomes
29
+ * terminal.
30
+ */
31
+ export interface MutationPreviewCardPayload {
32
+ /** Optional server-serialized pending action snapshot for the initial render. */
33
+ pendingAction?: AiPendingActionCardAction
34
+ }
35
+
36
+ export interface MutationPreviewCardProps extends AiUiPartProps {
37
+ /** Optional injected action for tests — bypasses the polling fetch. */
38
+ initialAction?: AiPendingActionCardAction
39
+ /** Endpoint base override for tests. */
40
+ endpoint?: string
41
+ }
42
+
43
+ function summarizeBatch(
44
+ records: NonNullable<AiPendingActionCardAction['records']>,
45
+ ): { count: number; labels: string[] } {
46
+ const labels = records.slice(0, 3).map((record) => record.label)
47
+ return { count: records.length, labels }
48
+ }
49
+
50
+ export function MutationPreviewCard(props: MutationPreviewCardProps) {
51
+ const t = useT()
52
+ const pendingActionId = props.pendingActionId ?? ''
53
+ const payload = (props.payload as MutationPreviewCardPayload | undefined) ?? {}
54
+ const injected = props.initialAction ?? payload.pendingAction ?? null
55
+
56
+ const { action: polled, refresh } = useAiPendingActionPolling({
57
+ pendingActionId,
58
+ endpoint: props.endpoint,
59
+ disabled: !pendingActionId,
60
+ })
61
+ const action = polled ?? injected
62
+
63
+ const [expanded, setExpanded] = React.useState(false)
64
+ const [phase, setPhase] = React.useState<'preview' | 'confirming'>('preview')
65
+ const [confirmError, setConfirmError] = React.useState<{
66
+ status: number
67
+ code?: string
68
+ message: string
69
+ extra?: Record<string, unknown>
70
+ } | null>(null)
71
+
72
+ const handleConfirm = React.useCallback(async () => {
73
+ if (!pendingActionId) return
74
+ if (phase !== 'preview') return
75
+ setPhase('confirming')
76
+ setConfirmError(null)
77
+ const result = await confirmPendingAction(pendingActionId, {
78
+ endpoint: props.endpoint,
79
+ })
80
+ if (!result.ok) {
81
+ // Network / timeout / 4xx / 5xx — surface the envelope and rewind the
82
+ // card to the preview phase so the operator can read the error,
83
+ // edit the proposal upstream, or retry.
84
+ setConfirmError(result.error)
85
+ setPhase('preview')
86
+ await refresh()
87
+ return
88
+ }
89
+ // HTTP 200 path. The dispatcher returns `ok: false` AND a populated
90
+ // `mutationResult.error` when the wrapped tool handler failed inside
91
+ // the confirm route — the row is already in a terminal state but the
92
+ // overall HTTP call succeeded. Treat that as a confirm error too so
93
+ // the alert renders inline instead of leaving the card on the
94
+ // generic "applying…" spinner forever.
95
+ const handlerError = result.data?.mutationResult?.error
96
+ if (result.data?.ok === false || handlerError) {
97
+ const mappedCode =
98
+ typeof handlerError?.code === 'string' && handlerError.code.length > 0
99
+ ? handlerError.code
100
+ : 'execution_failed'
101
+ setConfirmError({
102
+ status: 200,
103
+ code: mappedCode,
104
+ message:
105
+ handlerError?.message ??
106
+ t(
107
+ 'ai_assistant.chat.mutation_cards.preview.handlerError',
108
+ 'The mutation handler reported an error. Review the details and re-propose if needed.',
109
+ ),
110
+ })
111
+ }
112
+ await refresh()
113
+ }, [pendingActionId, phase, props.endpoint, refresh, t])
114
+
115
+ const handleCancel = React.useCallback(async () => {
116
+ if (!pendingActionId) return
117
+ const result = await cancelPendingAction(pendingActionId, {
118
+ endpoint: props.endpoint,
119
+ })
120
+ if (!result.ok) {
121
+ setConfirmError(result.error)
122
+ }
123
+ await refresh()
124
+ }, [pendingActionId, props.endpoint, refresh])
125
+
126
+ const currentStatus = action?.status ?? null
127
+ const executionError = action?.executionResult?.error
128
+ // Treat any captured handler error as terminal too — the dispatcher
129
+ // sometimes returns the action before the row has fully transitioned
130
+ // out of `executing`, and we must never leave the spinner masking a
131
+ // real failure. The MutationResultCard's failure path renders the
132
+ // error envelope as long as `executionResult.error` is set.
133
+ const isTerminal =
134
+ currentStatus === 'confirmed' ||
135
+ currentStatus === 'failed' ||
136
+ currentStatus === 'cancelled' ||
137
+ currentStatus === 'expired' ||
138
+ Boolean(executionError)
139
+
140
+ const { handleKeyDown } = useAiShortcuts({
141
+ onSubmit: () => {
142
+ void handleConfirm()
143
+ },
144
+ onCancel: () => {
145
+ void handleCancel()
146
+ },
147
+ enabled: phase === 'preview' && !isTerminal,
148
+ })
149
+
150
+ // Terminal — short-circuit into the result card.
151
+ if (isTerminal && action) {
152
+ return (
153
+ <MutationResultCard
154
+ componentId="mutation-result-card"
155
+ pendingActionId={pendingActionId}
156
+ initialAction={action}
157
+ endpoint={props.endpoint}
158
+ />
159
+ )
160
+ }
161
+
162
+ // Confirming — flip to the spinner card. Propagate the confirmError so
163
+ // the user sees the structured envelope even though the confirm call has
164
+ // already resolved.
165
+ if (phase === 'confirming') {
166
+ return (
167
+ <ConfirmationCard
168
+ componentId="confirmation-card"
169
+ pendingActionId={pendingActionId}
170
+ initialAction={action ?? undefined}
171
+ endpoint={props.endpoint}
172
+ payload={{
173
+ sideEffectsSummary: action?.sideEffectsSummary ?? null,
174
+ pendingAction: action ?? undefined,
175
+ confirmError: confirmError ?? undefined,
176
+ }}
177
+ />
178
+ )
179
+ }
180
+
181
+ const batch = Array.isArray(action?.records) && action!.records!.length > 0 ? action!.records! : null
182
+ const summary = batch ? summarizeBatch(batch) : null
183
+
184
+ return (
185
+ <section
186
+ className="rounded-md border border-border bg-background p-4 text-sm outline-none"
187
+ tabIndex={0}
188
+ onKeyDown={handleKeyDown}
189
+ data-ai-mutation-preview
190
+ data-ai-mutation-preview-mode={batch ? 'batch' : 'single'}
191
+ >
192
+ <header className="flex items-start justify-between gap-3">
193
+ <div className="flex items-start gap-2">
194
+ <ShieldAlert className="mt-0.5 size-4 text-status-warning-icon" aria-hidden />
195
+ <div>
196
+ <h4 className="text-sm font-semibold">
197
+ {t(
198
+ 'ai_assistant.chat.mutation_cards.preview.title',
199
+ 'Review proposed changes',
200
+ )}
201
+ </h4>
202
+ {action?.sideEffectsSummary ? (
203
+ <p className="mt-1 text-sm text-muted-foreground">
204
+ {action.sideEffectsSummary}
205
+ </p>
206
+ ) : null}
207
+ </div>
208
+ </div>
209
+ </header>
210
+
211
+ <div className="mt-3" data-ai-mutation-preview-body>
212
+ {summary ? (
213
+ <div
214
+ className="rounded-md border border-border bg-muted/30 p-3 text-sm"
215
+ data-ai-mutation-preview-batch-summary
216
+ >
217
+ <p className="font-medium">
218
+ {t(
219
+ 'ai_assistant.chat.mutation_cards.preview.batchSummary',
220
+ 'Batch update',
221
+ )}
222
+ {': '}
223
+ <span data-ai-mutation-preview-count>{summary.count}</span>{' '}
224
+ <span>
225
+ {t(
226
+ 'ai_assistant.chat.mutation_cards.preview.batchRecords',
227
+ 'records',
228
+ )}
229
+ </span>
230
+ </p>
231
+ {summary.labels.length > 0 ? (
232
+ <p className="mt-1 text-xs text-muted-foreground">
233
+ {summary.labels.join(', ')}
234
+ {summary.count > summary.labels.length
235
+ ? ` +${summary.count - summary.labels.length}`
236
+ : ''}
237
+ </p>
238
+ ) : null}
239
+ </div>
240
+ ) : (
241
+ <FieldDiffCard fieldDiff={action?.fieldDiff ?? null} />
242
+ )}
243
+ </div>
244
+
245
+ {expanded ? (
246
+ <div className="mt-3 rounded-md border border-border bg-muted/20 p-3" data-ai-mutation-preview-details>
247
+ <FieldDiffCard
248
+ fieldDiff={action?.fieldDiff ?? null}
249
+ records={action?.records ?? null}
250
+ />
251
+ </div>
252
+ ) : null}
253
+
254
+ <div className="mt-3 flex items-center justify-between gap-2">
255
+ <Button
256
+ type="button"
257
+ variant="ghost"
258
+ size="sm"
259
+ onClick={() => setExpanded((value) => !value)}
260
+ data-ai-mutation-preview-review
261
+ >
262
+ <Eye className="size-4" aria-hidden />
263
+ <span>
264
+ {t(
265
+ 'ai_assistant.chat.mutation_cards.preview.reviewDetails',
266
+ 'Review details',
267
+ )}
268
+ </span>
269
+ <ChevronDown
270
+ className={`size-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
271
+ aria-hidden
272
+ />
273
+ </Button>
274
+ <div className="flex items-center gap-2">
275
+ <Button
276
+ type="button"
277
+ variant="outline"
278
+ size="sm"
279
+ onClick={() => {
280
+ void handleCancel()
281
+ }}
282
+ data-ai-mutation-preview-cancel
283
+ >
284
+ {t('ai_assistant.chat.mutation_cards.preview.cancel', 'Cancel')}
285
+ </Button>
286
+ <Button
287
+ type="button"
288
+ size="sm"
289
+ onClick={() => {
290
+ void handleConfirm()
291
+ }}
292
+ data-ai-mutation-preview-confirm
293
+ >
294
+ {t('ai_assistant.chat.mutation_cards.preview.confirm', 'Confirm')}
295
+ </Button>
296
+ </div>
297
+ </div>
298
+ </section>
299
+ )
300
+ }
301
+
302
+ export default MutationPreviewCard