@linxin666/dsh-remote-web-ui 0.1.12 → 0.1.13
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/README.i18n.yaml +6 -0
- package/README.md +12 -1
- package/README.zh.md +171 -0
- package/lib/client.js +169 -98
- package/lib/client.js.map +1 -1
- package/lib/index.js +224 -4
- package/lib/mobile.js +109 -33
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +3 -1
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +10 -2
- package/lib/types/client/deep-link.js +10 -5
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +8 -0
- package/lib/types/client/update-api.d.ts +10 -0
- package/lib/types/client/update-api.d.ts.map +1 -1
- package/lib/types/client/update-api.js +22 -2
- package/lib/types/index.d.ts +7 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +7 -1
- package/lib/types/mobile/api.d.ts +9 -2
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +7 -3
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +3 -0
- package/lib/types/mobile/views/App.d.ts +3 -1
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +18 -3
- package/lib/types/mobile/views/ChatView.d.ts +6 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +95 -7
- package/lib/types/mobile/views/ChatView.test.js +161 -3
- package/lib/types/mobile/views/WorkspaceView.d.ts +3 -1
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +9 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +2 -0
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/WorkspaceView.test.js +56 -0
- package/lib/types/mobile-api.d.ts +2 -0
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +17 -2
- package/package.json +1 -11
- package/src/client/PluginSettingsCard.tsx +30 -5
- package/src/client/RemoteSettingsCard.tsx +18 -0
- package/src/client/UpdateEntry.tsx +10 -2
- package/src/client/deep-link.ts +10 -4
- package/src/client/locales.ts +8 -0
- package/src/client/settings-card.module.css +139 -144
- package/src/client/update-api.ts +22 -2
- package/src/index.ts +14 -1
- package/src/mobile/api.ts +14 -2
- package/src/mobile/mobile-styles.ts +3 -0
- package/src/mobile/views/App.tsx +22 -3
- package/src/mobile/views/ChatView.test.tsx +185 -3
- package/src/mobile/views/ChatView.tsx +102 -8
- package/src/mobile/views/WorkspaceView.test.tsx +66 -0
- package/src/mobile/views/WorkspaceView.tsx +11 -2
- package/src/mobile-api.ts +20 -2
package/src/client/update-api.ts
CHANGED
|
@@ -6,14 +6,34 @@
|
|
|
6
6
|
|
|
7
7
|
import type { UpdateRunResult, UpdateStatus } from '../update.ts'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Error thrown when the update status probe fails: carries the HTTP status
|
|
11
|
+
* so the panel can tell "the update route is not mounted" (404 — the host
|
|
12
|
+
* process runs an older plugin build) apart from real network failures.
|
|
13
|
+
*/
|
|
14
|
+
export class UpdateStatusError extends Error {
|
|
15
|
+
/** HTTP status of the failed response (0 when the fetch never returned). */
|
|
16
|
+
readonly status: number
|
|
17
|
+
constructor(status: number) {
|
|
18
|
+
super('update status unavailable (HTTP ' + String(status) + ')')
|
|
19
|
+
this.status = status
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
/**
|
|
10
24
|
* Probe the update status: install mode, owning profile, and the
|
|
11
25
|
* current-vs-latest comparison for every family package.
|
|
12
26
|
* @returns the status snapshot.
|
|
13
27
|
*/
|
|
14
28
|
export async function fetchUpdateStatus(): Promise<UpdateStatus> {
|
|
15
|
-
|
|
16
|
-
|
|
29
|
+
let response: Response
|
|
30
|
+
try {
|
|
31
|
+
response = await fetch('/api/update/status')
|
|
32
|
+
} catch {
|
|
33
|
+
// Network-level failure: nothing came back at all.
|
|
34
|
+
throw new UpdateStatusError(0)
|
|
35
|
+
}
|
|
36
|
+
if (!response.ok) throw new UpdateStatusError(response.status)
|
|
17
37
|
return await response.json() as UpdateStatus
|
|
18
38
|
}
|
|
19
39
|
|
package/src/index.ts
CHANGED
|
@@ -96,6 +96,13 @@ export interface Config {
|
|
|
96
96
|
* tunnel setup. The manual `publicBaseUrl` is ignored while this is on.
|
|
97
97
|
*/
|
|
98
98
|
autoTunnel?: boolean
|
|
99
|
+
/**
|
|
100
|
+
* Mobile composer behavior: when true (default), a plain Enter in the
|
|
101
|
+
* phone chat textarea sends the prompt and Shift+Enter inserts a newline.
|
|
102
|
+
* When false, plain Enter inserts a newline and only the send button
|
|
103
|
+
* sends (Shift+Enter keeps inserting a newline).
|
|
104
|
+
*/
|
|
105
|
+
mobileEnterToSend?: boolean
|
|
99
106
|
/** Master switch for the plugin (browser half + host pairing surfaces). */
|
|
100
107
|
enabled?: boolean
|
|
101
108
|
}
|
|
@@ -108,6 +115,7 @@ export const Config: z<Config> = z.object({
|
|
|
108
115
|
requirePairingForLan: z.boolean().default(true),
|
|
109
116
|
publicBaseUrl: z.string(),
|
|
110
117
|
autoTunnel: z.boolean().default(false),
|
|
118
|
+
mobileEnterToSend: z.boolean().default(true),
|
|
111
119
|
enabled: z.boolean().default(true),
|
|
112
120
|
})
|
|
113
121
|
|
|
@@ -130,6 +138,7 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
130
138
|
requirePairingForLan: true,
|
|
131
139
|
publicBaseUrl: undefined,
|
|
132
140
|
autoTunnel: false,
|
|
141
|
+
mobileEnterToSend: true,
|
|
133
142
|
enabled: true,
|
|
134
143
|
}
|
|
135
144
|
|
|
@@ -147,6 +156,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
147
156
|
requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
148
157
|
publicBaseUrl: config?.publicBaseUrl,
|
|
149
158
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
159
|
+
mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
150
160
|
enabled: config?.enabled ?? DEFAULTS.enabled,
|
|
151
161
|
}
|
|
152
162
|
// The live source the pairing service and the gate read: the settings
|
|
@@ -163,6 +173,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
163
173
|
requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
164
174
|
publicBaseUrl: value.publicBaseUrl,
|
|
165
175
|
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
176
|
+
mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
166
177
|
enabled: value.enabled ?? DEFAULTS.enabled,
|
|
167
178
|
}
|
|
168
179
|
}
|
|
@@ -267,7 +278,9 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
267
278
|
const routes = [
|
|
268
279
|
...makeRoutes({ service, lanAddresses }),
|
|
269
280
|
...makeMobileRoutes(),
|
|
270
|
-
...(apiProxy !== undefined
|
|
281
|
+
...(apiProxy !== undefined
|
|
282
|
+
? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
|
|
283
|
+
: []),
|
|
271
284
|
...updateRoutes,
|
|
272
285
|
]
|
|
273
286
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
|
package/src/mobile/api.ts
CHANGED
|
@@ -35,12 +35,23 @@ export interface HistoryPage {
|
|
|
35
35
|
projections?: SessionProjectionsBlock
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Read-only display preferences the plugin answers locally on `/m/api`. */
|
|
39
|
+
export interface MobilePreferences {
|
|
40
|
+
/** Plain Enter sends the drafted prompt (false: Enter inserts a newline). */
|
|
41
|
+
mobileEnterToSend: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
/** The workspace roster (session ids come back per workspace). */
|
|
39
45
|
export async function listWorkspaces(): Promise<WorkspaceView[]> {
|
|
40
46
|
const { items } = await callUnary<{ items: WorkspaceView[] }>('workspace.list', {})
|
|
41
47
|
return items
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
/** Read-only mobile display preferences (answered by the plugin, not the host proxy). */
|
|
51
|
+
export async function fetchMobilePreferences(): Promise<MobilePreferences> {
|
|
52
|
+
return await callUnary<MobilePreferences>('mobile.preferences', {})
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
/** One session.list page; omit the cursor for the first page. */
|
|
45
56
|
export async function listSessions(cursor?: string): Promise<SessionPage> {
|
|
46
57
|
return await callUnary<SessionPage>('session.list', cursor === undefined ? {} : { cursor })
|
|
@@ -56,17 +67,18 @@ export async function createSession(
|
|
|
56
67
|
return await callUnary<CreatedSession>('session.create', options)
|
|
57
68
|
}
|
|
58
69
|
|
|
59
|
-
/** One history window; omit beforeSeq for the tail page. */
|
|
70
|
+
/** One history window; omit beforeSeq for the tail page, pass a signal to abort. */
|
|
60
71
|
export async function history(
|
|
61
72
|
sessionId: string,
|
|
62
73
|
beforeSeq?: number,
|
|
63
74
|
maxMessages = 30,
|
|
75
|
+
signal?: AbortSignal,
|
|
64
76
|
): Promise<HistoryPage> {
|
|
65
77
|
return await callUnary<HistoryPage>('session.history', {
|
|
66
78
|
sessionId,
|
|
67
79
|
maxMessages,
|
|
68
80
|
...(beforeSeq !== undefined ? { beforeSeq } : {}),
|
|
69
|
-
})
|
|
81
|
+
}, signal)
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
/** Send one text prompt (queued: the agent picks it up in order). */
|
|
@@ -765,6 +765,9 @@ body {
|
|
|
765
765
|
min-width: 0;
|
|
766
766
|
min-height: 40px;
|
|
767
767
|
max-height: 120px;
|
|
768
|
+
/* Grow with the content (still bounded by min/max-height); browsers
|
|
769
|
+
without field-sizing support ignore this line and keep the old size. */
|
|
770
|
+
field-sizing: content;
|
|
768
771
|
padding: 9px 12px;
|
|
769
772
|
border: 1px solid var(--m-border);
|
|
770
773
|
border-radius: 10px;
|
package/src/mobile/views/App.tsx
CHANGED
|
@@ -42,6 +42,12 @@ export interface RenderMessage {
|
|
|
42
42
|
toolSummary?: string
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Read the optional workspace target carried from the pairing QR flow. */
|
|
46
|
+
export function mobileWorkspaceTarget(search: string): string | undefined {
|
|
47
|
+
const value = new URLSearchParams(search).get('workspace')
|
|
48
|
+
return value === null || value === '' ? undefined : value
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
/** Map a list row to the surface model; the title comes from projections when present. */
|
|
46
52
|
export function toSessionView(item: {
|
|
47
53
|
sessionId: string
|
|
@@ -83,6 +89,9 @@ export function formatTime(epochMs: number): string {
|
|
|
83
89
|
*/
|
|
84
90
|
export function App() {
|
|
85
91
|
const [route, setRoute] = useState<Route>({ kind: 'workspaces' })
|
|
92
|
+
const [initialWorkspaceId, setInitialWorkspaceId] = useState<string | undefined>(
|
|
93
|
+
() => mobileWorkspaceTarget(window.location.search),
|
|
94
|
+
)
|
|
86
95
|
const muxRef = useRef<MuxClient | undefined>(undefined)
|
|
87
96
|
|
|
88
97
|
// The mux stream lives for the page lifetime: session events keep the
|
|
@@ -113,10 +122,20 @@ export function App() {
|
|
|
113
122
|
setRoute({ kind: 'chat', session, workspace })
|
|
114
123
|
}, [])
|
|
115
124
|
|
|
125
|
+
const openWorkspace = useCallback((workspace: WorkspaceRow) => {
|
|
126
|
+
if (initialWorkspaceId !== undefined) {
|
|
127
|
+
const url = new URL(window.location.href)
|
|
128
|
+
url.searchParams.delete('workspace')
|
|
129
|
+
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
|
|
130
|
+
setInitialWorkspaceId(undefined)
|
|
131
|
+
}
|
|
132
|
+
setRoute({ kind: 'sessions', workspace })
|
|
133
|
+
}, [initialWorkspaceId])
|
|
134
|
+
|
|
116
135
|
return (
|
|
117
136
|
<div className="mobile">
|
|
118
137
|
{route.kind === 'workspaces'
|
|
119
|
-
? <WorkspaceRoster
|
|
138
|
+
? <WorkspaceRoster initialWorkspaceId={initialWorkspaceId} onPick={openWorkspace} />
|
|
120
139
|
: route.kind === 'sessions'
|
|
121
140
|
? (
|
|
122
141
|
<SessionListView
|
|
@@ -152,8 +171,8 @@ export function staleHostHint(message: string): string | undefined {
|
|
|
152
171
|
}
|
|
153
172
|
|
|
154
173
|
/** Fetch one history page (tail by default) — thin wrapper so views share the call shape. */
|
|
155
|
-
export function loadHistory(sessionId: string, beforeSeq?: number) {
|
|
156
|
-
return fetchHistory(sessionId, beforeSeq)
|
|
174
|
+
export function loadHistory(sessionId: string, beforeSeq?: number, signal?: AbortSignal) {
|
|
175
|
+
return fetchHistory(sessionId, beforeSeq, undefined, signal)
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
export { listSessions, listWorkspaces, prompt }
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
5
5
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
6
|
-
import { ChatView } from './ChatView.tsx'
|
|
6
|
+
import { ChatView, MAX_TAIL_BUFFER_EVENTS } from './ChatView.tsx'
|
|
7
7
|
import { type SessionView } from './App.tsx'
|
|
8
8
|
import type { HistoryPage } from '../api.ts'
|
|
9
9
|
import type { WireEvent } from '../messages.ts'
|
|
@@ -11,6 +11,7 @@ import type { WireEvent } from '../messages.ts'
|
|
|
11
11
|
// The api module is fully mocked; App.tsx's history wrapper is overridden to
|
|
12
12
|
// feed fixed history pages, its pure helpers (errorText / formatTime) stay real.
|
|
13
13
|
vi.mock('../api.ts', () => ({
|
|
14
|
+
fetchMobilePreferences: vi.fn(),
|
|
14
15
|
models: vi.fn(),
|
|
15
16
|
selectModel: vi.fn(),
|
|
16
17
|
sendCommand: vi.fn(),
|
|
@@ -23,8 +24,8 @@ vi.mock('./App.tsx', async importOriginal => {
|
|
|
23
24
|
prompt: vi.fn(async () => {}),
|
|
24
25
|
}
|
|
25
26
|
})
|
|
26
|
-
import { models, selectModel, sendCommand } from '../api.ts'
|
|
27
|
-
import { loadHistory } from './App.tsx'
|
|
27
|
+
import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
|
|
28
|
+
import { loadHistory, prompt } from './App.tsx'
|
|
28
29
|
|
|
29
30
|
const session: SessionView = {
|
|
30
31
|
sessionId: 's-1',
|
|
@@ -67,12 +68,16 @@ function turnEvents(): Array<{ event: WireEvent }> {
|
|
|
67
68
|
]
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences)
|
|
70
72
|
const modelsMock = vi.mocked(models)
|
|
71
73
|
const selectModelMock = vi.mocked(selectModel)
|
|
72
74
|
const sendCommandMock = vi.mocked(sendCommand)
|
|
73
75
|
const loadHistoryMock = vi.mocked(loadHistory)
|
|
76
|
+
const promptMock = vi.mocked(prompt)
|
|
74
77
|
|
|
75
78
|
beforeEach(() => {
|
|
79
|
+
fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: true })
|
|
80
|
+
promptMock.mockResolvedValue(undefined)
|
|
76
81
|
modelsMock.mockResolvedValue({
|
|
77
82
|
current: { provider: 'fx', model: 'fx-1' },
|
|
78
83
|
routable: true,
|
|
@@ -95,6 +100,7 @@ beforeEach(() => {
|
|
|
95
100
|
afterEach(() => {
|
|
96
101
|
cleanup()
|
|
97
102
|
vi.clearAllMocks()
|
|
103
|
+
vi.restoreAllMocks()
|
|
98
104
|
})
|
|
99
105
|
|
|
100
106
|
describe('ChatView message folds', () => {
|
|
@@ -187,6 +193,115 @@ describe('ChatView message folds', () => {
|
|
|
187
193
|
})
|
|
188
194
|
})
|
|
189
195
|
|
|
196
|
+
describe('ChatView initial-load race', () => {
|
|
197
|
+
/** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
|
|
198
|
+
class FakeMux {
|
|
199
|
+
listeners = new Set<(frame: unknown) => void>()
|
|
200
|
+
onFrame(listener: (frame: unknown) => void): () => void {
|
|
201
|
+
this.listeners.add(listener)
|
|
202
|
+
return () => { this.listeners.delete(listener) }
|
|
203
|
+
}
|
|
204
|
+
emit(frame: unknown): void {
|
|
205
|
+
for (const listener of this.listeners) listener(frame)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
it('keeps live events that arrive while the tail page is still loading', async () => {
|
|
210
|
+
let resolveHistory: (page: HistoryPage) => void = () => {}
|
|
211
|
+
loadHistoryMock.mockReturnValue(new Promise<HistoryPage>((resolve) => { resolveHistory = resolve }))
|
|
212
|
+
const mux = new FakeMux()
|
|
213
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
214
|
+
|
|
215
|
+
// A live turn starts before the snapshot resolves: chunk, tool call, final.
|
|
216
|
+
await act(async () => {
|
|
217
|
+
mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', text: '正在' } }, 6).event })
|
|
218
|
+
mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('tool/call', { turn: 1, step: 0, callId: 'c9', name: 'bash', arguments: '{"cmd":"ls"}' }, 7).event })
|
|
219
|
+
mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('assistant/message', { turn: 1, step: 0, message: { id: 'a-9', role: 'assistant', content: [{ type: 'text', text: '实时新消息' }] } }, 8).event })
|
|
220
|
+
})
|
|
221
|
+
// The snapshot predates those events; resolving it must not drop them.
|
|
222
|
+
await act(async () => { resolveHistory(historyPage(turnEvents())) })
|
|
223
|
+
|
|
224
|
+
expect(await screen.findByText('实时新消息')).toBeTruthy()
|
|
225
|
+
// The history turn's tool disclosure plus the live one both render.
|
|
226
|
+
expect((await screen.findAllByRole('button', { name: /工具/ })).length).toBe(2)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('caps the tail-load live buffer and re-pulls the history tail after an overflow', async () => {
|
|
230
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
231
|
+
let resolveHistory: (page: HistoryPage) => void = () => {}
|
|
232
|
+
loadHistoryMock
|
|
233
|
+
.mockReturnValueOnce(new Promise<HistoryPage>((resolve) => { resolveHistory = resolve }))
|
|
234
|
+
// The overflow follow-up load resolves a page newer than the buffered burst.
|
|
235
|
+
.mockResolvedValueOnce(historyPage([
|
|
236
|
+
makeEntry('assistant/message', {
|
|
237
|
+
id: 'a-refetch',
|
|
238
|
+
role: 'assistant',
|
|
239
|
+
content: [{ type: 'text', text: '补拉恢复' }],
|
|
240
|
+
}, 700),
|
|
241
|
+
]))
|
|
242
|
+
const mux = new FakeMux()
|
|
243
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
244
|
+
|
|
245
|
+
// 501 live final messages arrive before the snapshot resolves: the first one
|
|
246
|
+
// is dropped by the 500-event cap, the remaining 500 stay buffered.
|
|
247
|
+
await act(async () => {
|
|
248
|
+
for (let index = 0; index <= MAX_TAIL_BUFFER_EVENTS; index++) {
|
|
249
|
+
mux.emit({
|
|
250
|
+
type: 'session/event',
|
|
251
|
+
sessionId: 's-1',
|
|
252
|
+
event: makeEntry('assistant/message', {
|
|
253
|
+
id: `a-burst-${index}`,
|
|
254
|
+
role: 'assistant',
|
|
255
|
+
content: [{ type: 'text', text: `突发消息 ${index}` }],
|
|
256
|
+
}, 100 + index).event,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// The overflow is logged exactly once, and the cap mentions the limit.
|
|
262
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
263
|
+
expect(String(warnSpy.mock.calls[0]?.[0])).toContain(String(MAX_TAIL_BUFFER_EVENTS))
|
|
264
|
+
|
|
265
|
+
await act(async () => { resolveHistory(historyPage(turnEvents())) })
|
|
266
|
+
|
|
267
|
+
// The capped buffer keeps the render bounded: the dropped oldest burst
|
|
268
|
+
// message is gone, while the newest buffered one survives the snapshot fold.
|
|
269
|
+
expect(await screen.findByText('突发消息 500')).toBeTruthy()
|
|
270
|
+
expect(screen.queryByText('突发消息 0')).toBeNull()
|
|
271
|
+
|
|
272
|
+
// The overflow triggered exactly one follow-up tail load, still carrying the
|
|
273
|
+
// same abort signal as the initial load.
|
|
274
|
+
await waitFor(() => { expect(loadHistoryMock).toHaveBeenCalledTimes(2) })
|
|
275
|
+
expect(loadHistoryMock.mock.calls[1]?.[0]).toBe('s-1')
|
|
276
|
+
expect(loadHistoryMock.mock.calls[1]?.[1]).toBeUndefined()
|
|
277
|
+
expect(loadHistoryMock.mock.calls[1]?.[2]).toBeInstanceOf(AbortSignal)
|
|
278
|
+
expect(loadHistoryMock.mock.calls[1]?.[2]).toBe(loadHistoryMock.mock.calls[0]?.[2])
|
|
279
|
+
|
|
280
|
+
// The re-pulled page folds into the already-rendered messages.
|
|
281
|
+
expect(await screen.findByText('补拉恢复')).toBeTruthy()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('passes an AbortSignal to loadHistory and aborts it on unmount', async () => {
|
|
285
|
+
let capturedSignal: AbortSignal | undefined
|
|
286
|
+
loadHistoryMock.mockImplementation((_sessionId, _beforeSeq, signal) => {
|
|
287
|
+
capturedSignal = signal
|
|
288
|
+
return Promise.resolve(historyPage(turnEvents()))
|
|
289
|
+
})
|
|
290
|
+
const view = render(<ChatView session={session} onBack={() => {}} />)
|
|
291
|
+
|
|
292
|
+
expect(await screen.findByText('已完成修改')).toBeTruthy()
|
|
293
|
+
expect(loadHistoryMock).toHaveBeenCalledTimes(1)
|
|
294
|
+
expect(loadHistoryMock.mock.calls[0]?.[0]).toBe('s-1')
|
|
295
|
+
expect(loadHistoryMock.mock.calls[0]?.[1]).toBeUndefined()
|
|
296
|
+
expect(loadHistoryMock.mock.calls[0]?.[2]).toBeInstanceOf(AbortSignal)
|
|
297
|
+
expect(capturedSignal).toBeInstanceOf(AbortSignal)
|
|
298
|
+
expect(capturedSignal?.aborted).toBe(false)
|
|
299
|
+
|
|
300
|
+
view.unmount()
|
|
301
|
+
expect(capturedSignal?.aborted).toBe(true)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
190
305
|
describe('ChatView model sheet', () => {
|
|
191
306
|
it('labels the toolbar chip with the current model and selects a new one', async () => {
|
|
192
307
|
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
|
|
@@ -241,6 +356,73 @@ describe('ChatView model sheet', () => {
|
|
|
241
356
|
})
|
|
242
357
|
})
|
|
243
358
|
|
|
359
|
+
describe('ChatView composer', () => {
|
|
360
|
+
const inputBox = (): HTMLTextAreaElement => screen.getByRole('textbox') as HTMLTextAreaElement
|
|
361
|
+
|
|
362
|
+
/** Dispatch one keydown through the React tree and return the real event. */
|
|
363
|
+
const pressEnter = (input: HTMLTextAreaElement, shiftKey = false): KeyboardEvent => {
|
|
364
|
+
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, shiftKey })
|
|
365
|
+
input.dispatchEvent(event)
|
|
366
|
+
return event
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
it('sends on Enter by default and keeps Shift+Enter inserting a newline', async () => {
|
|
370
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
|
|
371
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
372
|
+
await screen.findByText('已完成修改')
|
|
373
|
+
|
|
374
|
+
const input = inputBox()
|
|
375
|
+
expect(input.getAttribute('enterKeyHint')).toBe('send')
|
|
376
|
+
expect(input.getAttribute('placeholder')).toContain('Enter 发送')
|
|
377
|
+
|
|
378
|
+
fireEvent.change(input, { target: { value: '第一行' } })
|
|
379
|
+
const enter = pressEnter(input)
|
|
380
|
+
expect(enter.defaultPrevented).toBe(true)
|
|
381
|
+
await waitFor(() => {
|
|
382
|
+
expect(promptMock).toHaveBeenCalledWith('s-1', '第一行')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// Shift+Enter stays a newline gesture and never sends.
|
|
386
|
+
promptMock.mockClear()
|
|
387
|
+
const shifted = pressEnter(input, true)
|
|
388
|
+
expect(shifted.defaultPrevented).toBe(false)
|
|
389
|
+
expect(promptMock).not.toHaveBeenCalled()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('inserts a newline on Enter and sends only from the button when the preference is false', async () => {
|
|
393
|
+
fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: false })
|
|
394
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
|
|
395
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
396
|
+
await screen.findByText('已完成修改')
|
|
397
|
+
|
|
398
|
+
const input = inputBox()
|
|
399
|
+
await waitFor(() => { expect(input.getAttribute('enterKeyHint')).toBe('enter') })
|
|
400
|
+
expect(input.getAttribute('placeholder')).not.toContain('Enter 发送')
|
|
401
|
+
|
|
402
|
+
// The handler no longer prevents Enter, so the browser's default inserts
|
|
403
|
+
// a newline (emulated here through the controlled value) and no send fires.
|
|
404
|
+
fireEvent.change(input, { target: { value: '第一行' } })
|
|
405
|
+
const enter = pressEnter(input)
|
|
406
|
+
expect(enter.defaultPrevented).toBe(false)
|
|
407
|
+
fireEvent.change(input, { target: { value: '第一行\n' } })
|
|
408
|
+
expect(input.value).toBe('第一行\n')
|
|
409
|
+
expect(promptMock).not.toHaveBeenCalled()
|
|
410
|
+
|
|
411
|
+
// The send button still sends the full multi-line draft.
|
|
412
|
+
fireEvent.change(input, { target: { value: '第一行\n第二行' } })
|
|
413
|
+
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
|
414
|
+
await waitFor(() => {
|
|
415
|
+
expect(promptMock).toHaveBeenCalledWith('s-1', '第一行\n第二行')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// Shift+Enter keeps inserting a newline in either mode.
|
|
419
|
+
promptMock.mockClear()
|
|
420
|
+
const shifted = pressEnter(input, true)
|
|
421
|
+
expect(shifted.defaultPrevented).toBe(false)
|
|
422
|
+
expect(promptMock).not.toHaveBeenCalled()
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
|
|
244
426
|
describe('ChatView scrolling', () => {
|
|
245
427
|
// Controllable scrollHeight + a write log for the chat-scroll element. The
|
|
246
428
|
// accessors live on Element.prototype, so patching them here lets every
|
|
@@ -16,7 +16,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
|
|
|
16
16
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
17
17
|
import { loadHistory, prompt, type SessionView } from './App.tsx'
|
|
18
18
|
import { errorText, formatTime, staleHostHint } from './App.tsx'
|
|
19
|
-
import { models, selectModel, sendCommand } from '../api.ts'
|
|
19
|
+
import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
|
|
20
20
|
import { foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
|
|
21
21
|
import { MuxClient } from '../mux.ts'
|
|
22
22
|
import { ThemeToggle } from '../theme-toggle.tsx'
|
|
@@ -29,6 +29,13 @@ export interface ChatViewProps {
|
|
|
29
29
|
onBack(): void
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Hard cap on live events buffered while the initial history tail page is in
|
|
34
|
+
* flight. Beyond this the oldest buffered event is dropped and a follow-up
|
|
35
|
+
* history tail re-pull closes the seam.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_TAIL_BUFFER_EVENTS = 500
|
|
38
|
+
|
|
32
39
|
/** Extract the raw event from one history entry (the fold consumes events only). */
|
|
33
40
|
function eventOf(entry: { event: WireEvent }): WireEvent {
|
|
34
41
|
return entry.event
|
|
@@ -101,6 +108,18 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
101
108
|
const [sending, setSending] = useState(false)
|
|
102
109
|
const scrollRef = useRef<HTMLDivElement | undefined>(undefined)
|
|
103
110
|
const pendingRef = useRef(false)
|
|
111
|
+
/**
|
|
112
|
+
* True while the initial tail page is in flight. Live events arriving in
|
|
113
|
+
* that window go to {@link liveBufferRef} instead of the message list: the
|
|
114
|
+
* tail load replaces the list wholesale, so a directly folded event would
|
|
115
|
+
* flash once, be discarded by the snapshot, and then be skipped forever by
|
|
116
|
+
* the seq watermark.
|
|
117
|
+
*/
|
|
118
|
+
const tailLoadingRef = useRef(true)
|
|
119
|
+
/** Live session events buffered while the initial tail page loads. */
|
|
120
|
+
const liveBufferRef = useRef<WireEvent[]>([])
|
|
121
|
+
/** True once the live buffer hit its cap (oldest events were dropped). */
|
|
122
|
+
const liveBufferOverflowRef = useRef(false)
|
|
104
123
|
|
|
105
124
|
/** The session's permission select (absent = capability not composed). */
|
|
106
125
|
const [permissions, setPermissions] = useState<PermissionSelectValue | undefined>(undefined)
|
|
@@ -108,17 +127,49 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
108
127
|
const [currentModel, setCurrentModel] = useState<{ provider: string; model: string; reasoningEffort?: string } | undefined>(undefined)
|
|
109
128
|
/** Which bottom sheet is open. */
|
|
110
129
|
const [sheet, setSheet] = useState<'model' | 'permission' | null>(null)
|
|
130
|
+
/**
|
|
131
|
+
* Composer preference from the plugin's host settings (default true keeps
|
|
132
|
+
* the legacy Enter-to-send behavior until the preference loads).
|
|
133
|
+
*/
|
|
134
|
+
const [mobileEnterToSend, setMobileEnterToSend] = useState(true)
|
|
135
|
+
|
|
136
|
+
// Read-only mobile display preferences ride the plugin's local
|
|
137
|
+
// `/m/api` method; a failure keeps the default (Enter sends).
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
let cancelled = false
|
|
140
|
+
void fetchMobilePreferences().then(
|
|
141
|
+
(preferences) => {
|
|
142
|
+
if (!cancelled) setMobileEnterToSend(preferences.mobileEnterToSend !== false)
|
|
143
|
+
},
|
|
144
|
+
() => { /* keep the default */ },
|
|
145
|
+
)
|
|
146
|
+
return () => { cancelled = true }
|
|
147
|
+
}, [])
|
|
111
148
|
|
|
112
149
|
// Tail page on open (content loads only when the session is opened).
|
|
113
150
|
useEffect(() => {
|
|
114
151
|
let cancelled = false
|
|
152
|
+
const controller = new AbortController()
|
|
153
|
+
// A stuck history load must not keep the chat empty (or the live buffer
|
|
154
|
+
// growing) forever: abort it and surface the transport error.
|
|
155
|
+
const timeout = setTimeout(() => {
|
|
156
|
+
controller.abort(new DOMException('history load timed out', 'TimeoutError'))
|
|
157
|
+
}, 15_000)
|
|
158
|
+
tailLoadingRef.current = true
|
|
159
|
+
liveBufferRef.current = []
|
|
160
|
+
liveBufferOverflowRef.current = false
|
|
115
161
|
setLoading(true)
|
|
116
162
|
setError(undefined)
|
|
117
163
|
setMessages([])
|
|
118
|
-
void loadHistory(session.sessionId).then(
|
|
164
|
+
void loadHistory(session.sessionId, undefined, controller.signal).then(
|
|
119
165
|
(page) => {
|
|
120
166
|
if (cancelled) return
|
|
121
|
-
|
|
167
|
+
// Buffered live events re-fold on top of the snapshot; the watermark
|
|
168
|
+
// drops any the snapshot already includes, so nothing is lost or doubled.
|
|
169
|
+
const buffered = liveBufferRef.current
|
|
170
|
+
liveBufferRef.current = []
|
|
171
|
+
tailLoadingRef.current = false
|
|
172
|
+
setMessages(foldEvents(buffered, foldEvents(page.events.map(eventOf))))
|
|
122
173
|
setHasOlder(page.hasMore)
|
|
123
174
|
setLoading(false)
|
|
124
175
|
// The history-tail projection baseline seeds the permission picker.
|
|
@@ -126,9 +177,31 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
126
177
|
// plugin (augmentation), so the base SDK map is indexed loosely.
|
|
127
178
|
const projections = page.projections?.values as Record<string, unknown> | undefined
|
|
128
179
|
setPermissions(parsePermissionSelect(projections?.['permissions']))
|
|
180
|
+
// The buffer overflowed while waiting (oldest events were dropped), so
|
|
181
|
+
// re-pull the freshest history page to close the gap on top of what is
|
|
182
|
+
// already rendered. Best-effort: a failure here only logs, it must not
|
|
183
|
+
// replace the loaded state with an error.
|
|
184
|
+
if (liveBufferOverflowRef.current) {
|
|
185
|
+
void loadHistory(session.sessionId, undefined, controller.signal).then(
|
|
186
|
+
(fresh) => {
|
|
187
|
+
if (cancelled) return
|
|
188
|
+
setMessages(previous => foldEvents(fresh.events.map(eventOf), previous))
|
|
189
|
+
liveBufferOverflowRef.current = false
|
|
190
|
+
},
|
|
191
|
+
(reason: unknown) => {
|
|
192
|
+
if (cancelled) return
|
|
193
|
+
console.warn('history tail re-pull after live buffer overflow failed', reason)
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
}
|
|
129
197
|
},
|
|
130
198
|
(reason: unknown) => {
|
|
131
199
|
if (cancelled) return
|
|
200
|
+
// Load failed: flush the buffer so the live stream still renders.
|
|
201
|
+
const buffered = liveBufferRef.current
|
|
202
|
+
liveBufferRef.current = []
|
|
203
|
+
tailLoadingRef.current = false
|
|
204
|
+
if (buffered.length > 0) setMessages(foldEvents(buffered))
|
|
132
205
|
setError(errorText(reason))
|
|
133
206
|
setLoading(false)
|
|
134
207
|
},
|
|
@@ -141,7 +214,11 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
141
214
|
},
|
|
142
215
|
() => { /* chip falls back to a plain label */ },
|
|
143
216
|
)
|
|
144
|
-
return () => {
|
|
217
|
+
return () => {
|
|
218
|
+
cancelled = true
|
|
219
|
+
clearTimeout(timeout)
|
|
220
|
+
controller.abort()
|
|
221
|
+
}
|
|
145
222
|
}, [session.sessionId])
|
|
146
223
|
|
|
147
224
|
// Live frames: fold session events for this session in as they arrive.
|
|
@@ -150,7 +227,24 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
150
227
|
return mux.onFrame((frame: MuxFrame) => {
|
|
151
228
|
if (frame.type === 'session/event') {
|
|
152
229
|
if (frame.sessionId !== session.sessionId) return
|
|
153
|
-
|
|
230
|
+
const event = frame.event as WireEvent
|
|
231
|
+
if (tailLoadingRef.current) {
|
|
232
|
+
if (liveBufferRef.current.length >= MAX_TAIL_BUFFER_EVENTS) {
|
|
233
|
+
// Bound the tail-load window: drop the oldest buffered event and
|
|
234
|
+
// remember that a follow-up history re-pull is needed. Warn once
|
|
235
|
+
// per load, not for every subsequent overflow.
|
|
236
|
+
liveBufferRef.current.shift()
|
|
237
|
+
if (!liveBufferOverflowRef.current) {
|
|
238
|
+
console.warn(
|
|
239
|
+
`history tail is slow: live buffer reached ${MAX_TAIL_BUFFER_EVENTS} events; oldest buffered events will be re-fetched`,
|
|
240
|
+
)
|
|
241
|
+
liveBufferOverflowRef.current = true
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
liveBufferRef.current.push(event)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
setMessages(previous => foldEvents([event], previous))
|
|
154
248
|
return
|
|
155
249
|
}
|
|
156
250
|
// Live projection pushes keep the permission picker current.
|
|
@@ -277,11 +371,11 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
277
371
|
className="chat-input"
|
|
278
372
|
rows={1}
|
|
279
373
|
value={input}
|
|
280
|
-
placeholder=
|
|
281
|
-
enterKeyHint=
|
|
374
|
+
placeholder={mobileEnterToSend ? '输入消息,Enter 发送…' : '输入消息,Enter 换行,点按钮发送…'}
|
|
375
|
+
enterKeyHint={mobileEnterToSend ? 'send' : 'enter'}
|
|
282
376
|
onChange={(event) => { setInput(event.target.value) }}
|
|
283
377
|
onKeyDown={(event) => {
|
|
284
|
-
if (event.key === 'Enter' && !event.shiftKey) {
|
|
378
|
+
if (mobileEnterToSend && event.key === 'Enter' && !event.shiftKey) {
|
|
285
379
|
event.preventDefault()
|
|
286
380
|
void send()
|
|
287
381
|
}
|