@linxin666/dsh-pet 0.1.19 → 0.2.0

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 (95) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +19 -12
  3. package/README.zh.md +19 -12
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +202 -38
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +897 -42
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
  10. package/lib/types/affinity.d.ts +22 -18
  11. package/lib/types/affinity.d.ts.map +1 -1
  12. package/lib/types/affinity.js +32 -6
  13. package/lib/types/chatter.d.ts +111 -0
  14. package/lib/types/chatter.d.ts.map +1 -0
  15. package/lib/types/chatter.js +666 -0
  16. package/lib/types/client/PetDockEntry.d.ts +2 -0
  17. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  18. package/lib/types/client/PetDockEntry.js +2 -2
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +2 -2
  21. package/lib/types/client/PetSprite.d.ts +2 -0
  22. package/lib/types/client/PetSprite.d.ts.map +1 -1
  23. package/lib/types/client/PetSprite.js +100 -25
  24. package/lib/types/client/PluginSettingsCard.d.ts +7 -1
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  26. package/lib/types/client/PluginSettingsCard.js +5 -3
  27. package/lib/types/client/index.d.ts +1 -1
  28. package/lib/types/client/index.d.ts.map +1 -1
  29. package/lib/types/client/index.js +31 -6
  30. package/lib/types/client/locales.d.ts +82 -80
  31. package/lib/types/client/locales.d.ts.map +1 -1
  32. package/lib/types/client/locales.js +2 -0
  33. package/lib/types/client/sequences.d.ts +10 -0
  34. package/lib/types/client/sequences.d.ts.map +1 -0
  35. package/lib/types/client/sequences.js +20 -0
  36. package/lib/types/client/settings-form.d.ts.map +1 -1
  37. package/lib/types/client/spritesheet.d.ts +1 -1
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +3 -12
  40. package/lib/types/event-projection.d.ts +14 -1
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +38 -17
  43. package/lib/types/index.d.ts +2 -1
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/index.js +15 -13
  46. package/lib/types/invariant.js +2 -2
  47. package/lib/types/ledger.d.ts.map +1 -1
  48. package/lib/types/ledger.js +18 -10
  49. package/lib/types/mount-once.d.ts +25 -0
  50. package/lib/types/mount-once.d.ts.map +1 -0
  51. package/lib/types/mount-once.js +42 -0
  52. package/lib/types/persist.d.ts.map +1 -1
  53. package/lib/types/persist.js +6 -4
  54. package/lib/types/registry.d.ts +7 -1
  55. package/lib/types/registry.d.ts.map +1 -1
  56. package/lib/types/registry.js +37 -1
  57. package/lib/types/remarks.d.ts +2 -0
  58. package/lib/types/remarks.d.ts.map +1 -1
  59. package/lib/types/remarks.js +5 -0
  60. package/lib/types/routes.js +2 -2
  61. package/lib/types/service.d.ts +16 -6
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +26 -8
  64. package/lib/types/state.d.ts +7 -4
  65. package/lib/types/state.d.ts.map +1 -1
  66. package/lib/types/state.js +20 -20
  67. package/package.json +9 -9
  68. package/src/affinity.test.ts +4 -2
  69. package/src/affinity.ts +37 -6
  70. package/src/chatter.test.ts +154 -0
  71. package/src/chatter.ts +697 -0
  72. package/src/client/PetDockEntry.tsx +3 -0
  73. package/src/client/PetSprite.test.tsx +246 -4
  74. package/src/client/PetSprite.tsx +132 -36
  75. package/src/client/PluginSettingsCard.tsx +10 -0
  76. package/src/client/index.ts +26 -3
  77. package/src/client/locales.ts +2 -0
  78. package/src/client/pet-css.test.ts +42 -0
  79. package/src/client/pet.module.css +173 -22
  80. package/src/client/sequences.test.ts +33 -0
  81. package/src/client/sequences.ts +33 -0
  82. package/src/client/spritesheet.ts +3 -13
  83. package/src/event-projection.ts +49 -16
  84. package/src/index.ts +4 -1
  85. package/src/ledger.test.ts +42 -1
  86. package/src/ledger.ts +15 -7
  87. package/src/mount-once.ts +48 -0
  88. package/src/persist.test.ts +18 -1
  89. package/src/persist.ts +3 -1
  90. package/src/registry.test.ts +50 -3
  91. package/src/registry.ts +47 -3
  92. package/src/remarks.ts +6 -0
  93. package/src/service.ts +42 -9
  94. package/src/state.test.ts +13 -0
  95. package/src/state.ts +24 -18
