@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.1

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 (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +83 -105
  3. package/README.zh.md +83 -105
  4. package/lib/client.js +902 -633
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2274 -5
  7. package/lib/types/client/PluginSettingsCard.d.ts +29 -1
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
  9. package/lib/types/client/board/TaskCard.d.ts +5 -2
  10. package/lib/types/client/host-api.d.ts +18 -0
  11. package/lib/types/client/locales.d.ts +10 -0
  12. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  13. package/lib/types/client/sidebar-entry.d.ts +8 -10
  14. package/lib/types/core/controller.d.ts +47 -15
  15. package/lib/types/core/schedule.d.ts +11 -2
  16. package/lib/types/core/tasks.d.ts +6 -6
  17. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  18. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  19. package/lib/types/dsh-home.d.ts +16 -0
  20. package/lib/types/host-ledger.d.ts +52 -0
  21. package/lib/types/host-routes.d.ts +26 -0
  22. package/lib/types/host-runner.d.ts +41 -0
  23. package/lib/types/host-service.d.ts +45 -0
  24. package/lib/types/index.d.ts +17 -9
  25. package/lib/types/loopback.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +78 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +229 -18
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +24 -7
  33. package/src/client/board/TaskCard.tsx +25 -8
  34. package/src/client/board/TaskDetail.tsx +83 -50
  35. package/src/client/board-mount.tsx +1 -0
  36. package/src/client/host-api.ts +104 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/client/settings-card.module.css +90 -1
  40. package/src/client/sidebar-entry-core.ts +208 -0
  41. package/src/client/sidebar-entry.ts +24 -144
  42. package/src/core/controller.ts +222 -24
  43. package/src/core/schedule.ts +77 -13
  44. package/src/core/store.ts +4 -8
  45. package/src/core/tasks.ts +6 -6
  46. package/src/core/use-cases/task-archive.ts +14 -5
  47. package/src/core/use-cases/task-schedule.ts +6 -5
  48. package/src/dsh-home.ts +36 -0
  49. package/src/host-ledger.ts +539 -0
  50. package/src/host-routes.ts +196 -0
  51. package/src/host-runner.ts +152 -0
  52. package/src/host-service.ts +226 -0
  53. package/src/index.ts +54 -11
  54. package/src/loopback.ts +63 -0
  55. package/src/power-inhibitor.ts +321 -0
  56. package/src/protocol.ts +220 -0
  57. package/lib/types/core/scheduler.d.ts +0 -70
  58. package/src/core/scheduler.ts +0 -161
@@ -1,20 +1,17 @@
1
1
  /**
2
2
  * Host loader entry for the task-board plugin.
3
3
  *
4
- * Everything the board does is browser work (DOM, localStorage, driving the
5
- * client runtime's session services over the wire), so the host half's main
6
- * behavior is a system-prompt section announcing the plugin to every agent.
7
- * The section registers while this plugin is in the host composition (mount /
8
- * DSH restart) and disappears when the plugin leaves it (unmount / restart),
9
- * so agents always know the board exists and how to cooperate with it. The
10
- * announcement can be turned off through the web settings plugin-configuration
11
- * surface (`announceToAgent`); the section then disappears live.
4
+ * The Host owns the v2 ledger, action API, cron scheduler, session runner,
5
+ * execution reconciliation, and optional idle-sleep inhibitor. The browser is
6
+ * a same-origin asynchronous view over that service.
12
7
  */
13
8
  import type { Context } from '@deepseek-ai/cordis';
14
9
  import z from 'schemastery';
10
+ /** Default environment variable holding the authenticated proxy token. */
11
+ export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
15
12
  export declare const inject: string[];
16
13
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
17
- export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\uFF1B\u4EFB\u52A1\u53EF\u771F\u5B9E\u6267\u884C\uFF08\u9A71\u52A8 agent \u4F1A\u8BDD\uFF09\uFF1B\u4EFB\u52A1\u53EF\u9489\u4F4F\u6267\u884C\u76EE\u6807\u2014\u2014\u5DE5\u4F5C\u533A / \u6A21\u5F0F\uFF08agent \u9884\u8BBE\uFF09/ \u6743\u9650\uFF08read-only / workspace-write / danger-full-access\uFF09\uFF0C\u7F3A\u7701\u7528\u8FD0\u884C\u65F6\u9ED8\u8BA4\uFF1B\u4EFB\u52A1\u652F\u6301 5 \u6BB5 cron \u5B9A\u65F6\u6267\u884C\uFF08\u5982 0 23 * * *\uFF09\uFF1B\u6570\u636E\u5B58\u6D4F\u89C8\u5668 localStorage\uFF08\u952E dsh.taskBoard.v1\uFF09\u3002\u9650\u5236\uFF1A\u5B9A\u65F6\u8C03\u5EA6\u5728\u6D4F\u89C8\u5668\u7AEF\uFF0C\u9700 GUI \u6807\u7B7E\u9875\u6253\u5F00\uFF0C\u9519\u8FC7\u5373\u8DF3\u8FC7\uFF1B\u6267\u884C\u6D88\u8017 API \u989D\u5EA6\u3002\u7528\u6237\u63D0\u5230\u300C\u4EFB\u52A1\u770B\u677F / \u770B\u677F / \u5B9A\u65F6\u4EFB\u52A1\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
14
+ export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\uFF1BHost \u6743\u5A01\u8D26\u672C\uFF1B\u5173\u95ED\u6D4F\u89C8\u5668\u540E\u4ECD\u7531 Host \u6267\u884C\u548C\u7ED3\u7B97\uFF1B\u4EFB\u52A1\u53EF\u9489\u4F4F\u5DE5\u4F5C\u533A\u3001agent \u9884\u8BBE\u548C\u6743\u9650\uFF1B\u652F\u6301 Host \u672C\u5730\u65F6\u533A\u7684 5 \u6BB5 cron\uFF0C\u9519\u8FC7\u7684\u89E6\u53D1\u70B9\u4E0D\u8865\u8DD1\uFF1B\u53EF\u9009\u4E14\u9ED8\u8BA4\u5173\u95ED\u7684\u7A7A\u95F2\u7CFB\u7EDF\u7761\u7720\u4FDD\u62A4\u5141\u8BB8\u5C4F\u5E55\u7184\u706D\uFF0C\u4F46\u4E0D\u627F\u8BFA\u62E6\u622A\u5408\u76D6\u3001\u624B\u52A8\u7761\u7720\u3001\u4F11\u7720\u3001\u5173\u673A\u6216\u5524\u9192\u5DF2\u7761\u7720\u673A\u5668\u3002\u6267\u884C\u6D88\u8017 API \u989D\u5EA6\u3002\u7528\u6237\u63D0\u5230\u300C\u4EFB\u52A1\u770B\u677F / \u770B\u677F / \u5B9A\u65F6\u4EFB\u52A1\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
18
15
  /**
19
16
  * Settings namespace of the board's announcement capability — the section the
20
17
  * web settings surface edits. Spelled here rather than imported: the browser
@@ -31,8 +28,19 @@ export interface Config {
31
28
  announceToAgent?: boolean;
32
29
  /** Master switch for the plugin (browser half + host announcement). */
33
30
  enabled?: boolean;
31
+ /** Prevent idle system sleep while sessions run or schedules are armed. */
32
+ preventIdleSleep?: boolean;
33
+ /** Canonical reverse-proxy Host authorities admitted with a server-side token. */
34
+ trustedProxyHosts?: string[];
35
+ /** Environment variable whose value the authenticated proxy injects upstream. */
36
+ proxyTokenEnv?: string;
34
37
  }
