dsh-taskboard 0.6.7 → 0.7.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.
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","names":[],"sources":["../../src/shared/api.ts"],"sourcesContent":["/**\n * Wire contract for the /taskboard host routes: the JSON envelope,\n * request/response shapes, and SSE event payloads shared by the host routes\n * and the browser client.\n *\n * @module dsh-taskboard/shared/api\n */\nimport type { BoardSettings, TaskLedger, TaskModel, TaskRecord, TaskSummary } from './protocol.ts'\n\nexport type { TaskModel, TaskRecord }\n\n/** Route prefix on the shared DSH webserver (same origin as the GUI). */\nexport const ROUTE_PREFIX = '/dsh-taskboard'\n\n/** SSE stream path (exact route; longest-prefix wins keep it disjoint). */\nexport const SSE_PATH = '/dsh-taskboard/events'\n\n/** Stable error codes (mirror the tool-level codes plus HTTP mapping). */\nexport type ApiErrorCode =\n | 'invalid_input'\n | 'not_found'\n | 'version_conflict'\n | 'invalid_transition'\n | 'forbidden'\n | 'internal'\n\n/** Success envelope. */\nexport type ApiOk<T> = { ok: true; value: T }\n\n/** Failure envelope. */\nexport type ApiFail = { ok: false; error: { code: ApiErrorCode; message: string } }\n\n/** The envelope either way. */\nexport type ApiResult<T> = ApiOk<T> | ApiFail\n\n// ---------------------------------------------------------------------------\n// payloads\n// ---------------------------------------------------------------------------\n\n/** Full-state response (the reconnect baseline after an SSE gap). */\nexport type StateResponse = TaskLedger & { capabilities?: { archiveSessions: boolean } }\n\n/**\n * Workspace listing for the UI pickers. `repoCount` (0.6.3): how many repos a\n * task mirror of this workspace would cover (root repo + nested) — the form's\n * worktree option shows the mirror badge when it exceeds 1.\n */\nexport type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean; repoCount?: number }\n\n/** Create-task request body (actor is always the GUI user). */\nexport type CreateTaskBody = {\n title: string\n workspaceId: string\n urgency: string\n description?: string\n prompt?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n /** Code isolation for executions ('worktree' | 'none'); omitted = default. */\n isolation?: string\n /** Agent preset for execution sessions; omitted = deployment default. */\n presetId?: string\n /** Execution permission preset ('workspace-write' | 'read-only' | 'danger-full-access'); omitted = default. */\n permission?: string\n /** Acceptance checklist item texts (host mints ids, all unchecked). */\n checklist?: string[]\n}\n\n/** Update-task request body (ifVersion mandatory). */\nexport type UpdateTaskBody = {\n ifVersion: number\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n blocked?: boolean\n /** Rebind the task to another project (GUI owner surface only). */\n workspaceId?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel | null\n /** Change isolation; locked once the task has execution history. */\n isolation?: string\n /** Change the execution preset (takes effect on the next run). */\n presetId?: string | null\n /** Change the execution permission (0.5.5; 'workspace-write' | 'read-only' | 'danger-full-access'). */\n permission?: string | null\n /** Replace the whole checklist (GUI owner surface); null clears it. */\n checklist?: unknown\n}\n\n/** Move-task request body (ifVersion mandatory; the user MAY move to done). */\nexport type MoveTaskBody = { ifVersion: number; status: string; archiveSessions?: boolean }\n\nexport type SessionArchiveResult = { archived: string[]; failed: Array<{ sessionId: string; error: string }>; unsupported: string[] }\nexport type MoveTaskResponse = TaskSummary & { sessionArchive?: SessionArchiveResult }\n\n/**\n * Quick-reject request body (card ✗ button): move back to todo plus an\n * optional user comment, committed as ONE ledger mutation so a failed move\n * can never strand an orphan comment.\n */\nexport type RejectTaskBody = { ifVersion: number; body?: string }\n\n/** Comment request body. */\nexport type CommentBody = { body: string }\n\n/** Delete request body (purge=true physically removes a trashed task). */\nexport type DeleteTaskBody = { ifVersion?: number; purge?: boolean }\n\n/** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */\nexport type RunTaskBody = { reuse?: boolean }\n\n/** One repo's merge outcome in a multi-repo merge (0.6.3; `repo: ''` = the workspace root repo). */\nexport type MergeRepoResult = {\n repo: string\n branch: string\n outcome: 'merged' | 'noop' | 'failed'\n /** Failure reason (verbatim git message) when outcome = 'failed'. */\n error?: string\n}\n\n/**\n * Merge outcome. Legacy single-repo tasks keep the flat shape; multi-repo\n * mirror tasks (0.6.3) additionally return per-repo results — merges run\n * sequentially and a failed repo does not block the others (plan §4.5).\n */\nexport type MergeBranchResponse = {\n merged: boolean\n noop?: boolean\n /** The merged task branch (legacy single-repo shape; multi-repo responses omit it). */\n branch?: string\n /** Present only on multi-repo mirror merges (0.6.3). */\n results?: MergeRepoResult[]\n}\n\n/** Remove a task's worktree; optionally delete its branch too. */\nexport type WorktreeRemoveBody = { deleteBranch?: boolean }\n\n/** One orphan worktree directory (exists on disk, owned by no live task). */\nexport type OrphanWorktree = { workspaceId: string; workspacePath: string; taskId: string; path: string }\n\n/** A git-enabled workspace whose .gitignore does not cover the worktree dir. */\nexport type GitignoreSuggestion = { workspaceId: string; workspacePath: string }\n\n/** Health-diagnostics response (⚙ panel). */\nexport type DiagnosticsResponse = {\n revision: number\n tasks: number\n /** Executions currently marked `running`. */\n staleRunning: number\n /** Worktree directories whose task no longer exists in the ledger. */\n orphanWorktrees: OrphanWorktree[]\n /** Git workspaces whose .gitignore does not ignore the worktree dir. */\n gitIgnoreSuggestions: GitignoreSuggestion[]\n}\n\n/** Fields a task template may prefill (0.4.0). */\nexport type TaskTemplateSpec = {\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n isolation?: string\n presetId?: string\n /** Execution permission preset (0.5.5). */\n permission?: string\n /** Checklist item texts (host mints ids at create time). */\n checklist?: string[]\n}\n\n/** One reusable task template (0.4.0). */\nexport type TaskTemplate = {\n id: string\n name: string\n task: TaskTemplateSpec\n /** Seeded built-in templates (kept on load, deletable like any other). */\n builtin?: boolean\n createdAt: number\n updatedAt: number\n}\n\n/** Templates listing response. */\nexport type TemplatesResponse = { templates: TaskTemplate[] }\n\n/** Board-settings response (0.5.0; absent fields follow factory defaults). */\nexport type SettingsResponse = BoardSettings\n\n/** Update-board-settings request body (0.5.0; whole-object replace semantics). */\nexport type UpdateSettingsBody = {\n /** Default code isolation for NEW tasks ('worktree' | 'none'). */\n defaultIsolation?: string\n /** Automatically capture external workspace sessions into the taskboard. */\n syncExternalSessions?: boolean\n /** Default permission preset for NEW tasks ('workspace-write' | 'read-only' | 'danger-full-access'). */\n defaultPermission?: string\n}\n\n/** Prompt completion item for skills and slash commands (0.5.5). */\nexport type PromptCompletionItem = {\n name: string\n kind: 'skill' | 'command'\n description?: string\n hint?: string\n}\n\n/** Prompt completions response (0.5.5). */\nexport type PromptCompletionsResponse = {\n commands: PromptCompletionItem[]\n skills: PromptCompletionItem[]\n}\n\n/** Model item in catalog (0.5.5). */\nexport type CatalogModelItem = {\n provider: string\n model: string\n name?: string\n description?: string\n reasoning?: {\n efforts: Array<{ id: string; name: string; description?: string }>\n defaultEffort?: string\n }\n}\n\n/** Preset item in catalog (0.5.5). */\nexport type CatalogPresetItem = {\n id: string\n name?: string\n}\n\n/** Model and preset catalog response (0.5.5). */\nexport type ModelCatalogResponse = {\n models: CatalogModelItem[]\n presets: CatalogPresetItem[]\n defaultPresetId?: string\n}\n\n/** Import dry-run response (0.4.0): every task classified, nothing written. */\nexport type ImportPreviewResponse = {\n plan: {\n create: Array<{ id: string; title: string; status: string }>\n overwrite: Array<{ id: string; title: string; status: string }>\n invalid: Array<{ id?: string; reason: string }>\n }\n}\n\n/** Import commit response. */\nexport type ImportCommitResponse = {\n mode: 'merge' | 'replace'\n created: number\n overwritten: number\n replacedTotal?: number\n /** The backup file written BEFORE a replace wiped the live ledger. */\n backupFile?: string\n}\n\n/** Diff-viewer response (0.4.0). */\nexport type DiffResponse = { diff: string; truncated: boolean }\n\n/** One task (full record) response. */\nexport type TaskResponse = TaskRecord\n\n/** Summary response used by list-ish endpoints. */\nexport type SummaryResponse = { tasks: TaskSummary[] }\n\n// ---------------------------------------------------------------------------\n// SSE\n// ---------------------------------------------------------------------------\n\n/** Change frame pushed on every committed ledger mutation. */\nexport type ChangeEvent = {\n revision: number\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n tasks: TaskSummary[]\n}\n"],"mappings":";;AAYA,MAAa,eAAe;;AAG5B,MAAa,WAAW"}
1
+ {"version":3,"file":"api.js","names":[],"sources":["../../src/shared/api.ts"],"sourcesContent":["/**\n * Wire contract for the /taskboard host routes: the JSON envelope,\n * request/response shapes, and SSE event payloads shared by the host routes\n * and the browser client.\n *\n * @module dsh-taskboard/shared/api\n */\nimport type { BoardSettings, TaskLedger, TaskModel, TaskRecord, TaskSummary } from './protocol.ts'\n\nexport type { TaskModel, TaskRecord }\n\n/** Route prefix on the shared DSH webserver (same origin as the GUI). */\nexport const ROUTE_PREFIX = '/dsh-taskboard'\n\n/** SSE stream path (exact route; longest-prefix wins keep it disjoint). */\nexport const SSE_PATH = '/dsh-taskboard/events'\n\n/** Stable error codes (mirror the tool-level codes plus HTTP mapping). */\nexport type ApiErrorCode =\n | 'invalid_input'\n | 'not_found'\n | 'version_conflict'\n | 'invalid_transition'\n | 'forbidden'\n | 'internal'\n\n/** Success envelope. */\nexport type ApiOk<T> = { ok: true; value: T }\n\n/** Failure envelope. */\nexport type ApiFail = { ok: false; error: { code: ApiErrorCode; message: string } }\n\n/** The envelope either way. */\nexport type ApiResult<T> = ApiOk<T> | ApiFail\n\n// ---------------------------------------------------------------------------\n// payloads\n// ---------------------------------------------------------------------------\n\n/** Full-state response (the reconnect baseline after an SSE gap). */\nexport type StateResponse = TaskLedger & { capabilities?: { archiveSessions: boolean } }\n\n/**\n * Workspace listing for the UI pickers. `repoCount` (0.6.3): how many repos a\n * task mirror of this workspace would cover (root repo + nested) — the form's\n * worktree option shows the mirror badge when it exceeds 1.\n */\nexport type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean; repoCount?: number }\n\n/** Create-task request body (actor is always the GUI user). */\nexport type CreateTaskBody = {\n title: string\n workspaceId: string\n urgency: string\n description?: string\n prompt?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n /** Code isolation for executions ('worktree' | 'none'); omitted = default. */\n isolation?: string\n /** Agent preset for execution sessions; omitted = deployment default. */\n presetId?: string\n /** Execution permission preset ('workspace-write' | 'read-only' | 'danger-full-access'); omitted = default. */\n permission?: string\n /** Acceptance checklist item texts (host mints ids, all unchecked). */\n checklist?: string[]\n}\n\n/** Update-task request body (ifVersion mandatory). */\nexport type UpdateTaskBody = {\n ifVersion: number\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n blocked?: boolean\n /** Rebind the task to another project (GUI owner surface only). */\n workspaceId?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel | null\n /** Change isolation; locked once the task has execution history. */\n isolation?: string\n /** Change the execution preset (takes effect on the next run). */\n presetId?: string | null\n /** Change the execution permission (0.5.5; 'workspace-write' | 'read-only' | 'danger-full-access'). */\n permission?: string | null\n /** Replace the whole checklist (GUI owner surface); null clears it. */\n checklist?: unknown\n}\n\n/** Move-task request body (ifVersion mandatory; the user MAY move to done). */\nexport type MoveTaskBody = { ifVersion: number; status: string; archiveSessions?: boolean }\n\nexport type SessionArchiveResult = { archived: string[]; failed: Array<{ sessionId: string; error: string }>; unsupported: string[] }\nexport type MoveTaskResponse = TaskSummary & { sessionArchive?: SessionArchiveResult }\n\n/**\n * Quick-reject request body (card ✗ button): move back to todo plus an\n * optional user comment, committed as ONE ledger mutation so a failed move\n * can never strand an orphan comment.\n */\nexport type RejectTaskBody = { ifVersion: number; body?: string }\n\n/** Comment request body. */\nexport type CommentBody = { body: string }\n\n/** One content-addressed image uploaded outside the ledger. */\nexport type AttachmentUpload = {\n id: string\n name: string\n size: number\n url: string\n extension: 'png' | 'jpg' | 'gif' | 'webp'\n mime: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'\n}\n\n/** Delete request body (purge=true physically removes a trashed task). */\nexport type DeleteTaskBody = { ifVersion?: number; purge?: boolean }\n\n/** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */\nexport type RunTaskBody = { reuse?: boolean }\n\n/** One repo's merge outcome in a multi-repo merge (0.6.3; `repo: ''` = the workspace root repo). */\nexport type MergeRepoResult = {\n repo: string\n branch: string\n outcome: 'merged' | 'noop' | 'failed'\n /** Failure reason (verbatim git message) when outcome = 'failed'. */\n error?: string\n}\n\n/**\n * Merge outcome. Legacy single-repo tasks keep the flat shape; multi-repo\n * mirror tasks (0.6.3) additionally return per-repo results — merges run\n * sequentially and a failed repo does not block the others (plan §4.5).\n */\nexport type MergeBranchResponse = {\n merged: boolean\n noop?: boolean\n /** The merged task branch (legacy single-repo shape; multi-repo responses omit it). */\n branch?: string\n /** Present only on multi-repo mirror merges (0.6.3). */\n results?: MergeRepoResult[]\n}\n\n/** Remove a task's worktree; optionally delete its branch too. */\nexport type WorktreeRemoveBody = { deleteBranch?: boolean }\n\n/** One orphan worktree directory (exists on disk, owned by no live task). */\nexport type OrphanWorktree = { workspaceId: string; workspacePath: string; taskId: string; path: string }\n\n/** A git-enabled workspace whose .gitignore does not cover the worktree dir. */\nexport type GitignoreSuggestion = { workspaceId: string; workspacePath: string }\n\n/** Health-diagnostics response (⚙ panel). */\nexport type DiagnosticsResponse = {\n revision: number\n tasks: number\n /** Executions currently marked `running`. */\n staleRunning: number\n /** Worktree directories whose task no longer exists in the ledger. */\n orphanWorktrees: OrphanWorktree[]\n /** Git workspaces whose .gitignore does not ignore the worktree dir. */\n gitIgnoreSuggestions: GitignoreSuggestion[]\n}\n\n/** Fields a task template may prefill (0.4.0). */\nexport type TaskTemplateSpec = {\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n isolation?: string\n presetId?: string\n /** Execution permission preset (0.5.5). */\n permission?: string\n /** Checklist item texts (host mints ids at create time). */\n checklist?: string[]\n}\n\n/** One reusable task template (0.4.0). */\nexport type TaskTemplate = {\n id: string\n name: string\n task: TaskTemplateSpec\n /** Seeded built-in templates (kept on load, deletable like any other). */\n builtin?: boolean\n createdAt: number\n updatedAt: number\n}\n\n/** Templates listing response. */\nexport type TemplatesResponse = { templates: TaskTemplate[] }\n\n/** Board-settings response (0.5.0; absent fields follow factory defaults). */\nexport type SettingsResponse = BoardSettings\n\n/** Current host-side location of all durable taskboard data. */\nexport type StorageStatus = {\n currentDirectory: string\n defaultDirectory: string\n isDefault: boolean\n configured: boolean\n writable: boolean\n assetCount: number\n assetBytes: number\n checkedDirectory?: string\n error?: string\n}\n\n/** Completed storage relocation, including non-fatal old-file cleanup failures. */\nexport type StorageMigrationResult = StorageStatus & { migrated: boolean; warnings: string[] }\n\n/** Update-board-settings request body (0.5.0; whole-object replace semantics). */\nexport type UpdateSettingsBody = {\n /** Default code isolation for NEW tasks ('worktree' | 'none'). */\n defaultIsolation?: string\n /** Automatically capture external workspace sessions into the taskboard. */\n syncExternalSessions?: boolean\n /** Default permission preset for NEW tasks ('workspace-write' | 'read-only' | 'danger-full-access'). */\n defaultPermission?: string\n}\n\n/** Prompt completion item for skills and slash commands (0.5.5). */\nexport type PromptCompletionItem = {\n name: string\n kind: 'skill' | 'command'\n description?: string\n hint?: string\n}\n\n/** Prompt completions response (0.5.5). */\nexport type PromptCompletionsResponse = {\n commands: PromptCompletionItem[]\n skills: PromptCompletionItem[]\n}\n\n/** Model item in catalog (0.5.5). */\nexport type CatalogModelItem = {\n provider: string\n model: string\n name?: string\n description?: string\n reasoning?: {\n efforts: Array<{ id: string; name: string; description?: string }>\n defaultEffort?: string\n }\n}\n\n/** Preset item in catalog (0.5.5). */\nexport type CatalogPresetItem = {\n id: string\n name?: string\n}\n\n/** Model and preset catalog response (0.5.5). */\nexport type ModelCatalogResponse = {\n models: CatalogModelItem[]\n presets: CatalogPresetItem[]\n defaultPresetId?: string\n}\n\n/** Import dry-run response (0.4.0): every task classified, nothing written. */\nexport type ImportPreviewResponse = {\n plan: {\n create: Array<{ id: string; title: string; status: string }>\n overwrite: Array<{ id: string; title: string; status: string }>\n invalid: Array<{ id?: string; reason: string }>\n }\n}\n\n/** Import commit response. */\nexport type ImportCommitResponse = {\n mode: 'merge' | 'replace'\n created: number\n overwritten: number\n replacedTotal?: number\n /** The backup file written BEFORE a replace wiped the live ledger. */\n backupFile?: string\n}\n\n/** Diff-viewer response (0.4.0). */\nexport type DiffResponse = { diff: string; truncated: boolean }\n\n/** One task (full record) response. */\nexport type TaskResponse = TaskRecord\n\n/** Summary response used by list-ish endpoints. */\nexport type SummaryResponse = { tasks: TaskSummary[] }\n\n// ---------------------------------------------------------------------------\n// SSE\n// ---------------------------------------------------------------------------\n\n/** Change frame pushed on every committed ledger mutation. */\nexport type ChangeEvent = {\n revision: number\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n tasks: TaskSummary[]\n}\n"],"mappings":";;AAYA,MAAa,eAAe;;AAG5B,MAAa,WAAW"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-taskboard",
3
3
  "description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, optional per-task git-worktree isolation (dedicated task branches, commit evidence, one-click merge), host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.6.7",
4
+ "version": "0.7.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -26,8 +26,9 @@
26
26
  "0.1.2-alpha.2": "compatible",
27
27
  "0.1.2-alpha.3": "compatible",
28
28
  "0.1.2-alpha.4": "compatible",
29
- "0.1.2-alpha.5": "compatible",
30
- "0.1.2-rc.1": "compatible"
29
+ "0.1.2-alpha.5": "compatible",
30
+ "0.1.2-rc.1": "compatible",
31
+ "0.1.5-rc.1": "compatible"
31
32
  }
