@tea-agent/loop-agent 0.31.1 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +1 -1
  4. package/dist/shared/operator/capabilities.js +22 -1
  5. package/dist/shared/operator/command-lifecycle.js +94 -0
  6. package/dist/shared/operator/index.js +1 -0
  7. package/dist/worker/cli.js +10 -24
  8. package/dist/worker/console/doctor.js +11 -4
  9. package/dist/worker/console/observe-health-match.js +18 -15
  10. package/dist/worker/console/operator-actions.js +2 -1
  11. package/dist/worker/console/static/assets/index-Bpa2qrc-.js +29 -0
  12. package/dist/worker/console/static/assets/index-BqfFDdnG.css +1 -0
  13. package/dist/worker/console/static/index.html +2 -2
  14. package/dist/worker/console/static-src/app/console-types.js +140 -0
  15. package/dist/worker/console/static-src/app/useConsoleShell.js +101 -0
  16. package/dist/worker/console/static-src/app/useOperatorActions.js +304 -0
  17. package/dist/worker/console/static-src/app/usePrdImport.js +171 -0
  18. package/dist/worker/console/static-src/app/useRecoveryActions.js +257 -0
  19. package/dist/worker/console/static-src/app/useRecoveryConsole.js +334 -0
  20. package/dist/worker/console/static-src/app/useTaskWizard.js +229 -0
  21. package/dist/worker/console/static-src/chat-view-types.js +2 -0
  22. package/dist/worker/console/static-src/night/night-types.js +24 -0
  23. package/dist/worker/console/static-src/night/useNightBoard.js +125 -0
  24. package/dist/worker/console/static-src/night/useNightWizard.js +171 -0
  25. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +253 -0
  26. package/dist/worker/console/static-src/operator-chat/format.js +71 -0
  27. package/dist/worker/console/static-src/operator-chat/refs.js +47 -0
  28. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +77 -0
  29. package/dist/worker/console/static-src/operator-chat/types.js +1 -0
  30. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +320 -0
  31. package/dist/worker/console/static-src/operator-chat/useChatStream.js +209 -0
  32. package/dist/worker/console/static-src/operator-chat/useChatThread.js +218 -0
  33. package/dist/worker/console/static-src/operator-chat/useComposer.js +125 -0
  34. package/dist/worker/console/static-src/operator-chat/useInterview.js +108 -0
  35. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +123 -0
  36. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +207 -0
  37. package/dist/worker/feature/acceptance-policy.js +227 -0
  38. package/dist/worker/feature/decision-loader.js +1 -1
  39. package/dist/worker/feature/next-action.js +56 -6
  40. package/dist/worker/feature/profile-schema.js +3 -0
  41. package/dist/worker/feature/reducer.js +1 -0
  42. package/dist/worker/feature/review.js +72 -8
  43. package/dist/worker/feature/scaffold.js +14 -0
  44. package/dist/worker/loop-agent/controller-protocol.js +143 -0
  45. package/dist/worker/materialize/harness-task-lifecycle-probe.js +126 -0
  46. package/dist/worker/materialize/harness-task-lineage.js +220 -0
  47. package/dist/worker/materialize/harness-task-materializer.js +350 -80
  48. package/dist/worker/observability/progress-composite.js +1 -0
  49. package/dist/worker/observability/read-model.js +66 -19
  50. package/dist/worker/pool/attempt-identity.js +41 -0
  51. package/dist/worker/pool/attempt-lease.js +184 -0
  52. package/dist/worker/pool/attempt-transition.js +210 -0
  53. package/dist/worker/pool/begin-attempt-with-lease.js +26 -0
  54. package/dist/worker/pool/begin-attempt.js +35 -0
  55. package/dist/worker/pool/failure-routing.js +49 -0
  56. package/dist/worker/pool/recovery-decision.js +163 -0
  57. package/dist/worker/pool/run-owner-store.js +126 -0
  58. package/dist/worker/pool/run-store.js +32 -46
  59. package/dist/worker/pool/runtime-reconcile-inventory.js +127 -0
  60. package/dist/worker/pool/state-projection.js +57 -0
  61. package/dist/worker/run-task/run-task.js +31 -4
  62. package/dist/worker/runner/run-ready.js +64 -14
  63. package/dist/worker/runner/single-task-attempt.js +42 -13
  64. package/dist/worker/task-graph/acceptance-schema.js +3 -0
  65. package/docs/README.md +3 -1
  66. package/docs/architecture/evolution.md +1 -1
  67. package/docs/operations/README.md +1 -0
  68. package/docs/templates/README.md +1 -0
  69. package/docs/templates/agent-worker-production-readiness-checklist.md +47 -0
  70. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  71. package/docs/templates/evaluation/agents-map-verbose-v0.md +1 -1
  72. package/docs/templates/init-managed-agents.md +1 -1
  73. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +3 -0
  74. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +11 -0
  75. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +3 -0
  76. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +11 -0
  77. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +3 -0
  78. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +11 -0
  79. package/package.json +5 -4
  80. package/skills/agent-worker/SKILL.md +1 -1
  81. package/skills/agent-worker/references/agent-worker-operator.md +1 -1
  82. package/skills/loop-agent/references/command-reference.md +2 -2
  83. package/skills/loop-agent/references/harness-policy.md +1 -1
  84. package/dist/worker/console/static/assets/index-D9gnJn_l.js +0 -29
  85. package/dist/worker/console/static/assets/index-rajoXwkM.css +0 -1