35
38
  export declare const Config: z<Config>;
39
+ /** Resolve proxy access without ever placing the token value in plugin config. */
40
+ export declare function resolveProxyAccess(config: Config | undefined, env?: NodeJS.ProcessEnv): {
41
+ trustedProxyHosts: string[];
42
+ proxyToken?: string;
43
+ };
36
44
  /**
37
45
  * Register the board's announcement section, gated on the composition entry's
38
46
  * `announceToAgent` (and the live settings value once the web settings
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Loopback trust fence shared by the host route families: socket address,
3
+ * Host header, and browser same-origin markers. Packages receive this file as
4
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
5
+ * re-run the sync instead of editing a copy.
6
+ *
7
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
8
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
9
+ * markers (sec-fetch-site and Origin) for the request-level fence.
10
+ * @module dsh-web-ui-shared/host/loopback
11
+ */
12
+ import type { IncomingMessage } from 'node:http';
13
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
14
+ export declare function isIPv4Loopback(v4: string): boolean;
15
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
16
+ export declare function isLoopbackAddress(address: string | undefined): boolean;
17
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
18
+ export declare function isLoopbackHostname(hostname: string): boolean;
19
+ /**
20
+ * Request-level trust fence: a loopback socket address AND a loopback Host
21
+ * header, plus browser same-origin markers. The socket address is
22
+ * authoritative; X-Forwarded-For is never trusted.
23
+ */
24
+ export declare function isLoopbackRequest(request: IncomingMessage): boolean;
@@ -0,0 +1,73 @@
1
+ import { type ChildProcess, type SpawnSyncReturns } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import type { TaskBoardPowerSnapshot } from './protocol.ts';
4
+ declare const WINDOWS_HELPER: string;
5
+ declare const LINUX_HELPER: string;
6
+ declare const LINUX_INHIBIT_PATHS: readonly ["/usr/bin/systemd-inhibit", "/bin/systemd-inhibit"];
7
+ export interface PowerReasons {
8
+ runningSessions: number;
9
+ armedSchedules: number;
10
+ sessionStateKnown: boolean;
11
+ }
12
+ export interface SpawnOptions {
13
+ shell: false;
14
+ windowsHide: boolean;
15
+ stdio: ['pipe', 'pipe', 'pipe'] | ['ignore', 'ignore', 'ignore'];
16
+ }
17
+ export type SpawnLike = (file: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
18
+ export type SpawnSyncLike = (file: string, args: readonly string[], options: {
19
+ stdio: 'ignore';
20
+ timeout: number;
21
+ windowsHide: boolean;
22
+ }) => Pick<SpawnSyncReturns<Buffer>, 'status' | 'error'>;
23
+ export interface PowerInhibitorOptions {
24
+ platform?: NodeJS.Platform;
25
+ pid?: number;
26
+ env?: NodeJS.ProcessEnv;
27
+ spawn?: SpawnLike;
28
+ spawnSync?: SpawnSyncLike;
29
+ exists?: typeof existsSync;
30
+ execPath?: string;
31
+ setTimeout?: typeof globalThis.setTimeout;
32
+ clearTimeout?: typeof globalThis.clearTimeout;
33
+ }
34
+ export declare class PowerInhibitor {
35
+ private readonly listeners;
36
+ private enabled;
37
+ private reasons;
38
+ private phase;
39
+ private child;
40
+ private retry;
41
+ private retryReset;
42
+ private retryIndex;
43
+ private lastError;
44
+ private stopping;
45
+ private readonly platform;
46
+ private readonly pid;
47
+ private readonly env;
48
+ private readonly spawn;
49
+ private readonly spawnSync;
50
+ private readonly exists;
51
+ private readonly execPath;
52
+ private readonly timer;
53
+ private readonly clearTimer;
54
+ private linuxProbe;
55
+ constructor(options?: PowerInhibitorOptions);
56
+ setEnabled(enabled: boolean): void;
57
+ updateReasons(reasons: PowerReasons): void;
58
+ subscribe(listener: () => void): () => void;
59
+ snapshot(): TaskBoardPowerSnapshot;
60
+ dispose(): void;
61
+ private desired;
62
+ private sync;
63
+ private acquire;
64
+ private markReady;
65
+ private fail;
66
+ private release;
67
+ private clearRetryReset;
68
+ private windowsPowerShell;
69
+ private linuxSystemdInhibit;
70
+ private spawnCommand;
71
+ private emit;
72
+ }
73
+ export { LINUX_HELPER, LINUX_INHIBIT_PATHS, WINDOWS_HELPER };
@@ -0,0 +1,78 @@
1
+ import type { TaskUpdatePatch } from './core/use-cases/task-update.ts';
2
+ import { type NewTaskInput, type TaskRecord, type TaskStatus } from './core/tasks.ts';
3
+ export declare const TASK_BOARD_SCHEMA_VERSION: 2;
4
+ export declare const TASK_BOARD_API_PREFIX = "/api/task-board";
5
+ export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported';
6
+ export interface TaskBoardPowerSnapshot {
7
+ platform: string;
8
+ phase: PowerPhase;
9
+ enabled: boolean;
10
+ runningSessions: number;
11
+ armedSchedules: number;
12
+ sessionStateKnown: boolean;
13
+ lastError?: string;
14
+ }
15
+ export interface TaskBoardSchedulerSnapshot {
16
+ timeZone: string;
17
+ /** Opaque identity of the current Host ledger generation. */
18
+ ledgerId?: string;
19
+ lastTickAt?: number;
20
+ error?: string;
21
+ }
22
+ export interface TaskBoardSnapshot {
23
+ schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION;
24
+ revision: number;
25
+ tasks: TaskRecord[];
26
+ scheduler: TaskBoardSchedulerSnapshot;
27
+ power: TaskBoardPowerSnapshot;
28
+ }
29
+ /** SSE event frame: revision/scheduler/power only, never the task list. */
30
+ export interface TaskBoardEventPayload {
31
+ revision: number;
32
+ scheduler: TaskBoardSchedulerSnapshot;
33
+ power: TaskBoardPowerSnapshot;
34
+ }
35
+ export type TaskBoardAction = {
36
+ kind: 'import';
37
+ sourceId: string;
38
+ tasks: TaskRecord[];
39
+ } | {
40
+ kind: 'create';
41
+ id: string;
42
+ input: NewTaskInput;
43
+ } | {
44
+ kind: 'update';
45
+ taskId: string;
46
+ patch: TaskUpdatePatch;
47
+ } | {
48
+ kind: 'delete';
49
+ taskId: string;
50
+ } | {
51
+ kind: 'move';
52
+ taskId: string;
53
+ status: TaskStatus;
54
+ } | {
55
+ kind: 'archive';
56
+ taskId: string;
57
+ } | {
58
+ kind: 'restore';
59
+ taskId: string;
60
+ } | {
61
+ kind: 'set-schedule';
62
+ taskId: string;
63
+ patch: {
64
+ enabled?: boolean;
65
+ cron?: string;
66
+ };
67
+ } | {
68
+ kind: 'run';
69
+ taskId: string;
70
+ } | {
71
+ kind: 'rerun';
72
+ taskId: string;
73
+ };
74
+ export interface TaskBoardActionEnvelope {
75
+ requestId: string;
76
+ action: TaskBoardAction;
77
+ }
78
+ export declare function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
- "description": "Task board for the dsh web GUI: a sidebar entry plus a multi-column kanban view with local persistence, task details, and real execution through dsh sessions (session.prompt). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.1.20",
3
+ "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -42,15 +42,18 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/cordis": "^4.0.1",
45
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
46
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
47
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
48
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
49
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
50
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
51
- "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
45
+ "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
46
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
47
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
48
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
49
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
50
+ "@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.7",
51
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
52
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
53
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.7",
52
54
  "@types/react": "~18.3.1",
53
55
  "@types/react-dom": "^18.3.5",
56
+ "@types/node": "^22.20.0",
54
57
  "jsdom": "^25.0.0",
55
58
  "lightningcss": "1.32.0",
56
59
  "react": "^18.3.1",
@@ -8,7 +8,7 @@
8
8
  * ui-plugin-config PluginCard in a self-contained slice.
9
9
  */
10
10
 
11
- import { useState, type ReactNode } from 'react'
11
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
12
12
  import type { CardShell } from './settings-form.ts'
13
13
  import css from './settings-card.module.css'
14
14
 
@@ -56,6 +56,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
56
56
  * already provides the selection.
57
57
  */
58
58
  alwaysOpen?: boolean
59
+ /**
60
+ * Hide the save/discard footer: cards whose body applies its own changes
61
+ * immediately (an embedded external settings section) have no staged
62
+ * edits, so the footer would sit there permanently disabled.
63
+ */
64
+ hideFooter?: boolean
59
65
  /** The plugin's controls. */
60
66
  children: ReactNode
61
67
  }
