@codori/client 0.0.8 → 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.
@@ -0,0 +1,314 @@
1
+ <script setup lang="ts">
2
+ import { Comark } from '@comark/vue'
3
+ import highlight from '@comark/vue/plugins/highlight'
4
+ import { computed, nextTick, ref, watch } from 'vue'
5
+ import { useRuntimeConfig } from '#imports'
6
+ import { useLocalFileViewer } from '../composables/useLocalFileViewer'
7
+ import { formatLocalFileSize, resolveProjectLocalFileUrl, type ProjectLocalFileResponse } from '../../shared/local-files'
8
+ import {
9
+ buildHighlightedFileMarkdown,
10
+ inferLocalFileLanguage,
11
+ resolveLocalFileLanguageLabel
12
+ } from '../../shared/file-highlighting'
13
+
14
+ const runtimeConfig = useRuntimeConfig()
15
+ const { state, closeViewer } = useLocalFileViewer()
16
+
17
+ const loading = ref(false)
18
+ const error = ref<string | null>(null)
19
+ const file = ref<ProjectLocalFileResponse['file'] | null>(null)
20
+ const lineContainer = ref<HTMLElement | null>(null)
21
+ const viewerPlugins = [
22
+ highlight({ preStyles: false })
23
+ ]
24
+
25
+ const isOpen = computed({
26
+ get: () => state.value.open,
27
+ set: (open: boolean) => {
28
+ if (!open) {
29
+ closeViewer()
30
+ }
31
+ }
32
+ })
33
+
34
+ const relativePathLabel = computed(() => {
35
+ if (!file.value) {
36
+ return state.value.path ?? ''
37
+ }
38
+
39
+ return file.value.relativePath || file.value.name
40
+ })
41
+
42
+ const lineCount = computed(() => {
43
+ if (!file.value) {
44
+ return 0
45
+ }
46
+
47
+ return file.value.text.split('\n').length
48
+ })
49
+
50
+ const inferredLanguage = computed(() =>
51
+ file.value ? inferLocalFileLanguage(file.value.path, file.value.text) : null
52
+ )
53
+
54
+ const languageLabel = computed(() =>
55
+ resolveLocalFileLanguageLabel(inferredLanguage.value)
56
+ )
57
+
58
+ const highlightedMarkdown = computed(() => {
59
+ if (!file.value) {
60
+ return ''
61
+ }
62
+
63
+ return buildHighlightedFileMarkdown(file.value.text, inferredLanguage.value)
64
+ })
65
+
66
+ const lineNumberWidth = computed(() =>
67
+ `${Math.max(3, String(lineCount.value || 1).length + 1)}ch`
68
+ )
69
+
70
+ const updatedAtLabel = computed(() => {
71
+ if (!file.value) {
72
+ return null
73
+ }
74
+
75
+ return new Intl.DateTimeFormat(undefined, {
76
+ month: 'short',
77
+ day: 'numeric',
78
+ hour: 'numeric',
79
+ minute: '2-digit'
80
+ }).format(new Date(file.value.updatedAt))
81
+ })
82
+
83
+ const syncRenderedCodeLines = async () => {
84
+ if (!lineContainer.value) {
85
+ return
86
+ }
87
+
88
+ await nextTick()
89
+ requestAnimationFrame(() => {
90
+ const renderedLines = Array.from(lineContainer.value?.querySelectorAll<HTMLElement>('.line') ?? [])
91
+ for (const [index, line] of renderedLines.entries()) {
92
+ const lineNumber = index + 1
93
+ line.dataset.fileLine = String(lineNumber)
94
+ line.classList.toggle('is-target-line', state.value.line === lineNumber)
95
+ }
96
+
97
+ const targetLine = state.value.line
98
+ if (!targetLine) {
99
+ return
100
+ }
101
+
102
+ const target = lineContainer.value?.querySelector<HTMLElement>(`[data-file-line="${targetLine}"]`)
103
+ target?.scrollIntoView({
104
+ block: 'center'
105
+ })
106
+ })
107
+ }
108
+
109
+ watch(
110
+ () => [state.value.open, state.value.projectId, state.value.path] as const,
111
+ async ([open, projectId, path]) => {
112
+ if (!open || !projectId || !path) {
113
+ loading.value = false
114
+ error.value = null
115
+ file.value = null
116
+ return
117
+ }
118
+
119
+ loading.value = true
120
+ error.value = null
121
+ file.value = null
122
+
123
+ try {
124
+ const response = await $fetch<ProjectLocalFileResponse>(resolveProjectLocalFileUrl({
125
+ projectId,
126
+ path,
127
+ configuredBase: String(runtimeConfig.public.serverBase ?? '')
128
+ }))
129
+ if (!state.value.open || state.value.projectId !== projectId || state.value.path !== path) {
130
+ return
131
+ }
132
+
133
+ file.value = response.file
134
+ await syncRenderedCodeLines()
135
+ } catch (caughtError) {
136
+ if (!state.value.open || state.value.projectId !== projectId || state.value.path !== path) {
137
+ return
138
+ }
139
+
140
+ error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
141
+ } finally {
142
+ if (state.value.open && state.value.projectId === projectId && state.value.path === path) {
143
+ loading.value = false
144
+ }
145
+ }
146
+ },
147
+ { immediate: true }
148
+ )
149
+
150
+ watch(() => state.value.line, () => {
151
+ if (file.value) {
152
+ void syncRenderedCodeLines()
153
+ }
154
+ })
155
+
156
+ watch(highlightedMarkdown, () => {
157
+ if (file.value) {
158
+ void syncRenderedCodeLines()
159
+ }
160
+ }, { flush: 'post' })
161
+ </script>
162
+
163
+ <template>
164
+ <UModal
165
+ v-model:open="isOpen"
166
+ fullscreen
167
+ :ui="{
168
+ header: 'hidden',
169
+ close: 'hidden',
170
+ content: 'overflow-hidden bg-default',
171
+ body: '!h-full !p-0'
172
+ }"
173
+ >
174
+ <template #body>
175
+ <section class="flex h-full min-h-0 flex-col bg-default">
176
+ <header class="flex items-center justify-between gap-3 border-b border-default px-4 py-3">
177
+ <div class="min-w-0">
178
+ <div class="truncate text-xs font-medium uppercase tracking-[0.22em] text-primary">
179
+ Local File Viewer
180
+ </div>
181
+ <div class="truncate text-sm font-semibold text-highlighted">
182
+ {{ relativePathLabel }}
183
+ </div>
184
+ <div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted">
185
+ <span v-if="file">{{ formatLocalFileSize(file.size) }}</span>
186
+ <span v-if="file">{{ lineCount }} lines</span>
187
+ <span v-if="updatedAtLabel">{{ updatedAtLabel }}</span>
188
+ <span v-if="languageLabel">{{ languageLabel }}</span>
189
+ <span v-if="state.line">Line {{ state.line }}</span>
190
+ </div>
191
+ </div>
192
+
193
+ <UButton
194
+ icon="i-lucide-x"
195
+ color="neutral"
196
+ variant="ghost"
197
+ size="sm"
198
+ aria-label="Close local file viewer"
199
+ @click="closeViewer"
200
+ />
201
+ </header>
202
+
203
+ <div
204
+ v-if="loading"
205
+ class="flex min-h-0 flex-1 items-center justify-center px-6 py-10 text-sm text-muted"
206
+ >
207
+ Loading local file preview...
208
+ </div>
209
+
210
+ <div
211
+ v-else-if="error"
212
+ class="flex min-h-0 flex-1 items-center justify-center px-6 py-10"
213
+ >
214
+ <UAlert
215
+ color="error"
216
+ variant="soft"
217
+ icon="i-lucide-circle-alert"
218
+ :title="error"
219
+ class="w-full max-w-2xl"
220
+ />
221
+ </div>
222
+
223
+ <div
224
+ v-else-if="file"
225
+ ref="lineContainer"
226
+ class="local-file-viewer-code min-h-0 flex-1 overflow-auto bg-elevated/15"
227
+ :style="{ '--lfv-line-number-width': lineNumberWidth }"
228
+ >
229
+ <Suspense>
230
+ <Comark
231
+ class="local-file-viewer-markdown"
232
+ :markdown="highlightedMarkdown"
233
+ :plugins="viewerPlugins"
234
+ />
235
+ </Suspense>
236
+ </div>
237
+ </section>
238
+ </template>
239
+ </UModal>
240
+ </template>
241
+
242
+ <style scoped>
243
+ .local-file-viewer-code :deep(.local-file-viewer-markdown) {
244
+ min-width: max-content;
245
+ }
246
+
247
+ .local-file-viewer-code :deep(.local-file-viewer-markdown > * + *) {
248
+ margin-top: 0;
249
+ }
250
+
251
+ .local-file-viewer-code :deep(pre),
252
+ .local-file-viewer-code :deep(.shiki) {
253
+ margin: 0;
254
+ min-width: max-content;
255
+ border: 0;
256
+ border-radius: 0;
257
+ padding: 0;
258
+ background: transparent !important;
259
+ }
260
+
261
+ .local-file-viewer-code :deep(pre code),
262
+ .local-file-viewer-code :deep(.shiki code) {
263
+ display: block;
264
+ min-width: max-content;
265
+ padding: 0;
266
+ }
267
+
268
+ .local-file-viewer-code :deep(.line) {
269
+ display: block;
270
+ min-width: max-content;
271
+ padding: 0 1.5rem 0 0;
272
+ padding-left: calc(var(--lfv-line-number-width) + 1.5rem);
273
+ position: relative;
274
+ white-space: pre;
275
+ }
276
+
277
+ .local-file-viewer-code :deep(.line::before) {
278
+ content: attr(data-file-line);
279
+ position: absolute;
280
+ top: 0;
281
+ left: 0;
282
+ width: var(--lfv-line-number-width);
283
+ padding-right: 0.75rem;
284
+ text-align: right;
285
+ color: var(--ui-text-muted);
286
+ user-select: none;
287
+ font-variant-numeric: tabular-nums;
288
+ }
289
+
290
+ .local-file-viewer-code :deep(.line.is-target-line) {
291
+ background: color-mix(in srgb, var(--ui-primary) 10%, transparent);
292
+ }
293
+
294
+ .local-file-viewer-code :deep(.line.is-target-line::before) {
295
+ color: var(--ui-primary);
296
+ }
297
+
298
+ .local-file-viewer-code :deep(.shiki),
299
+ .local-file-viewer-code :deep(.shiki code),
300
+ .local-file-viewer-code :deep(pre),
301
+ .local-file-viewer-code :deep(pre code) {
302
+ font-size: 13px;
303
+ line-height: 1.75;
304
+ }
305
+
306
+ :global(.dark) .local-file-viewer-code :deep(.shiki),
307
+ :global(.dark) .local-file-viewer-code :deep(.shiki span) {
308
+ color: var(--shiki-dark) !important;
309
+ background-color: var(--shiki-dark-bg) !important;
310
+ font-style: var(--shiki-dark-font-style) !important;
311
+ font-weight: var(--shiki-dark-font-weight) !important;
312
+ text-decoration: var(--shiki-dark-text-decoration) !important;
313
+ }
314
+ </style>
@@ -32,6 +32,7 @@ export default defineComponent({
32
32
  case 'text':
33
33
  return h(MessagePartText, {
34
34
  role: props.message?.role,
35
+ projectId: props.projectId,
35
36
  part: props.part
36
37
  })
37
38
  case 'reasoning':
@@ -10,6 +10,7 @@ import { resolveSubagentStatusMeta, type SubagentAccent } from '~~/shared/subage
10
10
 
11
11
  const props = withDefaults(defineProps<{
12
12
  agent: VisualSubagentPanel
13
+ projectId?: string | null
13
14
  accent?: SubagentAccent | null
14
15
  expanded?: boolean
15
16
  showExpandButton?: boolean
@@ -271,7 +272,10 @@ onBeforeUnmount(() => {
271
272
  compact
272
273
  >
273
274
  <template #content="{ message }">
274
- <MessageContent :message="message as VisualSubagentPanel['messages'][number]" />
275
+ <MessageContent
276
+ :message="message as VisualSubagentPanel['messages'][number]"
277
+ :project-id="projectId ?? undefined"
278
+ />
275
279
  </template>
276
280
  </UChatMessages>
277
281
  </div>
@@ -0,0 +1,265 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'
3
+ import { useProjects } from '../composables/useProjects'
4
+ import { useRpc } from '../composables/useRpc'
5
+ import {
6
+ normalizeAccountRateLimits,
7
+ formatRateLimitWindowDuration,
8
+ type RateLimitBucket
9
+ } from '../../shared/account-rate-limits'
10
+
11
+ const props = withDefaults(defineProps<{
12
+ projectId: string
13
+ open?: boolean
14
+ }>(), {
15
+ open: false
16
+ })
17
+
18
+ const emit = defineEmits<{
19
+ 'update:open': [open: boolean]
20
+ }>()
21
+
22
+ const { getClient } = useRpc()
23
+ const {
24
+ loaded,
25
+ refreshProjects,
26
+ getProject,
27
+ startProject
28
+ } = useProjects()
29
+ const loading = ref(false)
30
+ const error = ref<string | null>(null)
31
+ const buckets = ref<RateLimitBucket[]>([])
32
+ const selectedProject = computed(() => getProject(props.projectId))
33
+ let usageStatusLoadToken = 0
34
+ let releaseUsageStatusSubscription: (() => void) | null = null
35
+
36
+ const nextResetFormatter = new Intl.DateTimeFormat(undefined, {
37
+ month: 'short',
38
+ day: 'numeric',
39
+ hour: 'numeric',
40
+ minute: '2-digit'
41
+ })
42
+
43
+ const toRemainingPercent = (usedPercent: number | null) =>
44
+ usedPercent == null ? null : Math.max(0, 100 - usedPercent)
45
+
46
+ const formatProgressLabel = (usedPercent: number | null) => {
47
+ const remainingPercent = toRemainingPercent(usedPercent)
48
+ return remainingPercent == null ? 'Remaining quota unavailable' : `${Math.round(remainingPercent)}% remaining`
49
+ }
50
+
51
+ const formatNextResetLabel = (value: string | null) =>
52
+ value ? `Next reset ${nextResetFormatter.format(new Date(value))}` : 'Next reset unavailable'
53
+
54
+ const cards = computed(() =>
55
+ buckets.value.map((bucket) => {
56
+ const windows = [{
57
+ id: `${bucket.limitId}-primary`,
58
+ title: formatRateLimitWindowDuration(bucket.primary?.windowDurationMins ?? null) ?? 'Primary window',
59
+ remainingPercent: toRemainingPercent(bucket.primary?.usedPercent ?? null),
60
+ progressLabel: formatProgressLabel(bucket.primary?.usedPercent ?? null),
61
+ resetsAt: bucket.primary?.resetsAt ?? null,
62
+ nextResetLabel: formatNextResetLabel(bucket.primary?.resetsAt ?? null),
63
+ durationMins: bucket.primary?.windowDurationMins ?? null
64
+ }, {
65
+ id: `${bucket.limitId}-secondary`,
66
+ title: formatRateLimitWindowDuration(bucket.secondary?.windowDurationMins ?? null) ?? 'Secondary window',
67
+ remainingPercent: toRemainingPercent(bucket.secondary?.usedPercent ?? null),
68
+ progressLabel: formatProgressLabel(bucket.secondary?.usedPercent ?? null),
69
+ resetsAt: bucket.secondary?.resetsAt ?? null,
70
+ nextResetLabel: formatNextResetLabel(bucket.secondary?.resetsAt ?? null),
71
+ durationMins: bucket.secondary?.windowDurationMins ?? null
72
+ }]
73
+ .filter((window) =>
74
+ window.remainingPercent != null
75
+ || window.resetsAt != null
76
+ || window.durationMins != null
77
+ )
78
+ .sort((left, right) => (left.durationMins ?? Number.POSITIVE_INFINITY) - (right.durationMins ?? Number.POSITIVE_INFINITY))
79
+
80
+ return {
81
+ id: bucket.limitId,
82
+ label: bucket.limitName ?? bucket.limitId,
83
+ detail: bucket.limitName && bucket.limitName !== bucket.limitId ? bucket.limitId : null,
84
+ windows
85
+ }
86
+ })
87
+ )
88
+
89
+ const resetUsageStatusState = () => {
90
+ loading.value = false
91
+ error.value = null
92
+ buckets.value = []
93
+ }
94
+
95
+ const closeUsageStatus = () => {
96
+ usageStatusLoadToken += 1
97
+ releaseUsageStatusSubscription?.()
98
+ releaseUsageStatusSubscription = null
99
+ resetUsageStatusState()
100
+ }
101
+
102
+ const applyUsageStatusSnapshot = (value: unknown) => {
103
+ buckets.value = normalizeAccountRateLimits(value)
104
+ error.value = null
105
+ }
106
+
107
+ const ensureProjectRuntime = async () => {
108
+ if (!loaded.value) {
109
+ await refreshProjects()
110
+ }
111
+
112
+ if (selectedProject.value?.status === 'running') {
113
+ return
114
+ }
115
+
116
+ await startProject(props.projectId)
117
+ }
118
+
119
+ const openUsageStatus = async () => {
120
+ usageStatusLoadToken += 1
121
+ const loadToken = usageStatusLoadToken
122
+ loading.value = true
123
+ error.value = null
124
+ buckets.value = []
125
+
126
+ const client = getClient(props.projectId)
127
+ releaseUsageStatusSubscription?.()
128
+ releaseUsageStatusSubscription = client.subscribe((notification) => {
129
+ if (notification.method !== 'account/rateLimits/updated' || !props.open) {
130
+ return
131
+ }
132
+
133
+ applyUsageStatusSnapshot(notification.params)
134
+ loading.value = false
135
+ })
136
+
137
+ try {
138
+ await ensureProjectRuntime()
139
+ const response = await client.request('account/rateLimits/read')
140
+ if (!props.open || loadToken !== usageStatusLoadToken) {
141
+ return
142
+ }
143
+
144
+ applyUsageStatusSnapshot(response)
145
+ } catch (caughtError) {
146
+ if (!props.open || loadToken !== usageStatusLoadToken) {
147
+ return
148
+ }
149
+
150
+ error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
151
+ } finally {
152
+ if (loadToken === usageStatusLoadToken) {
153
+ loading.value = false
154
+ }
155
+ }
156
+ }
157
+
158
+ watch(() => [props.open, props.projectId] as const, ([open, projectId], previous) => {
159
+ const [wasOpen, previousProjectId] = previous ?? [false, projectId]
160
+
161
+ if (!open) {
162
+ closeUsageStatus()
163
+ return
164
+ }
165
+
166
+ if (!wasOpen || projectId !== previousProjectId) {
167
+ void openUsageStatus()
168
+ }
169
+ }, { immediate: true })
170
+
171
+ onBeforeUnmount(() => {
172
+ closeUsageStatus()
173
+ })
174
+ </script>
175
+
176
+ <template>
177
+ <UModal
178
+ :open="open"
179
+ title="Usage status"
180
+ @update:open="emit('update:open', $event)"
181
+ >
182
+ <template #body>
183
+ <div class="space-y-3">
184
+ <div
185
+ v-if="loading"
186
+ class="rounded-lg border border-default bg-elevated/40 px-4 py-6 text-sm text-muted"
187
+ >
188
+ Loading current quota windows...
189
+ </div>
190
+
191
+ <UAlert
192
+ v-else-if="error"
193
+ color="error"
194
+ variant="soft"
195
+ icon="i-lucide-circle-alert"
196
+ :title="error"
197
+ />
198
+
199
+ <div
200
+ v-else-if="!cards.length"
201
+ class="rounded-lg border border-dashed border-default px-4 py-6 text-center text-sm text-muted"
202
+ >
203
+ No live quota windows were reported.
204
+ </div>
205
+
206
+ <div
207
+ v-else
208
+ class="space-y-3"
209
+ >
210
+ <section
211
+ v-for="card in cards"
212
+ :key="card.id"
213
+ class="rounded-lg border border-default bg-elevated/35 p-3 sm:p-4"
214
+ >
215
+ <div class="mb-3 min-w-0">
216
+ <div class="min-w-0">
217
+ <h3 class="truncate text-sm font-semibold text-highlighted">
218
+ {{ card.label }}
219
+ </h3>
220
+ <p
221
+ v-if="card.detail"
222
+ class="text-xs text-muted"
223
+ >
224
+ {{ card.detail }}
225
+ </p>
226
+ </div>
227
+ </div>
228
+
229
+ <div class="space-y-2">
230
+ <div
231
+ v-for="window in card.windows"
232
+ :key="window.id"
233
+ class="rounded-lg border border-default/80 bg-default/75 p-3"
234
+ >
235
+ <div class="flex items-start justify-between gap-3">
236
+ <div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">
237
+ {{ window.title }}
238
+ </div>
239
+ <div class="text-sm font-semibold text-highlighted">
240
+ {{ window.progressLabel }}
241
+ </div>
242
+ </div>
243
+
244
+ <div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
245
+ <div
246
+ class="h-full rounded-full bg-primary transition-[width]"
247
+ :style="{ width: `${window.remainingPercent ?? 0}%` }"
248
+ />
249
+ </div>
250
+
251
+ <time
252
+ v-if="window.resetsAt"
253
+ class="mt-2 block text-xs text-muted"
254
+ :datetime="window.resetsAt"
255
+ >
256
+ {{ window.nextResetLabel }}
257
+ </time>
258
+ </div>
259
+ </div>
260
+ </section>
261
+ </div>
262
+ </div>
263
+ </template>
264
+ </UModal>
265
+ </template>
@@ -5,6 +5,7 @@ import { resolveSubagentAccent } from '~~/shared/subagent-panels'
5
5
 
6
6
  const props = defineProps<{
7
7
  agents: VisualSubagentPanel[]
8
+ projectId?: string | null
8
9
  }>()
9
10
 
10
11
  const emit = defineEmits<{
@@ -26,6 +27,7 @@ const paneSize = computed(() => {
26
27
  v-for="(agent, index) in agents"
27
28
  :key="agent.threadId"
28
29
  :agent="agent"
30
+ :project-id="projectId ?? null"
29
31
  :accent="resolveSubagentAccent(index)"
30
32
  scroll-scope="stack"
31
33
  show-expand-button
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useProjects } from '../../composables/useProjects'
4
+ import { useLocalFileViewer } from '../../composables/useLocalFileViewer'
5
+ import { isLocalFileWithinProject, parseLocalFileHref } from '../../../shared/local-files'
6
+
7
+ const props = defineProps<{
8
+ href?: string | null
9
+ title?: string | null
10
+ projectId?: string | null
11
+ }>()
12
+
13
+ const { getProject } = useProjects()
14
+ const { openViewer } = useLocalFileViewer()
15
+
16
+ const parsedTarget = computed(() =>
17
+ props.href ? parseLocalFileHref(props.href) : null
18
+ )
19
+ const projectPath = computed(() =>
20
+ props.projectId ? getProject(props.projectId)?.projectPath ?? null : null
21
+ )
22
+ const isProjectLocalFile = computed(() =>
23
+ parsedTarget.value
24
+ && isLocalFileWithinProject(parsedTarget.value.path, projectPath.value)
25
+ )
26
+
27
+ const onClick = (event: MouseEvent) => {
28
+ if (!props.projectId || !parsedTarget.value || !isProjectLocalFile.value) {
29
+ return
30
+ }
31
+
32
+ event.preventDefault()
33
+ openViewer({
34
+ projectId: props.projectId,
35
+ path: parsedTarget.value.path,
36
+ line: parsedTarget.value.line,
37
+ column: parsedTarget.value.column
38
+ })
39
+ }
40
+ </script>
41
+
42
+ <template>
43
+ <a
44
+ :href="href ?? undefined"
45
+ :title="title ?? undefined"
46
+ class="underline decoration-primary/35 underline-offset-3 transition hover:text-primary hover:decoration-primary"
47
+ @click="onClick"
48
+ >
49
+ <slot />
50
+ </a>
51
+ </template>