@codori/client 0.0.1

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.
Files changed (47) hide show
  1. package/README.md +16 -0
  2. package/app/app.config.ts +8 -0
  3. package/app/app.vue +7 -0
  4. package/app/assets/css/main.css +75 -0
  5. package/app/components/ChatWorkspace.vue +1499 -0
  6. package/app/components/MessageContent.vue +27 -0
  7. package/app/components/MessagePartRenderer.ts +55 -0
  8. package/app/components/ProjectSidebar.vue +146 -0
  9. package/app/components/ProjectStatusDot.vue +76 -0
  10. package/app/components/ThreadList.vue +165 -0
  11. package/app/components/ThreadPanel.vue +94 -0
  12. package/app/components/TunnelNotice.vue +27 -0
  13. package/app/components/VisualSubagentStack.vue +267 -0
  14. package/app/components/message-item/CommandExecution.vue +66 -0
  15. package/app/components/message-item/ContextCompaction.vue +9 -0
  16. package/app/components/message-item/DynamicToolCall.vue +108 -0
  17. package/app/components/message-item/FileChange.vue +114 -0
  18. package/app/components/message-item/McpToolCall.vue +86 -0
  19. package/app/components/message-item/SubagentActivity.vue +181 -0
  20. package/app/components/message-item/UnifiedDiffViewer.vue +190 -0
  21. package/app/components/message-item/WebSearch.vue +24 -0
  22. package/app/components/message-item/use-chat-tool-state.ts +27 -0
  23. package/app/components/message-part/Event.vue +57 -0
  24. package/app/components/message-part/Item.ts +50 -0
  25. package/app/components/message-part/Text.vue +35 -0
  26. package/app/composables/useChatSession.ts +59 -0
  27. package/app/composables/useChatSubmitGuard.ts +28 -0
  28. package/app/composables/useProjects.ts +102 -0
  29. package/app/composables/useRpc.ts +34 -0
  30. package/app/composables/useThreadPanel.ts +18 -0
  31. package/app/composables/useVisualSubagentPanels.ts +20 -0
  32. package/app/layouts/default.vue +81 -0
  33. package/app/pages/index.vue +62 -0
  34. package/app/pages/projects/[...projectId]/index.vue +104 -0
  35. package/app/pages/projects/[...projectId]/threads/[threadId].vue +303 -0
  36. package/nuxt.config.ts +19 -0
  37. package/package.json +50 -0
  38. package/server/api/codori/projects/[projectId]/start.post.ts +17 -0
  39. package/server/api/codori/projects/[projectId]/status.get.ts +16 -0
  40. package/server/api/codori/projects/[projectId]/stop.post.ts +17 -0
  41. package/server/api/codori/projects/[projectId].get.ts +13 -0
  42. package/server/api/codori/projects/index.get.ts +7 -0
  43. package/server/utils/server-proxy.ts +25 -0
  44. package/shared/codex-chat.ts +340 -0
  45. package/shared/codex-rpc.ts +467 -0
  46. package/shared/codori.ts +62 -0
  47. package/shared/network.ts +34 -0
