@dsh-workbench/shared 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/api/dsh.d.ts +1 -0
  2. package/dist/api/host-write.d.ts +1 -1
  3. package/dist/components/FormRows.css +10 -4
  4. package/dist/components/Rows.css +12 -5
  5. package/dist/components/SelectField.css +20 -0
  6. package/dist/components/SelectField.d.ts +3 -1
  7. package/dist/components/SelectField.js +43 -25
  8. package/dist/components/SelectFieldNodes.d.ts +21 -0
  9. package/dist/components/SelectFieldNodes.js +17 -0
  10. package/dist/config/constants.d.ts +3 -0
  11. package/dist/config/constants.js +3 -0
  12. package/dist/host/http.d.ts +3 -0
  13. package/dist/host/http.js +10 -1
  14. package/dist/host/tool-kit.d.ts +1 -0
  15. package/dist/host/tool-kit.js +4 -0
  16. package/dist/host.d.ts +2 -0
  17. package/dist/host.js +2 -0
  18. package/dist/i18n/en.js +14 -0
  19. package/dist/i18n/index.d.ts +15 -1
  20. package/dist/i18n/zh.d.ts +14 -0
  21. package/dist/i18n/zh.js +14 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +2 -0
  24. package/dist/lib/board-fact.d.ts +13 -0
  25. package/dist/lib/board-fact.js +28 -0
  26. package/dist/lib/diy-debug.d.ts +39 -0
  27. package/dist/lib/diy-debug.js +70 -0
  28. package/package.json +1 -1
  29. package/src/api/dsh.ts +6 -1
  30. package/src/api/host-write.ts +5 -1
  31. package/src/components/FormRows.css +10 -4
  32. package/src/components/Rows.css +12 -5
  33. package/src/components/SelectField.css +20 -0
  34. package/src/components/SelectField.test.tsx +63 -0
  35. package/src/components/SelectField.tsx +56 -81
  36. package/src/components/SelectFieldNodes.tsx +77 -0
  37. package/src/config/constants.ts +5 -0
  38. package/src/host/http.ts +16 -3
  39. package/src/host/tool-kit.ts +5 -0
  40. package/src/host.ts +2 -0
  41. package/src/i18n/en.ts +15 -0
  42. package/src/i18n/zh.ts +15 -0
  43. package/src/index.ts +2 -0
  44. package/src/lib/board-fact.test.ts +86 -0
  45. package/src/lib/board-fact.ts +45 -0
  46. package/src/lib/diy-debug.test.ts +110 -0
  47. package/src/lib/diy-debug.ts +118 -0
