@linxin666/dsh-remote-web-ui 0.1.17 → 0.1.18

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.
Files changed (36) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -2
  3. package/README.zh.md +2 -2
  4. package/lib/client.js +48 -58
  5. package/lib/client.js.map +1 -1
  6. package/lib/mobile.js +649 -45
  7. package/lib/mobile.js.map +1 -1
  8. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  9. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.js +11 -5
  11. package/lib/types/mobile/markdown.d.ts +26 -0
  12. package/lib/types/mobile/markdown.d.ts.map +1 -0
  13. package/lib/types/mobile/markdown.js +272 -0
  14. package/lib/types/mobile/markdown.test.d.ts +2 -0
  15. package/lib/types/mobile/markdown.test.d.ts.map +1 -0
  16. package/lib/types/mobile/markdown.test.js +67 -0
  17. package/lib/types/mobile/messages.d.ts +14 -0
  18. package/lib/types/mobile/messages.d.ts.map +1 -1
  19. package/lib/types/mobile/messages.js +54 -2
  20. package/lib/types/mobile/messages.test.js +99 -0
  21. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  22. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  23. package/lib/types/mobile/mobile-styles.js +167 -0
  24. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  25. package/lib/types/mobile/views/ChatView.js +81 -5
  26. package/lib/types/mobile/views/ChatView.test.js +105 -0
  27. package/package.json +1 -1
  28. package/src/client/PluginSettingsCard.tsx +50 -47
  29. package/src/client/settings-card.module.css +9 -0
  30. package/src/mobile/markdown.test.ts +77 -0
  31. package/src/mobile/markdown.ts +278 -0
  32. package/src/mobile/messages.test.ts +106 -0
  33. package/src/mobile/messages.ts +66 -2
  34. package/src/mobile/mobile-styles.ts +167 -0
  35. package/src/mobile/views/ChatView.test.tsx +121 -0
  36. package/src/mobile/views/ChatView.tsx +164 -6
@@ -219,4 +219,110 @@ describe('foldEvents', () => {
219
219
  foldEvents([makeEvent('assistant/message', assistantMessageData('a-1', 0, 0, 'world'), 1)], first)
220
220
  expect(JSON.stringify(first)).toBe(snapshot)
221
221
  })
