cozyclay 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +95 -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 +22 -56
  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/session-store.mjs +2 -2
  10. package/bin/agent/studio-prompt.mjs +1 -1
  11. package/bin/agent/studio-task-routes.mjs +185 -0
  12. package/bin/agent/studio-tasks.mjs +156 -0
  13. package/bin/agent/studio-tools.mjs +3 -2
  14. package/bin/agent/studio-turn-transaction.mjs +76 -0
  15. package/bin/cozyclay.mjs +5 -7
  16. package/dist/app/index.html +7 -6
  17. package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
  18. package/dist/assets/app-B2BpfCl2.js +5088 -0
  19. package/dist/assets/app-BlwOpest.css +1 -0
  20. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  21. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  22. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  23. package/dist/assets/motion3-compile-B887n77J.js +1 -0
  24. package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
  25. package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
  26. package/dist/assets/task-board-BKaM7d-9.js +14 -0
  27. package/dist/assets/workflow-B8Dhzexj.js +179 -0
  28. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  29. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  30. package/dist/cozyclay-package.json +1 -1
  31. package/dist/greybox-to-video/index.html +2 -3
  32. package/dist/index.html +7 -57
  33. package/dist/llms.txt +2 -1
  34. package/dist/previs-software/index.html +2 -2
  35. package/dist/privacy/index.html +3 -7
  36. package/dist/seedance-camera-control/index.html +2 -2
  37. package/dist/sitemap.xml +5 -5
  38. package/dist/sw.js +5 -1
  39. package/dist/thumbs/blank-stage-dark.webp +0 -0
  40. package/dist/thumbs/blank-stage-light.webp +0 -0
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/mesh-file.mjs +3 -1
  43. package/mcp/server.mjs +3 -0
  44. package/package.json +3 -3
  45. package/src/App.jsx +50 -281
  46. package/src/analytics.js +3 -148
  47. package/src/app-stage.jsx +1 -1
  48. package/src/asset-pane.jsx +5 -10
  49. package/src/auto-flat.js +10 -0
  50. package/src/bus-task-scopes.js +69 -0
  51. package/src/bus-task-targets.js +48 -0
  52. package/src/bus-tasks.js +181 -0
  53. package/src/command-bus.js +15 -40
  54. package/src/command-history.js +36 -0
  55. package/src/commands/cast.js +10 -3
  56. package/src/commands/motion.js +2 -1
  57. package/src/commands/motion3-compile.js +51 -27
  58. package/src/commands/motion3.js +13 -7
  59. package/src/commands/project.js +1 -1
  60. package/src/commands/shared.js +9 -0
  61. package/src/commands/shot.js +16 -4
  62. package/src/controls.jsx +0 -11
  63. package/src/cuts.js +11 -0
  64. package/src/domains/cast.js +3 -2
  65. package/src/domains/motion.js +3 -10
  66. package/src/domains/playback.js +32 -0
  67. package/src/domains/scenes.js +3 -22
  68. package/src/domains/shots.js +5 -5
  69. package/src/dualview.jsx +11 -15
  70. package/src/hierarchy-panel.css +18 -4
  71. package/src/hierarchy-panel.jsx +12 -11
  72. package/src/kits/held.js +3 -2
  73. package/src/light-blockers.js +2 -2
  74. package/src/main.jsx +5 -0
  75. package/src/motion/ground.js +5 -14
  76. package/src/motion/playback-frame-store.js +79 -0
  77. package/src/motion/playback-probe.js +16 -0
  78. package/src/motion/playback-subject.js +7 -0
  79. package/src/motion/timeline.css +43 -5
  80. package/src/motion/timeline.jsx +4 -3
  81. package/src/motion3/body.js +37 -5
  82. package/src/motion3/carried.js +11 -3
  83. package/src/motion3/compile-job.js +1 -1
  84. package/src/motion3/compiler/anchors.js +33 -5
  85. package/src/motion3/compiler/compile.js +27 -4
  86. package/src/motion3/compiler/finger-close.js +137 -0
  87. package/src/motion3/compiler/grip.js +9 -3
  88. package/src/motion3/compiler/limbs.js +18 -3
  89. package/src/motion3/compiler/pose.js +6 -0
  90. package/src/motion3/compiler/socket-grip.js +34 -10
  91. package/src/motion3/compiler/validate-objects.js +17 -2
  92. package/src/motion3/compiler/validate-parts.js +2 -1
  93. package/src/motion3/compiler/vocab.js +4 -4
  94. package/src/motion3/describe.js +2 -2
  95. package/src/motion3/docs.js +1 -1
  96. package/src/motion3/fix/index.js +7 -3
  97. package/src/motion3/flight.js +38 -0
  98. package/src/motion3/free-motion.js +10 -7
  99. package/src/motion3/load.js +125 -0
  100. package/src/motion3/mass.js +23 -0
  101. package/src/motion3/object-keys.js +11 -6
  102. package/src/motion3/object-pose.js +11 -3
  103. package/src/motion3/pipeline.js +5 -0
  104. package/src/motion3/qa/explain.js +3 -1
  105. package/src/motion3/qa/grasp-checks.js +123 -0
  106. package/src/motion3/qa/hints-grasp.js +71 -0
  107. package/src/motion3/qa/index.js +3 -1
  108. package/src/motion3/qa/object-checks.js +7 -2
  109. package/src/motion3/qa/patch.js +2 -1
  110. package/src/motion3/qa/spec-checks.js +21 -1
  111. package/src/motion3/schema/fields.js +3 -3
  112. package/src/motion3/schema/topics.js +11 -10
  113. package/src/motion3/schema/types.js +5 -1
  114. package/src/motion3/seat.js +84 -0
  115. package/src/motion3/settle.js +11 -6
  116. package/src/motion3/shapes.js +70 -0
  117. package/src/motion3/skills/basic.js +61 -13
  118. package/src/motion3/skills/catch.js +71 -0
  119. package/src/motion3/skills/index.js +13 -4
  120. package/src/motion3/skills/info.js +14 -1
  121. package/src/motion3/skills/reach-human.js +139 -0
  122. package/src/motion3/skills/stance.js +92 -0
  123. package/src/motion3/skills/throw.js +72 -0
  124. package/src/motion3/sockets.js +21 -4
  125. package/src/motion3/views.js +28 -5
  126. package/src/object-catalog.jsx +0 -4
  127. package/src/object-gizmo.jsx +2 -1
  128. package/src/object-path.js +1 -1
  129. package/src/panels/ProjectPanel.jsx +42 -21
  130. package/src/panels/details.css +15 -4
  131. package/src/popover.js +37 -0
  132. package/src/posestudio.jsx +15 -10
  133. package/src/project-browser-art.jsx +19 -0
  134. package/src/project-browser.css +74 -121
  135. package/src/project-browser.jsx +36 -60
  136. package/src/props.jsx +4 -7
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +3 -7
  139. package/src/scenes.js +4 -5
  140. package/src/settings-menu.jsx +1 -20
  141. package/src/shell/BottomDock.jsx +9 -9
  142. package/src/shell/MenuBar.jsx +1 -9
  143. package/src/shell/PreferencesDialog.jsx +3 -4
  144. package/src/shell/ViewportToolbar.jsx +3 -3
  145. package/src/shell/add-camera-shot.js +11 -0
  146. package/src/shell/glass-regions.css +3 -2
  147. package/src/shell/glass.css +0 -5
  148. package/src/shell/lens-scrub-math.js +51 -0
  149. package/src/shell/lens-scrub.css +73 -0
  150. package/src/shell/lens-scrub.jsx +98 -0
  151. package/src/shell/mode.css +6 -5
  152. package/src/shell/preferences.css +1 -32
  153. package/src/shell/shell.css +18 -1
  154. package/src/shell/studio-shell-context.js +5 -0
  155. package/src/shot-frame.js +26 -0
  156. package/src/stage-canvas-rigs.jsx +7 -8
  157. package/src/stage-characters.jsx +26 -0
  158. package/src/stage-gizmo.js +43 -0
  159. package/src/stage-labels-ko.js +0 -6
  160. package/src/stage-startup-defaults.js +3 -9
  161. package/src/starter-scenes.js +5 -11
  162. package/src/studio-agent-commands.js +2 -2
  163. package/src/studio-agent-protocol.js +2 -7
  164. package/src/studio-app-binding.js +3 -3
  165. package/src/studio-elements.js +1 -1
  166. package/src/studio-error-codes.js +1 -1
  167. package/src/studio-motion-visual.js +4 -4
  168. package/src/styles/themes.css +13 -0
  169. package/src/styles.css +6 -674
  170. package/src/text/text-object.jsx +1 -1
  171. package/src/ui.jsx +8 -16
  172. package/src/use-embed-bridge.js +3 -6
  173. package/src/use-render-activity.js +3 -1
  174. package/src/view-ghosts.jsx +5 -3
  175. package/src/viewport-visible-rect.js +37 -0
  176. package/src/workflow/AgentPanel.jsx +75 -42
  177. package/src/workflow/AgentProgressCard.jsx +1 -1
  178. package/src/workflow/TaskBoard.jsx +102 -0
  179. package/src/workflow/TaskInline.jsx +114 -0
  180. package/src/workflow/agent-client.js +11 -25
  181. package/src/workflow/agent-panel.css +14 -1
  182. package/src/workflow/attachment-image.js +10 -3
  183. package/src/workflow/attachment-video.js +91 -0
  184. package/src/workflow/task-board.css +98 -0
  185. package/src/workflow/task-board.js +169 -0
  186. package/tools/check-eslint.mjs +2 -1
  187. package/tools/check-knip.mjs +2 -1
  188. package/tools/dev/pages/greybox-to-video.html +2 -3
  189. package/tools/dev/pages/previs-software.html +2 -2
  190. package/tools/dev/pages/privacy.html +3 -7
  191. package/tools/dev/pages/seedance-camera-control.html +2 -2
  192. package/tools/dev-full.mjs +11 -2
  193. package/tools/eslint-baseline.json +6 -6
  194. package/tools/knip-baseline.json +0 -11
  195. package/tools/loc-baseline.json +30 -31
  196. package/tools/metrics-snapshot.mjs +43 -5
  197. package/tools/render-start-thumbs.mjs +175 -0
  198. package/tools/run-tests.mjs +7 -14
  199. package/tools/sitemap.mjs +1 -1
  200. package/dist/assets/analytics-DbKG0NgD.js +0 -6
  201. package/dist/assets/app-34i-iUy1.js +0 -5088
  202. package/dist/assets/app-f-fkV5uP.css +0 -1
  203. package/dist/assets/compile-worker-DYpa2nvO.js +0 -5
  204. package/dist/assets/first-shot-handoff-CrGZ3n_O.js +0 -1
  205. package/dist/assets/landing-mq5bnqFE.js +0 -1
  206. package/dist/assets/scene-asset-sync-7J-w5oNh.js +0 -20
  207. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  208. package/dist/assets/workflow-dUhurYUc.js +0 -179
  209. package/dist/scenes/city-block.cclayproject +0 -1
  210. package/src/burn-in.js +0 -91
  211. package/src/camera-tutorial.jsx +0 -520
  212. package/src/facing-marks.js +0 -88
  213. package/src/first-shot-handoff.js +0 -44
  214. package/src/panels/motion.css +0 -197
  215. package/src/set-pieces.jsx +0 -183
  216. package/src/tutorial-analytics.js +0 -86
  217. package/src/tutorial-walk.js +0 -17
  218. package/src/use-camera-tutorial.js +0 -50
  219. package/src/use-semantic-state.js +0 -12
  220. package/tools/metrics/archive-traffic.mjs +0 -138
  221. 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,6 +1,6 @@
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";
@@ -10,6 +10,7 @@ import {
10
10
  AGENT_PANEL_WIDTH_MAX,
11
11
  AGENT_PANEL_WIDTH_MIN,
12
12
  AGENT_STATES,
13
+ agentServerUnavailable,
13
14
  describeActivity,
14
15
  JOB_STATE_COPY,
15
16
  createAgentChatStore,
@@ -28,16 +29,17 @@ import {
28
29
  formatTurnClock,
29
30
  formatResetIn,
30
31
  readStoredPanelWidth,
31
- readStoredSupervisor,
32
32
  storeModel,
33
33
  storePanelWidth,
34
- storeSupervisor,
35
34
  STUDIO_SESSION_STORAGE_KEY,
36
35
  selectedModelRoles,
37
36
  shortModelId,
38
37
  supervisorFollowUp,
39
38
  } from "./agent-client.js";
39
+ import { TaskDetail, TaskRows, TaskStrip, readStoredTaskMode, storeTaskMode } from "./TaskInline.jsx";
40
40
  import "./agent-panel.css";
41
+ // The task board (task mode, parallel Studio tasks) loads only where it is used: the Studio, never the workflow page.
42
+ const TaskBoard = lazy(() => import("./TaskBoard.jsx"));
41
43
  import { AgentProgressCard } from "./AgentProgressCard.jsx";
42
44
  import { LoginPanel } from "./LoginPanel.jsx";
43
45
  import { TurnSteps } from "./AgentTurnSteps.jsx";
@@ -81,6 +83,20 @@ function ImageResultCard({ image, onUse, onUndo, onRegenerate, onOpen }) {
81
83
  </div>;
82
84
  }
83
85
 
86
+ // Plain `vite` serves the Studio without the agent server, so there is nothing to sign in to. The card says
87
+ // so and how to start it, in place of sign-in buttons that could only fail.
88
+ function OfflineCard({ probing, onRetry }) {
89
+ return <div className="agent-state-card agent-offline-card" data-agent-card="offline" role="status" aria-live="polite">
90
+ <FiPower className="agent-offline-icon" size={18} aria-hidden="true" />
91
+ <h3>{ko("Agent server is not running", "에이전트 서버가 실행 중이 아니에요")}</h3>
92
+ <p>{ko(<>Start CozyClay with <code>npx cozyclay</code> (or <code>npm run dev</code> in a checkout) to sign in.</>,
93
+ <>로그인하려면 <code>npx cozyclay</code>(체크아웃에서는 <code>npm run dev</code>)로 CozyClay를 시작하세요.</>)}</p>
94
+ <button type="button" className="agent-ghost-button agent-offline-retry" onClick={onRetry} disabled={probing} aria-busy={probing}>
95
+ <FiRotateCw size={11} aria-hidden="true" /> {probing ? ko("Checking…", "확인 중…") : ko("Retry", "다시 확인")}
96
+ </button>
97
+ </div>;
98
+ }
99
+
84
100
  function PausedCard({ resetAt, onRetry, onSwitchModel }) {
85
101
  const [now, setNow] = useState(() => Date.now());
86
102
  useEffect(() => {
@@ -275,6 +291,11 @@ export default function AgentPanel({
275
291
  const [menuOpen, setMenuOpen] = useState(false);
276
292
  const [account, setAccount] = useState(null);
277
293
  const [authState, setAuthState] = useState("loading");
294
+ // "offline" is its own auth state: no agent server answered /agent/models. Retry bumps `probe`.
295
+ const [probe, setProbe] = useState(0);
296
+ const [probing, setProbing] = useState(false);
297
+ // Why the last ChatGPT sign-in click did not start, under its button; a click never fails silently.
298
+ const [signInError, setSignInError] = useState("");
278
299
  // No model is assumed. Sending a guessed id is how a turn fails upstream with
279
300
  // nothing the panel can explain, so the composer waits for /agent/models.
280
301
  const [models, setModels] = useState([]);
@@ -292,10 +313,9 @@ export default function AgentPanel({
292
313
  // remembered so a reload does not quietly move the next turn to another
293
314
  // provider's model.
294
315
  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; }), []);
316
+ // Task mode (parallel Studio tasks): the chat talks to the main agent, which answers at once and runs tasks on the board.
317
+ const [taskMode, setTaskMode] = useState(() => surface === "studio" && readStoredTaskMode());
318
+ const toggleTaskMode = useCallback(() => setTaskMode((current) => { storeTaskMode(!current); return !current; }), []);
299
319
  // One place takes `{ providers, models }` from the sidecar: the grouped list
300
320
  // the dropdown draws, the flat key-addressed list every lookup uses, and the
301
321
  // model to open with. Returns false when nothing was advertised.
@@ -331,6 +351,7 @@ export default function AgentPanel({
331
351
  const [restoreReady, setRestoreReady] = useState(surface !== "studio");
332
352
 
333
353
  const composerRef = useRef(null);
354
+ const fileInputRef = useRef(null);
334
355
  const focusPendingRef = useRef(false);
335
356
  const transcriptRef = useRef(null);
336
357
 
@@ -371,8 +392,8 @@ export default function AgentPanel({
371
392
  if (!cancelled) {
372
393
  store.newSession();
373
394
  // 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");
395
+ // was ever sent) is nothing to restore, not a failure; with no agent server the offline card says why.
396
+ if (![404, 502, 503].includes(error?.status) && !(error instanceof TypeError || error instanceof SyntaxError)) setRestoreNotice("Previous conversation could not be restored");
376
397
  }
377
398
  } finally {
378
399
  if (!cancelled) setRestoreReady(true);
@@ -445,25 +466,18 @@ export default function AgentPanel({
445
466
  useEffect(() => {
446
467
  let cancelled = false;
447
468
  (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
- }
469
+ const [status, advertised] = await Promise.allSettled([transport.status(), transport.models()]);
470
+ if (cancelled) return;
471
+ setProbing(false);
472
+ // No agent server behind the page: the panel says so instead of offering sign-ins that cannot work.
473
+ if (advertised.status === "rejected" && agentServerUnavailable(advertised.reason)) { setAuthState("offline"); setModelsState("failed"); return; }
474
+ if (status.status === "fulfilled") { setAccount(status.value); setAuthState(sessionState(status.value)); } else setAuthState("signed-out");
475
+ // An unanswered model list is a visible state, not a silent guess.
476
+ if (advertised.status === "rejected" || !applyModelList(advertised.value)) setModelsState("failed");
464
477
  })();
465
478
  return () => { cancelled = true; };
466
- }, [applyModelList, sessionState, transport]);
479
+ }, [applyModelList, probe, sessionState, transport]);
480
+ const retryServer = useCallback(() => { setProbing(true); setProbe((count) => count + 1); }, []);
467
481
 
468
482
  // Sign-in finishes in another window. The panel picks the session up when
469
483
  // this document is looked at again, or when the host announces the return —
@@ -601,6 +615,11 @@ export default function AgentPanel({
601
615
  attachFiles(images);
602
616
  return true;
603
617
  }, [attachFiles]);
618
+ // The paperclip opens the file picker; what it returns goes through the same rules as a drop.
619
+ const onPickFiles = useCallback((event) => {
620
+ takeTransfer({ files: event.target.files });
621
+ event.target.value = "";
622
+ }, [takeTransfer]);
604
623
  const onComposerPaste = useCallback((event) => {
605
624
  if (takeTransfer(event.clipboardData)) event.preventDefault();
606
625
  }, [takeTransfer]);
@@ -617,14 +636,16 @@ export default function AgentPanel({
617
636
  }, []);
618
637
  const onComposerDrop = useCallback((event) => {
619
638
  setDropping(false);
620
- if (takeTransfer(event.dataTransfer)) event.preventDefault();
639
+ // The textarea and the composer both listen; the drop bubbles, so only the first one takes it.
640
+ if (!event.defaultPrevented && takeTransfer(event.dataTransfer)) event.preventDefault();
621
641
  }, [takeTransfer]);
622
642
 
623
643
  const runTurn = useCallback((text) => {
624
644
  setAttachNotice(null);
625
645
  setSteerNotice("");
626
- return store.send(text, { attachFrame, model, effort: effort ?? undefined, supervisor: supervisorOn });
627
- }, [attachFrame, effort, model, store, supervisorOn]);
646
+ return store.send(text, { attachFrame, model, effort: effort ?? undefined, tasks: taskMode });
647
+ }, [attachFrame, effort, model, store, taskMode]);
648
+ const taskStepLabel = useCallback((call) => stepLabel(call, presentation.toolLabels), [presentation.toolLabels]);
628
649
  const stopTurn = useCallback(() => store.stop(), [store]);
629
650
  // Steering the turn that is already running: the composer's text joins THAT
630
651
  // turn instead of starting a second one. A refusal (the turn ended while the
@@ -645,10 +666,13 @@ export default function AgentPanel({
645
666
  // "ready" with nothing it may send (#379).
646
667
  const signIn = useCallback(async () => {
647
668
  setAuthState("signing-in");
669
+ setSignInError("");
648
670
  try {
649
671
  await transport.signIn();
650
672
  await refreshProviderState();
651
- } catch {
673
+ } catch (error) {
674
+ if (agentServerUnavailable(error)) { setAuthState("offline"); return; }
675
+ setSignInError(error?.message || ko("Sign-in could not start.", "로그인을 시작하지 못했어요."));
652
676
  setAuthState("signed-out");
653
677
  }
654
678
  }, [refreshProviderState, transport]);
@@ -819,7 +843,8 @@ export default function AgentPanel({
819
843
  {resetLabel && <span className="agent-account-reset">resets in {resetLabel}</span>}
820
844
  </div>}
821
845
  <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>
846
+ <button type="button" role="menuitem" className="agent-menu-keys" aria-expanded={keysOpen} onClick={toggleProviderKeys}
847
+ disabled={authState === "offline"} title={authState === "offline" ? ko("The agent server is not running", "에이전트 서버가 실행 중이 아니에요") : undefined}>Connect a model…</button>
823
848
  {account?.signedIn && <button type="button" role="menuitem" onClick={signOut}>Sign out</button>}
824
849
  </div>}
825
850
  </span>
@@ -827,20 +852,23 @@ export default function AgentPanel({
827
852
  </header>
828
853
 
829
854
  {restoreNotice && <div className="agent-toast" role="status">{restoreNotice}</div>}
830
- {steerNotice && <div className="agent-toast alert agent-steer-notice" role="alert">{steerNotice}</div>}
855
+ {steerNotice && <div className="agent-toast alert agent-steer-notice" role="alert">{steerNotice}</div>}{surface === "studio" && taskMode && authenticated && <TaskStrip />}
831
856
 
832
857
  {/* An inline section, not a modal: the panel stays the one place the
833
858
  conversation and the credentials it runs on are managed. */}
834
- {keysOpen && <LoginPanel onClose={() => setKeysOpen(false)} onChatGPT={signIn} onChanged={refreshProviderState} />}
859
+ {keysOpen && authState !== "offline" && <LoginPanel onClose={() => setKeysOpen(false)} onChatGPT={signIn} onChanged={refreshProviderState} />}
835
860
 
836
861
  <div className="agent-transcript" ref={transcriptRef} aria-live="polite" aria-label="Conversation" data-agent-transcript="true">
837
862
  {authState === "signed-out" && <div className="agent-state-card" data-agent-card="signed-out">
838
863
  <h3>Connect a model</h3>
839
864
  <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
865
  <button type="button" className="agent-primary-button agent-signin" onClick={signIn}>Sign in with ChatGPT</button>
866
+ {signInError && <p className="agent-key-error agent-signin-error" role="alert">{signInError}</p>}
841
867
  <button type="button" className="agent-ghost-button agent-use-key" onClick={openProviderKeys}>Log in with another provider</button>
842
868
  </div>}
843
869
 
870
+ {authState === "offline" && <OfflineCard probing={probing} onRetry={retryServer} />}
871
+
844
872
  {authState === "signing-in" && <div className="agent-state-card" data-agent-card="signing-in">
845
873
  <span className="agent-spinner" aria-hidden="true" />
846
874
  <h3>Waiting for your browser…</h3>
@@ -863,10 +891,10 @@ export default function AgentPanel({
863
891
  </div>}
864
892
 
865
893
  {/* One step list per turn (AgentTurnSteps.jsx); the motion progress card sits just above the latest one. */}
866
- {layout.map((entry) => {
894
+ {layout.map((entry, index) => {
867
895
  if (entry.type === "steps") return <Fragment key={entry.id}>
868
896
  {entry.last && progressCard}
869
- <div className="agent-row"><TurnSteps block={entry} now={activityNow} toolLabels={presentation.toolLabels} formatClock={formatTurnClock} onRetry={() => runTurn(chat.lastPrompt)} /></div>
897
+ <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
898
  </Fragment>;
871
899
  const { item } = entry;
872
900
  if (item.kind === "user") return <div className="agent-row user" key={item.id}>
@@ -875,7 +903,7 @@ export default function AgentPanel({
875
903
  </div>}
876
904
  <div className="agent-bubble">{item.text}</div>
877
905
  </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>;
906
+ 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
907
  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
908
  if (item.kind === "receipt") return <div className="agent-row" key={item.id}><ReceiptCard item={item} /></div>;
881
909
  if (item.kind === "failure") return <div className="agent-row" key={item.id}><FailureCard failure={item.failure} onRetry={() => runTurn(chat.lastPrompt)} onFixKey={openProviderKeys} /></div>;
@@ -883,9 +911,10 @@ export default function AgentPanel({
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" />
@@ -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
+ }