@codori/client 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/app/assets/css/main.css +22 -0
  2. package/app/components/BottomDrawerShell.vue +62 -0
  3. package/app/components/ChatWorkspace.vue +1009 -13
  4. package/app/components/LocalFileViewerModal.vue +314 -0
  5. package/app/components/MessagePartRenderer.ts +1 -0
  6. package/app/components/PendingUserRequestDrawer.vue +88 -0
  7. package/app/components/ProjectStatusDot.vue +1 -2
  8. package/app/components/ReviewStartDrawer.vue +186 -0
  9. package/app/components/SubagentTranscriptPanel.vue +5 -1
  10. package/app/components/UsageStatusModal.vue +265 -0
  11. package/app/components/VisualSubagentStack.vue +2 -0
  12. package/app/components/message-part/Event.vue +42 -8
  13. package/app/components/message-part/LocalFileLink.vue +51 -0
  14. package/app/components/message-part/ReviewPriorityBadge.vue +38 -0
  15. package/app/components/message-part/Text.vue +159 -10
  16. package/app/components/pending-request/McpElicitationForm.vue +264 -0
  17. package/app/components/pending-request/McpElicitationUrlPrompt.vue +100 -0
  18. package/app/components/pending-request/RequestUserInputForm.vue +235 -0
  19. package/app/composables/useChatSession.ts +1 -0
  20. package/app/composables/useLocalFileViewer.ts +46 -0
  21. package/app/composables/usePendingUserRequest.ts +124 -0
  22. package/app/composables/useThreadSummaries.ts +28 -2
  23. package/app/pages/index.vue +0 -1
  24. package/app/pages/projects/[...projectId]/threads/[threadId].vue +2 -0
  25. package/app/utils/chat-turn-engagement.ts +11 -2
  26. package/app/utils/review-priority-badge.ts +90 -0
  27. package/app/utils/slash-prompt-focus.ts +8 -0
  28. package/package.json +8 -1
  29. package/server/api/codori/projects/[projectId]/git/branches.get.ts +15 -0
  30. package/server/api/codori/projects/[projectId]/local-file.get.ts +44 -0
  31. package/shared/account-rate-limits.ts +190 -0
  32. package/shared/codex-chat.ts +72 -2
  33. package/shared/codex-rpc.ts +79 -3
  34. package/shared/codori.ts +20 -0
  35. package/shared/file-autocomplete.ts +166 -0
  36. package/shared/file-highlighting.ts +127 -0
  37. package/shared/local-files.ts +122 -0
  38. package/shared/pending-user-request.ts +374 -0
  39. package/shared/slash-commands.ts +97 -0
@@ -1,25 +1,43 @@
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
+ } from 'vue'
11
+ import LocalFileViewerModal from './LocalFileViewerModal.vue'
4
12
  import MessageContent from './MessageContent.vue'
13
+ import ReviewStartDrawer from './ReviewStartDrawer.vue'
14
+ import PendingUserRequestDrawer from './PendingUserRequestDrawer.vue'
15
+ import UsageStatusModal from './UsageStatusModal.vue'
5
16
  import {
6
17
  reconcileOptimisticUserMessage,
7
18
  removeChatMessage,
8
19
  removePendingUserMessageId,
9
20
  resolvePromptSubmitStatus,
10
21
  resolveTurnSubmissionMethod,
22
+ shouldAdvanceLiveStreamTurn,
11
23
  shouldApplyNotificationToCurrentTurn,
12
24
  shouldSubmitViaTurnSteer,
13
25
  shouldAwaitThreadHydration,
14
26
  shouldRetrySteerWithTurnStart,
15
27
  shouldIgnoreNotificationAfterInterrupt
16
28
  } from '../utils/chat-turn-engagement'
29
+ import { isFocusWithinContainer } from '../utils/slash-prompt-focus'
17
30
  import { useChatAttachments, type DraftAttachment } from '../composables/useChatAttachments'
31
+ import { usePendingUserRequest } from '../composables/usePendingUserRequest'
18
32
  import { useChatSession, type LiveStream, type SubagentPanelState } from '../composables/useChatSession'
19
33
  import { useProjects } from '../composables/useProjects'
20
34
  import { useRpc } from '../composables/useRpc'
21
35
  import { useChatSubmitGuard } from '../composables/useChatSubmitGuard'
22
- import { resolveThreadSummaryTitle, useThreadSummaries } from '../composables/useThreadSummaries'
36
+ import {
37
+ normalizeThreadTitleCandidate,
38
+ resolveThreadSummaryTitle,
39
+ useThreadSummaries
40
+ } from '../composables/useThreadSummaries'
23
41
  import { resolveChatMessagesStatus, shouldAwaitAssistantOutput } from '../utils/chat-messages-status'
