dsh-ntr 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +614 -25
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/branch-action.js +133 -0
  8. package/lib/client/composer-mode.js +48 -13
  9. package/lib/client/context-meter.js +86 -0
  10. package/lib/client/index.js +238 -15
  11. package/lib/client/panel.js +127 -30
  12. package/lib/client/released-banner.js +66 -0
  13. package/lib/client/session-controls.js +38 -13
  14. package/lib/client/slash-isolation.js +2 -2
  15. package/lib/client/store.js +403 -16
  16. package/lib/client.js +1267 -70
  17. package/lib/client.js.map +1 -1
  18. package/lib/impl.js +54 -1
  19. package/lib/index.js +7 -0
  20. package/lib/protocol.js +19 -0
  21. package/lib/types/bridge.d.ts +230 -7
  22. package/lib/types/client/acp-ui.d.ts +34 -2
  23. package/lib/types/client/acp-view.d.ts +13 -2
  24. package/lib/types/client/branch-action.d.ts +40 -0
  25. package/lib/types/client/composer-mode.d.ts +28 -0
  26. package/lib/types/client/context-meter.d.ts +51 -0
  27. package/lib/types/client/released-banner.d.ts +34 -0
  28. package/lib/types/client/slash-isolation.d.ts +1 -1
  29. package/lib/types/client/store.d.ts +131 -1
  30. package/lib/types/impl.d.ts +41 -1
  31. package/lib/types/protocol.d.ts +130 -0
  32. package/lib/types/update.d.ts +7 -4
  33. package/lib/update.js +25 -10
  34. package/package.json +2 -1
  35. package/src/bridge.ts +645 -27
  36. package/src/client/acp-ui.tsx +123 -2
  37. package/src/client/acp-view.tsx +30 -5
  38. package/src/client/branch-action.tsx +160 -0
  39. package/src/client/composer-mode.tsx +59 -13
  40. package/src/client/context-meter.tsx +128 -0
  41. package/src/client/index.tsx +265 -14
  42. package/src/client/panel.tsx +229 -41
  43. package/src/client/primitives.d.ts +27 -0
  44. package/src/client/released-banner.tsx +97 -0
  45. package/src/client/session-controls.tsx +34 -11
  46. package/src/client/slash-isolation.ts +2 -1
  47. package/src/client/store.ts +463 -20
  48. package/src/impl.ts +84 -2
  49. package/src/index.ts +8 -0
  50. package/src/protocol.ts +129 -0
  51. package/src/update.ts +21 -8
@@ -78,6 +78,24 @@ export interface AcpChatInjected {
78
78
  acp: AcpStore
79
79
  /** Resolve the session's command/projection face; `undefined` before the session materializes. */
80
80
  faceFor(sessionId: string): AcpSessionFace | undefined
81
+ /**
82
+ * The runtime's `sessions.fork` face (the host's own branching primitive,
83
+ * resolved structurally per call); `undefined` when the host half lacks it —
84
+ * the branch button then renders disabled (`hostUnavailable`), never hidden.
85
+ */
86
+ hostFork?: () => AcpHostForkFace | undefined
87
+ /** The workspace's `openSession` face (the branch click's final step). */
88
+ openSession?: (sessionId: string) => void
89
+ }
90
+
91
+ /** The slice of the client runtime's session service the branch button needs. */
92
+ export interface AcpHostForkFace {
93
+ /**
94
+ * Fork the DSH session tree at the latest completed turn. Resolves the CHILD
95
+ * session id; the host owns the cut-point snapping, history copy, `(1)`
96
+ * title and workspace attribution — the plugin rewrites none of it.
97
+ */
98
+ fork(options: { sessionId: string; increaseTitle?: boolean }): Promise<unknown>
81
99
  }
82
100
 
83
101
  // ---- The session standard kit (framework-provided, declared structurally) ----
