@linxin666/dsh-pet 0.2.0 → 0.2.2

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 (59) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +15 -5
  3. package/README.zh.md +15 -5
  4. package/assets/whale/pet.json +1 -1
  5. package/assets/whale-refined/pet.json +7 -0
  6. package/assets/whale-refined/spritesheet.webp +0 -0
  7. package/lib/client.js +257 -53
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +235 -83
  10. package/lib/invariant.js +1 -1
  11. package/lib/{state-Ch3f4luZ.js → state-DrMX22GL.js} +35 -5
  12. package/lib/types/chatter.d.ts.map +1 -1
  13. package/lib/types/chatter.js +167 -77
  14. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  15. package/lib/types/client/PetDockEntry.js +1 -1
  16. package/lib/types/client/PetSprite.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.js +54 -11
  18. package/lib/types/client/PluginSettingsCard.d.ts +22 -0
  19. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PluginSettingsCard.js +142 -3
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +1 -0
  23. package/lib/types/client/locales.d.ts +4 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +4 -0
  26. package/lib/types/client/spritesheet.d.ts +1 -3
  27. package/lib/types/client/spritesheet.d.ts.map +1 -1
  28. package/lib/types/client/spritesheet.js +1 -5
  29. package/lib/types/loopback.d.ts +25 -0
  30. package/lib/types/loopback.d.ts.map +1 -0
  31. package/lib/types/loopback.js +68 -0
  32. package/lib/types/registry.d.ts +0 -2
  33. package/lib/types/registry.d.ts.map +1 -1
  34. package/lib/types/registry.js +11 -7
  35. package/lib/types/remarks.d.ts.map +1 -1
  36. package/lib/types/remarks.js +30 -0
  37. package/lib/types/routes.d.ts.map +1 -1
  38. package/lib/types/routes.js +14 -0
  39. package/lib/types/service.d.ts.map +1 -1
  40. package/package.json +1 -2
  41. package/src/chatter.ts +167 -77
  42. package/src/client/PetDockEntry.test.tsx +93 -0
  43. package/src/client/PetDockEntry.tsx +1 -0
  44. package/src/client/PetSprite.test.tsx +72 -11
  45. package/src/client/PetSprite.tsx +98 -30
  46. package/src/client/PluginSettingsCard.tsx +219 -18
  47. package/src/client/index.test.tsx +95 -0
  48. package/src/client/index.ts +1 -0
  49. package/src/client/locales.ts +4 -0
  50. package/src/client/pet-css.test.ts +15 -3
  51. package/src/client/pet.module.css +101 -71
  52. package/src/client/settings-card.module.css +90 -1
  53. package/src/client/spritesheet.ts +1 -6
  54. package/src/loopback.ts +63 -0
  55. package/src/registry.test.ts +49 -1
  56. package/src/registry.ts +11 -7
  57. package/src/remarks.ts +30 -0
  58. package/src/routes.ts +11 -0
  59. package/src/service.ts +0 -1
@@ -73,6 +73,13 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
73
73
  const bubbleRef = useRef<HTMLDivElement | null>(null)
74
74
  const [imageReady, setImageReady] = useState(false)
75
75
  const [hovered, setHovered] = useState(false)
76
+ // Multi-session bubble stack: collapsed by default (only the display
77
+ // session's bubble + a '+N' badge), expanded on stack hover (peek) or by
78
+ // tapping the badge (pinned, for touch). The display session's bubble
79
+ // anchors the bottom of the stack and never moves when extras open above
80
+ // it, so the pointer target cannot flicker.
81
+ const [stackPeek, setStackPeek] = useState(false)
82
+ const [stackPinned, setStackPinned] = useState(false)
76
83
  const [renaming, setRenaming] = useState(false)
77
84
  const [panelAbove, setPanelAbove] = useState(false)
78
85
  // Extra margin-bottom for the above-panel so it stacks clear of the
@@ -134,7 +141,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
134
141
  // Paint one static sprite frame up front either way, so the pet is never