@@ -0,0 +1,39 @@
1
+ export type DebugPhase = 'debugging' | 'paused';
2
+ export type DebugIntent = 'pause' | 'resume' | 'stop';
3
+ export declare function parseDebugIntent(value: unknown): DebugIntent | null;
4
+ export declare const DEBUG_STALE_MS: number;
5
+ export interface DebugState {
6
+ readonly phase: DebugPhase;
7
+ readonly sessionId: string;
8
+ readonly workspace: string;
9
+ readonly board: string;
10
+ readonly unit: string;
11
+ readonly name: string;
12
+ readonly startedAt: number;
13
+ readonly at: number;
14
+ }
15
+ export interface DebugPact {
16
+ readonly sessionId: string;
17
+ readonly workspace: string;
18
+ readonly board: string;
19
+ readonly unit: string;
20
+ readonly name: string;
21
+ readonly now: number;
22
+ }
23
+ export type DebugRefusal = 'already-held' | 'held-by-other' | 'not-owner' | 'none' | 'already-paused' | 'already-debugging';
24
+ export type DebugMove = {
25
+ readonly ok: true;
26
+ readonly state: DebugState;
27
+ } | {
28
+ readonly ok: false;
29
+ readonly code: DebugRefusal;
30
+ };
31
+ export interface DebugStore {
32
+ live(now: number): DebugState | null;
33
+ begin(pact: DebugPact): DebugMove;
34
+ pause(sessionId: string, now: number): DebugMove;
35
+ resume(sessionId: string, now: number): DebugMove;
36
+ stop(sessionId: string, now: number): DebugMove;
37
+ }
38
+ export declare function createDebugStore(): DebugStore;
39
+ export declare function debugOwnerText(state: DebugState): string;
@@ -0,0 +1,70 @@
1
+ export function parseDebugIntent(value) {
2
+ return value === 'pause' || value === 'resume' || value === 'stop' ? value : null;
3
+ }
4
+ export const DEBUG_STALE_MS = 4 * 60 * 60 * 1000;
5
+ function alive(state, now) {
6
+ if (state === null)
7
+ return null;
8
+ return now - state.at <= DEBUG_STALE_MS ? state : null;
9
+ }
10
+ function ownerOf(state, sessionId, now) {
11
+ const held = alive(state, now);
12
+ if (held === null)
13
+ return 'none';
14
+ return held.sessionId === sessionId ? held : 'not-owner';
15
+ }
16
+ export function createDebugStore() {
17
+ let held = null;
18
+ const live = (now) => alive(held, now);
19
+ return {
20
+ live,
21
+ begin(pact) {
22
+ const current = alive(held, pact.now);
23
+ if (current !== null) {
24
+ return { ok: false, code: current.sessionId === pact.sessionId ? 'already-held' : 'held-by-other' };
25
+ }
26
+ const state = {
27
+ phase: 'debugging',
28
+ sessionId: pact.sessionId,
29
+ workspace: pact.workspace,
30
+ board: pact.board,
31
+ unit: pact.unit,
32
+ name: pact.name,
33
+ startedAt: pact.now,
34
+ at: pact.now,
35
+ };
36
+ held = state;
37
+ return { ok: true, state };
38
+ },
39
+ pause(sessionId, now) {
40
+ const found = ownerOf(held, sessionId, now);
41
+ if (typeof found === 'string')
42
+ return { ok: false, code: found };
43
+ if (found.phase === 'paused')
44
+ return { ok: false, code: 'already-paused' };
45
+ const state = { ...found, phase: 'paused', at: now };
46
+ held = state;
47
+ return { ok: true, state };
48
+ },
49
+ resume(sessionId, now) {
50
+ const found = ownerOf(held, sessionId, now);
51
+ if (typeof found === 'string')
52
+ return { ok: false, code: found };
53
+ if (found.phase === 'debugging')
54
+ return { ok: false, code: 'already-debugging' };
55
+ const state = { ...found, phase: 'debugging', at: now };
56
+ held = state;
57
+ return { ok: true, state };
58
+ },
59
+ stop(sessionId, now) {
60
+ const found = ownerOf(held, sessionId, now);
61
+ if (typeof found === 'string')
62
+ return { ok: false, code: found };
63
+ held = null;
64
+ return { ok: true, state: found };
65
+ },
66
+ };
67
+ }
68
+ export function debugOwnerText(state) {
69
+ return `会话 ${state.sessionId}(自 ${new Date(state.startedAt).toISOString().slice(11, 19)}Z 起在调试 ${state.unit})`;
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/shared",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "工作台与模块共享的纯代码与契约类型:类型、纯逻辑、哑组件、平台解析。宿主侧的东西从 /host 进(不影响浏览器产物)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/api/dsh.ts CHANGED
@@ -151,7 +151,12 @@ export interface DshToolDefinition {
151
151
  export interface DshToolRunContext {
152
152
  readonly agent?:
153
153
  | {
154
- readonly session?: { readonly header?: { readonly cwd?: string } | undefined } | undefined;
154
+ readonly session?:
155
+ | {
156
+ readonly id?: string;
157
+ readonly header?: { readonly cwd?: string } | undefined;
158
+ }
159
+ | undefined;
155
160
  }
156
161
  | undefined;
157
162
  }
@@ -1,6 +1,10 @@
1
1
  import type { ApiResponse } from './workbench-client.js';
2
2
 
3
- export type WriteChannel = 'prefs' | 'actions' | 'provision';
3
+ export type WriteChannel =
4
+ | 'prefs'
5
+ | 'actions'
6
+ | 'provision'
7
+ | 'diy-debug';
4
8
 
5
9
  export function writeRoute(channel: WriteChannel, resource: string): string {
6
10
  if (channel === 'provision') return 'provision';
@@ -18,10 +18,6 @@
18
18
  row-gap: var(--wb-len-8);
19
19
  }
20
20
 
21
- .wb-formRows > .wb-formRow {
22
- grid-template-columns: subgrid;
23
- }
24
-
25
21
  .wb-formRow[data-layout='stacked'] {
26
22
  grid-template-columns: minmax(0, 1fr);
27
23
  align-items: stretch;
@@ -67,3 +63,13 @@
67
63
  .wb-formRow:has(:disabled) .wb-formLabel {
68
64
  opacity: 0.4;
69
65
  }
66
+
67
+ .wb-formRows > .wb-formRow {
68
+ grid-template-columns: subgrid;
69
+ }
70
+
71
+ @supports not (grid-template-columns: subgrid) {
72
+ .wb-formRows > .wb-formRow {
73
+ grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
74
+ }
75
+ }
@@ -1,14 +1,10 @@
1
-
2
- .wb-row:has(> [data-wb-dot]) {
3
- grid-template-columns: 84px minmax(0, 1fr);
4
- }
5
1
  .wb-rows {
6
2
  display: flex;
7
3
  flex-direction: column;
8
4
  }
9
5
  .wb-row {
10
6
  display: grid;
11
- grid-template-columns: 56px minmax(0, 1fr);
7
+ grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
12
8
  align-items: baseline;
13
9
  gap: var(--wb-len-8);
14
10
  padding: var(--wb-len-4) 0;
@@ -17,6 +13,7 @@
17
13
  border-top: 0.5px solid var(--dsw-alias-border-l2);
18
14
  }
19
15
  .wb-rowKey {
16
+ white-space: nowrap;
20
17
  font-size: var(--dsw-font-xxxs-11-font-size);
21
18
  line-height: var(--wb-line-16);
22
19
  color: var(--dsw-alias-label-tertiary);
@@ -28,3 +25,13 @@
28
25
  color: var(--dsw-alias-label-primary);
29
26
  overflow-wrap: anywhere;
30
27
  }
28
+
29
+ .wb-rows > .wb-row {
30
+ grid-template-columns: subgrid;
31
+ }
32
+
33
+ @supports not (grid-template-columns: subgrid) {
34
+ .wb-rows > .wb-row {
35
+ grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
36
+ }
37
+ }
@@ -61,6 +61,26 @@
61
61
  box-shadow: none;
62
62
  }
63
63
 
64
+ .wb-selectIconOnly {
65
+ width: var(--wb-len-36);
66
+ height: var(--wb-len-36);
67
+ min-height: 0;
68
+ gap: 0;
69
+ padding: 0;
70
+ justify-content: center;
71
+ }
72
+
73
+ .wb-selectReveal {
74
+ display: inline-flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ }
78
+
79
+ .wb-selectIconOnly .wb-selectIcon {
80
+ width: 100%;
81
+ height: 100%;
82
+ }
83
+
64
84
  .wb-selectAnchor {
65
85
  display: block;
66
86
  width: 100%;
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  let states: unknown[] = [];
4
4
  vi.mock('../lib/react.js', () => ({
5
+ Fragment: Symbol.for('react.fragment'),
5
6
  h: (type: unknown, props: unknown, ...children: unknown[]) => ({ type, props, children }),
6
7
  useEffect: () => {},
7
8
  useMemo: (compute: () => unknown) => compute(),
@@ -53,6 +54,10 @@ function elementsOf(root: unknown): readonly AnyElement[] {
53
54
  return collect(root);
54
55
  }
55
56
 
57
+ function marked(nodes: readonly AnyElement[]): boolean {
58
+ return nodes.some((one) => one.props?.['data-wb-select-reveal'] === '');
59
+ }
60
+
56
61
  function menuOf(root: unknown): AnyElement {
57
62
  const menu = elementsOf(root).find((one) => one.type === Menu);
58
63
  if (menu === undefined) throw new Error('没有渲染出 Menu');
@@ -113,6 +118,38 @@ describe('SelectField:工作区那套下拉的公共形状', () => {
113
118
  expect(inputOf(menu.props['anchor'])?.props['value']).toBe('');
114
119
  });
115
120
 
121
+ it('★ 只显示图标那一档(折叠左栏那个工作区钮):没有值也没有箭头,读屏名字只能来自 ariaLabel', () => {
122
+ const icon = { type: 'i', props: {}, children: [] };
123
+ const trigger = elementsOf(
124
+ menuOf(render({ variant: 'ghost', iconOnly: true, icon, ariaLabel: '切换项目' })).props['anchor'],
125
+ ).find((one) => one.type === 'button');
126
+
127
+ expect(trigger?.props['className']).toBe('wb-selectTrigger wb-selectGhost wb-selectIconOnly');
128
+ expect(trigger?.props['aria-label']).toBe('切换项目');
129
+ expect(JSON.stringify(trigger?.children)).not.toContain('wb-selectValue');
130
+ expect(JSON.stringify(trigger?.children)).not.toContain('wb-selectChevron');
131
+ expect(elementsOf(trigger?.children).some((one) => one.props['className'] === 'wb-selectIcon')).toBe(true);
132
+ });
133
+
134
+ it('不给 iconOnly 时一个字节都不变(宽态那套触发器照旧)', () => {
135
+ const trigger = elementsOf(menuOf(render({ variant: 'ghost' })).props['anchor']).find(
136
+ (one) => one.type === 'button',
137
+ );
138
+
139
+ expect(trigger?.props['className']).toBe('wb-selectTrigger wb-selectGhost');
140
+ expect(JSON.stringify(trigger?.children)).toContain('wb-selectValue');
141
+ });
142
+
143
+ it('★ search=false:锚点仍是触发钮(折叠左栏那个下拉要的就是"点开直接是列表")', () => {
144
+ const root = render({ search: false });
145
+ const anchor = elementsOf(menuOf(root).props['anchor']);
146
+
147
+ expect(anchor.some((one) => one.type === 'button')).toBe(true);
148
+ const classes = anchor.map((one) => one.props?.['className']).filter((one) => typeof one === 'string');
149
+ expect(classes).not.toContain('wb-selectSearch');
150
+ expect(inputOf(menuOf(root).props['anchor'])).toBeUndefined();
151
+ });
152
+
116
153
  it('禁用:触发钮真的 disabled(压暗交给样式,别只在视觉上假装)', () => {
117
154
  const root = render({ disabled: true });
118
155
  const trigger = elementsOf(menuOf(root).props['anchor']).find((one) => one.type === 'button');
@@ -239,6 +276,32 @@ describe('SelectField:工作区那套下拉的公共形状', () => {
239
276
  expect(defaultSelectFilter(option, 'nope')).toBe(false);
240
277
  });
241
278
 
279
+ it('★ revealId:只给那一行的图标裹上标记(开列表时靠它滚进可视区),别行一个字节不变', () => {
280
+ const items = selectEntries(
281
+ [
282
+ { value: 'a', label: '甲', icon: <span>甲</span> },
283
+ { value: 'b', label: '乙', icon: <span>乙</span> },
284
+ ],
285
+ undefined,
286
+ 'b',
287
+ );
288
+ const iconOf = (id: string): unknown =>
289
+ (items.find((one) => (one as { id?: string }).id === id) as { icon?: unknown } | undefined)?.icon;
290
+
291
+ expect(marked(elementsOf(iconOf('a')))).toBe(false);
292
+ expect(marked(elementsOf(iconOf('b')))).toBe(true);
293
+ });
294
+
295
+ it('★ revealId 撞上没有图标的行:退而标记它的文字(标记只落一处)', () => {
296
+ const items = selectEntries([{ value: 'a', label: '甲' }], undefined, 'a');
297
+ const row = items.find((one) => (one as { id?: string }).id === 'a') as
298
+ | { label?: unknown; icon?: unknown }
299
+ | undefined;
300
+
301
+ expect(row?.icon).toBeUndefined();
302
+ expect(marked(elementsOf(row?.label))).toBe(true);
303
+ });
304
+
242
305
  it('条目构造:图标与禁用照传,分组标题只在换组时出现一次', () => {
243
306
  const items = selectEntries(
244
307
  [
@@ -1,15 +1,10 @@
1
1
  import type { KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from 'react';
2
2
  import type { MenuEntry, MenuItem } from '@deepseek-ai/dsh-client-ui-primitives';
3
3
  import { h, useEffect, useMemo, useRef, useState } from '../lib/react.js';
4
- import {
5
- IconChevronDownOutlineRegular,
6
- IconSearchOutlineRegular,
7
- Menu,
8
- TextShimmer,
9
- } from '@deepseek-ai/dsh-client-ui-primitives';
4
+ import { IconSearchOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives';
5
+ import { selectLoadingNode, selectTriggerNode } from './SelectFieldNodes.js';
10
6
  import { Field } from './Field.js';
11
7
  import { TextField } from './TextField.js';
12
- import { Spinner } from './Spinner.js';
13
8
  import './SelectField.css';
14
9
 
15
10
  export interface SelectFieldOption {
@@ -56,6 +51,8 @@ export interface SelectFieldProps {
56
51
  readonly onFooter?: (() => void) | undefined;
57
52
  readonly selection?: 'check' | 'fill' | undefined;
58
53
  readonly variant?: 'ghost' | 'boxed' | undefined;
54
+ readonly iconOnly?: boolean | undefined;
55
+ readonly revealId?: string | undefined;
59
56
  readonly ariaLabel?: string | undefined;
60
57
  readonly title?: string | undefined;
61
58
  readonly id?: string | undefined;
@@ -71,75 +68,54 @@ function mergeClass(...parts: readonly (string | undefined)[]): string {
71
68
  return parts.filter((one) => one !== undefined && one !== '').join(' ');
72
69
  }
73
70
 
74
- function itemOf(option: SelectFieldOption): MenuItem {
71
+ function triggerClassOf(variantClass: string | undefined, iconOnly: boolean): string {
72
+ return mergeClass('wb-selectTrigger', variantClass, iconOnly ? 'wb-selectIconOnly' : undefined);
73
+ }
74
+
75
+ function itemOf(option: SelectFieldOption, revealId: string | undefined): MenuItem {
76
+ const mark = option.value === revealId;
77
+ if (!mark) {
78
+ return {
79
+ id: option.value,
80
+ label: option.label,
81
+ ...(option.icon === undefined ? {} : { icon: option.icon }),
82
+ ...(option.disabled === true ? { disabled: true } : {}),
83
+ };
84
+ }
85
+ const revealed = (node: ReactNode): ReactNode => (
86
+ <span className="wb-selectReveal" data-wb-select-reveal="">
87
+ {node}
88
+ </span>
89
+ );
75
90
  return {
76
91
  id: option.value,
77
- label: option.label,
78
- ...(option.icon === undefined ? {} : { icon: option.icon }),
92
+ label: option.icon === undefined ? revealed(option.label) : option.label,
93
+ ...(option.icon === undefined ? {} : { icon: revealed(option.icon) }),
79
94
  ...(option.disabled === true ? { disabled: true } : {}),
80
95
  };
81
96
  }
82
97
 
83
- function triggerNode(args: {
84
- readonly id: string | undefined;
85
- readonly className: string;
86
- readonly disabled: boolean;
87
- readonly invalid: boolean;
88
- readonly open: boolean;
89
- readonly ariaLabel: string | undefined;
90
- readonly title: string | undefined;
91
- readonly icon: ReactNode | undefined;
92
- readonly value: ReactNode;
93
- readonly onOpen: () => void;
94
- }): ReactNode {
95
- return (
96
- <button
97
- type="button"
98
- id={args.id}
99
- className={args.className}
100
- disabled={args.disabled}
101
- aria-haspopup="listbox"
102
- aria-expanded={args.open}
103
- aria-invalid={args.invalid || undefined}
104
- aria-label={args.ariaLabel}
105
- title={args.title}
106
- onClick={args.onOpen}
107
- >
108
- {args.icon === undefined ? null : <span className="wb-selectIcon">{args.icon}</span>}
109
- <span className="wb-selectValue">{args.value}</span>
110
- <span className="wb-selectChevron">
111
- <IconChevronDownOutlineRegular size={14} />
112
- </span>
113
- </button>
114
- );
98
+ function revealNode(): HTMLElement | null {
99
+ const found = document.querySelector('[data-wb-select-reveal]');
100
+ return found instanceof HTMLElement ? found : null;
115
101
  }
116
102
 
117
- function loadingNode(args: {
118
- readonly id: string | undefined;
119
- readonly className: string;
120
- readonly invalid: boolean;
121
- readonly ariaLabel: string | undefined;
122
- readonly loadingText: string | undefined;
123
- }): ReactNode {
124
- return (
125
- <button
126
- type="button"
127
- id={args.id}
128
- className={`${args.className} wb-selectLoading`}
129
- disabled
130
- aria-busy="true"
131
- aria-invalid={args.invalid || undefined}
132
- aria-label={args.ariaLabel}
133
- title={args.loadingText}
134
- >
135
- <span className="wb-selectValue">
136
- <TextShimmer active>{args.loadingText ?? '…'}</TextShimmer>
137
- </span>
138
- <span className="wb-selectChevron">
139
- <Spinner size={14} />
140
- </span>
141
- </button>
142
- );
103
+ function useRevealOnOpen(open: boolean, revealId: string | undefined): void {
104
+ useEffect(() => {
105
+ if (!open || revealId === undefined) return;
106
+ revealNode()?.scrollIntoView({ block: 'nearest' });
107
+ }, [open, revealId]);
108
+ }
109
+
110
+ function selectValueNode(
111
+ current: SelectFieldOption | undefined,
112
+ renderValue: SelectFieldProps['renderValue'],
113
+ placeholder: ReactNode,
114
+ ): ReactNode {
115
+ if (current === undefined) {
116
+ return <span className="wb-selectPlaceholder">{placeholder ?? '—'}</span>;
117
+ }
118
+ return renderValue === undefined ? current.label : renderValue(current);
143
119
  }
144
120
 
145
121
  function searchNode(args: {
@@ -269,6 +245,7 @@ export function stepSelectCursor(
269
245
  export function selectEntries(
270
246
  shown: readonly SelectFieldOption[],
271
247
  emptyText: string | undefined,
248
+ revealId?: string | undefined,
272
249
  ): readonly MenuEntry[] {
273
250
  if (shown.length === 0) {
274
251
  return emptyText === undefined ? [] : [{ type: 'label', id: EMPTY_ID, text: emptyText }];
@@ -280,7 +257,7 @@ export function selectEntries(
280
257
  out.push({ type: 'label', id: `__wb-select-group-${String(out.length)}`, text: option.group });
281
258
  group = option.group;
282
259
  }
283
- out.push(itemOf(option));
260
+ out.push(itemOf(option, revealId));
284
261
  }
285
262
  return out;
286
263
  }
@@ -368,6 +345,8 @@ export function SelectField(props: SelectFieldProps) {
368
345
  fallback,
369
346
  selection = 'fill',
370
347
  variant = 'boxed',
348
+ iconOnly = false,
349
+ revealId,
371
350
  ariaLabel,
372
351
  title,
373
352
  id,
@@ -377,15 +356,17 @@ export function SelectField(props: SelectFieldProps) {
377
356
  } = props;
378
357
  const state = useSelectState(props);
379
358
  const current = options.find((option) => option.value === value);
359
+
360
+ useRevealOnOpen(state.open, revealId);
380
361
  const variantClass = variant === 'ghost' ? 'wb-selectGhost' : undefined;
381
- const triggerClass = mergeClass('wb-selectTrigger', variantClass);
362
+ const triggerClass = triggerClassOf(variantClass, iconOnly);
382
363
 
383
364
  if (loading) {
384
365
  return framed(
385
366
  label,
386
367
  hint,
387
368
  className,
388
- loadingNode({ id, className: triggerClass, invalid, ariaLabel, loadingText }),
369
+ selectLoadingNode({ id, className: triggerClass, invalid, ariaLabel, loadingText }),
389
370
  );
390
371
  }
391
372
 
@@ -406,7 +387,7 @@ export function SelectField(props: SelectFieldProps) {
406
387
  );
407
388
  }
408
389
 
409
- const trigger = triggerNode({
390
+ const trigger = selectTriggerNode({
410
391
  id,
411
392
  className: triggerClass,
412
393
  disabled,
@@ -415,14 +396,8 @@ export function SelectField(props: SelectFieldProps) {
415
396
  ariaLabel,
416
397
  title,
417
398
  icon,
418
- value:
419
- current === undefined ? (
420
- <span className="wb-selectPlaceholder">{placeholder ?? '—'}</span>
421
- ) : renderValue === undefined ? (
422
- current.label
423
- ) : (
424
- renderValue(current)
425
- ),
399
+ value: selectValueNode(current, renderValue, placeholder),
400
+ iconOnly,
426
401
  onOpen: () => state.setOpen(true),
427
402
  });
428
403
 
@@ -469,7 +444,7 @@ export function SelectField(props: SelectFieldProps) {
469
444
  anchor,
470
445
  selection,
471
446
  selectedId: selectCursorValue(state.shown, state.cursor) ?? undefined,
472
- items: selectEntries(state.shown, state.empty),
447
+ items: selectEntries(state.shown, state.empty, revealId),
473
448
  footer:
474
449
  footer === undefined
475
450
  ? undefined
@@ -0,0 +1,77 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Fragment, h } from '../lib/react.js';
3
+ import {
4
+ IconChevronDownOutlineRegular,
5
+ TextShimmer,
6
+ } from '@deepseek-ai/dsh-client-ui-primitives';
7
+ import { Spinner } from './Spinner.js';
8
+
9
+ export function selectTriggerNode(args: {
10
+ readonly id: string | undefined;
11
+ readonly className: string;
12
+ readonly disabled: boolean;
13
+ readonly invalid: boolean;
14
+ readonly open: boolean;
15
+ readonly ariaLabel: string | undefined;
16
+ readonly title: string | undefined;
17
+ readonly icon: ReactNode | undefined;
18
+ readonly value: ReactNode;
19
+ readonly iconOnly: boolean;
20
+ readonly onOpen: () => void;
21
+ }): ReactNode {
22
+ return (
23
+ <button
24
+ type="button"
25
+ id={args.id}
26
+ className={args.className}
27
+ disabled={args.disabled}
28
+ aria-haspopup="listbox"
29
+ aria-expanded={args.open}
30
+ aria-invalid={args.invalid || undefined}
31
+ aria-label={args.ariaLabel}
32
+ title={args.title}
33
+ onClick={args.onOpen}
34
+ >
35
+ {args.iconOnly ? (
36
+ <span className="wb-selectIcon">{args.icon}</span>
37
+ ) : (
38
+ <Fragment>
39
+ {args.icon === undefined ? null : <span className="wb-selectIcon">{args.icon}</span>}
40
+ <span className="wb-selectValue">{args.value}</span>
41
+ <span className="wb-selectChevron">
42
+ <IconChevronDownOutlineRegular size={14} />
43
+ </span>
44
+ </Fragment>
45
+ )}
46
+ </button>
47
+ );
48
+ }
49
+
50
+ export function selectLoadingNode(args: {
51
+ readonly id: string | undefined;
52
+ readonly className: string;
53
+ readonly invalid: boolean;
54
+ readonly ariaLabel: string | undefined;
55
+ readonly loadingText: string | undefined;
56
+ }): ReactNode {
57
+ return (
58
+ <button
59
+ type="button"
60
+ id={args.id}
61
+ className={`${args.className} wb-selectLoading`}
62
+ disabled
63
+ aria-busy="true"
64
+ aria-invalid={args.invalid || undefined}
65
+ aria-label={args.ariaLabel}
66
+ title={args.loadingText}
67
+ >
68
+ <span className="wb-selectValue">
69
+ <TextShimmer active>{args.loadingText ?? '…'}</TextShimmer>
70
+ </span>
71
+ <span className="wb-selectChevron">
72
+ <Spinner size={14} />
73
+ </span>
74
+ </button>
75
+ );
76
+ }
77
+
@@ -16,6 +16,9 @@ export const PROVISION_PREFIX = `${API_PREFIX}/provision`;
16
16
 
17
17
  export const PANEL_STATE_PATH = "panel-state";
18
18
 
19
+ export const DIY_DEBUG_PATH = "diy-debug";
20
+ export const DIY_DEBUG_ACT_PATH = `${DIY_DEBUG_PATH}/act`;
21
+
19
22
  export const PANEL_STATE_PREFIX = `${API_PREFIX}/${PANEL_STATE_PATH}`;
20
23
 
21
24
  export const OPENCLI_PIN = "1.8.8";
@@ -28,6 +31,8 @@ export const UNITS_SERVICE = "workbenchUnits";
28
31
 
29
32
  export const PANEL_STATE_SERVICE = "workbenchPanelState";
30
33
 
34
+ export const DIY_DEBUG_SERVICE = "workbenchDiyDebug";
35
+
31
36
  export const PANEL_LIST_LIMIT = 50;
32
37
 
33
38
  export const OPENCLI_TIMEOUT_MS = 120_000;
package/src/host/http.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { DshHostContext, DshRequest, DshResponse } from '../api/dsh.js';
2
2
  import { unitFactScope } from '../api/facts.js';
3
3
  import { parseCardsReport, parseCrashReport, type PanelStateStore } from '../lib/panel-state.js';
4
- import { ACTIONS_PREFIX, API_PREFIX, PANEL_STATE_PREFIX, PLUGIN_ID, PREFS_PREFIX, PROVISION_PREFIX } from '../config/constants.js';
4
+ import { ACTIONS_PREFIX, API_PREFIX, DIY_DEBUG_PATH, PANEL_STATE_PREFIX, PLUGIN_ID, PREFS_PREFIX, PROVISION_PREFIX } from '../config/constants.js';
5
5
  import {
6
6
  parseProvisionBody,
7
7
  type ProvisionHandler,
@@ -300,8 +300,7 @@ export function registerActions(
300
300
  });
301
301
  }
302
302
 
303
- export function registerPanelState(ctx: DshHostContext, store: PanelStateStore): () => void {
304
- return registerPost(ctx, {
303
+ export function registerPanelState(ctx: DshHostContext, store: PanelStateStore): () => void { return registerPost(ctx, {
305
304
  prefix: PANEL_STATE_PREFIX,
306
305
  what: '面板运行态',
307
306
  refusedCode: 'report-refused',
@@ -327,3 +326,17 @@ export function registerPanelState(ctx: DshHostContext, store: PanelStateStore):
327
326
  ],
328
327
  });
329
328
  }
329
+
330
+ export function registerDiyDebugAct(
331
+ ctx: DshHostContext,
332
+ resource: PostResource,
333
+ log?: { bump(facts: readonly string[]): unknown } | undefined,
334
+ ): () => void {
335
+ return registerPost(ctx, {
336
+ prefix: `${API_PREFIX}/${DIY_DEBUG_PATH}`,
337
+ what: '工作台 DIY 调试动作',
338
+ refusedCode: 'action-refused',
339
+ resources: [resource],
340
+ wrote: () => log?.bump(['diy-debug']),
341
+ });
342
+ }