@mobius-os/mobius 0.3.42 → 0.3.45

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.42",
3
+ "version": "0.3.45",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -8,21 +8,13 @@
8
8
  "mobius-tui": "bin/mobius-tui.js"
9
9
  },
10
10
  "scripts": {
11
- "dev": "tsx src/main.tsx",
12
- "start": "tsx src/main.tsx",
13
- "typecheck": "tsc --noEmit",
14
- "test:integration": "tsx tests/integration.test.ts",
15
- "test:ui": "tsx tests/ui.test.tsx",
16
- "test:flow": "tsx tests/flow.test.tsx",
17
- "test:resume": "tsx tests/resume.test.tsx",
18
- "test:aimux": "tsx tests/aimux.test.tsx",
19
- "test:reconnect": "tsx tests/reconnect.test.tsx",
20
- "test:screen": "FORCE_COLOR=1 tsx tests/screen.test.tsx",
21
- "test:scroll": "tsx tests/scroll.test.tsx",
22
- "test:viewport": "tsx tests/viewport.test.ts",
23
- "test:selection": "FORCE_COLOR=1 tsx tests/selection.test.tsx",
24
- "test": "npm run typecheck && npm run test:ui && npm run test:integration"
11
+ "start": "tsx src/main.tsx"
25
12
  },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md"
17
+ ],
26
18
  "dependencies": {
27
19
  "chalk": "^5.3.0",
28
20
  "cli-highlight": "2.1.11",
@@ -33,16 +25,7 @@
33
25
  "tsx": "4.19.2",
34
26
  "wrap-ansi": "^9.0.0"
35
27
  },
36
- "devDependencies": {
37
- "@types/node": "18.19.34",
38
- "@types/react": "18.3.3",
39
- "ink-testing-library": "4.0.0",
40
- "typescript": "5.4.5"
41
- },
42
28
  "engines": {
43
29
  "node": ">=18"
44
- },
45
- "publishConfig": {
46
- "access": "public"
47
30
  }
48
31
  }
package/src/aimux.ts CHANGED
@@ -20,6 +20,8 @@ export interface AimuxStatus {
20
20
  state: AimuxState
21
21
  phase?: AimuxPhase
22
22
  detail?: string
23
+ /** Runtime package version when known; falls back to the bundled version. */
24
+ version?: string
23
25
  identifier?: string
24
26
  attempt?: number
25
27
  }
@@ -90,7 +92,9 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
90
92
  // 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
91
93
  // 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
92
94
  const BUNDLE_VER = '3'
93
- const BUNDLE_AIMUX_VERSION = '0.1.23'
95
+ const BUNDLE_AIMUX_VERSION = '0.1.29'
96
+ /** Version expected from the installed or bundled AIMUX runtime. */
97
+ export const AIMUX_VERSION = BUNDLE_AIMUX_VERSION
94
98
  const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
95
99
  const bundlePython = () => WIN
96
100
  ? path.join(bundleDir(), 'python', 'python.exe')
package/src/api.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  * bearer token (header `Authorization: Bearer <jwt>`); there is no cookie auth.
7
7
  */
8
8
  import type {
9
+ AnyEntry,
9
10
  AuthConfig,
11
+ HistoryGroup,
10
12
  Issue,
11
13
  LoginResponse,
12
14
  Memory,
@@ -160,6 +162,17 @@ export class MobiusClient {
160
162
  return this.request<SessionRuntimeStatus>(`/api/sessions/${encodeURIComponent(sessionId)}/status`, { signal })
161
163
  }
162
164
 
165
+ // ── agent-history (协议 ①②: 组元数据 + 整组条目) ──────────────────────────
166
+ /** ① 全部组元数据, 一次给全. */
167
+ async listHistoryGroups(sessionId: string): Promise<{ session_version: number; groups: HistoryGroup[] }> {
168
+ return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups`)
169
+ }
170
+
171
+ /** ② 某组全部条目 (全量, 无分页, 条目不可变). */
172
+ async listHistoryGroupEntries(sessionId: string, groupId: string): Promise<{ group_id: string; version: number; entries: AnyEntry[] }> {
173
+ return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups/${encodeURIComponent(groupId)}/entries`)
174
+ }
175
+
163
176
  // ── preference lookups ────────────────────────────────────────────────────
