Repository navigation
Expand file tree
/
Copy pathuseChatSession.ts
More file actions
1642 lines (1495 loc) · 63.3 KB
/
Copy pathuseChatSession.ts
File metadata and controls
1642 lines (1495 loc) · 63.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// ============================================
// useChatSession - 聊天会话管理
// ============================================
import { useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import {
messageStore,
useSessionFamily,
useSessionState,
autoApproveStore,
childSessionStore,
useActiveSessionStore,
type RevertHistoryItem,
} from '../store'
import {
useSessionManager,
registerSessionConsumer,
updateConsumerSessionId,
hasOtherConsumerForSession,
flushPendingRequests,
} from '../hooks'
import { usePermissions, usePermissionHandler, useMessageAnimation, useDirectory, useSessionContext, useAgents } from '../hooks'
import { useNotification } from './useNotification'
import { notificationEventSettingsStore } from '../store/notificationEventSettingsStore'
import { notificationStore } from '../store/notificationStore'
import {
sendMessageAsync,
getSessionMessages,
abortSession,
getPendingPermissions,
getPendingForms,
prefetchCommands,
prefetchRootDirectory,
getSessionChildren,
executeCommand,
summarizeSession,
forkSession,
exportSession,
getSessionInbox,
cancelInboxItem,
updateInboxDelivery,
executeSessionShell,
type ApiFormInfo,
type ApiPermissionRequest,
type ApiSession,
type Attachment,
type Model,
} from '../api'
import { sendAdmission, outboxAdd, outboxHas, outboxTryRollback } from '../api/sendAdmission'
import { buildEditedPromptInput, confirmQueueEdit, queuedPromptAttachments, rewriteQueueOrder } from '../api/queue'
import { queueMutationSettled, type QueueMutation } from '../features/chat/queueProjection'
import { buildPromptParts } from '../api/message'
import i18n from '../i18n'
import { sessionDisplayTitle } from '../utils/sessionTitle'
import {
assistantText,
isAssistantMessage,
isUserMessage,
type PromptAgentAttachment,
type PromptFileAttachment,
type SessionMessageInfo,
type UserMessage,
} from '../types/api/message'
import type { APIError } from '../types/api/common'
import { clipboardErrorHandler, copyTextToClipboard, createErrorHandler } from '../utils'
import { saveData } from '../utils/downloadUtils'
import { clearSessionRuntimeState } from '../utils/sessionLifecycle'
import { serverStorage } from '../utils/perServerStorage'
import { makeSessionKey, sessionKeyToServerId, splitSessionKey } from '../utils/sessionKey'
import { sweepTaskChildBindings, verifyTaskChildBindings } from '../utils/taskChildBinding'
import { createMessageId } from '../utils/identifier'
import { serverStore } from '../store/serverStore'
import { STORAGE_KEY_SELECTED_AGENT } from '../constants'
import type { ChatAreaHandle } from '../features/chat'
import { inboxStore, useInboxUserPrompts, queuedPromptText } from '../store/inboxStore'
import type { QueuedUserPrompt } from '../store/inboxStore'
import { themeStore } from '../store/themeStore'
const handleError = createErrorHandler('session')
/**
* Stable empty session state singleton.
*
* When routeSessionId is null (e.g. an empty split pane), useChatSession
* uses this instead of creating a new object on every render. A fresh
* literal `{ messages: [], ... }` would give a different reference each
* time, defeating React.memo on ChatArea and causing pointless re-renders
* of the entire message tree.
*/
const EMPTY_SESSION_STATE = {
messages: [] as SessionMessageInfo[],
isStreaming: false,
loadState: 'idle' as const,
loadError: undefined as APIError | undefined,
revertState: null,
canUndo: false,
canRedo: false,
redoSteps: 0,
revertedContent: null,
hasMoreHistory: false,
directory: '',
title: null,
model: undefined,
} as const
interface UseChatSessionOptions {
paneId: string
chatAreaRef: React.RefObject<ChatAreaHandle | null>
currentModel: Model | undefined
refetchModels: () => Promise<void>
sessionId: string | null
navigateToSession: (sessionId: string, directory?: string) => void
navigateHome: () => void
/** /btw 侧问请求(官方 session.btw 同款):打开侧问对话框,args 预填问题 */
onBtwRequest?: (question?: string) => void
}
interface LiveRetryStatus {
sessionID: string
attempt: number
message: string
next: number
}
export function useChatSession({
paneId,
chatAreaRef,
currentModel,
refetchModels,
sessionId: routeSessionId,
navigateToSession,
navigateHome,
onBtwRequest,
}: UseChatSessionOptions) {
const { statusMap } = useActiveSessionStore()
const { queueFollowupMessages } = useSyncExternalStore(themeStore.subscribe, themeStore.getSnapshot)
// busy 时的投递方式(PiUI 输入栏 steer/follow-up 切换同款):默认跟随全局
// queueFollowupMessages 设置,流式期间可在输入栏即时切换
const [busyDelivery, setBusyDelivery] = useState<'queue' | 'steer'>(queueFollowupMessages ? 'queue' : 'steer')
useEffect(() => {
setBusyDelivery(queueFollowupMessages ? 'queue' : 'steer')
}, [queueFollowupMessages])
// 队列编辑状态(官方 queue.edit 的 editing 同款):
// item=正在编辑的队列条目;stashRestoreNonce=取消编辑时恢复输入框暂存的触发键
const [queueEditing, setQueueEditing] = useState<{ item: QueuedUserPrompt; nonce: number } | null>(null)
const [stashRestoreNonce, setStashRestoreNonce] = useState(0)
// 队列 mutation 投影(官方 rows() 的 mutation.isPending 窗口同款):
// 编辑/重排期间队列直接显示期望形态,服务端事件落地无跳变
const [queueMutation, setQueueMutation] = useState<QueueMutation | null>(null)
// Agents
const [selectedAgent, setSelectedAgentRaw] = useState<string>(
() => serverStorage.get(`${STORAGE_KEY_SELECTED_AGENT}:${paneId}`) || '',
)
const [restoredContent, setRestoredContent] = useState<{ sessionId: string; content: RevertHistoryItem } | null>(null)
const setSelectedAgent = useCallback(
(agentName: string) => {
setSelectedAgentRaw(agentName)
serverStorage.set(`${STORAGE_KEY_SELECTED_AGENT}:${paneId}`, agentName)
},
[paneId],
)
// Hooks
const { resetPermissions } = usePermissions()
const { currentDirectory } = useDirectory()
const { createSession, sessions } = useSessionContext()
const { sendNotification } = useNotification()
const routeStatus = routeSessionId ? statusMap[routeSessionId] : undefined
const routeSessionIdRef = useRef(routeSessionId)
useEffect(() => {
routeSessionIdRef.current = routeSessionId
}, [routeSessionId])
/**
* 当前 pane 绑定的服务器(sessionId 为复合 key,split 出 serverId;home 状态跟随 active server)。
* 约定:凡发起服务器作用域请求的 memo/effect/回调都必须声明 paneServerId——
* 它的值会在 pane 生命周期内变化(切会话、活动服务器切换、WSL sidecar 就绪后切回),
* 漏声明就会把请求打到旧服务器。被 routeSessionId 守卫的单元除外:此时 paneServerId 是它的纯函数。
*/
const activeServerId = useSyncExternalStore(
cb => serverStore.subscribe(cb),
() => serverStore.getActiveServerId(),
() => serverStore.getActiveServerId(),
)
const paneServerId = useMemo(
() => (routeSessionId ? sessionKeyToServerId(routeSessionId) : activeServerId),
[routeSessionId, activeServerId],
)
// agents 目录:共享 store(按 server+directory 缓存,多 pane 不重复拉取);
// 健康度兜底与事件刷新由 useGlobalEvents 统一驱动(对齐官方 location store)
const { agents, refetch: refetchAgents } = useAgents(currentDirectory, paneServerId)
const handleMissingRouteSession = useCallback(
(missingSessionId: string) => {
if (routeSessionIdRef.current !== missingSessionId) return
clearSessionRuntimeState(missingSessionId)
navigateHome()
},
[navigateHome],
)
// 服务端 inbox 队列(官方 session.pending 的对应物):排队中的用户 prompt
const inboxUserPrompts = useInboxUserPrompts(routeSessionId)
// 队列视图只列 queue 条目(官方 queue.ts:83 同款过滤);steer 条目是瞬态的,
// 其回声由转写投影(projectQueueEchoes)挪到末尾展示
const queuedPrompts = useMemo(() => inboxUserPrompts.filter(item => item.delivery === 'queue'), [inboxUserPrompts])
const perSessionStateRaw = useSessionState(routeSessionId)
const perSessionState = perSessionStateRaw ?? EMPTY_SESSION_STATE
const messages = perSessionState.messages
const messagesRef = useRef(messages)
messagesRef.current = messages
const isStreaming = perSessionState.isStreaming
const sessionDirectory = perSessionState.directory
/** durable 会话级模型(进入会话时对齐输入框模型的权威来源) */
const sessionModel = perSessionState.model
const canUndo = perSessionState.canUndo
const canRedo = perSessionState.canRedo
const redoSteps = perSessionState.redoSteps
const revertedContent = perSessionState.revertedContent
const hasMoreHistory = perSessionState.hasMoreHistory
const loadState = routeSessionId ? perSessionState.loadState : ('idle' as const)
const loadError = routeSessionId ? perSessionState.loadError : undefined
// OpenAPI SessionStatus.retry: { attempt, message, next }
const retryStatus = useMemo<LiveRetryStatus | null>(() => {
if (!routeSessionId || routeStatus?.type !== 'retry') return null
return {
sessionID: routeSessionId,
attempt: routeStatus.attempt,
message: routeStatus.message,
next: routeStatus.next,
}
}, [routeSessionId, routeStatus])
// 注意:不能用 Boolean(routeStatus) 判断忙——{type:'idle'} 也是真值,
// 会让会话在出现任何状态条目后永久"忙",所有消息进队列且永不发出。
const isSessionBusy = useMemo(
() => routeStatus?.type === 'busy' || routeStatus?.type === 'retry' || isStreaming,
[routeStatus, isStreaming],
)
const getSessionTitle = useCallback(
(sessionId?: string) => {
const session = sessions.find(s => s.id === sessionId)
if (session) {
// 官方 displayLabel 同款兜底(无标题/历史时间戳标题 → 简洁标签)
return sessionDisplayTitle(session, {
newSession: i18n.t('commands:sessions.newSession'),
childSession: i18n.t('commands:sessions.childSession'),
})
}
return 'OpenCode'
},
[sessions],
)
const buildNotificationTitle = useCallback(
(sessionId: string | undefined, label: string) => {
const base = getSessionTitle(sessionId)
return `${base} - ${label}`
},
[getSessionTitle],
)
// Session family for permission polling
const sessionFamily = useSessionFamily(routeSessionId)
// Session Manager
const { loadSession, loadMoreHistory, handleUndo, handleRedo, handleRedoAll, clearRevert } = useSessionManager({
sessionId: routeSessionId,
directory: currentDirectory,
onSessionMissing: handleMissingRouteSession,
})
// Permission handling
const {
pendingPermissionRequests,
pendingQuestionRequests: pendingFormRequests,
setPendingPermissionRequests,
setPendingQuestionRequests,
handlePermissionReply,
handleFormReply,
handleFormCancel,
refreshPendingRequests,
resetPendingRequests,
isReplying,
} = usePermissionHandler(paneServerId)
// v2 的表单按 metadata.kind 分流(官方 requests/model.ts:44-58):
// websearch.provider → 专用 dock;其余 → 通用 QuestionDialog
const pendingWebsearchRequest = useMemo(
() => pendingFormRequests.find(form => (form.metadata as Record<string, unknown> | undefined)?.kind === 'websearch.provider'),
[pendingFormRequests],
)
const pendingQuestionRequests = useMemo(
() => pendingFormRequests.filter(form => (form.metadata as Record<string, unknown> | undefined)?.kind !== 'websearch.provider'),
[pendingFormRequests],
)
// Prevent infinite retry loops when auto-approve API calls fail
// but the server may have already processed the request (lost response).
const autoRetriedIdsRef = useRef(new Set<string>())
// Message animations
const { registerMessage, registerInputBox, animateUndo, animateRedo } = useMessageAnimation()
// Effective directory (used in multiple places)
const effectiveDirectory = sessionDirectory || currentDirectory
const fullAutoMode = useSyncExternalStore(
cb => autoApproveStore.onFullAutoChange(cb),
() => autoApproveStore.getPaneFullAutoMode(paneId),
)
const approvePendingOnFullAuto = useSyncExternalStore(
autoApproveStore.subscribe,
() => autoApproveStore.approvePendingOnFullAuto,
)
const replyPermissionOnceAutomatically = useCallback(
(request: ApiPermissionRequest) => {
if (!autoApproveStore.claimAutoReply(request.id)) return
void handlePermissionReply(request.id, 'once', effectiveDirectory, request.sessionID).then(success => {
if (!success) {
autoApproveStore.releaseAutoReply(request.id)
// Retry once on failure. handlePermissionReply already retries 3× via withRetry,
// but the server may have processed the request and the response was lost.
// Force effect re-run by creating a new array reference.
if (!autoRetriedIdsRef.current.has(request.id)) {
autoRetriedIdsRef.current.add(request.id)
setPendingPermissionRequests(prev => [...prev])
}
}
})
},
[effectiveDirectory, handlePermissionReply, setPendingPermissionRequests],
)
// Clear retry tracking on each auto-approve batch
useEffect(() => {
autoRetriedIdsRef.current.clear()
}, [approvePendingOnFullAuto, fullAutoMode])
useEffect(() => {
if (!routeSessionId || !approvePendingOnFullAuto || fullAutoMode !== 'session') return
void refreshPendingRequests(sessionFamily, effectiveDirectory)
}, [
approvePendingOnFullAuto,
effectiveDirectory,
fullAutoMode,
refreshPendingRequests,
routeSessionId,
sessionFamily,
])
useEffect(() => {
if (!approvePendingOnFullAuto || fullAutoMode === 'off' || pendingPermissionRequests.length === 0) return
for (const request of pendingPermissionRequests) {
replyPermissionOnceAutomatically(request)
}
}, [approvePendingOnFullAuto, fullAutoMode, pendingPermissionRequests, replyPermissionOnceAutomatically])
const buildLocalQueuedMessage = useCallback(
(input: {
sessionId: string
messageId: string
text: string
attachments: Attachment[]
agent?: string
model: { providerID: string; modelID: string; variant?: string }
createdAt: number
}): UserMessage => {
// v2 的用户消息自带 text / files / agents(不再有 parts 数组)
const files: PromptFileAttachment[] = []
const agents: PromptAgentAttachment[] = []
for (const attachment of input.attachments) {
const mention = attachment.textRange
? {
start: attachment.textRange.start,
end: attachment.textRange.end,
text: attachment.textRange.value,
}
: undefined
if (attachment.type === 'agent') {
agents.push({
name: attachment.agentName || attachment.displayName,
mention,
})
continue
}
if (attachment.type !== 'file' && attachment.type !== 'folder') continue
files.push({
// 本地乐观消息只用于展示;真实的 prompt 由 sendMessageAsync 组装,
// 因此 data 留空、URL 走 source.uri(与 AttachmentPartViews 的读取一致)
data: '',
mime: attachment.mime || (attachment.type === 'folder' ? 'application/x-directory' : 'text/plain'),
source: attachment.url ? { type: 'uri', uri: attachment.url } : { type: 'inline' },
name: attachment.displayName,
mention,
})
}
return {
id: input.messageId,
type: 'user',
time: { created: input.createdAt },
text: input.text,
...(files.length > 0 ? { files } : {}),
...(agents.length > 0 ? { agents } : {}),
}
},
[],
)
/**
* 本地 inbox 条目(官方 queue.ts 的 admitLocal 同款):busy 时发送的那一刻
* 就把条目塞进 inboxStore,转写投影立刻隐藏回声 → 队列气泡一步到位,
* 不会先闪一下实心用户气泡再跳回队列。
* 服务端 session.inbox.enqueued 到达时以同一 inboxID upsert,字段补齐。
*/
const admitLocalInboxItem = useCallback(
(
sessionId: string,
item: {
inboxId: string
text: string
delivery: 'queue' | 'steer'
attachments: Attachment[]
agent?: string
},
) => {
const files: PromptFileAttachment[] = []
const agents: PromptAgentAttachment[] = []
for (const attachment of item.attachments) {
const mention = attachment.textRange
? {
start: attachment.textRange.start,
end: attachment.textRange.end,
text: attachment.textRange.value,
}
: undefined
if (attachment.type === 'agent') {
agents.push({ name: attachment.agentName || attachment.displayName, mention })
continue
}
if (attachment.type !== 'file' && attachment.type !== 'folder') continue
files.push({
data: '',
mime: attachment.mime || (attachment.type === 'folder' ? 'application/x-directory' : 'text/plain'),
source: attachment.url ? { type: 'uri', uri: attachment.url } : { type: 'inline' },
name: attachment.displayName,
mention,
})
}
inboxStore.upsertItem(sessionId, {
id: item.inboxId,
sessionID: sessionId,
time: { created: Date.now() },
type: 'user',
delivery: item.delivery,
payload: {
text: item.text,
...(files.length > 0 ? { files } : {}),
...(agents.length > 0 ? { agents } : {}),
},
} as Parameters<typeof inboxStore.upsertItem>[1])
},
[],
)
// ============================================
// SSE 事件回调(permission / question / scroll / idle / error / reconnect)
// 每个 pane 都注册自己的 consumer,由 App 顶层统一建立 SSE 连接
// ============================================
/**
* 请求所属 session 是否就是本 pane 正在看的会话(含其子 / 孙会话)。
*
* 用于系统通知去重:属于当前会话 family 的权限 / 提问会**挂在这个 pane 的
* 界面上**(主会话里的子会话权限就是这种),用户在看着它,不该再收到一条
* 通知。父子关系从子会话创建起就存在(session.created 与 REST session
* 列表同源),这里直接复用 childSessionStore 的判定。
*/
const isWithinCurrentSession = useCallback(
(sessionId: string) => {
if (!routeSessionId) return false
const scoped = sessionId.includes('::') ? sessionId : makeSessionKey(paneServerId, sessionId)
if (scoped === routeSessionId) return true
return childSessionStore.belongsToSession(scoped, routeSessionId)
},
[routeSessionId, paneServerId],
)
const sseCallbacks = useMemo(
() => ({
onPermissionAsked: (request: import('../api').ApiPermissionRequest) => {
// Full Auto 会话级:当前 session 的 handler 天然只处理当前 session 的请求
const effectiveFullAutoMode = autoApproveStore.getPaneFullAutoMode(paneId)
if (effectiveFullAutoMode === 'session') {
replyPermissionOnceAutomatically(request)
return
}
// 自动批准检查(实验性功能)
if (
autoApproveStore.enabled &&
autoApproveStore.shouldAutoApprove(request.sessionID, request.action, request.resources)
) {
// 匹配规则,自动用 once 批准,不弹框
replyPermissionOnceAutomatically(request)
return
}
setPendingPermissionRequests(prev => {
if (prev.some(r => r.id === request.id)) return prev
return [...prev, request]
})
// 系统通知只在「这条请求不在你眼前的会话里」时才发:
// 请求所属 session 的父链上就是这个 pane 正在看的会话 → 权限卡片已经
// 挂在当前界面(主会话里的子会话就属于这种),再弹通知就是重复打扰。
// 父子关系从子会话创建起就存在(session.created / session 列表同源),
// 这里直接复用 childSessionStore 的判定即可。
const permDesc = request.resources?.length ? `${request.action}: ${request.resources[0]}` : request.action
if (notificationEventSettingsStore.isSystemEnabled('permission') && !isWithinCurrentSession(request.sessionID)) {
const title = buildNotificationTitle(request.sessionID, 'Permission Required')
sendNotification(title, permDesc, {
sessionId: request.sessionID,
directory: effectiveDirectory,
})
}
// 应用内 toast 已在 useGlobalEvents 中统一处理
},
onPermissionReplied: (data: { sessionID: string; requestID: string }) => {
setPendingPermissionRequests(prev =>
prev.some(r => r.id === data.requestID) ? prev.filter(r => r.id !== data.requestID) : prev,
)
},
onFormCreated: (form: ApiFormInfo) => {
setPendingQuestionRequests(prev => {
if (prev.some(r => r.id === form.id)) return prev
return [...prev, form]
})
// 同权限:本 pane 就会展示的提问不再重复发系统通知
const questionDesc = form.title || 'AI is waiting for your input'
if (notificationEventSettingsStore.isSystemEnabled('question') && !isWithinCurrentSession(form.sessionID)) {
const title = buildNotificationTitle(form.sessionID, 'Question')
sendNotification(title, questionDesc, {
sessionId: form.sessionID,
directory: effectiveDirectory,
})
}
// 应用内 toast 已在 useGlobalEvents 中统一处理
},
onFormReplied: (data: { sessionID: string; formID: string }) => {
setPendingQuestionRequests(prev => prev.filter(r => r.id !== data.formID))
},
onFormCancelled: (data: { sessionID: string; formID: string }) => {
setPendingQuestionRequests(prev => prev.filter(r => r.id !== data.formID))
},
onScrollRequest: () => {
chatAreaRef.current?.scrollToBottomIfAtBottom()
},
onSessionIdle: (sessionID: string) => {
// 页面不在前台时发送浏览器通知
const title = buildNotificationTitle(sessionID, 'Session completed')
if (notificationEventSettingsStore.isSystemEnabled('completed')) {
sendNotification(title, 'Session completed', {
sessionId: sessionID,
directory: effectiveDirectory,
})
}
// 应用内 toast 已在 useGlobalEvents 中统一处理
},
onExecutionFailed: (sessionID: string) => {
// 页面不在前台时通知用户 session 出错(v2:execution.failed)
const title = buildNotificationTitle(sessionID, 'Session error')
if (notificationEventSettingsStore.isSystemEnabled('error')) {
sendNotification(title, 'Session error', {
sessionId: sessionID,
directory: effectiveDirectory,
})
}
// 应用内 toast 已在 useGlobalEvents 中统一处理
},
onReconnected: (_reason: 'network' | 'server-switch') => {
messageStore.markAllSessionsStale()
// SSE 重连后重新加载当前会话,补齐断连期间可能丢失的消息
if (routeSessionId) {
// 使用 force 模式,确保覆盖本地可能不完整的数据
loadSession(routeSessionId, { force: true })
// 重连后刷新待处理的权限请求和问题,避免用户错过后台产生的请求
refreshPendingRequests(sessionFamily, effectiveDirectory)
// 断连期间的 inbox 增删事件已丢失,补拉队列快照(官方 pending.sync 同款)
void syncInboxSnapshotRef.current(routeSessionId)
}
refetchModels().catch(() => {})
// 重新获取 agents 列表(切换后端时 currentDirectory 可能没变,useEffect 不会触发)
refetchAgents().catch(() => {})
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs and stable functions
[
paneId,
paneServerId,
effectiveDirectory,
routeSessionId,
sessionFamily,
currentDirectory,
replyPermissionOnceAutomatically,
setPendingPermissionRequests,
setPendingQuestionRequests,
buildNotificationTitle,
sendNotification,
isWithinCurrentSession,
loadSession,
refreshPendingRequests,
refetchModels,
refetchAgents,
],
)
// 保存 callbacks ref 供 consumer 注册使用(避免频繁重新注册)
const sseCallbacksRef = useRef(sseCallbacks)
useEffect(() => {
sseCallbacksRef.current = sseCallbacks
}, [sseCallbacks])
// 注册 pane 级 consumer,SSE 事件按 sessionId 分发到此
useEffect(() => {
const unregister = registerSessionConsumer(paneId, routeSessionId, {
onPermissionAsked: req => sseCallbacksRef.current.onPermissionAsked(req),
onPermissionReplied: data => sseCallbacksRef.current.onPermissionReplied(data),
onFormCreated: form => sseCallbacksRef.current.onFormCreated(form),
onFormReplied: data => sseCallbacksRef.current.onFormReplied(data),
onFormCancelled: data => sseCallbacksRef.current.onFormCancelled(data),
onScrollRequest: () => sseCallbacksRef.current.onScrollRequest(),
onSessionIdle: sid => sseCallbacksRef.current.onSessionIdle(sid),
onExecutionFailed: sid => sseCallbacksRef.current.onExecutionFailed(sid),
onReconnected: reason => sseCallbacksRef.current.onReconnected(reason),
})
return unregister
}, [paneId, routeSessionId])
// sessionId 变化时更新 consumer 关注的 session(无需重新注册)
useEffect(() => {
updateConsumerSessionId(paneId, routeSessionId)
}, [paneId, routeSessionId])
const handleVisibleMessageIdsChange = useCallback((_ids: string[]) => {
// No-op: parts are always in memory now
}, [])
// Preload @ root directory and / commands for current session directory
useEffect(() => {
if (!routeSessionId || !effectiveDirectory) return
prefetchRootDirectory(effectiveDirectory, paneServerId).catch(() => {})
prefetchCommands(effectiveDirectory, paneServerId).catch(() => {})
}, [routeSessionId, effectiveDirectory, paneServerId])
// agents 列表加载后,校验当前选中的 agent 是否存在于列表中
// 选择值统一为 agent **id**(v2 API 用 id);历史存储的显示名会被归一化成 id
useEffect(() => {
if (agents.length === 0) return
const primaryAgents = agents.filter(a => a.mode !== 'subagent' && !a.hidden)
if (primaryAgents.length === 0) return
const matched = selectedAgent
? primaryAgents.find(a => a.id === selectedAgent || a.name === selectedAgent)
: undefined
if (matched) {
if (matched.id !== selectedAgent) {
const frameId = requestAnimationFrame(() => setSelectedAgent(matched.id))
return () => cancelAnimationFrame(frameId)
}
return
}
// 否则选第一个 primary agent
const frameId = requestAnimationFrame(() => {
setSelectedAgent(primaryAgents[0].id)
})
return () => cancelAnimationFrame(frameId)
}, [agents, selectedAgent, setSelectedAgent])
// Load child sessions and pending permissions on session change
// 页面刷新时 childSessionStore 是空的,需要先从 API 恢复子 session 关系
// 然后再加载权限请求(包括子 session 的权限)
// 切换 session 时同样要清空旧 session 的 pending 权限/问题,否则 A 会话的请求会残留在 B 会话
useEffect(() => {
resetPendingRequests()
if (!routeSessionId) {
return
}
let cancelled = false
async function loadChildSessionsAndPermissions() {
// Step 1: 恢复子 session 关系(如果 store 中还没有)
const existingChildren = childSessionStore.getChildSessionIds(routeSessionId!)
if (existingChildren.length === 0) {
try {
const children = await getSessionChildren(routeSessionId!, effectiveDirectory, paneServerId)
if (cancelled) return
// 注册所有子 session 到 store
for (const child of children) {
childSessionStore.registerChildSession(child, paneServerId)
}
// 父子关系刚由 REST 建立:此前因「不知道子会话归属」而只弹了 toast、
// 被缓存下来的权限/表单请求,现在补投进 pane(否则要刷新页面才挂上)
flushPendingRequests()
} catch {
// 获取子 session 失败不影响主流程
}
}
// 刷新/重进后运行中的 task 卡片 metadata 里没有 sessionID(progress 不落库):
// 先一对一同步直绑,剩下的用 input.prompt 与子 session 首条用户消息
// 逐字比对精确绑定(在线时由 tool.progress 的 metadata.sessionID 精确下发,
// 这里是离线恢复通道——绝不用 agent/title 做概率性配对,会挂错会话)
sweepTaskChildBindings(paneServerId, routeSessionId!)
await verifyTaskChildBindings(paneServerId, routeSessionId!, async childKey => {
try {
const messages = await getSessionMessages(childKey, undefined, undefined, paneServerId)
const firstUser = messages.find(m => m.type === 'user')
return firstUser?.type === 'user' ? firstUser.text : undefined
} catch {
return undefined
}
})
if (cancelled) return
// Step 2: 获取完整的 session family(主 session + 所有子孙)
const family = new Set(childSessionStore.getSessionAndDescendants(routeSessionId!))
// family 是复合 key;API 返回的 sessionID 是原始 id,兼容两种形式比较
const familyRaw = new Set([...family].map(k => splitSessionKey(k).sessionId))
const matchesFamily = (rawSessionId: string) =>
familyRaw.has(rawSessionId) || familyRaw.has(splitSessionKey(rawSessionId).sessionId)
// Step 3: 获取所有待处理请求,然后用 family 过滤
// GET /permission 和 GET /question 返回全量数据,不传 sessionId 避免 N 次重复请求
const [allPerms, allQuestions] = await Promise.all([
getPendingPermissions(undefined, effectiveDirectory, paneServerId).catch(() => []),
getPendingForms(undefined, effectiveDirectory, paneServerId).catch(() => []),
])
if (cancelled) return
// 只保留属于当前 session family 的请求。
// OMO background subagents may publish permission.asked over SSE before
// /permission can list it for this routed instance. Refresh 时序下子 session 关系
// 可能尚未注册(family 不含子 session),因此 SSE 已知请求必须无条件保留,
// 否则刷新后子任务的权限弹窗会被 family 过滤误删。
const nextPerms = allPerms.filter((p: ApiPermissionRequest) => matchesFamily(p.sessionID))
setPendingPermissionRequests(prev => {
const merged = new Map(nextPerms.map((p: ApiPermissionRequest) => [p.id, p]))
for (const request of prev) {
if (!merged.has(request.id)) merged.set(request.id, request)
}
return Array.from(merged.values())
})
setPendingQuestionRequests(prev => {
const merged = new Map(
allQuestions.filter((q: ApiFormInfo) => matchesFamily(q.sessionID)).map((q: ApiFormInfo) => [q.id, q]),
)
for (const q of prev) {
if (!merged.has(q.id)) merged.set(q.id, q)
}
return Array.from(merged.values())
})
}
loadChildSessionsAndPermissions()
return () => {
cancelled = true
}
}, [
routeSessionId,
effectiveDirectory,
paneServerId,
resetPendingRequests,
setPendingPermissionRequests,
setPendingQuestionRequests,
])
const sendMessageNow = useCallback(
async (input: {
sessionId?: string | null
content: string
attachments: Attachment[]
directory: string
model: { providerID: string; modelID: string }
options?: { agent?: string; variant?: string }
delivery?: 'queue' | 'steer'
allowCreateSession?: boolean
}) => {
let sessionId = input.sessionId ?? routeSessionId
if (sessionId && input.allowCreateSession) {
const state = messageStore.getSessionState(sessionId)
if (state?.loadState === 'error' && state.messages.length === 0) {
clearSessionRuntimeState(sessionId)
sessionId = null
}
}
let rollbackSnapshot = sessionId ? messageStore.createSendRollbackSnapshot(sessionId) : null
try {
if (!sessionId) {
if (!input.allowCreateSession) return false
// context 的 createSession 会带上当前目录(normalizeToForwardSlash(currentDirectory))
const newSession = await createSession()
sessionId = newSession.id
navigateToSession(sessionId, newSession.location?.directory)
}
if (rollbackSnapshot) {
messageStore.truncateAfterRevert(sessionId)
}
// 乐观上屏(官方 data.ts:1539-1575 同款):客户端铸造消息 id 并立即
// 插入本地用户消息,POST 时把同一 id 交给服务端 —— durable 行与乐观行
// 同 id 对账,inbox.enqueued 回声 upsert 补齐 files/skills。
// 不等 POST、不等 SSE,用户消息即时可见。
const messageId = createMessageId()
// fresh 判定(官方 data.ts:1550-1552):只对新 id 乐观准入,
// 重试复用已渲染/已确认 id 时不得重插,也不得回滚已确认状态
const fresh =
!(messageStore.getSessionState(sessionId)?.messages.some(message => message.id === messageId) ?? false) &&
!inboxStore.getItems(sessionId).some(item => item.id === messageId)
if (fresh) {
outboxAdd(messageId)
// 先 admit 本地 inbox 条目(官方 admitLocal):队列气泡一步到位,
// 不会先闪一下实心用户气泡。inboxID 与乐观消息 id 同值——服务端
// 回声、durable 行、投影三者靠它对齐。
if (input.delivery) {
admitLocalInboxItem(sessionId, {
inboxId: messageId,
text: input.content,
delivery: input.delivery,
attachments: input.attachments,
agent: input.options?.agent,
})
}
messageStore.upsertLocalMessage(
sessionId,
buildLocalQueuedMessage({
sessionId,
messageId,
text: input.content,
attachments: input.attachments,
agent: input.options?.agent,
model: { ...input.model, variant: input.options?.variant },
createdAt: Date.now(),
}),
)
}
// 记录发送前的消息数量,作为判断 SSE 是否推送新消息的基线
const msgCountBeforeSend = messageStore.getSessionState(sessionId)?.messages.length ?? 0
// 不要在 send 前 setStreaming:新 user 往往还没入列,过程折叠会把
// 「上一轮已收工」误判成最新 Working 再展开,造成一闪。
// streaming 在 send 成功后、或 SSE 推到 assistant 时再打开。
const admittedSessionId = sessionId
// 发送载荷提取成函数:失败后要按原样幂等重试一次
const postAdmission = () =>
sendAdmission(admittedSessionId, () =>
sendMessageAsync(
{
sessionId: admittedSessionId,
id: messageId,
text: input.content,
attachments: input.attachments,
model: input.model,
agent: input.options?.agent,
variant: input.options?.variant,
delivery: input.delivery,
directory: input.directory,
},
paneServerId,
),
)
try {
// 同会话发送串行化(官方 sendAdmission):等待上一次 POST 落定,
// 避免 switchModel/switchAgent/prompt 与另一次发送交错
await postAdmission()
} catch {
// 幂等自动重试一次(官方 composer submit.ts 同款):消息 ID 由
// 客户端铸造、服务端按 ID 幂等入场,同 payload 重试不会重复。
// 覆盖「请求已到服务端、响应在断网中丢失」的假象失败——不重试
// 直接回滚会把服务端已收到的消息撤掉,用户重发后变成重复消息。
try {
await postAdmission()
} catch (error) {
// 官方同款回滚守卫(data.ts:1578-1583):只撤本次乐观插入、且
// 服务端尚未确认的行;回声已到达的行属于服务端状态,绝不能撤
if (fresh && outboxTryRollback(messageId)) {
messageStore.removeMessage(sessionId, messageId)
if (input.delivery) inboxStore.removeItem(sessionId, messageId)
}
throw error
}
}
messageStore.setStreaming(sessionId, true)
// 兜底:等待短暂时间后检查 SSE 是否已推送用户消息,
// 若未收到则主动拉取补齐,避免 SSE 断流导致用户消息不显示
const pullSessionId = sessionId
const pullDir = input.directory
setTimeout(() => {
const state = messageStore.getSessionState(pullSessionId)
if (!state) return
// 消息数量增加了,说明 SSE 已正常推送
if (state.messages.length > msgCountBeforeSend) return
getSessionMessages(pullSessionId, 5, pullDir, paneServerId)
.then(apiMessages => {
// v2 的消息自带 content(没有独立的 part 事件),
// 因此整条替换即可,content 由投影层转成 UI parts。
for (const msg of apiMessages) {
messageStore.handleMessageUpdated(msg, pullSessionId)
}
})
.catch(() => {
// 拉取失败不影响主流程,SSE 重连后仍可补齐
})
}, 1500)
return true
} catch (error) {
handleError('send message', error)
// 用户可见反馈(修复「吞消息」无感知问题):乐观气泡被回滚后
// 必须有明确提示,否则用户看到的就是"消息发了又消失"。
// 输入框草稿本就在失败时保留,用户可直接重发。
notificationStore.push(
'error',
i18n.t('chat:inputArea.sendFailed'),
error instanceof Error && error.message ? error.message : i18n.t('chat:inputArea.sendFailedHint'),
sessionId ?? '',
input.directory,
)
if (sessionId) {
if (rollbackSnapshot) {
messageStore.restoreSendRollback(sessionId, rollbackSnapshot)
rollbackSnapshot = null
} else {
messageStore.setStreaming(sessionId, false)
}
}
return false
}
},
[routeSessionId, navigateToSession, createSession, paneServerId, buildLocalQueuedMessage, admitLocalInboxItem],
)
// Send message handler
const handleSend = useCallback(
async (content: string, attachments: Attachment[], options?: { agent?: string; variant?: string }) => {
if (!currentModel) {
handleError('send message', new Error('No model selected'))
return false
}
// 队列编辑确认(官方 confirmEdit 同款):发送走的是「替换提交」,
// 不是新 prompt——admit 替换条目(新 id)→ cancel 原条目 →
// rewrite 放回原来的位置;内容与原文一致则视为未修改,仅取消编辑
const editing = queueEditing
if (editing && routeSessionId) {
const original = editing.item
const originalAttachments = queuedPromptAttachments(original)
const pristine =
content.trim() === queuedPromptText(original).trim() && attachments.length === originalAttachments.length
if (pristine && original.delivery === 'queue') {
setQueueEditing(null)
setStashRestoreNonce(Date.now())