@codori/client 0.0.6 → 0.0.8

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,124 @@
1
+ import { computed, ref, type Ref } from 'vue'
2
+ import type { CodexRpcServerRequest } from '../../shared/codex-rpc'
3
+ import {
4
+ buildPendingUserRequestDismissResponse,
5
+ parsePendingUserRequest,
6
+ type PendingUserRequest
7
+ } from '../../shared/pending-user-request'
8
+
9
+ type PendingUserRequestQueueEntry = {
10
+ request: PendingUserRequest
11
+ resolve: (value: unknown) => void
12
+ reject: (error: unknown) => void
13
+ }
14
+
15
+ type PendingUserRequestSession = {
16
+ current: Ref<PendingUserRequest | null>
17
+ queue: PendingUserRequestQueueEntry[]
18
+ }
19
+
20
+ const sessions = new Map<string, PendingUserRequestSession>()
21
+
22
+ const sessionKey = (projectId: string, threadId: string | null) =>
23
+ `${projectId}::${threadId ?? '__draft__'}`
24
+
25
+ const getSession = (projectId: string, threadId: string | null): PendingUserRequestSession => {
26
+ const key = sessionKey(projectId, threadId)
27
+ const existing = sessions.get(key)
28
+ if (existing) {
29
+ return existing
30
+ }
31
+
32
+ const session: PendingUserRequestSession = {
33
+ current: ref<PendingUserRequest | null>(null),
34
+ queue: []
35
+ }
36
+ sessions.set(key, session)
37
+ return session
38
+ }
39
+
40
+ const promoteNextRequest = (session: PendingUserRequestSession) => {
41
+ session.current.value = session.queue[0]?.request ?? null
42
+ }
43
+
44
+ const resolveQueuedRequest = (session: PendingUserRequestSession, responseFactory: (request: PendingUserRequest) => unknown) => {
45
+ while (session.queue.length > 0) {
46
+ const entry = session.queue.shift()
47
+ if (!entry) {
48
+ continue
49
+ }
50
+
51
+ entry.resolve(responseFactory(entry.request))
52
+ }
53
+
54
+ session.current.value = null
55
+ }
56
+
57
+ export const usePendingUserRequest = (projectId: string, activeThreadId: Ref<string | null>) => {
58
+ const resolveSession = (threadId: string | null) => getSession(projectId, threadId)
59
+
60
+ const handleServerRequest = async (request: CodexRpcServerRequest) => {
61
+ const normalized = parsePendingUserRequest(request)
62
+ if (!normalized) {
63
+ return null
64
+ }
65
+
66
+ const session = resolveSession(normalized.threadId ?? activeThreadId.value)
67
+
68
+ return await new Promise((resolve, reject) => {
69
+ session.queue.push({
70
+ request: normalized,
71
+ resolve,
72
+ reject
73
+ })
74
+
75
+ if (!session.current.value) {
76
+ promoteNextRequest(session)
77
+ }
78
+ })
79
+ }
80
+
81
+ const resolveCurrentRequest = (response: unknown) => {
82
+ const session = resolveSession(activeThreadId.value)
83
+ const current = session.queue.shift()
84
+ if (!current) {
85
+ return
86
+ }
87
+
88
+ current.resolve(response)
89
+ promoteNextRequest(session)
90
+ }
91
+
92
+ const rejectCurrentRequest = (error: unknown) => {
93
+ const session = resolveSession(activeThreadId.value)
94
+ const current = session.queue.shift()
95
+ if (!current) {
96
+ return
97
+ }
98
+
99
+ current.reject(error)
100
+ promoteNextRequest(session)
101
+ }
102
+
103
+ const cancelAllPendingRequests = () => {
104
+ const projectSessionPrefix = `${projectId}::`
105
+
106
+ for (const [key, session] of sessions.entries()) {
107
+ if (!key.startsWith(projectSessionPrefix)) {
108
+ continue
109
+ }
110
+
111
+ resolveQueuedRequest(session, buildPendingUserRequestDismissResponse)
112
+ sessions.delete(key)
113
+ }
114
+ }
115
+
116
+ return {
117
+ pendingRequest: computed(() => resolveSession(activeThreadId.value).current.value),
118
+ hasPendingRequest: computed(() => resolveSession(activeThreadId.value).current.value !== null),
119
+ handleServerRequest,
120
+ resolveCurrentRequest,
121
+ rejectCurrentRequest,
122
+ cancelAllPendingRequests
123
+ }
124
+ }
@@ -23,8 +23,34 @@ type UseThreadSummariesResult = ThreadSummariesState & {
23
23
 
24
24
  const states = new Map<string, ThreadSummariesState>()
25
25
 
26
+ export const normalizeThreadTitleCandidate = (value: string | null | undefined) => {
27
+ const raw = value?.trim() ?? ''
28
+ if (!raw) {
29
+ return ''
30
+ }
31
+
32
+ const stripped = raw
33
+ .replace(/<\/?[a-z_]+>/gi, ' ')
34
+ .replace(/\s+/g, ' ')
35
+ .trim()
36
+
37
+ if (!stripped) {
38
+ return ''
39
+ }
40
+
41
+ if (
42
+ /<(?:user_action|context)>/i.test(raw)
43
+ || /user initiated a review task/i.test(stripped)
44
+ || /review output from reviewer mode/i.test(stripped)
45
+ ) {
46
+ return 'Code Review'
47
+ }
48
+
49
+ return stripped
50
+ }
51
+
26
52
  export const resolveThreadSummaryTitle = (thread: Pick<CodexThread, 'id' | 'name' | 'preview'>) => {
27
- const nextTitle = thread.name?.trim() || thread.preview.trim()
53
+ const nextTitle = normalizeThreadTitleCandidate(thread.name) || normalizeThreadTitleCandidate(thread.preview)
28
54
  return nextTitle || `Thread ${thread.id}`
29
55
  }
30
56
 
@@ -41,7 +67,7 @@ export const renameThreadSummary = (
41
67
  updatedAt?: number
42
68
  }
43
69
  ) => {
44
- const nextTitle = input.title.trim()
70
+ const nextTitle = normalizeThreadTitleCandidate(input.title)
45
71
  if (!nextTitle) {
46
72
  return threads
47
73
  }
@@ -53,7 +53,6 @@
53
53
  </p>
54
54
  </div>
55
55
  </div>
56
-
57
56
  </div>
58
57
  </div>
59
58
  </template>
@@ -31,9 +31,10 @@ export const shouldSubmitViaTurnSteer = (input: {
31
31
  liveStreamTurnId: string | null
32
32
  status: PromptSubmitStatus
33
33
  }) =>
34
- input.activeThreadId !== null
35
- && input.liveStreamThreadId === input.activeThreadId
36
- && (input.liveStreamTurnId !== null || input.status === 'submitted' || input.status === 'streaming')
34
+ (input.activeThreadId !== null
35
+ && input.liveStreamThreadId === input.activeThreadId
36
+ && input.status === 'submitted')
37
+ || hasSteerableTurn(input)
37
38
 
38
39
  export const shouldAwaitThreadHydration = (input: {
39
40
  hasPendingThreadHydration: boolean
@@ -43,7 +44,26 @@ export const shouldAwaitThreadHydration = (input: {
43
44
  && input.routeThreadId !== null
44
45
 
45
46
  export const shouldRetrySteerWithTurnStart = (message: string) =>
46
- /no active turn to steer/i.test(message)
47
+ /no active turn to steer|active turn is no longer available/i.test(message)
48
+
49
+ export const shouldApplyNotificationToCurrentTurn = (input: {
50
+ liveStreamTurnId: string | null
51
+ lockedTurnId?: string | null
52
+ notificationMethod: string
53
+ notificationTurnId: string | null
54
+ }) =>
55
+ (input.lockedTurnId ?? input.liveStreamTurnId) === null
56
+ || input.notificationTurnId === null
57
+ || input.notificationTurnId === (input.lockedTurnId ?? input.liveStreamTurnId)
58
+ || input.notificationMethod === 'turn/started'
59
+
60
+ export const shouldAdvanceLiveStreamTurn = (input: {
61
+ lockedTurnId?: string | null
62
+ nextTurnId: string | null
63
+ }) =>
64
+ !input.lockedTurnId
65
+ || !input.nextTurnId
66
+ || input.nextTurnId === input.lockedTurnId
47
67
 
48
68
  export const resolvePromptSubmitStatus = (input: {
49
69
  status: PromptSubmitStatus
@@ -0,0 +1,90 @@
1
+ import type { ComarkElement, ComarkNode, ComarkPlugin, ComarkTree } from '@comark/vue'
2
+
3
+ export const REVIEW_PRIORITY_BADGE_TAG = 'review-priority-badge'
4
+
5
+ const REVIEW_PRIORITY_PATTERN = /^P([1-3])$/i
6
+
7
+ const isElementNode = (node: ComarkNode): node is ComarkElement => {
8
+ return Array.isArray(node) && typeof node[0] === 'string'
9
+ }
10
+
11
+ const getPriorityLabel = (node: ComarkElement) => {
12
+ if (node[0] !== 'span') {
13
+ return null
14
+ }
15
+
16
+ const children = node.slice(2)
17
+
18
+ if (children.length !== 1 || typeof children[0] !== 'string') {
19
+ return null
20
+ }
21
+
22
+ const label = children[0].trim().toUpperCase()
23
+
24
+ return REVIEW_PRIORITY_PATTERN.test(label) ? label as `P${1 | 2 | 3}` : null
25
+ }
26
+
27
+ const createPriorityBadgeNode = (label: `P${1 | 2 | 3}`): ComarkElement => {
28
+ return [REVIEW_PRIORITY_BADGE_TAG, { priority: label.slice(1) }, label]
29
+ }
30
+
31
+ const replaceLeadingPriorityBadge = (children: ComarkNode[]) => {
32
+ const targetIndex = children.findIndex((child) => {
33
+ return typeof child !== 'string' || child.trim().length > 0
34
+ })
35
+
36
+ if (targetIndex === -1) {
37
+ return children
38
+ }
39
+
40
+ const target = children[targetIndex]
41
+
42
+ if (!target) {
43
+ return children
44
+ }
45
+
46
+ if (!isElementNode(target)) {
47
+ return children
48
+ }
49
+
50
+ const label = getPriorityLabel(target)
51
+
52
+ if (!label) {
53
+ return children
54
+ }
55
+
56
+ return children.map((child, index) => {
57
+ return index === targetIndex ? createPriorityBadgeNode(label) : child
58
+ })
59
+ }
60
+
61
+ const transformNode = (node: ComarkNode, parentTag?: string): ComarkNode => {
62
+ if (!isElementNode(node)) {
63
+ return node
64
+ }
65
+
66
+ const [tag, props, ...children] = node
67
+ let nextChildren = children.map(child => transformNode(child, tag))
68
+
69
+ if (tag === 'li' || (tag === 'p' && parentTag === 'li')) {
70
+ nextChildren = replaceLeadingPriorityBadge(nextChildren)
71
+ }
72
+
73
+ return [tag, props, ...nextChildren]
74
+ }
75
+
76
+ export const transformReviewPriorityBadges = (tree: ComarkTree): ComarkTree => {
77
+ return {
78
+ ...tree,
79
+ nodes: tree.nodes.map(node => transformNode(node))
80
+ }
81
+ }
82
+
83
+ export const reviewPriorityBadgePlugin = (): ComarkPlugin => {
84
+ return {
85
+ name: 'review-priority-badge',
86
+ post: (state) => {
87
+ state.tree = transformReviewPriorityBadges(state.tree)
88
+ }
89
+ }
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codori/client",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "private": false,
5
5
  "description": "Codori Nuxt dashboard for project browsing, Codex chat, and thread resume.",
6
6
  "type": "module",
@@ -41,10 +41,17 @@
41
41
  "@comark/vue": "^0.2.0",
42
42
  "@iconify-json/lucide": "^1.2.87",
43
43
  "@nuxt/ui": "^4.6.0",
44
+ "beautiful-mermaid": "^1.1.3",
45
+ "katex": "^0.16.45",
44
46
  "nuxt": "^4.3.0",
45
47
  "ofetch": "^1.4.1",
46
48
  "shiki": "^3.23.0",
47
49
  "tailwindcss": "^4.1.14",
48
50
  "vue": "^3.5.22"
51
+ },
52
+ "devDependencies": {
53
+ "@vitejs/plugin-vue": "^6.0.6",
54
+ "@vue/test-utils": "^2.4.6",
55
+ "jsdom": "^29.0.2"
49
56
  }
50
57
  }
@@ -0,0 +1,15 @@
1
+ import { defineEventHandler, getRouterParam } from 'h3'
2
+ import { encodeProjectIdSegment, type ProjectGitBranchesResponse } from '~~/shared/codori'
3
+ import { proxyServerRequest } from '../../../../../utils/server-proxy'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const projectId = getRouterParam(event, 'projectId')
7
+ if (!projectId) {
8
+ throw new Error('Missing project id.')
9
+ }
10
+
11
+ return await proxyServerRequest<ProjectGitBranchesResponse>(
12
+ event,
13
+ `/api/projects/${encodeProjectIdSegment(projectId)}/git/branches`
14
+ )
15
+ })
@@ -1,4 +1,4 @@
1
- import type { CodexThread, CodexThreadItem, CodexUserInput } from './codex-rpc'
1
+ import type { CodexThread, CodexThreadItem, CodexTurn, CodexUserInput } from './codex-rpc'
2
2
 
3
3
  export const EVENT_PART = 'data-thread-event' as const
4
4
  export const ITEM_PART = 'data-thread-item' as const
@@ -8,6 +8,13 @@ export type ThreadEventData =
8
8
  kind: 'thread.started' | 'thread.ended' | 'thread.title' | 'turn.started' | 'turn.completed'
9
9
  title?: string | null
10
10
  }
11
+ | {
12
+ kind: 'review.started'
13
+ summary: string | null
14
+ }
15
+ | {
16
+ kind: 'review.completed'
17
+ }
11
18
  | {
12
19
  kind: 'turn.failed'
13
20
  error: {
@@ -161,6 +168,29 @@ const userInputToParts = (input: CodexUserInput): ChatPart[] => {
161
168
  }]
162
169
  }
163
170
 
171
+ const getUserMessageText = (item: Extract<CodexThreadItem, { type: 'userMessage' }>) =>
172
+ item.content
173
+ .filter((input): input is Extract<CodexUserInput, { type: 'text' }> => input.type === 'text')
174
+ .map(input => input.text.trim())
175
+ .filter(Boolean)
176
+ .join('\n')
177
+
178
+ const shouldHideReviewBootstrapUserMessage = (
179
+ item: Extract<CodexThreadItem, { type: 'userMessage' }>,
180
+ turn: CodexTurn
181
+ ) => {
182
+ const reviewLifecycle = turn.items.find((candidate): candidate is Extract<CodexThreadItem, { type: 'enteredReviewMode' | 'exitedReviewMode' }> =>
183
+ (candidate.type === 'enteredReviewMode' || candidate.type === 'exitedReviewMode')
184
+ && candidate.id === item.id
185
+ )
186
+
187
+ if (!reviewLifecycle) {
188
+ return false
189
+ }
190
+
191
+ return getUserMessageText(item) === reviewLifecycle.review.trim()
192
+ }
193
+
164
194
  export const itemToMessages = (item: CodexThreadItem): ChatMessage[] => {
165
195
  switch (item.type) {
166
196
  case 'userMessage':
@@ -300,13 +330,53 @@ export const itemToMessages = (item: CodexThreadItem): ChatMessage[] => {
300
330
  }
301
331
  }]
302
332
  }]
333
+ case 'enteredReviewMode':
334
+ return [{
335
+ id: `${item.id}-review-started`,
336
+ role: 'system',
337
+ parts: [{
338
+ type: EVENT_PART,
339
+ data: {
340
+ kind: 'review.started',
341
+ summary: item.review.trim() || null
342
+ }
343
+ }]
344
+ }]
345
+ case 'exitedReviewMode': {
346
+ return [{
347
+ id: `${item.id}-review-completed`,
348
+ role: 'system',
349
+ parts: [{
350
+ type: EVENT_PART,
351
+ data: {
352
+ kind: 'review.completed'
353
+ }
354
+ }]
355
+ }, {
356
+ id: `${item.id}-review-output`,
357
+ role: 'assistant',
358
+ parts: [{
359
+ type: 'text',
360
+ text: item.review,
361
+ state: 'done'
362
+ }]
363
+ }]
364
+ }
303
365
  default:
304
366
  return []
305
367
  }