164
177
  async modelOptions(): Promise<SessionModelOption[]> {
165
178
  const r = await this.request<any>('/api/sessions/model-options')
@@ -24,11 +24,12 @@ import {
24
24
  import type { ReadyState } from './PrepScreen.js'
25
25
  import type { AnyEntry } from '../types.js'
26
26
  import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
27
- import type { AimuxStatus } from '../aimux.js'
27
+ import { AIMUX_VERSION, type AimuxStatus } from '../aimux.js'
28
28
  import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
29
29
  import { isEscapeKeypress, isMouseInput, useMouseEvents, useStableInput } from './primitives.js'
30
30
  import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
31
31
  import { useCursorKeyCapture } from '../lib/cursor-keys.js'
32
+ import { usePaintFlushOnInput } from '../lib/paint-flush.js'
32
33
 
33
34
  interface ChatProps {
34
35
  client: MobiusClient
@@ -62,6 +63,7 @@ const SLASH_COMMANDS = [
62
63
  { cmd: '/config', desc: '重新选择项目、任务和模型' },
63
64
  { cmd: '/logout', desc: '断开当前连接并返回登录界面' },
64
65
  { cmd: '/help', desc: '显示帮助' },
66
+ { cmd: '/version', desc: '显示 TUI、AIMUX 和运行环境版本' },
65
67
  { cmd: '/quit', desc: '退出 TUI' },
66
68
  ]
67
69
 
@@ -77,6 +79,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
77
79
  const [reconfigOpen, setReconfigOpen] = useState(false)
78
80
  // 本地命令错误 (如无会话时 /compact): 与 chat.error 分开, 下一次提交时清除。
79
81
  const [slashError, setSlashError] = useState<string | null>(null)
82
+ const [versionInfo, setVersionInfo] = useState<string[] | null>(null)
80
83
  // Ink may deliver one final event to Composer while an async config picker is
81
84
  // replacing it. The shared ref lets that stale listener report "not handled"
82
85
  // so App can replay the key after the new Select mounts.
@@ -88,6 +91,9 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
88
91
  const handlerRef = useRef<{ configOpen: boolean; reconfigOpen: boolean; sessionId: string | null }>({ configOpen: false, reconfigOpen: false, sessionId: null })
89
92
  handlerRef.current = { configOpen, reconfigOpen, sessionId: chat.sessionId }
90
93
  const terminal = useTerminalSize()
94
+ // Paint each keystroke immediately instead of waiting out Ink's 32ms render
95
+ // throttle — that dead window was the typing lag users felt.
96
+ usePaintFlushOnInput()
91
97
 
92
98
  const runSlash = useCallback((raw: string) => {
93
99
  const [name] = raw.trim().split(/\s+/)
@@ -108,13 +114,27 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
108
114
  }
109
115
  case '/resume': onResume(); return true
110
116
  case '/help': setShowHelp(s => !s); return true
117
+ case '/version': {
118
+ const details = [
119
+ `TUI v${TUI_VERSION}`,
120
+ `AIMUX v${aimuxStatus?.version ?? AIMUX_VERSION}`,
121
+ `Node ${process.version}`,
122
+ `平台 ${process.platform}/${process.arch}`,
123
+ `服务器 ${client.server}`,
124
+ `模型 ${modelLabel ?? ready.prefs.model ?? 'default'}`,
125
+ `AIMUX状态 ${aimuxStatus ? aimuxStatusText(aimuxStatus, true) : '未知'}`,
126
+ ]
127
+ setVersionInfo(details)
128
+ setShowHelp(false)
129
+ return true
130
+ }
111
131
  case '/model': setConfigOpen(true); return true
112
132
  case '/config': setReconfigOpen(true); return true
113
133
  case '/logout': onLogout(); return true
114
134
  case '/quit': case '/exit': onQuit(); return true
115
135
  default: return false
116
136
  }
117
- }, [chat, onClear, onResume, onQuit, onLogout])
137
+ }, [aimuxStatus, chat, client.server, modelLabel, ready.prefs.model, onClear, onResume, onQuit, onLogout])
118
138
 
