@dsh-workbench/shared 0.2.14 → 0.2.15
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/dist/api/dsh.d.ts +1 -0
- package/dist/components/SelectField.css +20 -0
- package/dist/components/SelectField.d.ts +3 -1
- package/dist/components/SelectField.js +43 -25
- package/dist/components/SelectFieldNodes.d.ts +21 -0
- package/dist/components/SelectFieldNodes.js +17 -0
- package/dist/config/constants.d.ts +1 -0
- package/dist/config/constants.js +1 -0
- package/dist/host/tool-kit.d.ts +1 -0
- package/dist/host/tool-kit.js +4 -0
- package/dist/host.d.ts +1 -0
- package/dist/host.js +1 -0
- package/dist/i18n/en.js +8 -0
- package/dist/i18n/index.d.ts +9 -1
- package/dist/i18n/zh.d.ts +8 -0
- package/dist/i18n/zh.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/diy-debug.d.ts +38 -0
- package/dist/lib/diy-debug.js +77 -0
- package/package.json +1 -1
- package/src/api/dsh.ts +6 -1
- package/src/components/SelectField.css +20 -0
- package/src/components/SelectField.test.tsx +63 -0
- package/src/components/SelectField.tsx +56 -81
- package/src/components/SelectFieldNodes.tsx +77 -0
- package/src/config/constants.ts +2 -0
- package/src/host/tool-kit.ts +5 -0
- package/src/host.ts +1 -0
- package/src/i18n/en.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/index.ts +1 -0
- package/src/lib/diy-debug.test.ts +103 -0
- package/src/lib/diy-debug.ts +122 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
|
package/src/config/constants.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const UNITS_SERVICE = "workbenchUnits";
|
|
|
28
28
|
|
|
29
29
|
export const PANEL_STATE_SERVICE = "workbenchPanelState";
|
|
30
30
|
|
|
31
|
+
export const DIY_DEBUG_SERVICE = "workbenchDiyDebug";
|
|
32
|
+
|
|
31
33
|
export const PANEL_LIST_LIMIT = 50;
|
|
32
34
|
|
|
33
35
|
export const OPENCLI_TIMEOUT_MS = 120_000;
|
package/src/host/tool-kit.ts
CHANGED
|
@@ -6,6 +6,11 @@ export function sessionWorkspace(exec: DshToolRunContext | undefined): string |
|
|
|
6
6
|
return typeof cwd === 'string' && cwd.trim() !== '' ? cwd.trim() : null;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export function sessionIdOf(exec: DshToolRunContext | undefined): string | null {
|
|
10
|
+
const id = exec?.agent?.session?.id;
|
|
11
|
+
return typeof id === 'string' && id.trim() !== '' ? id.trim() : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
export function textOutput(text: string): Array<Record<string, unknown>> {
|
|
10
15
|
return [{ type: 'text', text }];
|
|
11
16
|
}
|
package/src/host.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from './api/long-task.js';
|
|
|
7
7
|
export * from './lib/text.js';
|
|
8
8
|
export * from './lib/ui.js';
|
|
9
9
|
export * from './lib/panel-state.js';
|
|
10
|
+
export * from './lib/diy-debug.js';
|
|
10
11
|
export * from './api/blocker-table.js';
|
|
11
12
|
export * from './host/browser-find.js';
|
|
12
13
|
export * from './host/browser-read.js';
|
package/src/i18n/en.ts
CHANGED
|
@@ -420,4 +420,13 @@ export const en: Record<keyof WorkbenchMessages, string> = {
|
|
|
420
420
|
'configSync.noSource': 'This machine has no other profile to sync from',
|
|
421
421
|
'configSync.emptyProviders': 'The workbench has no model provider yet — sync the llm- rows from some profile, or configure one under Models.',
|
|
422
422
|
'configSync.failed': 'The sync did not go through; try again.',
|
|
423
|
+
|
|
424
|
+
'debug.status': 'Debugging',
|
|
425
|
+
'debug.stalled': 'Debug stalled',
|
|
426
|
+
'debug.tip.active': 'Debugging {unit} on the workshop board ({since}) · click to go back · tell the agent to pause when done',
|
|
427
|
+
'debug.tip.stalled': 'The debug is stalled — go back to the 工作台DIY screen · click to return',
|
|
428
|
+
'debug.back': 'Back to the workshop screen',
|
|
429
|
+
'debug.strip.active': 'Debugging · {unit} · {since}',
|
|
430
|
+
'debug.strip.note': 'Tell the agent to pause when you are done',
|
|
431
|
+
'debug.stripStalled': 'Debug stalled — tell the agent to continue',
|
|
423
432
|
};
|
package/src/i18n/zh.ts
CHANGED
|
@@ -415,6 +415,15 @@ export const zh = {
|
|
|
415
415
|
'configSync.noSource': '这台机器上只有工作台这一个 profile,没有可同步的来源',
|
|
416
416
|
'configSync.emptyProviders': '工作台还没有任何模型供应商 —— 去「配置同步」从某个 profile 把 llm- 那几行搬过来,或自己去「模型」里配。',
|
|
417
417
|
'configSync.failed': '同步没成功,再试一次。',
|
|
418
|
+
|
|
419
|
+
'debug.status': '调试中',
|
|
420
|
+
'debug.stalled': '调试搁置',
|
|
421
|
+
'debug.tip.active': '工坊那块板上正在调试 {unit}({since})· 点一下回到那一屏 · 要停就对 agent 说一句',
|
|
422
|
+
'debug.tip.stalled': '调试被搁置了 —— 要你回到工作台DIY 那一屏 · 点一下回去',
|
|
423
|
+
'debug.back': '回到工坊那一屏',
|
|
424
|
+
'debug.strip.active': '调试中 · {unit} · {since}',
|
|
425
|
+
'debug.strip.note': '要停就对 agent 说一句「暂停」',
|
|
426
|
+
'debug.stripStalled': '调试搁置 —— 对 agent 说一句「继续」',
|
|
418
427
|
} as const;
|
|
419
428
|
|
|
420
429
|
export type WorkbenchMessages = typeof zh;
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ export * from './i18n/zh.js';
|
|
|
45
45
|
export * from './lib/blocker-action.js';
|
|
46
46
|
export * from './lib/busy.js';
|
|
47
47
|
export * from './lib/form-problem.js';
|
|
48
|
+
export * from './lib/diy-debug.js';
|
|
48
49
|
export * from './lib/panel-state.js';
|
|
49
50
|
export * from './lib/react.js';
|
|
50
51
|
export * from './lib/text.js';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEBUG_STALE_MS, createDebugStore, type DebugPact } from './diy-debug.js';
|
|
3
|
+
|
|
4
|
+
const pact = (sessionId: string, now = 1000, unit = 'demo'): DebugPact => ({
|
|
5
|
+
sessionId,
|
|
6
|
+
workspace: '/w/diy',
|
|
7
|
+
board: '/w/diy',
|
|
8
|
+
unit,
|
|
9
|
+
name: unit,
|
|
10
|
+
now,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('调试态:一台机器一次一条,谁开始谁收工', () => {
|
|
14
|
+
it('★ 开始 ⇒ debugging,把目标、开始时间与会话都记下', () => {
|
|
15
|
+
const store = createDebugStore();
|
|
16
|
+
const move = store.begin(pact('s1', 1000));
|
|
17
|
+
|
|
18
|
+
expect(move.ok).toBe(true);
|
|
19
|
+
if (!move.ok) return;
|
|
20
|
+
expect(move.state.phase).toBe('debugging');
|
|
21
|
+
expect(move.state.unit).toBe('demo');
|
|
22
|
+
expect(move.state.startedAt).toBe(1000);
|
|
23
|
+
expect(store.live(1000)).toEqual(move.state);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('★★ 另一场调试占着就不许再开(一次一台机器只允许一场)', () => {
|
|
27
|
+
const store = createDebugStore();
|
|
28
|
+
store.begin(pact('s1', 1000));
|
|
29
|
+
|
|
30
|
+
const second = store.begin(pact('s2', 1200));
|
|
31
|
+
|
|
32
|
+
expect(second).toEqual({ ok: false, code: 'held-by-other' });
|
|
33
|
+
expect(store.live(1200)?.sessionId).toBe('s1');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('★ 同一个会话再来一次 ⇒ already-held(换目标要先收工,或接着看用 resume)', () => {
|
|
37
|
+
const store = createDebugStore();
|
|
38
|
+
store.begin(pact('s1', 1000));
|
|
39
|
+
|
|
40
|
+
expect(store.begin(pact('s1', 1100))).toEqual({ ok: false, code: 'already-held' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('★ 暂停 / 继续 / 收工的正常路径:每回都换 at,不动 startedAt', () => {
|
|
44
|
+
const store = createDebugStore();
|
|
45
|
+
store.begin(pact('s1', 1000));
|
|
46
|
+
|
|
47
|
+
const paused = store.pause('s1', 2000);
|
|
48
|
+
expect(paused.ok && paused.state.phase).toBe('paused');
|
|
49
|
+
expect(paused.ok && paused.state.at).toBe(2000);
|
|
50
|
+
|
|
51
|
+
const resumed = store.resume('s1', 3000);
|
|
52
|
+
expect(resumed.ok && resumed.state.phase).toBe('debugging');
|
|
53
|
+
expect(resumed.ok && resumed.state.startedAt).toBe(1000);
|
|
54
|
+
|
|
55
|
+
const stopped = store.stop('s1', 4000);
|
|
56
|
+
expect(stopped.ok && stopped.state.unit).toBe('demo');
|
|
57
|
+
expect(store.live(4000)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('★ 没开始就暂停 / 继续 / 收工 ⇒ none;暂停态里再暂停 ⇒ already-paused;调试中再继续 ⇒ already-debugging', () => {
|
|
61
|
+
const store = createDebugStore();
|
|
62
|
+
|
|
63
|
+
expect(store.pause('s1', 1000)).toEqual({ ok: false, code: 'none' });
|
|
64
|
+
expect(store.resume('s1', 1000)).toEqual({ ok: false, code: 'none' });
|
|
65
|
+
expect(store.stop('s1', 1000)).toEqual({ ok: false, code: 'none' });
|
|
66
|
+
|
|
67
|
+
store.begin(pact('s1', 1000));
|
|
68
|
+
expect(store.resume('s1', 1000)).toEqual({ ok: false, code: 'already-debugging' });
|
|
69
|
+
expect(store.pause('s1', 2000).ok).toBe(true);
|
|
70
|
+
expect(store.pause('s1', 2500)).toEqual({ ok: false, code: 'already-paused' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('★★ 别人不许动我的调试态(not-owner)—— 谁开始谁收工', () => {
|
|
74
|
+
const store = createDebugStore();
|
|
75
|
+
store.begin(pact('s1', 1000));
|
|
76
|
+
|
|
77
|
+
expect(store.pause('s2', 1500)).toEqual({ ok: false, code: 'not-owner' });
|
|
78
|
+
expect(store.resume('s2', 1500)).toEqual({ ok: false, code: 'not-owner' });
|
|
79
|
+
expect(store.stop('s2', 1500)).toEqual({ ok: false, code: 'not-owner' });
|
|
80
|
+
expect(store.live(1500)?.sessionId).toBe('s1');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('★★ 屏漂了由闸自动落 paused(stall);已经在 paused 就 already-paused', () => {
|
|
84
|
+
const store = createDebugStore();
|
|
85
|
+
store.begin(pact('s1', 1000));
|
|
86
|
+
|
|
87
|
+
const st = store.stall('s1', 2000);
|
|
88
|
+
|
|
89
|
+
expect(st.ok && st.state.phase).toBe('paused');
|
|
90
|
+
expect(store.live(2000)?.phase).toBe('paused');
|
|
91
|
+
expect(store.stall('s1', 2100)).toEqual({ ok: false, code: 'already-paused' });
|
|
92
|
+
expect(store.stall('s2', 2100)).toEqual({ ok: false, code: 'not-owner' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('★ 过期当它不存在(时间隔太久,别把上一场调试留给下一个人)', () => {
|
|
96
|
+
const store = createDebugStore();
|
|
97
|
+
store.begin(pact('s1', 1000));
|
|
98
|
+
|
|
99
|
+
expect(store.live(1000 + DEBUG_STALE_MS)).not.toBeNull();
|
|
100
|
+
expect(store.live(1001 + DEBUG_STALE_MS)).toBeNull();
|
|
101
|
+
expect(store.begin(pact('s2', 1001 + DEBUG_STALE_MS)).ok).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|