@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.
@@ -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, hasKnownThreadUsage.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 clearThinkingPlaceholder = () => {
512
- messages.value = hideThinkingPlaceholder(messages.value)
550
+ const markAwaitingAssistantOutput = (nextValue: boolean) => {
551
+ awaitingAssistantOutput.value = nextValue
513
552
  }
514
553
 
515
- const ensureThinkingPlaceholder = () => {
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
- clearThinkingPlaceholder()
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 resolveThreadTitle = (thread: { name: string | null, preview: string, id: string }) => {
579
- const nextTitle = thread.name?.trim() || thread.preview.trim()
580
- 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)
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 requestVersion = loadVersion.value + 1
703
- loadVersion.value = requestVersion
704
- error.value = null
705
- 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
706
775
 
707
- try {
708
- await ensureProjectRuntime()
709
- const client = getClient(props.projectId)
710
- activeThreadId.value = threadId
776
+ try {
777
+ await ensureProjectRuntime()
778
+ const client = getClient(props.projectId)
779
+ activeThreadId.value = threadId
711
780
 
712
- const existingLiveStream = session.liveStream
781
+ const existingLiveStream = session.liveStream
713
782
 
714
- if (existingLiveStream && existingLiveStream.threadId !== threadId) {
715
- clearLiveStream()
716
- }
783
+ if (existingLiveStream && existingLiveStream.threadId !== threadId) {
784
+ clearLiveStream()
785
+ }
717
786
 
718
- if (!session.liveStream) {
719
- const nextLiveStream = createLiveStreamState(threadId)
787
+ if (!session.liveStream) {
788
+ const nextLiveStream = createLiveStreamState(threadId)
720
789
 
721
- nextLiveStream.unsubscribe = client.subscribe((notification) => {
722
- const targetThreadId = notificationThreadId(notification)
723
- if (!targetThreadId) {
724
- return
725
- }
790
+ nextLiveStream.unsubscribe = client.subscribe((notification) => {
791
+ const targetThreadId = notificationThreadId(notification)
792
+ if (!targetThreadId) {
793
+ return
794
+ }
726
795
 
727
- if (targetThreadId !== threadId) {
728
- if (nextLiveStream.observedSubagentThreadIds.has(targetThreadId)) {
729
- applySubagentNotification(targetThreadId, notification)
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
- if (!nextLiveStream.turnId) {
735
- nextLiveStream.bufferedNotifications.push(notification)
736
- return
737
- }
803
+ if (!nextLiveStream.turnId) {
804
+ nextLiveStream.bufferedNotifications.push(notification)
805
+ return
806
+ }
738
807
 
739
- const turnId = notificationTurnId(notification)
740
- if (turnId && turnId !== nextLiveStream.turnId) {
741
- return
742
- }
808
+ const turnId = notificationTurnId(notification)
809
+ if (turnId && turnId !== nextLiveStream.turnId) {
810
+ return
811
+ }
743
812
 
744
- applyNotification(notification)
745
- })
813
+ applyNotification(notification)
814
+ })
746
815
 
747
- setSessionLiveStream(nextLiveStream)
748
- }
816
+ setSessionLiveStream(nextLiveStream)
817
+ }
749
818
 
750
- const resumeResponse = await client.request<ThreadResumeResponse>('thread/resume', {
751
- threadId,
752
- cwd: selectedProject.value?.projectPath ?? null,
753
- approvalPolicy: 'never',
754
- persistExtendedHistory: true
755
- })
756
- const response = await client.request<ThreadReadResponse>('thread/read', {
757
- threadId,
758
- includeTurns: true
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
- if (loadVersion.value !== requestVersion) {
762
- return
763
- }
830
+ if (loadVersion.value !== requestVersion) {
831
+ return
832
+ }
764
833
 
765
- syncPromptSelectionFromThread(
766
- resumeResponse.model ?? null,
767
- (resumeResponse.reasoningEffort as ReasoningEffort | null | undefined) ?? null
768
- )
769
- activeThreadId.value = response.thread.id
770
- threadTitle.value = resolveThreadTitle(response.thread)
771
- messages.value = threadToMessages(response.thread)
772
- rebuildSubagentPanelsFromThread(response.thread)
773
- 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))
774
845
 
775
- if (!activeTurn) {
776
- clearLiveStream()
777
- status.value = 'ready'
778
- return
779
- }
846
+ if (!activeTurn) {
847
+ clearLiveStream()
848
+ status.value = 'ready'
849
+ return
850
+ }
780
851
 
781
- if (!session.liveStream) {
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
- setLiveStreamTurnId(session.liveStream, activeTurn.id)
787
- 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
+ }
788
866
 
789
- const pendingNotifications = session.liveStream.bufferedNotifications.splice(0, session.liveStream.bufferedNotifications.length)
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
- applyNotification(notification)
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 = resolveThreadTitle(response.thread)
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
- clearThinkingPlaceholderForVisibleItem(params.item)
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
- clearThinkingPlaceholderForVisibleItem(params.item)
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
- clearThinkingPlaceholder()
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
- clearThinkingPlaceholder()
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
- clearThinkingPlaceholder()
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
- clearThinkingPlaceholder()
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
- clearThinkingPlaceholder()
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
- clearThinkingPlaceholder()
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
- clearThinkingPlaceholder()
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(hasActiveTurnEngagement())
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
- ensureThinkingPlaceholder()
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
- const uploadedAttachments = await uploadAttachments(liveStream.threadId, submittedAttachments)
1702
- liveStream.pendingUserMessageIds.push(optimisticMessageId)
1703
- const turnId = await waitForLiveStreamTurnId(liveStream)
1704
-
1705
- await client.request<TurnStartResponse>('turn/steer', {
1706
- threadId: liveStream.threadId,
1707
- expectedTurnId: turnId,
1708
- input: buildTurnStartInput(text, uploadedAttachments),
1709
- ...buildTurnOverrides(selectedModel.value, selectedEffort.value)
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
- const threadId = liveStream.threadId
1717
- const uploadedAttachments = await uploadAttachments(threadId, submittedAttachments)
1718
- liveStream.pendingUserMessageIds.push(optimisticMessageId)
1719
-
1720
- const turnStart = await client.request<TurnStartResponse>('turn/start', {
1721
- threadId,
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
- clearThinkingPlaceholder()
1862
+ markAwaitingAssistantOutput(false)
1742
1863
  untrackPendingUserMessage(optimisticMessageId)
1743
1864
  removeOptimisticMessage(optimisticMessageId)
1744
1865
 
1745
- if (submissionMethod === 'turn/start') {
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="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, 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.4",
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",
@@ -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
- lastInputTokens: toFiniteNumber(last.inputTokens) ?? 0,
260
- lastCachedInputTokens: toFiniteNumber(last.cachedInputTokens) ?? 0,
261
- lastOutputTokens: toFiniteNumber(last.outputTokens) ?? 0,
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
- usageKnown = true
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.totalInputTokens + tokenUsage.totalOutputTokens
315
- : usageKnown
316
- ? 0
317
- : null
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
@@ -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
 
package/shared/codori.ts CHANGED
@@ -7,6 +7,10 @@ export type ProjectRecord = {
7
7
  pid: number | null
8
8
  port: number | null
9
9
  startedAt: number | null
10
+ lastActivityAt: number | null
11
+ activeSessionCount: number
12
+ idleTimeoutMs: number | null
13
+ idleDeadlineAt: number | null
10
14
  error: string | null
11
15
  }
12
16