dsh-ntr 0.3.1 → 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 (48) 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 +607 -18
  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 +96 -29
  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 +348 -13
  16. package/lib/client.js +1138 -67
  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 +229 -6
  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 +98 -1
  30. package/lib/types/impl.d.ts +41 -1
  31. package/lib/types/protocol.d.ts +123 -0
  32. package/package.json +2 -1
  33. package/src/bridge.ts +638 -20
  34. package/src/client/acp-ui.tsx +123 -2
  35. package/src/client/acp-view.tsx +30 -5
  36. package/src/client/branch-action.tsx +160 -0
  37. package/src/client/composer-mode.tsx +59 -13
  38. package/src/client/context-meter.tsx +128 -0
  39. package/src/client/index.tsx +265 -14
  40. package/src/client/panel.tsx +157 -40
  41. package/src/client/primitives.d.ts +27 -0
  42. package/src/client/released-banner.tsx +97 -0
  43. package/src/client/session-controls.tsx +34 -11
  44. package/src/client/slash-isolation.ts +2 -1
  45. package/src/client/store.ts +375 -14
  46. package/src/impl.ts +84 -2
  47. package/src/index.ts +8 -0
  48. package/src/protocol.ts +121 -0
@@ -19,6 +19,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
19
19
  import { formatCommandLine, isAbsolutePathLike, splitCommandLine } from './acp-ui.tsx'
20
20
  import { DEFAULT_KNOWN_PRESETS, LOG_MAX_ENTRIES } from './store.ts'
21
21
  import type { AcpStore, UiState } from './store.ts'
22
+ import { IDLE_RELEASE_MS } from '../protocol.ts'
22
23
  import type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode, PluginUpdateState } from '../protocol.ts'
23
24
 
24
25
  interface WithStore { acp: AcpStore }
@@ -456,20 +457,14 @@ function partitionAgents(
456
457
  * def (env / npmPackage / exposedModels included); the synthesized fallback
457
458
  * covers only the fields an `AgentView` carries, so anything else compares as
458
459
  * "changed" — the honest answer when the host never reported it.
460
+ *
461
+ * The idle-release field is compared AS STORED: an absent per-agent field
462
+ * means "follow the global window", and the host's resolved value must not be
463
+ * written back into it — that would freeze the global window into this agent's
464
+ * definition and the global slider would then never reach it.
459
465
  */
460
- /**
461
- * The idle-release window in effect for an agent: an unset per-agent field is
462
- * resolved by the HOST and reported back on `AgentView`, so filling it from
463
- * there keeps "unset" and "explicitly the host default" the same state —
464
- * merely opening the form must never read as an unsaved change.
465
- */
466
- function resolvedIdleTimeoutMs(def: AcpAgentDef, activeAgent: AgentView | undefined): AcpAgentDef {
467
- if (def.idleTimeoutMs !== undefined || activeAgent?.idleTimeoutMs === undefined) return def
468
- return { ...def, idleTimeoutMs: activeAgent.idleTimeoutMs }
469
- }
470
-
471
466
  function persistedDef(detected: DetectedAgentInfo, activeAgent: AgentView | undefined): AcpAgentDef | undefined {
472
- if (detected.currentConfig !== undefined) return resolvedIdleTimeoutMs(detected.currentConfig, activeAgent)
467
+ if (detected.currentConfig !== undefined) return detected.currentConfig
473
468
  if (activeAgent === undefined) return undefined
474
469
  return {
475
470
  id: activeAgent.id,
@@ -479,7 +474,6 @@ function persistedDef(detected: DetectedAgentInfo, activeAgent: AgentView | unde
479
474
  ...(activeAgent.cwd === undefined ? {} : { cwd: activeAgent.cwd }),
480
475
  allowedCwdRoots: [...activeAgent.allowedCwdRoots],
481
476
  env: {},
482
- ...(activeAgent.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: activeAgent.idleTimeoutMs }),
483
477
  }
484
478
  }
485
479
 
@@ -501,9 +495,10 @@ export function payloadEquals(payload: AgentSavePayload, current: AcpAgentDef):
501
495
  if (exposureKey(payload.exposedModels) !== exposureKey(current.exposedModels)) return false
502
496
  if (mcpKey(payload.mcpServers) !== mcpKey(current.mcpServers)) return false
503
497
  if ((payload.autoDiscoverModels !== false) !== (current.autoDiscoverModels !== false)) return false
504
- // An absent field means "use the host default" — which is what `current`
505
- // already resolved to (see `resolvedIdleTimeoutMs`), so it is not a change.
506
- if (payload.idleTimeoutMs !== undefined && payload.idleTimeoutMs !== current.idleTimeoutMs) return false
498
+ // The per-agent OVERRIDE, compared as stored: an absent field on both sides
499
+ // means "follow the global window" and is not a change; a field on one
500
+ // side only means the user is adding or dropping an override.
501
+ if (payload.idleTimeoutMs !== current.idleTimeoutMs) return false
507
502
  return true
508
503
  }
