@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 +1 -1
- package/src/components/Chat.tsx +6 -2
- package/src/hooks/useChat.ts +60 -4
package/package.json
CHANGED
package/src/components/Chat.tsx
CHANGED
|
@@ -35,7 +35,7 @@ interface TerminalSize {
|
|
|
35
35
|
isTty: boolean
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const VERSION = '0.2.
|
|
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
|
-
|
|
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
|
package/src/hooks/useChat.ts
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|