@codori/client 0.0.5 → 0.0.6

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