@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,429 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Multi-tab AI chat sessions.
|
|
5
|
+
*
|
|
6
|
+
* Each agent can have several concurrent conversation threads (sessions).
|
|
7
|
+
* The provider stores their *metadata* — id, conversationId, optional
|
|
8
|
+
* user-given name, timestamps, status — in localStorage, so closing and
|
|
9
|
+
* re-opening the chat pane (or refreshing the page) restores the tabs and
|
|
10
|
+
* their history. The actual messages live in `useAiChat`'s per-conversation
|
|
11
|
+
* persistence slot keyed by `(agent, conversationId)`, which is why every
|
|
12
|
+
* session gets its own UUID even when it shares an agent with the next tab.
|
|
13
|
+
*
|
|
14
|
+
* Sessions are partitioned into:
|
|
15
|
+
* - `open` → currently shown in the tab strip
|
|
16
|
+
* - `closed` → hidden but kept for the history dropdown
|
|
17
|
+
*
|
|
18
|
+
* Closing a tab moves it to `closed`. Re-opening from history flips it back
|
|
19
|
+
* to `open` and surfaces it as the active tab. Renaming is a single
|
|
20
|
+
* `name` field; falling back to the formatted creation date when the user
|
|
21
|
+
* never named the session keeps the picker scannable.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as React from 'react'
|
|
25
|
+
|
|
26
|
+
const STORAGE_KEY = 'om-ai-chat-sessions-v1'
|
|
27
|
+
const HISTORY_LIMIT = 50
|
|
28
|
+
|
|
29
|
+
export type AiChatSessionStatus = 'open' | 'closed'
|
|
30
|
+
|
|
31
|
+
export interface AiChatSession {
|
|
32
|
+
id: string
|
|
33
|
+
agentId: string
|
|
34
|
+
conversationId: string
|
|
35
|
+
name?: string
|
|
36
|
+
createdAt: number
|
|
37
|
+
lastUsedAt: number
|
|
38
|
+
status: AiChatSessionStatus
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface AiChatSessionsState {
|
|
42
|
+
sessions: AiChatSession[]
|
|
43
|
+
activeByAgent: Record<string, string>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface AiChatSessionsApi {
|
|
47
|
+
state: AiChatSessionsState
|
|
48
|
+
/** Returns open sessions for `agentId`, ordered by creation. */
|
|
49
|
+
getOpenSessions: (agentId: string) => AiChatSession[]
|
|
50
|
+
/** Returns closed sessions (history) for `agentId`, newest first. */
|
|
51
|
+
getClosedSessions: (agentId: string, limit?: number) => AiChatSession[]
|
|
52
|
+
/** Returns the active session for `agentId`, or null if none. */
|
|
53
|
+
getActiveSession: (agentId: string) => AiChatSession | null
|
|
54
|
+
/** Creates and activates a fresh open session for `agentId`. */
|
|
55
|
+
createSession: (agentId: string) => AiChatSession
|
|
56
|
+
/** Closes a session (moves to history). If it was active, picks another. */
|
|
57
|
+
closeSession: (sessionId: string) => void
|
|
58
|
+
/** Re-opens a closed session and activates it. */
|
|
59
|
+
reopenSession: (sessionId: string) => void
|
|
60
|
+
/** Activates an open session. */
|
|
61
|
+
setActiveSession: (sessionId: string) => void
|
|
62
|
+
/** Renames a session — empty / whitespace clears the custom name. */
|
|
63
|
+
renameSession: (sessionId: string, name: string) => void
|
|
64
|
+
/** Bumps `lastUsedAt` on a session (call when activity happens). */
|
|
65
|
+
touchSession: (sessionId: string) => void
|
|
66
|
+
/** Ensures `agentId` has at least one open session and returns its id. */
|
|
67
|
+
ensureSession: (agentId: string) => AiChatSession
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const AiChatSessionsContext = React.createContext<AiChatSessionsApi | null>(null)
|
|
71
|
+
|
|
72
|
+
function makeId(): string {
|
|
73
|
+
const g = globalThis as unknown as { crypto?: { randomUUID?: () => string } }
|
|
74
|
+
if (g.crypto && typeof g.crypto.randomUUID === 'function') {
|
|
75
|
+
try {
|
|
76
|
+
return g.crypto.randomUUID()
|
|
77
|
+
} catch {
|
|
78
|
+
/* fall through */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const rand = () => Math.random().toString(16).slice(2, 10)
|
|
82
|
+
return `${Date.now().toString(16)}-${rand()}-${rand()}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readPersisted(): AiChatSessionsState {
|
|
86
|
+
if (typeof window === 'undefined') return { sessions: [], activeByAgent: {} }
|
|
87
|
+
try {
|
|
88
|
+
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
89
|
+
if (!raw) return { sessions: [], activeByAgent: {} }
|
|
90
|
+
const parsed = JSON.parse(raw) as Partial<AiChatSessionsState> | null
|
|
91
|
+
const sessions = Array.isArray(parsed?.sessions)
|
|
92
|
+
? (parsed!.sessions as unknown[])
|
|
93
|
+
.filter((entry): entry is AiChatSession => {
|
|
94
|
+
if (!entry || typeof entry !== 'object') return false
|
|
95
|
+
const value = entry as Record<string, unknown>
|
|
96
|
+
return (
|
|
97
|
+
typeof value.id === 'string' &&
|
|
98
|
+
typeof value.agentId === 'string' &&
|
|
99
|
+
typeof value.conversationId === 'string' &&
|
|
100
|
+
typeof value.createdAt === 'number' &&
|
|
101
|
+
typeof value.lastUsedAt === 'number' &&
|
|
102
|
+
(value.status === 'open' || value.status === 'closed')
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
.map((entry) => {
|
|
106
|
+
const value = entry as unknown as Record<string, unknown>
|
|
107
|
+
const candidate = value.name
|
|
108
|
+
return {
|
|
109
|
+
...entry,
|
|
110
|
+
name: typeof candidate === 'string' ? candidate : undefined,
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
: []
|
|
114
|
+
const activeByAgent =
|
|
115
|
+
parsed?.activeByAgent && typeof parsed.activeByAgent === 'object'
|
|
116
|
+
? Object.fromEntries(
|
|
117
|
+
Object.entries(parsed.activeByAgent as Record<string, unknown>).filter(
|
|
118
|
+
(entry): entry is [string, string] =>
|
|
119
|
+
typeof entry[0] === 'string' && typeof entry[1] === 'string',
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
: {}
|
|
123
|
+
return { sessions, activeByAgent }
|
|
124
|
+
} catch {
|
|
125
|
+
return { sessions: [], activeByAgent: {} }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writePersisted(state: AiChatSessionsState): void {
|
|
130
|
+
if (typeof window === 'undefined') return
|
|
131
|
+
try {
|
|
132
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
|
133
|
+
} catch {
|
|
134
|
+
/* quota / privacy mode — drop silently */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function AiChatSessionsProvider({ children }: { children: React.ReactNode }) {
|
|
139
|
+
// Hydrate synchronously via a lazy initializer. The previous "empty
|
|
140
|
+
// state + post-mount load effect" pattern had a window where the
|
|
141
|
+
// persistence effect ran with the empty closure value (because the
|
|
142
|
+
// hydrate effect's queued setState had not committed yet) and clobbered
|
|
143
|
+
// localStorage with `[]` before the loaded state's re-render wrote it
|
|
144
|
+
// back. The lazy initializer puts the loaded state into the very first
|
|
145
|
+
// render, so the persistence effect always sees the real data.
|
|
146
|
+
// `readPersisted` already short-circuits to an empty object on the
|
|
147
|
+
// server (no `window`), so SSR stays consistent with the bare-bones
|
|
148
|
+
// shell — actual session-dependent UI only renders after a user
|
|
149
|
+
// interaction opens a chat surface.
|
|
150
|
+
const [state, setState] = React.useState<AiChatSessionsState>(() => readPersisted())
|
|
151
|
+
|
|
152
|
+
React.useEffect(() => {
|
|
153
|
+
writePersisted(state)
|
|
154
|
+
}, [state])
|
|
155
|
+
|
|
156
|
+
const update = React.useCallback(
|
|
157
|
+
(mutator: (prev: AiChatSessionsState) => AiChatSessionsState) => {
|
|
158
|
+
setState((prev) => mutator(prev))
|
|
159
|
+
},
|
|
160
|
+
[],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const getOpenSessions = React.useCallback(
|
|
164
|
+
(agentId: string) =>
|
|
165
|
+
state.sessions
|
|
166
|
+
.filter((s) => s.agentId === agentId && s.status === 'open')
|
|
167
|
+
.sort((a, b) => a.createdAt - b.createdAt),
|
|
168
|
+
[state.sessions],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const getClosedSessions = React.useCallback(
|
|
172
|
+
(agentId: string, limit = HISTORY_LIMIT) =>
|
|
173
|
+
state.sessions
|
|
174
|
+
.filter((s) => s.agentId === agentId && s.status === 'closed')
|
|
175
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)
|
|
176
|
+
.slice(0, limit),
|
|
177
|
+
[state.sessions],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const getActiveSession = React.useCallback(
|
|
181
|
+
(agentId: string) => {
|
|
182
|
+
const id = state.activeByAgent[agentId]
|
|
183
|
+
if (!id) return null
|
|
184
|
+
const session = state.sessions.find((s) => s.id === id)
|
|
185
|
+
if (!session || session.status !== 'open') return null
|
|
186
|
+
return session
|
|
187
|
+
},
|
|
188
|
+
[state.activeByAgent, state.sessions],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const createSession = React.useCallback((agentId: string): AiChatSession => {
|
|
192
|
+
const session: AiChatSession = {
|
|
193
|
+
id: makeId(),
|
|
194
|
+
agentId,
|
|
195
|
+
conversationId: makeId(),
|
|
196
|
+
createdAt: Date.now(),
|
|
197
|
+
lastUsedAt: Date.now(),
|
|
198
|
+
status: 'open',
|
|
199
|
+
}
|
|
200
|
+
update((prev) => ({
|
|
201
|
+
sessions: [...prev.sessions, session],
|
|
202
|
+
activeByAgent: { ...prev.activeByAgent, [agentId]: session.id },
|
|
203
|
+
}))
|
|
204
|
+
return session
|
|
205
|
+
}, [update])
|
|
206
|
+
|
|
207
|
+
const closeSession = React.useCallback(
|
|
208
|
+
(sessionId: string) => {
|
|
209
|
+
update((prev) => {
|
|
210
|
+
const target = prev.sessions.find((s) => s.id === sessionId)
|
|
211
|
+
if (!target) return prev
|
|
212
|
+
const sessions = prev.sessions.map((s) =>
|
|
213
|
+
s.id === sessionId ? { ...s, status: 'closed' as const, lastUsedAt: Date.now() } : s,
|
|
214
|
+
)
|
|
215
|
+
const activeByAgent = { ...prev.activeByAgent }
|
|
216
|
+
if (activeByAgent[target.agentId] === sessionId) {
|
|
217
|
+
// Pick the next open session for this agent (most-recent first).
|
|
218
|
+
const fallback = sessions
|
|
219
|
+
.filter((s) => s.agentId === target.agentId && s.status === 'open')
|
|
220
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]
|
|
221
|
+
if (fallback) {
|
|
222
|
+
activeByAgent[target.agentId] = fallback.id
|
|
223
|
+
} else {
|
|
224
|
+
delete activeByAgent[target.agentId]
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { sessions, activeByAgent }
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
[update],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
const reopenSession = React.useCallback(
|
|
234
|
+
(sessionId: string) => {
|
|
235
|
+
update((prev) => {
|
|
236
|
+
const target = prev.sessions.find((s) => s.id === sessionId)
|
|
237
|
+
if (!target) return prev
|
|
238
|
+
const sessions = prev.sessions.map((s) =>
|
|
239
|
+
s.id === sessionId ? { ...s, status: 'open' as const, lastUsedAt: Date.now() } : s,
|
|
240
|
+
)
|
|
241
|
+
return {
|
|
242
|
+
sessions,
|
|
243
|
+
activeByAgent: { ...prev.activeByAgent, [target.agentId]: sessionId },
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
},
|
|
247
|
+
[update],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const setActiveSession = React.useCallback(
|
|
251
|
+
(sessionId: string) => {
|
|
252
|
+
update((prev) => {
|
|
253
|
+
const target = prev.sessions.find((s) => s.id === sessionId)
|
|
254
|
+
if (!target || target.status !== 'open') return prev
|
|
255
|
+
const sessions = prev.sessions.map((s) =>
|
|
256
|
+
s.id === sessionId ? { ...s, lastUsedAt: Date.now() } : s,
|
|
257
|
+
)
|
|
258
|
+
return {
|
|
259
|
+
sessions,
|
|
260
|
+
activeByAgent: { ...prev.activeByAgent, [target.agentId]: sessionId },
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
},
|
|
264
|
+
[update],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const renameSession = React.useCallback(
|
|
268
|
+
(sessionId: string, name: string) => {
|
|
269
|
+
const trimmed = name.trim()
|
|
270
|
+
update((prev) => ({
|
|
271
|
+
...prev,
|
|
272
|
+
sessions: prev.sessions.map((s) =>
|
|
273
|
+
s.id === sessionId
|
|
274
|
+
? { ...s, name: trimmed.length > 0 ? trimmed : undefined }
|
|
275
|
+
: s,
|
|
276
|
+
),
|
|
277
|
+
}))
|
|
278
|
+
},
|
|
279
|
+
[update],
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
const touchSession = React.useCallback(
|
|
283
|
+
(sessionId: string) => {
|
|
284
|
+
update((prev) => ({
|
|
285
|
+
...prev,
|
|
286
|
+
sessions: prev.sessions.map((s) =>
|
|
287
|
+
s.id === sessionId ? { ...s, lastUsedAt: Date.now() } : s,
|
|
288
|
+
),
|
|
289
|
+
}))
|
|
290
|
+
},
|
|
291
|
+
[update],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const ensureSession = React.useCallback(
|
|
295
|
+
(agentId: string): AiChatSession => {
|
|
296
|
+
// Everything happens inside a single functional setState so we always
|
|
297
|
+
// see the latest pending state (not a stale closure). React Strict
|
|
298
|
+
// Mode double-invokes both effects AND setState updaters in dev,
|
|
299
|
+
// which previously caused the auto-bootstrap path to mint two
|
|
300
|
+
// sessions on first chat-pane open. The `mintedSession` closure
|
|
301
|
+
// cache makes the updater itself idempotent across double-invokes
|
|
302
|
+
// (one fresh id per `ensureSession` call, regardless of how many
|
|
303
|
+
// times React replays the updater for purity testing); the
|
|
304
|
+
// functional `prev` lookup handles the case where two queued
|
|
305
|
+
// ensureSession calls resolve back-to-back — the second one sees
|
|
306
|
+
// the first's pending append and short-circuits.
|
|
307
|
+
let resolved: AiChatSession | null = null
|
|
308
|
+
let mintedSession: AiChatSession | null = null
|
|
309
|
+
setState((prev) => {
|
|
310
|
+
const activeId = prev.activeByAgent[agentId]
|
|
311
|
+
if (activeId) {
|
|
312
|
+
const active = prev.sessions.find((s) => s.id === activeId)
|
|
313
|
+
if (active && active.status === 'open') {
|
|
314
|
+
resolved = active
|
|
315
|
+
return prev
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const anyOpen = prev.sessions
|
|
319
|
+
.filter((s) => s.agentId === agentId && s.status === 'open')
|
|
320
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]
|
|
321
|
+
if (anyOpen) {
|
|
322
|
+
resolved = anyOpen
|
|
323
|
+
return {
|
|
324
|
+
...prev,
|
|
325
|
+
activeByAgent: { ...prev.activeByAgent, [agentId]: anyOpen.id },
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (!mintedSession) {
|
|
329
|
+
mintedSession = {
|
|
330
|
+
id: makeId(),
|
|
331
|
+
agentId,
|
|
332
|
+
conversationId: makeId(),
|
|
333
|
+
createdAt: Date.now(),
|
|
334
|
+
lastUsedAt: Date.now(),
|
|
335
|
+
status: 'open',
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
resolved = mintedSession
|
|
339
|
+
return {
|
|
340
|
+
sessions: [...prev.sessions, mintedSession],
|
|
341
|
+
activeByAgent: { ...prev.activeByAgent, [agentId]: mintedSession.id },
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
// `setState` returns synchronously after invoking the updater (twice
|
|
345
|
+
// in Strict Mode dev), so `resolved` is guaranteed to be set here.
|
|
346
|
+
return resolved as unknown as AiChatSession
|
|
347
|
+
},
|
|
348
|
+
[],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
const api = React.useMemo<AiChatSessionsApi>(
|
|
352
|
+
() => ({
|
|
353
|
+
state,
|
|
354
|
+
getOpenSessions,
|
|
355
|
+
getClosedSessions,
|
|
356
|
+
getActiveSession,
|
|
357
|
+
createSession,
|
|
358
|
+
closeSession,
|
|
359
|
+
reopenSession,
|
|
360
|
+
setActiveSession,
|
|
361
|
+
renameSession,
|
|
362
|
+
touchSession,
|
|
363
|
+
ensureSession,
|
|
364
|
+
}),
|
|
365
|
+
[
|
|
366
|
+
state,
|
|
367
|
+
getOpenSessions,
|
|
368
|
+
getClosedSessions,
|
|
369
|
+
getActiveSession,
|
|
370
|
+
createSession,
|
|
371
|
+
closeSession,
|
|
372
|
+
reopenSession,
|
|
373
|
+
setActiveSession,
|
|
374
|
+
renameSession,
|
|
375
|
+
touchSession,
|
|
376
|
+
ensureSession,
|
|
377
|
+
],
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
return <AiChatSessionsContext.Provider value={api}>{children}</AiChatSessionsContext.Provider>
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function useAiChatSessions(): AiChatSessionsApi {
|
|
384
|
+
const ctx = React.useContext(AiChatSessionsContext)
|
|
385
|
+
if (ctx) return ctx
|
|
386
|
+
// Fallback no-op API — keeps consumers safe when the provider is absent
|
|
387
|
+
// (legacy code paths, isolated unit tests). Every method is a no-op so a
|
|
388
|
+
// chat surface without the provider behaves like a single anonymous
|
|
389
|
+
// session (the behavior shipped before the multi-tab work).
|
|
390
|
+
return {
|
|
391
|
+
state: { sessions: [], activeByAgent: {} },
|
|
392
|
+
getOpenSessions: () => [],
|
|
393
|
+
getClosedSessions: () => [],
|
|
394
|
+
getActiveSession: () => null,
|
|
395
|
+
createSession: (agentId) => ({
|
|
396
|
+
id: 'noop',
|
|
397
|
+
agentId,
|
|
398
|
+
conversationId: 'noop',
|
|
399
|
+
createdAt: Date.now(),
|
|
400
|
+
lastUsedAt: Date.now(),
|
|
401
|
+
status: 'open',
|
|
402
|
+
}),
|
|
403
|
+
closeSession: () => {},
|
|
404
|
+
reopenSession: () => {},
|
|
405
|
+
setActiveSession: () => {},
|
|
406
|
+
renameSession: () => {},
|
|
407
|
+
touchSession: () => {},
|
|
408
|
+
ensureSession: (agentId) => ({
|
|
409
|
+
id: 'noop',
|
|
410
|
+
agentId,
|
|
411
|
+
conversationId: 'noop',
|
|
412
|
+
createdAt: Date.now(),
|
|
413
|
+
lastUsedAt: Date.now(),
|
|
414
|
+
status: 'open',
|
|
415
|
+
}),
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function defaultSessionLabel(session: AiChatSession): string {
|
|
420
|
+
if (session.name && session.name.trim().length > 0) return session.name.trim()
|
|
421
|
+
const date = new Date(session.createdAt)
|
|
422
|
+
if (Number.isNaN(date.getTime())) return 'Session'
|
|
423
|
+
return date.toLocaleString(undefined, {
|
|
424
|
+
month: 'short',
|
|
425
|
+
day: 'numeric',
|
|
426
|
+
hour: '2-digit',
|
|
427
|
+
minute: '2-digit',
|
|
428
|
+
})
|
|
429
|
+
}
|