@linxin666/dsh-client-ui-task-board 0.3.17 → 0.3.18

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/lib/index.js CHANGED
@@ -174,6 +174,29 @@ function isDigits(value) {
174
174
  return /^\d+$/.test(value);
175
175
  }
176
176
  //#endregion
177
+ //#region src/core/session-reuse.ts
178
+ /**
179
+ * Pick the session a new execution of this task may continue in, or undefined
180
+ * to mint a fresh conversation. Reuse requires ALL of:
181
+ * - the task opted in (`reuseSession === true`);
182
+ * - the task's newest execution carries a session id and has settled (an open
183
+ * execution would have made the ledger refuse the run in the first place);
184
+ * - the roster is known and that session is present and idle.
185
+ *
186
+ * An unknown roster (session/list unavailable) never reuses: minting a fresh
187
+ * conversation is always safe, prompting into a session we cannot see is not.
188
+ * @param task - the task about to run.
189
+ * @param idleSessionIds - ids the last roster saw as present and not running;
190
+ * undefined when that roster is unknown.
191
+ * @returns the session id to continue in, or undefined for a fresh session.
192
+ */
193
+ function reusableSessionId(task, idleSessionIds) {
194
+ if (task.reuseSession !== true || idleSessionIds === void 0) return void 0;
195
+ const last = task.executions[task.executions.length - 1];
196
+ if (last === void 0 || last.sessionId === void 0 || last.endedAt === void 0) return void 0;
197
+ return idleSessionIds.has(last.sessionId) ? last.sessionId : void 0;
198
+ }
199
+ //#endregion
177
200
  //#region src/dsh-home.ts