119
139
  const onSubmit = useCallback((text: string) => {
120
140
  const t = text.trim()
@@ -125,14 +145,14 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
125
145
  return
126
146
  }
127
147
  setShowHelp(false)
148
+ setVersionInfo(null)
128
149
  setRowAnchor(null)
129
150
  void chat.send(t)
130
151
  }, [chat, runSlash])
131
152
 
132
153
  // First query of a fresh session triggers the full backend bootstrap (lazy
133
154
  // session creation, worker spawn, context load) before any output streams.
134
- // Label that phase "第一个问题,正在初始化+全平台同步中,请稍候" instead of "Working"
135
- // so it reads as startup rather than a stuck agent. Once the first assistant
155
+ // Label that phase as startup rather than a stuck agent. Once the first assistant
136
156
  // output is observed (or the session is a resumed one with prior history),
137
157
  // the indicator falls back to the normal Working label for every turn.
138
158
  const firstQueryInFlight = !resumeSessionId && !chat.entries.some(isAssistantOutput)
@@ -177,7 +197,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
177
197
  (entry) => rowsForEntry(entry),
178
198
  ), [transcriptEntries, rowsForEntry])
179
199
  const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
180
- const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0) + (slashError ? 1 : 0)
200
+ const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0) + (slashError ? 1 : 0) + (versionInfo ? versionInfo.length + 2 : 0)
181
201
  const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
182
202
  // Conversation chrome is exactly two rows: compact header + navigation.
183
203
  const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 2)
@@ -384,6 +404,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
384
404
  {chat.typing ? <WorkingIndicator firstQuery={firstQueryInFlight} /> : null}
385
405
  {chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
386
406
  {slashError ? <Text color="red">⚠ {slashError}</Text> : null}
407
+ {versionInfo ? <VersionBlock lines={versionInfo} /> : null}
387
408
 
388
409
  <Composer
389
410
  onSubmit={onSubmit}
@@ -576,6 +597,15 @@ function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] })
576
597
  )
577
598
  }
578
599
 
