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.
Files changed (57) hide show
  1. package/README.en.md +3 -1
  2. package/README.md +3 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +2436 -82
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/activity-pill.js +156 -0
  8. package/lib/client/branch-action.js +149 -0
  9. package/lib/client/composer-mode.js +48 -13
  10. package/lib/client/context-meter.js +86 -0
  11. package/lib/client/index.js +279 -16
  12. package/lib/client/model-seat.js +8 -2
  13. package/lib/client/panel.js +96 -29
  14. package/lib/client/released-banner.js +66 -0
  15. package/lib/client/session-controls.js +38 -13
  16. package/lib/client/slash-isolation.js +2 -2
  17. package/lib/client/store.js +380 -13
  18. package/lib/client.js +1364 -70
  19. package/lib/client.js.map +1 -1
  20. package/lib/impl.js +1229 -8
  21. package/lib/index.js +34 -0
  22. package/lib/protocol.js +19 -0
  23. package/lib/tps.js +32 -0
  24. package/lib/types/bridge.d.ts +850 -18
  25. package/lib/types/client/acp-ui.d.ts +34 -2
  26. package/lib/types/client/acp-view.d.ts +13 -2
  27. package/lib/types/client/activity-pill.d.ts +81 -0
  28. package/lib/types/client/branch-action.d.ts +47 -0
  29. package/lib/types/client/composer-mode.d.ts +28 -0
  30. package/lib/types/client/context-meter.d.ts +51 -0
  31. package/lib/types/client/model-seat.d.ts +21 -0
  32. package/lib/types/client/released-banner.d.ts +34 -0
  33. package/lib/types/client/slash-isolation.d.ts +1 -1
  34. package/lib/types/client/store.d.ts +108 -1
  35. package/lib/types/impl.d.ts +94 -7
  36. package/lib/types/protocol.d.ts +145 -0
  37. package/lib/types/tps.d.ts +17 -0
  38. package/package.json +2 -1
  39. package/src/bridge.ts +2754 -86
  40. package/src/client/acp-ui.tsx +123 -2
  41. package/src/client/acp-view.tsx +30 -5
  42. package/src/client/activity-pill.tsx +201 -0
  43. package/src/client/branch-action.tsx +178 -0
  44. package/src/client/composer-mode.tsx +59 -13
  45. package/src/client/context-meter.tsx +128 -0
  46. package/src/client/index.tsx +298 -15
  47. package/src/client/model-seat.tsx +28 -3
  48. package/src/client/panel.tsx +157 -40
  49. package/src/client/primitives.d.ts +27 -0
  50. package/src/client/released-banner.tsx +97 -0
  51. package/src/client/session-controls.tsx +34 -11
  52. package/src/client/slash-isolation.ts +2 -1
  53. package/src/client/store.ts +412 -14
  54. package/src/impl.ts +1251 -13
  55. package/src/index.ts +33 -0
  56. package/src/protocol.ts +145 -0
  57. package/src/tps.ts +40 -0