135
142
  // blank while the loop heat-up runs.
136
143
  const leadCol = track.frames[0]!
137
- const lead = framePosition(cell, columns, row, leadCol, scaleRef.current)
144
+ const lead = framePosition(cell, row, leadCol, scaleRef.current)
138
145
  if (spriteRef.current !== null) {
139
146
  spriteRef.current.style.backgroundPosition = lead.x + 'px ' + lead.y + 'px'
140
147
  }
@@ -154,7 +161,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
154
161
  rows[currentRow] ?? tracks[current.animation].frames.length,
155
162
  )
156
163
  const col = currentTrack.frames[current.frameIndex]!
157
- const pos = framePosition(cell, columns, currentRow, col, scaleRef.current)
164
+ const pos = framePosition(cell, currentRow, col, scaleRef.current)
158
165
  if (spriteRef.current !== null) {
159
166
  spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
160
167
  }
@@ -185,7 +192,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
185
192
  }
186
193
  }
187
194
  const col = track.frames[st.index]!
188
- const pos = framePosition(cell, columns, row, col, scaleRef.current)
195
+ const pos = framePosition(cell, row, col, scaleRef.current)
189
196
  if (spriteRef.current !== null) {
190
197
  spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
191
198
  }
@@ -217,6 +224,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
217
224
  }
218
225
  }
219
226
 
227
+ // Clear any pending auto-hide timer on unmount: a stray callback after
228
+ // teardown reads window through react-dom and failed CI runs with
229
+ // "window is not defined" (slow-runner timing, PetSprite.test.tsx).
230
+ useEffect(() => () => clearHideTimer(), [])
231
+
220
232
  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
221
233
  e.preventDefault()
222
234
  ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
@@ -245,21 +257,33 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
245
257
  const spriteWidth = Math.round(cell.width * spriteScale)
246
258
  const spriteHeight = Math.round(cell.height * spriteScale)
247
259
 
248
- // Concurrent sessions each render their own bubble (stacked above the
249
- // sprite); the legacy single 'bubble' is the fallback when the host serves
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.
260
+ // Concurrent sessions share one bubble slot: only the display session
261
+ // speaks by default, and the rest hide behind a '+N' badge until the stack
262
+ // is hovered/pinned open. The legacy single 'bubble' is the fallback when
263
+ // the host serves no per-session list. The hover panel normally sits below
264
+ // the sprite, so the bubbles stay visible and clickable — no region swap.
252
265
  const sessionBubbles = snapshot?.sessions ?? []
266
+ const stackOpen = stackPeek || stackPinned
267
+ const collapsed = !stackOpen && sessionBubbles.length > 1
268
+ const visibleSessions = collapsed ? sessionBubbles.slice(0, 1) : sessionBubbles
253
269
  const statusBubble = feedback === null && sessionBubbles.length === 0
254
270
  ? snapshot?.bubble
255
271
  : undefined
256
272
  // 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.
273
+ // woken by the model's output. Instead of a second bubble of its own, a
274
+ // fresh whisper takes over the display session's bubble (the stack top, or
275
+ // the single status bubble) and re-tints it, so the pet never wears two
276
+ // voices at once. Interaction feedback takes over the whole bubble area
277
+ // while it plays, so whispers yield to it like status copy.
259
278
  const whisper = feedback === null ? snapshot?.whisper : undefined
260
279
  const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
261
280
  const displayName = snapshot?.name ?? definition.displayName
262
281
 
282
+ // A settled session list can no longer stay pinned open.
283
+ useEffect(() => {
284
+ if (sessionBubbles.length <= 1) setStackPinned(false)
285
+ }, [sessionBubbles.length])
286
+
263
287
  useLayoutEffect(() => {
264
288
  if (!hovered) {
265
289
  setPanelAbove(false)
@@ -282,7 +306,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
282
306
  updatePanelPlacement()
283
307
  window.addEventListener('resize', updatePanelPlacement)
284
308
  return () => window.removeEventListener('resize', updatePanelPlacement)
285
- }, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, feedback])
309
+ }, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, stackOpen, feedback])
286
310
 