222
+
223
+ it('request/context sets the context window used by a later assistant/message with usage', () => {
224
+ const events: WireEvent[] = [
225
+ makeEvent('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
226
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 1),
227
+ makeEvent('assistant/message', {
228
+ turn: 0,
229
+ step: 0,
230
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
231
+ usage: { inputTokens: 10, outputTokens: 5 },
232
+ }, 2),
233
+ ]
234
+ const result = foldEvents(events)
235
+ const assistant = result.find(message => message.kind === 'assistant')
236
+ expect(assistant).toMatchObject({
237
+ usage: { inputTokens: 10, outputTokens: 5 },
238
+ contextWindow: 100_000,
239
+ })
240
+ })
241
+
242
+ it('usage without a context window carries no contextWindow', () => {
243
+ const events: WireEvent[] = [
244
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
245
+ makeEvent('assistant/message', {
246
+ turn: 0,
247
+ step: 0,
248
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
249
+ usage: { inputTokens: 10, outputTokens: 5 },
250
+ }, 1),
251
+ ]
252
+ const result = foldEvents(events)
253
+ const assistant = result.find(message => message.kind === 'assistant')
254
+ expect(assistant).toMatchObject({ usage: { inputTokens: 10, outputTokens: 5 } })
255
+ expect(assistant?.contextWindow).toBeUndefined()
256
+ })
257
+
258
+ it('usage and contextWindow are both attached when request/context precedes the message', () => {
259
+ const events: WireEvent[] = [
260
+ makeEvent('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 200_000 }, 0),
261
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 1),
262
+ makeEvent('assistant/message', {
263
+ turn: 0,
264
+ step: 0,
265
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
266
+ usage: { inputTokens: 30, outputTokens: 2, cacheReadTokens: 8, cacheWriteTokens: 1 },
267
+ }, 2),
268
+ ]
269
+ const result = foldEvents(events)
270
+ const assistant = result.find(message => message.kind === 'assistant')
271
+ expect(assistant).toMatchObject({
272
+ usage: { inputTokens: 30, outputTokens: 2, cacheReadTokens: 8, cacheWriteTokens: 1 },
273
+ contextWindow: 200_000,
274
+ })
275
+ })
276
+
277
+ it('assistant/message without usage has no usage field', () => {
278
+ const events: WireEvent[] = [
279
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
280
+ makeEvent('assistant/message', { turn: 0, step: 0, message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] } }, 1),
281
+ ]
282
+ const result = foldEvents(events)
283
+ const assistant = result.find(message => message.kind === 'assistant')
284
+ expect(assistant?.usage).toBeUndefined()
285
+ expect(assistant?.contextWindow).toBeUndefined()
286
+ })
287
+
288
+ it('attaches sourceKind from source.kind for a user message', () => {
289
+ const events: WireEvent[] = [
290
+ makeEvent('user/message', {
291
+ id: 'u-plugin',
292
+ role: 'user',
293
+ content: [{ type: 'text', text: '系统注入' }],
294
+ source: { kind: 'plugin', name: 'react-extension' },
295
+ }, 0),
296
+ ]
297
+ const result = foldEvents(events)
298
+ expect(result[0]).toMatchObject({ id: 'u-plugin', kind: 'user', sourceKind: 'plugin' })
299
+ })
300
+
301
+ it('keeps sourceKind on a replayed/replaced user/message', () => {
302
+ const first = foldEvents([makeEvent('user/message', {
303
+ id: 'u-1',
304
+ role: 'user',
305
+ content: [{ type: 'text', text: '第一版' }],
306
+ source: { kind: 'plugin' },
307
+ }, 0)])
308
+ const second = foldEvents([makeEvent('user/message', {
309
+ id: 'u-1',
310
+ role: 'user',
311
+ content: [{ type: 'text', text: '第二版' }],
312
+ source: { kind: 'plugin' },
313
+ }, 1)], first)
314
+ expect(second[0]).toMatchObject({ id: 'u-1', text: '第二版', sourceKind: 'plugin' })
315
+ })
316
+
317
+ it('request/context and unknown events are still ignored by the fold', () => {
318
+ const events: WireEvent[] = [
319
+ makeEvent('some/future-event', { nope: true }, 0),
320
+ makeEvent('request/context', { provider: 'fx', model: 'fx-1' }, 1),
321
+ makeEvent('user/message', userMessageData('u-1', '真实消息'), 2),
322
+ ]
323
+ const result = foldEvents(events)
324
+ // request/context with no window and the unknown event render nothing.
325
+ expect(result).toHaveLength(1)
326
+ expect(result[0]).toMatchObject({ id: 'u-1', kind: 'user' })
327
+ })
222
328
  })
@@ -68,6 +68,20 @@ export interface RenderMessage {
68
68
  readonly toolSummary?: string
69
69
  /** Set when the owning turn ended in an error. */
70
70
  readonly failed?: boolean
71
+ /**
72
+ * Token usage reported by the final assistant event. cacheReadTokens and
73
+ * cacheWriteTokens are only attached when the wire carried finite values.
74
+ */
75
+ readonly usage?: {
76
+ inputTokens: number
77
+ outputTokens: number
78
+ cacheReadTokens?: number
79
+ cacheWriteTokens?: number
80
+ }
81
+ /** Context window for the model that produced this message (from request/context). */
82
+ readonly contextWindow?: number
83
+ /** Wire source.kind of a user message (e.g. plugin or user). */
84
+ readonly sourceKind?: string
71
85
  }
72
86
 
73
87
  /** One tool call attached to an assistant message (callId dedupes repeats). */
@@ -199,6 +213,8 @@ interface FoldState {
199
213
  messageTurn: Map<string, number>
200
214
  /** Deduped tool names per assistant message id. */
201
215
  toolNames: Map<string, Set<string>>
216
+ /** Context window for the current model (from request/context). */
217
+ contextWindow?: number
202
218
  }
203
219
 