600
+ function VersionBlock({ lines }: { lines: string[] }) {
601
+ return (
602
+ <Box flexDirection="column" borderStyle="round" borderColor="gray" borderDimColor paddingX={1} marginTop={1}>
603
+ <Text bold color="cyan">Mobius 版本信息</Text>
604
+ {lines.map(line => <Text key={line} dimColor>{line}</Text>)}
605
+ </Box>
606
+ )
607
+ }
608
+
579
609
  interface ComposerProps {
580
610
  onSubmit: (text: string) => void
581
611
  onStop: () => void
@@ -4,11 +4,14 @@
4
4
  * Lifecycle (per the TUI spec):
5
5
  * - lazily create a session (POST /api/issues/:issueId/sessions) on the first
6
6
  * submitted message, using the saved preferences;
7
- * - open the SSE stream (GET /api/sessions/:id/events?token=) and append
8
- * `jsonl_entry` payloads to the transcript as they arrive;
7
+ * - open the SSE stream (GET /api/sessions/:id/events?token=); on subscribe
8
+ * bootstrap the transcript via groups + ② tail-group entries, then apply
9
+ * live `entries` batches (watermark + uuid dedup) as they arrive;
10
+ * - reconnects re-run the same ① negotiation (stateless reconciliation):
11
+ * groups whose version changed are refetched whole, transcript rebuilt;
9
12
  * - keep the agent's busy state synchronized with the runtime status API.
10
13
  * `/clear` remounts the hook (fresh session next time); `/resume` injects a
11
- * pre-existing sessionId so the stream replays its history.
14
+ * pre-existing sessionId.
12
15
  */
13
16
  import { useCallback, useEffect, useRef, useState } from 'react'
14
17
  import { MobiusClient, ApiError } from '../api.js'
@@ -42,7 +45,7 @@ function nextId(): number { ID += 1; return ID }
42
45
  /**
43
46
  * Does this entry represent the user's just-submitted message? Used to retire the
44
47
  * optimistic `pendingUser` placeholder once the real entry is observed — including
45
- * via a reconnect's history replay (the live `jsonl_entry` path already clears it).
48
+ * via a reconnect's history reconciliation (the live `entries` path already clears it).
46
49
  *
47
50
  * Mobius may prepend injected context (project/issue framing) to a user turn, so we
48
51
  * match the typed text as a suffix of the entry's normalized text rather than
@@ -66,6 +69,22 @@ function entryKey(entry: AnyEntry): string | null {
66
69
  return typeof entry?.uuid === 'string' ? entry.uuid : null
67
70
  }
68
71
 
72
+ // 首次 bootstrap 拉取的末尾组数 (旧 SSE 尾部回放的等价物; 更早的组按需不拉,
73
+ // TUI 是平铺字幕, 没有轮次展开概念, 末尾几组已覆盖活跃对话).
74
+ const BOOTSTRAP_GROUP_COUNT = 3
75
+ // A fresh session can spend several seconds creating the worker and loading
76
+ // context before /status reports alive=true. Keep the first-turn indicator
77
+ // visible during that bootstrap window instead of letting the short generic
78
+ // hint expire and leaving the user with no feedback.
79
+ const FIRST_TURN_BOOTSTRAP_GRACE_MS = 30_000
80
+
81
+ /** Mini group store: 组序 + 水位线 (version) + 组内条目. */
82
+ interface GroupSlot {
83
+ seq: number
84
+ version: number
85
+ entries: AnyEntry[]
86
+ }
87
+
69
88
  // Retry transient gateway/transport errors so a brief 502/503/504 (a reverse-
70
89
  // proxy blip, a backend worker recycling after a deploy, a transient upstream
71
90
  // failure) doesn't immediately fail a message dispatch. 4xx errors are not
@@ -95,6 +114,8 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
95
114
  const [sending, setSending] = useState(false)
96
115
  const [error, setError] = useState<string | null>(null)
97
116
  const sseRef = useRef<SseConnection | null>(null)
117
+ // agent-history mini group store (协议 ①②③ 的 TUI 侧消费形态).
118
+ const groupSlotsRef = useRef<Map<string, GroupSlot>>(new Map())
98
119
  const pollNowRef = useRef<(() => void) | null>(null)
99
120
  const typingRef = useRef(false)
100
121
  const sendingRef = useRef(false)
@@ -103,7 +124,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
103
124
  // SSE auto-reconnect state. A reverse proxy's idle timeout (or a server
104
125
  // restart) drops the stream mid-session; without reconnect the TUI stops
105
126
  // receiving new jsonl entries even though the web client keeps updating.
106
- // On reconnect the server replays jsonl_history, so no entries are lost.
127
+ // On reconnect the stateless ①② reconciliation refills any missed entries.
107
128
  const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
108
129
  const reconnectAttemptRef = useRef(0)
109
130
  const aliveRef = useRef(true)
@@ -119,7 +140,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
119
140
  const appendEntries = useCallback((newOnes: AnyEntry[]) => {
120
141
  if (!newOnes.length) return
121
142
  setEntries(prev => {
122
- // De-duplicate by uuid so a live jsonl_entry that also appears in a
143
+ // De-duplicate by uuid so a live `entries` batch that also appears in a
123
144
  // reconnect's history replay is never shown twice.
124
145
  const seen = new Set<string>()
125
146
  for (const e of prev) { const k = entryKey(e); if (k) seen.add(k) }
@@ -145,6 +166,63 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
145
166
  setEntries(out)
146
167
  }, [])
147
168
 
169
+ // ── agent-history (协议 ①②③): bootstrap / 重连对账 ──────────────────────
170
+
171
+ /** 按组序摊平重建 transcript (uuid 去重由 setHistory 兜底). */
172
+ const rebuildEntriesFromGroups = useCallback(() => {
173
+ const slots = [...groupSlotsRef.current.values()].sort((a, b) => a.seq - b.seq)
174
+ const flat: AnyEntry[] = []
175
+ for (const s of slots) flat.push(...s.entries)
176
+ setHistory(flat)
177
+ }, [setHistory])
178
+
179
+ /**
180
+ * Stateless 对账 (订阅时/重连时同一条路径):
181
+ * ① 拿全部组元数据 → 本地没有的或 version 变了的组 ② 整组重拉 → 重建 transcript.
182
+ * 首次 (本地空) 只拉末尾 BOOTSTRAP_GROUP_COUNT 组; 之后每次只补差额, 常态零请求.
183
+ */
184
+ const reconcileHistory = useCallback(async (sid: string) => {
185
+ try {
186
+ const data = await client.listHistoryGroups(sid)
187
+ const groups: any[] = Array.isArray(data?.groups) ? data.groups : []
188
+ const local = groupSlotsRef.current
189
+ const targets = local.size === 0 ? groups.slice(-BOOTSTRAP_GROUP_COUNT) : groups
190
+ let changed = false
191
+ for (const g of targets) {
192
+ const gid = String(g?.id ?? '')
193
+ if (!gid) continue
194
+ const ver = Number(g?.version) || 0
195
+ const cur = local.get(gid)
196
+ if (cur && cur.version >= ver) continue
197
+ try {
198
+ const r = await client.listHistoryGroupEntries(sid, gid)
199
+ local.set(gid, {
200
+ seq: Number(g?.seq) || (local.size + 1),
201
+ version: Number(r?.version) || 0,
202
+ entries: Array.isArray(r?.entries) ? r.entries : [],
203
+ })
204
+ changed = true
205
+ } catch { /* 单组失败不阻塞其余组 */ }
206
+ }
207
+ // 服务端已不存在的组 → 丢弃 (会话被删/重建的防御).
208
+ const alive = new Set(groups.map((g: any) => String(g?.id ?? '')))
209
+ for (const gid of [...local.keys()]) {
210
+ if (!alive.has(gid)) { local.delete(gid); changed = true }
211
+ }
212
+ if (changed) rebuildEntriesFromGroups()
213
+ // 对账补齐后, 若乐观占位已被真实条目覆盖 → 退掉 (断线期间整轮完成的场景).
214
+ setPendingUser(prev => {
215
+ if (prev === null) return prev
216
+ const slots = [...local.values()].sort((a, b) => a.seq - b.seq)
217
+ const flat: AnyEntry[] = []
218
+ for (const s of slots) flat.push(...s.entries)
219
+ return flat.some(e => entryMatchesPendingUser(e, prev)) ? null : prev
220
+ })
221
+ } catch (e) {
222
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[history-reconcile]', (e as Error)?.message ?? e)
223
+ }
224
+ }, [client, rebuildEntriesFromGroups])
225
+
148
226
  // ── SSE connection ────────────────────────────────────────────────────────
149
227
  const connect = useCallback((sid: string) => {
150
228
  if (process.env.MOBIUS_TUI_DEBUG) console.error('[connect]', sid)
@@ -153,23 +231,49 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
153
231
  if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null }
154
232
  const url = `${client.server}/api/sessions/${encodeURIComponent(sid)}/events?token=${encodeURIComponent(client.token)}`
155
233
  const conn = new SseConnection(url, {
156
- onHistoryEntries: (es, _done) => {
157
- if (es.length) setHistory(es)
158
- // A reconnect replays the session tail. If our optimistic placeholder is
159
- // now backed by its real entry, retire it so the user's input isn't shown
160
- // twice (once as the entry, once as the placeholder). The live jsonl_entry
161
- // path already clears pendingUser, but a dropped SSE stream (reverse-proxy
162
- // idle timeout) can deliver the message only via this history replay — and
163
- // if the whole turn finished while disconnected, no live entry ever comes
164
- // to clear it, leaving the duplication on screen until the next send.
165
- setPendingUser(prev => (prev !== null && es.some(e => entryMatchesPendingUser(e, prev)) ? null : prev))
234
+ onGroupCreated: (group) => {
235
+ if (!group || typeof group !== 'object') return
236
+ const gid = String(group.id ?? '')
237
+ if (!gid || groupSlotsRef.current.has(gid)) return // 元数据不可变, 已知即忽略
238
+ groupSlotsRef.current.set(gid, {
239
+ seq: Number(group.seq) || (groupSlotsRef.current.size + 1),
240
+ version: Number(group.version) || 1,
241
+ entries: [],
242
+ })
166
243
  },
167
- onEntry: (entry) => {
168
- if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntry]', entry?.type, (entry?.message?.content?.[0]?.text ?? '').slice(0, 40))
169
- appendEntries([entry])
170
- setPendingUser(null)
244
+ onEntries: ({ group_id, group_id_version, entries }) => {
245
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntries]', group_id, group_id_version, entries.length)
246
+ const gid = String(group_id ?? '')
247
+ if (!gid) return
248
+ let slot = groupSlotsRef.current.get(gid)
249
+ if (!slot) {
250
+ // 事件早到且本地无该组 (错过 group_created): 建槽后按水位线对账.
251
+ slot = { seq: groupSlotsRef.current.size + 1, version: 0, entries: [] }
252
+ groupSlotsRef.current.set(gid, slot)
253
+ }
254
+ const version = Number(group_id_version) || 0
255
+ if (slot.entries.length > 0 && version <= slot.version) return // 水位线: ≤ 本地即丢弃
256
+ // uuid 去重保险丝: 与整组重拉/对账重叠的条目只留一份.
257
+ const known = new Set<string>()
258
+ for (const e of slot.entries) { const k = entryKey(e); if (k) known.add(k) }
259
+ const fresh = entries.filter(e => {
260
+ const k = entryKey(e)
261
+ if (k && known.has(k)) return false
262
+ if (k) known.add(k)
263
+ return true
264
+ })
265
+ slot.entries = slot.entries.concat(fresh)
266
+ slot.version = Math.max(slot.version, version)
267
+ if (fresh.length > 0) {
268
+ appendEntries(fresh)
269
+ setPendingUser(null)
270
+ }
271
+ },
272
+ onSubscribed: () => {
273
+ reconnectAttemptRef.current = 0
274
+ // 订阅即对账 (首开 = bootstrap 拉末尾几组; 重连 = stateless 补差额).
275
+ void reconcileHistory(sid)
171
276
  },
172
- onSubscribed: () => { reconnectAttemptRef.current = 0 },
173
277
  onTyping: (active) => {
174
278
  // SSE is a low-latency hint, not the source of truth. A `true` event
175
279
  // lights the indicator immediately; either edge requests a fresh
@@ -179,7 +283,11 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
179
283
  workingHintUntilRef.current = Date.now() + 1_500
180
284
  updateTyping(true)
181
285
  } else {
182
- workingHintUntilRef.current = 0
286
+ // A fresh turn uses a longer bootstrap grace period. Some agents
287
+ // emit an early typing=false edge before their worker is observable;
288
+ // do not let that transient edge erase the first-turn indicator.
289
+ const remaining = workingHintUntilRef.current - Date.now()
290
+ if (remaining < 5_000) workingHintUntilRef.current = 0
183
291
  }
184
292
  pollNowRef.current?.()
185
293
  },
@@ -204,7 +312,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
204
312
  })
205
313
  sseRef.current = conn
206
314
  conn.start()
207
- }, [client.server, client.token, appendEntries, setHistory, updateTyping])
315
+ }, [client.server, client.token, appendEntries, setHistory, updateTyping, reconcileHistory])
208
316
  doConnectRef.current = connect
209
317
 
210
318
  const ensureSseForSend = useCallback((sid: string): boolean => {
@@ -374,7 +482,8 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
374
482
  setError(null)
375
483
  setPendingUser(body)
376
484
  statusEpochRef.current += 1
377
- workingHintUntilRef.current = Date.now() + 2_000
485
+ const firstTurn = !sessionId && entries.length === 0
486
+ workingHintUntilRef.current = Date.now() + (firstTurn ? FIRST_TURN_BOOTSTRAP_GRACE_MS : 2_000)
378
487
  sendingRef.current = true
379
488
  updateTyping(true)
380
489
  setSending(true)
@@ -400,7 +509,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
400
509
  setSending(false)
401
510
  pollNowRef.current?.()
402
511
  }
403
- }, [sending, ensureSession, ensureSseForSend, client, updateTyping])
512
+ }, [sending, sessionId, entries.length, ensureSession, ensureSseForSend, client, updateTyping])
404
513
 
405
514
  const stop = useCallback(async () => {
406
515
  if (!sessionId) return