@linxin666/dsh-client-ui-task-board 0.3.22 → 0.3.23
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/README.i18n.yaml +2 -2
- package/README.md +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +458 -30
- package/lib/client.js.map +1 -1
- package/lib/index.js +2553 -156
- package/lib/types/client/board/EditTaskModal.d.ts +6 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -1
- package/lib/types/client/board/TaskBoard.d.ts +2 -0
- package/lib/types/client/board/TaskForm.d.ts +2 -2
- package/lib/types/client/host-api.d.ts +23 -1
- package/lib/types/client/locales.d.ts +25 -0
- package/lib/types/core/controller.d.ts +35 -1
- package/lib/types/host-ai.d.ts +51 -0
- package/lib/types/host-routes.d.ts +11 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/protocol.d.ts +19 -0
- package/package.json +1 -1
- package/src/client/board/EditTaskModal.tsx +36 -0
- package/src/client/board/NewTaskModal.tsx +91 -3
- package/src/client/board/TaskBoard.tsx +89 -0
- package/src/client/board/TaskDetail.tsx +38 -2
- package/src/client/board/TaskForm.tsx +4 -11
- package/src/client/board.module.css +61 -0
- package/src/client/host-api.ts +124 -5
- package/src/client/index.ts +7 -0
- package/src/client/locales.ts +50 -0
- package/src/core/controller.ts +57 -1
- package/src/host-ai.ts +152 -0
- package/src/host-ledger.ts +25 -3
- package/src/host-routes.ts +68 -3
- package/src/index.ts +26 -1
- package/src/protocol.ts +34 -0
|
@@ -6,3 +6,9 @@ export declare function EditTaskModal({ controller, task, onClose }: {
|
|
|
6
6
|
task: TaskRecord;
|
|
7
7
|
onClose: () => void;
|
|
8
8
|
}): import("react").JSX.Element;
|
|
9
|
+
/** Edit-tags modal: edit labels only, shown for tasks after first execution. */
|
|
10
|
+
export declare function EditTagsModal({ controller, task, onClose }: {
|
|
11
|
+
controller: BoardController;
|
|
12
|
+
task: TaskRecord;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
@@ -5,8 +5,13 @@ export interface NewTaskModalProps {
|
|
|
5
5
|
onClose: () => void;
|
|
6
6
|
/** Optional task template to clone/duplicate from. */
|
|
7
7
|
initialTask?: TaskRecord;
|
|
8
|
+
/**
|
|
9
|
+
* Workspace the board's project filter has selected (#1536): a task created
|
|
10
|
+
* while a project is open belongs to that project unless the user changes it.
|
|
11
|
+
*/
|
|
12
|
+
defaultWorkspaceId?: string;
|
|
8
13
|
/** Optional callback after successful duplication (e.g. to archive source). */
|
|
9
14
|
onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>;
|
|
10
15
|
}
|
|
11
16
|
/** New-task form overlay. */
|
|
12
|
-
export declare function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps): import("react").JSX.Element;
|
|
17
|
+
export declare function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess }: NewTaskModalProps): import("react").JSX.Element;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type BoardController } from '../../core/controller.ts';
|
|
2
2
|
import { type TaskRecord } from '../../core/tasks.ts';
|
|
3
|
+
/** Sentinel option value of the project row's "register a new project" entry. */
|
|
4
|
+
export declare const NEW_PROJECT_VALUE = "__dsh_new_project__";
|
|
3
5
|
/** Case-insensitive title/description/tag/freeze-snapshot match. */
|
|
4
6
|
export declare function matchesFilter(task: TaskRecord, filter: string): boolean;
|
|
5
7
|
/**
|
|
@@ -40,7 +40,7 @@ export declare function TaskTagFields({ tags, knownTags, onChange, }: {
|
|
|
40
40
|
onChange: (tags: TaskTag[]) => void;
|
|
41
41
|
}): import("react").JSX.Element;
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
43
|
+
* Clean a tag list via normalizeTags: trim, drop blanks and duplicates, cap
|
|
44
|
+
* lengths and count, so the wire always carries a valid tag list.
|
|
45
45
|
*/
|
|
46
46
|
export declare function cleanTags(tags: readonly TaskTag[]): TaskTag[];
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import type { TaskRecord } from '../core/tasks.ts';
|
|
2
|
-
import { type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from '../protocol.ts';
|
|
2
|
+
import { type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardParseDraft, type TaskBoardParseRequest, type TaskBoardSnapshot } from '../protocol.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Failure classes of the Host API, in the language the panel renders
|
|
5
|
+
* (issue #1528). The Host is the only part that knows whether a task-board
|
|
6
|
+
* route exists at all, so a raw fetch/parse error must never reach the user:
|
|
7
|
+
* "Unexpected token 'o', \"not found\" is not valid JSON" is what a missing
|
|
8
|
+
* Host half looks like today.
|
|
9
|
+
*/
|
|
10
|
+
export type HostApiFailure = 'not-mounted' | 'unauthorized' | 'locked' | 'rejected' | 'timeout' | 'unreachable' | 'unexpected';
|
|
11
|
+
/** Transport failure carrying a stable class next to its user-facing message. */
|
|
12
|
+
export declare class HostApiError extends Error {
|
|
13
|
+
readonly failure: HostApiFailure;
|
|
14
|
+
readonly status?: number | undefined;
|
|
15
|
+
constructor(failure: HostApiFailure, message: string, status?: number | undefined);
|
|
16
|
+
}
|
|
3
17
|
export interface TaskBoardHostTransport {
|
|
4
18
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>;
|
|
5
19
|
state(): Promise<TaskBoardSnapshot>;
|
|
6
20
|
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>;
|
|
7
21
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
|
|
22
|
+
/** One-shot model parse of pasted text (issue #1540). */
|
|
23
|
+
parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
|
|
8
24
|
}
|
|
9
25
|
export declare class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
10
26
|
private readonly storage;
|
|
@@ -14,5 +30,11 @@ export declare class HttpTaskBoardHostTransport implements TaskBoardHostTranspor
|
|
|
14
30
|
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>;
|
|
15
31
|
private post;
|
|
16
32
|
private request;
|
|
33
|
+
/**
|
|
34
|
+
* Ask the Host to turn pasted text into task fields. The route answers a
|
|
35
|
+
* typed failure (no model, timeout, unparseable reply) already phrased for
|
|
36
|
+
* the form, so the UI never renders a raw status code (issue #1540).
|
|
37
|
+
*/
|
|
38
|
+
parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
|
|
17
39
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
|
|
18
40
|
}
|
|
@@ -28,6 +28,30 @@ export declare const zh: {
|
|
|
28
28
|
'board.updated': string;
|
|
29
29
|
'board.created': string;
|
|
30
30
|
'board.hostError': string;
|
|
31
|
+
'board.hostError.notMounted': string;
|
|
32
|
+
'board.hostError.unauthorized': string;
|
|
33
|
+
'board.hostError.locked': string;
|
|
34
|
+
'board.hostError.timeout': string;
|
|
35
|
+
'board.hostError.unreachable': string;
|
|
36
|
+
'board.hostError.unexpected': string;
|
|
37
|
+
'board.project': string;
|
|
38
|
+
'board.projectAll': string;
|
|
39
|
+
'board.projectNew': string;
|
|
40
|
+
'board.projectNewPath': string;
|
|
41
|
+
'board.projectNewPathPlaceholder': string;
|
|
42
|
+
'board.projectCreate': string;
|
|
43
|
+
'board.projectCreateFailed': string;
|
|
44
|
+
'new.aiParse': string;
|
|
45
|
+
'new.aiParseHint': string;
|
|
46
|
+
'new.aiParsePlaceholder': string;
|
|
47
|
+
'new.aiParseModel': string;
|
|
48
|
+
'new.aiParseRun': string;
|
|
49
|
+
'new.aiParseCancel': string;
|
|
50
|
+
'new.aiParseEmpty': string;
|
|
51
|
+
'new.aiParseNoModel': string;
|
|
52
|
+
'new.aiParseUnavailable': string;
|
|
53
|
+
'new.aiParseTimeout': string;
|
|
54
|
+
'new.aiParseFailed': string;
|
|
31
55
|
'board.retryHost': string;
|
|
32
56
|
'board.hostMeta': string;
|
|
33
57
|
'new.title': string;
|
|
@@ -70,6 +94,7 @@ export declare const zh: {
|
|
|
70
94
|
'detail.permissionConfirmed': string;
|
|
71
95
|
'detail.title': string;
|
|
72
96
|
'detail.edit': string;
|
|
97
|
+
'detail.editTags': string;
|
|
73
98
|
'detail.duplicate': string;
|
|
74
99
|
'detail.duplicateAndEdit': string;
|
|
75
100
|
'detail.close': string;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { TaskStore } from './store.ts';
|
|
15
15
|
import { type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts';
|
|
16
16
|
import { type TaskUpdatePatch } from './use-cases/task-update.ts';
|
|
17
|
-
import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '../protocol.ts';
|
|
17
|
+
import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardParseDraft, TaskBoardParseRequest, TaskBoardSnapshot } from '../protocol.ts';
|
|
18
18
|
export interface TaskBoardTransport {
|
|
19
19
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>;
|
|
20
20
|
state(): Promise<TaskBoardSnapshot>;
|
|
@@ -24,6 +24,11 @@ export interface TaskBoardTransport {
|
|
|
24
24
|
*/
|
|
25
25
|
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>;
|
|
26
26
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
|
|
27
|
+
/**
|
|
28
|
+
* One-shot model parse of pasted text (issue #1540). Optional: a deployment
|
|
29
|
+
* that cannot parse simply omits it, and the form hides the section.
|
|
30
|
+
*/
|
|
31
|
+
parseDraft?(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
|
|
27
32
|
}
|
|
28
33
|
/** The sessions face the controller needs for navigation awareness. */
|
|
29
34
|
export interface SessionsControllerFace {
|
|
@@ -47,6 +52,14 @@ export interface ControllerDeps {
|
|
|
47
52
|
/** Host-authoritative transport; absent keeps the legacy in-memory test path. */
|
|
48
53
|
transport?: TaskBoardTransport;
|
|
49
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Register one host directory as a DSH project (workspace). Wired by the
|
|
57
|
+
* browser apply() to the runtime's workspace controller; without it the board
|
|
58
|
+
* hides its "new project" action instead of offering a dead control (#1536).
|
|
59
|
+
*/
|
|
60
|
+
export type WorkspaceCreator = (path: string) => Promise<{
|
|
61
|
+
workspaceId: string;
|
|
62
|
+
}>;
|
|
50
63
|
/** One workspace option the execution-target pickers offer. */
|
|
51
64
|
export interface ExecutionWorkspaceOption {
|
|
52
65
|
workspaceId: string;
|
|
@@ -85,6 +98,10 @@ export interface ControllerSnapshot {
|
|
|
85
98
|
/** Picker option sets (workspace list + agent-preset roster). */
|
|
86
99
|
executionOptions: ExecutionOptionsSnapshot;
|
|
87
100
|
pendingTaskIds: readonly string[];
|
|
101
|
+
/** Whether the board may offer "register a new project" (issue #1536). */
|
|
102
|
+
canCreateWorkspace?: boolean;
|
|
103
|
+
/** Whether this deployment can parse pasted text into task fields (issue #1540). */
|
|
104
|
+
canParseTask?: boolean;
|
|
88
105
|
transportError?: string;
|
|
89
106
|
host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'>;
|
|
90
107
|
}
|
|
@@ -101,6 +118,7 @@ export declare class BoardController {
|
|
|
101
118
|
private archiveView;
|
|
102
119
|
private selectedTaskId;
|
|
103
120
|
private executionOptions;
|
|
121
|
+
private workspaceCreator;
|
|
104
122
|
private listeners;
|
|
105
123
|
private disposers;
|
|
106
124
|
private readonly now;
|
|
@@ -151,6 +169,22 @@ export declare class BoardController {
|
|
|
151
169
|
* and agent-preset roster come from the runtime, not the ledger).
|
|
152
170
|
*/
|
|
153
171
|
setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void;
|
|
172
|
+
/** Wire (or clear) the runtime's project registration face (issue #1536). */
|
|
173
|
+
setWorkspaceCreator(creator: WorkspaceCreator | undefined): void;
|
|
174
|
+
/**
|
|
175
|
+
* Register an existing host directory as a DSH project, exactly as the GUI's
|
|
176
|
+
* own "add project" does; the runtime's failure message surfaces unchanged.
|
|
177
|
+
*/
|
|
178
|
+
createWorkspace(path: string): Promise<{
|
|
179
|
+
workspaceId: string;
|
|
180
|
+
}>;
|
|
181
|
+
/** Whether this deployment can parse pasted text into task fields (issue #1540). */
|
|
182
|
+
canParseTask(): boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Parse pasted text into task fields through the Host. The transport already
|
|
185
|
+
* phrases every failure for the user, so its message surfaces unchanged.
|
|
186
|
+
*/
|
|
187
|
+
parseTaskDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
|
|
154
188
|
moveTask(id: string, status: TaskStatus): void;
|
|
155
189
|
deleteTask(id: string): void;
|
|
156
190
|
/**
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot model parse behind the board's "parse pasted text" action
|
|
3
|
+
* (issue #1540).
|
|
4
|
+
*
|
|
5
|
+
* The browser half never talks to a model: it posts the pasted text and the
|
|
6
|
+
* route the user picked to the Host, which calls the injected `llm` service
|
|
7
|
+
* once and answers with the three draft fields the new-task form accepts. The
|
|
8
|
+
* reply is untrusted text, so extraction is defensive: code fences are
|
|
9
|
+
* stripped, the first JSON object wins, and an unusable reply falls back to
|
|
10
|
+
* the user's own words instead of an empty form.
|
|
11
|
+
*/
|
|
12
|
+
import { type LlmRuntime } from '@deepseek-ai/dsh-llm';
|
|
13
|
+
import type { TaskBoardParseDraft, TaskBoardParseRequest } from './protocol.ts';
|
|
14
|
+
/** How long one parse may take before the Host gives up on the model. */
|
|
15
|
+
export declare const TASK_PARSE_TIMEOUT_MS = 45000;
|
|
16
|
+
/** Largest pasted text one parse accepts (bytes). */
|
|
17
|
+
export declare const TASK_PARSE_MAX_INPUT: number;
|
|
18
|
+
export type TaskParseFailureCode = 'no-model' | 'model-error' | 'parse-failed' | 'timeout';
|
|
19
|
+
/** Typed parse failure; the route maps the code onto a status and a body. */
|
|
20
|
+
export declare class TaskParseError extends Error {
|
|
21
|
+
readonly code: TaskParseFailureCode;
|
|
22
|
+
constructor(code: TaskParseFailureCode, message: string);
|
|
23
|
+
}
|
|
24
|
+
/** Split a qualified `provider/model` route the same way the runner does. */
|
|
25
|
+
export declare function splitModelRoute(qualified: string | undefined): {
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
} | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Read the draft out of a model reply. Fences and surrounding prose are
|
|
31
|
+
* tolerated; anything unusable returns undefined so the caller can decide
|
|
32
|
+
* between a typed failure and a fallback.
|
|
33
|
+
* @param reply - raw model text.
|
|
34
|
+
*/
|
|
35
|
+
export declare function extractTaskParseReply(reply: string): Partial<TaskBoardParseDraft> | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Fill the three form fields, falling back to the pasted text so a weak model
|
|
38
|
+
* reply never loses what the user actually wrote.
|
|
39
|
+
* @param reply - raw model text.
|
|
40
|
+
* @param source - the text the user pasted.
|
|
41
|
+
*/
|
|
42
|
+
export declare function draftFromReply(reply: string, source: string): TaskBoardParseDraft;
|
|
43
|
+
/**
|
|
44
|
+
* Parse one pasted text through the injected `llm` service.
|
|
45
|
+
* @param llm - the Host's llm service.
|
|
46
|
+
* @param request - pasted text plus the qualified model route.
|
|
47
|
+
* @param signal - caller cancellation (client disconnect); combined with the timeout.
|
|
48
|
+
* @returns the draft fields.
|
|
49
|
+
* @throws TaskParseError with a code the route maps onto a status.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseTaskDraft(llm: LlmRuntime, request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
2
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
3
3
|
import type { TaskBoardHostService } from './host-service.ts';
|
|
4
|
+
import { type TaskBoardParseDraft, type TaskBoardParseRequest } from './protocol.ts';
|
|
4
5
|
/** Header replaced by an authenticated same-host reverse proxy. */
|
|
5
6
|
export declare const TASK_BOARD_PROXY_TOKEN_HEADER = "x-dsh-task-board-proxy-token";
|
|
6
7
|
/** Optional authenticated reverse-proxy access layered over the loopback default. */
|
|
@@ -8,6 +9,15 @@ export interface TaskBoardRouteAccess {
|
|
|
8
9
|
trustedProxyHosts?: readonly string[];
|
|
9
10
|
proxyToken?: string;
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Host faces the routes need beyond the ledger service. The parse face is
|
|
14
|
+
* resolved lazily by the caller, so a deployment without an llm service still
|
|
15
|
+
* registers the route and answers with a typed "no model" failure instead of
|
|
16
|
+
* leaving the panel with an unmounted path (issue #1540).
|
|
17
|
+
*/
|
|
18
|
+
export interface TaskBoardRouteOptions {
|
|
19
|
+
parseTask?: (request: TaskBoardParseRequest, signal: AbortSignal) => Promise<TaskBoardParseDraft>;
|
|
20
|
+
}
|
|
11
21
|
interface ResolvedRouteAccess {
|
|
12
22
|
trustedProxyHosts: ReadonlySet<string>;
|
|
13
23
|
proxyToken?: string;
|
|
@@ -22,5 +32,5 @@ interface ResolvedRouteAccess {
|
|
|
22
32
|
* internal token header after their own authentication step.
|
|
23
33
|
*/
|
|
24
34
|
export declare function isTrustedTaskBoardRequest(req: IncomingMessage, access: ResolvedRouteAccess): boolean;
|
|
25
|
-
export declare function makeTaskBoardRoutes(service: TaskBoardHostService, access?: TaskBoardRouteAccess): WebRoute[];
|
|
35
|
+
export declare function makeTaskBoardRoutes(service: TaskBoardHostService, access?: TaskBoardRouteAccess, options?: TaskBoardRouteOptions): WebRoute[];
|
|
26
36
|
export {};
|
package/lib/types/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
10
10
|
import z from 'schemastery';
|
|
11
|
+
import type { LlmRuntime } from '@deepseek-ai/dsh-llm';
|
|
11
12
|
import { type TaskPermission } from './core/tasks.ts';
|
|
12
13
|
/** Default environment variable holding the authenticated proxy token. */
|
|
13
14
|
export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
|
|
@@ -49,6 +50,14 @@ export declare function resolveProxyAccess(config: Config | undefined, env?: Nod
|
|
|
49
50
|
trustedProxyHosts: string[];
|
|
50
51
|
proxyToken?: string;
|
|
51
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* Read the optional `llm` service. The board deliberately does not inject it:
|
|
55
|
+
* a deployment without a model must still mount the board, and the parse route
|
|
56
|
+
* answers a typed failure instead of the plugin failing to load (issue #1540).
|
|
57
|
+
* @param ctx - the plugin context.
|
|
58
|
+
* @returns the llm service, or undefined when this deployment serves none.
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined;
|
|
52
61
|
/**
|
|
53
62
|
* Register the board's announcement section, gated on the composition entry's
|
|
54
63
|
* `announceToAgent` (and the live settings value once the web settings
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -36,6 +36,25 @@ export interface TaskBoardEventPayload {
|
|
|
36
36
|
scheduler: TaskBoardSchedulerSnapshot;
|
|
37
37
|
power: TaskBoardPowerSnapshot;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Request body of `POST {TASK_BOARD_API_PREFIX}/parse` (issue #1540): the text
|
|
41
|
+
* the user pasted plus the `provider/model` route that should parse it.
|
|
42
|
+
*/
|
|
43
|
+
export interface TaskBoardParseRequest {
|
|
44
|
+
text: string;
|
|
45
|
+
/** Qualified route from the model picker; absent when no route was chosen. */
|
|
46
|
+
model?: string;
|
|
47
|
+
}
|
|
48
|
+
/** Draft fields a parse returns; the user reviews them before creating the task. */
|
|
49
|
+
export interface TaskBoardParseDraft {
|
|
50
|
+
title: string;
|
|
51
|
+
description: string;
|
|
52
|
+
prompt: string;
|
|
53
|
+
}
|
|
54
|
+
/** Strict parse of a `/parse` request body; undefined rejects the request. */
|
|
55
|
+
export declare function parseTaskParseRequest(value: unknown): TaskBoardParseRequest | undefined;
|
|
56
|
+
/** Whether a decoded reply carries the three draft strings the form accepts. */
|
|
57
|
+
export declare function isTaskParseDraft(value: unknown): value is TaskBoardParseDraft;
|
|
39
58
|
export type TaskBoardAction = {
|
|
40
59
|
kind: 'import';
|
|
41
60
|
sourceId: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
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.3.
|
|
4
|
+
"version": "0.3.23",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -69,3 +69,39 @@ export function EditTaskModal({ controller, task, onClose }: { controller: Board
|
|
|
69
69
|
</ModalShell>
|
|
70
70
|
)
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
/** Edit-tags modal: edit labels only, shown for tasks after first execution. */
|
|
74
|
+
export function EditTagsModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
|
|
75
|
+
const [tags, setTags] = useState<TaskTag[]>(task.tags ?? [])
|
|
76
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
77
|
+
const [pending, setPending] = useState(false)
|
|
78
|
+
|
|
79
|
+
const submit = async (): Promise<void> => {
|
|
80
|
+
setPending(true)
|
|
81
|
+
const tagList = cleanTags(tags)
|
|
82
|
+
const patch = {
|
|
83
|
+
tags: tagList.length > 0 ? tagList : null,
|
|
84
|
+
}
|
|
85
|
+
if (await controller.updateTask(task.id, patch)) {
|
|
86
|
+
onClose()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
setPending(false)
|
|
90
|
+
setError(controller.getSnapshot().transportError ?? t('new.required'))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<ModalShell
|
|
95
|
+
ariaLabel={t('detail.editTags')}
|
|
96
|
+
title={t('detail.editTags')}
|
|
97
|
+
error={error}
|
|
98
|
+
pending={pending}
|
|
99
|
+
submitLabel={t('edit.save')}
|
|
100
|
+
onSubmit={() => { void submit() }}
|
|
101
|
+
onClose={onClose}
|
|
102
|
+
>
|
|
103
|
+
<TaskTagFields tags={tags} knownTags={collectKnownTags(controller.getSnapshot().tasks)} onChange={setTags} />
|
|
104
|
+
</ModalShell>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* New-task modal: title + description + the prompt that execution will send.
|
|
3
3
|
* Creates through the Host and closes only after the Host confirms it.
|
|
4
4
|
*/
|
|
5
|
-
import { useEffect, useState } from 'react'
|
|
5
|
+
import { useEffect, useRef, useState } from 'react'
|
|
6
6
|
import type { BoardController } from '../../core/controller.ts'
|
|
7
7
|
import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
|
|
8
8
|
import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
|
|
@@ -17,17 +17,22 @@ export interface NewTaskModalProps {
|
|
|
17
17
|
onClose: () => void
|
|
18
18
|
/** Optional task template to clone/duplicate from. */
|
|
19
19
|
initialTask?: TaskRecord
|
|
20
|
+
/**
|
|
21
|
+
* Workspace the board's project filter has selected (#1536): a task created
|
|
22
|
+
* while a project is open belongs to that project unless the user changes it.
|
|
23
|
+
*/
|
|
24
|
+
defaultWorkspaceId?: string
|
|
20
25
|
/** Optional callback after successful duplication (e.g. to archive source). */
|
|
21
26
|
onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
/** New-task form overlay. */
|
|
25
|
-
export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps) {
|
|
30
|
+
export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess }: NewTaskModalProps) {
|
|
26
31
|
const isDuplicate = initialTask !== undefined
|
|
27
32
|
const [title, setTitle] = useState(initialTask?.title ?? '')
|
|
28
33
|
const [description, setDescription] = useState(initialTask?.description ?? '')
|
|
29
34
|
const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
|
|
30
|
-
const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? '')
|
|
35
|
+
const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? defaultWorkspaceId ?? '')
|
|
31
36
|
const [mode, setMode] = useState(initialTask?.mode ?? '')
|
|
32
37
|
const [permission, setPermission] = useState(initialTask?.permission ?? '')
|
|
33
38
|
const [model, setModel] = useState(initialTask?.model ?? '')
|
|
@@ -45,6 +50,15 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
|
|
|
45
50
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
46
51
|
const [pending, setPending] = useState(false)
|
|
47
52
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
53
|
+
// "Parse pasted text" (issue #1540) exists only when the deployment carries a
|
|
54
|
+
// parse face; the section stays hidden otherwise.
|
|
55
|
+
const [canParse] = useState(controller.getSnapshot().canParseTask === true)
|
|
56
|
+
const [parseText, setParseText] = useState('')
|
|
57
|
+
const [parseModel, setParseModel] = useState('')
|
|
58
|
+
const [parsePending, setParsePending] = useState(false)
|
|
59
|
+
const [parseError, setParseError] = useState<string | undefined>(undefined)
|
|
60
|
+
const parseAbort = useRef<AbortController | undefined>(undefined)
|
|
61
|
+
const parseModels = options.models ?? []
|
|
48
62
|
|
|
49
63
|
// The workspace list and preset roster arrive from the runtime after mount;
|
|
50
64
|
// follow them so the pickers never freeze on an empty snapshot.
|
|
@@ -53,6 +67,36 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
|
|
|
53
67
|
[controller],
|
|
54
68
|
)
|
|
55
69
|
|
|
70
|
+
// The model roster arrives asynchronously: default to its first entry, which
|
|
71
|
+
// the user can change before parsing.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (parseModel === '' && parseModels.length > 0) setParseModel(parseModels[0]!.id)
|
|
74
|
+
}, [parseModel, options.models])
|
|
75
|
+
|
|
76
|
+
const runParse = async (): Promise<void> => {
|
|
77
|
+
const text = parseText.trim()
|
|
78
|
+
if (text === '') {
|
|
79
|
+
setParseError(t('new.aiParseEmpty'))
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const abort = new AbortController()
|
|
83
|
+
parseAbort.current = abort
|
|
84
|
+
setParsePending(true)
|
|
85
|
+
setParseError(undefined)
|
|
86
|
+
try {
|
|
87
|
+
const draft = await controller.parseTaskDraft({ text, ...(parseModel === '' ? {} : { model: parseModel }) }, abort.signal)
|
|
88
|
+
setTitle(draft.title)
|
|
89
|
+
setDescription(draft.description)
|
|
90
|
+
setPrompt(draft.prompt)
|
|
91
|
+
} catch (parseFailure) {
|
|
92
|
+
// A cancelled parse reports nothing: the user asked for it to stop.
|
|
93
|
+
if (!abort.signal.aborted) setParseError(parseFailure instanceof Error ? parseFailure.message : String(parseFailure))
|
|
94
|
+
} finally {
|
|
95
|
+
parseAbort.current = undefined
|
|
96
|
+
setParsePending(false)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
56
100
|
const submit = async (): Promise<void> => {
|
|
57
101
|
if (scheduleEnabled) {
|
|
58
102
|
const cron = scheduleCron.trim()
|
|
@@ -132,6 +176,50 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
|
|
|
132
176
|
onSubmit={() => { void submit() }}
|
|
133
177
|
onClose={onClose}
|
|
134
178
|
>
|
|
179
|
+
{canParse && (
|
|
180
|
+
<section className={css.aiParse} data-dsh-part="ai-parse">
|
|
181
|
+
<span className={css.fieldLabel}>{t('new.aiParse')}</span>
|
|
182
|
+
<p className={css.fieldHint}>{t('new.aiParseHint')}</p>
|
|
183
|
+
<textarea
|
|
184
|
+
className={css.input}
|
|
185
|
+
rows={3}
|
|
186
|
+
value={parseText}
|
|
187
|
+
placeholder={t('new.aiParsePlaceholder')}
|
|
188
|
+
spellCheck={false}
|
|
189
|
+
onChange={event => { setParseText(event.target.value); setParseError(undefined) }}
|
|
190
|
+
/>
|
|
191
|
+
<div className={css.aiParseRow}>
|
|
192
|
+
<select
|
|
193
|
+
className={css.select}
|
|
194
|
+
value={parseModel}
|
|
195
|
+
aria-label={t('new.aiParseModel')}
|
|
196
|
+
onChange={event => { setParseModel(event.target.value) }}
|
|
197
|
+
>
|
|
198
|
+
{parseModels.map(option => (
|
|
199
|
+
<option key={option.id} value={option.id}>{option.name ?? option.id}</option>
|
|
200
|
+
))}
|
|
201
|
+
</select>
|
|
202
|
+
{parsePending
|
|
203
|
+
? (
|
|
204
|
+
<button type="button" className={css.ghostButton} onClick={() => { parseAbort.current?.abort() }}>
|
|
205
|
+
{t('new.aiParseCancel')}
|
|
206
|
+
</button>
|
|
207
|
+
)
|
|
208
|
+
: (
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
className={css.primaryButton}
|
|
212
|
+
disabled={parseText.trim() === ''}
|
|
213
|
+
onClick={() => { void runParse() }}
|
|
214
|
+
>
|
|
215
|
+
{t('new.aiParseRun')}
|
|
216
|
+
</button>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
{parseError !== undefined && <p className={css.formError}>{parseError}</p>}
|
|
220
|
+
</section>
|
|
221
|
+
)}
|
|
222
|
+
|
|
135
223
|
<TaskContentFields
|
|
136
224
|
title={title}
|
|
137
225
|
description={description}
|
|
@@ -13,6 +13,9 @@ import { STATUS_KEY } from './status-key.ts'
|
|
|
13
13
|
import { TaskCard } from './TaskCard.tsx'
|
|
14
14
|
import { TaskDetail } from './TaskDetail.tsx'
|
|
15
15
|
|
|
16
|
+
/** Sentinel option value of the project row's "register a new project" entry. */
|
|
17
|
+
export const NEW_PROJECT_VALUE = '__dsh_new_project__'
|
|
18
|
+
|
|
16
19
|
/** Case-insensitive title/description/tag/freeze-snapshot match. */
|
|
17
20
|
export function matchesFilter(task: TaskRecord, filter: string): boolean {
|
|
18
21
|
if (filter.trim() === '') return true
|
|
@@ -55,6 +58,13 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
55
58
|
const [filter, setFilter] = useState('')
|
|
56
59
|
const [tagFilter, setTagFilter] = useState<string[]>([])
|
|
57
60
|
const [showNew, setShowNew] = useState(false)
|
|
61
|
+
// Project partition (#1536): '' means "all projects". A selected project
|
|
62
|
+
// narrows the board and becomes the new-task form's default workspace.
|
|
63
|
+
const [projectId, setProjectId] = useState('')
|
|
64
|
+
const [showNewProject, setShowNewProject] = useState(false)
|
|
65
|
+
const [newProjectPath, setNewProjectPath] = useState('')
|
|
66
|
+
const [newProjectError, setNewProjectError] = useState<string | undefined>(undefined)
|
|
67
|
+
const [newProjectPending, setNewProjectPending] = useState(false)
|
|
58
68
|
const selected = selectedTaskOf(snapshot)
|
|
59
69
|
const archiveView = snapshot.archiveView
|
|
60
70
|
// Every label in use across the ledger (board and archive alike), so the
|
|
@@ -63,9 +73,28 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
63
73
|
// Archived tasks leave the columns; the archive view shows them instead.
|
|
64
74
|
const visible = snapshot.tasks.filter(task =>
|
|
65
75
|
(archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined)
|
|
76
|
+
&& (projectId === '' || task.workspaceId === projectId)
|
|
66
77
|
&& matchesFilter(task, filter)
|
|
67
78
|
&& matchesTagFilter(task, tagFilter),
|
|
68
79
|
)
|
|
80
|
+
const projects = snapshot.executionOptions.workspaces
|
|
81
|
+
const canCreateProject = snapshot.canCreateWorkspace === true
|
|
82
|
+
const submitNewProject = async (): Promise<void> => {
|
|
83
|
+
const path = newProjectPath.trim()
|
|
84
|
+
if (path === '') return
|
|
85
|
+
setNewProjectPending(true)
|
|
86
|
+
setNewProjectError(undefined)
|
|
87
|
+
try {
|
|
88
|
+
const created = await controller.createWorkspace(path)
|
|
89
|
+
setProjectId(created.workspaceId)
|
|
90
|
+
setShowNewProject(false)
|
|
91
|
+
setNewProjectPath('')
|
|
92
|
+
} catch (error) {
|
|
93
|
+
setNewProjectError(error instanceof Error ? error.message : String(error))
|
|
94
|
+
} finally {
|
|
95
|
+
setNewProjectPending(false)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
69
98
|
const toggleTag = useCallback((name: string): void => {
|
|
70
99
|
setTagFilter(current => current.includes(name)
|
|
71
100
|
? current.filter(entry => entry !== name)
|
|
@@ -96,6 +125,32 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
96
125
|
})}
|
|
97
126
|
</span>
|
|
98
127
|
)}
|
|
128
|
+
{(projects.length > 0 || canCreateProject) && (
|
|
129
|
+
<label className={css.projectFilter}>
|
|
130
|
+
<span className={css.projectFilterLabel}>{t('board.project')}</span>
|
|
131
|
+
<select
|
|
132
|
+
className={css.select}
|
|
133
|
+
data-dsh-part="project-filter"
|
|
134
|
+
value={projectId}
|
|
135
|
+
aria-label={t('board.project')}
|
|
136
|
+
onChange={event => {
|
|
137
|
+
const value = event.target.value
|
|
138
|
+
if (value === NEW_PROJECT_VALUE) {
|
|
139
|
+
setNewProjectError(undefined)
|
|
140
|
+
setShowNewProject(true)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
setProjectId(value)
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<option value="">{t('board.projectAll')}</option>
|
|
147
|
+
{projects.map(project => (
|
|
148
|
+
<option key={project.workspaceId} value={project.workspaceId}>{project.title}</option>
|
|
149
|
+
))}
|
|
150
|
+
{canCreateProject && <option value={NEW_PROJECT_VALUE}>{t('board.projectNew')}</option>}
|
|
151
|
+
</select>
|
|
152
|
+
</label>
|
|
153
|
+
)}
|
|
99
154
|
<input
|
|
100
155
|
className={css.search}
|
|
101
156
|
type="search"
|
|
@@ -122,6 +177,39 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
122
177
|
</button>
|
|
123
178
|
</header>
|
|
124
179
|
|
|
180
|
+
{showNewProject && (
|
|
181
|
+
<div className={css.projectDialog} data-dsh-part="project-dialog">
|
|
182
|
+
<label className={css.field}>
|
|
183
|
+
<span className={css.fieldLabel}>{t('board.projectNewPath')}</span>
|
|
184
|
+
<input
|
|
185
|
+
className={css.input}
|
|
186
|
+
value={newProjectPath}
|
|
187
|
+
placeholder={t('board.projectNewPathPlaceholder')}
|
|
188
|
+
spellCheck={false}
|
|
189
|
+
onChange={event => { setNewProjectPath(event.target.value); setNewProjectError(undefined) }}
|
|
190
|
+
/>
|
|
191
|
+
</label>
|
|
192
|
+
{newProjectError !== undefined && <p className={css.formError}>{t('board.projectCreateFailed', { error: newProjectError })}</p>}
|
|
193
|
+
<div className={css.projectDialogActions}>
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
className={css.ghostButton}
|
|
197
|
+
onClick={() => { setShowNewProject(false); setNewProjectError(undefined) }}
|
|
198
|
+
>
|
|
199
|
+
{t('new.cancel')}
|
|
200
|
+
</button>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className={css.primaryButton}
|
|
204
|
+
disabled={newProjectPending || newProjectPath.trim() === ''}
|
|
205
|
+
onClick={() => { void submitNewProject() }}
|
|
206
|
+
>
|
|
207
|
+
{t('board.projectCreate')}
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
125
213
|
{!archiveView && knownTags.length > 0 && (
|
|
126
214
|
<div className={css.tagFilter} data-dsh-part="tag-filter">
|
|
127
215
|
<span className={css.tagFilterLabel}>{t('board.tagFilter')}</span>
|
|
@@ -225,6 +313,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
225
313
|
{showNew && (
|
|
226
314
|
<NewTaskModal
|
|
227
315
|
controller={controller}
|
|
316
|
+
{...(projectId === '' ? {} : { defaultWorkspaceId: projectId })}
|
|
228
317
|
onClose={() => { setShowNew(false) }}
|
|
229
318
|
/>
|
|
230
319
|
)}
|