@@ -0,0 +1,1499 @@
1
+ <script setup lang="ts">
2
+ import { useRouter } from '#imports'
3
+ import { computed, nextTick, onMounted, ref, watch } from 'vue'
4
+ import MessageContent from './MessageContent.vue'
5
+ import { useChatSession, type SubagentPanelState } from '../composables/useChatSession.js'
6
+ import { useProjects } from '../composables/useProjects.js'
7
+ import { useRpc } from '../composables/useRpc.js'
8
+ import { useChatSubmitGuard } from '../composables/useChatSubmitGuard.js'
9
+ import {
10
+ ITEM_PART,
11
+ eventToMessage,
12
+ isSubagentActiveStatus,
13
+ itemToMessages,
14
+ threadToMessages,
15
+ upsertStreamingMessage,
16
+ type ChatMessage,
17
+ type ChatPart,
18
+ type FileChangeItem,
19
+ type ItemData,
20
+ type McpToolCallItem
21
+ } from '~~/shared/codex-chat.js'
22
+ import {
23
+ notificationThreadId,
24
+ notificationTurnId,
25
+ type CodexRpcNotification,
26
+ type CodexThread,
27
+ type CodexThreadItem,
28
+ type ThreadReadResponse,
29
+ type ThreadResumeResponse,
30
+ type ThreadStartResponse,
31
+ type TurnStartResponse
32
+ } from '~~/shared/codex-rpc.js'
33
+ import { toProjectThreadRoute } from '~~/shared/codori.js'
34
+
35
+ const props = defineProps<{
36
+ projectId: string
37
+ threadId?: string | null
38
+ }>()
39
+
40
+ const router = useRouter()
41
+ const { getClient } = useRpc()
42
+ const {
43
+ loaded,
44
+ refreshProjects,
45
+ getProject,
46
+ startProject
47
+ } = useProjects()
48
+ const {
49
+ onCompositionStart,
50
+ onCompositionEnd,
51
+ shouldSubmit
52
+ } = useChatSubmitGuard()
53
+
54
+ const input = ref('')
55
+ const scrollViewport = ref<HTMLElement | null>(null)
56
+ const pinnedToBottom = ref(true)
57
+ const session = useChatSession(props.projectId)
58
+ const {
59
+ messages,
60
+ subagentPanels,
61
+ status,
62
+ error,
63
+ activeThreadId,
64
+ threadTitle,
65
+ pendingThreadId,
66
+ autoRedirectThreadId,
67
+ loadVersion
68
+ } = session
69
+
70
+ const selectedProject = computed(() => getProject(props.projectId))
71
+ const submitError = computed(() => error.value ? new Error(error.value) : undefined)
72
+ const isBusy = computed(() => status.value === 'submitted' || status.value === 'streaming')
73
+ const routeThreadId = computed(() => props.threadId ?? null)
74
+ const projectTitle = computed(() => selectedProject.value?.projectId ?? props.projectId)
75
+ const showWelcomeState = computed(() =>
76
+ !routeThreadId.value
77
+ && !activeThreadId.value
78
+ && messages.value.length === 0
79
+ && !isBusy.value
80
+ )
81
+
82
+ const starterPrompts = computed(() => {
83
+ const project = projectTitle.value
84
+
85
+ return [
86
+ {
87
+ title: 'Map the codebase',
88
+ text: `Summarize the structure of ${project} and identify the main entry points.`
89
+ },
90
+ {
91
+ title: 'Find the next task',
92
+ text: `Inspect ${project} and suggest the highest-impact improvement to make next.`
93
+ },
94
+ {
95
+ title: 'Build a plan',
96
+ text: `Read ${project} and propose a concrete implementation plan for the next feature.`
97
+ }
98
+ ]
99
+ })
100
+
101
+ const subagentBootstrapPromises = new Map<string, Promise<void>>()
102
+
103
+ const isActiveTurnStatus = (value: string | null | undefined) => {
104
+ if (!value) {
105
+ return false
106
+ }
107
+
108
+ return !/(completed|failed|error|cancelled|canceled|interrupted|stopped)/i.test(value)
109
+ }
110
+
111
+ const currentLiveStream = () =>
112
+ session.liveStream?.threadId === activeThreadId.value
113
+ ? session.liveStream
114
+ : null
115
+
116
+ const clearLiveStream = () => {
117
+ session.liveStream?.unsubscribe?.()
118
+ session.liveStream = null
119
+ }
120
+
121
+ const stripOptimisticDraftMessages = () => {
122
+ messages.value = messages.value.filter(message => !message.id.startsWith('local-user-'))
123
+ }
124
+
125
+ const resolveThreadTitle = (thread: { name: string | null, preview: string, id: string }) => {
126
+ const nextTitle = thread.name?.trim() || thread.preview.trim()
127
+ return nextTitle || `Thread ${thread.id}`
128
+ }
129
+
130
+ const shortThreadId = (value: string) => value.slice(0, 8)
131
+
132
+ const resolveSubagentName = (
133
+ threadId: string,
134
+ thread?: Pick<CodexThread, 'agentNickname' | 'name' | 'preview'> | null
135
+ ) => {
136
+ const candidate = thread?.agentNickname?.trim()
137
+ || thread?.name?.trim()
138
+ || thread?.preview?.trim()
139
+
140
+ if (candidate) {
141
+ return candidate
142
+ }
143
+
144
+ return `Agent ${shortThreadId(threadId)}`
145
+ }
146
+
147
+ const getSubagentPanel = (threadId: string) =>
148
+ subagentPanels.value.find(panel => panel.threadId === threadId)
149
+
150
+ const createSubagentPanelState = (threadId: string): SubagentPanelState => ({
151
+ threadId,
152
+ name: resolveSubagentName(threadId),
153
+ status: null,
154
+ messages: [],
155
+ firstSeenAt: Date.now(),
156
+ lastSeenAt: Date.now(),
157
+ turnId: null,
158
+ bootstrapped: false,
159
+ bufferedNotifications: []
160
+ })
161
+
162
+ const upsertSubagentPanel = (
163
+ threadId: string,
164
+ updater: (panel: SubagentPanelState | undefined) => SubagentPanelState
165
+ ) => {
166
+ const current = getSubagentPanel(threadId)
167
+ const nextPanel = updater(current)
168
+ const index = subagentPanels.value.findIndex(panel => panel.threadId === threadId)
169
+
170
+ if (index === -1) {
171
+ subagentPanels.value = [...subagentPanels.value, nextPanel]
172
+ return nextPanel
173
+ }
174
+
175
+ subagentPanels.value = subagentPanels.value.map((panel, panelIndex) =>
176
+ panelIndex === index ? nextPanel : panel
177
+ )
178
+ return nextPanel
179
+ }
180
+
181
+ const rememberObservedSubagentThread = (threadId: string) => {
182
+ session.liveStream?.observedSubagentThreadIds.add(threadId)
183
+ }
184
+
185
+ const isTextPart = (part: ChatPart): part is Extract<ChatPart, { type: 'text' }> =>
186
+ part.type === 'text'
187
+
188
+ const isItemPart = (part: ChatPart): part is Extract<ChatPart, { type: typeof ITEM_PART }> =>
189
+ part.type === ITEM_PART
190
+
191
+ const getFallbackItemData = (message: ChatMessage) => {
192
+ const itemPart = message.parts.find(isItemPart)
193
+ if (!itemPart) {
194
+ throw new Error('Expected fallback item part.')
195
+ }
196
+
197
+ return itemPart.data
198
+ }
199
+
200
+ const updatePinnedState = () => {
201
+ const viewport = scrollViewport.value
202
+ if (!viewport) {
203
+ return
204
+ }
205
+
206
+ pinnedToBottom.value = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80
207
+ }
208
+
209
+ const scrollToBottom = (behavior: ScrollBehavior = 'auto') => {
210
+ const viewport = scrollViewport.value
211
+ if (!viewport) {
212
+ return
213
+ }
214
+
215
+ viewport.scrollTo({
216
+ top: viewport.scrollHeight,
217
+ behavior
218
+ })
219
+ }
220
+
221
+ const scheduleScrollToBottom = async (behavior: ScrollBehavior = 'auto') => {
222
+ await nextTick()
223
+
224
+ if (!import.meta.client) {
225
+ scrollToBottom(behavior)
226
+ return
227
+ }
228
+
229
+ requestAnimationFrame(() => {
230
+ if (pinnedToBottom.value || isBusy.value) {
231
+ scrollToBottom(behavior)
232
+ }
233
+ })
234
+ }
235
+
236
+ const ensureProjectRuntime = async () => {
237
+ if (!loaded.value) {
238
+ await refreshProjects()
239
+ }
240
+
241
+ if (selectedProject.value?.status === 'running') {
242
+ return
243
+ }
244
+
245
+ await startProject(props.projectId)
246
+ }
247
+
248
+ const hydrateThread = async (threadId: string) => {
249
+ const requestVersion = loadVersion.value + 1
250
+ loadVersion.value = requestVersion
251
+ error.value = null
252
+
253
+ try {
254
+ await ensureProjectRuntime()
255
+ const client = getClient(props.projectId)
256
+ activeThreadId.value = threadId
257
+
258
+ const existingLiveStream = currentLiveStream()
259
+
260
+ if (!existingLiveStream) {
261
+ const nextLiveStream = {
262
+ threadId,
263
+ turnId: null,
264
+ bufferedNotifications: [] as CodexRpcNotification[],
265
+ observedSubagentThreadIds: new Set<string>(),
266
+ unsubscribe: null as (() => void) | null
267
+ }
268
+
269
+ nextLiveStream.unsubscribe = client.subscribe((notification) => {
270
+ const targetThreadId = notificationThreadId(notification)
271
+ if (!targetThreadId) {
272
+ return
273
+ }
274
+
275
+ if (targetThreadId !== threadId) {
276
+ if (nextLiveStream.observedSubagentThreadIds.has(targetThreadId)) {
277
+ applySubagentNotification(targetThreadId, notification)
278
+ }
279
+ return
280
+ }
281
+
282
+ if (!nextLiveStream.turnId) {
283
+ nextLiveStream.bufferedNotifications.push(notification)
284
+ return
285
+ }
286
+
287
+ const turnId = notificationTurnId(notification)
288
+ if (turnId && turnId !== nextLiveStream.turnId) {
289
+ return
290
+ }
291
+
292
+ applyNotification(notification)
293
+ })
294
+
295
+ session.liveStream = nextLiveStream
296
+ }
297
+
298
+ await client.request<ThreadResumeResponse>('thread/resume', {
299
+ threadId,
300
+ cwd: selectedProject.value?.projectPath ?? null,
301
+ approvalPolicy: 'never',
302
+ persistExtendedHistory: true
303
+ })
304
+ const response = await client.request<ThreadReadResponse>('thread/read', {
305
+ threadId,
306
+ includeTurns: true
307
+ })
308
+
309
+ if (loadVersion.value !== requestVersion) {
310
+ return
311
+ }
312
+
313
+ activeThreadId.value = response.thread.id
314
+ threadTitle.value = resolveThreadTitle(response.thread)
315
+ messages.value = threadToMessages(response.thread)
316
+ rebuildSubagentPanelsFromThread(response.thread)
317
+ const activeTurn = [...response.thread.turns].reverse().find(turn => isActiveTurnStatus(turn.status))
318
+
319
+ if (!activeTurn) {
320
+ clearLiveStream()
321
+ status.value = 'ready'
322
+ return
323
+ }
324
+
325
+ if (!session.liveStream) {
326
+ status.value = 'streaming'
327
+ return
328
+ }
329
+
330
+ session.liveStream.turnId = activeTurn.id
331
+ status.value = 'streaming'
332
+
333
+ const pendingNotifications = session.liveStream.bufferedNotifications.splice(0, session.liveStream.bufferedNotifications.length)
334
+ for (const notification of pendingNotifications) {
335
+ const turnId = notificationTurnId(notification)
336
+ if (turnId && turnId !== activeTurn.id) {
337
+ continue
338
+ }
339
+
340
+ applyNotification(notification)
341
+ }
342
+ } catch (caughtError) {
343
+ clearLiveStream()
344
+ error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
345
+ status.value = 'error'
346
+ }
347
+ }
348
+
349
+ const resetDraftThread = () => {
350
+ clearLiveStream()
351
+ activeThreadId.value = null
352
+ threadTitle.value = null
353
+ pendingThreadId.value = null
354
+ autoRedirectThreadId.value = null
355
+ messages.value = []
356
+ subagentPanels.value = []
357
+ error.value = null
358
+ status.value = 'ready'
359
+ }
360
+
361
+ const ensureThread = async () => {
362
+ if (activeThreadId.value) {
363
+ return {
364
+ threadId: activeThreadId.value,
365
+ created: false
366
+ }
367
+ }
368
+
369
+ await ensureProjectRuntime()
370
+ const client = getClient(props.projectId)
371
+ const response = await client.request<ThreadStartResponse>('thread/start', {
372
+ cwd: selectedProject.value?.projectPath ?? null,
373
+ approvalPolicy: 'never',
374
+ experimentalRawEvents: false,
375
+ persistExtendedHistory: true
376
+ })
377
+
378
+ activeThreadId.value = response.thread.id
379
+ threadTitle.value = resolveThreadTitle(response.thread)
380
+ return {
381
+ threadId: response.thread.id,
382
+ created: true
383
+ }
384
+ }
385
+
386
+ const seedStreamingMessage = (item: CodexThreadItem) => {
387
+ const [seed] = itemToMessages(item)
388
+ if (!seed) {
389
+ return
390
+ }
391
+
392
+ messages.value = upsertStreamingMessage(messages.value, {
393
+ ...seed,
394
+ pending: true
395
+ })
396
+ }
397
+
398
+ const updateMessage = (
399
+ messageId: string,
400
+ fallbackMessage: ChatMessage,
401
+ transform: (message: ChatMessage) => ChatMessage
402
+ ) => {
403
+ const existing = messages.value.find(message => message.id === messageId)
404
+ messages.value = upsertStreamingMessage(messages.value, transform(existing ?? fallbackMessage))
405
+ }
406
+
407
+ const appendTextPartDelta = (
408
+ messageId: string,
409
+ delta: string,
410
+ fallbackMessage: ChatMessage
411
+ ) => {
412
+ updateMessage(messageId, fallbackMessage, (message) => {
413
+ const partIndex = message.parts.findIndex(isTextPart)
414
+ const existingTextPart = partIndex === -1 ? null : message.parts[partIndex] as Extract<ChatPart, { type: 'text' }>
415
+ const nextText = existingTextPart ? `${existingTextPart.text}${delta}` : delta
416
+ const nextTextPart: Extract<ChatPart, { type: 'text' }> = {
417
+ type: 'text',
418
+ text: nextText,
419
+ state: 'streaming'
420
+ }
421
+ const nextParts = partIndex === -1
422
+ ? [...message.parts, nextTextPart]
423
+ : message.parts.map((part, index) => index === partIndex ? nextTextPart : part)
424
+
425
+ return {
426
+ ...message,
427
+ pending: true,
428
+ parts: nextParts
429
+ }
430
+ })
431
+ }
432
+
433
+ const updateItemPart = (
434
+ messageId: string,
435
+ fallbackMessage: ChatMessage,
436
+ transform: (itemData: ItemData) => ItemData
437
+ ) => {
438
+ updateMessage(messageId, fallbackMessage, (message) => {
439
+ const partIndex = message.parts.findIndex(isItemPart)
440
+ const existingData = partIndex === -1 ? null : (message.parts[partIndex] as Extract<ChatPart, { type: typeof ITEM_PART }>).data
441
+ const nextData = transform(existingData ?? getFallbackItemData(fallbackMessage))
442
+ const nextPart: Extract<ChatPart, { type: typeof ITEM_PART }> = {
443
+ type: ITEM_PART,
444
+ data: nextData
445
+ }
446
+ const nextParts = partIndex === -1
447
+ ? [...message.parts, nextPart]
448
+ : message.parts.map((part, index) => index === partIndex ? nextPart : part)
449
+
450
+ return {
451
+ ...message,
452
+ pending: true,
453
+ parts: nextParts
454
+ }
455
+ })
456
+ }
457
+
458
+ const fallbackCommandMessage = (itemId: string): ChatMessage => ({
459
+ id: itemId,
460
+ role: 'system',
461
+ pending: true,
462
+ parts: [{
463
+ type: ITEM_PART,
464
+ data: {
465
+ kind: 'command_execution',
466
+ item: {
467
+ type: 'commandExecution',
468
+ id: itemId,
469
+ command: 'Command',
470
+ aggregatedOutput: '',
471
+ exitCode: null,
472
+ status: 'inProgress'
473
+ }
474
+ }
475
+ }]
476
+ })
477
+
478
+ const fallbackFileChangeMessage = (itemId: string): ChatMessage => ({
479
+ id: itemId,
480
+ role: 'system',
481
+ pending: true,
482
+ parts: [{
483
+ type: ITEM_PART,
484
+ data: {
485
+ kind: 'file_change',
486
+ item: {
487
+ type: 'fileChange',
488
+ id: itemId,
489
+ changes: [],
490
+ status: 'inProgress',
491
+ liveOutput: ''
492
+ }
493
+ }
494
+ }]
495
+ })
496
+
497
+ const fallbackMcpToolMessage = (itemId: string): ChatMessage => ({
498
+ id: itemId,
499
+ role: 'system',
500
+ pending: true,
501
+ parts: [{
502
+ type: ITEM_PART,
503
+ data: {
504
+ kind: 'mcp_tool_call',
505
+ item: {
506
+ type: 'mcpToolCall',
507
+ id: itemId,
508
+ server: 'mcp',
509
+ tool: 'tool',
510
+ arguments: null,
511
+ result: null,
512
+ error: null,
513
+ status: 'inProgress',
514
+ progressMessages: []
515
+ }
516
+ }
517
+ }]
518
+ })
519
+
520
+ const fallbackCommandItemData = (itemId: string) =>
521
+ getFallbackItemData(fallbackCommandMessage(itemId)) as Extract<ItemData, { kind: 'command_execution' }>
522
+
523
+ const fallbackFileChangeItemData = (itemId: string) =>
524
+ getFallbackItemData(fallbackFileChangeMessage(itemId)) as Extract<ItemData, { kind: 'file_change' }>
525
+
526
+ const fallbackMcpToolItemData = (itemId: string) =>
527
+ getFallbackItemData(fallbackMcpToolMessage(itemId)) as Extract<ItemData, { kind: 'mcp_tool_call' }>
528
+
529
+ const pushEventMessage = (kind: 'turn.failed' | 'stream.error', messageText: string) => {
530
+ messages.value = upsertStreamingMessage(
531
+ messages.value,
532
+ eventToMessage(`event-${kind}-${Date.now()}`, kind === 'turn.failed'
533
+ ? {
534
+ kind,
535
+ error: {
536
+ message: messageText
537
+ }
538
+ }
539
+ : {
540
+ kind,
541
+ message: messageText
542
+ })
543
+ )
544
+ }
545
+
546
+ const updateSubagentPanelMessages = (
547
+ threadId: string,
548
+ updater: (panelMessages: ChatMessage[]) => ChatMessage[]
549
+ ) => {
550
+ upsertSubagentPanel(threadId, (panel) => {
551
+ const basePanel = panel ?? createSubagentPanelState(threadId)
552
+ return {
553
+ ...basePanel,
554
+ messages: updater(basePanel.messages),
555
+ lastSeenAt: Date.now()
556
+ }
557
+ })
558
+ }
559
+
560
+ const updateSubagentMessage = (
561
+ threadId: string,
562
+ messageId: string,
563
+ fallbackMessage: ChatMessage,
564
+ transform: (message: ChatMessage) => ChatMessage
565
+ ) => {
566
+ updateSubagentPanelMessages(threadId, (panelMessages) => {
567
+ const existing = panelMessages.find(message => message.id === messageId)
568
+ return upsertStreamingMessage(panelMessages, transform(existing ?? fallbackMessage))
569
+ })
570
+ }
571
+
572
+ const appendSubagentTextPartDelta = (
573
+ threadId: string,
574
+ messageId: string,
575
+ delta: string,
576
+ fallbackMessage: ChatMessage
577
+ ) => {
578
+ updateSubagentMessage(threadId, messageId, fallbackMessage, (message) => {
579
+ const partIndex = message.parts.findIndex(isTextPart)
580
+ const existingTextPart = partIndex === -1 ? null : message.parts[partIndex] as Extract<ChatPart, { type: 'text' }>
581
+ const nextText = existingTextPart ? `${existingTextPart.text}${delta}` : delta
582
+ const nextTextPart: Extract<ChatPart, { type: 'text' }> = {
583
+ type: 'text',
584
+ text: nextText,
585
+ state: 'streaming'
586
+ }
587
+ const nextParts = partIndex === -1
588
+ ? [...message.parts, nextTextPart]
589
+ : message.parts.map((part, index) => index === partIndex ? nextTextPart : part)
590
+
591
+ return {
592
+ ...message,
593
+ pending: true,
594
+ parts: nextParts
595
+ }
596
+ })
597
+ }
598
+
599
+ const updateSubagentItemPart = (
600
+ threadId: string,
601
+ messageId: string,
602
+ fallbackMessage: ChatMessage,
603
+ transform: (itemData: ItemData) => ItemData
604
+ ) => {
605
+ updateSubagentMessage(threadId, messageId, fallbackMessage, (message) => {
606
+ const partIndex = message.parts.findIndex(isItemPart)
607
+ const existingData = partIndex === -1 ? null : (message.parts[partIndex] as Extract<ChatPart, { type: typeof ITEM_PART }>).data
608
+ const nextData = transform(existingData ?? getFallbackItemData(fallbackMessage))
609
+ const nextPart: Extract<ChatPart, { type: typeof ITEM_PART }> = {
610
+ type: ITEM_PART,
611
+ data: nextData
612
+ }
613
+ const nextParts = partIndex === -1
614
+ ? [...message.parts, nextPart]
615
+ : message.parts.map((part, index) => index === partIndex ? nextPart : part)
616
+
617
+ return {
618
+ ...message,
619
+ pending: true,
620
+ parts: nextParts
621
+ }
622
+ })
623
+ }
624
+
625
+ const pushSubagentEventMessage = (threadId: string, kind: 'turn.failed' | 'stream.error', messageText: string) => {
626
+ updateSubagentPanelMessages(threadId, (panelMessages) =>
627
+ upsertStreamingMessage(
628
+ panelMessages,
629
+ eventToMessage(`subagent-event-${threadId}-${kind}-${Date.now()}`, kind === 'turn.failed'
630
+ ? {
631
+ kind,
632
+ error: {
633
+ message: messageText
634
+ }
635
+ }
636
+ : {
637
+ kind,
638
+ message: messageText
639
+ })
640
+ )
641
+ )
642
+ }
643
+
644
+ const seedSubagentStreamingMessage = (threadId: string, item: CodexThreadItem) => {
645
+ const [seed] = itemToMessages(item)
646
+ if (!seed) {
647
+ return
648
+ }
649
+
650
+ updateSubagentPanelMessages(threadId, (panelMessages) =>
651
+ upsertStreamingMessage(panelMessages, {
652
+ ...seed,
653
+ pending: true
654
+ })
655
+ )
656
+ }
657
+
658
+ const bootstrapSubagentPanel = async (threadId: string) => {
659
+ if (!threadId) {
660
+ return
661
+ }
662
+
663
+ const existingPromise = subagentBootstrapPromises.get(threadId)
664
+ if (existingPromise) {
665
+ await existingPromise
666
+ return
667
+ }
668
+
669
+ const bootstrapPromise = (async () => {
670
+ try {
671
+ await ensureProjectRuntime()
672
+ const client = getClient(props.projectId)
673
+ const response = await client.request<ThreadReadResponse>('thread/read', {
674
+ threadId,
675
+ includeTurns: true
676
+ })
677
+ const activeTurn = [...response.thread.turns].reverse().find(turn => isActiveTurnStatus(turn.status))
678
+ const pendingNotifications = getSubagentPanel(threadId)?.bufferedNotifications.slice() ?? []
679
+
680
+ upsertSubagentPanel(threadId, (panel) => {
681
+ const basePanel = panel ?? createSubagentPanelState(threadId)
682
+ return {
683
+ ...basePanel,
684
+ name: resolveSubagentName(threadId, response.thread),
685
+ messages: threadToMessages(response.thread),
686
+ turnId: activeTurn?.id ?? null,
687
+ bootstrapped: true,
688
+ bufferedNotifications: [],
689
+ status: panel?.status
690
+ ?? (activeTurn ? 'running' : basePanel.status)
691
+ ?? null,
692
+ lastSeenAt: Date.now()
693
+ }
694
+ })
695
+
696
+ for (const notification of pendingNotifications) {
697
+ applySubagentNotification(threadId, notification)
698
+ }
699
+ } catch {
700
+ upsertSubagentPanel(threadId, (panel) => ({
701
+ ...(panel ?? createSubagentPanelState(threadId)),
702
+ lastSeenAt: Date.now()
703
+ }))
704
+ } finally {
705
+ subagentBootstrapPromises.delete(threadId)
706
+ }
707
+ })()
708
+
709
+ subagentBootstrapPromises.set(threadId, bootstrapPromise)
710
+ await bootstrapPromise
711
+ }
712
+
713
+ const applySubagentActivityItem = (item: Extract<CodexThreadItem, { type: 'collabAgentToolCall' }>) => {
714
+ const orderedThreadIds = [
715
+ ...item.receiverThreadIds,
716
+ ...Object.keys(item.agentsStates).filter(threadId => !item.receiverThreadIds.includes(threadId))
717
+ ]
718
+
719
+ for (const threadId of orderedThreadIds) {
720
+ const agentState = item.agentsStates[threadId]
721
+ rememberObservedSubagentThread(threadId)
722
+ upsertSubagentPanel(threadId, (panel) => {
723
+ const basePanel = panel ?? createSubagentPanelState(threadId)
724
+ return {
725
+ ...basePanel,
726
+ status: agentState?.status ?? basePanel.status,
727
+ lastSeenAt: Date.now()
728
+ }
729
+ })
730
+ }
731
+
732
+ for (const threadId of orderedThreadIds) {
733
+ void bootstrapSubagentPanel(threadId)
734
+ }
735
+ }
736
+
737
+ const rebuildSubagentPanelsFromThread = (thread: CodexThread) => {
738
+ subagentPanels.value = []
739
+ for (const turn of thread.turns) {
740
+ for (const item of turn.items) {
741
+ if (item.type === 'collabAgentToolCall') {
742
+ applySubagentActivityItem(item)
743
+ }
744
+ }
745
+ }
746
+ }
747
+
748
+ const applySubagentNotification = (threadId: string, notification: CodexRpcNotification) => {
749
+ const panel = getSubagentPanel(threadId)
750
+ if (!panel) {
751
+ return
752
+ }
753
+
754
+ if (!panel.bootstrapped) {
755
+ upsertSubagentPanel(threadId, (existingPanel) => ({
756
+ ...(existingPanel ?? createSubagentPanelState(threadId)),
757
+ bufferedNotifications: [...(existingPanel?.bufferedNotifications ?? []), notification],
758
+ lastSeenAt: Date.now()
759
+ }))
760
+ return
761
+ }
762
+
763
+ const turnId = notificationTurnId(notification)
764
+ if (panel.turnId && turnId && turnId !== panel.turnId && notification.method !== 'turn/started') {
765
+ return
766
+ }
767
+
768
+ switch (notification.method) {
769
+ case 'turn/started': {
770
+ upsertSubagentPanel(threadId, (existingPanel) => ({
771
+ ...(existingPanel ?? createSubagentPanelState(threadId)),
772
+ turnId: notificationTurnId(notification),
773
+ status: existingPanel?.status === 'completed' ? existingPanel.status : 'running',
774
+ lastSeenAt: Date.now()
775
+ }))
776
+ return
777
+ }
778
+ case 'item/started': {
779
+ const params = notification.params as { item: CodexThreadItem }
780
+ if (params.item.type === 'collabAgentToolCall') {
781
+ applySubagentActivityItem(params.item)
782
+ }
783
+ seedSubagentStreamingMessage(threadId, params.item)
784
+ return
785
+ }
786
+ case 'item/completed': {
787
+ const params = notification.params as { item: CodexThreadItem }
788
+ if (params.item.type === 'collabAgentToolCall') {
789
+ applySubagentActivityItem(params.item)
790
+ }
791
+ for (const nextMessage of itemToMessages(params.item)) {
792
+ updateSubagentPanelMessages(threadId, (panelMessages) =>
793
+ upsertStreamingMessage(panelMessages, {
794
+ ...nextMessage,
795
+ pending: false
796
+ })
797
+ )
798
+ }
799
+ return
800
+ }
801
+ case 'item/agentMessage/delta':
802
+ case 'item/plan/delta': {
803
+ const params = notification.params as { itemId: string, delta: string }
804
+ appendSubagentTextPartDelta(threadId, params.itemId, params.delta, {
805
+ id: params.itemId,
806
+ role: 'assistant',
807
+ pending: true,
808
+ parts: [{
809
+ type: 'text',
810
+ text: '',
811
+ state: 'streaming'
812
+ }]
813
+ })
814
+ return
815
+ }
816
+ case 'item/reasoning/textDelta':
817
+ case 'item/reasoning/summaryTextDelta': {
818
+ const params = notification.params as { itemId: string, delta: string }
819
+ updateSubagentMessage(threadId, params.itemId, {
820
+ id: params.itemId,
821
+ role: 'assistant',
822
+ pending: true,
823
+ parts: [{
824
+ type: 'reasoning',
825
+ summary: [],
826
+ content: [],
827
+ state: 'streaming'
828
+ }]
829
+ }, (message) => {
830
+ const partIndex = message.parts.findIndex(part => part.type === 'reasoning')
831
+ const existingPart = partIndex === -1
832
+ ? {
833
+ type: 'reasoning' as const,
834
+ summary: [],
835
+ content: []
836
+ }
837
+ : message.parts[partIndex] as Extract<ChatPart, { type: 'reasoning' }>
838
+ const nextPart: Extract<ChatPart, { type: 'reasoning' }> = {
839
+ type: 'reasoning',
840
+ summary: notification.method === 'item/reasoning/summaryTextDelta'
841
+ ? [...existingPart.summary, params.delta]
842
+ : existingPart.summary,
843
+ content: notification.method === 'item/reasoning/textDelta'
844
+ ? [...existingPart.content, params.delta]
845
+ : existingPart.content,
846
+ state: 'streaming'
847
+ }
848
+ const nextParts = partIndex === -1
849
+ ? [...message.parts, nextPart]
850
+ : message.parts.map((part, index) => index === partIndex ? nextPart : part)
851
+
852
+ return {
853
+ ...message,
854
+ pending: true,
855
+ parts: nextParts
856
+ }
857
+ })
858
+ return
859
+ }
860
+ case 'item/commandExecution/outputDelta': {
861
+ const params = notification.params as { itemId: string, delta: string }
862
+ const fallbackItem = fallbackCommandItemData(params.itemId)
863
+ updateSubagentItemPart(threadId, params.itemId, fallbackCommandMessage(params.itemId), (itemData) => ({
864
+ kind: 'command_execution',
865
+ item: {
866
+ ...(itemData.kind === 'command_execution' ? itemData.item : fallbackItem.item),
867
+ aggregatedOutput: `${(itemData.kind === 'command_execution' ? itemData.item.aggregatedOutput : '') ?? ''}${params.delta}`,
868
+ status: 'inProgress'
869
+ }
870
+ }))
871
+ return
872
+ }
873
+ case 'item/fileChange/outputDelta': {
874
+ const params = notification.params as { itemId: string, delta: string }
875
+ const fallbackItem = fallbackFileChangeItemData(params.itemId)
876
+ updateSubagentItemPart(threadId, params.itemId, fallbackFileChangeMessage(params.itemId), (itemData) => {
877
+ const baseItem: FileChangeItem = itemData.kind === 'file_change'
878
+ ? itemData.item
879
+ : fallbackItem.item
880
+ return {
881
+ kind: 'file_change',
882
+ item: {
883
+ ...baseItem,
884
+ liveOutput: `${baseItem.liveOutput ?? ''}${params.delta}`,
885
+ status: 'inProgress'
886
+ }
887
+ }
888
+ })
889
+ return
890
+ }
891
+ case 'item/mcpToolCall/progress': {
892
+ const params = notification.params as { itemId: string, message: string }
893
+ const fallbackItem = fallbackMcpToolItemData(params.itemId)
894
+ updateSubagentItemPart(threadId, params.itemId, fallbackMcpToolMessage(params.itemId), (itemData) => {
895
+ const baseItem: McpToolCallItem = itemData.kind === 'mcp_tool_call'
896
+ ? itemData.item
897
+ : fallbackItem.item
898
+ return {
899
+ kind: 'mcp_tool_call',
900
+ item: {
901
+ ...baseItem,
902
+ progressMessages: [...(baseItem.progressMessages ?? []), params.message],
903
+ status: 'inProgress'
904
+ }
905
+ }
906
+ })
907
+ return
908
+ }
909
+ case 'turn/completed': {
910
+ upsertSubagentPanel(threadId, (existingPanel) => ({
911
+ ...(existingPanel ?? createSubagentPanelState(threadId)),
912
+ turnId: null,
913
+ status: isSubagentActiveStatus(existingPanel?.status ?? null)
914
+ ? 'completed'
915
+ : existingPanel?.status ?? null,
916
+ lastSeenAt: Date.now()
917
+ }))
918
+ return
919
+ }
920
+ case 'turn/failed': {
921
+ const params = notification.params as { error?: { message?: string } }
922
+ const messageText = params.error?.message ?? 'The turn failed.'
923
+ pushSubagentEventMessage(threadId, 'turn.failed', messageText)
924
+ upsertSubagentPanel(threadId, (existingPanel) => ({
925
+ ...(existingPanel ?? createSubagentPanelState(threadId)),
926
+ status: 'errored',
927
+ turnId: null,
928
+ lastSeenAt: Date.now()
929
+ }))
930
+ return
931
+ }
932
+ case 'stream/error': {
933
+ const params = notification.params as { message?: string }
934
+ const messageText = params.message ?? 'The stream failed.'
935
+ pushSubagentEventMessage(threadId, 'stream.error', messageText)
936
+ upsertSubagentPanel(threadId, (existingPanel) => ({
937
+ ...(existingPanel ?? createSubagentPanelState(threadId)),
938
+ status: 'errored',
939
+ turnId: null,
940
+ lastSeenAt: Date.now()
941
+ }))
942
+ return
943
+ }
944
+ default:
945
+ return
946
+ }
947
+ }
948
+
949
+ const applyNotification = (notification: CodexRpcNotification) => {
950
+ switch (notification.method) {
951
+ case 'thread/started': {
952
+ const nextThreadId = notificationThreadId(notification)
953
+ if (!nextThreadId) {
954
+ return
955
+ }
956
+
957
+ activeThreadId.value = nextThreadId
958
+ pendingThreadId.value = pendingThreadId.value ?? nextThreadId
959
+ return
960
+ }
961
+ case 'turn/started': {
962
+ messages.value = upsertStreamingMessage(
963
+ messages.value,
964
+ eventToMessage(`event-turn-started-${notificationTurnId(notification) ?? Date.now()}`, {
965
+ kind: 'turn.started'
966
+ })
967
+ )
968
+ status.value = 'streaming'
969
+ return
970
+ }
971
+ case 'item/started': {
972
+ const params = notification.params as { item: CodexThreadItem }
973
+ if (params.item.type === 'collabAgentToolCall') {
974
+ applySubagentActivityItem(params.item)
975
+ }
976
+ seedStreamingMessage(params.item)
977
+ status.value = 'streaming'
978
+ return
979
+ }
980
+ case 'item/completed': {
981
+ const params = notification.params as { item: CodexThreadItem }
982
+ if (params.item.type === 'collabAgentToolCall') {
983
+ applySubagentActivityItem(params.item)
984
+ }
985
+ if (params.item.type === 'userMessage') {
986
+ stripOptimisticDraftMessages()
987
+ }
988
+ for (const nextMessage of itemToMessages(params.item)) {
989
+ messages.value = upsertStreamingMessage(messages.value, {
990
+ ...nextMessage,
991
+ pending: false
992
+ })
993
+ }
994
+ return
995
+ }
996
+ case 'item/agentMessage/delta': {
997
+ const params = notification.params as { itemId: string, delta: string }
998
+ appendTextPartDelta(params.itemId, params.delta, {
999
+ id: params.itemId,
1000
+ role: 'assistant',
1001
+ pending: true,
1002
+ parts: [{
1003
+ type: 'text',
1004
+ text: '',
1005
+ state: 'streaming'
1006
+ }]
1007
+ })
1008
+ status.value = 'streaming'
1009
+ return
1010
+ }
1011
+ case 'item/plan/delta': {
1012
+ const params = notification.params as { itemId: string, delta: string }
1013
+ appendTextPartDelta(params.itemId, params.delta, {
1014
+ id: params.itemId,
1015
+ role: 'assistant',
1016
+ pending: true,
1017
+ parts: [{
1018
+ type: 'text',
1019
+ text: '',
1020
+ state: 'streaming'
1021
+ }]
1022
+ })
1023
+ status.value = 'streaming'
1024
+ return
1025
+ }
1026
+ case 'item/reasoning/textDelta':
1027
+ case 'item/reasoning/summaryTextDelta': {
1028
+ const params = notification.params as { itemId: string, delta: string }
1029
+ updateMessage(params.itemId, {
1030
+ id: params.itemId,
1031
+ role: 'assistant',
1032
+ pending: true,
1033
+ parts: [{
1034
+ type: 'reasoning',
1035
+ summary: [],
1036
+ content: [],
1037
+ state: 'streaming'
1038
+ }]
1039
+ }, (message) => {
1040
+ const partIndex = message.parts.findIndex(part => part.type === 'reasoning')
1041
+ const existingPart = partIndex === -1
1042
+ ? {
1043
+ type: 'reasoning' as const,
1044
+ summary: [],
1045
+ content: []
1046
+ }
1047
+ : message.parts[partIndex] as Extract<ChatPart, { type: 'reasoning' }>
1048
+ const nextPart: Extract<ChatPart, { type: 'reasoning' }> = {
1049
+ type: 'reasoning',
1050
+ summary: notification.method === 'item/reasoning/summaryTextDelta'
1051
+ ? [...existingPart.summary, params.delta]
1052
+ : existingPart.summary,
1053
+ content: notification.method === 'item/reasoning/textDelta'
1054
+ ? [...existingPart.content, params.delta]
1055
+ : existingPart.content,
1056
+ state: 'streaming'
1057
+ }
1058
+ const nextParts = partIndex === -1
1059
+ ? [...message.parts, nextPart]
1060
+ : message.parts.map((part, index) => index === partIndex ? nextPart : part)
1061
+
1062
+ return {
1063
+ ...message,
1064
+ pending: true,
1065
+ parts: nextParts
1066
+ }
1067
+ })
1068
+ status.value = 'streaming'
1069
+ return
1070
+ }
1071
+ case 'item/commandExecution/outputDelta': {
1072
+ const params = notification.params as { itemId: string, delta: string }
1073
+ const fallbackItem = fallbackCommandItemData(params.itemId)
1074
+ updateItemPart(params.itemId, fallbackCommandMessage(params.itemId), (itemData) => ({
1075
+ kind: 'command_execution',
1076
+ item: {
1077
+ ...(itemData.kind === 'command_execution' ? itemData.item : fallbackItem.item),
1078
+ aggregatedOutput: `${(itemData.kind === 'command_execution' ? itemData.item.aggregatedOutput : '') ?? ''}${params.delta}`,
1079
+ status: 'inProgress'
1080
+ }
1081
+ }))
1082
+ status.value = 'streaming'
1083
+ return
1084
+ }
1085
+ case 'item/fileChange/outputDelta': {
1086
+ const params = notification.params as { itemId: string, delta: string }
1087
+ const fallbackItem = fallbackFileChangeItemData(params.itemId)
1088
+ updateItemPart(params.itemId, fallbackFileChangeMessage(params.itemId), (itemData) => {
1089
+ const baseItem: FileChangeItem = itemData.kind === 'file_change'
1090
+ ? itemData.item
1091
+ : fallbackItem.item
1092
+ return {
1093
+ kind: 'file_change',
1094
+ item: {
1095
+ ...baseItem,
1096
+ liveOutput: `${baseItem.liveOutput ?? ''}${params.delta}`,
1097
+ status: 'inProgress'
1098
+ }
1099
+ }
1100
+ })
1101
+ status.value = 'streaming'
1102
+ return
1103
+ }
1104
+ case 'item/mcpToolCall/progress': {
1105
+ const params = notification.params as { itemId: string, message: string }
1106
+ const fallbackItem = fallbackMcpToolItemData(params.itemId)
1107
+ updateItemPart(params.itemId, fallbackMcpToolMessage(params.itemId), (itemData) => {
1108
+ const baseItem: McpToolCallItem = itemData.kind === 'mcp_tool_call'
1109
+ ? itemData.item
1110
+ : fallbackItem.item
1111
+ return {
1112
+ kind: 'mcp_tool_call',
1113
+ item: {
1114
+ ...baseItem,
1115
+ progressMessages: [...(baseItem.progressMessages ?? []), params.message],
1116
+ status: 'inProgress'
1117
+ }
1118
+ }
1119
+ })
1120
+ status.value = 'streaming'
1121
+ return
1122
+ }
1123
+ case 'turn/failed': {
1124
+ const params = notification.params as { error?: { message?: string } }
1125
+ const messageText = params.error?.message ?? 'The turn failed.'
1126
+ pushEventMessage('turn.failed', messageText)
1127
+ clearLiveStream()
1128
+ error.value = messageText
1129
+ status.value = 'error'
1130
+ return
1131
+ }
1132
+ case 'stream/error': {
1133
+ const params = notification.params as { message?: string }
1134
+ const messageText = params.message ?? 'The stream failed.'
1135
+ pushEventMessage('stream.error', messageText)
1136
+ clearLiveStream()
1137
+ error.value = messageText
1138
+ status.value = 'error'
1139
+ return
1140
+ }
1141
+ case 'turn/completed': {
1142
+ status.value = 'ready'
1143
+ clearLiveStream()
1144
+ return
1145
+ }
1146
+ default:
1147
+ return
1148
+ }
1149
+ }
1150
+
1151
+ const sendMessage = async () => {
1152
+ const text = input.value.trim()
1153
+ if (!text) {
1154
+ return
1155
+ }
1156
+
1157
+ pinnedToBottom.value = true
1158
+ error.value = null
1159
+ status.value = 'submitted'
1160
+ input.value = ''
1161
+ const shouldRenderOptimisticDraft = !routeThreadId.value
1162
+
1163
+ if (shouldRenderOptimisticDraft) {
1164
+ const optimisticMessage: ChatMessage = {
1165
+ id: `local-user-${Date.now()}`,
1166
+ role: 'user',
1167
+ parts: [{
1168
+ type: 'text',
1169
+ text,
1170
+ state: 'done'
1171
+ }]
1172
+ }
1173
+
1174
+ messages.value = [...messages.value, optimisticMessage]
1175
+ }
1176
+ let unsubscribe: (() => void) | null = null
1177
+
1178
+ try {
1179
+ await ensureProjectRuntime()
1180
+ const { threadId, created } = await ensureThread()
1181
+ const client = getClient(props.projectId)
1182
+ const buffered: CodexRpcNotification[] = []
1183
+ let currentTurnId: string | null = null
1184
+ let completionResolved = false
1185
+ let resolveCompletion: (() => void) | null = null
1186
+ let rejectCompletion: ((error: Error) => void) | null = null
1187
+
1188
+ const settleNotification = (notification: CodexRpcNotification) => {
1189
+ if (notificationThreadId(notification) !== threadId) {
1190
+ return false
1191
+ }
1192
+
1193
+ const turnId = notificationTurnId(notification)
1194
+ if (currentTurnId && turnId && turnId !== currentTurnId) {
1195
+ return false
1196
+ }
1197
+
1198
+ if (notification.method === 'error') {
1199
+ completionResolved = true
1200
+ unsubscribe?.()
1201
+ if (session.liveStream?.unsubscribe === unsubscribe) {
1202
+ session.liveStream = null
1203
+ }
1204
+ const params = notification.params as { error?: { message?: string } }
1205
+ const messageText = params.error?.message ?? 'Turn failed.'
1206
+ pushEventMessage('stream.error', messageText)
1207
+ rejectCompletion?.(new Error(messageText))
1208
+ return true
1209
+ }
1210
+
1211
+ applyNotification(notification)
1212
+
1213
+ if (notification.method === 'turn/completed') {
1214
+ completionResolved = true
1215
+ unsubscribe?.()
1216
+ if (session.liveStream?.unsubscribe === unsubscribe) {
1217
+ session.liveStream = null
1218
+ }
1219
+ resolveCompletion?.()
1220
+ return true
1221
+ }
1222
+
1223
+ if (notification.method === 'turn/failed' || notification.method === 'stream/error') {
1224
+ completionResolved = true
1225
+ unsubscribe?.()
1226
+ if (session.liveStream?.unsubscribe === unsubscribe) {
1227
+ session.liveStream = null
1228
+ }
1229
+ rejectCompletion?.(new Error(error.value ?? 'Turn failed.'))
1230
+ return true
1231
+ }
1232
+
1233
+ return false
1234
+ }
1235
+
1236
+ clearLiveStream()
1237
+
1238
+ const completion = new Promise<void>((resolve, reject) => {
1239
+ resolveCompletion = resolve
1240
+ rejectCompletion = (caughtError: Error) => reject(caughtError)
1241
+ })
1242
+
1243
+ const liveStream = {
1244
+ threadId,
1245
+ turnId: null as string | null,
1246
+ bufferedNotifications: buffered,
1247
+ observedSubagentThreadIds: new Set<string>(),
1248
+ unsubscribe: null as (() => void) | null
1249
+ }
1250
+
1251
+ unsubscribe = client.subscribe((notification) => {
1252
+ const targetThreadId = notificationThreadId(notification)
1253
+ if (!targetThreadId) {
1254
+ return
1255
+ }
1256
+
1257
+ if (targetThreadId !== threadId) {
1258
+ if (liveStream.observedSubagentThreadIds.has(targetThreadId)) {
1259
+ applySubagentNotification(targetThreadId, notification)
1260
+ }
1261
+ return
1262
+ }
1263
+
1264
+ if (!currentTurnId) {
1265
+ buffered.push(notification)
1266
+ return
1267
+ }
1268
+
1269
+ settleNotification(notification)
1270
+ })
1271
+ liveStream.unsubscribe = unsubscribe
1272
+ session.liveStream = liveStream
1273
+
1274
+ if (created && !routeThreadId.value) {
1275
+ pendingThreadId.value = threadId
1276
+ }
1277
+
1278
+ const turnStart = await client.request<TurnStartResponse>('turn/start', {
1279
+ threadId,
1280
+ input: [{
1281
+ type: 'text',
1282
+ text,
1283
+ text_elements: []
1284
+ }],
1285
+ cwd: selectedProject.value?.projectPath ?? null,
1286
+ approvalPolicy: 'never'
1287
+ })
1288
+
1289
+ currentTurnId = turnStart.turn.id
1290
+ liveStream.turnId = turnStart.turn.id
1291
+
1292
+ for (const notification of buffered.splice(0, buffered.length)) {
1293
+ if (settleNotification(notification) && completionResolved) {
1294
+ break
1295
+ }
1296
+ }
1297
+
1298
+ await completion
1299
+ status.value = 'ready'
1300
+
1301
+ if (routeThreadId.value) {
1302
+ await hydrateThread(threadId)
1303
+ return
1304
+ }
1305
+ } catch (caughtError) {
1306
+ unsubscribe?.()
1307
+ if (session.liveStream?.unsubscribe === unsubscribe) {
1308
+ session.liveStream = null
1309
+ }
1310
+ error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
1311
+ status.value = 'error'
1312
+ }
1313
+ }
1314
+
1315
+ const sendStarterPrompt = async (text: string) => {
1316
+ if (isBusy.value) {
1317
+ return
1318
+ }
1319
+
1320
+ input.value = text
1321
+ await sendMessage()
1322
+ }
1323
+
1324
+ const onPromptEnter = (event: KeyboardEvent) => {
1325
+ if (!shouldSubmit(event)) {
1326
+ return
1327
+ }
1328
+
1329
+ event.preventDefault()
1330
+ void sendMessage()
1331
+ }
1332
+
1333
+ onMounted(() => {
1334
+ if (!loaded.value) {
1335
+ void refreshProjects()
1336
+ }
1337
+
1338
+ void scheduleScrollToBottom('auto')
1339
+ })
1340
+
1341
+ watch(() => props.threadId ?? null, (threadId) => {
1342
+ if (!threadId) {
1343
+ if (isBusy.value || pendingThreadId.value) {
1344
+ return
1345
+ }
1346
+
1347
+ resetDraftThread()
1348
+ return
1349
+ }
1350
+
1351
+ if (
1352
+ autoRedirectThreadId.value === threadId
1353
+ && activeThreadId.value === threadId
1354
+ && isBusy.value
1355
+ ) {
1356
+ autoRedirectThreadId.value = null
1357
+ pendingThreadId.value = null
1358
+ return
1359
+ }
1360
+
1361
+ void hydrateThread(threadId)
1362
+ }, { immediate: true })
1363
+
1364
+ watch(pendingThreadId, async (threadId) => {
1365
+ if (!threadId) {
1366
+ return
1367
+ }
1368
+
1369
+ if (routeThreadId.value === threadId) {
1370
+ pendingThreadId.value = null
1371
+ autoRedirectThreadId.value = null
1372
+ return
1373
+ }
1374
+
1375
+ autoRedirectThreadId.value = threadId
1376
+ await router.push(toProjectThreadRoute(props.projectId, threadId))
1377
+ pendingThreadId.value = null
1378
+ })
1379
+
1380
+ watch(messages, () => {
1381
+ void scheduleScrollToBottom(status.value === 'streaming' ? 'auto' : 'smooth')
1382
+ }, { flush: 'post' })
1383
+
1384
+ watch(status, (nextStatus, previousStatus) => {
1385
+ if (nextStatus === previousStatus) {
1386
+ return
1387
+ }
1388
+
1389
+ void scheduleScrollToBottom(nextStatus === 'streaming' ? 'auto' : 'smooth')
1390
+ }, { flush: 'post' })
1391
+ </script>
1392
+
1393
+ <template>
1394
+ <section class="flex h-full min-h-0 flex-col bg-default">
1395
+ <div
1396
+ ref="scrollViewport"
1397
+ class="min-h-0 flex-1 overflow-y-auto"
1398
+ @scroll="updatePinnedState"
1399
+ >
1400
+ <div
1401
+ v-if="showWelcomeState"
1402
+ class="flex min-h-full items-center justify-center px-6 py-10"
1403
+ >
1404
+ <div class="flex w-full max-w-4xl flex-col items-center gap-10 text-center">
1405
+ <div class="space-y-4">
1406
+ <div class="text-xs font-medium uppercase tracking-[0.28em] text-primary">
1407
+ Ready To Code
1408
+ </div>
1409
+ <div class="space-y-2">
1410
+ <h1 class="text-balance text-4xl font-semibold tracking-tight text-highlighted md:text-5xl">
1411
+ Let's build
1412
+ </h1>
1413
+ <p class="text-balance text-3xl font-medium tracking-tight text-toned md:text-4xl">
1414
+ {{ projectTitle }}
1415
+ </p>
1416
+ </div>
1417
+ <p class="mx-auto max-w-2xl text-base leading-7 text-muted md:text-lg">
1418
+ Start with a goal, a bug, or a question. Codori will start the runtime when needed and keep the thread ready to continue.
1419
+ </p>
1420
+ </div>
1421
+
1422
+ <div class="grid w-full gap-3 md:grid-cols-3">
1423
+ <button
1424
+ v-for="prompt in starterPrompts"
1425
+ :key="prompt.title"
1426
+ type="button"
1427
+ class="rounded-3xl border border-default/70 bg-elevated/25 px-5 py-5 text-left transition hover:border-primary/30 hover:bg-elevated/45"
1428
+ @click="sendStarterPrompt(prompt.text)"
1429
+ >
1430
+ <div class="text-sm font-semibold text-highlighted">
1431
+ {{ prompt.title }}
1432
+ </div>
1433
+ <p class="mt-3 text-sm leading-6 text-muted">
1434
+ {{ prompt.text }}
1435
+ </p>
1436
+ </button>
1437
+ </div>
1438
+ </div>
1439
+ </div>
1440
+
1441
+ <UChatMessages
1442
+ v-else
1443
+ :messages="messages"
1444
+ :status="status"
1445
+ :should-auto-scroll="false"
1446
+ :should-scroll-to-bottom="false"
1447
+ :auto-scroll="false"
1448
+ :spacing-offset="140"
1449
+ :user="{
1450
+ ui: {
1451
+ root: 'scroll-mt-4',
1452
+ container: 'gap-3 pb-8',
1453
+ content: 'px-4 py-3 rounded-2xl min-h-12'
1454
+ }
1455
+ }"
1456
+ :ui="{
1457
+ root: 'min-h-full px-4 py-5 md:px-6',
1458
+ message: 'max-w-none',
1459
+ content: 'w-full max-w-5xl'
1460
+ }"
1461
+ compact
1462
+ >
1463
+ <template #content="{ message }">
1464
+ <MessageContent :message="message as ChatMessage" />
1465
+ </template>
1466
+ </UChatMessages>
1467
+ </div>
1468
+
1469
+ <div class="sticky bottom-0 shrink-0 border-t border-default bg-default/95 px-4 py-3 backdrop-blur md:px-6">
1470
+ <div class="mx-auto w-full max-w-5xl">
1471
+ <TunnelNotice class="mb-3" />
1472
+ <UAlert
1473
+ v-if="error"
1474
+ color="error"
1475
+ variant="soft"
1476
+ icon="i-lucide-circle-alert"
1477
+ :title="error"
1478
+ class="mb-3"
1479
+ />
1480
+
1481
+ <UChatPrompt
1482
+ v-model="input"
1483
+ placeholder="Describe the change you want Codex to make"
1484
+ :error="submitError"
1485
+ :disabled="isBusy"
1486
+ autoresize
1487
+ @submit.prevent="sendMessage"
1488
+ @keydown.enter="onPromptEnter"
1489
+ @compositionstart="onCompositionStart"
1490
+ @compositionend="onCompositionEnd"
1491
+ >
1492
+ <UChatPromptSubmit
1493
+ :status="status"
1494
+ />
1495
+ </UChatPrompt>
1496
+ </div>
1497
+ </div>
1498
+ </section>
1499
+ </template>