dsh-ntr 0.2.0 → 0.3.0

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.
@@ -19,7 +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 } from './store.ts'
21
21
  import type { AcpStore, UiState } from './store.ts'
22
- import type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode } from '../protocol.ts'
22
+ import type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode, PluginUpdateState } from '../protocol.ts'
23
23
 
24
24
  interface WithStore { acp: AcpStore }
25
25
 
@@ -57,18 +57,34 @@ const STYLE = `
57
57
  --ntr-success: color-mix(in srgb, var(--dsw-alias-state-success-primary, #16a34a) 72%, var(--dsw-alias-label-primary, #111827));
58
58
  --ntr-accent: var(--dsw-alias-button-primary-fill, #2563eb);
59
59
  width: 100%; max-width: 960px; box-sizing: border-box; margin: 0 auto;
60
- padding: 20px; overflow: auto; font-size: var(--ntr-font); line-height: 1.55;
60
+ padding: 28px 32px; min-width: 0; container-type: inline-size; font-size: var(--ntr-font); line-height: 1.55;
61
61
  }
62
- .dwa-root button, .dwa-root input, .dwa-root select { font-family: inherit; }
62
+ .dwa-root [hidden] { display: none !important; }
63
+ .dwa-root button, .dwa-root input, .dwa-root select, .dwa-root textarea { font-family: inherit; }
64
+ .dwa-root input, .dwa-root textarea { min-width: 0; }
65
+ .dwa-tabs { display: flex; gap: 24px; flex-wrap: wrap; border-bottom: var(--ntr-line); margin-bottom: 24px; }
66
+ .dwa-tabs > button { font: inherit; color: var(--ntr-muted); background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 0 0 12px; min-height: 40px; white-space: nowrap; cursor: pointer; }
67
+ .dwa-tabs > button:hover { color: var(--dsw-alias-label-primary, #111827); }
68
+ .dwa-tabs > button[aria-pressed="true"] { color: var(--dsw-alias-label-primary, #111827); border-bottom-color: currentColor; font-weight: 550; }
69
+ .dwa-detail-tabs { margin: 4px 0 20px; gap: 20px; }
70
+ .dwa-detail-tabs > button { font-size: var(--ntr-font-sm); }
71
+ .dwa-row-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
72
+ .dwa-notice { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
73
+ .dwa-notice > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
74
+ .dwa-empty { text-align: center; padding: 40px 16px; color: var(--ntr-muted); }
75
+ .dwa-empty h3 { font-size: 16px; font-weight: 550; color: var(--dsw-alias-label-primary, #111827); margin: 0 0 8px; }
76
+ .dwa-empty .dwa-btn { margin-top: 20px; }
77
+ .dwa-health { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 24px; color: var(--ntr-muted); font-size: var(--ntr-font-sm); flex-wrap: wrap; }
78
+ .dwa-health-status { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
79
+ .dwa-health-status > span:last-child:not(:nth-child(2)) { padding-left: 12px; border-left: var(--ntr-line); }
80
+ .dwa-back { margin-bottom: 16px; }
63
81
  .dwa-root button:focus-visible, .dwa-root input:focus-visible, .dwa-root select:focus-visible {
64
82
  outline: 2px solid var(--ntr-accent); outline-offset: 2px;
65
83
  }
66
- .dwa-page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
84
+ .dwa-page-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; }
67
85
  .dwa-page-heading { min-width: 0; }
68
- .dwa-page-title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 650; letter-spacing: -0.02em; }
86
+ .dwa-page-title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 550; letter-spacing: -0.02em; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
69
87
  .dwa-page-description { margin: 5px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-sm); }
70
- .dwa-page-status { display: flex; align-items: center; gap: 8px; margin-top: 10px; color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
71
- .dwa-page-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
72
88
  .dwa-btn {
73
89
  min-height: 32px; box-sizing: border-box; font: inherit; border-radius: var(--ntr-radius-sm);
74
90
  border: 1px solid var(--dsw-alias-border-l2, #d1d5db); background: var(--dsw-alias-bg-layer-1, #fff);
@@ -81,14 +97,16 @@ const STYLE = `
81
97
  .dwa-btn-primary { background: var(--ntr-accent); color: var(--dsw-alias-label-primary-foreground, #fff); border-color: transparent; font-weight: 550; }
82
98
  .dwa-btn-primary:hover { background: var(--ntr-accent); border-color: transparent; opacity: 0.88; }
83
99
  .dwa-btn-secondary { background: var(--dsw-alias-bg-layer-2, #f3f4f6); }
100
+ .dwa-btn-quiet { background: transparent; border-color: transparent; color: var(--ntr-muted); }
101
+ .dwa-btn-quiet:hover { border-color: transparent; color: var(--dsw-alias-label-primary, #111827); }
84
102
  .dwa-btn-danger { color: var(--ntr-danger); border-color: var(--dsw-alias-state-error-secondary, #fca5a5); }
85
103
  .dwa-btn-danger:hover { background: var(--dsw-alias-interactive-bg-hover-danger, #fee2e2); }
86
104
  .dwa-section { margin-bottom: 24px; }
87
105
  .dwa-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
88
- .dwa-section-title { font-size: 14px; font-weight: 650; margin: 0; display: flex; align-items: center; gap: 8px; }
106
+ .dwa-section-title { font-size: 13px; font-weight: 550; margin: 0; display: flex; align-items: center; gap: 8px; }
89
107
  .dwa-section-copy { margin: 3px 0 0; font-size: var(--ntr-font-xs); color: var(--ntr-muted); }
90
108
  .dwa-card { border: var(--ntr-line); border-radius: var(--ntr-radius); padding: 16px; background: var(--dsw-alias-bg-layer-1, #fff); font-size: var(--ntr-font); line-height: 1.55; }
91
- .dwa-card-warn { border-left: 3px solid var(--dsw-alias-state-warn-primary, #d97706); background: var(--dsw-alias-state-warn-tertiary, #fffbeb); }
109
+ .dwa-card-warn { border-color: color-mix(in srgb, var(--ntr-warn) 30%, var(--dsw-alias-border-l2, #e5e7eb)); background: var(--dsw-alias-state-warn-tertiary, #fffbeb); }
92
110
  .dwa-mode-banner { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 16px; align-items: start; margin-bottom: 24px; padding: 14px 16px; }
93
111
  .dwa-mode-title { display: flex; align-items: center; gap: 8px; font-weight: 650; }
94
112
  .dwa-mode-copy { grid-column: 1 / -1; color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
@@ -97,26 +115,36 @@ const STYLE = `
97
115
  .dwa-badge-success { background: var(--dsw-alias-state-success-tertiary, #dcfce7); color: var(--ntr-success); }
98
116
  .dwa-badge-warn { background: var(--dsw-alias-state-warn-tertiary, #fef3c7); color: var(--ntr-warn); }
99
117
  .dwa-badge-info { background: var(--dsw-alias-state-business-tertiary, #dbeafe); color: var(--dsw-alias-state-business-primary, #2563eb); }
118
+ /* The update badge is a button (it expands the update card): strip the
119
+ browser's default button chrome, keep the badge look. */
120
+ .dwa-update-badge { border: 0; font-family: inherit; cursor: pointer; }
121
+ .dwa-update-badge:hover { opacity: 0.85; }
122
+ /* The version badge doubles as the update entry: same badge look, no chrome. */
123
+ .dwa-version-btn { border: 0; font-family: inherit; cursor: pointer; }
124
+ .dwa-version-btn:hover { opacity: 0.85; }
125
+ .dwa-version { font-family: var(--ds-font-family-code, monospace); }
126
+ /* The update card sits between the page header and the permission mirror. */
127
+ .dwa-update-card { margin-bottom: 16px; }
128
+ .dwa-update-summary { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-weight: 550; }
129
+ .dwa-update-copy { margin: 6px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
130
+ .dwa-update-message { margin: 6px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-xs); white-space: pre-wrap; overflow-wrap: anywhere; }
131
+ .dwa-update-actions { margin-top: 10px; }
100
132
  .dwa-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
101
- .dwa-agent-list { display: flex; flex-direction: column; gap: 8px; }
102
- .dwa-agent-card { border: var(--ntr-line); border-radius: var(--ntr-radius); background: var(--dsw-alias-bg-layer-1, #fff); overflow: visible; transition: border-color 0.15s ease; }
103
- .dwa-agent-card.is-open { border-color: var(--dsw-alias-border-l1, #c7ccd1); }
104
- .dwa-agent-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 12px 14px; }
133
+ .dwa-agent-list { display: flex; flex-direction: column; border-top: var(--ntr-line); }
134
+ .dwa-agent-card { border-bottom: var(--ntr-line); min-width: 0; }
135
+ .dwa-agent-row { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 16px 0; }
136
+ .dwa-agent-avatar { display: grid; place-items: center; width: 38px; height: 38px; border: var(--ntr-line); border-radius: 9px; background: var(--dsw-alias-bg-layer-2, #f3f4f6); color: var(--ntr-muted); font: 12px var(--ds-font-family-code, monospace); }
105
137
  .dwa-caret { font-size: 14px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; width: 1em; }
106
138
  .dwa-caret-btn { font: inherit; border: none; background: var(--dsw-alias-bg-layer-2, #f3f4f6); width: 28px; height: 28px; padding: 0; border-radius: var(--ntr-radius-sm); display: inline-flex; align-items: center; justify-content: center; color: var(--ntr-muted); cursor: pointer; }
107
139
  .dwa-caret-btn:hover { background: var(--dsw-alias-interactive-bg-active, #e5e7eb); color: inherit; }
108
140
  .dwa-agent-summary { min-width: 0; }
109
141
  .dwa-agent-titleline { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }
110
- .dwa-agent-name { font-size: 13px; font-weight: 650; overflow-wrap: anywhere; }
142
+ .dwa-agent-name { font-size: 13px; font-weight: 550; overflow-wrap: anywhere; }
111
143
  .dwa-agent-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
112
144
  .dwa-agent-meta-separator { opacity: 0.45; }
113
- .dwa-agent-body { border-top: var(--ntr-line); padding: 0 16px 16px; }
114
- .dwa-subsection { padding-top: 16px; }
115
- .dwa-subsection + .dwa-subsection { margin-top: 16px; border-top: var(--ntr-line); }
116
- .dwa-subsection-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
117
- .dwa-subsection-title { margin: 0; font-size: 13px; font-weight: 650; }
118
- .dwa-subsection-copy { margin: 3px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
119
- .dwa-form-row { display: flex; gap: var(--ntr-gap-3); margin-bottom: 14px; align-items: flex-start; }
145
+ .dwa-agent-body { padding: 0 0 24px 52px; }
146
+ .dwa-subsection { min-width: 0; }
147
+ .dwa-form-row { display: flex; gap: var(--ntr-gap-3); margin-bottom: 14px; align-items: stretch; }
120
148
  .dwa-form-col { flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
121
149
  .dwa-form-label { font-size: var(--ntr-font-sm); font-weight: 600; color: var(--dsw-alias-label-primary, #374151); }
122
150
  .dwa-hint { font-size: var(--ntr-font-xs); line-height: 1.55; color: var(--ntr-muted); }
@@ -125,11 +153,13 @@ const STYLE = `
125
153
  .dwa-select { min-height: 32px; font: inherit; font-size: var(--ntr-font-sm); padding: 5px 8px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); border-radius: var(--ntr-radius-sm); background: var(--dsw-alias-bg-layer-1, #fff); color: inherit; }
126
154
  .dwa-input:hover, .dwa-select:hover { border-color: var(--dsw-alias-border-l1, #9ca3af); }
127
155
  .dwa-input:focus, .dwa-select:focus { border-color: var(--ntr-accent); }
128
- .dwa-input-mono, .dwa-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
156
+ .dwa-input-mono, .dwa-mono { font-family: var(--ds-font-family-code, ui-monospace), SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
129
157
  .dwa-stderr { max-height: 120px; overflow: auto; font-family: monospace; font-size: var(--ntr-font-xs); white-space: pre-wrap; color: var(--ntr-warn); margin: 12px 0 0; }
130
158
  .dwa-default-hint { background: var(--dsw-alias-bg-layer-2, #f9fafb); border: var(--ntr-line); border-radius: var(--ntr-radius-sm); padding: 10px 12px; font-size: var(--ntr-font-sm); display: flex; align-items: center; justify-content: space-between; margin: 12px 0 14px; gap: 12px; }
131
159
  .dwa-form-actions { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding-top: 14px; border-top: var(--ntr-line); flex-wrap: wrap; }
132
160
  .dwa-form-actions .dwa-btn-primary { min-width: 88px; }
161
+ .dwa-form-actions > .dwa-hint:first-child { margin-right: auto; }
162
+ .dwa-form-actions > .dwa-hint:last-child:not(:first-child) { width: 100%; }
133
163
  .dwa-menu-wrap { position: relative; }
134
164
  .dwa-menu-wrap > .dwa-btn { width: 32px; padding: 0; }
135
165
  .dwa-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; min-width: 190px; border: var(--ntr-line); border-radius: var(--ntr-radius-sm); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); padding: 4px; display: flex; flex-direction: column; }
@@ -137,10 +167,10 @@ const STYLE = `
137
167
  .dwa-menu-item:hover { background: var(--dsw-alias-interactive-bg-active, #f3f4f6); }
138
168
  .dwa-menu-item-danger { color: var(--ntr-danger); }
139
169
  .dwa-menu-confirm { display: flex; align-items: center; gap: 4px; padding: 4px; }
140
- .dwa-add-panel { border: var(--ntr-line); border-radius: var(--ntr-radius); background: var(--dsw-alias-bg-layer-1, #fff); }
141
- .dwa-add-toggle { width: 100%; border: 0; background: transparent; color: inherit; padding: 13px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; text-align: left; cursor: pointer; border-radius: var(--ntr-radius); }
142
- .dwa-add-toggle:hover { background: var(--dsw-alias-interactive-bg-active, #f9fafb); }
143
- .dwa-add-content { border-top: var(--ntr-line); padding: 14px 16px 16px; }
170
+ .dwa-add-panel .dwa-agent-row { display: none; }
171
+ .dwa-add-panel .dwa-agent-body { padding: 0 0 24px; }
172
+ .dwa-add-panel .dwa-agent-card { border: 0; }
173
+ .dwa-add-content { padding-top: 10px; }
144
174
  .dwa-chip { min-height: 30px; font: inherit; font-size: var(--ntr-font-sm); border: var(--ntr-line); border-radius: 999px; padding: 4px 12px; background: var(--dsw-alias-bg-layer-1, #fff); cursor: pointer; color: inherit; }
145
175
  .dwa-chip:hover { background: var(--dsw-alias-interactive-bg-active, #f3f4f6); }
146
176
  .dwa-chip-active { border-color: var(--ntr-accent); background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #2563eb) 8%, transparent); color: var(--dsw-alias-state-business-primary, #2563eb); font-weight: 550; }
@@ -172,13 +202,13 @@ const STYLE = `
172
202
  .dwa-model-status { margin-left: auto; white-space: nowrap; }
173
203
  .dwa-model-search { width: 200px; min-height: 30px; }
174
204
  .dwa-model-empty { padding: 6px; }
175
- .dwa-model-row { display: flex; align-items: center; gap: 6px; width: 100%; border: none; background: none; padding: 5px 6px; border-radius: 5px; cursor: pointer; color: inherit; font: inherit; font-size: var(--ntr-font-sm); text-align: left; }
205
+ .dwa-model-row { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 38px; border: none; background: none; padding: 7px 8px; border-radius: 5px; cursor: pointer; color: inherit; font: inherit; font-size: var(--ntr-font-sm); text-align: left; }
176
206
  .dwa-model-row:hover { background: var(--dsw-alias-interactive-bg-active, #f3f4f6); }
177
207
  .dwa-model-row.is-on { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #2563eb) 8%, transparent); color: var(--dsw-alias-state-business-primary, #2563eb); }
178
208
  .dwa-model-row-mark { width: 12px; flex: none; text-align: center; opacity: 0.75; }
179
209
  .dwa-model-row-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
180
210
  /* Name and credit keep their room; the id is the only cell that collapses. */
181
- .dwa-model-row-desc { flex: 0 0 auto; max-width: 45%; opacity: 0.75; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
211
+ .dwa-model-row-desc { flex: 0 0 auto; max-width: 45%; opacity: 0.75; font-size: var(--ntr-font-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
182
212
  /* The trailing 14px is reserved for the fade mask: while the id fits, the
183
213
  gradient only covers empty padding; once it overflows, its tail dissolves
184
214
  instead of ending in a hard ellipsis. armModelIdMarquee reads the same
@@ -192,12 +222,12 @@ const STYLE = `
192
222
  }
193
223
  .dwa-model-row-remove { flex: none; opacity: 0.5; padding: 0 2px; }
194
224
  .dwa-model-row:hover .dwa-model-row-remove { opacity: 1; }
195
- .dwa-seg { display: inline-flex; border-radius: var(--ntr-radius-sm); border: 1px solid var(--dsw-alias-border-l2, #d1d5db); overflow: hidden; vertical-align: middle; }
196
- .dwa-seg > button { min-height: 30px; font: inherit; font-size: var(--ntr-font-sm); color: inherit; border: none; background: var(--dsw-alias-bg-layer-1, #fff); padding: 4px 14px; cursor: pointer; }
197
- .dwa-seg > button + button { border-left: 1px solid var(--dsw-alias-border-l2, #d1d5db); }
225
+ .dwa-seg { display: inline-flex; gap: 4px; padding: 4px; box-sizing: border-box; border-radius: var(--ntr-radius-sm); border: var(--ntr-line); background: var(--dsw-alias-bg-layer-2, #f3f4f6); vertical-align: middle; }
226
+ .dwa-seg > button { min-height: 32px; font: inherit; font-size: var(--ntr-font-sm); color: var(--ntr-muted); border: 1px solid transparent; border-radius: 4px; background: transparent; padding: 4px 12px; cursor: pointer; white-space: nowrap; }
198
227
  .dwa-seg > button:hover { background: var(--dsw-alias-interactive-bg-active, #f3f4f6); }
199
- .dwa-seg > button.is-on { background: var(--ntr-accent); color: var(--dsw-alias-label-primary-foreground, #fff); font-weight: 550; }
200
- .dwa-preference-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; }
228
+ .dwa-seg > button.is-on { background: var(--dsw-alias-bg-layer-1, #fff); border: var(--ntr-line); color: var(--dsw-alias-label-primary, #111827); font-weight: 550; }
229
+ .dwa-preferences { border-top: var(--ntr-line); }
230
+ .dwa-preference-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 0; }
201
231
  .dwa-preference-row + .dwa-preference-row { border-top: var(--ntr-line); }
202
232
  /* A control that needs the whole card: the label sits above it, not beside
203
233
  it. A right-aligned segmented control squeezed its labels into two lines
@@ -222,13 +252,20 @@ const STYLE = `
222
252
  .dwa-deadline-warn { margin-top: 10px; padding: 9px 12px; border-left: 3px solid var(--dsw-alias-state-warn-primary, #d97706); background: var(--dsw-alias-state-warn-secondary, #fffbeb); border-radius: var(--ntr-radius-sm); font-size: var(--ntr-font-xs); line-height: 1.55; }
223
253
  .dwa-deadline-reset { display: flex; justify-content: flex-end; margin-top: 8px; }
224
254
  @media (max-width: 640px) {
225
- .dwa-root { padding: 14px; }
226
- .dwa-page-header { flex-direction: column; }
227
- .dwa-page-actions { width: 100%; }
228
- .dwa-page-actions .dwa-btn { width: 100%; }
255
+ .dwa-root { padding: 20px 16px; }
256
+ }
257
+ @container (max-width: 640px) {
258
+ .dwa-page-header { align-items: flex-start; flex-wrap: wrap; }
259
+ .dwa-agent-body { padding-left: 0; }
260
+ .dwa-agent-row { grid-template-columns: 32px minmax(0, 1fr) auto; gap: 10px; }
261
+ .dwa-agent-avatar { width: 32px; height: 32px; }
262
+ .dwa-section-heading { flex-wrap: wrap; }
263
+ .dwa-tabs { gap: 20px; }
264
+ .dwa-notice { flex-wrap: wrap; }
265
+ .dwa-seg-wide > button { padding-inline: 5px; }
229
266
  .dwa-model-toolbar-actions { width: 100%; }
230
267
  .dwa-model-search { flex: 1 1 auto; width: auto; }
231
- .dwa-subsection-head, .dwa-preference-row { align-items: flex-start; flex-direction: column; }
268
+ .dwa-preference-row { align-items: flex-start; flex-direction: column; }
232
269
  .dwa-default-hint { align-items: flex-start; flex-direction: column; }
233
270
  /* The slider's scale: up to eight rungs with full unit words ("15 分钟")
234
271
  need a smaller face before they can collide on a narrow panel. The rung
@@ -809,7 +846,7 @@ function ApprovalDeadlinesCard(props: { acp: AcpStore; ui: UiState }) {
809
846
  </p>
810
847
  </div>
811
848
  </div>
812
- <div className="dwa-card" style={{ paddingTop: 4, paddingBottom: 4 }}>
849
+ <div className="dwa-preferences">
813
850
  <DeadlineModeBlock acp={acp} mode={deadlines.mode} override={modeNote} />
814
851
  <DeadlineSliderBlock
815
852
  acp={acp}
@@ -829,53 +866,238 @@ function ApprovalDeadlinesCard(props: { acp: AcpStore; ui: UiState }) {
829
866
  )
830
867
  }
831
868
 
832
- /** Settings configuration section for ACP agents. */
869
+ type SettingsPage = 'agents' | 'add' | 'approvals' | 'appearance' | 'diagnostics'
870
+
871
+ /** The states that carry an update badge next to the version badge. */
872
+ function updateBadgeText(update: PluginUpdateState, acp: AcpStore): string | undefined {
873
+ if (update.status === 'available') {
874
+ return acp.label(`可更新 v${update.latest ?? update.current}`, `Update v${update.latest ?? update.current}`)
875
+ }
876
+ if (update.status === 'applying') return acp.label('更新中…', 'Updating…')
877
+ if (update.status === 'applied') {
878
+ const installed = update.installed ?? update.latest ?? update.current
879
+ return acp.label(`已更新 v${installed}`, `Updated v${installed}`)
880
+ }
881
+ if (update.status === 'restart-required') {
882
+ // Only a KNOWN disk version can be named. An interrupted install leaves
883
+ // `installed` unset, and a badge would have to invent a version.
884
+ if (update.installed === undefined) return undefined
885
+ // Neutral: the disk version can be newer OR older than the running one.
886
+ return acp.label(`待重启 v${update.installed}`, `Restart for v${update.installed}`)
887
+ }
888
+ return undefined
889
+ }
890
+
891
+ /**
892
+ * The version badge's update companion. Every badge is a button, `applying`
893
+ * included: the card is where the install's outcome is read, so the running
894
+ * install must be able to open it too. States without anything to act on get
895
+ * no badge at all — the version badge itself still opens the card.
896
+ */
897
+ function PluginUpdateBadge(props: { acp: AcpStore; update: PluginUpdateState; open: boolean; onToggle: () => void }) {
898
+ const { acp, update, open, onToggle } = props
899
+ const text = updateBadgeText(update, acp)
900
+ if (text === undefined) return null
901
+ return (
902
+ <button
903
+ type="button"
904
+ className="dwa-badge dwa-badge-info dwa-update-badge"
905
+ aria-expanded={open}
906
+ aria-controls="ntr-plugin-update-card"
907
+ onClick={onToggle}
908
+ >
909
+ {text}
910
+ </button>
911
+ )
912
+ }
913
+
914
+ /**
915
+ * The update card. In-page, no dialog: the settings page already owns the one
916
+ * action (install the exact version the host reported), and a modal on top of
917
+ * a settings section would hide the version it is updating.
918
+ *
919
+ * The copy stays to one line per state (constraint 33); the host's raw error
920
+ * (`message`) is the only long text, and it wraps on its own line so a stack
921
+ * trace or a registry error is readable without stretching the card.
922
+ */
923
+ function PluginUpdateCard(props: { acp: AcpStore; update: PluginUpdateState; onClose: () => void }) {
924
+ const { acp, update, onClose } = props
925
+ const applying = update.status === 'applying'
926
+ const latest = update.latest ?? update.current
927
+ const onDisk = update.installed !== undefined && update.installed !== update.current
928
+ ? update.installed
929
+ : undefined
930
+ const published = update.publishedAt !== undefined && !Number.isNaN(new Date(update.publishedAt).getTime())
931
+ ? new Date(update.publishedAt).toLocaleDateString()
932
+ : undefined
933
+ const copy = update.status === 'available'
934
+ ? acp.label('更新会卸载并重装插件,之后可能需要重启 DSH。', 'Updating uninstalls and reinstalls the plugin, and may require restarting DSH.')
935
+ : update.status === 'applied'
936
+ ? acp.label('已应用,刷新页面即可使用新版本。', 'Applied. Reload the page to use the new version.')
937
+ : update.status === 'restart-required'
938
+ ? update.installed !== undefined
939
+ ? acp.label(
940
+ `磁盘已是 v${update.installed},重启 DSH 后生效(桌面 App 退出重开;命令行重启 dsh web)。`,
941
+ `On disk v${update.installed}. Restart DSH to apply (quit and reopen the app, or restart dsh web).`,
942
+ )
943
+ : acp.label(
944
+ '更新可能已完成,请刷新页面确认当前版本。',
945
+ 'The update may already be complete. Refresh the page to confirm the current version.',
946
+ )
947
+ : update.status === 'busy'
948
+ ? acp.label('有进行中的对话,结束后才能更新。', 'Finish the running conversation before updating.')
949
+ : update.status === 'failed'
950
+ ? acp.label('更新失败。', 'Update failed.')
951
+ : update.status === 'current'
952
+ ? acp.label('已是最新版本。', 'Up to date.')
953
+ : update.status === 'source-install'
954
+ ? acp.label(
955
+ '源码或本地安装,无法通过 registry 更新。',
956
+ 'Source or local install; update it through its own checkout.',
957
+ )
958
+ : update.status === 'managed'
959
+ ? acp.label('此安装由宿主管理,不能在这里更新。', 'This install is managed by the host and cannot be updated here.')
960
+ : undefined
961
+ const showMessage = (update.status === 'failed' || update.status === 'busy' || update.status === 'managed')
962
+ && update.message !== undefined
963
+ const apply = async (): Promise<void> => {
964
+ if (update.latest === undefined) return
965
+ try {
966
+ await acp.applyPluginUpdate(update.latest)
967
+ } catch {
968
+ // The store keeps the "disk may have changed" state and writes its own notice.
969
+ }
970
+ }
971
+ return (
972
+ <div
973
+ id="ntr-plugin-update-card"
974
+ className="dwa-card dwa-update-card"
975
+ role="group"
976
+ aria-label={acp.label('插件更新', 'Plugin update')}
977
+ >
978
+ <div className="dwa-update-summary">
979
+ <span>{acp.label(`当前 v${update.current}`, `Current v${update.current}`)}</span>
980
+ {onDisk !== undefined && <span className="dwa-hint">{acp.label(`磁盘 v${onDisk}`, `On disk v${onDisk}`)}</span>}
981
+ {update.latest !== undefined && (
982
+ <>
983
+ <span aria-hidden="true">→</span>
984
+ <span>{acp.label(`最新 v${latest}`, `Latest v${latest}`)}</span>
985
+ {published !== undefined && <span className="dwa-hint">{published}</span>}
986
+ </>
987
+ )}
988
+ </div>
989
+ {copy !== undefined && <p className="dwa-update-copy">{copy}</p>}
990
+ {showMessage && <div className="dwa-update-message">{update.message}</div>}
991
+ <div className="dwa-row-actions dwa-update-actions">
992
+ {update.status === 'available' && (
993
+ <button type="button" className="dwa-btn dwa-btn-primary" onClick={() => { void apply() }}>
994
+ {acp.label('更新', 'Update')}
995
+ </button>
996
+ )}
997
+ {applying && (
998
+ <button type="button" className="dwa-btn dwa-btn-primary" disabled>
999
+ {acp.label('正在更新…', 'Updating…')}
1000
+ </button>
1001
+ )}
1002
+ {update.status !== 'applying' && (
1003
+ <button type="button" className="dwa-btn" onClick={onClose}>{acp.label('关闭', 'Close')}</button>
1004
+ )}
1005
+ <button type="button" className="dwa-btn dwa-btn-quiet" disabled={applying} onClick={() => void acp.checkPluginUpdate(true)}>
1006
+ {acp.label('重新检查', 'Check again')}
1007
+ </button>
1008
+ </div>
1009
+ </div>
1010
+ )
1011
+ }
1012
+
1013
+ /**
1014
+ * The card is drawn for every state a host can report about this install,
1015
+ * except the two where there is no install to talk about at all.
1016
+ */
1017
+ function updateCardVisible(update: PluginUpdateState | undefined, open: boolean): boolean {
1018
+ if (update === undefined || !open) return false
1019
+ return update.status !== 'unsupported' && update.status !== 'not-installed'
1020
+ }
1021
+
833
1022
  export function AcpSettingsSection(props: WithStore) {
834
1023
  const acp = props.acp
835
1024
  ensureStyle()
836
1025
  const ui = useUi(acp)
837
1026
  const ledger = permissionLedgerCard(ui)
838
- // The agent list section opens by default. `addTarget === undefined` means
839
- // "follow the first available preset"; an explicit chip click pins the
840
- // selection until the next create resets it.
841
- const [addOpen, setAddOpen] = useState(true)
1027
+ const [page, setPage] = useState<SettingsPage>('agents')
1028
+ const [addOpen, setAddOpen] = useState(false)
842
1029
  const [addTarget, setAddTarget] = useState<DetectedAgentInfo | undefined>(undefined)
843
- const [createNonce, setCreateNonce] = useState(0)
1030
+ const [updateOpen, setUpdateOpen] = useState(false)
1031
+ // The store throttles this to one registry read per ten minutes, so every
1032
+ // remount (both settings entries mount this section) is cheap.
1033
+ useEffect(() => {
1034
+ void acp.checkPluginUpdate()
1035
+ }, [acp])
844
1036
  const { configured, available } = partitionAgents(ui.detectedAgents, ui.agents)
845
1037
  const selected = addTarget ?? available[0] ?? NEW_AGENT_SEED
1038
+ const openAdd = (): void => {
1039
+ setAddOpen(true)
1040
+ setPage('add')
1041
+ }
1042
+ const presetProblem = ui.preset !== undefined && (!ui.preset.installed || ui.preset.problem !== undefined)
1043
+ const update = ui.pluginUpdate
1044
+ // A source install cannot be replaced by this browser; the version badge
1045
+ // says so on hover. Neutral wording: a checkout may be git or not.
1046
+ const versionTitle = update?.status === 'source-install'
1047
+ ? acp.label('源码或本地安装,无法通过 registry 更新。', 'Source or local install; update it through its own checkout.')
1048
+ : undefined
1049
+ // The running version is the update entry: every state with a real install
1050
+ // report can open the card, including "up to date" (which offers a re-check).
1051
+ const versionOpensUpdate = update !== undefined
1052
+ && update.status !== 'unsupported'
1053
+ && update.status !== 'not-installed'
846
1054
 
847
1055
  return (
848
1056
  <div className="dwa-root">
849
1057
  <header className="dwa-page-header">
850
1058
  <div className="dwa-page-heading">
851
- <h2 className="dwa-page-title">{acp.label('NTR 智能体', 'NTR Agents')}</h2>
1059
+ <h2 className="dwa-page-title" aria-label={acp.label('NTR 设置', 'NTR settings')}>
1060
+ {acp.label('NTR 设置', 'NTR settings')}
1061
+ {ui.pluginVersion !== undefined && (
1062
+ versionOpensUpdate
1063
+ ? (
1064
+ <button
1065
+ type="button"
1066
+ className="dwa-badge dwa-version dwa-version-btn"
1067
+ title={versionTitle}
1068
+ aria-expanded={updateOpen}
1069
+ aria-controls="ntr-plugin-update-card"
1070
+ onClick={() => setUpdateOpen(open => !open)}
1071
+ >
1072
+ {`v${ui.pluginVersion}`}
1073
+ </button>
1074
+ )
1075
+ : <span className="dwa-badge dwa-version" title={versionTitle}>{`v${ui.pluginVersion}`}</span>
1076
+ )}
1077
+ {update !== undefined && (
1078
+ <PluginUpdateBadge
1079
+ acp={acp}
1080
+ update={update}
1081
+ open={updateOpen}
1082
+ onToggle={() => setUpdateOpen(open => !open)}
1083
+ />
1084
+ )}
1085
+ </h2>
852
1086
  <p className="dwa-page-description">
853
- {acp.label('管理智能体连接、可用模型与对话显示偏好。', 'Manage agent connections, available models, and conversation display preferences.')}
1087
+ {acp.label('连接智能体,设置你的对话偏好。', 'Connect agents and set your conversation preferences.')}
854
1088
  </p>
855
- <div className="dwa-page-status">
856
- <span
857
- className="dwa-dot"
858
- style={{ background: ui.stream === 'live' ? 'var(--dsw-alias-state-success-primary, #22c55e)' : ui.stream === 'down' ? 'var(--dsw-alias-state-error-primary, #ef4444)' : 'var(--dsw-alias-state-idle-primary, #9ca3af)' }}
859
- title={ui.stream}
860
- />
861
- <span>{streamText(ui.stream, acp)}</span>
862
- {ui.detectSummary !== undefined && <span>· {ui.detectSummary}</span>}
863
- </div>
864
1089
  </div>
865
- <div className="dwa-page-actions">
866
- <button
867
- className="dwa-btn"
868
- onClick={() => {
869
- void acp.resync()
870
- void acp.detectAgents()
871
- }}
872
- disabled={ui.detecting}
873
- >
874
- {ui.detecting ? acp.label('检测中...', 'Detecting...') : acp.label('重新检测与刷新', 'Re-detect & Refresh')}
1090
+ {page === 'agents' && configured.length > 0 && (
1091
+ <button type="button" className="dwa-btn dwa-btn-primary" onClick={openAdd}>
1092
+ {acp.label('添加智能体', 'Add agent')}
875
1093
  </button>
876
- </div>
1094
+ )}
877
1095
  </header>
878
1096
 
1097
+ {update !== undefined && updateCardVisible(update, updateOpen) && (
1098
+ <PluginUpdateCard acp={acp} update={update} onClose={() => setUpdateOpen(false)} />
1099
+ )}
1100
+
879
1101
  {/* The cross-session mirror: the conversation dock answers the asks
880
1102
  belonging to the open conversation; this card answers the rest. */}
881
1103
  {ledger.visible && (
@@ -900,14 +1122,15 @@ export function AcpSettingsSection(props: WithStore) {
900
1122
  {permission.options.map(option => (
901
1123
  <button
902
1124
  key={option.optionId}
903
- className="dwa-btn dwa-btn-primary"
1125
+ type="button"
1126
+ className="dwa-btn"
904
1127
  onClick={() => { void acp.respondPermission(permission.requestId, option.optionId).catch(() => undefined) }}
905
1128
  >
906
1129
  {option.name}
907
1130
  </button>
908
1131
  ))}
909
- <button className="dwa-btn" onClick={() => { void acp.respondPermission(permission.requestId).catch(() => undefined) }}>
910
- {acp.label('拒绝', 'Dismiss')}
1132
+ <button type="button" className="dwa-btn dwa-btn-quiet" onClick={() => { void acp.respondPermission(permission.requestId).catch(() => undefined) }}>
1133
+ {acp.label('取消', 'Cancel')}
911
1134
  </button>
912
1135
  </div>
913
1136
  ))}
@@ -917,17 +1140,34 @@ export function AcpSettingsSection(props: WithStore) {
917
1140
  {ui.notice !== undefined && (
918
1141
  <div className="dwa-card dwa-card-warn" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
919
1142
  <span style={{ flex: 1 }}>{ui.notice}</span>
920
- <button className="dwa-btn" onClick={() => acp.clearNotice()}>✕</button>
1143
+ <button type="button" className="dwa-btn dwa-btn-quiet" aria-label={acp.label('关闭通知', 'Dismiss notice')} onClick={() => acp.clearNotice()}>✕</button>
921
1144
  </div>
922
1145
  )}
923
1146
 
924
- {/* ACP 模式: the agent preset the new-session picker offers.
925
-
926
- Nothing is installed from here — the preset is a declaration this
927
- package ships in its OWN bundle patch (`presets/acp/acp.patch.yml`),
928
- so the card is a read-only window onto the harness roster: it exists
929
- to say why the mode is missing when its activation failed. */}
930
- {ui.preset !== undefined && (
1147
+ {presetProblem && (
1148
+ <div className="dwa-card dwa-card-warn dwa-notice" role="status">
1149
+ <span>{acp.label('NTR 模式需要检查,当前可能无法开始对话。', 'NTR mode needs attention; new conversations may be unavailable.')}</span>
1150
+ <button type="button" className="dwa-btn" onClick={() => setPage('diagnostics')}>{acp.label('查看诊断', 'View diagnostics')}</button>
1151
+ </div>
1152
+ )}
1153
+ <nav className="dwa-tabs" aria-label={acp.label('NTR 设置分类', 'NTR settings categories')}>
1154
+ <button type="button" aria-pressed={page === 'agents'} onClick={() => setPage('agents')}>{acp.label('智能体', 'Agents')}</button>
1155
+ {ui.approvalDeadlines !== undefined && <button type="button" aria-pressed={page === 'approvals'} onClick={() => setPage('approvals')}>{acp.label('审批时限', 'Approval deadlines')}</button>}
1156
+ <button type="button" aria-pressed={page === 'appearance'} onClick={() => setPage('appearance')}>{acp.label('对话外观', 'Appearance')}</button>
1157
+ </nav>
1158
+ {page === 'diagnostics' && (
1159
+ <section className="dwa-section">
1160
+ <h3 className="dwa-section-title">{acp.label('连接诊断', 'Connection diagnostics')}</h3>
1161
+ <div className="dwa-preference-row"><span>{acp.label('宿主事件流', 'Host event stream')}</span><span className="dwa-hint">{streamText(ui.stream, acp)}</span></div>
1162
+ <div className="dwa-row-actions">
1163
+ <button type="button" className="dwa-btn" onClick={() => { void acp.resync(); void acp.detectAgents() }} disabled={ui.detecting}>
1164
+ {ui.detecting ? acp.label('检测中...', 'Detecting...') : acp.label('重新检测与刷新', 'Re-detect & Refresh')}
1165
+ </button>
1166
+ {ui.detectSummary !== undefined && <span className="dwa-hint" role="status">{ui.detectSummary}</span>}
1167
+ </div>
1168
+ </section>
1169
+ )}
1170
+ {page === 'diagnostics' && ui.preset !== undefined && (
931
1171
  <div className="dwa-card dwa-mode-banner">
932
1172
  <div className="dwa-mode-title">
933
1173
  {acp.label('NTR 模式', 'NTR mode')}
@@ -950,24 +1190,24 @@ export function AcpSettingsSection(props: WithStore) {
950
1190
  </div>
951
1191
  )}
952
1192
 
953
- {/* Connected ACP agents: one compact row per agent, details on demand */}
954
- <section className="dwa-section">
1193
+ {/* Keep editors mounted so navigation preserves unsaved values. */}
1194
+ <section className="dwa-section" hidden={page !== 'agents'} aria-label={acp.label('已接入的智能体', 'Connected agents')}>
955
1195
  <div className="dwa-section-heading">
956
1196
  <div>
957
1197
  <h3 className="dwa-section-title">
958
1198
  {acp.label('已接入的智能体', 'Connected agents')}
959
- <span className="dwa-badge">{configured.length}</span>
1199
+ {ui.agentsLoaded && <span className="dwa-badge">{configured.length}</span>}
960
1200
  </h3>
961
- <p className="dwa-section-copy">{acp.label('展开智能体可管理模型、启动命令与工作区。', 'Expand an agent to manage its models, launch command, and workspace.')}</p>
962
1201
  </div>
1202
+ {configured.length > 0 && <span className="dwa-hint">{acp.label('默认项用于新的 NTR 会话', 'The default serves new NTR conversations')}</span>}
963
1203
  </div>
964
1204
 
965
- {configured.length === 0 && (
966
- <div className="dwa-card" style={{ textAlign: 'center', color: 'var(--ntr-muted)', padding: 20 }}>
967
- {acp.label(
968
- '尚未启用任何 Agent。从下方「添加智能体」点一个检测到的预设(会展开预填命令的创建卡),再点「保存并启用」;也可以直接新建自定义 Agent。',
969
- 'No agents configured yet. Pick a detected preset from "Add agent" below (it opens a creation card prefilled with its command), then click "Save & Enable" — or create a custom one from scratch.',
970
- )}
1205
+ {!ui.agentsLoaded && <div className="dwa-empty" role="status">{acp.label('正在读取智能体…', 'Loading agents…')}</div>}
1206
+ {ui.agentsLoaded && configured.length === 0 && (
1207
+ <div className="dwa-empty">
1208
+ <h3>{acp.label('接入第一个智能体', 'Connect your first agent')}</h3>
1209
+ <p className="dwa-section-copy">{acp.label('选择已有预设,或填写自己的 ACP 启动命令。', 'Choose a preset or enter your own ACP launch command.')}</p>
1210
+ <button type="button" className="dwa-btn dwa-btn-primary" onClick={openAdd}>{acp.label('添加智能体', 'Add agent')}</button>
971
1211
  </div>
972
1212
  )}
973
1213
 
@@ -982,24 +1222,22 @@ export function AcpSettingsSection(props: WithStore) {
982
1222
  acp={acp}
983
1223
  isDefault={ui.defaultAgent === info.id}
984
1224
  synthesized={synthesized}
1225
+ active={page === 'agents'}
985
1226
  />
986
1227
  )
987
1228
  })}
988
1229
  </div>
989
1230
  </section>
990
1231
 
991
- {/* Agent list: available presets plus the from-scratch creation form,
992
- open by default so the page answers "what can I enable" without a
993
- click. Selection follows the first available preset until pinned. */}
994
- <section className="dwa-section">
1232
+ <section className="dwa-section" hidden={page !== 'add'} aria-label={acp.label('添加智能体', 'Add agent')}>
1233
+ <button type="button" className="dwa-btn dwa-btn-quiet dwa-back" onClick={() => setPage('agents')}>{acp.label('返回智能体列表', 'Back to agents')}</button>
995
1234
  <div className="dwa-add-panel">
996
- <button type="button" className="dwa-add-toggle" aria-expanded={addOpen} onClick={() => setAddOpen(open => !open)}>
997
- <span>
998
- <span className="dwa-section-title">{acp.label('添加智能体', 'Add agent')}</span>
999
- <span className="dwa-section-copy" style={{ display: 'block' }}>{acp.label('从预设快速启用,或创建自定义连接。', 'Enable a preset or create a custom connection.')}</span>
1000
- </span>
1001
- <span className="dwa-caret-btn" aria-hidden><span className="dwa-caret">{addOpen ? '▾' : '▸'}</span></span>
1002
- </button>
1235
+ <div className="dwa-section-heading">
1236
+ <div>
1237
+ <h3 className="dwa-section-title">{acp.label('添加智能体', 'Add agent')}</h3>
1238
+ <p className="dwa-section-copy">{acp.label('从预设快速启用,或创建自定义连接。', 'Enable a preset or create a custom connection.')}</p>
1239
+ </div>
1240
+ </div>
1003
1241
  {addOpen && (
1004
1242
  <div className="dwa-add-content">
1005
1243
  <div className="dwa-chip-list">
@@ -1028,19 +1266,17 @@ export function AcpSettingsSection(props: WithStore) {
1028
1266
  </div>
1029
1267
  )}
1030
1268
  <AgentRow
1031
- key={selected.id === '' ? `custom-${createNonce}` : selected.id}
1269
+ key={selected.id}
1032
1270
  detected={selected}
1033
1271
  acp={acp}
1034
1272
  isDefault={false}
1035
1273
  synthesized={false}
1036
1274
  newCustom={selected.id === ''}
1275
+ active={page === 'add'}
1037
1276
  onCreated={() => {
1038
- // Creation done: drop the pin so the selection re-derives to
1039
- // the next available preset (or a fresh custom card), and bump
1040
- // the nonce so an all-custom reset still remounts empty
1041
- // instead of keeping the just-saved values in the form.
1042
1277
  setAddTarget(undefined)
1043
- setCreateNonce(nonce => nonce + 1)
1278
+ setAddOpen(false)
1279
+ setPage('agents')
1044
1280
  }}
1045
1281
  />
1046
1282
  </div>
@@ -1048,24 +1284,16 @@ export function AcpSettingsSection(props: WithStore) {
1048
1284
  </div>
1049
1285
  </section>
1050
1286
 
1051
- {/* Approval deadlines: the two windows that bound an ask nobody
1052
- answered. Placed above the appearance card because it changes what
1053
- happens to a turn, and that card only decorates a nameplate. */}
1054
- <ApprovalDeadlinesCard acp={acp} ui={ui} />
1055
-
1056
- {/* Agent 挂牌光晕: how the composer tag shows a live agent. Plugin
1057
- display preference — host-persisted with the agent registry, so it
1058
- follows the profile rather than this one browser. Kept at the BOTTOM
1059
- of the section: the cards above answer "what serves my turns", this
1060
- one only decorates their nameplate. */}
1061
- <section className="dwa-section">
1287
+ <div hidden={page !== 'approvals'}><ApprovalDeadlinesCard acp={acp} ui={ui} /></div>
1288
+
1289
+ <section className="dwa-section" hidden={page !== 'appearance'} aria-label={acp.label('对话外观', 'Appearance')}>
1062
1290
  <div className="dwa-section-heading">
1063
1291
  <div>
1064
1292
  <h3 className="dwa-section-title">{acp.label('对话外观', 'Conversation appearance')}</h3>
1065
1293
  <p className="dwa-section-copy">{acp.label('设置对话中 NTR 挂牌的进程存活提示。', 'Choose how the NTR tag indicates a running agent process.')}</p>
1066
1294
  </div>
1067
1295
  </div>
1068
- <div className="dwa-card" style={{ paddingTop: 4, paddingBottom: 4 }}>
1296
+ <div className="dwa-preferences">
1069
1297
  <div className="dwa-preference-row">
1070
1298
  <div>
1071
1299
  <div className="dwa-preference-label">{acp.label('挂牌光晕', 'Tag halo')}</div>
@@ -1106,6 +1334,14 @@ export function AcpSettingsSection(props: WithStore) {
1106
1334
  )}
1107
1335
  </div>
1108
1336
  </section>
1337
+ <footer className="dwa-health">
1338
+ <div className="dwa-health-status">
1339
+ <span className="dwa-dot" style={{ background: ui.stream === 'live' ? 'var(--ntr-success)' : ui.stream === 'down' ? 'var(--ntr-danger)' : 'var(--ntr-muted)' }} />
1340
+ <span>{streamText(ui.stream, acp)}</span>
1341
+ {ui.preset !== undefined && <span>{ui.preset.installed && !presetProblem ? acp.label('NTR 模式已就绪', 'NTR mode ready') : acp.label('NTR 模式需检查', 'NTR mode needs attention')}</span>}
1342
+ </div>
1343
+ {page !== 'diagnostics' && <button type="button" className="dwa-btn dwa-btn-quiet" onClick={() => setPage('diagnostics')}>{acp.label('查看诊断', 'View diagnostics')}</button>}
1344
+ </footer>
1109
1345
  </div>
1110
1346
  )
1111
1347
  }
@@ -1117,6 +1353,7 @@ function AgentRow({
1117
1353
  acp,
1118
1354
  isDefault,
1119
1355
  synthesized,
1356
+ active,
1120
1357
  newCustom = false,
1121
1358
  onCreated,
1122
1359
  }: {
@@ -1125,6 +1362,7 @@ function AgentRow({
1125
1362
  acp: AcpStore
1126
1363
  isDefault: boolean
1127
1364
  synthesized: boolean
1365
+ active: boolean
1128
1366
  /** From-scratch creation: the id is editable and no preset defaults exist. */
1129
1367
  newCustom?: boolean
1130
1368
  /** Fired after a creation-card save succeeds (the list re-derives its selection). */
@@ -1135,6 +1373,7 @@ function AgentRow({
1135
1373
  // A configured agent starts collapsed; a creation card opens straight away
1136
1374
  // (nothing to summarise yet).
1137
1375
  const [expanded, setExpanded] = useState(!configured)
1376
+ const [detailPage, setDetailPage] = useState<'models' | 'connection' | 'advanced'>(configured ? 'models' : 'connection')
1138
1377
  const [form, setForm] = useState<AgentForm>(() => initialForm(detected, activeAgent))
1139
1378
  const [saving, setSaving] = useState(false)
1140
1379
  const [savedSuccess, setSavedSuccess] = useState(false)
@@ -1169,7 +1408,8 @@ function AgentRow({
1169
1408
 
1170
1409
  const payload = buildPayload(form, detected, newCustom)
1171
1410
  const current = persistedDef(detected, activeAgent)
1172
- const dirty = current !== undefined && !payloadEquals(payload, current)
1411
+ const clearingIdleOverride = payload.idleTimeoutMs === undefined && detected.currentConfig?.idleTimeoutMs !== undefined
1412
+ const dirty = current !== undefined && (!payloadEquals(payload, current) || clearingIdleOverride)
1173
1413
  // A save stops the child and kills every live session (see BridgeManager.saveAgent).
1174
1414
  const showSaveWarning = dirty && activeAgent !== undefined && activeAgent.state !== 'stopped'
1175
1415
 
@@ -1221,7 +1461,7 @@ function AgentRow({
1221
1461
  : (activeAgent?.title ?? detected.currentConfig?.title ?? detected.name)
1222
1462
  const stderr = activeAgent?.state === 'error' ? (activeAgent.stderrTail ?? []) : []
1223
1463
  const errorSummary = stderr.length > 0 ? stderr[stderr.length - 1] : undefined
1224
- const currentModel = ui.modelCatalogs[detected.id]?.catalog.model?.currentValue
1464
+ const selectedModelCount = form.exposed?.length ?? 0
1225
1465
  const isPreset = PRESET_IDS.has(detected.id)
1226
1466
  // An error row opens itself so the stderr tail is never behind a click.
1227
1467
  const open = expanded || (activeAgent?.state === 'error' && configured)
@@ -1229,19 +1469,11 @@ function AgentRow({
1229
1469
  return (
1230
1470
  <div className={`dwa-agent-card${open ? ' is-open' : ''}`}>
1231
1471
  <div className="dwa-agent-row">
1232
- <button
1233
- type="button"
1234
- className="dwa-caret-btn"
1235
- aria-expanded={open}
1236
- onClick={() => setExpanded(value => !value)}
1237
- title={open ? acp.label('收起', 'Collapse') : acp.label('展开配置与模型', 'Expand configuration and models')}
1238
- >
1239
- <span className="dwa-caret" aria-hidden>{open ? '▾' : '▸'}</span>
1240
- </button>
1472
+ <span className="dwa-agent-avatar" aria-hidden>{displayName.slice(0, 2).toUpperCase()}</span>
1241
1473
  <div className="dwa-agent-summary">
1242
1474
  <div className="dwa-agent-titleline">
1243
1475
  <span className="dwa-agent-name" title={detected.executablePath ?? detected.description}>{displayName}</span>
1244
- {isDefault && <span className="dwa-badge dwa-badge-success">{acp.label('默认', 'Default')}</span>}
1476
+ {isDefault && <span className="dwa-badge dwa-badge-info">{acp.label('默认', 'Default')}</span>}
1245
1477
  {!isPreset && !newCustom && <span className="dwa-badge">{acp.label('自定义', 'Custom')}</span>}
1246
1478
  </div>
1247
1479
  <div className="dwa-agent-meta">
@@ -1254,7 +1486,7 @@ function AgentRow({
1254
1486
  {configured && (
1255
1487
  <>
1256
1488
  {state !== undefined && <span className="dwa-agent-meta-separator">·</span>}
1257
- <span>{currentModel === undefined ? acp.label('未探测模型', 'Model not probed') : `${acp.label('模型', 'Model')}: ${currentModel}`}</span>
1489
+ <span>{acp.label(`${String(selectedModelCount)} 个已选模型`, `${String(selectedModelCount)} selected models`)}</span>
1258
1490
  </>
1259
1491
  )}
1260
1492
  {!synthesized && !newCustom && !detected.detected && (
@@ -1267,7 +1499,12 @@ function AgentRow({
1267
1499
  <div className="dwa-error dwa-mono" style={{ marginTop: 6, wordBreak: 'break-all' }}>{errorSummary}</div>
1268
1500
  )}
1269
1501
  </div>
1270
- <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
1502
+ <div className="dwa-row-actions">
1503
+ {configured && (
1504
+ <button type="button" className="dwa-btn dwa-btn-quiet" aria-expanded={open} aria-label={acp.label(`${open ? '收起' : '管理'} ${displayName}`, `${open ? 'Collapse' : 'Manage'} ${displayName}`)} onClick={() => setExpanded(value => !value)}>
1505
+ {open ? acp.label('收起', 'Collapse') : acp.label('管理', 'Manage')}
1506
+ </button>
1507
+ )}
1271
1508
  {activeAgent?.state === 'error' && (
1272
1509
  <button className="dwa-btn" disabled={testing} onClick={() => void handleTest()}>
1273
1510
  {testing ? acp.label('测试中...', 'Testing...') : acp.label('测试', 'Test')}
@@ -1290,19 +1527,18 @@ function AgentRow({
1290
1527
  survives collapsing the row. */}
1291
1528
  <div className="dwa-agent-body" style={open ? undefined : { display: 'none' }}>
1292
1529
  {stderr.length > 0 && <pre className="dwa-stderr">{stderr.join('\n')}</pre>}
1530
+ <nav className="dwa-tabs dwa-detail-tabs" aria-label={acp.label(`${displayName} 配置`, `${displayName} settings`)}>
1531
+ {configured && <button type="button" aria-pressed={detailPage === 'models'} onClick={() => setDetailPage('models')}>{acp.label('模型', 'Models')}</button>}
1532
+ <button type="button" aria-pressed={detailPage === 'connection'} onClick={() => setDetailPage('connection')}>{acp.label('连接', 'Connection')}</button>
1533
+ <button type="button" aria-pressed={detailPage === 'advanced'} onClick={() => setDetailPage('advanced')}>{acp.label('高级', 'Advanced')}</button>
1534
+ </nav>
1293
1535
  {configured && (
1294
- <section className="dwa-subsection">
1295
- <div className="dwa-subsection-head">
1296
- <div>
1297
- <h4 className="dwa-subsection-title">{acp.label('模型与默认值', 'Models & defaults')}</h4>
1298
- <p className="dwa-subsection-copy">{acp.label('检测模型目录,并控制对话中可选择的模型。', 'Detect the model catalog and choose what appears in conversations.')}</p>
1299
- </div>
1300
- </div>
1536
+ <section className="dwa-subsection" hidden={detailPage !== 'models'}>
1301
1537
  <ModelCatalogBlock
1302
1538
  agentId={detected.id}
1303
1539
  detected={detected}
1304
1540
  acp={acp}
1305
- active={open}
1541
+ active={active && open && detailPage === 'models'}
1306
1542
  exposed={form.exposed}
1307
1543
  onExposedChange={exposed => patch({ exposed })}
1308
1544
  autoDiscover={form.autoDiscoverModels}
@@ -1311,15 +1547,11 @@ function AgentRow({
1311
1547
  />
1312
1548
  </section>
1313
1549
  )}
1314
- <section className="dwa-subsection">
1315
- <div className="dwa-subsection-head">
1316
- <div>
1317
- <h4 className="dwa-subsection-title">{acp.label('连接配置', 'Connection')}</h4>
1318
- <p className="dwa-subsection-copy">{acp.label('设置显示名与启动命令,高级选项按需展开。', 'Set the display name and launch command; expand advanced options only when needed.')}</p>
1319
- </div>
1320
- </div>
1550
+ <section className="dwa-subsection" hidden={detailPage === 'models'}>
1321
1551
  <AgentDetails
1322
1552
  acp={acp}
1553
+ advancedOpen={detailPage === 'advanced'}
1554
+ dirty={dirty}
1323
1555
  detected={detected}
1324
1556
  form={form}
1325
1557
  patch={patch}
@@ -1502,6 +1734,8 @@ export function buildPayload(form: AgentForm, detected: DetectedAgentInfo, newCu
1502
1734
  /** The expanded body: connection form, workspace allow-list, env, actions. */
1503
1735
  function AgentDetails({
1504
1736
  acp,
1737
+ advancedOpen,
1738
+ dirty,
1505
1739
  detected,
1506
1740
  form,
1507
1741
  patch,
@@ -1524,6 +1758,8 @@ function AgentDetails({
1524
1758
  onTest,
1525
1759
  }: {
1526
1760
  acp: AcpStore
1761
+ advancedOpen: boolean
1762
+ dirty: boolean
1527
1763
  detected: DetectedAgentInfo
1528
1764
  form: AgentForm
1529
1765
  patch: (next: Partial<AgentForm>) => void
@@ -1545,8 +1781,6 @@ function AgentDetails({
1545
1781
  onSave: () => void
1546
1782
  onTest: () => void
1547
1783
  }) {
1548
- const ui = useUi(acp)
1549
- const [advancedOpen, setAdvancedOpen] = useState(false)
1550
1784
  // The same parse the host will run (`validateAgentDef` → `splitCommandLine`).
1551
1785
  // Showing it is what turns "the first box swallowed my arguments" into a
1552
1786
  // visible, fixable state instead of a spawn error.
@@ -1557,15 +1791,14 @@ function AgentDetails({
1557
1791
  + (parsedCommand.args.length === 0 ? '' : ` ${acp.label('参数', 'Arguments')}: ${parsedCommand.args.join(' ')}`)
1558
1792
  return (
1559
1793
  <div>
1560
- {/* 基础参数: id / display name / command / args — the preset chip already
1561
- filled command+args, so a plain "enable this agent" flow needs
1562
- nothing below. Everything else lives under 高级参数. */}
1794
+ <div hidden={advancedOpen}>
1563
1795
  {newCustom && (
1564
1796
  <div className="dwa-form-row">
1565
1797
  <div className="dwa-form-col">
1566
1798
  <label className="dwa-form-label">{acp.label('Agent ID(唯一标识,创建后不可改)', 'Agent ID (unique, immutable after creation)')}</label>
1567
1799
  <input
1568
1800
  className="dwa-input dwa-input-mono"
1801
+ aria-label={acp.label('Agent ID', 'Agent ID')}
1569
1802
  value={form.agentId}
1570
1803
  onChange={e => patch({ agentId: e.target.value })}
1571
1804
  placeholder="e.g. my-agent"
@@ -1586,6 +1819,7 @@ function AgentDetails({
1586
1819
  <label className="dwa-form-label">{acp.label('显示名', 'Display Name')}</label>
1587
1820
  <input
1588
1821
  className="dwa-input"
1822
+ aria-label={acp.label('显示名', 'Display Name')}
1589
1823
  value={form.title}
1590
1824
  onChange={e => patch({ title: e.target.value })}
1591
1825
  placeholder={detected.name === '' ? acp.label('我的 Agent', 'My Agent') : detected.name}
@@ -1604,6 +1838,7 @@ function AgentDetails({
1604
1838
  <label className="dwa-form-label">{acp.label('启动命令(程序 + 参数)', 'Launch Command (program + arguments)')}</label>
1605
1839
  <input
1606
1840
  className="dwa-input dwa-input-mono"
1841
+ aria-label={acp.label('启动命令(程序 + 参数)', 'Launch Command (program + arguments)')}
1607
1842
  value={form.commandLine}
1608
1843
  onChange={e => patch({ commandLine: e.target.value })}
1609
1844
  placeholder={detected.defaultCommand === ''
@@ -1613,39 +1848,17 @@ function AgentDetails({
1613
1848
  <span className="dwa-hint">{commandPreview}</span>
1614
1849
  <span className="dwa-hint">
1615
1850
  {acp.label(
1616
- '参数直接跟在程序后面即可;路径里含空格时用引号包住整条路径,例如 "C:\\Program Files\\nodejs\\node.exe" --acp。',
1617
- 'Arguments go right after the program; quote a whole path that contains spaces, e.g. "C:\\Program Files\\nodejs\\node.exe" --acp.',
1851
+ '含空格的程序路径请加引号。',
1852
+ 'Quote the program path if it contains spaces.',
1618
1853
  )}
1619
1854
  </span>
1620
1855
  </div>
1621
1856
  </div>
1622
1857
 
1623
- {/* 高级参数: collapsed by default, hidden with `display: none` (never
1624
- unmounted) so an edit in progress survives the toggle. Path validity
1625
- is surfaced on the toggle itself — a collapsed section must not hide
1626
- an error that blocks saving. */}
1627
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
1628
- <button
1629
- type="button"
1630
- className="dwa-btn dwa-btn-secondary"
1631
- aria-expanded={advancedOpen}
1632
- onClick={() => setAdvancedOpen(value => !value)}
1633
- >
1634
- <span className="dwa-caret" aria-hidden>{advancedOpen ? '▾' : '▸'}</span> {acp.label('高级参数', 'Advanced Parameters')}
1635
- </button>
1636
- {!advancedOpen && (cwdInvalid || rootsInvalid) && (
1637
- <span className="dwa-error">
1638
- {acp.label('里面有非法的工作空间路径', 'Contains an invalid workspace path')}
1639
- </span>
1640
- )}
1641
- {!advancedOpen && mcpInvalid && (
1642
- <span className="dwa-error">
1643
- {acp.label('里面的 MCP 服务器配置不是合法 JSON', 'Contains an invalid MCP server list')}
1644
- </span>
1645
- )}
1858
+ <WorkspaceBlock acp={acp} form={form} patch={patch} cwdInvalid={cwdInvalid} rootsInvalid={rootsInvalid} />
1646
1859
  </div>
1647
1860
 
1648
- <div style={advancedOpen ? undefined : { display: 'none' }}>
1861
+ <div hidden={!advancedOpen}>
1649
1862
  {!newCustom && (
1650
1863
  <div className="dwa-default-hint">
1651
1864
  <div>
@@ -1679,14 +1892,6 @@ function AgentDetails({
1679
1892
  </div>
1680
1893
  </div>
1681
1894
 
1682
- <WorkspaceBlock
1683
- acp={acp}
1684
- form={form}
1685
- patch={patch}
1686
- cwdInvalid={cwdInvalid}
1687
- rootsInvalid={rootsInvalid}
1688
- />
1689
-
1690
1895
  {/* Idle release: scoped to agents with NO live session (see
1691
1896
  AgentBridge.releaseIfIdle), so it reclaims the processes that the
1692
1897
  model probe / post-save probe leave behind without ever costing a
@@ -1699,6 +1904,7 @@ function AgentDetails({
1699
1904
  <label className="dwa-form-label">{acp.label('空闲释放(毫秒)', 'Idle Release (ms)')}</label>
1700
1905
  <input
1701
1906
  className="dwa-input dwa-input-mono"
1907
+ aria-label={acp.label('空闲释放(毫秒)', 'Idle Release (ms)')}
1702
1908
  value={form.idleStr}
1703
1909
  onChange={e => patch({ idleStr: e.target.value })}
1704
1910
  placeholder={acp.label(
@@ -1715,18 +1921,8 @@ function AgentDetails({
1715
1921
  : (
1716
1922
  <>
1717
1923
  {acp.label(
1718
- '空闲超过此时长就挂起进程(会话保留,下一条消息自动恢复)。',
1719
- 'The process is parked once it has been idle this long (the session is kept and resumes on the next message).',
1720
- )}
1721
- <br />
1722
- {acp.label(
1723
- '0 = 永不释放;回合在跑、取消未结算或审批待答时不释放。',
1724
- '0 = never; a process with a running turn, an unsettled cancel, or a pending approval is never released.',
1725
- )}
1726
- <br />
1727
- {acp.label(
1728
- '只对承载过会话的进程生效;探测进程固定 5 分钟释放,填 0 也拦不住。',
1729
- 'This window applies only to a process that has hosted a session; the model-probe process is always released after 5 minutes, and 0 does not stop it.',
1924
+ '承载过会话的进程空闲后挂起;0 不释放,探测进程固定 5 分钟。',
1925
+ 'Park idle chat processes; 0 keeps them alive. Probe processes always release after 5 minutes.',
1730
1926
  )}
1731
1927
  </>
1732
1928
  )}
@@ -1783,6 +1979,7 @@ function AgentDetails({
1783
1979
  <textarea
1784
1980
  className="dwa-input dwa-input-mono"
1785
1981
  style={{ minHeight: 96, resize: 'vertical' }}
1982
+ aria-label={acp.label('MCP 服务器(JSON)', 'MCP Servers (JSON)')}
1786
1983
  value={form.mcpText}
1787
1984
  onChange={e => patch({ mcpText: e.target.value })}
1788
1985
  spellCheck={false}
@@ -1795,14 +1992,17 @@ function AgentDetails({
1795
1992
  )}
1796
1993
  <span className="dwa-hint">
1797
1994
  {acp.label(
1798
- '这些 MCP 服务器会在建会话时交给 agent,由 agent 自己拉起并使用(stdio 需填 command,可选 args / env;http 与 sse 需填 url,可选 headers)。agent 自己配置的 MCP 服务器不受影响,这里只是额外追加。',
1799
- 'These MCP servers are handed to the agent when a session is created; the agent launches and uses them itself (stdio needs command, with optional args / env; http and sse need url, with optional headers). The MCP servers the agent configures for itself are unaffected — this only adds more.',
1995
+ '额外追加给智能体的 MCP 服务器,不影响其已有配置。',
1996
+ 'Additional MCP servers for the agent; its own configuration is unchanged.',
1800
1997
  )}
1801
1998
  </span>
1802
1999
  </div>
1803
2000
  </div>
1804
2001
 
2002
+ {!advancedOpen && (mcpInvalid || idleInvalid) && <p className="dwa-error" role="alert">{acp.label('请检查「高级」中的无效配置。', 'Check the invalid settings in Advanced.')}</p>}
2003
+ {advancedOpen && (!idValid || idTaken || cwdInvalid || rootsInvalid || parsedCommand.command === '') && <p className="dwa-error" role="alert">{acp.label('请检查「连接」中的未完成或无效配置。', 'Check the incomplete or invalid settings in Connection.')}</p>}
1805
2004
  <div className="dwa-form-actions">
2005
+ <span className="dwa-hint">{isConfigured ? dirty ? acp.label('有未保存的更改', 'Unsaved changes') : acp.label('配置已保存', 'Settings saved') : ''}</span>
1806
2006
  <button
1807
2007
  className="dwa-btn"
1808
2008
  onClick={onTest}
@@ -1815,14 +2015,14 @@ function AgentDetails({
1815
2015
  <button
1816
2016
  className="dwa-btn dwa-btn-primary"
1817
2017
  onClick={onSave}
1818
- disabled={saving || testing || formIncomplete}
2018
+ disabled={saving || testing || formIncomplete || (isConfigured && !dirty)}
1819
2019
  >
1820
2020
  {saving
1821
2021
  ? acp.label('保存中...', 'Saving...')
1822
2022
  : savedSuccess
1823
2023
  ? acp.label('✓ 已保存', '✓ Saved')
1824
2024
  : isConfigured
1825
- ? acp.label('保存', 'Save')
2025
+ ? acp.label('保存更改', 'Save changes')
1826
2026
  : acp.label('保存并启用', 'Save & Enable')}
1827
2027
  </button>
1828
2028
 
@@ -1896,8 +2096,8 @@ function WorkspaceBlock({
1896
2096
  )}
1897
2097
  <span className="dwa-hint">
1898
2098
  {acp.label(
1899
- '对话里的 NTR 会话以该 DSH 会话选中的工作区为工作目录(agent 进程也从那里启动);此项只在 /acp 直连会话与宿主未上报工作区时生效。',
1900
- 'An NTR session inside a conversation runs \u2014 and starts its agent process \u2014 in that DSH session\u2019s own workspace; this value applies to /acp-direct sessions and when the host reports no workspace.',
2099
+ '聊天使用会话工作区;此处用于直连会话或宿主未提供工作区时。',
2100
+ 'Chats use their session workspace; this applies to direct sessions or when the host provides no workspace.',
1901
2101
  )}
1902
2102
  </span>
1903
2103
  {/* The effective allow-list stays visible in single-root mode too:
@@ -2360,7 +2560,7 @@ function ModelCatalogBlock({
2360
2560
  'The NTR surfaces only list what is checked here; unchecked models still work, and the one currently in force is still listed with a "· current" row — the rest are not offered.',
2361
2561
  )}
2362
2562
  >
2363
- {acp.label('(NTR 座位 / 芯片的下拉列表)', '(the NTR seat and chip pickers)')}
2563
+ {acp.label('仅影响菜单可见性', 'Menu visibility only')}
2364
2564
  </span>
2365
2565
  </span>
2366
2566
  <span className="dwa-badge dwa-badge-info">{selectionBadge}</span>
@@ -2389,6 +2589,7 @@ function ModelCatalogBlock({
2389
2589
  structurally taller, so the two could never share one height. */}
2390
2590
  <input
2391
2591
  className="dwa-input dwa-model-search"
2592
+ aria-label={acp.label('搜索模型', 'Search models')}
2392
2593
  value={search}
2393
2594
  onChange={e => setSearch(e.target.value)}
2394
2595
  placeholder={acp.label('搜索模型…', 'Search models…')}
@@ -2488,7 +2689,7 @@ function ModelCatalogBlock({
2488
2689
  <div className="dwa-model-list">
2489
2690
  {selectedChoices.length === 0 && (
2490
2691
  <span className="dwa-hint dwa-model-empty">
2491
- {acp.label('还没有选择模型(点左侧的模型加入)', 'Nothing selected yet — pick models on the left')}
2692
+ {acp.label('尚未选择模型,从「全部模型」中加入。', 'No models selected. Add from All models.')}
2492
2693
  </span>
2493
2694
  )}
2494
2695
  {selectedChoices.map(choice => (