@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.
- package/app/assets/css/main.css +22 -0
- package/app/components/BottomDrawerShell.vue +62 -0
- package/app/components/ChatWorkspace.vue +1009 -13
- package/app/components/LocalFileViewerModal.vue +314 -0
- package/app/components/MessagePartRenderer.ts +1 -0
- package/app/components/PendingUserRequestDrawer.vue +88 -0
- package/app/components/ProjectStatusDot.vue +1 -2
- package/app/components/ReviewStartDrawer.vue +186 -0
- package/app/components/SubagentTranscriptPanel.vue +5 -1
- package/app/components/UsageStatusModal.vue +265 -0
- package/app/components/VisualSubagentStack.vue +2 -0
- package/app/components/message-part/Event.vue +42 -8
- package/app/components/message-part/LocalFileLink.vue +51 -0
- package/app/components/message-part/ReviewPriorityBadge.vue +38 -0
- package/app/components/message-part/Text.vue +159 -10
- package/app/components/pending-request/McpElicitationForm.vue +264 -0
- package/app/components/pending-request/McpElicitationUrlPrompt.vue +100 -0
- package/app/components/pending-request/RequestUserInputForm.vue +235 -0
- package/app/composables/useChatSession.ts +1 -0
- package/app/composables/useLocalFileViewer.ts +46 -0
- package/app/composables/usePendingUserRequest.ts +124 -0
- package/app/composables/useThreadSummaries.ts +28 -2
- package/app/pages/index.vue +0 -1
- package/app/pages/projects/[...projectId]/threads/[threadId].vue +2 -0
- package/app/utils/chat-turn-engagement.ts +11 -2
- package/app/utils/review-priority-badge.ts +90 -0
- package/app/utils/slash-prompt-focus.ts +8 -0
- package/package.json +8 -1
- package/server/api/codori/projects/[projectId]/git/branches.get.ts +15 -0
- package/server/api/codori/projects/[projectId]/local-file.get.ts +44 -0
- package/shared/account-rate-limits.ts +190 -0
- package/shared/codex-chat.ts +72 -2
- package/shared/codex-rpc.ts +79 -3
- package/shared/codori.ts +20 -0
- package/shared/file-autocomplete.ts +166 -0
- package/shared/file-highlighting.ts +127 -0
- package/shared/local-files.ts +122 -0
- package/shared/pending-user-request.ts +374 -0
- package/shared/slash-commands.ts +97 -0
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { useRouter } from '#imports'
|
|
3
|
-
import {
|
|
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 {
|
|
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 {
|
|
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 !==
|
|
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
|
|
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,
|
|
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-${
|
|
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
|
-
|
|
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
|
|
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="
|
|
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>
|