dsh-ntr 0.3.2 → 0.3.3

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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The composer dock's `x轮x步 · xx tok/s` pill on an ACP conversation.
3
+ *
4
+ * WHY SHADOW INSTEAD OF A SECOND PILL: the host's `ActivityPill` already states
5
+ * the two things this conversation has — turn/step counts and a whole-session
6
+ * decode speed — in the one place a speed belongs. Only its speed is wrong for
7
+ * ACP: the host divides the tokens a step emitted by `firstTokenTime →
8
+ * completedTime`, and that window contains every tool wait. Rendering a second
9
+ * speed beside it would leave two numbers on screen and no way to tell which
10
+ * is the honest one. So this module takes the host pill's own cell (`list` slot
11
+ * `conversation.composer.dock`, id `activity`) at a lower priority, renders the
12
+ * HOST's component, and hands it a patched `sessionStats` whose decode fields
13
+ * are the plugin's burst aggregate. Style, counts, dialog, aria and locale stay
14
+ * the host's; only the number changes.
15
+ *
16
+ * NON-ACP CONVERSATIONS PASS THROUGH. The shadow is installed only while the
17
+ * gate confirms the staged conversation runs the ACP preset, but a list entry
18
+ * is projected into EVERY session's composer. The wrapper therefore renders the
19
+ * host component with the ORIGINAL projection whenever `acpMode` is not
20
+ * affirmatively true, making the shadow visually indistinguishable from the
21
+ * shipped entry there.
22
+ *
23
+ * FAIL-OPEN IS STRUCTURAL: if the host entry, its inject face, its locale or
24
+ * its `performanceUsage` source is missing, no claim is created and the native
25
+ * pill stays exactly as shipped. If the host component rejects the patched
26
+ * projection, the slot's error boundary retires this entry and the native
27
+ * `activity` entry wins again. No CSS hiding and no DOM patching are involved.
28
+ */
29
+
30
+ import { createElement, useMemo } from 'react'
31
+ import type { ComponentType } from 'react'
32
+ // Type-only: pulls the conversation SlotMap merge into this program.
33
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
34
+ import type { SessionBurst } from '../protocol.ts'
35
+ import { useUi } from './acp-ui.tsx'
36
+ import type { AcpStore, UiState } from './store.ts'
37
+ import { createSeatClaim } from './model-seat.tsx'
38
+ import type { ModelSeatClaim, SeatEntry, SeatSlots } from './model-seat.tsx'
39
+
40
+ export const DOCK_SLOT = 'conversation.composer.dock'
41
+ export const ACTIVITY_ENTRY_ID = 'activity'
42
+
43
+ type ActivityProjection = (key: string, selector?: (value: unknown) => unknown) => unknown
44
+ type ActivityPerformanceHook = (selector?: (value: unknown) => unknown) => unknown
45
+ type ActivityTranslate = (key: string, params?: Record<string, unknown>) => string
46
+
47
+ /** The standard kit + injected face the shadow renders the host component with. */
48
+ export interface ActivityPillHostProps {
49
+ sessionId: string
50
+ useChat: unknown
51
+ useProjection: ActivityProjection
52
+ usePerformanceUsage: ActivityPerformanceHook
53
+ t?: ActivityTranslate
54
+ acp: AcpStore
55
+ }
56
+
57
+ interface OccupantProps extends ActivityPillHostProps {
58
+ host: ComponentType<Record<string, unknown>>
59
+ }
60
+
61
+ /**
62
+ * The aggregate the dock should show: the sum of every ACP session backing this
63
+ * chat (the host pill's counts are whole-chat too), with a live `turn/metrics`
64
+ * entry preferred per session over its persisted row. `undefined` means "no
65
+ * measurable burst yet" — the wrapper then hides the speed instead of showing
66
+ * the host's tool-inclusive number.
67
+ */
68
+ export function activityBurst(
69
+ ui: Pick<UiState, 'chatSessions' | 'chatSessionsFor' | 'burstStats'>,
70
+ dshSessionId: string,
71
+ ): SessionBurst | undefined {
72
+ if (ui.chatSessionsFor !== dshSessionId) return undefined
73
+ let outputTokens = 0
74
+ let burstMs = 0
75
+ for (const row of ui.chatSessions) {
76
+ const measured = ui.burstStats[row.sessionId] ?? row.burst
77
+ if (measured === undefined) continue
78
+ outputTokens += measured.outputTokens
79
+ burstMs += measured.burstMs
80
+ }
81
+ return outputTokens > 0 && burstMs > 0 ? { outputTokens, burstMs } : undefined
82
+ }
83
+
84
+ /**
85
+ * Replace ONLY the decode half of the host's session stats.
86
+ *
87
+ * `decodeTokens` and `decodeMs` are the two fields the host's `decodeSpeed`
88
+ * reads (`decodeTokens / (decodeMs / 1000)`), so writing the plugin's aggregate
89
+ * there makes the host's own formatter, dialog row and aria label display the
90
+ * plugin's burst rate. Every other field — turns, steps, LLM/tool/TTFT — stays
91
+ * host-native. With no measurable burst the two decode fields are zeroed: the
92
+ * host then hides the speed (`decodeMs > 0` guard) while keeping the counts.
93
+ */
94
+ export function patchedActivityStats(
95
+ native: Record<string, unknown> | undefined,
96
+ burst: SessionBurst | undefined,
97
+ acp: boolean,
98
+ ): Record<string, unknown> | undefined {
99
+ if (!acp || native === undefined) return native
100
+ if (burst !== undefined && burst.outputTokens > 0 && burst.burstMs > 0) {
101
+ return { ...native, decodeTokens: burst.outputTokens, decodeMs: burst.burstMs }
102
+ }
103
+ return { ...native, decodeTokens: 0, decodeMs: 0 }
104
+ }
105
+
106
+ function ActivityPillOccupant({ host: Host, ...props }: OccupantProps) {
107
+ const ui = useUi(props.acp)
108
+ const dshSessionId = String(props.sessionId)
109
+ const acpMode = ui.acpMode[dshSessionId] === true
110
+ const native = props.useProjection('sessionStats') as Record<string, unknown> | undefined
111
+ const burst = acpMode ? activityBurst(ui, dshSessionId) : undefined
112
+ const stats = useMemo(
113
+ () => patchedActivityStats(native, burst, acpMode),
114
+ [native, burst?.outputTokens, burst?.burstMs, acpMode],
115
+ )
116
+ // The current host pill reads exactly one projection. Returning `undefined`
117
+ // for another key keeps the wrapper from claiming support it has not been
118
+ // taught; a future host that asks for more will abdicate to the native entry.
119
+ const projection = useMemo<ActivityProjection>(
120
+ () => (key, selector) => {
121
+ if (key !== 'sessionStats') return undefined
122
+ return selector === undefined ? stats : selector(stats)
123
+ },
124
+ [stats],
125
+ )
126
+ return createElement(Host, {
127
+ useChat: props.useChat,
128
+ useProjection: acpMode ? projection : props.useProjection,
129
+ usePerformanceUsage: props.usePerformanceUsage,
130
+ t: props.t,
131
+ })
132
+ }
133
+
134
+ function findHostActivity(slots: SeatSlots): SeatEntry | undefined {
135
+ try {
136
+ if (slots.spec?.(DOCK_SLOT)?.kind !== 'list') return undefined
137
+ const heads = slots.entriesOfSlot?.(DOCK_SLOT) ?? []
138
+ return heads.find(entry => entry.options?.id === ACTIVITY_ENTRY_ID)
139
+ } catch {
140
+ return undefined
141
+ }
142
+ }
143
+
144
+ export interface ActivityPillClaimOptions {
145
+ slots: SeatSlots
146
+ /** Session-scoped face factory (the gate's `chatFace`). */
147
+ inject(sessionId: unknown, actions?: unknown): unknown
148
+ onDiag?(line: string): void
149
+ maxClaims?: number
150
+ }
151
+
152
+ /**
153
+ * Take the dock's `activity` cell for one ACP-mode spell.
154
+ *
155
+ * Returns `undefined` on any host shape this bundle cannot serve — an older
156
+ * host without the `activity` entry (0.1.5 ships `stats`), a slot kind change,
157
+ * a missing inject/locale, or no `performanceUsage` source. The native pill is
158
+ * then left untouched; that is the honest failure mode, not a hidden control.
159
+ */
160
+ export function createActivityPillClaim(options: ActivityPillClaimOptions): ModelSeatClaim | undefined {
161
+ const host = findHostActivity(options.slots)
162
+ if (host === undefined
163
+ || host.component === undefined
164
+ || typeof host.inject !== 'function'
165
+ || typeof host.locale !== 'string'
166
+ || host.locale === '') {
167
+ options.onDiag?.('activity pill: host entry not available')
168
+ return undefined
169
+ }
170
+ let performanceUsage: unknown
171
+ try {
172
+ const face = host.inject() as { hooks?: { performanceUsage?: unknown } } | undefined
173
+ performanceUsage = face?.hooks?.performanceUsage
174
+ } catch {
175
+ performanceUsage = undefined
176
+ }
177
+ if (performanceUsage === undefined) {
178
+ options.onDiag?.('activity pill: host performance hook missing')
179
+ return undefined
180
+ }
181
+ const Host = host.component as ComponentType<Record<string, unknown>>
182
+ return createSeatClaim({
183
+ slots: options.slots,
184
+ slotKey: DOCK_SLOT,
185
+ entryId: ACTIVITY_ENTRY_ID,
186
+ order: host.options?.order ?? 0,
187
+ locale: host.locale,
188
+ basePriority: -1,
189
+ occupant: () => (props: Record<string, unknown>) => createElement(ActivityPillOccupant, {
190
+ ...props,
191
+ host: Host,
192
+ } as unknown as OccupantProps),
193
+ inject: (sessionId, actions) => ({
194
+ ...(options.inject(sessionId, actions) as Record<string, unknown>),
195
+ hooks: { performanceUsage },
196
+ }),
197
+ label: 'activity pill',
198
+ onDiag: options.onDiag,
199
+ maxClaims: options.maxClaims ?? 1,
200
+ })
201
+ }
@@ -26,10 +26,28 @@ import type { ComponentType } from 'react'
26
26
  import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