287
311
  const float = (
288
312
  <div
@@ -343,27 +367,71 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
343
367
  </div>
344
368
  )}
345
369
  {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
346
- <div ref={bubbleRef} className={styles.bubbleStack}>
347
- {sessionBubbles.map(session => (
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) }}
370
+ <div
371
+ ref={bubbleRef}
372
+ className={styles.bubbleStack}
373
+ onPointerEnter={() => setStackPeek(true)}
374
+ onPointerLeave={() => setStackPeek(false)}
375
+ >
376
+ {visibleSessions.map((session, index) => {
377
+ // The whisper rides the display session's bubble — the stack's
378
+ // primary entry (DOM-first, rendered bottom-most by the reversed
379
+ // column so it stays glued to the sprite when extras open above).
380
+ // The key swap restarts the entrance animation so the mood change
381
+ // reads as the bubble re-speaking.
382
+ const speaksWhisper = index === 0 && whisper !== undefined
383
+ const bubble = (
384
+ <button
385
+ key={speaksWhisper ? 'whisper:' + whisper : session.sessionId}
386
+ type="button"
387
+ className={clsx(
388
+ styles.bubble,
389
+ styles.bubbleStatus,
390
+ styles.bubbleClickable,
391
+ speaksWhisper && styles.bubbleWhisper,
392
+ )}
393
+ title={props.t('pet.openSessionHint')}
394
+ onClick={() => { props.onOpenSession(session.sessionId) }}
395
+ >
396
+ {speaksWhisper ? whisper : session.bubble}
397
+ </button>
398
+ )
399
+ // The primary bubble carries the '+N' badge while other sessions
400
+ // hide behind it; the badge toggles the pinned (touch) expansion.
401
+ if (index !== 0 || sessionBubbles.length <= 1) return bubble
402
+ return (
403
+ <span key="primary" className={styles.bubbleAnchor}>
404
+ {bubble}
405
+ <button
406
+ type="button"
407
+ className={styles.bubbleMore}
408
+ title={stackOpen
409
+ ? props.t('pet.collapseSessions')
410
+ : props.t('pet.moreSessions', { n: sessionBubbles.length - 1 })}
411
+ aria-label={stackOpen
412
+ ? props.t('pet.collapseSessions')
413
+ : props.t('pet.moreSessions', { n: sessionBubbles.length - 1 })}
414
+ aria-expanded={stackOpen}
415
+ onClick={(e) => {
416
+ e.stopPropagation()
417
+ setStackPinned(open => !open)
418
+ }}
419
+ >
420
+ {stackOpen ? '×' : '+' + String(sessionBubbles.length - 1)}
421
+ </button>
422
+ </span>
423
+ )
424
+ })}
425
+ {sessionBubbles.length === 0 && (statusBubble !== undefined || whisper !== undefined) && (
426
+ // The key swap (status copy <-> whisper) restarts the entrance
427
+ // animation on every mood change.
428
+ <div
429
+ key={whisper === undefined ? 'status' : 'whisper:' + whisper}
430
+ className={clsx(styles.bubble, styles.bubbleStatus, whisper !== undefined && styles.bubbleWhisper)}
431
+ role="status"
432
+ aria-live="polite"
354
433
  >
355
- {session.bubble}
356
- </button>
357
- ))}
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}
434
+ {whisper ?? statusBubble}
367
435
  </div>
368
436
  )}
369
437
  </div>
@@ -8,7 +8,7 @@
8
8
  * ui-plugin-config PluginCard in a self-contained slice.
9
9
  */
10
10
 
11
- import { useState, type ReactNode } from 'react'
11
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
12
12
  import type { CardShell } from './settings-form.ts'
13
13
  import css from './settings-card.module.css'
14
14
 
