@codori/client 0.0.5 → 0.0.7
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/app/components/ChatWorkspace.vue +318 -170
- package/app/components/ProjectSidebar.vue +2 -1
- package/app/components/ThreadList.vue +21 -21
- package/app/composables/useThreadSummaries.ts +99 -0
- package/app/utils/chat-messages-status.ts +16 -0
- package/app/utils/chat-turn-engagement.ts +79 -1
- package/app/utils/project-sidebar-order.ts +24 -0
- package/package.json +1 -1
- package/shared/codex-chat.ts +0 -27
- package/shared/codex-rpc.ts +16 -0
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
removePendingUserMessageId,
|
|
9
9
|
resolvePromptSubmitStatus,
|
|
10
10
|
resolveTurnSubmissionMethod,
|
|
11
|
+
shouldApplyNotificationToCurrentTurn,
|
|
12
|
+
shouldSubmitViaTurnSteer,
|
|
13
|
+
shouldAwaitThreadHydration,
|
|
14
|
+
shouldRetrySteerWithTurnStart,
|
|
11
15
|
shouldIgnoreNotificationAfterInterrupt
|
|
12
16
|
} from '../utils/chat-turn-engagement'
|
|
13
17
|
import { useChatAttachments, type DraftAttachment } from '../composables/useChatAttachments'
|
|
@@ -15,14 +19,14 @@ import { useChatSession, type LiveStream, type SubagentPanelState } from '../com
|
|
|
15
19
|
import { useProjects } from '../composables/useProjects'
|
|
16
20
|
import { useRpc } from '../composables/useRpc'
|
|
17
21
|
import { useChatSubmitGuard } from '../composables/useChatSubmitGuard'
|
|
22
|
+
import { resolveThreadSummaryTitle, useThreadSummaries } from '../composables/useThreadSummaries'
|
|
23
|
+
import { resolveChatMessagesStatus, shouldAwaitAssistantOutput } from '../utils/chat-messages-status'
|
|
18
24
|
import {
|
|
19
|
-
hideThinkingPlaceholder,
|
|
20
25
|
ITEM_PART,
|
|
21
26
|
eventToMessage,
|
|
22
27
|
isSubagentActiveStatus,
|
|
23
28
|
itemToMessages,
|
|
24
29
|
replaceStreamingMessage,
|
|
25
|
-
showThinkingPlaceholder,
|
|
26
30
|
threadToMessages,
|
|
27
31
|
upsertStreamingMessage,
|
|
28
32
|
type ChatMessage,
|
|
@@ -31,11 +35,13 @@ import {
|
|
|
31
35
|
type ItemData,
|
|
32
36
|
type McpToolCallItem
|
|
33
37
|
} from '~~/shared/codex-chat'
|
|
34
|
-
import { buildTurnStartInput } from '~~/shared/chat-attachments'
|
|
38
|
+
import { buildTurnStartInput, type PersistedProjectAttachment } from '~~/shared/chat-attachments'
|
|
35
39
|
import {
|
|
36
40
|
type ConfigReadResponse,
|
|
37
41
|
type ModelListResponse,
|
|
42
|
+
notificationThreadName,
|
|
38
43
|
notificationThreadId,
|
|
44
|
+
notificationThreadUpdatedAt,
|
|
39
45
|
notificationTurnId,
|
|
40
46
|
type CodexRpcNotification,
|
|
41
47
|
type CodexThread,
|
|
@@ -105,6 +111,7 @@ const input = ref('')
|
|
|
105
111
|
const scrollViewport = ref<HTMLElement | null>(null)
|
|
106
112
|
const pinnedToBottom = ref(true)
|
|
107
113
|
const session = useChatSession(props.projectId)
|
|
114
|
+
const { syncThreadSummary, updateThreadSummaryTitle } = useThreadSummaries(props.projectId)
|
|
108
115
|
const {
|
|
109
116
|
messages,
|
|
110
117
|
subagentPanels,
|
|
@@ -128,6 +135,8 @@ const selectedProject = computed(() => getProject(props.projectId))
|
|
|
128
135
|
const composerError = computed(() => attachmentError.value ?? error.value)
|
|
129
136
|
const submitError = computed(() => composerError.value ? new Error(composerError.value) : undefined)
|
|
130
137
|
const interruptRequested = ref(false)
|
|
138
|
+
const awaitingAssistantOutput = ref(false)
|
|
139
|
+
const sendMessageLocked = ref(false)
|
|
131
140
|
const isBusy = computed(() =>
|
|
132
141
|
status.value === 'submitted'
|
|
133
142
|
|| status.value === 'streaming'
|
|
@@ -149,6 +158,9 @@ const promptSubmitStatus = computed(() =>
|
|
|
149
158
|
)
|
|
150
159
|
const routeThreadId = computed(() => props.threadId ?? null)
|
|
151
160
|
const projectTitle = computed(() => selectedProject.value?.projectId ?? props.projectId)
|
|
161
|
+
const chatMessagesStatus = computed(() =>
|
|
162
|
+
resolveChatMessagesStatus(status.value, awaitingAssistantOutput.value)
|
|
163
|
+
)
|
|
152
164
|
const showWelcomeState = computed(() =>
|
|
153
165
|
!routeThreadId.value
|
|
154
166
|
&& !activeThreadId.value
|
|
@@ -303,6 +315,7 @@ const loadPromptControls = async () => {
|
|
|
303
315
|
const subagentBootstrapPromises = new Map<string, Promise<void>>()
|
|
304
316
|
const optimisticAttachmentSnapshots = new Map<string, DraftAttachment[]>()
|
|
305
317
|
let promptControlsPromise: Promise<void> | null = null
|
|
318
|
+
let pendingThreadHydration: Promise<void> | null = null
|
|
306
319
|
|
|
307
320
|
const isActiveTurnStatus = (value: string | null | undefined) => {
|
|
308
321
|
if (!value) {
|
|
@@ -320,6 +333,14 @@ const currentLiveStream = () =>
|
|
|
320
333
|
const hasActiveTurnEngagement = () =>
|
|
321
334
|
Boolean(currentLiveStream() || session.pendingLiveStream)
|
|
322
335
|
|
|
336
|
+
const shouldSubmitWithTurnSteer = () =>
|
|
337
|
+
shouldSubmitViaTurnSteer({
|
|
338
|
+
activeThreadId: activeThreadId.value,
|
|
339
|
+
liveStreamThreadId: session.liveStream?.threadId ?? null,
|
|
340
|
+
liveStreamTurnId: session.liveStream?.turnId ?? null,
|
|
341
|
+
status: status.value
|
|
342
|
+
})
|
|
343
|
+
|
|
323
344
|
const rejectLiveStreamTurnWaiters = (liveStream: LiveStream, error: Error) => {
|
|
324
345
|
const waiters = liveStream.turnIdWaiters.splice(0, liveStream.turnIdWaiters.length)
|
|
325
346
|
for (const waiter of waiters) {
|
|
@@ -379,6 +400,25 @@ const waitForLiveStreamTurnId = async (liveStream: LiveStream) => {
|
|
|
379
400
|
})
|
|
380
401
|
}
|
|
381
402
|
|
|
403
|
+
const queuePendingUserMessage = (liveStream: LiveStream, messageId: string) => {
|
|
404
|
+
if (liveStream.pendingUserMessageIds.includes(messageId)) {
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
liveStream.pendingUserMessageIds.push(messageId)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const replayBufferedNotifications = (liveStream: LiveStream) => {
|
|
412
|
+
for (const notification of liveStream.bufferedNotifications.splice(0, liveStream.bufferedNotifications.length)) {
|
|
413
|
+
const turnId = notificationTurnId(notification)
|
|
414
|
+
if (turnId && turnId !== liveStream.turnId) {
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
applyNotification(notification)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
382
422
|
const clearLiveStream = (reason?: Error) => {
|
|
383
423
|
const liveStream = session.liveStream
|
|
384
424
|
if (!liveStream) {
|
|
@@ -429,7 +469,11 @@ const ensurePendingLiveStream = async () => {
|
|
|
429
469
|
}
|
|
430
470
|
|
|
431
471
|
const turnId = notificationTurnId(notification)
|
|
432
|
-
if (
|
|
472
|
+
if (!shouldApplyNotificationToCurrentTurn({
|
|
473
|
+
liveStreamTurnId: liveStream.turnId,
|
|
474
|
+
notificationMethod: notification.method,
|
|
475
|
+
notificationTurnId: turnId
|
|
476
|
+
})) {
|
|
433
477
|
return
|
|
434
478
|
}
|
|
435
479
|
|
|
@@ -509,17 +553,13 @@ const removeOptimisticMessage = (messageId: string) => {
|
|
|
509
553
|
optimisticAttachmentSnapshots.delete(messageId)
|
|
510
554
|
}
|
|
511
555
|
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const ensureThinkingPlaceholder = () => {
|
|
517
|
-
messages.value = showThinkingPlaceholder(messages.value)
|
|
556
|
+
const markAwaitingAssistantOutput = (nextValue: boolean) => {
|
|
557
|
+
awaitingAssistantOutput.value = nextValue
|
|
518
558
|
}
|
|
519
559
|
|
|
520
|
-
const
|
|
560
|
+
const markAssistantOutputStartedForItem = (item: CodexThreadItem) => {
|
|
521
561
|
if (item.type !== 'userMessage') {
|
|
522
|
-
|
|
562
|
+
markAwaitingAssistantOutput(false)
|
|
523
563
|
}
|
|
524
564
|
}
|
|
525
565
|
|
|
@@ -576,9 +616,42 @@ const clearPendingOptimisticMessages = (liveStream: LiveStream | null, options?:
|
|
|
576
616
|
liveStream.pendingUserMessageIds = []
|
|
577
617
|
}
|
|
578
618
|
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
619
|
+
const submitTurnStart = async (input: {
|
|
620
|
+
client: ReturnType<typeof getClient>
|
|
621
|
+
liveStream: LiveStream
|
|
622
|
+
text: string
|
|
623
|
+
submittedAttachments: DraftAttachment[]
|
|
624
|
+
uploadedAttachments?: PersistedProjectAttachment[]
|
|
625
|
+
optimisticMessageId: string
|
|
626
|
+
queueOptimisticMessage?: boolean
|
|
627
|
+
}) => {
|
|
628
|
+
const {
|
|
629
|
+
client,
|
|
630
|
+
liveStream,
|
|
631
|
+
text,
|
|
632
|
+
submittedAttachments,
|
|
633
|
+
uploadedAttachments: existingUploadedAttachments,
|
|
634
|
+
optimisticMessageId,
|
|
635
|
+
queueOptimisticMessage: shouldQueueOptimisticMessage = true
|
|
636
|
+
} = input
|
|
637
|
+
|
|
638
|
+
if (shouldQueueOptimisticMessage) {
|
|
639
|
+
queuePendingUserMessage(liveStream, optimisticMessageId)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const uploadedAttachments = existingUploadedAttachments
|
|
643
|
+
?? await uploadAttachments(liveStream.threadId, submittedAttachments)
|
|
644
|
+
const turnStart = await client.request<TurnStartResponse>('turn/start', {
|
|
645
|
+
threadId: liveStream.threadId,
|
|
646
|
+
input: buildTurnStartInput(text, uploadedAttachments),
|
|
647
|
+
cwd: selectedProject.value?.projectPath ?? null,
|
|
648
|
+
approvalPolicy: 'never',
|
|
649
|
+
...buildTurnOverrides(selectedModel.value, selectedEffort.value)
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
tokenUsage.value = null
|
|
653
|
+
setLiveStreamTurnId(liveStream, turnStart.turn.id)
|
|
654
|
+
replayBufferedNotifications(liveStream)
|
|
582
655
|
}
|
|
583
656
|
|
|
584
657
|
const shortThreadId = (value: string) => value.slice(0, 8)
|
|
@@ -700,106 +773,124 @@ const ensureProjectRuntime = async () => {
|
|
|
700
773
|
}
|
|
701
774
|
|
|
702
775
|
const hydrateThread = async (threadId: string) => {
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
776
|
+
const hydratePromise = (async () => {
|
|
777
|
+
const requestVersion = loadVersion.value + 1
|
|
778
|
+
loadVersion.value = requestVersion
|
|
779
|
+
error.value = null
|
|
780
|
+
tokenUsage.value = null
|
|
707
781
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
782
|
+
try {
|
|
783
|
+
await ensureProjectRuntime()
|
|
784
|
+
const client = getClient(props.projectId)
|
|
785
|
+
activeThreadId.value = threadId
|
|
712
786
|
|
|
713
|
-
|
|
787
|
+
const existingLiveStream = session.liveStream
|
|
714
788
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
789
|
+
if (existingLiveStream && existingLiveStream.threadId !== threadId) {
|
|
790
|
+
clearLiveStream()
|
|
791
|
+
}
|
|
718
792
|
|
|
719
|
-
|
|
720
|
-
|
|
793
|
+
if (!session.liveStream) {
|
|
794
|
+
const nextLiveStream = createLiveStreamState(threadId)
|
|
721
795
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
796
|
+
nextLiveStream.unsubscribe = client.subscribe((notification) => {
|
|
797
|
+
const targetThreadId = notificationThreadId(notification)
|
|
798
|
+
if (!targetThreadId) {
|
|
799
|
+
return
|
|
800
|
+
}
|
|
727
801
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
802
|
+
if (targetThreadId !== threadId) {
|
|
803
|
+
if (nextLiveStream.observedSubagentThreadIds.has(targetThreadId)) {
|
|
804
|
+
applySubagentNotification(targetThreadId, notification)
|
|
805
|
+
}
|
|
806
|
+
return
|
|
731
807
|
}
|
|
732
|
-
return
|
|
733
|
-
}
|
|
734
808
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
809
|
+
if (!nextLiveStream.turnId) {
|
|
810
|
+
nextLiveStream.bufferedNotifications.push(notification)
|
|
811
|
+
return
|
|
812
|
+
}
|
|
739
813
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
814
|
+
const turnId = notificationTurnId(notification)
|
|
815
|
+
if (!shouldApplyNotificationToCurrentTurn({
|
|
816
|
+
liveStreamTurnId: nextLiveStream.turnId,
|
|
817
|
+
notificationMethod: notification.method,
|
|
818
|
+
notificationTurnId: turnId
|
|
819
|
+
})) {
|
|
820
|
+
return
|
|
821
|
+
}
|
|
744
822
|
|
|
745
|
-
|
|
746
|
-
|
|
823
|
+
applyNotification(notification)
|
|
824
|
+
})
|
|
747
825
|
|
|
748
|
-
|
|
749
|
-
|
|
826
|
+
setSessionLiveStream(nextLiveStream)
|
|
827
|
+
}
|
|
750
828
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
829
|
+
const resumeResponse = await client.request<ThreadResumeResponse>('thread/resume', {
|
|
830
|
+
threadId,
|
|
831
|
+
cwd: selectedProject.value?.projectPath ?? null,
|
|
832
|
+
approvalPolicy: 'never',
|
|
833
|
+
persistExtendedHistory: true
|
|
834
|
+
})
|
|
835
|
+
const response = await client.request<ThreadReadResponse>('thread/read', {
|
|
836
|
+
threadId,
|
|
837
|
+
includeTurns: true
|
|
838
|
+
})
|
|
761
839
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
840
|
+
if (loadVersion.value !== requestVersion) {
|
|
841
|
+
return
|
|
842
|
+
}
|
|
765
843
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
844
|
+
syncPromptSelectionFromThread(
|
|
845
|
+
resumeResponse.model ?? null,
|
|
846
|
+
(resumeResponse.reasoningEffort as ReasoningEffort | null | undefined) ?? null
|
|
847
|
+
)
|
|
848
|
+
activeThreadId.value = response.thread.id
|
|
849
|
+
threadTitle.value = resolveThreadSummaryTitle(response.thread)
|
|
850
|
+
syncThreadSummary(response.thread)
|
|
851
|
+
messages.value = threadToMessages(response.thread)
|
|
852
|
+
rebuildSubagentPanelsFromThread(response.thread)
|
|
853
|
+
markAwaitingAssistantOutput(false)
|
|
854
|
+
const activeTurn = [...response.thread.turns].reverse().find(turn => isActiveTurnStatus(turn.status))
|
|
775
855
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
856
|
+
if (!activeTurn) {
|
|
857
|
+
clearLiveStream()
|
|
858
|
+
status.value = 'ready'
|
|
859
|
+
return
|
|
860
|
+
}
|
|
781
861
|
|
|
782
|
-
|
|
862
|
+
if (!session.liveStream) {
|
|
863
|
+
status.value = 'streaming'
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
setLiveStreamTurnId(session.liveStream, activeTurn.id)
|
|
783
868
|
status.value = 'streaming'
|
|
784
|
-
return
|
|
785
|
-
}
|
|
786
869
|
|
|
787
|
-
|
|
788
|
-
|
|
870
|
+
const pendingNotifications = session.liveStream.bufferedNotifications.splice(0, session.liveStream.bufferedNotifications.length)
|
|
871
|
+
for (const notification of pendingNotifications) {
|
|
872
|
+
const turnId = notificationTurnId(notification)
|
|
873
|
+
if (turnId && turnId !== activeTurn.id) {
|
|
874
|
+
continue
|
|
875
|
+
}
|
|
789
876
|
|
|
790
|
-
|
|
791
|
-
for (const notification of pendingNotifications) {
|
|
792
|
-
const turnId = notificationTurnId(notification)
|
|
793
|
-
if (turnId && turnId !== activeTurn.id) {
|
|
794
|
-
continue
|
|
877
|
+
applyNotification(notification)
|
|
795
878
|
}
|
|
879
|
+
} catch (caughtError) {
|
|
880
|
+
clearLiveStream()
|
|
881
|
+
error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
882
|
+
status.value = 'error'
|
|
883
|
+
}
|
|
884
|
+
})()
|
|
796
885
|
|
|
797
|
-
|
|
886
|
+
pendingThreadHydration = hydratePromise
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
await hydratePromise
|
|
890
|
+
} finally {
|
|
891
|
+
if (pendingThreadHydration === hydratePromise) {
|
|
892
|
+
pendingThreadHydration = null
|
|
798
893
|
}
|
|
799
|
-
} catch (caughtError) {
|
|
800
|
-
clearLiveStream()
|
|
801
|
-
error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
802
|
-
status.value = 'error'
|
|
803
894
|
}
|
|
804
895
|
}
|
|
805
896
|
|
|
@@ -814,6 +905,7 @@ const resetDraftThread = () => {
|
|
|
814
905
|
subagentPanels.value = []
|
|
815
906
|
error.value = null
|
|
816
907
|
tokenUsage.value = null
|
|
908
|
+
markAwaitingAssistantOutput(false)
|
|
817
909
|
status.value = 'ready'
|
|
818
910
|
}
|
|
819
911
|
|
|
@@ -835,7 +927,8 @@ const ensureThread = async () => {
|
|
|
835
927
|
})
|
|
836
928
|
|
|
837
929
|
activeThreadId.value = response.thread.id
|
|
838
|
-
threadTitle.value =
|
|
930
|
+
threadTitle.value = resolveThreadSummaryTitle(response.thread)
|
|
931
|
+
syncThreadSummary(response.thread)
|
|
839
932
|
return {
|
|
840
933
|
threadId: response.thread.id,
|
|
841
934
|
created: true
|
|
@@ -1422,6 +1515,24 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1422
1515
|
pendingThreadId.value = pendingThreadId.value ?? nextThreadId
|
|
1423
1516
|
return
|
|
1424
1517
|
}
|
|
1518
|
+
case 'thread/name/updated': {
|
|
1519
|
+
const nextThreadId = notificationThreadId(notification)
|
|
1520
|
+
const nextThreadName = notificationThreadName(notification)
|
|
1521
|
+
if (!nextThreadId || !nextThreadName) {
|
|
1522
|
+
return
|
|
1523
|
+
}
|
|
1524
|
+
const nextTitle = nextThreadName.trim()
|
|
1525
|
+
if (!nextTitle) {
|
|
1526
|
+
return
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if (activeThreadId.value === nextThreadId) {
|
|
1530
|
+
threadTitle.value = nextTitle
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
updateThreadSummaryTitle(nextThreadId, nextTitle, notificationThreadUpdatedAt(notification))
|
|
1534
|
+
return
|
|
1535
|
+
}
|
|
1425
1536
|
case 'turn/started': {
|
|
1426
1537
|
if (liveStream) {
|
|
1427
1538
|
setLiveStreamTurnId(liveStream, notificationTurnId(notification))
|
|
@@ -1451,7 +1562,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1451
1562
|
status.value = 'streaming'
|
|
1452
1563
|
return
|
|
1453
1564
|
}
|
|
1454
|
-
|
|
1565
|
+
markAssistantOutputStartedForItem(params.item)
|
|
1455
1566
|
seedStreamingMessage(params.item)
|
|
1456
1567
|
status.value = 'streaming'
|
|
1457
1568
|
return
|
|
@@ -1461,7 +1572,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1461
1572
|
if (params.item.type === 'collabAgentToolCall') {
|
|
1462
1573
|
applySubagentActivityItem(params.item)
|
|
1463
1574
|
}
|
|
1464
|
-
|
|
1575
|
+
markAssistantOutputStartedForItem(params.item)
|
|
1465
1576
|
for (const nextMessage of itemToMessages(params.item)) {
|
|
1466
1577
|
const confirmedMessage = {
|
|
1467
1578
|
...nextMessage,
|
|
@@ -1478,7 +1589,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1478
1589
|
}
|
|
1479
1590
|
case 'item/agentMessage/delta': {
|
|
1480
1591
|
const params = notification.params as { itemId: string, delta: string }
|
|
1481
|
-
|
|
1592
|
+
markAwaitingAssistantOutput(false)
|
|
1482
1593
|
appendTextPartDelta(params.itemId, params.delta, {
|
|
1483
1594
|
id: params.itemId,
|
|
1484
1595
|
role: 'assistant',
|
|
@@ -1494,7 +1605,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1494
1605
|
}
|
|
1495
1606
|
case 'item/plan/delta': {
|
|
1496
1607
|
const params = notification.params as { itemId: string, delta: string }
|
|
1497
|
-
|
|
1608
|
+
markAwaitingAssistantOutput(false)
|
|
1498
1609
|
appendTextPartDelta(params.itemId, params.delta, {
|
|
1499
1610
|
id: params.itemId,
|
|
1500
1611
|
role: 'assistant',
|
|
@@ -1511,7 +1622,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1511
1622
|
case 'item/reasoning/textDelta':
|
|
1512
1623
|
case 'item/reasoning/summaryTextDelta': {
|
|
1513
1624
|
const params = notification.params as { itemId: string, delta: string }
|
|
1514
|
-
|
|
1625
|
+
markAwaitingAssistantOutput(false)
|
|
1515
1626
|
updateMessage(params.itemId, {
|
|
1516
1627
|
id: params.itemId,
|
|
1517
1628
|
role: 'assistant',
|
|
@@ -1619,7 +1730,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1619
1730
|
case 'error': {
|
|
1620
1731
|
const params = notification.params as { error?: { message?: string } }
|
|
1621
1732
|
const messageText = params.error?.message ?? 'The stream failed.'
|
|
1622
|
-
|
|
1733
|
+
markAwaitingAssistantOutput(false)
|
|
1623
1734
|
pushEventMessage('stream.error', messageText)
|
|
1624
1735
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1625
1736
|
clearLiveStream(new Error(messageText))
|
|
@@ -1630,7 +1741,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1630
1741
|
case 'turn/failed': {
|
|
1631
1742
|
const params = notification.params as { error?: { message?: string } }
|
|
1632
1743
|
const messageText = params.error?.message ?? 'The turn failed.'
|
|
1633
|
-
|
|
1744
|
+
markAwaitingAssistantOutput(false)
|
|
1634
1745
|
pushEventMessage('turn.failed', messageText)
|
|
1635
1746
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1636
1747
|
clearLiveStream(new Error(messageText))
|
|
@@ -1641,7 +1752,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1641
1752
|
case 'stream/error': {
|
|
1642
1753
|
const params = notification.params as { message?: string }
|
|
1643
1754
|
const messageText = params.message ?? 'The stream failed.'
|
|
1644
|
-
|
|
1755
|
+
markAwaitingAssistantOutput(false)
|
|
1645
1756
|
pushEventMessage('stream.error', messageText)
|
|
1646
1757
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1647
1758
|
clearLiveStream(new Error(messageText))
|
|
@@ -1650,7 +1761,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1650
1761
|
return
|
|
1651
1762
|
}
|
|
1652
1763
|
case 'turn/completed': {
|
|
1653
|
-
|
|
1764
|
+
markAwaitingAssistantOutput(false)
|
|
1654
1765
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1655
1766
|
error.value = null
|
|
1656
1767
|
status.value = 'ready'
|
|
@@ -1663,101 +1774,131 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1663
1774
|
}
|
|
1664
1775
|
|
|
1665
1776
|
const sendMessage = async () => {
|
|
1777
|
+
if (sendMessageLocked.value) {
|
|
1778
|
+
return
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
sendMessageLocked.value = true
|
|
1666
1782
|
const text = input.value.trim()
|
|
1667
1783
|
const submittedAttachments = attachments.value.slice()
|
|
1668
1784
|
|
|
1669
1785
|
if (!text && submittedAttachments.length === 0) {
|
|
1786
|
+
sendMessageLocked.value = false
|
|
1670
1787
|
return
|
|
1671
1788
|
}
|
|
1672
1789
|
|
|
1790
|
+
input.value = ''
|
|
1791
|
+
clearAttachments({ revoke: false })
|
|
1792
|
+
|
|
1673
1793
|
try {
|
|
1674
1794
|
await loadPromptControls()
|
|
1675
1795
|
} catch (caughtError) {
|
|
1796
|
+
restoreDraftIfPristine(text, submittedAttachments)
|
|
1676
1797
|
error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1677
1798
|
status.value = 'error'
|
|
1799
|
+
sendMessageLocked.value = false
|
|
1678
1800
|
return
|
|
1679
1801
|
}
|
|
1680
1802
|
|
|
1681
|
-
pinnedToBottom.value = true
|
|
1682
|
-
error.value = null
|
|
1683
|
-
attachmentError.value = null
|
|
1684
|
-
const submissionMethod = resolveTurnSubmissionMethod(hasActiveTurnEngagement())
|
|
1685
|
-
if (submissionMethod === 'turn/start') {
|
|
1686
|
-
status.value = 'submitted'
|
|
1687
|
-
}
|
|
1688
|
-
input.value = ''
|
|
1689
|
-
clearAttachments({ revoke: false })
|
|
1690
|
-
const optimisticMessage = buildOptimisticMessage(text, submittedAttachments)
|
|
1691
|
-
const optimisticMessageId = optimisticMessage.id
|
|
1692
|
-
rememberOptimisticAttachments(optimisticMessageId, submittedAttachments)
|
|
1693
|
-
messages.value = [...messages.value, optimisticMessage]
|
|
1694
|
-
ensureThinkingPlaceholder()
|
|
1695
|
-
let startedLiveStream: LiveStream | null = null
|
|
1696
|
-
|
|
1697
1803
|
try {
|
|
1698
|
-
|
|
1804
|
+
if (pendingThreadHydration && shouldAwaitThreadHydration({
|
|
1805
|
+
hasPendingThreadHydration: true,
|
|
1806
|
+
routeThreadId: routeThreadId.value
|
|
1807
|
+
})) {
|
|
1808
|
+
await pendingThreadHydration
|
|
1809
|
+
}
|
|
1699
1810
|
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
await client.request<TurnStartResponse>('turn/steer', {
|
|
1707
|
-
threadId: liveStream.threadId,
|
|
1708
|
-
expectedTurnId: turnId,
|
|
1709
|
-
input: buildTurnStartInput(text, uploadedAttachments),
|
|
1710
|
-
...buildTurnOverrides(selectedModel.value, selectedEffort.value)
|
|
1711
|
-
})
|
|
1712
|
-
tokenUsage.value = null
|
|
1713
|
-
return
|
|
1811
|
+
pinnedToBottom.value = true
|
|
1812
|
+
error.value = null
|
|
1813
|
+
attachmentError.value = null
|
|
1814
|
+
const submissionMethod = resolveTurnSubmissionMethod(shouldSubmitWithTurnSteer())
|
|
1815
|
+
if (submissionMethod === 'turn/start') {
|
|
1816
|
+
status.value = 'submitted'
|
|
1714
1817
|
}
|
|
1818
|
+
const optimisticMessage = buildOptimisticMessage(text, submittedAttachments)
|
|
1819
|
+
const optimisticMessageId = optimisticMessage.id
|
|
1820
|
+
rememberOptimisticAttachments(optimisticMessageId, submittedAttachments)
|
|
1821
|
+
messages.value = [...messages.value, optimisticMessage]
|
|
1822
|
+
markAwaitingAssistantOutput(shouldAwaitAssistantOutput(submissionMethod))
|
|
1823
|
+
let startedLiveStream: LiveStream | null = null
|
|
1824
|
+
let executedSubmissionMethod = submissionMethod
|
|
1715
1825
|
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
const threadId = liveStream.threadId
|
|
1719
|
-
const uploadedAttachments = await uploadAttachments(threadId, submittedAttachments)
|
|
1720
|
-
liveStream.pendingUserMessageIds.push(optimisticMessageId)
|
|
1721
|
-
|
|
1722
|
-
const turnStart = await client.request<TurnStartResponse>('turn/start', {
|
|
1723
|
-
threadId,
|
|
1724
|
-
input: buildTurnStartInput(text, uploadedAttachments),
|
|
1725
|
-
cwd: selectedProject.value?.projectPath ?? null,
|
|
1726
|
-
approvalPolicy: 'never',
|
|
1727
|
-
...buildTurnOverrides(selectedModel.value, selectedEffort.value)
|
|
1728
|
-
})
|
|
1826
|
+
try {
|
|
1827
|
+
const client = getClient(props.projectId)
|
|
1729
1828
|
|
|
1730
|
-
|
|
1731
|
-
|
|
1829
|
+
if (submissionMethod === 'turn/steer') {
|
|
1830
|
+
const liveStream = await ensurePendingLiveStream()
|
|
1831
|
+
queuePendingUserMessage(liveStream, optimisticMessageId)
|
|
1832
|
+
let uploadedAttachments: PersistedProjectAttachment[] | undefined
|
|
1732
1833
|
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1834
|
+
try {
|
|
1835
|
+
uploadedAttachments = await uploadAttachments(liveStream.threadId, submittedAttachments)
|
|
1836
|
+
const turnId = await waitForLiveStreamTurnId(liveStream)
|
|
1837
|
+
|
|
1838
|
+
await client.request<TurnStartResponse>('turn/steer', {
|
|
1839
|
+
threadId: liveStream.threadId,
|
|
1840
|
+
expectedTurnId: turnId,
|
|
1841
|
+
input: buildTurnStartInput(text, uploadedAttachments),
|
|
1842
|
+
...buildTurnOverrides(selectedModel.value, selectedEffort.value)
|
|
1843
|
+
})
|
|
1844
|
+
tokenUsage.value = null
|
|
1845
|
+
} catch (caughtError) {
|
|
1846
|
+
const errorToHandle = caughtError instanceof Error ? caughtError : new Error(String(caughtError))
|
|
1847
|
+
if (!shouldRetrySteerWithTurnStart(errorToHandle.message)) {
|
|
1848
|
+
throw errorToHandle
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
executedSubmissionMethod = 'turn/start'
|
|
1852
|
+
startedLiveStream = liveStream
|
|
1853
|
+
status.value = 'submitted'
|
|
1854
|
+
setLiveStreamTurnId(liveStream, null)
|
|
1855
|
+
setLiveStreamInterruptRequested(liveStream, false)
|
|
1856
|
+
|
|
1857
|
+
await submitTurnStart({
|
|
1858
|
+
client,
|
|
1859
|
+
liveStream,
|
|
1860
|
+
text,
|
|
1861
|
+
submittedAttachments,
|
|
1862
|
+
uploadedAttachments,
|
|
1863
|
+
optimisticMessageId,
|
|
1864
|
+
queueOptimisticMessage: false
|
|
1865
|
+
})
|
|
1866
|
+
}
|
|
1867
|
+
return
|
|
1737
1868
|
}
|
|
1738
1869
|
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1870
|
+
const liveStream = await ensurePendingLiveStream()
|
|
1871
|
+
startedLiveStream = liveStream
|
|
1872
|
+
await submitTurnStart({
|
|
1873
|
+
client,
|
|
1874
|
+
liveStream,
|
|
1875
|
+
text,
|
|
1876
|
+
submittedAttachments,
|
|
1877
|
+
optimisticMessageId
|
|
1878
|
+
})
|
|
1879
|
+
} catch (caughtError) {
|
|
1880
|
+
const messageText = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1743
1881
|
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1882
|
+
markAwaitingAssistantOutput(false)
|
|
1883
|
+
untrackPendingUserMessage(optimisticMessageId)
|
|
1884
|
+
removeOptimisticMessage(optimisticMessageId)
|
|
1747
1885
|
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1886
|
+
if (executedSubmissionMethod === 'turn/start') {
|
|
1887
|
+
if (startedLiveStream && session.liveStream === startedLiveStream) {
|
|
1888
|
+
clearPendingOptimisticMessages(clearLiveStream(new Error(messageText)))
|
|
1889
|
+
}
|
|
1890
|
+
session.pendingLiveStream = null
|
|
1891
|
+
restoreDraftIfPristine(text, submittedAttachments)
|
|
1892
|
+
error.value = messageText
|
|
1893
|
+
status.value = 'error'
|
|
1894
|
+
return
|
|
1751
1895
|
}
|
|
1752
|
-
|
|
1896
|
+
|
|
1753
1897
|
restoreDraftIfPristine(text, submittedAttachments)
|
|
1754
1898
|
error.value = messageText
|
|
1755
|
-
status.value = 'error'
|
|
1756
|
-
return
|
|
1757
1899
|
}
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
error.value = messageText
|
|
1900
|
+
} finally {
|
|
1901
|
+
sendMessageLocked.value = false
|
|
1761
1902
|
}
|
|
1762
1903
|
}
|
|
1763
1904
|
|
|
@@ -1933,7 +2074,7 @@ watch([selectedModel, availableModels], () => {
|
|
|
1933
2074
|
<UChatMessages
|
|
1934
2075
|
v-else
|
|
1935
2076
|
:messages="messages"
|
|
1936
|
-
:status="
|
|
2077
|
+
:status="chatMessagesStatus"
|
|
1937
2078
|
:should-auto-scroll="false"
|
|
1938
2079
|
:should-scroll-to-bottom="false"
|
|
1939
2080
|
:auto-scroll="false"
|
|
@@ -1958,6 +2099,13 @@ watch([selectedModel, availableModels], () => {
|
|
|
1958
2099
|
:project-id="projectId"
|
|
1959
2100
|
/>
|
|
1960
2101
|
</template>
|
|
2102
|
+
<template #indicator>
|
|
2103
|
+
<UChatShimmer
|
|
2104
|
+
v-if="awaitingAssistantOutput"
|
|
2105
|
+
text="Thinking..."
|
|
2106
|
+
class="px-1 py-2"
|
|
2107
|
+
/>
|
|
2108
|
+
</template>
|
|
1961
2109
|
</UChatMessages>
|
|
1962
2110
|
</div>
|
|
1963
2111
|
|
|
@@ -3,6 +3,7 @@ import type { NavigationMenuItem } from '@nuxt/ui'
|
|
|
3
3
|
import { useRoute } from '#imports'
|
|
4
4
|
import { computed, onMounted } from 'vue'
|
|
5
5
|
import { useProjects } from '../composables/useProjects'
|
|
6
|
+
import { sortSidebarProjects } from '../utils/project-sidebar-order'
|
|
6
7
|
import { toProjectRoute } from '~~/shared/codori'
|
|
7
8
|
|
|
8
9
|
const props = defineProps<{
|
|
@@ -38,7 +39,7 @@ onMounted(() => {
|
|
|
38
39
|
})
|
|
39
40
|
|
|
40
41
|
const projectItems = computed<ProjectNavigationItem[][]>(() => [
|
|
41
|
-
projects.value.map(project => ({
|
|
42
|
+
sortSidebarProjects(projects.value, activeProjectId.value).map(project => ({
|
|
42
43
|
label: project.projectId,
|
|
43
44
|
icon: 'i-lucide-folder-git-2',
|
|
44
45
|
to: toProjectRoute(project.projectId),
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { NavigationMenuItem } from '@nuxt/ui'
|
|
3
3
|
import { useRoute } from '#imports'
|
|
4
|
-
import { computed, onMounted,
|
|
4
|
+
import { computed, onMounted, watch } from 'vue'
|
|
5
5
|
import { useProjects } from '../composables/useProjects'
|
|
6
6
|
import { useRpc } from '../composables/useRpc'
|
|
7
7
|
import { useThreadPanel } from '../composables/useThreadPanel'
|
|
8
|
+
import {
|
|
9
|
+
resolveThreadSummaryTitle,
|
|
10
|
+
useThreadSummaries,
|
|
11
|
+
type ThreadSummary
|
|
12
|
+
} from '../composables/useThreadSummaries'
|
|
8
13
|
import type { ThreadListResponse } from '~~/shared/codex-rpc'
|
|
9
14
|
import { toProjectThreadRoute } from '~~/shared/codori'
|
|
10
15
|
|
|
@@ -13,12 +18,6 @@ const props = defineProps<{
|
|
|
13
18
|
autoCloseOnSelect?: boolean
|
|
14
19
|
}>()
|
|
15
20
|
|
|
16
|
-
type ThreadSummary = {
|
|
17
|
-
id: string
|
|
18
|
-
title: string
|
|
19
|
-
updatedAt: number
|
|
20
|
-
}
|
|
21
|
-
|
|
22
21
|
type ThreadNavigationItem = NavigationMenuItem & {
|
|
23
22
|
updatedAt: number
|
|
24
23
|
}
|
|
@@ -27,20 +26,21 @@ const route = useRoute()
|
|
|
27
26
|
const { loaded, refreshProjects, startProject, getProject } = useProjects()
|
|
28
27
|
const { getClient } = useRpc()
|
|
29
28
|
const { closePanel } = useThreadPanel()
|
|
30
|
-
|
|
31
|
-
const threads =
|
|
32
|
-
const loading =
|
|
33
|
-
const error =
|
|
29
|
+
const currentThreadSummaries = () => useThreadSummaries(props.projectId ?? '__missing-project__')
|
|
30
|
+
const threads = computed(() => currentThreadSummaries().threads.value)
|
|
31
|
+
const loading = computed(() => currentThreadSummaries().loading.value)
|
|
32
|
+
const error = computed(() => currentThreadSummaries().error.value)
|
|
34
33
|
|
|
35
34
|
const project = computed(() => getProject(props.projectId))
|
|
36
35
|
|
|
37
36
|
const fetchThreads = async () => {
|
|
38
|
-
|
|
37
|
+
const threadSummaries = currentThreadSummaries()
|
|
38
|
+
if (!props.projectId || threadSummaries.loading.value) {
|
|
39
39
|
return
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
threadSummaries.setLoading(true)
|
|
43
|
+
threadSummaries.setError(null)
|
|
44
44
|
|
|
45
45
|
try {
|
|
46
46
|
if (!loaded.value) {
|
|
@@ -57,17 +57,17 @@ const fetchThreads = async () => {
|
|
|
57
57
|
cwd: project.value?.projectPath ?? null
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
threadSummaries.setThreads(response.data
|
|
61
61
|
.map(thread => ({
|
|
62
62
|
id: thread.id,
|
|
63
|
-
title: (thread
|
|
63
|
+
title: resolveThreadSummaryTitle(thread),
|
|
64
64
|
updatedAt: thread.updatedAt
|
|
65
65
|
}))
|
|
66
|
-
|
|
66
|
+
)
|
|
67
67
|
} catch (caughtError) {
|
|
68
|
-
|
|
68
|
+
threadSummaries.setError(caughtError instanceof Error ? caughtError.message : String(caughtError))
|
|
69
69
|
} finally {
|
|
70
|
-
|
|
70
|
+
threadSummaries.setLoading(false)
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
const activeThreadId = computed(() => {
|
|
@@ -80,7 +80,7 @@ const threadItems = computed<ThreadNavigationItem[][]>(() => {
|
|
|
80
80
|
return [[]]
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
return [threads.value.map(thread => ({
|
|
83
|
+
return [threads.value.map((thread: ThreadSummary) => ({
|
|
84
84
|
label: thread.title,
|
|
85
85
|
icon: 'i-lucide-message-square-text',
|
|
86
86
|
to: toProjectThreadRoute(props.projectId!, thread.id),
|
|
@@ -104,7 +104,7 @@ onMounted(() => {
|
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
watch(() => props.projectId, () => {
|
|
107
|
-
|
|
107
|
+
currentThreadSummaries().setThreads([])
|
|
108
108
|
void fetchThreads()
|
|
109
109
|
}, { immediate: true })
|
|
110
110
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ref, type Ref } from 'vue'
|
|
2
|
+
import type { CodexThread } from '~~/shared/codex-rpc'
|
|
3
|
+
|
|
4
|
+
export type ThreadSummary = {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
updatedAt: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type ThreadSummariesState = {
|
|
11
|
+
threads: Ref<ThreadSummary[]>
|
|
12
|
+
loading: Ref<boolean>
|
|
13
|
+
error: Ref<string | null>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type UseThreadSummariesResult = ThreadSummariesState & {
|
|
17
|
+
setThreads: (nextThreads: ThreadSummary[]) => void
|
|
18
|
+
setLoading: (nextLoading: boolean) => void
|
|
19
|
+
setError: (nextError: string | null) => void
|
|
20
|
+
syncThreadSummary: (thread: Pick<CodexThread, 'id' | 'name' | 'preview' | 'updatedAt'>) => void
|
|
21
|
+
updateThreadSummaryTitle: (threadId: string, title: string, updatedAt?: number) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const states = new Map<string, ThreadSummariesState>()
|
|
25
|
+
|
|
26
|
+
export const resolveThreadSummaryTitle = (thread: Pick<CodexThread, 'id' | 'name' | 'preview'>) => {
|
|
27
|
+
const nextTitle = thread.name?.trim() || thread.preview.trim()
|
|
28
|
+
return nextTitle || `Thread ${thread.id}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const mergeThreadSummary = (threads: ThreadSummary[], nextThread: ThreadSummary) => {
|
|
32
|
+
const filtered = threads.filter(thread => thread.id !== nextThread.id)
|
|
33
|
+
return [...filtered, nextThread].sort((left, right) => right.updatedAt - left.updatedAt)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const renameThreadSummary = (
|
|
37
|
+
threads: ThreadSummary[],
|
|
38
|
+
input: {
|
|
39
|
+
threadId: string
|
|
40
|
+
title: string
|
|
41
|
+
updatedAt?: number
|
|
42
|
+
}
|
|
43
|
+
) => {
|
|
44
|
+
const nextTitle = input.title.trim()
|
|
45
|
+
if (!nextTitle) {
|
|
46
|
+
return threads
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const existing = threads.find(thread => thread.id === input.threadId)
|
|
50
|
+
return mergeThreadSummary(threads, {
|
|
51
|
+
id: input.threadId,
|
|
52
|
+
title: nextTitle,
|
|
53
|
+
updatedAt: input.updatedAt ?? existing?.updatedAt ?? Date.now()
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const createState = (): ThreadSummariesState => ({
|
|
58
|
+
threads: ref<ThreadSummary[]>([]),
|
|
59
|
+
loading: ref(false),
|
|
60
|
+
error: ref<string | null>(null)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const createApi = (state: ThreadSummariesState): UseThreadSummariesResult => ({
|
|
64
|
+
...state,
|
|
65
|
+
setThreads: (nextThreads: ThreadSummary[]) => {
|
|
66
|
+
state.threads.value = [...nextThreads].sort((left, right) => right.updatedAt - left.updatedAt)
|
|
67
|
+
},
|
|
68
|
+
setLoading: (nextLoading: boolean) => {
|
|
69
|
+
state.loading.value = nextLoading
|
|
70
|
+
},
|
|
71
|
+
setError: (nextError: string | null) => {
|
|
72
|
+
state.error.value = nextError
|
|
73
|
+
},
|
|
74
|
+
syncThreadSummary: (thread: Pick<CodexThread, 'id' | 'name' | 'preview' | 'updatedAt'>) => {
|
|
75
|
+
state.threads.value = mergeThreadSummary(state.threads.value, {
|
|
76
|
+
id: thread.id,
|
|
77
|
+
title: resolveThreadSummaryTitle(thread),
|
|
78
|
+
updatedAt: thread.updatedAt
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
updateThreadSummaryTitle: (threadId: string, title: string, updatedAt?: number) => {
|
|
82
|
+
state.threads.value = renameThreadSummary(state.threads.value, {
|
|
83
|
+
threadId,
|
|
84
|
+
title,
|
|
85
|
+
updatedAt
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
export const useThreadSummaries = (projectId: string): UseThreadSummariesResult => {
|
|
91
|
+
const existing = states.get(projectId)
|
|
92
|
+
if (existing) {
|
|
93
|
+
return createApi(existing)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const state = createState()
|
|
97
|
+
states.set(projectId, state)
|
|
98
|
+
return createApi(state)
|
|
99
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ChatStatus } from '../composables/useChatSession'
|
|
2
|
+
|
|
3
|
+
export const shouldAwaitAssistantOutput = (
|
|
4
|
+
submissionMethod: 'turn/start' | 'turn/steer'
|
|
5
|
+
) => submissionMethod === 'turn/start'
|
|
6
|
+
|
|
7
|
+
export const resolveChatMessagesStatus = (
|
|
8
|
+
status: ChatStatus,
|
|
9
|
+
awaitingAssistantOutput: boolean
|
|
10
|
+
): ChatStatus => {
|
|
11
|
+
if (status === 'ready' || status === 'error') {
|
|
12
|
+
return status
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return awaitingAssistantOutput ? 'submitted' : 'streaming'
|
|
16
|
+
}
|
|
@@ -16,6 +16,46 @@ const interruptIgnoredMethods = new Set([
|
|
|
16
16
|
export const resolveTurnSubmissionMethod = (hasActiveTurn: boolean) =>
|
|
17
17
|
hasActiveTurn ? 'turn/steer' : 'turn/start'
|
|
18
18
|
|
|
19
|
+
export const hasSteerableTurn = (input: {
|
|
20
|
+
activeThreadId: string | null
|
|
21
|
+
liveStreamThreadId: string | null
|
|
22
|
+
liveStreamTurnId: string | null
|
|
23
|
+
}) =>
|
|
24
|
+
input.activeThreadId !== null
|
|
25
|
+
&& input.liveStreamThreadId === input.activeThreadId
|
|
26
|
+
&& input.liveStreamTurnId !== null
|
|
27
|
+
|
|
28
|
+
export const shouldSubmitViaTurnSteer = (input: {
|
|
29
|
+
activeThreadId: string | null
|
|
30
|
+
liveStreamThreadId: string | null
|
|
31
|
+
liveStreamTurnId: string | null
|
|
32
|
+
status: PromptSubmitStatus
|
|
33
|
+
}) =>
|
|
34
|
+
(input.activeThreadId !== null
|
|
35
|
+
&& input.liveStreamThreadId === input.activeThreadId
|
|
36
|
+
&& input.status === 'submitted')
|
|
37
|
+
|| hasSteerableTurn(input)
|
|
38
|
+
|
|
39
|
+
export const shouldAwaitThreadHydration = (input: {
|
|
40
|
+
hasPendingThreadHydration: boolean
|
|
41
|
+
routeThreadId: string | null
|
|
42
|
+
}) =>
|
|
43
|
+
input.hasPendingThreadHydration
|
|
44
|
+
&& input.routeThreadId !== null
|
|
45
|
+
|
|
46
|
+
export const shouldRetrySteerWithTurnStart = (message: string) =>
|
|
47
|
+
/no active turn to steer|active turn is no longer available/i.test(message)
|
|
48
|
+
|
|
49
|
+
export const shouldApplyNotificationToCurrentTurn = (input: {
|
|
50
|
+
liveStreamTurnId: string | null
|
|
51
|
+
notificationMethod: string
|
|
52
|
+
notificationTurnId: string | null
|
|
53
|
+
}) =>
|
|
54
|
+
input.liveStreamTurnId === null
|
|
55
|
+
|| input.notificationTurnId === null
|
|
56
|
+
|| input.notificationTurnId === input.liveStreamTurnId
|
|
57
|
+
|| input.notificationMethod === 'turn/started'
|
|
58
|
+
|
|
19
59
|
export const resolvePromptSubmitStatus = (input: {
|
|
20
60
|
status: PromptSubmitStatus
|
|
21
61
|
hasDraftContent: boolean
|
|
@@ -30,12 +70,50 @@ export const removeChatMessage = (messages: ChatMessage[], messageId: string) =>
|
|
|
30
70
|
export const removePendingUserMessageId = (messageIds: string[], messageId: string) =>
|
|
31
71
|
messageIds.filter(candidateId => candidateId !== messageId)
|
|
32
72
|
|
|
73
|
+
const isEquivalentUserMessagePart = (
|
|
74
|
+
currentPart: ChatMessage['parts'][number],
|
|
75
|
+
confirmedPart: ChatMessage['parts'][number]
|
|
76
|
+
) => {
|
|
77
|
+
if (currentPart.type === 'text' && confirmedPart.type === 'text') {
|
|
78
|
+
return currentPart.text === confirmedPart.text
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (currentPart.type === 'attachment' && confirmedPart.type === 'attachment') {
|
|
82
|
+
return currentPart.attachment.kind === confirmedPart.attachment.kind
|
|
83
|
+
&& currentPart.attachment.name === confirmedPart.attachment.name
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const findOptimisticUserMessageIndex = (
|
|
90
|
+
messages: ChatMessage[],
|
|
91
|
+
optimisticMessageId: string,
|
|
92
|
+
confirmedMessage: ChatMessage
|
|
93
|
+
) => {
|
|
94
|
+
const directIndex = messages.findIndex(message => message.id === optimisticMessageId)
|
|
95
|
+
if (directIndex !== -1) {
|
|
96
|
+
return directIndex
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (confirmedMessage.role !== 'user') {
|
|
100
|
+
return -1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return messages.findIndex(message =>
|
|
104
|
+
message.role === 'user'
|
|
105
|
+
&& message.pending === true
|
|
106
|
+
&& message.parts.length === confirmedMessage.parts.length
|
|
107
|
+
&& message.parts.every((part, index) => isEquivalentUserMessagePart(part, confirmedMessage.parts[index]!))
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
33
111
|
export const reconcileOptimisticUserMessage = (
|
|
34
112
|
messages: ChatMessage[],
|
|
35
113
|
optimisticMessageId: string,
|
|
36
114
|
confirmedMessage: ChatMessage
|
|
37
115
|
) => {
|
|
38
|
-
const index = messages
|
|
116
|
+
const index = findOptimisticUserMessageIndex(messages, optimisticMessageId, confirmedMessage)
|
|
39
117
|
if (index === -1) {
|
|
40
118
|
return upsertStreamingMessage(messages, confirmedMessage)
|
|
41
119
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const sortSidebarProjects = <T extends { projectId: string }>(
|
|
2
|
+
projects: T[],
|
|
3
|
+
activeProjectId: string | null
|
|
4
|
+
) => {
|
|
5
|
+
const alphabeticalProjects = [...projects].sort((left, right) =>
|
|
6
|
+
left.projectId.localeCompare(right.projectId)
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
if (!activeProjectId) {
|
|
10
|
+
return alphabeticalProjects
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const activeIndex = alphabeticalProjects.findIndex(project => project.projectId === activeProjectId)
|
|
14
|
+
if (activeIndex < 0) {
|
|
15
|
+
return alphabeticalProjects
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const [activeProject] = alphabeticalProjects.splice(activeIndex, 1)
|
|
19
|
+
if (!activeProject) {
|
|
20
|
+
return alphabeticalProjects
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return [activeProject, ...alphabeticalProjects]
|
|
24
|
+
}
|
package/package.json
CHANGED
package/shared/codex-chat.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type { CodexThread, CodexThreadItem, CodexUserInput } from './codex-rpc'
|
|
|
2
2
|
|
|
3
3
|
export const EVENT_PART = 'data-thread-event' as const
|
|
4
4
|
export const ITEM_PART = 'data-thread-item' as const
|
|
5
|
-
export const THINKING_PLACEHOLDER_MESSAGE_ID = 'assistant-thinking-placeholder'
|
|
6
5
|
|
|
7
6
|
export type ThreadEventData =
|
|
8
7
|
| {
|
|
@@ -377,29 +376,3 @@ export const replaceStreamingMessage = (messages: ChatMessage[], nextMessage: Ch
|
|
|
377
376
|
nextMessages.splice(existingIndex, 1, normalizedMessage)
|
|
378
377
|
return nextMessages
|
|
379
378
|
}
|
|
380
|
-
|
|
381
|
-
export const buildThinkingPlaceholderMessage = (): ChatMessage => ({
|
|
382
|
-
id: THINKING_PLACEHOLDER_MESSAGE_ID,
|
|
383
|
-
role: 'assistant',
|
|
384
|
-
pending: true,
|
|
385
|
-
parts: [{
|
|
386
|
-
type: 'reasoning',
|
|
387
|
-
summary: ['Thinking...'],
|
|
388
|
-
content: [],
|
|
389
|
-
state: 'streaming'
|
|
390
|
-
}]
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
export const showThinkingPlaceholder = (messages: ChatMessage[]) =>
|
|
394
|
-
upsertStreamingMessage(messages, buildThinkingPlaceholderMessage())
|
|
395
|
-
|
|
396
|
-
export const hideThinkingPlaceholder = (messages: ChatMessage[]) => {
|
|
397
|
-
const index = messages.findIndex(message => message.id === THINKING_PLACEHOLDER_MESSAGE_ID)
|
|
398
|
-
if (index === -1) {
|
|
399
|
-
return messages
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const nextMessages = messages.slice()
|
|
403
|
-
nextMessages.splice(index, 1)
|
|
404
|
-
return nextMessages
|
|
405
|
-
}
|
package/shared/codex-rpc.ts
CHANGED
|
@@ -259,6 +259,22 @@ export const notificationTurnId = (notification: CodexRpcNotification) => {
|
|
|
259
259
|
return null
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
export const notificationThreadName = (notification: CodexRpcNotification) => {
|
|
263
|
+
const params = isObjectRecord(notification.params) ? notification.params : null
|
|
264
|
+
const thread = isObjectRecord(params?.thread) ? params.thread : null
|
|
265
|
+
const directName = thread?.name ?? params?.name ?? params?.title
|
|
266
|
+
|
|
267
|
+
return typeof directName === 'string' ? directName : null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const notificationThreadUpdatedAt = (notification: CodexRpcNotification) => {
|
|
271
|
+
const params = isObjectRecord(notification.params) ? notification.params : null
|
|
272
|
+
const thread = isObjectRecord(params?.thread) ? params.thread : null
|
|
273
|
+
const directUpdatedAt = thread?.updatedAt ?? params?.updatedAt
|
|
274
|
+
|
|
275
|
+
return typeof directUpdatedAt === 'number' ? directUpdatedAt : undefined
|
|
276
|
+
}
|
|
277
|
+
|
|
262
278
|
export class CodexRpcClient {
|
|
263
279
|
private readonly url: string
|
|
264
280
|
|