204
220
  function createState(existing: readonly RenderMessage[] | undefined): FoldState {
@@ -294,6 +310,14 @@ function applyEvent(state: FoldState, event: WireEvent): void {
294
310
  case 'tool/call':
295
311
  applyToolCall(state, event)
296
312
  break
313
+ case 'request/context': {
314
+ // Wire shape: { provider, model, contextWindow? }. A present finite
315
+ // contextWindow seeds every later assistant message that reports usage.
316
+ const data = isRecord(event.data) ? event.data : {}
317
+ const window = pickNumber(data['contextWindow'])
318
+ if (window !== undefined) state.contextWindow = window
319
+ break
320
+ }
297
321
  // turn/start, session/end-seed, and every other/unknown type render nothing.
298
322
  default:
299
323
  break
@@ -304,13 +328,28 @@ function applyUserMessage(state: FoldState, event: WireEvent): void {
304
328
  const data = isRecord(event.data) ? event.data : {}
305
329
  const id = pickString(data['id']) ?? syntheticId('user', event.seq)
306
330
  const text = textFromContent(data['content'])
331
+ const source = isRecord(data['source']) ? data['source'] : {}
332
+ const sourceKind = pickString(source['kind'])
307
333
  const existing = state.byId.get(id)
308
334
  if (existing !== undefined) {
309
335
  // Idempotent replace (replayed events update in place, never duplicate).
310
- replaceMessage(state, existing, { ...existing, text, seq: event.seq, time: event.time })
336
+ replaceMessage(state, existing, {
337
+ ...existing,
338
+ ...(sourceKind !== undefined ? { sourceKind } : {}),
339
+ text,
340
+ seq: event.seq,
341
+ time: event.time,
342
+ })
311
343
  return
312
344
  }
313
- const message: RenderMessage = { id, kind: 'user', text, seq: event.seq, time: event.time }
345
+ const message: RenderMessage = {
346
+ id,
347
+ kind: 'user',
348
+ text,
349
+ ...(sourceKind !== undefined ? { sourceKind } : {}),
350
+ seq: event.seq,
351
+ time: event.time,
352
+ }
314
353
  state.messages.push(message)
315
354
  state.byId.set(id, message)
316
355
  }
@@ -324,6 +363,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
324
363
  const finalText = textFromContent(messageData['content'])
325
364
  const finalReasoning = reasoningFromContent(messageData['content'])
326
365
  const key = tsKey(turn, step)
366
+ const usage = usageFromData(data)
367
+ const contextWindow = state.contextWindow
327
368
 
328
369
  // Finalize the matching assistant message (by id, or by turn/step for the
329
370
  // streaming partial that chunks built before the final event arrived).
@@ -338,6 +379,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
338
379
  // The final content block list is authoritative; an adapter that omits
339
380
  // reasoning from the final message keeps the streamed reasoning text.
340
381
  ...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
382
+ ...(usage !== undefined ? { usage } : {}),
383
+ ...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
341
384
  seq: event.seq,
342
385
  time: event.time,
343
386
  pending: false,
@@ -353,6 +396,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
353
396
  kind: 'assistant',
354
397
  text: finalText,
355
398
  ...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
399
+ ...(usage !== undefined ? { usage } : {}),
400
+ ...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
356
401
  seq: event.seq,
357
402
  time: event.time,
358
403
  }
@@ -365,6 +410,25 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
365
410
  if (turn !== undefined) state.messageTurn.set(id, turn)
366
411
  }
367
412
 
