@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.
@@ -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
- * Drop blank rows and trim what survives, so the wire always carries a valid
44
- * tag list (the protocol rejects a list with a blank name or an empty array).
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 {};
@@ -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
@@ -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.22",
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
  )}