@@ -0,0 +1,304 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { formatOperatorUserError } from "../../operator-user-error.js";
3
+ import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
4
+ import { confirmationToken, CONFIRMATION_HEADER, LONG_RUNNING_ACTIONS, postOperator, waitForOperation, } from "./console-types.js";
5
+ /** Central operator-action dispatcher: POST /api/operator/v1/operations with
6
+ * polling, long-running background waits, interview payload handoff and the
7
+ * per-operation SSE follow log. */
8
+ export function useOperatorActions(params) {
9
+ const { shell, applyInterviewPayload } = params;
10
+ const { setBusy, setOpError, setOpErrorCode, setOpMessage, setReadiness, setStep, setNav, setLastOpState, setLayers, step, nav, } = shell;
11
+ const [operationId, setOperationId] = useState(null);
12
+ const [sseLog, setSseLog] = useState([]);
13
+ const [followSse, setFollowSse] = useState(true);
14
+ const sseAbort = useRef(null);
15
+ const [confirmationId, setConfirmationId] = useState(null);
16
+ const [confirmationDetails, setConfirmationDetails] = useState(null);
17
+ const runAction = useCallback(async (action, actionParams = {}, opts) => {
18
+ setBusy(true);
19
+ setOpError(null);
20
+ setOpErrorCode(null);
21
+ setOpMessage(null);
22
+ try {
23
+ const { status, body } = await postOperator(action, actionParams, opts?.clientRequestId);
24
+ if (status === 412 || body.error?.code === "PI_SETUP_REQUIRED") {
25
+ setOpError(formatOperatorUserError(body.error?.message ?? "Pi 未就绪或已降级:该操作暂不可用"));
26
+ setOpErrorCode(body.error?.code ?? "PI_SETUP_REQUIRED");
27
+ setReadiness("setup-required");
28
+ setStep("setup");
29
+ setNav("models");
30
+ return body;
31
+ }
32
+ if (status >= 400 || body.ok === false) {
33
+ setOpError(formatOperatorUserError(body.error?.message ??
34
+ body.message ??
35
+ `操作失败(HTTP ${status})`));
36
+ setOpErrorCode(body.error?.code ?? String(status));
37
+ setLastOpState(body.state ?? `failed:${status}`);
38
+ setLayers((prev) => ({
39
+ ...prev,
40
+ command: body.error?.message ?? `failed:${body.error?.code ?? status}`,
41
+ }));
42
+ return body;
43
+ }
44
+ if (body.operationId) {
45
+ const activeChatSessionId = window.localStorage
46
+ .getItem(ACTIVE_CHAT_SESSION_STORAGE_KEY)
47
+ ?.trim();
48
+ if (activeChatSessionId) {
49
+ void fetch(`/api/operator/v1/chat/sessions/${encodeURIComponent(activeChatSessionId)}/operation-links`, {
50
+ method: "POST",
51
+ credentials: "same-origin",
52
+ headers: {
53
+ "Content-Type": "application/json",
54
+ [CONFIRMATION_HEADER]: confirmationToken(),
55
+ },
56
+ body: JSON.stringify({ operationId: body.operationId }),
57
+ })
58
+ .then((response) => {
59
+ if (!response.ok)
60
+ console.warn("Operator Chat operation link failed", response.status);
61
+ })
62
+ .catch((error) => console.warn("Operator Chat operation link failed", error));
63
+ }
64
+ setOperationId(body.operationId);
65
+ setLastOpState(body.state ?? "accepted");
66
+ setLayers((prev) => ({
67
+ ...prev,
68
+ command: body.state ?? "accepted",
69
+ dag: action === "runDag" ? "running" : prev.dag,
70
+ }));
71
+ // Long DAG runs must not block the UI on a short poll timeout.
72
+ if (LONG_RUNNING_ACTIONS.has(action)) {
73
+ setFollowSse(true);
74
+ setOpMessage(`操作已接受,后台运行中:${body.operationId}(请看事件流;可同时打开 Observe)`);
75
+ // Recovery / long actions still need terminal feedback on this page.
76
+ const acceptedOpId = body.operationId;
77
+ void waitForOperation(acceptedOpId, 30 * 60_000).then((finalOp) => {
78
+ if (!finalOp)
79
+ return;
80
+ setLastOpState(finalOp.state ?? null);
81
+ if (finalOp.state === "failed" ||
82
+ finalOp.state === "needs-reconcile" ||
83
+ finalOp.state === "timed-out") {
84
+ setOpError(formatOperatorUserError(finalOp.errorMessage ??
85
+ finalOp.stderrPreview ??
86
+ `操作失败:${finalOp.state}`));
87
+ setOpErrorCode(finalOp.errorCode ?? finalOp.state ?? null);
88
+ setLayers((prev) => ({
89
+ ...prev,
90
+ command: `failed:${finalOp.errorCode ?? finalOp.state}`,
91
+ }));
92
+ return;
93
+ }
94
+ if (finalOp.state === "succeeded") {
95
+ setOpMessage(`${action} 已成功`);
96
+ setLayers((prev) => ({
97
+ ...prev,
98
+ command: "succeeded",
99
+ }));
100
+ }
101
+ });
102
+ }
103
+ else {
104
+ setOpMessage(`操作已接受:${body.operationId}`);
105
+ const finalOp = await waitForOperation(body.operationId);
106
+ if (finalOp) {
107
+ setLastOpState(finalOp.state ?? null);
108
+ if (finalOp.state === "failed" ||
109
+ finalOp.state === "needs-reconcile" ||
110
+ finalOp.state === "timed-out") {
111
+ setOpError(formatOperatorUserError(finalOp.errorMessage ??
112
+ finalOp.stderrPreview ??
113
+ `操作失败:${finalOp.state}`));
114
+ setOpErrorCode(finalOp.errorCode ?? finalOp.state ?? null);
115
+ setLayers((prev) => ({
116
+ ...prev,
117
+ command: `failed:${finalOp.errorCode ?? finalOp.state}`,
118
+ }));
119
+ return {
120
+ ok: false,
121
+ operationId: body.operationId,
122
+ state: finalOp.state,
123
+ error: {
124
+ code: finalOp.errorCode ?? finalOp.state ?? "INTERNAL_ERROR",
125
+ message: finalOp.errorMessage ??
126
+ finalOp.stderrPreview ??
127
+ `操作失败:${finalOp.state}`,
128
+ },
129
+ };
130
+ }
131
+ setOpMessage(finalOp.errorMessage
132
+ ? `${action}:${finalOp.errorMessage}`
133
+ : `${action} 已成功`);
134
+ setLayers((prev) => ({
135
+ ...prev,
136
+ command: finalOp.state ?? "succeeded",
137
+ }));
138
+ const finalResult = (finalOp.result ?? {});
139
+ const succeeded = {
140
+ ok: true,
141
+ operationId: body.operationId,
142
+ state: finalOp.state ?? "succeeded",
143
+ result: finalResult,
144
+ };
145
+ if (action === "interviewStart" ||
146
+ action === "interviewTurn" ||
147
+ action === "interviewSkip") {
148
+ applyInterviewPayload(finalResult);
149
+ }
150
+ const conf = finalResult.confirmation;
151
+ if (conf?.confirmationId) {
152
+ setConfirmationId(conf.confirmationId);
153
+ setConfirmationDetails(finalResult);
154
+ }
155
+ if (opts?.advanceTo)
156
+ setStep(opts.advanceTo);
157
+ return succeeded;
158
+ }
159
+ }
160
+ }
161
+ else {
162
+ setOpMessage(`${action} 已完成`);
163
+ setLastOpState(body.state ?? "succeeded");
164
+ setLayers((prev) => ({
165
+ ...prev,
166
+ command: "succeeded",
167
+ verification: action === "contractValidate" || action === "spineCheck"
168
+ ? "ran"
169
+ : prev.verification,
170
+ }));
171
+ }
172
+ const result = (body.result ?? {});
173
+ if (action === "interviewStart" ||
174
+ action === "interviewTurn" ||
175
+ action === "interviewSkip") {
176
+ applyInterviewPayload(result);
177
+ }
178
+ const conf = result.confirmation;
179
+ if (conf?.confirmationId) {
180
+ setConfirmationId(conf.confirmationId);
181
+ setConfirmationDetails(result);
182
+ }
183
+ if (opts?.advanceTo)
184
+ setStep(opts.advanceTo);
185
+ return body;
186
+ }
187
+ catch (error) {
188
+ setOpError(error instanceof Error ? error.message : String(error));
189
+ return undefined;
190
+ }
191
+ finally {
192
+ setBusy(false);
193
+ }
194
+ }, [
195
+ applyInterviewPayload,
196
+ setBusy,
197
+ setOpError,
198
+ setOpErrorCode,
199
+ setOpMessage,
200
+ setReadiness,
201
+ setStep,
202
+ setNav,
203
+ setLastOpState,
204
+ setLayers,
205
+ ]);
206
+ const summarizeEnvelope = useCallback((body) => {
207
+ if (!body)
208
+ return "无响应";
209
+ if (body.error?.message)
210
+ return body.error.message;
211
+ if (body.message)
212
+ return body.message;
213
+ const result = body.result;
214
+ if (result && typeof result === "object") {
215
+ try {
216
+ return JSON.stringify(result).slice(0, 1200);
217
+ }
218
+ catch {
219
+ return "已返回结果";
220
+ }
221
+ }
222
+ return body.ok === false ? "失败" : "成功";
223
+ }, []);
224
+ const isActionSuccess = useCallback((body) => {
225
+ if (!body || body.ok === false || body.error)
226
+ return false;
227
+ const result = body.result;
228
+ if (!result)
229
+ return true;
230
+ if (typeof result.valid === "boolean")
231
+ return result.valid;
232
+ const nested = result.result;
233
+ if (nested && typeof nested.valid === "boolean")
234
+ return nested.valid;
235
+ return true;
236
+ }, []);
237
+ const followOperationEvents = useCallback(async (opId) => {
238
+ sseAbort.current?.abort();
239
+ const ac = new AbortController();
240
+ sseAbort.current = ac;
241
+ setSseLog((prev) => [...prev, `开始跟随事件流 ${opId}`]);
242
+ try {
243
+ const res = await fetch(`/api/operator/v1/operations/${encodeURIComponent(opId)}/events`, { credentials: "same-origin", signal: ac.signal });
244
+ if (!res.ok || !res.body) {
245
+ setSseLog((prev) => [...prev, `事件流 HTTP ${res.status}`]);
246
+ return;
247
+ }
248
+ const reader = res.body.getReader();
249
+ const decoder = new TextDecoder();
250
+ let buffer = "";
251
+ while (followSse) {
252
+ const { done, value } = await reader.read();
253
+ if (done)
254
+ break;
255
+ buffer += decoder.decode(value, { stream: true });
256
+ const chunks = buffer.split("\n\n");
257
+ buffer = chunks.pop() ?? "";
258
+ for (const chunk of chunks) {
259
+ const dataLine = chunk
260
+ .split("\n")
261
+ .find((l) => l.startsWith("data:"));
262
+ if (!dataLine)
263
+ continue;
264
+ const payload = dataLine.slice(5).trim();
265
+ setSseLog((prev) => [...prev.slice(-80), payload.slice(0, 240)]);
266
+ }
267
+ }
268
+ }
269
+ catch (error) {
270
+ if (error.name !== "AbortError") {
271
+ setSseLog((prev) => [
272
+ ...prev,
273
+ `事件流错误:${error instanceof Error ? error.message : String(error)}`,
274
+ ]);
275
+ }
276
+ }
277
+ }, [followSse]);
278
+ useEffect(() => {
279
+ if (operationId && followSse && (step === "run" || nav === "recovery")) {
280
+ void followOperationEvents(operationId);
281
+ }
282
+ return () => sseAbort.current?.abort();
283
+ }, [operationId, followSse, step, nav, followOperationEvents]);
284
+ const stopFollowSse = useCallback(() => {
285
+ setFollowSse(false);
286
+ sseAbort.current?.abort();
287
+ setOpMessage("已停止跟随日志(未取消子进程)");
288
+ }, [setOpMessage]);
289
+ return {
290
+ runAction,
291
+ summarizeEnvelope,
292
+ isActionSuccess,
293
+ operationId,
294
+ setOperationId,
295
+ sseLog,
296
+ followSse,
297
+ setFollowSse,
298
+ stopFollowSse,
299
+ confirmationId,
300
+ setConfirmationId,
301
+ confirmationDetails,
302
+ setConfirmationDetails,
303
+ };
304
+ }
@@ -0,0 +1,171 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { composePrdDocuments, importPrdFiles, validatePrdFileList, } from "../prd-file-import.js";
3
+ /** PRD multi-file import: drag/drop + selection, primary-volume selection,
4
+ * reorder/remove and composed-text regeneration with dirty tracking. */
5
+ export function usePrdImport() {
6
+ const [prdText, setPrdText] = useState("# 需求\n\n实现 Loop Operator Console Happy Path:Interview → 契约落地 → DAG confirm → run。\n");
7
+ const [prdDocuments, setPrdDocuments] = useState([]);
8
+ const [prdPrimaryIndex, setPrdPrimaryIndex] = useState(0);
9
+ const [prdComposedDirty, setPrdComposedDirty] = useState(false);
10
+ const [prdFileError, setPrdFileError] = useState(null);
11
+ const [prdFileReading, setPrdFileReading] = useState(false);
12
+ const [prdDragOver, setPrdDragOver] = useState(false);
13
+ const prdDragDepth = useRef(0);
14
+ const prdImportGen = useRef(0);
15
+ const applyPrdComposition = useCallback((docs, primaryIndex) => {
16
+ const safePrimary = docs.length === 0
17
+ ? 0
18
+ : Math.min(Math.max(0, primaryIndex), docs.length - 1);
19
+ setPrdPrimaryIndex(safePrimary);
20
+ setPrdText(composePrdDocuments(docs, { primaryIndex: safePrimary }));
21
+ setPrdComposedDirty(false);
22
+ }, []);
23
+ const loadPrdFromFiles = useCallback(async (fileList) => {
24
+ const countError = validatePrdFileList(fileList, {
25
+ existingCount: prdDocuments.length,
26
+ });
27
+ if (countError) {
28
+ setPrdFileError(countError.message);
29
+ return;
30
+ }
31
+ const incoming = Array.from(fileList);
32
+ const gen = ++prdImportGen.current;
33
+ setPrdFileReading(true);
34
+ setPrdFileError(null);
35
+ try {
36
+ const result = await importPrdFiles(incoming);
37
+ if (gen !== prdImportGen.current)
38
+ return;
39
+ if (!result.ok) {
40
+ setPrdFileError(result.message);
41
+ return;
42
+ }
43
+ const nextDocs = [...prdDocuments, ...result.documents];
44
+ const nextPrimary = prdDocuments.length === 0 ? 0 : prdPrimaryIndex;
45
+ setPrdDocuments(nextDocs);
46
+ if (prdDocuments.length === 0 || !prdComposedDirty) {
47
+ applyPrdComposition(nextDocs, nextPrimary);
48
+ setPrdFileError(null);
49
+ }
50
+ else {
51
+ setPrdFileError("已追加文件,未改正文(正文已手动编辑);可点「按文件重新合成」覆盖。");
52
+ }
53
+ }
54
+ catch {
55
+ if (gen !== prdImportGen.current)
56
+ return;
57
+ setPrdFileError("文件读取失败,请重试或直接粘贴");
58
+ }
59
+ finally {
60
+ if (gen === prdImportGen.current) {
61
+ setPrdFileReading(false);
62
+ }
63
+ }
64
+ }, [applyPrdComposition, prdComposedDirty, prdDocuments, prdPrimaryIndex]);
65
+ const onPrdTextChange = useCallback((value) => {
66
+ setPrdText(value);
67
+ if (prdDocuments.length > 0) {
68
+ setPrdComposedDirty(true);
69
+ }
70
+ }, [prdDocuments.length]);
71
+ const setPrimaryDocument = useCallback((index) => {
72
+ setPrdPrimaryIndex(index);
73
+ if (!prdComposedDirty) {
74
+ applyPrdComposition(prdDocuments, index);
75
+ }
76
+ }, [applyPrdComposition, prdComposedDirty, prdDocuments]);
77
+ const moveDocument = useCallback((index, delta) => {
78
+ const target = index + delta;
79
+ const next = [...prdDocuments];
80
+ const tmp = next[target];
81
+ next[target] = next[index];
82
+ next[index] = tmp;
83
+ const nextPrimary = prdPrimaryIndex === index
84
+ ? target
85
+ : prdPrimaryIndex === target
86
+ ? index
87
+ : prdPrimaryIndex;
88
+ setPrdDocuments(next);
89
+ setPrdPrimaryIndex(nextPrimary);
90
+ if (!prdComposedDirty) {
91
+ applyPrdComposition(next, nextPrimary);
92
+ }
93
+ }, [applyPrdComposition, prdComposedDirty, prdDocuments, prdPrimaryIndex]);
94
+ const removeDocument = useCallback((index) => {
95
+ const next = prdDocuments.filter((_, i) => i !== index);
96
+ const nextPrimary = next.length === 0
97
+ ? 0
98
+ : Math.min(prdPrimaryIndex >= index
99
+ ? Math.max(0, prdPrimaryIndex - 1)
100
+ : prdPrimaryIndex, Math.max(0, next.length - 1));
101
+ setPrdDocuments(next);
102
+ setPrdPrimaryIndex(nextPrimary);
103
+ if (next.length === 0) {
104
+ setPrdComposedDirty(false);
105
+ }
106
+ else if (!prdComposedDirty) {
107
+ applyPrdComposition(next, nextPrimary);
108
+ }
109
+ }, [applyPrdComposition, prdComposedDirty, prdDocuments, prdPrimaryIndex]);
110
+ const clearDocuments = useCallback(() => {
111
+ setPrdDocuments([]);
112
+ setPrdPrimaryIndex(0);
113
+ setPrdComposedDirty(false);
114
+ setPrdFileError(null);
115
+ }, []);
116
+ const recomposeDocuments = useCallback(() => {
117
+ if (prdComposedDirty &&
118
+ !window.confirm("将用当前文件列表重新合成正文,覆盖你在编辑区的修改。继续?")) {
119
+ return;
120
+ }
121
+ applyPrdComposition(prdDocuments, prdPrimaryIndex);
122
+ setPrdFileError(null);
123
+ }, [applyPrdComposition, prdComposedDirty, prdDocuments, prdPrimaryIndex]);
124
+ const onDragEnter = useCallback((e, disabled) => {
125
+ e.preventDefault();
126
+ e.stopPropagation();
127
+ if (disabled)
128
+ return;
129
+ prdDragDepth.current += 1;
130
+ setPrdDragOver(true);
131
+ }, []);
132
+ const onDragLeave = useCallback((e) => {
133
+ e.preventDefault();
134
+ e.stopPropagation();
135
+ prdDragDepth.current = Math.max(0, prdDragDepth.current - 1);
136
+ if (prdDragDepth.current === 0) {
137
+ setPrdDragOver(false);
138
+ }
139
+ }, []);
140
+ const onDrop = useCallback((e, disabled) => {
141
+ e.preventDefault();
142
+ e.stopPropagation();
143
+ prdDragDepth.current = 0;
144
+ setPrdDragOver(false);
145
+ if (disabled)
146
+ return;
147
+ void loadPrdFromFiles(e.dataTransfer.files);
148
+ }, [loadPrdFromFiles]);
149
+ return {
150
+ prdText,
151
+ setPrdText,
152
+ onPrdTextChange,
153
+ prdDocuments,
154
+ prdPrimaryIndex,
155
+ prdComposedDirty,
156
+ prdFileError,
157
+ setPrdFileError,
158
+ prdFileReading,
159
+ prdDragOver,
160
+ loadPrdFromFiles,
161
+ applyPrdComposition,
162
+ setPrimaryDocument,
163
+ moveDocument,
164
+ removeDocument,
165
+ clearDocuments,
166
+ recomposeDocuments,
167
+ onDragEnter,
168
+ onDragLeave,
169
+ onDrop,
170
+ };
171
+ }