@mobius-os/mobius 0.2.2 → 0.2.4

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.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -35,7 +35,7 @@ interface TerminalSize {
35
35
  isTty: boolean
36
36
  }
37
37
 
38
- const VERSION = '0.2.1'
38
+ const VERSION = '0.2.4'
39
39
  const WELCOME_ROWS = 12
40
40
  const CHROME_ROWS = 11
41
41
 
@@ -78,7 +78,11 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
78
78
  () => fitTranscript(chat.entries, transcriptRows, terminal.columns),
79
79
  [chat.entries, transcriptRows, terminal.columns],
80
80
  )
81
- const showWelcome = fitted.estimatedRows + WELCOME_ROWS <= transcriptRows
81
+ // Welcome card is for fresh / short sessions only. Once the conversation is
82
+ // long enough that fitTranscript hides older entries, switch to the compact
83
+ // header + full transcript — otherwise the 12-row welcome card crowds out the
84
+ // recent messages and the chat area reads as blank after "已隐藏较早的…".
85
+ const showWelcome = fitted.hiddenCount === 0 && fitted.estimatedRows + WELCOME_ROWS <= transcriptRows
82
86
 
83
87
  return (
84
88
  <Box
@@ -15,6 +15,7 @@ import { MobiusClient, ApiError } from '../api.js'
15
15
  import { SseConnection } from '../sse.js'
16
16
  import { updateIssuePreference } from '../config.js'
17
17
  import { tuiAimuxIdentifier, probeAimuxBridgeConnection } from '../aimux.js'
18
+ import { viewsForEntry } from '../lib/entry-view.js'
18
19
  import type { AnyEntry } from '../types.js'
19
20
  import type { ReadyState } from '../components/PrepScreen.js'
20
21
 
@@ -38,6 +39,33 @@ export interface ChatController {
38
39
  let ID = 0
39
40
  function nextId(): number { ID += 1; return ID }
40
41
 
42
+ /**
43
+ * Does this entry represent the user's just-submitted message? Used to retire the
44
+ * 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).
46
+ *
47
+ * Mobius may prepend injected context (project/issue framing) to a user turn, so we
48
+ * match the typed text as a suffix of the entry's normalized text rather than
49
+ * requiring exact equality; a verbatim entry still matches because it ends with the
50
+ * typed text.
51
+ */
52
+ function entryMatchesPendingUser(entry: AnyEntry, pendingText: string): boolean {
53
+ if (!pendingText) return false
54
+ const want = pendingText.replace(/\s+/g, ' ').trim()
55
+ if (!want) return false
56
+ for (const view of viewsForEntry(entry)) {
57
+ if (view.kind !== 'user') continue
58
+ const got = view.text.replace(/\s+/g, ' ').trim()
59
+ if (got === want || got.endsWith(want)) return true
60
+ }
61
+ return false
62
+ }
63
+
64
+ /** Stable identity for de-duplication. Every Mobius jsonl entry carries a uuid. */
65
+ function entryKey(entry: AnyEntry): string | null {
66
+ return typeof entry?.uuid === 'string' ? entry.uuid : null
67
+ }
68
+
41
69
  // Retry transient gateway/transport errors so a brief 502/503/504 (a reverse-
42
70
  // proxy blip, a backend worker recycling after a deploy, a transient upstream
43
71
  // failure) doesn't immediately fail a message dispatch. 4xx errors are not
@@ -90,13 +118,30 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
90
118
  const appendEntries = useCallback((newOnes: AnyEntry[]) => {
91
119
  if (!newOnes.length) return
92
120
  setEntries(prev => {
93
- const stamped = newOnes.map(e => ({ ...e, __id: e.__id ?? nextId() }))
94
- return [...prev, ...stamped]
121
+ // De-duplicate by uuid so a live jsonl_entry that also appears in a
122
+ // reconnect's history replay is never shown twice.
123
+ const seen = new Set<string>()
124
+ for (const e of prev) { const k = entryKey(e); if (k) seen.add(k) }
125
+ const stamped: AnyEntry[] = []
126
+ for (const e of newOnes) {
127
+ const k = entryKey(e)
128
+ if (k && seen.has(k)) continue
129
+ if (k) seen.add(k)
130
+ stamped.push({ ...e, __id: e.__id ?? nextId() })
131
+ }
132
+ return stamped.length ? [...prev, ...stamped] : prev
95
133
  })
96
134
  }, [])
97
135
 
98
136
  const setHistory = useCallback((list: AnyEntry[]) => {
99
- setEntries(list.map(e => ({ ...e, __id: e.__id ?? nextId() })))
137
+ const seen = new Set<string>()
138
+ const out: AnyEntry[] = []
139
+ for (const e of list) {
140
+ const k = entryKey(e)
141
+ if (k) { if (seen.has(k)) continue; seen.add(k) }
142
+ out.push({ ...e, __id: e.__id ?? nextId() })
143
+ }
144
+ setEntries(out)
100
145
  }, [])
101
146
 
102
147
  // ── SSE connection ────────────────────────────────────────────────────────
@@ -108,6 +153,14 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
108
153
  const conn = new SseConnection(url, {
109
154
  onHistoryEntries: (es, _done) => {
110
155
  if (es.length) setHistory(es)
156
+ // A reconnect replays the session tail. If our optimistic placeholder is
157
+ // now backed by its real entry, retire it so the user's input isn't shown
158
+ // twice (once as the entry, once as the placeholder). The live jsonl_entry
159
+ // path already clears pendingUser, but a dropped SSE stream (reverse-proxy
160
+ // idle timeout) can deliver the message only via this history replay — and
161
+ // if the whole turn finished while disconnected, no live entry ever comes
162
+ // to clear it, leaving the duplication on screen until the next send.
163
+ setPendingUser(prev => (prev !== null && es.some(e => entryMatchesPendingUser(e, prev)) ? null : prev))
111
164
  },
112
165
  onEntry: (entry) => {
113
166
  if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntry]', entry?.type, (entry?.message?.content?.[0]?.text ?? '').slice(0, 40))
@@ -289,7 +342,10 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
289
342
 
290
343
  const send = useCallback(async (text: string) => {
291
344
  const body = text.trim()
292
- if (!body || sending) return
345
+ // Guard on the ref (synchronous truth) as well as the state so a stale
346
+ // closure can't dispatch the same message twice (two distinct reqIds → two
347
+ // user entries on the server).
348
+ if (!body || sending || sendingRef.current) return
293
349
  setError(null)
294
350
  setPendingUser(body)
295
351
  statusEpochRef.current += 1