@codori/client 0.0.6 → 0.0.8

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.
@@ -1,24 +1,41 @@
1
1
  <script setup lang="ts">
2
- import { useRouter } from '#imports'
3
- import { computed, nextTick, onMounted, ref, watch } from 'vue'
2
+ import { useRouter, useRuntimeConfig } from '#imports'
3
+ import {
4
+ computed,
5
+ nextTick,
6
+ onBeforeUnmount,
7
+ onMounted,
8
+ ref,
9
+ watch,
10
+ type ComponentPublicInstance
11
+ } from 'vue'
4
12
  import MessageContent from './MessageContent.vue'
13
+ import ReviewStartDrawer from './ReviewStartDrawer.vue'
14
+ import PendingUserRequestDrawer from './PendingUserRequestDrawer.vue'
5
15
  import {
6
16
  reconcileOptimisticUserMessage,
7
17
  removeChatMessage,
8
18
  removePendingUserMessageId,
9
19
  resolvePromptSubmitStatus,
10
20
  resolveTurnSubmissionMethod,
21
+ shouldAdvanceLiveStreamTurn,
22
+ shouldApplyNotificationToCurrentTurn,
11
23
  shouldSubmitViaTurnSteer,
12
24
  shouldAwaitThreadHydration,
13
25
  shouldRetrySteerWithTurnStart,
14
26
  shouldIgnoreNotificationAfterInterrupt
15
27
  } from '../utils/chat-turn-engagement'
16
28
  import { useChatAttachments, type DraftAttachment } from '../composables/useChatAttachments'
29
+ import { usePendingUserRequest } from '../composables/usePendingUserRequest'
17
30
  import { useChatSession, type LiveStream, type SubagentPanelState } from '../composables/useChatSession'
18
31
  import { useProjects } from '../composables/useProjects'
19
32
  import { useRpc } from '../composables/useRpc'
20
33
  import { useChatSubmitGuard } from '../composables/useChatSubmitGuard'
21
- import { resolveThreadSummaryTitle, useThreadSummaries } from '../composables/useThreadSummaries'
34
+ import {
35
+ normalizeThreadTitleCandidate,
36
+ resolveThreadSummaryTitle,
37
+ useThreadSummaries
38
+ } from '../composables/useThreadSummaries'
22
39
  import { resolveChatMessagesStatus, shouldAwaitAssistantOutput } from '../utils/chat-messages-status'
