@codori/client 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/app/components/ChatWorkspace.vue +882 -187
  2. package/app/components/MessageContent.vue +4 -2
  3. package/app/components/MessagePartRenderer.ts +12 -2
  4. package/app/components/ProjectSidebar.vue +2 -2
  5. package/app/components/SubagentDrawerList.vue +64 -0
  6. package/app/components/SubagentTranscriptPanel.vue +305 -0
  7. package/app/components/ThreadList.vue +5 -5
  8. package/app/components/ThreadPanel.vue +65 -46
  9. package/app/components/VisualSubagentStack.vue +14 -244
  10. package/app/components/message-item/CommandExecution.vue +2 -2
  11. package/app/components/message-item/DynamicToolCall.vue +2 -2
  12. package/app/components/message-item/FileChange.vue +2 -2
  13. package/app/components/message-item/McpToolCall.vue +2 -2
  14. package/app/components/message-item/SubagentActivity.vue +2 -2
  15. package/app/components/message-item/WebSearch.vue +2 -2
  16. package/app/components/message-part/Attachment.vue +61 -0
  17. package/app/components/message-part/Event.vue +1 -1
  18. package/app/components/message-part/Item.ts +1 -1
  19. package/app/composables/useChatAttachments.ts +208 -0
  20. package/app/composables/useChatSession.ts +33 -2
  21. package/app/composables/useProjects.ts +4 -5
  22. package/app/composables/useRpc.ts +3 -3
  23. package/app/composables/useVisualSubagentPanels.ts +1 -1
  24. package/app/pages/projects/[...projectId]/index.vue +5 -5
  25. package/app/pages/projects/[...projectId]/threads/[threadId].vue +228 -75
  26. package/app/utils/chat-turn-engagement.ts +46 -0
  27. package/package.json +1 -1
  28. package/server/api/codori/projects/[projectId]/attachments/file.get.ts +62 -0
  29. package/server/api/codori/projects/[projectId]/attachments.post.ts +53 -0
  30. package/server/api/codori/projects/[projectId]/start.post.ts +3 -3
  31. package/server/api/codori/projects/[projectId]/status.get.ts +3 -3
  32. package/server/api/codori/projects/[projectId]/stop.post.ts +3 -3
  33. package/server/api/codori/projects/[projectId].get.ts +3 -3
  34. package/server/api/codori/projects/index.get.ts +2 -2
  35. package/server/utils/server-proxy.ts +23 -0
  36. package/shared/chat-attachments.ts +135 -0
  37. package/shared/chat-prompt-controls.ts +339 -0
  38. package/shared/codex-chat.ts +33 -11
  39. package/shared/codex-rpc.ts +19 -0
  40. package/shared/network.ts +8 -0
  41. package/shared/subagent-panels.ts +158 -0
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
- import MessagePartRenderer from './MessagePartRenderer.js'
3
- import type { ChatMessage, ChatPart } from '~~/shared/codex-chat.js'
2
+ import MessagePartRenderer from './MessagePartRenderer'
3
+ import type { ChatMessage, ChatPart } from '~~/shared/codex-chat'
4
4
 
5
5
  defineProps<{
6
6
  message?: ChatMessage | null
7
+ projectId?: string
7
8
  }>()
8
9
 
