@codori/client 0.0.4 → 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 +279 -159
- 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/chat-prompt-controls.ts +29 -11
- package/shared/codex-chat.ts +0 -27
- package/shared/codex-rpc.ts +16 -0
- package/shared/codori.ts +4 -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,
|
|
@@ -57,6 +62,7 @@ import {
|
|
|
57
62
|
normalizeThreadTokenUsage,
|
|
58
63
|
resolveContextWindowState,
|
|
59
64
|
resolveEffortOptions,
|
|
65
|
+
shouldShowContextWindowIndicator,
|
|
60
66
|
visibleModelOptions,
|
|
61
67
|
type ReasoningEffort
|
|
62
68
|
} from '~~/shared/chat-prompt-controls'
|
|
@@ -104,6 +110,7 @@ const input = ref('')
|
|
|
104
110
|
const scrollViewport = ref<HTMLElement | null>(null)
|
|
105
111
|
const pinnedToBottom = ref(true)
|
|
106
112
|
const session = useChatSession(props.projectId)
|
|
113
|
+
const { syncThreadSummary, updateThreadSummaryTitle } = useThreadSummaries(props.projectId)
|
|
107
114
|
const {
|
|
108
115
|
messages,
|
|
109
116
|
subagentPanels,
|
|
@@ -127,6 +134,7 @@ const selectedProject = computed(() => getProject(props.projectId))
|
|
|
127
134
|
const composerError = computed(() => attachmentError.value ?? error.value)
|
|
128
135
|
const submitError = computed(() => composerError.value ? new Error(composerError.value) : undefined)
|
|
129
136
|
const interruptRequested = ref(false)
|
|
137
|
+
const awaitingAssistantOutput = ref(false)
|
|
130
138
|
const isBusy = computed(() =>
|
|
131
139
|
status.value === 'submitted'
|
|
132
140
|
|| status.value === 'streaming'
|
|
@@ -148,6 +156,9 @@ const promptSubmitStatus = computed(() =>
|
|
|
148
156
|
)
|
|
149
157
|
const routeThreadId = computed(() => props.threadId ?? null)
|
|
150
158
|
const projectTitle = computed(() => selectedProject.value?.projectId ?? props.projectId)
|
|
159
|
+
const chatMessagesStatus = computed(() =>
|
|
160
|
+
resolveChatMessagesStatus(status.value, awaitingAssistantOutput.value)
|
|
161
|
+
)
|
|
151
162
|
const showWelcomeState = computed(() =>
|
|
152
163
|
!routeThreadId.value
|
|
153
164
|
&& !activeThreadId.value
|
|
@@ -174,7 +185,6 @@ const starterPrompts = computed(() => {
|
|
|
174
185
|
]
|
|
175
186
|
})
|
|
176
187
|
|
|
177
|
-
const hasKnownThreadUsage = computed(() => !activeThreadId.value || tokenUsage.value !== null)
|
|
178
188
|
const effectiveModelList = computed(() => {
|
|
179
189
|
const withSelected = ensureModelOption(
|
|
180
190
|
availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS,
|
|
@@ -202,8 +212,9 @@ const effortSelectItems = computed(() =>
|
|
|
202
212
|
}))
|
|
203
213
|
)
|
|
204
214
|
const contextWindowState = computed(() =>
|
|
205
|
-
resolveContextWindowState(tokenUsage.value, modelContextWindow.value
|
|
215
|
+
resolveContextWindowState(tokenUsage.value, modelContextWindow.value)
|
|
206
216
|
)
|
|
217
|
+
const showContextIndicator = computed(() => shouldShowContextWindowIndicator(contextWindowState.value))
|
|
207
218
|
const contextUsedPercent = computed(() => contextWindowState.value.usedPercent ?? 0)
|
|
208
219
|
const contextIndicatorLabel = computed(() => {
|
|
209
220
|
const remainingPercent = contextWindowState.value.remainingPercent
|
|
@@ -302,6 +313,7 @@ const loadPromptControls = async () => {
|
|
|
302
313
|
const subagentBootstrapPromises = new Map<string, Promise<void>>()
|
|
303
314
|
const optimisticAttachmentSnapshots = new Map<string, DraftAttachment[]>()
|
|
304
315
|
let promptControlsPromise: Promise<void> | null = null
|
|
316
|
+
let pendingThreadHydration: Promise<void> | null = null
|
|
305
317
|
|
|
306
318
|
const isActiveTurnStatus = (value: string | null | undefined) => {
|
|
307
319
|
if (!value) {
|
|
@@ -319,6 +331,14 @@ const currentLiveStream = () =>
|
|
|
319
331
|
const hasActiveTurnEngagement = () =>
|
|
320
332
|
Boolean(currentLiveStream() || session.pendingLiveStream)
|
|
321
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
|
+
|
|
322
342
|
const rejectLiveStreamTurnWaiters = (liveStream: LiveStream, error: Error) => {
|
|
323
343
|
const waiters = liveStream.turnIdWaiters.splice(0, liveStream.turnIdWaiters.length)
|
|
324
344
|
for (const waiter of waiters) {
|
|
@@ -378,6 +398,25 @@ const waitForLiveStreamTurnId = async (liveStream: LiveStream) => {
|
|
|
378
398
|
})
|
|
379
399
|
}
|
|
380
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
|
+
|
|
381
420
|
const clearLiveStream = (reason?: Error) => {
|
|
382
421
|
const liveStream = session.liveStream
|
|
383
422
|
if (!liveStream) {
|
|
@@ -508,17 +547,13 @@ const removeOptimisticMessage = (messageId: string) => {
|
|
|
508
547
|
optimisticAttachmentSnapshots.delete(messageId)
|
|
509
548
|
}
|
|
510
549
|
|
|
511
|
-
const
|
|
512
|
-
|
|
550
|
+
const markAwaitingAssistantOutput = (nextValue: boolean) => {
|
|
551
|
+
awaitingAssistantOutput.value = nextValue
|
|
513
552
|
}
|
|
514
553
|
|
|
515
|
-
const
|
|
516
|
-
messages.value = showThinkingPlaceholder(messages.value)
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const clearThinkingPlaceholderForVisibleItem = (item: CodexThreadItem) => {
|
|
554
|
+
const markAssistantOutputStartedForItem = (item: CodexThreadItem) => {
|
|
520
555
|
if (item.type !== 'userMessage') {
|
|
521
|
-
|
|
556
|
+
markAwaitingAssistantOutput(false)
|
|
522
557
|
}
|
|
523
558
|
}
|
|
524
559
|
|
|
@@ -575,9 +610,42 @@ const clearPendingOptimisticMessages = (liveStream: LiveStream | null, options?:
|
|
|
575
610
|
liveStream.pendingUserMessageIds = []
|
|
576
611
|
}
|
|
577
612
|
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
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)
|
|
581
649
|
}
|
|
582
650
|
|
|
583
651
|
const shortThreadId = (value: string) => value.slice(0, 8)
|
|
@@ -699,106 +767,120 @@ const ensureProjectRuntime = async () => {
|
|
|
699
767
|
}
|
|
700
768
|
|
|
701
769
|
const hydrateThread = async (threadId: string) => {
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
770
|
+
const hydratePromise = (async () => {
|
|
771
|
+
const requestVersion = loadVersion.value + 1
|
|
772
|
+
loadVersion.value = requestVersion
|
|
773
|
+
error.value = null
|
|
774
|
+
tokenUsage.value = null
|
|
706
775
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
776
|
+
try {
|
|
777
|
+
await ensureProjectRuntime()
|
|
778
|
+
const client = getClient(props.projectId)
|
|
779
|
+
activeThreadId.value = threadId
|
|
711
780
|
|
|
712
|
-
|
|
781
|
+
const existingLiveStream = session.liveStream
|
|
713
782
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
783
|
+
if (existingLiveStream && existingLiveStream.threadId !== threadId) {
|
|
784
|
+
clearLiveStream()
|
|
785
|
+
}
|
|
717
786
|
|
|
718
|
-
|
|
719
|
-
|
|
787
|
+
if (!session.liveStream) {
|
|
788
|
+
const nextLiveStream = createLiveStreamState(threadId)
|
|
720
789
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
790
|
+
nextLiveStream.unsubscribe = client.subscribe((notification) => {
|
|
791
|
+
const targetThreadId = notificationThreadId(notification)
|
|
792
|
+
if (!targetThreadId) {
|
|
793
|
+
return
|
|
794
|
+
}
|
|
726
795
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
796
|
+
if (targetThreadId !== threadId) {
|
|
797
|
+
if (nextLiveStream.observedSubagentThreadIds.has(targetThreadId)) {
|
|
798
|
+
applySubagentNotification(targetThreadId, notification)
|
|
799
|
+
}
|
|
800
|
+
return
|
|
730
801
|
}
|
|
731
|
-
return
|
|
732
|
-
}
|
|
733
802
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
803
|
+
if (!nextLiveStream.turnId) {
|
|
804
|
+
nextLiveStream.bufferedNotifications.push(notification)
|
|
805
|
+
return
|
|
806
|
+
}
|
|
738
807
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
808
|
+
const turnId = notificationTurnId(notification)
|
|
809
|
+
if (turnId && turnId !== nextLiveStream.turnId) {
|
|
810
|
+
return
|
|
811
|
+
}
|
|
743
812
|
|
|
744
|
-
|
|
745
|
-
|
|
813
|
+
applyNotification(notification)
|
|
814
|
+
})
|
|
746
815
|
|
|
747
|
-
|
|
748
|
-
|
|
816
|
+
setSessionLiveStream(nextLiveStream)
|
|
817
|
+
}
|
|
749
818
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
+
})
|
|
760
829
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
830
|
+
if (loadVersion.value !== requestVersion) {
|
|
831
|
+
return
|
|
832
|
+
}
|
|
764
833
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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))
|
|
774
845
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
846
|
+
if (!activeTurn) {
|
|
847
|
+
clearLiveStream()
|
|
848
|
+
status.value = 'ready'
|
|
849
|
+
return
|
|
850
|
+
}
|
|
780
851
|
|
|
781
|
-
|
|
852
|
+
if (!session.liveStream) {
|
|
853
|
+
status.value = 'streaming'
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
setLiveStreamTurnId(session.liveStream, activeTurn.id)
|
|
782
858
|
status.value = 'streaming'
|
|
783
|
-
return
|
|
784
|
-
}
|
|
785
859
|
|
|
786
|
-
|
|
787
|
-
|
|
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
|
+
}
|
|
788
866
|
|
|
789
|
-
|
|
790
|
-
for (const notification of pendingNotifications) {
|
|
791
|
-
const turnId = notificationTurnId(notification)
|
|
792
|
-
if (turnId && turnId !== activeTurn.id) {
|
|
793
|
-
continue
|
|
867
|
+
applyNotification(notification)
|
|
794
868
|
}
|
|
869
|
+
} catch (caughtError) {
|
|
870
|
+
clearLiveStream()
|
|
871
|
+
error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
872
|
+
status.value = 'error'
|
|
873
|
+
}
|
|
874
|
+
})()
|
|
795
875
|
|
|
796
|
-
|
|
876
|
+
pendingThreadHydration = hydratePromise
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
await hydratePromise
|
|
880
|
+
} finally {
|
|
881
|
+
if (pendingThreadHydration === hydratePromise) {
|
|
882
|
+
pendingThreadHydration = null
|
|
797
883
|
}
|
|
798
|
-
} catch (caughtError) {
|
|
799
|
-
clearLiveStream()
|
|
800
|
-
error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
801
|
-
status.value = 'error'
|
|
802
884
|
}
|
|
803
885
|
}
|
|
804
886
|
|
|
@@ -813,6 +895,7 @@ const resetDraftThread = () => {
|
|
|
813
895
|
subagentPanels.value = []
|
|
814
896
|
error.value = null
|
|
815
897
|
tokenUsage.value = null
|
|
898
|
+
markAwaitingAssistantOutput(false)
|
|
816
899
|
status.value = 'ready'
|
|
817
900
|
}
|
|
818
901
|
|
|
@@ -834,7 +917,8 @@ const ensureThread = async () => {
|
|
|
834
917
|
})
|
|
835
918
|
|
|
836
919
|
activeThreadId.value = response.thread.id
|
|
837
|
-
threadTitle.value =
|
|
920
|
+
threadTitle.value = resolveThreadSummaryTitle(response.thread)
|
|
921
|
+
syncThreadSummary(response.thread)
|
|
838
922
|
return {
|
|
839
923
|
threadId: response.thread.id,
|
|
840
924
|
created: true
|
|
@@ -1421,6 +1505,24 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1421
1505
|
pendingThreadId.value = pendingThreadId.value ?? nextThreadId
|
|
1422
1506
|
return
|
|
1423
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
|
+
}
|
|
1424
1526
|
case 'turn/started': {
|
|
1425
1527
|
if (liveStream) {
|
|
1426
1528
|
setLiveStreamTurnId(liveStream, notificationTurnId(notification))
|
|
@@ -1450,7 +1552,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1450
1552
|
status.value = 'streaming'
|
|
1451
1553
|
return
|
|
1452
1554
|
}
|
|
1453
|
-
|
|
1555
|
+
markAssistantOutputStartedForItem(params.item)
|
|
1454
1556
|
seedStreamingMessage(params.item)
|
|
1455
1557
|
status.value = 'streaming'
|
|
1456
1558
|
return
|
|
@@ -1460,7 +1562,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1460
1562
|
if (params.item.type === 'collabAgentToolCall') {
|
|
1461
1563
|
applySubagentActivityItem(params.item)
|
|
1462
1564
|
}
|
|
1463
|
-
|
|
1565
|
+
markAssistantOutputStartedForItem(params.item)
|
|
1464
1566
|
for (const nextMessage of itemToMessages(params.item)) {
|
|
1465
1567
|
const confirmedMessage = {
|
|
1466
1568
|
...nextMessage,
|
|
@@ -1477,7 +1579,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1477
1579
|
}
|
|
1478
1580
|
case 'item/agentMessage/delta': {
|
|
1479
1581
|
const params = notification.params as { itemId: string, delta: string }
|
|
1480
|
-
|
|
1582
|
+
markAwaitingAssistantOutput(false)
|
|
1481
1583
|
appendTextPartDelta(params.itemId, params.delta, {
|
|
1482
1584
|
id: params.itemId,
|
|
1483
1585
|
role: 'assistant',
|
|
@@ -1493,7 +1595,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1493
1595
|
}
|
|
1494
1596
|
case 'item/plan/delta': {
|
|
1495
1597
|
const params = notification.params as { itemId: string, delta: string }
|
|
1496
|
-
|
|
1598
|
+
markAwaitingAssistantOutput(false)
|
|
1497
1599
|
appendTextPartDelta(params.itemId, params.delta, {
|
|
1498
1600
|
id: params.itemId,
|
|
1499
1601
|
role: 'assistant',
|
|
@@ -1510,7 +1612,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1510
1612
|
case 'item/reasoning/textDelta':
|
|
1511
1613
|
case 'item/reasoning/summaryTextDelta': {
|
|
1512
1614
|
const params = notification.params as { itemId: string, delta: string }
|
|
1513
|
-
|
|
1615
|
+
markAwaitingAssistantOutput(false)
|
|
1514
1616
|
updateMessage(params.itemId, {
|
|
1515
1617
|
id: params.itemId,
|
|
1516
1618
|
role: 'assistant',
|
|
@@ -1618,7 +1720,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1618
1720
|
case 'error': {
|
|
1619
1721
|
const params = notification.params as { error?: { message?: string } }
|
|
1620
1722
|
const messageText = params.error?.message ?? 'The stream failed.'
|
|
1621
|
-
|
|
1723
|
+
markAwaitingAssistantOutput(false)
|
|
1622
1724
|
pushEventMessage('stream.error', messageText)
|
|
1623
1725
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1624
1726
|
clearLiveStream(new Error(messageText))
|
|
@@ -1629,7 +1731,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1629
1731
|
case 'turn/failed': {
|
|
1630
1732
|
const params = notification.params as { error?: { message?: string } }
|
|
1631
1733
|
const messageText = params.error?.message ?? 'The turn failed.'
|
|
1632
|
-
|
|
1734
|
+
markAwaitingAssistantOutput(false)
|
|
1633
1735
|
pushEventMessage('turn.failed', messageText)
|
|
1634
1736
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1635
1737
|
clearLiveStream(new Error(messageText))
|
|
@@ -1640,7 +1742,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1640
1742
|
case 'stream/error': {
|
|
1641
1743
|
const params = notification.params as { message?: string }
|
|
1642
1744
|
const messageText = params.message ?? 'The stream failed.'
|
|
1643
|
-
|
|
1745
|
+
markAwaitingAssistantOutput(false)
|
|
1644
1746
|
pushEventMessage('stream.error', messageText)
|
|
1645
1747
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1646
1748
|
clearLiveStream(new Error(messageText))
|
|
@@ -1649,7 +1751,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1649
1751
|
return
|
|
1650
1752
|
}
|
|
1651
1753
|
case 'turn/completed': {
|
|
1652
|
-
|
|
1754
|
+
markAwaitingAssistantOutput(false)
|
|
1653
1755
|
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1654
1756
|
error.value = null
|
|
1655
1757
|
status.value = 'ready'
|
|
@@ -1677,10 +1779,17 @@ const sendMessage = async () => {
|
|
|
1677
1779
|
return
|
|
1678
1780
|
}
|
|
1679
1781
|
|
|
1782
|
+
if (pendingThreadHydration && shouldAwaitThreadHydration({
|
|
1783
|
+
hasPendingThreadHydration: true,
|
|
1784
|
+
routeThreadId: routeThreadId.value
|
|
1785
|
+
})) {
|
|
1786
|
+
await pendingThreadHydration
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1680
1789
|
pinnedToBottom.value = true
|
|
1681
1790
|
error.value = null
|
|
1682
1791
|
attachmentError.value = null
|
|
1683
|
-
const submissionMethod = resolveTurnSubmissionMethod(
|
|
1792
|
+
const submissionMethod = resolveTurnSubmissionMethod(shouldSubmitWithTurnSteer())
|
|
1684
1793
|
if (submissionMethod === 'turn/start') {
|
|
1685
1794
|
status.value = 'submitted'
|
|
1686
1795
|
}
|
|
@@ -1690,59 +1799,71 @@ const sendMessage = async () => {
|
|
|
1690
1799
|
const optimisticMessageId = optimisticMessage.id
|
|
1691
1800
|
rememberOptimisticAttachments(optimisticMessageId, submittedAttachments)
|
|
1692
1801
|
messages.value = [...messages.value, optimisticMessage]
|
|
1693
|
-
|
|
1802
|
+
markAwaitingAssistantOutput(shouldAwaitAssistantOutput(submissionMethod))
|
|
1694
1803
|
let startedLiveStream: LiveStream | null = null
|
|
1804
|
+
let executedSubmissionMethod = submissionMethod
|
|
1695
1805
|
|
|
1696
1806
|
try {
|
|
1697
1807
|
const client = getClient(props.projectId)
|
|
1698
1808
|
|
|
1699
1809
|
if (submissionMethod === 'turn/steer') {
|
|
1700
1810
|
const liveStream = await ensurePendingLiveStream()
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
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
|
+
}
|
|
1711
1847
|
return
|
|
1712
1848
|
}
|
|
1713
1849
|
|
|
1714
1850
|
const liveStream = await ensurePendingLiveStream()
|
|
1715
1851
|
startedLiveStream = liveStream
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
input: buildTurnStartInput(text, uploadedAttachments),
|
|
1723
|
-
cwd: selectedProject.value?.projectPath ?? null,
|
|
1724
|
-
approvalPolicy: 'never',
|
|
1725
|
-
...buildTurnOverrides(selectedModel.value, selectedEffort.value)
|
|
1852
|
+
await submitTurnStart({
|
|
1853
|
+
client,
|
|
1854
|
+
liveStream,
|
|
1855
|
+
text,
|
|
1856
|
+
submittedAttachments,
|
|
1857
|
+
optimisticMessageId
|
|
1726
1858
|
})
|
|
1727
|
-
|
|
1728
|
-
setLiveStreamTurnId(liveStream, turnStart.turn.id)
|
|
1729
|
-
|
|
1730
|
-
for (const notification of liveStream.bufferedNotifications.splice(0, liveStream.bufferedNotifications.length)) {
|
|
1731
|
-
const turnId = notificationTurnId(notification)
|
|
1732
|
-
if (turnId && turnId !== liveStream.turnId) {
|
|
1733
|
-
continue
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
applyNotification(notification)
|
|
1737
|
-
}
|
|
1738
1859
|
} catch (caughtError) {
|
|
1739
1860
|
const messageText = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1740
1861
|
|
|
1741
|
-
|
|
1862
|
+
markAwaitingAssistantOutput(false)
|
|
1742
1863
|
untrackPendingUserMessage(optimisticMessageId)
|
|
1743
1864
|
removeOptimisticMessage(optimisticMessageId)
|
|
1744
1865
|
|
|
1745
|
-
if (
|
|
1866
|
+
if (executedSubmissionMethod === 'turn/start') {
|
|
1746
1867
|
if (startedLiveStream && session.liveStream === startedLiveStream) {
|
|
1747
1868
|
clearPendingOptimisticMessages(clearLiveStream(new Error(messageText)))
|
|
1748
1869
|
}
|
|
@@ -1930,7 +2051,7 @@ watch([selectedModel, availableModels], () => {
|
|
|
1930
2051
|
<UChatMessages
|
|
1931
2052
|
v-else
|
|
1932
2053
|
:messages="messages"
|
|
1933
|
-
:status="
|
|
2054
|
+
:status="chatMessagesStatus"
|
|
1934
2055
|
:should-auto-scroll="false"
|
|
1935
2056
|
:should-scroll-to-bottom="false"
|
|
1936
2057
|
:auto-scroll="false"
|
|
@@ -1955,6 +2076,21 @@ watch([selectedModel, availableModels], () => {
|
|
|
1955
2076
|
:project-id="projectId"
|
|
1956
2077
|
/>
|
|
1957
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>
|
|
1958
2094
|
</UChatMessages>
|
|
1959
2095
|
</div>
|
|
1960
2096
|
|
|
@@ -2089,6 +2225,7 @@ watch([selectedModel, availableModels], () => {
|
|
|
2089
2225
|
|
|
2090
2226
|
<div class="ml-auto flex shrink-0 items-center">
|
|
2091
2227
|
<UPopover
|
|
2228
|
+
v-if="showContextIndicator"
|
|
2092
2229
|
:content="{ side: 'top', align: 'end' }"
|
|
2093
2230
|
arrow
|
|
2094
2231
|
>
|
|
@@ -2128,8 +2265,6 @@ watch([selectedModel, availableModels], () => {
|
|
|
2128
2265
|
{{ contextIndicatorLabel }}
|
|
2129
2266
|
</span>
|
|
2130
2267
|
</span>
|
|
2131
|
-
|
|
2132
|
-
<span class="text-[11px] leading-none text-muted">context window</span>
|
|
2133
2268
|
</button>
|
|
2134
2269
|
|
|
2135
2270
|
<template #content>
|
|
@@ -2143,10 +2278,7 @@ watch([selectedModel, availableModels], () => {
|
|
|
2143
2278
|
</div>
|
|
2144
2279
|
</div>
|
|
2145
2280
|
|
|
2146
|
-
<div
|
|
2147
|
-
v-if="contextWindowState.contextWindow && contextWindowState.usedTokens !== null"
|
|
2148
|
-
class="grid grid-cols-2 gap-3 text-sm"
|
|
2149
|
-
>
|
|
2281
|
+
<div class="grid grid-cols-2 gap-3 text-sm">
|
|
2150
2282
|
<div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
|
|
2151
2283
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted">
|
|
2152
2284
|
Remaining
|
|
@@ -2166,23 +2298,11 @@ watch([selectedModel, availableModels], () => {
|
|
|
2166
2298
|
{{ formatCompactTokenCount(contextWindowState.usedTokens ?? 0) }}
|
|
2167
2299
|
</div>
|
|
2168
2300
|
<div class="text-xs text-muted">
|
|
2169
|
-
of {{ formatCompactTokenCount(contextWindowState.contextWindow) }}
|
|
2301
|
+
of {{ formatCompactTokenCount(contextWindowState.contextWindow ?? 0) }}
|
|
2170
2302
|
</div>
|
|
2171
2303
|
</div>
|
|
2172
2304
|
</div>
|
|
2173
2305
|
|
|
2174
|
-
<div
|
|
2175
|
-
v-else
|
|
2176
|
-
class="rounded-2xl border border-default bg-elevated/35 px-3 py-2 text-sm text-muted"
|
|
2177
|
-
>
|
|
2178
|
-
<div v-if="contextWindowState.contextWindow">
|
|
2179
|
-
Live token usage will appear after the next turn completes.
|
|
2180
|
-
</div>
|
|
2181
|
-
<div v-else>
|
|
2182
|
-
Context window details are not available from the runtime yet.
|
|
2183
|
-
</div>
|
|
2184
|
-
</div>
|
|
2185
|
-
|
|
2186
2306
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
|
2187
2307
|
<div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
|
|
2188
2308
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted">
|
|
@@ -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
|
@@ -20,15 +20,26 @@ export type ModelOption = {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export type TokenUsageSnapshot = {
|
|
23
|
+
totalTokens: number | null
|
|
23
24
|
totalInputTokens: number
|
|
24
25
|
totalCachedInputTokens: number
|
|
25
26
|
totalOutputTokens: number
|
|
27
|
+
lastUsageKnown: boolean
|
|
28
|
+
lastTotalTokens: number | null
|
|
26
29
|
lastInputTokens: number
|
|
27
30
|
lastCachedInputTokens: number
|
|
28
31
|
lastOutputTokens: number
|
|
29
32
|
modelContextWindow: number | null
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
export type ContextWindowState = {
|
|
36
|
+
contextWindow: number | null
|
|
37
|
+
usedTokens: number | null
|
|
38
|
+
remainingTokens: number | null
|
|
39
|
+
usedPercent: number | null
|
|
40
|
+
remainingPercent: number | null
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
type ReasoningEffortOptionRecord = {
|
|
33
44
|
reasoningEffort?: unknown
|
|
34
45
|
}
|
|
@@ -250,15 +261,18 @@ export const normalizeThreadTokenUsage = (value: unknown): TokenUsageSnapshot |
|
|
|
250
261
|
}
|
|
251
262
|
|
|
252
263
|
const total = isObjectRecord(tokenUsage.total) ? tokenUsage.total : {}
|
|
253
|
-
const last = isObjectRecord(tokenUsage.last) ? tokenUsage.last :
|
|
264
|
+
const last = isObjectRecord(tokenUsage.last) ? tokenUsage.last : null
|
|
254
265
|
|
|
255
266
|
return {
|
|
267
|
+
totalTokens: toFiniteNumber(total.totalTokens),
|
|
256
268
|
totalInputTokens: toFiniteNumber(total.inputTokens) ?? 0,
|
|
257
269
|
totalCachedInputTokens: toFiniteNumber(total.cachedInputTokens) ?? 0,
|
|
258
270
|
totalOutputTokens: toFiniteNumber(total.outputTokens) ?? 0,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
271
|
+
lastUsageKnown: last !== null,
|
|
272
|
+
lastTotalTokens: toFiniteNumber(last?.totalTokens),
|
|
273
|
+
lastInputTokens: toFiniteNumber(last?.inputTokens) ?? 0,
|
|
274
|
+
lastCachedInputTokens: toFiniteNumber(last?.cachedInputTokens) ?? 0,
|
|
275
|
+
lastOutputTokens: toFiniteNumber(last?.outputTokens) ?? 0,
|
|
262
276
|
modelContextWindow: toFiniteNumber(tokenUsage.modelContextWindow)
|
|
263
277
|
}
|
|
264
278
|
}
|
|
@@ -306,15 +320,16 @@ export const formatCompactTokenCount = (value: number) => {
|
|
|
306
320
|
|
|
307
321
|
export const resolveContextWindowState = (
|
|
308
322
|
tokenUsage: TokenUsageSnapshot | null,
|
|
309
|
-
fallbackContextWindow: number | null
|
|
310
|
-
|
|
311
|
-
) => {
|
|
323
|
+
fallbackContextWindow: number | null
|
|
324
|
+
): ContextWindowState => {
|
|
312
325
|
const contextWindow = tokenUsage?.modelContextWindow ?? fallbackContextWindow
|
|
326
|
+
// App-server exposes cumulative thread totals separately; the latest turn total
|
|
327
|
+
// is the closest match to current context occupancy.
|
|
313
328
|
const usedTokens = tokenUsage
|
|
314
|
-
? tokenUsage.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
329
|
+
? tokenUsage.lastTotalTokens ?? (tokenUsage.lastUsageKnown
|
|
330
|
+
? tokenUsage.lastInputTokens + tokenUsage.lastOutputTokens
|
|
331
|
+
: null)
|
|
332
|
+
: null
|
|
318
333
|
|
|
319
334
|
if (!contextWindow || usedTokens == null) {
|
|
320
335
|
return {
|
|
@@ -337,3 +352,6 @@ export const resolveContextWindowState = (
|
|
|
337
352
|
remainingPercent: Math.max(0, 100 - usedPercent)
|
|
338
353
|
}
|
|
339
354
|
}
|
|
355
|
+
|
|
356
|
+
export const shouldShowContextWindowIndicator = (state: ContextWindowState) =>
|
|
357
|
+
state.contextWindow !== null && state.usedTokens !== null
|
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
|
|
package/shared/codori.ts
CHANGED