27
27
  import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
28
28
  import { useUi } from './acp-ui.tsx'
29
- import type { AcpChatInjected } from './acp-ui.tsx'
29
+ import type { AcpChatInjected, AcpHostForkFace } from './acp-ui.tsx'
30
30
  import { forkStateOf } from './store.ts'
31
31
  import type { ChatForkState } from './store.ts'
32
32
 
33
+ /**
34
+ * Resolves the runtime's `sessions.fork` primitive, binding it to the service
35
+ * instance so that internal `this` accesses (such as `this.list.getSnapshot()`)
36
+ * resolve correctly when called through `face.fork(...)`.
37
+ * Returns `undefined` if the service or `fork` method is absent or not a function.
38
+ */
39
+ export function resolveHostFork(sessionsService: unknown): AcpHostForkFace | undefined {
40
+ try {
41
+ const sessions = sessionsService as {
42
+ fork?: (options: { sessionId: string; increaseTitle?: boolean }) => Promise<unknown>
43
+ } | null | undefined
44
+ const fork = sessions?.fork
45
+ return typeof fork === 'function' ? { fork: fork.bind(sessions) } : undefined
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
50
+
33
51
  /**
34
52
  * The host's own branch-button icon, picked per host generation. The icon
35
53
  * exports MOVED between shipped hosts — desktop 0.2.1-rc.2 has
@@ -26,7 +26,8 @@ import { AcpConversationView } from './acp-view.tsx'
26
26
  import { AcpSettingsDialog } from './settings-dialog.tsx'
27
27
  import { PermissionDock } from './permission-dock.tsx'
28
28
  import { ReleasedBanner } from './released-banner.tsx'
29
- import { AcpBranchAction } from './branch-action.tsx'
29
+ import { AcpBranchAction, resolveHostFork } from './branch-action.tsx'
30
+ import { createActivityPillClaim, DOCK_SLOT } from './activity-pill.tsx'
30
31
  import {
31
32
  createModelSeatClaim,
32
33
  createPermissionSeatClaim,
@@ -241,8 +242,7 @@ export function apply(ctx: Context): void {
241
242
  */
242
243
  const hostFork = (): AcpHostForkFace | undefined => {
243
244
  try {
244
- const fork = service<StructuralServices['sessions']>('sessions')?.fork
245
- return typeof fork === 'function' ? { fork } : undefined
245
+ return resolveHostFork(service<StructuralServices['sessions']>('sessions'))
246
246
  } catch {
247
247
  return undefined
248
248
  }
@@ -482,6 +482,26 @@ export function apply(ctx: Context): void {
482
482
  * available yet.
483
483
  */
484
484
  let permissionClaim: ModelSeatClaim | undefined
485
+ /**
486
+ * Claim on the dock's `activity` cell (`conversation.composer.dock`, id
487
+ * `activity`), held for the same ACP-mode spell as the two seats. The
488
+ * occupant renders the HOST's ActivityPill with the plugin's burst speed
489
+ * patched into its `sessionStats`; it is a list cell, so the claim is
490
+ * identified by id rather than by the slot's heads[0]. If the host shape is
491
+ * not one this bundle knows, no claim is created and the native pill stays.
492
+ */
493
+ let activityClaim: ModelSeatClaim | undefined
494
+ let activityDiag = 'idle'
495
+ const noteActivityDiag = (line: string): void => {
496
+ if (line === activityDiag) return
497
+ activityDiag = line
498
+ // Success and the ordinary older-host "no activity entry" are silent; a
499
+ // shape mismatch or a lost registration must leave a breadcrumb. The ACP
500
+ // view has no line for this surface, and a missing speed fix is otherwise
501
+ // invisible.
502
+ if (!/failed|missing|taken|gave up|still held/.test(line)) return
503
+ console.warn(`[dsh-ntr] ${line}`)
504
+ }
485
505
  /**
486
506
  * ACP-only auxiliary entries: the NTR view tab and the composer's context
487
507
  * ring. Unlike the two seats these are `list` slots with no visibility
@@ -567,6 +587,7 @@ export function apply(ctx: Context): void {
567
587
  releaseAuxEntries()
568
588
  if (claim !== undefined) { claim.release(); claim = undefined }
569
589
  if (permissionClaim !== undefined) { permissionClaim.release(); permissionClaim = undefined }
590
+ if (activityClaim !== undefined) { activityClaim.release(); activityClaim = undefined }
570
591
  }
571
592
  /**
572
593
  * Re-ask timer for the staged session, armed while the row is silent and a
@@ -706,6 +727,16 @@ export function apply(ctx: Context): void {
706
727
  onDiag: (line) => { permissionSeatDiag = `staged ${staged.sessionId} · ${line}` },
707
728
  })
708
729
  permissionClaim.sync()
730
+ // The dock's activity pill is a list CELL, not a whole slot: this claim
731
+ // shadows the shipped `activity` entry by id and renders it back with the
732
+ // plugin's burst speed. No host entry / inject / locale -> undefined and
733
+ // the native pill is untouched; the next evaluation retries.
734
+ activityClaim ??= createActivityPillClaim({
735
+ slots: ctx.slots as unknown as SeatSlots,
736
+ inject: (sessionId: unknown) => chatFace(sessionId),
737
+ onDiag: noteActivityDiag,
738
+ })
739
+ activityClaim?.sync()
709
740
  // The tab and the occupancy ring have the same lifetime as the claims:
710
741
  // they exist only while this staged conversation is an ACP one, and a
711
742
  // stand-down disposes them (a standard conversation must not see an NTR
@@ -826,7 +857,7 @@ export function apply(ctx: Context): void {
826
857
  // retakes the cell only when it is actually holding something that is not
827
858
  // ours — so this is evidence-driven, not a poll.
828
859
  const stopCell = ctx.on('slots/changed', (...args: unknown[]) => {
829
- if (args[0] !== MODEL_SEAT_KEY && args[0] !== PERMISSION_SEAT_KEY) return
860
+ if (args[0] !== MODEL_SEAT_KEY && args[0] !== PERMISSION_SEAT_KEY && args[0] !== DOCK_SLOT) return
830
861
  runGate()
831
862
  })
832
863
  // Late services: the runtime's session list normally exists before this
@@ -847,6 +878,7 @@ export function apply(ctx: Context): void {
847
878
  releaseAuxEntries()
848
879
  if (claim !== undefined) { claim.release(); claim = undefined }
849
880
  if (permissionClaim !== undefined) { permissionClaim.release(); permissionClaim = undefined }
881
+ if (activityClaim !== undefined) { activityClaim.release(); activityClaim = undefined }
850
882
  store.noteSeatDiag('gate disposed')
851
883
  store.notePermissionSeatDiag('gate disposed')
852
884
  }
@@ -128,7 +128,11 @@ export const MAX_SEAT_CLAIMS = 4
128
128
  /** One live entry in the cell, narrowed to what the claim reads. */
129
129
  export interface SeatEntry {
130
130
  component?: unknown
131
- options?: { priority?: number; registrant?: string }
131
+ options?: { priority?: number; registrant?: string; id?: string; order?: number }
132
+ /** Entry-owned inject face; needed when the claim reuses a shipped occupant. */
133
+ inject?: (...args: unknown[]) => unknown
134
+ /** Locale namespace the occupant's `t` is bound to; needed for the same reuse. */
135
+ locale?: string
132
136
  }
133
137
 
134
138
  /** The slot-registry face this module uses (structural: the registry is not a dependency of this bundle). */
@@ -148,6 +152,12 @@ export interface SeatSlots {
148
152
  */
149
153
  export interface SeatRegisterOptions {
150
154
  name: string
155
+ /** List-slot cell to shadow; omitted for single/keyed registers. */
156
+ id?: string
157
+ /** Display position within the list; only meaningful with `id`. */
158
+ order?: number
159
+ /** Locale namespace the occupant's `t` is bound to. */
160
+ locale?: string
151
161
  priority?: number
152
162
  /**
153
163
  * The session-scoped face factory the renderer calls with the scope binding's
@@ -227,6 +237,15 @@ export interface PermissionSeatClaimOptions {
227
237
  export interface SeatClaimOptions {
228
238
  slots: SeatSlots
229
239
  slotKey: string
240
+ /**
241
+ * List-slot cell id to hold. Omitted for single/keyed seats; a list slot
242
+ * needs it because its winner view is one entry PER id, not one per slot.
243
+ */
244
+ entryId?: string
245
+ /** Display order for the installed list entry (same cell, so usually the shipped order). */
246
+ order?: number
247
+ /** Locale namespace the installed occupant resolves `t` against. */
248
+ locale?: string
230
249
  basePriority: number
231
250
  occupant(): unknown
232
251
  inject?(sessionId: unknown, actions?: unknown): unknown
@@ -265,7 +284,7 @@ interface CellView {
265
284
  * `entriesOfSlot` and performing bounded retakes if displaced by later registrations.
266
285
  */
267
286
  export function createSeatClaim(options: SeatClaimOptions): ModelSeatClaim {
268
- const { slots, slotKey, basePriority, occupant, inject } = options
287
+ const { slots, slotKey, basePriority, occupant, inject, entryId } = options
269
288
  const label = options.label ?? slotKey
270
289
  const onDiag = options.onDiag
271
290
  const maxClaims = options.maxClaims ?? MAX_SEAT_CLAIMS
@@ -293,7 +312,10 @@ export function createSeatClaim(options: SeatClaimOptions): ModelSeatClaim {
293
312
  if (typeof slots.entriesOfSlot !== 'function') return { declared: isDeclared, verified: false }
294
313
  try {
295
314
  const heads = slots.entriesOfSlot(slotKey) ?? []
296
- return { declared: isDeclared, verified: true, ...heads[0] === undefined ? {} : { entry: heads[0] } }
315
+ // A list slot projects one winner per id, so the cell this claim owns is
316
+ // the head WITH that id — not heads[0], which may be a sibling cell.
317
+ const entry = entryId === undefined ? heads[0] : heads.find(head => head.options?.id === entryId)
318
+ return { declared: isDeclared, verified: true, ...entry === undefined ? {} : { entry } }
297
319
  } catch {
298
320
  return { declared: isDeclared, verified: false }
299
321
  }
@@ -312,6 +334,9 @@ export function createSeatClaim(options: SeatClaimOptions): ModelSeatClaim {
312
334
  try {
313
335
  installed.push(slots.inject(slotKey, () => slots.register({
314
336
  name: slotKey,
337
+ ...(entryId === undefined ? {} : { id: entryId }),
338
+ ...(options.order === undefined ? {} : { order: options.order }),
339
+ ...(options.locale === undefined ? {} : { locale: options.locale }),
315
340
  priority: rank,
316
341
  ...(inject ? { inject } : {}),
317
342
  }, component)))
@@ -268,6 +268,11 @@ export interface UiState {
268
268
  chatSessions: ChatSessionView[]
269
269
  /** The DSH chat session id {@link chatSessions} was loaded for. */
270
270
  chatSessionsFor?: string
271
+ /**
272
+ * Live burst aggregate per ACP session id, from `turn/metrics`. Absent means
273
+ * no measured burst yet; a row's persisted `burst` is the reload seed.
274
+ */
275
+ burstStats: Record<string, { outputTokens: number; burstMs: number; turnId: string; final: boolean }>
271
276
  /**
272
277
  * The agent tag's halo preference (settings-editable display choice). The
273
278
  * tag reads it per render; the host answer in `resync` is the authority and
@@ -687,6 +692,7 @@ export class AcpStore {
687
692
  probingModels: {},
688
693
  probeErrors: {},
689
694
  chatSessions: [],
695
+ burstStats: {},
690
696
  tagHalo: { ...DEFAULT_TAG_HALO },
691
697
  approvalDeadlines: undefined,
692
698
  idleRelease: undefined,
@@ -2234,11 +2240,20 @@ export class AcpStore {
2234
2240
  // entry would keep reading as a live session (the agent tag's halo
2235
2241
  // breathes on `!released`) until some future turn re-reads the list.
2236
2242
  const chatSessions = this.state.chatSessions.filter(s => s.sessionId !== event.sessionId)
2243
+ // The burst aggregate belongs to this ACP session only; another
2244
+ // session's entry must survive. Only patch when there was an entry.
2245
+ const hadBurst = this.state.burstStats[event.sessionId] !== undefined
2246
+ let burstStats = this.state.burstStats
2247
+ if (hadBurst) {
2248
+ burstStats = { ...burstStats }
2249
+ delete burstStats[event.sessionId]
2250
+ }
2237
2251
  this.mutate({
2238
2252
  sessions,
2239
2253
  activeSessionId: active,
2240
2254
  chatTranscripts: closedTranscripts,
2241
2255
  ...(chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {}),
2256
+ ...(hadBurst ? { burstStats } : {}),
2242
2257
  permissions: this.state.permissions.filter(p => p.sessionId !== event.sessionId),
2243
2258
  ...(event.reason === 'agent-died'
2244
2259
  ? this.logPatch('error', this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died'), closingSession?.agentId)
@@ -2425,6 +2440,28 @@ export class AcpStore {
2425
2440
  })
2426
2441
  return Promise.resolve()
2427
2442
  }
2443
+ case 'turn/metrics': {
2444
+ // Plugin-side burst aggregate for one ACP session. A late LIVE frame
2445
+ // must not regress a turn this page already saw finalized (frames can
2446
+ // land out of order across a stream reconnect); any other frame writes
2447
+ // the entry. Blocks / sessions stay untouched — this is a metric.
2448
+ const prior = this.state.burstStats[event.sessionId]
2449
+ if (event.final === false && prior !== undefined && prior.final === true && prior.turnId === event.turnId) {
2450
+ return Promise.resolve()
2451
+ }
2452
+ this.mutate({
2453
+ burstStats: {
2454
+ ...this.state.burstStats,
2455
+ [event.sessionId]: {
2456
+ outputTokens: event.outputTokens,
2457
+ burstMs: event.burstMs,
2458
+ turnId: event.turnId,
2459
+ final: event.final,
2460
+ },
2461
+ },
2462
+ })
2463
+ return Promise.resolve()
2464
+ }
2428
2465
  case 'turn/start': {
2429
2466
  this.openTurns.add(event.sessionId)
2430
2467
  this.markWatchedRunning(event.sessionId, true)