@codori/client 0.0.5 → 0.0.7

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