@@ -37,6 +37,8 @@ export interface PetInjected {
37
37
  dragEnd: (right: number, bottom: number) => void
38
38
  /** Rename the selected pet (persisted by the host). */
39
39
  rename: (name: string) => void
40
+ /** Navigate the GUI to the session a bubble reports on. */
41
+ openSession: (sessionId: string) => void
40
42
  /** Clear the reaction bubble. */
41
43
  feedbackDone: () => void
42
44
  }
@@ -83,6 +85,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
83
85
  onHide={props.hide}
84
86
  onDragEnd={props.dragEnd}
85
87
  onRename={props.rename}
88
+ onOpenSession={props.openSession}
86
89
  onFeedbackDone={props.feedbackDone}
87
90
  t={props.t}
88
91
  />
@@ -5,7 +5,7 @@
5
5
  * selection) as composition input, never as submit/cancel (issue #89).
6
6
  */
7
7
  import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
8
- import { cleanup, fireEvent, render, screen } from '@testing-library/react'
8
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
9
9
  import { PetSprite, type PetSpriteProps } from './PetSprite.tsx'
10
10
  import { t } from './locales.ts'
11
11
  import type { PetStateView } from '../service.ts'
@@ -27,6 +27,7 @@ function petDefinition(): PetDefinition {
27
27
  cell: { width: 192, height: 208 },
28
28
  columns: 8,
29
29
  rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
30
+ atlasRows: 9,
30
31
  tracks: {
31
32
  idle: track([0, 1, 2, 3, 4, 5], [400, 400, 400, 400, 400, 400]),
32
33
  'running-right': track([0, 1, 2, 3, 4, 5, 6, 7], [225, 225, 225, 225, 225, 225, 225, 225]),
@@ -86,11 +87,16 @@ beforeAll(() => {
86
87
 
87
88
  afterEach(() => {
88
89
  cleanup()
90
+ vi.restoreAllMocks()
89
91
  })
90
92
 
91
- /** Render the pet with mocked callbacks; returns the rename spy. */
92
- function renderPet(overrides: Partial<PetSpriteProps> = {}): { onRename: ReturnType<typeof vi.fn> } {
93
+ /** Render the pet with mocked callbacks; returns the rename and open spys. */
94
+ function renderPet(overrides: Partial<PetSpriteProps> = {}): {
95
+ onRename: ReturnType<typeof vi.fn>
96
+ onOpenSession: ReturnType<typeof vi.fn>
97
+ } {
93
98
  const onRename = vi.fn()
99
+ const onOpenSession = vi.fn()
94
100
  const props: PetSpriteProps = {
95
101
  snapshot,
96
102
  definition: petDefinition(),
@@ -101,12 +107,13 @@ function renderPet(overrides: Partial<PetSpriteProps> = {}): { onRename: ReturnT
101
107
  onHide: vi.fn(),
102
108
  onDragEnd: vi.fn(),
103
109
  onRename,
110
+ onOpenSession,
104
111
  onFeedbackDone: vi.fn(),
105
112
  t,
106
113
  ...overrides,
107
114
  }
108
115
  render(<PetSprite {...props} />)
109
- return { onRename }
116
+ return { onRename, onOpenSession }
110
117
  }
111
118
 
112
119
  /** Hover the sprite to open the panel, then click the rename button. */
@@ -171,6 +178,131 @@ describe('PetSprite rename input', () => {
171
178
  expect(onRename).not.toHaveBeenCalled()
172
179
  expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
173
180
  })
181
+
182
+ it('ignores Enter between compositionStart and compositionEnd even when isComposing is false (#303)', () => {
183
+ // WeChat IME (Windows) marks composition keydowns with isComposing ===
184
+ // false; only the explicit composition events can be trusted.
185
+ const { onRename } = renderPet()
186
+ const input = openRename()
187
+ fireEvent.change(input, { target: { value: '泡泡酱' } })
188
+ fireEvent.compositionStart(input)
189
+ fireEvent.keyDown(input, { key: 'Enter' })
190
+ expect(onRename).not.toHaveBeenCalled()
191
+ expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
192
+ fireEvent.compositionEnd(input)
193
+ fireEvent.keyDown(input, { key: 'Enter' })
194
+ expect(onRename).toHaveBeenCalledWith('泡泡酱')
195
+ })
196
+
197
+ it("ignores 'Process' keydowns emitted by IMEs mid-composition (#303)", () => {
198
+ const { onRename } = renderPet()
199
+ const input = openRename()
200
+ fireEvent.change(input, { target: { value: '泡泡酱' } })
201
+ fireEvent.keyDown(input, { key: 'Process' })
202
+ expect(onRename).not.toHaveBeenCalled()
203
+ expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
204
+ })
205
+
206
+ it('keeps the rename panel open when the pointer leaves mid-rename (#303)', () => {
207
+ // An IME candidate window is an OS-level window: moving the pointer onto
208
+ // it fires pointerleave on the float. The hide timer must not unmount
209
+ // the input mid-composition (that crashes some IMEs / the renderer).
210
+ vi.useFakeTimers()
211
+ try {
212
+ renderPet()
213
+ const input = openRename()
214
+ const float = input.parentElement?.parentElement?.parentElement
215
+ expect(float).not.toBeNull()
216
+ fireEvent.pointerOut(float!, { relatedTarget: null })
217
+ act(() => { vi.advanceTimersByTime(1000) })
218
+ expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
219
+ // After the rename ends, hover behavior works again.
220
+ fireEvent.keyDown(input, { key: 'Escape' })
221
+ fireEvent.pointerOut(float!, { relatedTarget: null })
222
+ act(() => { vi.advanceTimersByTime(1000) })
223
+ expect(screen.queryByText('改名')).toBeNull()
224
+ } finally {
225
+ vi.useRealTimers()
226
+ }
227
+ })
228
+ })
229
+
230
+ describe('PetSprite hover panel placement', () => {
231
+ it('places the panel above when an old saved position leaves no room below', () => {
232
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
233
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
234
+ if (this.style.bottom !== '') {
235
+ return { top: 720, right: 1256, bottom: 880, left: 1108, width: 148, height: 160, x: 1108, y: 720, toJSON: () => ({}) }
236
+ }
237
+ return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
238
+ })
239
+
240
+ renderPet({ display: { ...snapshot.display, bottom: 20 } })
241
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
242
+
243
+ const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
244
+ expect(panel.getAttribute('data-placement')).toBe('above')
245
+ // No bubble above the sprite: the panel keeps its default 8px gap.
246
+ expect(panel.style.marginBottom).toBe('')
247
+ })
248
+
249
+ it('lifts the above-panel clear of the status bubble instead of overlapping it', () => {
250
+ // Regression: the fallback above-placement anchored the panel at the
251
+ // sprite's top edge — the exact region the status bubble occupies — so
252
+ // the panel covered the bubble. The panel must now ride above it.
253
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
254
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
255
+ if (this.className.includes('bubbleStack')) {
256
+ // The bubble area (stack wrapping the status bubble): 40px tall.
257
+ return { top: 650, right: 1200, bottom: 690, left: 1120, width: 80, height: 40, x: 1120, y: 650, toJSON: () => ({}) }
258
+ }
259
+ // Sprite (bottom 974 of a 900px viewport) and panel (86px tall).
260
+ return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
261
+ })
262
+
263
+ renderPet({
264
+ snapshot: { ...snapshot, bubble: '正在思考' },
265
+ display: { ...snapshot.display, bottom: 20 },
266
+ })
267
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
268
+
269
+ const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
270
+ expect(panel.getAttribute('data-placement')).toBe('above')
271
+ // 40px bubble height + 14px (8px base gap + 6px clearance).
272
+ expect(panel.style.marginBottom).toBe('54px')
273
+ // The bubble stays rendered while the lifted panel is open.
274
+ expect(screen.queryByText('正在思考')).not.toBeNull()
275
+ })
276
+
277
+ it('lifts the above-panel clear of the session bubble stack', () => {
278
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
279
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
280
+ // The bubble stack contains buttons, not role=status; spot it by the
281
+ // session bubble text content being present among its descendants.
282
+ if (this.textContent === '正在思考正在使用 grep') {
283
+ // Two stacked bubbles: 88px tall.
284
+ return { top: 600, right: 1200, bottom: 688, left: 1100, width: 100, height: 88, x: 1100, y: 600, toJSON: () => ({}) }
285
+ }
286
+ return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
287
+ })
288
+
289
+ renderPet({
290
+ snapshot: {
291
+ ...snapshot,
292
+ sessions: [
293
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
294
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
295
+ ],
296
+ },
297
+ display: { ...snapshot.display, bottom: 20 },
298
+ })
299
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
300
+
301
+ const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
302
+ expect(panel.getAttribute('data-placement')).toBe('above')
303
+ // 88px stack height + 14px clearance.
304
+ expect(panel.style.marginBottom).toBe('102px')
305
+ })
174
306
  })