413
+ /**
414
+ * Extract token usage from an assistant event payload. Only attaches when the
415
+ * wire carries finite `inputTokens` AND `outputTokens`; the cache fields are
416
+ * included only for finite numbers.
417
+ */
418
+ function usageFromData(data: Record<string, unknown>): { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | undefined {
419
+ const usageData = data['usage']
420
+ if (!isRecord(usageData)) return undefined
421
+ const inputTokens = pickNumber(usageData['inputTokens'])
422
+ const outputTokens = pickNumber(usageData['outputTokens'])
423
+ if (inputTokens === undefined || outputTokens === undefined) return undefined
424
+ const usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } = { inputTokens, outputTokens }
425
+ const cacheReadTokens = pickNumber(usageData['cacheReadTokens'])
426
+ const cacheWriteTokens = pickNumber(usageData['cacheWriteTokens'])
427
+ if (cacheReadTokens !== undefined) usage.cacheReadTokens = cacheReadTokens
428
+ if (cacheWriteTokens !== undefined) usage.cacheWriteTokens = cacheWriteTokens
429
+ return usage
430
+ }
431
+
368
432
  function applyChunk(state: FoldState, event: WireEvent): void {
369
433
  const target = chunkTarget(event.data)
370
434
  if (target === null) return
@@ -393,6 +393,85 @@ body {
393
393
  cursor: pointer;
394
394
  }
395
395
 
396
+ /* Assistant markdown content (GFM subset; renderer in markdown.ts). */
397
+ .chat-md {
398
+ white-space: normal;
399
+ }
400
+ .chat-md-collapsed {
401
+ max-height: 45vh;
402
+ overflow: hidden;
403
+ position: relative;
404
+ }
405
+ .chat-md-collapsed::after {
406
+ content: '';
407
+ position: absolute;
408
+ left: 0;
409
+ right: 0;
410
+ bottom: 0;
411
+ height: 28px;
412
+ background: linear-gradient(transparent, var(--m-bg-raised));
413
+ pointer-events: none;
414
+ }
415
+ .chat-md-body p { margin: 0 0 8px; }
416
+ .chat-md-body p:last-child { margin-bottom: 0; }
417
+ .chat-md-body h1, .chat-md-body h2, .chat-md-body h3,
418
+ .chat-md-body h4, .chat-md-body h5, .chat-md-body h6 {
419
+ margin: 12px 0 6px;
420
+ font-weight: 650;
421
+ line-height: 1.3;
422
+ }
423
+ .chat-md-body h1 { font-size: 1.35em; }
424
+ .chat-md-body h2 { font-size: 1.25em; }
425
+ .chat-md-body h3 { font-size: 1.15em; }
426
+ .chat-md-body h4, .chat-md-body h5, .chat-md-body h6 { font-size: 1.05em; }
427
+ .chat-md-body code {
428
+ font-family: var(--m-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
429
+ font-size: 0.9em;
430
+ background: var(--m-accent-soft);
431
+ padding: 1px 5px;
432
+ border-radius: 4px;
433
+ }
434
+ .chat-md-body pre {
435
+ margin: 8px 0;
436
+ padding: 10px;
437
+ background: #101726;
438
+ color: #dbe4f5;
439
+ border-radius: 8px;
440
+ overflow-x: auto;
441
+ font-size: 12.5px;
442
+ line-height: 1.5;
443
+ }
444
+ .chat-md-body pre code {
445
+ background: transparent;
446
+ padding: 0;
447
+ color: inherit;
448
+ }
449
+ .chat-md-body ul, .chat-md-body ol { margin: 4px 0 8px; padding-left: 22px; }
450
+ .chat-md-body li { margin: 2px 0; }
451
+ .chat-md-body blockquote {
452
+ margin: 8px 0;
453
+ padding: 4px 10px;
454
+ border-left: 3px solid var(--m-accent);
455
+ background: var(--m-accent-soft);
456
+ border-radius: 0 6px 6px 0;
457
+ color: var(--m-text-secondary);
458
+ }
459
+ .chat-md-body table {
460
+ margin: 8px 0;
461
+ border-collapse: collapse;
462
+ display: block;
463
+ overflow-x: auto;
464
+ font-size: 13px;
465
+ }
466
+ .chat-md-body th, .chat-md-body td {
467
+ border: 1px solid var(--m-border);
468
+ padding: 4px 8px;
469
+ }
470
+ .chat-md-body th { background: var(--m-accent-soft); }
471
+ .chat-md-body a { color: var(--m-accent); }
472
+ .chat-md-body hr { border: none; border-top: 1px solid var(--m-border); margin: 10px 0; }
473
+ .chat-md-body img { max-width: 100%; border-radius: 8px; }
474
+
396
475
  /* ── message disclosures (reasoning / tools) ─────────────────────────── */
397
476
 
398
477
  .chat-disclosure {
@@ -824,11 +903,97 @@ body {
824
903
  .chat-disclosure-head:focus-visible,
825
904
  .sheet-option:focus-visible,
826
905
  .sheet-confirm-danger:focus-visible,
906
+ .sheet-toggle-switch:focus-visible,
827
907
  .chat-input:focus-visible {
828
908
  outline: none;
829
909
  box-shadow: 0 0 0 2px var(--m-bg), 0 0 0 4px var(--m-accent);
830
910
  }
831
911
 
912
+ /* ── chat context usage chip (chat-tools row) ───────────────────────── */
913
+
914
+ .chat-context {
915
+ flex: none;
916
+ align-self: center;
917
+ padding: 2px 10px;
918
+ border: 1px solid var(--m-border);
919
+ border-radius: 999px;
920
+ background: color-mix(in srgb, var(--m-text-tertiary) 10%, transparent);
921
+ color: var(--m-text-tertiary);
922
+ font-size: 11px;
923
+ line-height: 1.6;
924
+ white-space: nowrap;
925
+ }
926
+
927
+ .chat-context-warn {
928
+ border-color: var(--m-danger);
929
+ background: color-mix(in srgb, var(--m-danger) 14%, transparent);
930
+ color: var(--m-danger);
931
+ }
932
+
933
+ /* ── display sheet toggle rows ──────────────────────────────────────── */
934
+
935
+ .sheet-toggle-row {
936
+ display: flex;
937
+ align-items: center;
938
+ gap: 12px;
939
+ padding: 12px 8px;
940
+ border-bottom: 1px solid var(--m-border);
941
+ }
942
+
943
+ .sheet-toggle-row:last-child {
944
+ border-bottom: none;
945
+ }
946
+
947
+ .sheet-toggle-copy {
948
+ flex: 1;
949
+ min-width: 0;
950
+ display: flex;
951
+ flex-direction: column;
952
+ gap: 2px;
953
+ }
954
+
955
+ .sheet-toggle-title {
956
+ font-size: 14px;
957
+ }
958
+
959
+ .sheet-toggle-desc {
960
+ color: var(--m-text-tertiary);
961
+ font-size: 12px;
962
+ line-height: 1.4;
963
+ }
964
+
965
+ .sheet-toggle-switch {
966
+ position: relative;
967
+ flex: none;
968
+ width: 44px;
969
+ height: 26px;
970
+ border: none;
971
+ border-radius: 999px;
972
+ background: var(--m-border);
973
+ cursor: pointer;
974
+ transition: background-color 0.15s ease;
975
+ }
976
+
977
+ .sheet-toggle-switch-on {
978
+ background: var(--m-accent);
979
+ }
980
+
981
+ .sheet-toggle-switch-knob {
982
+ position: absolute;
983
+ top: 3px;
984
+ left: 3px;
985
+ width: 20px;
986
+ height: 20px;
987
+ border-radius: 50%;
988
+ background: #fff;
989
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.2);
990
+ transition: transform 0.15s ease;
991
+ }
992
+
993
+ .sheet-toggle-switch-on .sheet-toggle-switch-knob {
994
+ transform: translateX(18px);
995
+ }
996
+
832
997
  /* Accessibility: collapse every interaction/enter animation for
833
998
  reduced-motion users; surface layout itself stays static. */
834
999
  @media (prefers-reduced-motion: reduce) {
@@ -848,6 +1013,8 @@ body {
848
1013
  .sheet,
849
1014
  .sheet-option,
850
1015
  .sheet-confirm-danger,
1016
+ .sheet-toggle-switch,
1017
+ .sheet-toggle-switch-knob,
851
1018
  .chat-input {
852
1019
  animation: none;
853
1020
  transition: none;
@@ -518,3 +518,124 @@ describe('ChatView scrolling', () => {
518
518
  await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore) })
519
519
  })
520
520
  })
521
+
522
+ describe('ChatView display toggles and context usage', () => {
523
+ /** jsdom in this setup ships a bare localStorage object; install a real fake. */
524
+ const makeStorage = (): Storage => {
525
+ const map = new Map<string, string>()
526
+ return {
527
+ getItem: (key: string) => map.get(key) ?? null,
528
+ setItem: (key: string, value: string) => { map.set(key, value) },
529
+ removeItem: (key: string) => { map.delete(key) },
530
+ clear: () => { map.clear() },
531
+ key: (index: number) => [...map.keys()][index] ?? null,
532
+ get length() { return map.size },
533
+ } as Storage
534
+ }
535
+
536
+ /** A user/message whose source.kind is 'plugin' (injected system message). */
537
+ const systemEvents = (): Array<{ event: WireEvent }> => [
538
+ makeEntry('user/message', {
539
+ id: 'u-plugin',
540
+ role: 'user',
541
+ content: [{ type: 'text', text: '系统注入消息' }],
542
+ source: { kind: 'plugin', name: 'react-extension' },
543
+ }, 0),
544
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '普通消息' }] }, 1),
545
+ ]
546
+
547
+ const toolEvents = (): Array<{ event: WireEvent }> => [
548
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '改文件' }] }, 0),
549
+ makeEntry('assistant/message', {
550
+ turn: 0,
551
+ step: 0,
552
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: '完成' }] },
553
+ }, 1),
554
+ makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, 2),
555
+ ]
556
+
557
+ beforeEach(() => {
558
+ vi.stubGlobal('localStorage', makeStorage())
559
+ })
560
+
561
+ afterEach(() => {
562
+ vi.unstubAllGlobals()
563
+ })
564
+
565
+ it('hides injected user messages by default and reveals them via the display sheet', async () => {
566
+ loadHistoryMock.mockResolvedValue(historyPage(systemEvents()))
567
+ render(<ChatView session={session} onBack={() => {}} />)
568
+
569
+ // The plugin-injected message is hidden by default; the real user message shows.
570
+ expect(await screen.findByText('普通消息')).toBeTruthy()
571
+ expect(screen.queryByText('系统注入消息')).toBeNull()
572
+
573
+ // Open the display sheet and flip the system-message switch on.
574
+ fireEvent.click(screen.getByRole('button', { name: /显示/ }))
575
+ const systemSwitch = await screen.findByRole('switch', { name: '系统提示词' })
576
+ expect(systemSwitch.getAttribute('aria-checked')).toBe('false')
577
+ fireEvent.click(systemSwitch)
578
+ expect(await screen.findByText('系统注入消息')).toBeTruthy()
579
+
580
+ // Flip it back off: the injected row disappears again.
581
+ const systemSwitchAfter = screen.getByRole('switch', { name: '系统提示词' })
582
+ expect(systemSwitchAfter.getAttribute('aria-checked')).toBe('true')
583
+ fireEvent.click(systemSwitchAfter)
584
+ expect(screen.queryByText('系统注入消息')).toBeNull()
585
+ })
586
+
587
+ it('hides the tool disclosure when the tool-call toggle is off', async () => {
588
+ loadHistoryMock.mockResolvedValue(historyPage(toolEvents()))
589
+ render(<ChatView session={session} onBack={() => {}} />)
590
+
591
+ // Tool disclosure visible by default.
592
+ expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy()
593
+
594
+ fireEvent.click(screen.getByRole('button', { name: /显示/ }))
595
+ const toolSwitch = await screen.findByRole('switch', { name: '工具调用' })
596
+ expect(toolSwitch.getAttribute('aria-checked')).toBe('true')
597
+ fireEvent.click(toolSwitch)
598
+
599
+ // The disclosure is gone while reasoning/text remain.
600
+ expect(screen.queryByRole('button', { name: /工具/ })).toBeNull()
601
+ expect(screen.getByText('完成')).toBeTruthy()
602
+ })
603
+
604
+ it('renders the context usage chip from request/context plus assistant usage', async () => {
605
+ loadHistoryMock.mockResolvedValue(historyPage([
606
+ makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
607
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
608
+ makeEntry('assistant/message', {
609
+ turn: 0,
610
+ step: 0,
611
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
612
+ usage: { inputTokens: 30_000, outputTokens: 1_000 },
613
+ }, 2),
614
+ ]))
615
+ render(<ChatView session={session} onBack={() => {}} />)
616
+ expect(await screen.findByText('上下文 30%')).toBeTruthy()
617
+ })
618
+
619
+ it('adds the warn class when context usage is at or above 80%', async () => {
620
+ loadHistoryMock.mockResolvedValue(historyPage([
621
+ makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
622
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
623
+ makeEntry('assistant/message', {
624
+ turn: 0,
625
+ step: 0,
626
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
627
+ usage: { inputTokens: 80_000, outputTokens: 0 },
628
+ }, 2),
629
+ ]))
630
+ render(<ChatView session={session} onBack={() => {}} />)
631
+ const chip = await screen.findByText('上下文 80%')
632
+ expect(chip.className).toContain('chat-context-warn')
633
+ })
634
+
635
+ it('renders no context chip when there is no usage/context data', async () => {
636
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
637
+ render(<ChatView session={session} onBack={() => {}} />)
638
+ await screen.findByText('已完成修改')
639
+ expect(screen.queryByText(/上下文/)).toBeNull()
640
+ })
641
+ })