dsh-taskboard 0.6.7 → 0.7.0
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.md +24 -14
- package/lib/client.js +56 -8
- package/lib/host/assets.js +139 -0
- package/lib/host/assets.js.map +1 -0
- package/lib/host/routes.js +87 -0
- package/lib/host/routes.js.map +1 -1
- package/lib/host/storage-queue.js +14 -0
- package/lib/host/storage-queue.js.map +1 -0
- package/lib/host/storage.js +249 -0
- package/lib/host/storage.js.map +1 -0
- package/lib/host/store.js +34 -7
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +62 -38
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +6 -4
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +83 -27
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/package.json +12 -11
- package/src/client/api.ts +30 -0
- package/src/client/board/SettingsModal.tsx +67 -4
- package/src/client/board/SlashPromptInput.tsx +80 -1
- package/src/client/board/TaskDetail.tsx +71 -8
- package/src/client/board/TaskFormModal.tsx +8 -5
- package/src/client/controller.ts +44 -2
- package/src/client/i18n/en.ts +17 -3
- package/src/client/i18n/zh.ts +17 -3
- package/src/client/image-insert.ts +29 -0
- package/src/client/styles.ts +34 -1
- package/src/host/assets.ts +120 -0
- package/src/host/routes.ts +92 -0
- package/src/host/storage-queue.ts +10 -0
- package/src/host/storage.ts +212 -0
- package/src/host/store.ts +40 -12
- package/src/host/templates.ts +30 -7
- package/src/host/tools.ts +8 -4
- package/src/index.ts +88 -33
- package/src/shared/api.ts +26 -0
- package/src/shared/version.ts +1 -1
package/lib/shared/api.js.map
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.7.0",
|
|
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.
|
|
72
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
73
|
-
"@deepseek-ai/dsh-home-paths": "^0.1.
|
|
74
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.
|
|
75
|
-
"@deepseek-ai/dsh-system-prompt": "^0.1.
|
|
76
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
77
|
-
"@deepseek-ai/dsh-workspace": "^0.1.
|
|
78
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
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",
|
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
|
|
4
|
-
*
|
|
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,61 @@ 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) }}
|
|
166
|
+
/>
|
|
167
|
+
{state.storage !== undefined && (
|
|
168
|
+
<div className="dsh-atb-storage-meta">
|
|
169
|
+
<span>{t('set.storage.current', { path: state.storage.currentDirectory })}</span>
|
|
170
|
+
<span>{t('set.storage.assets', { count: state.storage.assetCount, size: (state.storage.assetBytes / 1024 / 1024).toFixed(1) })}</span>
|
|
171
|
+
{state.storage.error !== undefined && <span className="dsh-atb-storage-error">{state.storage.error}</span>}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
<div className="dsh-atb-storage-actions">
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className="dsh-atb-btn"
|
|
178
|
+
disabled={state.storage === undefined || storageBusy}
|
|
179
|
+
onClick={() => { setStoragePath(state.storage?.defaultDirectory ?? ''); setStorageTouched(true) }}
|
|
180
|
+
>
|
|
181
|
+
{t('set.storage.default')}
|
|
182
|
+
</button>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className="dsh-atb-btn"
|
|
186
|
+
disabled={state.storage === undefined || storageBusy || effectiveStoragePath.length === 0}
|
|
187
|
+
onClick={() => {
|
|
188
|
+
setStorageBusy(true)
|
|
189
|
+
void controller.checkStorage(effectiveStoragePath).finally(() => setStorageBusy(false))
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
{t('set.storage.check')}
|
|
193
|
+
</button>
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
className="dsh-atb-btn"
|
|
197
|
+
data-primary="true"
|
|
198
|
+
disabled={!storageDirty || storageBusy}
|
|
199
|
+
onClick={() => {
|
|
200
|
+
if (!window.confirm(t('set.storage.confirm', { from: state.storage?.currentDirectory ?? '', to: effectiveStoragePath }))) return
|
|
201
|
+
setStorageBusy(true)
|
|
202
|
+
void controller.migrateStorage(effectiveStoragePath).then(ok => {
|
|
203
|
+
if (ok) { setStorageTouched(false); setStoragePath(effectiveStoragePath) }
|
|
204
|
+
}).finally(() => setStorageBusy(false))
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
{storageBusy ? t('set.storage.migrating') : t('set.storage.migrate')}
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
</section>
|
|
148
211
|
</div>
|
|
149
212
|
|
|
150
213
|
<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"
|
|
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
|
-
|
|
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>
|