cozyclay 3.1.0 → 3.1.2

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 (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/bin/agent/agent-routes.mjs +12 -11
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/plan-tool.mjs +114 -0
  6. package/bin/agent/script-tool.mjs +4 -1
  7. package/bin/agent/sculpt-sketch.mjs +190 -0
  8. package/bin/agent/session-store.mjs +2 -1
  9. package/bin/agent/sketch-code.mjs +151 -0
  10. package/bin/agent/sketch-sandbox.mjs +102 -0
  11. package/bin/agent/studio-prompt.mjs +1 -1
  12. package/bin/agent/studio-rebase.mjs +5 -4
  13. package/bin/agent/studio-task-routes.mjs +1 -1
  14. package/bin/agent/studio-tasks.mjs +116 -25
  15. package/bin/agent/studio-tools.mjs +3 -2
  16. package/bin/agent/turn-frames.mjs +4 -0
  17. package/bin/cozyclay.mjs +4 -2
  18. package/bin/live-endpoint.mjs +2 -0
  19. package/dist/app/index.html +6 -6
  20. package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
  21. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  22. package/dist/assets/app-C4QTcDcb.js +5144 -0
  23. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  24. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  25. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  26. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  27. package/dist/assets/task-board-BqReHzOi.js +15 -0
  28. package/dist/assets/workflow-C87p6E1E.js +179 -0
  29. package/dist/cozyclay-package.json +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist/sitemap.xml +1 -1
  32. package/dist/workflow/index.html +4 -4
  33. package/mcp/README.md +1 -1
  34. package/mcp/live-hub.mjs +5 -3
  35. package/mcp/tool-handlers.mjs +7 -7
  36. package/package.json +3 -1
  37. package/src/App.jsx +44 -66
  38. package/src/bus-task-scopes.js +59 -11
  39. package/src/bus-task-targets.js +35 -9
  40. package/src/bus-tasks.js +23 -10
  41. package/src/command-bus.js +7 -4
  42. package/src/commands/ai.js +1 -1
  43. package/src/commands/elements.js +4 -3
  44. package/src/commands/export.js +1 -1
  45. package/src/commands/motion3.js +27 -7
  46. package/src/commands/objects.js +1 -1
  47. package/src/export-workspace.js +244 -0
  48. package/src/keyframe-pack.js +5 -1
  49. package/src/live-control.js +14 -7
  50. package/src/material-look.js +189 -0
  51. package/src/material-presets.js +78 -0
  52. package/src/material-textures.js +157 -0
  53. package/src/motion3/compile-job.js +15 -3
  54. package/src/motion3/compiler/channels.js +74 -24
  55. package/src/motion3/compiler/compile.js +2 -1
  56. package/src/motion3/compiler/idle.js +2 -0
  57. package/src/motion3/compiler/overlap.js +180 -0
  58. package/src/motion3/compiler/pose.js +6 -4
  59. package/src/motion3/compiler/validate-common.js +1 -0
  60. package/src/motion3/compiler/validate-parts.js +5 -1
  61. package/src/motion3/compiler/validate.js +30 -10
  62. package/src/motion3/correct.js +236 -0
  63. package/src/motion3/docs.js +8 -6
  64. package/src/motion3/face-lean.js +31 -0
  65. package/src/motion3/fix/index.js +5 -1
  66. package/src/motion3/fix/reach-body.js +172 -0
  67. package/src/motion3/hint-check.js +44 -0
  68. package/src/motion3/json-patch.js +120 -12
  69. package/src/motion3/lenient.js +46 -4
  70. package/src/motion3/object-keys.js +17 -5
  71. package/src/motion3/pipeline.js +46 -9
  72. package/src/motion3/qa/arm-checks.js +96 -0
  73. package/src/motion3/qa/asked.js +113 -0
  74. package/src/motion3/qa/goals.js +37 -8
  75. package/src/motion3/qa/hints-goal.js +6 -5
  76. package/src/motion3/qa/index.js +5 -2
  77. package/src/motion3/qa/key-checks.js +4 -3
  78. package/src/motion3/qa/motion-life.js +243 -0
  79. package/src/motion3/qa/spec-checks.js +15 -9
  80. package/src/motion3/qa/verdict.js +50 -0
  81. package/src/motion3/refs.js +38 -14
  82. package/src/motion3/repair-watch.js +132 -0
  83. package/src/motion3/repair.js +52 -19
  84. package/src/motion3/report.js +50 -9
  85. package/src/motion3/schema/topics.js +6 -4
  86. package/src/motion3/schema/types.js +1 -1
  87. package/src/motion3/skills/basic.js +15 -8
  88. package/src/motion3/skills/index.js +52 -18
  89. package/src/motion3/skills/info.js +1 -1
  90. package/src/motion3/skills/stance.js +6 -0
  91. package/src/motion3/skills/walk-fit.js +170 -0
  92. package/src/object-look.js +9 -5
  93. package/src/panels/PoseImagePanel.jsx +3 -2
  94. package/src/panels/SculptPanel.jsx +2 -2
  95. package/src/primitive-look.js +38 -0
  96. package/src/props.jsx +20 -28
  97. package/src/render-passes.js +7 -1
  98. package/src/result-modal.jsx +3 -3
  99. package/src/scene-objects.js +2 -2
  100. package/src/sculpt-colours.js +65 -0
  101. package/src/sculpt-compare.js +13 -50
  102. package/src/sculpt-csg.js +112 -0
  103. package/src/sculpt-curve.js +123 -0
  104. package/src/sculpt-cutters.js +43 -0
  105. package/src/sculpt-detail.js +207 -0
  106. package/src/sculpt-geometry-loft-convex.js +100 -0
  107. package/src/sculpt-geometry-shapes.js +250 -0
  108. package/src/sculpt-geometry.js +33 -15
  109. package/src/sculpt-loft-rings.js +225 -0
  110. package/src/sculpt-noise.js +65 -0
  111. package/src/sculpt-part-fields.js +27 -0
  112. package/src/sculpt-recipe-read.js +65 -0
  113. package/src/sculpt-recipe.js +33 -75
  114. package/src/sculpt-run.js +1 -1
  115. package/src/sculpt-scatter-items.js +183 -0
  116. package/src/sculpt-scatter-shape.js +57 -0
  117. package/src/sculpt-scatter.js +124 -0
  118. package/src/sculpt-shapes-curved.js +211 -0
  119. package/src/sculpt-shapes-loft-convex.js +218 -0
  120. package/src/sculpt-shapes.js +138 -0
  121. package/src/sculpt-sketch-state.js +76 -0
  122. package/src/sculpt-sketch.jsx +124 -0
  123. package/src/sculpt-surface-fields.js +65 -0
  124. package/src/shell/ExportFolderPreference.jsx +103 -0
  125. package/src/shell/PreferencesDialog.jsx +3 -166
  126. package/src/shell/StatusBar.jsx +1 -1
  127. package/src/shell/preference-controls.jsx +114 -0
  128. package/src/shell/preference-pages.jsx +72 -0
  129. package/src/shell/preferences.css +46 -0
  130. package/src/shot-prompt.js +29 -9
  131. package/src/studio-actions.js +2 -2
  132. package/src/studio-agent-motion.js +19 -9
  133. package/src/studio-agent-protocol.js +1 -1
  134. package/src/studio-app-binding.js +3 -2
  135. package/src/studio-elements.js +5 -1
  136. package/src/workflow/AgentPanel.jsx +6 -6
  137. package/src/workflow/AgentPlanCard.jsx +41 -0
  138. package/src/workflow/CozySceneNode.jsx +1 -1
  139. package/src/workflow/WorkflowBuilder.jsx +2 -2
  140. package/src/workflow/agent-client.js +14 -11
  141. package/src/workflow/agent-plan.css +93 -0
  142. package/src/workflow/agent-plan.js +59 -0
  143. package/src/workflow/keyframe-pack-request.js +3 -10
  144. package/src/workflow/task-board.css +1 -1
  145. package/src/workflow/task-board.js +7 -5
  146. package/tools/loc-baseline.json +1 -2
  147. package/tools/run-tests.mjs +8 -8
  148. package/dist/assets/app-B2BpfCl2.js +0 -5088
  149. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  150. package/dist/assets/motion3-compile-B887n77J.js +0 -1
  151. package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
  152. package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
  153. package/dist/assets/task-board-BKaM7d-9.js +0 -14
  154. package/dist/assets/workflow-B8Dhzexj.js +0 -179
@@ -0,0 +1,72 @@
1
+ import { getAnalyticsOptOut, setAnalyticsOptOut } from "../analytics.js";
2
+ import { loadAutoColor, saveAutoColor } from "../auto-color.js";
3
+ import { readStoredGridView, writeStoredGridView } from "../grid-view.js";
4
+ import { ko } from "../locale.js";
5
+ import ExportFolderPreference from "./ExportFolderPreference.jsx";
6
+ import {
7
+ LanguageControls,
8
+ PreferenceRow,
9
+ PreferenceSection,
10
+ ReadonlyHotkeys,
11
+ ThemeControls,
12
+ ToggleControl,
13
+ } from "./preference-controls.jsx";
14
+
15
+ export function preferenceSection(section, props) {
16
+ const { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView } = props;
17
+
18
+ switch (section) {
19
+ case "Appearance":
20
+ return (
21
+ <>
22
+ <PreferenceSection title={ko("Display", "표시")}>
23
+ <PreferenceRow title={ko("Language", "언어")} description={ko("Choose the labels used throughout the editor.", "에디터에서 사용할 언어를 선택하세요.")}>
24
+ <LanguageControls />
25
+ </PreferenceRow>
26
+ <PreferenceRow title={ko("Theme", "테마")} description={ko("Dark keeps the studio dim around the stage; Light uses bright glass panels.", "다크는 무대 주변을 어둡게, 라이트는 밝은 유리 패널을 써요.")}>
27
+ <ThemeControls />
28
+ </PreferenceRow>
29
+ </PreferenceSection>
30
+ <PreferenceSection title={ko("Editor", "에디터")}>
31
+ <PreferenceRow title={ko("Auto colour", "자동 색")} description={ko("Give scene objects stable display colours.", "씬 오브젝트에 고정된 표시 색을 사용해요.")}>
32
+ <ToggleControl pressed={autoColor} onChange={(value) => { setAutoColor(value); saveAutoColor(value); }} label={ko("Auto colour", "자동 색")} testid="preferences-auto-color" />
33
+ </PreferenceRow>
34
+ <PreferenceRow title={ko("Anonymous analytics", "익명 사용 통계")} description={ko("Help improve CozyClay with anonymous product events.", "익명 제품 이벤트로 CozyClay 개선을 도와요.")}>
35
+ <ToggleControl
36
+ pressed={!analyticsOptOut}
37
+ onChange={async (value) => setAnalyticsOptOutState(await setAnalyticsOptOut(!value))}
38
+ label={ko("Anonymous analytics", "익명 사용 통계")}
39
+ testid="settings-analytics"
40
+ />
41
+ </PreferenceRow>
42
+ </PreferenceSection>
43
+ </>
44
+ );
45
+ case "Viewport":
46
+ return (
47
+ <PreferenceSection>
48
+ <PreferenceRow title={ko("Reference grid", "기준 그리드")} description={ko("Use a dark reference grid instead of the clay deck.", "클레이 데크 대신 어두운 기준 그리드를 사용해요.")}>
49
+ <ToggleControl pressed={gridView} onChange={(value) => { setGridView(value); writeStoredGridView(globalThis.localStorage, value); }} label={ko("Reference grid", "기준 그리드")} testid="preferences-grid" />
50
+ </PreferenceRow>
51
+ </PreferenceSection>
52
+ );
53
+ case "Export":
54
+ return (
55
+ <PreferenceSection>
56
+ <ExportFolderPreference />
57
+ </PreferenceSection>
58
+ );
59
+ case "Hotkeys":
60
+ return (
61
+ <PreferenceSection>
62
+ <p className="v2-preferences__section-note">{ko("Shortcuts are read-only here. They stay active whenever focus is outside a text field.", "단축키는 여기서 읽기 전용이에요. 텍스트 입력란 밖에서 항상 사용할 수 있어요.")}</p>
63
+ <ReadonlyHotkeys />
64
+ </PreferenceSection>
65
+ );
66
+ default:
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // Kept here so the dialog can load a new setting's initial value lazily.
72
+ export { getAnalyticsOptOut, loadAutoColor, readStoredGridView };
@@ -623,3 +623,49 @@
623
623
  grid-template-columns: 1fr;
624
624
  }
625
625
  }
626
+
627
+ /* Export folder: full-width row because folder names and three controls do not
628
+ fit the 240px control column used by toggles and language pairs. */
629
+ .v2-preferences .v2-preferences__row--full .v2-preferences__row-copy {
630
+ margin-bottom: var(--sp-2, 12px);
631
+ }
632
+
633
+ .v2-preferences .v2-preferences__row--full .v2-preferences__row-control {
634
+ justify-content: flex-start;
635
+ flex-wrap: wrap;
636
+ width: 100%;
637
+ }
638
+
639
+ .v2-preferences .v2-preferences__export-folder {
640
+ display: flex;
641
+ flex-direction: column;
642
+ align-items: flex-start;
643
+ gap: var(--sp-1, 8px);
644
+ min-width: 0;
645
+ width: 100%;
646
+ }
647
+
648
+ .v2-preferences .v2-preferences__export-folder .v2-preferences__language-controls {
649
+ justify-content: flex-start;
650
+ }
651
+
652
+ .v2-preferences .v2-preferences__export-folder-name {
653
+ color: var(--text-1, #e4e4e7);
654
+ font-size: var(--prefs-ui-size, 12px);
655
+ line-height: 1.35;
656
+ overflow-wrap: anywhere;
657
+ }
658
+
659
+ .v2-preferences .v2-preferences__export-folder-status {
660
+ color: var(--select, #e8a33d);
661
+ font-size: var(--prefs-secondary-size, 11px);
662
+ line-height: 1.35;
663
+ }
664
+
665
+ .v2-preferences .v2-preferences__export-folder-note {
666
+ display: block;
667
+ margin-top: var(--sp-1, 8px);
668
+ color: var(--text-3, #636369);
669
+ font-size: var(--prefs-secondary-size, 11px);
670
+ line-height: 1.35;
671
+ }
@@ -1,20 +1,40 @@
1
1
  // Builds the labelled text prompt sent alongside a reference clip when a shot
2
- // goes to an image or video model. Pure string building: no DOM, no imports.
2
+ // goes to an image or video model. Pure string building: no DOM; the only
3
+ // import is the editor's own lens math, so the prompt and the viewport share
4
+ // one focal <-> fov calibration.
5
+ import { fovToFocalMm, shotAspectRatio } from "./shot.js";
3
6
 
4
7
  const CAMERA_FOLLOWS_REFERENCE = "follow the reference clip's framing and camera motion exactly";
5
8
  const NEUTRAL_LOOK = "clay previs, neutral look";
6
9
  const MOTION_LINE = "motion and blocking come from the reference; keep timing";
7
10
 
8
- // FOV degrees -> 35mm-equivalent focal length -> shot-size word.
9
- // A wide field of view reads as a wide shot; a narrow one as a long shot.
10
- export function shotSizeFromFov(fovDeg) {
11
- if (!Number.isFinite(fovDeg) || fovDeg <= 0) return "normal";
12
- const equivalentFocalMm = 18 / Math.tan((fovDeg * Math.PI) / 360);
13
- if (equivalentFocalMm <= 20) return "wide";
14
- if (equivalentFocalMm <= 40) return "normal";
11
+ // Focal length (on the editor's filmback) -> lens word, in crew terms:
12
+ // under 35 mm reads wide, 35-50 mm normal, longer than 50 mm long.
13
+ export function lensWordFromFocalMm(focalMm) {
14
+ if (!Number.isFinite(focalMm) || focalMm <= 0) return "normal";
15
+ if (focalMm < 35) return "wide";
16
+ if (focalMm <= 50) return "normal";
15
17
  return "long";
16
18
  }
17
19
 
20
+ // FOV degrees -> focal length through the editor's own calibration
21
+ // (shot.js fovToFocalMm on the given filmback) -> lens word. No second,
22
+ // independently calibrated conversion: the word matches the lens the editor
23
+ // reports for that field of view.
24
+ export function shotSizeFromFov(fovDeg, filmback = {}) {
25
+ if (!Number.isFinite(fovDeg) || fovDeg <= 0) return "normal";
26
+ const focalMm = fovToFocalMm((fovDeg * Math.PI) / 180, filmback.sensorId, filmback.aspectRatio);
27
+ // Round to 0.1 mm so a lens that round-trips through fov keeps its word.
28
+ return lensWordFromFocalMm(Math.round(focalMm * 10) / 10);
29
+ }
30
+
31
+ // The editor already reports the focal length it framed with; only a meta
32
+ // without one falls back to converting its fov on the meta's filmback.
33
+ function lensWord(meta) {
34
+ if (Number.isFinite(meta.focalMm) && meta.focalMm > 0) return lensWordFromFocalMm(meta.focalMm);
35
+ return shotSizeFromFov(meta.fovDeg, { sensorId: meta.sensorId, aspectRatio: shotAspectRatio(meta.aspect) });
36
+ }
37
+
18
38
  function secondsText(seconds) {
19
39
  return `${Number(seconds.toFixed(2))}s`;
20
40
  }
@@ -68,7 +88,7 @@ export function buildShotPrompt(meta, options = {}) {
68
88
 
69
89
  const lines = [
70
90
  `SHOT: ${meta.shotTitle || "untitled shot"}, ${secondsText(seconds)}, ${meta.aspect || ""}`.trimEnd(),
71
- `LENS: ${meta.focalMm}mm, ${shotSizeFromFov(meta.fovDeg)} shot`,
91
+ `LENS: ${meta.focalMm}mm, ${lensWord(meta)} shot`,
72
92
  referenceOwnsCamera ? `CAMERA: ${CAMERA_FOLLOWS_REFERENCE}` : `CAMERA: ${cameraWords(meta.cameraMove)}`,
73
93
  `LIGHT: ${lightWords(meta.keyLight)}`,
74
94
  `CAST: ${(meta.cast || []).map((member) => member.name).join(", ") || "none"}`,
@@ -130,7 +130,7 @@ export const STUDIO_ACTIONS = freezeStudioData([
130
130
  { id: "view.setGhosts", label: "Ghosts", kind: "transient", input: input({ mode: { type: "string", enum: ["off", "step", "keys"] } }, { count: { type: "integer", minimum: 1, maximum: 6 }, step: { type: "integer", minimum: 1, maximum: 24 } }),
131
131
  description: "Onion skin in the editor viewport, like the View menu's Ghosts: translucent poses of the active character's installed take, count (1-6, default 3) before the playhead in blue and as many after it in orange, fading with distance in time; props the take carries (a kit's bat) ghost with the body. mode step spaces them every step frames (1-24, default 4), keys puts them on the spec's key frames, off hides them; e.g. { mode: \"step\", count: 3, step: 4 }. An editor overlay: never in capture_frame, exports or contact sheets (verify_result ghosts: true draws them on a views sheet). A viewer setting: never saved or undone." },
132
132
  { id: "export.shotVideo", exposure: "confirm", confirmationReason: "Exporting writes a video file outside the Studio.", label: "Export video (mp4)", kind: "job", timeoutMs: 300_000, input: input({}, { ...shotId, scope: { type: "string", enum: ["timeline", "shot"], description: "timeline: the whole timeline, every shot back to back; shot: one shot (shotId, or the one under the playhead)." } }),
133
- description: "Record a shot to an MP4 (camera move and character motion, no editor chrome), like the Export menu's Video (mp4), and ask the browser to download it. With shotId it records that shot's own range; without it, what the menu records: the whole take when there is motion, else the shot under the playhead (else the first shot), keyed from the current camera if it has no keys. Waits until the file is encoded (up to 5 minutes) and answers status \"completed\" with output.fileName and output.frameCount. One export at a time; not a motion generation." },
133
+ description: "Record a shot to an MP4 (camera move and character motion, no editor chrome), like the Export menu's Video (mp4), and hand it to the configured export folder or, when none is usable, the browser download. With shotId it records that shot's own range; without it, what the menu records: the whole take when there is motion, else the shot under the playhead (else the first shot), keyed from the current camera if it has no keys. Waits until the file is encoded (up to 5 minutes) and answers status \"completed\" with output.fileName and output.frameCount. One export at a time; not a motion generation." },
134
134
  { id: "scene.create", label: "New scene", kind: "document", input: input(),
135
135
  description: `Add an empty scene (named SCENE 01, SCENE 02, ...) after the others and open it, like the scene menu's New scene. ${SCENE_MOVES} ${NOT_UNDOABLE}` },
136
136
  { id: "scene.duplicate", label: "Duplicate scene", kind: "document", input: input(sceneId),
@@ -152,7 +152,7 @@ export const STUDIO_ACTIONS = freezeStudioData([
152
152
  { id: "object.duplicate", label: "Duplicate object", kind: "mutation", undoDomain: "objects", input: input({}, { objectId: idSchema }),
153
153
  description: "Copy a scene object (the selected one when objectId is omitted) and place the copy half a metre beside it." },
154
154
  { id: "ai.prepareShot", label: "Send to AI", kind: "job", input: input({}, { mode: { type: "string", enum: ["image", "video"] }, model: { type: "string", enum: AI_IMAGE_MODELS } }),
155
- description: "Prepare the Studio's Send-to-AI package for the shot under the playhead (else the current camera): the prompt for an external image or video model, written from the shot's camera, cast, poses, environment and style, plus reference frames rendered from the shot camera (its first and last camera key when it has keys). No AI service is called. mode \"image\" is a still, \"video\" a shot with its camera move; model is the image model the prompt is written for (image mode only). Each omitted choice keeps the Studio's current one; a given one becomes it. The Studio opens its result panel, where the user copies the prompt and downloads the frames; the frame images stay there and are not in the answer. Answers status \"completed\" with output.prompt, mode, modelLabel, shot (id, name, range), aspectRatio, cameraMode and referenceFrames (how many frames the panel shows). Changes nothing in the scene; not a motion generation." },
155
+ description: "Prepare the Studio's Send-to-AI package for the shot under the playhead (else the current camera): the prompt for an external image or video model, written from the shot's camera, cast, poses, environment and style, plus reference frames rendered from the shot camera (its first and last camera key when it has keys). No AI service is called. mode \"image\" is a still, \"video\" a shot with its camera move; model is the image model the prompt is written for (image mode only). Each omitted choice keeps the Studio's current one; a given one becomes it. The Studio opens its result panel, where the user copies the prompt and exports the frames to the configured export folder or browser downloads; the frame images stay there and are not in the answer. Answers status \"completed\" with output.prompt, mode, modelLabel, shot (id, name, range), aspectRatio, cameraMode and referenceFrames (how many frames the panel shows). Changes nothing in the scene; not a motion generation." },
156
156
  // The instruction leads the H3 prompt, which the motion API caps at 4000
157
157
  // characters with its camera-lock text (about 225) appended.
158
158
  ]);
@@ -12,7 +12,8 @@ import { buildCollisionCapsules, detectPenetrations, supportsCollisionCleanup }
12
12
  import { collisionBlockers, blockerSummary } from './motion/collision-blockers.js';
13
13
  import { createGroundSampler } from './motion/ground.js';
14
14
  import { OBJECT_LIBRARY } from './scene-objects.js';
15
- import { objectTransformAt } from './object-path.js';
15
+ import { sceneObjectsAt } from './object-travel.js';
16
+ import { objectTracksOf } from './motion3/object-tracks.js';
16
17
  import { sampleAt } from './sample-at.js';
17
18
  import { isProxyFigure } from './scenes.js';
18
19
 
@@ -88,7 +89,7 @@ function stageCast(env, characterId, cast) {
88
89
 
89
90
  /* One take under evaluation, `t`: {evaluator, motion, env, cast, characterId,
90
91
  * character, protectedFrames, range, poseCast?, yieldTask, checkpoint}. */
91
- function blockersAt(t, frame) {
92
+ function blockersAt(t, frame, objects) {
92
93
  t.checkpoint();
93
94
  const rigs = {};
94
95
  for (const member of t.cast) {
@@ -99,18 +100,19 @@ function blockersAt(t, frame) {
99
100
  rigs[member.character.id] = member.evaluator.rig;
100
101
  }
101
102
  const cast = t.cast.map((member) => member.character).filter(Boolean);
102
- return collisionBlockers({ rigs, activeId: t.characterId, characterIds: cast, sceneObjects: t.env.objects, library: OBJECT_LIBRARY, frame, take: { frameCount: t.env.frameCount, fps: 24 } });
103
+ // `objects` already stand where this frame renders them (routes and take tracks), so no re-sampling here.
104
+ return collisionBlockers({ rigs, activeId: t.characterId, characterIds: cast, sceneObjects: objects, library: OBJECT_LIBRARY });
103
105
  }
104
106
  async function samples(t, layer) {
105
107
  const rows = [], poses = [], collisions = [], blockers = [];
106
108
  const cast = t.cast.map((member) => member.character).filter(Boolean);
107
109
  for (let frame = t.range.startFrame; frame < t.range.endFrameExclusive; frame++) {
108
110
  t.checkpoint(); poseFrame(t.evaluator, t.motion, layer, frame);
109
- const shapes = blockersAt(t, frame);
110
- const objects = t.env.objects.map(object => {
111
- const at = objectTransformAt(object, frame, { frameCount: t.env.frameCount, fps: 24 });
112
- return at ? { ...object, x: at.x, y: at.y, z: at.z, rot: at.rot ?? object.rot } : object;
113
- });
111
+ // Objects as the editor renders them at this frame: travel routes plus the
112
+ // object tracks the installed takes bake (object-travel.js sceneObjectsAt).
113
+ const objects = sceneObjectsAt(t.env.objects, frame, t.objectTake);
114
+ for (const o of objects) if (!o.hidden && (o.rotX || o.rotZ)) t.tilted.add(o.id);
115
+ const shapes = blockersAt(t, frame, objects);
114
116
  rows.push(readSample(t.evaluator, createGroundSampler(objects, { floorY: t.env.floor.y, characters: cast })));
115
117
  poses.push(poseValues(t.evaluator.rest));
116
118
  const capsules = buildCollisionCapsules(t.evaluator.rig);
@@ -136,7 +138,7 @@ function judge(t, before, after) {
136
138
  }
137
139
  const supportedCollisionFrames = after.collisions.filter(p => p.length).length;
138
140
  const elevatedFrames = after.rows.flatMap((row, f) => Object.values(row.ground).some(y => y !== t.env.floor.y) ? [t.range.startFrame + f] : []);
139
- const unsupportedObjects = t.env.objects.filter(o => o.attach || o.rotX || o.rotZ);
141
+ const unsupportedObjects = t.env.objects.filter(o => o.attach || o.rotX || o.rotZ || t.tilted.has(o.id));
140
142
  const missingCast = t.cast.filter(m => !m.evaluator || ((m.character.layer?.waypoints?.length ?? 0) > 0 && !t.poseCast));
141
143
  const measuredSupportFrames = after.support.frames.filter(f => f.measured).length;
142
144
  const flat = t.env.floor.model === 'flat' && elevatedFrames.length === 0 && Math.abs(t.character.y ?? 0) < 1e-8;
@@ -164,6 +166,13 @@ function receiptVerification(v) {
164
166
  limitations: v.limitations, surfaceMeasured: m.surfaceMeasured, maxFloorPenetrationM: m.maxFloorPenetrationM, maxContactSlipM: m.maxContactSlipM, maxContactFloatM: m.maxContactFloatM,
165
167
  unsupportedFrames: m.unsupportedFrames, supportedCollisionFrames: m.supportedCollisionFrames, continuityRegressed: m.continuityRegressed, semanticStatus: 'unavailable' };
166
168
  }
169
+ /** Object tracks the installed takes bake (this take and the other cast's), keyed by object id. */
170
+ function installedObjectTracks(target, environment) {
171
+ const takes = new Map((environment.cast ?? []).map(member => [member.character?.id, member.motion]));
172
+ takes.set(target.character.id, target.motion);
173
+ const characters = [...takes.keys()].filter(id => typeof id === 'string').map(id => ({ id }));
174
+ return objectTracksOf({ motionFor: id => takes.get(id) }, characters);
175
+ }
167
176
  /** Motion verification of an installed take, on a private rig clone over
168
177
  * `range` (default and upper bound: the whole take). target: {character, rig,
169
178
  * motion, ikState?, protectedFrames?}; environment has readEnvironment()'s
@@ -177,6 +186,7 @@ export async function verifyInstalledTake({ target, environment, range, poseCast
177
186
  const deadline = now() + verificationBudget(endFrameExclusive - startFrame, verificationMs);
178
187
  const t = { evaluator: isolatedRig(target.rig), motion, env, cast: [], characterId: target.character.id, character: target.character,
179
188
  protectedFrames: target.protectedFrames ?? [], range: { startFrame, endFrameExclusive }, poseCast, yieldTask,
189
+ objectTake: { frameCount: env.frameCount, fps: 24, objectTracks: installedObjectTracks(target, environment) }, tilted: new Set(),
180
190
  checkpoint: () => { if (now() > deadline) fail('VERIFICATION_FAILED', 'Private verification budget exceeded.'); } };
181
191
  try {
182
192
  stageCast(environment, t.characterId, t.cast);
@@ -307,7 +307,7 @@ const receiptSchema = union(...Object.values(receiptVariants));
307
307
  const failureSchema = object({ ok: literal(false), commandId: id, host: identity, code: choices(STUDIO_ERROR_CODES), phase: choices(["admission", "execution", "prepare", "verify", "repair", "commit", "reconcile", "undo"]),
308
308
  affectedIds: ids(100, 0), expectedTargets: array(guardSchema, 24), currentTargets: array(guardSchema, 24), mutated: union(bool, literal("unknown")),
309
309
  preserved: object({ authoredState: choices(["unchanged", "changed", "unknown"]) }), recovery: object({ action: choices(["none", "inspect", "retry", "new_intent", "reconcile", "sign_in"]) }, { retryAllowed: bool }),
310
- }, { message: text(500), jobId: id, txId: id, candidates: array(object({ id, kind: choices(["object", "character", "rig"]), position: nullable(vec3) }), 5) });
310
+ }, { message: text(2000), jobId: id, txId: id, candidates: array(object({ id, kind: choices(["object", "character", "rig"]), position: nullable(vec3) }), 5) });
311
311
 
312
312
  // These are JSON Schema data, not validators with hidden browser dependencies.
313
313
  // x-studio-range is the sole relational schema annotation: end > start.
@@ -196,9 +196,10 @@ export function createStudioAppBinding(ports) {
196
196
  ...(childIds.length ? { childIds: childIds.slice(0, 12), ...(childIds.length > 12 ? { childCount: childIds.length } : {}) } : {}) };
197
197
  };
198
198
  if (patched.length) return { patched, ...extras() };
199
- const shot = s.shots.find(row => row.id === id);
199
+ // A camera key lives inside its shot: still present there means retained, not removed (#863).
200
+ const shot = s.shots.find(row => row.id === id), key = !shot && !entity && s.shots.flatMap(row => row.cameraKeys?.map(k => ({ shotId: row.id, frame: k.frame, id: k.id })) ?? []).find(k => k.id === id);
200
201
  if (shot) return { name: shot.name || shot.id, range: { startFrame: shot.startFrame, endFrameExclusive: shot.endFrame + 1 } };
201
- if (!entity) return { removed: true };
202
+ if (!entity) return key ? { shotId: key.shotId, frame: key.frame } : { removed: true };
202
203
  const brief = { name: entity.name || entity.subject || entity.id, position: { x: entity.x, y: entity.y ?? 0, z: entity.z } };
203
204
  if (compact) return brief;
204
205
  if (!object) return { ...brief, yawDeg: entity.rot ?? 0, scale: entity.scale ?? 1, ...extras() };
@@ -40,7 +40,11 @@ const entries = [
40
40
  { path: "object.scale", type: "vec3", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: { x: 0.01, y: 0.01, z: 0.01 }, max: { x: 100, y: 100, z: 100 } },
41
41
  { path: "object.name", type: "string", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
42
42
  { path: "object.color", type: "color", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
43
- { path: "object.opacity", type: "number", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: 0.05, max: 1, note: "1 is solid; lower is see-through, e.g. 0.3 for clear glass" },
43
+ { path: "object.opacity", type: "number", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: 0.05, max: 1, note: "1 is solid; lower is see-through, e.g. 0.4 for a ghost or water; for a glass object use object.material glass" },
44
+ // The preset names mirror material-presets.js MATERIAL_PRESETS / MATERIAL_FINISHES (test/verify-material-presets.mjs);
45
+ // sparse: a receipt lists them only on a record that has one.
46
+ { path: "object.material", type: "enum", sparse: true, persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", enum: ["clay", "wood", "straw", "stone", "brick", "concrete", "metal", "glass", "ceramic", "plastic", "rubber", "fabric", "paper", "foliage"], note: "cube/sphere/capsule/cylinder/cone/plane only: surface preset with real texture and shine; color stays the tint and a grey (#c2c6c8) object takes the preset's own colour. clay is the default matte look; wood (planks, furniture), straw (thatch, hay, wicker), stone, brick (walls, chimneys), concrete (plaster, pavement), metal (reflects the stage), glass (clear, see-through; tint with color), ceramic (glazed), plastic, rubber (tyres), fabric (cloth, upholstery), paper, foliage (leaves, hedges)" },
47
+ { path: "object.finish", type: "enum", sparse: true, persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", enum: ["matte", "satin", "gloss"], note: "with object.material: overrides the preset's shine, e.g. metal + gloss = chrome, glass + matte = frosted, wood + gloss = varnished" },
44
48
  { path: "object.parent", type: "id", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
45
49
  { path: "object.attach", type: "id", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", actions: ["object.attach", "object.detach"], note: "carried by a character's root or bone; channels convert so the prop stays put" },
46
50
  { path: "object.path", type: "array", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
@@ -3,12 +3,11 @@ import { FiAlertTriangle, FiArrowUp, FiCamera, FiCheck, FiChevronRight, FiClock,
3
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
- import { ko } from "../locale.js";
6
+ import { ko } from "../locale.js"; import { saveExport } from "../export-workspace.js";
7
7
  import {
8
8
  AGENT_PANEL_OVERLAY_BREAKPOINT,
9
9
  AGENT_PANEL_RAIL_WIDTH,
10
- AGENT_PANEL_WIDTH_MAX,
11
- AGENT_PANEL_WIDTH_MIN,
10
+ AGENT_PANEL_WIDTH_MAX, AGENT_PANEL_WIDTH_MIN,
12
11
  AGENT_STATES,
13
12
  agentServerUnavailable,
14
13
  describeActivity,
@@ -29,8 +28,7 @@ import {
29
28
  formatTurnClock,
30
29
  formatResetIn,
31
30
  readStoredPanelWidth,
32
- storeModel,
33
- storePanelWidth,
31
+ storeModel, storePanelWidth,
34
32
  STUDIO_SESSION_STORAGE_KEY,
35
33
  selectedModelRoles,
36
34
  shortModelId,
@@ -43,6 +41,7 @@ const TaskBoard = lazy(() => import("./TaskBoard.jsx"));
43
41
  import { AgentProgressCard } from "./AgentProgressCard.jsx";
44
42
  import { LoginPanel } from "./LoginPanel.jsx";
45
43
  import { TurnSteps } from "./AgentTurnSteps.jsx";
44
+ import { PlanCard } from "./AgentPlanCard.jsx";
46
45
  import { ReplyText } from "./AgentReplyText.jsx";
47
46
  import { stepLabel, turnLayout } from "./agent-turn.js";
48
47
 
@@ -74,7 +73,7 @@ function ImageResultCard({ image, onUse, onUndo, onRegenerate, onOpen }) {
74
73
  ? <div className="agent-placed"><StatusDot tone="ok" />Placed<button type="button" className="agent-image-undo" disabled={applying} onClick={() => onUndo(image)}>{applying ? "Removing…" : "Undo"}</button></div>
75
74
  : <div className="agent-image-actions">
76
75
  <button type="button" className="primary agent-image-use" disabled={applying} onClick={() => onUse(image)}><FiImage size={11} /> {applying ? "Applying…" : "Use in scene"}</button>
77
- <a className="agent-image-download" role="button" href={image.dataUrl} download={`${image.imageId || "agent-image"}.png`}><FiDownload size={11} /> Download</a>
76
+ <a className="agent-image-download" role="button" href={image.dataUrl} download={`${image.imageId || "agent-image"}.png`} onClick={(event) => { event.preventDefault(); void saveExport(image.dataUrl, `${image.imageId || "agent-image"}.png`); }}><FiDownload size={11} /> Download</a>
78
77
  <button type="button" className="agent-image-regenerate" onClick={() => onRegenerate(image)}><FiRotateCw size={11} /> Regenerate</button>
79
78
  </div>}
80
79
  {/* The card states what the editor actually did: an unacknowledged or
@@ -908,6 +907,7 @@ export default function AgentPanel({
908
907
  if (item.kind === "receipt") return <div className="agent-row" key={item.id}><ReceiptCard item={item} /></div>;
909
908
  if (item.kind === "failure") return <div className="agent-row" key={item.id}><FailureCard failure={item.failure} onRetry={() => runTurn(chat.lastPrompt)} onFixKey={openProviderKeys} /></div>;
910
909
  if (item.kind === "supervisor") return <div className="agent-row" key={item.id}><SupervisorCard note={item} onSend={(text) => { store.setDraft(text); runTurn(text); }} /></div>;
910
+ if (item.kind === "plan") return <PlanCard key={item.id} plan={item} live={streaming && !item.outcome} />;
911
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>;
912
912
  })}
913
913
 
@@ -0,0 +1,41 @@
1
+ // A turn's step checklist in the agent panel (agent-plan.js keeps it): a header that says how far the plan is ("2/5")
2
+ // beside a thin progress bar, and one row per step: done steps checked, the step under way highlighted with a soft
3
+ // pulse, the rest waiting. While the turn runs the card stays pinned to the top of the transcript, so what is happening
4
+ // and what comes next stay in view however long the step list below grows. Once the turn ends nothing pulses, and a
5
+ // step a failed or stopped turn left unfinished reads muted.
6
+ import { FiCheck, FiMinus } from "react-icons/fi";
7
+ import { ko } from "../locale.js";
8
+ import "./agent-plan.css";
9
+
10
+ const STATUS_WORDS = {
11
+ done: ko("done", "완료"),
12
+ active: ko("in progress", "진행 중"),
13
+ pending: ko("next", "대기"),
14
+ unfinished: ko("not finished", "미완료"),
15
+ };
16
+
17
+ function PlanMark({ status }) {
18
+ if (status === "done") return <FiCheck className="agent-plan-mark done" size={11} aria-hidden="true" />;
19
+ if (status === "unfinished") return <FiMinus className="agent-plan-mark unfinished" size={11} aria-hidden="true" />;
20
+ return <span className={`agent-plan-mark ${status}`} aria-hidden="true" />;
21
+ }
22
+
23
+ export function PlanCard({ plan, live }) {
24
+ const total = plan.steps.length, done = plan.steps.filter(step => step.status === "done").length;
25
+ const state = live ? "live" : plan.outcome ?? "done";
26
+ return <div className="agent-row agent-plan-row" data-plan-live={live ? "true" : "false"}>
27
+ <section className="agent-card agent-plan" data-agent-card="plan" data-plan-state={state} aria-label={ko(`Plan, ${done} of ${total} done`, `계획, ${total}단계 중 ${done}단계 완료`)}>
28
+ <div className="agent-plan-head">
29
+ <span className="agent-plan-title">{ko("Plan", "계획")}</span>
30
+ <span className="agent-plan-bar" aria-hidden="true"><span className="agent-plan-fill" style={{ "--agent-plan-done": total ? done / total : 0 }} /></span>
31
+ <span className="agent-plan-count">{done}/{total}</span>
32
+ </div>
33
+ <ol className="agent-plan-steps">
34
+ {plan.steps.map((step, index) => <li key={index} className={`agent-plan-step ${step.status}`} data-plan-status={step.status} aria-current={step.status === "active" ? "step" : undefined} aria-label={`${step.text}: ${STATUS_WORDS[step.status] ?? step.status}`}>
35
+ <PlanMark status={step.status} />
36
+ <span className="agent-plan-text">{step.text}</span>
37
+ </li>)}
38
+ </ol>
39
+ </section>
40
+ </div>;
41
+ }
@@ -58,7 +58,7 @@ export default function CozySceneNode({ id = "cozy-scene", data: rawData = {}, s
58
58
  // No shotId: the Scene node tracks a scene, not a shot, so the embed
59
59
  // packs the shot under its own playhead — the one being previewed.
60
60
  const result = await requestKeyframePack(frame, { surface: "workflow" });
61
- downloadPack(result);
61
+ await downloadPack(result);
62
62
  attempt.succeed();
63
63
  track("export:keyframe_pack", { entries: result.entries.length, source: "workflow" });
64
64
  // QA hook, mirroring window.__cozyclay in the Studio: the analytics
@@ -13,7 +13,7 @@ import { FiActivity, FiBox, FiCode, FiFileText, FiFilm, FiImage, FiLink, FiMusic
13
13
  import { Toaster, toast } from "react-hot-toast";
14
14
  import { loadWorkflowGraph, normalizeWorkflowGraph, storeWorkflowGraph, WORKFLOW_STORAGE_KEY } from "../project.js";
15
15
  import AgentPanel from "./AgentPanel.jsx";
16
- import CozySceneNode from "./CozySceneNode.jsx";
16
+ import CozySceneNode from "./CozySceneNode.jsx"; import { saveExport } from "../export-workspace.js";
17
17
  import { applyCozyScenePatch, normalizeCozySceneData, sceneConnectionAllowed } from "./cozy-scene-node.js";
18
18
  import { activeSceneCharacters, characterHandleId, characterIdFromHandle, normalizeMotionInputData, motionInputOutput, parseMotionSpec } from "./motion-input.js";
19
19
  import { DEFAULT_NODE_SCHEMAS, defaultFormValues, schemaCategoryForType, schemaModelEntries, schemaProperties } from "./node-schema.js";
@@ -652,7 +652,7 @@ export default function WorkflowBuilder() {
652
652
  },
653
653
  })), [changeModel, nodeSchemas, nodes, runScene, runWorkflow, sceneCharacters, sceneContext, sendSceneToVideo, updateNode, updateScene, uploadFile, useVersionAsReference]);
654
654
 
655
- const exportGraph = useCallback(() => { const blob = new Blob([JSON.stringify(graph, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = "cozyclay-workflow.json"; anchor.click(); URL.revokeObjectURL(url); toast.success("Workflow exported"); }, [graph]);
655
+ const exportGraph = useCallback(async () => { const blob = new Blob([JSON.stringify(graph, null, 2)], { type: "application/json" }); await saveExport(blob, "cozyclay-workflow.json"); toast.success("Workflow exported"); }, [graph]);
656
656
  const onConnect = useCallback((params) => {
657
657
  const target = nodes.find((node) => node.id === params.target);
658
658
  const source = nodes.find((node) => node.id === params.source);
@@ -28,8 +28,9 @@ import { appendAttachments, ATTACHMENT_MAX_COUNT } from "./attachment-image.js";
28
28
  import { ko } from "../locale.js";
29
29
  import { AGENT_TOOL_CATEGORIES, EXECUTION_TELEMETRY_VALUES } from "../execution-telemetry.js";
30
30
  import { STUDIO_VARIANTS, validateReceipt } from "../studio-agent-protocol.js";
31
- import { requestAgentImage, sidecarRequest, sidecarUrl } from "../agent-image-client.js";
31
+ import { requestAgentImage, sidecarRequest, sidecarUrl } from "../agent-image-client.js"; import { clearSculptSketch, sculptSketchFrame } from "../sculpt-sketch-state.js";
32
32
  import { actionLabelsOf, changedLine, scriptNow, stepFields, thinkingLine, withCompose, withThinking, withToolStart, withTurnEnd } from "./agent-turn.js";
33
+ import { isPlanFrame, restoredPlans, withPlanEnd, withPlanFrame } from "./agent-plan.js";
33
34
 
34
35
  const AGENT_PANEL_WIDTH_KEY = "cozyclay.workflow.agentPanel.width";
35
36
  export const AGENT_PANEL_WIDTH_DEFAULT = 360;
@@ -1325,6 +1326,8 @@ export function createAgentChatStore({
1325
1326
  });
1326
1327
  return;
1327
1328
  }
1329
+ // The turn's step checklist (bin/agent/plan-tool.mjs): one card under the prompt; the plan call itself is no step.
1330
+ if (isPlanFrame(event)) { setItems((items) => withPlanFrame(items, event, newId)); return; }
1328
1331
  if (event?.type === "tool.start") {
1329
1332
  setItems((items) => withToolStart(items, { kind: "tool", id: event.callId || newId(), callId: event.callId, name: event.name, label: event.label, args: event.args, status: "running", startedAt: clock(), ...stepFields(event.name, event.args, actionLabels) }));
1330
1333
  return;
@@ -1406,11 +1409,10 @@ export function createAgentChatStore({
1406
1409
  set({ sessionId: newId(), turnId: null, items: [], pendingAttachments: [], rateLimit: null, lastPrompt: "", turnStartedAt: null, lastTurn: null });
1407
1410
  };
1408
1411
  const restore = (transcript, sessionId = state.sessionId) => {
1409
- abandonSupervisorNotes();
1410
- seenReceipts.clear();
1411
- settledActions.clear();
1412
+ abandonSupervisorNotes(); seenReceipts.clear(); settledActions.clear();
1412
1413
  const restoredReceiptIds = new Set();
1413
- const restored = (Array.isArray(transcript) ? transcript : []).flatMap((item) => {
1414
+ // Each turn's plan card comes back under its prompt, settled by how the turn ended (agent-plan.js).
1415
+ const restored = restoredPlans((Array.isArray(transcript) ? transcript : []).flatMap((item) => {
1414
1416
  if (item?.kind === "user" || item?.kind === "assistant") {
1415
1417
  const attachments = Array.isArray(item.attachments) ? item.attachments.filter((entry) => typeof entry?.dataUrl === "string") : [];
1416
1418
  return [{ kind: item.kind, id: newId(), text: String(item.text ?? ""), ...(attachments.length ? { attachments } : {}) }];
@@ -1425,8 +1427,8 @@ export function createAgentChatStore({
1425
1427
  return [{ kind: "receipt", id: `receipt:${item.receiptId || newId()}`, receiptId: item.receiptId, summary: item.summary || "Receipt", receipt: { status: "applied", warnings: [], verification: { status: "verified" } } }];
1426
1428
  }
1427
1429
  if (item?.kind === "failure") return [{ kind: "failure", id: newId(), failure: { code: item.code || "upstream", message: String(item.message || "The turn failed."), recovery: { action: "retry", retryAllowed: true } } }];
1428
- return [];
1429
- });
1430
+ return item?.kind === "plan" ? [{ kind: "plan", id: newId(), steps: item.steps }] : [];
1431
+ }));
1430
1432
  const lastPrompt = [...restored].reverse().find((item) => item.kind === "user")?.text || "";
1431
1433
  set({ sessionId, turnId: null, items: restored, rateLimit: null, lastPrompt, turnStartedAt: null, lastTurn: null, streaming: false });
1432
1434
  return restored;
@@ -1498,14 +1500,15 @@ export function createAgentChatStore({
1498
1500
  model: options.model,
1499
1501
  effort: options.effort ?? undefined,
1500
1502
  ...(typeof options.supervisor === "boolean" ? { supervisor: options.supervisor } : {}), ...(options.tasks ? { tasks: true } : {}),
1501
- }, applyEvent, signal);
1503
+ }, (event) => { sculptSketchFrame(event); applyEvent(event); }, signal);
1502
1504
  } catch (error) {
1503
1505
  if (!signal.aborted) applyEvent({ type: "error", code: "upstream", message: String(error?.message || error) });
1504
1506
  } finally {
1505
1507
  if (controller?.signal === signal) controller = null;
1506
- // A turn the author already replaced owns none of this state.
1508
+ // A turn the author already replaced owns none of this state. Its live sculpt sketch
1509
+ // (the ghost clay of a call still being written) ends with it, whatever ended it.
1507
1510
  if (activeTurn === turn) {
1508
- wireTurnId = null;
1511
+ wireTurnId = null; clearSculptSketch();
1509
1512
  // Ending with nothing on screen is a failure, not a result.
1510
1513
  if (!turn.produced && !turn.failure && !signal.aborted) {
1511
1514
  turn.failure = { code: "no_output", message: "The turn ended without a response.", recovery: { action: "retry", retryAllowed: true } };
@@ -1515,7 +1518,7 @@ export function createAgentChatStore({
1515
1518
  activeTurn = null;
1516
1519
  // The end state stays in the transcript with the turn's steps (agent-turn.js withTurnEnd).
1517
1520
  const lastTurn = { status: signal.aborted ? "stopped" : turn.failure ? "failed" : "done", endedAt, durationMs: endedAt - startedAt, failure: turn.failure };
1518
- set({ streaming: false, turnStartedAt: null, lastTurn, items: withTurnEnd(state.items, lastTurn, newId) });
1521
+ set({ streaming: false, turnStartedAt: null, lastTurn, items: withPlanEnd(withTurnEnd(state.items, lastTurn, newId), lastTurn.status) });
1519
1522
  if (studio && turn.reviewable && options.supervisor === true && !signal.aborted) requestSupervisorNote(turnId);
1520
1523
  }
1521
1524
  }
@@ -0,0 +1,93 @@
1
+ /* A turn's step checklist (AgentPlanCard.jsx); colours, sizes and motion come from agent-panel.css. */
2
+ .agent-plan-row[data-plan-live="true"] {
3
+ position: sticky;
4
+ top: calc(-1 * var(--agent-space-5));
5
+ z-index: 1;
6
+ /* the transcript's own colour above the card, so steps scrolling under it never show through the gap */
7
+ padding-top: var(--agent-space-2);
8
+ margin-top: calc(-1 * var(--agent-space-2));
9
+ background: var(--agent-bg);
10
+ }
11
+
12
+ .agent-plan {
13
+ --agent-plan-mark: 11px;
14
+ display: flex;
15
+ flex-direction: column;
16
+ }
17
+
18
+ .agent-plan-row[data-plan-live="true"] .agent-plan { box-shadow: 0 6px 14px -8px #000c; }
19
+
20
+ .agent-plan-head {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: var(--agent-space-3);
24
+ padding: var(--agent-space-2) var(--agent-space-4);
25
+ color: var(--agent-text-soft, var(--agent-text));
26
+ font-size: var(--agent-font-md);
27
+ }
28
+
29
+ .agent-plan-title { flex: none; font-weight: 600; }
30
+
31
+ .agent-plan-bar {
32
+ flex: 1;
33
+ height: 3px;
34
+ overflow: hidden;
35
+ border-radius: var(--agent-radius-pill);
36
+ background: var(--agent-track);
37
+ }
38
+
39
+ .agent-plan-fill {
40
+ display: block;
41
+ height: 100%;
42
+ background: var(--agent-ok);
43
+ transform: scaleX(var(--agent-plan-done, 0));
44
+ transform-origin: left;
45
+ transition: transform var(--agent-motion);
46
+ }
47
+
48
+ .agent-plan-count { flex: none; color: var(--agent-text-dim); font: var(--agent-font-sm) ui-monospace, monospace; font-variant-numeric: tabular-nums; }
49
+
50
+ .agent-plan-steps {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: 2px;
54
+ margin: 0;
55
+ padding: var(--agent-space-1) 0 var(--agent-space-2);
56
+ border-top: 1px solid var(--agent-border);
57
+ list-style: none;
58
+ }
59
+
60
+ .agent-plan-step {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: var(--agent-space-3);
64
+ min-width: 0;
65
+ padding: 2px var(--agent-space-4);
66
+ color: var(--agent-text-muted);
67
+ font-size: var(--agent-font-md);
68
+ line-height: 1.4;
69
+ }
70
+
71
+ .agent-plan-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .agent-plan-step.done { color: var(--agent-text-dim); }
73
+ .agent-plan-step.active { background: color-mix(in srgb, var(--agent-accent) 12%, transparent); color: var(--agent-text); }
74
+ .agent-plan-step.unfinished { color: var(--agent-text-dim); }
75
+ .agent-plan-step.unfinished .agent-plan-text { font-style: italic; }
76
+
77
+ .agent-plan-mark { flex: none; width: var(--agent-plan-mark); height: var(--agent-plan-mark); }
78
+ .agent-plan-mark.done { color: var(--agent-ok); }
79
+ .agent-plan-mark.unfinished { color: var(--agent-warn); }
80
+ .agent-plan-mark.pending { border: 1.5px solid var(--agent-border-strong); border-radius: var(--agent-radius-pill); }
81
+ /* the step under way: an accent ring whose centre breathes while the turn runs */
82
+ .agent-plan-mark.active { position: relative; border: 1.5px solid var(--agent-accent); border-radius: var(--agent-radius-pill); }
83
+ .agent-plan-mark.active::after { content: ""; position: absolute; inset: 2px; border-radius: var(--agent-radius-pill); background: var(--agent-accent); }
84
+ .agent-plan[data-plan-state="live"] .agent-plan-mark.active::after { animation: agent-pulse 1.4s ease-in-out infinite; }
85
+
86
+ /* A finished turn's card is a record: it keeps its rows but no longer pulls the eye. */
87
+ .agent-plan:not([data-plan-state="live"]) .agent-plan-fill { background: var(--agent-text-dim); }
88
+ .agent-plan[data-plan-state="done"] .agent-plan-fill { background: var(--agent-ok); }
89
+
90
+ @media (prefers-reduced-motion: reduce) {
91
+ .agent-plan[data-plan-state="live"] .agent-plan-mark.active::after { animation: none; }
92
+ .agent-plan-fill { transition: none; }
93
+ }