@@ -305,6 +323,13 @@ export interface AcpStagedSession {
305
323
  /**
306
324
  * Which session the composer is currently staged on.
307
325
  *
326
+ * The renderer's own current scope binding is the first answer when the caller
327
+ * passes it (`currentSessionId`, read from `uiSession.adapter.current` in the
328
+ * browser entry): that is the exact session the host has selected, while the
329
+ * list can retain more than one row in a multi-view layout. Falling back to
330
+ * the list is still required — a host without that scope adapter, and the old
331
+ * snapshot shape below, have no better source.
332
+ *
308
333
  * Old runtimes answer from `snapshot.current` (with `currentAddress` as the
309
334
  * subagent marker). rc.2 has no `current`: the staged session is the row the
310
335
  * main view retains (`retainedBy.mainView > 0`) — the exact rule
@@ -312,8 +337,16 @@ export interface AcpStagedSession {
312
337
  * row with `origin === "subagent"` is an addressed subagent route. `undefined`
313
338
  * when nothing is staged yet (the list has not loaded, or no row holds the main
314
339
  * view): callers must stand down rather than guess.
340
+ *
341
+ * A current id the list has no row for is ALSO `undefined`: the selection may
342
+ * be a subagent address, and guessing "not a subagent" there would let the
343
+ * gate install ACP chrome on a route the plugin must not drive. The next list
344
+ * notification retries.
315
345
  */
316
- export function stagedSessionOf(snapshot: AcpSessionsSnapshot | undefined): AcpStagedSession | undefined {
346
+ export function stagedSessionOf(
347
+ snapshot: AcpSessionsSnapshot | undefined,
348
+ currentSessionId?: string,
349
+ ): AcpStagedSession | undefined {
317
350
  if (snapshot === undefined) return undefined
318
351
  if (snapshot.current !== undefined) {
319
352
  return {
@@ -323,6 +356,11 @@ export function stagedSessionOf(snapshot: AcpSessionsSnapshot | undefined): AcpS
323
356
  }
324
357
  }
325
358
  const byId = snapshot.byId ?? {}
359
+ if (currentSessionId !== undefined) {
360
+ const row = byId[currentSessionId]
361
+ if (row === undefined) return undefined
362
+ return { sessionId: currentSessionId, row, subagent: row.origin === 'subagent' }
363
+ }
326
364
  // Candidate set is `byId`, NOT `ids`. In rc.2 the controller projects
327
365
  // `ids` from the host list only, then appends two fallback families to
328
366
  // `byId` without an `ids` entry: subagent-catalog children of a projected
@@ -349,8 +387,9 @@ export function stagedSessionOf(snapshot: AcpSessionsSnapshot | undefined): AcpS
349
387
  export function stagedIsAcpMode(
350
388
  snapshot: AcpSessionsSnapshot | undefined,
351
389
  acpMode?: (sessionId: string) => boolean | undefined,
390
+ currentSessionId?: string,
352
391
  ): boolean {
353
- const staged = stagedSessionOf(snapshot)
392
+ const staged = stagedSessionOf(snapshot, currentSessionId)
354
393
  if (staged === undefined || staged.subagent) return false
355
394
  const listed = rowAgentPreset(staged.row)
356
395
  if (listed !== undefined) return listed === ACP_PRESET_ID
@@ -795,6 +834,15 @@ const STYLE = `
795
834
  .ntr-acp-chip.is-static:hover { background: var(--dsw-alias-bg-layer-2, #f3f4f6); }
796
835
  .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); }
797
836
  .ntr-acp-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
837
+ /* Composer context-occupancy ring (conversation.composer.dock,
838
+ context-meter.tsx): the agent's own prompt-side used/size in the host
839
+ ContextMeter's ring geometry, pill and tokens, so it reads as that meter's
840
+ sibling in the bottom info row. Gated inside the component; absent until the
841
+ agent reports a sample. */
842
+ .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; }
843
+ .ntr-acp-meter-track { fill: none; stroke: var(--dsw-alias-border-l3, #e5e7eb); stroke-width: 2px; }
844
+ .ntr-acp-meter-fill { fill: none; stroke: var(--dsw-alias-label-tertiary, #9ca3af); stroke-width: 2px; stroke-linecap: round; }
845
+ .ntr-acp-meter-percent { color: inherit; }
798
846
  /* The lock hand-off (src/client/agent-lock-anim.ts): the outgoing agent base
799
847
  breaks into debris for 460ms, then the edge tag condenses from the same
800
848
  particles. Both halves read one shared clock in that module; these durations
@@ -970,6 +1018,79 @@ const STYLE = `
970
1018
  .ntr-permission-option:focus-visible, .ntr-permission-cancel:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #2563eb); outline-offset: 2px; }
971
1019
  .ntr-permission-cancel { margin-left: auto; color: var(--dsw-alias-label-secondary, #6b7280); }
972
1020
  .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; }
1021
+ /* ---- Released-session banner (conversation.input.dock, released-banner.tsx) ---- */
1022
+ /* Same composer-width formula as the permission dock above: the slot spans
1023
+ the column, the card lines up with the input box. One row of state + one
1024
+ action; the error pins under it, beside the button that failed. */
1025
+ .ntr-released-banner {
1026
+ box-sizing: border-box;
1027
+ width: calc(100% - var(--dsh-composer-side-clearance, 16px) - var(--dsh-composer-side-clearance, 16px));
1028
+ max-width: var(--dsh-composer-card-max-width, 920px);
1029
+ margin: 0 auto;
1030
+ display: flex; flex-direction: column; gap: 6px;
1031
+ padding: 8px 12px;
1032
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
1033
+ border-left: 3px solid var(--dsw-alias-state-warn-primary, #d97706);
1034
+ border-radius: 10px;
1035
+ background: var(--dsw-alias-bg-layer-1, #fff);
1036
+ font-size: 12px; line-height: 1.5;
1037
+ }
1038
+ .ntr-released-banner-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
1039
+ .ntr-released-banner-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
1040
+ .ntr-released-banner-title { font-weight: 600; }
1041
+ .ntr-released-banner-body { color: var(--dsw-alias-label-secondary, #6b7280); }
1042
+ .ntr-released-banner .ntr-acp-more { flex: none; white-space: nowrap; }
1043
+ /* ---- NTR message actions (conversation.chat.assistant-actions) ---- */
1044
+ /* In an NTR conversation the host's like / dislike / branch icons are the
1045
+ plugin's business (the agent decides its own feedback surface, and the
1046
+ host's branch would fork a conversation the agent does not remember). The
1047
+ three icons hide under the html[data-ntr-acp] scope marker, by their
1048
+ LOCALIZED aria-labels — the only stable handles the host leaves (its button
1049
+ classes are CSS-module hashes and there is no data-* hook). Hiding the
1050
+ button is the WHOLE job: the host's Tooltip clones its child without a
1051
+ wrapper element, so there is no container left behind to plug the row gap
1052
+ — and any :has(> button…) rule would match the actions row itself (and the
1053
+ list slot's shared display:contents anchor), hiding the entire row. The
1054
+ :not([data-ntr-branch]) exemption is the pass our OWN button carries —
1055
+ same label, same row. Known fragility: a host copy change or a third
1056
+ language un-hides the icons; the entry probes the labels once per page and
1057
+ warns in the console. */
1058
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="在新对话中分支"]:not([data-ntr-branch]),
1059
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Branch into a new conversation"]:not([data-ntr-branch]),
1060
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="好的回答"],
1061
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="有问题的回答"],
1062
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="取消标记"],
1063
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Good response"],
1064
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Bad response"],
1065
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
1066
+ display: none !important;
1067
+ }
1068
+ /* The plugin's own branch button, dressed exactly like the host's .action
1069
+ class (the values are copied from the host's compiled CSS, token colors
1070
+ only). It rides the same actions row, so the host's row gap, font-delta
1071
+ sizing and the [data-actions-reveal] hover rules already govern it. */
1072
+ .ntr-branch-action {
1073
+ width: calc(28px + var(--dsh-content-font-delta, 0px));
1074
+ height: calc(28px + var(--dsh-content-font-delta, 0px));
1075
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
1076
+ cursor: pointer;
1077
+ background: none;
1078
+ border: none;
1079
+ border-radius: 28px;
1080
+ justify-content: center;
1081
+ align-items: center;
1082
+ padding: 6px;
1083
+ display: inline-flex;
1084
+ }
1085
+ .ntr-branch-action svg { width: calc(15px + var(--dsh-content-font-delta, 0px)); height: calc(15px + var(--dsh-content-font-delta, 0px)); }
1086
+ .ntr-branch-action:hover { background: var(--dsw-alias-interactive-bg-hover, #0000000d); color: var(--dsw-alias-label-secondary, #6b7280); }
1087
+ .ntr-branch-action[data-unavailable] { cursor: default; opacity: 0.4; }
1088
+ .ntr-branch-action[data-unavailable]:hover { color: var(--dsw-alias-label-tertiary, #9ca3af); background: none; }
1089
+ .ntr-branch-action:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #2563eb); outline-offset: 2px; }
1090
+ /* A failed branch click, pinned in the row beside the button (the conversation
1091
+ surface of the settings banner the store also writes). One line, truncated;
1092
+ the full text is the title tooltip and the banner. */
1093
+ .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); }
973
1094
  /* ---- NTR settings dialog (shell.overlay) ---- */
974
1095
  /* Frame-wide surface opened from the agent base's empty-roster menu. The
975
1096
  overlay layer itself is click-through, so the layer opts back in and blocks
@@ -19,7 +19,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
19
19
  import { ensureAcpStyle, useUi } from './acp-ui.tsx'
20
20
  import type { AcpChatInjected } from './acp-ui.tsx'
21
21
  import type { Block, ChatTranscript } from './store.ts'
22
- import type { AgentView, ChatSessionView } from '../protocol.ts'
22
+ import type { AgentView, ChatSessionView, SessionView } from '../protocol.ts'
23
23
 
24
24
  /** Props of the ACP view entry (injected face + the frame's session id). */
25
25
  export interface AcpViewProps extends AcpChatInjected {
@@ -338,6 +338,20 @@ function boundSession(list: readonly ChatSessionView[]): ChatSessionView | undef
338
338
  return list.find(session => !session.released) ?? list[0]
339
339
  }
340
340
 
341
+ /**
342
+ * Whether an empty stream means "history exists but is not shown yet": the
343
+ * record was restored from disk after a host restart (the agent's replay has
344
+ * not landed), or a resume cleared the buffer ahead of the replay. The view
345
+ * then promises the replay instead of claiming the conversation never
346
+ * happened — "还没有消息" to someone whose chat plane still shows yesterday's
347
+ * turns is exactly the kind of contradiction this file's copy rules exist to
348
+ * prevent. In-memory released sessions keep their buffer, so this only fires
349
+ * when the buffer really is absent.
350
+ */
351
+ export function historyAwaitsReplay(session: Pick<SessionView, 'released' | 'hasHistory'>): boolean {
352
+ return session.released === true && session.hasHistory === true
353
+ }
354
+
341
355
  /**
342
356
  * Everything the view can read that says "this conversation is set up on an
343
357
  * agent": a live or parked ACP session, the host's binding (`session/chat-list`'s
@@ -467,10 +481,15 @@ export function AcpConversationView(props: AcpViewProps) {
467
481
  node.scrollTop = node.scrollHeight
468
482
  }, [blockCount, tail])
469
483
 
484
+ // The gate installs the NTR tab only for a conversation it has confirmed as
485
+ // ACP; a stale release must not paint NTR content into an ordinary one for a
486
+ // frame, so anything but the affirmative verdict renders nothing.
487
+ if (ui.acpMode[sessionId] !== true) return null
488
+
470
489
  if (transcript === undefined) {
471
- // `ui.acpMode[sessionId]` is `undefined` until the host has answered for
472
- // this conversation; the copy builder reads that as "unknown" and says
473
- // nothing rather than guessing.
490
+ // The guard above makes this a confirmed ACP conversation; the copy
491
+ // builder keeps its "unknown" branch for callers that only read facts
492
+ // (`docs` and tests), not for this rendered surface.
474
493
  const empty = acpEmptyCopy({
475
494
  bound: boundAgent,
476
495
  hasAgents: ui.agents.length > 0,
@@ -547,7 +566,13 @@ export function AcpConversationView(props: AcpViewProps) {
547
566
  }}
548
567
  >
549
568
  {transcript.blocks.length === 0
550
- ? <div className="ntr-acp-empty-hint">{t('还没有消息:在下面的输入框里发给它。', 'Nothing yet — send it a message below.')}</div>
569
+ ? (
570
+ <div className="ntr-acp-empty-hint">
571
+ {historyAwaitsReplay(session)
572
+ ? t('进程已释放;历史将在恢复后回放显示。', 'The process is released — history replays once it is restored.')
573
+ : t('还没有消息:在下面的输入框里发给它。', 'Nothing yet — send it a message below.')}
574
+ </div>
575
+ )
551
576
  : transcript.blocks.map((block, index) => <BlockView key={index} block={block} label={acp.label} localeRev={ui.localeRev} />)}
552
577
  </div>
553
578
  </div>
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The NTR branch button (slot `conversation.chat.assistant-actions`): in an
3
+ * ACP conversation this is the ONLY branch entry — the host's like / dislike /
4
+
5
+ * branch icons are hidden under `html[data-ntr-acp]` (see acp-ui.tsx), and
6
+ * this button takes the host branch's place with the same icon, size and
7
+ * row position (order 20, after the hidden feedback entry's order 10).
8
+ *
9
+ * Clicking runs the fixed two-stage branch: the HOST forks the DSH session
10
+ * tree first (`sessions.fork`, structurally resolved), then the plugin adopts
11
+ * the ACP side (`session/chat-fork-adopt` → the agent's own `session/fork`),
12
+ * so the child conversation's agent actually remembers the parent context —
13
+ * see the NTR message-actions plan, §D5/§D7.
14
+ *
15
+ * Availability is the store's `forkStateOf` six-state mapping over facts the
16
+ * button never derives itself: the host's per-row `fork` verdict
17
+ * (`session/chat-list`), the SSE running flag, the host-fork structural probe,
18
+ * and the DOM tail verdict. The tail check reads the host's OWN
19
+ * `[data-turn-tail]` marker (`data-actions-reveal === "always"` = the latest
20
+ * completed turn) and fails CLOSED: a missing marker disables the button
21
+ * rather than risking a fork cut at a point the user is not looking at.
22
+ */
23
+
24
+ import { useLayoutEffect, useRef, useState } from 'react'
25
+ import type { ComponentType } from 'react'
26
+ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
27
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
28
+ import { useUi } from './acp-ui.tsx'
29
+ import type { AcpChatInjected } from './acp-ui.tsx'
30
+ import { forkStateOf } from './store.ts'
31
+ import type { ChatForkState } from './store.ts'
32
+
33
+ /**
34
+ * The host's own branch-button icon, picked per host generation. The icon
35
+ * exports MOVED between shipped hosts — desktop 0.2.1-rc.2 has
36
+ * `IconBranchOutlineRegular` / `…Medium` but NOT `…16`, newer packages export
37
+ * `…16` — and a hard import of one name compiles to a plain property read
38
+ * (`primitives.IconBranchOutline16`), so the missing export is `undefined`
39
+ * and `<undefined />` throws "Element type is invalid". The slot's error
40
+ * boundary then abdicates the whole entry: no button, no tooltip, nothing in
41
+ * any log — the exact silent disappearance this picker exists to prevent.
42
+ * Rendering the button never depends on the icon: with all three absent the
43
+ * click still works, only the 28px glyph is missing.
44
+ */
45
+ export function branchIconOf(source: Record<string, unknown>): ComponentType<Record<string, never>> | undefined {
46
+ return (source.IconBranchOutline16 ?? source.IconBranchOutlineRegular ?? source.IconBranchOutlineMedium) as
47
+ | ComponentType<Record<string, never>>
48
+ | undefined
49
+ }
50
+ const BranchIcon = branchIconOf(primitives as unknown as Record<string, unknown>)
51
+ /** What the label reads for each state (zh / en pairs, per the plan's §D4). */
52
+ function tooltipFor(state: ChatForkState, label: (zh: string, en: string) => string): string {
53
+ switch (state) {
54
+ case 'ready': return label('在新对话中分支', 'Branch into a new conversation')
55
+ case 'unsupported': return label('该 agent 不支持原生分叉', 'This agent cannot fork natively')
56
+ case 'released': return label('会话已挂起,先发一条消息恢复再分叉', 'Resume this session before branching')
57
+ case 'busy': return label('当前轮次结束后才能分叉', 'Wait for the current turn to finish')
58
+ case 'notTail': return label('仅可从已完成轮次的最后一条消息分支', 'Available only on the last message of a completed turn')
59
+ case 'hostUnavailable': return label('当前宿主不提供分叉', 'This host does not support branching')
60
+ }
61
+ }
62
+
63
+ export function AcpBranchAction(props: AcpChatInjected & { sessionId: string }) {
64
+ const ui = useUi(props.acp)
65
+ const buttonRef = useRef<HTMLButtonElement>(null)
66
+ const [pending, setPending] = useState(false)
67
+ /**
68
+ * The host's own availability verdict for the turn tail this button sits
69
+ * in, re-read after EVERY render (no dep array): the marker flips without
70
+ * any store event of our own when a new turn lands, and before the first
71
+ * layout pass the ref is not attached yet. Two facts, both the HOST's:
72
+ * `data-actions-reveal === "always"` (this tail is the latest turn and
73
+ * completed) and the host branch button's own `data-unavailable` (a stricter
74
+ * gate — a tail whose final node is not the transcript's last, or a turn
75
+ * with later nodes, is one the HOST refuses to fork). Reading the host's
76
+ * button keeps the plugin's readiness a SUBSET of the host's: wherever the
77
+ * two would disagree about the cut point, the plugin button disables, and
78
+ * the remaining clicks fork at the same latest-completed prefix the host
79
+ * would (the tail marked `always` is the latest completed turn, which is
80
+ * exactly where the host's fallback `atSeq` lands).
81
+ */
82
+ const [tailReady, setTailReady] = useState(false)
83
+ useLayoutEffect(() => {
84
+ const tail = buttonRef.current?.closest('[data-turn-tail]')
85
+ const hostBranch = tail?.querySelector('button[aria-label="在新对话中分支"]:not([data-ntr-branch]), button[aria-label="Branch into a new conversation"]:not([data-ntr-branch])')
86
+ setTailReady(
87
+ tail?.getAttribute('data-actions-reveal') === 'always'
88
+ && hostBranch?.hasAttribute('data-unavailable') !== true,
89
+ )
90
+ })
91
+ /**
92
+ * The click's local failure line. The store ALSO flashes the same reason
93
+ * into the settings banner, but the user is HERE — without this row the
94
+ * click would "do nothing" visibly, the exact invisibility the permission
95
+ * dock's pinned-error pattern exists to prevent.
96
+ */
97
+ const [failure, setFailure] = useState<string | undefined>(undefined)
98
+
99
+ // ACP conversations only, fail-closed: the slot renders in EVERY
100
+ // conversation, and the confirmed verdict (`session/chat-list`, folded from
101
+ // the runtime row by the seat gate) is the same fact the scope marker and
102
+ // the CSS hiding run on. `undefined` renders nothing. The row guard keeps
103
+ // the button from rendering on a conversation whose rows have not landed —
104
+ // without it a rc.2-style host (verdict folded from the row preset, ahead
105
+ // of the list read) would show a disabled button flashing the `released`
106
+ // tooltip for a perfectly live conversation.
107
+ if (ui.acpMode[props.sessionId] !== true) return null
108
+ if (ui.chatSessionsFor !== props.sessionId) return null
109
+
110
+ const row = ui.chatSessionsFor === props.sessionId ? ui.chatSessions[0] : undefined
111
+ const forkFace = props.hostFork?.()
112
+ // The row's `running` only refreshes on a chat-list read, so a turn that
113
+ // just started would leave it stale for seconds; the SSE-facing session
114
+ // view is the live copy of the same fact.
115
+ const liveRunning = row !== undefined && ui.sessions.some(s => s.sessionId === row.sessionId && s.running)
116
+ const state = forkStateOf(row, forkFace !== undefined, tailReady, liveRunning)
117
+ const disabled = state !== 'ready' || pending
118
+ const branchLabel = props.acp.label('在新对话中分支', 'Branch into a new conversation')
119
+
120
+ const onClick = (): void => {
121
+ if (state !== 'ready' || pending) return
122
+ setPending(true)
123
+ setFailure(undefined)
124
+ void props.acp.forkChatSession(props.sessionId, sessionId => {
125
+ // Re-resolved per click: a face that vanished between render and click
126
+ // fails loudly in the notice banner and the local line instead of
127
+ // throwing here.
128
+ const face = props.hostFork?.()
129
+ if (face === undefined) throw new Error('acp: host fork service is gone')
130
+ return face.fork({ sessionId, increaseTitle: true })
131
+ }).then(outcome => {
132
+ // A failure opens NOTHING (an unadopted child is a copied-history
133
+ // conversation the user did not ask to visit) and says why, right here.
134
+ if ('childId' in outcome) props.openSession?.(outcome.childId)
135
+ else setFailure(outcome.failure)
136
+ }).finally(() => { setPending(false) })
137
+ }
138
+
139
+ return (
140
+ <>
141
+ <Tooltip label={tooltipFor(state, props.acp.label)} side="bottom">
142
+ <button
143
+ ref={buttonRef}
144
+ type="button"
145
+ className="ntr-branch-action"
146
+ aria-label={branchLabel}
147
+ aria-disabled={disabled || undefined}
148
+ data-ntr-branch=""
149
+ data-unavailable={disabled || undefined}
150
+ onClick={disabled ? undefined : onClick}
151
+ >
152
+ {BranchIcon === undefined ? null : <BranchIcon />}
153
+ </button>
154
+ </Tooltip>
155
+ {failure !== undefined && (
156
+ <span className="ntr-branch-error" role="alert" title={failure}>{failure}</span>
157
+ )}
158
+ </>
159
+ )
160
+ }
@@ -65,6 +65,44 @@ export function agentModeTitle(agentId: string | undefined, acp: AcpStore): stri
65
65
  )
66
66
  }
67
67
 
68
+ /**
69
+ * Whether this conversation is one the agent-mode control may serve.
70
+ *
71
+ * An EXPLICIT non-ACP verdict outranks a lingering session row: switching a
72
+ * conversation off the NTR preset does not close its ACP session, so "a row
73
+ * exists" alone would keep this control on an ordinary conversation — the same
74
+ * precedence the agent tag, the context ring and the released banner already
75
+ * apply. Only while the host has not answered (`undefined`) does a bound
76
+ * session count as proof, which is what keeps the control available before the
77
+ * host's first `session/chat-list` answer lands.
78
+ */
79
+ export function agentModeVisible(
80
+ acpMode: boolean | undefined,
81
+ hasBoundSession: boolean,
82
+ ): boolean {
83
+ if (acpMode === false) return false
84
+ return acpMode === true || hasBoundSession
85
+ }
86
+
87
+ /**
88
+ * The mode the select shows as in force.
89
+ *
90
+ * A LIVE session is the authority (it is what the agent runs right now). A
91
+ * RELEASED row only describes the mode the agent was parked ON, so a newer
92
+ * pick — which `applyChatChoice` will apply on the next resume — outranks it
93
+ * (and the parked row still beats the catalog default). Same precedence as
94
+ * the model and thought level in `describeRoute`: a pre-switch on a parked
95
+ * session must be visible or the control reads as dead.
96
+ */
97
+ export function modeInForce(
98
+ target: { released?: boolean; modes?: { currentModeId?: string } } | undefined,
99
+ persisted: string | undefined,
100
+ fallback?: string,
101
+ ): string {
102
+ const live = target !== undefined && target.released !== true ? target.modes?.currentModeId : undefined
103
+ return live ?? persisted ?? target?.modes?.currentModeId ?? fallback ?? ''
104
+ }
105
+
68
106
  export function AcpComposerMode({ acp, sessionId, useSession }: ComposerModeProps) {
69
107
  const ui = useUi(acp)
70
108
  const dshSessionId = String(sessionId)
@@ -85,9 +123,10 @@ export function AcpComposerMode({ acp, sessionId, useSession }: ComposerModeProp
85
123
  // unconditionally), so it must gate itself. The `defaultAgent` fallback below
86
124
  // would otherwise put an ACP mode select into ORDINARY conversations: the
87
125
  // agent id is plugin-global, so "an agent is configured" is not the same
88
- // question as "this conversation is served by one". A live session is itself
89
- // proof; otherwise it is the same host verdict the seat gate reads.
90
- const isAcp = target !== undefined || ui.acpMode[dshSessionId] === true
126
+ // question as "this conversation is served by one". A live session is proof
127
+ // only while the host has not answered; an explicit `false` wins over the
128
+ // lingering row a preset switch leaves behind (see `agentModeVisible`).
129
+ const isAcp = agentModeVisible(ui.acpMode[dshSessionId], target !== undefined)
91
130
  // The agent this conversation is set up on, live session or not. Before the
92
131
  // first message there is no session to read a mode list from, yet the user is
93
132
  // already choosing the agent — and the mode is one of the things they may
@@ -116,6 +155,12 @@ export function AcpComposerMode({ acp, sessionId, useSession }: ComposerModeProp
116
155
  void acp.loadAgentCatalog(agentId)
117
156
  }, [acp, agentId, isAcp, target])
118
157
 
158
+ // The whole control is ACP-only: a lingering live session row survives a
159
+ // preset switch, so its `availableModes` are NOT a licence to render here.
160
+ // Without this guard an ordinary conversation kept an agent-mode select
161
+ // beside its native controls (reported as "the bottom bar lost its native
162
+ // capabilities").
163
+ if (!isAcp) return null
119
164
  const live = target?.modes?.availableModes ?? []
120
165
  // TWO sources, one list. A live session's `session/new` answer is the
121
166
  // authority on which modes exist and which is in force; the probed catalog
@@ -123,20 +168,21 @@ export function AcpComposerMode({ acp, sessionId, useSession }: ComposerModeProp
123
168
  // agent published on ITS throwaway session). A live session always wins.
124
169
  const modes = live.length > 0
125
170
  ? live
126
- : (isAcp ? catalog?.mode?.choices ?? [] : []).map(choice => ({ id: choice.id, name: choice.name, description: choice.description }))
171
+ : (catalog?.mode?.choices ?? []).map(choice => ({ id: choice.id, name: choice.name, description: choice.description }))
127
172
  if (modes.length === 0) return null
128
- // The mode IN FORCE comes from the live session; before one exists the only
129
- // claims available are the agent's own reported current mode and the pick this
130
- // conversation recorded (a pick outranks the agent's default, exactly like the
131
- // model and thought level do in `describeRoute`).
173
+ // The mode IN FORCE: a live session is the authority, before one exists (or
174
+ // while one is parked) the claims available are the pick this conversation
175
+ // recorded and the agent's own reported current mode (a pick outranks the
176
+ // agent's default, exactly like the model and thought level do in
177
+ // `describeRoute`; the parked row yields to the pick — see `modeInForce`).
132
178
  const persisted = ui.chatChoice[dshSessionId]?.agentId === agentId
133
179
  ? ui.chatChoice[dshSessionId]?.mode
134
180
  : undefined
135
- const current = target?.modes?.currentModeId
136
- ?? persisted
137
- ?? catalog?.mode?.currentValue
138
- ?? modes[0]?.id
139
- ?? ''
181
+ const current = modeInForce(
182
+ target,
183
+ persisted,
184
+ catalog?.mode?.currentValue ?? modes[0]?.id,
185
+ )
140
186
 
141
187
  return (
142
188
  <label
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Composer context-occupancy ring at `conversation.composer.dock` — the bottom
3
+ * info row below the input card, where the host also renders its ContextMeter.
4
+ *
5
+ * WHY A PLUGIN-OWNED RING: the host's meter reads its `contextPressure`
6
+ * projection, folded from DSH provider-usage samples plus a route capacity. An
7
+ * ACP conversation produces neither — the agent owns the request and the host
8
+ * never sees its prompt — so the native meter renders nothing there. This ring
9
+ * reports the agent's OWN `usage_update` used/size instead: occupancy only. ACP
10
+ * declares no system/tools/messages split, so no breakdown is invented.
11
+ *
12
+ * Registered for every conversation and gating itself: it renders only for an
13
+ * ACP conversation whose session row carries usage. The row comes from
14
+ * `session/chat-list`; the store live-patches it from the coalesced
15
+ * `usage_update` frames (see `AcpStore.flushUpdates`).
16
+ */
17
+
18
+ import { useEffect } from 'react'
19
+ // Type-only: pulls the ui-conversation SlotMap merge into this program.
20
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
21
+ import { ensureAcpStyle, useUi } from './acp-ui.tsx'
22
+ import type { AcpSessionHook } from './acp-ui.tsx'
23
+ import type { AcpStore, UiState } from './store.ts'
24
+ import type { SessionView } from '../protocol.ts'
25
+
26
+ /** Props of the composer entry (injected face + the frame's standard kit). */
27
+ export interface AcpContextMeterProps {
28
+ /** Injected face: the chat session id this entry belongs to. */
29
+ sessionId: string
30
+ /** Framework-bound conversation snapshot hook (`SessionStandardProps.useSession`). */
31
+ useSession: AcpSessionHook
32
+ acp: AcpStore
33
+ }
34
+
35
+ export interface ContextOccupancy {
36
+ /** Rounded occupancy percentage, clamped to 0..100. */
37
+ percent: number
38
+ used: number
39
+ size: number
40
+ }
41
+
42
+ /**
43
+ * The sample this conversation's ring should show, or `undefined` when the
44
+ * entry must render nothing. An explicit `acpMode === false` outranks a stale
45
+ * `chatSessions` row: the conversation's preset left NTR while its ACP session
46
+ * was still alive, and the ring must leave with it (same precedence as the
47
+ * agent tag's `agentTagVisible`). While the verdict is unknown, a LIVE row is
48
+ * proof; a PARKED one is not — it can outlive the preset switch, and the ring
49
+ * must not appear on an ordinary conversation during the answer window.
50
+ */
51
+ export function acpMeterUsage(
52
+ ui: Pick<UiState, 'acpMode' | 'chatSessions' | 'chatSessionsFor'>,
53
+ dshSessionId: string,
54
+ ): SessionView['usage'] | undefined {
55
+ if (ui.acpMode[dshSessionId] === false) return undefined
56
+ if (ui.chatSessionsFor !== dshSessionId) return undefined
57
+ const live = ui.chatSessions.find(row => !row.released)
58
+ // A PARKED row alone is not proof this conversation is still in NTR mode:
59
+ // switching off the preset leaves the ACP session record alive, and while the
60
+ // host has not answered (`undefined`) the ring must not appear on an
61
+ // ordinary conversation. A live row is proof; a parked one needs the
62
+ // affirmative verdict.
63
+ if (live === undefined && ui.acpMode[dshSessionId] !== true) return undefined
64
+ return (live ?? ui.chatSessions[0])?.usage
65
+ }
66
+
67
+ /**
68
+ * Bounded display occupancy from one agent sample. `undefined` (no meter) for
69
+ * an absent / malformed / zero-capacity sample rather than a made-up 0%.
70
+ */
71
+ export function contextOccupancy(usage: SessionView['usage'] | undefined): ContextOccupancy | undefined {
72
+ if (usage === undefined) return undefined
73
+ const { used, size } = usage
74
+ if (!Number.isFinite(used) || !Number.isFinite(size) || used < 0 || size <= 0) return undefined
75
+ return { percent: Math.min(100, Math.round(used / size * 100)), used, size }
76
+ }
77
+
78
+ /** Compact token count for the tooltip, in the host meter's K/M shape. */
79
+ export function compactTokenCount(value: number): string {
80
+ if (value < 1e3) return String(Math.round(value))
81
+ const thousands = value / 1e3
82
+ if (value < 1e6) {
83
+ return `${thousands >= 100 ? String(Math.round(thousands)) : String(Math.round(thousands * 10) / 10)}K`
84
+ }
85
+ const millions = value / 1e6
86
+ return `${millions >= 100 ? String(Math.round(millions)) : String(Math.round(millions * 10) / 10)}M`
87
+ }
88
+
89
+ /** Ring geometry matches the host ContextMeter: 14px viewBox, 2px stroke. */
90
+ const RADIUS = 5.5
91
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS
92
+
93
+ export function AcpContextMeter({ acp, sessionId, useSession }: AcpContextMeterProps) {
94
+ ensureAcpStyle()
95
+ const ui = useUi(acp)
96
+ const dshSessionId = String(sessionId)
97
+ // A fresh conversation gets its ACP session binding during its first turn,
98
+ // and no SSE frame announces that; the running flip is the re-read trigger
99
+ // every composer entry shares.
100
+ const running = useSession(snapshot => snapshot.running)
101
+ useEffect(() => {
102
+ void acp.loadChatSessions(dshSessionId)
103
+ }, [acp, dshSessionId, running])
104
+
105
+ const context = contextOccupancy(acpMeterUsage(ui, dshSessionId))
106
+ if (context === undefined) return null
107
+
108
+ const title = acp.label(
109
+ `上下文已用 ${String(context.percent)}% · ${compactTokenCount(context.used)} / ${compactTokenCount(context.size)}`,
110
+ `${String(context.percent)}% of context used · ${compactTokenCount(context.used)} / ${compactTokenCount(context.size)}`,
111
+ )
112
+ return (
113
+ <span className="ntr-acp-meter" role="img" aria-label={title} title={title}>
114
+ <svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
115
+ <circle className="ntr-acp-meter-track" cx="7" cy="7" r={RADIUS} />
116
+ <circle
117
+ className="ntr-acp-meter-fill"
118
+ cx="7"
119
+ cy="7"
120
+ r={RADIUS}
121
+ strokeDasharray={`${String(CIRCUMFERENCE * context.percent / 100)} ${String(CIRCUMFERENCE)}`}
122
+ transform="rotate(-90 7 7)"
123
+ />
124
+ </svg>
125
+ <span className="ntr-acp-meter-percent">{context.percent}%</span>
126
+ </span>
127
+ )
128
+ }