175
307
 
176
308
  describe('PetSprite status bubble', () => {
@@ -212,6 +344,36 @@ describe('PetSprite status bubble', () => {
212
344
  expect(screen.queryByText('正在使用 grep')).not.toBeNull()
213
345
  })
214
346
 
347
+ it('renders the inner whisper beneath the status bubble', () => {
348
+ renderPet({ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' } })
349
+ // The whisper shows alongside (not instead of) the status copy.
350
+ expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).not.toBeNull()
351
+ expect(screen.queryByText('正在思考')).not.toBeNull()
352
+ })
353
+
354
+ it('renders the whisper alongside the session bubble stack', () => {
355
+ renderPet({
356
+ snapshot: {
357
+ ...workingSnapshot,
358
+ whisper: '我在这儿陪着你呢,别急别急',
359
+ sessions: [
360
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
361
+ ],
362
+ },
363
+ })
364
+ expect(screen.queryByText('我在这儿陪着你呢,别急别急')).not.toBeNull()
365
+ expect(screen.queryByText('正在思考')).not.toBeNull()
366
+ })
367
+
368
+ it('lets transient interaction feedback take over the whisper too', () => {
369
+ renderPet({
370
+ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' },
371
+ feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
372
+ })
373
+ expect(screen.queryByText('摸摸成功')).not.toBeNull()
374
+ expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).toBeNull()
375
+ })
376
+
215
377
  it('lets feedback replace the whole session bubble stack', () => {
216
378
  renderPet({
217
379
  snapshot: {
@@ -227,6 +389,54 @@ describe('PetSprite status bubble', () => {
227
389
  expect(screen.queryByText('正在思考')).toBeNull()
228
390
  expect(screen.queryByText('正在使用 grep')).toBeNull()
229
391
  })
392
+
393
+ it('clicking a session bubble navigates to that session', () => {
394
+ const { onOpenSession } = renderPet({
395
+ snapshot: {
396
+ ...workingSnapshot,
397
+ bubble: '正在思考',
398
+ sessions: [
399
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
400
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
401
+ ],
402
+ },
403
+ })
404
+ fireEvent.click(screen.getByText('正在使用 grep'))
405
+ expect(onOpenSession).toHaveBeenCalledTimes(1)
406
+ expect(onOpenSession).toHaveBeenCalledWith('s-b')
407
+ fireEvent.click(screen.getByText('正在思考'))
408
+ expect(onOpenSession).toHaveBeenCalledTimes(2)
409
+ expect(onOpenSession).toHaveBeenCalledWith('s-a')
410
+ // Petting stays on the sprite only: bubble clicks must not pet.
411
+ })
412
+
413
+ it('clicking the legacy single bubble does not navigate (no session identity)', () => {
414
+ const { onOpenSession } = renderPet({ snapshot: workingSnapshot })
415
+ fireEvent.click(screen.getByText('正在思考'))
416
+ expect(onOpenSession).not.toHaveBeenCalled()
417
+ })
418
+
419
+ it('keeps session bubbles visible and clickable while the hover panel is open', () => {
420
+ // Regression: the panel used to occupy the same region as the bubble
421
+ // stack and hide it on hover, so reaching a bubble was impossible. The
422
+ // panel now opens beside the sprite and the stack stays interactive.
423
+ const { onOpenSession } = renderPet({
424
+ snapshot: {
425
+ ...workingSnapshot,
426
+ sessions: [
427
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
428
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
429
+ ],
430
+ },
431
+ })
432
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
433
+ // The hover panel is open...
434
+ expect(screen.queryByText('改名')).not.toBeNull()
435
+ // ...and the bubbles are still there, still clickable.
436
+ expect(screen.getByText('正在使用 grep')).not.toBeNull()
437
+ fireEvent.click(screen.getByText('正在使用 grep'))
438
+ expect(onOpenSession).toHaveBeenCalledWith('s-b')
439
+ })
230
440
  })
231
441
 
232
442
  describe('PetSprite definition-driven render', () => {
@@ -240,4 +450,36 @@ describe('PetSprite definition-driven render', () => {
240
450
  fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
241
451
  expect(screen.queryByText('泡泡')).not.toBeNull()
242
452
  })
453
+
454
+ it('advances a configured scene sequence after the current track duration', () => {
455
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
456
+ matches: false,
457
+ media: '(prefers-reduced-motion: reduce)',
458
+ onchange: null,
459
+ addEventListener: () => {},
460
+ removeEventListener: () => {},
461
+ addListener: () => {},
462
+ removeListener: () => {},
463
+ dispatchEvent: () => false,
464
+ })
465
+ vi.spyOn(performance, 'now').mockReturnValue(0)
466
+ let nextFrame: FrameRequestCallback | undefined
467
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
468
+ nextFrame = callback
469
+ return 1
470
+ })
471
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
472
+ const definition = petDefinition()
473
+ definition.sequences = {
474
+ thinking: ['running', 'waiting', 'running', 'waiting', 'running'],
475
+ }
476
+ renderPet({
477
+ definition,
478
+ snapshot: { ...snapshot, animation: 'running', phase: 'thinking' },
479
+ })
480
+ const sprite = screen.getByRole('button', { name: '鲸鱼娘' })
481
+ expect(sprite.style.backgroundPosition).toBe('0px -1120px')
482
+ act(() => { nextFrame?.(1_500) })
483
+ expect(sprite.style.backgroundPosition).toBe('0px -960px')
484
+ })
243
485
  })
@@ -9,8 +9,8 @@
9
9
  * @module @linxin666/dsh-pet/client/PetSprite
10
10
  */
11
11
 
12
- import { useEffect, useRef, useState } from 'react'
13
- import type { PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
12
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
13
+ import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
14
14
  import { createPortal } from 'react-dom'
15
15
  import clsx from 'clsx'
16
16
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -19,7 +19,8 @@ import type { PetStateView } from '../service.ts'
19
19
  import type { PetDefinition } from '../registry.ts'
20
20
  import type { PetFeedback } from './pet-store.ts'
21
21
  import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
22
- import type { PetAnimation } from '../state.ts'
22
+ import { sequenceFrameAt } from './sequences.ts'
23
+ import { animationForPhase, type PetAnimation } from '../state.ts'
23
24
  import { NS } from './locales.ts'
24
25
  import styles from './pet.module.css'
25
26
 
@@ -43,6 +44,8 @@ export interface PetSpriteProps {
43
44
  onDragEnd: (right: number, bottom: number) => void
44
45
  /** Rename the selected pet (persisted by the host). */
45
46
  onRename: (name: string) => void
47
+ /** Navigate to the session one status bubble reports on. */
48
+ onOpenSession: (sessionId: string) => void
46
49
  /** Clear the reaction bubble (after its CSS animation). */
47
50
  onFeedbackDone: () => void
48
51
  /** Locale translate seat (namespace-bound). */
@@ -64,10 +67,22 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
64
67
  const { snapshot, definition, display, feedback } = props
65
68
  const spriteRef = useRef<HTMLDivElement | null>(null)
66
69
  const floatRef = useRef<HTMLDivElement | null>(null)
70
+ const panelRef = useRef<HTMLDivElement | null>(null)
71
+ // Whichever bubble surface is currently rendered (feedback, the session
72
+ // stack, or the legacy status bubble) — only one exists at a time.
73
+ const bubbleRef = useRef<HTMLDivElement | null>(null)
67
74
  const [imageReady, setImageReady] = useState(false)
68
75
  const [hovered, setHovered] = useState(false)
69
76
  const [renaming, setRenaming] = useState(false)
77
+ const [panelAbove, setPanelAbove] = useState(false)
78
+ // Extra margin-bottom for the above-panel so it stacks clear of the
79
+ // bubbles instead of overlapping them (both anchor at the sprite's top).
80
+ const [panelLift, setPanelLift] = useState(0)
70
81
  const [nameDraft, setNameDraft] = useState('')
82
+ // Explicit IME composition tracking: some input methods (WeChat IME on
83
+ // Windows) report keydowns with isComposing === false mid-composition, so
84
+ // the native flag alone is not a safe submit/cancel guard (#303).
85
+ const composingRef = useRef(false)
71
86
  const [dragPos, setDragPos] = useState<{ right: number; bottom: number } | null>(null)
72
87
  const dragRef = useRef<{ startX: number; startY: number; right: number; bottom: number } | null>(null)
73
88
  const hideTimerRef = useRef<number | null>(null)
@@ -81,6 +96,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
81
96
  const columns = definition.columns
82
97
  const rows = definition.rows
83
98
  const tracks = definition.tracks
99
+ const sequences = definition.sequences
84
100
 
85
101
  // Load the atlas once; the definition carries the authoritative per-row
86
102
  // frame counts and per-track durations, so nothing else is fetched.
@@ -104,14 +120,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
104
120
  // its track's first frame instead of animating (presentation-only; the
105
121
  // animation state machine is untouched).
106
122
  const spriteScale = display.size / cell.height
123
+ const phase = snapshot?.phase ?? 'idle'
107
124
  const animation = snapshot?.animation ?? 'idle'
108
125
  const scaleRef = useRef(spriteScale)
109
126
  scaleRef.current = spriteScale
110
127
  useEffect(() => {
111
128
  const reduceMotion = typeof window !== 'undefined'
112
129
  && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
113
- const row = rowOfTrack(animation)
114
- const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
130
+ const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
131
+ const leadAnimation = sequence?.[0] ?? animation
132
+ const row = rowOfTrack(leadAnimation)
133
+ const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length)
115
134
  // Paint one static sprite frame up front either way, so the pet is never
116
135
  // blank while the loop heat-up runs.
117
136
  const leadCol = track.frames[0]!
@@ -122,13 +141,29 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
122
141
  if (reduceMotion) return
123
142
  let raf = 0
124
143
  let last = performance.now()
144
+ let sequenceElapsed = 0
125
145
  const tick = (ts: number): void => {
126
146
  const delta = ts - last
127
147
  last = ts
128
- // Trim the track to the row's real frame count (transparent cells
129
- // would render as a vanishing pet).
130
- const row = rowOfTrack(animation)
131
- const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
148
+ if (sequence !== undefined) {
149
+ sequenceElapsed += delta
150
+ const current = sequenceFrameAt(sequence, tracks, sequenceElapsed)
151
+ const currentRow = rowOfTrack(current.animation)
152
+ const currentTrack = trimTrack(
153
+ tracks[current.animation],
154
+ rows[currentRow] ?? tracks[current.animation].frames.length,
155
+ )
156
+ const col = currentTrack.frames[current.frameIndex]!
157
+ const pos = framePosition(cell, columns, currentRow, col, scaleRef.current)
158
+ if (spriteRef.current !== null) {
159
+ spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
160
+ }
161
+ raf = requestAnimationFrame(tick)
162
+ return
163
+ }
164
+ // row/track come from the effect scope: they were computed once above
165
+ // and this effect re-runs when animation/tracks/rows change, so the
166
+ // per-frame recompute (trimTrack slices fresh arrays) is pure waste.
132
167
  const st = frameRef.current
133
168
  if (st.track !== animation) {
134
169
  st.track = animation
@@ -158,7 +193,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
158
193
  }
159
194
  raf = requestAnimationFrame(tick)
160
195
  return () => cancelAnimationFrame(raf)
161
- }, [animation, cell, columns, rows, tracks])
196
+ }, [animation, phase, cell, columns, rows, tracks, sequences])
162
197
 
163
198
  // Auto-clear the feedback bubble after its CSS animation. The callback
164
199
  // rides a ref so re-renders never reset the timer: the 2s poll rebuilds
@@ -209,15 +244,46 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
209
244
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
210
245
  const spriteWidth = Math.round(cell.width * spriteScale)
211
246
  const spriteHeight = Math.round(cell.height * spriteScale)
247
+
212
248
  // Concurrent sessions each render their own bubble (stacked above the
213
249
  // sprite); the legacy single 'bubble' is the fallback when the host serves
214
- // no per-session list.
250
+ // no per-session list. The hover panel normally sits below the sprite, so
251
+ // the bubbles stay visible and clickable while hovering — no region swap.
215
252
  const sessionBubbles = snapshot?.sessions ?? []
216
- const statusBubble = feedback === null && !hovered && sessionBubbles.length === 0
253
+ const statusBubble = feedback === null && sessionBubbles.length === 0
217
254
  ? snapshot?.bubble
218
255
  : undefined
256
+ // The display session's inner whisper (碎碎念) — short inner-voice copy
257
+ // woken by the model's output. Interaction feedback takes over the whole
258
+ // bubble area while it plays, so whispers yield to it like status copy.
259
+ const whisper = feedback === null ? snapshot?.whisper : undefined
260
+ const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
219
261
  const displayName = snapshot?.name ?? definition.displayName
220
262
 
263
+ useLayoutEffect(() => {
264
+ if (!hovered) {
265
+ setPanelAbove(false)
266
+ setPanelLift(0)
267
+ return
268
+ }
269
+ const updatePanelPlacement = (): void => {
270
+ const sprite = spriteRef.current
271
+ const panel = panelRef.current
272
+ if (sprite === null || panel === null) return
273
+ const availableBelow = window.innerHeight - sprite.getBoundingClientRect().bottom
274
+ const above = availableBelow < panel.getBoundingClientRect().height + 8
275
+ setPanelAbove(above)
276
+ // The fallback above-placement shares the sprite's top edge with the
277
+ // bubble(s); lift the panel by the bubble area's height so the two
278
+ // never overlap (8px base gap + 6px clearance above the top bubble).
279
+ const bubbleHeight = above ? bubbleRef.current?.getBoundingClientRect().height ?? 0 : 0
280
+ setPanelLift(bubbleHeight > 0 ? Math.ceil(bubbleHeight) + 14 : 0)
281
+ }
282
+ updatePanelPlacement()
283
+ window.addEventListener('resize', updatePanelPlacement)
284
+ return () => window.removeEventListener('resize', updatePanelPlacement)
285
+ }, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, feedback])
286
+
221
287
  const float = (
222
288
  <div
223
289
  ref={floatRef}
@@ -228,15 +294,21 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
228
294
  setHovered(true)
229
295
  }}
