@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
- package/dist/ai/AiAssistantLauncher.js +596 -0
- package/dist/ai/AiAssistantLauncher.js.map +7 -0
- package/dist/ai/AiChat.js +1092 -0
- package/dist/ai/AiChat.js.map +7 -0
- package/dist/ai/AiChatSessions.js +297 -0
- package/dist/ai/AiChatSessions.js.map +7 -0
- package/dist/ai/AiDock.js +347 -0
- package/dist/ai/AiDock.js.map +7 -0
- package/dist/ai/AiMessageContent.js +369 -0
- package/dist/ai/AiMessageContent.js.map +7 -0
- package/dist/ai/ChatPaneTabs.js +251 -0
- package/dist/ai/ChatPaneTabs.js.map +7 -0
- package/dist/ai/index.js +115 -0
- package/dist/ai/index.js.map +7 -0
- package/dist/ai/parts/ConfirmationCard.js +211 -0
- package/dist/ai/parts/ConfirmationCard.js.map +7 -0
- package/dist/ai/parts/FieldDiffCard.js +119 -0
- package/dist/ai/parts/FieldDiffCard.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +224 -0
- package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
- package/dist/ai/parts/MutationResultCard.js +240 -0
- package/dist/ai/parts/MutationResultCard.js.map +7 -0
- package/dist/ai/parts/approval-cards-map.js +15 -0
- package/dist/ai/parts/approval-cards-map.js.map +7 -0
- package/dist/ai/parts/index.js +24 -0
- package/dist/ai/parts/index.js.map +7 -0
- package/dist/ai/parts/pending-action-api.js +60 -0
- package/dist/ai/parts/pending-action-api.js.map +7 -0
- package/dist/ai/parts/types.js +1 -0
- package/dist/ai/parts/types.js.map +7 -0
- package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
- package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
- package/dist/ai/records/ActivityCard.js +83 -0
- package/dist/ai/records/ActivityCard.js.map +7 -0
- package/dist/ai/records/CompanyCard.js +81 -0
- package/dist/ai/records/CompanyCard.js.map +7 -0
- package/dist/ai/records/DealCard.js +76 -0
- package/dist/ai/records/DealCard.js.map +7 -0
- package/dist/ai/records/PersonCard.js +68 -0
- package/dist/ai/records/PersonCard.js.map +7 -0
- package/dist/ai/records/ProductCard.js +68 -0
- package/dist/ai/records/ProductCard.js.map +7 -0
- package/dist/ai/records/RecordCard.js +29 -0
- package/dist/ai/records/RecordCard.js.map +7 -0
- package/dist/ai/records/RecordCardShell.js +103 -0
- package/dist/ai/records/RecordCardShell.js.map +7 -0
- package/dist/ai/records/index.js +31 -0
- package/dist/ai/records/index.js.map +7 -0
- package/dist/ai/records/registry.js +51 -0
- package/dist/ai/records/registry.js.map +7 -0
- package/dist/ai/records/types.js +1 -0
- package/dist/ai/records/types.js.map +7 -0
- package/dist/ai/ui-part-registry.js +112 -0
- package/dist/ai/ui-part-registry.js.map +7 -0
- package/dist/ai/ui-part-slots.js +14 -0
- package/dist/ai/ui-part-slots.js.map +7 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
- package/dist/ai/upload-adapter.js +256 -0
- package/dist/ai/upload-adapter.js.map +7 -0
- package/dist/ai/useAiChat.js +549 -0
- package/dist/ai/useAiChat.js.map +7 -0
- package/dist/ai/useAiChatUpload.js +127 -0
- package/dist/ai/useAiChatUpload.js.map +7 -0
- package/dist/ai/useAiShortcuts.js +43 -0
- package/dist/ai/useAiShortcuts.js.map +7 -0
- package/dist/backend/AppShell.js +8 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +2 -0
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/DataTable.js +19 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +19 -15
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +31 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/spotIds.js +6 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/notifications/useNotificationEffect.js +38 -2
- package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +2 -2
- package/jest.config.cjs +7 -1
- package/jest.markdown-mock.tsx +7 -0
- package/package.json +10 -4
- package/src/ai/AiAssistantLauncher.tsx +805 -0
- package/src/ai/AiChat.tsx +1483 -0
- package/src/ai/AiChatSessions.tsx +429 -0
- package/src/ai/AiDock.tsx +505 -0
- package/src/ai/AiMessageContent.tsx +515 -0
- package/src/ai/ChatPaneTabs.tsx +310 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
- package/src/ai/__tests__/AiChat.test.tsx +257 -0
- package/src/ai/__tests__/AiDock.test.tsx +124 -0
- package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
- package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
- package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
- package/src/ai/__tests__/upload-adapter.test.ts +213 -0
- package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
- package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
- package/src/ai/index.ts +125 -0
- package/src/ai/parts/ConfirmationCard.tsx +310 -0
- package/src/ai/parts/FieldDiffCard.tsx +173 -0
- package/src/ai/parts/MutationPreviewCard.tsx +302 -0
- package/src/ai/parts/MutationResultCard.tsx +360 -0
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
- package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
- package/src/ai/parts/approval-cards-map.ts +24 -0
- package/src/ai/parts/index.ts +27 -0
- package/src/ai/parts/pending-action-api.ts +123 -0
- package/src/ai/parts/types.ts +84 -0
- package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
- package/src/ai/records/ActivityCard.tsx +102 -0
- package/src/ai/records/CompanyCard.tsx +89 -0
- package/src/ai/records/DealCard.tsx +85 -0
- package/src/ai/records/PersonCard.tsx +77 -0
- package/src/ai/records/ProductCard.tsx +83 -0
- package/src/ai/records/RecordCard.tsx +37 -0
- package/src/ai/records/RecordCardShell.tsx +169 -0
- package/src/ai/records/index.ts +30 -0
- package/src/ai/records/registry.tsx +80 -0
- package/src/ai/records/types.ts +90 -0
- package/src/ai/ui-part-registry.ts +233 -0
- package/src/ai/ui-part-slots.ts +32 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
- package/src/ai/upload-adapter.ts +421 -0
- package/src/ai/useAiChat.ts +865 -0
- package/src/ai/useAiChatUpload.ts +180 -0
- package/src/ai/useAiShortcuts.ts +79 -0
- package/src/backend/AppShell.tsx +12 -5
- package/src/backend/BackendChromeProvider.tsx +2 -0
- package/src/backend/DataTable.tsx +20 -1
- package/src/backend/FilterBar.tsx +26 -13
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
- package/src/backend/dashboard/DashboardScreen.tsx +38 -3
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
- package/src/backend/injection/spotIds.ts +6 -0
- package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
- package/src/backend/notifications/useNotificationEffect.ts +47 -2
- package/src/index.ts +1 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, render } from '@testing-library/react'
|
|
7
|
+
import { useAiChatUpload, type UseAiChatUploadState } from '../useAiChatUpload'
|
|
8
|
+
|
|
9
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
10
|
+
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
|
11
|
+
return new Response(payload, {
|
|
12
|
+
status,
|
|
13
|
+
headers: { 'content-type': 'application/json' },
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeFile(name: string, content = 'hello', type = 'text/plain'): File {
|
|
18
|
+
return new File([content], name, { type })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface HarnessProps {
|
|
22
|
+
fetchImpl: typeof fetch
|
|
23
|
+
signal?: AbortSignal
|
|
24
|
+
onState?: (state: UseAiChatUploadState) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function HookHarness({ fetchImpl, signal, onState }: HarnessProps) {
|
|
28
|
+
const state = useAiChatUpload({ fetchImpl, signal })
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
onState?.(state)
|
|
31
|
+
})
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('useAiChatUpload', () => {
|
|
36
|
+
it('toggles busy and writes per-file done status on success', async () => {
|
|
37
|
+
const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
38
|
+
const form = init?.body as FormData
|
|
39
|
+
const file = form.get('file') as File
|
|
40
|
+
return jsonResponse(200, {
|
|
41
|
+
ok: true,
|
|
42
|
+
item: {
|
|
43
|
+
id: `att_${file.name}`,
|
|
44
|
+
fileName: file.name,
|
|
45
|
+
fileSize: file.size,
|
|
46
|
+
mimeType: file.type || 'text/plain',
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
}) as unknown as typeof fetch
|
|
50
|
+
|
|
51
|
+
const snapshots: UseAiChatUploadState[] = []
|
|
52
|
+
render(<HookHarness fetchImpl={fetchImpl} onState={(s) => snapshots.push(s)} />)
|
|
53
|
+
const latest = () => snapshots[snapshots.length - 1]
|
|
54
|
+
|
|
55
|
+
await act(async () => {
|
|
56
|
+
await latest().upload([makeFile('a.txt'), makeFile('b.txt')])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const final = latest()
|
|
60
|
+
expect(final.busy).toBe(false)
|
|
61
|
+
expect(final.files).toHaveLength(2)
|
|
62
|
+
expect(final.files.every((entry) => entry.status === 'done')).toBe(true)
|
|
63
|
+
expect(final.files.map((entry) => entry.attachmentId)).toEqual(['att_a.txt', 'att_b.txt'])
|
|
64
|
+
expect(final.overallProgress).toBe(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('averages overallProgress across files', async () => {
|
|
68
|
+
const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
69
|
+
const form = init?.body as FormData
|
|
70
|
+
const file = form.get('file') as File
|
|
71
|
+
if (file.name === 'fail.txt') {
|
|
72
|
+
return jsonResponse(413, { error: 'Attachment exceeds the maximum upload size.' })
|
|
73
|
+
}
|
|
74
|
+
return jsonResponse(200, {
|
|
75
|
+
ok: true,
|
|
76
|
+
item: {
|
|
77
|
+
id: `att_${file.name}`,
|
|
78
|
+
fileName: file.name,
|
|
79
|
+
fileSize: file.size,
|
|
80
|
+
mimeType: 'text/plain',
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}) as unknown as typeof fetch
|
|
84
|
+
|
|
85
|
+
const snapshots: UseAiChatUploadState[] = []
|
|
86
|
+
render(<HookHarness fetchImpl={fetchImpl} onState={(s) => snapshots.push(s)} />)
|
|
87
|
+
const latest = () => snapshots[snapshots.length - 1]
|
|
88
|
+
|
|
89
|
+
await act(async () => {
|
|
90
|
+
await latest().upload([makeFile('ok.txt'), makeFile('fail.txt')])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const final = latest()
|
|
94
|
+
// One done at 1.0, one error at 0.0 → average 0.5.
|
|
95
|
+
expect(final.overallProgress).toBeCloseTo(0.5, 5)
|
|
96
|
+
expect(final.files[0].status).toBe('done')
|
|
97
|
+
expect(final.files[1].status).toBe('error')
|
|
98
|
+
expect(final.files[1].reason).toBe('size_exceeded')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('reset() clears state', async () => {
|
|
102
|
+
const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
103
|
+
const form = init?.body as FormData
|
|
104
|
+
const file = form.get('file') as File
|
|
105
|
+
return jsonResponse(200, {
|
|
106
|
+
ok: true,
|
|
107
|
+
item: {
|
|
108
|
+
id: `att_${file.name}`,
|
|
109
|
+
fileName: file.name,
|
|
110
|
+
fileSize: file.size,
|
|
111
|
+
mimeType: 'text/plain',
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}) as unknown as typeof fetch
|
|
115
|
+
|
|
116
|
+
const snapshots: UseAiChatUploadState[] = []
|
|
117
|
+
render(<HookHarness fetchImpl={fetchImpl} onState={(s) => snapshots.push(s)} />)
|
|
118
|
+
const latest = () => snapshots[snapshots.length - 1]
|
|
119
|
+
|
|
120
|
+
await act(async () => {
|
|
121
|
+
await latest().upload([makeFile('a.txt')])
|
|
122
|
+
})
|
|
123
|
+
expect(latest().files).toHaveLength(1)
|
|
124
|
+
|
|
125
|
+
act(() => {
|
|
126
|
+
latest().reset()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const final = latest()
|
|
130
|
+
expect(final.files).toEqual([])
|
|
131
|
+
expect(final.busy).toBe(false)
|
|
132
|
+
expect(final.overallProgress).toBe(0)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('propagates AbortSignal into per-file aborted status', async () => {
|
|
136
|
+
const controller = new AbortController()
|
|
137
|
+
const fetchImpl = jest.fn(async () => {
|
|
138
|
+
controller.abort()
|
|
139
|
+
const error = new Error('aborted') as Error & { name: string }
|
|
140
|
+
error.name = 'AbortError'
|
|
141
|
+
throw error
|
|
142
|
+
}) as unknown as typeof fetch
|
|
143
|
+
|
|
144
|
+
const snapshots: UseAiChatUploadState[] = []
|
|
145
|
+
render(
|
|
146
|
+
<HookHarness
|
|
147
|
+
fetchImpl={fetchImpl}
|
|
148
|
+
signal={controller.signal}
|
|
149
|
+
onState={(s) => snapshots.push(s)}
|
|
150
|
+
/>,
|
|
151
|
+
)
|
|
152
|
+
const latest = () => snapshots[snapshots.length - 1]
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await latest().upload([makeFile('a.txt'), makeFile('b.txt')])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const final = latest()
|
|
159
|
+
expect(final.busy).toBe(false)
|
|
160
|
+
expect(final.files.every((entry) => entry.status === 'error')).toBe(true)
|
|
161
|
+
expect(final.files.every((entry) => entry.reason === 'aborted')).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
7
|
+
import { useAiShortcuts } from '../useAiShortcuts'
|
|
8
|
+
|
|
9
|
+
function Harness({
|
|
10
|
+
onSubmit,
|
|
11
|
+
onCancel,
|
|
12
|
+
enabled,
|
|
13
|
+
}: {
|
|
14
|
+
onSubmit?: () => void
|
|
15
|
+
onCancel?: () => void
|
|
16
|
+
enabled?: boolean
|
|
17
|
+
}) {
|
|
18
|
+
const { handleKeyDown } = useAiShortcuts({ onSubmit, onCancel, enabled })
|
|
19
|
+
return (
|
|
20
|
+
<textarea aria-label="shortcuts harness" onKeyDown={handleKeyDown} />
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('useAiShortcuts', () => {
|
|
25
|
+
it('calls onSubmit on plain Enter and prevents default', () => {
|
|
26
|
+
const onSubmit = jest.fn()
|
|
27
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
28
|
+
const textarea = screen.getByLabelText('shortcuts harness')
|
|
29
|
+
|
|
30
|
+
const result = fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
31
|
+
// `fireEvent.keyDown` returns `false` when the handler called preventDefault.
|
|
32
|
+
expect(result).toBe(false)
|
|
33
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('calls onSubmit on Cmd+Enter (still works for power users)', () => {
|
|
37
|
+
const onSubmit = jest.fn()
|
|
38
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
39
|
+
fireEvent.keyDown(screen.getByLabelText('shortcuts harness'), {
|
|
40
|
+
key: 'Enter',
|
|
41
|
+
metaKey: true,
|
|
42
|
+
})
|
|
43
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('calls onSubmit on Ctrl+Enter (still works for power users)', () => {
|
|
47
|
+
const onSubmit = jest.fn()
|
|
48
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
49
|
+
fireEvent.keyDown(screen.getByLabelText('shortcuts harness'), {
|
|
50
|
+
key: 'Enter',
|
|
51
|
+
ctrlKey: true,
|
|
52
|
+
})
|
|
53
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not fire submit on Shift+Enter (native newline behavior preserved)', () => {
|
|
57
|
+
const onSubmit = jest.fn()
|
|
58
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
59
|
+
fireEvent.keyDown(screen.getByLabelText('shortcuts harness'), {
|
|
60
|
+
key: 'Enter',
|
|
61
|
+
shiftKey: true,
|
|
62
|
+
})
|
|
63
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('calls onCancel on Escape', () => {
|
|
67
|
+
const onCancel = jest.fn()
|
|
68
|
+
render(<Harness onCancel={onCancel} />)
|
|
69
|
+
fireEvent.keyDown(screen.getByLabelText('shortcuts harness'), { key: 'Escape' })
|
|
70
|
+
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not fire when disabled', () => {
|
|
74
|
+
const onSubmit = jest.fn()
|
|
75
|
+
const onCancel = jest.fn()
|
|
76
|
+
render(<Harness onSubmit={onSubmit} onCancel={onCancel} enabled={false} />)
|
|
77
|
+
const textarea = screen.getByLabelText('shortcuts harness')
|
|
78
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
79
|
+
fireEvent.keyDown(textarea, { key: 'Escape' })
|
|
80
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
81
|
+
expect(onCancel).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('does not swallow Escape when no onCancel is bound', () => {
|
|
85
|
+
render(<Harness />)
|
|
86
|
+
const textarea = screen.getByLabelText('shortcuts harness')
|
|
87
|
+
const result = fireEvent.keyDown(textarea, { key: 'Escape' })
|
|
88
|
+
// preventDefault NOT called — native Escape bubbles.
|
|
89
|
+
expect(result).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('does not double-fire when keyDown is dispatched twice', () => {
|
|
93
|
+
const onSubmit = jest.fn()
|
|
94
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
95
|
+
const textarea = screen.getByLabelText('shortcuts harness')
|
|
96
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
97
|
+
fireEvent.keyDown(textarea, { key: 'a' })
|
|
98
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
99
|
+
})
|
|
100
|
+
})
|
package/src/ai/index.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export { AiChat, type AiChatProps, type AiChatDebugTool, type AiChatDebugPromptSection } from './AiChat'
|
|
2
|
+
export {
|
|
3
|
+
AiAssistantLauncher,
|
|
4
|
+
type AiAssistantLauncherAgent,
|
|
5
|
+
type AiAssistantLauncherProps,
|
|
6
|
+
} from './AiAssistantLauncher'
|
|
7
|
+
export {
|
|
8
|
+
AiDockProvider,
|
|
9
|
+
useAiDock,
|
|
10
|
+
type AiDockedAssistant,
|
|
11
|
+
} from './AiDock'
|
|
12
|
+
export {
|
|
13
|
+
AiChatSessionsProvider,
|
|
14
|
+
useAiChatSessions,
|
|
15
|
+
defaultSessionLabel,
|
|
16
|
+
type AiChatSession,
|
|
17
|
+
} from './AiChatSessions'
|
|
18
|
+
export { ChatPaneTabs } from './ChatPaneTabs'
|
|
19
|
+
export {
|
|
20
|
+
useAiChat,
|
|
21
|
+
type AiChatMessage,
|
|
22
|
+
type AiChatToolCallSnapshot,
|
|
23
|
+
type AiChatMessageFile,
|
|
24
|
+
type AiChatErrorEnvelope,
|
|
25
|
+
type UseAiChatInput,
|
|
26
|
+
type UseAiChatResult,
|
|
27
|
+
} from './useAiChat'
|
|
28
|
+
export {
|
|
29
|
+
useAiShortcuts,
|
|
30
|
+
type UseAiShortcutsOptions,
|
|
31
|
+
type UseAiShortcutsResult,
|
|
32
|
+
} from './useAiShortcuts'
|
|
33
|
+
export {
|
|
34
|
+
registerAiUiPart,
|
|
35
|
+
resolveAiUiPart,
|
|
36
|
+
unregisterAiUiPart,
|
|
37
|
+
resetAiUiPartRegistryForTests,
|
|
38
|
+
listAiUiParts,
|
|
39
|
+
createAiUiPartRegistry,
|
|
40
|
+
defaultAiUiPartRegistry,
|
|
41
|
+
RESERVED_AI_UI_PART_IDS,
|
|
42
|
+
isReservedAiUiPartId,
|
|
43
|
+
type AiUiPartComponent,
|
|
44
|
+
type AiUiPartComponentId,
|
|
45
|
+
type AiUiPartProps,
|
|
46
|
+
type AiUiPartRegistry,
|
|
47
|
+
type AiUiPartRegistryEntry,
|
|
48
|
+
type CreateAiUiPartRegistryOptions,
|
|
49
|
+
type ReservedAiUiPartId,
|
|
50
|
+
} from './ui-part-registry'
|
|
51
|
+
export { PendingPhase3Placeholder } from './ui-parts/pending-phase3-placeholder'
|
|
52
|
+
export {
|
|
53
|
+
MutationPreviewCard,
|
|
54
|
+
FieldDiffCard,
|
|
55
|
+
ConfirmationCard,
|
|
56
|
+
MutationResultCard,
|
|
57
|
+
AI_MUTATION_APPROVAL_CARDS,
|
|
58
|
+
useAiPendingActionPolling,
|
|
59
|
+
confirmPendingAction,
|
|
60
|
+
cancelPendingAction,
|
|
61
|
+
type UseAiPendingActionPollingOptions,
|
|
62
|
+
type UseAiPendingActionPollingResult,
|
|
63
|
+
type PendingActionMutationOk,
|
|
64
|
+
type PendingActionMutationError,
|
|
65
|
+
type PendingActionMutationResult,
|
|
66
|
+
type AiPendingActionCardAction,
|
|
67
|
+
type AiPendingActionCardStatus,
|
|
68
|
+
type AiPendingActionCardFieldDiff,
|
|
69
|
+
type AiPendingActionCardRecordDiff,
|
|
70
|
+
type AiPendingActionCardFailedRecord,
|
|
71
|
+
type AiPendingActionCardExecutionResult,
|
|
72
|
+
} from './parts'
|
|
73
|
+
export {
|
|
74
|
+
uploadAttachmentsForChat,
|
|
75
|
+
type UploadAttachmentsForChatOptions,
|
|
76
|
+
type UploadAttachmentsForChatResult,
|
|
77
|
+
type UploadedAttachment,
|
|
78
|
+
type UploadFailure,
|
|
79
|
+
type UploadFailureReason,
|
|
80
|
+
} from './upload-adapter'
|
|
81
|
+
export {
|
|
82
|
+
RecordCard,
|
|
83
|
+
DealCard,
|
|
84
|
+
PersonCard,
|
|
85
|
+
CompanyCard,
|
|
86
|
+
ProductCard,
|
|
87
|
+
ActivityCard,
|
|
88
|
+
RecordCardShell,
|
|
89
|
+
KeyValueList,
|
|
90
|
+
TagRow,
|
|
91
|
+
statusToTagVariant,
|
|
92
|
+
type RecordCardProps,
|
|
93
|
+
type RecordCardShellProps,
|
|
94
|
+
type KeyValueListItem,
|
|
95
|
+
type DealCardProps,
|
|
96
|
+
type PersonCardProps,
|
|
97
|
+
type CompanyCardProps,
|
|
98
|
+
type ProductCardProps,
|
|
99
|
+
type ActivityCardProps,
|
|
100
|
+
type RecordCardKind,
|
|
101
|
+
type RecordCardPayload,
|
|
102
|
+
type RecordCardBaseProps,
|
|
103
|
+
type DealRecordPayload,
|
|
104
|
+
type PersonRecordPayload,
|
|
105
|
+
type CompanyRecordPayload,
|
|
106
|
+
type ProductRecordPayload,
|
|
107
|
+
type ActivityRecordPayload,
|
|
108
|
+
registerRecordCardUiParts,
|
|
109
|
+
RECORD_CARD_COMPONENT_IDS,
|
|
110
|
+
type RecordCardComponentId,
|
|
111
|
+
} from './records'
|
|
112
|
+
export {
|
|
113
|
+
AiMessageContent,
|
|
114
|
+
parseAiContentSegments,
|
|
115
|
+
RECORD_CARD_FENCE_INFO_PREFIX,
|
|
116
|
+
type AiMessageContentSegment,
|
|
117
|
+
type AiMessageContentProps,
|
|
118
|
+
} from './AiMessageContent'
|
|
119
|
+
export {
|
|
120
|
+
useAiChatUpload,
|
|
121
|
+
type UseAiChatUploadOptions,
|
|
122
|
+
type UseAiChatUploadState,
|
|
123
|
+
type AiChatUploadFileState,
|
|
124
|
+
type AiChatUploadFileStatus,
|
|
125
|
+
} from './useAiChatUpload'
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { AlertTriangle, Loader2 } from 'lucide-react'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { Alert, AlertDescription, AlertTitle } from '../../primitives/alert'
|
|
7
|
+
import { Button } from '../../primitives/button'
|
|
8
|
+
import { Spinner } from '../../primitives/spinner'
|
|
9
|
+
import { useAiShortcuts } from '../useAiShortcuts'
|
|
10
|
+
import type { AiUiPartProps } from '../ui-part-registry'
|
|
11
|
+
import { cancelPendingAction } from './pending-action-api'
|
|
12
|
+
import { useAiPendingActionPolling } from './useAiPendingActionPolling'
|
|
13
|
+
import { MutationResultCard } from './MutationResultCard'
|
|
14
|
+
import type { AiPendingActionCardAction } from './types'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Confirmation / in-flight card rendered after the user clicks `Confirm` on
|
|
18
|
+
* the preview card. Shows a spinner + the side-effects summary while the
|
|
19
|
+
* server runs the re-check contract and executes the wrapped tool.
|
|
20
|
+
*
|
|
21
|
+
* The user can race the confirm by clicking Cancel — but only while the
|
|
22
|
+
* server has not yet flipped the row to `executing`. The polling hook
|
|
23
|
+
* drives the disable logic.
|
|
24
|
+
*
|
|
25
|
+
* Surfaces structured error envelopes from the confirm route: 412
|
|
26
|
+
* `stale_version` (records changed since preview) and 412 `schema_drift`
|
|
27
|
+
* (tool input schema changed) render targeted alerts with the specific
|
|
28
|
+
* recovery copy. Keyboard: `Escape` triggers Cancel; `Cmd/Ctrl+Enter` is
|
|
29
|
+
* intentionally inert (the user already confirmed).
|
|
30
|
+
*
|
|
31
|
+
* Terminal states flip this card into a {@link MutationResultCard} render
|
|
32
|
+
* so the chat transcript does not need two separate cards for confirmed
|
|
33
|
+
* vs pending.
|
|
34
|
+
*/
|
|
35
|
+
export interface ConfirmationCardPayload {
|
|
36
|
+
sideEffectsSummary?: string | null
|
|
37
|
+
pendingAction?: AiPendingActionCardAction
|
|
38
|
+
confirmError?: {
|
|
39
|
+
status: number
|
|
40
|
+
code?: string
|
|
41
|
+
message: string
|
|
42
|
+
extra?: Record<string, unknown>
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ConfirmationCardProps extends AiUiPartProps {
|
|
47
|
+
/** Optional override for tests — bypasses the polling fetch. */
|
|
48
|
+
initialAction?: AiPendingActionCardAction
|
|
49
|
+
/** Endpoint override (tests). */
|
|
50
|
+
endpoint?: string
|
|
51
|
+
/** Optional cancel handler override (tests). */
|
|
52
|
+
onCancel?: () => Promise<void> | void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ConfirmationCard(props: ConfirmationCardProps) {
|
|
56
|
+
const t = useT()
|
|
57
|
+
const pendingActionId = props.pendingActionId ?? ''
|
|
58
|
+
const payload = (props.payload as ConfirmationCardPayload | undefined) ?? {}
|
|
59
|
+
const injected = props.initialAction ?? payload.pendingAction ?? null
|
|
60
|
+
|
|
61
|
+
const { action, status, refresh } = useAiPendingActionPolling({
|
|
62
|
+
pendingActionId,
|
|
63
|
+
endpoint: props.endpoint,
|
|
64
|
+
disabled: !pendingActionId,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const effectiveAction = action ?? injected ?? null
|
|
68
|
+
const effectiveStatus = effectiveAction?.status ?? status
|
|
69
|
+
|
|
70
|
+
const [isCancelling, setIsCancelling] = React.useState(false)
|
|
71
|
+
const [localError, setLocalError] = React.useState<{
|
|
72
|
+
code?: string
|
|
73
|
+
message: string
|
|
74
|
+
extra?: Record<string, unknown>
|
|
75
|
+
} | null>(
|
|
76
|
+
payload.confirmError
|
|
77
|
+
? { code: payload.confirmError.code, message: payload.confirmError.message, extra: payload.confirmError.extra }
|
|
78
|
+
: null,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const canCancel =
|
|
82
|
+
!isCancelling &&
|
|
83
|
+
(effectiveStatus === 'pending' || effectiveStatus === 'confirmed' || effectiveStatus == null)
|
|
84
|
+
|
|
85
|
+
const handleCancel = React.useCallback(async () => {
|
|
86
|
+
if (!canCancel) return
|
|
87
|
+
if (props.onCancel) {
|
|
88
|
+
await props.onCancel()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (!pendingActionId) return
|
|
92
|
+
setIsCancelling(true)
|
|
93
|
+
try {
|
|
94
|
+
const result = await cancelPendingAction(pendingActionId, {
|
|
95
|
+
endpoint: props.endpoint,
|
|
96
|
+
})
|
|
97
|
+
if (!result.ok) {
|
|
98
|
+
setLocalError({
|
|
99
|
+
code: result.error.code,
|
|
100
|
+
message: result.error.message,
|
|
101
|
+
extra: result.error.extra,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
await refresh()
|
|
105
|
+
} finally {
|
|
106
|
+
setIsCancelling(false)
|
|
107
|
+
}
|
|
108
|
+
}, [canCancel, pendingActionId, props, refresh])
|
|
109
|
+
|
|
110
|
+
const { handleKeyDown } = useAiShortcuts({
|
|
111
|
+
onCancel: () => {
|
|
112
|
+
void handleCancel()
|
|
113
|
+
},
|
|
114
|
+
enabled: canCancel,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Terminal states — hand off to the result card renderer. Also short-
|
|
118
|
+
// circuit when the dispatcher has already populated an
|
|
119
|
+
// `executionResult.error`: the row may not have transitioned out of
|
|
120
|
+
// `executing` in the latest polling snapshot, but the handler error is
|
|
121
|
+
// authoritative, and leaving the spinner up while a known error is
|
|
122
|
+
// available is exactly the "stalled at processing" symptom the user
|
|
123
|
+
// reported. Surface the error card immediately.
|
|
124
|
+
const executionError = effectiveAction?.executionResult?.error
|
|
125
|
+
if (
|
|
126
|
+
effectiveStatus === 'confirmed' ||
|
|
127
|
+
effectiveStatus === 'failed' ||
|
|
128
|
+
effectiveStatus === 'cancelled' ||
|
|
129
|
+
effectiveStatus === 'expired' ||
|
|
130
|
+
executionError
|
|
131
|
+
) {
|
|
132
|
+
return (
|
|
133
|
+
<MutationResultCard
|
|
134
|
+
componentId="mutation-result-card"
|
|
135
|
+
pendingActionId={pendingActionId}
|
|
136
|
+
initialAction={effectiveAction ?? undefined}
|
|
137
|
+
endpoint={props.endpoint}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const summary =
|
|
143
|
+
effectiveAction?.sideEffectsSummary ??
|
|
144
|
+
payload.sideEffectsSummary ??
|
|
145
|
+
t(
|
|
146
|
+
'ai_assistant.chat.mutation_cards.confirmation.defaultSummary',
|
|
147
|
+
'Applying the requested changes...',
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<section
|
|
152
|
+
className="rounded-md border border-border bg-muted/30 p-4 text-sm outline-none"
|
|
153
|
+
tabIndex={0}
|
|
154
|
+
onKeyDown={handleKeyDown}
|
|
155
|
+
data-ai-confirmation-card
|
|
156
|
+
data-ai-confirmation-status={effectiveStatus ?? 'pending'}
|
|
157
|
+
aria-busy
|
|
158
|
+
>
|
|
159
|
+
<div className="flex items-start gap-3">
|
|
160
|
+
<Spinner size="sm" className="mt-0.5" />
|
|
161
|
+
<div className="flex-1">
|
|
162
|
+
<h4 className="text-sm font-semibold">
|
|
163
|
+
{t(
|
|
164
|
+
'ai_assistant.chat.mutation_cards.confirmation.title',
|
|
165
|
+
'Applying action...',
|
|
166
|
+
)}
|
|
167
|
+
</h4>
|
|
168
|
+
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{localError ? (
|
|
173
|
+
<ConfirmationErrorAlert error={localError} />
|
|
174
|
+
) : null}
|
|
175
|
+
|
|
176
|
+
<div className="mt-3 flex items-center justify-end gap-2">
|
|
177
|
+
<Button
|
|
178
|
+
type="button"
|
|
179
|
+
variant="outline"
|
|
180
|
+
size="sm"
|
|
181
|
+
onClick={() => {
|
|
182
|
+
void handleCancel()
|
|
183
|
+
}}
|
|
184
|
+
disabled={!canCancel}
|
|
185
|
+
data-ai-confirmation-cancel
|
|
186
|
+
>
|
|
187
|
+
{isCancelling ? (
|
|
188
|
+
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
189
|
+
) : null}
|
|
190
|
+
<span>
|
|
191
|
+
{t('ai_assistant.chat.mutation_cards.confirmation.cancel', 'Cancel')}
|
|
192
|
+
</span>
|
|
193
|
+
</Button>
|
|
194
|
+
</div>
|
|
195
|
+
</section>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function ConfirmationErrorAlert({
|
|
200
|
+
error,
|
|
201
|
+
}: {
|
|
202
|
+
error: { code?: string; message: string; extra?: Record<string, unknown> }
|
|
203
|
+
}) {
|
|
204
|
+
const t = useT()
|
|
205
|
+
const code = error.code ?? 'unknown'
|
|
206
|
+
|
|
207
|
+
if (code === 'stale_version') {
|
|
208
|
+
const failedRecords = Array.isArray(error.extra?.failedRecords)
|
|
209
|
+
? (error.extra?.failedRecords as Array<{ recordId?: string }>)
|
|
210
|
+
: []
|
|
211
|
+
return (
|
|
212
|
+
<Alert
|
|
213
|
+
variant="warning"
|
|
214
|
+
className="mt-3"
|
|
215
|
+
data-ai-confirmation-error="stale_version"
|
|
216
|
+
>
|
|
217
|
+
<AlertTriangle className="size-4" aria-hidden />
|
|
218
|
+
<AlertTitle>
|
|
219
|
+
{t(
|
|
220
|
+
'ai_assistant.chat.mutation_cards.confirmation.staleVersionTitle',
|
|
221
|
+
'Re-propose required',
|
|
222
|
+
)}
|
|
223
|
+
</AlertTitle>
|
|
224
|
+
<AlertDescription>
|
|
225
|
+
<p>
|
|
226
|
+
{t(
|
|
227
|
+
'ai_assistant.chat.mutation_cards.confirmation.staleVersionBody',
|
|
228
|
+
'One or more records changed since this preview was generated. Ask the assistant to re-propose the change.',
|
|
229
|
+
)}
|
|
230
|
+
</p>
|
|
231
|
+
{failedRecords.length > 0 ? (
|
|
232
|
+
<ul className="mt-2 list-disc pl-5 text-xs">
|
|
233
|
+
{failedRecords.map((record, idx) => (
|
|
234
|
+
<li
|
|
235
|
+
key={`${record.recordId ?? idx}`}
|
|
236
|
+
data-ai-confirmation-stale-record={record.recordId ?? ''}
|
|
237
|
+
>
|
|
238
|
+
<span className="font-mono">{record.recordId ?? '—'}</span>
|
|
239
|
+
</li>
|
|
240
|
+
))}
|
|
241
|
+
</ul>
|
|
242
|
+
) : null}
|
|
243
|
+
</AlertDescription>
|
|
244
|
+
</Alert>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (code === 'schema_drift') {
|
|
249
|
+
return (
|
|
250
|
+
<Alert
|
|
251
|
+
variant="warning"
|
|
252
|
+
className="mt-3"
|
|
253
|
+
data-ai-confirmation-error="schema_drift"
|
|
254
|
+
>
|
|
255
|
+
<AlertTriangle className="size-4" aria-hidden />
|
|
256
|
+
<AlertTitle>
|
|
257
|
+
{t(
|
|
258
|
+
'ai_assistant.chat.mutation_cards.confirmation.schemaDriftTitle',
|
|
259
|
+
'Schema changed',
|
|
260
|
+
)}
|
|
261
|
+
</AlertTitle>
|
|
262
|
+
<AlertDescription>
|
|
263
|
+
{t(
|
|
264
|
+
'ai_assistant.chat.mutation_cards.confirmation.schemaDriftBody',
|
|
265
|
+
'The tool signature changed since this preview was generated. Ask the assistant to re-propose the change.',
|
|
266
|
+
)}
|
|
267
|
+
</AlertDescription>
|
|
268
|
+
</Alert>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (code === 'invalid_status') {
|
|
273
|
+
return (
|
|
274
|
+
<Alert
|
|
275
|
+
variant="warning"
|
|
276
|
+
className="mt-3"
|
|
277
|
+
data-ai-confirmation-error="invalid_status"
|
|
278
|
+
>
|
|
279
|
+
<AlertTriangle className="size-4" aria-hidden />
|
|
280
|
+
<AlertTitle>
|
|
281
|
+
{t(
|
|
282
|
+
'ai_assistant.chat.mutation_cards.confirmation.invalidStatusTitle',
|
|
283
|
+
'Action already resolved',
|
|
284
|
+
)}
|
|
285
|
+
</AlertTitle>
|
|
286
|
+
<AlertDescription>
|
|
287
|
+
{t(
|
|
288
|
+
'ai_assistant.chat.mutation_cards.confirmation.invalidStatusBody',
|
|
289
|
+
'This action has already been confirmed, cancelled, or executed.',
|
|
290
|
+
)}
|
|
291
|
+
</AlertDescription>
|
|
292
|
+
</Alert>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<Alert variant="destructive" className="mt-3" data-ai-confirmation-error={code}>
|
|
298
|
+
<AlertTriangle className="size-4" aria-hidden />
|
|
299
|
+
<AlertTitle>
|
|
300
|
+
{t('ai_assistant.chat.mutation_cards.confirmation.errorTitle', 'Confirm failed')}
|
|
301
|
+
</AlertTitle>
|
|
302
|
+
<AlertDescription>
|
|
303
|
+
<span className="mr-2 font-mono text-xs">{code}</span>
|
|
304
|
+
<span>{error.message}</span>
|
|
305
|
+
</AlertDescription>
|
|
306
|
+
</Alert>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export default ConfirmationCard
|