9
10
  const partKey = (messageId: string | undefined, part: ChatPart, index: number) => {
@@ -21,6 +22,7 @@ const partKey = (messageId: string | undefined, part: ChatPart, index: number) =
21
22
  v-for="(part, index) in message?.parts ?? []"
22
23
  :key="partKey(message?.id, part, index)"
23
24
  :message="message"
25
+ :project-id="projectId"
24
26
  :part="part"
25
27
  />
26
28
  </div>
@@ -1,8 +1,9 @@
1
1
  import { defineComponent, h, type PropType } from 'vue'
2
2
  import { UChatReasoning } from '#components'
3
- import { EVENT_PART, ITEM_PART, type ChatMessage, type ChatPart } from '~~/shared/codex-chat.js'
3
+ import { EVENT_PART, ITEM_PART, type ChatMessage, type ChatPart } from '~~/shared/codex-chat'
4
+ import MessagePartAttachment from './message-part/Attachment.vue'
4
5
  import MessagePartEvent from './message-part/Event.vue'
5
- import MessagePartItem from './message-part/Item.js'
6
+ import MessagePartItem from './message-part/Item'
6
7
  import MessagePartText from './message-part/Text.vue'
7
8
 
8
9
  export default defineComponent({
@@ -12,6 +13,10 @@ export default defineComponent({
12
13
  type: Object as PropType<ChatMessage | null>,
13
14
  default: null
14
15
  },
16
+ projectId: {
17
+ type: String as PropType<string | undefined>,
18
+ default: undefined
19
+ },
15
20
  part: {
16
21
  type: Object as PropType<ChatPart | null>,
17
22
  default: null
@@ -37,6 +42,11 @@ export default defineComponent({
37
42
  defaultOpen: props.part.state === 'streaming',
38
43
  autoCloseDelay: 600
39
44
  })
45
+ case 'attachment':
46
+ return h(MessagePartAttachment, {
47
+ projectId: props.projectId,
48
+ part: props.part
49
+ })
40
50
  case EVENT_PART:
41
51
  return h(MessagePartEvent, {
42
52
  part: props.part
@@ -2,8 +2,8 @@
2
2
  import type { NavigationMenuItem } from '@nuxt/ui'
3
3
  import { useRoute } from '#imports'
4
4
  import { computed, onMounted } from 'vue'
5
- import { useProjects } from '../composables/useProjects.js'
6
- import { toProjectRoute } from '~~/shared/codori.js'
5
+ import { useProjects } from '../composables/useProjects'
6
+ import { toProjectRoute } from '~~/shared/codori'
7
7
 
8
8
  const props = defineProps<{
9
9
  collapsed?: boolean
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import type { VisualSubagentPanel } from '~~/shared/codex-chat'
3
+ import {
4
+ resolveSubagentAccent,
5
+ resolveSubagentStatusMeta
6
+ } from '~~/shared/subagent-panels'
7
+
8
+ defineProps<{
9
+ agents: VisualSubagentPanel[]
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ expand: [threadId: string]
14
+ }>()
15
+ </script>
16
+
17
+ <template>
18
+ <div class="min-h-0">
19
+ <div
20
+ v-if="agents.length === 0"
21
+ class="rounded-lg border border-dashed border-default px-4 py-6 text-sm text-muted"
22
+ >
23
+ No subagents yet.
24
+ </div>
25
+
26
+ <ul
27
+ v-else
28
+ class="divide-y divide-default"
29
+ >
30
+ <li
31
+ v-for="(agent, index) in agents"
32
+ :key="agent.threadId"
33
+ class="flex items-center gap-3 px-4 py-3"
34
+ >
35
+ <div
36
+ class="size-2.5 shrink-0 rounded-full ring-4"
37
+ :class="resolveSubagentAccent(index).dotClass"
38
+ />
39
+
40
+ <div class="min-w-0 flex-1">
41
+ <p
42
+ class="truncate text-sm font-medium"
43
+ :class="resolveSubagentAccent(index).textClass"
44
+ >
45
+ {{ agent.name }}
46
+ </p>
47
+ <p class="text-xs text-muted">
48
+ {{ resolveSubagentStatusMeta(agent.status).label }}
49
+ </p>
50
+ </div>
51
+
52
+ <UButton
53
+ icon="i-lucide-expand"
54
+ color="neutral"
55
+ variant="ghost"
56
+ size="sm"
57
+ square
58
+ :aria-label="`Expand ${agent.name}`"
59
+ @click="emit('expand', agent.threadId)"
60
+ />
61
+ </li>
62
+ </ul>
63
+ </div>
64
+ </template>
@@ -0,0 +1,305 @@
1
+ <script lang="ts">
2
+ const sharedScrollPositions = new Map<string, number>()
3
+ const sharedStickToBottomStates = new Map<string, boolean>()
4
+ </script>
5
+
6
+ <script setup lang="ts">
7
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
8
+ import type { VisualSubagentPanel } from '~~/shared/codex-chat'
9
+ import { resolveSubagentStatusMeta, type SubagentAccent } from '~~/shared/subagent-panels'
10
+
11
+ const props = withDefaults(defineProps<{
12
+ agent: VisualSubagentPanel
13
+ accent?: SubagentAccent | null
14
+ expanded?: boolean
15
+ showExpandButton?: boolean
16
+ showCollapseButton?: boolean
17
+ scrollScope?: string
18
+ }>(), {
19
+ accent: null,
20
+ expanded: false,
21
+ showExpandButton: false,
22
+ showCollapseButton: false,
23
+ scrollScope: 'default'
24
+ })
25
+
26
+ const emit = defineEmits<{
27
+ expand: []
28
+ collapse: []
29
+ }>()
30
+
31
+ const SCROLL_RETRY_DELAY_MS = 48
32
+ const SCROLL_RETRY_COUNT = 4
33
+ const SCROLL_STICKY_THRESHOLD_PX = 24
34
+
35
+ const scrollContainer = ref<HTMLElement | null>(null)
36
+ let scrollRetryTimer: number | null = null
37
+ let isTickPending = false
38
+
39
+ const statusMeta = computed(() => resolveSubagentStatusMeta(props.agent.status))
40
+ const scrollStateKey = computed(() => `${props.scrollScope}:${props.agent.threadId}`)
41
+ const lastMessageSignature = computed(() => {
42
+ const lastMessage = props.agent.messages.at(-1)
43
+ if (!lastMessage) {
44
+ return ''
45
+ }
46
+
47
+ return JSON.stringify({
48
+ id: lastMessage.id,
49
+ pending: lastMessage.pending ?? false,
50
+ parts: lastMessage.parts.map((part) => {
51
+ if (part.type === 'text') {
52
+ return [part.type, part.state ?? '', part.text.length]
53
+ }
54
+
55
+ if (part.type === 'reasoning') {
56
+ return [
57
+ part.type,
58
+ part.state ?? '',
59
+ part.summary.join('\n').length,
60
+ part.content.join('\n').length
61
+ ]
62
+ }
63
+
64
+ if (part.type === 'attachment') {
65
+ return [part.type, part.attachment.name, part.attachment.url ?? part.attachment.localPath ?? '']
66
+ }
67
+
68
+ return [part.type, JSON.stringify(part.data).length]
69
+ })
70
+ })
71
+ })
72
+ const panelSignature = computed(() => [
73
+ props.agent.messages.length,
74
+ lastMessageSignature.value,
75
+ props.agent.status ?? ''
76
+ ].join(':'))
77
+
78
+ const clearScrollRetry = () => {
79
+ if (scrollRetryTimer !== null) {
80
+ window.clearTimeout(scrollRetryTimer)
81
+ scrollRetryTimer = null
82
+ }
83
+ }
84
+
85
+ const isNearBottom = (container: HTMLElement) =>
86
+ container.scrollHeight - container.scrollTop - container.clientHeight <= SCROLL_STICKY_THRESHOLD_PX
87
+
88
+ const rememberScrollState = () => {
89
+ if (!scrollContainer.value) {
90
+ return
91
+ }
92
+
93
+ sharedScrollPositions.set(scrollStateKey.value, scrollContainer.value.scrollTop)
94
+ sharedStickToBottomStates.set(scrollStateKey.value, isNearBottom(scrollContainer.value))
95
+ }
96
+
97
+ const restoreScrollState = () => {
98
+ if (!scrollContainer.value) {
99
+ return
100
+ }
101
+
102
+ const savedTop = sharedScrollPositions.get(scrollStateKey.value)
103
+ if (savedTop !== undefined) {
104
+ scrollContainer.value.scrollTop = savedTop
105
+ }
106
+
107
+ sharedStickToBottomStates.set(
108
+ scrollStateKey.value,
109
+ savedTop === undefined || isNearBottom(scrollContainer.value)
110
+ )
111
+ }
112
+
113
+ const scrollToBottom = () => {
114
+ if (!scrollContainer.value) {
115
+ return
116
+ }
117
+
118
+ scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
119
+ rememberScrollState()
120
+ }
121
+
122
+ const queueScrollToBottom = (attempt = 0) => {
123
+ if (!import.meta.client) {
124
+ return
125
+ }
126
+
127
+ if (attempt === 0) {
128
+ clearScrollRetry()
129
+ if (isTickPending) {
130
+ return
131
+ }
132
+ isTickPending = true
133
+ }
134
+
135
+ void nextTick(() => {
136
+ if (attempt === 0) {
137
+ isTickPending = false
138
+ }
139
+
140
+ scrollToBottom()
141
+ if (attempt >= SCROLL_RETRY_COUNT) {
142
+ return
143
+ }
144
+
145
+ scrollRetryTimer = window.setTimeout(() => {
146
+ queueScrollToBottom(attempt + 1)
147
+ }, SCROLL_RETRY_DELAY_MS)
148
+ })
149
+ }
150
+
151
+ const onScroll = () => {
152
+ rememberScrollState()
153
+ }
154
+
155
+ watch(scrollContainer, (container) => {
156
+ if (!container) {
157
+ clearScrollRetry()
158
+ return
159
+ }
160
+
161
+ restoreScrollState()
162
+ if (!sharedScrollPositions.has(scrollStateKey.value)) {
163
+ queueScrollToBottom()
164
+ }
165
+ }, { immediate: true })
166
+
167
+ watch(() => props.agent.threadId, (_, previousThreadId) => {
168
+ if (previousThreadId) {
169
+ rememberScrollState()
170
+ }
171
+
172
+ clearScrollRetry()
173
+ void nextTick(() => {
174
+ restoreScrollState()
175
+ if (!sharedScrollPositions.has(scrollStateKey.value)) {
176
+ queueScrollToBottom()
177
+ }
178
+ })
179
+ })
180
+
181
+ watch(panelSignature, () => {
182
+ if (sharedStickToBottomStates.get(scrollStateKey.value) === false) {
183
+ return
184
+ }
185
+
186
+ queueScrollToBottom()
187
+ })
188
+
189
+ onBeforeUnmount(() => {
190
+ rememberScrollState()
191
+ clearScrollRetry()
192
+ })
193
+ </script>
194
+
195
+ <template>
196
+ <section class="flex h-full min-h-0 flex-col bg-elevated/25">
197
+ <header
198
+ class="flex items-center justify-between gap-2 border-b border-default px-3 py-2"
199
+ :class="props.accent?.headerClass"
200
+ >
201
+ <div class="min-w-0">
202
+ <p
203
+ class="truncate text-sm font-semibold"
204
+ :class="props.accent?.textClass || 'text-highlighted'"
205
+ >
206
+ {{ agent.name }}
207
+ </p>
208
+ <p
209
+ v-if="expanded"
210
+ class="text-xs text-muted"
211
+ >
212
+ Focused subagent transcript
213
+ </p>
214
+ </div>
215
+
216
+ <div class="flex shrink-0 items-center gap-1.5">
217
+ <UBadge
218
+ :color="statusMeta.color"
219
+ variant="soft"
220
+ size="sm"
221
+ >
222
+ {{ statusMeta.label }}
223
+ </UBadge>
224
+ <UButton
225
+ v-if="showExpandButton"
226
+ icon="i-lucide-expand"
227
+ color="neutral"
228
+ variant="ghost"
229
+ size="xs"
230
+ square
231
+ aria-label="Expand subagent"
232
+ @click="emit('expand')"
233
+ />
234
+ <UButton
235
+ v-if="showCollapseButton"
236
+ icon="i-lucide-shrink"
237
+ color="neutral"
238
+ variant="ghost"
239
+ size="xs"
240
+ square
241
+ aria-label="Collapse subagent"
242
+ @click="emit('collapse')"
243
+ />
244
+ </div>
245
+ </header>
246
+
247
+ <div
248
+ ref="scrollContainer"
249
+ class="min-h-0 flex-1 overflow-y-auto px-3 py-2"
250
+ @scroll="onScroll"
251
+ >
252
+ <div
253
+ v-if="agent.messages.length === 0"
254
+ class="rounded-lg border border-dashed border-default px-3 py-4 text-sm text-muted"
255
+ >
256
+ Waiting for subagent output...
257
+ </div>
258
+
259
+ <UChatMessages
260
+ v-else
261
+ :messages="agent.messages"
262
+ :status="agent.status === 'pendingInit' || agent.status === 'running' ? 'streaming' : 'ready'"
263
+ :user="{
264
+ ui: {
265
+ root: 'scroll-mt-4',
266
+ container: 'gap-3 pb-8',
267
+ content: 'px-4 py-3 rounded-lg min-h-12'
268
+ }
269
+ }"
270
+ :ui="{ root: 'subagent-chat-messages min-h-full min-w-0 [&>article]:min-w-0 [&_[data-slot=content]]:min-w-0' }"
271
+ compact
272
+ >
273
+ <template #content="{ message }">
274
+ <MessageContent :message="message as VisualSubagentPanel['messages'][number]" />
275
+ </template>
276
+ </UChatMessages>
277
+ </div>
278
+ </section>
279
+ </template>
280
+
281
+ <style scoped>
282
+ .subagent-chat-messages :deep(.cd-markdown),
283
+ .subagent-chat-messages :deep([data-slot='content']) {
284
+ min-width: 0;
285
+ max-width: 100%;
286
+ }
287
+
288
+ .subagent-chat-messages :deep(.cd-markdown p),
289
+ .subagent-chat-messages :deep(.cd-markdown li),
290
+ .subagent-chat-messages :deep(.cd-markdown a),
291
+ .subagent-chat-messages :deep(.cd-markdown code) {
292
+ overflow-wrap: anywhere;
293
+ word-break: break-word;
294
+ }
295
+
296
+ .subagent-chat-messages :deep(.cd-markdown pre),
297
+ .subagent-chat-messages :deep(.cd-markdown .shiki),
298
+ .subagent-chat-messages :deep(.cd-markdown .shiki code) {
299
+ max-width: 100%;
300
+ white-space: pre-wrap !important;
301
+ overflow-wrap: anywhere;
302
+ word-break: break-word;
303
+ overflow-x: hidden !important;
304
+ }
305
+ </style>
@@ -2,11 +2,11 @@
2
2
  import type { NavigationMenuItem } from '@nuxt/ui'
3
3
  import { useRoute } from '#imports'
4
4
  import { computed, onMounted, ref, watch } from 'vue'
5
- import { useProjects } from '../composables/useProjects.js'
6
- import { useRpc } from '../composables/useRpc.js'
7
- import { useThreadPanel } from '../composables/useThreadPanel.js'
8
- import type { ThreadListResponse } from '~~/shared/codex-rpc.js'
9
- import { toProjectThreadRoute } from '~~/shared/codori.js'
5
+ import { useProjects } from '../composables/useProjects'
6
+ import { useRpc } from '../composables/useRpc'
7
+ import { useThreadPanel } from '../composables/useThreadPanel'
8
+ import type { ThreadListResponse } from '~~/shared/codex-rpc'
9
+ import { toProjectThreadRoute } from '~~/shared/codori'
10
10
 
11
11
  const props = defineProps<{
12
12
  projectId: string | null
@@ -1,68 +1,87 @@
1
1
  <script setup lang="ts">
2
- import { ref } from 'vue'
3
- import { useThreadPanel } from '../composables/useThreadPanel.js'
2
+ import { onBeforeUnmount, onMounted, ref } from 'vue'
3
+ import { useThreadPanel } from '../composables/useThreadPanel'
4
4
  import ThreadList from './ThreadList.vue'
5
5
 
6
6
  defineProps<{
7
7
  projectId: string | null
8
8
  }>()
9
9
 
10
+ const readDesktopViewport = () =>
11
+ import.meta.client ? window.matchMedia('(min-width: 1280px)').matches : false
12
+
10
13
  const { open, closePanel } = useThreadPanel()
11
14
  const threadListKey = ref(0)
15
+ const isDesktopViewport = ref(readDesktopViewport())
16
+
17
+ let viewportQuery: MediaQueryList | null = null
18
+ let removeViewportListener: (() => void) | null = null
12
19
 
13
20
  const refreshThreads = () => {
14
21
  threadListKey.value += 1
15
22
  }
23
+
24
+ onMounted(() => {
25
+ if (!import.meta.client) {
26
+ return
27
+ }
28
+
29
+ viewportQuery = window.matchMedia('(min-width: 1280px)')
30
+ isDesktopViewport.value = viewportQuery.matches
31
+
32
+ const handleViewportChange = (event: MediaQueryListEvent) => {
33
+ isDesktopViewport.value = event.matches
34
+ }
35
+
36
+ viewportQuery.addEventListener('change', handleViewportChange)
37
+ removeViewportListener = () => {
38
+ viewportQuery?.removeEventListener('change', handleViewportChange)
39
+ }
40
+ })
41
+
42
+ onBeforeUnmount(() => {
43
+ removeViewportListener?.()
44
+ })
16
45
  </script>
17
46
 
18
47
  <template>
19
- <div class="hidden h-full min-h-0 xl:flex">
20
- <UDashboardPanel
21
- v-if="projectId && open"
22
- id="thread-panel"
23
- :ui="{ body: 'p-0' }"
24
- class="h-full min-h-0 border-l border-default"
25
- :default-size="24"
26
- :min-size="20"
27
- :max-size="30"
28
- resizable
29
- >
30
- <template #header>
31
- <div class="flex items-center justify-between gap-2 px-3 py-2">
32
- <div class="text-sm font-medium text-highlighted">
33
- Resume Threads
34
- </div>
35
- <div class="flex items-center gap-1">
36
- <UButton
37
- icon="i-lucide-refresh-cw"
38
- color="neutral"
39
- variant="ghost"
40
- size="xs"
41
- square
42
- @click="refreshThreads"
43
- />
44
- <UButton
45
- icon="i-lucide-panel-right-close"
46
- color="neutral"
47
- variant="ghost"
48
- size="xs"
49
- square
50
- @click="closePanel"
51
- />
52
- </div>
53
- </div>
54
- </template>
55
- <template #body>
56
- <ThreadList
57
- :key="threadListKey"
58
- :project-id="projectId"
48
+ <aside
49
+ v-if="projectId && open && isDesktopViewport"
50
+ class="hidden h-full min-h-0 w-[22rem] shrink-0 flex-col border-l border-default bg-default xl:flex"
51
+ >
52
+ <div class="flex items-center justify-between gap-2 border-b border-default px-3 py-2">
53
+ <div class="text-sm font-medium text-highlighted">
54
+ Resume Threads
55
+ </div>
56
+ <div class="flex items-center gap-1">
57
+ <UButton
58
+ icon="i-lucide-refresh-cw"
59
+ color="neutral"
60
+ variant="ghost"
61
+ size="xs"
62
+ square
63
+ @click="refreshThreads"
64
+ />
65
+ <UButton
66
+ icon="i-lucide-panel-right-close"
67
+ color="neutral"
68
+ variant="ghost"
69
+ size="xs"
70
+ square
71
+ @click="closePanel"
59
72
  />
60
- </template>
61
- </UDashboardPanel>
62
- </div>
73
+ </div>
74
+ </div>
75
+ <div class="min-h-0 flex-1 overflow-hidden">
76
+ <ThreadList
77
+ :key="threadListKey"
78
+ :project-id="projectId"
79
+ />
80
+ </div>
81
+ </aside>
63
82
 
64
83
  <USlideover
65
- v-if="projectId"
84
+ v-if="projectId && !isDesktopViewport"
66
85
  v-model:open="open"
67
86
  title="Resume Threads"
68
87
  side="right"