@linxin666/dsh-client-ui-task-board 0.3.5 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -4
  3. package/README.zh.md +9 -4
  4. package/lib/client.js +947 -354
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +712 -180
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
  8. package/lib/types/client/board/TaskBoard.d.ts +3 -0
  9. package/lib/types/client/board/TaskForm.d.ts +28 -0
  10. package/lib/types/client/host-api.d.ts +2 -2
  11. package/lib/types/client/index.d.ts +10 -2
  12. package/lib/types/client/locales.d.ts +19 -0
  13. package/lib/types/client/settings-form.d.ts +35 -43
  14. package/lib/types/core/controller.d.ts +11 -2
  15. package/lib/types/core/freeze-snapshot.d.ts +71 -0
  16. package/lib/types/core/handover.d.ts +58 -0
  17. package/lib/types/core/tasks.d.ts +70 -1
  18. package/lib/types/core/use-cases/task-update.d.ts +16 -7
  19. package/lib/types/host-ledger.d.ts +17 -2
  20. package/lib/types/host-runner.d.ts +44 -17
  21. package/lib/types/host-service.d.ts +8 -5
  22. package/lib/types/index.d.ts +9 -1
  23. package/lib/types/protocol.d.ts +15 -2
  24. package/package.json +26 -17
  25. package/src/client/TaskBoardSettingsCard.tsx +2 -1
  26. package/src/client/board/EditTaskModal.tsx +19 -55
  27. package/src/client/board/NewTaskModal.tsx +59 -42
  28. package/src/client/board/TaskBoard.tsx +5 -3
  29. package/src/client/board/TaskCard.tsx +3 -0
  30. package/src/client/board/TaskDetail.tsx +54 -0
  31. package/src/client/board/TaskForm.tsx +111 -0
  32. package/src/client/board-mount.tsx +7 -1
  33. package/src/client/host-api.ts +5 -5
  34. package/src/client/index.ts +67 -7
  35. package/src/client/locales.ts +40 -2
  36. package/src/client/settings-form.ts +78 -92
  37. package/src/core/controller.ts +36 -16
  38. package/src/core/freeze-snapshot.ts +213 -0
  39. package/src/core/handover.ts +117 -0
  40. package/src/core/store.ts +48 -1
  41. package/src/core/tasks.ts +87 -0
  42. package/src/core/use-cases/task-update.ts +30 -10
  43. package/src/dsh-home.ts +1 -1
  44. package/src/host-ledger.ts +137 -59
  45. package/src/host-routes.ts +1 -1
  46. package/src/host-runner.ts +223 -83
  47. package/src/host-service.ts +14 -10
  48. package/src/index.ts +24 -10
  49. package/src/protocol.ts +98 -14
@@ -1,13 +1,25 @@
1
- import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
1
+ import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway';
2
+ import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/types';
2
3
  import type { CommandResult } from '@deepseek-ai/dsh-commands/types';
4
+ import type { Workspace } from '@deepseek-ai/dsh-workspace/types';
3
5
  import type { TaskRecord } from './core/tasks.ts';
4
- /** One session-list row, extracted from the sessions.list RPC result. */
5
- export type SessionSummary = Extract<Awaited<ReturnType<ApiProxy['sessions']['list']>>['result'], {
6
- ok: true;
7
- }>['value']['items'][number];
8
- type ExecutionSessionId = Extract<Awaited<ReturnType<ApiProxy['sessions']['create']>>['result'], {
9
- ok: true;
10
- }>['value']['sessionId'];
6
+ /** Host services needed to validate a task's workspace before creating a session. */
7
+ export interface TaskBoardWorkspaceRegistry {
8
+ list(): readonly Workspace[];
9
+ }
10
+ interface GatewayRequest {
11
+ namespace: string;
12
+ method: string;
13
+ args: Record<string, unknown>;
14
+ signal?: AbortSignal;
15
+ }
16
+ interface SessionGateway {
17
+ invoke(request: GatewayRequest): Promise<unknown>;
18
+ stream?(request: GatewayRequest): Promise<AsyncIterable<unknown>>;
19
+ }
20
+ /** One session-list row consumed by task-board reconciliation. */
21
+ export type { SessionSummary };
22
+ type ExecutionSessionId = SessionSummary['sessionId'];
11
23
  export interface SessionCommandDispatcher {
12
24
  execute(sessionId: ExecutionSessionId, line: string, signal: AbortSignal): Promise<CommandResult | undefined>;
13
25
  }