306
368
  }
307
369
 
308
370
  export const threadToMessages = (thread: CodexThread) =>
309
- thread.turns.flatMap(turn => turn.items.flatMap(item => itemToMessages(item)))
371
+ thread.turns.flatMap((turn) =>
372
+ turn.items.flatMap((item) => {
373
+ if (item.type === 'userMessage' && shouldHideReviewBootstrapUserMessage(item, turn)) {
374
+ return []
375
+ }
376
+
377
+ return itemToMessages(item)
378
+ })
379
+ )
310
380
 
311
381
  export const eventToMessage = (id: string, data: ThreadEventData): ChatMessage => ({
312
382
  id,
@@ -24,13 +24,17 @@ type JsonRpcNotification = {
24
24
  params?: unknown
25
25
  }
26
26
 
27
- type JsonRpcServerRequest = JsonRpcRequest
27
+ export type CodexRpcServerRequest = JsonRpcRequest
28
+
29
+ type JsonRpcServerRequest = CodexRpcServerRequest
28
30
 
29
31
  type PendingRequest = {
30
32
  resolve: (value: unknown) => void
31
33
  reject: (error: unknown) => void
32
34
  }
33
35
 
36
+ export type CodexRpcServerRequestHandler = (request: CodexRpcServerRequest) => Promise<unknown> | unknown
37
+
34
38
  export type CodexUserInput =
35
39
  | {
36
40
  type: 'text'
@@ -131,6 +135,16 @@ export type CodexThreadItem =
131
135
  type: 'contextCompaction'
132
136
  id: string
133
137
  }
138
+ | {
139
+ type: 'enteredReviewMode'
140
+ id: string
141
+ review: string
142
+ }
143
+ | {
144
+ type: 'exitedReviewMode'
145
+ id: string
146
+ review: string
147
+ }
134
148
 
135
149
  export type CodexTurn = {
136
150
  id: string
@@ -186,6 +200,37 @@ export type TurnStartResponse = {
186
200
  }
187
201
  }
188
202
 
203
+ export type ReviewTarget =
204
+ | {
205
+ type: 'uncommittedChanges'
206
+ }
207
+ | {
208
+ type: 'baseBranch'
209
+ branch: string
210
+ }
211
+ | {
212
+ type: 'commit'
213
+ sha: string
214
+ title?: string | null
215
+ }
216
+ | {
217
+ type: 'custom'
218
+ instructions: string
219
+ }
220
+
221
+ export type ReviewDelivery = 'inline' | 'detached'
222
+
223
+ export type ReviewStartParams = {
224
+ threadId: string
225
+ delivery?: ReviewDelivery
226
+ target: ReviewTarget
227
+ }
228
+
229
+ export type ReviewStartResponse = {
230
+ reviewThreadId: string
231
+ turn: CodexTurn
232
+ }
233
+
189
234
  export type ModelListResponse = {
190
235
  data?: unknown[]
191
236
  }
@@ -209,7 +254,12 @@ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
209
254
  const asError = (value: unknown) =>
210
255
  value instanceof Error ? value : new Error(typeof value === 'string' ? value : 'Unknown RPC error.')
211
256
 
212
- const toServerRequestResponse = async (request: JsonRpcServerRequest) => {
257
+ export const toServerRequestResponse = async (
258
+ request: JsonRpcServerRequest,
259
+ options?: {
260
+ handler?: CodexRpcServerRequestHandler | null
261
+ }
262
+ ) => {
213
263
  switch (request.method) {
214
264
  case 'item/commandExecution/requestApproval':
215
265
  case 'item/fileChange/requestApproval':
@@ -217,8 +267,20 @@ const toServerRequestResponse = async (request: JsonRpcServerRequest) => {
217
267
  case 'item/permissions/requestApproval':
218
268
  return { permissions: {}, scope: 'turn' }
219
269
  case 'item/tool/requestUserInput':
270
+ if (options?.handler) {
271
+ const handled = await options.handler(request)
272
+ if (handled != null) {
273
+ return handled
274
+ }
275
+ }
220
276
  return { answers: {} }
221
277
  case 'mcpServer/elicitation/request':
278
+ if (options?.handler) {
279
+ const handled = await options.handler(request)
280
+ if (handled != null) {
281
+ return handled
282
+ }
283
+ }
222
284
  return { action: 'decline', content: null, _meta: null }
223
285
  case 'applyPatchApproval':
224
286
  case 'execCommandApproval':
@@ -290,6 +352,8 @@ export class CodexRpcClient {
290
352
 
291
353
  private listeners = new Set<(notification: CodexRpcNotification) => void>()
292
354
 
355
+ private serverRequestHandler: CodexRpcServerRequestHandler | null = null
356
+
293
357
  constructor(url: string) {
294
358
  this.url = url
295
359
  }
@@ -301,6 +365,16 @@ export class CodexRpcClient {
301
365
  }
302
366
  }
303
367
 
368
+ setServerRequestHandler(handler: CodexRpcServerRequestHandler | null) {
369
+ this.serverRequestHandler = handler
370
+
371
+ return () => {
372
+ if (this.serverRequestHandler === handler) {
373
+ this.serverRequestHandler = null
374
+ }
375
+ }
376
+ }
377
+
304
378
  async connect() {
305
379
  if (this.initialized && this.socket?.readyState === WebSocket.OPEN) {
306
380
  return
@@ -389,7 +463,9 @@ export class CodexRpcClient {
389
463
  }
390
464
 
391
465
  if ('id' in payload && 'method' in payload) {
392
- const result = await toServerRequestResponse(payload)
466
+ const result = await toServerRequestResponse(payload, {
467
+ handler: this.serverRequestHandler
468
+ })
393
469
  this.sendRaw({
394
470
  id: payload.id,
395
471
  result
package/shared/codori.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolveApiUrl, shouldUseServerProxy } from './network'
2
+
1
3
  export type ProjectRuntimeStatus = 'running' | 'stopped' | 'error'
2
4
 
3
5
  export type ProjectRecord = {
@@ -38,6 +40,11 @@ export type ServiceUpdateResponse = {
38
40
  serviceUpdate: ServiceUpdateStatus
39
41
  }
40
42
 
43
+ export type ProjectGitBranchesResponse = {
44
+ currentBranch: string | null
45
+ branches: string[]
46
+ }
47
+
41
48
  export const normalizeProjectIdParam = (value: string | string[] | undefined) => {
42
49
  if (!value) {
43
50
  return null
@@ -57,6 +64,19 @@ export const toProjectThreadRoute = (projectId: string, threadId: string) =>
57
64
 
58
65
  export const encodeProjectIdSegment = (projectId: string) => encodeURIComponent(projectId)
59
66
 
67
+ export const resolveProjectGitBranchesUrl = (input: {
68
+ projectId: string
69
+ configuredBase?: string | null
70
+ }) => {
71
+ const requestPath = `/projects/${encodeProjectIdSegment(input.projectId)}/git/branches`
72
+
73
+ if (shouldUseServerProxy(input.configuredBase)) {
74
+ return `/api/codori${requestPath}`
75
+ }
76
+
77
+ return resolveApiUrl(requestPath, input.configuredBase)
78
+ }
79
+
60
80
  export const projectStatusMeta = (status: ProjectRuntimeStatus) => {
61
81
  switch (status) {
62
82
  case 'running':