@linxin666/dsh-pet 0.1.19 → 0.1.20

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 (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +3 -3
  3. package/README.zh.md +3 -3
  4. package/lib/client.js +54 -22
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +44 -1
  7. package/lib/types/affinity.d.ts +18 -18
  8. package/lib/types/affinity.d.ts.map +1 -1
  9. package/lib/types/affinity.js +1 -1
  10. package/lib/types/client/PetDockEntry.d.ts +2 -0
  11. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  12. package/lib/types/client/PetDockEntry.js +2 -2
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +2 -2
  15. package/lib/types/client/PetSprite.d.ts +2 -0
  16. package/lib/types/client/PetSprite.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.js +17 -16
  18. package/lib/types/client/PluginSettingsCard.d.ts +1 -1
  19. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/index.d.ts +1 -1
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +31 -6
  23. package/lib/types/client/locales.d.ts +82 -80
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +2 -0
  26. package/lib/types/client/settings-form.d.ts.map +1 -1
  27. package/lib/types/client/spritesheet.d.ts +1 -1
  28. package/lib/types/client/spritesheet.d.ts.map +1 -1
  29. package/lib/types/client/spritesheet.js +3 -12
  30. package/lib/types/index.d.ts +2 -1
  31. package/lib/types/index.d.ts.map +1 -1
  32. package/lib/types/index.js +15 -13
  33. package/lib/types/invariant.js +2 -2
  34. package/lib/types/ledger.d.ts.map +1 -1
  35. package/lib/types/ledger.js +3 -3
  36. package/lib/types/mount-once.d.ts +25 -0
  37. package/lib/types/mount-once.d.ts.map +1 -0
  38. package/lib/types/mount-once.js +42 -0
  39. package/lib/types/persist.js +3 -3
  40. package/lib/types/registry.js +1 -1
  41. package/lib/types/remarks.d.ts.map +1 -1
  42. package/lib/types/routes.js +2 -2
  43. package/lib/types/service.d.ts +10 -6
  44. package/lib/types/service.d.ts.map +1 -1
  45. package/lib/types/service.js +11 -6
  46. package/lib/types/state.d.ts.map +1 -1
  47. package/package.json +1 -1
  48. package/src/client/PetDockEntry.tsx +3 -0
  49. package/src/client/PetSprite.test.tsx +56 -3
  50. package/src/client/PetSprite.tsx +26 -17
  51. package/src/client/index.ts +26 -3
  52. package/src/client/locales.ts +2 -0
  53. package/src/client/pet.module.css +35 -12
  54. package/src/client/spritesheet.ts +3 -13
  55. package/src/index.ts +4 -1
  56. package/src/mount-once.ts +48 -0
  57. package/src/service.ts +15 -7
@@ -11,7 +11,7 @@
11
11
  * @module @linxin666/dsh-pet/client
12
12
  */
13
13
 
14
- import type { ClientContext, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
14
+ import type { ClientContext, ISessions, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
15
15
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
16
16
  import type {} from '@deepseek-ai/dsh-client-locale/client'
17
17
  // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
@@ -72,8 +72,8 @@ const POLL_MS = 2000
72
72
  /** Settings namespace the pet settings card edits (the Host plugin registers it). */
73
73
  const PET_SETTINGS_NS = 'pet'
74
74
 
75
- /** Required services. */
76
- export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote']
75
+ /** Required services (sessions powers bubble-to-session navigation). */
76
+ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote', 'sessions']
77
77
 
78
78
  /** Re-exported for consumers that type against the injected face. */
79
79
  export type { PetInjected, PetDockEntryProps } from './PetDockEntry.tsx'
@@ -152,6 +152,11 @@ export function apply(ctx: ClientContext): void {
152
152
  // tick tries again. After it lands, one list feeds both the sprite and
153
153
  // the settings card's choices.
154
154
  let petsLoaded = false
155
+ // Latest-wins guard: the 2s tick, visibility recovery, and
156
+ // interaction-triggered refreshes can overlap; only the newest
157
+ // response may publish, so a slow older one can never roll the
158
+ // snapshot back.
159
+ let stateSeq = 0
155
160
  const pollNow = (): void => {
156
161
  if (!petsLoaded) {
157
162
  petApi.pets().then((list) => {
@@ -161,9 +166,13 @@ export function apply(ctx: ClientContext): void {
161
166
  // Retry on the next poll tick.
162
167
  })
163
168
  }
169
+ const seq = stateSeq + 1
170
+ stateSeq = seq
164
171
  petApi.state().then((snapshot) => {
172
+ if (seq !== stateSeq) return
165
173
  setSnapshot(snapshot)
166
174
  }, () => {
175
+ if (seq !== stateSeq) return
167
176
  setState('error', 'pet.state transport error')
168
177
  })
169
178
  }
@@ -202,9 +211,23 @@ export function apply(ctx: ClientContext): void {
202
211
  }
203
212
  }, 'pet: poll')
204
213
 
214
+ // Clicking a session bubble jumps the GUI to that session. A bubble
215
+ // can outlive its disposed session by one poll tick, and the sessions
216
+ // service fails loud on unknown ids, so consult the live list first.
217
+ // The pet's type program also loads the host-side dsh-session package
218
+ // through the service types, whose Context merge declares a different
219
+ // 'sessions' face; pin the browser runtime's outward face here.
220
+ const sessions = ctx.sessions as unknown as ISessions
221
+ const openSession = (sessionId: string): void => {
222
+ const list = sessions.list.getSnapshot()
223
+ if (list.byId[sessionId as SessionId] === undefined) return
224
+ sessions.open(sessionId as SessionId)
225
+ }
226
+
205
227
  const injected = (): PetInjected => ({
206
228
  store: petStore,
207
229
  ensure: pollNow,
230
+ openSession,
208
231
  pet: () => {
209
232
  petApi.interact('pet').then((result) => {
210
233
  setFeedback({
@@ -19,6 +19,7 @@ export const zh = {
19
19
  'pet.treats': '小鱼干 ×{n}',
20
20
  'pet.state.loading': '宠物正在赶来…',
21
21
  'pet.state.error': '宠物迷路了(连接失败)',
22
+ 'pet.openSessionHint': '点击跳转到对应会话',
22
23
  // 一级设置页(settings.section 席位)。
23
24
  'settings.title': '宠物',
24
25
  'settings.description': '选择宠物并调整它的显示布局。',
@@ -64,6 +65,7 @@ export const en = {
64
65
  'pet.treats': 'Treats ×{n}',
65
66
  'pet.state.loading': 'The pet is on its way…',
66
67
  'pet.state.error': 'The pet is lost (connection failed)',
68
+ 'pet.openSessionHint': 'Click to jump to this session',
67
69
  // First-level settings section (the `settings.section` seat).
68
70
  'settings.title': 'Pet',
69
71
  'settings.description': 'Pick a pet and tune its display layout.',
@@ -48,7 +48,7 @@
48
48
 
49
49
  /* 多会话气泡栈:并行会话各自一个气泡,自精灵头顶向上堆叠,
50
50
  最近更新的会话在最上。栈内气泡改流入列布局,不再绝对定位
51
- 互相覆盖。 */
51
+ 互相覆盖。气泡可点击:点击跳转到对应会话。 */
52
52
  .bubbleStack {
53
53
  position: absolute;
54
54
  bottom: 100%;
@@ -56,13 +56,32 @@
56
56
  flex-direction: column;
57
57
  align-items: center;
58
58
  gap: 4px;
59
- pointer-events: none;
59
+ pointer-events: auto;
60
60
  }
61
61
 
62
62
  .bubbleStack .bubble {
63
63
  position: relative;
64
64
  bottom: auto;
65
65
  margin-bottom: 0;
66
+ pointer-events: auto;
67
+ }
68
+
69
+ .bubbleClickable {
70
+ cursor: pointer;
71
+ font: inherit;
72
+ text-align: center;
73
+ appearance: none;
74
+ transition: filter 120ms ease, box-shadow 120ms ease;
75
+ }
76
+
77
+ .bubbleClickable:hover {
78
+ filter: brightness(1.12);
79
+ }
80
+
81
+ .bubbleClickable:focus-visible {
82
+ outline: none;
83
+ /* 2px accent ring on the pet's own palette, theme-agnostic. */
84
+ box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
66
85
  }
67
86
 
68
87
  @keyframes pet-bubble-pop {
@@ -86,9 +105,14 @@
86
105
  }
87
106
  }
88
107
 
108
+ /* 悬浮面板位于精灵左侧(bottom 对齐),不再占据精灵上方的空间:
109
+ 会话气泡栈独占精灵头顶区域,两者互不遮挡 —— 气泡因此可以始终
110
+ 显示并保持可点击,不必在 hover 时让位给面板。 */
89
111
  .panel {
90
112
  position: absolute;
91
- bottom: 100%;
113
+ bottom: 0;
114
+ right: 100%;
115
+ margin-right: 8px;
92
116
  background: rgba(15, 23, 42, 0.92);
93
117
  border: 1px solid rgba(148, 163, 184, 0.35);
94
118
  border-radius: 10px;
@@ -103,18 +127,17 @@
103
127
  min-width: 132px;
104
128
  }
105
129
 
106
- /* 面板底边与图标顶边之间的 hover 桥接(issue #49):
107
- 面板绝对定位在图标上方(bottom: 100%),若两者之间存在哪怕几个
108
- 像素的间隙,指针滑过间隙时会触发容器的 pointerleave,面板在指针
109
- 抵达按钮前就收起。这段透明伪元素把命中区向下延伸、覆盖图标顶部,
110
- 使指针在图标与按钮之间始终停留在 hover 区域内。 */
130
+ /* 面板右缘与图标左缘之间的 hover 桥接(issue #49 的水平版):
131
+ 面板在图标左侧留有 8px 间隙,指针滑过间隙时会触发容器的
132
+ pointerleave,面板会在指针抵达按钮前收起。这段透明伪元素把命中区
133
+ 向右延伸、覆盖间隙,使指针在图标与按钮之间始终停留在 hover 区域。 */
111
134
  .panel::after {
112
135
  content: '';
113
136
  position: absolute;
114
- top: 100%;
115
- left: 0;
116
- right: 0;
117
- height: 14px;
137
+ top: 0;
138
+ bottom: 0;
139
+ left: 100%;
140
+ width: 14px;
118
141
  }
119
142
 
120
143
  .rankRow {
@@ -7,7 +7,7 @@
7
7
  * @module @linxin666/dsh-pet/client/spritesheet
8
8
  */
9
9
 
10
- import type { PetAnimation } from '../state.ts'
10
+ import { rowOf, type PetAnimation } from '../state.ts'
11
11
  import type { PetCell, PetTrackDef } from '../registry.ts'
12
12
 
13
13
  /** Animation track shape the frame loop consumes. */
@@ -15,18 +15,8 @@ export type TrackDef = PetTrackDef
15
15
 
16
16
  /** Row index of one animation track (the fixed 9-row contract). */
17
17
  export function rowOfTrack(animation: PetAnimation): number {
18
- const rows: Record<PetAnimation, number> = {
19
- idle: 0,
20
- 'running-right': 1,
21
- 'running-left': 2,
22
- waving: 3,
23
- jumping: 4,
24
- failed: 5,
25
- waiting: 6,
26
- running: 7,
27
- review: 8,
28
- }
29
- return rows[animation]
18
+ // The table itself lives in state.ts (rowOf) — the single source of truth.
19
+ return rowOf(animation)
30
20
  }
31
21
 
32
22
  /**
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSec
19
19
  import { makePetRoutes } from './routes.ts'
20
20
  import { loadPetRegistry, petPackageRoot } from './registry.ts'
21
21
  import { DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
22
+ import { mountOnce } from './mount-once.ts'
22
23
 
23
24
  export { PetService, MAX_SESSION_BUBBLES } from './service.ts'
24
25
  export type {
@@ -137,7 +138,9 @@ export function makePetSettingsSchema(fallbackPetId: string) {
137
138
  }
138
139
 
139
140
  /** Register the pet service and its API + asset routes on the context. */
140
- export function apply(ctx: Context, config: PetConfig = {}): void {
141
+ export const apply = mountOnce('@linxin666/dsh-pet', applyImpl)
142
+
143
+ function applyImpl(ctx: Context, config: PetConfig = {}): void {
141
144
  const registry = config.registry
142
145
  ?? loadPetRegistry({
143
146
  packageRoot: petPackageRoot(import.meta.url),
@@ -0,0 +1,48 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Host single-instance guard shared by the plugin family. The family bundle
4
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
5
+ * the loader accepts a standalone install of the same package side by side;
6
+ * without this guard the second instance would still re-register the same
7
+ * webserver routes, tools, settings namespaces, and system-prompt sections
8
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
9
+ * lifetime of the first instance (the browser half is already deduped by
10
+ * package name in the client module host).
11
+ *
12
+ * The registry rides a global symbol so two module instances of the same
13
+ * package (npm copy vs repository link) still share one verdict. cordis
14
+ * `ctx.effect` runs its callback immediately and treats the callback's
15
+ * return value as the fiber disposer, so the unmarker is returned, not run.
16
+ */
17
+
18
+ const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
19
+
20
+ interface MountRegistry {
21
+ [MOUNTED]?: Set<string>
22
+ }
23
+
24
+ function mountedSet(): Set<string> {
25
+ const registry = globalThis as MountRegistry
26
+ return (registry[MOUNTED] ??= new Set())
27
+ }
28
+
29
+ /**
30
+ * Wrap a cordis plugin apply so the package runs at most once per process.
31
+ * The first mount registers normally and unmarks when its fiber disposes;
32
+ * any later mount of the same package name is a no-op.
33
+ * @param packageName - npm package identity shared by every install source.
34
+ * @param fn - the original plugin apply.
35
+ * @returns an apply of the same shape.
36
+ */
37
+ export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
+ return ((...args: unknown[]) => {
39
+ const mounted = mountedSet()
40
+ if (mounted.has(packageName)) return
41
+ mounted.add(packageName)
42
+ const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
+ ctx?.effect?.(() => () => {
44
+ mounted.delete(packageName)
45
+ })
46
+ return fn(...args)
47
+ }) as T
48
+ }
package/src/service.ts CHANGED
@@ -96,9 +96,12 @@ export interface PetSettingsSection {
96
96
  export const PET_SETTINGS_NAMESPACE = 'pet'
97
97
 
98
98
  /**
99
- * One active session as the pet displays it. Sessions run in parallel, so
100
- * each gets its own bubble while the sprite itself follows the most recent
101
- * meaningful event (the display session).
99
+ * One active TOP-LEVEL session as the pet displays it. Sessions run in
100
+ * parallel, so each gets its own bubble while the sprite itself follows the
101
+ * most recent meaningful event (the display session). Subagent children
102
+ * report no bubble of their own: their work is already reflected by the
103
+ * bubble of the conversation that spawned them, and the bubble buttons
104
+ * navigate to GUI sessions, which subagents are not.
102
105
  */
103
106
  export interface PetSessionView {
104
107
  /** Session identity (stringified for the wire; never exposed as a key). */
@@ -121,9 +124,10 @@ export interface PetStateView {
121
124
  phase: PetStateSnapshot['phase']
122
125
  sessionActive: boolean
123
126
  /**
124
- * Per-session bubbles for every concurrently active session, most recent
125
- * first; optional so older hosts without the multi-session view stay
126
- * consumable. The single 'bubble' above mirrors the display session.
127
+ * Per-session bubbles for every concurrently active TOP-LEVEL session,
128
+ * most recent first; optional so older hosts without the multi-session
129
+ * view stay consumable. The single 'bubble' above mirrors the display
130
+ * session.
127
131
  */
128
132
  sessions?: PetSessionView[]
129
133
  /** Affinity ledger snapshot. */
@@ -472,12 +476,16 @@ export class PetService extends Service {
472
476
  private view(): PetStateView {
473
477
  const snapshot = this.machine.render()
474
478
  const entry = this.activeEntry()
475
- // One bubble per concurrently active session, most recent first.
479
+ // One bubble per concurrently active TOP-LEVEL session, most recent
480
+ // first. Subagent children render no bubble of their own (their activity
481
+ // already shows through the spawning conversation's bubble/display, and
482
+ // the bubble buttons navigate to GUI sessions, which subagents are not).
476
483
  // Sessions whose own machine has settled (no bubble copy) drop out, so a
477
484
  // finished turn does not leave a stale bubble behind.
478
485
  const sessions: PetSessionView[] = []
479
486
  for (const [session, activity] of [...this.sessionActivity.entries()].reverse()) {
480
487
  if (sessions.length >= MAX_SESSION_BUBBLES) break
488
+ if (session.header?.origin === 'subagent') continue
481
489
  const perSession = activity.machine.render()
482
490
  if (perSession.bubble === undefined) continue
483
491
  sessions.push({