230
296
  onPointerLeave={(e) => {
231
- // The panel and bubble render OUTSIDE the container's box (absolute,
232
- // above the sprite), so moving onto them fires pointerleave on the
233
- // container. Treat a target still inside the container's DOM (the
234
- // overflowed panel) as "still hovering"; otherwise give the pointer a
235
- // short grace period to reach the panel across the gap above it. The
236
- // bridge ('.panel::after') keeps the pointer inside the hit area, and
237
- // the grace period covers a slow mouse crossing the remaining sliver.
297
+ // The panel renders OUTSIDE the container's box (absolute, below
298
+ // the sprite), so moving onto it fires pointerleave on the container.
299
+ // Treat a target still inside the container's DOM (the overflowed
300
+ // panel) as "still hovering"; otherwise give the pointer a short
301
+ // grace period to reach the panel across the gap below the sprite.
302
+ // The bridge ('.panel::after') keeps the pointer inside the hit
303
+ // area, and the grace period covers a slow mouse crossing the
304
+ // remaining sliver.
238
305
  const next = e.relatedTarget
239
306
  if (next instanceof Node && floatRef.current?.contains(next)) return
307
+ // Never auto-hide while the rename box is open: moving the pointer
308
+ // onto an IME candidate window (an OS-level window outside the
309
+ // webview) fires pointerleave, and unmounting the input mid-IME-
310
+ // composition crashes some input methods / the renderer (#303).
311
+ if (renaming) return
240
312
  clearHideTimer()
241
313
  hideTimerRef.current = window.setTimeout(() => setHovered(false), 300)
242
314
  }}
