@codori/client 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,27 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue'
3
+ import { useProjects } from '../composables/useProjects'
3
4
 
4
5
  const sidebarCollapsed = ref(false)
6
+ const { serviceUpdate, serviceUpdatePending, triggerServiceUpdate } = useProjects()
7
+
8
+ const showServiceUpdateButton = computed(() =>
9
+ serviceUpdate.value.enabled && (serviceUpdate.value.updateAvailable || serviceUpdate.value.updating)
10
+ )
11
+
12
+ const serviceUpdateLabel = computed(() =>
13
+ serviceUpdate.value.updating ? 'Updating' : 'Update'
14
+ )
15
+
16
+ const serviceUpdateTooltip = computed(() => {
17
+ if (!serviceUpdate.value.latestVersion || !serviceUpdate.value.installedVersion) {
18
+ return serviceUpdate.value.updating ? 'Applying the latest server package update.' : 'Install the latest @codori/server package.'
19
+ }
20
+
21
+ return serviceUpdate.value.updating
22
+ ? `Updating @codori/server ${serviceUpdate.value.installedVersion} -> ${serviceUpdate.value.latestVersion}`
23
+ : `Update @codori/server ${serviceUpdate.value.installedVersion} -> ${serviceUpdate.value.latestVersion}`
24
+ })
5
25
 