@@ -255,6 +255,210 @@ export function ValueField(props: FieldProps & {
255
255
  )
256
256
  }
257
257
 
258
+ const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
259
+ // 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
260
+ function isSkinActive(): boolean {
261
+ const datasetList = Object.keys(document.body.dataset)
262
+ const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
263
+ return isActive
264
+ }
265
+
266
+ interface SelectOption {
267
+ value: string
268
+ label: string
269
+ }
270
+
271
+ const SELECT_CLOSE_MS = 100
272
+
273
+ /**
274
+ * The shared dual-mode select control. While an appearance skin is active it
275
+ * renders the legacy native `<select>` untouched, so element-level skin
276
+ * selectors keep working; under the default appearance it renders a
277
+ * self-drawn `role="listbox"` popup whose open/close is transition-animated.
278
+ * Staged cards reach it through BooleanField/ChoiceField; immediate-apply
279
+ * editors (the side-card prefs) bind it directly through onEdit.
280
+ * 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
281
+ */
282
+ export function SelectField(props: {
283
+ id: string
284
+ options: ReadonlyArray<SelectOption>
285
+ value: string
286
+ disabled: boolean
287
+ invalid: boolean
288
+ onEdit: (text: string) => void
289
+ }) {
290
+ const { id, options, value } = props
291
+ const [open, setOpen] = useState(false)
292
+ const [closing, setClosing] = useState(false)
293
+ const [phase, setPhase] = useState<'initial' | 'open'>('initial')
294
+ const [activeIndex, setActiveIndex] = useState(0)
295
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
296
+ const wrapRef = useRef<HTMLDivElement | null>(null)
297
+ const popupRef = useRef<HTMLDivElement | null>(null)
298
+
299
+ const currentIndex = () => {
300
+ const index = options.findIndex(option => option.value === value)
301
+ return index >= 0 ? index : 0
302
+ }
303
+
304
+ const close = useCallback(() => {
305
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
306
+ setClosing(true)
307
+ closeTimer.current = setTimeout(() => {
308
+ setClosing(false)
309
+ setOpen(false)
310
+ }, SELECT_CLOSE_MS)
311
+ }, [])
312
+
313
+ const openPopup = () => {
314
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
315
+ setActiveIndex(currentIndex())
316
+ setPhase('initial')
317
+ setClosing(false)
318
+ setOpen(true)
319
+ }
320
+
321
+ const commit = (index: number) => {
322
+ const option = options[index]
323
+ if (option) props.onEdit(option.value)
324
+ close()
325
+ }
326
+
327
+ const onTriggerClick = () => {
328
+ if (props.disabled) return
329
+ if (open && !closing) close()
330
+ else openPopup()
331
+ }
332
+
333
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
334
+ if (props.disabled) return
335
+ const count = options.length
336
+ switch (event.key) {
337
+ case 'ArrowDown':
338
+ case 'ArrowUp':
339
+ case 'Enter':
340
+ case ' ':
341
+ event.preventDefault()
342
+ if (!open) {
343
+ openPopup()
344
+ } else if (!closing) {
345
+ if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
346
+ else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
347
+ else commit(activeIndex)
348
+ }
349
+ break
350
+ case 'Escape':
351
+ if (open) {
352
+ event.preventDefault()
353
+ event.stopPropagation()
354
+ close()
355
+ }
356
+ break
357
+ case 'Tab':
358
+ if (open) close()
359
+ break
360
+ }
361
+ }
362
+
363
+ useEffect(() => () => {
364
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
365
+ }, [])
366
+ useLayoutEffect(() => {
367
+ if (open && !closing && phase === 'initial') {
368
+ void popupRef.current?.offsetHeight
369
+ setPhase('open')
370
+ }
371
+ }, [open, closing, phase])
372
+
373
+ useEffect(() => {
374
+ if (!open) return
375
+ const onPointerDown = (event: PointerEvent) => {
376
+ const target = event.target
377
+ if (target instanceof Node && !wrapRef.current?.contains(target)) close()
378
+ }
379
+ document.addEventListener('pointerdown', onPointerDown)
380
+ return () => document.removeEventListener('pointerdown', onPointerDown)
381
+ }, [open, close])
382
+
383
+ useEffect(() => {
384
+ if (props.disabled && open) close()
385
+ }, [props.disabled, open, close])
386
+
387
+ // 使用了皮肤则退回原生select样式防止样式冲突。
388
+ if (isSkinActive()) {
389
+ return (
390
+ <select
391
+ id={id}
392
+ className={css.select}
393
+ value={value}
394
+ disabled={props.disabled}
395
+ onChange={(event) => { props.onEdit(event.target.value) }}
396
+ >
397
+ {options.map(option => (
398
+ <option key={option.value} value={option.value}>{option.label}</option>
399
+ ))}
400
+ </select>
401
+ )
402
+ }
403
+
404
+ const label = options.find(option => option.value === value)?.label ?? ''
405
+ const popupClass = closing
406
+ ? `${css.selectPopup} ${css.selectPopupClose}`
407
+ : phase === 'open'
408
+ ? `${css.selectPopup} ${css.selectPopupOpen}`
409
+ : css.selectPopup
410
+ return (
411
+ <div className={css.selectWrap} ref={wrapRef}>
412
+ <button
413
+ type="button"
414
+ id={id}
415
+ className={`${css.select} ${css.selectButton}`}
416
+ disabled={props.disabled}
417
+ aria-haspopup="listbox"
418
+ aria-expanded={open}
419
+ aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
420
+ aria-invalid={props.invalid || undefined}
421
+ onClick={onTriggerClick}
422
+ onKeyDown={onKeyDown}
423
+ >
424
+ <span className={css.selectLabel}>{label}</span>
425
+ <svg
426
+ width="14"
427
+ height="14"
428
+ viewBox="0 0 14 14"
429
+ fill="none"
430
+ xmlns="http://www.w3.org/2000/svg"
431
+ className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
432
+ aria-hidden="true"
433
+ >
434
+ <path
435
+ d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
436
+ fill="currentColor"
437
+ />
438
+ </svg>
439
+ </button>
440
+ {open
441
+ ? (
442
+ <div className={popupClass} role="listbox" ref={popupRef}>
443
+ {options.map((option, index) => (
444
+ <div
445
+ key={option.value}
446
+ id={`${id}-o${index}`}
447
+ role="option"
448
+ aria-selected={option.value === value}
449
+ className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
450
+ onClick={() => { commit(index) }}
451
+ >
452
+ {option.label}
453
+ </div>
454
+ ))}
455
+ </div>
456
+ )
457
+ : null}
458
+ </div>
459
+ )
460
+ }
461
+
258
462
  /** A staged boolean field: 继承 / 开 / 关. */