23
40
  import {
24
41
  ITEM_PART,
@@ -45,6 +62,9 @@ import {
45
62
  type CodexRpcNotification,
46
63
  type CodexThread,
47
64
  type CodexThreadItem,
65
+ type ReviewStartParams,
66
+ type ReviewStartResponse,
67
+ type ReviewTarget,
48
68
  type ThreadReadResponse,
49
69
  type ThreadResumeResponse,
50
70
  type ThreadStartResponse,
@@ -66,7 +86,19 @@ import {
66
86
  visibleModelOptions,
67
87
  type ReasoningEffort
68
88
  } from '~~/shared/chat-prompt-controls'
69
- import { toProjectThreadRoute } from '~~/shared/codori'
89
+ import {
90
+ resolveProjectGitBranchesUrl,
91
+ toProjectThreadRoute,
92
+ type ProjectGitBranchesResponse
93
+ } from '~~/shared/codori'
94
+ import {
95
+ filterSlashCommands,
96
+ findActiveSlashCommand,
97
+ parseSubmittedSlashCommand,
98
+ SLASH_COMMANDS,
99
+ toSlashCommandCompletion,
100
+ type SlashCommandDefinition
101
+ } from '~~/shared/slash-commands'
70
102
 
71
103
  const props = defineProps<{
72
104
  projectId: string
@@ -74,6 +106,7 @@ const props = defineProps<{
74
106
  }>()
75
107
 
76
108
  const router = useRouter()
109
+ const runtimeConfig = useRuntimeConfig()
77
110
  const { getClient } = useRpc()
78
111
  const {
79
112
  loaded,
@@ -105,8 +138,11 @@ const {
105
138
  onDrop,
106
139
  uploadAttachments
107
140
  } = useChatAttachments(props.projectId)
108
-
109
141
  const input = ref('')
142
+ const chatPromptRef = ref<{
143
+ textareaRef?: unknown
144
+ } | null>(null)
145
+ const slashDropdownRef = ref<ComponentPublicInstance | HTMLElement | null>(null)
110
146
  const scrollViewport = ref<HTMLElement | null>(null)
111
147
  const pinnedToBottom = ref(true)
112
148
  const session = useChatSession(props.projectId)
@@ -129,16 +165,38 @@ const {
129
165
  modelContextWindow,
130
166
  tokenUsage
131
167
  } = session
168
+ const {
169
+ pendingRequest,
170
+ hasPendingRequest,
171
+ handleServerRequest,
172
+ resolveCurrentRequest,
173
+ cancelAllPendingRequests
174
+ } = usePendingUserRequest(props.projectId, activeThreadId)
132
175
 
133
176
  const selectedProject = computed(() => getProject(props.projectId))
134
177
  const composerError = computed(() => attachmentError.value ?? error.value)
135
178
  const submitError = computed(() => composerError.value ? new Error(composerError.value) : undefined)
136
179
  const interruptRequested = ref(false)
137
180
  const awaitingAssistantOutput = ref(false)
181
+ const sendMessageLocked = ref(false)
182
+ const reviewStartPending = ref(false)
183
+ const promptSelectionStart = ref(0)
184
+ const promptSelectionEnd = ref(0)
185
+ const isPromptFocused = ref(false)
186
+ const dismissedSlashMatchKey = ref<string | null>(null)
187
+ const slashHighlightIndex = ref(0)
188
+ const reviewDrawerOpen = ref(false)
189
+ const reviewDrawerMode = ref<'target' | 'branch'>('target')
190
+ const reviewDrawerCommandText = ref('/review')
191
+ const reviewBranches = ref<string[]>([])
192
+ const reviewCurrentBranch = ref<string | null>(null)
193
+ const reviewBranchesLoading = ref(false)
194
+ const reviewBranchesError = ref<string | null>(null)
138
195
  const isBusy = computed(() =>
139
196
  status.value === 'submitted'
140
197
  || status.value === 'streaming'
141
198
  || isUploading.value
199
+ || reviewStartPending.value
142
200
  )
143
201
  const hasDraftContent = computed(() =>
144
202
  input.value.trim().length > 0
@@ -147,6 +205,13 @@ const hasDraftContent = computed(() =>
147
205
  const isComposerDisabled = computed(() =>
148
206
  isUploading.value
149
207
  || interruptRequested.value
208
+ || hasPendingRequest.value
209
+ || reviewStartPending.value
210
+ )
211
+ const composerPlaceholder = computed(() =>
212
+ hasPendingRequest.value
213
+ ? 'Respond to the pending request below to let Codex continue'
214
+ : 'Describe the change you want Codex to make'
150
215
  )
151
216
  const promptSubmitStatus = computed(() =>
152
217
  resolvePromptSubmitStatus({
@@ -166,6 +231,60 @@ const showWelcomeState = computed(() =>
166
231
  && !isBusy.value
167
232
  )
168
233
 
234
+ const slashCommands = computed(() =>
235
+ SLASH_COMMANDS.filter(() => !hasPendingRequest.value)
236
+ )
237
+
238
+ const activeSlashMatch = computed(() =>
239
+ isPromptFocused.value
240
+ ? findActiveSlashCommand(input.value, promptSelectionStart.value, promptSelectionEnd.value)
241
+ : null
242
+ )
243
+
244
+ const activeSlashMatchKey = computed(() => {
245
+ const match = activeSlashMatch.value
246
+ if (!match) {
247
+ return null
248
+ }
249
+
250
+ return `${match.start}:${match.end}:${match.raw}`
251
+ })
252
+
253
+ const filteredSlashCommands = computed(() =>
254
+ activeSlashMatch.value
255
+ ? filterSlashCommands(slashCommands.value, activeSlashMatch.value.query)
256
+ : []
257
+ )
258
+
259
+ const slashDropdownOpen = computed(() =>
260
+ !reviewDrawerOpen.value
261
+ && Boolean(activeSlashMatch.value)
262
+ && activeSlashMatchKey.value !== dismissedSlashMatchKey.value
263
+ && filteredSlashCommands.value.length > 0
264
+ )
265
+
266
+ const highlightedSlashCommand = computed(() =>
267
+ filteredSlashCommands.value[slashHighlightIndex.value] ?? filteredSlashCommands.value[0] ?? null
268
+ )
269
+
270
+ const slashPaletteGroups = computed(() => [{
271
+ id: 'slash-commands',
272
+ ignoreFilter: true,
273
+ items: filteredSlashCommands.value.map((command, index) => ({
274
+ label: `/${command.name}`,
275
+ description: command.description,
276
+ icon: command.name === 'review' ? 'i-lucide-search-check' : 'i-lucide-terminal',
277
+ active: index === slashHighlightIndex.value,
278
+ onSelect: () => {
279
+ void activateSlashCommand(command, command.supportsInlineArgs ? 'complete' : 'execute')
280
+ }
281
+ }))
282
+ }])
283
+
284
+ const reviewBaseBranches = computed(() =>
285
+ reviewBranches.value.filter(branch => branch !== reviewCurrentBranch.value)
286
+ )
287
+
169
288
  const starterPrompts = computed(() => {
170
289
  const project = projectTitle.value
171
290
 
@@ -314,6 +433,7 @@ const subagentBootstrapPromises = new Map<string, Promise<void>>()
314
433
  const optimisticAttachmentSnapshots = new Map<string, DraftAttachment[]>()
315
434
  let promptControlsPromise: Promise<void> | null = null
316
435
  let pendingThreadHydration: Promise<void> | null = null
436
+ let releaseServerRequestHandler: (() => void) | null = null
317
437
 
318
438
  const isActiveTurnStatus = (value: string | null | undefined) => {
319
439
  if (!value) {
@@ -352,6 +472,7 @@ const createLiveStreamState = (
352
472
  ): LiveStream => ({
353
473
  threadId,
354
474
  turnId: null,
475
+ lockedTurnId: null,
355
476
  bufferedNotifications,
356
477
  observedSubagentThreadIds: new Set<string>(),
357
478
  pendingUserMessageIds: [],
@@ -378,6 +499,7 @@ const setLiveStreamTurnId = (liveStream: LiveStream, turnId: string | null) => {
378
499
  liveStream.turnId = turnId
379
500
 
380
501
  if (!turnId) {
502
+ liveStream.lockedTurnId = null
381
503
  return
382
504
  }
383
505
 
@@ -388,6 +510,11 @@ const setLiveStreamTurnId = (liveStream: LiveStream, turnId: string | null) => {
388
510
  }
389
511
  }
390
512
 
513
+ const lockLiveStreamTurnId = (liveStream: LiveStream, turnId: string | null) => {
514
+ liveStream.lockedTurnId = turnId
515
+ setLiveStreamTurnId(liveStream, turnId)
516
+ }
517
+
391
518
  const waitForLiveStreamTurnId = async (liveStream: LiveStream) => {
392
519
  if (liveStream.turnId) {
393
520
  return liveStream.turnId
@@ -407,9 +534,10 @@ const queuePendingUserMessage = (liveStream: LiveStream, messageId: string) => {
407
534
  }
408
535
 
409
536
  const replayBufferedNotifications = (liveStream: LiveStream) => {
537
+ const trackedTurnId = liveStream.lockedTurnId ?? liveStream.turnId
410
538
  for (const notification of liveStream.bufferedNotifications.splice(0, liveStream.bufferedNotifications.length)) {
411
539
  const turnId = notificationTurnId(notification)
412
- if (turnId && turnId !== liveStream.turnId) {
540
+ if (turnId && trackedTurnId && turnId !== trackedTurnId) {
413
541
  continue
414
542
  }
415
543
 
@@ -467,7 +595,12 @@ const ensurePendingLiveStream = async () => {
467
595
  }
468
596
 
469
597
  const turnId = notificationTurnId(notification)
470
- if (turnId && turnId !== liveStream.turnId) {
598
+ if (!shouldApplyNotificationToCurrentTurn({
599
+ liveStreamTurnId: liveStream.turnId,
600
+ lockedTurnId: liveStream.lockedTurnId,
601
+ notificationMethod: notification.method,
602
+ notificationTurnId: turnId
603
+ })) {
471
604
  return
472
605
  }
473
606
 
@@ -542,6 +675,297 @@ const formatAttachmentSize = (size: number) => {
542
675
  return `${size} B`
543
676
  }
544
677
 
678
+ const isTextareaElement = (value: unknown): value is HTMLTextAreaElement =>
679
+ typeof HTMLTextAreaElement !== 'undefined'
680
+ && value instanceof HTMLTextAreaElement
681
+
682
+ const getPromptTextarea = () => {
683
+ const exposed = chatPromptRef.value?.textareaRef
684
+ if (isTextareaElement(exposed)) {
685
+ return exposed
686
+ }
687
+
688
+ if (
689
+ typeof exposed === 'object'
690
+ && exposed !== null
691
+ && 'value' in exposed
692
+ && isTextareaElement(exposed.value)
693
+ ) {
694
+ return exposed.value
695
+ }
696
+
697
+ return null
698
+ }
699
+
700
+ const syncPromptSelectionFromDom = () => {
701
+ const textarea = getPromptTextarea()
702
+ promptSelectionStart.value = textarea?.selectionStart ?? input.value.length
703
+ promptSelectionEnd.value = textarea?.selectionEnd ?? input.value.length
704
+
705
+ if (!activeSlashMatchKey.value || activeSlashMatchKey.value !== dismissedSlashMatchKey.value) {
706
+ dismissedSlashMatchKey.value = null
707
+ }
708
+ }
709
+
710
+ const focusPromptAt = async (position?: number) => {
711
+ await nextTick()
712
+ const textarea = getPromptTextarea()
713
+ if (!textarea) {
714
+ return
715
+ }
716
+
717
+ const nextPosition = position ?? textarea.value.length
718
+ textarea.focus()
719
+ textarea.setSelectionRange(nextPosition, nextPosition)
720
+ promptSelectionStart.value = nextPosition
721
+ promptSelectionEnd.value = nextPosition
722
+ isPromptFocused.value = true
723
+ }
724
+
725
+ const resolveSlashDropdownElement = () => {
726
+ const target = slashDropdownRef.value
727
+ if (target instanceof HTMLElement) {
728
+ return target
729
+ }
730
+
731
+ const rootElement = target?.$el
732
+ return rootElement instanceof HTMLElement ? rootElement : null
733
+ }
734
+
735
+ const shouldRetainPromptFocus = (nextFocused: EventTarget | null) =>
736
+ nextFocused instanceof Node
737
+ && Boolean(resolveSlashDropdownElement()?.contains(nextFocused))
738
+
739
+ const handlePromptBlur = (event: FocusEvent) => {
740
+ if (shouldRetainPromptFocus(event.relatedTarget)) {
741
+ isPromptFocused.value = true
742
+ void focusPromptAt(promptSelectionStart.value)
743
+ return
744
+ }
745
+
746
+ isPromptFocused.value = false
747
+ }
748
+
749
+ const handleSlashDropdownFocusIn = () => {
750
+ if (!slashDropdownOpen.value) {
751
+ return
752
+ }
753
+
754
+ void focusPromptAt(promptSelectionStart.value)
755
+ }
756
+
757
+ const preventSlashPaletteEntryFocus = (event: CustomEvent) => {
758
+ event.preventDefault()
759
+ }
760
+
761
+ const setComposerError = (messageText: string) => {
762
+ markAwaitingAssistantOutput(false)
763
+ error.value = messageText
764
+ status.value = 'error'
765
+ }
766
+
767
+ const resetReviewDrawerState = () => {
768
+ reviewDrawerMode.value = 'target'
769
+ reviewDrawerCommandText.value = '/review'
770
+ reviewBranchesLoading.value = false
771
+ reviewBranchesError.value = null
772
+ }
773
+
774
+ const closeReviewDrawer = () => {
775
+ reviewDrawerOpen.value = false
776
+ resetReviewDrawerState()
777
+ }
778
+
779
+ const openReviewDrawer = (commandText = '/review') => {
780
+ dismissedSlashMatchKey.value = null
781
+ reviewDrawerCommandText.value = commandText
782
+ reviewDrawerMode.value = 'target'
783
+ reviewBranchesError.value = null
784
+ reviewDrawerOpen.value = true
785
+ }
786
+
787
+ const moveSlashHighlight = (delta: number) => {
788
+ if (!filteredSlashCommands.value.length) {
789
+ slashHighlightIndex.value = 0
790
+ return
791
+ }
792
+
793
+ const maxIndex = filteredSlashCommands.value.length - 1
794
+ const nextIndex = slashHighlightIndex.value + delta
795
+ if (nextIndex < 0) {
796
+ slashHighlightIndex.value = maxIndex
797
+ return
798
+ }
799
+
800
+ if (nextIndex > maxIndex) {
801
+ slashHighlightIndex.value = 0
802
+ return
803
+ }
804
+
805
+ slashHighlightIndex.value = nextIndex
806
+ }
807
+
808
+ const completeSlashCommand = async (command: SlashCommandDefinition) => {
809
+ const match = activeSlashMatch.value
810
+ if (!match) {
811
+ return
812
+ }
813
+
814
+ dismissedSlashMatchKey.value = null
815
+ const completion = toSlashCommandCompletion(command)
816
+ input.value = `${input.value.slice(0, match.start)}${completion}${input.value.slice(match.end)}`
817
+ await focusPromptAt(match.start + completion.length)
818
+ }
819
+
820
+ const fetchProjectGitBranches = async () => {
821
+ reviewBranchesLoading.value = true
822
+ reviewBranchesError.value = null
823
+
824
+ try {
825
+ const response = await fetch(resolveProjectGitBranchesUrl({
826
+ projectId: props.projectId,
827
+ configuredBase: String(runtimeConfig.public.serverBase ?? '')
828
+ }))
829
+
830
+ const body = await response.json() as ProjectGitBranchesResponse | { error?: { message?: string } }
831
+ if (!response.ok) {
832
+ throw new Error(body && typeof body === 'object' && 'error' in body
833
+ ? body.error?.message ?? 'Failed to load local branches.'
834
+ : 'Failed to load local branches.')
835
+ }
836
+
837
+ const result = body as ProjectGitBranchesResponse
838
+ reviewCurrentBranch.value = result.currentBranch
839
+ reviewBranches.value = result.branches
840
+ } finally {
841
+ reviewBranchesLoading.value = false
842
+ }
843
+ }
844
+
845
+ const openBaseBranchPicker = async () => {
846
+ reviewDrawerMode.value = 'branch'
847
+
848
+ try {
849
+ await fetchProjectGitBranches()
850
+ if (!reviewBaseBranches.value.length) {
851
+ reviewBranchesError.value = 'No local base branches are available to compare against.'
852
+ }
853
+ } catch (caughtError) {
854
+ reviewBranchesError.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
855
+ }
856
+ }
857
+
858
+ const startReview = async (target: ReviewTarget) => {
859
+ if (reviewStartPending.value) {
860
+ return
861
+ }
862
+
863
+ if (hasPendingRequest.value || isBusy.value) {
864
+ setComposerError('Review can only start when the current thread is idle.')
865
+ return
866
+ }
867
+
868
+ reviewStartPending.value = true
869
+ const draftText = reviewDrawerCommandText.value
870
+ const submittedAttachments = attachments.value.slice()
871
+ input.value = ''
872
+ clearAttachments({ revoke: false })
873
+
874
+ try {
875
+ const client = getClient(props.projectId)
876
+ const liveStream = await ensurePendingLiveStream()
877
+ error.value = null
878
+ status.value = 'submitted'
879
+ tokenUsage.value = null
880
+ markAwaitingAssistantOutput(true)
881
+
882
+ const response = await client.request<ReviewStartResponse>('review/start', {
883
+ threadId: liveStream.threadId,
884
+ delivery: 'inline',
885
+ target
886
+ } satisfies ReviewStartParams)
887
+
888
+ for (const item of response.turn.items) {
889
+ if (item.type === 'userMessage') {
890
+ continue
891
+ }
892
+
893
+ for (const nextMessage of itemToMessages(item)) {
894
+ messages.value = upsertStreamingMessage(messages.value, {
895
+ ...nextMessage,
896
+ pending: false
897
+ })
898
+ }
899
+ }
900
+
901
+ lockLiveStreamTurnId(liveStream, response.turn.id)
902
+ replayBufferedNotifications(liveStream)
903
+ closeReviewDrawer()
904
+ } catch (caughtError) {
905
+ restoreDraftIfPristine(draftText, submittedAttachments)
906
+ setComposerError(caughtError instanceof Error ? caughtError.message : String(caughtError))
907
+ } finally {
908
+ reviewStartPending.value = false
909
+ }
910
+ }
911
+
912
+ const handleSlashCommandSubmission = async (
913
+ rawText: string,
914
+ submittedAttachments: DraftAttachment[]
915
+ ) => {
916
+ const slashCommand = parseSubmittedSlashCommand(rawText)
917
+ if (!slashCommand) {
918
+ return false
919
+ }
920
+
921
+ const command = slashCommands.value.find(candidate => candidate.name === slashCommand.name)
922
+ if (!command) {
923
+ return false
924
+ }
925
+
926
+ if (submittedAttachments.length > 0) {
927
+ setComposerError('Slash commands do not support image attachments yet.')
928
+ return true
929
+ }
930
+
931
+ if (!slashCommand.isBare) {
932
+ setComposerError('`/review` currently only supports the bare command. Choose the diff target in the review drawer.')
933
+ return true
934
+ }
935
+
936
+ error.value = null
937
+ status.value = 'ready'
938
+ openReviewDrawer(`/${command.name}`)
939
+ await focusPromptAt(rawText.trim().length)
940
+ return true
941
+ }
942
+
943
+ const activateSlashCommand = async (
944
+ command: SlashCommandDefinition,
945
+ mode: 'complete' | 'execute'
946
+ ) => {
947
+ if (mode === 'complete') {
948
+ await completeSlashCommand(command)
949
+ return
950
+ }
951
+
952
+ await handleSlashCommandSubmission(`/${command.name}`, attachments.value.slice())
953
+ }
954
+
955
+ const handleReviewDrawerOpenChange = (open: boolean) => {
956
+ if (open) {
957
+ reviewDrawerOpen.value = true
958
+ return
959
+ }
960
+
961
+ closeReviewDrawer()
962
+ }
963
+
964
+ const handleReviewDrawerBack = () => {
965
+ reviewDrawerMode.value = 'target'
966
+ reviewBranchesError.value = null
967
+ }
968
+
545
969
  const removeOptimisticMessage = (messageId: string) => {
546
970
  messages.value = removeChatMessage(messages.value, messageId)
547
971
  optimisticAttachmentSnapshots.delete(messageId)
@@ -806,7 +1230,12 @@ const hydrateThread = async (threadId: string) => {
806
1230
  }
807
1231
 
808
1232
  const turnId = notificationTurnId(notification)
809
- if (turnId && turnId !== nextLiveStream.turnId) {
1233
+ if (!shouldApplyNotificationToCurrentTurn({
1234
+ liveStreamTurnId: nextLiveStream.turnId,
1235
+ lockedTurnId: nextLiveStream.lockedTurnId,
1236
+ notificationMethod: notification.method,
1237
+ notificationTurnId: turnId
1238
+ })) {
810
1239
  return
811
1240
  }
812
1241
 
@@ -1511,7 +1940,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
1511
1940
  if (!nextThreadId || !nextThreadName) {
1512
1941
  return
1513
1942
  }
1514
- const nextTitle = nextThreadName.trim()
1943
+ const nextTitle = normalizeThreadTitleCandidate(nextThreadName)
1515
1944
  if (!nextTitle) {
1516
1945
  return
1517
1946
  }
@@ -1524,13 +1953,21 @@ const applyNotification = (notification: CodexRpcNotification) => {
1524
1953
  return
1525
1954
  }
1526
1955
  case 'turn/started': {
1956
+ const nextTurnId = notificationTurnId(notification)
1957
+ if (liveStream && !shouldAdvanceLiveStreamTurn({
1958
+ lockedTurnId: liveStream.lockedTurnId,
1959
+ nextTurnId
1960
+ })) {
1961
+ return
1962
+ }
1963
+
1527
1964
  if (liveStream) {
1528
- setLiveStreamTurnId(liveStream, notificationTurnId(notification))
1965
+ setLiveStreamTurnId(liveStream, nextTurnId)
1529
1966
  setLiveStreamInterruptRequested(liveStream, false)
1530
1967
  }
1531
1968
  messages.value = upsertStreamingMessage(
1532
1969
  messages.value,
1533
- eventToMessage(`event-turn-started-${notificationTurnId(notification) ?? Date.now()}`, {
1970
+ eventToMessage(`event-turn-started-${nextTurnId ?? Date.now()}`, {
1534
1971
  kind: 'turn.started'
1535
1972
  })
1536
1973
  )
@@ -1723,6 +2160,9 @@ const applyNotification = (notification: CodexRpcNotification) => {
1723
2160
  markAwaitingAssistantOutput(false)
1724
2161
  pushEventMessage('stream.error', messageText)
1725
2162
  clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
2163
+ if (liveStream) {
2164
+ liveStream.lockedTurnId = null
2165
+ }
1726
2166
  clearLiveStream(new Error(messageText))
1727
2167
  error.value = messageText
1728
2168
  status.value = 'error'
@@ -1734,6 +2174,9 @@ const applyNotification = (notification: CodexRpcNotification) => {
1734
2174
  markAwaitingAssistantOutput(false)
1735
2175
  pushEventMessage('turn.failed', messageText)
1736
2176
  clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
2177
+ if (liveStream) {
2178
+ liveStream.lockedTurnId = null
2179
+ }
1737
2180
  clearLiveStream(new Error(messageText))
1738
2181
  error.value = messageText
1739
2182
  status.value = 'error'
@@ -1745,6 +2188,9 @@ const applyNotification = (notification: CodexRpcNotification) => {
1745
2188
  markAwaitingAssistantOutput(false)
1746
2189
  pushEventMessage('stream.error', messageText)
1747
2190
  clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
2191
+ if (liveStream) {
2192
+ liveStream.lockedTurnId = null
2193
+ }
1748
2194
  clearLiveStream(new Error(messageText))
1749
2195
  error.value = messageText
1750
2196
  status.value = 'error'
@@ -1753,6 +2199,9 @@ const applyNotification = (notification: CodexRpcNotification) => {
1753
2199
  case 'turn/completed': {
1754
2200
  markAwaitingAssistantOutput(false)
1755
2201
  clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
2202
+ if (liveStream) {
2203
+ liveStream.lockedTurnId = null
2204
+ }
1756
2205
  error.value = null
1757
2206
  status.value = 'ready'
1758
2207
  clearLiveStream()
@@ -1764,118 +2213,137 @@ const applyNotification = (notification: CodexRpcNotification) => {
1764
2213
  }
1765
2214
 
1766
2215
  const sendMessage = async () => {
1767
- const text = input.value.trim()
2216
+ if (sendMessageLocked.value || hasPendingRequest.value) {
2217
+ return
2218
+ }
2219
+
2220
+ sendMessageLocked.value = true
2221
+ const rawText = input.value
2222
+ const text = rawText.trim()
1768
2223
  const submittedAttachments = attachments.value.slice()
1769
2224
 
1770
2225
  if (!text && submittedAttachments.length === 0) {
2226
+ sendMessageLocked.value = false
1771
2227
  return
1772
2228
  }
1773
2229
 
2230
+ if (await handleSlashCommandSubmission(rawText, submittedAttachments)) {
2231
+ sendMessageLocked.value = false
2232
+ return
2233
+ }
2234
+
2235
+ input.value = ''
2236
+ clearAttachments({ revoke: false })
2237
+
1774
2238
  try {
1775
2239
  await loadPromptControls()
1776
2240
  } catch (caughtError) {
2241
+ restoreDraftIfPristine(text, submittedAttachments)
1777
2242
  error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
1778
2243
  status.value = 'error'
2244
+ sendMessageLocked.value = false
1779
2245
  return
1780
2246
  }
1781
2247
 
1782
- if (pendingThreadHydration && shouldAwaitThreadHydration({
1783
- hasPendingThreadHydration: true,
1784
- routeThreadId: routeThreadId.value
1785
- })) {
1786
- await pendingThreadHydration
1787
- }
2248
+ try {
2249
+ if (pendingThreadHydration && shouldAwaitThreadHydration({
2250
+ hasPendingThreadHydration: true,
2251
+ routeThreadId: routeThreadId.value
2252
+ })) {
2253
+ await pendingThreadHydration
2254
+ }
1788
2255
 
1789
- pinnedToBottom.value = true
1790
- error.value = null
1791
- attachmentError.value = null
1792
- const submissionMethod = resolveTurnSubmissionMethod(shouldSubmitWithTurnSteer())
1793
- if (submissionMethod === 'turn/start') {
1794
- status.value = 'submitted'
1795
- }
1796
- input.value = ''
1797
- clearAttachments({ revoke: false })
1798
- const optimisticMessage = buildOptimisticMessage(text, submittedAttachments)
1799
- const optimisticMessageId = optimisticMessage.id
1800
- rememberOptimisticAttachments(optimisticMessageId, submittedAttachments)
1801
- messages.value = [...messages.value, optimisticMessage]
1802
- markAwaitingAssistantOutput(shouldAwaitAssistantOutput(submissionMethod))
1803
- let startedLiveStream: LiveStream | null = null
1804
- let executedSubmissionMethod = submissionMethod
2256
+ pinnedToBottom.value = true
2257
+ error.value = null
2258
+ attachmentError.value = null
2259
+ const submissionMethod = resolveTurnSubmissionMethod(shouldSubmitWithTurnSteer())
2260
+ if (submissionMethod === 'turn/start') {
2261
+ status.value = 'submitted'
2262
+ }
2263
+ const optimisticMessage = buildOptimisticMessage(text, submittedAttachments)
2264
+ const optimisticMessageId = optimisticMessage.id
2265
+ rememberOptimisticAttachments(optimisticMessageId, submittedAttachments)
2266
+ messages.value = [...messages.value, optimisticMessage]
2267
+ markAwaitingAssistantOutput(shouldAwaitAssistantOutput(submissionMethod))
2268
+ let startedLiveStream: LiveStream | null = null
2269
+ let executedSubmissionMethod = submissionMethod
1805
2270
 
1806
- try {
1807
- const client = getClient(props.projectId)
2271
+ try {
2272
+ const client = getClient(props.projectId)
1808
2273
 
1809
- if (submissionMethod === 'turn/steer') {
1810
- const liveStream = await ensurePendingLiveStream()
1811
- queuePendingUserMessage(liveStream, optimisticMessageId)
1812
- let uploadedAttachments: PersistedProjectAttachment[] | undefined
1813
-
1814
- try {
1815
- uploadedAttachments = await uploadAttachments(liveStream.threadId, submittedAttachments)
1816
- const turnId = await waitForLiveStreamTurnId(liveStream)
1817
-
1818
- await client.request<TurnStartResponse>('turn/steer', {
1819
- threadId: liveStream.threadId,
1820
- expectedTurnId: turnId,
1821
- input: buildTurnStartInput(text, uploadedAttachments),
1822
- ...buildTurnOverrides(selectedModel.value, selectedEffort.value)
1823
- })
1824
- tokenUsage.value = null
1825
- } catch (caughtError) {
1826
- const errorToHandle = caughtError instanceof Error ? caughtError : new Error(String(caughtError))
1827
- if (!shouldRetrySteerWithTurnStart(errorToHandle.message)) {
1828
- throw errorToHandle
1829
- }
2274
+ if (submissionMethod === 'turn/steer') {
2275
+ const liveStream = await ensurePendingLiveStream()
2276
+ queuePendingUserMessage(liveStream, optimisticMessageId)
2277
+ let uploadedAttachments: PersistedProjectAttachment[] | undefined
1830
2278
 
1831
- executedSubmissionMethod = 'turn/start'
1832
- startedLiveStream = liveStream
1833
- status.value = 'submitted'
1834
- setLiveStreamTurnId(liveStream, null)
1835
- setLiveStreamInterruptRequested(liveStream, false)
2279
+ try {
2280
+ uploadedAttachments = await uploadAttachments(liveStream.threadId, submittedAttachments)
2281
+ const turnId = await waitForLiveStreamTurnId(liveStream)
1836
2282
 
1837
- await submitTurnStart({
1838
- client,
1839
- liveStream,
1840
- text,
1841
- submittedAttachments,
1842
- uploadedAttachments,
1843
- optimisticMessageId,
1844
- queueOptimisticMessage: false
1845
- })
2283
+ await client.request<TurnStartResponse>('turn/steer', {
2284
+ threadId: liveStream.threadId,
2285
+ expectedTurnId: turnId,
2286
+ input: buildTurnStartInput(text, uploadedAttachments),
2287
+ ...buildTurnOverrides(selectedModel.value, selectedEffort.value)
2288
+ })
2289
+ tokenUsage.value = null
2290
+ } catch (caughtError) {
2291
+ const errorToHandle = caughtError instanceof Error ? caughtError : new Error(String(caughtError))
2292
+ if (!shouldRetrySteerWithTurnStart(errorToHandle.message)) {
2293
+ throw errorToHandle
2294
+ }
2295
+
2296
+ executedSubmissionMethod = 'turn/start'
2297
+ startedLiveStream = liveStream
2298
+ status.value = 'submitted'
2299
+ setLiveStreamTurnId(liveStream, null)
2300
+ setLiveStreamInterruptRequested(liveStream, false)
2301
+
2302
+ await submitTurnStart({
2303
+ client,
2304
+ liveStream,
2305
+ text,
2306
+ submittedAttachments,
2307
+ uploadedAttachments,
2308
+ optimisticMessageId,
2309
+ queueOptimisticMessage: false
2310
+ })
2311
+ }
2312
+ return
1846
2313
  }
1847
- return
1848
- }
1849
2314
 
1850
- const liveStream = await ensurePendingLiveStream()
1851
- startedLiveStream = liveStream
1852
- await submitTurnStart({
1853
- client,
1854
- liveStream,
1855
- text,
1856
- submittedAttachments,
1857
- optimisticMessageId
1858
- })
1859
- } catch (caughtError) {
1860
- const messageText = caughtError instanceof Error ? caughtError.message : String(caughtError)
2315
+ const liveStream = await ensurePendingLiveStream()
2316
+ startedLiveStream = liveStream
2317
+ await submitTurnStart({
2318
+ client,
2319
+ liveStream,
2320
+ text,
2321
+ submittedAttachments,
2322
+ optimisticMessageId
2323
+ })
2324
+ } catch (caughtError) {
2325
+ const messageText = caughtError instanceof Error ? caughtError.message : String(caughtError)
1861
2326
 
1862
- markAwaitingAssistantOutput(false)
1863
- untrackPendingUserMessage(optimisticMessageId)
1864
- removeOptimisticMessage(optimisticMessageId)
2327
+ markAwaitingAssistantOutput(false)
2328
+ untrackPendingUserMessage(optimisticMessageId)
2329
+ removeOptimisticMessage(optimisticMessageId)
1865
2330
 
1866
- if (executedSubmissionMethod === 'turn/start') {
1867
- if (startedLiveStream && session.liveStream === startedLiveStream) {
1868
- clearPendingOptimisticMessages(clearLiveStream(new Error(messageText)))
2331
+ if (executedSubmissionMethod === 'turn/start') {
2332
+ if (startedLiveStream && session.liveStream === startedLiveStream) {
2333
+ clearPendingOptimisticMessages(clearLiveStream(new Error(messageText)))
2334
+ }
2335
+ session.pendingLiveStream = null
2336
+ restoreDraftIfPristine(text, submittedAttachments)
2337
+ error.value = messageText
2338
+ status.value = 'error'
2339
+ return
1869
2340
  }
1870
- session.pendingLiveStream = null
2341
+
1871
2342
  restoreDraftIfPristine(text, submittedAttachments)
1872
2343
  error.value = messageText
1873
- status.value = 'error'
1874
- return
1875
2344
  }
1876
-
1877
- restoreDraftIfPristine(text, submittedAttachments)
1878
- error.value = messageText
2345
+ } finally {
2346
+ sendMessageLocked.value = false
1879
2347
  }
1880
2348
  }
1881
2349
 
@@ -1910,7 +2378,7 @@ const stopActiveTurn = async () => {
1910
2378
  }
1911
2379
 
1912
2380
  const sendStarterPrompt = async (text: string) => {
1913
- if (isBusy.value) {
2381
+ if (isBusy.value || hasPendingRequest.value) {
1914
2382
  return
1915
2383
  }
1916
2384
 
@@ -1918,16 +2386,71 @@ const sendStarterPrompt = async (text: string) => {
1918
2386
  await sendMessage()
1919
2387
  }
1920
2388
 
2389
+ const respondToPendingRequest = (response: unknown) => {
2390
+ error.value = null
2391
+ resolveCurrentRequest(response)
2392
+ }
2393
+
2394
+ const onPromptKeydown = (event: KeyboardEvent) => {
2395
+ if (!slashDropdownOpen.value) {
2396
+ return
2397
+ }
2398
+
2399
+ if (event.key === 'ArrowDown') {
2400
+ event.preventDefault()
2401
+ moveSlashHighlight(1)
2402
+ return
2403
+ }
2404
+
2405
+ if (event.key === 'ArrowUp') {
2406
+ event.preventDefault()
2407
+ moveSlashHighlight(-1)
2408
+ return
2409
+ }
2410
+
2411
+ if (event.key === 'Escape') {
2412
+ event.preventDefault()
2413
+ dismissedSlashMatchKey.value = activeSlashMatchKey.value
2414
+ isPromptFocused.value = true
2415
+ void focusPromptAt(promptSelectionStart.value)
2416
+ return
2417
+ }
2418
+
2419
+ if (event.key === 'Tab' || event.key === ' ') {
2420
+ const command = highlightedSlashCommand.value
2421
+ if (!command || (event.key === ' ' && !command.completeOnSpace)) {
2422
+ return
2423
+ }
2424
+
2425
+ event.preventDefault()
2426
+ void activateSlashCommand(command, 'complete')
2427
+ }
2428
+ }
2429
+
1921
2430
  const onPromptEnter = (event: KeyboardEvent) => {
1922
2431
  if (!shouldSubmit(event)) {
1923
2432
  return
1924
2433
  }
1925
2434
 
2435
+ if (slashDropdownOpen.value) {
2436
+ const command = highlightedSlashCommand.value
2437
+ if (command) {
2438
+ event.preventDefault()
2439
+ const isExactMatch = activeSlashMatch.value?.query === command.name
2440
+ const action = isExactMatch && command.executeOnEnter && !command.supportsInlineArgs
2441
+ ? 'execute'
2442
+ : 'complete'
2443
+ void activateSlashCommand(command, action)
2444
+ return
2445
+ }
2446
+ }
2447
+
1926
2448
  event.preventDefault()
1927
2449
  void sendMessage()
1928
2450
  }
1929
2451
 
1930
2452
  onMounted(() => {
2453
+ releaseServerRequestHandler = getClient(props.projectId).setServerRequestHandler(handleServerRequest)
1931
2454
  if (!loaded.value) {
1932
2455
  void refreshProjects()
1933
2456
  }
@@ -1936,6 +2459,12 @@ onMounted(() => {
1936
2459
  void scheduleScrollToBottom('auto')
1937
2460
  })
1938
2461
 
2462
+ onBeforeUnmount(() => {
2463
+ cancelAllPendingRequests()
2464
+ releaseServerRequestHandler?.()
2465
+ releaseServerRequestHandler = null
2466
+ })
2467
+
1939
2468
  watch(() => props.threadId ?? null, (threadId) => {
1940
2469
  if (!threadId) {
1941
2470
  if (isBusy.value || pendingThreadId.value) {
@@ -1987,6 +2516,17 @@ watch(status, (nextStatus, previousStatus) => {
1987
2516
  void scheduleScrollToBottom(nextStatus === 'streaming' ? 'auto' : 'smooth')
1988
2517
  }, { flush: 'post' })
1989
2518
 
2519
+ watch(filteredSlashCommands, (commands) => {
2520
+ if (!commands.length) {
2521
+ slashHighlightIndex.value = 0
2522
+ return
2523
+ }
2524
+
2525
+ if (slashHighlightIndex.value >= commands.length) {
2526
+ slashHighlightIndex.value = 0
2527
+ }
2528
+ }, { flush: 'sync' })
2529
+
1990
2530
  watch([selectedModel, availableModels], () => {
1991
2531
  const nextSelection = coercePromptSelection(effectiveModelList.value, selectedModel.value, selectedEffort.value)
1992
2532
  if (selectedModel.value !== nextSelection.model) {
@@ -1998,6 +2538,11 @@ watch([selectedModel, availableModels], () => {
1998
2538
  selectedEffort.value = nextSelection.effort
1999
2539
  }
2000
2540
  }, { flush: 'sync' })
2541
+
2542
+ watch(input, async () => {
2543
+ await nextTick()
2544
+ syncPromptSelectionFromDom()
2545
+ }, { flush: 'post' })
2001
2546
  </script>
2002
2547
 
2003
2548
  <template>
@@ -2077,19 +2622,11 @@ watch([selectedModel, availableModels], () => {
2077
2622
  />
2078
2623
  </template>
2079
2624
  <template #indicator>
2080
- <div
2625
+ <UChatShimmer
2081
2626
  v-if="awaitingAssistantOutput"
2082
- class="flex items-center gap-3 px-1 py-2 text-sm text-muted"
2083
- >
2084
- <UIcon
2085
- name="i-lucide-loader-circle"
2086
- class="size-4 animate-spin text-primary"
2087
- />
2088
- <div class="flex flex-col gap-1">
2089
- <UChatShimmer text="Waiting for response…" />
2090
- <span class="text-xs text-toned">Real reasoning will appear separately when streaming starts.</span>
2091
- </div>
2092
- </div>
2627
+ text="Thinking..."
2628
+ class="px-1 py-2"
2629
+ />
2093
2630
  </template>
2094
2631
  </UChatMessages>
2095
2632
  </div>
@@ -2105,6 +2642,13 @@ watch([selectedModel, availableModels], () => {
2105
2642
  class="mb-3"
2106
2643
  />
2107
2644
 
2645
+ <div
2646
+ v-if="pendingRequest"
2647
+ class="mb-3 rounded-2xl border border-primary/30 bg-primary/8 px-4 py-3 text-sm text-default"
2648
+ >
2649
+ Codex is waiting for the response in the drawer below. Normal chat sending is paused until you answer it.
2650
+ </div>
2651
+
2108
2652
  <div
2109
2653
  class="relative"
2110
2654
  @dragenter="onDragEnter"
@@ -2119,14 +2663,44 @@ watch([selectedModel, availableModels], () => {
2119
2663
  Drop images to attach them
2120
2664
  </div>
2121
2665
 
2666
+ <UCommandPalette
2667
+ v-if="slashDropdownOpen"
2668
+ ref="slashDropdownRef"
2669
+ class="absolute bottom-full left-0 z-20 mb-2 w-[90vw] max-w-[calc(100vw-2rem)] md:w-[min(50vw,52rem)] md:max-w-[min(50vw,52rem)]"
2670
+ :groups="slashPaletteGroups"
2671
+ :input="false"
2672
+ :autofocus="false"
2673
+ :search-term="activeSlashMatch?.query ?? ''"
2674
+ :ui="{
2675
+ root: 'min-h-0 overflow-hidden rounded-2xl border border-default bg-default/95 shadow-2xl backdrop-blur',
2676
+ content: 'max-h-72 overflow-y-auto p-2',
2677
+ group: 'space-y-1',
2678
+ item: 'rounded-xl px-3 py-2.5',
2679
+ itemLeadingIcon: 'size-4',
2680
+ itemLabel: 'text-sm font-medium',
2681
+ itemDescription: 'text-xs leading-5'
2682
+ }"
2683
+ @entry-focus="preventSlashPaletteEntryFocus"
2684
+ @focusin.capture="handleSlashDropdownFocusIn"
2685
+ @mousedown.prevent
2686
+ />
2687
+
2122
2688
  <UChatPrompt
2689
+ ref="chatPromptRef"
2123
2690
  v-model="input"
2124
- placeholder="Describe the change you want Codex to make"
2691
+ :placeholder="composerPlaceholder"
2125
2692
  :error="submitError"
2126
2693
  :disabled="isComposerDisabled"
2127
2694
  autoresize
2128
2695
  @submit.prevent="sendMessage"
2696
+ @keydown="onPromptKeydown"
2129
2697
  @keydown.enter="onPromptEnter"
2698
+ @input="syncPromptSelectionFromDom"
2699
+ @click="syncPromptSelectionFromDom"
2700
+ @keyup="syncPromptSelectionFromDom"
2701
+ @focus="isPromptFocused = true; syncPromptSelectionFromDom()"
2702
+ @blur="handlePromptBlur"
2703
+ @select="syncPromptSelectionFromDom"
2130
2704
  @compositionstart="onCompositionStart"
2131
2705
  @compositionend="onCompositionEnd"
2132
2706
  @paste="onPaste"
@@ -2338,4 +2912,24 @@ watch([selectedModel, availableModels], () => {
2338
2912
  </div>
2339
2913
  </div>
2340
2914
  </section>
2915
+
2916
+ <PendingUserRequestDrawer
2917
+ :request="pendingRequest"
2918
+ @respond="respondToPendingRequest"
2919
+ />
2920
+
2921
+ <ReviewStartDrawer
2922
+ :open="reviewDrawerOpen"
2923
+ :mode="reviewDrawerMode"
2924
+ :branches="reviewBaseBranches"
2925
+ :current-branch="reviewCurrentBranch"
2926
+ :loading="reviewBranchesLoading"
2927
+ :submitting="reviewStartPending"
2928
+ :error="reviewBranchesError"
2929
+ @update:open="handleReviewDrawerOpenChange"
2930
+ @choose-current-changes="startReview({ type: 'uncommittedChanges' })"
2931
+ @choose-base-branch-mode="openBaseBranchPicker"
2932
+ @choose-base-branch="(branch) => startReview({ type: 'baseBranch', branch })"
2933
+ @back="handleReviewDrawerBack"
2934
+ />
2341
2935
  </template>