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.
- package/CHANGELOG.md +95 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +22 -56
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/session-store.mjs +2 -2
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
- package/dist/assets/app-B2BpfCl2.js +5088 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-B887n77J.js +1 -0
- package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
- package/dist/assets/task-board-BKaM7d-9.js +14 -0
- package/dist/assets/workflow-B8Dhzexj.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/package.json +3 -3
- package/src/App.jsx +50 -281
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-task-scopes.js +69 -0
- package/src/bus-task-targets.js +48 -0
- package/src/bus-tasks.js +181 -0
- package/src/command-bus.js +15 -40
- package/src/command-history.js +36 -0
- package/src/commands/cast.js +10 -3
- package/src/commands/motion.js +2 -1
- package/src/commands/motion3-compile.js +51 -27
- package/src/commands/motion3.js +13 -7
- package/src/commands/project.js +1 -1
- package/src/commands/shared.js +9 -0
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/anchors.js +33 -5
- package/src/motion3/compiler/compile.js +27 -4
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/grip.js +9 -3
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/socket-grip.js +34 -10
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/docs.js +1 -1
- package/src/motion3/fix/index.js +7 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +5 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +21 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +11 -10
- package/src/motion3/schema/types.js +5 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/basic.js +61 -13
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +13 -4
- package/src/motion3/skills/info.js +14 -1
- package/src/motion3/skills/reach-human.js +139 -0
- package/src/motion3/skills/stance.js +92 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/motion3/sockets.js +21 -4
- package/src/motion3/views.js +28 -5
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +4 -7
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +3 -7
- package/src/scenes.js +4 -5
- package/src/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +3 -3
- package/src/studio-elements.js +1 -1
- package/src/studio-error-codes.js +1 -1
- package/src/studio-motion-visual.js +4 -4
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +75 -42
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +11 -25
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +7 -14
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-DbKG0NgD.js +0 -6
- package/dist/assets/app-34i-iUy1.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DYpa2nvO.js +0 -5
- package/dist/assets/first-shot-handoff-CrGZ3n_O.js +0 -1
- package/dist/assets/landing-mq5bnqFE.js +0 -1
- package/dist/assets/scene-asset-sync-7J-w5oNh.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-dUhurYUc.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- package/tools/perf/ik-camera-drag.mjs +0 -138
package/src/text/text-object.jsx
CHANGED
|
@@ -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 "../
|
|
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
|
|
29
|
-
// inside rootRef, so it is checked separately
|
|
30
|
-
// unmounting while open.
|
|
31
|
-
|
|
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:
|
|
136
|
+
style={{ position: "fixed", zIndex: "var(--z-popover, 90)", ...menuBox, overflowY: "auto" }}
|
|
145
137
|
onKeyDown={onKeyDown}
|
|
146
138
|
>
|
|
147
139
|
{options.map((opt, i) => (
|
package/src/use-embed-bridge.js
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
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
|
-
|
|
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(() => {
|
package/src/view-ghosts.jsx
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
//
|
|
296
|
-
|
|
297
|
-
const
|
|
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
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
|
|
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
|
-
|
|
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,
|
|
627
|
-
}, [attachFrame, effort, model, store,
|
|
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}
|
|
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"
|
|
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" /> : <
|
|
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${
|
|
953
|
-
{ko("
|
|
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
|
|
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
|
+
}
|