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.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +607 -18
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/branch-action.js +133 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +238 -15
- package/lib/client/panel.js +96 -29
- package/lib/client/released-banner.js +66 -0
- package/lib/client/session-controls.js +38 -13
- package/lib/client/slash-isolation.js +2 -2
- package/lib/client/store.js +348 -13
- package/lib/client.js +1138 -67
- package/lib/client.js.map +1 -1
- package/lib/impl.js +54 -1
- package/lib/index.js +7 -0
- package/lib/protocol.js +19 -0
- package/lib/types/bridge.d.ts +229 -6
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/branch-action.d.ts +40 -0
- package/lib/types/client/composer-mode.d.ts +28 -0
- package/lib/types/client/context-meter.d.ts +51 -0
- package/lib/types/client/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +98 -1
- package/lib/types/impl.d.ts +41 -1
- package/lib/types/protocol.d.ts +123 -0
- package/package.json +2 -1
- package/src/bridge.ts +638 -20
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/branch-action.tsx +160 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +265 -14
- package/src/client/panel.tsx +157 -40
- package/src/client/primitives.d.ts +27 -0
- package/src/client/released-banner.tsx +97 -0
- package/src/client/session-controls.tsx +34 -11
- package/src/client/slash-isolation.ts +2 -1
- package/src/client/store.ts +375 -14
- package/src/impl.ts +84 -2
- package/src/index.ts +8 -0
- package/src/protocol.ts +121 -0
package/src/client/panel.tsx
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1723
|
-
|
|
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:
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
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('
|
|
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('
|
|
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
|
-
`留空 =
|
|
1983
|
-
`Blank =
|
|
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
|
-
'
|
|
1996
|
-
'
|
|
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?.
|
|
300
|
-
|
|
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
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|