@@ -27,10 +39,31 @@ export declare class SessionLaunchError extends Error {
27
39
  readonly sessionId: string;
28
40
  constructor(sessionId: string, cause: unknown);
29
41
  }
42
+ /**
43
+ * Compose the execution prompt (issue #6): a continuation card (one carrying
44
+ * a frozen snapshot) has its instruction mandatorily wrapped in a source
45
+ * declaration (freeze instant, source session, unreviewed-content warning)
46
+ * templated by the board, so the picking-up agent stays wary of stored
47
+ * prompt-instruction injection in card text (adversarial scenario c). The
48
+ * wrap composes with the T4 handover preamble: the reference preamble comes
49
+ * first, the provenance wrap then encloses the instruction. Plain tasks (no
50
+ * freeze) keep the bare handover preamble + prompt.
51
+ */
52
+ export declare function promptText(task: TaskRecord): string;
30
53
  export declare class HostExecutionRunner {
31
- private readonly api;
54
+ private readonly gateway;
32
55
  private readonly commands?;
33
- constructor(api: ApiProxy, commands?: SessionCommandDispatcher | undefined);
56
+ private readonly workspaceRegistry?;
57
+ /** Newest scanned event sequence per session with no matching execution end. */
58
+ private readonly scanMemos;
59
+ private readonly unavailableAttempts;
60
+ private readonly unavailableBackoffMs;
61
+ constructor(gateway: SessionGateway | TypertGateway, commands?: SessionCommandDispatcher | undefined, workspaceRegistry?: TaskBoardWorkspaceRegistry | undefined, unavailableRetry?: {
62
+ attempts?: number;
63
+ backoffMs?: number;
64
+ });
65
+ private invoke;
66
+ private stream;
34
67
  launch(task: TaskRecord): Promise<string>;
35
68
  listRunning(): Promise<{
36
69
  known: true;
@@ -39,12 +72,6 @@ export declare class HostExecutionRunner {
39
72
  } | {
40
73
  known: false;
41
74
  }>;
42
- /**
43
- * Resolve one execution's outcome. The caller may pass the session list it
44
- * already fetched this poll tick; otherwise inspect lists sessions itself.
45
- * Sharing the list keeps a poll with E open executions at one list RPC
46
- * instead of 1 + E.
47
- */
75
+ /** Resolve an execution outcome from the session list and bounded history pages. */
48
76
  inspect(sessionId: string, startedAt?: number, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection>;
49
77
  }
50
- export {};
@@ -1,8 +1,9 @@
1
- import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
1
+ import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway';
2
2
  import { HostTaskLedger } from './host-ledger.ts';
3
- import { HostExecutionRunner, type SessionCommandDispatcher } from './host-runner.ts';
3
+ import { HostExecutionRunner, type SessionCommandDispatcher, type TaskBoardWorkspaceRegistry } from './host-runner.ts';
4
4
  import { PowerInhibitor } from './power-inhibitor.ts';
5
- import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts';
5
+ import { type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts';
6
+ import type { TaskPermission } from './core/handover.ts';
6
7
  export declare class TaskBoardHostService {
7
8
  readonly ledger: HostTaskLedger;
8
9
  readonly runner: HostExecutionRunner;
@@ -17,11 +18,13 @@ export declare class TaskBoardHostService {
17
18
  private preventIdleSleep;
18
19
  private lastPowerJson;
19
20
  private readonly now;
20
- constructor(api: ApiProxy, options?: {
21
+ constructor(gateway: TypertGateway, options?: {
21
22
  ledger?: HostTaskLedger;
22
23
  power?: PowerInhibitor;
23
24
  now?: () => number;
24
25
  commandDispatcher?: SessionCommandDispatcher;
26
+ workspaceRegistry?: TaskBoardWorkspaceRegistry;
27
+ sessionDefaultPermission?: TaskPermission;
25
28
  });
26
29
  start(): void;
27
30
  setConfiguration(active: boolean, preventIdleSleep: boolean): void;
@@ -29,7 +32,7 @@ export declare class TaskBoardHostService {
29
32
  /** SSE frame payload; deliberately skips the tasks deep-clone of {@link snapshot}. */
30
33
  eventPayload(): TaskBoardEventPayload;
31
34
  subscribe(listener: () => void): () => void;
32
- apply(requestId: string, action: TaskBoardAction): TaskBoardSnapshot;
35
+ apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot;
33
36
  dispose(): void;
34
37
  private launch;
35
38
  private pollSessions;
@@ -6,7 +6,9 @@
6
6
  * a same-origin asynchronous view over that service.
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis';
9
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
9
10
  import z from 'schemastery';
11
+ import { type TaskPermission } from './core/tasks.ts';
10
12
  /** Default environment variable holding the authenticated proxy token. */
11
13
  export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
12
14
  export declare const inject: string[];
@@ -17,7 +19,7 @@ export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-t
17
19
  * web settings surface edits. Spelled here rather than imported: the browser
18
20
  * half spells the same value and must not depend on a Host package.
19
21
  */
20
- export declare const TASK_BOARD_SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").SettingsNamespace;
22
+ export declare const TASK_BOARD_SETTINGS_NAMESPACE: SettingsNamespace;
21
23
  /** Plugin config, validated by the same-named schemastery schema. */
22
24
  export interface Config {
23
25
  /**
@@ -34,6 +36,12 @@ export interface Config {
34
36
  trustedProxyHosts?: string[];
35
37
  /** Environment variable whose value the authenticated proxy injects upstream. */
36
38
  proxyTokenEnv?: string;
39
+ /**
40
+ * The deployment's session-default permission. A card whose effective
41
+ * permission (handover bundle or pin) is above this value requires a human
42
+ * confirmation before it may run; cron refuses unconfirmed cards.
43
+ */
44
+ sessionDefaultPermission?: TaskPermission;
37
45
  }
38
46
  export declare const Config: z<Config>;
39
47
  /** Resolve proxy access without ever placing the token value in plugin config. */
@@ -1,6 +1,8 @@
1
1
  import type { TaskUpdatePatch } from './core/use-cases/task-update.ts';
2
- import { type NewTaskInput, type TaskRecord, type TaskStatus } from './core/tasks.ts';
3
- export declare const TASK_BOARD_SCHEMA_VERSION: 2;
2
+ import { type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts';
3
+ export declare const TASK_BOARD_SCHEMA_VERSION: 3;
4
+ /** Ledger documents written before v3; loaded once and migrated on startup. */
5
+ export declare const TASK_BOARD_LEGACY_SCHEMA_VERSION: 2;
4
6
  export declare const TASK_BOARD_API_PREFIX = "/api/task-board";
5
7
  export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported';
6
8
  export interface TaskBoardPowerSnapshot {
@@ -25,6 +27,8 @@ export interface TaskBoardSnapshot {
25
27
  tasks: TaskRecord[];
26
28
  scheduler: TaskBoardSchedulerSnapshot;
27
29
  power: TaskBoardPowerSnapshot;
30
+ /** Session-default permission the confirmation gate compares against. */
31
+ sessionDefaultPermission?: TaskPermission;
28
32
  }
29
33
  /** SSE event frame: revision/scheduler/power only, never the task list. */
30
34
  export interface TaskBoardEventPayload {
@@ -70,9 +74,18 @@ export type TaskBoardAction = {
70
74
  } | {
71
75
  kind: 'rerun';
72
76
  taskId: string;
77
+ } | {
78
+ kind: 'confirm-permission';
79
+ taskId: string;
73
80
  };
74
81
  export interface TaskBoardActionEnvelope {
75
82
  requestId: string;
76
83
  action: TaskBoardAction;
84
+ /**
85
+ * Session id of the DSH session issuing the action, for the execution audit
86
+ * trail (issue #6). Client-asserted, not a trust boundary; parsed only as a
87
+ * bounded non-empty string and recorded on opened executions / freeze stamps.
88
+ */
89
+ initiator?: string;
77
90
  }
78
91
  export declare function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.3.5",
4
+ "version": "0.3.9",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -23,16 +23,19 @@
23
23
  },
24
24
  "dsh": {
25
25
  "engines": {
26
- "dsh": ">=0.1.1-rc.1"
26
+ "dsh": ">=0.1.2-alpha.1"
27
27
  },
28
28
  "bundle": {
29
29
  "patch": "./cordis.patch.yml"
30
30
  },
31
31
  "client": {
32
32
  "inject": [
33
- "@deepseek-ai/dsh-client-runtime",
34
33
  "@deepseek-ai/dsh-client-connection",
35
- "@deepseek-ai/dsh-client-ui-settings"
34
+ "@deepseek-ai/dsh-client-ui-settings",
35
+ "@deepseek-ai/dsh-client-ui-renderer",
36
+ "@deepseek-ai/dsh-api-session-controller",
37
+ "@deepseek-ai/dsh-api-workspace-controller",
38
+ "@deepseek-ai/dsh-api-remotes"
36
39
  ],
37
40
  "platform": "web"
38
41
  }
@@ -44,21 +47,27 @@
44
47
  "schemastery": "^3.18.0"
45
48
  },
46
49
  "devDependencies": {
47
- "@deepseek-ai/cordis": "^4.0.1",
48
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
49
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
50
- "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
51
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
52
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
53
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
54
- "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
55
- "@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
56
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
57
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
58
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
50
+ "@deepseek-ai/cordis": "^4.0.2",
51
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
52
+ "@deepseek-ai/dsh-agent-presets": "^0.1.2-alpha.2",
53
+ "@deepseek-ai/dsh-api-gateway": "^0.1.2-alpha.2",
54
+ "@deepseek-ai/dsh-api-remotes": "^0.1.2-alpha.2",
55
+ "@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
56
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.2-alpha.2",
57
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-alpha.2",
58
+ "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.2",
59
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
60
+ "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.2",
61
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.2",
62
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
63
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
64
+ "@deepseek-ai/dsh-commands": "^0.1.2-alpha.2",
65
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
66
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
67
+ "@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2",
68
+ "@types/node": "^22.20.0",
59
69
  "@types/react": "~18.3.1",
60
70
  "@types/react-dom": "^18.3.5",
61
- "@types/node": "^22.20.0",
62
71
  "jsdom": "^25.0.0",
63
72
  "react": "^18.3.1",
64
73
  "react-dom": "^18.3.1",
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
8
- import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
8
+ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
9
+ import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
9
10
  import { useEffect, useState } from 'react'
10
11
  import type { TaskBoardPowerSnapshot } from '../protocol.ts'
11
12
  import { PluginSettingsCard, BooleanField } from './PluginSettingsCard.tsx'
@@ -9,7 +9,7 @@ import { useState } from 'react'
9
9
  import type { BoardController } from '../../core/controller.ts'
10
10
  import type { TaskRecord } from '../../core/tasks.ts'
11
11
  import { t } from '../locales.ts'
12
- import css from '../board.module.css'
12
+ import { ModalShell, TaskContentFields } from './TaskForm.tsx'
13
13
 
14
14
  /** Edit-task form overlay. */
15
15
  export function EditTaskModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
@@ -36,59 +36,23 @@ export function EditTaskModal({ controller, task, onClose }: { controller: Board
36
36
  }
37
37
 
38
38
  return (
39
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
40
- <form
41
- className={css.modal}
42
- role="dialog"
43
- aria-label={t('edit.title')}
44
- onSubmit={event => { event.preventDefault(); void submit() }}
45
- >
46
- <h2 className={css.modalTitle}>{t('edit.title')}</h2>
47
-
48
- <label className={css.field}>
49
- <span className={css.fieldLabel}>{t('new.title')}</span>
50
- <input
51
- className={css.input}
52
- value={title}
53
- autoFocus
54
- placeholder={t('new.titlePlaceholder')}
55
- onChange={event => { setTitle(event.target.value); setError(undefined) }}
56
- />
57
- </label>
58
-
59
- <label className={css.field}>
60
- <span className={css.fieldLabel}>{t('new.description')}</span>
61
- <textarea
62
- className={css.input}
63
- rows={3}
64
- value={description}
65
- placeholder={t('new.descriptionPlaceholder')}
66
- onChange={event => { setDescription(event.target.value) }}
67
- />
68
- </label>
69
-
70
- <label className={css.field}>
71
- <span className={css.fieldLabel}>{t('new.prompt')}</span>
72
- <textarea
73
- className={css.input}
74
- rows={4}
75
- value={prompt}
76
- placeholder={t('new.promptPlaceholder')}
77
- onChange={event => { setPrompt(event.target.value) }}
78
- />
79
- </label>
80
-
81
- {error !== undefined && <p className={css.formError}>{error}</p>}
82
-
83
- <footer className={css.modalFooter}>
84
- <button type="button" className={css.ghostButton} onClick={onClose}>
85
- {t('new.cancel')}
86
- </button>
87
- <button type="submit" className={css.primaryButton} disabled={pending}>
88
- {t('edit.save')}
89
- </button>
90
- </footer>
91
- </form>
92
- </div>
39
+ <ModalShell
40
+ ariaLabel={t('edit.title')}
41
+ title={t('edit.title')}
42
+ error={error}
43
+ pending={pending}
44
+ submitLabel={t('edit.save')}
45
+ onSubmit={() => { void submit() }}
46
+ onClose={onClose}
47
+ >
48
+ <TaskContentFields
49
+ title={title}
50
+ description={description}
51
+ prompt={prompt}
52
+ onTitleChange={value => { setTitle(value); setError(undefined) }}
53
+ onDescriptionChange={setDescription}
54
+ onPromptChange={setPrompt}
55
+ />
56
+ </ModalShell>
93
57
  )
94
58
  }
@@ -5,9 +5,11 @@
5
5
  import { useEffect, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
7
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
+ import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
8
9
  import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
9
10
  import { t, type TaskBoardKey } from '../locales.ts'
10
11
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
12
+ import { ModalShell, TaskContentFields } from './TaskForm.tsx'
11
13
  import css from '../board.module.css'
12
14
 
13
15
  /** New-task form overlay. */
@@ -21,6 +23,9 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
21
23
  const [scheduleEnabled, setScheduleEnabled] = useState(false)
22
24
  const [scheduleCron, setScheduleCron] = useState('')
23
25
  const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
26
+ const [freezeText, setFreezeText] = useState('')
27
+ const [freezeError, setFreezeError] = useState<string | undefined>(undefined)
28
+ const [handoverText, setHandoverText] = useState('')
24
29
  const [error, setError] = useState<string | undefined>(undefined)
25
30
  const [pending, setPending] = useState(false)
26
31
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
@@ -40,11 +45,34 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
40
45
  return
41
46
  }
42
47
  }
48
+ // Optional continuation-card snapshot: parse the freeze block through the
49
+ // T2 gate (structure + redaction + taint + size); a malformed block stops
50
+ // submission with the parser's error instead of creating a plain task.
51
+ let freeze: Parameters<typeof controller.createTaskConfirmed>[0]['freeze'] = undefined
52
+ if (freezeText.trim() !== '') {
53
+ const parsed = parseFreezeRequest(freezeText)
54
+ if (!parsed.ok) {
55
+ setFreezeError(parsed.error.message)
56
+ return
57
+ }
58
+ freeze = { ...parsed.snapshot, ...(parsed.warnings.includes('redacted') ? { redacted: true } : {}) }
59
+ }
60
+ // Optional handover bundle: non-empty reference lines attach the picked
61
+ // triplet (workspace/mode/permission above) plus the references.
62
+ const references = handoverText.split('\n').map(line => line.trim()).filter(line => line !== '')
63
+ const handover = references.length === 0 ? undefined : {
64
+ references,
65
+ workspaceId: workspaceId === '' ? undefined : workspaceId,
66
+ mode: mode === '' ? undefined : mode,
67
+ permission: permission === '' ? undefined : permission as TaskPermission,
68
+ }
43
69
  setPending(true)
44
70
  const task = await controller.createTaskConfirmed({
45
71
  title,
46
72
  description,
47
73
  prompt,
74
+ freeze,
75
+ handover,
48
76
  workspaceId: workspaceId === '' ? undefined : workspaceId,
49
77
  mode: mode === '' ? undefined : mode,
50
78
  permission: permission === '' ? undefined : permission as TaskPermission,
@@ -64,45 +92,46 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
64
92
  : undefined
65
93
 
66
94
  return (
67
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
68
- <form
69
- className={css.modal}
70
- role="dialog"
71
- aria-label={t('board.new')}
72
- onSubmit={event => { event.preventDefault(); void submit() }}
73
- >
74
- <h2 className={css.modalTitle}>{t('board.new')}</h2>
75
-
76
- <label className={css.field}>
77
- <span className={css.fieldLabel}>{t('new.title')}</span>
78
- <input
79
- className={css.input}
80
- value={title}
81
- autoFocus
82
- placeholder={t('new.titlePlaceholder')}
83
- onChange={event => { setTitle(event.target.value); setError(undefined) }}
84
- />
85
- </label>
95
+ <ModalShell
96
+ ariaLabel={t('board.new')}
97
+ title={t('board.new')}
98
+ error={error}
99
+ pending={pending}
100
+ submitLabel={t('new.submit')}
101
+ onSubmit={() => { void submit() }}
102
+ onClose={onClose}
103
+ >
104
+ <TaskContentFields
105
+ title={title}
106
+ description={description}
107
+ prompt={prompt}
108
+ onTitleChange={value => { setTitle(value); setError(undefined) }}
109
+ onDescriptionChange={setDescription}
110
+ onPromptChange={setPrompt}
111
+ />
86
112
 
87
113
  <label className={css.field}>
88
- <span className={css.fieldLabel}>{t('new.description')}</span>
114
+ <span className={css.fieldLabel}>{t('new.freeze')}</span>
89
115
  <textarea
90
116
  className={css.input}
91
- rows={3}
92
- value={description}
93
- placeholder={t('new.descriptionPlaceholder')}
94
- onChange={event => { setDescription(event.target.value) }}
117
+ rows={4}
118
+ value={freezeText}
119
+ placeholder={t('new.freezePlaceholder')}
120
+ spellCheck={false}
121
+ onChange={event => { setFreezeText(event.target.value); setFreezeError(undefined) }}
95
122
  />
96
123
  </label>
124
+ {freezeError !== undefined && <p className={css.formError}>{freezeError}</p>}
97
125
 
98
126
  <label className={css.field}>
99
- <span className={css.fieldLabel}>{t('new.prompt')}</span>
127
+ <span className={css.fieldLabel}>{t('new.handover')}</span>
100
128
  <textarea
101
129
  className={css.input}
102
- rows={4}
103
- value={prompt}
104
- placeholder={t('new.promptPlaceholder')}
105
- onChange={event => { setPrompt(event.target.value) }}
130
+ rows={3}
131
+ value={handoverText}
132
+ placeholder={t('new.handoverPlaceholder')}
133
+ spellCheck={false}
134
+ onChange={event => { setHandoverText(event.target.value) }}
106
135
  />
107
136
  </label>
108
137
 
@@ -201,18 +230,6 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
201
230
  </>
202
231
  )}
203
232
  </section>
204
-
205
- {error !== undefined && <p className={css.formError}>{error}</p>}
206
-
207
- <footer className={css.modalFooter}>
208
- <button type="button" className={css.ghostButton} onClick={onClose}>
209
- {t('new.cancel')}
210
- </button>
211
- <button type="submit" className={css.primaryButton} disabled={pending}>
212
- {t('new.submit')}
213
- </button>
214
- </footer>
215
- </form>
216
- </div>
233
+ </ModalShell>
217
234
  )
218
235
  }
@@ -13,11 +13,13 @@ import { STATUS_KEY } from './status-key.ts'
13
13
  import { TaskCard } from './TaskCard.tsx'
14
14
  import { TaskDetail } from './TaskDetail.tsx'
15
15
 
16
- /** Case-insensitive title/description match. */
17
- function matchesFilter(task: TaskRecord, filter: string): boolean {
16
+ /** Case-insensitive title/description/freeze-snapshot match. */
17
+ export function matchesFilter(task: TaskRecord, filter: string): boolean {
18
18
  if (filter.trim() === '') return true
19
19
  const needle = filter.trim().toLowerCase()
20
- return task.title.toLowerCase().includes(needle) || task.description.toLowerCase().includes(needle)
20
+ const haystacks = [task.title, task.description]
21
+ if (task.freeze !== undefined) haystacks.push(task.freeze.goal, task.freeze.progress, task.freeze.next)
22
+ return haystacks.some(text => text.toLowerCase().includes(needle))
21
23
  }
22
24
 
23
25
  /**
@@ -62,6 +62,9 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
62
62
  {task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
63
63
  <span className={css.cardMeta}>
64
64
  <span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
65
+ {task.freeze !== undefined && (
66
+ <span className={css.cardSchedule} title={task.freeze.goal}>{t('card.frozen')}</span>
67
+ )}
65
68
  {!archived && task.schedule?.enabled === true && (
66
69
  <span
67
70
  className={css.cardSchedule}
@@ -9,6 +9,7 @@ import type { BoardController } from '../../core/controller.ts'
9
9
  import { isValidCron } from '../../core/schedule.ts'
10
10
  import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
11
11
  import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
12
+ import { requiresPermissionConfirmation } from '../../core/handover.ts'
12
13
  import { t, type TaskBoardKey } from '../locales.ts'
13
14
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
14
15
  import css from '../board.module.css'
@@ -36,6 +37,11 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
36
37
  {t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
37
38
  {execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
38
39
  </span>
40
+ {execution.initiatedBy !== undefined && (
41
+ <span className={css.executionTimes} title={execution.initiatedBy}>
42
+ {t('detail.execution.initiator', { session: execution.initiatedBy })}
43
+ </span>
44
+ )}
39
45
  {execution.sessionId !== undefined && (
40
46
  <button
41
47
  type="button"
@@ -248,6 +254,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
248
254
  const pending = snapshot.pendingTaskIds.includes(current.id)
249
255
  const transportError = snapshot.transportError
250
256
  const timeZone = snapshot.host?.scheduler.timeZone
257
+ const permissionPending = requiresPermissionConfirmation(current, snapshot.host?.sessionDefaultPermission)
251
258
 
252
259
  return (
253
260
  <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
@@ -281,6 +288,53 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
281
288
  <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
282
289
  </section>
283
290
 
291
+ {current.freeze !== undefined && (
292
+ <section className={css.detailSection} data-dsh-part="freeze">
293
+ <h4>{t('detail.freeze')}</h4>
294
+ {current.freeze.redacted === true && <p className={css.formError}>{t('detail.freeze.redacted')}</p>}
295
+ <p className={css.detailText}><strong>{t('detail.freeze.goal')}</strong></p>
296
+ <pre className={css.promptBlock}>{current.freeze.goal}</pre>
297
+ <p className={css.detailText}><strong>{t('detail.freeze.progress')}</strong></p>
298
+ <pre className={css.promptBlock}>{current.freeze.progress}</pre>
299
+ <p className={css.detailText}><strong>{t('detail.freeze.next')}</strong></p>
300
+ <pre className={css.promptBlock}>{current.freeze.next}</pre>
301
+ <p className={css.detailMeta}>{t('detail.freeze.frozenAt', { time: formatHostTimestamp(current.freeze.frozenAt, timeZone) })}</p>
302
+ {current.freeze.frozenBy !== undefined && (
303
+ <p className={css.detailMeta}>{t('detail.freeze.frozenBy', { session: current.freeze.frozenBy })}</p>
304
+ )}
305
+ </section>
306
+ )}
307
+
308
+ {current.handover !== undefined && (
309
+ <section className={css.detailSection} data-dsh-part="handover">
310
+ <h4>{t('detail.handover')}</h4>
311
+ <p className={css.detailText}>
312
+ {t('new.workspace')}: {current.handover.workspaceId ?? t('exec.workspace.recent')}
313
+ {' · '}{t('new.mode')}: {current.handover.mode ?? t('exec.mode.default')}
314
+ {' · '}{t('new.permission')}: {current.handover.permission === undefined ? t('exec.permission.default') : t(`exec.permission.${current.handover.permission}` as TaskBoardKey)}
315
+ </p>
316
+ <p className={css.detailText}><strong>{t('detail.handover.references')}</strong></p>
317
+ <ul className={css.executionList}>
318
+ {current.handover.references.map(reference => (
319
+ <li key={reference} className={css.executionRow}><code>{reference}</code></li>
320
+ ))}
321
+ </ul>
322
+ <p className={css.detailMeta}>{t('detail.handover.bundledAt', { time: formatHostTimestamp(current.handover.bundledAt, timeZone) })}</p>
323
+ </section>
324
+ )}
325
+
326
+ {permissionPending && (
327
+ <section className={css.detailSection} data-dsh-part="permission-gate">
328
+ <p className={css.formError}>{t('detail.permissionPending', { permission: t(`exec.permission.${current.handover?.permission ?? current.permission}` as TaskBoardKey) })}</p>
329
+ <button type="button" className={css.primaryButton} disabled={pending} onClick={() => { void controller.confirmPermission(current.id) }}>
330
+ {t('detail.permissionConfirm')}
331
+ </button>
332
+ </section>
333
+ )}
334
+ {current.permissionConfirmedAt !== undefined && (
335
+ <p className={css.detailMeta}>{t('detail.permissionConfirmed', { time: formatHostTimestamp(current.permissionConfirmedAt, timeZone) })}</p>
336
+ )}
337
+
284
338
  <section className={css.detailSection}>
285
339
  <h4>{t('detail.prompt')}</h4>
286
340
  <pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>