259
463
  export function BooleanField(props: FieldProps & {
260
464
  /** Copy for the inherit option. */
@@ -284,17 +488,18 @@ export function BooleanField(props: FieldProps & {
284
488
  )
285
489
  : null}
286
490
  </div>
287
- <select
491
+ <SelectField
288
492
  id={props.id}
289
- className={css.select}
493
+ options={[
494
+ { value: '', label: props.inheritLabel },
495
+ { value: 'true', label: props.onLabel },
496
+ { value: 'false', label: props.offLabel },
497
+ ]}
290
498
  value={props.text}
291
499
  disabled={props.disabled}
292
- onChange={(event) => { props.onEdit(event.target.value) }}
293
- >
294
- <option value="">{props.inheritLabel}</option>
295
- <option value="true">{props.onLabel}</option>
296
- <option value="false">{props.offLabel}</option>
297
- </select>
500
+ invalid={props.invalid}
501
+ onEdit={props.onEdit}
502
+ />
298
503
  <p className={css.hint}>{props.hint}</p>
299
504
  </div>
300
505
  )
@@ -327,18 +532,14 @@ export function ChoiceField(props: FieldProps & {
327
532
  )
328
533
  : null}
329
534
  </div>
330
- <select
535
+ <SelectField
331
536
  id={props.id}
332
- className={css.select}
537
+ options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
333
538
  value={props.text}
334
539
  disabled={props.disabled}
335
- onChange={(event) => { props.onEdit(event.target.value) }}
336
- >
337
- <option value="">{props.inheritLabel}</option>
338
- {props.choices.map(choice => (
339
- <option key={choice.value} value={choice.value}>{choice.label}</option>
340
- ))}
341
- </select>
540
+ invalid={props.invalid}
541
+ onEdit={props.onEdit}
542
+ />
342
543
  <p className={props.invalid ? css.invalid : css.hint}>
343
544
  {props.invalid ? props.invalidLabel : props.hint}
344
545
  </p>
@@ -0,0 +1,95 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The global pet entry container opts into the L2 semantic attributes
4
+ * (issue #506): the apply body mounts [data-dsh-pet-root] with
5
+ * data-dsh-plugin="pet" so skins can target the pet subtree.
6
+ */
7
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
8
+ // The npm SDK's client half is a closure-factory bundle for the GUI's
9
+ // __ModuleLoader__ (not importable under vitest); provide defineStore /
10
+ // createSnapshotStore (same fake-store pattern as the settings-card tests).
11
+ vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
12
+ defineStore: (spec: {
13
+ init: () => unknown
14
+ actions: Record<string, (draft: never, ...args: never[]) => void>
15
+ }) => ({
16
+ create: () => {
17
+ let value = spec.init()
18
+ const listeners = new Set<() => void>()
19
+ const actions: Record<string, (...args: unknown[]) => void> = {}
20
+ for (const [name, fn] of Object.entries(spec.actions)) {
21
+ actions[name] = (...args: unknown[]) => {
22
+ fn(value as never, ...(args as never[]))
23
+ for (const listener of listeners) listener()
24
+ }
25
+ }
26
+ return {
27
+ getSnapshot: () => value,
28
+ subscribe: (listener: () => void) => {
29
+ listeners.add(listener)
30
+ return () => { listeners.delete(listener) }
31
+ },
32
+ actions,
33
+ }
34
+ },
35
+ }),
36
+ createSnapshotStore: (init: unknown) => {
37
+ let value = init
38
+ const listeners = new Set<() => void>()
39
+ return {
40
+ getSnapshot: () => value,
41
+ set: (next: unknown) => { value = next; for (const listener of listeners) listener() },
42
+ update: (mutator: (draft: never) => void) => { mutator(value as never); for (const listener of listeners) listener() },
43
+ subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
44
+ }
45
+ },
46
+ }))
47
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
48
+ import { apply } from './index.ts'
49
+
50
+ beforeAll(() => {
51
+ document.documentElement.lang = 'zh'
52
+ })
53
+
54
+ afterEach(() => {
55
+ document.body.replaceChildren()
56
+ })
57
+
58
+ /** A minimal client root context: ready settings scope, no-op slot system. */
59
+ function fakeContext(): ClientContext {
60
+ const scope = {
61
+ getSnapshot: () => ({
62
+ status: 'ready',
63
+ writable: true,
64
+ value: undefined,
65
+ base: undefined,
66
+ user: {},
67
+ revision: 1,
68
+ mode: 'host',
69
+ }),
70
+ subscribe: () => () => {},
71
+ }
72
+ return {
73
+ effect: (fn: () => unknown) => {
74
+ const dispose = fn()
75
+ return typeof dispose === 'function' ? dispose : () => {}
76
+ },
77
+ locale: { register: () => () => {} },
78
+ get: () => undefined,
79
+ settingsScope: { bind: () => scope },
80
+ slots: {
81
+ inject: (_name: string, callback: () => () => void) => callback(),
82
+ register: () => () => {},
83
+ },
84
+ sessions: undefined,
85
+ } as unknown as ClientContext
86
+ }
87
+
88
+ describe('pet client apply L2 semantic attributes (#506)', () => {
89
+ it('mounts the pet root container with data-dsh-plugin="pet"', () => {
90
+ apply(fakeContext())
91
+ const root = document.body.querySelector('[data-dsh-pet-root]')
92
+ expect(root).not.toBeNull()
93
+ expect(root!.getAttribute('data-dsh-plugin')).toBe('pet')
94
+ })
95
+ })
@@ -292,6 +292,7 @@ export function apply(ctx: ClientContext): void {
292
292
  // when visible, and the hidden-state summon button is fixed-positioned.
293
293
  const container = document.createElement('div')
294
294
  container.dataset.dshPetRoot = ''
295
+ container.dataset.dshPlugin = 'pet'
295
296
  document.body.appendChild(container)
296
297
  const petRoot = createRoot(container)
297
298
  petRoot.render(createElement(PetDockEntry, { ...injected(), t }))
@@ -20,6 +20,8 @@ export const zh = {
20
20
  'pet.state.loading': '宠物正在赶来…',
21
21
  'pet.state.error': '宠物迷路了(连接失败)',
22
22
  'pet.openSessionHint': '点击跳转到对应会话',
23
+ 'pet.moreSessions': '展开其余 {n} 个会话的气泡',
24
+ 'pet.collapseSessions': '收起会话气泡',
23
25
  // 一级设置页(settings.section 席位)。
24
26
  'settings.title': '宠物',
25
27
  'settings.description': '选择宠物并调整它的显示布局。',
@@ -66,6 +68,8 @@ export const en = {
66
68
  'pet.state.loading': 'The pet is on its way…',
67
69
  'pet.state.error': 'The pet is lost (connection failed)',
68
70
  'pet.openSessionHint': 'Click to jump to this session',
71
+ 'pet.moreSessions': 'Expand {n} more session bubbles',
72
+ 'pet.collapseSessions': 'Collapse session bubbles',
69
73
  // First-level settings section (the `settings.section` seat).
70
74
  'settings.title': 'Pet',
71
75
  'settings.description': 'Pick a pet and tune its display layout.',
@@ -17,11 +17,23 @@ describe('pet hover panel css', () => {
17
17
  expect(bridge).toContain('height: 14px')
18
18
  })
19
19
 
20
- it('styles the whisper bubble as a distinct non-interactive inner voice', () => {
20
+ it('keeps the whisper on the shared bubble glass for a unified palette', () => {
21
21
  const whisper = css.match(/\.bubbleWhisper\s*\{([^}]*)\}/)?.[1] ?? ''
22
- expect(whisper).toContain('font-style: italic')
23
- expect(whisper).toContain('pointer-events: none')
22
+ // Unified palette: the whisper inherits background/border/shadow/color
23
+ // from .bubble/.bubbleStatus, so a whispering bubble and a status bubble
24
+ // stacked side by side share the same DeepSeek-blue glass; only the mood
25
+ // (quotes, letter-spacing, entrance) is overridden.
26
+ expect(whisper).not.toContain('background')
27
+ expect(whisper).not.toContain('border')
28
+ expect(whisper).not.toContain('box-shadow')
29
+ expect(whisper).not.toContain('color')
30
+ expect(whisper).toContain('animation: pet-whisper-in')
31
+ expect(whisper).not.toContain('font-style: italic')
32
+ expect(whisper).not.toContain('pointer-events')
33
+ expect(whisper).not.toContain('position:')
24
34
  expect(css).toContain('@keyframes pet-whisper-in')
35
+ // The mood overrides must be declared after .bubbleStatus to win the cascade.
36
+ expect(css.indexOf('.bubbleWhisper {')).toBeGreaterThan(css.indexOf('.bubbleStatus {'))
25
37
  })
26
38
 
27
39
  it('gives the panel and status bubbles entrance animations', () => {