@codori/client 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/ChatWorkspace.vue +905 -183
- package/app/components/MessageContent.vue +2 -0
- package/app/components/MessagePartRenderer.ts +10 -0
- package/app/components/SubagentDrawerList.vue +64 -0
- package/app/components/SubagentTranscriptPanel.vue +305 -0
- package/app/components/ThreadPanel.vue +64 -45
- package/app/components/VisualSubagentStack.vue +13 -243
- package/app/components/message-part/Attachment.vue +61 -0
- package/app/composables/useChatAttachments.ts +208 -0
- package/app/composables/useChatSession.ts +31 -0
- package/app/composables/useProjects.ts +42 -0
- package/app/layouts/default.vue +56 -5
- package/app/pages/index.vue +0 -1
- package/app/pages/projects/[...projectId]/index.vue +2 -2
- package/app/pages/projects/[...projectId]/threads/[threadId].vue +223 -70
- package/app/utils/chat-turn-engagement.ts +46 -0
- package/package.json +1 -1
- package/server/api/codori/projects/[projectId]/attachments/file.get.ts +62 -0
- package/server/api/codori/projects/[projectId]/attachments.post.ts +53 -0
- package/server/api/codori/service/update.get.ts +7 -0
- package/server/api/codori/service/update.post.ts +9 -0
- package/server/utils/server-proxy.ts +23 -0
- package/shared/chat-attachments.ts +135 -0
- package/shared/chat-prompt-controls.ts +339 -0
- package/shared/codex-chat.ts +79 -14
- package/shared/codex-rpc.ts +19 -0
- package/shared/codori.ts +12 -0
- package/shared/subagent-panels.ts +158 -0
- package/app/components/TunnelNotice.vue +0 -27
|
@@ -2,15 +2,27 @@
|
|
|
2
2
|
import { useRouter } from '#imports'
|
|
3
3
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
4
4
|
import MessageContent from './MessageContent.vue'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
reconcileOptimisticUserMessage,
|
|
7
|
+
removeChatMessage,
|
|
8
|
+
removePendingUserMessageId,
|
|
9
|
+
resolvePromptSubmitStatus,
|
|
10
|
+
resolveTurnSubmissionMethod,
|
|
11
|
+
shouldIgnoreNotificationAfterInterrupt
|
|
12
|
+
} from '../utils/chat-turn-engagement'
|
|
13
|
+
import { useChatAttachments, type DraftAttachment } from '../composables/useChatAttachments'
|
|
14
|
+
import { useChatSession, type LiveStream, type SubagentPanelState } from '../composables/useChatSession'
|
|
6
15
|
import { useProjects } from '../composables/useProjects'
|
|
7
16
|
import { useRpc } from '../composables/useRpc'
|
|
8
17
|
import { useChatSubmitGuard } from '../composables/useChatSubmitGuard'
|
|
9
18
|
import {
|
|
19
|
+
hideThinkingPlaceholder,
|
|
10
20
|
ITEM_PART,
|
|
11
21
|
eventToMessage,
|
|
12
22
|
isSubagentActiveStatus,
|
|
13
23
|
itemToMessages,
|
|
24
|
+
replaceStreamingMessage,
|
|
25
|
+
showThinkingPlaceholder,
|
|
14
26
|
threadToMessages,
|
|
15
27
|
upsertStreamingMessage,
|
|
16
28
|
type ChatMessage,
|
|
@@ -19,7 +31,10 @@ import {
|
|
|
19
31
|
type ItemData,
|
|
20
32
|
type McpToolCallItem
|
|
21
33
|
} from '~~/shared/codex-chat'
|
|
34
|
+
import { buildTurnStartInput } from '~~/shared/chat-attachments'
|
|
22
35
|
import {
|
|
36
|
+
type ConfigReadResponse,
|
|
37
|
+
type ModelListResponse,
|
|
23
38
|
notificationThreadId,
|
|
24
39
|
notificationTurnId,
|
|
25
40
|
type CodexRpcNotification,
|
|
@@ -30,6 +45,21 @@ import {
|
|
|
30
45
|
type ThreadStartResponse,
|
|
31
46
|
type TurnStartResponse
|
|
32
47
|
} from '~~/shared/codex-rpc'
|
|
48
|
+
import {
|
|
49
|
+
buildTurnOverrides,
|
|
50
|
+
coercePromptSelection,
|
|
51
|
+
ensureModelOption,
|
|
52
|
+
FALLBACK_MODELS,
|
|
53
|
+
formatCompactTokenCount,
|
|
54
|
+
formatReasoningEffortLabel,
|
|
55
|
+
normalizeConfigDefaults,
|
|
56
|
+
normalizeModelList,
|
|
57
|
+
normalizeThreadTokenUsage,
|
|
58
|
+
resolveContextWindowState,
|
|
59
|
+
resolveEffortOptions,
|
|
60
|
+
visibleModelOptions,
|
|
61
|
+
type ReasoningEffort
|
|
62
|
+
} from '~~/shared/chat-prompt-controls'
|
|
33
63
|
import { toProjectThreadRoute } from '~~/shared/codori'
|
|
34
64
|
|
|
35
65
|
const props = defineProps<{
|
|
@@ -50,6 +80,25 @@ const {
|
|
|
50
80
|
onCompositionEnd,
|
|
51
81
|
shouldSubmit
|
|
52
82
|
} = useChatSubmitGuard()
|
|
83
|
+
const {
|
|
84
|
+
attachments,
|
|
85
|
+
isDragging,
|
|
86
|
+
isUploading,
|
|
87
|
+
error: attachmentError,
|
|
88
|
+
fileInput,
|
|
89
|
+
removeAttachment,
|
|
90
|
+
replaceAttachments,
|
|
91
|
+
clearAttachments,
|
|
92
|
+
discardSnapshot,
|
|
93
|
+
openFilePicker,
|
|
94
|
+
onFileInputChange,
|
|
95
|
+
onPaste,
|
|
96
|
+
onDragEnter,
|
|
97
|
+
onDragLeave,
|
|
98
|
+
onDragOver,
|
|
99
|
+
onDrop,
|
|
100
|
+
uploadAttachments
|
|
101
|
+
} = useChatAttachments(props.projectId)
|
|
53
102
|
|
|
54
103
|
const input = ref('')
|
|
55
104
|
const scrollViewport = ref<HTMLElement | null>(null)
|
|
@@ -64,12 +113,39 @@ const {
|
|
|
64
113
|
threadTitle,
|
|
65
114
|
pendingThreadId,
|
|
66
115
|
autoRedirectThreadId,
|
|
67
|
-
loadVersion
|
|
116
|
+
loadVersion,
|
|
117
|
+
promptControlsLoaded,
|
|
118
|
+
promptControlsLoading,
|
|
119
|
+
availableModels,
|
|
120
|
+
selectedModel,
|
|
121
|
+
selectedEffort,
|
|
122
|
+
modelContextWindow,
|
|
123
|
+
tokenUsage
|
|
68
124
|
} = session
|
|
69
125
|
|
|
70
126
|
const selectedProject = computed(() => getProject(props.projectId))
|
|
71
|
-
const
|
|
72
|
-
const
|
|
127
|
+
const composerError = computed(() => attachmentError.value ?? error.value)
|
|
128
|
+
const submitError = computed(() => composerError.value ? new Error(composerError.value) : undefined)
|
|
129
|
+
const interruptRequested = ref(false)
|
|
130
|
+
const isBusy = computed(() =>
|
|
131
|
+
status.value === 'submitted'
|
|
132
|
+
|| status.value === 'streaming'
|
|
133
|
+
|| isUploading.value
|
|
134
|
+
)
|
|
135
|
+
const hasDraftContent = computed(() =>
|
|
136
|
+
input.value.trim().length > 0
|
|
137
|
+
|| attachments.value.length > 0
|
|
138
|
+
)
|
|
139
|
+
const isComposerDisabled = computed(() =>
|
|
140
|
+
isUploading.value
|
|
141
|
+
|| interruptRequested.value
|
|
142
|
+
)
|
|
143
|
+
const promptSubmitStatus = computed(() =>
|
|
144
|
+
resolvePromptSubmitStatus({
|
|
145
|
+
status: status.value,
|
|
146
|
+
hasDraftContent: hasDraftContent.value
|
|
147
|
+
})
|
|
148
|
+
)
|
|
73
149
|
const routeThreadId = computed(() => props.threadId ?? null)
|
|
74
150
|
const projectTitle = computed(() => selectedProject.value?.projectId ?? props.projectId)
|
|
75
151
|
const showWelcomeState = computed(() =>
|
|
@@ -98,7 +174,134 @@ const starterPrompts = computed(() => {
|
|
|
98
174
|
]
|
|
99
175
|
})
|
|
100
176
|
|
|
177
|
+
const hasKnownThreadUsage = computed(() => !activeThreadId.value || tokenUsage.value !== null)
|
|
178
|
+
const effectiveModelList = computed(() => {
|
|
179
|
+
const withSelected = ensureModelOption(
|
|
180
|
+
availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS,
|
|
181
|
+
selectedModel.value,
|
|
182
|
+
selectedEffort.value
|
|
183
|
+
)
|
|
184
|
+
return visibleModelOptions(withSelected)
|
|
185
|
+
})
|
|
186
|
+
const selectedModelOption = computed(() =>
|
|
187
|
+
effectiveModelList.value.find(model => model.model === selectedModel.value)
|
|
188
|
+
?? effectiveModelList.value[0]
|
|
189
|
+
?? FALLBACK_MODELS[0]
|
|
190
|
+
)
|
|
191
|
+
const modelSelectItems = computed(() =>
|
|
192
|
+
effectiveModelList.value.map(model => ({
|
|
193
|
+
label: model.displayName,
|
|
194
|
+
value: model.model
|
|
195
|
+
}))
|
|
196
|
+
)
|
|
197
|
+
const effortOptions = computed(() => resolveEffortOptions(effectiveModelList.value, selectedModel.value))
|
|
198
|
+
const effortSelectItems = computed(() =>
|
|
199
|
+
effortOptions.value.map(effort => ({
|
|
200
|
+
label: formatReasoningEffortLabel(effort),
|
|
201
|
+
value: effort
|
|
202
|
+
}))
|
|
203
|
+
)
|
|
204
|
+
const contextWindowState = computed(() =>
|
|
205
|
+
resolveContextWindowState(tokenUsage.value, modelContextWindow.value, hasKnownThreadUsage.value)
|
|
206
|
+
)
|
|
207
|
+
const contextUsedPercent = computed(() => contextWindowState.value.usedPercent ?? 0)
|
|
208
|
+
const contextIndicatorLabel = computed(() => {
|
|
209
|
+
const remainingPercent = contextWindowState.value.remainingPercent
|
|
210
|
+
return remainingPercent == null ? 'ctx' : `${Math.round(remainingPercent)}%`
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const normalizePromptSelection = (
|
|
214
|
+
preferredModel?: string | null,
|
|
215
|
+
preferredEffort?: ReasoningEffort | null
|
|
216
|
+
) => {
|
|
217
|
+
const withSelected = ensureModelOption(
|
|
218
|
+
availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS,
|
|
219
|
+
preferredModel ?? selectedModel.value,
|
|
220
|
+
preferredEffort ?? selectedEffort.value
|
|
221
|
+
)
|
|
222
|
+
const visibleModels = visibleModelOptions(withSelected)
|
|
223
|
+
const nextSelection = coercePromptSelection(visibleModels, preferredModel ?? selectedModel.value, preferredEffort ?? selectedEffort.value)
|
|
224
|
+
|
|
225
|
+
availableModels.value = visibleModels
|
|
226
|
+
if (selectedModel.value !== nextSelection.model) {
|
|
227
|
+
selectedModel.value = nextSelection.model
|
|
228
|
+
}
|
|
229
|
+
if (selectedEffort.value !== nextSelection.effort) {
|
|
230
|
+
selectedEffort.value = nextSelection.effort
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const syncPromptSelectionFromThread = (
|
|
235
|
+
model: string | null | undefined,
|
|
236
|
+
effort: ReasoningEffort | null | undefined
|
|
237
|
+
) => {
|
|
238
|
+
normalizePromptSelection(model ?? null, effort ?? null)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const loadPromptControls = async () => {
|
|
242
|
+
if (promptControlsLoaded.value) {
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (promptControlsPromise) {
|
|
247
|
+
return await promptControlsPromise
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
promptControlsPromise = (async () => {
|
|
251
|
+
promptControlsLoading.value = true
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
await ensureProjectRuntime()
|
|
255
|
+
const client = getClient(props.projectId)
|
|
256
|
+
const initialModel = selectedModel.value
|
|
257
|
+
const initialEffort = selectedEffort.value
|
|
258
|
+
let nextModels = availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS
|
|
259
|
+
let defaultModel: string | null = null
|
|
260
|
+
let defaultEffort: ReasoningEffort | null = null
|
|
261
|
+
|
|
262
|
+
const [modelsResponse, configResponse] = await Promise.allSettled([
|
|
263
|
+
client.request<ModelListResponse>('model/list'),
|
|
264
|
+
client.request<ConfigReadResponse>('config/read')
|
|
265
|
+
])
|
|
266
|
+
|
|
267
|
+
if (modelsResponse.status === 'fulfilled') {
|
|
268
|
+
nextModels = visibleModelOptions(normalizeModelList(modelsResponse.value))
|
|
269
|
+
} else {
|
|
270
|
+
nextModels = visibleModelOptions(nextModels)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (configResponse.status === 'fulfilled') {
|
|
274
|
+
const defaults = normalizeConfigDefaults(configResponse.value)
|
|
275
|
+
if (defaults.contextWindow != null) {
|
|
276
|
+
modelContextWindow.value = defaults.contextWindow
|
|
277
|
+
}
|
|
278
|
+
defaultModel = defaults.model
|
|
279
|
+
defaultEffort = defaults.effort
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
availableModels.value = nextModels
|
|
283
|
+
|
|
284
|
+
const preferredModel = selectedModel.value !== initialModel
|
|
285
|
+
? selectedModel.value
|
|
286
|
+
: defaultModel ?? initialModel
|
|
287
|
+
const preferredEffort = selectedEffort.value !== initialEffort
|
|
288
|
+
? selectedEffort.value
|
|
289
|
+
: defaultEffort ?? initialEffort
|
|
290
|
+
|
|
291
|
+
normalizePromptSelection(preferredModel, preferredEffort)
|
|
292
|
+
promptControlsLoaded.value = true
|
|
293
|
+
} finally {
|
|
294
|
+
promptControlsLoading.value = false
|
|
295
|
+
promptControlsPromise = null
|
|
296
|
+
}
|
|
297
|
+
})()
|
|
298
|
+
|
|
299
|
+
return await promptControlsPromise
|
|
300
|
+
}
|
|
301
|
+
|
|
101
302
|
const subagentBootstrapPromises = new Map<string, Promise<void>>()
|
|
303
|
+
const optimisticAttachmentSnapshots = new Map<string, DraftAttachment[]>()
|
|
304
|
+
let promptControlsPromise: Promise<void> | null = null
|
|
102
305
|
|
|
103
306
|
const isActiveTurnStatus = (value: string | null | undefined) => {
|
|
104
307
|
if (!value) {
|
|
@@ -113,13 +316,263 @@ const currentLiveStream = () =>
|
|
|
113
316
|
? session.liveStream
|
|
114
317
|
: null
|
|
115
318
|
|
|
116
|
-
const
|
|
117
|
-
session.
|
|
118
|
-
|
|
319
|
+
const hasActiveTurnEngagement = () =>
|
|
320
|
+
Boolean(currentLiveStream() || session.pendingLiveStream)
|
|
321
|
+
|
|
322
|
+
const rejectLiveStreamTurnWaiters = (liveStream: LiveStream, error: Error) => {
|
|
323
|
+
const waiters = liveStream.turnIdWaiters.splice(0, liveStream.turnIdWaiters.length)
|
|
324
|
+
for (const waiter of waiters) {
|
|
325
|
+
waiter.reject(error)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const createLiveStreamState = (
|
|
330
|
+
threadId: string,
|
|
331
|
+
bufferedNotifications: CodexRpcNotification[] = []
|
|
332
|
+
): LiveStream => ({
|
|
333
|
+
threadId,
|
|
334
|
+
turnId: null,
|
|
335
|
+
bufferedNotifications,
|
|
336
|
+
observedSubagentThreadIds: new Set<string>(),
|
|
337
|
+
pendingUserMessageIds: [],
|
|
338
|
+
turnIdWaiters: [],
|
|
339
|
+
interruptRequested: false,
|
|
340
|
+
interruptAcknowledged: false,
|
|
341
|
+
unsubscribe: null
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
const setSessionLiveStream = (liveStream: LiveStream | null) => {
|
|
345
|
+
session.liveStream = liveStream
|
|
346
|
+
interruptRequested.value = liveStream?.interruptRequested === true
|
|
119
347
|
}
|
|
120
348
|
|
|
121
|
-
const
|
|
122
|
-
|
|
349
|
+
const setLiveStreamInterruptRequested = (liveStream: LiveStream, nextValue: boolean) => {
|
|
350
|
+
liveStream.interruptRequested = nextValue
|
|
351
|
+
|
|
352
|
+
if (session.liveStream === liveStream) {
|
|
353
|
+
interruptRequested.value = nextValue
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const setLiveStreamTurnId = (liveStream: LiveStream, turnId: string | null) => {
|
|
358
|
+
liveStream.turnId = turnId
|
|
359
|
+
|
|
360
|
+
if (!turnId) {
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
liveStream.interruptAcknowledged = false
|
|
365
|
+
const waiters = liveStream.turnIdWaiters.splice(0, liveStream.turnIdWaiters.length)
|
|
366
|
+
for (const waiter of waiters) {
|
|
367
|
+
waiter.resolve(turnId)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const waitForLiveStreamTurnId = async (liveStream: LiveStream) => {
|
|
372
|
+
if (liveStream.turnId) {
|
|
373
|
+
return liveStream.turnId
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return await new Promise<string>((resolve, reject) => {
|
|
377
|
+
liveStream.turnIdWaiters.push({ resolve, reject })
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const clearLiveStream = (reason?: Error) => {
|
|
382
|
+
const liveStream = session.liveStream
|
|
383
|
+
if (!liveStream) {
|
|
384
|
+
interruptRequested.value = false
|
|
385
|
+
return null
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
liveStream.unsubscribe?.()
|
|
389
|
+
rejectLiveStreamTurnWaiters(liveStream, reason ?? new Error('The active turn is no longer available.'))
|
|
390
|
+
setSessionLiveStream(null)
|
|
391
|
+
return liveStream
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const ensurePendingLiveStream = async () => {
|
|
395
|
+
const existingLiveStream = currentLiveStream()
|
|
396
|
+
if (existingLiveStream) {
|
|
397
|
+
return existingLiveStream
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (session.pendingLiveStream) {
|
|
401
|
+
return await session.pendingLiveStream
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const pendingLiveStream = (async () => {
|
|
405
|
+
await ensureProjectRuntime()
|
|
406
|
+
const { threadId, created } = await ensureThread()
|
|
407
|
+
const client = getClient(props.projectId)
|
|
408
|
+
const buffered: CodexRpcNotification[] = []
|
|
409
|
+
clearLiveStream()
|
|
410
|
+
|
|
411
|
+
const liveStream = createLiveStreamState(threadId, buffered)
|
|
412
|
+
liveStream.unsubscribe = client.subscribe((notification) => {
|
|
413
|
+
const targetThreadId = notificationThreadId(notification)
|
|
414
|
+
if (!targetThreadId) {
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (targetThreadId !== threadId) {
|
|
419
|
+
if (liveStream.observedSubagentThreadIds.has(targetThreadId)) {
|
|
420
|
+
applySubagentNotification(targetThreadId, notification)
|
|
421
|
+
}
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!liveStream.turnId) {
|
|
426
|
+
buffered.push(notification)
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const turnId = notificationTurnId(notification)
|
|
431
|
+
if (turnId && turnId !== liveStream.turnId) {
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
applyNotification(notification)
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
setSessionLiveStream(liveStream)
|
|
439
|
+
|
|
440
|
+
if (created && !routeThreadId.value) {
|
|
441
|
+
pendingThreadId.value = threadId
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return liveStream
|
|
445
|
+
})()
|
|
446
|
+
|
|
447
|
+
session.pendingLiveStream = pendingLiveStream
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
return await pendingLiveStream
|
|
451
|
+
} finally {
|
|
452
|
+
if (session.pendingLiveStream === pendingLiveStream) {
|
|
453
|
+
session.pendingLiveStream = null
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const createOptimisticMessageId = () =>
|
|
459
|
+
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
460
|
+
? `local-user-${crypto.randomUUID()}`
|
|
461
|
+
: `local-user-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
462
|
+
|
|
463
|
+
const buildOptimisticMessage = (text: string, submittedAttachments: DraftAttachment[]): ChatMessage => ({
|
|
464
|
+
id: createOptimisticMessageId(),
|
|
465
|
+
role: 'user',
|
|
466
|
+
parts: [
|
|
467
|
+
...(text.trim()
|
|
468
|
+
? [{
|
|
469
|
+
type: 'text' as const,
|
|
470
|
+
text,
|
|
471
|
+
state: 'done' as const
|
|
472
|
+
}]
|
|
473
|
+
: []),
|
|
474
|
+
...submittedAttachments.map((attachment) => ({
|
|
475
|
+
type: 'attachment' as const,
|
|
476
|
+
attachment: {
|
|
477
|
+
kind: 'image' as const,
|
|
478
|
+
name: attachment.name,
|
|
479
|
+
mediaType: attachment.mediaType,
|
|
480
|
+
url: attachment.previewUrl
|
|
481
|
+
}
|
|
482
|
+
}))
|
|
483
|
+
]
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
const rememberOptimisticAttachments = (messageId: string, submittedAttachments: DraftAttachment[]) => {
|
|
487
|
+
if (submittedAttachments.length === 0) {
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
optimisticAttachmentSnapshots.set(messageId, submittedAttachments)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const formatAttachmentSize = (size: number) => {
|
|
495
|
+
if (size >= 1024 * 1024) {
|
|
496
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (size >= 1024) {
|
|
500
|
+
return `${Math.round(size / 1024)} KB`
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return `${size} B`
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const removeOptimisticMessage = (messageId: string) => {
|
|
507
|
+
messages.value = removeChatMessage(messages.value, messageId)
|
|
508
|
+
optimisticAttachmentSnapshots.delete(messageId)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const clearThinkingPlaceholder = () => {
|
|
512
|
+
messages.value = hideThinkingPlaceholder(messages.value)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const ensureThinkingPlaceholder = () => {
|
|
516
|
+
messages.value = showThinkingPlaceholder(messages.value)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const clearThinkingPlaceholderForVisibleItem = (item: CodexThreadItem) => {
|
|
520
|
+
if (item.type !== 'userMessage') {
|
|
521
|
+
clearThinkingPlaceholder()
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const restoreDraftIfPristine = (text: string, submittedAttachments: DraftAttachment[]) => {
|
|
526
|
+
if (!input.value.trim()) {
|
|
527
|
+
input.value = text
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (attachments.value.length === 0 && submittedAttachments.length > 0) {
|
|
531
|
+
replaceAttachments(submittedAttachments)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const untrackPendingUserMessage = (messageId: string) => {
|
|
536
|
+
const liveStream = currentLiveStream()
|
|
537
|
+
if (!liveStream) {
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
liveStream.pendingUserMessageIds = removePendingUserMessageId(liveStream.pendingUserMessageIds, messageId)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const reconcilePendingUserMessage = (confirmedMessage: ChatMessage) => {
|
|
545
|
+
const liveStream = currentLiveStream()
|
|
546
|
+
const optimisticMessageId = liveStream?.pendingUserMessageIds.shift() ?? null
|
|
547
|
+
if (!optimisticMessageId) {
|
|
548
|
+
messages.value = upsertStreamingMessage(messages.value, confirmedMessage)
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const pendingAttachments = optimisticAttachmentSnapshots.get(optimisticMessageId)
|
|
553
|
+
if (pendingAttachments) {
|
|
554
|
+
discardSnapshot(pendingAttachments)
|
|
555
|
+
optimisticAttachmentSnapshots.delete(optimisticMessageId)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
messages.value = reconcileOptimisticUserMessage(messages.value, optimisticMessageId, confirmedMessage)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const clearPendingOptimisticMessages = (liveStream: LiveStream | null, options?: { discardSnapshots?: boolean }) => {
|
|
562
|
+
if (!liveStream) {
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
for (const messageId of liveStream.pendingUserMessageIds) {
|
|
567
|
+
messages.value = removeChatMessage(messages.value, messageId)
|
|
568
|
+
const pendingAttachments = optimisticAttachmentSnapshots.get(messageId)
|
|
569
|
+
if (options?.discardSnapshots && pendingAttachments) {
|
|
570
|
+
discardSnapshot(pendingAttachments)
|
|
571
|
+
}
|
|
572
|
+
optimisticAttachmentSnapshots.delete(messageId)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
liveStream.pendingUserMessageIds = []
|
|
123
576
|
}
|
|
124
577
|
|
|
125
578
|
const resolveThreadTitle = (thread: { name: string | null, preview: string, id: string }) => {
|
|
@@ -249,22 +702,21 @@ const hydrateThread = async (threadId: string) => {
|
|
|
249
702
|
const requestVersion = loadVersion.value + 1
|
|
250
703
|
loadVersion.value = requestVersion
|
|
251
704
|
error.value = null
|
|
705
|
+
tokenUsage.value = null
|
|
252
706
|
|
|
253
707
|
try {
|
|
254
708
|
await ensureProjectRuntime()
|
|
255
709
|
const client = getClient(props.projectId)
|
|
256
710
|
activeThreadId.value = threadId
|
|
257
711
|
|
|
258
|
-
const existingLiveStream =
|
|
712
|
+
const existingLiveStream = session.liveStream
|
|
259
713
|
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
unsubscribe: null as (() => void) | null
|
|
267
|
-
}
|
|
714
|
+
if (existingLiveStream && existingLiveStream.threadId !== threadId) {
|
|
715
|
+
clearLiveStream()
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (!session.liveStream) {
|
|
719
|
+
const nextLiveStream = createLiveStreamState(threadId)
|
|
268
720
|
|
|
269
721
|
nextLiveStream.unsubscribe = client.subscribe((notification) => {
|
|
270
722
|
const targetThreadId = notificationThreadId(notification)
|
|
@@ -292,10 +744,10 @@ const hydrateThread = async (threadId: string) => {
|
|
|
292
744
|
applyNotification(notification)
|
|
293
745
|
})
|
|
294
746
|
|
|
295
|
-
|
|
747
|
+
setSessionLiveStream(nextLiveStream)
|
|
296
748
|
}
|
|
297
749
|
|
|
298
|
-
await client.request<ThreadResumeResponse>('thread/resume', {
|
|
750
|
+
const resumeResponse = await client.request<ThreadResumeResponse>('thread/resume', {
|
|
299
751
|
threadId,
|
|
300
752
|
cwd: selectedProject.value?.projectPath ?? null,
|
|
301
753
|
approvalPolicy: 'never',
|
|
@@ -310,6 +762,10 @@ const hydrateThread = async (threadId: string) => {
|
|
|
310
762
|
return
|
|
311
763
|
}
|
|
312
764
|
|
|
765
|
+
syncPromptSelectionFromThread(
|
|
766
|
+
resumeResponse.model ?? null,
|
|
767
|
+
(resumeResponse.reasoningEffort as ReasoningEffort | null | undefined) ?? null
|
|
768
|
+
)
|
|
313
769
|
activeThreadId.value = response.thread.id
|
|
314
770
|
threadTitle.value = resolveThreadTitle(response.thread)
|
|
315
771
|
messages.value = threadToMessages(response.thread)
|
|
@@ -327,7 +783,7 @@ const hydrateThread = async (threadId: string) => {
|
|
|
327
783
|
return
|
|
328
784
|
}
|
|
329
785
|
|
|
330
|
-
session.liveStream
|
|
786
|
+
setLiveStreamTurnId(session.liveStream, activeTurn.id)
|
|
331
787
|
status.value = 'streaming'
|
|
332
788
|
|
|
333
789
|
const pendingNotifications = session.liveStream.bufferedNotifications.splice(0, session.liveStream.bufferedNotifications.length)
|
|
@@ -348,6 +804,7 @@ const hydrateThread = async (threadId: string) => {
|
|
|
348
804
|
|
|
349
805
|
const resetDraftThread = () => {
|
|
350
806
|
clearLiveStream()
|
|
807
|
+
session.pendingLiveStream = null
|
|
351
808
|
activeThreadId.value = null
|
|
352
809
|
threadTitle.value = null
|
|
353
810
|
pendingThreadId.value = null
|
|
@@ -355,6 +812,7 @@ const resetDraftThread = () => {
|
|
|
355
812
|
messages.value = []
|
|
356
813
|
subagentPanels.value = []
|
|
357
814
|
error.value = null
|
|
815
|
+
tokenUsage.value = null
|
|
358
816
|
status.value = 'ready'
|
|
359
817
|
}
|
|
360
818
|
|
|
@@ -790,7 +1248,7 @@ const applySubagentNotification = (threadId: string, notification: CodexRpcNotif
|
|
|
790
1248
|
}
|
|
791
1249
|
for (const nextMessage of itemToMessages(params.item)) {
|
|
792
1250
|
updateSubagentPanelMessages(threadId, (panelMessages) =>
|
|
793
|
-
|
|
1251
|
+
replaceStreamingMessage(panelMessages, {
|
|
794
1252
|
...nextMessage,
|
|
795
1253
|
pending: false
|
|
796
1254
|
})
|
|
@@ -947,6 +1405,11 @@ const applySubagentNotification = (threadId: string, notification: CodexRpcNotif
|
|
|
947
1405
|
}
|
|
948
1406
|
|
|
949
1407
|
const applyNotification = (notification: CodexRpcNotification) => {
|
|
1408
|
+
const liveStream = currentLiveStream()
|
|
1409
|
+
if (liveStream?.interruptAcknowledged && shouldIgnoreNotificationAfterInterrupt(notification.method)) {
|
|
1410
|
+
return
|
|
1411
|
+
}
|
|
1412
|
+
|
|
950
1413
|
switch (notification.method) {
|
|
951
1414
|
case 'thread/started': {
|
|
952
1415
|
const nextThreadId = notificationThreadId(notification)
|
|
@@ -959,6 +1422,10 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
959
1422
|
return
|
|
960
1423
|
}
|
|
961
1424
|
case 'turn/started': {
|
|
1425
|
+
if (liveStream) {
|
|
1426
|
+
setLiveStreamTurnId(liveStream, notificationTurnId(notification))
|
|
1427
|
+
setLiveStreamInterruptRequested(liveStream, false)
|
|
1428
|
+
}
|
|
962
1429
|
messages.value = upsertStreamingMessage(
|
|
963
1430
|
messages.value,
|
|
964
1431
|
eventToMessage(`event-turn-started-${notificationTurnId(notification) ?? Date.now()}`, {
|
|
@@ -973,6 +1440,17 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
973
1440
|
if (params.item.type === 'collabAgentToolCall') {
|
|
974
1441
|
applySubagentActivityItem(params.item)
|
|
975
1442
|
}
|
|
1443
|
+
if (params.item.type === 'userMessage') {
|
|
1444
|
+
for (const nextMessage of itemToMessages(params.item)) {
|
|
1445
|
+
reconcilePendingUserMessage({
|
|
1446
|
+
...nextMessage,
|
|
1447
|
+
pending: false
|
|
1448
|
+
})
|
|
1449
|
+
}
|
|
1450
|
+
status.value = 'streaming'
|
|
1451
|
+
return
|
|
1452
|
+
}
|
|
1453
|
+
clearThinkingPlaceholderForVisibleItem(params.item)
|
|
976
1454
|
seedStreamingMessage(params.item)
|
|
977
1455
|
status.value = 'streaming'
|
|
978
1456
|
return
|
|
@@ -982,19 +1460,24 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
982
1460
|
if (params.item.type === 'collabAgentToolCall') {
|
|
983
1461
|
applySubagentActivityItem(params.item)
|
|
984
1462
|
}
|
|
985
|
-
|
|
986
|
-
stripOptimisticDraftMessages()
|
|
987
|
-
}
|
|
1463
|
+
clearThinkingPlaceholderForVisibleItem(params.item)
|
|
988
1464
|
for (const nextMessage of itemToMessages(params.item)) {
|
|
989
|
-
|
|
1465
|
+
const confirmedMessage = {
|
|
990
1466
|
...nextMessage,
|
|
991
1467
|
pending: false
|
|
992
|
-
}
|
|
1468
|
+
}
|
|
1469
|
+
if (params.item.type === 'userMessage') {
|
|
1470
|
+
reconcilePendingUserMessage(confirmedMessage)
|
|
1471
|
+
continue
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
messages.value = replaceStreamingMessage(messages.value, confirmedMessage)
|
|
993
1475
|
}
|
|
994
1476
|
return
|
|
995
1477
|
}
|
|
996
1478
|
case 'item/agentMessage/delta': {
|
|
997
1479
|
const params = notification.params as { itemId: string, delta: string }
|
|
1480
|
+
clearThinkingPlaceholder()
|
|
998
1481
|
appendTextPartDelta(params.itemId, params.delta, {
|
|
999
1482
|
id: params.itemId,
|
|
1000
1483
|
role: 'assistant',
|
|
@@ -1010,6 +1493,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1010
1493
|
}
|
|
1011
1494
|
case 'item/plan/delta': {
|
|
1012
1495
|
const params = notification.params as { itemId: string, delta: string }
|
|
1496
|
+
clearThinkingPlaceholder()
|
|
1013
1497
|
appendTextPartDelta(params.itemId, params.delta, {
|
|
1014
1498
|
id: params.itemId,
|
|
1015
1499
|
role: 'assistant',
|
|
@@ -1026,6 +1510,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1026
1510
|
case 'item/reasoning/textDelta':
|
|
1027
1511
|
case 'item/reasoning/summaryTextDelta': {
|
|
1028
1512
|
const params = notification.params as { itemId: string, delta: string }
|
|
1513
|
+
clearThinkingPlaceholder()
|
|
1029
1514
|
updateMessage(params.itemId, {
|
|
1030
1515
|
id: params.itemId,
|
|
1031
1516
|
role: 'assistant',
|
|
@@ -1120,11 +1605,34 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1120
1605
|
status.value = 'streaming'
|
|
1121
1606
|
return
|
|
1122
1607
|
}
|
|
1608
|
+
case 'thread/tokenUsage/updated': {
|
|
1609
|
+
const nextUsage = normalizeThreadTokenUsage(notification.params)
|
|
1610
|
+
if (nextUsage) {
|
|
1611
|
+
tokenUsage.value = nextUsage
|
|
1612
|
+
if (nextUsage.modelContextWindow != null) {
|
|
1613
|
+
modelContextWindow.value = nextUsage.modelContextWindow
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
return
|
|
1617
|
+
}
|
|
1618
|
+
case 'error': {
|
|
1619
|
+
const params = notification.params as { error?: { message?: string } }
|
|
1620
|
+
const messageText = params.error?.message ?? 'The stream failed.'
|
|
1621
|
+
clearThinkingPlaceholder()
|
|
1622
|
+
pushEventMessage('stream.error', messageText)
|
|
1623
|
+
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1624
|
+
clearLiveStream(new Error(messageText))
|
|
1625
|
+
error.value = messageText
|
|
1626
|
+
status.value = 'error'
|
|
1627
|
+
return
|
|
1628
|
+
}
|
|
1123
1629
|
case 'turn/failed': {
|
|
1124
1630
|
const params = notification.params as { error?: { message?: string } }
|
|
1125
1631
|
const messageText = params.error?.message ?? 'The turn failed.'
|
|
1632
|
+
clearThinkingPlaceholder()
|
|
1126
1633
|
pushEventMessage('turn.failed', messageText)
|
|
1127
|
-
|
|
1634
|
+
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1635
|
+
clearLiveStream(new Error(messageText))
|
|
1128
1636
|
error.value = messageText
|
|
1129
1637
|
status.value = 'error'
|
|
1130
1638
|
return
|
|
@@ -1132,13 +1640,18 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1132
1640
|
case 'stream/error': {
|
|
1133
1641
|
const params = notification.params as { message?: string }
|
|
1134
1642
|
const messageText = params.message ?? 'The stream failed.'
|
|
1643
|
+
clearThinkingPlaceholder()
|
|
1135
1644
|
pushEventMessage('stream.error', messageText)
|
|
1136
|
-
|
|
1645
|
+
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1646
|
+
clearLiveStream(new Error(messageText))
|
|
1137
1647
|
error.value = messageText
|
|
1138
1648
|
status.value = 'error'
|
|
1139
1649
|
return
|
|
1140
1650
|
}
|
|
1141
1651
|
case 'turn/completed': {
|
|
1652
|
+
clearThinkingPlaceholder()
|
|
1653
|
+
clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
|
|
1654
|
+
error.value = null
|
|
1142
1655
|
status.value = 'ready'
|
|
1143
1656
|
clearLiveStream()
|
|
1144
1657
|
return
|
|
@@ -1150,165 +1663,128 @@ const applyNotification = (notification: CodexRpcNotification) => {
|
|
|
1150
1663
|
|
|
1151
1664
|
const sendMessage = async () => {
|
|
1152
1665
|
const text = input.value.trim()
|
|
1153
|
-
|
|
1666
|
+
const submittedAttachments = attachments.value.slice()
|
|
1667
|
+
|
|
1668
|
+
if (!text && submittedAttachments.length === 0) {
|
|
1669
|
+
return
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
try {
|
|
1673
|
+
await loadPromptControls()
|
|
1674
|
+
} catch (caughtError) {
|
|
1675
|
+
error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1676
|
+
status.value = 'error'
|
|
1154
1677
|
return
|
|
1155
1678
|
}
|
|
1156
1679
|
|
|
1157
1680
|
pinnedToBottom.value = true
|
|
1158
1681
|
error.value = null
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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]
|
|
1682
|
+
attachmentError.value = null
|
|
1683
|
+
const submissionMethod = resolveTurnSubmissionMethod(hasActiveTurnEngagement())
|
|
1684
|
+
if (submissionMethod === 'turn/start') {
|
|
1685
|
+
status.value = 'submitted'
|
|
1175
1686
|
}
|
|
1176
|
-
|
|
1687
|
+
input.value = ''
|
|
1688
|
+
clearAttachments({ revoke: false })
|
|
1689
|
+
const optimisticMessage = buildOptimisticMessage(text, submittedAttachments)
|
|
1690
|
+
const optimisticMessageId = optimisticMessage.id
|
|
1691
|
+
rememberOptimisticAttachments(optimisticMessageId, submittedAttachments)
|
|
1692
|
+
messages.value = [...messages.value, optimisticMessage]
|
|
1693
|
+
ensureThinkingPlaceholder()
|
|
1694
|
+
let startedLiveStream: LiveStream | null = null
|
|
1177
1695
|
|
|
1178
1696
|
try {
|
|
1179
|
-
await ensureProjectRuntime()
|
|
1180
|
-
const { threadId, created } = await ensureThread()
|
|
1181
1697
|
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
1698
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1699
|
+
if (submissionMethod === 'turn/steer') {
|
|
1700
|
+
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
|
+
})
|
|
1711
|
+
return
|
|
1234
1712
|
}
|
|
1235
1713
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
})
|
|
1714
|
+
const liveStream = await ensurePendingLiveStream()
|
|
1715
|
+
startedLiveStream = liveStream
|
|
1716
|
+
const threadId = liveStream.threadId
|
|
1717
|
+
const uploadedAttachments = await uploadAttachments(threadId, submittedAttachments)
|
|
1718
|
+
liveStream.pendingUserMessageIds.push(optimisticMessageId)
|
|
1242
1719
|
|
|
1243
|
-
const
|
|
1720
|
+
const turnStart = await client.request<TurnStartResponse>('turn/start', {
|
|
1244
1721
|
threadId,
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
}
|
|
1722
|
+
input: buildTurnStartInput(text, uploadedAttachments),
|
|
1723
|
+
cwd: selectedProject.value?.projectPath ?? null,
|
|
1724
|
+
approvalPolicy: 'never',
|
|
1725
|
+
...buildTurnOverrides(selectedModel.value, selectedEffort.value)
|
|
1726
|
+
})
|
|
1250
1727
|
|
|
1251
|
-
|
|
1252
|
-
const targetThreadId = notificationThreadId(notification)
|
|
1253
|
-
if (!targetThreadId) {
|
|
1254
|
-
return
|
|
1255
|
-
}
|
|
1728
|
+
setLiveStreamTurnId(liveStream, turnStart.turn.id)
|
|
1256
1729
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
return
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
if (!currentTurnId) {
|
|
1265
|
-
buffered.push(notification)
|
|
1266
|
-
return
|
|
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
|
|
1267
1734
|
}
|
|
1268
1735
|
|
|
1269
|
-
|
|
1270
|
-
})
|
|
1271
|
-
liveStream.unsubscribe = unsubscribe
|
|
1272
|
-
session.liveStream = liveStream
|
|
1273
|
-
|
|
1274
|
-
if (created && !routeThreadId.value) {
|
|
1275
|
-
pendingThreadId.value = threadId
|
|
1736
|
+
applyNotification(notification)
|
|
1276
1737
|
}
|
|
1738
|
+
} catch (caughtError) {
|
|
1739
|
+
const messageText = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1277
1740
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
|
1741
|
+
clearThinkingPlaceholder()
|
|
1742
|
+
untrackPendingUserMessage(optimisticMessageId)
|
|
1743
|
+
removeOptimisticMessage(optimisticMessageId)
|
|
1291
1744
|
|
|
1292
|
-
|
|
1293
|
-
if (
|
|
1294
|
-
|
|
1745
|
+
if (submissionMethod === 'turn/start') {
|
|
1746
|
+
if (startedLiveStream && session.liveStream === startedLiveStream) {
|
|
1747
|
+
clearPendingOptimisticMessages(clearLiveStream(new Error(messageText)))
|
|
1295
1748
|
}
|
|
1749
|
+
session.pendingLiveStream = null
|
|
1750
|
+
restoreDraftIfPristine(text, submittedAttachments)
|
|
1751
|
+
error.value = messageText
|
|
1752
|
+
status.value = 'error'
|
|
1753
|
+
return
|
|
1296
1754
|
}
|
|
1297
1755
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1756
|
+
restoreDraftIfPristine(text, submittedAttachments)
|
|
1757
|
+
error.value = messageText
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const stopActiveTurn = async () => {
|
|
1762
|
+
if (!hasActiveTurnEngagement()) {
|
|
1763
|
+
return
|
|
1764
|
+
}
|
|
1300
1765
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1766
|
+
let liveStream: LiveStream | null = null
|
|
1767
|
+
|
|
1768
|
+
try {
|
|
1769
|
+
liveStream = await ensurePendingLiveStream()
|
|
1770
|
+
if (liveStream.interruptRequested) {
|
|
1303
1771
|
return
|
|
1304
1772
|
}
|
|
1773
|
+
|
|
1774
|
+
setLiveStreamInterruptRequested(liveStream, true)
|
|
1775
|
+
error.value = null
|
|
1776
|
+
const client = getClient(props.projectId)
|
|
1777
|
+
const turnId = await waitForLiveStreamTurnId(liveStream)
|
|
1778
|
+
await client.request('turn/interrupt', {
|
|
1779
|
+
threadId: liveStream.threadId,
|
|
1780
|
+
turnId
|
|
1781
|
+
})
|
|
1782
|
+
liveStream.interruptAcknowledged = true
|
|
1305
1783
|
} catch (caughtError) {
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
session.liveStream = null
|
|
1784
|
+
if (liveStream) {
|
|
1785
|
+
setLiveStreamInterruptRequested(liveStream, false)
|
|
1309
1786
|
}
|
|
1310
1787
|
error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1311
|
-
status.value = 'error'
|
|
1312
1788
|
}
|
|
1313
1789
|
}
|
|
1314
1790
|
|
|
@@ -1335,6 +1811,7 @@ onMounted(() => {
|
|
|
1335
1811
|
void refreshProjects()
|
|
1336
1812
|
}
|
|
1337
1813
|
|
|
1814
|
+
void loadPromptControls()
|
|
1338
1815
|
void scheduleScrollToBottom('auto')
|
|
1339
1816
|
})
|
|
1340
1817
|
|
|
@@ -1388,6 +1865,18 @@ watch(status, (nextStatus, previousStatus) => {
|
|
|
1388
1865
|
|
|
1389
1866
|
void scheduleScrollToBottom(nextStatus === 'streaming' ? 'auto' : 'smooth')
|
|
1390
1867
|
}, { flush: 'post' })
|
|
1868
|
+
|
|
1869
|
+
watch([selectedModel, availableModels], () => {
|
|
1870
|
+
const nextSelection = coercePromptSelection(effectiveModelList.value, selectedModel.value, selectedEffort.value)
|
|
1871
|
+
if (selectedModel.value !== nextSelection.model) {
|
|
1872
|
+
selectedModel.value = nextSelection.model
|
|
1873
|
+
return
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
if (selectedEffort.value !== nextSelection.effort) {
|
|
1877
|
+
selectedEffort.value = nextSelection.effort
|
|
1878
|
+
}
|
|
1879
|
+
}, { flush: 'sync' })
|
|
1391
1880
|
</script>
|
|
1392
1881
|
|
|
1393
1882
|
<template>
|
|
@@ -1461,38 +1950,271 @@ watch(status, (nextStatus, previousStatus) => {
|
|
|
1461
1950
|
compact
|
|
1462
1951
|
>
|
|
1463
1952
|
<template #content="{ message }">
|
|
1464
|
-
<MessageContent
|
|
1953
|
+
<MessageContent
|
|
1954
|
+
:message="message as ChatMessage"
|
|
1955
|
+
:project-id="projectId"
|
|
1956
|
+
/>
|
|
1465
1957
|
</template>
|
|
1466
1958
|
</UChatMessages>
|
|
1467
1959
|
</div>
|
|
1468
1960
|
|
|
1469
1961
|
<div class="sticky bottom-0 shrink-0 border-t border-default bg-default/95 px-4 py-3 backdrop-blur md:px-6">
|
|
1470
1962
|
<div class="mx-auto w-full max-w-5xl">
|
|
1471
|
-
<
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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"
|
|
1963
|
+
<UAlert
|
|
1964
|
+
v-if="composerError"
|
|
1965
|
+
color="error"
|
|
1966
|
+
variant="soft"
|
|
1967
|
+
icon="i-lucide-circle-alert"
|
|
1968
|
+
:title="composerError"
|
|
1969
|
+
class="mb-3"
|
|
1494
1970
|
/>
|
|
1495
|
-
|
|
1971
|
+
|
|
1972
|
+
<div
|
|
1973
|
+
class="relative"
|
|
1974
|
+
@dragenter="onDragEnter"
|
|
1975
|
+
@dragleave="onDragLeave"
|
|
1976
|
+
@dragover="onDragOver"
|
|
1977
|
+
@drop="onDrop"
|
|
1978
|
+
>
|
|
1979
|
+
<div
|
|
1980
|
+
v-if="isDragging"
|
|
1981
|
+
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-3xl border border-dashed border-primary/50 bg-primary/10 text-sm font-medium text-primary backdrop-blur-sm"
|
|
1982
|
+
>
|
|
1983
|
+
Drop images to attach them
|
|
1984
|
+
</div>
|
|
1985
|
+
|
|
1986
|
+
<UChatPrompt
|
|
1987
|
+
v-model="input"
|
|
1988
|
+
placeholder="Describe the change you want Codex to make"
|
|
1989
|
+
:error="submitError"
|
|
1990
|
+
:disabled="isComposerDisabled"
|
|
1991
|
+
autoresize
|
|
1992
|
+
@submit.prevent="sendMessage"
|
|
1993
|
+
@keydown.enter="onPromptEnter"
|
|
1994
|
+
@compositionstart="onCompositionStart"
|
|
1995
|
+
@compositionend="onCompositionEnd"
|
|
1996
|
+
@paste="onPaste"
|
|
1997
|
+
>
|
|
1998
|
+
<template #header>
|
|
1999
|
+
<div
|
|
2000
|
+
v-if="attachments.length"
|
|
2001
|
+
class="flex flex-wrap gap-2 pb-2"
|
|
2002
|
+
>
|
|
2003
|
+
<div
|
|
2004
|
+
v-for="attachment in attachments"
|
|
2005
|
+
:key="attachment.id"
|
|
2006
|
+
class="flex max-w-full items-center gap-2 rounded-2xl border border-default bg-elevated/35 px-2 py-1.5"
|
|
2007
|
+
>
|
|
2008
|
+
<img
|
|
2009
|
+
:src="attachment.previewUrl"
|
|
2010
|
+
:alt="attachment.name"
|
|
2011
|
+
class="size-10 rounded-xl object-cover"
|
|
2012
|
+
>
|
|
2013
|
+
<div class="min-w-0">
|
|
2014
|
+
<div class="max-w-40 truncate text-xs font-medium text-highlighted">
|
|
2015
|
+
{{ attachment.name }}
|
|
2016
|
+
</div>
|
|
2017
|
+
<div class="text-[11px] text-muted">
|
|
2018
|
+
{{ formatAttachmentSize(attachment.size) }}
|
|
2019
|
+
</div>
|
|
2020
|
+
</div>
|
|
2021
|
+
<UButton
|
|
2022
|
+
type="button"
|
|
2023
|
+
color="neutral"
|
|
2024
|
+
variant="ghost"
|
|
2025
|
+
size="xs"
|
|
2026
|
+
icon="i-lucide-x"
|
|
2027
|
+
:disabled="isComposerDisabled"
|
|
2028
|
+
class="rounded-full"
|
|
2029
|
+
:aria-label="`Remove ${attachment.name}`"
|
|
2030
|
+
@click="removeAttachment(attachment.id)"
|
|
2031
|
+
/>
|
|
2032
|
+
</div>
|
|
2033
|
+
</div>
|
|
2034
|
+
</template>
|
|
2035
|
+
|
|
2036
|
+
<UChatPromptSubmit
|
|
2037
|
+
:status="promptSubmitStatus"
|
|
2038
|
+
@stop="stopActiveTurn"
|
|
2039
|
+
/>
|
|
2040
|
+
|
|
2041
|
+
<template #footer>
|
|
2042
|
+
<input
|
|
2043
|
+
ref="fileInput"
|
|
2044
|
+
type="file"
|
|
2045
|
+
accept="image/*"
|
|
2046
|
+
class="hidden"
|
|
2047
|
+
:disabled="isComposerDisabled"
|
|
2048
|
+
@change="onFileInputChange"
|
|
2049
|
+
>
|
|
2050
|
+
|
|
2051
|
+
<div class="flex w-full flex-wrap items-center gap-2 pt-1">
|
|
2052
|
+
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
2053
|
+
<UButton
|
|
2054
|
+
type="button"
|
|
2055
|
+
color="neutral"
|
|
2056
|
+
variant="ghost"
|
|
2057
|
+
size="sm"
|
|
2058
|
+
icon="i-lucide-plus"
|
|
2059
|
+
:disabled="isComposerDisabled"
|
|
2060
|
+
class="size-8 shrink-0 justify-center rounded-full border border-default/70"
|
|
2061
|
+
:ui="{ leadingIcon: 'size-4', base: 'px-0' }"
|
|
2062
|
+
aria-label="Attach image"
|
|
2063
|
+
@click="openFilePicker"
|
|
2064
|
+
/>
|
|
2065
|
+
|
|
2066
|
+
<USelect
|
|
2067
|
+
v-model="selectedModel"
|
|
2068
|
+
:items="modelSelectItems"
|
|
2069
|
+
color="neutral"
|
|
2070
|
+
variant="ghost"
|
|
2071
|
+
size="sm"
|
|
2072
|
+
:loading="promptControlsLoading"
|
|
2073
|
+
:disabled="isComposerDisabled"
|
|
2074
|
+
class="min-w-0 flex-1 sm:max-w-52 sm:flex-none"
|
|
2075
|
+
:ui="{ base: 'rounded-full border border-default/70 bg-default/70', value: 'truncate', content: 'min-w-56' }"
|
|
2076
|
+
/>
|
|
2077
|
+
|
|
2078
|
+
<USelect
|
|
2079
|
+
v-model="selectedEffort"
|
|
2080
|
+
:items="effortSelectItems"
|
|
2081
|
+
color="neutral"
|
|
2082
|
+
variant="ghost"
|
|
2083
|
+
size="sm"
|
|
2084
|
+
:disabled="isComposerDisabled"
|
|
2085
|
+
class="min-w-0 flex-1 sm:max-w-36 sm:flex-none"
|
|
2086
|
+
:ui="{ base: 'rounded-full border border-default/70 bg-default/70', value: 'truncate' }"
|
|
2087
|
+
/>
|
|
2088
|
+
</div>
|
|
2089
|
+
|
|
2090
|
+
<div class="ml-auto flex shrink-0 items-center">
|
|
2091
|
+
<UPopover
|
|
2092
|
+
:content="{ side: 'top', align: 'end' }"
|
|
2093
|
+
arrow
|
|
2094
|
+
>
|
|
2095
|
+
<button
|
|
2096
|
+
type="button"
|
|
2097
|
+
class="flex h-8 shrink-0 items-center gap-2 px-0 text-left text-muted"
|
|
2098
|
+
>
|
|
2099
|
+
<span class="relative flex size-7 items-center justify-center">
|
|
2100
|
+
<svg
|
|
2101
|
+
viewBox="0 0 36 36"
|
|
2102
|
+
class="size-7 -rotate-90"
|
|
2103
|
+
aria-hidden="true"
|
|
2104
|
+
>
|
|
2105
|
+
<circle
|
|
2106
|
+
cx="18"
|
|
2107
|
+
cy="18"
|
|
2108
|
+
r="15.5"
|
|
2109
|
+
fill="none"
|
|
2110
|
+
stroke-width="3"
|
|
2111
|
+
pathLength="100"
|
|
2112
|
+
class="stroke-current text-muted/20"
|
|
2113
|
+
stroke-dasharray="100 100"
|
|
2114
|
+
/>
|
|
2115
|
+
<circle
|
|
2116
|
+
cx="18"
|
|
2117
|
+
cy="18"
|
|
2118
|
+
r="15.5"
|
|
2119
|
+
fill="none"
|
|
2120
|
+
stroke-width="3"
|
|
2121
|
+
pathLength="100"
|
|
2122
|
+
class="stroke-current text-primary"
|
|
2123
|
+
stroke-linecap="round"
|
|
2124
|
+
:stroke-dasharray="`${contextUsedPercent} 100`"
|
|
2125
|
+
/>
|
|
2126
|
+
</svg>
|
|
2127
|
+
<span class="absolute text-[9px] font-semibold text-highlighted">
|
|
2128
|
+
{{ contextIndicatorLabel }}
|
|
2129
|
+
</span>
|
|
2130
|
+
</span>
|
|
2131
|
+
|
|
2132
|
+
<span class="text-[11px] leading-none text-muted">context window</span>
|
|
2133
|
+
</button>
|
|
2134
|
+
|
|
2135
|
+
<template #content>
|
|
2136
|
+
<div class="w-72 space-y-3 p-4">
|
|
2137
|
+
<div class="space-y-1">
|
|
2138
|
+
<div class="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
|
|
2139
|
+
Context Window
|
|
2140
|
+
</div>
|
|
2141
|
+
<div class="text-sm font-medium text-highlighted">
|
|
2142
|
+
{{ selectedModelOption?.displayName ?? 'Selected model' }}
|
|
2143
|
+
</div>
|
|
2144
|
+
</div>
|
|
2145
|
+
|
|
2146
|
+
<div
|
|
2147
|
+
v-if="contextWindowState.contextWindow && contextWindowState.usedTokens !== null"
|
|
2148
|
+
class="grid grid-cols-2 gap-3 text-sm"
|
|
2149
|
+
>
|
|
2150
|
+
<div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
|
|
2151
|
+
<div class="text-[11px] uppercase tracking-[0.18em] text-muted">
|
|
2152
|
+
Remaining
|
|
2153
|
+
</div>
|
|
2154
|
+
<div class="mt-1 font-semibold text-highlighted">
|
|
2155
|
+
{{ Math.round(contextWindowState.remainingPercent ?? 0) }}%
|
|
2156
|
+
</div>
|
|
2157
|
+
<div class="text-xs text-muted">
|
|
2158
|
+
{{ formatCompactTokenCount(contextWindowState.remainingTokens ?? 0) }} tokens
|
|
2159
|
+
</div>
|
|
2160
|
+
</div>
|
|
2161
|
+
<div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
|
|
2162
|
+
<div class="text-[11px] uppercase tracking-[0.18em] text-muted">
|
|
2163
|
+
Used
|
|
2164
|
+
</div>
|
|
2165
|
+
<div class="mt-1 font-semibold text-highlighted">
|
|
2166
|
+
{{ formatCompactTokenCount(contextWindowState.usedTokens ?? 0) }}
|
|
2167
|
+
</div>
|
|
2168
|
+
<div class="text-xs text-muted">
|
|
2169
|
+
of {{ formatCompactTokenCount(contextWindowState.contextWindow) }}
|
|
2170
|
+
</div>
|
|
2171
|
+
</div>
|
|
2172
|
+
</div>
|
|
2173
|
+
|
|
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
|
+
<div class="grid grid-cols-2 gap-3 text-sm">
|
|
2187
|
+
<div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
|
|
2188
|
+
<div class="text-[11px] uppercase tracking-[0.18em] text-muted">
|
|
2189
|
+
Input
|
|
2190
|
+
</div>
|
|
2191
|
+
<div class="mt-1 font-semibold text-highlighted">
|
|
2192
|
+
{{ formatCompactTokenCount(tokenUsage?.totalInputTokens ?? 0) }}
|
|
2193
|
+
</div>
|
|
2194
|
+
<div class="text-xs text-muted">
|
|
2195
|
+
cached {{ formatCompactTokenCount(tokenUsage?.totalCachedInputTokens ?? 0) }}
|
|
2196
|
+
</div>
|
|
2197
|
+
</div>
|
|
2198
|
+
<div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
|
|
2199
|
+
<div class="text-[11px] uppercase tracking-[0.18em] text-muted">
|
|
2200
|
+
Output
|
|
2201
|
+
</div>
|
|
2202
|
+
<div class="mt-1 font-semibold text-highlighted">
|
|
2203
|
+
{{ formatCompactTokenCount(tokenUsage?.totalOutputTokens ?? 0) }}
|
|
2204
|
+
</div>
|
|
2205
|
+
<div class="text-xs text-muted">
|
|
2206
|
+
effort {{ formatReasoningEffortLabel(selectedEffort) }}
|
|
2207
|
+
</div>
|
|
2208
|
+
</div>
|
|
2209
|
+
</div>
|
|
2210
|
+
</div>
|
|
2211
|
+
</template>
|
|
2212
|
+
</UPopover>
|
|
2213
|
+
</div>
|
|
2214
|
+
</div>
|
|
2215
|
+
</template>
|
|
2216
|
+
</UChatPrompt>
|
|
2217
|
+
</div>
|
|
1496
2218
|
</div>
|
|
1497
2219
|
</div>
|
|
1498
2220
|
</section>
|