24
42
  import {
25
43
  ITEM_PART,
@@ -46,6 +64,9 @@ import {
46
64
  type CodexRpcNotification,
47
65
  type CodexThread,
48
66
  type CodexThreadItem,
67
+ type ReviewStartParams,
68
+ type ReviewStartResponse,
69
+ type ReviewTarget,
49
70
  type ThreadReadResponse,
50
71
  type ThreadResumeResponse,
51
72
  type ThreadStartResponse,
@@ -67,7 +88,29 @@ import {
67
88
  visibleModelOptions,
68
89
  type ReasoningEffort
69
90
  } from '~~/shared/chat-prompt-controls'
70
- import { toProjectThreadRoute } from '~~/shared/codori'
91
+ import {
92
+ resolveProjectGitBranchesUrl,
93
+ toProjectThreadRoute,
94
+ type ProjectGitBranchesResponse
95
+ } from '~~/shared/codori'
96
+ import {
97
+ filterSlashCommands,
98
+ findActiveSlashCommand,
99
+ parseSubmittedSlashCommand,
100
+ SLASH_COMMANDS,
101
+ toSlashCommandCompletion,
102
+ type SlashCommandDefinition
103
+ } from '~~/shared/slash-commands'
104
+ import {
105
+ buildFileAutocompletePathSegments,
106
+ findActiveFileAutocompleteMatch,
107
+ normalizeFileAutocompleteQuery,
108
+ normalizeFuzzyFileSearchMatches,
109
+ replaceActiveFileAutocompleteMatch,
110
+ toFileAutocompleteHandle,
111
+ type FileAutocompletePathSegment,
112
+ type NormalizedFuzzyFileSearchMatch
113
+ } from '~~/shared/file-autocomplete'
71
114
 
72
115
  const props = defineProps<{
73
116
  projectId: string
@@ -75,6 +118,7 @@ const props = defineProps<{
75
118
  }>()
76
119
 
77
120
  const router = useRouter()
121
+ const runtimeConfig = useRuntimeConfig()
78
122
  const { getClient } = useRpc()
79
123
  const {
80
124
  loaded,
@@ -106,8 +150,13 @@ const {
106
150
  onDrop,
107
151
  uploadAttachments
108
152
  } = useChatAttachments(props.projectId)
109
-
110
153
  const input = ref('')
154
+ const chatPromptRef = ref<{
155
+ textareaRef?: unknown
156
+ } | null>(null)
157
+ const slashDropdownRef = ref<HTMLElement | null>(null)
158
+ const fileAutocompleteDropdownRef = ref<HTMLElement | null>(null)
159
+ const fileAutocompleteListRef = ref<HTMLElement | null>(null)
111
160
  const scrollViewport = ref<HTMLElement | null>(null)
112
161
  const pinnedToBottom = ref(true)
113
162
  const session = useChatSession(props.projectId)
@@ -130,6 +179,13 @@ const {
130
179
  modelContextWindow,
131
180
  tokenUsage
132
181
  } = session
182
+ const {
183
+ pendingRequest,
184
+ hasPendingRequest,
185
+ handleServerRequest,
186
+ resolveCurrentRequest,
187
+ cancelAllPendingRequests
188
+ } = usePendingUserRequest(props.projectId, activeThreadId)
133
189
 
134
190
  const selectedProject = computed(() => getProject(props.projectId))
135
191
  const composerError = computed(() => attachmentError.value ?? error.value)
@@ -137,10 +193,30 @@ const submitError = computed(() => composerError.value ? new Error(composerError
137
193
  const interruptRequested = ref(false)
138
194
  const awaitingAssistantOutput = ref(false)
139
195
  const sendMessageLocked = ref(false)
196
+ const reviewStartPending = ref(false)
197
+ const promptSelectionStart = ref(0)
198
+ const promptSelectionEnd = ref(0)
199
+ const isPromptFocused = ref(false)
200
+ const dismissedSlashMatchKey = ref<string | null>(null)
201
+ const dismissedFileAutocompleteMatchKey = ref<string | null>(null)
202
+ const slashHighlightIndex = ref(0)
203
+ const fileAutocompleteHighlightIndex = ref(0)
204
+ const fileAutocompleteLoading = ref(false)
205
+ const fileAutocompleteError = ref<string | null>(null)
206
+ const fileAutocompleteResults = ref<NormalizedFuzzyFileSearchMatch[]>([])
207
+ const reviewDrawerOpen = ref(false)
208
+ const reviewDrawerMode = ref<'target' | 'branch'>('target')
209
+ const reviewDrawerCommandText = ref('/review')
210
+ const usageStatusModalOpen = ref(false)
211
+ const reviewBranches = ref<string[]>([])
212
+ const reviewCurrentBranch = ref<string | null>(null)
213
+ const reviewBranchesLoading = ref(false)
214
+ const reviewBranchesError = ref<string | null>(null)
140
215
  const isBusy = computed(() =>
141
216
  status.value === 'submitted'
142
217
  || status.value === 'streaming'
143
218
  || isUploading.value
219
+ || reviewStartPending.value
144
220
  )
145
221
  const hasDraftContent = computed(() =>
146
222
  input.value.trim().length > 0
@@ -149,6 +225,13 @@ const hasDraftContent = computed(() =>
149
225
  const isComposerDisabled = computed(() =>
150
226
  isUploading.value
151
227
  || interruptRequested.value
228
+ || hasPendingRequest.value
229
+ || reviewStartPending.value
230
+ )
231
+ const composerPlaceholder = computed(() =>
232
+ hasPendingRequest.value
233
+ ? 'Respond to the pending request below to let Codex continue'
234
+ : 'Describe the change you want Codex to make'
152
235
  )
153
236
  const promptSubmitStatus = computed(() =>
154
237
  resolvePromptSubmitStatus({
@@ -168,6 +251,86 @@ const showWelcomeState = computed(() =>
168
251
  && !isBusy.value
169
252
  )
170
253
 
254
+ const slashCommands = computed(() =>
255
+ SLASH_COMMANDS.filter(() => !hasPendingRequest.value)
256
+ )
257
+
258
+ const activeSlashMatch = computed(() =>
259
+ isPromptFocused.value
260
+ ? findActiveSlashCommand(input.value, promptSelectionStart.value, promptSelectionEnd.value)
261
+ : null
262
+ )
263
+
264
+ const activeSlashMatchKey = computed(() => {
265
+ const match = activeSlashMatch.value
266
+ if (!match) {
267
+ return null
268
+ }
269
+
270
+ return `${match.start}:${match.end}:${match.raw}`
271
+ })
272
+
273
+ const filteredSlashCommands = computed(() =>
274
+ activeSlashMatch.value
275
+ ? filterSlashCommands(slashCommands.value, activeSlashMatch.value.query)
276
+ : []
277
+ )
278
+
279
+ const activeFileAutocompleteMatch = computed(() =>
280
+ isPromptFocused.value
281
+ ? findActiveFileAutocompleteMatch(input.value, promptSelectionStart.value, promptSelectionEnd.value)
282
+ : null
283
+ )
284
+
285
+ const activeFileAutocompleteMatchKey = computed(() => {
286
+ const match = activeFileAutocompleteMatch.value
287
+ if (!match) {
288
+ return null
289
+ }
290
+
291
+ return `${match.start}:${match.end}:${match.raw}`
292
+ })
293
+
294
+ const normalizedFileAutocompleteQuery = computed(() =>
295
+ activeFileAutocompleteMatch.value
296
+ ? normalizeFileAutocompleteQuery(activeFileAutocompleteMatch.value.query)
297
+ : ''
298
+ )
299
+
300
+ const fileAutocompleteOpen = computed(() =>
301
+ !reviewDrawerOpen.value
302
+ && Boolean(activeFileAutocompleteMatch.value)
303
+ && activeFileAutocompleteMatchKey.value !== dismissedFileAutocompleteMatchKey.value
304
+ )
305
+
306
+ const visibleFileAutocompleteResults = computed(() =>
307
+ fileAutocompleteResults.value
308
+ .filter((match) => match.matchType === 'file')
309
+ .slice(0, 8)
310
+ )
311
+
312
+ const highlightedFileAutocompleteResult = computed(() =>
313
+ visibleFileAutocompleteResults.value[fileAutocompleteHighlightIndex.value]
314
+ ?? visibleFileAutocompleteResults.value[0]
315
+ ?? null
316
+ )
317
+
318
+ const slashDropdownOpen = computed(() =>
319
+ !reviewDrawerOpen.value
320
+ && !fileAutocompleteOpen.value
321
+ && Boolean(activeSlashMatch.value)
322
+ && activeSlashMatchKey.value !== dismissedSlashMatchKey.value
323
+ && filteredSlashCommands.value.length > 0
324
+ )
325
+
326
+ const highlightedSlashCommand = computed(() =>
327
+ filteredSlashCommands.value[slashHighlightIndex.value] ?? filteredSlashCommands.value[0] ?? null
328
+ )
329
+
330
+ const reviewBaseBranches = computed(() =>
331
+ reviewBranches.value.filter(branch => branch !== reviewCurrentBranch.value)
332
+ )
333
+
171
334
  const starterPrompts = computed(() => {
172
335
  const project = projectTitle.value
173
336
 
@@ -223,6 +386,34 @@ const contextIndicatorLabel = computed(() => {
223
386
  return remainingPercent == null ? 'ctx' : `${Math.round(remainingPercent)}%`
224
387
  })
225
388
 
389
+ const fileAutocompleteEmptyState = computed(() => {
390
+ if (!fileAutocompleteOpen.value) {
391
+ return null
392
+ }
393
+
394
+ if (!selectedProject.value?.projectPath) {
395
+ return 'Project runtime is not ready yet.'
396
+ }
397
+
398
+ if (!normalizedFileAutocompleteQuery.value) {
399
+ return 'Type a path or filename to search project files.'
400
+ }
401
+
402
+ if (fileAutocompleteLoading.value) {
403
+ return 'Searching project files...'
404
+ }
405
+
406
+ if (fileAutocompleteError.value) {
407
+ return fileAutocompleteError.value
408
+ }
409
+
410
+ if (visibleFileAutocompleteResults.value.length === 0) {
411
+ return 'No matching project files.'
412
+ }
413
+
414
+ return null
415
+ })
416
+
226
417
  const normalizePromptSelection = (
227
418
  preferredModel?: string | null,
228
419
  preferredEffort?: ReasoningEffort | null
@@ -316,6 +507,8 @@ const subagentBootstrapPromises = new Map<string, Promise<void>>()
316
507
  const optimisticAttachmentSnapshots = new Map<string, DraftAttachment[]>()
317
508
  let promptControlsPromise: Promise<void> | null = null
318
509
  let pendingThreadHydration: Promise<void> | null = null
510
+ let releaseServerRequestHandler: (() => void) | null = null
511
+ let fileAutocompleteRequestSequence = 0
319
512
 
320
513
  const isActiveTurnStatus = (value: string | null | undefined) => {
321
514
  if (!value) {
@@ -354,6 +547,7 @@ const createLiveStreamState = (
354
547
  ): LiveStream => ({
355
548
  threadId,
356
549
  turnId: null,
550
+ lockedTurnId: null,
357
551
  bufferedNotifications,
358
552
  observedSubagentThreadIds: new Set<string>(),
359
553
  pendingUserMessageIds: [],
@@ -380,6 +574,7 @@ const setLiveStreamTurnId = (liveStream: LiveStream, turnId: string | null) => {
380
574
  liveStream.turnId = turnId
381
575
 
382
576
  if (!turnId) {
577
+ liveStream.lockedTurnId = null
383
578
  return
384
579
  }
385
580
 
@@ -390,6 +585,11 @@ const setLiveStreamTurnId = (liveStream: LiveStream, turnId: string | null) => {
390
585
  }
391
586
  }
392
587
 
588
+ const lockLiveStreamTurnId = (liveStream: LiveStream, turnId: string | null) => {
589
+ liveStream.lockedTurnId = turnId
590
+ setLiveStreamTurnId(liveStream, turnId)
591
+ }
592
+
393
593
  const waitForLiveStreamTurnId = async (liveStream: LiveStream) => {
394
594
  if (liveStream.turnId) {
395
595
  return liveStream.turnId
@@ -409,9 +609,10 @@ const queuePendingUserMessage = (liveStream: LiveStream, messageId: string) => {
409
609
  }
410
610
 
411
611
  const replayBufferedNotifications = (liveStream: LiveStream) => {
612
+ const trackedTurnId = liveStream.lockedTurnId ?? liveStream.turnId
412
613
  for (const notification of liveStream.bufferedNotifications.splice(0, liveStream.bufferedNotifications.length)) {
413
614
  const turnId = notificationTurnId(notification)
414
- if (turnId && turnId !== liveStream.turnId) {
615
+ if (turnId && trackedTurnId && turnId !== trackedTurnId) {
415
616
  continue
416
617
  }
417
618
 
@@ -471,6 +672,7 @@ const ensurePendingLiveStream = async () => {
471
672
  const turnId = notificationTurnId(notification)
472
673
  if (!shouldApplyNotificationToCurrentTurn({
473
674
  liveStreamTurnId: liveStream.turnId,
675
+ lockedTurnId: liveStream.lockedTurnId,
474
676
  notificationMethod: notification.method,
475
677
  notificationTurnId: turnId
476
678
  })) {
@@ -548,6 +750,415 @@ const formatAttachmentSize = (size: number) => {
548
750
  return `${size} B`
549
751
  }
550
752
 
753
+ const isTextareaElement = (value: unknown): value is HTMLTextAreaElement =>
754
+ typeof HTMLTextAreaElement !== 'undefined'
755
+ && value instanceof HTMLTextAreaElement
756
+
757
+ const getPromptTextarea = () => {
758
+ const exposed = chatPromptRef.value?.textareaRef
759
+ if (isTextareaElement(exposed)) {
760
+ return exposed
761
+ }
762
+
763
+ if (
764
+ typeof exposed === 'object'
765
+ && exposed !== null
766
+ && 'value' in exposed
767
+ && isTextareaElement(exposed.value)
768
+ ) {
769
+ return exposed.value
770
+ }
771
+
772
+ return null
773
+ }
774
+
775
+ const syncPromptSelectionFromDom = () => {
776
+ const textarea = getPromptTextarea()
777
+ promptSelectionStart.value = textarea?.selectionStart ?? input.value.length
778
+ promptSelectionEnd.value = textarea?.selectionEnd ?? input.value.length
779
+
780
+ if (!activeSlashMatchKey.value || activeSlashMatchKey.value !== dismissedSlashMatchKey.value) {
781
+ dismissedSlashMatchKey.value = null
782
+ }
783
+
784
+ if (!activeFileAutocompleteMatchKey.value || activeFileAutocompleteMatchKey.value !== dismissedFileAutocompleteMatchKey.value) {
785
+ dismissedFileAutocompleteMatchKey.value = null
786
+ }
787
+ }
788
+
789
+ const focusPromptAt = async (position?: number) => {
790
+ await nextTick()
791
+ const textarea = getPromptTextarea()
792
+ if (!textarea) {
793
+ return
794
+ }
795
+
796
+ const nextPosition = position ?? textarea.value.length
797
+ textarea.focus()
798
+ textarea.setSelectionRange(nextPosition, nextPosition)
799
+ promptSelectionStart.value = nextPosition
800
+ promptSelectionEnd.value = nextPosition
801
+ isPromptFocused.value = true
802
+ }
803
+
804
+ const shouldRetainPromptFocus = (nextFocused: EventTarget | null | undefined) =>
805
+ isFocusWithinContainer(nextFocused, slashDropdownRef.value)
806
+ || isFocusWithinContainer(nextFocused, fileAutocompleteDropdownRef.value)
807
+
808
+ const handlePromptBlur = (event: FocusEvent) => {
809
+ // Composer suggestion popups must stay focusless. If focus ever moves into
810
+ // the overlay, the textarea selection state no longer matches the active
811
+ // token and the menu collapses while the draft still contains `/` or `@`.
812
+ if (shouldRetainPromptFocus(event.relatedTarget)) {
813
+ void focusPromptAt(promptSelectionStart.value)
814
+ return
815
+ }
816
+
817
+ isPromptFocused.value = false
818
+ }
819
+
820
+ const handleSlashDropdownPointerDown = (event: PointerEvent) => {
821
+ // Fundamental rule for slash commands: the popup is only a visual chooser.
822
+ // Selection happens through the textarea's keyboard handlers, and mouse/touch
823
+ // clicks must not transfer DOM focus into popup items.
824
+ event.preventDefault()
825
+ void focusPromptAt(promptSelectionStart.value)
826
+ }
827
+
828
+ const handleFileAutocompletePointerDown = (event: PointerEvent) => {
829
+ // File suggestions follow the same inert-overlay rule as slash commands.
830
+ // Keeping focus anchored in the textarea avoids collapsing the popup while
831
+ // the active `@token` is still being edited.
832
+ event.preventDefault()
833
+ void focusPromptAt(promptSelectionStart.value)
834
+ }
835
+
836
+ const onPromptEnterCapture = (event: KeyboardEvent) => {
837
+ if (!fileAutocompleteOpen.value) {
838
+ return
839
+ }
840
+
841
+ // UChatPrompt wires its own `keydown.enter.exact` handler on the textarea and
842
+ // emits `submit` immediately. Intercept exact Enter in capture phase so file
843
+ // palette selection never falls through to the component's submit path.
844
+ event.preventDefault()
845
+ event.stopPropagation()
846
+ event.stopImmediatePropagation()
847
+
848
+ const match = highlightedFileAutocompleteResult.value
849
+ if (match) {
850
+ void selectFileAutocompleteResult(match)
851
+ }
852
+ }
853
+
854
+ const setComposerError = (messageText: string) => {
855
+ markAwaitingAssistantOutput(false)
856
+ error.value = messageText
857
+ status.value = 'error'
858
+ }
859
+
860
+ const resetReviewDrawerState = () => {
861
+ reviewDrawerMode.value = 'target'
862
+ reviewDrawerCommandText.value = '/review'
863
+ reviewBranchesLoading.value = false
864
+ reviewBranchesError.value = null
865
+ }
866
+
867
+ const closeReviewDrawer = () => {
868
+ reviewDrawerOpen.value = false
869
+ resetReviewDrawerState()
870
+ }
871
+
872
+ const openReviewDrawer = (commandText = '/review') => {
873
+ dismissedSlashMatchKey.value = null
874
+ reviewDrawerCommandText.value = commandText
875
+ reviewDrawerMode.value = 'target'
876
+ reviewBranchesError.value = null
877
+ reviewDrawerOpen.value = true
878
+ }
879
+
880
+ const clearSlashCommandDraft = () => {
881
+ // Successful slash commands consume the draft immediately. Leaving `/usage`
882
+ // or `/review` in the prompt after opening the modal/drawer makes the next
883
+ // edit start from stale command text and feels like the action did not finish.
884
+ input.value = ''
885
+ clearAttachments({ revoke: false })
886
+ dismissedSlashMatchKey.value = null
887
+ }
888
+
889
+ const moveSlashHighlight = (delta: number) => {
890
+ if (!filteredSlashCommands.value.length) {
891
+ slashHighlightIndex.value = 0
892
+ return
893
+ }
894
+
895
+ const maxIndex = filteredSlashCommands.value.length - 1
896
+ const nextIndex = slashHighlightIndex.value + delta
897
+ if (nextIndex < 0) {
898
+ slashHighlightIndex.value = maxIndex
899
+ return
900
+ }
901
+
902
+ if (nextIndex > maxIndex) {
903
+ slashHighlightIndex.value = 0
904
+ return
905
+ }
906
+
907
+ slashHighlightIndex.value = nextIndex
908
+ }
909
+
910
+ const moveFileAutocompleteHighlight = (delta: number) => {
911
+ if (!visibleFileAutocompleteResults.value.length) {
912
+ fileAutocompleteHighlightIndex.value = 0
913
+ return
914
+ }
915
+
916
+ const maxIndex = visibleFileAutocompleteResults.value.length - 1
917
+ const nextIndex = fileAutocompleteHighlightIndex.value + delta
918
+ if (nextIndex < 0) {
919
+ fileAutocompleteHighlightIndex.value = maxIndex
920
+ return
921
+ }
922
+
923
+ if (nextIndex > maxIndex) {
924
+ fileAutocompleteHighlightIndex.value = 0
925
+ return
926
+ }
927
+
928
+ fileAutocompleteHighlightIndex.value = nextIndex
929
+ }
930
+
931
+ const resolveSlashCommandIcon = (command: SlashCommandDefinition) => {
932
+ if (command.name === 'review') {
933
+ return 'i-lucide-search-check'
934
+ }
935
+
936
+ if (command.name === 'usage' || command.name === 'status') {
937
+ return 'i-lucide-gauge'
938
+ }
939
+
940
+ return 'i-lucide-terminal'
941
+ }
942
+
943
+ const completeSlashCommand = async (command: SlashCommandDefinition) => {
944
+ const match = activeSlashMatch.value
945
+ if (!match) {
946
+ return
947
+ }
948
+
949
+ dismissedSlashMatchKey.value = null
950
+ const completion = toSlashCommandCompletion(command)
951
+ input.value = `${input.value.slice(0, match.start)}${completion}${input.value.slice(match.end)}`
952
+ await focusPromptAt(match.start + completion.length)
953
+ }
954
+
955
+ const resolveFileAutocompleteIcon = (match: Pick<NormalizedFuzzyFileSearchMatch, 'matchType'>) =>
956
+ match.matchType === 'directory' ? 'i-lucide-folder-open' : 'i-lucide-file-code-2'
957
+
958
+ const renderFileAutocompletePathSegments = (
959
+ match: Pick<NormalizedFuzzyFileSearchMatch, 'path' | 'indices'>
960
+ ): FileAutocompletePathSegment[] =>
961
+ buildFileAutocompletePathSegments(match)
962
+
963
+ const selectFileAutocompleteResult = async (match: NormalizedFuzzyFileSearchMatch) => {
964
+ const activeMatch = activeFileAutocompleteMatch.value
965
+ if (!activeMatch) {
966
+ return
967
+ }
968
+
969
+ dismissedFileAutocompleteMatchKey.value = null
970
+ fileAutocompleteError.value = null
971
+ const replacement = toFileAutocompleteHandle(match)
972
+ const nextDraft = replaceActiveFileAutocompleteMatch(input.value, activeMatch, replacement)
973
+ input.value = nextDraft.value
974
+ fileAutocompleteResults.value = []
975
+ await focusPromptAt(nextDraft.caret)
976
+ }
977
+
978
+ const syncFileAutocompleteScroll = async () => {
979
+ await nextTick()
980
+
981
+ const selected = fileAutocompleteListRef.value?.querySelector<HTMLElement>(
982
+ '[data-file-autocomplete-option][aria-selected="true"]'
983
+ )
984
+ selected?.scrollIntoView({
985
+ block: 'nearest'
986
+ })
987
+ }
988
+
989
+ const fetchProjectGitBranches = async () => {
990
+ reviewBranchesLoading.value = true
991
+ reviewBranchesError.value = null
992
+
993
+ try {
994
+ const response = await fetch(resolveProjectGitBranchesUrl({
995
+ projectId: props.projectId,
996
+ configuredBase: String(runtimeConfig.public.serverBase ?? '')
997
+ }))
998
+
999
+ const body = await response.json() as ProjectGitBranchesResponse | { error?: { message?: string } }
1000
+ if (!response.ok) {
1001
+ throw new Error(body && typeof body === 'object' && 'error' in body
1002
+ ? body.error?.message ?? 'Failed to load local branches.'
1003
+ : 'Failed to load local branches.')
1004
+ }
1005
+
1006
+ const result = body as ProjectGitBranchesResponse
1007
+ reviewCurrentBranch.value = result.currentBranch
1008
+ reviewBranches.value = result.branches
1009
+ } finally {
1010
+ reviewBranchesLoading.value = false
1011
+ }
1012
+ }
1013
+
1014
+ const openBaseBranchPicker = async () => {
1015
+ reviewDrawerMode.value = 'branch'
1016
+
1017
+ try {
1018
+ await fetchProjectGitBranches()
1019
+ if (!reviewBaseBranches.value.length) {
1020
+ reviewBranchesError.value = 'No local base branches are available to compare against.'
1021
+ }
1022
+ } catch (caughtError) {
1023
+ reviewBranchesError.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
1024
+ }
1025
+ }
1026
+
1027
+ const startReview = async (target: ReviewTarget) => {
1028
+ if (reviewStartPending.value) {
1029
+ return
1030
+ }
1031
+
1032
+ if (hasPendingRequest.value || isBusy.value) {
1033
+ setComposerError('Review can only start when the current thread is idle.')
1034
+ return
1035
+ }
1036
+
1037
+ reviewStartPending.value = true
1038
+ const draftText = reviewDrawerCommandText.value
1039
+ const submittedAttachments = attachments.value.slice()
1040
+ input.value = ''
1041
+ clearAttachments({ revoke: false })
1042
+
1043
+ try {
1044
+ const client = getClient(props.projectId)
1045
+ const liveStream = await ensurePendingLiveStream()
1046
+ error.value = null
1047
+ status.value = 'submitted'
1048
+ tokenUsage.value = null
1049
+ markAwaitingAssistantOutput(true)
1050
+
1051
+ const response = await client.request<ReviewStartResponse>('review/start', {
1052
+ threadId: liveStream.threadId,
1053
+ delivery: 'inline',
1054
+ target
1055
+ } satisfies ReviewStartParams)
1056
+
1057
+ for (const item of response.turn.items) {
1058
+ if (item.type === 'userMessage') {
1059
+ continue
1060
+ }
1061
+
1062
+ for (const nextMessage of itemToMessages(item)) {
1063
+ messages.value = upsertStreamingMessage(messages.value, {
1064
+ ...nextMessage,
1065
+ pending: false
1066
+ })
1067
+ }
1068
+ }
1069
+
1070
+ lockLiveStreamTurnId(liveStream, response.turn.id)
1071
+ replayBufferedNotifications(liveStream)
1072
+ closeReviewDrawer()
1073
+ } catch (caughtError) {
1074
+ restoreDraftIfPristine(draftText, submittedAttachments)
1075
+ setComposerError(caughtError instanceof Error ? caughtError.message : String(caughtError))
1076
+ } finally {
1077
+ reviewStartPending.value = false
1078
+ }
1079
+ }
1080
+
1081
+ const handleSlashCommandSubmission = async (
1082
+ rawText: string,
1083
+ submittedAttachments: DraftAttachment[]
1084
+ ) => {
1085
+ const slashCommand = parseSubmittedSlashCommand(rawText)
1086
+ if (!slashCommand) {
1087
+ return false
1088
+ }
1089
+
1090
+ const command = slashCommands.value.find(candidate => candidate.name === slashCommand.name)
1091
+ if (!command) {
1092
+ return false
1093
+ }
1094
+
1095
+ if (submittedAttachments.length > 0) {
1096
+ setComposerError('Slash commands do not support image attachments yet.')
1097
+ return true
1098
+ }
1099
+
1100
+ switch (command.name) {
1101
+ case 'review': {
1102
+ if (!slashCommand.isBare) {
1103
+ setComposerError('`/review` currently only supports the bare command. Choose the diff target in the review drawer.')
1104
+ return true
1105
+ }
1106
+
1107
+ error.value = null
1108
+ status.value = 'ready'
1109
+ clearSlashCommandDraft()
1110
+ openReviewDrawer(rawText.trim())
1111
+ await focusPromptAt(0)
1112
+ return true
1113
+ }
1114
+ case 'usage':
1115
+ case 'status': {
1116
+ if (!slashCommand.isBare) {
1117
+ setComposerError(`\`/${command.name}\` currently only supports the bare command.`)
1118
+ return true
1119
+ }
1120
+
1121
+ error.value = null
1122
+ status.value = 'ready'
1123
+ clearSlashCommandDraft()
1124
+ usageStatusModalOpen.value = true
1125
+ return true
1126
+ }
1127
+ default:
1128
+ return false
1129
+ }
1130
+ }
1131
+
1132
+ const activateSlashCommand = async (
1133
+ command: SlashCommandDefinition,
1134
+ mode: 'complete' | 'execute'
1135
+ ) => {
1136
+ if (mode === 'complete') {
1137
+ await completeSlashCommand(command)
1138
+ return
1139
+ }
1140
+
1141
+ await handleSlashCommandSubmission(`/${command.name}`, attachments.value.slice())
1142
+ }
1143
+
1144
+ const handleReviewDrawerOpenChange = (open: boolean) => {
1145
+ if (open) {
1146
+ reviewDrawerOpen.value = true
1147
+ return
1148
+ }
1149
+
1150
+ closeReviewDrawer()
1151
+ }
1152
+
1153
+ const handleUsageStatusOpenChange = (open: boolean) => {
1154
+ usageStatusModalOpen.value = open
1155
+ }
1156
+
1157
+ const handleReviewDrawerBack = () => {
1158
+ reviewDrawerMode.value = 'target'
1159
+ reviewBranchesError.value = null
1160
+ }
1161
+
551
1162
  const removeOptimisticMessage = (messageId: string) => {
552
1163
  messages.value = removeChatMessage(messages.value, messageId)
553
1164
  optimisticAttachmentSnapshots.delete(messageId)
@@ -814,6 +1425,7 @@ const hydrateThread = async (threadId: string) => {
814
1425
  const turnId = notificationTurnId(notification)
815
1426
  if (!shouldApplyNotificationToCurrentTurn({
816
1427
  liveStreamTurnId: nextLiveStream.turnId,
1428
+ lockedTurnId: nextLiveStream.lockedTurnId,
817
1429
  notificationMethod: notification.method,
818
1430
  notificationTurnId: turnId
819
1431
  })) {
@@ -1521,7 +2133,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
1521
2133
  if (!nextThreadId || !nextThreadName) {
1522
2134
  return
1523
2135
  }
1524
- const nextTitle = nextThreadName.trim()
2136
+ const nextTitle = normalizeThreadTitleCandidate(nextThreadName)
1525
2137
  if (!nextTitle) {
1526
2138
  return
1527
2139
  }
@@ -1534,13 +2146,21 @@ const applyNotification = (notification: CodexRpcNotification) => {
1534
2146
  return
1535
2147
  }
1536
2148
  case 'turn/started': {
2149
+ const nextTurnId = notificationTurnId(notification)
2150
+ if (liveStream && !shouldAdvanceLiveStreamTurn({
2151
+ lockedTurnId: liveStream.lockedTurnId,
2152
+ nextTurnId
2153
+ })) {
2154
+ return
2155
+ }
2156
+
1537
2157
  if (liveStream) {
1538
- setLiveStreamTurnId(liveStream, notificationTurnId(notification))
2158
+ setLiveStreamTurnId(liveStream, nextTurnId)
1539
2159
  setLiveStreamInterruptRequested(liveStream, false)
1540
2160
  }
1541
2161
  messages.value = upsertStreamingMessage(
1542
2162
  messages.value,
1543
- eventToMessage(`event-turn-started-${notificationTurnId(notification) ?? Date.now()}`, {
2163
+ eventToMessage(`event-turn-started-${nextTurnId ?? Date.now()}`, {
1544
2164
  kind: 'turn.started'
1545
2165
  })
1546
2166
  )
@@ -1733,6 +2353,9 @@ const applyNotification = (notification: CodexRpcNotification) => {
1733
2353
  markAwaitingAssistantOutput(false)
1734
2354
  pushEventMessage('stream.error', messageText)
1735
2355
  clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
2356
+ if (liveStream) {
2357
+ liveStream.lockedTurnId = null
2358
+ }
1736
2359
  clearLiveStream(new Error(messageText))
1737
2360
  error.value = messageText
1738
2361
  status.value = 'error'
@@ -1744,6 +2367,9 @@ const applyNotification = (notification: CodexRpcNotification) => {
1744
2367
  markAwaitingAssistantOutput(false)
1745
2368
  pushEventMessage('turn.failed', messageText)
1746
2369
  clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
2370
+ if (liveStream) {
2371
+ liveStream.lockedTurnId = null
2372
+ }
1747
2373
  clearLiveStream(new Error(messageText))
1748
2374
  error.value = messageText
1749
2375
  status.value = 'error'
@@ -1755,6 +2381,9 @@ const applyNotification = (notification: CodexRpcNotification) => {
1755
2381
  markAwaitingAssistantOutput(false)
1756
2382
  pushEventMessage('stream.error', messageText)
1757
2383
  clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
2384
+ if (liveStream) {
2385
+ liveStream.lockedTurnId = null
2386
+ }
1758
2387
  clearLiveStream(new Error(messageText))
1759
2388
  error.value = messageText
1760
2389
  status.value = 'error'
@@ -1763,6 +2392,9 @@ const applyNotification = (notification: CodexRpcNotification) => {
1763
2392
  case 'turn/completed': {
1764
2393
  markAwaitingAssistantOutput(false)
1765
2394
  clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
2395
+ if (liveStream) {
2396
+ liveStream.lockedTurnId = null
2397
+ }
1766
2398
  error.value = null
1767
2399
  status.value = 'ready'
1768
2400
  clearLiveStream()
@@ -1774,12 +2406,26 @@ const applyNotification = (notification: CodexRpcNotification) => {
1774
2406
  }
1775
2407
 
1776
2408
  const sendMessage = async () => {
1777
- if (sendMessageLocked.value) {
2409
+ // Nuxt UI's UChatPrompt emits `submit` directly from its internal
2410
+ // `keydown.enter.exact` handler, so guarding only in our keydown callback is
2411
+ // not sufficient to stop Enter from racing into a send while the file palette
2412
+ // is open. Keep the submit path itself aware of the palette state.
2413
+ if (fileAutocompleteOpen.value) {
2414
+ const match = highlightedFileAutocompleteResult.value
2415
+ if (match) {
2416
+ await selectFileAutocompleteResult(match)
2417
+ }
2418
+
2419
+ return
2420
+ }
2421
+
2422
+ if (sendMessageLocked.value || hasPendingRequest.value) {
1778
2423
  return
1779
2424
  }
1780
2425
 
1781
2426
  sendMessageLocked.value = true
1782
- const text = input.value.trim()
2427
+ const rawText = input.value
2428
+ const text = rawText.trim()
1783
2429
  const submittedAttachments = attachments.value.slice()
1784
2430
 
1785
2431
  if (!text && submittedAttachments.length === 0) {
@@ -1787,6 +2433,11 @@ const sendMessage = async () => {
1787
2433
  return
1788
2434
  }
1789
2435
 
2436
+ if (await handleSlashCommandSubmission(rawText, submittedAttachments)) {
2437
+ sendMessageLocked.value = false
2438
+ return
2439
+ }
2440
+
1790
2441
  input.value = ''
1791
2442
  clearAttachments({ revoke: false })
1792
2443
 
@@ -1933,7 +2584,7 @@ const stopActiveTurn = async () => {
1933
2584
  }
1934
2585
 
1935
2586
  const sendStarterPrompt = async (text: string) => {
1936
- if (isBusy.value) {
2587
+ if (isBusy.value || hasPendingRequest.value) {
1937
2588
  return
1938
2589
  }
1939
2590
 
@@ -1941,16 +2592,122 @@ const sendStarterPrompt = async (text: string) => {
1941
2592
  await sendMessage()
1942
2593
  }
1943
2594
 
2595
+ const respondToPendingRequest = (response: unknown) => {
2596
+ error.value = null
2597
+ resolveCurrentRequest(response)
2598
+ }
2599
+
2600
+ const onPromptKeydown = (event: KeyboardEvent) => {
2601
+ if (fileAutocompleteOpen.value) {
2602
+ if (event.key === 'ArrowDown') {
2603
+ if (visibleFileAutocompleteResults.value.length === 0) {
2604
+ return
2605
+ }
2606
+
2607
+ event.preventDefault()
2608
+ moveFileAutocompleteHighlight(1)
2609
+ return
2610
+ }
2611
+
2612
+ if (event.key === 'ArrowUp') {
2613
+ if (visibleFileAutocompleteResults.value.length === 0) {
2614
+ return
2615
+ }
2616
+
2617
+ event.preventDefault()
2618
+ moveFileAutocompleteHighlight(-1)
2619
+ return
2620
+ }
2621
+
2622
+ if (event.key === 'Escape') {
2623
+ event.preventDefault()
2624
+ dismissedFileAutocompleteMatchKey.value = activeFileAutocompleteMatchKey.value
2625
+ isPromptFocused.value = true
2626
+ void focusPromptAt(promptSelectionStart.value)
2627
+ return
2628
+ }
2629
+
2630
+ if (event.key === 'Tab') {
2631
+ const match = highlightedFileAutocompleteResult.value
2632
+ if (!match) {
2633
+ return
2634
+ }
2635
+
2636
+ event.preventDefault()
2637
+ void selectFileAutocompleteResult(match)
2638
+ return
2639
+ }
2640
+ }
2641
+
2642
+ if (!slashDropdownOpen.value) {
2643
+ return
2644
+ }
2645
+
2646
+ if (event.key === 'ArrowDown') {
2647
+ event.preventDefault()
2648
+ moveSlashHighlight(1)
2649
+ return
2650
+ }
2651
+
2652
+ if (event.key === 'ArrowUp') {
2653
+ event.preventDefault()
2654
+ moveSlashHighlight(-1)
2655
+ return
2656
+ }
2657
+
2658
+ if (event.key === 'Escape') {
2659
+ event.preventDefault()
2660
+ dismissedSlashMatchKey.value = activeSlashMatchKey.value
2661
+ isPromptFocused.value = true
2662
+ void focusPromptAt(promptSelectionStart.value)
2663
+ return
2664
+ }
2665
+
2666
+ if (event.key === 'Tab' || event.key === ' ') {
2667
+ const command = highlightedSlashCommand.value
2668
+ if (!command || (event.key === ' ' && !command.completeOnSpace)) {
2669
+ return
2670
+ }
2671
+
2672
+ event.preventDefault()
2673
+ void activateSlashCommand(command, 'complete')
2674
+ }
2675
+ }
2676
+
1944
2677
  const onPromptEnter = (event: KeyboardEvent) => {
1945
2678
  if (!shouldSubmit(event)) {
1946
2679
  return
1947
2680
  }
1948
2681
 
2682
+ if (fileAutocompleteOpen.value) {
2683
+ event.preventDefault()
2684
+ const match = highlightedFileAutocompleteResult.value
2685
+ if (match) {
2686
+ void selectFileAutocompleteResult(match)
2687
+ }
2688
+
2689
+ return
2690
+ }
2691
+
2692
+ if (slashDropdownOpen.value) {
2693
+ const command = highlightedSlashCommand.value
2694
+ if (command) {
2695
+ event.preventDefault()
2696
+ const isExactMatch = activeSlashMatch.value?.query === command.name
2697
+ const action = isExactMatch && command.executeOnEnter && !command.supportsInlineArgs
2698
+ ? 'execute'
2699
+ : 'complete'
2700
+ void activateSlashCommand(command, action)
2701
+ return
2702
+ }
2703
+ }
2704
+
1949
2705
  event.preventDefault()
1950
2706
  void sendMessage()
1951
2707
  }
1952
2708
 
1953
2709
  onMounted(() => {
2710
+ releaseServerRequestHandler = getClient(props.projectId).setServerRequestHandler(handleServerRequest)
1954
2711
  if (!loaded.value) {
1955
2712
  void refreshProjects()
1956
2713
  }
@@ -1959,6 +2716,12 @@ onMounted(() => {
1959
2716
  void scheduleScrollToBottom('auto')
1960
2717
  })
1961
2718
 
2719
+ onBeforeUnmount(() => {
2720
+ cancelAllPendingRequests()
2721
+ releaseServerRequestHandler?.()
2722
+ releaseServerRequestHandler = null
2723
+ })
2724
+
1962
2725
  watch(() => props.threadId ?? null, (threadId) => {
1963
2726
  if (!threadId) {
1964
2727
  if (isBusy.value || pendingThreadId.value) {
@@ -2010,6 +2773,40 @@ watch(status, (nextStatus, previousStatus) => {
2010
2773
  void scheduleScrollToBottom(nextStatus === 'streaming' ? 'auto' : 'smooth')
2011
2774
  }, { flush: 'post' })
2012
2775
 
2776
+ watch(filteredSlashCommands, (commands) => {
2777
+ if (!commands.length) {
2778
+ slashHighlightIndex.value = 0
2779
+ return
2780
+ }
2781
+
2782
+ if (slashHighlightIndex.value >= commands.length) {
2783
+ slashHighlightIndex.value = 0
2784
+ }
2785
+ }, { flush: 'sync' })
2786
+
2787
+ watch(visibleFileAutocompleteResults, (results) => {
2788
+ if (!results.length) {
2789
+ fileAutocompleteHighlightIndex.value = 0
2790
+ return
2791
+ }
2792
+
2793
+ if (fileAutocompleteHighlightIndex.value >= results.length) {
2794
+ fileAutocompleteHighlightIndex.value = 0
2795
+ }
2796
+ }, { flush: 'sync' })
2797
+
2798
+ watch(
2799
+ [fileAutocompleteOpen, fileAutocompleteHighlightIndex, visibleFileAutocompleteResults],
2800
+ async ([open, highlightIndex, results]) => {
2801
+ if (!open || !results.length || highlightIndex >= results.length) {
2802
+ return
2803
+ }
2804
+
2805
+ await syncFileAutocompleteScroll()
2806
+ },
2807
+ { flush: 'post' }
2808
+ )
2809
+
2013
2810
  watch([selectedModel, availableModels], () => {
2014
2811
  const nextSelection = coercePromptSelection(effectiveModelList.value, selectedModel.value, selectedEffort.value)
2015
2812
  if (selectedModel.value !== nextSelection.model) {
@@ -2021,6 +2818,71 @@ watch([selectedModel, availableModels], () => {
2021
2818
  selectedEffort.value = nextSelection.effort
2022
2819
  }
2023
2820
  }, { flush: 'sync' })
2821
+
2822
+ watch(input, async () => {
2823
+ await nextTick()
2824
+ syncPromptSelectionFromDom()
2825
+ }, { flush: 'post' })
2826
+
2827
+ watch(
2828
+ [fileAutocompleteOpen, normalizedFileAutocompleteQuery, () => selectedProject.value?.projectPath ?? null],
2829
+ async ([open, query, projectPath]) => {
2830
+ const requestSequence = ++fileAutocompleteRequestSequence
2831
+
2832
+ if (!open) {
2833
+ fileAutocompleteLoading.value = false
2834
+ fileAutocompleteError.value = null
2835
+ fileAutocompleteResults.value = []
2836
+ return
2837
+ }
2838
+
2839
+ if (!projectPath) {
2840
+ fileAutocompleteLoading.value = false
2841
+ fileAutocompleteError.value = null
2842
+ fileAutocompleteResults.value = []
2843
+ return
2844
+ }
2845
+
2846
+ fileAutocompleteError.value = null
2847
+ if (!query) {
2848
+ fileAutocompleteLoading.value = false
2849
+ fileAutocompleteResults.value = []
2850
+ return
2851
+ }
2852
+
2853
+ fileAutocompleteLoading.value = true
2854
+ fileAutocompleteResults.value = []
2855
+
2856
+ try {
2857
+ await ensureProjectRuntime()
2858
+ const response = await getClient(props.projectId).request('fuzzyFileSearch', {
2859
+ query,
2860
+ roots: [projectPath],
2861
+ cancellationToken: `chat-file-autocomplete:${props.projectId}`
2862
+ })
2863
+
2864
+ if (requestSequence !== fileAutocompleteRequestSequence) {
2865
+ return
2866
+ }
2867
+
2868
+ fileAutocompleteResults.value = normalizeFuzzyFileSearchMatches(response)
2869
+ } catch (caughtError) {
2870
+ if (requestSequence !== fileAutocompleteRequestSequence) {
2871
+ return
2872
+ }
2873
+
2874
+ fileAutocompleteResults.value = []
2875
+ fileAutocompleteError.value = caughtError instanceof Error
2876
+ ? caughtError.message
2877
+ : 'Failed to search project files.'
2878
+ } finally {
2879
+ if (requestSequence === fileAutocompleteRequestSequence) {
2880
+ fileAutocompleteLoading.value = false
2881
+ }
2882
+ }
2883
+ },
2884
+ { flush: 'post' }
2885
+ )
2024
2886
  </script>
2025
2887
 
2026
2888
  <template>
@@ -2120,6 +2982,13 @@ watch([selectedModel, availableModels], () => {
2120
2982
  class="mb-3"
2121
2983
  />
2122
2984
 
2985
+ <div
2986
+ v-if="pendingRequest"
2987
+ class="mb-3 rounded-2xl border border-primary/30 bg-primary/8 px-4 py-3 text-sm text-default"
2988
+ >
2989
+ Codex is waiting for the response in the drawer below. Normal chat sending is paused until you answer it.
2990
+ </div>
2991
+
2123
2992
  <div
2124
2993
  class="relative"
2125
2994
  @dragenter="onDragEnter"
@@ -2134,14 +3003,113 @@ watch([selectedModel, availableModels], () => {
2134
3003
  Drop images to attach them
2135
3004
  </div>
2136
3005
 
3006
+ <div
3007
+ v-if="fileAutocompleteOpen"
3008
+ ref="fileAutocompleteDropdownRef"
3009
+ role="listbox"
3010
+ aria-label="Project files"
3011
+ class="absolute bottom-full left-0 z-20 mb-2 w-[90vw] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-default bg-default/95 shadow-2xl backdrop-blur md:w-[min(50vw,52rem)] md:max-w-[min(50vw,52rem)]"
3012
+ @pointerdown="handleFileAutocompletePointerDown"
3013
+ >
3014
+ <div
3015
+ ref="fileAutocompleteListRef"
3016
+ class="max-h-72 overflow-y-auto p-2"
3017
+ >
3018
+ <div
3019
+ v-if="fileAutocompleteEmptyState"
3020
+ class="px-3 py-2 text-sm leading-6 text-muted"
3021
+ >
3022
+ {{ fileAutocompleteEmptyState }}
3023
+ </div>
3024
+ <div
3025
+ v-for="(match, index) in visibleFileAutocompleteResults"
3026
+ v-else
3027
+ :key="`${match.matchType}:${match.path}`"
3028
+ role="option"
3029
+ :aria-selected="index === fileAutocompleteHighlightIndex"
3030
+ data-file-autocomplete-option=""
3031
+ class="group relative flex cursor-pointer items-center gap-1.5 px-3 py-2 text-sm text-highlighted outline-none transition-colors before:absolute before:inset-px before:z-[-1] before:rounded-md"
3032
+ :class="index === fileAutocompleteHighlightIndex
3033
+ ? 'before:bg-elevated'
3034
+ : 'hover:before:bg-elevated/60'"
3035
+ @click="void selectFileAutocompleteResult(match)"
3036
+ >
3037
+ <UIcon
3038
+ :name="resolveFileAutocompleteIcon(match)"
3039
+ class="size-4 shrink-0 text-dimmed group-aria-selected:text-highlighted"
3040
+ />
3041
+ <div class="min-w-0 truncate font-mono text-[13px] leading-6">
3042
+ <span
3043
+ v-for="(segment, segmentIndex) in renderFileAutocompletePathSegments(match)"
3044
+ :key="`${match.path}:${segmentIndex}:${segment.isMatch ? 'match' : 'plain'}`"
3045
+ class="truncate"
3046
+ :class="segment.isMatch ? 'font-semibold text-primary' : 'text-toned'"
3047
+ >
3048
+ {{ segment.text }}
3049
+ </span>
3050
+ </div>
3051
+ </div>
3052
+ </div>
3053
+ </div>
3054
+
3055
+ <div
3056
+ v-if="slashDropdownOpen"
3057
+ ref="slashDropdownRef"
3058
+ role="listbox"
3059
+ aria-label="Slash commands"
3060
+ class="absolute bottom-full left-0 z-20 mb-2 w-[90vw] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-default bg-default/95 shadow-2xl backdrop-blur md:w-[min(50vw,52rem)] md:max-w-[min(50vw,52rem)]"
3061
+ @pointerdown="handleSlashDropdownPointerDown"
3062
+ >
3063
+ <!-- Do not swap this back to a focus-managing listbox/palette. Slash
3064
+ suggestions are anchored to the textarea selection state; the
3065
+ popup must remain an inert overlay or the first `/` can move
3066
+ focus out of the composer and permanently collapse matching. -->
3067
+ <div class="max-h-72 space-y-1 overflow-y-auto p-2">
3068
+ <div
3069
+ v-for="(command, index) in filteredSlashCommands"
3070
+ :key="command.name"
3071
+ role="option"
3072
+ :aria-selected="index === slashHighlightIndex"
3073
+ data-slash-command-option=""
3074
+ class="group relative flex cursor-pointer items-start gap-1.5 px-3 py-2.5 text-sm text-highlighted outline-none transition-colors before:absolute before:inset-px before:z-[-1] before:rounded-md"
3075
+ :class="index === slashHighlightIndex
3076
+ ? 'before:bg-elevated'
3077
+ : 'hover:before:bg-elevated/60'"
3078
+ @click="void activateSlashCommand(command, command.supportsInlineArgs ? 'complete' : 'execute')"
3079
+ >
3080
+ <UIcon
3081
+ :name="resolveSlashCommandIcon(command)"
3082
+ class="mt-0.5 size-4 shrink-0 text-dimmed group-aria-selected:text-highlighted"
3083
+ />
3084
+ <div class="min-w-0">
3085
+ <div class="text-sm font-medium">
3086
+ /{{ command.name }}
3087
+ </div>
3088
+ <div class="text-xs leading-5 text-toned">
3089
+ {{ command.description }}
3090
+ </div>
3091
+ </div>
3092
+ </div>
3093
+ </div>
3094
+ </div>
3095
+
2137
3096
  <UChatPrompt
3097
+ ref="chatPromptRef"
2138
3098
  v-model="input"
2139
- placeholder="Describe the change you want Codex to make"
3099
+ :placeholder="composerPlaceholder"
2140
3100
  :error="submitError"
2141
3101
  :disabled="isComposerDisabled"
2142
3102
  autoresize
2143
3103
  @submit.prevent="sendMessage"
3104
+ @keydown.enter.exact.capture="onPromptEnterCapture"
3105
+ @keydown="onPromptKeydown"
2144
3106
  @keydown.enter="onPromptEnter"
3107
+ @input="syncPromptSelectionFromDom"
3108
+ @click="syncPromptSelectionFromDom"
3109
+ @keyup="syncPromptSelectionFromDom"
3110
+ @focus="isPromptFocused = true; syncPromptSelectionFromDom()"
3111
+ @blur="handlePromptBlur"
3112
+ @select="syncPromptSelectionFromDom"
2145
3113
  @compositionstart="onCompositionStart"
2146
3114
  @compositionend="onCompositionEnd"
2147
3115
  @paste="onPaste"
@@ -2353,4 +3321,32 @@ watch([selectedModel, availableModels], () => {
2353
3321
  </div>
2354
3322
  </div>
2355
3323
  </section>
3324
+
3325
+ <PendingUserRequestDrawer
3326
+ :request="pendingRequest"
3327
+ @respond="respondToPendingRequest"
3328
+ />
3329
+
3330
+ <LocalFileViewerModal />
3331
+
3332
+ <ReviewStartDrawer
3333
+ :open="reviewDrawerOpen"
3334
+ :mode="reviewDrawerMode"
3335
+ :branches="reviewBaseBranches"
3336
+ :current-branch="reviewCurrentBranch"
3337
+ :loading="reviewBranchesLoading"
3338
+ :submitting="reviewStartPending"
3339
+ :error="reviewBranchesError"
3340
+ @update:open="handleReviewDrawerOpenChange"
3341
+ @choose-current-changes="startReview({ type: 'uncommittedChanges' })"
3342
+ @choose-base-branch-mode="openBaseBranchPicker"
3343
+ @choose-base-branch="(branch) => startReview({ type: 'baseBranch', branch })"
3344
+ @back="handleReviewDrawerBack"
3345
+ />
3346
+
3347
+ <UsageStatusModal
3348
+ :project-id="props.projectId"
3349
+ :open="usageStatusModalOpen"
3350
+ @update:open="handleUsageStatusOpenChange"
3351
+ />
2356
3352
  </template>