6
26
  const sidebarUi = computed(() =>
7
27
  sidebarCollapsed.value
@@ -46,13 +66,44 @@ const sidebarUi = computed(() =>
46
66
  class="size-5"
47
67
  />
48
68
  </div>
49
- <div v-if="!collapsed">
50
- <div class="text-sm font-semibold">
51
- Codori
69
+ <div
70
+ v-if="!collapsed"
71
+ class="flex min-w-0 flex-1 items-start justify-between gap-3"
72
+ >
73
+ <div class="min-w-0">
74
+ <div class="text-sm font-semibold">
75
+ Codori
76
+ </div>
77
+ <div class="text-xs text-muted">
78
+ Codex project control
79
+ </div>
52
80
  </div>
53
- <div class="text-xs text-muted">
81
+ <UTooltip
82
+ v-if="showServiceUpdateButton"
83
+ :text="serviceUpdateTooltip"
84
+ >
85
+ <UButton
86
+ color="neutral"
87
+ variant="outline"
88
+ size="xs"
89
+ :loading="serviceUpdatePending || serviceUpdate.updating"
90
+ :disabled="serviceUpdatePending || serviceUpdate.updating"
91
+ @click="triggerServiceUpdate"
92
+ >
93
+ {{ serviceUpdateLabel }}
94
+ </UButton>
95
+ </UTooltip>
96
+ </div>
97
+ <div
98
+ v-else-if="collapsed"
99
+ class="sr-only"
100
+ >
101
+ <span>
102
+ Codori
103
+ </span>
104
+ <span>
54
105
  Codex project control
55
- </div>
106
+ </span>
56
107
  </div>
57
108
  </div>
58
109
  </template>
@@ -54,7 +54,6 @@
54
54
  </div>
55
55
  </div>
56
56
 
57
- <TunnelNotice class="w-full text-left" />
58
57
  </div>
59
58
  </div>
60
59
  </template>
@@ -7,7 +7,7 @@ import { normalizeProjectIdParam } from '~~/shared/codori'
7
7
 
8
8
  const route = useRoute()
9
9
  const router = useRouter()
10
- const { openPanel } = useThreadPanel()
10
+ const { togglePanel } = useThreadPanel()
11
11
  const { loaded, refreshProjects, getProject, pendingProjectId } = useProjects()
12
12
 
13
13
  const projectId = computed(() => normalizeProjectIdParam(route.params.projectId as string | string[] | undefined))
@@ -83,7 +83,7 @@ onMounted(() => {
83
83
  color="neutral"
84
84
  variant="outline"
85
85
  square
86
- @click="openPanel"
86
+ @click="togglePanel"
87
87
  />
88
88
  </UTooltip>
89
89
  </div>
@@ -1,15 +1,22 @@
1
1
  <script setup lang="ts">
2
2
  import { useRoute, useRouter } from '#imports'
3
- import { computed, onMounted, ref, watch } from 'vue'
3
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
4
4
  import { useChatSession } from '../../../../composables/useChatSession'
5
5
  import { useProjects } from '../../../../composables/useProjects'
6
6
  import { useThreadPanel } from '../../../../composables/useThreadPanel'
7
7
  import { useVisualSubagentPanels } from '../../../../composables/useVisualSubagentPanels'
8
8
  import { normalizeProjectIdParam, toProjectRoute } from '~~/shared/codori'
9
+ import {
10
+ pruneExpandedSubagentThreadId,
11
+ resolveExpandedSubagentPanel,
12
+ resolveSubagentAccent,
13
+ resolveSubagentPanelAutoOpen,
14
+ toSubagentAvatarText
15
+ } from '~~/shared/subagent-panels'
9
16
 
10
17
  const route = useRoute()
11
18
  const router = useRouter()
12
- const { openPanel } = useThreadPanel()
19
+ const { togglePanel } = useThreadPanel()
13
20
  const { loaded, refreshProjects, getProject, pendingProjectId } = useProjects()
14
21
 
15
22
  const projectId = computed(() => normalizeProjectIdParam(route.params.projectId as string | string[] | undefined))
@@ -51,41 +58,81 @@ const rpcStatus = computed(() => {
51
58
  }
52
59
  })
53
60
  const isSubagentsPanelOpen = ref(false)
61
+ const isMobileSubagentsDrawerOpen = ref(false)
62
+ const expandedSubagentThreadId = ref<string | null>(null)
54
63
  const hasUserToggledSubagentsPanel = ref(false)
55
64
  const hasResolvedSubagentPanelState = ref(false)
56
65
  const previousActiveSubagentCount = ref(0)
66
+ const isMobileViewport = ref(true)
57
67
  const hasAvailableSubagents = computed(() => availablePanels.value.length > 0)
58
- const isSubagentsPanelVisible = computed(() =>
59
- isSubagentsPanelOpen.value && hasAvailableSubagents.value
68
+ const isDesktopSubagentsPanelVisible = computed(() =>
69
+ !isMobileViewport.value && isSubagentsPanelOpen.value && hasAvailableSubagents.value
60
70
  )
61
- const subagentsToggleIcon = computed(() =>
62
- isSubagentsPanelVisible.value
63
- ? 'i-lucide-panel-right-close'
64
- : 'i-lucide-panel-right-open'
71
+ const isSubagentsSurfaceOpen = computed(() =>
72
+ isMobileViewport.value ? isMobileSubagentsDrawerOpen.value : isDesktopSubagentsPanelVisible.value
73
+ )
74
+ const subagentsToggleIcon = computed(() => 'i-lucide-bot')
75
+ const subagentsToggleLabel = computed(() =>
76
+ isMobileViewport.value
77
+ ? (isMobileSubagentsDrawerOpen.value ? 'Hide subagents' : 'Show subagents')
78
+ : (isDesktopSubagentsPanelVisible.value ? 'Hide subagents' : 'Show subagents')
65
79
  )
66
- const toSubagentAvatarText = (name: string) => {
67
- const normalized = name.replace(/\s+/g, '').trim()
68
- return Array.from(normalized || 'AG').slice(0, 2).join('')
69
- }
70
80
  const subagentAvatarItems = computed(() =>
71
81
  availablePanels.value.map((panel, index) => ({
72
82
  threadId: panel.threadId,
73
83
  name: panel.name,
74
84
  text: toSubagentAvatarText(panel.name),
75
- class: [
76
- 'bg-emerald-500/15 text-emerald-700 ring-1 ring-inset ring-emerald-500/30 dark:text-emerald-300',
77
- 'bg-sky-500/15 text-sky-700 ring-1 ring-inset ring-sky-500/30 dark:text-sky-300',
78
- 'bg-amber-500/15 text-amber-800 ring-1 ring-inset ring-amber-500/35 dark:text-amber-300',
79
- 'bg-rose-500/15 text-rose-700 ring-1 ring-inset ring-rose-500/30 dark:text-rose-300',
80
- 'bg-violet-500/15 text-violet-700 ring-1 ring-inset ring-violet-500/30 dark:text-violet-300'
81
- ][index % 5]
85
+ class: resolveSubagentAccent(index).avatarClass
82
86
  }))
83
87
  )
88
+ const expandedSubagentPanel = computed(() =>
89
+ resolveExpandedSubagentPanel(availablePanels.value, expandedSubagentThreadId.value)
90
+ )
91
+ const expandedSubagentAccent = computed(() => {
92
+ if (!expandedSubagentPanel.value) {
93
+ return null
94
+ }
95
+
96
+ const index = availablePanels.value.findIndex(panel => panel.threadId === expandedSubagentPanel.value?.threadId)
97
+ return index >= 0 ? resolveSubagentAccent(index) : null
98
+ })
99
+ const isExpandedSubagentOpen = computed({
100
+ get: () => expandedSubagentPanel.value !== null,
101
+ set: (open) => {
102
+ if (!open) {
103
+ expandedSubagentThreadId.value = null
104
+ }
105
+ }
106
+ })
107
+
108
+ let viewportQuery: MediaQueryList | null = null
109
+ let removeViewportListener: (() => void) | null = null
110
+
111
+ const syncViewportMode = (mobile: boolean) => {
112
+ isMobileViewport.value = mobile
113
+
114
+ if (mobile) {
115
+ isSubagentsPanelOpen.value = false
116
+ return
117
+ }
118
+
119
+ isMobileSubagentsDrawerOpen.value = false
120
+
121
+ if (hasAvailableSubagents.value && !hasUserToggledSubagentsPanel.value && activePanels.value.length > 0) {
122
+ isSubagentsPanelOpen.value = true
123
+ }
124
+ }
84
125
 
85
126
  const toggleSubagentsPanel = () => {
86
127
  if (!hasAvailableSubagents.value) {
87
128
  return
88
129
  }
130
+
131
+ if (isMobileViewport.value) {
132
+ isMobileSubagentsDrawerOpen.value = !isMobileSubagentsDrawerOpen.value
133
+ return
134
+ }
135
+
89
136
  hasUserToggledSubagentsPanel.value = true
90
137
  isSubagentsPanelOpen.value = !isSubagentsPanelOpen.value
91
138
  }
@@ -95,6 +142,15 @@ const closeSubagentsPanel = () => {
95
142
  isSubagentsPanelOpen.value = false
96
143
  }
97
144
 
145
+ const openExpandedSubagent = (subagentThreadId: string) => {
146
+ expandedSubagentThreadId.value = subagentThreadId
147
+ isMobileSubagentsDrawerOpen.value = false
148
+ }
149
+
150
+ const closeExpandedSubagent = () => {
151
+ expandedSubagentThreadId.value = null
152
+ }
153
+
98
154
  const onNewThread = async () => {
99
155
  if (!projectId.value) {
100
156
  return
@@ -107,6 +163,26 @@ onMounted(() => {
107
163
  if (!loaded.value) {
108
164
  void refreshProjects()
109
165
  }
166
+
167
+ if (!import.meta.client) {
168
+ return
169
+ }
170
+
171
+ viewportQuery = window.matchMedia('(max-width: 767px)')
172
+ syncViewportMode(viewportQuery.matches)
173
+
174
+ const handleViewportChange = (event: MediaQueryListEvent) => {
175
+ syncViewportMode(event.matches)
176
+ }
177
+
178
+ viewportQuery.addEventListener('change', handleViewportChange)
179
+ removeViewportListener = () => {
180
+ viewportQuery?.removeEventListener('change', handleViewportChange)
181
+ }
182
+ })
183
+
184
+ onBeforeUnmount(() => {
185
+ removeViewportListener?.()
110
186
  })
111
187
 
112
188
  watch(threadId, () => {
@@ -114,6 +190,8 @@ watch(threadId, () => {
114
190
  hasResolvedSubagentPanelState.value = false
115
191
  previousActiveSubagentCount.value = 0
116
192
  isSubagentsPanelOpen.value = false
193
+ isMobileSubagentsDrawerOpen.value = false
194
+ expandedSubagentThreadId.value = null
117
195
  }, { immediate: true })
118
196
 
119
197
  watch(hasAvailableSubagents, (value) => {
@@ -122,31 +200,33 @@ watch(hasAvailableSubagents, (value) => {
122
200
  hasResolvedSubagentPanelState.value = false
123
201
  previousActiveSubagentCount.value = 0
124
202
  isSubagentsPanelOpen.value = false
203
+ isMobileSubagentsDrawerOpen.value = false
204
+ expandedSubagentThreadId.value = null
125
205
  }
126
206
  }, { immediate: true })
127
207
 
208
+ watch(availablePanels, (panels) => {
209
+ expandedSubagentThreadId.value = pruneExpandedSubagentThreadId(panels, expandedSubagentThreadId.value)
210
+ }, { immediate: true })
211
+
128
212
  watch(
129
213
  () => activePanels.value.length,
130
214
  (nextCount) => {
131
- if (!hasAvailableSubagents.value) {
132
- return
133
- }
215
+ const nextState = resolveSubagentPanelAutoOpen({
216
+ isMobile: isMobileViewport.value,
217
+ hasAvailableSubagents: hasAvailableSubagents.value,
218
+ hasResolvedState: hasResolvedSubagentPanelState.value,
219
+ hasUserToggled: hasUserToggledSubagentsPanel.value,
220
+ previousActiveCount: previousActiveSubagentCount.value,
221
+ nextActiveCount: nextCount
222
+ })
134
223
 
135
- if (!hasResolvedSubagentPanelState.value) {
136
- hasResolvedSubagentPanelState.value = true
137
- previousActiveSubagentCount.value = nextCount
138
- isSubagentsPanelOpen.value = nextCount > 0
139
- return
140
- }
224
+ hasResolvedSubagentPanelState.value = nextState.hasResolvedState
225
+ previousActiveSubagentCount.value = nextState.previousActiveCount
141
226
 
142
- if (!hasUserToggledSubagentsPanel.value
143
- && previousActiveSubagentCount.value === 0
144
- && nextCount > 0
145
- ) {
146
- isSubagentsPanelOpen.value = true
227
+ if (nextState.nextOpen !== null) {
228
+ isSubagentsPanelOpen.value = nextState.nextOpen
147
229
  }
148
-
149
- previousActiveSubagentCount.value = nextCount
150
230
  },
151
231
  { immediate: true }
152
232
  )
@@ -157,10 +237,10 @@ watch(
157
237
  <UDashboardPanel
158
238
  id="thread-shell"
159
239
  class="min-h-0 min-w-0 flex-1"
160
- :default-size="isSubagentsPanelVisible ? 70 : undefined"
161
- :min-size="isSubagentsPanelVisible ? 50 : undefined"
162
- :max-size="isSubagentsPanelVisible ? 75 : undefined"
163
- :resizable="isSubagentsPanelVisible"
240
+ :default-size="isDesktopSubagentsPanelVisible ? 70 : undefined"
241
+ :min-size="isDesktopSubagentsPanelVisible ? 50 : undefined"
242
+ :max-size="isDesktopSubagentsPanelVisible ? 75 : undefined"
243
+ :resizable="isDesktopSubagentsPanelVisible"
164
244
  :ui="{ root: '!p-0', body: '!p-0 sm:!p-0 !gap-0 sm:!gap-0' }"
165
245
  >
166
246
  <template #header>
@@ -188,17 +268,42 @@ watch(
188
268
  </template>
189
269
  <template #right>
190
270
  <div class="flex items-center gap-1.5 lg:gap-2">
271
+ <UTooltip :text="`RPC ${rpcStatus}`">
272
+ <ProjectStatusDot
273
+ :status="rpcStatus"
274
+ pulse
275
+ padded
276
+ />
277
+ </UTooltip>
278
+ <UTooltip text="New thread">
279
+ <UButton
280
+ icon="i-lucide-plus"
281
+ color="primary"
282
+ variant="soft"
283
+ square
284
+ @click="onNewThread"
285
+ />
286
+ </UTooltip>
287
+ <UTooltip text="Previous threads">
288
+ <UButton
289
+ icon="i-lucide-history"
290
+ color="neutral"
291
+ variant="outline"
292
+ square
293
+ @click="togglePanel"
294
+ />
295
+ </UTooltip>
191
296
  <UTooltip
192
297
  v-if="hasAvailableSubagents"
193
298
  text="Subagents"
194
299
  >
195
300
  <UButton
196
- :color="isSubagentsPanelVisible ? 'primary' : 'neutral'"
197
- :variant="isSubagentsPanelVisible ? 'soft' : 'ghost'"
301
+ :color="isSubagentsSurfaceOpen ? 'primary' : 'neutral'"
302
+ :variant="isSubagentsSurfaceOpen ? 'soft' : 'ghost'"
198
303
  :icon="subagentsToggleIcon"
199
304
  size="sm"
200
305
  class="px-2 xl:ps-2 xl:pe-2.5"
201
- :aria-label="isSubagentsPanelVisible ? 'Hide subagents' : 'Show subagents'"
306
+ :aria-label="subagentsToggleLabel"
202
307
  @click="toggleSubagentsPanel"
203
308
  >
204
309
  <UAvatarGroup
@@ -217,31 +322,6 @@ watch(
217
322
  </UAvatarGroup>
218
323
  </UButton>
219
324
  </UTooltip>
220
- <UTooltip :text="`RPC ${rpcStatus}`">
221
- <ProjectStatusDot
222
- :status="rpcStatus"
223
- pulse
224
- padded
225
- />
226
- </UTooltip>
227
- <UTooltip text="New thread">
228
- <UButton
229
- icon="i-lucide-plus"
230
- color="primary"
231
- variant="soft"
232
- square
233
- @click="onNewThread"
234
- />
235
- </UTooltip>
236
- <UTooltip text="Previous threads">
237
- <UButton
238
- icon="i-lucide-history"
239
- color="neutral"
240
- variant="outline"
241
- square
242
- @click="openPanel"
243
- />
244
- </UTooltip>
245
325
  </div>
246
326
  </template>
247
327
  </UDashboardNavbar>
@@ -258,17 +338,20 @@ watch(
258
338
  </UDashboardPanel>
259
339
 
260
340
  <UDashboardPanel
261
- v-if="isSubagentsPanelVisible"
341
+ v-if="isDesktopSubagentsPanelVisible"
262
342
  id="thread-subagents-panel"
263
343
  class="h-full min-h-0"
264
344
  :default-size="30"
265
345
  :min-size="20"
266
346
  :max-size="40"
267
347
  resizable
268
- :ui="{ body: '!p-0' }"
348
+ :ui="{ header: '!p-0 bg-[var(--ui-bg)]', body: '!p-0' }"
269
349
  >
270
350
  <template #header>
271
- <div class="flex items-center justify-between gap-2 px-4 py-3">
351
+ <div
352
+ class="flex items-center justify-between gap-2 border-b border-default px-4 py-3"
353
+ style="background-color: var(--ui-bg);"
354
+ >
272
355
  <div class="flex items-center gap-2">
273
356
  <span class="text-sm font-semibold text-highlighted">Subagents</span>
274
357
  <UBadge
@@ -294,10 +377,80 @@ watch(
294
377
  <VisualSubagentStack
295
378
  :agents="availablePanels"
296
379
  class="h-full min-h-0"
380
+ @expand="openExpandedSubagent"
297
381
  />
298
382
  </template>
299
383
  </UDashboardPanel>
300
384
 
385
+ <UDrawer
386
+ v-if="hasAvailableSubagents"
387
+ v-model:open="isMobileSubagentsDrawerOpen"
388
+ direction="bottom"
389
+ :handle="true"
390
+ :ui="{
391
+ content: 'max-h-[85dvh] rounded-t-2xl md:hidden',
392
+ container: 'gap-0 p-0',
393
+ header: 'px-4 pb-2 pt-4',
394
+ body: '!p-0',
395
+ footer: 'hidden'
396
+ }"
397
+ >
398
+ <template #header>
399
+ <div class="flex items-center justify-between gap-2">
400
+ <div class="flex items-center gap-2">
401
+ <span class="text-sm font-semibold text-highlighted">Subagents</span>
402
+ <UBadge
403
+ color="primary"
404
+ variant="soft"
405
+ size="sm"
406
+ >
407
+ {{ availablePanels.length }}
408
+ </UBadge>
409
+ </div>
410
+ <UButton
411
+ icon="i-lucide-x"
412
+ color="neutral"
413
+ variant="ghost"
414
+ size="xs"
415
+ square
416
+ aria-label="Close subagents drawer"
417
+ @click="isMobileSubagentsDrawerOpen = false"
418
+ />
419
+ </div>
420
+ </template>
421
+
422
+ <template #body>
423
+ <SubagentDrawerList
424
+ :agents="availablePanels"
425
+ @expand="openExpandedSubagent"
426
+ />
427
+ </template>
428
+ </UDrawer>
429
+
430
+ <UModal
431
+ v-model:open="isExpandedSubagentOpen"
432
+ fullscreen
433
+ :ui="{
434
+ header: 'hidden',
435
+ close: 'hidden',
436
+ content: 'overflow-hidden bg-default',
437
+ body: '!h-full !p-0'
438
+ }"
439
+ >
440
+ <template #body>
441
+ <SubagentTranscriptPanel
442
+ v-if="expandedSubagentPanel"
443
+ :agent="expandedSubagentPanel"
444
+ :accent="expandedSubagentAccent"
445
+ scroll-scope="expanded"
446
+ expanded
447
+ show-collapse-button
448
+ class="h-full"
449
+ @collapse="closeExpandedSubagent"
450
+ />
451
+ </template>
452
+ </UModal>
453
+
301
454
  <ThreadPanel :project-id="projectId" />
302
455
  </div>
303
456
  </template>
@@ -0,0 +1,46 @@
1
+ import { upsertStreamingMessage, type ChatMessage } from '../../shared/codex-chat'
2
+
3
+ export type PromptSubmitStatus = 'ready' | 'submitted' | 'streaming' | 'error'
4
+
5
+ const interruptIgnoredMethods = new Set([
6
+ 'item/started',
7
+ 'item/agentMessage/delta',
8
+ 'item/plan/delta',
9
+ 'item/reasoning/textDelta',
10
+ 'item/reasoning/summaryTextDelta',
11
+ 'item/commandExecution/outputDelta',
12
+ 'item/fileChange/outputDelta',
13
+ 'item/mcpToolCall/progress'
14
+ ])
15
+
16
+ export const resolveTurnSubmissionMethod = (hasActiveTurn: boolean) =>
17
+ hasActiveTurn ? 'turn/steer' : 'turn/start'
18
+
19
+ export const resolvePromptSubmitStatus = (input: {
20
+ status: PromptSubmitStatus
21
+ hasDraftContent: boolean
22
+ }) => input.hasDraftContent ? 'ready' : input.status
23
+
24
+ export const shouldIgnoreNotificationAfterInterrupt = (method: string) =>
25
+ interruptIgnoredMethods.has(method)
26
+
27
+ export const removeChatMessage = (messages: ChatMessage[], messageId: string) =>
28
+ messages.filter(message => message.id !== messageId)
29
+
30
+ export const removePendingUserMessageId = (messageIds: string[], messageId: string) =>
31
+ messageIds.filter(candidateId => candidateId !== messageId)
32
+
33
+ export const reconcileOptimisticUserMessage = (
34
+ messages: ChatMessage[],
35
+ optimisticMessageId: string,
36
+ confirmedMessage: ChatMessage
37
+ ) => {
38
+ const index = messages.findIndex(message => message.id === optimisticMessageId)
39
+ if (index === -1) {
40
+ return upsertStreamingMessage(messages, confirmedMessage)
41
+ }
42
+
43
+ return messages.map((message, messageIndex) =>
44
+ messageIndex === index ? confirmedMessage : message
45
+ )
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codori/client",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "private": false,
5
5
  "description": "Codori Nuxt dashboard for project browsing, Codex chat, and thread resume.",
6
6
  "type": "module",
@@ -0,0 +1,62 @@
1
+ import {
2
+ createError,
3
+ defineEventHandler,
4
+ getQuery,
5
+ getRouterParam
6
+ } from 'h3'
7
+ import { encodeProjectIdSegment } from '~~/shared/codori'
8
+ import { proxyServerFetch } from '../../../../../utils/server-proxy'
9
+
10
+ export default defineEventHandler(async (event) => {
11
+ const projectId = getRouterParam(event, 'projectId')
12
+ if (!projectId) {
13
+ throw createError({
14
+ statusCode: 400,
15
+ statusMessage: 'Missing project id.'
16
+ })
17
+ }
18
+
19
+ const queryValue = getQuery(event).path
20
+ const path = typeof queryValue === 'string'
21
+ ? queryValue
22
+ : ''
23
+
24
+ if (!path) {
25
+ throw createError({
26
+ statusCode: 400,
27
+ statusMessage: 'Missing attachment path.'
28
+ })
29
+ }
30
+
31
+ const query = new URLSearchParams()
32
+ query.set('path', path)
33
+ const response = await proxyServerFetch(
34
+ event,
35
+ `/api/projects/${encodeProjectIdSegment(projectId)}/attachments/file?${query.toString()}`
36
+ )
37
+
38
+ if (!response.ok) {
39
+ let statusMessage = 'Attachment preview failed.'
40
+ try {
41
+ const body = await response.json() as { error?: { message?: string } }
42
+ statusMessage = body.error?.message ?? statusMessage
43
+ } catch {
44
+ // Ignore parse failures and fall back to the default message.
45
+ }
46
+
47
+ throw createError({
48
+ statusCode: response.status,
49
+ statusMessage
50
+ })
51
+ }
52
+
53
+ const headers = new Headers()
54
+ for (const [key, value] of response.headers.entries()) {
55
+ headers.set(key, value)
56
+ }
57
+
58
+ return new Response(response.body, {
59
+ status: response.status,
60
+ headers
61
+ })
62
+ })