509
504
 
@@ -877,7 +872,132 @@ function ApprovalDeadlinesCard(props: { acp: AcpStore; ui: UiState }) {
877
872
  )
878
873
  }
879
874
 
880
- type SettingsPage = 'agents' | 'add' | 'approvals' | 'appearance' | 'logs' | 'diagnostics'
875
+ /**
876
+ * The idle-release window, as the settings page edits it.
877
+ *
878
+ * ONE control, because there is ONE window: how long a chat process may sit
879
+ * idle before it is parked. The slider snaps to the shared ladder
880
+ * ({@link IDLE_RELEASE_MS}), so every rung it offers is a window the host can
881
+ * run.
882
+ *
883
+ * The card reports the deployment baseline as the fact being overridden, and
884
+ * offers a reset ONLY when something is really overridden — the same rule the
885
+ * approval deadline card follows.
886
+ */
887
+ function IdleReleaseCard(props: { acp: AcpStore; ui: UiState }) {
888
+ const { acp, ui } = props
889
+ const release = ui.idleRelease
890
+ // Older host half: no card rather than a number this browser invented.
891
+ if (release === undefined) return null
892
+ // The value a RESET returns to: the window `cordis.patch.yml` names, else
893
+ // the built-in default. The same split the approval card keeps — the
894
+ // reset must restore what the deployment asked for, not a constant.
895
+ const baseline = release.defaultIdleTimeoutMs
896
+ // A reset is only honest when something is actually being overridden.
897
+ const overridden = release.idleTimeoutMs !== baseline
898
+ // A window the deployment named that is not on the ladder still runs as
899
+ // configured: the thumb parks on the nearest rung and the readout prints the
900
+ // exact number, so the card never lies about what is in force.
901
+ const index = IDLE_RELEASE_MS.indexOf(release.idleTimeoutMs)
902
+ const exact = index >= 0
903
+ const shown = exact ? release.idleTimeoutMs : nearestIdleRung(release.idleTimeoutMs)
904
+ // The config file's window is worth saying only when it is being
905
+ // outranked — otherwise it IS what runs, and saying "the value set here
906
+ // wins" would name a win nobody claimed.
907
+ const note = release.configuredIdleTimeoutMs === undefined
908
+ || release.configuredIdleTimeoutMs === release.idleTimeoutMs
909
+ ? undefined
910
+ : acp.label(
911
+ `配置文件(cordis.patch.yml)设为 ${idleWindowText(release.configuredIdleTimeoutMs, acp)},此处设置优先。`,
912
+ `The config file (cordis.patch.yml) asks for ${idleWindowText(release.configuredIdleTimeoutMs, acp)}; the value set here wins.`,
913
+ )
914
+ const resetLabel = release.configuredIdleTimeoutMs === undefined
915
+ ? acp.label('恢复默认值', 'Restore default')
916
+ : acp.label('恢复配置文件的值', 'Use config file value')
917
+ return (
918
+ <section className="dwa-section">
919
+ <div className="dwa-section-heading">
920
+ <div>
921
+ <h3 className="dwa-section-title">{acp.label('空闲释放', 'Idle release')}</h3>
922
+ <p className="dwa-section-copy">
923
+ {acp.label('空闲多久后挂起聊天进程。', 'How long an idle chat process is kept before it is parked.')}
924
+ </p>
925
+ </div>
926
+ </div>
927
+ <div className="dwa-preferences">
928
+ <div className="dwa-preference-block">
929
+ <div className="dwa-preference-label">{acp.label('释放窗口', 'Release window')}</div>
930
+ <div className="dwa-deadline-value">{idleWindowText(release.idleTimeoutMs, acp)}</div>
931
+ <input
932
+ className="dwa-deadline-range"
933
+ type="range"
934
+ min={0}
935
+ max={IDLE_RELEASE_MS.length - 1}
936
+ step={1}
937
+ value={exact ? index : IDLE_RELEASE_MS.indexOf(shown)}
938
+ aria-label={acp.label('释放窗口', 'Release window')}
939
+ onChange={event => {
940
+ const next = IDLE_RELEASE_MS[Number(event.target.value)]
941
+ if (next !== undefined && next !== release.idleTimeoutMs) void acp.setIdleRelease(next)
942
+ }}
943
+ />
944
+ <div className="dwa-deadline-ticks">
945
+ {IDLE_RELEASE_MS.filter((_, i) => i % 2 === 0).map(ms => (
946
+ <span key={ms}>{idleWindowText(ms, acp)}</span>
947
+ ))}
948
+ </div>
949
+ <div className="dwa-preference-copy">
950
+ {acp.label(
951
+ '挂起后会话仍保留,下一条消息自动恢复。',
952
+ 'A parked session is kept; the next message resumes it.',
953
+ )}
954
+ </div>
955
+ {note !== undefined && <div className="dwa-deadline-flag">⚠ {note}</div>}
956
+ </div>
957
+ {overridden && (
958
+ <div className="dwa-deadline-reset">
959
+ <button type="button" className="dwa-btn dwa-btn-secondary" onClick={() => void acp.resetIdleRelease()}>
960
+ {resetLabel}
961
+ </button>
962
+ </div>
963
+ )}
964
+ </div>
965
+ </section>
966
+ )
967
+ }
968
+
969
+ /**
970
+ * The rung nearest a window that is not on the ladder — used only to park the
971
+ * thumb; the readout always prints the exact value.
972
+ */
973
+ function nearestIdleRung(ms: number): number {
974
+ let best = IDLE_RELEASE_MS[0]
975
+ let bestDistance = Number.POSITIVE_INFINITY
976
+ for (const rung of IDLE_RELEASE_MS) {
977
+ const distance = Math.abs(rung - ms)
978
+ if (distance < bestDistance) {
979
+ bestDistance = distance
980
+ best = rung
981
+ }
982
+ }
983
+ return best
984
+ }
985
+
986
+ /**
987
+ * `30 分钟` — the readout and the tick label, one formatter.
988
+ *
989
+ * ALWAYS minutes, even at the top of the ladder: a scale that runs "1 分钟"
990
+ * to "50 分钟" and then "1 小时" reads as two different scales.
991
+ */
992
+ function idleWindowText(ms: number, acp: AcpStore): string {
993
+ // 0 is the deployment's way of saying "never release", and reading it as
994
+ // "0 分钟" would say the opposite.
995
+ if (ms <= 0) return acp.label('永不释放', 'Never')
996
+ const minutes = Math.round(ms / 60_000)
997
+ return acp.label(`${String(minutes)} 分钟`, `${String(minutes)}m`)
998
+ }
999
+
1000
+ type SettingsPage = 'agents' | 'add' | 'approvals' | 'processes' | 'appearance' | 'logs' | 'diagnostics'
881
1001
 