178
201
  /**
179
202
  * DSH_HOME resolution shared by the plugin family's Host halves: the
@@ -285,6 +308,7 @@ function createTask(input, now, id) {
285
308
  mode: normalizeTargetId(input.mode),
286
309
  permission: isTaskPermission(input.permission) ? input.permission : void 0,
287
310
  model: normalizeTargetId(input.model),
311
+ reuseSession: input.reuseSession === true ? true : void 0,
288
312
  ...input.freeze === void 0 ? {} : { freeze: freezeOf(input.freeze, now) },
289
313
  ...input.handover === void 0 ? {} : { handover: {
290
314
  ...input.handover,
@@ -582,6 +606,7 @@ function isTaskRecordShape(value) {
582
606
  if (record.workspaceId !== void 0 && typeof record.workspaceId !== "string") return false;
583
607
  if (record.mode !== void 0 && typeof record.mode !== "string") return false;
584
608
  if (record.permission !== void 0 && typeof record.permission !== "string") return false;
609
+ if (record.reuseSession !== void 0 && typeof record.reuseSession !== "boolean") return false;
585
610
  if (!Array.isArray(record.executions)) return false;
586
611
  for (const execution of record.executions) {
587
612
  if (typeof execution !== "object" || execution === null) return false;
@@ -695,6 +720,7 @@ function parseLedger(raw) {
695
720
  task.mode = normalizeTargetId(row.mode);
696
721
  task.archivedAt = typeof row.archivedAt === "number" && Number.isFinite(row.archivedAt) ? row.archivedAt : void 0;
697
722
  task.permission = isTaskPermission(row.permission) ? row.permission : void 0;
723
+ task.reuseSession = row.reuseSession === true ? true : void 0;
698
724
  task.freeze = normalizeFreeze(row.freeze);
699
725
  task.handover = normalizeHandover(row.handover);
700
726
  task.permissionConfirmedAt = typeof row.permissionConfirmedAt === "number" && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : void 0;
@@ -929,6 +955,7 @@ function applyUpdateTask(tasks, id, patch, now) {
929
955
  bundledAt: now
930
956
  };
931
957
  if ("permission" in patch && patch.permission !== void 0 && patch.permission !== task.permission || "handover" in patch) next.permissionConfirmedAt = void 0;
958
+ if ("reuseSession" in patch) next.reuseSession = patch.reuseSession === true ? true : void 0;
932
959
  if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
933
960
  if (mode !== void 0 || "mode" in patch) next.mode = mode;
934
961
  if (permission !== void 0 || "permission" in patch) next.permission = permission;
@@ -1022,6 +1049,7 @@ function importedTask(value) {
1022
1049
  ...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
1023
1050
  ...task.mode === void 0 ? {} : { mode: task.mode },
1024
1051
  ...task.permission === void 0 ? {} : { permission: task.permission },
1052
+ ...task.reuseSession === void 0 ? {} : { reuseSession: task.reuseSession },
1025
1053
  ...task.archivedAt === void 0 ? {} : { archivedAt: task.archivedAt },
1026
1054
  ...task.freeze === void 0 ? {} : { freeze: task.freeze },
1027
1055
  ...task.handover === void 0 ? {} : { handover: task.handover }
@@ -1063,10 +1091,12 @@ function createInput(value) {
1063
1091
  "schedule",
1064
1092
  "freeze",
1065
1093
  "handover",
1066
- "model"
1094
+ "model",
1095
+ "reuseSession"
1067
1096
  ])) return false;
1068
1097
  if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return false;
1069
1098
  if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false;
1099
+ if (input.reuseSession !== void 0 && typeof input.reuseSession !== "boolean") return false;
1070
1100
  if (input.permission !== void 0 && !isTaskPermission(input.permission)) return false;
1071
1101
  if (input.freeze !== void 0 && freezePayload(input.freeze) === void 0) return false;
1072
1102
  if (input.handover !== void 0 && handoverPayload(input.handover) === void 0) return false;
@@ -1088,8 +1118,10 @@ function updatePatch(value) {
1088
1118
  "permission",
1089
1119
  "freeze",
1090
1120
  "handover",
1091
- "model"
1121
+ "model",
1122
+ "reuseSession"
1092
1123
  ])) return false;
1124
+ if (patch.reuseSession !== void 0 && patch.reuseSession !== null && typeof patch.reuseSession !== "boolean") return false;
1093
1125
  for (const key of [
1094
1126
  "title",
1095
1127
  "description",
@@ -2128,7 +2160,17 @@ var HostExecutionRunner = class {
2128
2160
  ...signal === void 0 ? {} : { signal }
2129
2161
  });
2130
2162
  }
2131
- async launch(task) {
2163
+ /**
2164
+ * Launch one execution. Without `options.reuseSessionId` a fresh session is
2165
+ * created, renamed, pinned, and prompted (the historical contract). With it,
2166
+ * the run continues in that existing session (issue #1419): the conversation
2167
+ * keeps its title and history, the pinned permission/model are re-asserted so
2168
+ * the task's execution contract still holds, and the prompt is queued.
2169
+ * @param task - the task to run.
2170
+ * @param options - optional session to continue in.
2171
+ * @returns the session id the execution runs in.
2172
+ */
2173
+ async launch(task, options = {}) {
2132
2174
  const workspaceId = task.handover?.workspaceId ?? task.workspaceId;
2133
2175
  const mode = task.handover?.mode ?? task.mode;
2134
2176
  const permission = task.handover?.permission ?? task.permission;
@@ -2140,6 +2182,15 @@ var HostExecutionRunner = class {
2140
2182
  if (preset === void 0) throw new Error("agent preset not found: " + mode);
2141
2183
  if (preset.broken !== void 0) throw new Error("agent preset is unavailable: " + preset.broken);
2142
2184
  }
2185
+ const reused = options.reuseSessionId;
2186
+ if (reused !== void 0) {
2187
+ try {
2188
+ await this.pinAndPrompt(reused, task, permission);
2189
+ } catch (error) {
2190
+ throw new SessionLaunchError(reused, error);
2191
+ }
2192
+ return reused;
2193
+ }
2143
2194
  const sessionId = (await this.invoke("session", "create", {
2144
2195
  ...workspaceId === void 0 ? {} : { workspaceId },
2145
2196
  ...mode === void 0 ? {} : { agentPreset: mode }
@@ -2149,41 +2200,49 @@ var HostExecutionRunner = class {
2149
2200
  sessionId,
2150
2201
  title: task.title
2151
2202
  });
2152
- if (permission !== void 0) {
2153
- if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
2154
- const command = await this.commands.execute(sessionId, "/permission " + permission, AbortSignal.timeout(3e4));
2155
- if (command === void 0) throw new Error("permission command was not acknowledged");
2156
- if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
2157
- }
2158
- if (task.model !== void 0 && task.model.trim() !== "") {
2159
- const rawModel = task.model.trim();
2160
- const slashIdx = rawModel.indexOf("/");
2161
- const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : void 0;
2162
- const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel;
2163
- try {
2164
- await this.invoke("session", "selectModel", {
2165
- sessionId,
2166
- ...provider ? { provider } : {},
2167
- model: modelId
2168
- });
2169
- } catch (modelError) {
2170
- console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError);
2171
- }
2172
- }
2173
- await this.invoke("session", "prompt", {
2174
- sessionId,
2175
- requestId: "task-board-" + crypto.randomUUID(),
2176
- mode: "queue",
2177
- content: [{
2178
- type: "text",
2179
- text: promptText(task)
2180
- }]
2181
- });
2203
+ await this.pinAndPrompt(sessionId, task, permission);
2182
2204
  } catch (error) {
2183
2205
  throw new SessionLaunchError(sessionId, error);
2184
2206
  }
2185
2207
  return sessionId;
2186
2208
  }
