@flamingo-stack/openframe-frontend-core 0.0.195 → 0.0.197
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/dist/chunk-4CWSZPXH.cjs.map +1 -1
- package/dist/{chunk-PJ5KFD2V.js → chunk-4ML3NA2L.js} +75 -1
- package/dist/{chunk-PJ5KFD2V.js.map → chunk-4ML3NA2L.js.map} +1 -1
- package/dist/{chunk-U6AJSRJP.js → chunk-GIQZAYY5.js} +731 -365
- package/dist/chunk-GIQZAYY5.js.map +1 -0
- package/dist/{chunk-IQM3G2I6.cjs → chunk-IMDXOVYD.cjs} +777 -411
- package/dist/chunk-IMDXOVYD.cjs.map +1 -0
- package/dist/{chunk-CVMSC7M4.cjs → chunk-OII2IERE.cjs} +77 -3
- package/dist/chunk-OII2IERE.cjs.map +1 -0
- package/dist/chunk-UC43NICZ.cjs.map +1 -1
- package/dist/chunk-V2FNIPZJ.cjs.map +1 -1
- package/dist/chunk-VJTFBYVG.cjs.map +1 -1
- package/dist/chunk-VRHGVLSL.cjs.map +1 -1
- package/dist/chunk-WZW7C7TF.cjs.map +1 -1
- package/dist/chunk-XQFFGR6U.cjs.map +1 -1
- package/dist/components/chart.d.ts +7 -14
- package/dist/components/chart.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +1 -0
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +15 -0
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +43 -0
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/network.types.d.ts +4 -0
- package/dist/components/chat/types/network.types.d.ts.map +1 -1
- package/dist/components/features/board/ticket-card-skeleton.d.ts.map +1 -1
- package/dist/components/features/board/ticket-card.d.ts.map +1 -1
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +2 -2
- package/dist/components/icons/index.cjs.map +1 -1
- package/dist/components/icons-v2-generated/index.cjs.map +1 -1
- package/dist/components/index.cjs +5 -3
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +4 -2
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/resizable.d.ts +1 -1
- package/dist/components/toast/index.cjs.map +1 -1
- package/dist/components/ui/file-manager/index.cjs.map +1 -1
- package/dist/components/ui/index.cjs +5 -3
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +4 -2
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/index.cjs +5 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -2
- package/dist/nats/index.cjs +73 -0
- package/dist/nats/index.cjs.map +1 -1
- package/dist/nats/index.js +73 -0
- package/dist/nats/index.js.map +1 -1
- package/dist/nats/nats.d.ts +22 -1
- package/dist/nats/nats.d.ts.map +1 -1
- package/dist/tailwind.config.cjs +7 -7
- package/dist/tailwind.config.cjs.map +1 -1
- package/dist/tailwind.config.js +7 -7
- package/dist/tailwind.config.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/hooks/index.ts +1 -0
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +474 -0
- package/src/components/chat/types/api.types.ts +45 -0
- package/src/components/chat/types/network.types.ts +4 -0
- package/src/components/features/board/ticket-card-skeleton.tsx +16 -4
- package/src/components/features/board/ticket-card.tsx +6 -4
- package/src/nats/nats.ts +117 -0
- package/dist/chunk-CVMSC7M4.cjs.map +0 -1
- package/dist/chunk-IQM3G2I6.cjs.map +0 -1
- package/dist/chunk-U6AJSRJP.js.map +0 -1
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
createNatsClient,
|
|
6
|
+
type JetStreamSubscriptionHandle,
|
|
7
|
+
type NatsClient,
|
|
8
|
+
} from '../../../nats'
|
|
9
|
+
import {
|
|
10
|
+
type UseJetStreamDialogSubscriptionOptions,
|
|
11
|
+
type UseJetStreamDialogSubscriptionReturn,
|
|
12
|
+
NETWORK_CONFIG,
|
|
13
|
+
} from '../types'
|
|
14
|
+
|
|
15
|
+
type SharedConnection = {
|
|
16
|
+
wsUrl: string
|
|
17
|
+
client: NatsClient
|
|
18
|
+
connectPromise: Promise<void> | null
|
|
19
|
+
refCount: number
|
|
20
|
+
closeTimer: ReturnType<typeof setTimeout> | null
|
|
21
|
+
retryTimer: ReturnType<typeof setTimeout> | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let shared: SharedConnection | null = null
|
|
25
|
+
|
|
26
|
+
const DEFAULT_INACTIVE_THRESHOLD_MS = 5 * 60_000
|
|
27
|
+
const DEFAULT_STREAM_NAME = 'CHAT_CHUNKS'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Subscribe to a chat dialog stream via a JetStream **ephemeral OrderedConsumer**.
|
|
31
|
+
*
|
|
32
|
+
* - Subject: `chat.{dialogId}.{topic}`
|
|
33
|
+
* - When `optStartSeq` is a number, the consumer resumes at `optStartSeq + 1`
|
|
34
|
+
* (`DeliverPolicy.ByStartSequence`). When null/undefined, it live-tails
|
|
35
|
+
* (`DeliverPolicy.New`).
|
|
36
|
+
* - On reconnect, the consumer is recreated starting from the highest stream
|
|
37
|
+
* sequence we've already observed + 1, so no chunk is replayed or skipped.
|
|
38
|
+
* - Consumer is ephemeral with `AckPolicy.None` and a 5-minute inactivity
|
|
39
|
+
* threshold (overridable via `inactiveThresholdMs`).
|
|
40
|
+
*/
|
|
41
|
+
export function useJetStreamDialogSubscription({
|
|
42
|
+
enabled,
|
|
43
|
+
dialogId,
|
|
44
|
+
streamName = DEFAULT_STREAM_NAME,
|
|
45
|
+
topic,
|
|
46
|
+
optStartSeq,
|
|
47
|
+
onEvent,
|
|
48
|
+
onConnect,
|
|
49
|
+
onDisconnect,
|
|
50
|
+
onSubscribed,
|
|
51
|
+
onBeforeReconnect,
|
|
52
|
+
getNatsWsUrl,
|
|
53
|
+
clientConfig = {},
|
|
54
|
+
reconnectionBackoff,
|
|
55
|
+
inactiveThresholdMs,
|
|
56
|
+
}: UseJetStreamDialogSubscriptionOptions): UseJetStreamDialogSubscriptionReturn {
|
|
57
|
+
const [isConnected, setIsConnected] = useState(false)
|
|
58
|
+
const [isSubscribed, setIsSubscribed] = useState(false)
|
|
59
|
+
const [reconnectionCount, setReconnectionCount] = useState(0)
|
|
60
|
+
const [currentStreamSeq, setCurrentStreamSeq] = useState<number | null>(null)
|
|
61
|
+
|
|
62
|
+
const clientRef = useRef<NatsClient | null>(null)
|
|
63
|
+
const subscriptionRef = useRef<JetStreamSubscriptionHandle | null>(null)
|
|
64
|
+
const highestStreamSeqRef = useRef<number | null>(null)
|
|
65
|
+
|
|
66
|
+
const onEventRef = useRef(onEvent)
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
onEventRef.current = onEvent
|
|
69
|
+
}, [onEvent])
|
|
70
|
+
|
|
71
|
+
const onConnectRef = useRef(onConnect)
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
onConnectRef.current = onConnect
|
|
74
|
+
}, [onConnect])
|
|
75
|
+
|
|
76
|
+
const onDisconnectRef = useRef(onDisconnect)
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
onDisconnectRef.current = onDisconnect
|
|
79
|
+
}, [onDisconnect])
|
|
80
|
+
|
|
81
|
+
const onSubscribedRef = useRef(onSubscribed)
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
onSubscribedRef.current = onSubscribed
|
|
84
|
+
}, [onSubscribed])
|
|
85
|
+
|
|
86
|
+
const onBeforeReconnectRef = useRef(onBeforeReconnect)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
onBeforeReconnectRef.current = onBeforeReconnect
|
|
89
|
+
}, [onBeforeReconnect])
|
|
90
|
+
|
|
91
|
+
const getNatsWsUrlRef = useRef(getNatsWsUrl)
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
getNatsWsUrlRef.current = getNatsWsUrl
|
|
94
|
+
}, [getNatsWsUrl])
|
|
95
|
+
|
|
96
|
+
const reconnectionBackoffRef = useRef(reconnectionBackoff)
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
reconnectionBackoffRef.current = reconnectionBackoff
|
|
99
|
+
}, [reconnectionBackoff])
|
|
100
|
+
|
|
101
|
+
const optStartSeqRef = useRef<number | null | undefined>(optStartSeq)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
optStartSeqRef.current = optStartSeq
|
|
104
|
+
}, [optStartSeq])
|
|
105
|
+
|
|
106
|
+
const inactiveThresholdRef = useRef<number | undefined>(inactiveThresholdMs)
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
inactiveThresholdRef.current = inactiveThresholdMs
|
|
109
|
+
}, [inactiveThresholdMs])
|
|
110
|
+
|
|
111
|
+
const hadConnectionBeforeRef = useRef(false)
|
|
112
|
+
|
|
113
|
+
const acquireClient = useCallback(
|
|
114
|
+
(url: string): SharedConnection => {
|
|
115
|
+
if (shared?.wsUrl !== url) {
|
|
116
|
+
if (shared) {
|
|
117
|
+
if (shared.closeTimer) clearTimeout(shared.closeTimer)
|
|
118
|
+
const old = shared
|
|
119
|
+
shared = null
|
|
120
|
+
void old.client.close().catch(() => {})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { name = 'openframe-frontend-jetstream', user = 'machine', pass = '' } = clientConfig
|
|
124
|
+
|
|
125
|
+
const client = createNatsClient({
|
|
126
|
+
servers: url,
|
|
127
|
+
name,
|
|
128
|
+
user,
|
|
129
|
+
pass,
|
|
130
|
+
connectTimeoutMs: NETWORK_CONFIG.CONNECT_TIMEOUT_MS,
|
|
131
|
+
reconnect: false,
|
|
132
|
+
pingIntervalMs: NETWORK_CONFIG.PING_INTERVAL_MS,
|
|
133
|
+
maxPingOut: NETWORK_CONFIG.MAX_PING_OUT,
|
|
134
|
+
})
|
|
135
|
+
shared = {
|
|
136
|
+
wsUrl: url,
|
|
137
|
+
client,
|
|
138
|
+
connectPromise: null,
|
|
139
|
+
refCount: 0,
|
|
140
|
+
closeTimer: null,
|
|
141
|
+
retryTimer: null,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
shared.refCount += 1
|
|
146
|
+
if (shared.closeTimer) {
|
|
147
|
+
clearTimeout(shared.closeTimer)
|
|
148
|
+
shared.closeTimer = null
|
|
149
|
+
}
|
|
150
|
+
return shared
|
|
151
|
+
},
|
|
152
|
+
[clientConfig],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const releaseClient = useCallback((url: string) => {
|
|
156
|
+
if (!shared || shared.wsUrl !== url) return
|
|
157
|
+
|
|
158
|
+
shared.refCount = Math.max(0, shared.refCount - 1)
|
|
159
|
+
if (shared.refCount > 0) return
|
|
160
|
+
|
|
161
|
+
shared.closeTimer = setTimeout(() => {
|
|
162
|
+
const s = shared
|
|
163
|
+
shared = null
|
|
164
|
+
if (s) {
|
|
165
|
+
if (s.retryTimer) {
|
|
166
|
+
clearTimeout(s.retryTimer)
|
|
167
|
+
s.retryTimer = null
|
|
168
|
+
}
|
|
169
|
+
void s.client.close().catch(() => {})
|
|
170
|
+
}
|
|
171
|
+
}, NETWORK_CONFIG.SHARED_CLOSE_DELAY_MS)
|
|
172
|
+
}, [])
|
|
173
|
+
|
|
174
|
+
const currentWsUrlRef = useRef<string>('')
|
|
175
|
+
|
|
176
|
+
// Connection lifecycle: acquire/release the shared client based on enabled + URL.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const wsUrl = getNatsWsUrl()
|
|
179
|
+
if (!enabled || !wsUrl) {
|
|
180
|
+
if (currentWsUrlRef.current && clientRef.current) {
|
|
181
|
+
releaseClient(currentWsUrlRef.current)
|
|
182
|
+
clientRef.current = null
|
|
183
|
+
currentWsUrlRef.current = ''
|
|
184
|
+
setIsConnected(false)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
wsUrl === currentWsUrlRef.current &&
|
|
191
|
+
clientRef.current &&
|
|
192
|
+
clientRef.current.isConnected()
|
|
193
|
+
) {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
currentWsUrlRef.current &&
|
|
199
|
+
currentWsUrlRef.current !== wsUrl &&
|
|
200
|
+
clientRef.current
|
|
201
|
+
) {
|
|
202
|
+
releaseClient(currentWsUrlRef.current)
|
|
203
|
+
clientRef.current = null
|
|
204
|
+
setIsConnected(false)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
currentWsUrlRef.current = wsUrl
|
|
208
|
+
const sharedConn = acquireClient(wsUrl)
|
|
209
|
+
const client = sharedConn.client
|
|
210
|
+
|
|
211
|
+
clientRef.current = client
|
|
212
|
+
setIsConnected(false)
|
|
213
|
+
|
|
214
|
+
let closed = false
|
|
215
|
+
let retryAttempt = 0
|
|
216
|
+
|
|
217
|
+
function scheduleRetry() {
|
|
218
|
+
if (closed) return
|
|
219
|
+
if (shared !== sharedConn) return
|
|
220
|
+
|
|
221
|
+
if (sharedConn.retryTimer) {
|
|
222
|
+
clearTimeout(sharedConn.retryTimer)
|
|
223
|
+
sharedConn.retryTimer = null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const cfg = reconnectionBackoffRef.current ?? {}
|
|
227
|
+
const fastRetries = cfg.fastRetries ?? 0
|
|
228
|
+
const fastDelay = cfg.fastRetryDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
|
|
229
|
+
const baseDelay = cfg.initialDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
|
|
230
|
+
const maxDelay = cfg.maxDelayMs ?? NETWORK_CONFIG.RETRY_MAX_DELAY_MS
|
|
231
|
+
const multiplier = cfg.multiplier ?? NETWORK_CONFIG.RETRY_BACKOFF_MULTIPLIER
|
|
232
|
+
|
|
233
|
+
const delay =
|
|
234
|
+
retryAttempt < fastRetries
|
|
235
|
+
? fastDelay
|
|
236
|
+
: Math.min(baseDelay * multiplier ** (retryAttempt - fastRetries), maxDelay)
|
|
237
|
+
const jitteredDelay = delay * (0.5 + Math.random() * 0.5)
|
|
238
|
+
retryAttempt++
|
|
239
|
+
|
|
240
|
+
sharedConn.retryTimer = setTimeout(async () => {
|
|
241
|
+
sharedConn.retryTimer = null
|
|
242
|
+
if (closed) return
|
|
243
|
+
if (shared !== sharedConn) return
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await onBeforeReconnectRef.current?.()
|
|
247
|
+
} catch {
|
|
248
|
+
// Token refresh failed; still try to reconnect.
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (closed) return
|
|
252
|
+
if (shared !== sharedConn) return
|
|
253
|
+
|
|
254
|
+
const freshUrl = getNatsWsUrlRef.current()
|
|
255
|
+
if (freshUrl !== wsUrl) return
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
sharedConn.connectPromise = null
|
|
259
|
+
sharedConn.connectPromise = client.connect()
|
|
260
|
+
await sharedConn.connectPromise
|
|
261
|
+
if (!closed && shared === sharedConn) {
|
|
262
|
+
retryAttempt = 0
|
|
263
|
+
setIsConnected(true)
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
sharedConn.connectPromise = null
|
|
267
|
+
if (!closed && shared === sharedConn) {
|
|
268
|
+
scheduleRetry()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}, jitteredDelay)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const unsubscribeStatus = client.onStatus((event) => {
|
|
275
|
+
const connected = event.status === 'connected'
|
|
276
|
+
// `error` is a protocol-level signal (e.g. -ERR Permissions Violation when
|
|
277
|
+
// CONSUMER.CREATE is denied) that does NOT close the WebSocket. Treating
|
|
278
|
+
// it as a disconnect causes scheduleRetry() to fire on every -ERR, which
|
|
279
|
+
// re-runs onBeforeReconnect (auth refresh / `/api/me`) on a loop. Real
|
|
280
|
+
// transport loss arrives separately as `disconnected` or `closed`.
|
|
281
|
+
const disconnected = event.status === 'closed' || event.status === 'disconnected'
|
|
282
|
+
if (connected) {
|
|
283
|
+
setIsConnected(true)
|
|
284
|
+
if (hadConnectionBeforeRef.current) {
|
|
285
|
+
setReconnectionCount((c) => c + 1)
|
|
286
|
+
}
|
|
287
|
+
hadConnectionBeforeRef.current = true
|
|
288
|
+
retryAttempt = 0
|
|
289
|
+
onConnectRef.current?.()
|
|
290
|
+
}
|
|
291
|
+
if (event.status === 'error') {
|
|
292
|
+
// Subscription-level failures (e.g. consumer.get rejected by JetStream
|
|
293
|
+
// ACLs) already surface to the subscribe effect via the rejected
|
|
294
|
+
// promise; log here for diagnostics and let the existing WS connection
|
|
295
|
+
// keep running.
|
|
296
|
+
console.warn('[JetStream] NATS protocol error:', event.data)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
if (disconnected) {
|
|
300
|
+
setIsConnected(false)
|
|
301
|
+
setIsSubscribed(false)
|
|
302
|
+
|
|
303
|
+
if (subscriptionRef.current) {
|
|
304
|
+
try {
|
|
305
|
+
subscriptionRef.current.unsubscribe()
|
|
306
|
+
} catch {
|
|
307
|
+
// ignore
|
|
308
|
+
}
|
|
309
|
+
subscriptionRef.current = null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
onDisconnectRef.current?.()
|
|
313
|
+
scheduleRetry()
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
;(async () => {
|
|
318
|
+
try {
|
|
319
|
+
sharedConn.connectPromise ||= client.connect()
|
|
320
|
+
await sharedConn.connectPromise
|
|
321
|
+
if (!closed) {
|
|
322
|
+
setIsConnected(true)
|
|
323
|
+
hadConnectionBeforeRef.current = true
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
sharedConn.connectPromise = null
|
|
327
|
+
if (!closed) {
|
|
328
|
+
setIsConnected(false)
|
|
329
|
+
onDisconnectRef.current?.()
|
|
330
|
+
scheduleRetry()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
})()
|
|
334
|
+
|
|
335
|
+
return () => {
|
|
336
|
+
closed = true
|
|
337
|
+
setIsConnected(false)
|
|
338
|
+
setIsSubscribed(false)
|
|
339
|
+
unsubscribeStatus()
|
|
340
|
+
|
|
341
|
+
if (sharedConn.retryTimer) {
|
|
342
|
+
clearTimeout(sharedConn.retryTimer)
|
|
343
|
+
sharedConn.retryTimer = null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (subscriptionRef.current) {
|
|
347
|
+
try {
|
|
348
|
+
subscriptionRef.current.unsubscribe()
|
|
349
|
+
} catch {
|
|
350
|
+
// ignore
|
|
351
|
+
}
|
|
352
|
+
subscriptionRef.current = null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (clientRef.current && currentWsUrlRef.current) {
|
|
356
|
+
releaseClient(currentWsUrlRef.current)
|
|
357
|
+
clientRef.current = null
|
|
358
|
+
currentWsUrlRef.current = ''
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}, [enabled, getNatsWsUrl, acquireClient, releaseClient])
|
|
362
|
+
|
|
363
|
+
// Subscription lifecycle: (re)create the ephemeral JetStream consumer whenever
|
|
364
|
+
// we transition into a connected state for a dialog, and whenever the dialog
|
|
365
|
+
// changes. On reconnect we resume from highestStreamSeq + 1.
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (!enabled || !dialogId || !isConnected) {
|
|
368
|
+
if (subscriptionRef.current) {
|
|
369
|
+
try {
|
|
370
|
+
subscriptionRef.current.unsubscribe()
|
|
371
|
+
} catch {
|
|
372
|
+
// ignore
|
|
373
|
+
}
|
|
374
|
+
subscriptionRef.current = null
|
|
375
|
+
}
|
|
376
|
+
setIsSubscribed(false)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const client = clientRef.current
|
|
381
|
+
if (!client) return
|
|
382
|
+
|
|
383
|
+
const abortController = new AbortController()
|
|
384
|
+
const decoder = new TextDecoder()
|
|
385
|
+
const filterSubject = `chat.${dialogId}.${topic}`
|
|
386
|
+
|
|
387
|
+
const resumeSeq = highestStreamSeqRef.current
|
|
388
|
+
const initialOptStart = optStartSeqRef.current
|
|
389
|
+
const startSeq =
|
|
390
|
+
resumeSeq != null ? resumeSeq + 1 : initialOptStart != null ? initialOptStart + 1 : undefined
|
|
391
|
+
|
|
392
|
+
let cancelled = false
|
|
393
|
+
|
|
394
|
+
void (async () => {
|
|
395
|
+
try {
|
|
396
|
+
const handle = await client.subscribeJetStreamOrdered(
|
|
397
|
+
(msg) => {
|
|
398
|
+
if (cancelled) return
|
|
399
|
+
const streamSeq = msg.info.streamSequence
|
|
400
|
+
if (typeof streamSeq === 'number') {
|
|
401
|
+
if (
|
|
402
|
+
highestStreamSeqRef.current == null ||
|
|
403
|
+
streamSeq > highestStreamSeqRef.current
|
|
404
|
+
) {
|
|
405
|
+
highestStreamSeqRef.current = streamSeq
|
|
406
|
+
setCurrentStreamSeq(streamSeq)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const cb = onEventRef.current
|
|
410
|
+
if (!cb) return
|
|
411
|
+
try {
|
|
412
|
+
const parsed = JSON.parse(decoder.decode(msg.data)) as Record<string, unknown>
|
|
413
|
+
if (typeof streamSeq === 'number') {
|
|
414
|
+
;(parsed as { streamSeq?: number }).streamSeq = streamSeq
|
|
415
|
+
}
|
|
416
|
+
cb(parsed, topic)
|
|
417
|
+
} catch {
|
|
418
|
+
// Ignore malformed payloads.
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
streamName,
|
|
423
|
+
filterSubject,
|
|
424
|
+
deliverPolicy: startSeq != null ? 'byStartSequence' : 'new',
|
|
425
|
+
optStartSeq: startSeq,
|
|
426
|
+
inactiveThresholdMs: inactiveThresholdRef.current ?? DEFAULT_INACTIVE_THRESHOLD_MS,
|
|
427
|
+
signal: abortController.signal,
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if (cancelled) {
|
|
432
|
+
try {
|
|
433
|
+
handle.unsubscribe()
|
|
434
|
+
} catch {
|
|
435
|
+
// ignore
|
|
436
|
+
}
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
subscriptionRef.current = handle
|
|
441
|
+
setIsSubscribed(true)
|
|
442
|
+
onSubscribedRef.current?.()
|
|
443
|
+
} catch {
|
|
444
|
+
if (!cancelled) {
|
|
445
|
+
setIsSubscribed(false)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
})()
|
|
449
|
+
|
|
450
|
+
return () => {
|
|
451
|
+
cancelled = true
|
|
452
|
+
abortController.abort()
|
|
453
|
+
if (subscriptionRef.current) {
|
|
454
|
+
try {
|
|
455
|
+
subscriptionRef.current.unsubscribe()
|
|
456
|
+
} catch {
|
|
457
|
+
// ignore
|
|
458
|
+
}
|
|
459
|
+
subscriptionRef.current = null
|
|
460
|
+
}
|
|
461
|
+
setIsSubscribed(false)
|
|
462
|
+
}
|
|
463
|
+
}, [enabled, dialogId, isConnected, streamName, topic, reconnectionCount])
|
|
464
|
+
|
|
465
|
+
// Reset the highest-seen sequence whenever the dialog changes so a new dialog
|
|
466
|
+
// starts from optStartSeq (or DeliverPolicy.New) rather than the previous
|
|
467
|
+
// dialog's offset.
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
highestStreamSeqRef.current = null
|
|
470
|
+
setCurrentStreamSeq(null)
|
|
471
|
+
}, [dialogId])
|
|
472
|
+
|
|
473
|
+
return { isConnected, isSubscribed, reconnectionCount, currentStreamSeq }
|
|
474
|
+
}
|
|
@@ -90,6 +90,51 @@ export interface UseNatsDialogSubscriptionReturn {
|
|
|
90
90
|
reconnectionCount: number
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
export interface UseJetStreamDialogSubscriptionOptions {
|
|
94
|
+
enabled: boolean
|
|
95
|
+
dialogId: string | null
|
|
96
|
+
/** JetStream stream name. Default: 'CHAT_CHUNKS'. */
|
|
97
|
+
streamName?: string
|
|
98
|
+
/** Single topic to subscribe to. */
|
|
99
|
+
topic: NatsMessageType
|
|
100
|
+
/**
|
|
101
|
+
* Resume from this JetStream sequence + 1. When null/undefined, the consumer starts with
|
|
102
|
+
* DeliverPolicy.New (live tail only).
|
|
103
|
+
*/
|
|
104
|
+
optStartSeq?: number | null
|
|
105
|
+
onEvent?: (payload: unknown, messageType: NatsMessageType) => void
|
|
106
|
+
onConnect?: () => void
|
|
107
|
+
onDisconnect?: () => void
|
|
108
|
+
onSubscribed?: () => void
|
|
109
|
+
/** Called on disconnect, before reconnect attempt. Use to refresh auth. */
|
|
110
|
+
onBeforeReconnect?: () => Promise<void> | void
|
|
111
|
+
/** Build the NATS WebSocket URL (or null when not yet available). */
|
|
112
|
+
getNatsWsUrl: () => string | null
|
|
113
|
+
clientConfig?: {
|
|
114
|
+
name?: string
|
|
115
|
+
user?: string
|
|
116
|
+
pass?: string
|
|
117
|
+
}
|
|
118
|
+
reconnectionBackoff?: {
|
|
119
|
+
fastRetries?: number
|
|
120
|
+
fastRetryDelayMs?: number
|
|
121
|
+
initialDelayMs?: number
|
|
122
|
+
maxDelayMs?: number
|
|
123
|
+
multiplier?: number
|
|
124
|
+
}
|
|
125
|
+
/** Consumer inactivity threshold in ms before NATS auto-cleans it. Default: 5 minutes. */
|
|
126
|
+
inactiveThresholdMs?: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface UseJetStreamDialogSubscriptionReturn {
|
|
130
|
+
isConnected: boolean
|
|
131
|
+
isSubscribed: boolean
|
|
132
|
+
/** Incremented each time the underlying NATS connection reconnects. Starts at 0. */
|
|
133
|
+
reconnectionCount: number
|
|
134
|
+
/** Highest JetStream stream sequence observed so far (null before first delivery). */
|
|
135
|
+
currentStreamSeq: number | null
|
|
136
|
+
}
|
|
137
|
+
|
|
93
138
|
export interface SegmentsUpdateMetadata {
|
|
94
139
|
/** Segments should be appended to the last assistant message */
|
|
95
140
|
append?: boolean
|
|
@@ -34,6 +34,10 @@ export type NetworkConfig = typeof NETWORK_CONFIG
|
|
|
34
34
|
|
|
35
35
|
export interface ChunkData {
|
|
36
36
|
sequenceId?: number
|
|
37
|
+
/** JetStream stream sequence, populated from JsMsg.info.streamSequence when delivered via JetStream. */
|
|
38
|
+
streamSeq?: number
|
|
39
|
+
/** Authoritative dialog streaming state at the time this chunk was emitted; consumers should mirror it into their dialog cache. */
|
|
40
|
+
streamState?: 'IDLE' | 'STREAMING'
|
|
37
41
|
type: string
|
|
38
42
|
text?: string
|
|
39
43
|
integratedToolType?: string
|
|
@@ -17,11 +17,23 @@ export const TicketCardSkeleton = React.forwardRef<HTMLDivElement, TicketCardSke
|
|
|
17
17
|
{...props}
|
|
18
18
|
>
|
|
19
19
|
<div className="flex items-start gap-[var(--spacing-system-sf)]">
|
|
20
|
-
<div className="flex flex-1 flex-col gap-[var(--spacing-system-
|
|
21
|
-
<
|
|
22
|
-
|
|
20
|
+
<div className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-system-xxs)]">
|
|
21
|
+
<div className="text-h3 flex items-center">
|
|
22
|
+
<Skeleton className="h-4 w-3/4" />
|
|
23
|
+
</div>
|
|
24
|
+
<div className="text-h6 flex items-center gap-[var(--spacing-system-xxs)]">
|
|
25
|
+
<Skeleton className="size-4 shrink-0 rounded-sm" />
|
|
26
|
+
<Skeleton className="h-3 w-1/2" />
|
|
27
|
+
</div>
|
|
23
28
|
</div>
|
|
24
|
-
<
|
|
29
|
+
<div className="flex shrink-0 items-center gap-[var(--spacing-system-xsf)]">
|
|
30
|
+
<Skeleton className="size-4 rounded-sm" />
|
|
31
|
+
<Skeleton className="size-8 rounded-full" />
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="flex h-8 items-center gap-[var(--spacing-system-xxs)]">
|
|
35
|
+
<Skeleton className="h-8 w-16 rounded-md" />
|
|
36
|
+
<Skeleton className="h-8 w-12 rounded-md" />
|
|
25
37
|
</div>
|
|
26
38
|
</div>
|
|
27
39
|
),
|
|
@@ -99,7 +99,7 @@ export function TicketCard({
|
|
|
99
99
|
const body = (
|
|
100
100
|
<>
|
|
101
101
|
<div className="flex items-start gap-[var(--spacing-system-sf)]">
|
|
102
|
-
<div className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-system-
|
|
102
|
+
<div className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-system-zero)]">
|
|
103
103
|
<p className="text-h3 truncate text-ods-text-primary">{ticket.title}</p>
|
|
104
104
|
{showDeviceRow && (
|
|
105
105
|
<div className="flex min-w-0 items-center gap-[var(--spacing-system-xxs)] text-h6 text-ods-text-secondary">
|
|
@@ -129,10 +129,12 @@ export function TicketCard({
|
|
|
129
129
|
...(isOverlay ? {} : sortable.listeners),
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
const innerWrapperClass = 'relative z-10 flex flex-col gap-[var(--spacing-system-sf)]'
|
|
133
|
+
|
|
132
134
|
if (isOverlay) {
|
|
133
135
|
return (
|
|
134
136
|
<div {...outerProps}>
|
|
135
|
-
<div className=
|
|
137
|
+
<div className={innerWrapperClass}>{body}</div>
|
|
136
138
|
</div>
|
|
137
139
|
)
|
|
138
140
|
}
|
|
@@ -148,7 +150,7 @@ export function TicketCard({
|
|
|
148
150
|
aria-label={ticket.title}
|
|
149
151
|
className="absolute inset-0 z-0 rounded-md focus-visible:outline-none"
|
|
150
152
|
/>
|
|
151
|
-
<div className=
|
|
153
|
+
<div className={cn('pointer-events-none', innerWrapperClass)}>{body}</div>
|
|
152
154
|
</div>
|
|
153
155
|
)
|
|
154
156
|
}
|
|
@@ -161,7 +163,7 @@ export function TicketCard({
|
|
|
161
163
|
aria-label={ticket.title}
|
|
162
164
|
className="absolute inset-0 z-0 cursor-pointer rounded-md focus-visible:outline-none"
|
|
163
165
|
/>
|
|
164
|
-
<div className=
|
|
166
|
+
<div className={cn('pointer-events-none', innerWrapperClass)}>{body}</div>
|
|
165
167
|
</div>
|
|
166
168
|
)
|
|
167
169
|
}
|