@shendeguize/dsh-agent-sidecar 0.1.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.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for the inject panel (design §5.1 view 3, §5.3 confirmation
|
|
3
|
+
* boundary, §8 S5-S7 wording). No React, no I/O, no imports from the data
|
|
4
|
+
* layer — everything arrives and leaves as plain values, so this module is
|
|
5
|
+
* unit-testable in a bare node environment (same posture as board/logic.ts).
|
|
6
|
+
*
|
|
7
|
+
* Decoupling contract (T4.5 ↔ S5 integration): the wire-facing types below
|
|
8
|
+
* (PrepareSuccess / InjectResultView / InjectPlanView / ApiErrorLike) are
|
|
9
|
+
* this module's OWN mirrors of the host action contract (src/routes.ts +
|
|
10
|
+
* src/inject-gateway.ts) and of the data layer's normalized error
|
|
11
|
+
* (client/api.ts ApiError, matched structurally so real instances satisfy
|
|
12
|
+
* {@link ApiErrorLike} without an import). The integration layer feeds the
|
|
13
|
+
* panel through the `onPrepare`/`onExecute` props; this module classifies
|
|
14
|
+
* whatever comes back.
|
|
15
|
+
*
|
|
16
|
+
* Two-phase state machine (§4.f.5):
|
|
17
|
+
*
|
|
18
|
+
* idle ──PREPARE_START──▶ preparing ──PREPARE_OK──▶ confirm
|
|
19
|
+
* ▲ │ │
|
|
20
|
+
* │◀──PREPARE_REJECTED/ERROR─┘ TICK(expired) / CANCEL
|
|
21
|
+
* │◀──────────────────────────────────────────────────┤
|
|
22
|
+
* │ EXECUTE_START
|
|
23
|
+
* │ ▼
|
|
24
|
+
* │◀──RESET (delivered/failed only)── result ◀── executing
|
|
25
|
+
*
|
|
26
|
+
* `outcome: 'unknown'` is terminal: the reducer refuses RESET out of an
|
|
27
|
+
* unknown result, so there is NO machine path that could re-drive an
|
|
28
|
+
* execute after an unknown delivery (S6).
|
|
29
|
+
*
|
|
30
|
+
* @module
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { SidecarLocaleKey } from '../locales/index.ts'
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Wire-facing mirrors (host source of truth noted per type).
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** Injection mode, aligned with dsh `session.prompt` semantics (host: inject-gateway.ts). */
|
|
40
|
+
export type InjectMode = 'queue' | 'steer'
|
|
41
|
+
|
|
42
|
+
/** Terminal outcome vocabulary (host: inject-gateway.ts). */
|
|
43
|
+
export type InjectOutcome = 'delivered' | 'failed' | 'unknown'
|
|
44
|
+
|
|
45
|
+
/** Injection target reference as sent in the prepare envelope. */
|
|
46
|
+
export interface InjectTargetRef {
|
|
47
|
+
agent: string
|
|
48
|
+
sessionId: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Live target snapshot captured by the server-side prepare re-check. */
|
|
52
|
+
export interface TargetStatusView {
|
|
53
|
+
agent: string
|
|
54
|
+
sessionId: string
|
|
55
|
+
status: string
|
|
56
|
+
title?: string
|
|
57
|
+
project?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Confirmation plan echoed by `inject.prepare` (host: inject-gateway.ts). */
|
|
61
|
+
export interface InjectPlanView {
|
|
62
|
+
target: InjectTargetRef
|
|
63
|
+
mode: InjectMode
|
|
64
|
+
targetStatus: TargetStatusView
|
|
65
|
+
messagePreview: { bytes: number; head: string }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Success body of `POST action {type:'inject.prepare'}` (host: routes.ts). */
|
|
69
|
+
export interface PrepareSuccess {
|
|
70
|
+
requestId: string
|
|
71
|
+
confirmToken: string
|
|
72
|
+
plan: InjectPlanView
|
|
73
|
+
/** Epoch ms after which the confirmToken is dead. */
|
|
74
|
+
expiresAt: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Body of `POST action {type:'inject.execute'}` answers (host: routes.ts). */
|
|
78
|
+
export interface InjectResultView {
|
|
79
|
+
outcome: InjectOutcome
|
|
80
|
+
errorCode?: string
|
|
81
|
+
detail?: string
|
|
82
|
+
/** True when the gateway replayed a cached first result (idempotency). */
|
|
83
|
+
replayed?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Structural mirror of the data layer's ApiError (client/api.ts): real
|
|
88
|
+
* instances satisfy this shape, so the panel can classify them without
|
|
89
|
+
* importing the data layer.
|
|
90
|
+
*/
|
|
91
|
+
export interface ApiErrorLike {
|
|
92
|
+
kind: 'timeout' | 'aborted' | 'network' | 'http' | 'parse'
|
|
93
|
+
/** Server `{reason}` code for kind 'http', a stable local code otherwise. */
|
|
94
|
+
reason: string
|
|
95
|
+
status: number | null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What the panel hands the integration's `onPrepare` callback. */
|
|
99
|
+
export interface PanelPrepareRequest {
|
|
100
|
+
target: InjectTargetRef
|
|
101
|
+
mode: InjectMode
|
|
102
|
+
message: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What the panel hands the integration's `onExecute` callback. */
|
|
106
|
+
export interface PanelExecuteRequest {
|
|
107
|
+
requestId: string
|
|
108
|
+
confirmToken: string
|
|
109
|
+
message: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Byte counting and message validation (mirrors the host gate so the UI
|
|
114
|
+
// refuses locally exactly what the server would refuse remotely).
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Message byte cap; must mirror inject-gateway.ts (pinned by test). */
|
|
118
|
+
export const MAX_MESSAGE_BYTES = 16 * 1024
|
|
119
|
+
|
|
120
|
+
const utf8 = new TextEncoder()
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* UTF-8 byte size of a message. TextEncoder agrees with the host's
|
|
124
|
+
* `Buffer.byteLength(message, 'utf8')` for every well-formed string, so the
|
|
125
|
+
* 16 KiB verdict is identical on both sides.
|
|
126
|
+
*/
|
|
127
|
+
export function messageBytes(message: string): number {
|
|
128
|
+
return utf8.encode(message).length
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type MessageInvalidCode = 'empty' | 'nul' | 'too_large'
|
|
132
|
+
|
|
133
|
+
export type MessageValidation =
|
|
134
|
+
| { ok: true; bytes: number }
|
|
135
|
+
| { ok: false; code: MessageInvalidCode; bytes: number }
|
|
136
|
+
|
|
137
|
+
/** Local pre-validation, same checks and order as the host gateway. */
|
|
138
|
+
export function validateMessage(message: string): MessageValidation {
|
|
139
|
+
const bytes = messageBytes(message)
|
|
140
|
+
if (bytes === 0) return { ok: false, code: 'empty', bytes }
|
|
141
|
+
if (message.includes('\u0000')) return { ok: false, code: 'nul', bytes }
|
|
142
|
+
if (bytes > MAX_MESSAGE_BYTES) return { ok: false, code: 'too_large', bytes }
|
|
143
|
+
return { ok: true, bytes }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Render model for the live byte counter / limit progress bar. */
|
|
147
|
+
export interface ByteUsage {
|
|
148
|
+
bytes: number
|
|
149
|
+
limit: number
|
|
150
|
+
/** Fill fraction for the progress bar, clamped to [0, 1]. */
|
|
151
|
+
ratio: number
|
|
152
|
+
over: boolean
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Derive the byte counter view from a byte count. */
|
|
156
|
+
export function byteUsage(bytes: number): ByteUsage {
|
|
157
|
+
return {
|
|
158
|
+
bytes,
|
|
159
|
+
limit: MAX_MESSAGE_BYTES,
|
|
160
|
+
ratio: Math.min(1, Math.max(0, bytes / MAX_MESSAGE_BYTES)),
|
|
161
|
+
over: bytes > MAX_MESSAGE_BYTES,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Target visibility warning (design §4.d / S7).
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Whether the process-list visibility warning applies. Only cursor-cli
|
|
171
|
+
* qualifies: its upstream contract puts the prompt on the native
|
|
172
|
+
* subprocess argv. claude / codex / dsh do not warn — after S4a the
|
|
173
|
+
* sidecar's own argv (send --message-stdin) and the dsh in-process path
|
|
174
|
+
* both keep the body off the process list.
|
|
175
|
+
*/
|
|
176
|
+
export function showsProcessListWarning(agent: string): boolean {
|
|
177
|
+
return agent.trim().toLowerCase() === 'cursor-cli'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Token countdown (60s TTL is server-owned; the UI only reads expiresAt).
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export interface TokenCountdown {
|
|
185
|
+
remainingMs: number
|
|
186
|
+
/** Whole seconds for display, rounded up so "1s" never shows as "0s". */
|
|
187
|
+
seconds: number
|
|
188
|
+
expired: boolean
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Countdown view for a server-issued expiresAt (epoch ms). */
|
|
192
|
+
export function tokenCountdown(expiresAt: number, nowMs: number): TokenCountdown {
|
|
193
|
+
const remainingMs = Math.max(0, expiresAt - nowMs)
|
|
194
|
+
return {
|
|
195
|
+
remainingMs,
|
|
196
|
+
seconds: Math.ceil(remainingMs / 1000),
|
|
197
|
+
expired: nowMs >= expiresAt,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Two-phase state machine.
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** Non-blocking notice shown on the editor after a failed/expired attempt. */
|
|
206
|
+
export type PanelNotice =
|
|
207
|
+
| { kind: 'token_expired' }
|
|
208
|
+
/** Server vocabulary rejection (prepare answered with an error status). */
|
|
209
|
+
| { kind: 'prepare_rejected'; code: string; detail?: string }
|
|
210
|
+
/** Transport-level prepare failure (timeout/network/…); safe to retry. */
|
|
211
|
+
| { kind: 'prepare_error'; code: string }
|
|
212
|
+
|
|
213
|
+
export type PanelState =
|
|
214
|
+
| { phase: 'idle'; notice: PanelNotice | null }
|
|
215
|
+
| { phase: 'preparing'; message: string; mode: InjectMode }
|
|
216
|
+
| {
|
|
217
|
+
phase: 'confirm'
|
|
218
|
+
message: string
|
|
219
|
+
mode: InjectMode
|
|
220
|
+
requestId: string
|
|
221
|
+
confirmToken: string
|
|
222
|
+
plan: InjectPlanView
|
|
223
|
+
expiresAt: number
|
|
224
|
+
}
|
|
225
|
+
| {
|
|
226
|
+
phase: 'executing'
|
|
227
|
+
message: string
|
|
228
|
+
mode: InjectMode
|
|
229
|
+
requestId: string
|
|
230
|
+
confirmToken: string
|
|
231
|
+
plan: InjectPlanView
|
|
232
|
+
}
|
|
233
|
+
| { phase: 'result'; result: InjectResultView; plan: InjectPlanView | null }
|
|
234
|
+
|
|
235
|
+
export type InjectPhase = PanelState['phase']
|
|
236
|
+
|
|
237
|
+
export type PanelEvent =
|
|
238
|
+
| { type: 'PREPARE_START'; message: string; mode: InjectMode }
|
|
239
|
+
| { type: 'PREPARE_OK'; response: PrepareSuccess }
|
|
240
|
+
| { type: 'PREPARE_REJECTED'; code: string; detail?: string }
|
|
241
|
+
| { type: 'PREPARE_ERROR'; code: string }
|
|
242
|
+
| { type: 'TICK'; nowMs: number }
|
|
243
|
+
| { type: 'CANCEL' }
|
|
244
|
+
| { type: 'EXECUTE_START' }
|
|
245
|
+
| { type: 'EXECUTE_RESULT'; result: InjectResultView }
|
|
246
|
+
| { type: 'RESET' }
|
|
247
|
+
|
|
248
|
+
/** Fresh machine state (factory, so callers can never share a mutable seed). */
|
|
249
|
+
export function initialPanelState(): PanelState {
|
|
250
|
+
return { phase: 'idle', notice: null }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Pure transition function. Events that do not apply to the current phase
|
|
255
|
+
* return the state unchanged (same reference), which both makes stale
|
|
256
|
+
* async callbacks harmless and gives React a free bail-out.
|
|
257
|
+
*/
|
|
258
|
+
export function reducePanel(state: PanelState, event: PanelEvent): PanelState {
|
|
259
|
+
switch (event.type) {
|
|
260
|
+
case 'PREPARE_START':
|
|
261
|
+
if (state.phase !== 'idle') return state
|
|
262
|
+
return { phase: 'preparing', message: event.message, mode: event.mode }
|
|
263
|
+
|
|
264
|
+
case 'PREPARE_OK':
|
|
265
|
+
if (state.phase !== 'preparing') return state
|
|
266
|
+
return {
|
|
267
|
+
phase: 'confirm',
|
|
268
|
+
message: state.message,
|
|
269
|
+
mode: state.mode,
|
|
270
|
+
requestId: event.response.requestId,
|
|
271
|
+
confirmToken: event.response.confirmToken,
|
|
272
|
+
plan: event.response.plan,
|
|
273
|
+
expiresAt: event.response.expiresAt,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case 'PREPARE_REJECTED':
|
|
277
|
+
if (state.phase !== 'preparing') return state
|
|
278
|
+
return {
|
|
279
|
+
phase: 'idle',
|
|
280
|
+
notice: {
|
|
281
|
+
kind: 'prepare_rejected',
|
|
282
|
+
code: event.code,
|
|
283
|
+
...(event.detail !== undefined ? { detail: event.detail } : {}),
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
case 'PREPARE_ERROR':
|
|
288
|
+
if (state.phase !== 'preparing') return state
|
|
289
|
+
return { phase: 'idle', notice: { kind: 'prepare_error', code: event.code } }
|
|
290
|
+
|
|
291
|
+
case 'TICK':
|
|
292
|
+
if (state.phase !== 'confirm') return state
|
|
293
|
+
if (event.nowMs < state.expiresAt) return state
|
|
294
|
+
// Token TTL elapsed while waiting for the click: back to the editor
|
|
295
|
+
// with a "prepare again" notice (§4.f.5, 60s server TTL).
|
|
296
|
+
return { phase: 'idle', notice: { kind: 'token_expired' } }
|
|
297
|
+
|
|
298
|
+
case 'CANCEL':
|
|
299
|
+
if (state.phase !== 'confirm') return state
|
|
300
|
+
return { phase: 'idle', notice: null }
|
|
301
|
+
|
|
302
|
+
case 'EXECUTE_START':
|
|
303
|
+
if (state.phase !== 'confirm') return state
|
|
304
|
+
return {
|
|
305
|
+
phase: 'executing',
|
|
306
|
+
message: state.message,
|
|
307
|
+
mode: state.mode,
|
|
308
|
+
requestId: state.requestId,
|
|
309
|
+
confirmToken: state.confirmToken,
|
|
310
|
+
plan: state.plan,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
case 'EXECUTE_RESULT':
|
|
314
|
+
if (state.phase !== 'executing') return state
|
|
315
|
+
return { phase: 'result', result: event.result, plan: state.plan }
|
|
316
|
+
|
|
317
|
+
case 'RESET':
|
|
318
|
+
if (state.phase !== 'result') return state
|
|
319
|
+
// 'unknown' is terminal (S6): no machine path leads back to the
|
|
320
|
+
// editor, so no UI built on this reducer can offer a retry.
|
|
321
|
+
if (state.result.outcome === 'unknown') return state
|
|
322
|
+
return { phase: 'idle', notice: null }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Response classification (PrepareSuccess/InjectResult vs normalized error).
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
const API_ERROR_KINDS: ReadonlySet<string> = new Set([
|
|
331
|
+
'timeout',
|
|
332
|
+
'aborted',
|
|
333
|
+
'network',
|
|
334
|
+
'http',
|
|
335
|
+
'parse',
|
|
336
|
+
])
|
|
337
|
+
|
|
338
|
+
/** Structural guard matching client/api.ts ApiError instances. */
|
|
339
|
+
export function isApiErrorLike(value: unknown): value is ApiErrorLike {
|
|
340
|
+
if (typeof value !== 'object' || value === null) return false
|
|
341
|
+
const v = value as Record<string, unknown>
|
|
342
|
+
return (
|
|
343
|
+
typeof v['kind'] === 'string' &&
|
|
344
|
+
API_ERROR_KINDS.has(v['kind']) &&
|
|
345
|
+
typeof v['reason'] === 'string' &&
|
|
346
|
+
!('outcome' in v)
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Classify what `onPrepare` resolved with. An HTTP-kind error is a server
|
|
352
|
+
* vocabulary rejection (the reason carries the errorCode routes mapped to
|
|
353
|
+
* the status); any other kind is a transport failure — harmless for
|
|
354
|
+
* prepare, which is side-effect-free beyond a token that expires on its
|
|
355
|
+
* own, so the editor may simply offer another attempt.
|
|
356
|
+
*/
|
|
357
|
+
export function classifyPrepareResponse(
|
|
358
|
+
value: PrepareSuccess | ApiErrorLike,
|
|
359
|
+
): PanelEvent {
|
|
360
|
+
if (isApiErrorLike(value)) {
|
|
361
|
+
return value.kind === 'http'
|
|
362
|
+
? { type: 'PREPARE_REJECTED', code: value.reason }
|
|
363
|
+
: { type: 'PREPARE_ERROR', code: value.reason }
|
|
364
|
+
}
|
|
365
|
+
return { type: 'PREPARE_OK', response: value }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Classify what `onExecute` resolved with. An HTTP-kind error is the
|
|
370
|
+
* routes-mapped failed outcome. Everything else (timeout/network/parse/
|
|
371
|
+
* aborted) happened AFTER the execute may already have been dispatched, so
|
|
372
|
+
* the honest verdict is `outcome: 'unknown'` — terminal, no retry (S6);
|
|
373
|
+
* the user is pointed at the target session to verify.
|
|
374
|
+
*/
|
|
375
|
+
export function classifyExecuteResponse(
|
|
376
|
+
value: InjectResultView | ApiErrorLike,
|
|
377
|
+
): PanelEvent {
|
|
378
|
+
if (isApiErrorLike(value)) {
|
|
379
|
+
if (value.kind === 'http') {
|
|
380
|
+
return {
|
|
381
|
+
type: 'EXECUTE_RESULT',
|
|
382
|
+
result: { outcome: 'failed', errorCode: value.reason },
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
type: 'EXECUTE_RESULT',
|
|
387
|
+
result: { outcome: 'unknown', errorCode: value.reason },
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { type: 'EXECUTE_RESULT', result: value }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Enable/disable gate for the editor (design: 置灰规则).
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
export type EditorBlock = 'inject_off' | 'no_target' | 'busy' | 'invalid_message'
|
|
398
|
+
|
|
399
|
+
export interface EditorGate {
|
|
400
|
+
canPrepare: boolean
|
|
401
|
+
block: EditorBlock | null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Why (if at all) the prepare action is unavailable, in priority order:
|
|
406
|
+
* capability off > no target > a phase already in flight > invalid message.
|
|
407
|
+
*/
|
|
408
|
+
export function deriveEditorGate(input: {
|
|
409
|
+
injectEnabled: boolean
|
|
410
|
+
hasTarget: boolean
|
|
411
|
+
phase: InjectPhase
|
|
412
|
+
validation: MessageValidation
|
|
413
|
+
}): EditorGate {
|
|
414
|
+
if (!input.injectEnabled) return { canPrepare: false, block: 'inject_off' }
|
|
415
|
+
if (!input.hasTarget) return { canPrepare: false, block: 'no_target' }
|
|
416
|
+
if (input.phase !== 'idle') return { canPrepare: false, block: 'busy' }
|
|
417
|
+
if (!input.validation.ok) return { canPrepare: false, block: 'invalid_message' }
|
|
418
|
+
return { canPrepare: true, block: null }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Result actions (S6: unknown is terminal).
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
export interface ResultActions {
|
|
426
|
+
/** True only for 'failed': a fresh prepare (fresh confirmation) is offered. */
|
|
427
|
+
canReprepare: boolean
|
|
428
|
+
/** True only for 'unknown': point the user at the session to verify. */
|
|
429
|
+
showCheckSessionHint: boolean
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Which follow-up affordances a terminal outcome earns. */
|
|
433
|
+
export function resultActions(outcome: InjectOutcome): ResultActions {
|
|
434
|
+
return {
|
|
435
|
+
canReprepare: outcome === 'failed',
|
|
436
|
+
showCheckSessionHint: outcome === 'unknown',
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Copy mapping (keys live in the T2.3 locale table, inject.* domain).
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
/** A locale key plus its `{name}` template params, ready for `t()`. */
|
|
445
|
+
export interface CopyRef {
|
|
446
|
+
key: SidecarLocaleKey
|
|
447
|
+
params?: Record<string, unknown>
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Mode radio copy (label + semantics hint) per injection mode. */
|
|
451
|
+
export const MODE_COPY = {
|
|
452
|
+
queue: { label: 'inject.modeQueue', hint: 'inject.modeQueueHint' },
|
|
453
|
+
steer: { label: 'inject.modeSteer', hint: 'inject.modeSteerHint' },
|
|
454
|
+
} as const satisfies Record<InjectMode, { label: SidecarLocaleKey; hint: SidecarLocaleKey }>
|
|
455
|
+
|
|
456
|
+
/** Local validation verdict → copy key. */
|
|
457
|
+
export const MESSAGE_INVALID_COPY = {
|
|
458
|
+
empty: 'inject.msgEmpty',
|
|
459
|
+
nul: 'inject.msgNul',
|
|
460
|
+
too_large: 'inject.msgTooLarge',
|
|
461
|
+
} as const satisfies Record<MessageInvalidCode, SidecarLocaleKey>
|
|
462
|
+
|
|
463
|
+
/** Validation verdict → renderable copy (too_large carries bytes/limit). */
|
|
464
|
+
export function messageInvalidCopy(
|
|
465
|
+
validation: Extract<MessageValidation, { ok: false }>,
|
|
466
|
+
): CopyRef {
|
|
467
|
+
if (validation.code === 'too_large') {
|
|
468
|
+
return {
|
|
469
|
+
key: MESSAGE_INVALID_COPY.too_large,
|
|
470
|
+
params: { bytes: validation.bytes, limit: MAX_MESSAGE_BYTES },
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return { key: MESSAGE_INVALID_COPY[validation.code] }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Terminal outcome → headline copy key. */
|
|
477
|
+
export const RESULT_COPY = {
|
|
478
|
+
delivered: 'inject.resultDelivered',
|
|
479
|
+
failed: 'inject.resultFailed',
|
|
480
|
+
unknown: 'inject.resultUnknown',
|
|
481
|
+
} as const satisfies Record<InjectOutcome, SidecarLocaleKey>
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Error vocabulary → copy key: the gateway codes (inject-gateway.ts), plus
|
|
485
|
+
* the data layer's transport reasons (api.ts). Unlisted codes fall back to
|
|
486
|
+
* the generic template via {@link errorCopy}.
|
|
487
|
+
*/
|
|
488
|
+
export const ERROR_COPY: Readonly<Record<string, SidecarLocaleKey>> = {
|
|
489
|
+
inject_disabled: 'inject.errInjectDisabled',
|
|
490
|
+
invalid_message: 'inject.errInvalidMessage',
|
|
491
|
+
target_not_found: 'inject.errTargetNotFound',
|
|
492
|
+
target_dead: 'inject.errTargetDead',
|
|
493
|
+
too_many_pending: 'inject.errTooManyPending',
|
|
494
|
+
token_missing: 'inject.errTokenMissing',
|
|
495
|
+
token_expired: 'inject.errTokenExpired',
|
|
496
|
+
token_reused: 'inject.errTokenReused',
|
|
497
|
+
token_mismatch: 'inject.errTokenMismatch',
|
|
498
|
+
unsupported_agent: 'inject.errUnsupportedAgent',
|
|
499
|
+
executor_error: 'inject.errExecutorError',
|
|
500
|
+
request_timeout: 'inject.errTimeout',
|
|
501
|
+
request_aborted: 'inject.errAborted',
|
|
502
|
+
network_error: 'inject.errNetwork',
|
|
503
|
+
invalid_json: 'inject.errParse',
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Error code → renderable copy; unknown codes get the generic template. */
|
|
507
|
+
export function errorCopy(code: string): CopyRef {
|
|
508
|
+
const key = ERROR_COPY[code]
|
|
509
|
+
return key === undefined ? { key: 'inject.errGeneric', params: { code } } : { key }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Editor notice → renderable copy. */
|
|
513
|
+
export function noticeCopy(notice: PanelNotice): CopyRef {
|
|
514
|
+
if (notice.kind === 'token_expired') return { key: 'inject.tokenExpired' }
|
|
515
|
+
return errorCopy(notice.code)
|
|
516
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* Modal host for the inject panel (S5 wiring): a centered dialog over the
|
|
2
|
+
* board, opened by selecting a session card. Backdrop rides the dsh mask
|
|
3
|
+
* token --dsw-alias-bg-mask-1 (the ui-attachment / ui-settings-general
|
|
4
|
+
* modal precedent) — same no-literal-colors discipline as the panel. */
|
|
5
|
+
|
|
6
|
+
.backdrop {
|
|
7
|
+
position: fixed;
|
|
8
|
+
inset: 0;
|
|
9
|
+
z-index: 1000;
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
justify-content: center;
|
|
13
|
+
padding: 24px;
|
|
14
|
+
background: var(--dsw-alias-bg-mask-1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.dialog {
|
|
18
|
+
width: min(560px, 100%);
|
|
19
|
+
max-height: min(85vh, 720px);
|
|
20
|
+
overflow: auto;
|
|
21
|
+
border-radius: 12px;
|
|
22
|
+
}
|