@@ -248,7 +320,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
248
320
  width: spriteWidth,
249
321
  height: spriteHeight,
250
322
  backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
251
- backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * rows.length * spriteScale) + 'px',
323
+ backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
252
324
  backgroundRepeat: 'no-repeat',
253
325
  backgroundPosition: '0 0',
254
326
  cursor: dragRef.current === null ? 'grab' : 'grabbing',
@@ -266,27 +338,44 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
266
338
  aria-label={definition.displayName}
267
339
  />
268
340
  {feedback !== null && (
269
- <div key={feedback.at} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
341
+ <div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
270
342
  {feedback.text}
271
343
  </div>
272
344
  )}
273
- {feedback === null && !hovered && sessionBubbles.length > 0 && (
274
- <div className={styles.bubbleStack} role="status" aria-live="polite">
345
+ {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
346
+ <div ref={bubbleRef} className={styles.bubbleStack}>
275
347
  {sessionBubbles.map(session => (
276
- <div key={session.sessionId} className={clsx(styles.bubble, styles.bubbleStatus)}>
348
+ <button
349
+ key={session.sessionId}
350
+ type="button"
351
+ className={clsx(styles.bubble, styles.bubbleStatus, styles.bubbleClickable)}
352
+ title={props.t('pet.openSessionHint')}
353
+ onClick={() => { props.onOpenSession(session.sessionId) }}
354
+ >
277
355
  {session.bubble}
278
- </div>
356
+ </button>
279
357
  ))}
280
- </div>
281
- )}
282
- {statusBubble !== undefined && (
283
- <div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
284
- {statusBubble}
358
+ {sessionBubbles.length === 0 && statusBubble !== undefined && (
359
+ <div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
360
+ {statusBubble}
361
+ </div>
362
+ )}
363
+ {whisper !== undefined && (
364
+ // key restarts the entrance animation per fresh whisper.
365
+ <div key={whisper} className={styles.bubbleWhisper}>
366
+ {whisper}
367
+ </div>
368
+ )}
285
369
  </div>
286
370
  )}
287
371
  {hovered && dragRef.current === null && (
288
372
  <div
289
- className={styles.panel}
373
+ ref={panelRef}
374
+ className={clsx(styles.panel, panelAbove && styles.panelAbove)}
375
+ data-placement={panelAbove ? 'above' : 'below'}
376
+ style={panelAbove && panelLift > 0
377
+ ? ({ marginBottom: panelLift } as CSSProperties)
378
+ : undefined}
290
379
  onPointerEnter={() => {
291
380
  // Reaching the panel (or its bridge) must cancel any hide timer
292
381
  // the container's pointerleave may have armed while the pointer
@@ -303,12 +392,16 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
303
392
  placeholder={props.t('pet.namePlaceholder')}
304
393
  autoFocus
305
394
  onChange={(e) => setNameDraft(e.target.value)}
395
+ onCompositionStart={() => { composingRef.current = true }}
396
+ onCompositionEnd={() => { composingRef.current = false }}
306
397
  onKeyDown={(e) => {
307
398
  // While an IME composition is active (e.g. selecting a
308
399
  // Chinese candidate), Enter/Escape keydowns belong to the
309
400
  // input method: ignore them so candidate selection can
310
- // neither submit the draft nor close the rename box.
311
- if (e.nativeEvent.isComposing) return
401
+ // neither submit the draft nor close the rename box. The
402
+ // explicit ref and the 'Process' key cover IMEs that mark
403
+ // composition keydowns with isComposing === false (#303).
404
+ if (composingRef.current || e.nativeEvent.isComposing || e.key === 'Process') return
312
405
  if (e.key === 'Enter') {
313
406
  const trimmed = nameDraft.trim()
314
407
  if (trimmed !== '') {
@@ -338,11 +431,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
338
431
  <>
339
432
  <div className={styles.rankRow}>
340
433
  <span className={styles.nameCell}>{displayName}</span>
341
- <span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
434
+ <span className={styles.statRank}>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
342
435
  </div>
343
436
  <div className={styles.rankRow}>
344
- <span>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
345
- <span>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
437
+ <span className={styles.statTreats}>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
438
+ <span className={styles.statPoints}>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
346
439
  </div>
347
440
  <div className={styles.actions}>
348
441
  <button type="button" className={styles.action} onClick={props.onFeed}>
@@ -352,6 +445,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
352
445
  type="button"
353
446
  className={styles.action}
354
447
  onClick={() => {
448
+ // Cancel any pending hide so the rename box cannot
449
+ // unmount right as the user starts typing (#303).
450
+ clearHideTimer()
355
451
  setNameDraft(displayName)
356
452
  setRenaming(true)
357
453
  }}
@@ -56,6 +56,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
56
56
  * already provides the selection.
57
57
  */
58
58
  alwaysOpen?: boolean
59
+ /**
60
+ * Hide the save/discard footer: cards whose body applies its own changes
61
+ * immediately (an embedded external settings section) have no staged
62
+ * edits, so the footer would sit there permanently disabled.
63
+ */
64
+ hideFooter?: boolean
59
65
  /** The plugin's controls. */
60
66
  children: ReactNode
61
67
  }
@@ -140,6 +146,9 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
140
146
  <div className={css.body}>
141
147
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
142
148
  {props.children}
149
+ {props.hideFooter === true
150
+ ? null
151
+ : (
143
152
  <div className={css.footer}>
144
153
  {state.failed
145
154
  ? (
@@ -165,6 +174,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
165
174
  {props.t(!state.saving ? 'settings.save' : 'settings.saving')}
166
175
  </button>
167
176
  </div>
177
+ )}
168
178
  </div>
169
179
  )
170
180
  : null}