cozyclay 3.0.3 → 3.1.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 (246) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +29 -62
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/plan-tool.mjs +114 -0
  10. package/bin/agent/script-tool.mjs +2 -0
  11. package/bin/agent/sculpt-sketch.mjs +190 -0
  12. package/bin/agent/session-store.mjs +4 -3
  13. package/bin/agent/sketch-code.mjs +151 -0
  14. package/bin/agent/sketch-sandbox.mjs +102 -0
  15. package/bin/agent/studio-prompt.mjs +1 -1
  16. package/bin/agent/studio-task-routes.mjs +185 -0
  17. package/bin/agent/studio-tasks.mjs +156 -0
  18. package/bin/agent/studio-tools.mjs +3 -2
  19. package/bin/agent/studio-turn-transaction.mjs +76 -0
  20. package/bin/agent/turn-frames.mjs +4 -0
  21. package/bin/cozyclay.mjs +5 -7
  22. package/dist/app/index.html +7 -6
  23. package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
  24. package/dist/assets/app-BlwOpest.css +1 -0
  25. package/dist/assets/app-DmR7jBkz.js +5144 -0
  26. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  27. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
  30. package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
  31. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
  32. package/dist/assets/task-board-ErQvfoaz.js +14 -0
  33. package/dist/assets/workflow-zQM-EKW1.js +179 -0
  34. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  35. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/greybox-to-video/index.html +2 -3
  38. package/dist/index.html +7 -57
  39. package/dist/llms.txt +2 -1
  40. package/dist/previs-software/index.html +2 -2
  41. package/dist/privacy/index.html +3 -7
  42. package/dist/seedance-camera-control/index.html +2 -2
  43. package/dist/sitemap.xml +5 -5
  44. package/dist/sw.js +5 -1
  45. package/dist/thumbs/blank-stage-dark.webp +0 -0
  46. package/dist/thumbs/blank-stage-light.webp +0 -0
  47. package/dist/workflow/index.html +5 -4
  48. package/mcp/mesh-file.mjs +3 -1
  49. package/mcp/server.mjs +3 -0
  50. package/mcp/tool-handlers.mjs +2 -2
  51. package/package.json +5 -3
  52. package/src/App.jsx +51 -282
  53. package/src/analytics.js +3 -148
  54. package/src/app-stage.jsx +1 -1
  55. package/src/asset-pane.jsx +5 -10
  56. package/src/auto-flat.js +10 -0
  57. package/src/bus-tasks.js +11 -5
  58. package/src/command-bus.js +1 -1
  59. package/src/commands/elements.js +4 -3
  60. package/src/commands/motion3-compile.js +51 -27
  61. package/src/commands/motion3.js +3 -3
  62. package/src/commands/objects.js +1 -1
  63. package/src/commands/project.js +1 -1
  64. package/src/commands/shot.js +16 -4
  65. package/src/controls.jsx +0 -11
  66. package/src/cuts.js +11 -0
  67. package/src/domains/cast.js +3 -2
  68. package/src/domains/motion.js +3 -10
  69. package/src/domains/playback.js +32 -0
  70. package/src/domains/scenes.js +3 -22
  71. package/src/domains/shots.js +5 -5
  72. package/src/dualview.jsx +11 -15
  73. package/src/hierarchy-panel.css +18 -4
  74. package/src/hierarchy-panel.jsx +12 -11
  75. package/src/kits/held.js +3 -2
  76. package/src/light-blockers.js +2 -2
  77. package/src/main.jsx +5 -0
  78. package/src/material-look.js +189 -0
  79. package/src/material-presets.js +78 -0
  80. package/src/material-textures.js +157 -0
  81. package/src/motion/ground.js +5 -14
  82. package/src/motion/playback-frame-store.js +79 -0
  83. package/src/motion/playback-probe.js +16 -0
  84. package/src/motion/playback-subject.js +7 -0
  85. package/src/motion/timeline.css +43 -5
  86. package/src/motion/timeline.jsx +4 -3
  87. package/src/motion3/body.js +37 -5
  88. package/src/motion3/carried.js +11 -3
  89. package/src/motion3/compile-job.js +1 -1
  90. package/src/motion3/compiler/compile.js +7 -1
  91. package/src/motion3/compiler/finger-close.js +137 -0
  92. package/src/motion3/compiler/limbs.js +18 -3
  93. package/src/motion3/compiler/pose.js +6 -0
  94. package/src/motion3/compiler/validate-objects.js +17 -2
  95. package/src/motion3/compiler/validate-parts.js +2 -1
  96. package/src/motion3/compiler/vocab.js +4 -4
  97. package/src/motion3/describe.js +2 -2
  98. package/src/motion3/fix/index.js +5 -3
  99. package/src/motion3/flight.js +38 -0
  100. package/src/motion3/free-motion.js +10 -7
  101. package/src/motion3/load.js +125 -0
  102. package/src/motion3/mass.js +23 -0
  103. package/src/motion3/object-keys.js +11 -6
  104. package/src/motion3/object-pose.js +11 -3
  105. package/src/motion3/pipeline.js +3 -0
  106. package/src/motion3/qa/explain.js +3 -1
  107. package/src/motion3/qa/grasp-checks.js +123 -0
  108. package/src/motion3/qa/hints-grasp.js +71 -0
  109. package/src/motion3/qa/index.js +3 -1
  110. package/src/motion3/qa/object-checks.js +7 -2
  111. package/src/motion3/qa/patch.js +2 -1
  112. package/src/motion3/qa/spec-checks.js +10 -1
  113. package/src/motion3/schema/fields.js +3 -3
  114. package/src/motion3/schema/topics.js +9 -8
  115. package/src/motion3/schema/types.js +4 -1
  116. package/src/motion3/seat.js +84 -0
  117. package/src/motion3/settle.js +11 -6
  118. package/src/motion3/shapes.js +70 -0
  119. package/src/motion3/skills/catch.js +71 -0
  120. package/src/motion3/skills/index.js +10 -3
  121. package/src/motion3/skills/info.js +13 -0
  122. package/src/motion3/skills/throw.js +72 -0
  123. package/src/object-catalog.jsx +0 -4
  124. package/src/object-gizmo.jsx +2 -1
  125. package/src/object-look.js +9 -5
  126. package/src/object-path.js +1 -1
  127. package/src/panels/ProjectPanel.jsx +42 -21
  128. package/src/panels/details.css +15 -4
  129. package/src/popover.js +37 -0
  130. package/src/posestudio.jsx +15 -10
  131. package/src/primitive-look.js +38 -0
  132. package/src/project-browser-art.jsx +19 -0
  133. package/src/project-browser.css +74 -121
  134. package/src/project-browser.jsx +36 -60
  135. package/src/props.jsx +24 -35
  136. package/src/render-passes.js +7 -1
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +5 -9
  139. package/src/scenes.js +4 -5
  140. package/src/sculpt-colours.js +65 -0
  141. package/src/sculpt-compare.js +13 -50
  142. package/src/sculpt-csg.js +112 -0
  143. package/src/sculpt-curve.js +123 -0
  144. package/src/sculpt-cutters.js +43 -0
  145. package/src/sculpt-detail.js +207 -0
  146. package/src/sculpt-geometry-loft-convex.js +100 -0
  147. package/src/sculpt-geometry-shapes.js +250 -0
  148. package/src/sculpt-geometry.js +33 -15
  149. package/src/sculpt-loft-rings.js +225 -0
  150. package/src/sculpt-noise.js +65 -0
  151. package/src/sculpt-part-fields.js +27 -0
  152. package/src/sculpt-recipe-read.js +65 -0
  153. package/src/sculpt-recipe.js +33 -75
  154. package/src/sculpt-run.js +1 -1
  155. package/src/sculpt-scatter-items.js +183 -0
  156. package/src/sculpt-scatter-shape.js +57 -0
  157. package/src/sculpt-scatter.js +124 -0
  158. package/src/sculpt-shapes-curved.js +211 -0
  159. package/src/sculpt-shapes-loft-convex.js +218 -0
  160. package/src/sculpt-shapes.js +138 -0
  161. package/src/sculpt-sketch-state.js +76 -0
  162. package/src/sculpt-sketch.jsx +124 -0
  163. package/src/sculpt-surface-fields.js +65 -0
  164. package/src/settings-menu.jsx +1 -20
  165. package/src/shell/BottomDock.jsx +9 -9
  166. package/src/shell/MenuBar.jsx +1 -9
  167. package/src/shell/PreferencesDialog.jsx +3 -4
  168. package/src/shell/ViewportToolbar.jsx +3 -3
  169. package/src/shell/add-camera-shot.js +11 -0
  170. package/src/shell/glass-regions.css +3 -2
  171. package/src/shell/glass.css +0 -5
  172. package/src/shell/lens-scrub-math.js +51 -0
  173. package/src/shell/lens-scrub.css +73 -0
  174. package/src/shell/lens-scrub.jsx +98 -0
  175. package/src/shell/mode.css +6 -5
  176. package/src/shell/preferences.css +1 -32
  177. package/src/shell/shell.css +18 -1
  178. package/src/shell/studio-shell-context.js +5 -0
  179. package/src/shot-frame.js +26 -0
  180. package/src/stage-canvas-rigs.jsx +7 -8
  181. package/src/stage-characters.jsx +26 -0
  182. package/src/stage-gizmo.js +43 -0
  183. package/src/stage-labels-ko.js +0 -6
  184. package/src/stage-startup-defaults.js +3 -9
  185. package/src/starter-scenes.js +5 -11
  186. package/src/studio-agent-commands.js +2 -2
  187. package/src/studio-agent-protocol.js +2 -7
  188. package/src/studio-app-binding.js +1 -1
  189. package/src/studio-elements.js +6 -2
  190. package/src/styles/themes.css +13 -0
  191. package/src/styles.css +6 -674
  192. package/src/text/text-object.jsx +1 -1
  193. package/src/ui.jsx +8 -16
  194. package/src/use-embed-bridge.js +3 -6
  195. package/src/use-render-activity.js +3 -1
  196. package/src/view-ghosts.jsx +5 -3
  197. package/src/viewport-visible-rect.js +37 -0
  198. package/src/workflow/AgentPanel.jsx +79 -46
  199. package/src/workflow/AgentPlanCard.jsx +41 -0
  200. package/src/workflow/AgentProgressCard.jsx +1 -1
  201. package/src/workflow/TaskBoard.jsx +102 -0
  202. package/src/workflow/TaskInline.jsx +114 -0
  203. package/src/workflow/agent-client.js +25 -36
  204. package/src/workflow/agent-panel.css +14 -1
  205. package/src/workflow/agent-plan.css +93 -0
  206. package/src/workflow/agent-plan.js +59 -0
  207. package/src/workflow/attachment-image.js +10 -3
  208. package/src/workflow/attachment-video.js +91 -0
  209. package/src/workflow/task-board.css +98 -0
  210. package/src/workflow/task-board.js +169 -0
  211. package/tools/check-eslint.mjs +2 -1
  212. package/tools/check-knip.mjs +2 -1
  213. package/tools/dev/pages/greybox-to-video.html +2 -3
  214. package/tools/dev/pages/previs-software.html +2 -2
  215. package/tools/dev/pages/privacy.html +3 -7
  216. package/tools/dev/pages/seedance-camera-control.html +2 -2
  217. package/tools/dev-full.mjs +11 -2
  218. package/tools/eslint-baseline.json +6 -6
  219. package/tools/knip-baseline.json +0 -11
  220. package/tools/loc-baseline.json +30 -31
  221. package/tools/metrics-snapshot.mjs +43 -5
  222. package/tools/render-start-thumbs.mjs +175 -0
  223. package/tools/run-tests.mjs +10 -17
  224. package/tools/sitemap.mjs +1 -1
  225. package/dist/assets/analytics-BibyE5ar.js +0 -6
  226. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  227. package/dist/assets/app-f-fkV5uP.css +0 -1
  228. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  229. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  230. package/dist/assets/landing-DYg87MDY.js +0 -1
  231. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  232. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  233. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  234. package/dist/scenes/city-block.cclayproject +0 -1
  235. package/src/burn-in.js +0 -91
  236. package/src/camera-tutorial.jsx +0 -520
  237. package/src/facing-marks.js +0 -88
  238. package/src/first-shot-handoff.js +0 -44
  239. package/src/panels/motion.css +0 -197
  240. package/src/set-pieces.jsx +0 -183
  241. package/src/tutorial-analytics.js +0 -86
  242. package/src/tutorial-walk.js +0 -17
  243. package/src/use-camera-tutorial.js +0 -50
  244. package/src/use-semantic-state.js +0 -12
  245. package/tools/metrics/archive-traffic.mjs +0 -138
  246. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from "react";
