better-codex 0.1.4 → 0.2.1

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.
@@ -78,10 +78,16 @@ export const useHubConnection = () => {
78
78
  setAccountLoginId,
79
79
  upsertReviewSession,
80
80
  updateReviewSession,
81
+ resolveThreadId,
81
82
  } = useAppStore()
82
83
 
83
84
  const nowTimestamp = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
84
85
 
86
+ const resolve = (backendThreadId: string | undefined): string | undefined => {
87
+ if (!backendThreadId) return undefined
88
+ return resolveThreadId(backendThreadId)
89
+ }
90
+
85
91
  const addSystemMessage = (threadId: string, title: string, content: string) => {
86
92
  addMessage(threadId, {
87
93
  id: `sys-${Date.now()}-${Math.random().toString(16).slice(2)}`,
@@ -238,7 +244,8 @@ export const useHubConnection = () => {
238
244
  }
239
245
 
240
246
  if (method === 'turn/started' && params && typeof params === 'object') {
241
- const { threadId, turn } = params as { threadId?: string; turn?: { id?: string } }
247
+ const { threadId: rawThreadId, turn } = params as { threadId?: string; turn?: { id?: string } }
248
+ const threadId = resolve(rawThreadId)
242
249
  if (threadId) {
243
250
  updateThread(threadId, { status: 'active' })
244
251
  setThreadTurnStartedAt(threadId, Date.now())
@@ -249,7 +256,8 @@ export const useHubConnection = () => {
249
256
  }
250
257
 
251
258
  if (method === 'turn/completed' && params && typeof params === 'object') {
252
- const { threadId, turn } = params as { threadId?: string; turn?: { id?: string; status?: string } }
259
+ const { threadId: rawThreadId, turn } = params as { threadId?: string; turn?: { id?: string; status?: string } }
260
+ const threadId = resolve(rawThreadId)
253
261
  // console.log('[HubConnection] turn/completed event for thread:', threadId)
254
262
  if (threadId) {
255
263
  const startedAt = useAppStore.getState().threadTurnStartedAt[threadId]
@@ -271,27 +279,30 @@ export const useHubConnection = () => {
271
279
  }
272
280
 
273
281
  if (method === 'turn/diff/updated' && params && typeof params === 'object') {
274
- const { threadId, turnId, diff } = params as { threadId?: string; turnId?: string; diff?: string }
282
+ const { threadId: rawThreadId, turnId, diff } = params as { threadId?: string; turnId?: string; diff?: string }
283
+ const threadId = resolve(rawThreadId)
275
284
  if (threadId && diff) {
276
285
  const content = diff.length > 4000 ? `${diff.slice(0, 4000)}\n…` : diff
277
286
  upsertMessage(threadId, {
278
287
  id: `diff-${turnId ?? threadId}`,
279
288
  role: 'assistant',
280
289
  kind: 'file',
281
- title: 'Diff · updated',
290
+ title: 'Diff',
282
291
  content,
292
+ meta: { diff },
283
293
  timestamp: nowTimestamp(),
284
294
  })
285
295
  }
286
296
  }
287
297
 
288
298
  if (method === 'turn/plan/updated' && params && typeof params === 'object') {
289
- const { threadId, turnId, plan, explanation } = params as {
299
+ const { threadId: rawThreadId, turnId, plan, explanation } = params as {
290
300
  threadId?: string
291
301
  turnId?: string
292
302
  plan?: Array<{ step?: string; status?: string }>
293
303
  explanation?: string
294
304
  }
305
+ const threadId = resolve(rawThreadId)
295
306
  if (threadId && Array.isArray(plan)) {
296
307
  const steps = plan
297
308
  .map((entry) => `${entry.status ?? 'pending'} · ${entry.step ?? ''}`.trim())
@@ -310,7 +321,8 @@ export const useHubConnection = () => {
310
321
  }
311
322
 
312
323
  if (method === 'thread/tokenUsage/updated' && params && typeof params === 'object') {
313
- const { threadId } = params as { threadId?: string }
324
+ const { threadId: rawThreadId } = params as { threadId?: string }
325
+ const threadId = resolve(rawThreadId)
314
326
  const usage = (params as { usage?: unknown }).usage ?? (params as { tokenUsage?: unknown }).tokenUsage
315
327
  if (threadId && usage) {
316
328
  setThreadTokenUsage(threadId, usage)
@@ -318,73 +330,80 @@ export const useHubConnection = () => {
318
330
  }
319
331
 
320
332
  if (method === 'item/agentMessage/delta' && params && typeof params === 'object') {
321
- const { threadId, itemId, delta } = params as {
333
+ const { threadId: rawThreadId, itemId, delta } = params as {
322
334
  threadId?: string
323
335
  itemId?: string
324
336
  delta?: string
325
337
  }
338
+ const threadId = resolve(rawThreadId)
326
339
  if (threadId && itemId && delta) {
327
340
  appendMessageDelta(threadId, itemId, delta)
328
341
  }
329
342
  }
330
343
 
331
344
  if (method === 'item/reasoning/summaryTextDelta' && params && typeof params === 'object') {
332
- const { threadId, itemId, delta } = params as {
345
+ const { threadId: rawThreadId, itemId, delta } = params as {
333
346
  threadId?: string
334
347
  itemId?: string
335
348
  delta?: string
336
349
  }
350
+ const threadId = resolve(rawThreadId)
337
351
  if (threadId && itemId && delta) {
338
352
  appendMessageDelta(threadId, itemId, delta)
339
353
  }
340
354
  }
341
355
 
342
356
  if (method === 'item/reasoning/summaryPartAdded' && params && typeof params === 'object') {
343
- const { threadId, itemId } = params as { threadId?: string; itemId?: string }
357
+ const { threadId: rawThreadId, itemId } = params as { threadId?: string; itemId?: string }
358
+ const threadId = resolve(rawThreadId)
344
359
  if (threadId && itemId) {
345
360
  appendMessageDelta(threadId, itemId, '\n\n')
346
361
  }
347
362
  }
348
363
 
349
364
  if (method === 'item/reasoning/textDelta' && params && typeof params === 'object') {
350
- const { threadId, itemId, delta } = params as {
365
+ const { threadId: rawThreadId, itemId, delta } = params as {
351
366
  threadId?: string
352
367
  itemId?: string
353
368
  delta?: string
354
369
  }
370
+ const threadId = resolve(rawThreadId)
355
371
  if (threadId && itemId && delta) {
356
372
  appendMessageDelta(threadId, itemId, delta)
357
373
  }
358
374
  }
359
375
 
360
376
  if (method === 'item/commandExecution/outputDelta' && params && typeof params === 'object') {
361
- const { threadId, itemId, delta } = params as {
377
+ const { threadId: rawThreadId, itemId, delta } = params as {
362
378
  threadId?: string
363
379
  itemId?: string
364
380
  delta?: string
365
381
  }
382
+ const threadId = resolve(rawThreadId)
366
383
  if (threadId && itemId && delta) {
367
384
  appendMessageDelta(threadId, itemId, delta)
368
385
  }
369
386
  }
370
387
 
371
388
  if (method === 'item/fileChange/outputDelta' && params && typeof params === 'object') {
372
- const { threadId, itemId, delta } = params as {
389
+ const { threadId: rawThreadId, itemId, delta } = params as {
373
390
  threadId?: string
374
391
  itemId?: string
375
392
  delta?: string
376
393
  }
394
+ const threadId = resolve(rawThreadId)
377
395
  if (threadId && itemId && delta) {
378
396
  appendMessageDelta(threadId, itemId, delta)
379
397
  }
380
398
  }
381
399
 
382
400
  if (method === 'item/started' && params && typeof params === 'object') {
383
- const { item, threadId, turnId } = params as {
401
+ const { item, threadId: rawThreadId, turnId } = params as {
384
402
  item?: ItemPayload & { review?: string }
385
403
  threadId?: string
386
404
  turnId?: string
387
405
  }
406
+ const threadId = resolve(rawThreadId)
388
407
  if (threadId && item?.type === 'agentMessage' && item.id) {
389
408
  ensureAssistantMessage(threadId, item.id)
390
409
  return
@@ -410,11 +429,12 @@ export const useHubConnection = () => {
410
429
  }
411
430
 
412
431
  if (method === 'item/completed' && params && typeof params === 'object') {
413
- const { item, threadId, turnId } = params as {
432
+ const { item, threadId: rawThreadId, turnId } = params as {
414
433
  item?: ItemPayload & { review?: string }
415
434
  threadId?: string
416
435
  turnId?: string
417
436
  }
437
+ const threadId = resolve(rawThreadId)
418
438
  if (threadId && item?.type === 'exitedReviewMode') {
419
439
  const sessionId = turnId ?? item.id
420
440
  if (sessionId) {
@@ -435,7 +455,8 @@ export const useHubConnection = () => {
435
455
  }
436
456
 
437
457
  if (method === 'error' && params && typeof params === 'object') {
438
- const { threadId, error } = params as { threadId?: string; error?: { message?: string } }
458
+ const { threadId: rawThreadId, error } = params as { threadId?: string; error?: { message?: string } }
459
+ const threadId = resolve(rawThreadId)
439
460
  if (threadId) {
440
461
  const message = error?.message ?? 'Unknown error.'
441
462
  addSystemMessage(threadId, 'Error', message)
@@ -466,11 +487,12 @@ export const useHubConnection = () => {
466
487
  parsedCmd?: string
467
488
  command?: string[]
468
489
  }
490
+ const resolvedThreadId = resolve(parsed.threadId) ?? ''
469
491
  addApproval({
470
492
  id: parsed.itemId ?? String(id),
471
493
  requestId: id,
472
494
  profileId,
473
- threadId: parsed.threadId ?? '',
495
+ threadId: resolvedThreadId,
474
496
  type: 'command',
475
497
  payload: parsed.parsedCmd ?? parsed.command?.join(' ') ?? 'Command approval required',
476
498
  status: 'pending',
@@ -483,11 +505,12 @@ export const useHubConnection = () => {
483
505
  threadId?: string
484
506
  reason?: string
485
507
  }
508
+ const resolvedThreadId = resolve(parsed.threadId) ?? ''
486
509
  addApproval({
487
510
  id: parsed.itemId ?? String(id),
488
511
  requestId: id,
489
512
  profileId,
490
- threadId: parsed.threadId ?? '',
513
+ threadId: resolvedThreadId,
491
514
  type: 'file',
492
515
  payload: parsed.reason ?? 'File changes requested',
493
516
  status: 'pending',
@@ -537,8 +560,25 @@ export const useHubConnection = () => {
537
560
  })
538
561
  if (threads) {
539
562
  setThreadsForAccount(profile.id, toThreads(profile.id, threads))
540
- if (profile.id === profiles[0]?.id && threads.data?.length) {
541
- setSelectedThreadId(threads.data[0].id)
563
+ }
564
+ if (threads?.data?.length) {
565
+ try {
566
+ const activeThreads = await hubClient.listActiveThreads({ profileId: profile.id })
567
+ const knownThreadIds = new Set(threads.data.map((thread) => thread.id))
568
+ for (const entry of activeThreads) {
569
+ if (!knownThreadIds.has(entry.threadId)) {
570
+ continue
571
+ }
572
+ updateThread(entry.threadId, { status: 'active' })
573
+ if (entry.turnId) {
574
+ setThreadTurnId(entry.threadId, entry.turnId)
575
+ }
576
+ if (Number.isFinite(entry.startedAt)) {
577
+ setThreadTurnStartedAt(entry.threadId, entry.startedAt)
578
+ }
579
+ }
580
+ } catch (error) {
581
+ console.error(error)
542
582
  }
543
583
  }
544
584
 
@@ -24,6 +24,7 @@ type ThreadData = {
24
24
  type TurnData = {
25
25
  id: string
26
26
  items?: ThreadItem[]
27
+ status?: string
27
28
  }
28
29
 
29
30
  type ThreadItem = {
@@ -109,6 +110,72 @@ const buildMessagesFromTurns = (turns: TurnData[] = []): Message[] => {
109
110
  return messages
110
111
  }
111
112
 
113
+ const mergeMessages = (base: Message[], incoming: Message[]): Message[] => {
114
+ if (incoming.length === 0) {
115
+ return base
116
+ }
117
+ if (base.length === 0) {
118
+ return incoming
119
+ }
120
+ const merged = [...base]
121
+ const indexById = new Map(base.map((msg, index) => [msg.id, index]))
122
+ const userContentIndex = new Map<string, number[]>()
123
+
124
+ const contentKey = (message: Message) => message.content.trim()
125
+ const isUserChat = (message: Message) => message.role === 'user' && message.kind === 'chat'
126
+
127
+ base.forEach((message, index) => {
128
+ if (!isUserChat(message) || message.timestamp) {
129
+ return
130
+ }
131
+ const key = contentKey(message)
132
+ if (!key) {
133
+ return
134
+ }
135
+ const existing = userContentIndex.get(key)
136
+ if (existing) {
137
+ existing.push(index)
138
+ } else {
139
+ userContentIndex.set(key, [index])
140
+ }
141
+ })
142
+
143
+ for (const message of incoming) {
144
+ const existingIndex = indexById.get(message.id)
145
+ if (existingIndex === undefined) {
146
+ if (isUserChat(message)) {
147
+ const key = contentKey(message)
148
+ const candidates = key ? userContentIndex.get(key) : undefined
149
+ const targetIndex = candidates?.shift()
150
+ if (targetIndex !== undefined) {
151
+ const target = merged[targetIndex]
152
+ merged[targetIndex] = {
153
+ ...target,
154
+ ...message,
155
+ id: target.id,
156
+ timestamp: message.timestamp || target.timestamp,
157
+ }
158
+ if (candidates && candidates.length === 0) {
159
+ userContentIndex.delete(key)
160
+ }
161
+ continue
162
+ }
163
+ }
164
+ merged.push(message)
165
+ } else {
166
+ merged[existingIndex] = { ...merged[existingIndex], ...message }
167
+ }
168
+ }
169
+ return merged
170
+ }
171
+
172
+ const isTurnInProgress = (status?: string) => {
173
+ if (!status) {
174
+ return false
175
+ }
176
+ return status === 'inProgress' || status === 'in_progress' || status === 'inprogress'
177
+ }
178
+
112
179
  export const useThreadHistory = () => {
113
180
  const {
114
181
  threads,
@@ -121,9 +188,11 @@ export const useThreadHistory = () => {
121
188
  setThreadEffort,
122
189
  setThreadApproval,
123
190
  setThreadCwd,
191
+ setThreadTurnId,
124
192
  } = useAppStore()
125
193
 
126
194
  const inFlight = useRef<Set<string>>(new Set())
195
+ const loaded = useRef<Set<string>>(new Set())
127
196
 
128
197
  useEffect(() => {
129
198
  if (!selectedThreadId || connectionStatus !== 'connected') {
@@ -135,8 +204,7 @@ export const useThreadHistory = () => {
135
204
  return
136
205
  }
137
206
 
138
- const existing = messages[selectedThreadId]
139
- if (existing !== undefined) {
207
+ if (loaded.current.has(selectedThreadId)) {
140
208
  return
141
209
  }
142
210
 
@@ -162,14 +230,32 @@ export const useThreadHistory = () => {
162
230
  return
163
231
  }
164
232
 
165
- const loadedMessages = buildMessagesFromTurns(resumeThread.turns)
166
- setMessagesForThread(selectedThreadId, loadedMessages)
233
+ const turns = resumeThread.turns ?? []
234
+ const loadedMessages = buildMessagesFromTurns(turns)
235
+ const currentMessages = useAppStore.getState().messages[selectedThreadId] ?? []
236
+ const mergedMessages = mergeMessages(loadedMessages, currentMessages)
237
+ setMessagesForThread(selectedThreadId, mergedMessages)
238
+
239
+ let activeTurn: TurnData | null = null
240
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
241
+ if (isTurnInProgress(turns[index]?.status)) {
242
+ activeTurn = turns[index]
243
+ break
244
+ }
245
+ }
246
+ setThreadTurnId(selectedThreadId, activeTurn?.id ?? null)
247
+ const nextStatus = thread.status === 'archived'
248
+ ? 'archived'
249
+ : activeTurn
250
+ ? 'active'
251
+ : 'idle'
167
252
 
168
253
  updateThread(selectedThreadId, {
169
254
  title: resumeThread.preview?.trim() || thread.title,
170
255
  preview: resumeThread.preview?.trim() || thread.preview,
171
256
  model: resumeThread.modelProvider ?? thread.model,
172
- messageCount: loadedMessages.length,
257
+ messageCount: mergedMessages.length,
258
+ status: nextStatus,
173
259
  })
174
260
  if (result.model) {
175
261
  setThreadModel(selectedThreadId, result.model)
@@ -184,6 +270,7 @@ export const useThreadHistory = () => {
184
270
  if (result.cwd) {
185
271
  setThreadCwd(selectedThreadId, result.cwd)
186
272
  }
273
+ loaded.current.add(selectedThreadId)
187
274
  } catch (error) {
188
275
  console.error(error)
189
276
  } finally {
@@ -206,5 +293,7 @@ export const useThreadHistory = () => {
206
293
  setThreadApproval,
207
294
  threads,
208
295
  updateThread,
296
+ setThreadTurnId,
297
+ setThreadCwd,
209
298
  ])
210
299
  }
@@ -12,6 +12,32 @@ export type PromptSummary = {
12
12
  description?: string
13
13
  }
14
14
 
15
+ export type McpServerConfig = {
16
+ name: string
17
+ command?: string
18
+ args?: string[]
19
+ env?: Record<string, string>
20
+ env_vars?: string[]
21
+ cwd?: string
22
+ url?: string
23
+ bearer_token_env_var?: string
24
+ http_headers?: Record<string, string>
25
+ env_http_headers?: Record<string, string>
26
+ enabled?: boolean
27
+ startup_timeout_sec?: number
28
+ startup_timeout_ms?: number
29
+ tool_timeout_sec?: number
30
+ enabled_tools?: string[]
31
+ disabled_tools?: string[]
32
+ }
33
+
34
+ export type ProfileConfigSnapshot = {
35
+ path: string
36
+ codexHome: string
37
+ content: string
38
+ mcpServers: McpServerConfig[]
39
+ }
40
+
15
41
  export type ThreadSearchResult = {
16
42
  threadId: string
17
43
  profileId: string
@@ -27,6 +53,13 @@ export type ThreadSearchResult = {
27
53
  lastSeenAt: number | null
28
54
  }
29
55
 
56
+ export type ActiveThread = {
57
+ threadId: string
58
+ profileId: string
59
+ turnId: string | null
60
+ startedAt: number
61
+ }
62
+
30
63
  export type ReviewSessionResult = {
31
64
  id: string
32
65
  threadId: string
@@ -234,6 +267,40 @@ class HubClient {
234
267
  return data.content ?? ''
235
268
  }
236
269
 
270
+ async getProfileConfig(profileId: string): Promise<ProfileConfigSnapshot> {
271
+ const response = await fetch(`${HUB_URL}/profiles/${profileId}/config`)
272
+ if (!response.ok) {
273
+ throw new Error('Failed to load config')
274
+ }
275
+ return (await response.json()) as ProfileConfigSnapshot
276
+ }
277
+
278
+ async saveProfileConfig(profileId: string, content: string): Promise<ProfileConfigSnapshot> {
279
+ const response = await fetch(`${HUB_URL}/profiles/${profileId}/config`, {
280
+ method: 'PUT',
281
+ headers: { 'Content-Type': 'application/json' },
282
+ body: JSON.stringify({ content }),
283
+ })
284
+ if (!response.ok) {
285
+ throw new Error('Failed to save config')
286
+ }
287
+ const data = (await response.json()) as { content?: string } & ProfileConfigSnapshot
288
+ return data
289
+ }
290
+
291
+ async saveMcpServers(profileId: string, servers: McpServerConfig[]): Promise<ProfileConfigSnapshot> {
292
+ const response = await fetch(`${HUB_URL}/profiles/${profileId}/mcp-servers`, {
293
+ method: 'PUT',
294
+ headers: { 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({ servers }),
296
+ })
297
+ if (!response.ok) {
298
+ throw new Error('Failed to save MCP servers')
299
+ }
300
+ const data = (await response.json()) as ProfileConfigSnapshot
301
+ return data
302
+ }
303
+
237
304
  async searchThreads(params: {
238
305
  query?: string
239
306
  profileId?: string
@@ -261,6 +328,17 @@ class HubClient {
261
328
  return data.threads ?? []
262
329
  }
263
330
 
331
+ async listActiveThreads(params?: { profileId?: string }): Promise<ActiveThread[]> {
332
+ const url = new URL('/threads/active', HUB_URL)
333
+ if (params?.profileId) url.searchParams.set('profileId', params.profileId)
334
+ const response = await fetch(url.toString())
335
+ if (!response.ok) {
336
+ throw new Error('Failed to load active threads')
337
+ }
338
+ const data = (await response.json()) as { threads?: ActiveThread[] }
339
+ return data.threads ?? []
340
+ }
341
+
264
342
  async listReviews(params?: { profileId?: string; limit?: number; offset?: number }): Promise<ReviewSessionResult[]> {
265
343
  const url = new URL('/reviews', HUB_URL)
266
344
  if (params?.profileId) url.searchParams.set('profileId', params.profileId)
@@ -274,7 +352,7 @@ class HubClient {
274
352
  return data.sessions ?? []
275
353
  }
276
354
 
277
- async request(profileId: string, method: string, params?: unknown): Promise<unknown> {
355
+ private async sendRequest(profileId: string, method: string, params?: unknown): Promise<unknown> {
278
356
  if (!this.ws) {
279
357
  console.error('[HubClient] WebSocket not connected')
280
358
  throw new Error('WebSocket not connected')
@@ -306,6 +384,18 @@ class HubClient {
306
384
  })
307
385
  }
308
386
 
387
+ async request(profileId: string, method: string, params?: unknown): Promise<unknown> {
388
+ try {
389
+ return await this.sendRequest(profileId, method, params)
390
+ } catch (error) {
391
+ if (this.isProfileNotRunning(error)) {
392
+ await this.startProfile(profileId)
393
+ return await this.sendRequest(profileId, method, params)
394
+ }
395
+ throw error
396
+ }
397
+ }
398
+
309
399
  respond(profileId: string, id: number, result?: unknown, error?: { code?: number; message: string }) {
310
400
  if (!this.ws) {
311
401
  return
@@ -354,6 +444,13 @@ class HubClient {
354
444
 
355
445
  this.listeners.forEach((listener) => listener(payload as WsEvent))
356
446
  }
447
+
448
+ private isProfileNotRunning(error: unknown): boolean {
449
+ if (!error || typeof error !== 'object') {
450
+ return false
451
+ }
452
+ return error instanceof Error && error.message.includes('profile app-server not running')
453
+ }
357
454
  }
358
455
 
359
456
  export const hubClient = new HubClient()
@@ -20,6 +20,8 @@ interface AppState {
20
20
  threadTurnStartedAt: Record<string, number>
21
21
  threadLastTurnDuration: Record<string, number>
22
22
  threadTokenUsage: Record<string, unknown>
23
+ threadPendingAccountSwitch: Record<string, { originalThreadId: string; previousAccountId: string }>
24
+ backendToUiThreadId: Record<string, string>
23
25
 
24
26
  messages: Record<string, Message[]>
25
27
  queuedMessages: Record<string, QueuedMessage[]>
@@ -59,6 +61,9 @@ interface AppState {
59
61
  setThreadTurnStartedAt: (threadId: string, startedAt: number | null) => void
60
62
  setThreadLastTurnDuration: (threadId: string, duration: number | null) => void
61
63
  setThreadTokenUsage: (threadId: string, usage: unknown) => void
64
+ setThreadPendingAccountSwitch: (threadId: string, pending: { originalThreadId: string; previousAccountId: string } | null) => void
65
+ setBackendToUiThreadId: (backendThreadId: string, uiThreadId: string | null) => void
66
+ resolveThreadId: (threadId: string) => string
62
67
 
63
68
  addMessage: (threadId: string, message: Message) => void
64
69
  appendAgentDelta: (threadId: string, messageId: string, delta: string) => void
@@ -87,7 +92,7 @@ interface AppState {
87
92
  closeMobileDrawers: () => void
88
93
  }
89
94
 
90
- export const useAppStore = create<AppState>((set) => ({
95
+ export const useAppStore = create<AppState>((set, get) => ({
91
96
  accounts: [],
92
97
  selectedAccountId: null,
93
98
  connectionStatus: 'idle',
@@ -105,6 +110,8 @@ export const useAppStore = create<AppState>((set) => ({
105
110
  threadTurnStartedAt: {},
106
111
  threadLastTurnDuration: {},
107
112
  threadTokenUsage: {},
113
+ threadPendingAccountSwitch: {},
114
+ backendToUiThreadId: {},
108
115
  messages: {},
109
116
  queuedMessages: {},
110
117
  approvals: [],
@@ -325,6 +332,34 @@ export const useAppStore = create<AppState>((set) => ({
325
332
  [threadId]: usage,
326
333
  },
327
334
  })),
335
+ setThreadPendingAccountSwitch: (threadId, pending) => set((state) => {
336
+ if (pending === null) {
337
+ const { [threadId]: _, ...rest } = state.threadPendingAccountSwitch
338
+ return { threadPendingAccountSwitch: rest }
339
+ }
340
+ return {
341
+ threadPendingAccountSwitch: {
342
+ ...state.threadPendingAccountSwitch,
343
+ [threadId]: pending,
344
+ },
345
+ }
346
+ }),
347
+ setBackendToUiThreadId: (backendThreadId, uiThreadId) => set((state) => {
348
+ if (uiThreadId === null) {
349
+ const { [backendThreadId]: _, ...rest } = state.backendToUiThreadId
350
+ return { backendToUiThreadId: rest }
351
+ }
352
+ return {
353
+ backendToUiThreadId: {
354
+ ...state.backendToUiThreadId,
355
+ [backendThreadId]: uiThreadId,
356
+ },
357
+ }
358
+ }),
359
+ resolveThreadId: (threadId: string): string => {
360
+ const state = get()
361
+ return state.backendToUiThreadId[threadId] ?? threadId
362
+ },
328
363
 
329
364
  addMessage: (threadId, message) => set((state) => ({
330
365
  messages: {
@@ -8,6 +8,29 @@ export type ReasoningSummary = 'auto' | 'concise' | 'detailed' | 'none'
8
8
  export type ApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never'
9
9
  export type ReviewStatus = 'pending' | 'running' | 'completed' | 'failed'
10
10
 
11
+ export type CommandAction =
12
+ | { type: 'read'; command: string; name: string; path: string }
13
+ | { type: 'listFiles'; command: string; path?: string | null }
14
+ | { type: 'search'; command: string; query?: string | null; path?: string | null }
15
+ | { type: 'unknown'; command: string }
16
+
17
+ export type FileChangeKind = 'add' | 'delete' | 'update'
18
+
19
+ export interface FileChangeMeta {
20
+ path: string
21
+ kind: FileChangeKind
22
+ diff?: string
23
+ movePath?: string | null
24
+ }
25
+
26
+ export interface MessageMeta {
27
+ commandActions?: CommandAction[]
28
+ command?: string
29
+ status?: string
30
+ fileChanges?: FileChangeMeta[]
31
+ diff?: string
32
+ }
33
+
11
34
  export interface Attachment {
12
35
  id: string
13
36
  type: 'image' | 'file'
@@ -60,6 +83,7 @@ export interface Thread {
60
83
  createdAt: string
61
84
  status: ThreadStatus
62
85
  messageCount: number
86
+ backendThreadId?: string
63
87
  }
64
88
 
65
89
  export interface Message {
@@ -69,6 +93,7 @@ export interface Message {
69
93
  kind?: MessageKind
70
94
  title?: string
71
95
  timestamp: string
96
+ meta?: MessageMeta
72
97
  }
73
98
 
74
99
  export interface QueuedMessage {