32
33
  }
33
34
  },
@@ -68,14 +69,14 @@
68
69
  "test": "vitest run"
69
70
  },
70
71
  "devDependencies": {
71
- "@deepseek-ai/cordis": "^4.0.1",
72
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
73
- "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
74
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
75
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
76
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
77
- "@deepseek-ai/dsh-workspace": "^0.1.1-rc.2",
78
- "@deepseek-ai/schemastery": "^3.18.1",
72
+ "@deepseek-ai/cordis": "^4.0.2",
73
+ "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
74
+ "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.2",
75
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
76
+ "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
77
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
78
+ "@deepseek-ai/dsh-workspace": "^0.1.5-rc.2",
79
+ "@deepseek-ai/schemastery": "^3.18.2",
79
80
  "@types/node": "^22.20.1",
80
81
  "@types/react": "~18.3.1",
81
82
  "@types/react-dom": "^18.3.7",
@@ -88,3 +89,4 @@
88
89
  "vitest": "^3.0.0"
89
90
  }
90
91
  }
92
+
package/src/client/api.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type {
9
9
  ApiResult,
10
+ AttachmentUpload,
10
11
  ChangeEvent,
11
12
  CreateTaskBody,
12
13
  DeleteTaskBody,
@@ -23,6 +24,8 @@ import type {
23
24
  RejectTaskBody,
24
25
  RunTaskBody,
25
26
  SettingsResponse,
27
+ StorageMigrationResult,
28
+ StorageStatus,
26
29
  StateResponse,
27
30
  TaskRecord,
28
31
  TaskTemplate,
@@ -60,6 +63,17 @@ async function post<T>(path: string, body: unknown): Promise<T> {
60
63
  return unwrap<T>(res)
61
64
  }
62
65
 
66
+ /** Upload raw image bytes; a custom header keeps the route outside simple CSRF requests. */
67
+ async function uploadImage(file: Blob): Promise<AttachmentUpload> {
68
+ const res = await fetch('/dsh-taskboard/assets', {
69
+ method: 'POST',
70
+ headers: { 'content-type': file.type, 'x-dsh-taskboard-upload': '1' },
71
+ body: file,
72
+ signal: AbortSignal.timeout(30_000),
73
+ })
74
+ return unwrap<AttachmentUpload>(res)
75
+ }
76
+
63
77
  /** Route client face (the controller consumes this narrow surface). */
64
78
  export interface TaskboardClient {
65
79
  state(): Promise<StateResponse>
@@ -72,6 +86,8 @@ export interface TaskboardClient {
72
86
  /** Quick-reject (card ✗): back to todo + optional comment, one mutation. */
73
87
  reject(id: string, body: RejectTaskBody): Promise<TaskSummary>
74
88
  comment(id: string, bodyText: string): Promise<CommentRecord>
89
+ /** Persist an image and return the short Markdown-safe URL. */
90
+ uploadImage(file: Blob): Promise<AttachmentUpload>
75
91
  remove(id: string, body: DeleteTaskBody): Promise<{ trashed?: boolean; purged?: boolean }>
76
92
  /** Trigger a manual run (fresh in-project session); `reuse: true` = 续跑. */
77
93
  run(id: string, body?: RunTaskBody): Promise<{ executionId: string; sessionId: string }>
@@ -101,6 +117,11 @@ export interface TaskboardClient {
101
117
  settings(): Promise<SettingsResponse>
102
118
  /** Replace board settings (whole-object semantics; affects new tasks only). */
103
119
  updateSettings(body: UpdateSettingsBody): Promise<SettingsResponse>
120
+ /** Inspect and validate the host-side data directory. */
121
+ storage(): Promise<StorageStatus>
122
+ checkStorage(directory: string): Promise<StorageStatus>
123
+ /** Move ledger, templates, and attachments together. */
124
+ migrateStorage(directory: string): Promise<StorageMigrationResult>
104
125
  /** Prompt completions for skills and slash commands (0.5.5). */
105
126
  promptCompletions(): Promise<PromptCompletionsResponse>
106
127
  /** Model catalog and agent preset roster (0.5.5). */
@@ -121,6 +142,7 @@ export function createClient(): TaskboardClient {
121
142
  archiveSessions: id => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/archive-sessions`, {}),
122
143
  reject: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/reject`, body),
123
144
  comment: (id, bodyText) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/comment`, { body: bodyText }),
145
+ uploadImage,
124
146
  remove: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/delete`, body),
125
147
  run: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/run`, body ?? {}),
126
148
  cancel: id => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/cancel`, {}),
@@ -142,6 +164,14 @@ export function createClient(): TaskboardClient {
142
164
  templateDelete: id => post('/dsh-taskboard/templates/delete', { id }),
143
165
  settings: () => get<SettingsResponse>('/dsh-taskboard/settings'),
144
166
  updateSettings: body => post('/dsh-taskboard/settings/update', body),
167
+ storage: () => get<StorageStatus>('/dsh-taskboard/storage'),
168
+ checkStorage: directory => post<StorageStatus>('/dsh-taskboard/storage/check', { directory }),
169
+ migrateStorage: directory => unwrap<StorageMigrationResult>(fetch('/dsh-taskboard/storage/migrate', {
170
+ method: 'POST',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({ directory }),
173
+ signal: AbortSignal.timeout(120_000),
174
+ })),
145
175
  promptCompletions: () => get<PromptCompletionsResponse>('/dsh-taskboard/prompt-completions'),
146
176
  modelCatalog: () => get<ModelCatalogResponse>('/dsh-taskboard/model-catalog'),
147
177
  stream(onChange, onGap) {
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Board-settings modal (0.5.0): the user-owned defaults applied when a NEW
3
- * task is created without an explicit choice. Currently one section — 默认执行
4
- * 隔离 (worktree vs original directory); further sections can slot into the
5
- * body below. Saving goes through the host route (whole-object replace) and
3
+ * task is created without an explicit choice, plus the host data-directory
4
+ * migration surface. Saving goes through host routes and
6
5
  * the SSE change stream refreshes every open view.
7
6
  *
8
7
  * @module dsh-taskboard/client/board/SettingsModal
9
8
  */
10
- import { useState } from 'react'
9
+ import { useEffect, useState } from 'react'
11
10
  import type { BoardController } from '../controller.ts'
12
11
  import { DEFAULT_ISOLATION, defaultPermissionOf, defaultSyncExternalSessionsOf, type IsolationMode, type PermissionMode } from '../../shared/protocol.ts'
13
12
  import { useT, type Translate } from '../i18n/runtime.ts'
@@ -32,7 +31,16 @@ export function SettingsModal({ controller }: { controller: BoardController }) {
32
31
  const [draftIso, setDraftIso] = useState<IsolationMode>(currentIso)
33
32
  const [draftSync, setDraftSync] = useState<boolean>(currentSync)
34
33
  const [draftPerm, setDraftPerm] = useState<PermissionMode>(currentPerm)
34
+ const [storagePath, setStoragePath] = useState(state.storage?.currentDirectory ?? '')
35
+ const [storageTouched, setStorageTouched] = useState(false)
36
+ const [storageBusy, setStorageBusy] = useState(false)
35
37
  const dirty = draftIso !== currentIso || draftSync !== currentSync || draftPerm !== currentPerm
38
+ const effectiveStoragePath = storagePath.trim().length === 0 ? state.storage?.defaultDirectory ?? '' : storagePath.trim()
39
+ const storageDirty = state.storage !== undefined && effectiveStoragePath !== state.storage.currentDirectory
40
+
41
+ useEffect(() => {
42
+ if (!storageTouched && state.storage !== undefined) setStoragePath(state.storage.currentDirectory)
43
+ }, [state.storage, storageTouched])
36
44
 
37
45
  const save = (): void => {
38
46
  void controller.updateSettings({
@@ -145,6 +153,69 @@ export function SettingsModal({ controller }: { controller: BoardController }) {
145
153
  {t('set.perm.current', { current: currentPerm === 'read-only' ? t('set.perm.readOnlyName') : currentPerm === 'danger-full-access' ? t('set.perm.fullName') : t('set.perm.writeName') })}
146
154
  </span>
147
155
  </section>
156
+
157
+ <section className="dsh-atb-diag-sec">
158
+ <h4>{t('set.storage.heading')}</h4>
159
+ <p className="dsh-atb-isolation-note">{t('set.storage.hint')}</p>
160
+ <input
161
+ className="dsh-atb-input dsh-atb-storage-path"
162
+ value={storagePath}
163
+ disabled={state.storage === undefined || storageBusy}
164
+ placeholder={state.storage?.defaultDirectory ?? t('set.storage.loading')}
165
+ onChange={e => { setStoragePath(e.target.value); setStorageTouched(true); controller.dismissStorageNotice() }}
166
+ />
167
+ {state.storageNotice !== undefined && (
168
+ <div className="dsh-atb-storage-notice" role="status">
169
+ <span className="dsh-atb-storage-notice-ok">✓ {t('set.storage.migrated', { path: state.storageNotice.path })}</span>
170
+ {state.storageNotice.warnings.length > 0 && (
171
+ <span className="dsh-atb-storage-notice-warn">{t('set.storage.warnings', { warnings: state.storageNotice.warnings.join('; ') })}</span>
172
+ )}
173
+ </div>
174
+ )}
175
+ {state.storage !== undefined && (
176
+ <div className="dsh-atb-storage-meta">
177
+ <span>{t('set.storage.current', { path: state.storage.currentDirectory })}</span>
178
+ <span>{t('set.storage.assets', { count: state.storage.assetCount, size: (state.storage.assetBytes / 1024 / 1024).toFixed(1) })}</span>
179
+ {state.storage.error !== undefined && <span className="dsh-atb-storage-error">{state.storage.error}</span>}
180
+ </div>
181
+ )}
182
+ <div className="dsh-atb-storage-actions">
183
+ <button
184
+ type="button"
185
+ className="dsh-atb-btn"
186
+ disabled={state.storage === undefined || storageBusy}
187
+ onClick={() => { setStoragePath(state.storage?.defaultDirectory ?? ''); setStorageTouched(true) }}
188
+ >
189
+ {t('set.storage.default')}
190
+ </button>
191
+ <button
192
+ type="button"
193
+ className="dsh-atb-btn"
194
+ disabled={state.storage === undefined || storageBusy || effectiveStoragePath.length === 0}
195
+ onClick={() => {
196
+ setStorageBusy(true)
197
+ void controller.checkStorage(effectiveStoragePath).finally(() => setStorageBusy(false))
198
+ }}
199
+ >
200
+ {t('set.storage.check')}
201
+ </button>
202
+ <button
203
+ type="button"
204
+ className="dsh-atb-btn"
205
+ data-primary="true"
206
+ disabled={!storageDirty || storageBusy}
207
+ onClick={() => {
208
+ if (!window.confirm(t('set.storage.confirm', { from: state.storage?.currentDirectory ?? '', to: effectiveStoragePath }))) return
209
+ setStorageBusy(true)
210
+ void controller.migrateStorage(effectiveStoragePath).then(ok => {
211
+ if (ok) { setStorageTouched(false); setStoragePath(effectiveStoragePath) }
212
+ }).finally(() => setStorageBusy(false))
213
+ }}
214
+ >
215
+ {storageBusy ? t('set.storage.migrating') : t('set.storage.migrate')}
216
+ </button>
217
+ </div>
218
+ </section>
148
219
  </div>
149
220
 
150
221
  <div className="dsh-atb-modal-foot">
@@ -11,6 +11,7 @@ import { createPortal } from 'react-dom'
11
11
  import type { BoardController } from '../controller.ts'
12
12
  import type { PromptCompletionItem } from '../../shared/api.ts'
13
13
  import { useT, type Translate } from '../i18n/runtime.ts'
14
+ import { IMAGE_ACCEPT, acceptedImageFiles, imageAlt, imageMarkdown, insertImageMarkdown } from '../image-insert.ts'
14
15
 
15
16
  /** Default built-in slash commands (descriptions resolve through t at render,
16
17
  * so they follow the GUI language live; host-provided items override by name). */
@@ -65,6 +66,8 @@ export interface SlashPromptInputProps {
65
66
  autoFocus?: boolean
66
67
  className?: string
67
68
  ariaLabel?: string
69
+ allowImages?: boolean
70
+ onUploadingChange?: (uploading: boolean) => void
68
71
  }
69
72
 
70
73
  /**
@@ -81,11 +84,14 @@ export function SlashPromptInput({
81
84
  autoFocus = false,
82
85
  className,
83
86
  ariaLabel,
87
+ allowImages = false,
88
+ onUploadingChange,
84
89
  }: SlashPromptInputProps) {
85
90
  const t = useT()
86
91
  const textareaRef = useRef<HTMLTextAreaElement>(null)
87
92
  const popupRef = useRef<HTMLDivElement>(null)
88
93
  const listRef = useRef<HTMLDivElement>(null)
94
+ const fileInputRef = useRef<HTMLInputElement>(null)
89
95
  // Inline fixed-position style for the portaled popup (set by positionPopup).
90
96
  const [popupStyle, setPopupStyle] = useState<CSSProperties>({})
91
97
 
@@ -106,6 +112,40 @@ export function SlashPromptInput({
106
112
  const [slashQuery, setSlashQuery] = useState('')
107
113
  const [slashStart, setSlashStart] = useState(-1)
108
114
  const [selectedIndex, setSelectedIndex] = useState(0)
115
+ const [uploading, setUploading] = useState(false)
116
+
117
+ const uploadFiles = async (rawFiles: Iterable<File>): Promise<void> => {
118
+ const files = acceptedImageFiles(rawFiles)
119
+ if (!allowImages || controller === undefined || files.length === 0 || uploading) return
120
+ setUploading(true)
121
+ onUploadingChange?.(true)
122
+ const element = textareaRef.current
123
+ let nextValue = element?.value ?? value
124
+ let start = element?.selectionStart ?? nextValue.length
125
+ let end = element?.selectionEnd ?? start
126
+ let changed = false
127
+ try {
128
+ for (const file of files) {
129
+ const asset = await controller.uploadImage(file)
130
+ if (asset === undefined) continue
131
+ const next = insertImageMarkdown(nextValue, start, end, imageMarkdown(asset, imageAlt(file.name, t('image.defaultAlt'))))
132
+ nextValue = next.value
133
+ start = next.cursor
134
+ end = start
135
+ changed = true
136
+ }
137
+ if (changed) {
138
+ onChange(nextValue)
139
+ setTimeout(() => {
140
+ textareaRef.current?.focus()
141
+ textareaRef.current?.setSelectionRange(start, start)
142
+ }, 0)
143
+ }
144
+ } finally {
145
+ setUploading(false)
146
+ onUploadingChange?.(false)
147
+ }
148
+ }
109
149
 
110
150
  // Fetch host completions if controller provided
111
151
  useEffect(() => {
@@ -282,7 +322,7 @@ export function SlashPromptInput({
282
322
  value={value}
283
323
  rows={rows}
284
324
  maxLength={maxLength}
285
- disabled={disabled}
325
+ disabled={disabled || uploading}
286
326
  autoFocus={autoFocus}
287
327
  placeholder={placeholder}
288
328
  aria-label={ariaLabel}
@@ -293,6 +333,25 @@ export function SlashPromptInput({
293
333
  onKeyUp={checkSlashTrigger}
294
334
  onClick={checkSlashTrigger}
295
335
  onKeyDown={handleKeyDown}
336
+ onPaste={e => {
337
+ const files = acceptedImageFiles(e.clipboardData.files)
338
+ if (allowImages && files.length > 0) {
339
+ e.preventDefault()
340
+ void uploadFiles(files)
341
+ }
342
+ }}
343
+ onDragOver={e => {
344
+ // Browsers keep DataTransfer.files empty while dragging over a
345
+ // page; the concrete files become readable only on drop.
346
+ if (allowImages && e.dataTransfer.types.includes('Files')) e.preventDefault()
347
+ }}
348
+ onDrop={e => {
349
+ const files = acceptedImageFiles(e.dataTransfer.files)
350
+ if (allowImages && files.length > 0) {
351
+ e.preventDefault()
352
+ void uploadFiles(files)
353
+ }
354
+ }}
296
355
  />
297
356
 
298
357
  {/* Slash Autocomplete Popup — portaled to document.body so the
@@ -329,6 +388,26 @@ export function SlashPromptInput({
329
388
  )}
330
389
  </div>
331
390
 
391
+ {allowImages && (
392
+ <div className="dsh-atb-image-actions">
393
+ <input
394
+ ref={fileInputRef}
395
+ type="file"
396
+ accept={IMAGE_ACCEPT}
397
+ multiple
398
+ hidden
399
+ onChange={e => {
400
+ if (e.target.files !== null) void uploadFiles(e.target.files)
401
+ e.target.value = ''
402
+ }}
403
+ />
404
+ <button type="button" className="dsh-atb-image-add" disabled={disabled || uploading} onClick={() => fileInputRef.current?.click()}>
405
+ {uploading ? t('image.uploading') : t('image.add')}
406
+ </button>
407
+ <span className="dsh-atb-image-hint">{t('image.hint')}</span>
408
+ </div>
409
+ )}
410
+
332
411
  {/* Bottom helper toolbar */}
333
412
  <div className="dsh-atb-prompt-foot">
334
413
  <span className="dsh-atb-prompt-tip">
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module dsh-taskboard/client/board/TaskDetail
9
9
  */
10
- import { useEffect, useState, type ReactNode } from 'react'
10
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
11
11
  import type { BoardController } from '../controller.ts'
12
12
  import type { CommentRecord, ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
13
13
  import { canTransition, checklistProgress, taskAssociatedSessionIds } from '../../shared/protocol.ts'
@@ -15,6 +15,7 @@ import { useAlert } from './AlertModal.tsx'
15
15
  import { fmtTime, isStaleClaim } from './format.ts'
16
16
  import { MOVE_KEYS, OUTCOME_KEYS, STATUS_KEYS, URGENCY_KEYS } from './labels.ts'
17
17
  import { useT, type Translate } from '../i18n/runtime.ts'
18
+ import { IMAGE_ACCEPT, acceptedImageFiles, imageAlt, imageMarkdown, insertImageMarkdown } from '../image-insert.ts'
18
19
 
19
20
  /** Statuses a user may move this task to, per the state machine. */
20
21
  function moveTargets(task: TaskRecord): TaskRecord['status'][] {
@@ -514,6 +515,9 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
514
515
  export function TaskDetail({ task, controller, now }: { task: TaskRecord; controller: BoardController; now?: number }) {
515
516
  const t = useT()
516
517
  const [comment, setComment] = useState('')
518
+ const [commentUploading, setCommentUploading] = useState(false)
519
+ const commentRef = useRef<HTMLTextAreaElement>(null)
520
+ const commentFileRef = useRef<HTMLInputElement>(null)
517
521
  const [confirmDone, setConfirmDone] = useState(false)
518
522
  const [confirmPurge, setConfirmPurge] = useState(false)
519
523
  const [confirmCancel, setConfirmCancel] = useState(false)
@@ -542,6 +546,42 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
542
546
  void action().catch(() => undefined).finally(() => setActionBusy(false))
543
547
  }
544
548
 
549
+ const postComment = (): void => {
550
+ if (commentUploading || comment.trim().length === 0) return
551
+ void controller.comment(task.id, comment).then(ok => { if (ok) setComment('') })
552
+ }
553
+
554
+ const uploadCommentImages = async (rawFiles: Iterable<File>): Promise<void> => {
555
+ const files = acceptedImageFiles(rawFiles)
556
+ if (files.length === 0 || commentUploading) return
557
+ setCommentUploading(true)
558
+ const element = commentRef.current
559
+ let nextValue = element?.value ?? comment
560
+ let start = element?.selectionStart ?? nextValue.length
561
+ let end = element?.selectionEnd ?? start
562
+ let changed = false
563
+ try {
564
+ for (const file of files) {
565
+ const asset = await controller.uploadImage(file)
566
+ if (asset === undefined) continue
567
+ const next = insertImageMarkdown(nextValue, start, end, imageMarkdown(asset, imageAlt(file.name, t('image.defaultAlt'))))
568
+ nextValue = next.value
569
+ start = next.cursor
570
+ end = start
571
+ changed = true
572
+ }
573
+ if (changed) {
574
+ setComment(nextValue)
575
+ setTimeout(() => {
576
+ commentRef.current?.focus()
577
+ commentRef.current?.setSelectionRange(start, start)
578
+ }, 0)
579
+ }
580
+ } finally {
581
+ setCommentUploading(false)
582
+ }
583
+ }
584
+
545
585
  /** Jump to an execution's session; prompt precisely when it cannot open. */
546
586
  const jumpToSession = (sessionId: string): void => {
547
587
  void controller.openSession(sessionId).then(result => {
@@ -836,7 +876,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
836
876
  <b>{c.threadId !== undefined ? `agent ${shortId(c.threadId)}` : t('detail.comments.user')}</b>
837
877
  <span>{fmtTime(c.createdAt)}</span>
838
878
  </div>
839
- <div className="dsh-atb-bubble-body">{commentBody(t, c)}</div>
879
+ <div className="dsh-atb-bubble-body"><MarkdownContent text={commentBody(t, c)} /></div>
840
880
  </div>
841
881
  </div>
842
882
  ))}
@@ -844,24 +884,47 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
844
884
  )}
845
885
  <div className="dsh-atb-composer">
846
886
  <textarea
887
+ ref={commentRef}
847
888
  className="dsh-atb-composer-input"
848
889
  value={comment}
849
890
  placeholder={t('detail.composer.placeholder')}
891
+ disabled={commentUploading}
850
892
  onChange={e => setComment(e.target.value)}
851
893
  onKeyDown={e => {
852
- if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && comment.trim().length > 0) {
894
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && comment.trim().length > 0 && !commentUploading) {
853
895
  // T13: keep the draft when the post fails (reject 表单同样保留).
854
- void controller.comment(task.id, comment).then(ok => { if (ok) setComment('') })
896
+ postComment()
855
897
  }
856
898
  }}
899
+ onPaste={e => {
900
+ const files = acceptedImageFiles(e.clipboardData.files)
901
+ if (files.length > 0) { e.preventDefault(); void uploadCommentImages(files) }
902
+ }}
903
+ onDragOver={e => { if (e.dataTransfer.types.includes('Files')) e.preventDefault() }}
904
+ onDrop={e => {
905
+ const files = acceptedImageFiles(e.dataTransfer.files)
906
+ if (files.length > 0) { e.preventDefault(); void uploadCommentImages(files) }
907
+ }}
908
+ />
909
+ <input
910
+ ref={commentFileRef}
911
+ type="file"
912
+ accept={IMAGE_ACCEPT}
913
+ multiple
914
+ hidden
915
+ onChange={e => {
916
+ if (e.target.files !== null) void uploadCommentImages(e.target.files)
917
+ e.target.value = ''
918
+ }}
857
919
  />
920
+ <button type="button" className="dsh-atb-image-add" disabled={commentUploading} title={t('image.add')} onClick={() => commentFileRef.current?.click()}>
921
+ {commentUploading ? '…' : '🖼'}
922
+ </button>
858
923
  <button
859
924
  type="button"
860
925
  className="dsh-atb-composer-send"
861
- disabled={comment.trim().length === 0}
862
- onClick={() => {
863
- void controller.comment(task.id, comment).then(ok => { if (ok) setComment('') })
864
- }}
926
+ disabled={comment.trim().length === 0 || commentUploading}
927
+ onClick={postComment}
865
928
  >
866
929
  {t('detail.composer.send')}
867
930
  </button>
@@ -212,6 +212,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
212
212
  // create/update/run round-trip is pending — a double click used to fire
213
213
  // duplicate creates (and runs) before the first one returned (review P0).
214
214
  const [busy, setBusy] = useState(false)
215
+ const [imageUploading, setImageUploading] = useState(false)
215
216
 
216
217
  // Focus the title and close on Esc while the dialog is open.
217
218
  useEffect(() => {
@@ -294,7 +295,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
294
295
  }
295
296
 
296
297
  const submit = (): void => {
297
- if (!valid || busy) return
298
+ if (!valid || busy || imageUploading) return
298
299
  const picked = buildPickedModel()
299
300
  if (!editing) saveLastModel(picked)
300
301
  const isolationOut = isolationPayload()
@@ -335,7 +336,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
335
336
 
336
337
  /** Save the form, then immediately trigger a manual run of the task. */
337
338
  const submitAndRun = (): void => {
338
- if (!valid || runBlocked || busy) return
339
+ if (!valid || runBlocked || busy || imageUploading) return
339
340
  const picked = buildPickedModel()
340
341
  if (!editing) saveLastModel(picked)
341
342
  const isolationOut = isolationPayload()
@@ -602,6 +603,8 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
602
603
  controller={controller}
603
604
  rows={7}
604
605
  placeholder={t('form.desc.placeholder')}
606
+ allowImages
607
+ onUploadingChange={setImageUploading}
605
608
  />
606
609
  </Field>
607
610
 
@@ -624,13 +627,13 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
624
627
  <button
625
628
  type="button"
626
629
  className="dsh-atb-btn"
627
- disabled={!valid || runBlocked || busy}
628
- title={runBlocked ? t('form.action.runBlockedTitle') : busy ? t('form.action.runBusyTitle') : t('form.action.runTitle')}
630
+ disabled={!valid || runBlocked || busy || imageUploading}
631
+ title={runBlocked ? t('form.action.runBlockedTitle') : busy || imageUploading ? t('form.action.runBusyTitle') : t('form.action.runTitle')}
629
632
  onClick={submitAndRun}
630
633
  >
631
634
  {t('form.action.run')}
632
635
  </button>
633
- <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid || busy} onClick={submit}>
636
+ <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid || busy || imageUploading} onClick={submit}>
634
637
  {editing ? t('form.action.save') : t('form.action.create')}
635
638
  </button>
636
639
  </span>