@dsh-workbench/shared 0.2.13 → 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.
Files changed (47) hide show
  1. package/dist/api/dsh.d.ts +1 -0
  2. package/dist/components/FormActions.css +13 -0
  3. package/dist/components/FormActions.d.ts +15 -0
  4. package/dist/components/FormActions.js +38 -0
  5. package/dist/components/FormRows.css +21 -5
  6. package/dist/components/SaveBar.d.ts +0 -1
  7. package/dist/components/SaveBar.js +13 -5
  8. package/dist/components/SelectField.css +20 -0
  9. package/dist/components/SelectField.d.ts +3 -1
  10. package/dist/components/SelectField.js +43 -25
  11. package/dist/components/SelectFieldNodes.d.ts +21 -0
  12. package/dist/components/SelectFieldNodes.js +17 -0
  13. package/dist/config/constants.d.ts +1 -0
  14. package/dist/config/constants.js +1 -0
  15. package/dist/host/tool-kit.d.ts +1 -0
  16. package/dist/host/tool-kit.js +4 -0
  17. package/dist/host.d.ts +1 -0
  18. package/dist/host.js +1 -0
  19. package/dist/i18n/en.js +17 -0
  20. package/dist/i18n/index.d.ts +18 -1
  21. package/dist/i18n/zh.d.ts +17 -0
  22. package/dist/i18n/zh.js +17 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/lib/diy-debug.d.ts +38 -0
  26. package/dist/lib/diy-debug.js +77 -0
  27. package/package.json +1 -1
  28. package/src/api/dsh.ts +6 -1
  29. package/src/components/FormActions.css +13 -0
  30. package/src/components/FormActions.test.tsx +157 -0
  31. package/src/components/FormActions.tsx +75 -0
  32. package/src/components/FormRows.css +21 -5
  33. package/src/components/FormRows.test.tsx +56 -0
  34. package/src/components/SaveBar.tsx +14 -18
  35. package/src/components/SelectField.css +20 -0
  36. package/src/components/SelectField.test.tsx +63 -0
  37. package/src/components/SelectField.tsx +56 -81
  38. package/src/components/SelectFieldNodes.tsx +77 -0
  39. package/src/config/constants.ts +2 -0
  40. package/src/host/tool-kit.ts +5 -0
  41. package/src/host.ts +1 -0
  42. package/src/i18n/en.ts +18 -0
  43. package/src/i18n/zh.ts +18 -0
  44. package/src/index.ts +2 -0
  45. package/src/lib/diy-debug.test.ts +103 -0
  46. package/src/lib/diy-debug.ts +122 -0
  47. package/src/components/SaveBar.css +0 -7
@@ -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
+
@@ -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;
@@ -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
@@ -48,6 +48,15 @@ export const en: Record<keyof WorkbenchMessages, string> = {
48
48
  'status.completed': 'Completed',
49
49
  'status.subagentsRunning.one': '{n} subagent running',
50
50
  'status.subagentsRunning.other': '{n} subagents running',
51
+ 'project.attention.warning.one': '1 session waiting for you',
52
+ 'project.attention.warning.other': '{n} sessions waiting for you',
53
+ 'project.attention.running.one': '1 session running',
54
+ 'project.attention.running.other': '{n} sessions running',
55
+ 'project.attention.done.one': '1 session finished, unread',
56
+ 'project.attention.done.other': '{n} sessions finished, unread',
57
+ 'project.attention.state.warning': 'Approval',
58
+ 'project.attention.state.running': 'Running',
59
+ 'project.attention.state.done': 'New',
51
60
 
52
61
  'hover.copied': 'Copied',
53
62
  'time.now': 'just now',
@@ -411,4 +420,13 @@ export const en: Record<keyof WorkbenchMessages, string> = {
411
420
  'configSync.noSource': 'This machine has no other profile to sync from',
412
421
  'configSync.emptyProviders': 'The workbench has no model provider yet — sync the llm- rows from some profile, or configure one under Models.',
413
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',
414
432
  };
package/src/i18n/zh.ts CHANGED
@@ -46,6 +46,15 @@ export const zh = {
46
46
  'status.completed': '已完成',
47
47
  'status.subagentsRunning.one': '{n} 个子代理运行中',
48
48
  'status.subagentsRunning.other': '{n} 个子代理运行中',
49
+ 'project.attention.warning.one': '1 个会话在等你点头',
50
+ 'project.attention.warning.other': '{n} 个会话在等你点头',
51
+ 'project.attention.running.one': '1 个会话在跑',
52
+ 'project.attention.running.other': '{n} 个会话在跑',
53
+ 'project.attention.done.one': '1 个会话跑完没看',
54
+ 'project.attention.done.other': '{n} 个会话跑完没看',
55
+ 'project.attention.state.warning': '待审批',
56
+ 'project.attention.state.running': '在跑',
57
+ 'project.attention.state.done': '新消息',
49
58
 
50
59
  'hover.copied': '已复制',
51
60
  'time.now': '刚刚',
@@ -406,6 +415,15 @@ export const zh = {
406
415
  'configSync.noSource': '这台机器上只有工作台这一个 profile,没有可同步的来源',
407
416
  'configSync.emptyProviders': '工作台还没有任何模型供应商 —— 去「配置同步」从某个 profile 把 llm- 那几行搬过来,或自己去「模型」里配。',
408
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 说一句「继续」',
409
427
  } as const;
410
428
 
411
429
  export type WorkbenchMessages = typeof zh;
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export * from './components/CardBoundary.js';
20
20
  export * from './components/CardHead.js';
21
21
  export * from './components/ChoiceGroup.js';
22
22
  export * from './components/Field.js';
23
+ export * from './components/FormActions.js';
23
24
  export * from './components/FormRows.js';
24
25
  export * from './components/Link.js';
25
26
  export * from './components/LongTaskRow.js';
@@ -44,6 +45,7 @@ export * from './i18n/zh.js';
44
45
  export * from './lib/blocker-action.js';
45
46
  export * from './lib/busy.js';
46
47
  export * from './lib/form-problem.js';
48
+ export * from './lib/diy-debug.js';
47
49
  export * from './lib/panel-state.js';
48
50
  export * from './lib/react.js';
49
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
+ });