2209
+ /**
2210
+ * Re-assert the pinned execution contract on a session and queue the task
2211
+ * prompt. Shared by the fresh-session and reuse paths so both apply exactly
2212
+ * the same permission/model pins before the prompt.
2213
+ */
2214
+ async pinAndPrompt(sessionId, task, permission) {
2215
+ if (permission !== void 0) {
2216
+ if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
2217
+ const command = await this.commands.execute(sessionId, "/permission " + permission, AbortSignal.timeout(3e4));
2218
+ if (command === void 0) throw new Error("permission command was not acknowledged");
2219
+ if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
2220
+ }
2221
+ if (task.model !== void 0 && task.model.trim() !== "") {
2222
+ const rawModel = task.model.trim();
2223
+ const slashIdx = rawModel.indexOf("/");
2224
+ const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : void 0;
2225
+ const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel;
2226
+ try {
2227
+ await this.invoke("session", "selectModel", {
2228
+ sessionId,
2229
+ ...provider ? { provider } : {},
2230
+ model: modelId
2231
+ });
2232
+ } catch (modelError) {
2233
+ console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError);
2234
+ }
2235
+ }
2236
+ await this.invoke("session", "prompt", {
2237
+ sessionId,
2238
+ requestId: "task-board-" + crypto.randomUUID(),
2239
+ mode: "queue",
2240
+ content: [{
2241
+ type: "text",
2242
+ text: promptText(task)
2243
+ }]
2244
+ });
2245
+ }
2187
2246
  async listRunning() {
2188
2247
  for (let attempt = 1;; attempt++) try {
2189
2248
  const response = await this.invoke("session", "list", {});
@@ -2617,6 +2676,13 @@ var TaskBoardHostService = class {
2617
2676
  pollInFlight = false;
2618
2677
  tickInFlight = false;
2619
2678
  active = true;
2679
+ /**
2680
+ * Ids the last roster poll saw as present and idle; undefined while the
2681
+ * roster is unknown. Session reuse (issue #1419) requires this positive
2682
+ * evidence, so a launch before the first successful poll mints a fresh
2683
+ * conversation instead of prompting into a session it cannot see.
2684
+ */
2685
+ idleSessionIds;
2620
2686
  preventIdleSleep = false;
2621
2687
  lastPowerJson = "";
2622
2688
  now;
@@ -2714,7 +2780,8 @@ var TaskBoardHostService = class {
2714
2780
  }
2715
2781
  async launch(opened) {
2716
2782
  try {
2717
- const sessionId = await this.runner.launch(opened.task);
2783
+ const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds);
2784
+ const sessionId = await this.runner.launch(opened.task, reuseSessionId === void 0 ? {} : { reuseSessionId });
2718
2785
  this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId);
2719
2786
  } catch (error) {
2720
2787
  if (error instanceof SessionLaunchError) this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId);
@@ -2727,6 +2794,7 @@ var TaskBoardHostService = class {
2727
2794
  const running = await this.runner.listRunning();
2728
2795
  const previous = this.power.snapshot();
2729
2796
  if (!running.known) {
2797
+ this.idleSessionIds = void 0;
2730
2798
  this.power.updateReasons({
2731
2799
  runningSessions: previous.runningSessions,
2732
2800
  armedSchedules: this.ledger.armedScheduleCount(),
@@ -2734,6 +2802,7 @@ var TaskBoardHostService = class {
2734
2802
  });
2735
2803
  return;
2736
2804
  }
2805
+ this.idleSessionIds = new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId));
2737
2806
  const runtime = this.ledger.runtimeView();
2738
2807
  this.power.updateReasons({
2739
2808
  runningSessions: running.count,
@@ -1,6 +1,12 @@
1
1
  import type { BoardController } from '../../core/controller.ts';
2
- /** New-task form overlay. */
3
- export declare function NewTaskModal({ controller, onClose }: {
2
+ import { type TaskRecord } from '../../core/tasks.ts';
3
+ export interface NewTaskModalProps {
4
4
  controller: BoardController;
5
5
  onClose: () => void;
6
- }): import("react").JSX.Element;
6
+ /** Optional task template to clone/duplicate from. */
7
+ initialTask?: TaskRecord;
8
+ /** Optional callback after successful duplication (e.g. to archive source). */
9
+ onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>;
10
+ }
11
+ /** New-task form overlay. */
12
+ export declare function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps): import("react").JSX.Element;
@@ -59,9 +59,13 @@ export declare const zh: {
59
59
  'detail.permissionConfirmed': string;
60
60
  'detail.title': string;
61
61
  'detail.edit': string;
62
+ 'detail.duplicate': string;
63
+ 'detail.duplicateAndEdit': string;
62
64
  'detail.close': string;
63
65
  'edit.title': string;
64
66
  'edit.save': string;
67
+ 'new.duplicateTitle': string;
68
+ 'new.archiveOriginal': string;
65
69
  'detail.prompt': string;
66
70
  'detail.description': string;
67
71
  'detail.execution': string;
@@ -120,6 +124,8 @@ export declare const zh: {
120
124
  'exec.permission.danger-full-access': string;
121
125
  'exec.model.default': string;
122
126
  'exec.model.unknown': string;
127
+ 'exec.reuseSession': string;
128
+ 'exec.reuseSessionHint': string;
123
129
  'detail.executionSettings': string;
124
130
  'exec.hint': string;
125
131
  'settings.title': string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Session-reuse rule (issue #1419): decides which task runs continue in the
3
+ * previous execution's conversation. Pure, so the fail-closed conditions stay
4
+ * unit-testable without a gateway.
5
+ */
6
+ import type { TaskRecord } from './tasks.ts';
7
+ /**
8
+ * Pick the session a new execution of this task may continue in, or undefined
9
+ * to mint a fresh conversation. Reuse requires ALL of:
10
+ * - the task opted in (`reuseSession === true`);
11
+ * - the task's newest execution carries a session id and has settled (an open
12
+ * execution would have made the ledger refuse the run in the first place);
13
+ * - the roster is known and that session is present and idle.
14
+ *
15
+ * An unknown roster (session/list unavailable) never reuses: minting a fresh
16
+ * conversation is always safe, prompting into a session we cannot see is not.
17
+ * @param task - the task about to run.
18
+ * @param idleSessionIds - ids the last roster saw as present and not running;
19
+ * undefined when that roster is unknown.
20
+ * @returns the session id to continue in, or undefined for a fresh session.
21
+ */
22
+ export declare function reusableSessionId(task: TaskRecord, idleSessionIds: ReadonlySet<string> | undefined): string | undefined;
@@ -124,6 +124,14 @@ export interface TaskRecord {
124
124
  * absent falls back to the host default (agent-default-model).
125
125
  */
126
126
  model?: string;
127
+ /**
128
+ * Whether later executions continue in the previous execution's session
129
+ * (issue #1419) instead of minting a fresh conversation per run. Absent or
130
+ * false keeps the historical one-session-per-execution behavior; the reuse
131
+ * itself only happens when that session is idle and still present (see
132
+ * {@link reusableSessionId}).
133
+ */
134
+ reuseSession?: boolean;
127
135
  /**
128
136
  * Frozen context snapshot for a continuation card; absent on plain tasks.
129
137
  * Sanitized before it enters the ledger (redaction, slash-command taint,
@@ -171,6 +179,8 @@ export interface NewTaskInput {
171
179
  permission?: TaskPermission;
172
180
  /** Optional pinned model for the execution session; absent = host default. */
173
181
  model?: string;
182
+ /** Reuse the previous execution's session for later runs (issue #1419). */
183
+ reuseSession?: boolean;
174
184
  /**
175
185
  * Optional scheduled-run rule requested at creation time (the new-task
176
186
  * dialog): an enable flag plus a 5-field cron expression. The create use
@@ -15,7 +15,7 @@ import type { TaskHandoverInput } from '../handover.ts';
15
15
  * Editable fields on a task (the update patch surface). `freeze` replaces the
16
16
  * continuation-card snapshot (restamping frozenAt); an explicit null clears it.
17
17
  */
18
- export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model'>> & {
18
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession'>> & {
19
19
  freeze?: FreezeSnapshot & {
20
20
  redacted?: boolean;
21
21
  } | null;
@@ -65,7 +65,25 @@ export declare class HostExecutionRunner {
65
65
  });
66
66
  private invoke;
67
67
  private stream;
68
- launch(task: TaskRecord): Promise<string>;
68
+ /**
69
+ * Launch one execution. Without `options.reuseSessionId` a fresh session is
70
+ * created, renamed, pinned, and prompted (the historical contract). With it,
71
+ * the run continues in that existing session (issue #1419): the conversation
72
+ * keeps its title and history, the pinned permission/model are re-asserted so
73
+ * the task's execution contract still holds, and the prompt is queued.
74
+ * @param task - the task to run.
75
+ * @param options - optional session to continue in.
76
+ * @returns the session id the execution runs in.
77
+ */
78
+ launch(task: TaskRecord, options?: {
79
+ reuseSessionId?: string;
80
+ }): Promise<string>;
81
+ /**
82
+ * Re-assert the pinned execution contract on a session and queue the task
83
+ * prompt. Shared by the fresh-session and reuse paths so both apply exactly
84
+ * the same permission/model pins before the prompt.
85
+ */
86
+ private pinAndPrompt;
69
87
  listRunning(): Promise<{
70
88
  known: true;
71
89
  count: number;
@@ -15,6 +15,13 @@ export declare class TaskBoardHostService {
15
15
  private pollInFlight;
16
16
  private tickInFlight;
17
17
  private active;
18
+ /**
19
+ * Ids the last roster poll saw as present and idle; undefined while the
20
+ * roster is unknown. Session reuse (issue #1419) requires this positive
21
+ * evidence, so a launch before the first successful poll mints a fresh
22
+ * conversation instead of prompting into a session it cannot see.
23
+ */
24
+ private idleSessionIds;
18
25
  private preventIdleSleep;
19
26
  private lastPowerJson;
20
27
  private readonly now;
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.17",
4
+ "version": "0.3.18",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -6,27 +6,41 @@ import { useEffect, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
7
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
8
  import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
9
- import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
9
+ import { TASK_PERMISSIONS, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
10
10
  import { t, type TaskBoardKey } from '../locales.ts'
11
11
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
12
12
  import { ModalShell, TaskContentFields } from './TaskForm.tsx'
13
13
  import css from '../board.module.css'
14
14
 
15
+ export interface NewTaskModalProps {
16
+ controller: BoardController
17
+ onClose: () => void
18
+ /** Optional task template to clone/duplicate from. */
19
+ initialTask?: TaskRecord
20
+ /** Optional callback after successful duplication (e.g. to archive source). */
21
+ onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>
22
+ }
23
+
15
24
  /** New-task form overlay. */
16
- export function NewTaskModal({ controller, onClose }: { controller: BoardController; onClose: () => void }) {
17
- const [title, setTitle] = useState('')
18
- const [description, setDescription] = useState('')
19
- const [prompt, setPrompt] = useState('')
20
- const [workspaceId, setWorkspaceId] = useState('')
21
- const [mode, setMode] = useState('')
22
- const [permission, setPermission] = useState('')
23
- const [model, setModel] = useState('')
24
- const [scheduleEnabled, setScheduleEnabled] = useState(false)
25
- const [scheduleCron, setScheduleCron] = useState('')
25
+ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps) {
26
+ const isDuplicate = initialTask !== undefined
27
+ const [title, setTitle] = useState(initialTask?.title ?? '')
28
+ const [description, setDescription] = useState(initialTask?.description ?? '')
29
+ const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
30
+ const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? '')
31
+ const [mode, setMode] = useState(initialTask?.mode ?? '')
32
+ const [permission, setPermission] = useState(initialTask?.permission ?? '')
33
+ const [model, setModel] = useState(initialTask?.model ?? '')
34
+ const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false)
35
+ const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
36
+ const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
26
37
  const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
27
38
  const [freezeText, setFreezeText] = useState('')
28
39
  const [freezeError, setFreezeError] = useState<string | undefined>(undefined)
29
- const [handoverText, setHandoverText] = useState('')
40
+ const [handoverText, setHandoverText] = useState(
41
+ initialTask?.handover?.references !== undefined ? initialTask.handover.references.join('\n') : '',
42
+ )
43
+ const [archiveOriginal, setArchiveOriginal] = useState(true)
30
44
  const [error, setError] = useState<string | undefined>(undefined)
31
45
  const [pending, setPending] = useState(false)
32
46
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
@@ -78,6 +92,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
78
92
  mode: mode === '' ? undefined : mode,
79
93
  permission: permission === '' ? undefined : permission as TaskPermission,
80
94
  model: model === '' ? undefined : model,
95
+ ...(reuseSession ? { reuseSession: true } : {}),
81
96
  schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
82
97
  })
83
98
  if (task === undefined) {
@@ -85,6 +100,13 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
85
100
  setError(controller.getSnapshot().transportError ?? t('new.required'))
86
101
  return
87
102
  }
103
+ if (isDuplicate && archiveOriginal && initialTask !== undefined) {
104
+ if (onDuplicateSuccess !== undefined) {
105
+ await onDuplicateSuccess(initialTask.id)
106
+ } else {
107
+ await controller.archiveTask(initialTask.id)
108
+ }
109
+ }
88
110
  onClose()
89
111
  }
90
112
 
@@ -93,10 +115,12 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
93
115
  ? nextRunAtMs(scheduleCron, Date.now())
94
116
  : undefined
95
117
 
118
+ const modalTitle = isDuplicate ? t('new.duplicateTitle') : t('board.new')
119
+
96
120
  return (
97
121
  <ModalShell
98
- ariaLabel={t('board.new')}
99
- title={t('board.new')}
122
+ ariaLabel={modalTitle}
123
+ title={modalTitle}
100
124
  error={error}
101
125
  pending={pending}
102
126
  submitLabel={t('new.submit')}
@@ -197,6 +221,16 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
197
221
  </select>
198
222
  </label>
199
223
 
224
+ <label className={css.scheduleToggle}>
225
+ <input
226
+ type="checkbox"
227
+ checked={reuseSession}
228
+ onChange={event => { setReuseSession(event.target.checked) }}
229
+ />
230
+ <span>{t('exec.reuseSession')}</span>
231
+ </label>
232
+ <p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
233
+
200
234
  <section className={css.detailSection}>
201
235
  <h4>{t('detail.schedule')}</h4>
202
236
  <label className={css.scheduleToggle}>
@@ -246,6 +280,16 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
246
280
  </>
247
281
  )}
248
282
  </section>
283
+ {isDuplicate && (
284
+ <label className={css.checkboxLabel} style={{ marginTop: '12px' }}>
285
+ <input
286
+ type="checkbox"
287
+ checked={archiveOriginal}
288
+ onChange={event => { setArchiveOriginal(event.target.checked) }}
289
+ />
290
+ <span>{t('new.archiveOriginal')}</span>
291
+ </label>
292
+ )}
249
293
  </ModalShell>
250
294
  )
251
295
  }
@@ -15,6 +15,7 @@ import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
15
15
  import css from '../board.module.css'
16
16
  import { ConfirmDialog } from './ConfirmDialog.tsx'
17
17
  import { EditTaskModal } from './EditTaskModal.tsx'
18
+ import { NewTaskModal } from './NewTaskModal.tsx'
18
19
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
19
20
  import { STATUS_KEY } from './status-key.ts'
20
21
 
@@ -143,6 +144,16 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
143
144
  ))}