@@ -140,6 +146,9 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
140
146
  <div className={css.body}>
141
147
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
142
148
  {props.children}
149
+ {props.hideFooter === true
150
+ ? null
151
+ : (
143
152
  <div className={css.footer}>
144
153
  {state.failed
145
154
  ? (
@@ -165,6 +174,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
165
174
  {props.t(!state.saving ? 'settings.save' : 'settings.saving')}
166
175
  </button>
167
176
  </div>
177
+ )}
168
178
  </div>
169
179
  )
170
180
  : null}
@@ -245,6 +255,210 @@ export function ValueField(props: FieldProps & {
245
255
  )
246
256
  }
247
257
 
258
+ const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
259
+ // 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
260
+ function isSkinActive(): boolean {
261
+ const datasetList = Object.keys(document.body.dataset)
262
+ const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
263
+ return isActive
264
+ }
265
+
266
+ interface SelectOption {
267
+ value: string
268
+ label: string
269
+ }
270
+
271
+ const SELECT_CLOSE_MS = 100
272
+
273
+ /**
274
+ * The shared dual-mode select control. While an appearance skin is active it
275
+ * renders the legacy native `<select>` untouched, so element-level skin
276
+ * selectors keep working; under the default appearance it renders a
277
+ * self-drawn `role="listbox"` popup whose open/close is transition-animated.
278
+ * Staged cards reach it through BooleanField/ChoiceField; immediate-apply
279
+ * editors (the side-card prefs) bind it directly through onEdit.
280
+ * 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
281
+ */
282
+ export function SelectField(props: {
283
+ id: string
284
+ options: ReadonlyArray<SelectOption>
285
+ value: string
286
+ disabled: boolean
287
+ invalid: boolean
288
+ onEdit: (text: string) => void
289
+ }) {
290
+ const { id, options, value } = props
291
+ const [open, setOpen] = useState(false)
292
+ const [closing, setClosing] = useState(false)
293
+ const [phase, setPhase] = useState<'initial' | 'open'>('initial')
294
+ const [activeIndex, setActiveIndex] = useState(0)
295
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
296
+ const wrapRef = useRef<HTMLDivElement | null>(null)
297
+ const popupRef = useRef<HTMLDivElement | null>(null)
298
+
299
+ const currentIndex = () => {
300
+ const index = options.findIndex(option => option.value === value)
301
+ return index >= 0 ? index : 0
302
+ }
303
+
304
+ const close = useCallback(() => {
305
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
306
+ setClosing(true)
307
+ closeTimer.current = setTimeout(() => {
308
+ setClosing(false)
309
+ setOpen(false)
310
+ }, SELECT_CLOSE_MS)
311
+ }, [])
312
+
313
+ const openPopup = () => {
314
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
315
+ setActiveIndex(currentIndex())
316
+ setPhase('initial')
317
+ setClosing(false)
318
+ setOpen(true)
319
+ }
320
+
321
+ const commit = (index: number) => {
322
+ const option = options[index]
323
+ if (option) props.onEdit(option.value)
324
+ close()
325
+ }
326
+
327
+ const onTriggerClick = () => {
328
+ if (props.disabled) return
329
+ if (open && !closing) close()
330
+ else openPopup()
331
+ }
332
+
333
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
334
+ if (props.disabled) return
335
+ const count = options.length
336
+ switch (event.key) {
337
+ case 'ArrowDown':
338
+ case 'ArrowUp':
339
+ case 'Enter':
340
+ case ' ':
341
+ event.preventDefault()
342
+ if (!open) {
343
+ openPopup()
344
+ } else if (!closing) {
345
+ if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
346
+ else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
347
+ else commit(activeIndex)
348
+ }
349
+ break
350
+ case 'Escape':
351
+ if (open) {
352
+ event.preventDefault()
353
+ event.stopPropagation()
354
+ close()
355
+ }
356
+ break
357
+ case 'Tab':
358
+ if (open) close()
359
+ break
360
+ }
361
+ }
362
+
363
+ useEffect(() => () => {
364
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
365
+ }, [])
366
+ useLayoutEffect(() => {
367
+ if (open && !closing && phase === 'initial') {
368
+ void popupRef.current?.offsetHeight
369
+ setPhase('open')
370
+ }
371
+ }, [open, closing, phase])
372
+
373
+ useEffect(() => {
374
+ if (!open) return
375
+ const onPointerDown = (event: PointerEvent) => {
376
+ const target = event.target
377
+ if (target instanceof Node && !wrapRef.current?.contains(target)) close()
378
+ }
379
+ document.addEventListener('pointerdown', onPointerDown)
380
+ return () => document.removeEventListener('pointerdown', onPointerDown)
381
+ }, [open, close])
382
+
383
+ useEffect(() => {
384
+ if (props.disabled && open) close()
385
+ }, [props.disabled, open, close])
386
+
387
+ // 使用了皮肤则退回原生select样式防止样式冲突。
388
+ if (isSkinActive()) {
389
+ return (
390
+ <select
391
+ id={id}
392
+ className={css.select}
393
+ value={value}
394
+ disabled={props.disabled}
395
+ onChange={(event) => { props.onEdit(event.target.value) }}
396
+ >
397
+ {options.map(option => (
398
+ <option key={option.value} value={option.value}>{option.label}</option>
399
+ ))}
400
+ </select>
401
+ )
402
+ }
403
+
404
+ const label = options.find(option => option.value === value)?.label ?? ''
405
+ const popupClass = closing
406
+ ? `${css.selectPopup} ${css.selectPopupClose}`
407
+ : phase === 'open'
408
+ ? `${css.selectPopup} ${css.selectPopupOpen}`
409
+ : css.selectPopup
410
+ return (
411
+ <div className={css.selectWrap} ref={wrapRef}>
412
+ <button
413
+ type="button"
414
+ id={id}
415
+ className={`${css.select} ${css.selectButton}`}
416
+ disabled={props.disabled}
417
+ aria-haspopup="listbox"
418
+ aria-expanded={open}
419
+ aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
420
+ aria-invalid={props.invalid || undefined}
421
+ onClick={onTriggerClick}
422
+ onKeyDown={onKeyDown}
423
+ >
424
+ <span className={css.selectLabel}>{label}</span>
425
+ <svg
426
+ width="14"
427
+ height="14"
428
+ viewBox="0 0 14 14"
429
+ fill="none"
430
+ xmlns="http://www.w3.org/2000/svg"
431
+ className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
432
+ aria-hidden="true"
433
+ >
434
+ <path
435
+ d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
436
+ fill="currentColor"
437
+ />
438
+ </svg>
439
+ </button>
440
+ {open
441
+ ? (
442
+ <div className={popupClass} role="listbox" ref={popupRef}>
443
+ {options.map((option, index) => (
444
+ <div
445
+ key={option.value}
446
+ id={`${id}-o${index}`}
447
+ role="option"
448
+ aria-selected={option.value === value}
449
+ className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
450
+ onClick={() => { commit(index) }}
451
+ >
452
+ {option.label}
453
+ </div>
454
+ ))}
455
+ </div>
456
+ )
457
+ : null}
458
+ </div>
459
+ )
460
+ }
461
+
248
462
  /** A staged boolean field: 继承 / 开 / 关. */