882
1002
  /**
883
1003
  * Local wall-clock `HH:MM:SS` for one runtime-log line. Hand-built on purpose:
@@ -1181,6 +1301,7 @@ export function AcpSettingsSection(props: WithStore) {
1181
1301
  <nav className="dwa-tabs" aria-label={acp.label('NTR 设置分类', 'NTR settings categories')}>
1182
1302
  <button type="button" aria-pressed={page === 'agents'} onClick={() => setPage('agents')}>{acp.label('智能体', 'Agents')}</button>
1183
1303
  {ui.approvalDeadlines !== undefined && <button type="button" aria-pressed={page === 'approvals'} onClick={() => setPage('approvals')}>{acp.label('审批时限', 'Approval deadlines')}</button>}
1304
+ {ui.idleRelease !== undefined && <button type="button" aria-pressed={page === 'processes'} onClick={() => setPage('processes')}>{acp.label('进程管理', 'Processes')}</button>}
1184
1305
  <button type="button" aria-pressed={page === 'appearance'} onClick={() => setPage('appearance')}>{acp.label('对话外观', 'Appearance')}</button>
1185
1306
  <button type="button" aria-pressed={page === 'logs'} onClick={() => setPage('logs')}>
1186
1307
  {acp.label('日志', 'Logs')}
@@ -1317,6 +1438,7 @@ export function AcpSettingsSection(props: WithStore) {
1317
1438
  </section>
1318
1439
 
1319
1440
  <div hidden={page !== 'approvals'}><ApprovalDeadlinesCard acp={acp} ui={ui} /></div>
1441
+ <div hidden={page !== 'processes'}><IdleReleaseCard acp={acp} ui={ui} /></div>
1320
1442
 
1321
1443
  <section className="dwa-section" hidden={page !== 'appearance'} aria-label={acp.label('对话外观', 'Appearance')}>
1322
1444
  <div className="dwa-section-heading">
@@ -1683,14 +1805,6 @@ interface AgentForm {
1683
1805
  mcpText: string
1684
1806
  }
1685
1807
 
1686
- /**
1687
- * Hand copy of `DEFAULT_TUNING.idleTimeoutMs` (src/bridge.ts): the browser
1688
- * cannot import the host plane, and this number is only ever placeholder text
1689
- * for a NOT-yet-configured agent (a configured one renders the host's resolved
1690
- * value from `AgentView.idleTimeoutMs`). Keep the two in sync.
1691
- */
1692
- const DEFAULT_IDLE_RELEASE_MS = 1_800_000
1693
-
1694
1808
  /**
1695
1809
  * Hand copy of `DEFAULT_TUNING.probeIdleTimeoutMs` (src/bridge.ts). A process
1696
1810
  * that has never hosted a session — the model probe / post-save probe — lives
@@ -1719,8 +1833,11 @@ export function initialForm(detected: DetectedAgentInfo, activeAgent: AgentView
1719
1833
  envRows: Object.entries(config?.env ?? {}).map(([key, value]) => ({ key, value })),
1720
1834
  exposed: config?.exposedModels === undefined ? undefined : [...config.exposedModels],
1721
1835
  autoDiscoverModels: config?.autoDiscoverModels !== false,
1722
- // Prefer the host's resolved value: it is the one actually in effect.
1723
- idleStr: idleText(activeAgent?.idleTimeoutMs ?? config?.idleTimeoutMs),
1836
+ // The agent's OWN override only. The host's resolved value is NOT
1837
+ // written back here: it would freeze the global window into this
1838
+ // agent's definition, and the global slider would then never reach it.
1839
+ // An empty field means "follow the global window".
1840
+ idleStr: idleText(config?.idleTimeoutMs),
1724
1841
  // The MCP list comes back from the host in its NORMALIZED shape (defaults
1725
1842
  // filled in), so re-serializing it reproduces exactly what is stored —
1726
1843
  // that is what keeps the card from being permanently dirty.
@@ -1963,24 +2080,24 @@ function AgentDetails({
1963
2080
  </div>
1964
2081
  </div>
1965
2082
 
1966
- {/* Idle release: scoped to agents with NO live session (see
1967
- AgentBridge.releaseIfIdle), so it reclaims the processes that the
1968
- model probe / post-save probe leave behind without ever costing a
1969
- chat a cold start. TWO windows exist (AgentBridge.currentIdleTimeoutMs):
1970
- this field is the chat window of a process that HAS hosted a session;
1971
- a never-used process (a probe) always gets the short probe window and
1972
- ignores this field — which is why the copy below names both. */}
2083
+ {/* Idle release: an OVERRIDE of the global window (「进程管理」). Empty
2084
+ means "follow the global window", so the global slider reaches every
2085
+ agent that does not pin its own. TWO windows exist
2086
+ (AgentBridge.currentIdleTimeoutMs): this field is the chat window of
2087
+ a process that HAS hosted a session; a never-used process (a probe)
2088
+ always gets the short probe window and ignores this field — which is
2089
+ why the copy below names both. */}
1973
2090
  <div className="dwa-form-row">