144
145
  </select>
145
146
  </label>
147
+ <label className={css.scheduleToggle}>
148
+ <input
149
+ type="checkbox"
150
+ checked={task.reuseSession === true}
151
+ disabled={pending}
152
+ onChange={event => { controller.updateTask(task.id, { reuseSession: event.target.checked }) }}
153
+ />
154
+ <span>{t('exec.reuseSession')}</span>
155
+ </label>
156
+ <p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
146
157
  </section>
147
158
  )
148
159
  }
@@ -258,12 +269,16 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
258
269
  export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
259
270
  const [confirmDelete, setConfirmDelete] = useState(false)
260
271
  const [showEdit, setShowEdit] = useState(false)
272
+ const [showDuplicate, setShowDuplicate] = useState(false)
261
273
 
262
274
  // Keep the overlay in sync if the task record changes underneath.
263
275
  const [latest, setLatest] = useState(task)
264
276
  useEffect(() => { setLatest(task) }, [task])
265
277
  // A re-used overlay instance must not carry an edit session across tasks.
266
- useEffect(() => { setShowEdit(false) }, [task.id])
278
+ useEffect(() => {
279
+ setShowEdit(false)
280
+ setShowDuplicate(false)
281
+ }, [task.id])
267
282
  const current = latest
268
283
  const snapshot = controller.getSnapshot()