@@ -128,6 +128,13 @@ export function rowAgentPreset(row) {
128
128
  /**
129
129
  * Which session the composer is currently staged on.
130
130
  *
131
+ * The renderer's own current scope binding is the first answer when the caller
132
+ * passes it (`currentSessionId`, read from `uiSession.adapter.current` in the
133
+ * browser entry): that is the exact session the host has selected, while the
134
+ * list can retain more than one row in a multi-view layout. Falling back to
135
+ * the list is still required — a host without that scope adapter, and the old
136
+ * snapshot shape below, have no better source.
137
+ *
131
138
  * Old runtimes answer from `snapshot.current` (with `currentAddress` as the
132
139
  * subagent marker). rc.2 has no `current`: the staged session is the row the
133
140
  * main view retains (`retainedBy.mainView > 0`) — the exact rule
@@ -135,8 +142,13 @@ export function rowAgentPreset(row) {
135
142
  * row with `origin === "subagent"` is an addressed subagent route. `undefined`
136
143
  * when nothing is staged yet (the list has not loaded, or no row holds the main
137
144
  * view): callers must stand down rather than guess.
145
+ *
146
+ * A current id the list has no row for is ALSO `undefined`: the selection may
147
+ * be a subagent address, and guessing "not a subagent" there would let the
148
+ * gate install ACP chrome on a route the plugin must not drive. The next list
149
+ * notification retries.
138
150
  */
139
- export function stagedSessionOf(snapshot) {
151
+ export function stagedSessionOf(snapshot, currentSessionId) {
140
152
  if (snapshot === undefined)
141
153
  return undefined;
142
154
  if (snapshot.current !== undefined) {
@@ -147,6 +159,12 @@ export function stagedSessionOf(snapshot) {
147
159
  };
148
160
  }
149
161
  const byId = snapshot.byId ?? {};
162
+ if (currentSessionId !== undefined) {
163
+ const row = byId[currentSessionId];
164
+ if (row === undefined)
165
+ return undefined;
166
+ return { sessionId: currentSessionId, row, subagent: row.origin === 'subagent' };
167
+ }
150
168
  // Candidate set is `byId`, NOT `ids`. In rc.2 the controller projects
151
169
  // `ids` from the host list only, then appends two fallback families to
152
170
  // `byId` without an `ids` entry: subagent-catalog children of a projected
@@ -171,8 +189,8 @@ export function stagedSessionOf(snapshot) {
171
189
  return { sessionId, row, subagent: row?.origin === 'subagent' };
172
190
  }
173
191
  /** Whether the staged session runs the ACP agent preset. */
174
- export function stagedIsAcpMode(snapshot, acpMode) {
175
- const staged = stagedSessionOf(snapshot);
192
+ export function stagedIsAcpMode(snapshot, acpMode, currentSessionId) {
193
+ const staged = stagedSessionOf(snapshot, currentSessionId);
176
194
  if (staged === undefined || staged.subagent)
177
195
  return false;
178
196
  const listed = rowAgentPreset(staged.row);
@@ -423,6 +441,15 @@ const STYLE = `
423
441
  .ntr-acp-chip.is-static:hover { background: var(--dsw-alias-bg-layer-2, #f3f4f6); }
424
442
  .ntr-acp-chip.is-active { border-color: var(--dsw-alias-state-business-primary, #2563eb); color: var(--dsw-alias-state-business-primary, #2563eb); background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #2563eb) 10%, transparent); }
425
443
  .ntr-acp-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
444
+ /* Composer context-occupancy ring (conversation.composer.dock,
445
+ context-meter.tsx): the agent's own prompt-side used/size in the host
446
+ ContextMeter's ring geometry, pill and tokens, so it reads as that meter's
447
+ sibling in the bottom info row. Gated inside the component; absent until the
448
+ agent reports a sample. */
449
+ .ntr-acp-meter { display: inline-flex; align-items: center; gap: 6px; padding: 1px 8px; border-radius: 24px; color: var(--dsw-alias-label-tertiary, #9ca3af); font-family: inherit; font-size: var(--dsh-content-font-size-secondary, 13px); font-variant-numeric: tabular-nums; line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); white-space: nowrap; flex: none; }
450
+ .ntr-acp-meter-track { fill: none; stroke: var(--dsw-alias-border-l3, #e5e7eb); stroke-width: 2px; }
451
+ .ntr-acp-meter-fill { fill: none; stroke: var(--dsw-alias-label-tertiary, #9ca3af); stroke-width: 2px; stroke-linecap: round; }
452
+ .ntr-acp-meter-percent { color: inherit; }
426
453
  /* The lock hand-off (src/client/agent-lock-anim.ts): the outgoing agent base
427
454
  breaks into debris for 460ms, then the edge tag condenses from the same
428
455
  particles. Both halves read one shared clock in that module; these durations
@@ -598,6 +625,79 @@ const STYLE = `
598
625
  .ntr-permission-option:focus-visible, .ntr-permission-cancel:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #2563eb); outline-offset: 2px; }
599
626
  .ntr-permission-cancel { margin-left: auto; color: var(--dsw-alias-label-secondary, #6b7280); }
600
627
  .ntr-permission-error { padding: 4px 8px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #b91c1c) 12%, transparent); color: var(--dsw-alias-state-error-primary, #b91c1c); font-size: 11px; word-break: break-word; }
628
+ /* ---- Released-session banner (conversation.input.dock, released-banner.tsx) ---- */
629
+ /* Same composer-width formula as the permission dock above: the slot spans
630
+ the column, the card lines up with the input box. One row of state + one
631
+ action; the error pins under it, beside the button that failed. */
632
+ .ntr-released-banner {
633
+ box-sizing: border-box;
634
+ width: calc(100% - var(--dsh-composer-side-clearance, 16px) - var(--dsh-composer-side-clearance, 16px));
635
+ max-width: var(--dsh-composer-card-max-width, 920px);
636
+ margin: 0 auto;
637
+ display: flex; flex-direction: column; gap: 6px;
638
+ padding: 8px 12px;
639
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
640
+ border-left: 3px solid var(--dsw-alias-state-warn-primary, #d97706);
641
+ border-radius: 10px;
642
+ background: var(--dsw-alias-bg-layer-1, #fff);
643
+ font-size: 12px; line-height: 1.5;
644
+ }
645
+ .ntr-released-banner-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
646
+ .ntr-released-banner-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
647
+ .ntr-released-banner-title { font-weight: 600; }
648
+ .ntr-released-banner-body { color: var(--dsw-alias-label-secondary, #6b7280); }
649
+ .ntr-released-banner .ntr-acp-more { flex: none; white-space: nowrap; }
650
+ /* ---- NTR message actions (conversation.chat.assistant-actions) ---- */
651
+ /* In an NTR conversation the host's like / dislike / branch icons are the
652
+ plugin's business (the agent decides its own feedback surface, and the
653
+ host's branch would fork a conversation the agent does not remember). The
654
+ three icons hide under the html[data-ntr-acp] scope marker, by their
655
+ LOCALIZED aria-labels — the only stable handles the host leaves (its button
656
+ classes are CSS-module hashes and there is no data-* hook). Hiding the
657
+ button is the WHOLE job: the host's Tooltip clones its child without a
658
+ wrapper element, so there is no container left behind to plug the row gap
659
+ — and any :has(> button…) rule would match the actions row itself (and the
660
+ list slot's shared display:contents anchor), hiding the entire row. The
661
+ :not([data-ntr-branch]) exemption is the pass our OWN button carries —
662
+ same label, same row. Known fragility: a host copy change or a third
663
+ language un-hides the icons; the entry probes the labels once per page and
664
+ warns in the console. */
665
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="在新对话中分支"]:not([data-ntr-branch]),
666
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Branch into a new conversation"]:not([data-ntr-branch]),
667
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="好的回答"],
668
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="有问题的回答"],
669
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="取消标记"],
670
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Good response"],
671
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Bad response"],
672
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
673
+ display: none !important;
674
+ }
675
+ /* The plugin's own branch button, dressed exactly like the host's .action
676
+ class (the values are copied from the host's compiled CSS, token colors
677
+ only). It rides the same actions row, so the host's row gap, font-delta
678
+ sizing and the [data-actions-reveal] hover rules already govern it. */
679
+ .ntr-branch-action {
680
+ width: calc(28px + var(--dsh-content-font-delta, 0px));
681
+ height: calc(28px + var(--dsh-content-font-delta, 0px));
682
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
683
+ cursor: pointer;
684
+ background: none;
685
+ border: none;
686
+ border-radius: 28px;
687
+ justify-content: center;
688
+ align-items: center;
689
+ padding: 6px;
690
+ display: inline-flex;
691
+ }
692
+ .ntr-branch-action svg { width: calc(15px + var(--dsh-content-font-delta, 0px)); height: calc(15px + var(--dsh-content-font-delta, 0px)); }
693
+ .ntr-branch-action:hover { background: var(--dsw-alias-interactive-bg-hover, #0000000d); color: var(--dsw-alias-label-secondary, #6b7280); }
694
+ .ntr-branch-action[data-unavailable] { cursor: default; opacity: 0.4; }
695
+ .ntr-branch-action[data-unavailable]:hover { color: var(--dsw-alias-label-tertiary, #9ca3af); background: none; }
696
+ .ntr-branch-action:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #2563eb); outline-offset: 2px; }
697
+ /* A failed branch click, pinned in the row beside the button (the conversation
698
+ surface of the settings banner the store also writes). One line, truncated;
699
+ the full text is the title tooltip and the banner. */
700
+ .ntr-branch-error { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; line-height: calc(28px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-state-error-primary, #b91c1c); }
601
701
  /* ---- NTR settings dialog (shell.overlay) ---- */
602
702
  /* Frame-wide surface opened from the agent base's empty-roster menu. The
603
703
  overlay layer itself is click-through, so the layer opts back in and blocks
@@ -209,6 +209,19 @@ const BlockView = memo(function BlockView({ block, label }) {
209
209
  function boundSession(list) {
210
210
  return list.find(session => !session.released) ?? list[0];
211
211
  }
212
+ /**
213
+ * Whether an empty stream means "history exists but is not shown yet": the
214
+ * record was restored from disk after a host restart (the agent's replay has
215
+ * not landed), or a resume cleared the buffer ahead of the replay. The view
216
+ * then promises the replay instead of claiming the conversation never
217
+ * happened — "还没有消息" to someone whose chat plane still shows yesterday's
218
+ * turns is exactly the kind of contradiction this file's copy rules exist to
219
+ * prevent. In-memory released sessions keep their buffer, so this only fires
220
+ * when the buffer really is absent.
221
+ */
222
+ export function historyAwaitsReplay(session) {
223
+ return session.released === true && session.hasHistory === true;
224
+ }
212
225
  /**
213
226
  * Everything the view can read that says "this conversation is set up on an
214
227
  * agent": a live or parked ACP session, the host's binding (`session/chat-list`'s
@@ -302,10 +315,15 @@ export function AcpConversationView(props) {
302
315
  return;
303
316
  node.scrollTop = node.scrollHeight;
304
317
  }, [blockCount, tail]);
318
+ // The gate installs the NTR tab only for a conversation it has confirmed as
319
+ // ACP; a stale release must not paint NTR content into an ordinary one for a
320
+ // frame, so anything but the affirmative verdict renders nothing.
321
+ if (ui.acpMode[sessionId] !== true)
322
+ return null;
305
323
  if (transcript === undefined) {
306
- // `ui.acpMode[sessionId]` is `undefined` until the host has answered for
307
- // this conversation; the copy builder reads that as "unknown" and says
308
- // nothing rather than guessing.
324
+ // The guard above makes this a confirmed ACP conversation; the copy
325
+ // builder keeps its "unknown" branch for callers that only read facts
326
+ // (`docs` and tests), not for this rendered surface.
309
327
  const empty = acpEmptyCopy({
310
328
  bound: boundAgent,
311
329
  hasAgents: ui.agents.length > 0,
@@ -327,6 +345,8 @@ export function AcpConversationView(props) {
327
345
  const node = event.currentTarget;
328
346
  pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < 40;
329
347
  }, children: transcript.blocks.length === 0
330
- ? _jsx("div", { className: "ntr-acp-empty-hint", children: t('还没有消息:在下面的输入框里发给它。', 'Nothing yet — send it a message below.') })
348
+ ? (_jsx("div", { className: "ntr-acp-empty-hint", children: historyAwaitsReplay(session)
349
+ ? t('进程已释放;历史将在恢复后回放显示。', 'The process is released — history replays once it is restored.')
350
+ : t('还没有消息:在下面的输入框里发给它。', 'Nothing yet — send it a message below.') }))
331
351
  : transcript.blocks.map((block, index) => _jsx(BlockView, { block: block, label: acp.label, localeRev: ui.localeRev }, index)) })] }));
332
352
  }
@@ -0,0 +1,156 @@
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
+ import { createElement, useMemo } from 'react';
30
+ import { useUi } from "./acp-ui.js";
31
+ import { createSeatClaim } from "./model-seat.js";
32
+ export const DOCK_SLOT = 'conversation.composer.dock';
33
+ export const ACTIVITY_ENTRY_ID = 'activity';
34
+ /**
35
+ * The aggregate the dock should show: the sum of every ACP session backing this
36
+ * chat (the host pill's counts are whole-chat too), with a live `turn/metrics`
37
+ * entry preferred per session over its persisted row. `undefined` means "no
38
+ * measurable burst yet" — the wrapper then hides the speed instead of showing
39
+ * the host's tool-inclusive number.
40
+ */
41
+ export function activityBurst(ui, dshSessionId) {
42
+ if (ui.chatSessionsFor !== dshSessionId)
43
+ return undefined;
44
+ let outputTokens = 0;
45
+ let burstMs = 0;
46
+ for (const row of ui.chatSessions) {
47
+ const measured = ui.burstStats[row.sessionId] ?? row.burst;
48
+ if (measured === undefined)
49
+ continue;
50
+ outputTokens += measured.outputTokens;
51
+ burstMs += measured.burstMs;
52
+ }
53
+ return outputTokens > 0 && burstMs > 0 ? { outputTokens, burstMs } : undefined;
54
+ }
55
+ /**
56
+ * Replace ONLY the decode half of the host's session stats.
57
+ *
58
+ * `decodeTokens` and `decodeMs` are the two fields the host's `decodeSpeed`
59
+ * reads (`decodeTokens / (decodeMs / 1000)`), so writing the plugin's aggregate
60
+ * there makes the host's own formatter, dialog row and aria label display the
61
+ * plugin's burst rate. Every other field — turns, steps, LLM/tool/TTFT — stays
62
+ * host-native. With no measurable burst the two decode fields are zeroed: the
63
+ * host then hides the speed (`decodeMs > 0` guard) while keeping the counts.
64
+ */
65
+ export function patchedActivityStats(native, burst, acp) {
66
+ if (!acp || native === undefined)
67
+ return native;
68
+ if (burst !== undefined && burst.outputTokens > 0 && burst.burstMs > 0) {
69
+ return { ...native, decodeTokens: burst.outputTokens, decodeMs: burst.burstMs };
70
+ }
71
+ return { ...native, decodeTokens: 0, decodeMs: 0 };
72
+ }
73
+ function ActivityPillOccupant({ host: Host, ...props }) {
74
+ const ui = useUi(props.acp);
75
+ const dshSessionId = String(props.sessionId);
76
+ const acpMode = ui.acpMode[dshSessionId] === true;
77
+ const native = props.useProjection('sessionStats');
78
+ const burst = acpMode ? activityBurst(ui, dshSessionId) : undefined;
79
+ const stats = useMemo(() => patchedActivityStats(native, burst, acpMode), [native, burst?.outputTokens, burst?.burstMs, acpMode]);
80
+ // The current host pill reads exactly one projection. Returning `undefined`
81
+ // for another key keeps the wrapper from claiming support it has not been
82
+ // taught; a future host that asks for more will abdicate to the native entry.
83
+ const projection = useMemo(() => (key, selector) => {
84
+ if (key !== 'sessionStats')
85
+ return undefined;
86
+ return selector === undefined ? stats : selector(stats);
87
+ }, [stats]);
88
+ return createElement(Host, {
89
+ useChat: props.useChat,
90
+ useProjection: acpMode ? projection : props.useProjection,
91
+ usePerformanceUsage: props.usePerformanceUsage,
92
+ t: props.t,
93
+ });
94
+ }
95
+ function findHostActivity(slots) {
96
+ try {
97
+ if (slots.spec?.(DOCK_SLOT)?.kind !== 'list')
98
+ return undefined;
99
+ const heads = slots.entriesOfSlot?.(DOCK_SLOT) ?? [];
100
+ return heads.find(entry => entry.options?.id === ACTIVITY_ENTRY_ID);
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ /**
107
+ * Take the dock's `activity` cell for one ACP-mode spell.
108
+ *
109
+ * Returns `undefined` on any host shape this bundle cannot serve — an older
110
+ * host without the `activity` entry (0.1.5 ships `stats`), a slot kind change,
111
+ * a missing inject/locale, or no `performanceUsage` source. The native pill is
112
+ * then left untouched; that is the honest failure mode, not a hidden control.
113
+ */
114
+ export function createActivityPillClaim(options) {
115
+ const host = findHostActivity(options.slots);
116
+ if (host === undefined
117
+ || host.component === undefined
118
+ || typeof host.inject !== 'function'
119
+ || typeof host.locale !== 'string'
120
+ || host.locale === '') {
121
+ options.onDiag?.('activity pill: host entry not available');
122
+ return undefined;
123
+ }
124
+ let performanceUsage;
125
+ try {
126
+ const face = host.inject();
127
+ performanceUsage = face?.hooks?.performanceUsage;
128
+ }
129
+ catch {
130
+ performanceUsage = undefined;
131
+ }
132
+ if (performanceUsage === undefined) {
133
+ options.onDiag?.('activity pill: host performance hook missing');
134
+ return undefined;
135
+ }
136
+ const Host = host.component;
137
+ return createSeatClaim({
138
+ slots: options.slots,
139
+ slotKey: DOCK_SLOT,
140
+ entryId: ACTIVITY_ENTRY_ID,
141
+ order: host.options?.order ?? 0,
142
+ locale: host.locale,
143
+ basePriority: -1,
144
+ occupant: () => (props) => createElement(ActivityPillOccupant, {
145
+ ...props,
146
+ host: Host,
147
+ }),
148
+ inject: (sessionId, actions) => ({
149
+ ...options.inject(sessionId, actions),
150
+ hooks: { performanceUsage },
151
+ }),
152
+ label: 'activity pill',
153
+ onDiag: options.onDiag,
154
+ maxClaims: options.maxClaims ?? 1,
155
+ });
156
+ }
@@ -0,0 +1,149 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The NTR branch button (slot `conversation.chat.assistant-actions`): in an
4
+ * ACP conversation this is the ONLY branch entry — the host's like / dislike /
5
+
6
+ * branch icons are hidden under `html[data-ntr-acp]` (see acp-ui.tsx), and
7
+ * this button takes the host branch's place with the same icon, size and
8
+ * row position (order 20, after the hidden feedback entry's order 10).
9
+ *
10
+ * Clicking runs the fixed two-stage branch: the HOST forks the DSH session
11
+ * tree first (`sessions.fork`, structurally resolved), then the plugin adopts
12
+ * the ACP side (`session/chat-fork-adopt` → the agent's own `session/fork`),
13
+ * so the child conversation's agent actually remembers the parent context —
14
+ * see the NTR message-actions plan, §D5/§D7.
15
+ *
16
+ * Availability is the store's `forkStateOf` six-state mapping over facts the
17
+ * button never derives itself: the host's per-row `fork` verdict
18
+ * (`session/chat-list`), the SSE running flag, the host-fork structural probe,
19
+ * and the DOM tail verdict. The tail check reads the host's OWN
20
+ * `[data-turn-tail]` marker (`data-actions-reveal === "always"` = the latest
21
+ * completed turn) and fails CLOSED: a missing marker disables the button
22
+ * rather than risking a fork cut at a point the user is not looking at.
23
+ */
24
+ import { useLayoutEffect, useRef, useState } from 'react';
25
+ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives';
26
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives';
27
+ import { useUi } from "./acp-ui.js";
28
+ import { forkStateOf } from "./store.js";
29
+ /**
30
+ * Resolves the runtime's `sessions.fork` primitive, binding it to the service
31
+ * instance so that internal `this` accesses (such as `this.list.getSnapshot()`)
32
+ * resolve correctly when called through `face.fork(...)`.
33
+ * Returns `undefined` if the service or `fork` method is absent or not a function.
34
+ */
35
+ export function resolveHostFork(sessionsService) {
36
+ try {
37
+ const sessions = sessionsService;
38
+ const fork = sessions?.fork;
39
+ return typeof fork === 'function' ? { fork: fork.bind(sessions) } : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ /**
46
+ * The host's own branch-button icon, picked per host generation. The icon
47
+ * exports MOVED between shipped hosts — desktop 0.2.1-rc.2 has
48
+ * `IconBranchOutlineRegular` / `…Medium` but NOT `…16`, newer packages export
49
+ * `…16` — and a hard import of one name compiles to a plain property read
50
+ * (`primitives.IconBranchOutline16`), so the missing export is `undefined`
51
+ * and `<undefined />` throws "Element type is invalid". The slot's error
52
+ * boundary then abdicates the whole entry: no button, no tooltip, nothing in
53
+ * any log — the exact silent disappearance this picker exists to prevent.
54
+ * Rendering the button never depends on the icon: with all three absent the
55
+ * click still works, only the 28px glyph is missing.
56
+ */
57
+ export function branchIconOf(source) {
58
+ return (source.IconBranchOutline16 ?? source.IconBranchOutlineRegular ?? source.IconBranchOutlineMedium);
59
+ }
60
+ const BranchIcon = branchIconOf(primitives);
61
+ /** What the label reads for each state (zh / en pairs, per the plan's §D4). */
62
+ function tooltipFor(state, label) {
63
+ switch (state) {
64
+ case 'ready': return label('在新对话中分支', 'Branch into a new conversation');
65
+ case 'unsupported': return label('该 agent 不支持原生分叉', 'This agent cannot fork natively');
66
+ case 'released': return label('会话已挂起,先发一条消息恢复再分叉', 'Resume this session before branching');
67
+ case 'busy': return label('当前轮次结束后才能分叉', 'Wait for the current turn to finish');
68
+ case 'notTail': return label('仅可从已完成轮次的最后一条消息分支', 'Available only on the last message of a completed turn');
69
+ case 'hostUnavailable': return label('当前宿主不提供分叉', 'This host does not support branching');
70
+ }
71
+ }
72
+ export function AcpBranchAction(props) {
73
+ const ui = useUi(props.acp);
74
+ const buttonRef = useRef(null);
75
+ const [pending, setPending] = useState(false);
76
+ /**
77
+ * The host's own availability verdict for the turn tail this button sits
78
+ * in, re-read after EVERY render (no dep array): the marker flips without
79
+ * any store event of our own when a new turn lands, and before the first
80
+ * layout pass the ref is not attached yet. Two facts, both the HOST's:
81
+ * `data-actions-reveal === "always"` (this tail is the latest turn and
82
+ * completed) and the host branch button's own `data-unavailable` (a stricter
83
+ * gate — a tail whose final node is not the transcript's last, or a turn
84
+ * with later nodes, is one the HOST refuses to fork). Reading the host's
85
+ * button keeps the plugin's readiness a SUBSET of the host's: wherever the
86
+ * two would disagree about the cut point, the plugin button disables, and
87
+ * the remaining clicks fork at the same latest-completed prefix the host
88
+ * would (the tail marked `always` is the latest completed turn, which is
89
+ * exactly where the host's fallback `atSeq` lands).
90
+ */
91
+ const [tailReady, setTailReady] = useState(false);
92
+ useLayoutEffect(() => {
93
+ const tail = buttonRef.current?.closest('[data-turn-tail]');
94
+ const hostBranch = tail?.querySelector('button[aria-label="在新对话中分支"]:not([data-ntr-branch]), button[aria-label="Branch into a new conversation"]:not([data-ntr-branch])');
95
+ setTailReady(tail?.getAttribute('data-actions-reveal') === 'always'
96
+ && hostBranch?.hasAttribute('data-unavailable') !== true);
97
+ });
98
+ /**
99
+ * The click's local failure line. The store ALSO flashes the same reason
100
+ * into the settings banner, but the user is HERE — without this row the
101
+ * click would "do nothing" visibly, the exact invisibility the permission
102
+ * dock's pinned-error pattern exists to prevent.
103
+ */
104
+ const [failure, setFailure] = useState(undefined);
105
+ // ACP conversations only, fail-closed: the slot renders in EVERY
106
+ // conversation, and the confirmed verdict (`session/chat-list`, folded from
107
+ // the runtime row by the seat gate) is the same fact the scope marker and
108
+ // the CSS hiding run on. `undefined` renders nothing. The row guard keeps
109
+ // the button from rendering on a conversation whose rows have not landed —
110
+ // without it a rc.2-style host (verdict folded from the row preset, ahead
111
+ // of the list read) would show a disabled button flashing the `released`
112
+ // tooltip for a perfectly live conversation.
113
+ if (ui.acpMode[props.sessionId] !== true)
114
+ return null;
115
+ if (ui.chatSessionsFor !== props.sessionId)
116
+ return null;
117
+ const row = ui.chatSessionsFor === props.sessionId ? ui.chatSessions[0] : undefined;
118
+ const forkFace = props.hostFork?.();
119
+ // The row's `running` only refreshes on a chat-list read, so a turn that
120
+ // just started would leave it stale for seconds; the SSE-facing session
121
+ // view is the live copy of the same fact.
122
+ const liveRunning = row !== undefined && ui.sessions.some(s => s.sessionId === row.sessionId && s.running);
123
+ const state = forkStateOf(row, forkFace !== undefined, tailReady, liveRunning);
124
+ const disabled = state !== 'ready' || pending;
125
+ const branchLabel = props.acp.label('在新对话中分支', 'Branch into a new conversation');
126
+ const onClick = () => {
127
+ if (state !== 'ready' || pending)
128
+ return;
129
+ setPending(true);
130
+ setFailure(undefined);
131
+ void props.acp.forkChatSession(props.sessionId, sessionId => {
132
+ // Re-resolved per click: a face that vanished between render and click
133
+ // fails loudly in the notice banner and the local line instead of
134
+ // throwing here.
135
+ const face = props.hostFork?.();
136
+ if (face === undefined)
137
+ throw new Error('acp: host fork service is gone');
138
+ return face.fork({ sessionId, increaseTitle: true });
139
+ }).then(outcome => {
140
+ // A failure opens NOTHING (an unadopted child is a copied-history
141
+ // conversation the user did not ask to visit) and says why, right here.
142
+ if ('childId' in outcome)
143
+ props.openSession?.(outcome.childId);
144
+ else
145
+ setFailure(outcome.failure);
146
+ }).finally(() => { setPending(false); });
147
+ };
148
+ return (_jsxs(_Fragment, { children: [_jsx(Tooltip, { label: tooltipFor(state, props.acp.label), side: "bottom", children: _jsx("button", { ref: buttonRef, type: "button", className: "ntr-branch-action", "aria-label": branchLabel, "aria-disabled": disabled || undefined, "data-ntr-branch": "", "data-unavailable": disabled || undefined, onClick: disabled ? undefined : onClick, children: BranchIcon === undefined ? null : _jsx(BranchIcon, {}) }) }), failure !== undefined && (_jsx("span", { className: "ntr-branch-error", role: "alert", title: failure, children: failure }))] }));
149
+ }
@@ -34,6 +34,36 @@ function useUi(acp) {
34
34
  export function agentModeTitle(agentId, acp) {
35
35
  return acp.label(`agent 模式(${agentId ?? 'agent'}):由 agent 自己定义。NTR 会话里 DSH 的 Access mode 不显示,模式就在此切换。`, `agent mode (${agentId ?? 'agent'}): defined by the agent. In an NTR conversation the DSH access mode is not shown, so this is where the mode is switched.`);
36
36
  }
37
+ /**
38
+ * Whether this conversation is one the agent-mode control may serve.
39
+ *
40
+ * An EXPLICIT non-ACP verdict outranks a lingering session row: switching a
41
+ * conversation off the NTR preset does not close its ACP session, so "a row
42
+ * exists" alone would keep this control on an ordinary conversation — the same
43
+ * precedence the agent tag, the context ring and the released banner already
44
+ * apply. Only while the host has not answered (`undefined`) does a bound
45
+ * session count as proof, which is what keeps the control available before the
46
+ * host's first `session/chat-list` answer lands.
47
+ */
48
+ export function agentModeVisible(acpMode, hasBoundSession) {
49
+ if (acpMode === false)
50
+ return false;
51
+ return acpMode === true || hasBoundSession;
52
+ }
53
+ /**
54
+ * The mode the select shows as in force.
55
+ *
56
+ * A LIVE session is the authority (it is what the agent runs right now). A
57
+ * RELEASED row only describes the mode the agent was parked ON, so a newer
58
+ * pick — which `applyChatChoice` will apply on the next resume — outranks it
59
+ * (and the parked row still beats the catalog default). Same precedence as
60
+ * the model and thought level in `describeRoute`: a pre-switch on a parked
61
+ * session must be visible or the control reads as dead.
62
+ */
63
+ export function modeInForce(target, persisted, fallback) {
64
+ const live = target !== undefined && target.released !== true ? target.modes?.currentModeId : undefined;
65
+ return live ?? persisted ?? target?.modes?.currentModeId ?? fallback ?? '';
66
+ }
37
67
  export function AcpComposerMode({ acp, sessionId, useSession }) {
38
68
  const ui = useUi(acp);
39
69
  const dshSessionId = String(sessionId);
@@ -52,9 +82,10 @@ export function AcpComposerMode({ acp, sessionId, useSession }) {
52
82
  // unconditionally), so it must gate itself. The `defaultAgent` fallback below
53
83
  // would otherwise put an ACP mode select into ORDINARY conversations: the
54
84
  // agent id is plugin-global, so "an agent is configured" is not the same
55
- // question as "this conversation is served by one". A live session is itself
56
- // proof; otherwise it is the same host verdict the seat gate reads.
57
- const isAcp = target !== undefined || ui.acpMode[dshSessionId] === true;
85
+ // question as "this conversation is served by one". A live session is proof
86
+ // only while the host has not answered; an explicit `false` wins over the
87
+ // lingering row a preset switch leaves behind (see `agentModeVisible`).
88
+ const isAcp = agentModeVisible(ui.acpMode[dshSessionId], target !== undefined);
58
89
  // The agent this conversation is set up on, live session or not. Before the
59
90
  // first message there is no session to read a mode list from, yet the user is
60
91
  // already choosing the agent — and the mode is one of the things they may
@@ -83,6 +114,13 @@ export function AcpComposerMode({ acp, sessionId, useSession }) {
83
114
  return;
84
115
  void acp.loadAgentCatalog(agentId);
85
116
  }, [acp, agentId, isAcp, target]);
117
+ // The whole control is ACP-only: a lingering live session row survives a
118
+ // preset switch, so its `availableModes` are NOT a licence to render here.
119
+ // Without this guard an ordinary conversation kept an agent-mode select
120
+ // beside its native controls (reported as "the bottom bar lost its native
121
+ // capabilities").
122
+ if (!isAcp)
123
+ return null;
86
124
  const live = target?.modes?.availableModes ?? [];
87
125
  // TWO sources, one list. A live session's `session/new` answer is the
88
126
  // authority on which modes exist and which is in force; the probed catalog
@@ -90,21 +128,18 @@ export function AcpComposerMode({ acp, sessionId, useSession }) {
90
128
  // agent published on ITS throwaway session). A live session always wins.
91
129
  const modes = live.length > 0
92
130
  ? live
93
- : (isAcp ? catalog?.mode?.choices ?? [] : []).map(choice => ({ id: choice.id, name: choice.name, description: choice.description }));
131
+ : (catalog?.mode?.choices ?? []).map(choice => ({ id: choice.id, name: choice.name, description: choice.description }));
94
132
  if (modes.length === 0)
95
133
  return null;
96
- // The mode IN FORCE comes from the live session; before one exists the only
97
- // claims available are the agent's own reported current mode and the pick this
98
- // conversation recorded (a pick outranks the agent's default, exactly like the
99
- // model and thought level do in `describeRoute`).
134
+ // The mode IN FORCE: a live session is the authority, before one exists (or
135
+ // while one is parked) the claims available are the pick this conversation
136
+ // recorded and the agent's own reported current mode (a pick outranks the
137
+ // agent's default, exactly like the model and thought level do in
138
+ // `describeRoute`; the parked row yields to the pick — see `modeInForce`).
100
139
  const persisted = ui.chatChoice[dshSessionId]?.agentId === agentId
101
140
  ? ui.chatChoice[dshSessionId]?.mode
102
141
  : undefined;
103
- const current = target?.modes?.currentModeId
104
- ?? persisted
105
- ?? catalog?.mode?.currentValue
106
- ?? modes[0]?.id
107
- ?? '';
142
+ const current = modeInForce(target, persisted, catalog?.mode?.currentValue ?? modes[0]?.id);
108
143
  return (_jsx("label", { style: { display: 'inline-flex', alignItems: 'center', fontSize: 12 },
109
144
  // No visible "Agent mode" caption (user decision, 2026-10-17): the option
110
145
  // labels are the agent's own mode names (Plan / Accept edits / …), which