8
8
  import * as THREE from "three";
9
9
  import { Font } from "three/examples/jsm/loaders/FontLoader.js";
10
10
  import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
11
- import { autoFlat } from "../set-pieces.jsx";
11
+ import { autoFlat } from "../auto-flat.js";
12
12
  import { textIsSolid, textLayout } from "./text-record.js";
13
13
 
14
14
  // The app's own sans first, then the system's, which carries Hangul and CJK.
package/src/ui.jsx CHANGED
@@ -2,6 +2,7 @@ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { createScrubGesture } from "./ui-scrub.js";
4
4
  import { isImeComposing } from "./ime.js";
5
+ import { usePopover } from "./popover.js";
5
6
 
6
7
  // Custom dropdown, structurally matched to the reference (`.dropdown`,
7
8
  // `dd-caret`, `dropdown-menu`, `dropdown-item`, `dd-check`) with the keyboard
@@ -13,7 +14,7 @@ import { isImeComposing } from "./ime.js";
13
14
  // scroll pane, so an in-place absolute menu gets clipped to the card's
14
15
  // height (the pose foldout is ~70 px tall against a ~290 px menu). Scrolling
15
16
  // or resizing while open closes the menu instead of chasing the trigger.
16
- export function Dropdown({ value, options, onChange, ariaLabel }) {
17
+ export function Dropdown({ value, options, onChange, ariaLabel, menuClassName = "" }) {
17
18
  const [open, setOpen] = useState(false);
18
19
  const [highlight, setHighlight] = useState(-1);
19
20
  const rootRef = useRef(null);
@@ -25,19 +26,10 @@ export function Dropdown({ value, options, onChange, ariaLabel }) {
25
26
  const selectedIndex = options.findIndex((o) => o.value === value);
26
27
  const selected = selectedIndex >= 0 ? options[selectedIndex] : null;
27
28
 
28
- // Close on any mousedown outside the dropdown; the portaled menu is not
29
- // inside rootRef, so it is checked separately. Effect cleanup also covers
30
- // unmounting while open.
31
- useEffect(() => {
32
- if (!open) return;
33
- const onDocMouseDown = (e) => {
34
- if (rootRef.current?.contains(e.target)) return;
35
- if (menuRef.current?.contains(e.target)) return;
36
- setOpen(false);
37
- };
38
- document.addEventListener("mousedown", onDocMouseDown);
39
- return () => document.removeEventListener("mousedown", onDocMouseDown);
40
- }, [open]);
29
+ // Close on any mousedown outside the dropdown (the portaled menu is not
30
+ // inside rootRef, so it is checked separately) and when another shell
31
+ // popover opens. Effect cleanup also covers unmounting while open.
32
+ usePopover(open, () => setOpen(false), [rootRef, menuRef]);
41
33
 
42
34
  // Position the portaled menu from the trigger: below when it fits, above
43
35
  // otherwise, height capped to the free space. Scroll and resize close the
@@ -136,12 +128,12 @@ export function Dropdown({ value, options, onChange, ariaLabel }) {
136
128
  </button>
137
129
  {open && menuBox && createPortal(
138
130
  <div
139
- className="dropdown-menu"
131
+ className={"dropdown-menu " + menuClassName}
140
132
  ref={menuRef}
141
133
  id={listId}
142
134
  role="listbox"
143
135
  aria-activedescendant={highlight >= 0 && highlight < options.length ? `${uid}-opt-${highlight}` : undefined}
144
- style={{ position: "fixed", zIndex: 80, ...menuBox, overflowY: "auto" }}
136
+ style={{ position: "fixed", zIndex: "var(--z-popover, 90)", ...menuBox, overflowY: "auto" }}
145
137
  onKeyDown={onKeyDown}
146
138
  >
147
139
  {options.map((opt, i) => (
@@ -1,12 +1,9 @@
1
- import { useRef, useState } from "react";
2
1
  import { isPlaygroundEmbed } from "./playground.js";
3
2
 
4
3
  export function useEmbedBridge() {
5
4
  const embedMode = ["scene", "playview"].includes(new URLSearchParams(globalThis.location?.search || "").get("embed"));
6
- // The landing page's try-it iframe: full studio interaction on a preset
7
- // scene with the project chrome hidden and saving off (see playground.js).
5
+ // The landing page's try-it iframe: full studio interaction with the
6
+ // project chrome hidden and saving off (see playground.js).
8
7
  const playgroundMode = isPlaygroundEmbed(globalThis.location?.search);
9
- const [playgroundHint, setPlaygroundHint] = useState(null);
10
- const playgroundExportRef = useRef(null);
11
- return { embedMode, playgroundMode, playgroundHint, setPlaygroundHint, playgroundExportRef };
8
+ return { embedMode, playgroundMode };
12
9
  }
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { usePlaybackEffects } from "./motion/playback-frame-store.js";
2
3
 
3
4
  export const RENDER_ACTIVITY_KEYS = new Set([
4
5
  "KeyW",
@@ -31,7 +32,8 @@ export function shouldRefreshIkExposure({ cameraGesture, cameraGestureEnded, exp
31
32
  * this hook covers direct ref mutations such as IK drags, camera look/dolly,
32
33
  * plan puck drags, scrubbing, and held WASD/QE movement.
33
34
  */
34
- export function useRenderActivity(alwaysActive) {
35
+ export function useRenderActivity(alwaysActive, playback = {}) {
36
+ usePlaybackEffects(playback);
35
37
  const [interactive, setInteractive] = useState(false);
36
38
 
37
39
  useEffect(() => {
@@ -7,6 +7,7 @@ import { useFrame, useThree } from "@react-three/fiber";
7
7
  import { GIZMO_LAYER } from "./dualview.jsx";
8
8
  import { GhostSet } from "./ghost-set.js";
9
9
  import { isTakeV3 } from "./motion3/playback.js";
10
+ import { playbackFrame, usePlaybackSnapshot } from "./motion/playback-frame-store.js";
10
11
  import { ghostFrames, takeKeyFrames, useGhostSettings } from "./view-ghosts.js";
11
12
 
12
13
  // Kit parts mount in the same commit as a take or record change, sometimes a frame later: the
@@ -17,14 +18,15 @@ const SOURCE_PASSES = 3;
17
18
  * rig: the active character's live rig; take: its installed motion; frame: the playhead;
18
19
  * objects: the authored records; propTake: { frameCount, fps, objectTracks } as SetProps gets it.
19
20
  */
20
- export function ViewGhosts({ rig, take, frame, objects, propTake }) {
21
+ export function ViewGhosts({ rig, take, objects, propTake }) {
22
+ const liveFrame = usePlaybackSnapshot(playbackFrame);
21
23
  const settings = useGhostSettings();
22
24
  const scene = useThree((state) => state.scene), gl = useThree((state) => state.gl), invalidate = useThree((state) => state.invalidate);
23
25
  const set = useMemo(() => new GhostSet({ layer: GIZMO_LAYER }), []);
24
26
  const on = settings.mode !== "off" && !!rig && isTakeV3(take);
25
27
  const keyFrames = useMemo(() => (on && settings.mode === "keys" ? takeKeyFrames(take, objects) : []), [on, settings.mode, take, objects]);
26
- const entries = useMemo(() => (on ? ghostFrames({ frame, last: take.frameCount - 1, ...settings, keyFrames }) : []),
27
- [on, frame, take, settings, keyFrames]);
28
+ const entries = useMemo(() => (on ? ghostFrames({ frame: liveFrame, last: take.frameCount - 1, ...settings, keyFrames }) : []),
29
+ [on, liveFrame, take, settings, keyFrames]);
28
30
  useEffect(() => {
29
31
  if (!on) return undefined;
30
32
  scene.add(set.object);
@@ -0,0 +1,37 @@
1
+ /** Return the centred aspect rectangle in the canvas not covered by chrome. */
2
+ export function visibleViewOffset({ canvas, leftColumn, rightColumn, topbar, dock }, aspect) {
3
+ if (!canvas || !Number.isFinite(aspect) || aspect <= 0) return null;
4
+ const edge = (rect, side, fallback) => rect?.width > 0 && rect?.height > 0 ? rect[side] : fallback;
5
+ const left = Math.max(canvas.left, edge(leftColumn, "right", canvas.left));
6
+ const right = Math.min(canvas.right, edge(rightColumn, "left", canvas.right));
7
+ const top = Math.max(canvas.top, edge(topbar, "bottom", canvas.top));
8
+ const bottom = Math.min(canvas.bottom, edge(dock, "top", canvas.bottom));
9
+ if (right - left < 50 || bottom - top < 50) return null;
10
+ const centerX = (left + right) / 2;
11
+ const centerY = (top + bottom) / 2;
12
+ return {
13
+ fullWidth: canvas.width,
14
+ fullHeight: canvas.height,
15
+ x: canvas.left + canvas.width / 2 - centerX,
16
+ y: canvas.top + canvas.height / 2 - centerY,
17
+ width: canvas.width,
18
+ height: canvas.height,
19
+ };
20
+ }
21
+
22
+ export function updateVisibleViewOffsets({ editorCam, poserCam, canvas, aspect }) {
23
+ const offset = visibleViewOffset({
24
+ canvas,
25
+ leftColumn: document.querySelector(".studio-left-column")?.getBoundingClientRect(),
26
+ rightColumn: document.querySelector(".studio-right-column")?.getBoundingClientRect(),
27
+ topbar: document.querySelector(".topbar")?.getBoundingClientRect(),
28
+ dock: document.querySelector(".studio-dock-slot")?.getBoundingClientRect(),
29
+ }, aspect);
30
+ for (const camera of [editorCam, poserCam]) if (camera) {
31
+ camera.aspect = offset?.fullWidth / offset?.fullHeight || aspect;
32
+ offset
33
+ ? camera.setViewOffset(offset.fullWidth, offset.fullHeight, offset.x, offset.y, offset.width, offset.height)
34
+ : camera.clearViewOffset();
35
+ camera.updateProjectionMatrix();
36
+ }
37
+ }
@@ -1,15 +1,15 @@
1
- import { Fragment, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
- import { FiAlertTriangle, FiArrowUp, FiCheck, FiChevronRight, FiClock, FiDownload, FiImage, FiMoreHorizontal, FiPaperclip, FiPlus, FiRotateCw, FiX } from "react-icons/fi";
3
- import { ATTACHMENT_FAILED_NOTICE, ATTACHMENT_LIMIT_NOTICE, ATTACHMENT_TYPE_NOTICE, attachmentFilesFromTransfer, attachmentFromFile, transferCarriesFiles } from "./attachment-image.js";
1
+ import { Fragment, Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { FiAlertTriangle, FiArrowUp, FiCamera, FiCheck, FiChevronRight, FiClock, FiDownload, FiImage, FiMoreHorizontal, FiPaperclip, FiPlus, FiPower, FiRotateCw, FiX } from "react-icons/fi";
3
+ import { ATTACHMENT_ACCEPT, ATTACHMENT_FAILED_NOTICE, ATTACHMENT_LIMIT_NOTICE, ATTACHMENT_TYPE_NOTICE, attachmentFilesFromTransfer, attachmentFromFile, transferCarriesFiles } from "./attachment-image.js";
4
4
  import { isImeComposing } from "../ime.js";
5
5
  import { useStudioShell } from "../shell/studio-shell-context.js";
6
6
  import { ko } from "../locale.js";
7
7
  import {
8
8
  AGENT_PANEL_OVERLAY_BREAKPOINT,
9
9
  AGENT_PANEL_RAIL_WIDTH,
10
- AGENT_PANEL_WIDTH_MAX,
11
- AGENT_PANEL_WIDTH_MIN,
10
+ AGENT_PANEL_WIDTH_MAX, AGENT_PANEL_WIDTH_MIN,
12
11
  AGENT_STATES,
12
+ agentServerUnavailable,
13
13
  describeActivity,
14
14
  JOB_STATE_COPY,
15
15
  createAgentChatStore,
@@ -28,19 +28,20 @@ import {
28
28
  formatTurnClock,
29
29
  formatResetIn,
30
30
  readStoredPanelWidth,
31
- readStoredSupervisor,
32
- storeModel,
33
- storePanelWidth,
34
- storeSupervisor,
31
+ storeModel, storePanelWidth,
35
32
  STUDIO_SESSION_STORAGE_KEY,
36
33
  selectedModelRoles,
37
34
  shortModelId,
38
35
  supervisorFollowUp,
39
36
  } from "./agent-client.js";
37
+ import { TaskDetail, TaskRows, TaskStrip, readStoredTaskMode, storeTaskMode } from "./TaskInline.jsx";
40
38
  import "./agent-panel.css";
39
+ // The task board (task mode, parallel Studio tasks) loads only where it is used: the Studio, never the workflow page.
40
+ const TaskBoard = lazy(() => import("./TaskBoard.jsx"));
41
41
  import { AgentProgressCard } from "./AgentProgressCard.jsx";
42
42
  import { LoginPanel } from "./LoginPanel.jsx";
43
43
  import { TurnSteps } from "./AgentTurnSteps.jsx";
44
+ import { PlanCard } from "./AgentPlanCard.jsx";
44
45
  import { ReplyText } from "./AgentReplyText.jsx";
45
46
  import { stepLabel, turnLayout } from "./agent-turn.js";
46
47
 
@@ -81,6 +82,20 @@ function ImageResultCard({ image, onUse, onUndo, onRegenerate, onOpen }) {
81
82
  </div>;
82
83
  }
83
84
 
85
+ // Plain `vite` serves the Studio without the agent server, so there is nothing to sign in to. The card says
86
+ // so and how to start it, in place of sign-in buttons that could only fail.
87
+ function OfflineCard({ probing, onRetry }) {
88
+ return <div className="agent-state-card agent-offline-card" data-agent-card="offline" role="status" aria-live="polite">
89
+ <FiPower className="agent-offline-icon" size={18} aria-hidden="true" />
90
+ <h3>{ko("Agent server is not running", "에이전트 서버가 실행 중이 아니에요")}</h3>
91
+ <p>{ko(<>Start CozyClay with <code>npx cozyclay</code> (or <code>npm run dev</code> in a checkout) to sign in.</>,
92
+ <>로그인하려면 <code>npx cozyclay</code>(체크아웃에서는 <code>npm run dev</code>)로 CozyClay를 시작하세요.</>)}</p>
93
+ <button type="button" className="agent-ghost-button agent-offline-retry" onClick={onRetry} disabled={probing} aria-busy={probing}>
94
+ <FiRotateCw size={11} aria-hidden="true" /> {probing ? ko("Checking…", "확인 중…") : ko("Retry", "다시 확인")}
95
+ </button>
96
+ </div>;
97
+ }
98
+
84
99
  function PausedCard({ resetAt, onRetry, onSwitchModel }) {
85
100
  const [now, setNow] = useState(() => Date.now());
86
101
  useEffect(() => {
@@ -275,6 +290,11 @@ export default function AgentPanel({
275
290
  const [menuOpen, setMenuOpen] = useState(false);
276
291
  const [account, setAccount] = useState(null);
277
292
  const [authState, setAuthState] = useState("loading");
293
+ // "offline" is its own auth state: no agent server answered /agent/models. Retry bumps `probe`.
294
+ const [probe, setProbe] = useState(0);
295
+ const [probing, setProbing] = useState(false);
296
+ // Why the last ChatGPT sign-in click did not start, under its button; a click never fails silently.
297
+ const [signInError, setSignInError] = useState("");
278
298
  // No model is assumed. Sending a guessed id is how a turn fails upstream with
279
299
  // nothing the panel can explain, so the composer waits for /agent/models.
280
300
  const [models, setModels] = useState([]);
@@ -292,10 +312,9 @@ export default function AgentPanel({
292
312
  // remembered so a reload does not quietly move the next turn to another
293
313
  // provider's model.
294
314
  const chooseModel = useCallback((id) => { setModel(id); setEffort(null); storeModel(id); }, []);
295
- // The supervisor switch (#728): a preference like the model pick. Off skips
296
- // the post-turn review for every turn sent from this panel.
297
- const [supervisorOn, setSupervisorOn] = useState(() => readStoredSupervisor());
298
- const toggleSupervisor = useCallback(() => setSupervisorOn((current) => { storeSupervisor(!current); return !current; }), []);
315
+ // Task mode (parallel Studio tasks): the chat talks to the main agent, which answers at once and runs tasks on the board.
316
+ const [taskMode, setTaskMode] = useState(() => surface === "studio" && readStoredTaskMode());
317
+ const toggleTaskMode = useCallback(() => setTaskMode((current) => { storeTaskMode(!current); return !current; }), []);
299
318
  // One place takes `{ providers, models }` from the sidecar: the grouped list
300
319
  // the dropdown draws, the flat key-addressed list every lookup uses, and the
301
320
  // model to open with. Returns false when nothing was advertised.
@@ -331,6 +350,7 @@ export default function AgentPanel({
331
350
  const [restoreReady, setRestoreReady] = useState(surface !== "studio");
332
351
 
333
352
  const composerRef = useRef(null);
353
+ const fileInputRef = useRef(null);
334
354
  const focusPendingRef = useRef(false);
335
355
  const transcriptRef = useRef(null);
336
356
 
@@ -371,8 +391,8 @@ export default function AgentPanel({
371
391
  if (!cancelled) {
372
392
  store.newSession();
373
393
  // A session id with no record behind it (the pane opened, nothing
374
- // was ever sent) is nothing to restore, not a failure.
375
- if (error?.status !== 404) setRestoreNotice("Previous conversation could not be restored");
394
+ // was ever sent) is nothing to restore, not a failure; with no agent server the offline card says why.
395
+ if (![404, 502, 503].includes(error?.status) && !(error instanceof TypeError || error instanceof SyntaxError)) setRestoreNotice("Previous conversation could not be restored");
376
396
  }
377
397
  } finally {
378
398
  if (!cancelled) setRestoreReady(true);
@@ -445,25 +465,18 @@ export default function AgentPanel({
445
465
  useEffect(() => {
446
466
  let cancelled = false;
447
467
  (async () => {
448
- try {
449
- const status = await transport.status();
450
- if (cancelled) return;
451
- setAccount(status);
452
- setAuthState(sessionState(status));
453
- } catch {
454
- if (!cancelled) setAuthState("signed-out");
455
- }
456
- try {
457
- const advertised = await transport.models();
458
- if (cancelled) return;
459
- if (!applyModelList(advertised)) { setModelsState("failed"); return; }
460
- } catch {
461
- // An unanswered model list is a visible state, not a silent guess.
462
- if (!cancelled) setModelsState("failed");
463
- }
468
+ const [status, advertised] = await Promise.allSettled([transport.status(), transport.models()]);
469
+ if (cancelled) return;
470
+ setProbing(false);
471
+ // No agent server behind the page: the panel says so instead of offering sign-ins that cannot work.
472
+ if (advertised.status === "rejected" && agentServerUnavailable(advertised.reason)) { setAuthState("offline"); setModelsState("failed"); return; }
473
+ if (status.status === "fulfilled") { setAccount(status.value); setAuthState(sessionState(status.value)); } else setAuthState("signed-out");
474
+ // An unanswered model list is a visible state, not a silent guess.
475
+ if (advertised.status === "rejected" || !applyModelList(advertised.value)) setModelsState("failed");
464
476
  })();
465
477
  return () => { cancelled = true; };
466
- }, [applyModelList, sessionState, transport]);
478
+ }, [applyModelList, probe, sessionState, transport]);
479
+ const retryServer = useCallback(() => { setProbing(true); setProbe((count) => count + 1); }, []);
467
480
 
468
481
  // Sign-in finishes in another window. The panel picks the session up when
469
482
  // this document is looked at again, or when the host announces the return —
@@ -601,6 +614,11 @@ export default function AgentPanel({
601
614
  attachFiles(images);
602
615
  return true;
603
616
  }, [attachFiles]);
617
+ // The paperclip opens the file picker; what it returns goes through the same rules as a drop.
618
+ const onPickFiles = useCallback((event) => {
619
+ takeTransfer({ files: event.target.files });
620
+ event.target.value = "";
621
+ }, [takeTransfer]);
604
622
  const onComposerPaste = useCallback((event) => {
605
623
  if (takeTransfer(event.clipboardData)) event.preventDefault();
606
624
  }, [takeTransfer]);
@@ -617,14 +635,16 @@ export default function AgentPanel({
617
635
  }, []);
618
636
  const onComposerDrop = useCallback((event) => {
619
637
  setDropping(false);
620
- if (takeTransfer(event.dataTransfer)) event.preventDefault();
638
+ // The textarea and the composer both listen; the drop bubbles, so only the first one takes it.
639
+ if (!event.defaultPrevented && takeTransfer(event.dataTransfer)) event.preventDefault();
621
640
  }, [takeTransfer]);
622
641
 
623
642
  const runTurn = useCallback((text) => {
624
643
  setAttachNotice(null);
625
644
  setSteerNotice("");
626
- return store.send(text, { attachFrame, model, effort: effort ?? undefined, supervisor: supervisorOn });
627
- }, [attachFrame, effort, model, store, supervisorOn]);
645
+ return store.send(text, { attachFrame, model, effort: effort ?? undefined, tasks: taskMode });
646
+ }, [attachFrame, effort, model, store, taskMode]);
647
+ const taskStepLabel = useCallback((call) => stepLabel(call, presentation.toolLabels), [presentation.toolLabels]);
628
648
  const stopTurn = useCallback(() => store.stop(), [store]);
629
649
  // Steering the turn that is already running: the composer's text joins THAT
630
650
  // turn instead of starting a second one. A refusal (the turn ended while the
@@ -645,10 +665,13 @@ export default function AgentPanel({
645
665
  // "ready" with nothing it may send (#379).
646
666
  const signIn = useCallback(async () => {
647
667
  setAuthState("signing-in");
668
+ setSignInError("");
648
669
  try {
649
670
  await transport.signIn();
650
671
  await refreshProviderState();
651
- } catch {
672
+ } catch (error) {
673
+ if (agentServerUnavailable(error)) { setAuthState("offline"); return; }
674
+ setSignInError(error?.message || ko("Sign-in could not start.", "로그인을 시작하지 못했어요."));
652
675
  setAuthState("signed-out");
653
676
  }
654
677
  }, [refreshProviderState, transport]);
@@ -819,7 +842,8 @@ export default function AgentPanel({
819
842
  {resetLabel && <span className="agent-account-reset">resets in {resetLabel}</span>}
820
843
  </div>}
821
844
  <button type="button" role="menuitem" onClick={clearContext}>Clear context</button>
822
- <button type="button" role="menuitem" className="agent-menu-keys" aria-expanded={keysOpen} onClick={toggleProviderKeys}>Connect a model…</button>
845
+ <button type="button" role="menuitem" className="agent-menu-keys" aria-expanded={keysOpen} onClick={toggleProviderKeys}
846
+ disabled={authState === "offline"} title={authState === "offline" ? ko("The agent server is not running", "에이전트 서버가 실행 중이 아니에요") : undefined}>Connect a model…</button>
823
847
  {account?.signedIn && <button type="button" role="menuitem" onClick={signOut}>Sign out</button>}
824
848
  </div>}
825
849
  </span>
@@ -827,20 +851,23 @@ export default function AgentPanel({
827
851
  </header>
828
852
 
829
853
  {restoreNotice && <div className="agent-toast" role="status">{restoreNotice}</div>}
830
- {steerNotice && <div className="agent-toast alert agent-steer-notice" role="alert">{steerNotice}</div>}
854
+ {steerNotice && <div className="agent-toast alert agent-steer-notice" role="alert">{steerNotice}</div>}{surface === "studio" && taskMode && authenticated && <TaskStrip />}
831
855
 
832
856
  {/* An inline section, not a modal: the panel stays the one place the
833
857
  conversation and the credentials it runs on are managed. */}
834
- {keysOpen && <LoginPanel onClose={() => setKeysOpen(false)} onChatGPT={signIn} onChanged={refreshProviderState} />}
858
+ {keysOpen && authState !== "offline" && <LoginPanel onClose={() => setKeysOpen(false)} onChatGPT={signIn} onChanged={refreshProviderState} />}
835
859
 
836
860
  <div className="agent-transcript" ref={transcriptRef} aria-live="polite" aria-label="Conversation" data-agent-transcript="true">
837
861
  {authState === "signed-out" && <div className="agent-state-card" data-agent-card="signed-out">
838
862
  <h3>Connect a model</h3>
839
863
  <p>Sign in with ChatGPT, or log in to another provider with a subscription, an API key, or your own proxy server. Sign-in happens in your browser — no token is ever stored in this page.</p>
840
864
  <button type="button" className="agent-primary-button agent-signin" onClick={signIn}>Sign in with ChatGPT</button>
865
+ {signInError && <p className="agent-key-error agent-signin-error" role="alert">{signInError}</p>}
841
866
  <button type="button" className="agent-ghost-button agent-use-key" onClick={openProviderKeys}>Log in with another provider</button>
842
867
  </div>}
843
868
 
869
+ {authState === "offline" && <OfflineCard probing={probing} onRetry={retryServer} />}
870
+
844
871
  {authState === "signing-in" && <div className="agent-state-card" data-agent-card="signing-in">
845
872
  <span className="agent-spinner" aria-hidden="true" />
846
873
  <h3>Waiting for your browser…</h3>
@@ -863,10 +890,10 @@ export default function AgentPanel({
863
890
  </div>}
864
891
 
865
892
  {/* One step list per turn (AgentTurnSteps.jsx); the motion progress card sits just above the latest one. */}
866
- {layout.map((entry) => {
893
+ {layout.map((entry, index) => {
867
894
  if (entry.type === "steps") return <Fragment key={entry.id}>
868
895
  {entry.last && progressCard}
869
- <div className="agent-row"><TurnSteps block={entry} now={activityNow} toolLabels={presentation.toolLabels} formatClock={formatTurnClock} onRetry={() => runTurn(chat.lastPrompt)} /></div>
896
+ <div className="agent-row"><TurnSteps block={entry} now={activityNow} toolLabels={presentation.toolLabels} formatClock={formatTurnClock} onRetry={() => runTurn(chat.lastPrompt)} /></div>{taskMode && layout[index + 1]?.item?.kind !== "assistant" && <TaskRows steps={entry.steps} />}
870
897
  </Fragment>;
871
898
  const { item } = entry;
872
899
  if (item.kind === "user") return <div className="agent-row user" key={item.id}>
@@ -875,17 +902,19 @@ export default function AgentPanel({
875
902
  </div>}
876
903
  <div className="agent-bubble">{item.text}</div>
877
904
  </div>;
878
- if (item.kind === "assistant") return <div className="agent-row assistant" key={item.id}><div className="agent-assistant-text"><ReplyText text={item.text} tail={streaming && <span className="agent-caret">▌</span>} /></div></div>;
905
+ if (item.kind === "assistant") return <Fragment key={item.id}><div className="agent-row assistant"><div className="agent-assistant-text"><ReplyText text={item.text} tail={streaming && <span className="agent-caret">▌</span>} /></div></div>{taskMode && layout[index - 1]?.type === "steps" && <TaskRows steps={layout[index - 1].steps} />}</Fragment>;
879
906
  if (item.kind === "job") return <div className="agent-row" key={item.id}><JobCard job={item} onStop={stopTurn} onAccept={(job) => store.acceptJob(job.jobId)} /></div>;
880
907
  if (item.kind === "receipt") return <div className="agent-row" key={item.id}><ReceiptCard item={item} /></div>;
881
908
  if (item.kind === "failure") return <div className="agent-row" key={item.id}><FailureCard failure={item.failure} onRetry={() => runTurn(chat.lastPrompt)} onFixKey={openProviderKeys} /></div>;
882
909
  if (item.kind === "supervisor") return <div className="agent-row" key={item.id}><SupervisorCard note={item} onSend={(text) => { store.setDraft(text); runTurn(text); }} /></div>;
910
+ if (item.kind === "plan") return <PlanCard key={item.id} plan={item} live={streaming && !item.outcome} />;
883
911
  return <div className="agent-row" key={item.id}><ImageResultCard image={item} onUse={(image) => store.applyImage(image.id)} onUndo={(image) => store.undoImage(image.id)} onRegenerate={() => runTurn(chat.lastPrompt)} onOpen={setLightbox} /></div>;
884
912
  })}
885
913
 
886
- {!layout.some((entry) => entry.type === "steps" && entry.last) && progressCard}
914
+ {!layout.some((entry) => entry.type === "steps" && entry.last) && progressCard}{taskMode && <TaskRows items={items} />}
887
915
  {rateLimit && <PausedCard resetAt={rateLimit.resetAt} onRetry={() => { store.clearRateLimit(); runTurn(chat.lastPrompt); }} onSwitchModel={switchModel} />}
888
916
  </div>
917
+ {surface === "studio" && taskMode && authenticated && <><Suspense fallback={null}><TaskBoard sessionId={chat.sessionId} pulse={chat.lastTurn?.endedAt ?? 0} labelOf={taskStepLabel} onMainNote={store.note} /></Suspense><TaskDetail /></>}
889
918
 
890
919
  {/* Never a blank panel: busy states name the phase and tick, a finished
891
920
  turn states how it ended, and idle says so in as many words. */}
@@ -925,8 +954,12 @@ export default function AgentPanel({
925
954
  />
926
955
  <div className="agent-composer-controls">
927
956
  <button type="button" className="agent-attach-chip" aria-pressed={attachFrame} aria-label="Attach current frame" title="Attach current frame" onClick={() => setAttachFrame((value) => !value)}>
928
- {attachFrame ? <span className="agent-attach-thumb" aria-hidden="true" /> : <FiPaperclip size={13} aria-hidden="true" />}
957
+ {attachFrame ? <span className="agent-attach-thumb" aria-hidden="true" /> : <FiCamera size={13} aria-hidden="true" />}
958
+ </button>
959
+ <button type="button" className="agent-attach-chip agent-attach-file" aria-label="Attach image or video" title="Attach image or video" disabled={composerDisabled} onClick={() => fileInputRef.current?.click()}>
960
+ <FiPaperclip size={13} aria-hidden="true" />
929
961
  </button>
962
+ <input ref={fileInputRef} type="file" accept={ATTACHMENT_ACCEPT} multiple hidden onChange={onPickFiles} />
930
963
  {/* Five providers in one dropdown: grouped by the provider that serves
931
964
  them, and a provider without a credential still lists its models —
932
965
  unpickable, and saying what they are waiting for. */}
@@ -949,8 +982,8 @@ export default function AgentPanel({
949
982
  </select>
950
983
  )}
951
984
  {surface === "studio" && (
952
- <button type="button" className={`agent-supervisor-toggle${supervisorOn ? " is-on" : ""}`} role="switch" aria-checked={supervisorOn} aria-label={ko("Supervisor review", "감독 검토")} title={ko(supervisorOn ? "Supervisor review is on: a note arrives after each editing turn" : "Supervisor review is off", supervisorOn ? "감독 검토 켜짐: 편집 턴이 끝나면 메모가 옵니다" : "감독 검토 꺼짐")} onClick={toggleSupervisor}>
953
- {ko("Supervisor", "감독")} {supervisorOn ? "ON" : "OFF"}
985
+ <button type="button" className={`agent-supervisor-toggle agent-tasks-toggle${taskMode ? " is-on" : ""}`} role="switch" aria-checked={taskMode} aria-label={ko("Task mode", "작업 모드")} title={ko(taskMode ? "Task mode is on: the agent answers at once and runs your requests as parallel tasks" : "Task mode is off: one request at a time", taskMode ? "작업 모드 켜짐: 에이전트가 바로 답하고 요청을 병렬 작업으로 돌려요" : "작업 모드 꺼짐: 한 번에 요청 하나")} onClick={toggleTaskMode} disabled={streaming}>
986
+ {ko("Tasks", "작업")} {taskMode ? "ON" : "OFF"}
954
987
  </button>
955
988
  )}
956
989
  <span className="agent-composer-spacer" aria-hidden="true" />
@@ -0,0 +1,41 @@
1
+ // A turn's step checklist in the agent panel (agent-plan.js keeps it): a header that says how far the plan is ("2/5")
2
+ // beside a thin progress bar, and one row per step: done steps checked, the step under way highlighted with a soft
3
+ // pulse, the rest waiting. While the turn runs the card stays pinned to the top of the transcript, so what is happening
4
+ // and what comes next stay in view however long the step list below grows. Once the turn ends nothing pulses, and a
5
+ // step a failed or stopped turn left unfinished reads muted.
6
+ import { FiCheck, FiMinus } from "react-icons/fi";
7
+ import { ko } from "../locale.js";
8
+ import "./agent-plan.css";
9
+
10
+ const STATUS_WORDS = {
11
+ done: ko("done", "완료"),
12
+ active: ko("in progress", "진행 중"),
13
+ pending: ko("next", "대기"),
14
+ unfinished: ko("not finished", "미완료"),
15
+ };
16
+
17
+ function PlanMark({ status }) {
18
+ if (status === "done") return <FiCheck className="agent-plan-mark done" size={11} aria-hidden="true" />;
19
+ if (status === "unfinished") return <FiMinus className="agent-plan-mark unfinished" size={11} aria-hidden="true" />;
20
+ return <span className={`agent-plan-mark ${status}`} aria-hidden="true" />;
21
+ }
22
+
23
+ export function PlanCard({ plan, live }) {
24
+ const total = plan.steps.length, done = plan.steps.filter(step => step.status === "done").length;
25
+ const state = live ? "live" : plan.outcome ?? "done";
26
+ return <div className="agent-row agent-plan-row" data-plan-live={live ? "true" : "false"}>
27
+ <section className="agent-card agent-plan" data-agent-card="plan" data-plan-state={state} aria-label={ko(`Plan, ${done} of ${total} done`, `계획, ${total}단계 중 ${done}단계 완료`)}>
28
+ <div className="agent-plan-head">
29
+ <span className="agent-plan-title">{ko("Plan", "계획")}</span>
30
+ <span className="agent-plan-bar" aria-hidden="true"><span className="agent-plan-fill" style={{ "--agent-plan-done": total ? done / total : 0 }} /></span>
31
+ <span className="agent-plan-count">{done}/{total}</span>
32
+ </div>
33
+ <ol className="agent-plan-steps">
34
+ {plan.steps.map((step, index) => <li key={index} className={`agent-plan-step ${step.status}`} data-plan-status={step.status} aria-current={step.status === "active" ? "step" : undefined} aria-label={`${step.text}: ${STATUS_WORDS[step.status] ?? step.status}`}>
35
+ <PlanMark status={step.status} />
36
+ <span className="agent-plan-text">{step.text}</span>
37
+ </li>)}
38
+ </ol>
39
+ </section>
40
+ </div>;
41
+ }
@@ -45,7 +45,7 @@ function SheetViewer({ sheet, onClose }) {
45
45
  }
46
46
 
47
47
  /** `since`: when the latest turn started (null: no turn yet). Drafts and images from before it are not this turn's
48
- * work, such as the take the camera tutorial compiles at startup. `live`: that turn is still running. */
48
+ * work, such as a take compiled at startup. `live`: that turn is still running. */
49
49
  export function AgentProgressCard({ items, since = null, live = false }) {
50
50
  useSyncExternalStore(subscribeProgress, progressVersion);
51
51
  const [open, setOpen] = useState(null);
@@ -0,0 +1,102 @@
1
+ // Task mode's board, headless (task-board.js): it follows the main session's task events, each running task's
2
+ // progress and the compile pool, and publishes them to taskView; TaskInline.jsx draws them in the chat. It also marks
3
+ // the outliner rows inside a running task's scope, and carries the main agent's own reports (main.turn) into the chat.
4
+ // Loaded lazily: the compile pool it watches belongs to the Studio, never the workflow page.
5
+ import { useEffect, useMemo, useReducer, useRef, useState } from "react";
6
+ import { ko } from "../locale.js";
7
+ import { useBus } from "../app-context.js";
8
+ import { compileActivity, watchCompileActivity } from "../commands/motion3-compile.js";
9
+ import { createTaskBoardClient, emptyBoard, progressLine, reduceBoard, reduceScopes, seedBoard, taskView } from "./task-board.js";
10
+
11
+ const RETRY_MS = 2000;
12
+ const sleep = (ms, signal) => new Promise(resolve => { const timer = setTimeout(resolve, ms); signal.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true }); });
13
+
14
+ /** Marks outliner rows (data-node-id is the scope key: object:<id>, character:<id>) inside running tasks' scopes. */
15
+ function useScopeBadges(scopes, titles) {
16
+ useEffect(() => {
17
+ const owner = new Map();
18
+ for (const [taskId, keys] of Object.entries(scopes)) for (const key of keys) owner.set(key, titles[taskId] ?? taskId);
19
+ const paint = () => {
20
+ for (const row of document.querySelectorAll("[data-node-id]")) {
21
+ const title = owner.get(row.getAttribute("data-node-id"));
22
+ if (title) { row.setAttribute("data-task-scope", title); row.setAttribute("title", ko(`In task: ${title}`, `작업 중: ${title}`)); }
23
+ else if (row.hasAttribute("data-task-scope")) { row.removeAttribute("data-task-scope"); row.removeAttribute("title"); }
24
+ }
25
+ };
26
+ paint();
27
+ const observer = new MutationObserver(paint);
28
+ observer.observe(document.body, { childList: true, subtree: true });
29
+ return () => { observer.disconnect(); owner.clear(); paint(); };
30
+ }, [scopes, titles]);
31
+ }
32
+
33
+ export default function TaskBoard({ sessionId, pulse, labelOf, onMainNote }) {
34
+ const client = useMemo(() => createTaskBoardClient(), []);
35
+ const bus = useBus();
36
+ const [board, dispatch] = useReducer((state, action) => (action.seed ? seedBoard(state, action.seed) : action.reset ? emptyBoard() : reduceBoard(state, action.event)), undefined, emptyBoard);
37
+ const [scopes, setScopes] = useState({});
38
+ const [activity, setActivity] = useState(compileActivity);
39
+ const afterRef = useRef(0);
40
+ const noteRef = useRef(onMainNote);
41
+ useEffect(() => { afterRef.current = board.after; noteRef.current = onMainNote; }, [board.after, onMainNote]);
42
+
43
+ useEffect(() => { dispatch({ reset: true }); afterRef.current = 0; }, [sessionId]);
44
+ useEffect(() => watchCompileActivity(setActivity), []);
45
+ useEffect(() => bus.subscribe(event => {
46
+ if (typeof event?.type !== "string" || !event.type.startsWith("task.")) return;
47
+ setScopes(current => reduceScopes(current, event));
48
+ if (event.type === "task.userEdit" || event.type === "task.undone") dispatch({ event });
49
+ }), [bus]);
50
+
51
+ // The board's live events; the main session exists only after its first turn, so a refusal retries (pulse: a turn ended).
52
+ useEffect(() => {
53
+ if (!sessionId) return undefined;
54
+ const controller = new AbortController();
55
+ (async () => {
56
+ while (!controller.signal.aborted) {
57
+ try {
58
+ const { tasks } = await client.board(sessionId);
59
+ dispatch({ seed: tasks });
60
+ await client.events(sessionId, afterRef.current, event => {
61
+ if (event.type === "main.turn" && event.turnId) {
62
+ let text = "";
63
+ client.frames(event.turnId, frame => { if (frame.type === "text.delta") text += frame.text ?? ""; if (frame.type === "tool.start") text = ""; }, controller.signal)
64
+ .then(() => noteRef.current?.(text), () => {});
65
+ }
66
+ dispatch({ event });
67
+ }, controller.signal);
68
+ } catch { /* not there yet, or the stream dropped: try again */ }
69
+ await sleep(RETRY_MS, controller.signal);
70
+ }
71
+ })();
72
+ return () => controller.abort();
73
+ }, [client, sessionId, pulse]);
74
+
75
+ // A running card's progress line: its own frames, the last tool it started.
76
+ const running = board.order.filter(id => board.tasks[id]?.status === "running" && board.tasks[id]?.turnId);
77
+ const runningKey = running.map(id => `${id}:${board.tasks[id].turnId}`).join();
78
+ useEffect(() => {
79
+ const controllers = running.map(id => {
80
+ const controller = new AbortController(), { turnId } = board.tasks[id];
81
+ client.frames(turnId, frame => { const line = progressLine(frame, labelOf); if (line) dispatch({ event: { type: "task.progress", taskId: id, status: "running", progress: line } }); }, controller.signal).catch(() => {});
82
+ return controller;
83
+ });
84
+ return () => controllers.forEach(controller => controller.abort());
85
+ // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ }, [client, runningKey, labelOf]);
87
+
88
+ const titles = useMemo(() => Object.fromEntries(board.order.map(id => [id, board.tasks[id].title])), [board]);
89
+ useScopeBadges(scopes, titles);
90
+
91
+ useEffect(() => {
92
+ taskView.set({ client, labelOf, undo: async taskId => {
93
+ const receipt = await bus.run("task.undo", { taskId });
94
+ if (receipt?.ok === false) throw new Error(receipt.message ?? receipt.code);
95
+ dispatch({ event: { type: "task.undone", taskId } });
96
+ } });
97
+ }, [bus, client, labelOf]);
98
+ useEffect(() => { taskView.set({ board, activity }); }, [board, activity]);
99
+ // Task mode off or another session: nothing of this board is shown any more.
100
+ useEffect(() => () => taskView.set({ board: emptyBoard(), open: null }), [sessionId]);
101
+ return null;
102
+ }