269
284
  const running = current.status === 'running'
@@ -414,6 +429,17 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
414
429
  {t('detail.edit')}
415
430
  </button>
416
431
  )}
432
+ {!archived && (
433
+ <button
434
+ type="button"
435
+ className={css.ghostButton}
436
+ disabled={pending}
437
+ onClick={() => { setShowDuplicate(true) }}
438
+ title={canEditTaskContent(current) ? t('detail.duplicate') : t('detail.duplicateAndEdit')}
439
+ >
440
+ {canEditTaskContent(current) ? t('detail.duplicate') : t('detail.duplicateAndEdit')}
441
+ </button>
442
+ )}
417
443
  {!archived && (
418
444
  <button
419
445
  type="button"
@@ -485,6 +511,18 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
485
511
  {showEdit && !archived && canEditTaskContent(current) && (
486
512
  <EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
487
513
  )}
514
+
515
+ {showDuplicate && !archived && (
516
+ <NewTaskModal
517
+ controller={controller}
518
+ initialTask={current}
519
+ onClose={() => { setShowDuplicate(false) }}
520
+ onDuplicateSuccess={async (sourceId) => {
521
+ await controller.archiveTask(sourceId)
522
+ controller.closeTask()
523
+ }}
524
+ />
525
+ )}
488
526
  </div>
489
527
  )
490
528
  }