1974
2091
  <div className="dwa-form-col">
1975
- <label className="dwa-form-label">{acp.label('空闲释放(毫秒)', 'Idle Release (ms)')}</label>
2092
+ <label className="dwa-form-label">{acp.label('空闲释放覆盖(毫秒)', 'Idle Release Override (ms)')}</label>
1976
2093
  <input
1977
2094
  className="dwa-input dwa-input-mono"
1978
- aria-label={acp.label('空闲释放(毫秒)', 'Idle Release (ms)')}
2095
+ aria-label={acp.label('空闲释放覆盖(毫秒)', 'Idle Release Override (ms)')}
1979
2096
  value={form.idleStr}
1980
2097
  onChange={e => patch({ idleStr: e.target.value })}
1981
2098
  placeholder={acp.label(
1982
- `留空 = 宿主默认(承载会话 ${String(DEFAULT_IDLE_RELEASE_MS / 60_000)} 分钟 / 探测进程 ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} 分钟)`,
1983
- `Blank = host default (${String(DEFAULT_IDLE_RELEASE_MS / 60_000)} min for a chat process / ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} min for a probe)`,
2099
+ `留空 = 跟随全局设置(探测进程固定 ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} 分钟)`,
2100
+ `Blank = follow the global setting (probes always release after ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} min)`,
1984
2101
  )}
