dsh-ntr 0.3.1 → 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.
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +2436 -82
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/activity-pill.js +156 -0
- package/lib/client/branch-action.js +149 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +279 -16
- package/lib/client/model-seat.js +8 -2
- package/lib/client/panel.js +96 -29
- package/lib/client/released-banner.js +66 -0
- package/lib/client/session-controls.js +38 -13
- package/lib/client/slash-isolation.js +2 -2
- package/lib/client/store.js +380 -13
- package/lib/client.js +1364 -70
- package/lib/client.js.map +1 -1
- package/lib/impl.js +1229 -8
- package/lib/index.js +34 -0
- package/lib/protocol.js +19 -0
- package/lib/tps.js +32 -0
- package/lib/types/bridge.d.ts +850 -18
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/activity-pill.d.ts +81 -0
- package/lib/types/client/branch-action.d.ts +47 -0
- package/lib/types/client/composer-mode.d.ts +28 -0
- package/lib/types/client/context-meter.d.ts +51 -0
- package/lib/types/client/model-seat.d.ts +21 -0
- package/lib/types/client/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +108 -1
- package/lib/types/impl.d.ts +94 -7
- package/lib/types/protocol.d.ts +145 -0
- package/lib/types/tps.d.ts +17 -0
- package/package.json +2 -1
- package/src/bridge.ts +2754 -86
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/activity-pill.tsx +201 -0
- package/src/client/branch-action.tsx +178 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +298 -15
- package/src/client/model-seat.tsx +28 -3
- package/src/client/panel.tsx +157 -40
- package/src/client/primitives.d.ts +27 -0
- package/src/client/released-banner.tsx +97 -0
- package/src/client/session-controls.tsx +34 -11
- package/src/client/slash-isolation.ts +2 -1
- package/src/client/store.ts +412 -14
- package/src/impl.ts +1251 -13
- package/src/index.ts +33 -0
- package/src/protocol.ts +145 -0
- package/src/tps.ts +40 -0
package/src/client/index.tsx
CHANGED
|
@@ -13,13 +13,21 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
|
14
14
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
15
15
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
16
|
+
// The chat package's client types carry the SlotMap augmentation for
|
|
17
|
+
// `conversation.chat.assistant-actions` (the branch entry's seat). Type-only:
|
|
18
|
+
// erased at build, and the seat is owned by the host's chat module at runtime.
|
|
19
|
+
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
|
16
20
|
import { AcpStore, type RpcCall } from './store.ts'
|
|
17
21
|
import { AcpSettingsSection } from './panel.tsx'
|
|
18
22
|
import { AcpComposerMode } from './composer-mode.tsx'
|
|
23
|
+
import { AcpContextMeter } from './context-meter.tsx'
|
|
19
24
|
import { AcpComposerAgentTag, AcpSessionControls, AcpSessionMenu } from './session-controls.tsx'
|
|
20
25
|
import { AcpConversationView } from './acp-view.tsx'
|
|
21
26
|
import { AcpSettingsDialog } from './settings-dialog.tsx'
|
|
22
27
|
import { PermissionDock } from './permission-dock.tsx'
|
|
28
|
+
import { ReleasedBanner } from './released-banner.tsx'
|
|
29
|
+
import { AcpBranchAction, resolveHostFork } from './branch-action.tsx'
|
|
30
|
+
import { createActivityPillClaim, DOCK_SLOT } from './activity-pill.tsx'
|
|
23
31
|
import {
|
|
24
32
|
createModelSeatClaim,
|
|
25
33
|
createPermissionSeatClaim,
|
|
@@ -28,9 +36,9 @@ import {
|
|
|
28
36
|
} from './model-seat.tsx'
|
|
29
37
|
import type { ModelSeatClaim, SeatSlots } from './model-seat.tsx'
|
|
30
38
|
import {
|
|
31
|
-
acpModeFromPresetEvent, rowAgentPreset, stagedIsAcpMode, stagedSessionOf,
|
|
39
|
+
acpModeFromPresetEvent, ACP_PRESET_ID, rowAgentPreset, stagedIsAcpMode, stagedSessionOf,
|
|
32
40
|
} from './acp-ui.tsx'
|
|
33
|
-
import type { AcpChatInjected, AcpSessionFace, AcpSessionsSnapshot, SessionListSource } from './acp-ui.tsx'
|
|
41
|
+
import type { AcpChatInjected, AcpHostForkFace, AcpSessionFace, AcpSessionsSnapshot, SessionListSource } from './acp-ui.tsx'
|
|
34
42
|
import { createSlashIsolation, slashAcpVerdict } from './slash-isolation.ts'
|
|
35
43
|
import type { SlashSource } from './slash-isolation.ts'
|
|
36
44
|
|
|
@@ -58,11 +66,78 @@ interface StructuralServices {
|
|
|
58
66
|
binding(sessionId: string): { session?: unknown } | undefined
|
|
59
67
|
/** The client runtime's session list store (see `AcpSessionsSnapshot`). */
|
|
60
68
|
list?: SessionListSource
|
|
69
|
+
/**
|
|
70
|
+
* The runtime's own branching primitive (`forkAt`'s engine): forks the DSH
|
|
71
|
+
* session tree at the latest completed turn and resolves the child id.
|
|
72
|
+
* Read structurally and used under try/catch — an absent `fork` is the
|
|
73
|
+
* branch button's `hostUnavailable` state, never a load failure.
|
|
74
|
+
*/
|
|
75
|
+
fork?: (options: { sessionId: string; increaseTitle?: boolean }) => Promise<unknown>
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The renderer's Session scope adapter (`uiSession.adapter`). Its `current`
|
|
79
|
+
* observable is the host's own selection — the session the composer is
|
|
80
|
+
* actually staged on — while the list can retain more than one row in a
|
|
81
|
+
* multi-view layout. Read structurally and used under try/catch: a host
|
|
82
|
+
* without it falls back to the list's main-view scan.
|
|
83
|
+
*/
|
|
84
|
+
uiSession: {
|
|
85
|
+
adapter?: {
|
|
86
|
+
current?: {
|
|
87
|
+
getSnapshot(): { key?: unknown } | undefined
|
|
88
|
+
subscribe?(listener: () => void): () => void
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The shell workspace service (`ctx.get("uiWorkspace")`): `openSession`
|
|
94
|
+
* focuses a session in the main view — the branch click's final step.
|
|
95
|
+
*/
|
|
96
|
+
uiWorkspace: {
|
|
97
|
+
openSession?(sessionId: string): void
|
|
61
98
|
}
|
|
62
99
|
}
|
|
63
100
|
|
|
64
101
|
const browserPrefersZh = typeof navigator !== 'undefined' && /^zh\b/i.test(navigator.language)
|
|
65
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The NTR scope marker (`html[data-ntr-acp]`): the ONLY hook the message-action
|
|
105
|
+
* hiding CSS keys on, so the host's like / dislike / branch icons disappear in
|
|
106
|
+
* exactly the conversations this gate has confirmed to run the ACP preset —
|
|
107
|
+
* and reappear the moment the evidence stops answering (every `standDown`
|
|
108
|
+
* clears it; non-ACP sessions never see it). Idempotent per call.
|
|
109
|
+
*/
|
|
110
|
+
const setScopeMarker = (active: boolean): void => {
|
|
111
|
+
if (typeof document === 'undefined') return
|
|
112
|
+
document.documentElement.toggleAttribute('data-ntr-acp', active)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One-shot sanity probe for the aria-label hiding: the host's action buttons
|
|
117
|
+
* carry LOCALIZED labels and no data-* hook, so a copy change (or a third
|
|
118
|
+
* language) silently un-hides them while everything else keeps working. Once
|
|
119
|
+
* the marker is on and a turn tail is actually on screen, check that at least
|
|
120
|
+
* one label the rules key on still exists and leave one console line for
|
|
121
|
+
* whoever debugs "the icons came back" — dev-visible, never user UI.
|
|
122
|
+
*/
|
|
123
|
+
let probedHostLabels = false
|
|
124
|
+
const probeHostActionLabels = (): void => {
|
|
125
|
+
if (probedHostLabels || typeof document === 'undefined') return
|
|
126
|
+
// No turn tail rendered yet: the check would answer from nothing, so leave
|
|
127
|
+
// the probe armed for the next activation instead of burning it once.
|
|
128
|
+
if (document.querySelector('[data-turn-tail]') === null) return
|
|
129
|
+
probedHostLabels = true
|
|
130
|
+
const found = document.querySelector([
|
|
131
|
+
'button:not([data-ntr-branch])[aria-label="在新对话中分支"]',
|
|
132
|
+
'button:not([data-ntr-branch])[aria-label="Branch into a new conversation"]',
|
|
133
|
+
'button[aria-label="好的回答"]',
|
|
134
|
+
'button[aria-label="Good response"]',
|
|
135
|
+
].join(', '))
|
|
136
|
+
if (found === null) {
|
|
137
|
+
console.warn('[dsh-ntr] none of the host message-action labels matched; the like/dislike/branch icons may not be hidden in NTR conversations')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
66
141
|
/**
|
|
67
142
|
* Structural view of the shell's locale service (`@deepseek-ai/dsh-client-locale`).
|
|
68
143
|
*
|
|
@@ -160,11 +235,36 @@ export function apply(ctx: Context): void {
|
|
|
160
235
|
}
|
|
161
236
|
}
|
|
162
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The runtime's `sessions.fork`, resolved per call: `undefined` renders the
|
|
240
|
+
* branch button disabled (`hostUnavailable`) instead of hiding it, so a
|
|
241
|
+
* host that gains the primitive later is picked up on the next render.
|
|
242
|
+
*/
|
|
243
|
+
const hostFork = (): AcpHostForkFace | undefined => {
|
|
244
|
+
try {
|
|
245
|
+
return resolveHostFork(service<StructuralServices['sessions']>('sessions'))
|
|
246
|
+
} catch {
|
|
247
|
+
return undefined
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Focus a session in the workspace (the branch click's last step). */
|
|
252
|
+
const openSession = (sessionId: string): void => {
|
|
253
|
+
try {
|
|
254
|
+
service<StructuralServices['uiWorkspace']>('uiWorkspace')?.openSession?.(sessionId)
|
|
255
|
+
} catch {
|
|
256
|
+
// Opening is presentation; a host without the workspace service leaves
|
|
257
|
+
// the branched session in the sidebar list, reachable by hand.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
163
261
|
/** The injected face every ACP conversation entry receives. */
|
|
164
262
|
const chatFace = (sessionId: unknown): AcpChatInjected & { sessionId: string } => ({
|
|
165
263
|
acp: store,
|
|
166
264
|
sessionId: String(sessionId),
|
|
167
265
|
faceFor,
|
|
266
|
+
hostFork,
|
|
267
|
+
openSession,
|
|
168
268
|
})
|
|
169
269
|
|
|
170
270
|
ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
@@ -207,6 +307,13 @@ export function apply(ctx: Context): void {
|
|
|
207
307
|
// (see the note there). Runtime passes the real shares either way.
|
|
208
308
|
}, AcpComposerMode as unknown as SlotComponent<any>)), 'dsh-ntr: composer agent-mode control')
|
|
209
309
|
|
|
310
|
+
// The agent's OWN context occupancy in the composer's bottom info row
|
|
311
|
+
// (`conversation.composer.dock` — the dock below the input card where the
|
|
312
|
+
// host renders its native ContextMeter) is installed by the ACP-mode gate
|
|
313
|
+
// below, with the same lifetime as the NTR view tab and the seats. It is
|
|
314
|
+
// NOT registered unconditionally: in an ordinary conversation this row must
|
|
315
|
+
// hold the host's native meter and nothing of ours (see `holdAuxEntries`).
|
|
316
|
+
|
|
210
317
|
// Its picker rides the composer's overlay seat — the platform's home for
|
|
211
318
|
// menus, so the popup floats above the card instead of being clipped by it.
|
|
212
319
|
ctx.effect(() => ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
|
|
@@ -248,6 +355,34 @@ export function apply(ctx: Context): void {
|
|
|
248
355
|
inject: (sessionId: unknown) => chatFace(sessionId),
|
|
249
356
|
}, PermissionDock as unknown as SlotComponent<any>)), 'dsh-ntr: conversation permission dock')
|
|
250
357
|
|
|
358
|
+
// The released-session banner in the same dock seat: when this
|
|
359
|
+
// conversation's agent process is parked (idle release or crash), one row
|
|
360
|
+
// says so and offers the explicit resume — the gesture form of the
|
|
361
|
+
// "next message resumes it" promise. Gated inside the component on the
|
|
362
|
+
// chat-list rows (released banner renders iff nothing live is left).
|
|
363
|
+
ctx.effect(() => ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
364
|
+
name: 'conversation.input.dock',
|
|
365
|
+
id: 'acp-released-banner',
|
|
366
|
+
order: 20,
|
|
367
|
+
inject: (sessionId: unknown) => chatFace(sessionId),
|
|
368
|
+
}, ReleasedBanner as unknown as SlotComponent<any>)), 'dsh-ntr: released-session banner')
|
|
369
|
+
|
|
370
|
+
// The NTR branch entry riding the host's own assistant-actions row
|
|
371
|
+
// (`conversation.chat.assistant-actions` — list, session scope; the
|
|
372
|
+
// positional inject argument is the chat session id, like every other
|
|
373
|
+
// session slot). Registered for EVERY conversation; the component gates
|
|
374
|
+
// itself on the confirmed ACP verdict (non-ACP renders nothing), and the
|
|
375
|
+
// `data-ntr-branch` attribute it stamps is the CSS pass that keeps the
|
|
376
|
+
// hiding rules from swallowing the plugin's own button (same aria-label as
|
|
377
|
+
// the host's). Order 20 puts it after the (hidden) feedback entry's 10 —
|
|
378
|
+
// visually the spot the host's own branch button occupied.
|
|
379
|
+
ctx.effect(() => ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({
|
|
380
|
+
name: 'conversation.chat.assistant-actions',
|
|
381
|
+
id: 'ntr-branch',
|
|
382
|
+
order: 20,
|
|
383
|
+
inject: (sessionId: unknown) => chatFace(sessionId),
|
|
384
|
+
}, AcpBranchAction as unknown as SlotComponent<any>)), 'dsh-ntr: message branch action')
|
|
385
|
+
|
|
251
386
|
/**
|
|
252
387
|
* The client runtime's session list store, read structurally. Used to find
|
|
253
388
|
* the session the composer is staged on and whether it runs the ACP preset —
|
|
@@ -259,6 +394,30 @@ export function apply(ctx: Context): void {
|
|
|
259
394
|
return list
|
|
260
395
|
}
|
|
261
396
|
|
|
397
|
+
/**
|
|
398
|
+
* The renderer's current Session scope observable, when this host exposes it.
|
|
399
|
+
* This is the source the host's own main-view picker prefers; the list's
|
|
400
|
+
* retained rows are only the fallback for hosts without the adapter.
|
|
401
|
+
*/
|
|
402
|
+
const currentScopeSource = (): NonNullable<StructuralServices['uiSession']['adapter']>['current'] => {
|
|
403
|
+
try {
|
|
404
|
+
const current = service<StructuralServices['uiSession']>('uiSession')?.adapter?.current
|
|
405
|
+
return current !== undefined && typeof current.getSnapshot === 'function' ? current : undefined
|
|
406
|
+
} catch {
|
|
407
|
+
return undefined
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The session the composer is actually staged on, if the runtime can say. */
|
|
412
|
+
const currentScopeSessionId = (): string | undefined => {
|
|
413
|
+
try {
|
|
414
|
+
const key = currentScopeSource()?.getSnapshot?.()?.key
|
|
415
|
+
return typeof key === 'string' && key !== '' ? key : undefined
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
262
421
|
/**
|
|
263
422
|
* Chat session ids the ACP-mode tracker has already asked the host about (the
|
|
264
423
|
* `session/chat-list` fallback), mapped to WHEN the last ask went out. The
|
|
@@ -286,6 +445,8 @@ export function apply(ctx: Context): void {
|
|
|
286
445
|
|
|
287
446
|
ctx.effect(() => {
|
|
288
447
|
let stopWatch: (() => void) | undefined
|
|
448
|
+
/** Subscription to the renderer's current Session binding (see `currentScopeSource`). */
|
|
449
|
+
let stopCurrent: (() => void) | undefined
|
|
289
450
|
/**
|
|
290
451
|
* The claim on the host's model seat, held while the staged conversation
|
|
291
452
|
* runs the ACP preset. That cell is where the host's own base sits (the
|
|
@@ -321,6 +482,72 @@ export function apply(ctx: Context): void {
|
|
|
321
482
|
* available yet.
|
|
322
483
|
*/
|
|
323
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
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* ACP-only auxiliary entries: the NTR view tab and the composer's context
|
|
507
|
+
* ring. Unlike the two seats these are `list` slots with no visibility
|
|
508
|
+
* predicate of their own, so "self-hide in the component" cannot remove a
|
|
509
|
+
* tab from the header or free the native bottom row. They are installed by
|
|
510
|
+
* this gate for exactly the same lifetime as the claims and disposed on
|
|
511
|
+
* every stand-down — a standard conversation then has no NTR tab at all,
|
|
512
|
+
* and the host's native context meter sits alone in its dock row.
|
|
513
|
+
*/
|
|
514
|
+
let viewEntry: (() => void) | undefined
|
|
515
|
+
let meterEntry: (() => void) | undefined
|
|
516
|
+
const releaseAuxEntries = (): void => {
|
|
517
|
+
try { viewEntry?.() } catch {}
|
|
518
|
+
viewEntry = undefined
|
|
519
|
+
try { meterEntry?.() } catch {}
|
|
520
|
+
meterEntry = undefined
|
|
521
|
+
}
|
|
522
|
+
const holdAuxEntries = (): void => {
|
|
523
|
+
// A refused registration leaves the native surface in place (fail-open);
|
|
524
|
+
// the next evaluation retries when the slot is declared.
|
|
525
|
+
if (viewEntry === undefined) {
|
|
526
|
+
try {
|
|
527
|
+
viewEntry = ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
528
|
+
name: 'conversation.view',
|
|
529
|
+
id: 'acp',
|
|
530
|
+
order: 20,
|
|
531
|
+
label: () => label('NTR', 'NTR'),
|
|
532
|
+
inject: (sessionId: unknown) => chatFace(sessionId),
|
|
533
|
+
}, AcpConversationView as unknown as SlotComponent<any>))
|
|
534
|
+
} catch {
|
|
535
|
+
viewEntry = undefined
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (meterEntry === undefined) {
|
|
539
|
+
try {
|
|
540
|
+
meterEntry = ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
541
|
+
name: 'conversation.composer.dock',
|
|
542
|
+
id: 'acp-context-meter',
|
|
543
|
+
order: 10,
|
|
544
|
+
inject: (sessionId: unknown) => chatFace(sessionId),
|
|
545
|
+
}, AcpContextMeter as unknown as SlotComponent<any>))
|
|
546
|
+
} catch {
|
|
547
|
+
meterEntry = undefined
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
324
551
|
/**
|
|
325
552
|
* Last diagnostic line from {@link syncAcpMode}. Every way this gate can
|
|
326
553
|
* end with the native seat still on screen used to be silent — an inner
|
|
@@ -333,7 +560,8 @@ export function apply(ctx: Context): void {
|
|
|
333
560
|
let seatDiag = 'idle'
|
|
334
561
|
let permissionSeatDiag = 'idle'
|
|
335
562
|
/**
|
|
336
|
-
* Hand
|
|
563
|
+
* Hand every ACP-only surface back: both composer cells, the NTR view tab
|
|
564
|
+
* and the context ring.
|
|
337
565
|
*
|
|
338
566
|
* Every path that is not "this staged conversation is CONFIRMED to run the
|
|
339
567
|
* ACP preset" ends here — including the paths that carry no verdict at all
|
|
@@ -355,8 +583,11 @@ export function apply(ctx: Context): void {
|
|
|
355
583
|
// read identically in the one place either is observable.
|
|
356
584
|
permissionSeatDiag = `${reason} (released)`
|
|
357
585
|
disarmVerdictReask()
|
|
586
|
+
setScopeMarker(false)
|
|
587
|
+
releaseAuxEntries()
|
|
358
588
|
if (claim !== undefined) { claim.release(); claim = undefined }
|
|
359
589
|
if (permissionClaim !== undefined) { permissionClaim.release(); permissionClaim = undefined }
|
|
590
|
+
if (activityClaim !== undefined) { activityClaim.release(); activityClaim = undefined }
|
|
360
591
|
}
|
|
361
592
|
/**
|
|
362
593
|
* Re-ask timer for the staged session, armed while the row is silent and a
|
|
@@ -388,7 +619,13 @@ export function apply(ctx: Context): void {
|
|
|
388
619
|
standDown('no session list service')
|
|
389
620
|
return
|
|
390
621
|
}
|
|
391
|
-
|
|
622
|
+
// Prefer the renderer's own current selection; the list's retained rows
|
|
623
|
+
// are the fallback for hosts without the scope adapter. A multi-view
|
|
624
|
+
// layout can leave several rows retained, and judging the first one (or
|
|
625
|
+
// the one the previous switch left behind) would hold the ACP chrome
|
|
626
|
+
// while an ordinary conversation is on screen — the reported drift.
|
|
627
|
+
const current = currentScopeSessionId()
|
|
628
|
+
const staged = stagedSessionOf(snapshot, current)
|
|
392
629
|
if (staged === undefined) {
|
|
393
630
|
standDown('no staged session (nothing retains the main view)')
|
|
394
631
|
return
|
|
@@ -444,13 +681,23 @@ export function apply(ctx: Context): void {
|
|
|
444
681
|
// nothing. Both cells follow the same evidence — the staged
|
|
445
682
|
// conversation's preset — through one {@link standDown}, so neither can
|
|
446
683
|
// stay claimed after the evidence for it stops answering.
|
|
447
|
-
if (!stagedIsAcpMode(snapshot, acpModeVerdict)) {
|
|
684
|
+
if (!stagedIsAcpMode(snapshot, acpModeVerdict, current)) {
|
|
685
|
+
// The row's own verdict is authoritative even when it says no: fold it
|
|
686
|
+
// into the store so every per-conversation surface (the branch
|
|
687
|
+
// button's ACP gate included) stops rendering without waiting for a
|
|
688
|
+
// host read. A silent row folds nothing — that case is the ask path's.
|
|
689
|
+
if (rowAgentPreset(staged.row) !== undefined) store.noteAcpMode(staged.sessionId, false)
|
|
448
690
|
standDown(`staged ${staged.sessionId} is not in ACP mode`)
|
|
449
691
|
return
|
|
450
692
|
}
|
|
451
693
|
// The row speaks, so the host verdict is not this gate's only fact
|
|
452
|
-
// source and no re-ask timer is needed.
|
|
453
|
-
|
|
694
|
+
// source and no re-ask timer is needed. The same fold in the affirmative
|
|
695
|
+
// direction: the runtime row IS the preset truth, and the branch
|
|
696
|
+
// button's gate reads the folded map, not the snapshot.
|
|
697
|
+
if (rowAgentPreset(staged.row) !== undefined) {
|
|
698
|
+
store.noteAcpMode(staged.sessionId, rowAgentPreset(staged.row) === ACP_PRESET_ID)
|
|
699
|
+
disarmVerdictReask()
|
|
700
|
+
}
|
|
454
701
|
// Stays re-runnable on purpose: the seat is not declared before the
|
|
455
702
|
// composer bar mounts, a registration can be refused, and the cell can be
|
|
456
703
|
// taken from us later. The claim reports each of those through the
|
|
@@ -458,6 +705,8 @@ export function apply(ctx: Context): void {
|
|
|
458
705
|
// next evaluation (store mutation, list notification, preset frame, or a
|
|
459
706
|
// change to the cell itself) simply tries again.
|
|
460
707
|
seatDiag = `staged ${staged.sessionId} is ACP mode`
|
|
708
|
+
setScopeMarker(true)
|
|
709
|
+
probeHostActionLabels()
|
|
461
710
|
claim ??= createModelSeatClaim({
|
|
462
711
|
slots: ctx.slots as unknown as SeatSlots,
|
|
463
712
|
occupant: () => AcpSessionControls,
|
|
@@ -478,6 +727,21 @@ export function apply(ctx: Context): void {
|
|
|
478
727
|
onDiag: (line) => { permissionSeatDiag = `staged ${staged.sessionId} · ${line}` },
|
|
479
728
|
})
|
|
480
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()
|
|
740
|
+
// The tab and the occupancy ring have the same lifetime as the claims:
|
|
741
|
+
// they exist only while this staged conversation is an ACP one, and a
|
|
742
|
+
// stand-down disposes them (a standard conversation must not see an NTR
|
|
743
|
+
// tab, and the host's native bottom row must stand alone there).
|
|
744
|
+
holdAuxEntries()
|
|
481
745
|
// Confirmed ACP on a silent row: the verdict is all the evidence there
|
|
482
746
|
// is, so it gets a re-ask deadline (see {@link ACP_MODE_VERDICT_TTL_MS}).
|
|
483
747
|
if (rowAgentPreset(staged.row) === undefined) armVerdictReask()
|
|
@@ -531,6 +795,18 @@ export function apply(ctx: Context): void {
|
|
|
531
795
|
const attach = (): void => {
|
|
532
796
|
const list = sessionsList()
|
|
533
797
|
if (list !== undefined && stopWatch === undefined) stopWatch = list.subscribe(runGate)
|
|
798
|
+
// The selection can move without the list changing (a tab or sidebar
|
|
799
|
+
// click), and `uiSession.current` is the host's own answer to "which
|
|
800
|
+
// conversation is on screen". Re-run the gate on that transition so the
|
|
801
|
+
// ACP chrome leaves before the newly staged ordinary conversation paints.
|
|
802
|
+
const current = currentScopeSource()
|
|
803
|
+
if (current?.subscribe !== undefined && stopCurrent === undefined) {
|
|
804
|
+
try {
|
|
805
|
+
stopCurrent = current.subscribe(runGate)
|
|
806
|
+
} catch {
|
|
807
|
+
stopCurrent = undefined
|
|
808
|
+
}
|
|
809
|
+
}
|
|
534
810
|
attachPresetFrames()
|
|
535
811
|
runGate()
|
|
536
812
|
}
|
|
@@ -581,7 +857,7 @@ export function apply(ctx: Context): void {
|
|
|
581
857
|
// retakes the cell only when it is actually holding something that is not
|
|
582
858
|
// ours — so this is evidence-driven, not a poll.
|
|
583
859
|
const stopCell = ctx.on('slots/changed', (...args: unknown[]) => {
|
|
584
|
-
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
|
|
585
861
|
runGate()
|
|
586
862
|
})
|
|
587
863
|
// Late services: the runtime's session list normally exists before this
|
|
@@ -594,9 +870,15 @@ export function apply(ctx: Context): void {
|
|
|
594
870
|
stopCell()
|
|
595
871
|
stopPreset?.()
|
|
596
872
|
stopWatch?.()
|
|
873
|
+
stopCurrent?.()
|
|
597
874
|
disarmVerdictReask()
|
|
875
|
+
// The scope marker dies with the gate: a reload (HMR) must not leave the
|
|
876
|
+
// hiding CSS pointed at conversations nothing confirms anymore.
|
|
877
|
+
setScopeMarker(false)
|
|
878
|
+
releaseAuxEntries()
|
|
598
879
|
if (claim !== undefined) { claim.release(); claim = undefined }
|
|
599
880
|
if (permissionClaim !== undefined) { permissionClaim.release(); permissionClaim = undefined }
|
|
881
|
+
if (activityClaim !== undefined) { activityClaim.release(); activityClaim = undefined }
|
|
600
882
|
store.noteSeatDiag('gate disposed')
|
|
601
883
|
store.notePermissionSeatDiag('gate disposed')
|
|
602
884
|
}
|
|
@@ -636,12 +918,14 @@ export function apply(ctx: Context): void {
|
|
|
636
918
|
sessionsList()?.getSnapshot(),
|
|
637
919
|
sessionId,
|
|
638
920
|
id => store.getSnapshot().acpMode[id],
|
|
921
|
+
currentScopeSessionId(),
|
|
639
922
|
),
|
|
640
923
|
ensureVerdict: async (sessionId) => {
|
|
641
924
|
const held = (): boolean | undefined => slashAcpVerdict(
|
|
642
925
|
sessionsList()?.getSnapshot(),
|
|
643
926
|
sessionId,
|
|
644
927
|
id => store.getSnapshot().acpMode[id],
|
|
928
|
+
currentScopeSessionId(),
|
|
645
929
|
)
|
|
646
930
|
const known = held()
|
|
647
931
|
if (known !== undefined) return known
|
|
@@ -724,11 +1008,10 @@ export function apply(ctx: Context): void {
|
|
|
724
1008
|
// diffs). Session-scope list entry; the view ring renders it one tab at a
|
|
725
1009
|
// time. Meanwhile the agent's prose and thinking ride the NATIVE chat plane
|
|
726
1010
|
// through the LLM adapter — see docs/acp-convergence.md §2.1.
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
}, AcpConversationView as unknown as SlotComponent<any>)), 'dsh-ntr: ACP view tab')
|
|
1011
|
+
//
|
|
1012
|
+
// It is installed by the ACP-mode gate above (`holdAuxEntries`) and NOT at
|
|
1013
|
+
// plugin load: the host projects every registered `conversation.view` entry
|
|
1014
|
+
// into EVERY session's tab strip, so an unconditional registration showed
|
|
1015
|
+
// the NTR tab on ordinary conversations too. The gate's lifetime is the
|
|
1016
|
+
// only per-session seam the host offers.
|
|
734
1017
|
}
|
|
@@ -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
|
-
|
|
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)))
|