@codori/client 0.0.5 → 0.0.6
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 +273 -140
- 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 +68 -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,9 @@ import {
|
|
|
8
8
|
removePendingUserMessageId,
|
|
9
9
|
resolvePromptSubmitStatus,
|
|
10
10
|
resolveTurnSubmissionMethod,
|
|
11
|
+
shouldSubmitViaTurnSteer,
|
|
12
|
+
shouldAwaitThreadHydration,
|
|
13
|
+
shouldRetrySteerWithTurnStart,
|
|
11
14
|
shouldIgnoreNotificationAfterInterrupt
|
|
12
15
|
} from '../utils/chat-turn-engagement'
|
|
13
16
|
import { useChatAttachments, type DraftAttachment } from '../composables/useChatAttachments'
|
|
@@ -15,14 +18,14 @@ import { useChatSession, type LiveStream, type SubagentPanelState } from '../com
|
|
|
15
18
|
import { useProjects } from '../composables/useProjects'
|
|
16
19
|
import { useRpc } from '../composables/useRpc'
|
|
17
20
|
import { useChatSubmitGuard } from '../composables/useChatSubmitGuard'
|
|
21
|
+
import { resolveThreadSummaryTitle, useThreadSummaries } from '../composables/useThreadSummaries'
|
|
22
|
+
import { resolveChatMessagesStatus, shouldAwaitAssistantOutput } from '../utils/chat-messages-status'
|
|
18
23
|
import {
|
|
19
|
-
hideThinkingPlaceholder,
|
|
20
24
|
ITEM_PART,
|
|
21
25
|
eventToMessage,
|
|
22
26
|
isSubagentActiveStatus,
|
|
23
27
|
itemToMessages,
|
|
24
28
|
replaceStreamingMessage,
|
|
25
|
-
showThinkingPlaceholder,
|
|
26
29
|
threadToMessages,
|
|
27
30
|
upsertStreamingMessage,
|
|
28
31
|
type ChatMessage,
|
|
@@ -31,11 +34,13 @@ import {
|
|
|
31
34
|
type ItemData,
|
|
32
35
|
type McpToolCallItem
|
|
33
36
|
} from '~~/shared/codex-chat'
|
|
34
|
-
import { buildTurnStartInput } from '~~/shared/chat-attachments'
|
|
37
|
+
import { buildTurnStartInput, type PersistedProjectAttachment } from '~~/shared/chat-attachments'
|
|
35
38
|
import {
|
|
36
39
|
type ConfigReadResponse,
|
|
37
40
|
type ModelListResponse,
|
|
41
|
+
notificationThreadName,
|
|
38
42
|
notificationThreadId,
|
|
43
|
+
notificationThreadUpdatedAt,
|
|
39
44
|
notificationTurnId,
|
|
40
45
|
type CodexRpcNotification,
|
|
41
46
|
type CodexThread,
|
|
@@ -105,6 +110,7 @@ const input = ref('')
|
|
|
105
110
|
const scrollViewport = ref<HTMLElement | null>(null)
|
|
106
111
|
const pinnedToBottom = ref(true)
|
|
107
112
|
const session = useChatSession(props.projectId)
|
|
113
|
+
const { syncThreadSummary, updateThreadSummaryTitle } = useThreadSummaries(props.projectId)
|
|
108
114
|
const {
|
|
109
115
|
messages,
|
|
110
116
|
subagentPanels,
|
|
@@ -128,6 +134,7 @@ const selectedProject = computed(() => getProject(props.projectId))
|
|
|
128
134
|
const composerError = computed(() => attachmentError.value ?? error.value)
|
|
129
135
|
const submitError = computed(() => composerError.value ? new Error(composerError.value) : undefined)
|
|
130
136
|
const interruptRequested = ref(false)
|
|
137
|
+
const awaitingAssistantOutput = ref(false)
|
|
131
138
|
const isBusy = computed(() =>
|
|
132
139
|
status.value === 'submitted'
|
|
133
140
|
|| status.value === 'streaming'
|
|
@@ -149,6 +156,9 @@ const promptSubmitStatus = computed(() =>
|
|
|
149
156
|
)
|
|
150
157
|
const routeThreadId = computed(() => props.threadId ?? null)
|
|
151
158
|
const projectTitle = computed(() => selectedProject.value?.projectId ?? props.projectId)
|
|
159
|
+
const chatMessagesStatus = computed(() =>
|
|
160
|
+
resolveChatMessagesStatus(status.value, awaitingAssistantOutput.value)
|
|
161
|
+
)
|
|
152
162
|
const showWelcomeState = computed(() =>
|
|
153
163
|
!routeThreadId.value
|
|
154
164
|
&& !activeThreadId.value
|
|
@@ -303,6 +313,7 @@ const loadPromptControls = async () => {
|
|
|
303
313
|
const subagentBootstrapPromises = new Map<string, Promise<void>>()
|
|
304
314
|
const optimisticAttachmentSnapshots = new Map<string, DraftAttachment[]>()
|
|
305
315
|
let promptControlsPromise: Promise<void> | null = null
|
|
316
|
+
let pendingThreadHydration: Promise<void> | null = null
|
|
306
317
|
|
|
307
318
|
const isActiveTurnStatus = (value: string | null | undefined) => {
|
|
308
319
|
if (!value) {
|
|
@@ -320,6 +331,14 @@ const currentLiveStream = () =>
|
|
|
320
331
|
const hasActiveTurnEngagement = () =>
|
|
321
332
|
Boolean(currentLiveStream() || session.pendingLiveStream)
|
|
322
333
|
|
|
334
|
+
const shouldSubmitWithTurnSteer = () =>
|
|
335
|
+
shouldSubmitViaTurnSteer({
|
|
336
|
+
activeThreadId: activeThreadId.value,
|
|
337
|
+
liveStreamThreadId: session.liveStream?.threadId ?? null,
|
|
338
|
+
liveStreamTurnId: session.liveStream?.turnId ?? null,
|
|
339
|
+
status: status.value
|
|
340
|
+
})
|
|
341
|
+
|
|
323
342
|
const rejectLiveStreamTurnWaiters = (liveStream: LiveStream, error: Error) => {
|
|
324
343
|
const waiters = liveStream.turnIdWaiters.splice(0, liveStream.turnIdWaiters.length)
|
|
325
344
|
for (const waiter of waiters) {
|
|
@@ -379,6 +398,25 @@ const waitForLiveStreamTurnId = async (liveStream: LiveStream) => {
|
|
|
379
398
|
})
|
|
380
399
|
}
|
|
381
400
|
|
|
401
|
+
const queuePendingUserMessage = (liveStream: LiveStream, messageId: string) => {
|
|
402
|
+
if (liveStream.pendingUserMessageIds.includes(messageId)) {
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
liveStream.pendingUserMessageIds.push(messageId)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const replayBufferedNotifications = (liveStream: LiveStream) => {
|
|
410
|
+
for (const notification of liveStream.bufferedNotifications.splice(0, liveStream.bufferedNotifications.length)) {
|
|
411
|
+
const turnId = notificationTurnId(notification)
|
|
412
|
+
if (turnId && turnId !== liveStream.turnId) {
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
applyNotification(notification)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
382
420
|
const clearLiveStream = (reason?: Error) => {
|
|
383
421
|
const liveStream = session.liveStream
|
|
384
422
|
if (!liveStream) {
|
|
@@ -509,17 +547,13 @@ const removeOptimisticMessage = (messageId: string) => {
|
|
|
509
547
|
optimisticAttachmentSnapshots.delete(messageId)
|
|
510
548
|
}
|
|
511
549
|
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const ensureThinkingPlaceholder = () => {
|
|
517
|
-
messages.value = showThinkingPlaceholder(messages.value)
|
|
550
|
+
const markAwaitingAssistantOutput = (nextValue: boolean) => {
|
|
551
|
+
awaitingAssistantOutput.value = nextValue
|
|
518
552
|
}
|
|
519
553
|
|
|
520
|
-
const
|
|
554
|
+
const markAssistantOutputStartedForItem = (item: CodexThreadItem) => {
|
|
521
555
|
if (item.type !== 'userMessage') {
|
|
522
|
-
|
|
556
|
+
markAwaitingAssistantOutput(false)
|
|
523
557
|
}
|
|
524
558
|
}
|
|
525
559
|
|
|
@@ -576,9 +610,42 @@ const clearPendingOptimisticMessages = (liveStream: LiveStream | null, options?:
|
|
|
576
610
|
liveStream.pendingUserMessageIds = []
|
|
577
611
|
}
|
|
578
612
|
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
613
|
+
const submitTurnStart = async (input: {
|
|
614
|
+
client: ReturnType<typeof getClient>
|
|
615
|
+
liveStream: LiveStream
|
|
616
|
+
text: string
|
|
617
|
+
submittedAttachments: DraftAttachment[]
|
|
618
|
+
uploadedAttachments?: PersistedProjectAttachment[]
|
|
619
|
+
optimisticMessageId: string
|
|
620
|
+
queueOptimisticMessage?: boolean
|
|
621
|
+
}) => {
|
|
622
|
+
const {
|
|
623
|
+
client,
|
|
624
|
+
liveStream,
|
|
625
|
+
text,
|
|
626
|
+
submittedAttachments,
|
|
627
|
+
uploadedAttachments: existingUploadedAttachments,
|
|
628
|
+
optimisticMessageId,
|
|
629
|
+
queueOptimisticMessage: shouldQueueOptimisticMessage = true
|
|
630
|
+
} = input
|
|
631
|
+
|
|
632
|
+
if (shouldQueueOptimisticMessage) {
|
|
633
|
+
queuePendingUserMessage(liveStream, optimisticMessageId)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const uploadedAttachments = existingUploadedAttachments
|
|
637
|
+
?? await uploadAttachments(liveStream.threadId, submittedAttachments)
|
|
638
|
+
const turnStart = await client.request<TurnStartResponse>('turn/start', {
|
|
639
|
+
threadId: liveStream.threadId,
|
|
640
|
+
input: buildTurnStartInput(text, uploadedAttachments),
|
|
641
|
+
cwd: selectedProject.value?.projectPath ?? null,
|
|
642
|
+
approvalPolicy: 'never',
|
|
643
|
+
...buildTurnOverrides(selectedModel.value, selectedEffort.value)
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
tokenUsage.value = null
|
|
647
|
+
setLiveStreamTurnId(liveStream, turnStart.turn.id)
|
|
648
|
+
replayBufferedNotifications(liveStream)
|
|
582
649
|
}
|
|
583
650
|
|
|
584
651
|
const shortThreadId = (value: string) => value.slice(0, 8)
|
|
@@ -700,106 +767,120 @@ const ensureProjectRuntime = async () => {
|
|
|
700
767
|
}
|
|
701
768
|
|
|
702
769
|
const hydrateThread = async (threadId: string) => {
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
770
|
+
const hydratePromise = (async () => {
|
|
771
|
+
const requestVersion = loadVersion.value + 1
|
|
772
|
+
loadVersion.value = requestVersion
|
|
773
|
+
error.value = null
|
|
774
|
+
tokenUsage.value = null
|
|
707
775
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
776
|
+
try {
|
|
777
|
+
await ensureProjectRuntime()
|
|
778
|
+
const client = getClient(props.projectId)
|
|
779
|
+
activeThreadId.value = threadId
|
|
712
780
|
|
|
713
|
-
|
|
781
|
+
const existingLiveStream = session.liveStream
|
|
714
782
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
783
|
+
if (existingLiveStream && existingLiveStream.threadId !== threadId) {
|
|
784
|
+
clearLiveStream()
|
|
785
|
+
}
|
|
718
786
|
|
|
719
|
-
|
|
720
|
-
|
|
787
|
+
if (!session.liveStream) {
|
|
788
|
+
const nextLiveStream = createLiveStreamState(threadId)
|
|
721
789
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
790
|
+
nextLiveStream.unsubscribe = client.subscribe((notification) => {
|
|
791
|
+
const targetThreadId = notificationThreadId(notification)
|
|
792
|
+
if (!targetThreadId) {
|
|
793
|
+
return
|
|
794
|
+
}
|
|
727
795
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
796
|
+
if (targetThreadId !== threadId) {
|
|
797
|
+
if (nextLiveStream.observedSubagentThreadIds.has(targetThreadId)) {
|
|
798
|
+
applySubagentNotification(targetThreadId, notification)
|
|
799
|
+
}
|
|
800
|
+
return
|
|
731
801
|
}
|
|
732
|
-
return
|
|
733
|
-
}
|
|
734
802
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
803
|
+
if (!nextLiveStream.turnId) {
|
|
804
|
+
nextLiveStream.bufferedNotifications.push(notification)
|
|
805
|
+
return
|
|
806
|
+
}
|
|
739
807
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
808
|
+
const turnId = notificationTurnId(notification)
|
|
809
|
+
if (turnId && turnId !== nextLiveStream.turnId) {
|
|
810
|
+
return
|
|
811
|
+
}
|
|
744
812
|
|
|
745
|
-
|
|
746
|
-
|
|
813
|
+
applyNotification(notification)
|
|
814
|
+
})
|
|
747
815
|
|
|
748
|
-
|
|
749
|
-
|
|
816
|
+
setSessionLiveStream(nextLiveStream)
|
|
817
|
+
}
|
|
750
818
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
819
|
+
const resumeResponse = await client.request<ThreadResumeResponse>('thread/resume', {
|
|
820
|
+
threadId,
|
|
821
|
+
cwd: selectedProject.value?.projectPath ?? null,
|
|
822
|
+
approvalPolicy: 'never',
|
|
823
|
+
persistExtendedHistory: true
|
|
824
|
+
})
|
|
825
|
+
const response = await client.request<ThreadReadResponse>('thread/read', {
|
|
826
|
+
threadId,
|
|
827
|
+
includeTurns: true
|
|
828
|
+
})
|
|
761
829
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
830
|
+
if (loadVersion.value !== requestVersion) {
|
|
831
|
+
return
|
|
832
|
+
}
|
|
765
833
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
834
|
+
syncPromptSelectionFromThread(
|
|
835
|
+
resumeResponse.model ?? null,
|
|
836
|
+
(resumeResponse.reasoningEffort as ReasoningEffort | null | undefined) ?? null
|
|
837
|
+
)
|
|
838
|
+
activeThreadId.value = response.thread.id
|
|
839
|
+
threadTitle.value = resolveThreadSummaryTitle(response.thread)
|
|
840
|
+
syncThreadSummary(response.thread)
|
|
841
|
+
messages.value = threadToMessages(response.thread)
|
|
842
|
+
rebuildSubagentPanelsFromThread(response.thread)
|
|
843
|
+
markAwaitingAssistantOutput(false)
|
|
844
|
+
const activeTurn = [...response.thread.turns].reverse().find(turn => isActiveTurnStatus(turn.status))
|
|
775
845
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
846
|
+
if (!activeTurn) {
|
|
847
|
+
clearLiveStream()
|
|
848
|
+
status.value = 'ready'
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (!session.liveStream) {
|
|
853
|
+
status.value = 'streaming'
|
|
854
|
+
return
|
|
855
|
+
}
|
|
781
856
|
|
|
782
|
-
|
|
857
|
+
setLiveStreamTurnId(session.liveStream, activeTurn.id)
|
|
783
858
|
status.value = 'streaming'
|
|
784
|
-
return
|
|
785
|
-
}
|
|
786
859
|
|
|
787
|
-
|
|
788
|
-
|
|
860
|
+
const pendingNotifications = session.liveStream.bufferedNotifications.splice(0, session.liveStream.bufferedNotifications.length)
|
|
861
|
+
for (const notification of pendingNotifications) {
|
|
862
|
+
const turnId = notificationTurnId(notification)
|
|
863
|
+
if (turnId && turnId !== activeTurn.id) {
|
|
864
|
+
continue
|
|
865
|
+
}
|
|
789
866
|
|
|
790
|
-
|
|
791
|
-
for (const notification of pendingNotifications) {
|
|
792
|
-
const turnId = notificationTurnId(notification)
|
|
793
|
-
if (turnId && turnId !== activeTurn.id) {
|
|
794
|
-
continue
|
|
867
|
+
applyNotification(notification)
|
|
795
868
|
}
|
|
869
|
+
} catch (caughtError) {
|
|
870
|
+
clearLiveStream()
|
|
871
|
+
error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
872
|
+
status.value = 'error'
|
|
873
|
+
}
|
|
874
|
+
})()
|
|
796
875
|
|
|
797
|
-
|
|
876
|
+
pendingThreadHydration = hydratePromise
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
await hydratePromise
|
|
880
|
+
} finally {
|
|
881
|
+
if (pendingThreadHydration === hydratePromise) {
|
|
882
|
+
pendingThreadHydration = null
|
|
798
883
|
}
|
|
799
|
-
} catch (caughtError) {
|
|
800
|
-
clearLiveStream()
|
|
801
|
-
error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
802
|
-
status.value = 'error'
|
|
803
884
|
}
|
|
804
885
|
}
|
|
805
886
|
|
|
@@ -814,6 +895,7 @@ const resetDraftThread = () => {
|
|
|
814
895
|
subagentPanels.value = []
|
|
815
896
|
error.value = null
|
|
816
897
|
tokenUsage.value = null
|
|
898
|
+
markAwaitingAssistantOutput(false)
|
|
817
899
|
status.value = 'ready'
|
|
818
900
|
}
|
|
819
901
|
|
|
@@ -835,7 +917,8 @@ const ensureThread = async () => {
|
|
|
835
917
|
})
|
|
836
918
|
|
|
837
919
|
activeThreadId.value = response.thread.id
|
|
838
|
-
threadTitle.value =
|
|
920
|
+
threadTitle.value = resolveThreadSummaryTitle(response.thread)
|
|
921
|
+
syncThreadSummary(response.thread)
|
|
839
922
|
return {
|
|
840
923
|
threadId: response.thread.id,
|
|
841
924
|
created: true
|
|
@@ -1422,6 +1505,24 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1422
1505
|
pendingThreadId.value = pendingThreadId.value ?? nextThreadId
|
|
1423
1506
|
return
|
|
1424
1507
|
}
|
|
1508
|
+
case 'thread/name/updated': {
|
|
1509
|
+
const nextThreadId = notificationThreadId(notification)
|
|
1510
|
+
const nextThreadName = notificationThreadName(notification)
|
|
1511
|
+
if (!nextThreadId || !nextThreadName) {
|
|
1512
|
+
return
|
|
1513
|
+
}
|
|
1514
|
+
const nextTitle = nextThreadName.trim()
|
|
1515
|
+
if (!nextTitle) {
|
|
1516
|
+
return
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (activeThreadId.value === nextThreadId) {
|
|
1520
|
+
threadTitle.value = nextTitle
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
updateThreadSummaryTitle(nextThreadId, nextTitle, notificationThreadUpdatedAt(notification))
|
|
1524
|
+
return
|
|
1525
|
+
}
|
|
1425
1526
|
case 'turn/started': {
|
|
1426
1527
|
if (liveStream) {
|
|
1427
1528
|
setLiveStreamTurnId(liveStream, notificationTurnId(notification))
|
|
@@ -1451,7 +1552,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1451
1552
|
status.value = 'streaming'
|
|
1452
1553
|
return
|
|
1453
1554
|
}
|
|
1454
|
-
|
|
1555
|
+
markAssistantOutputStartedForItem(params.item)
|
|
1455
1556
|
seedStreamingMessage(params.item)
|
|
1456
1557
|
status.value = 'streaming'
|
|
1457
1558
|
return
|
|
@@ -1461,7 +1562,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1461
1562
|
if (params.item.type === 'collabAgentToolCall') {
|
|
1462
1563
|
applySubagentActivityItem(params.item)
|
|
1463
1564
|
}
|
|
1464
|
-
|
|
1565
|
+
markAssistantOutputStartedForItem(params.item)
|
|
1465
1566
|
for (const nextMessage of itemToMessages(params.item)) {
|
|
1466
1567
|
const confirmedMessage = {
|
|
1467
1568
|
...nextMessage,
|
|
@@ -1478,7 +1579,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1478
1579
|
}
|
|
1479
1580
|
case 'item/agentMessage/delta': {
|
|
1480
1581
|
const params = notification.params as { itemId: string, delta: string }
|
|
1481
|
-
|
|
1582
|
+
markAwaitingAssistantOutput(false)
|
|
1482
1583
|
appendTextPartDelta(params.itemId, params.delta, {
|
|
1483
1584
|
id: params.itemId,
|
|
1484
1585
|
role: 'assistant',
|
|
@@ -1494,7 +1595,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1494
1595
|
}
|
|
1495
1596
|
case 'item/plan/delta': {
|
|
1496
1597
|
const params = notification.params as { itemId: string, delta: string }
|
|
1497
|
-
|
|
1598
|
+
markAwaitingAssistantOutput(false)
|
|
1498
1599
|
appendTextPartDelta(params.itemId, params.delta, {
|
|
1499
1600
|
id: params.itemId,
|
|
1500
1601
|
role: 'assistant',
|
|
@@ -1511,7 +1612,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1511
1612
|
case 'item/reasoning/textDelta':
|
|
1512
1613
|
case 'item/reasoning/summaryTextDelta': {
|
|
1513
1614
|
const params = notification.params as { itemId: string, delta: string }
|
|
1514
|
-
|
|
1615
|
+
markAwaitingAssistantOutput(false)
|
|
1515
1616
|
updateMessage(params.itemId, {
|
|
1516
1617
|
id: params.itemId,
|
|
1517
1618
|
role: 'assistant',
|
|
@@ -1619,7 +1720,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1619
1720
|
case 'error': {
|
|
1620
1721
|
const params = notification.params as { error?: { message?: string } }
|
|
1621
1722
|
const messageText = params.error?.message ?? 'The stream failed.'
|
|
1622
|
-
|
|
1723
|
+
markAwaitingAssistantOutput(false)
|
|
1623
1724
|
pushEventMessage('stream.error', messageText)
|
|
1624
1725
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1625
1726
|
clearLiveStream(new Error(messageText))
|
|
@@ -1630,7 +1731,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1630
1731
|
case 'turn/failed': {
|
|
1631
1732
|
const params = notification.params as { error?: { message?: string } }
|
|
1632
1733
|
const messageText = params.error?.message ?? 'The turn failed.'
|
|
1633
|
-
|
|
1734
|
+
markAwaitingAssistantOutput(false)
|
|
1634
1735
|
pushEventMessage('turn.failed', messageText)
|
|
1635
1736
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1636
1737
|
clearLiveStream(new Error(messageText))
|
|
@@ -1641,7 +1742,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1641
1742
|
case 'stream/error': {
|
|
1642
1743
|
const params = notification.params as { message?: string }
|
|
1643
1744
|
const messageText = params.message ?? 'The stream failed.'
|
|
1644
|
-
|
|
1745
|
+
markAwaitingAssistantOutput(false)
|
|
1645
1746
|
pushEventMessage('stream.error', messageText)
|
|
1646
1747
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1647
1748
|
clearLiveStream(new Error(messageText))
|
|
@@ -1650,7 +1751,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1650
1751
|
return
|
|
1651
1752
|
}
|
|
1652
1753
|
case 'turn/completed': {
|
|
1653
|
-
|
|
1754
|
+
markAwaitingAssistantOutput(false)
|
|
1654
1755
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1655
1756
|
error.value = null
|
|
1656
1757
|
status.value = 'ready'
|
|
@@ -1678,10 +1779,17 @@ const sendMessage = async () => {
|
|
|
1678
1779
|
return
|
|
1679
1780
|
}
|
|
1680
1781
|
|
|
1782
|
+
if (pendingThreadHydration && shouldAwaitThreadHydration({
|
|
1783
|
+
hasPendingThreadHydration: true,
|
|
1784
|
+
routeThreadId: routeThreadId.value
|
|
1785
|
+
})) {
|
|
1786
|
+
await pendingThreadHydration
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1681
1789
|
pinnedToBottom.value = true
|
|
1682
1790
|
error.value = null
|
|
1683
1791
|
attachmentError.value = null
|
|
1684
|
-
const submissionMethod = resolveTurnSubmissionMethod(
|
|
1792
|
+
const submissionMethod = resolveTurnSubmissionMethod(shouldSubmitWithTurnSteer())
|
|
1685
1793
|
if (submissionMethod === 'turn/start') {
|
|
1686
1794
|
status.value = 'submitted'
|
|
1687
1795
|
}
|
|
@@ -1691,61 +1799,71 @@ const sendMessage = async () => {
|
|
|
1691
1799
|
const optimisticMessageId = optimisticMessage.id
|
|
1692
1800
|
rememberOptimisticAttachments(optimisticMessageId, submittedAttachments)
|
|
1693
1801
|
messages.value = [...messages.value, optimisticMessage]
|
|
1694
|
-
|
|
1802
|
+
markAwaitingAssistantOutput(shouldAwaitAssistantOutput(submissionMethod))
|
|
1695
1803
|
let startedLiveStream: LiveStream | null = null
|
|
1804
|
+
let executedSubmissionMethod = submissionMethod
|
|
1696
1805
|
|
|
1697
1806
|
try {
|
|
1698
1807
|
const client = getClient(props.projectId)
|
|
1699
1808
|
|
|
1700
1809
|
if (submissionMethod === 'turn/steer') {
|
|
1701
1810
|
const liveStream = await ensurePendingLiveStream()
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1811
|
+
queuePendingUserMessage(liveStream, optimisticMessageId)
|
|
1812
|
+
let uploadedAttachments: PersistedProjectAttachment[] | undefined
|
|
1813
|
+
|
|
1814
|
+
try {
|
|
1815
|
+
uploadedAttachments = await uploadAttachments(liveStream.threadId, submittedAttachments)
|
|
1816
|
+
const turnId = await waitForLiveStreamTurnId(liveStream)
|
|
1817
|
+
|
|
1818
|
+
await client.request<TurnStartResponse>('turn/steer', {
|
|
1819
|
+
threadId: liveStream.threadId,
|
|
1820
|
+
expectedTurnId: turnId,
|
|
1821
|
+
input: buildTurnStartInput(text, uploadedAttachments),
|
|
1822
|
+
...buildTurnOverrides(selectedModel.value, selectedEffort.value)
|
|
1823
|
+
})
|
|
1824
|
+
tokenUsage.value = null
|
|
1825
|
+
} catch (caughtError) {
|
|
1826
|
+
const errorToHandle = caughtError instanceof Error ? caughtError : new Error(String(caughtError))
|
|
1827
|
+
if (!shouldRetrySteerWithTurnStart(errorToHandle.message)) {
|
|
1828
|
+
throw errorToHandle
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
executedSubmissionMethod = 'turn/start'
|
|
1832
|
+
startedLiveStream = liveStream
|
|
1833
|
+
status.value = 'submitted'
|
|
1834
|
+
setLiveStreamTurnId(liveStream, null)
|
|
1835
|
+
setLiveStreamInterruptRequested(liveStream, false)
|
|
1836
|
+
|
|
1837
|
+
await submitTurnStart({
|
|
1838
|
+
client,
|
|
1839
|
+
liveStream,
|
|
1840
|
+
text,
|
|
1841
|
+
submittedAttachments,
|
|
1842
|
+
uploadedAttachments,
|
|
1843
|
+
optimisticMessageId,
|
|
1844
|
+
queueOptimisticMessage: false
|
|
1845
|
+
})
|
|
1846
|
+
}
|
|
1713
1847
|
return
|
|
1714
1848
|
}
|
|
1715
1849
|
|
|
1716
1850
|
const liveStream = await ensurePendingLiveStream()
|
|
1717
1851
|
startedLiveStream = liveStream
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
input: buildTurnStartInput(text, uploadedAttachments),
|
|
1725
|
-
cwd: selectedProject.value?.projectPath ?? null,
|
|
1726
|
-
approvalPolicy: 'never',
|
|
1727
|
-
...buildTurnOverrides(selectedModel.value, selectedEffort.value)
|
|
1852
|
+
await submitTurnStart({
|
|
1853
|
+
client,
|
|
1854
|
+
liveStream,
|
|
1855
|
+
text,
|
|
1856
|
+
submittedAttachments,
|
|
1857
|
+
optimisticMessageId
|
|
1728
1858
|
})
|
|
1729
|
-
|
|
1730
|
-
tokenUsage.value = null
|
|
1731
|
-
setLiveStreamTurnId(liveStream, turnStart.turn.id)
|
|
1732
|
-
|
|
1733
|
-
for (const notification of liveStream.bufferedNotifications.splice(0, liveStream.bufferedNotifications.length)) {
|
|
1734
|
-
const turnId = notificationTurnId(notification)
|
|
1735
|
-
if (turnId && turnId !== liveStream.turnId) {
|
|
1736
|
-
continue
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
applyNotification(notification)
|
|
1740
|
-
}
|
|
1741
1859
|
} catch (caughtError) {
|
|
1742
1860
|
const messageText = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1743
1861
|
|
|
1744
|
-
|
|
1862
|
+
markAwaitingAssistantOutput(false)
|
|
1745
1863
|
untrackPendingUserMessage(optimisticMessageId)
|
|
1746
1864
|
removeOptimisticMessage(optimisticMessageId)
|
|
1747
1865
|
|
|
1748
|
-
if (
|
|
1866
|
+
if (executedSubmissionMethod === 'turn/start') {
|
|
1749
1867
|
if (startedLiveStream && session.liveStream === startedLiveStream) {
|
|
1750
1868
|
clearPendingOptimisticMessages(clearLiveStream(new Error(messageText)))
|
|
1751
1869
|
}
|
|
@@ -1933,7 +2051,7 @@ watch([selectedModel, availableModels], () => {
|
|
|
1933
2051
|
<UChatMessages
|
|
1934
2052
|
v-else
|
|
1935
2053
|
:messages="messages"
|
|
1936
|
-
:status="
|
|
2054
|
+
:status="chatMessagesStatus"
|
|
1937
2055
|
:should-auto-scroll="false"
|
|
1938
2056
|
:should-scroll-to-bottom="false"
|
|
1939
2057
|
:auto-scroll="false"
|
|
@@ -1958,6 +2076,21 @@ watch([selectedModel, availableModels], () => {
|
|
|
1958
2076
|
:project-id="projectId"
|
|
1959
2077
|
/>
|
|
1960
2078
|
</template>
|
|
2079
|
+
<template #indicator>
|
|
2080
|
+
<div
|
|
2081
|
+
v-if="awaitingAssistantOutput"
|
|
2082
|
+
class="flex items-center gap-3 px-1 py-2 text-sm text-muted"
|
|
2083
|
+
>
|
|
2084
|
+
<UIcon
|
|
2085
|
+
name="i-lucide-loader-circle"
|
|
2086
|
+
class="size-4 animate-spin text-primary"
|
|
2087
|
+
/>
|
|
2088
|
+
<div class="flex flex-col gap-1">
|
|
2089
|
+
<UChatShimmer text="Waiting for response…" />
|
|
2090
|
+
<span class="text-xs text-toned">Real reasoning will appear separately when streaming starts.</span>
|
|
2091
|
+
</div>
|
|
2092
|
+
</div>
|
|
2093
|
+
</template>
|
|
1961
2094
|
</UChatMessages>
|
|
1962
2095
|
</div>
|
|
1963
2096
|
|
|
@@ -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,35 @@ 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.liveStreamTurnId !== null || input.status === 'submitted' || input.status === 'streaming')
|
|
37
|
+
|
|
38
|
+
export const shouldAwaitThreadHydration = (input: {
|
|
39
|
+
hasPendingThreadHydration: boolean
|
|
40
|
+
routeThreadId: string | null
|
|
41
|
+
}) =>
|
|
42
|
+
input.hasPendingThreadHydration
|
|
43
|
+
&& input.routeThreadId !== null
|
|
44
|
+
|
|
45
|
+
export const shouldRetrySteerWithTurnStart = (message: string) =>
|
|
46
|
+
/no active turn to steer/i.test(message)
|
|
47
|
+
|
|
19
48
|
export const resolvePromptSubmitStatus = (input: {
|
|
20
49
|
status: PromptSubmitStatus
|
|
21
50
|
hasDraftContent: boolean
|
|
@@ -30,12 +59,50 @@ export const removeChatMessage = (messages: ChatMessage[], messageId: string) =>
|
|
|
30
59
|
export const removePendingUserMessageId = (messageIds: string[], messageId: string) =>
|
|
31
60
|
messageIds.filter(candidateId => candidateId !== messageId)
|
|
32
61
|
|
|
62
|
+
const isEquivalentUserMessagePart = (
|
|
63
|
+
currentPart: ChatMessage['parts'][number],
|
|
64
|
+
confirmedPart: ChatMessage['parts'][number]
|
|
65
|
+
) => {
|
|
66
|
+
if (currentPart.type === 'text' && confirmedPart.type === 'text') {
|
|
67
|
+
return currentPart.text === confirmedPart.text
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (currentPart.type === 'attachment' && confirmedPart.type === 'attachment') {
|
|
71
|
+
return currentPart.attachment.kind === confirmedPart.attachment.kind
|
|
72
|
+
&& currentPart.attachment.name === confirmedPart.attachment.name
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const findOptimisticUserMessageIndex = (
|
|
79
|
+
messages: ChatMessage[],
|
|
80
|
+
optimisticMessageId: string,
|
|
81
|
+
confirmedMessage: ChatMessage
|
|
82
|
+
) => {
|
|
83
|
+
const directIndex = messages.findIndex(message => message.id === optimisticMessageId)
|
|
84
|
+
if (directIndex !== -1) {
|
|
85
|
+
return directIndex
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (confirmedMessage.role !== 'user') {
|
|
89
|
+
return -1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return messages.findIndex(message =>
|
|
93
|
+
message.role === 'user'
|
|
94
|
+
&& message.pending === true
|
|
95
|
+
&& message.parts.length === confirmedMessage.parts.length
|
|
96
|
+
&& message.parts.every((part, index) => isEquivalentUserMessagePart(part, confirmedMessage.parts[index]!))
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
33
100
|
export const reconcileOptimisticUserMessage = (
|
|
34
101
|
messages: ChatMessage[],
|
|
35
102
|
optimisticMessageId: string,
|
|
36
103
|
confirmedMessage: ChatMessage
|
|
37
104
|
) => {
|
|
38
|
-
const index = messages
|
|
105
|
+
const index = findOptimisticUserMessageIndex(messages, optimisticMessageId, confirmedMessage)
|
|
39
106
|
if (index === -1) {
|
|
40
107
|
return upsertStreamingMessage(messages, confirmedMessage)
|
|
41
108
|
}
|
|
@@ -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
|
|