1985
2102
  />
1986
2103
  <span className={idleInvalid ? 'dwa-error' : 'dwa-hint'}>
@@ -1992,8 +2109,8 @@ function AgentDetails({
1992
2109
  : (
1993
2110
  <>
1994
2111
  {acp.label(
1995
- '承载过会话的进程空闲后挂起;0 不释放,探测进程固定 5 分钟。',
1996
- 'Park idle chat processes; 0 keeps them alive. Probe processes always release after 5 minutes.',
2112
+ '仅覆盖此智能体;留空即跟随「进程管理」的全局设置。',
2113
+ 'Overrides this agent only; leave blank to follow the global setting in “Processes”.',
1997
2114
  )}
1998
2115
  </>
1999
2116
  )}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Type shim for `@deepseek-ai/dsh-client-ui-primitives` — a HOST-provided
3
+ * client module (resolved by the shell's lazy CJS module table at runtime, so
4
+ * it is intentionally absent from devDependencies and listed in
5
+ * tsdown.config.mjs's EXTERNAL + package.json `dsh.client.inject`).
6
+ *
7
+ * Only the members this bundle uses are declared, structurally: the host
8
+ * package is not part of a plugin's dependency closure, and the full surface
9
+ * would be a second copy of types this plugin cannot import anyway. The
10
+ * branch icons are the candidates the host's own branch button renders
11
+ * (`MessageIconActions`) across shipped host generations — desktop 0.2.1-rc.2
12
+ * exports `IconBranchOutlineRegular` (and `…Medium`) but NOT `…16`, while
13
+ * newer packages export `…16` — which is why `branch-action.tsx` picks the
14
+ * first present one instead of hard-importing a single name.
15
+ */
16
+ declare module '@deepseek-ai/dsh-client-ui-primitives' {
17
+ import type { ComponentType, ReactNode } from 'react'
18
+
19
+ export const IconBranchOutline16: ComponentType<Record<string, never>> | undefined
20
+ export const IconBranchOutlineRegular: ComponentType<Record<string, never>> | undefined
21
+ export const IconBranchOutlineMedium: ComponentType<Record<string, never>> | undefined
22
+ export const Tooltip: ComponentType<{
23
+ label: ReactNode
24
+ side?: 'top' | 'bottom' | 'left' | 'right'
25
+ children: ReactNode
26
+ }>
27
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The conversation's released-session banner: one row above the composer
3
+ * (`conversation.input.dock`) saying the agent process behind this
4
+ * conversation has been released, with the explicit resume button.
5
+ *
6
+ * WHY A BANNER: releasing a process is deliberately silent (constraint 19 —
7
+ * an idle teardown is the expected lifecycle, no `agent/notice`), and the
8
+ * only other trace was the NTR view tab's status badge — a surface most
9
+ * users never open. The "your next message resumes it" promise is real but
10
+ * unverifiable from the chat itself, so the banner states it here and the
11
+ * button turns it into a gesture (`session/chat-resume`), the explicit form
12
+ * of the same recovery the next prompt performs lazily.
13
+ *
14
+ * The gate is a pure function ({@link releasedBannerVisible}) — the seam the
15
+ * composer's other visibility decisions use, so the rules are testable with
16
+ * no DOM. Like the agent tag and the context ring, an explicit
17
+ * `acpMode === false` outranks a lingering `chatSessions` row: a conversation
18
+ * that left NTR mode must not keep claiming a parked NTR process.
19
+ */
20
+
21
+ import { useEffect, useState } from 'react'
22
+ import { ensureAcpStyle, useUi } from './acp-ui.tsx'
23
+ import type { AcpStore, UiState } from './store.ts'
24
+
25
+ export interface ReleasedBannerProps {
26
+ /** Injected face: the store plus the chat session id this banner belongs to. */
27
+ acp: AcpStore
28
+ sessionId: string
29
+ }
30
+
31
+ /**
32
+ * Whether the banner should render: this conversation's ACP session records
33
+ * are all released — the process is parked (idle release or crash) and
34
+ * nothing is live to serve the next message yet. An empty list (no agent has
35
+ * run here) and a live row anywhere both mean "nothing to recover", so the
36
+ * banner stays out of the way.
37
+ */
38
+ export function releasedBannerVisible(
39
+ ui: Pick<UiState, 'acpMode' | 'chatSessions' | 'chatSessionsFor'>,
40
+ dshSessionId: string,
41
+ ): boolean {
42
+ if (ui.acpMode[dshSessionId] === false) return false
43
+ if (ui.chatSessionsFor !== dshSessionId) return false
44
+ return ui.chatSessions.length > 0 && ui.chatSessions.every(row => row.released === true)
45
+ }
46
+
47
+ export function ReleasedBanner({ acp, sessionId }: ReleasedBannerProps) {
48
+ ensureAcpStyle()
49
+ const ui = useUi(acp)
50
+ const dshSessionId = String(sessionId)
51
+ const [busy, setBusy] = useState(false)
52
+ const [error, setError] = useState<string>()
53
+ // The binding has no announcing frame on load (a conversation opened on an
54
+ // already-parked session shows nothing else that would read the list), so
55
+ // this entry reads it once itself — deduped in flight by the store.
56
+ useEffect(() => {
57
+ void acp.loadChatSessions(dshSessionId)
58
+ }, [acp, dshSessionId])
59
+ if (!releasedBannerVisible(ui, dshSessionId)) return null
60
+ const resume = (): void => {
61
+ if (busy) return
62
+ setBusy(true)
63
+ setError(undefined)
64
+ void acp.resumeChatSession(dshSessionId)
65
+ // `resumeChatSession` reports to the store's notice too, but that notice
66
+ // lives on the settings page; this pins the failure where the user is
67
+ // looking (same contract as the permission card).
68
+ .catch((failure: unknown) => {
69
+ setError(failure instanceof Error ? failure.message : String(failure))
70
+ })
71
+ .finally(() => { setBusy(false) })
72
+ }
73
+ return (
74
+ <div className="ntr-released-banner" role="region" aria-label={acp.label('进程已释放', 'Process released')}>
75
+ <div className="ntr-released-banner-row">
76
+ <div className="ntr-released-banner-text">
77
+ <span className="ntr-released-banner-title">{acp.label('进程已释放', 'Process released')}</span>
78
+ <span className="ntr-released-banner-body">
79
+ {acp.label('上下文已保留;下一条消息将自动恢复,或点击【恢复进程】按钮。', 'Context is kept — your next message resumes it, or click [Resume process].')}
80
+ </span>
81
+ </div>
82
+ <button
83
+ type="button"
84
+ className="ntr-acp-more is-primary"
85
+ disabled={busy}
86
+ title={acp.label('立即拉起 agent 进程并恢复这个会话', 'Bring the agent process back and restore this session now')}
87
+ onClick={resume}
88
+ >
89
+ {busy ? acp.label('正在恢复…', 'Resuming…') : acp.label('恢复进程', 'Resume process')}
90
+ </button>
91
+ </div>
92
+ {error !== undefined && (
93
+ <div className="ntr-permission-error" role="alert">{error}</div>
94
+ )}
95
+ </div>
96
+ )
97
+ }
@@ -295,9 +295,20 @@ export function describeRoute(
295
295
  // pick a conversation set up ahead of its first message could not show the
296
296
  // model and thought level it is about to use, which is the whole point of
297
297
  // choosing them early.
298
+ //
299
+ // A RELEASED row flips the precedence: it only describes what ran when the
300
+ // process was parked, while the pick is what the next resume applies
301
+ // (`applyChatChoice`) — a newer pick must outrank it, or a pre-switch on a
302
+ // parked session looks like a dead control (the chip snaps back to the
303
+ // parked value on every re-read). The parked row still beats nothing, so a
304
+ // conversation that never picked keeps naming what it ran.
298
305
  const pick = choice !== undefined && choice.agentId === agentId ? choice : undefined
299
- const modelId = bound?.modelId ?? pick?.modelId
300
- const effort = bound?.reasoningEffort ?? pick?.effort
306
+ const modelId = bound?.released === true
307
+ ? (pick?.modelId ?? bound?.modelId)
308
+ : (bound?.modelId ?? pick?.modelId)
309
+ const effort = bound?.released === true
310
+ ? (pick?.effort ?? bound?.reasoningEffort)
311
+ : (bound?.reasoningEffort ?? pick?.effort)
301
312
  const reasoning = catalog?.reasoning
302
313
  // The model the AGENT says it is on, for a conversation nobody has picked a
303
314
  // model for yet. Without it the chip has no label and does not render at all,
@@ -554,6 +565,16 @@ export function AcpSessionControls(props: AcpClusterProps) {
554
565
  useEffect(() => {
555
566
  if (served && agentListed && agentId !== undefined) void acp.loadAgentCatalog(agentId)
556
567
  }, [acp, served, agentListed, agentId])
568
+ // The picker could already be open when the first turn starts (clicking the
569
+ // send button is inside the composer card, which AcpMenu treats as its own
570
+ // furniture, so it does not close the menu). Once locked, that menu can only
571
+ // offer a switch the host will refuse — close it instead of leaving a live
572
+ // control over a locked conversation. Kept above every early return so the
573
+ // component never changes its hook order between renders.
574
+ useEffect(() => {
575
+ if (lockedTo === undefined || menu === null) return
576
+ if (menu.sessionId === sessionId && menu.kind === 'agent') closeAcpMenu()
577
+ }, [lockedTo, menu?.sessionId, menu?.kind, sessionId])
557
578
  const route = describeRoute(
558
579
  agents,
559
580
  bound,
@@ -579,15 +600,14 @@ export function AcpSessionControls(props: AcpClusterProps) {
579
600
  // base never comes back, and the tag is the only place the agent's name
580
601
  // lives.
581
602
  //
582
- // The picker could already be open when the first turn starts (clicking the
583
- // send button is inside the composer card, which AcpMenu treats as its own
584
- // furniture, so it does not close the menu). Once locked, that menu can only
585
- // offer a switch the host will refuse — close it instead of leaving a live
586
- // control over a locked conversation.
587
- useEffect(() => {
588
- if (lockedTo === undefined || menu === null) return
589
- if (menu.sessionId === sessionId && menu.kind === 'agent') closeAcpMenu()
590
- }, [lockedTo, menu?.sessionId, menu?.kind, sessionId])
603
+ // The gate releases this cell when the conversation leaves ACP mode, and it
604
+ // only ever installs the claim for a session whose verdict it has already
605
+ // folded as TRUE — so the occupant requires that same affirmative fact. A
606
+ // claim held for one more evaluation (or a layout with more than one visible
607
+ // session) must never put NTR chips on an ordinary conversation's bar.
608
+ // Placed AFTER every hook: an early return above a hook would change the
609
+ // hook order between renders.
610
+ if (ui.acpMode[sessionId] !== true) return null
591
611
  // Keep the base mounted for the shatter half even though the lock already
592
612
  // retired it: unmounting on the same commit the lock arrives would skip the
593
613
  // transition entirely (the tag waits for the same phase to leave 'shatter').
@@ -1008,6 +1028,9 @@ export function AcpSessionMenu(props: AcpClusterProps) {
1008
1028
  }, [acp, menu, served, agentId])
1009
1029
 
1010
1030
  if (menu === null || menu.sessionId !== sessionId) return null
1031
+ // A stale picker must not outlive the conversation's ACP mode (the gate
1032
+ // releases the view/tab, but the overlay is unconditionally registered).
1033
+ if (ui.acpMode[sessionId] !== true) return null
1011
1034
  const route = describeRoute(
1012
1035
  agents,
1013
1036
  bound,
@@ -131,8 +131,9 @@ export function slashAcpVerdict(
131
131
  snapshot: AcpSessionsSnapshot | undefined,
132
132
  sessionId: string,
133
133
  verdict: (sessionId: string) => boolean | undefined,
134
+ currentSessionId?: string,
134
135
  ): boolean | undefined {
135
- const staged = stagedSessionOf(snapshot)
136
+ const staged = stagedSessionOf(snapshot, currentSessionId)
136
137
  if (staged !== undefined && staged.sessionId === sessionId) {
137
138
  const listed = rowAgentPreset(staged.row)
138
139
  if (listed !== undefined) return listed === ACP_PRESET_ID