@@ -60,9 +60,13 @@ export const zh = {
60
60
  'detail.permissionConfirmed': '已确认权限绑定 · {time}',
61
61
  'detail.title': '任务详情',
62
62
  'detail.edit': '编辑',
63
+ 'detail.duplicate': '复制为新任务',
64
+ 'detail.duplicateAndEdit': '修改并新建副本',
63
65
  'detail.close': '关闭',
64
66
  'edit.title': '编辑任务',
65
67
  'edit.save': '保存',
68
+ 'new.duplicateTitle': '新建任务(副本)',
69
+ 'new.archiveOriginal': '创建后归档原任务',
66
70
  'detail.prompt': '执行 Prompt',
67
71
  'detail.description': '描述',
68
72
  'detail.execution': '执行记录',
@@ -121,6 +125,8 @@ export const zh = {
121
125
  'exec.permission.danger-full-access': '完全访问',
122
126
  'exec.model.default': '宿主默认(agent-default-model)',
123
127
  'exec.model.unknown': '(未知模型/回退默认)',
128
+ 'exec.reuseSession': '在同一对话继续',
129
+ 'exec.reuseSessionHint': '开启后,本任务的后续执行在上一次会话里继续(该会话空闲且仍存在时),不再每次新建对话;每次复用时都会重新应用上面钉住的权限与模型。',
124
130
  'detail.executionSettings': '执行设置',
125
131
  'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
126
132
  // 插件设置卡片(settings.plugin.item 席位)。
@@ -208,9 +214,13 @@ export const en: Record<keyof typeof zh, string> = {
208
214
  'detail.permissionConfirmed': 'Permission confirmed · {time}',
209
215
  'detail.title': 'Task Detail',
210
216
  'detail.edit': 'Edit',
217
+ 'detail.duplicate': 'Duplicate Task',
218
+ 'detail.duplicateAndEdit': 'Edit as New Copy',
211
219
  'detail.close': 'Close',
212
220
  'edit.title': 'Edit Task',
213
221
  'edit.save': 'Save',
222
+ 'new.duplicateTitle': 'New Task (Copy)',
223
+ 'new.archiveOriginal': 'Archive original task upon creation',
214
224
  'detail.prompt': 'Run Prompt',
215
225
  'detail.description': 'Description',
216
226
  'detail.execution': 'Execution History',
@@ -269,6 +279,8 @@ export const en: Record<keyof typeof zh, string> = {
269
279
  'exec.permission.danger-full-access': 'Full Access',
270
280
  'exec.model.default': 'Host default (agent-default-model)',
271
281
  'exec.model.unknown': ' (unknown / fallback to default)',
282
+ 'exec.reuseSession': 'Continue in the same conversation',
283
+ 'exec.reuseSessionHint': 'When on, later runs continue in the previous session (when that session is idle and still exists) instead of starting a new conversation each time; the pinned permission and model above are re-applied on every reuse.',
272
284
  'detail.executionSettings': 'Execution Settings',
273
285
  'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
274
286
  // Plugin settings card (the `settings.plugin.item` seat).