249
463
  export function BooleanField(props: FieldProps & {
250
464
  /** Copy for the inherit option. */
@@ -274,17 +488,18 @@ export function BooleanField(props: FieldProps & {
274
488
  )
275
489
  : null}
276
490
  </div>
277
- <select
491
+ <SelectField
278
492
  id={props.id}
279
- className={css.select}
493
+ options={[
494
+ { value: '', label: props.inheritLabel },
495
+ { value: 'true', label: props.onLabel },
496
+ { value: 'false', label: props.offLabel },
497
+ ]}
280
498
  value={props.text}
281
499
  disabled={props.disabled}
282
- onChange={(event) => { props.onEdit(event.target.value) }}
283
- >
284
- <option value="">{props.inheritLabel}</option>
285
- <option value="true">{props.onLabel}</option>
286
- <option value="false">{props.offLabel}</option>
287
- </select>
500
+ invalid={props.invalid}
501
+ onEdit={props.onEdit}
502
+ />
288
503
  <p className={css.hint}>{props.hint}</p>
289
504
  </div>
290
505
  )
@@ -317,18 +532,14 @@ export function ChoiceField(props: FieldProps & {
317
532
  )
318
533
  : null}
319
534
  </div>
320
- <select
535
+ <SelectField
321
536
  id={props.id}
322
- className={css.select}
537
+ options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
323
538
  value={props.text}
324
539
  disabled={props.disabled}
325
- onChange={(event) => { props.onEdit(event.target.value) }}
326
- >
327
- <option value="">{props.inheritLabel}</option>
328
- {props.choices.map(choice => (
329
- <option key={choice.value} value={choice.value}>{choice.label}</option>
330
- ))}
331
- </select>
540
+ invalid={props.invalid}
541
+ onEdit={props.onEdit}
542
+ />
332
543
  <p className={props.invalid ? css.invalid : css.hint}>
333
544
  {props.invalid ? props.invalidLabel : props.hint}
334
545
  </p>
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The task-board settings card: whether the board announces itself in every
3
- * agent's system prompt. Registers into the `settings.plugin.item` slot the
4
- * plugin-configuration section renders, bound to the `task-board` settings
5
- * namespace.
2
+ * Task-board settings for availability, agent announcement, and optional Host
3
+ * idle-sleep protection. Registers into the `web-ui.plugin.item` child slot
4
+ * the Web UI plugin group renders, bound to the `task-board` namespace.
6
5
  */
7
6
 
8
7
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
9
8
  import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
9
+ import { useEffect, useState } from 'react'
10
+ import type { TaskBoardPowerSnapshot } from '../protocol.ts'
10
11
  import { PluginSettingsCard, BooleanField } from './PluginSettingsCard.tsx'
11
12
  import { CardForm, booleanField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
12
13
 
@@ -16,6 +17,8 @@ export interface TaskBoardSettings {
16
17
  enabled?: boolean
17
18
  /** Whether the board announces itself in every agent's system prompt. */
18
19
  announceToAgent?: boolean
20
+ /** Prevent host idle sleep while sessions run or schedules are armed. */
21
+ preventIdleSleep?: boolean
19
22
  }
20
23
 
21
24
  /** What the task-board card renders. */
@@ -24,6 +27,8 @@ export interface TaskBoardSettingsCardState extends CardShell {
24
27
  enabled: CardFieldState
25
28
  /** System-prompt announcement flag. */
26
29
  announceToAgent: CardFieldState
30
+ /** Idle-system-sleep protection flag. */
31
+ preventIdleSleep: CardFieldState
27
32
  }
28
33
 
29
34
  /** The registration-side face the card's slot entry injects. */
@@ -44,6 +49,7 @@ export class TaskBoardSettingsCardController {
44
49
  this.form = new CardForm(scope, [
45
50
  booleanField('enabled'),
46
51
  booleanField('announceToAgent'),
52
+ booleanField('preventIdleSleep'),
47
53
  ])
48
54
  this.store = this.form.bind(() => this.projection())
49
55
  }
@@ -53,6 +59,7 @@ export class TaskBoardSettingsCardController {
53
59
  ...this.form.shell(),
54
60
  enabled: this.form.field('enabled'),
55
61
  announceToAgent: this.form.field('announceToAgent'),
62
+ preventIdleSleep: this.form.field('preventIdleSleep'),
56
63
  }
57
64
  }
58
65
 
@@ -88,6 +95,23 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
88
95
  const { t } = props
89
96
  const state = props.useTaskBoardSettingsCard(snapshot => snapshot)
90
97
  const disabled = !state.writable
98
+ const [power, setPower] = useState<TaskBoardPowerSnapshot | undefined>()
99
+ useEffect(() => {
100
+ // The SSE channel already carries power on every real change and pushes
101
+ // one frame on subscribe; polling the full /state snapshot every 5 s
102
+ // re-cloned and re-serialized the whole ledger server-side for one field.
103
+ let live = true
104
+ const events = new EventSource('/api/task-board/events')
105
+ events.onmessage = (message: MessageEvent<string>): void => {
106
+ try {
107
+ const frame = JSON.parse(message.data) as { power?: TaskBoardPowerSnapshot }
108
+ if (frame.power !== undefined && live) setPower(frame.power)
109
+ } catch {
110
+ // The settings form remains usable while the host status is reconnecting.
111
+ }
112
+ }
113
+ return () => { live = false; events.close() }
114
+ }, [])
91
115
  const fieldProps = {
92
116
  overriddenLabel: t('settings.overridden'),
93
117
  resetLabel: t('settings.reset'),
@@ -99,6 +123,7 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
99
123
  t={t}
100
124
  titleKey="settings.title"
101
125
  descriptionKey="settings.description"
126
+ defaultOpen={false}
102
127
  state={state}
103
128
  onSave={props.save}
104
129
  onDiscard={props.discard}
@@ -127,6 +152,28 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
127
152
  onEdit={(text) => { props.edit('announceToAgent', text) }}
128
153
  onReset={() => { props.resetField('announceToAgent') }}
129
154
  />
155
+ <BooleanField
156
+ id="settings-task-board-prevent-idle-sleep"
157
+ label={t('settings.preventIdleSleep')}
158
+ hint={t('settings.preventIdleSleepHint')}
159
+ inheritLabel={t('settings.inherit')}
160
+ onLabel={t('settings.on')}
161
+ offLabel={t('settings.off')}
162
+ {...fieldProps}
163
+ {...state.preventIdleSleep}
164
+ onEdit={(text) => { props.edit('preventIdleSleep', text) }}
165
+ onReset={() => { props.resetField('preventIdleSleep') }}
166
+ />
167
+ <p>
168
+ {t('settings.powerStatus', {
169
+ platform: power?.platform ?? t('settings.powerUnknown'),
170
+ phase: power?.phase ?? t('settings.powerUnknown'),
171
+ running: String(power?.runningSessions ?? 0),
172
+ schedules: String(power?.armedSchedules ?? 0),
173
+ })}
174
+ </p>
175
+ <p>{t('settings.powerBoundary')}</p>
176
+ {power?.lastError !== undefined && <p>{t('settings.powerError', { error: power.lastError })}</p>}
130
177
  </PluginSettingsCard>
131
178
  )
132
179
  }