cozyclay 1.10.0 → 2.0.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 (275) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/bin/agent/agent-routes.mjs +82 -72
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/codex-auth.mjs +2 -1
  10. package/bin/cozyclay.mjs +38 -11
  11. package/bin/live/cli.mjs +22 -3
  12. package/bin/mcp-runtime.mjs +45 -20
  13. package/bin/telemetry-state.mjs +5 -2
  14. package/bin/update-check.mjs +15 -0
  15. package/dist/app/index.html +6 -6
  16. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  17. package/dist/assets/app-BxBLC6GT.js +4876 -0
  18. package/dist/assets/app-CJdxvnrl.css +1 -0
  19. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  20. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  21. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  22. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  23. package/dist/assets/shot-prompt-hN0j_O-p.js +17 -0
  24. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  25. package/dist/assets/workflow-B5XZwVfe.js +179 -0
  26. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  29. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  30. package/dist/fonts/README.md +13 -9
  31. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  33. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  35. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  36. package/dist/index.html +7 -7
  37. package/dist/privacy/index.html +13 -8
  38. package/dist/sitemap.xml +2 -2
  39. package/dist/workflow/index.html +5 -5
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +219 -376
  44. package/package.json +2 -1
  45. package/src/App.jsx +1275 -9086
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/fix-collisions.js +10 -66
  51. package/src/ardy/ik-drag.js +101 -0
  52. package/src/ardy/ik-key-json.js +43 -0
  53. package/src/ardy/ik.js +97 -67
  54. package/src/ardy/physics-panel.css +2 -0
  55. package/src/ardy/physics-panel.jsx +17 -5
  56. package/src/ardy/platform-fit-panel.css +64 -0
  57. package/src/ardy/platform-fit-panel.jsx +40 -0
  58. package/src/ardy/platform-fit.js +326 -0
  59. package/src/ardy/playback.js +166 -20
  60. package/src/ardy/range-pin.js +299 -0
  61. package/src/ardy/timeline-coordinates.js +26 -0
  62. package/src/ardy/timeline.css +1214 -0
  63. package/src/ardy/timeline.jsx +152 -41
  64. package/src/ardy/waypoints.js +3 -3
  65. package/src/asset-pane.css +462 -0
  66. package/src/asset-pane.jsx +461 -232
  67. package/src/camera-tutorial.jsx +38 -18
  68. package/src/command-bus.js +349 -0
  69. package/src/commands/ai.js +31 -0
  70. package/src/commands/cast.js +140 -0
  71. package/src/commands/elements/character.js +13 -0
  72. package/src/commands/elements/motion.js +14 -0
  73. package/src/commands/elements/object.js +22 -0
  74. package/src/commands/elements/scene.js +16 -0
  75. package/src/commands/elements/shot.js +9 -0
  76. package/src/commands/elements/stage.js +11 -0
  77. package/src/commands/elements.js +155 -0
  78. package/src/commands/export.js +20 -0
  79. package/src/commands/index.js +40 -0
  80. package/src/commands/motion.js +231 -0
  81. package/src/commands/objects.js +174 -0
  82. package/src/commands/project.js +53 -0
  83. package/src/commands/scene.js +81 -0
  84. package/src/commands/shared.js +19 -0
  85. package/src/commands/shot.js +103 -0
  86. package/src/commands/stage.js +17 -0
  87. package/src/commands/view.js +42 -0
  88. package/src/document-store.js +144 -0
  89. package/src/domains/cast.js +1007 -0
  90. package/src/domains/motion.js +3219 -0
  91. package/src/domains/objects.js +932 -0
  92. package/src/domains/scenes.js +823 -0
  93. package/src/domains/shots.js +404 -0
  94. package/src/domains/stage.js +93 -0
  95. package/src/dualview.jsx +113 -57
  96. package/src/facing-marks.js +3 -0
  97. package/src/first-success-guide.jsx +14 -14
  98. package/src/grid-view.js +18 -5
  99. package/src/hierarchy-panel.css +386 -0
  100. package/src/hierarchy-panel.jsx +88 -19
  101. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  102. package/src/i2v-motion-studio.jsx +180 -0
  103. package/src/ik-camera.js +3 -0
  104. package/src/main.jsx +6 -0
  105. package/src/motion/generation.js +105 -0
  106. package/src/motion-readiness-ui.jsx +4 -4
  107. package/src/motion-readiness.js +11 -0
  108. package/src/motion-trail.js +366 -9
  109. package/src/object-gizmo.jsx +7 -1
  110. package/src/panels/CameraPanel.jsx +60 -0
  111. package/src/panels/CharacterTransformPanel.jsx +48 -0
  112. package/src/panels/EnvironmentPanel.jsx +40 -0
  113. package/src/panels/Foldout.jsx +32 -0
  114. package/src/panels/LightPanel.jsx +24 -0
  115. package/src/panels/ObjectTransformPanel.jsx +554 -0
  116. package/src/panels/PosePanel.jsx +97 -0
  117. package/src/panels/ProjectPanel.jsx +33 -0
  118. package/src/panels/PromptBlocksPanel.jsx +442 -0
  119. package/src/panels/PropsPanel.jsx +70 -0
  120. package/src/panels/ReferenceImageField.jsx +91 -0
  121. package/src/panels/RigControlPanel.jsx +78 -0
  122. package/src/panels/RigPanel.jsx +37 -0
  123. package/src/panels/SubjectBox.jsx +47 -0
  124. package/src/panels/SubjectsPanel.jsx +39 -0
  125. package/src/panels/VideoCapturePanel.jsx +180 -0
  126. package/src/panels/details.css +1171 -0
  127. package/src/panels/motion.css +197 -0
  128. package/src/panels/pose.css +39 -0
  129. package/src/planview.jsx +60 -31
  130. package/src/posestudio.jsx +59 -4
  131. package/src/project-browser.css +1073 -0
  132. package/src/project-browser.jsx +288 -123
  133. package/src/range-pin-object-transform.js +19 -0
  134. package/src/range-pin-panel.css +518 -0
  135. package/src/range-pin-panel.jsx +344 -0
  136. package/src/result-modal.jsx +5 -5
  137. package/src/room.jsx +79 -51
  138. package/src/scenes.js +9 -8
  139. package/src/semantic-edit.js +2 -0
  140. package/src/settings-menu.jsx +39 -105
  141. package/src/shell/BottomDock.jsx +450 -0
  142. package/src/shell/DetailsSlot.jsx +513 -0
  143. package/src/shell/LibrarySlot.jsx +86 -0
  144. package/src/shell/MenuBar.jsx +601 -0
  145. package/src/shell/OutlinerSlot.jsx +50 -0
  146. package/src/shell/PreferencesDialog.jsx +538 -0
  147. package/src/shell/PreferencesSlot.jsx +40 -0
  148. package/src/shell/StatusBar.jsx +125 -0
  149. package/src/shell/StudioShell.jsx +92 -0
  150. package/src/shell/TopBar.jsx +129 -0
  151. package/src/shell/ViewportToolbar.jsx +656 -0
  152. package/src/shell/agent-glass.css +296 -0
  153. package/src/shell/dock.css +93 -0
  154. package/src/shell/glass-regions.css +985 -0
  155. package/src/shell/glass.css +687 -0
  156. package/src/shell/log-store.js +52 -0
  157. package/src/shell/mode.css +9 -0
  158. package/src/shell/preferences.css +651 -0
  159. package/src/shell/shell.css +282 -0
  160. package/src/shell/studio-shell-context.js +19 -0
  161. package/src/shell/topbar.css +435 -0
  162. package/src/shell/viewport.css +451 -0
  163. package/src/store/authored-intent.js +22 -0
  164. package/src/store/runtime-adapters.js +92 -0
  165. package/src/store/scene-stage.js +21 -0
  166. package/src/store/use-document-store.js +14 -0
  167. package/src/studio-actions.js +210 -0
  168. package/src/studio-agent-commands.js +38 -268
  169. package/src/studio-agent-context.js +38 -9
  170. package/src/studio-agent-motion.js +122 -378
  171. package/src/studio-agent-protocol.js +66 -30
  172. package/src/studio-app-binding.js +379 -0
  173. package/src/studio-contact-sheet.js +73 -0
  174. package/src/studio-elements.js +58 -51
  175. package/src/styles/themes.css +200 -0
  176. package/src/styles/tokens.css +69 -0
  177. package/src/styles.css +456 -1748
  178. package/src/theme.js +44 -0
  179. package/src/timeline-extent.js +16 -0
  180. package/src/timeline-fps.js +13 -0
  181. package/src/trail-key-conflicts.js +39 -0
  182. package/src/trail-pick.js +59 -0
  183. package/src/ui.jsx +8 -8
  184. package/src/use-case-question.jsx +66 -0
  185. package/src/workflow/AgentPanel.jsx +86 -71
  186. package/src/workflow/agent-client.js +12 -2
  187. package/src/workflow/agent-panel.css +34 -32
  188. package/src/workflow/cozy-scene-node.css +2 -0
  189. package/src/workflow/workflow.css +2 -0
  190. package/tools/ardy/bridge.mjs +164 -146
  191. package/tools/ardy/visual-qa.mjs +5 -5
  192. package/tools/bench/EXP3.md +61 -0
  193. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  194. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  195. package/tools/bench/cclay_bench_runner.py +42 -0
  196. package/tools/bench/cube-contact.mjs +162 -0
  197. package/tools/bench/exp3.mjs +152 -0
  198. package/tools/bench/extract-bench-lib.mjs +216 -0
  199. package/tools/bench/extract-bench.mjs +302 -0
  200. package/tools/bench/fal-generate.mjs +88 -0
  201. package/tools/bench/fit/README.md +189 -0
  202. package/tools/bench/fit/camera.mjs +59 -0
  203. package/tools/bench/fit/contact.mjs +419 -0
  204. package/tools/bench/fit/footlock.mjs +234 -0
  205. package/tools/bench/fit/motion.mjs +85 -0
  206. package/tools/bench/fit/pin.mjs +27 -0
  207. package/tools/bench/fit/remote.mjs +105 -0
  208. package/tools/bench/fit-bench.mjs +126 -0
  209. package/tools/bench/fit-sanity.mjs +45 -0
  210. package/tools/bench/metrics.mjs +313 -0
  211. package/tools/bench/obs/depth.mjs +258 -0
  212. package/tools/bench/obs/extrinsics.mjs +175 -0
  213. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  214. package/tools/bench/obs/ground.mjs +294 -0
  215. package/tools/bench/obs/heading.mjs +151 -0
  216. package/tools/bench/obs/ladder.mjs +555 -0
  217. package/tools/bench/obs/mannequin-betas.json +124 -0
  218. package/tools/bench/obs/remote.mjs +83 -0
  219. package/tools/bench/obs/rest_joints.py +52 -0
  220. package/tools/bench/obs/ybot-targets.mjs +49 -0
  221. package/tools/bench/obs-bench.mjs +520 -0
  222. package/tools/bench/score.mjs +343 -0
  223. package/tools/bench/summarize.mjs +86 -0
  224. package/tools/dev/pages/privacy.html +13 -8
  225. package/tools/gt-render/browser.mjs +159 -0
  226. package/tools/gt-render/camera-math.mjs +276 -0
  227. package/tools/gt-render/page.mjs +267 -0
  228. package/tools/gt-render/render.mjs +514 -0
  229. package/tools/gt-render/scene-box.mjs +31 -0
  230. package/tools/gt-render/take-transform.mjs +87 -0
  231. package/tools/qa/css-rule-usage.mjs +283 -0
  232. package/tools/qa/studio-control-count.mjs +70 -32
  233. package/tools/run-tests.mjs +60 -3
  234. package/tools/track/DESIGN.md +513 -0
  235. package/tools/track/backfill-provenance.mjs +116 -0
  236. package/tools/track/budget.mjs +8 -0
  237. package/tools/track/check-rig.mjs +209 -0
  238. package/tools/track/diagnostics.schema.json +60 -0
  239. package/tools/track/export-rig.mjs +256 -0
  240. package/tools/track/fallback.mjs +7 -0
  241. package/tools/track/fk-parity-fixture.mjs +161 -0
  242. package/tools/track/gate.mjs +349 -0
  243. package/tools/track/masks.mjs +318 -0
  244. package/tools/track/metrics.mjs +201 -0
  245. package/tools/track/publish-obs.mjs +108 -0
  246. package/tools/track/py/check_env.py +61 -0
  247. package/tools/track/py/eval_lr.py +229 -0
  248. package/tools/track/py/lr_viterbi.py +294 -0
  249. package/tools/track/py/masks.py +405 -0
  250. package/tools/track/py/objective.py +382 -0
  251. package/tools/track/py/rig.py +508 -0
  252. package/tools/track/py/scene.py +326 -0
  253. package/tools/track/py/test_joint_indices.py +123 -0
  254. package/tools/track/py/test_lr_viterbi.py +195 -0
  255. package/tools/track/py/test_masks.py +229 -0
  256. package/tools/track/py/test_rig.py +288 -0
  257. package/tools/track/py/test_scene.py +354 -0
  258. package/tools/track/py/test_track.py +328 -0
  259. package/tools/track/py/track.py +411 -0
  260. package/tools/track/remote.mjs +140 -0
  261. package/tools/track/rig-dump.mjs +227 -0
  262. package/tools/track/run-box-tests.mjs +14 -0
  263. package/tools/track/run-box.mjs +168 -0
  264. package/tools/track/setup-box.sh +59 -0
  265. package/tools/track/study-2d.mjs +728 -0
  266. package/dist/assets/analytics-B1hnH66c.js +0 -1
  267. package/dist/assets/app-Cu1cexZR.css +0 -1
  268. package/dist/assets/app-Dxdh8kds.js +0 -4861
  269. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  270. package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
  271. package/dist/assets/workflow-CJd-PDtV.js +0 -179
  272. package/dist/assets/workflow-DsudxKHF.css +0 -1
  273. package/src/fal-motion-studio.jsx +0 -180
  274. package/src/scene-history.js +0 -129
  275. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
package/src/theme.js ADDED
@@ -0,0 +1,44 @@
1
+ // Studio colour theme (#570). The choice lives on <html data-theme>, so the
2
+ // shell, its regions and body-level popovers all read the same token set.
3
+ export const THEMES = ["dark", "light"];
4
+ export const THEME_KEY = "cozyclay.theme.v1";
5
+ export const DEFAULT_THEME = "light";
6
+
7
+ import { useEffect, useState } from "react";
8
+
9
+ /** The theme on <html data-theme>, following Preferences' live switch. */
10
+ export function useUiTheme() {
11
+ const [theme, setTheme] = useState(() => globalThis.document?.documentElement.dataset.theme ?? readTheme());
12
+ useEffect(() => {
13
+ const onChange = (event) => setTheme(event.detail);
14
+ globalThis.addEventListener("cozyclay:theme-change", onChange);
15
+ return () => globalThis.removeEventListener("cozyclay:theme-change", onChange);
16
+ }, []);
17
+ return theme;
18
+ }
19
+
20
+ export function readTheme() {
21
+ try {
22
+ const stored = globalThis.localStorage?.getItem(THEME_KEY);
23
+ return THEMES.includes(stored) ? stored : DEFAULT_THEME;
24
+ } catch {
25
+ return DEFAULT_THEME;
26
+ }
27
+ }
28
+
29
+ export function applyTheme(theme) {
30
+ const next = THEMES.includes(theme) ? theme : DEFAULT_THEME;
31
+ document.documentElement.dataset.theme = next;
32
+ return next;
33
+ }
34
+
35
+ export function saveTheme(theme) {
36
+ const next = applyTheme(theme);
37
+ try {
38
+ globalThis.localStorage?.setItem(THEME_KEY, next);
39
+ } catch (error) {
40
+ console.warn("[cozyclay] theme not saved:", error?.name ?? error);
41
+ }
42
+ globalThis.dispatchEvent?.(new CustomEvent("cozyclay:theme-change", { detail: next }));
43
+ return next;
44
+ }
@@ -37,3 +37,19 @@ export function timelineContentExtent(
37
37
  ? authoredExtent
38
38
  : Number.isFinite(extraFrames) ? extraFrames : 0;
39
39
  }
40
+
41
+ /**
42
+ * Return the frame count the shared timeline shows for a content `extent`.
43
+ *
44
+ * It never ends under an authored shot: a shot outside the timeline is an
45
+ * invalid scene for the Studio agent and for playback, so the count is at
46
+ * least every shot's inclusive `endFrame + 1`. A zero extent keeps
47
+ * `currentCount` (the existing authored duration), raised only by a shot.
48
+ */
49
+ export function timelineSpan(extent, shots = [], currentCount = 0) {
50
+ const shotEnd = shots.reduce(
51
+ (max, shot) => Math.max(max, Number.isFinite(shot?.endFrame) ? shot.endFrame + 1 : 0),
52
+ 0,
53
+ );
54
+ return Math.max(extent > 0 ? extent : currentCount, shotEnd);
55
+ }
@@ -0,0 +1,13 @@
1
+ // The timeline clock, on its own so the agent sidecar (bin/agent) can read it
2
+ // without importing scenes.js, whose scene-object and motion-trail imports pull
3
+ // in `three` — a devDependency the npm package does not ship
4
+ // (test/verify-package-agent-imports.mjs).
5
+
6
+ /** v3 and older authored every frame number on ARDY's 20 fps clock; v4 reads
7
+ * them on the 24 fps production clock. The numbers are MULTIPLIED, never
8
+ * reinterpreted: a waypoint at frame 40 meant 2.0 s and must still mean 2.0 s,
9
+ * which is frame 48 — reinterpreting it would silently speed the scene up. */
10
+ export const LEGACY_FRAME_FPS = 20;
11
+ export const TIMELINE_FRAME_FPS = 24;
12
+ export const toTimelineFrame = (frame) =>
13
+ Math.round((frame * TIMELINE_FRAME_FPS) / LEGACY_FRAME_FPS);
@@ -0,0 +1,39 @@
1
+ const CHAIN_BY_EFFECTOR = Object.freeze({
2
+ LeftHand: "leftHand",
3
+ RightHand: "rightHand",
4
+ LeftFoot: "leftFoot",
5
+ RightFoot: "rightFoot",
6
+ });
7
+ const EFFECTOR_BY_CHAIN = Object.freeze({
8
+ leftHand: "LeftHand",
9
+ rightHand: "RightHand",
10
+ leftFoot: "LeftFoot",
11
+ rightFoot: "RightFoot",
12
+ });
13
+
14
+ function trackIds(track) {
15
+ const id = typeof track === "string" ? track : track?.id ?? track?.joint ?? track?.effector;
16
+ const chain = typeof track === "object" ? track?.chain : null;
17
+ return new Set([id, chain, CHAIN_BY_EFFECTOR[id], EFFECTOR_BY_CHAIN[id]].filter(Boolean));
18
+ }
19
+
20
+ function entryFor(entries, id) {
21
+ return typeof entries?.get === "function" ? entries.get(id) : entries?.[id];
22
+ }
23
+
24
+ /** Return absolute IK-key frames that can override a trail edit. */
25
+ export function findAbsoluteIkKeyConflicts({ keys, track, startFrame, endFrame }) {
26
+ const ids = trackIds(track);
27
+ const frames = [];
28
+ for (const [frame, entries] of keys ?? []) {
29
+ if (frame < startFrame || frame >= endFrame) continue;
30
+ for (const id of ids) {
31
+ const key = entryFor(entries, id);
32
+ if (key && key.baseQ == null) {
33
+ frames.push(frame);
34
+ break;
35
+ }
36
+ }
37
+ }
38
+ return [...new Set(frames)].sort((a, b) => a - b);
39
+ }
@@ -0,0 +1,59 @@
1
+ /** Pure picking for motion-trail pointer rays. */
2
+
3
+ const FRAME_PENALTY = 0.001;
4
+ const TIE_DISTANCE = 0.01;
5
+ const MIN_FRAME_WINDOW = 12;
6
+
7
+ /** Pick the trail track/frame intended by a ray near the current playhead. */
8
+ export function pickTrailPoint({
9
+ tracks,
10
+ playheadFrame,
11
+ falloffFrames,
12
+ rayOrigin,
13
+ rayDirection,
14
+ maxDistance,
15
+ }) {
16
+ if (!Array.isArray(tracks) || !rayOrigin || !rayDirection) return null;
17
+ const directionLength = Math.hypot(rayDirection.x, rayDirection.y, rayDirection.z);
18
+ if (!Number.isFinite(directionLength) || directionLength === 0) return null;
19
+ const dx = rayDirection.x / directionLength;
20
+ const dy = rayDirection.y / directionLength;
21
+ const dz = rayDirection.z / directionLength;
22
+ const center = Number.isFinite(playheadFrame) ? playheadFrame : 0;
23
+ const radius = Math.max(2 * (Number.isFinite(falloffFrames) ? falloffFrames : 0), MIN_FRAME_WINDOW);
24
+ const limit = Number.isFinite(maxDistance) ? maxDistance : 0;
25
+ let best = null;
26
+
27
+ for (const track of tracks) {
28
+ const points = track?.flat;
29
+ if (!track?.id || !points || points.length < 3) continue;
30
+ const firstFrame = Math.max(0, Math.ceil(center - radius));
31
+ const lastFrame = Math.min(Math.floor(points.length / 3) - 1, Math.floor(center + radius));
32
+ for (let frame = firstFrame; frame <= lastFrame; frame += 1) {
33
+ const offset = frame * 3;
34
+ const px = points[offset] - rayOrigin.x;
35
+ const py = points[offset + 1] - rayOrigin.y;
36
+ const pz = points[offset + 2] - rayOrigin.z;
37
+ const depth = px * dx + py * dy + pz * dz;
38
+ if (depth < 0) continue;
39
+ const ox = px - depth * dx;
40
+ const oy = py - depth * dy;
41
+ const oz = pz - depth * dz;
42
+ const distance = Math.hypot(ox, oy, oz);
43
+ if (distance > limit) continue;
44
+ const score = distance + Math.abs(frame - center) * FRAME_PENALTY;
45
+ const scoreDelta = score - (best?.score ?? Infinity);
46
+ if (
47
+ !best
48
+ || scoreDelta < -TIE_DISTANCE
49
+ || (Math.abs(scoreDelta) <= TIE_DISTANCE && (
50
+ depth < best.depth
51
+ || (depth === best.depth && score < best.score)
52
+ ))
53
+ ) {
54
+ best = { track: track.id, grabFrame: frame, score, depth };
55
+ }
56
+ }
57
+ }
58
+ return best ? { track: best.track, grabFrame: best.grabFrame } : null;
59
+ }
package/src/ui.jsx CHANGED
@@ -247,7 +247,7 @@ export function Slider({ label, min, max, step, value, unit = "", onChange, comp
247
247
  );
248
248
  if (compact) {
249
249
  return (
250
- <div className="cslider">
250
+ <div className="cslider v2-details-compact-slider">
251
251
  <div className={"cslider-head" + (softMax ? " scrub" : "")} {...scrubProps}>
252
252
  <span>{label}</span>
253
253
  <span className="val">
@@ -260,8 +260,8 @@ export function Slider({ label, min, max, step, value, unit = "", onChange, comp
260
260
  );
261
261
  }
262
262
  return (
263
- <div className="row">
264
- <span>{label}</span>
263
+ <div className="row v2-details-row">
264
+ <span className="v2-details-row-label">{label}</span>
265
265
  {input}
266
266
  <span className="val">
267
267
  {text}
@@ -273,7 +273,7 @@ export function Slider({ label, min, max, step, value, unit = "", onChange, comp
273
273
 
274
274
  export function Field({ label, children }) {
275
275
  return (
276
- <div className="field">
276
+ <div className="field v2-details-field">
277
277
  <label>{label}</label>
278
278
  {children}
279
279
  </div>
@@ -460,13 +460,13 @@ export function NumberField({ label, value, step, precision = 2, scrubRange, onC
460
460
 
461
461
  return (
462
462
  <span
463
- className={`number-field${focused ? " editing" : ""}`}
463
+ className={`number-field v2-details-number-field${focused ? " editing" : ""}`}
464
464
  title={title}
465
465
  onPointerDown={onFieldPointerDown}
466
466
  onPointerUp={endFieldDrag}
467
467
  onPointerCancel={endFieldDrag}
468
468
  >
469
- <span className="axis">{label}</span>
469
+ <span className="axis" data-axis={label}>{label}</span>
470
470
  <input
471
471
  ref={inputRef}
472
472
  type="text"
@@ -485,8 +485,8 @@ export function NumberField({ label, value, step, precision = 2, scrubRange, onC
485
485
  // per axis. `fields` entries are `{ axis, value, step, precision, onChange }`.
486
486
  export function Vector3Row({ label, fields }) {
487
487
  return (
488
- <div className="vec3-row">
489
- <span className="vec3-label">{label}</span>
488
+ <div className="vec3-row v2-details-transform-row">
489
+ <span className="vec3-label v2-details-transform-label">{label}</span>
490
490
  {fields.map((field) => (
491
491
  <NumberField
492
492
  key={field.axis}
@@ -0,0 +1,66 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ // Closed choices only (analytics USE_CASE_VALUES / TEAM_VALUES): no free text
4
+ // ever leaves the browser from this card.
5
+ const USE_CASES = Object.freeze([
6
+ ["animation", "Animation", "애니메이션"],
7
+ ["film", "Film / drama", "영화·드라마"],
8
+ ["game", "Game", "게임"],
9
+ ["ad_mv", "Ad / music video", "광고·뮤비"],
10
+ ["personal", "Personal project", "개인 작업"],
11
+ ["other", "Something else", "기타"],
12
+ ]);
13
+ const TEAMS = Object.freeze([
14
+ ["team", "With a team or company", "팀·회사에서"],
15
+ ["solo", "On my own", "혼자"],
16
+ ]);
17
+
18
+ /**
19
+ * One optional question after the first successful export (#466). Two taps:
20
+ * what the export is for, then team or solo. Closing it at any point counts as
21
+ * a skip, and the card never comes back on this browser profile.
22
+ */
23
+ export default function UseCaseQuestion({ open, isKo, onAnswer }) {
24
+ const [useCase, setUseCase] = useState(null);
25
+ const label = (en, ko) => (isKo ? ko : en);
26
+ useEffect(() => {
27
+ if (!open) setUseCase(null);
28
+ }, [open]);
29
+ if (!open) return null;
30
+ const close = () => onAnswer(useCase ?? "skip", "skip");
31
+ return (
32
+ <section
33
+ className="use-case-ask"
34
+ role="dialog"
35
+ aria-modal="false"
36
+ aria-labelledby="use-case-ask-title"
37
+ onKeyDown={(event) => {
38
+ if (event.key === "Escape") close();
39
+ }}
40
+ >
41
+ <div className="use-case-ask-head">
42
+ <strong id="use-case-ask-title">{label("Quick question (optional)", "짧은 질문 (선택)")}</strong>
43
+ <button type="button" className="use-case-ask-close" aria-label={label("Close", "닫기")} onClick={close}>
44
+ ×
45
+ </button>
46
+ </div>
47
+ <p className="use-case-ask-prompt">
48
+ {useCase
49
+ ? label("Who are you making it with?", "누구와 작업하나요?")
50
+ : label("What are you making with CozyClay?", "CozyClay로 뭘 만들고 있나요?")}
51
+ </p>
52
+ <div className="use-case-ask-choices" role="group" aria-label={useCase ? label("Team or solo", "팀 또는 혼자") : label("Use case", "용도")}>
53
+ {(useCase ? TEAMS : USE_CASES).map(([value, en, ko]) => (
54
+ <button
55
+ key={value}
56
+ type="button"
57
+ onClick={() => (useCase ? onAnswer(useCase, value) : setUseCase(value))}
58
+ >
59
+ {label(en, ko)}
60
+ </button>
61
+ ))}
62
+ </div>
63
+ <p className="use-case-ask-note">{label("Anonymous. Only the choice is sent.", "익명이에요. 고른 항목만 전송돼요.")}</p>
64
+ </section>
65
+ );
66
+ }
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
- import { FiAlertTriangle, FiCheck, FiChevronRight, FiClock, FiDownload, FiImage, FiMoreHorizontal, FiPaperclip, FiPlus, FiRotateCw, FiX } from "react-icons/fi";
2
+ import { FiAlertTriangle, FiArrowUp, FiCheck, FiChevronRight, FiClock, FiDownload, FiFilm, FiImage, FiMoreHorizontal, FiPaperclip, FiPlus, FiRotateCw, FiX } from "react-icons/fi";
3
3
  import {
4
4
  ATTACHMENT_FAILED_NOTICE,
5
5
  ATTACHMENT_LIMIT_NOTICE,
@@ -132,10 +132,12 @@ function JobCard({ job, onStop, onAccept }) {
132
132
  const phase = job.state === "installed" ? null : job.phase;
133
133
  const unverified = job.state === "review_required" || job.verification?.status === "unverified";
134
134
  const limitations = job.verification?.limitations ?? [];
135
+ // An unverified take is installed all the same; the label says both.
136
+ const label = job.state === "installed" && unverified ? "Installed with warnings" : JOB_STATE_COPY[job.state] || job.state;
135
137
  return <div className="agent-card agent-job-card" data-job-id={job.jobId} data-job-state={job.state}>
136
138
  <div className="agent-job-head">
137
139
  <StatusDot tone={jobStateTone(job.state)} title={job.state} />
138
- <span className="agent-job-label">{JOB_STATE_COPY[job.state] || job.state}</span>
140
+ <span className="agent-job-label">{label}</span>
139
141
  {unverified && <span className="agent-job-badge">Unverified</span>}
140
142
  {percent !== null && <span className="agent-job-progress">{formatJobProgress(job.progress)}</span>}
141
143
  </div>
@@ -163,15 +165,20 @@ function ReceiptCard({ item }) {
163
165
  const unverified = receipt.verification?.status === "unverified";
164
166
  const limitations = receipt.verification?.limitations ?? [];
165
167
  const warnings = receipt.warnings ?? [];
168
+ const installedWithWarnings = receipt.status === "installed" && unverified;
166
169
  return <div className="agent-card agent-receipt-card" data-receipt-id={item.receiptId} data-receipt-status={receipt.status}>
167
170
  <div className="agent-receipt-head">
168
171
  <StatusDot tone={unverified ? "warn" : "ok"} title={receipt.status} />
172
+ {installedWithWarnings && <span className="agent-job-label">Installed with warnings</span>}
169
173
  <span className="agent-receipt-summary">{summary}</span>
170
174
  </div>
171
175
  {(warnings.length > 0 || limitations.length > 0) && <ul className="agent-receipt-notes">
172
176
  {warnings.map((warning) => <li key={warning.code}>{warning.message || warning.code}</li>)}
173
177
  {limitations.map((limit) => <li key={limit}>{limit}</li>)}
174
178
  </ul>}
179
+ {/* The pane has no direct Undo into the Studio editor; it names the two
180
+ paths that do exist rather than drawing a button it cannot honour. */}
181
+ {installedWithWarnings && <p className="agent-job-note">Undo with Cmd+Z or ask the agent.</p>}
175
182
  </div>;
176
183
  }
177
184
 
@@ -268,7 +275,7 @@ export default function AgentPanel({
268
275
  surface = "workflow",
269
276
  buildContext = null,
270
277
  onImageAction = null,
271
- onFalAction = null,
278
+ onI2vAction = null,
272
279
  onReceipt = null,
273
280
  }) {
274
281
  // What this host shows around the shared conversation: labels, chips, the
@@ -346,8 +353,8 @@ export default function AgentPanel({
346
353
  buildContextRef.current = buildContext;
347
354
  const imageActionRef = useRef(onImageAction);
348
355
  imageActionRef.current = onImageAction;
349
- const falActionRef = useRef(onFalAction);
350
- falActionRef.current = onFalAction;
356
+ const i2vActionRef = useRef(onI2vAction);
357
+ i2vActionRef.current = onI2vAction;
351
358
  const receiptRef = useRef(onReceipt);
352
359
  receiptRef.current = onReceipt;
353
360
 
@@ -375,10 +382,12 @@ export default function AgentPanel({
375
382
  const payload = await transport.loadSession(stored);
376
383
  if (!Array.isArray(payload?.transcript)) throw new Error("missing transcript");
377
384
  if (!cancelled) store.restore(payload.transcript, payload.sessionId || stored);
378
- } catch {
385
+ } catch (error) {
379
386
  if (!cancelled) {
380
387
  store.newSession();
381
- setRestoreNotice("Previous conversation could not be restored");
388
+ // A session id with no record behind it (the pane opened, nothing
389
+ // was ever sent) is nothing to restore, not a failure.
390
+ if (error?.status !== 404) setRestoreNotice("Previous conversation could not be restored");
382
391
  }
383
392
  } finally {
384
393
  if (!cancelled) setRestoreReady(true);
@@ -814,9 +823,9 @@ export default function AgentPanel({
814
823
  <StatusDot tone={statusTone} title={panelState} />
815
824
  <h2 className="agent-title">Agent</h2>
816
825
  <span className="agent-header-spacer" />
817
- <button type="button" className="agent-ghost-button agent-new" onClick={newSession}><FiPlus size={11} /> New</button>
826
+ <button type="button" className="agent-icon-button agent-new" aria-label="New conversation" title="New conversation" onClick={newSession}><FiPlus size={14} aria-hidden="true" /></button>
818
827
  {presentation.history && <span className="agent-history-wrap">
819
- <button type="button" className="agent-ghost-button agent-history" aria-haspopup="listbox" aria-expanded={surface === "studio" ? historyOpen : undefined} onClick={surface === "studio" ? openHistory : undefined} disabled={surface !== "studio"}>History</button>
828
+ <button type="button" className="agent-icon-button agent-history" aria-label="History" title="History" aria-haspopup="listbox" aria-expanded={surface === "studio" ? historyOpen : undefined} onClick={surface === "studio" ? openHistory : undefined} disabled={surface !== "studio"}><FiClock size={13} aria-hidden="true" /></button>
820
829
  {surface === "studio" && historyOpen && <div className="agent-history-popover" role="listbox" aria-label="Agent history">
821
830
  {historySessions.length ? historySessions.map((entry) => <button type="button" role="option" className="agent-history-item" key={entry.sessionId} onClick={() => restoreHistorySession(entry.sessionId)}>
822
831
  <span className="agent-history-time">{relativeTime(entry.updatedAt)}</span>
@@ -827,6 +836,13 @@ export default function AgentPanel({
827
836
  <span className="agent-overflow">
828
837
  <button type="button" className="agent-icon-button agent-overflow-toggle" aria-haspopup="menu" aria-expanded={menuOpen} aria-label="More agent actions" onClick={() => setMenuOpen((value) => !value)}><FiMoreHorizontal size={13} /></button>
829
838
  {menuOpen && <div className="agent-menu" role="menu">
839
+ {/* Who the session is lives with the actions that change it, not
840
+ as a standing strip over the conversation. */}
841
+ {account?.signedIn && <div className="agent-account" role="presentation">
842
+ <span className="agent-account-email">{account.email}</span>
843
+ <span className="agent-plan-badge">{quota?.plan || account.plan || "Free"}</span>
844
+ {resetLabel && <span className="agent-account-reset">resets in {resetLabel}</span>}
845
+ </div>}
830
846
  <button type="button" role="menuitem" onClick={clearContext}>Clear context</button>
831
847
  <button type="button" role="menuitem" className="agent-menu-keys" aria-expanded={keysOpen} onClick={toggleProviderKeys}>Provider keys…</button>
832
848
  <button type="button" role="menuitem" onClick={signOut}>Sign out</button>
@@ -838,12 +854,6 @@ export default function AgentPanel({
838
854
  {restoreNotice && <div className="agent-toast" role="status">{restoreNotice}</div>}
839
855
  {steerNotice && <div className="agent-toast alert agent-steer-notice" role="alert">{steerNotice}</div>}
840
856
 
841
- {account?.signedIn && <div className="agent-account">
842
- <span className="agent-account-email">{account.email}</span>
843
- <span className="agent-plan-badge">{quota?.plan || account.plan || "Free"}</span>
844
- {resetLabel && <span className="agent-account-reset">resets in {resetLabel}</span>}
845
- </div>}
846
-
847
857
  {/* An inline section, not a modal: the panel stays the one place the
848
858
  conversation and the credentials it runs on are managed. */}
849
859
  {keysOpen && <section className="agent-keys" aria-label="Provider keys" data-agent-keys="true">
@@ -923,63 +933,68 @@ export default function AgentPanel({
923
933
  </li>)}
924
934
  </ul>}
925
935
  {attachNotice && <p className="agent-attachment-notice" role="status">{attachNotice}</p>}
926
- <textarea
927
- ref={composerRef}
928
- className="agent-input"
929
- aria-label="Message the agent"
930
- placeholder={panelState === "rate-limited" ? "Composer is paused" : model ? presentation.composerPlaceholder : "Waiting for the model list…"}
931
- value={draft}
932
- disabled={composerDisabled}
933
- onChange={(event) => store.setDraft(event.target.value)}
934
- onKeyDown={onComposerKeyDown}
935
- onPaste={onComposerPaste}
936
- onDragOver={onComposerDragOver}
937
- onDrop={onComposerDrop}
938
- />
939
- <div className="agent-composer-controls agent-composer-picks">
940
- {/* Five providers in one dropdown: grouped by the provider that serves
941
- them, and a provider without a credential still lists its models —
942
- unpickable, and saying what they are waiting for. */}
943
- <select className="agent-model-select" aria-label="Model" value={model} disabled={!models.length} onChange={(event) => chooseModel(event.target.value)}>
944
- {modelProviders.length
945
- ? modelProviders.map((provider) => <optgroup key={provider.id} label={provider.label}>
946
- {provider.models.length
947
- ? provider.models.map((entry) => <option key={entry.key} value={entry.key} disabled={!provider.signedIn}>{provider.signedIn ? entry.label : `${entry.label} — add key`}</option>)
948
- : <option value={`${provider.id}/`} disabled>{provider.signedIn ? "No models" : "No models — add key"}</option>}
949
- </optgroup>)
950
- : models.length
951
- ? models.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)
952
- : <option value="">{modelsState === "failed" ? "No model available" : "Loading models…"}</option>}
953
- </select>
954
- {efforts.length > 0 && (
955
- <select className="agent-model-select agent-effort-select" aria-label="Reasoning effort" title="Reasoning effort" value={effort ?? efforts[0]} onChange={(event) => setEffort(event.target.value)}>
956
- {efforts.map((value, index) => <option key={value} value={value}>{index === 0 ? `${value} · default` : value}</option>)}
936
+ {/* One field: the message, and under it on the same surface the
937
+ frame toggle, the model and effort picks, and Send. */}
938
+ <div className="agent-field">
939
+ <textarea
940
+ ref={composerRef}
941
+ className="agent-input"
942
+ aria-label="Message the agent"
943
+ placeholder={panelState === "rate-limited" ? "Composer is paused" : model ? presentation.composerPlaceholder : "Waiting for the model list…"}
944
+ value={draft}
945
+ disabled={composerDisabled}
946
+ onChange={(event) => store.setDraft(event.target.value)}
947
+ onKeyDown={onComposerKeyDown}
948
+ onPaste={onComposerPaste}
949
+ onDragOver={onComposerDragOver}
950
+ onDrop={onComposerDrop}
951
+ />
952
+ <div className="agent-composer-controls">
953
+ <button type="button" className="agent-attach-chip" aria-pressed={attachFrame} aria-label="Attach current frame" title="Attach current frame" onClick={() => setAttachFrame((value) => !value)}>
954
+ {attachFrame ? <span className="agent-attach-thumb" aria-hidden="true" /> : <FiPaperclip size={13} aria-hidden="true" />}
955
+ </button>
956
+ {/* Five providers in one dropdown: grouped by the provider that serves
957
+ them, and a provider without a credential still lists its models —
958
+ unpickable, and saying what they are waiting for. */}
959
+ <select className="agent-model-select" aria-label="Model" title="Model" value={model} disabled={!models.length} onChange={(event) => chooseModel(event.target.value)}>
960
+ {modelProviders.length
961
+ ? modelProviders.map((provider) => <optgroup key={provider.id} label={provider.label}>
962
+ {provider.models.length
963
+ ? provider.models.map((entry) => <option key={entry.key} value={entry.key} disabled={!provider.signedIn}>{provider.signedIn ? entry.label : `${entry.label} — add key`}</option>)
964
+ : <option value={`${provider.id}/`} disabled>{provider.signedIn ? "No models" : "No models — add key"}</option>}
965
+ </optgroup>)
966
+ : models.length
967
+ ? models.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)
968
+ : <option value="">{modelsState === "failed" ? "No model available" : "Loading models…"}</option>}
957
969
  </select>
958
- )}
959
- </div>
960
- <div className="agent-composer-controls">
961
- <button type="button" className="agent-attach-chip" aria-pressed={attachFrame} onClick={() => setAttachFrame((value) => !value)}>
962
- {attachFrame ? <span className="agent-attach-thumb" aria-hidden="true" /> : <FiPaperclip size={11} aria-hidden="true" />}
963
- Attach current frame
964
- </button>
965
- {surface === "studio" && falActionRef.current && <button
966
- type="button"
967
- className="agent-attach-chip agent-fal-action"
968
- data-testid="fal-agent-action"
969
- disabled={composerDisabled || !draft.trim() || streaming}
970
- onClick={() => { const instruction = draft.trim(); store.setDraft(""); falActionRef.current?.(instruction); }}
971
- >
972
- Generate motion
973
- </button>}
974
- <span className="agent-composer-spacer" aria-hidden="true" />
975
- {/* While a turn runs, Stop is still the way out of it; on a surface
976
- that can be steered, Send becomes the way INTO it. */}
977
- {streaming
978
- ? <>
979
- <button type="button" className="agent-send stop agent-stop" onClick={stopTurn}>Stop</button>
980
- {presentation.steer && <button type="button" className="agent-send agent-steer" disabled={!draft.trim()} onClick={() => steerTurn(draft)}>Steer</button>}
981
- </>
982
- : <button type="button" className="agent-send" disabled={composerDisabled || !draft.trim()} onClick={() => runTurn(draft)}>Send</button>}
970
+ {efforts.length > 0 && (
971
+ <select className="agent-model-select agent-effort-select" aria-label="Reasoning effort" title="Reasoning effort" value={effort ?? efforts[0]} onChange={(event) => setEffort(event.target.value)}>
972
+ {/* The closed select shows only the word; "default" rides in the
973
+ option's tooltip so the composer row stays one line. */}
974
+ {efforts.map((value, index) => <option key={value} value={value} title={index === 0 ? "Default for this model" : undefined}>{value}</option>)}
975
+ </select>
976
+ )}
977
+ <span className="agent-composer-spacer" aria-hidden="true" />
978
+ {surface === "studio" && i2vActionRef.current && <button
979
+ type="button"
980
+ className="agent-attach-chip agent-i2v-action"
981
+ data-testid="i2v-agent-action"
982
+ aria-label="Generate motion"
983
+ title="Generate motion from this message"
984
+ disabled={composerDisabled || !draft.trim() || streaming}
985
+ onClick={() => { const instruction = draft.trim(); store.setDraft(""); i2vActionRef.current?.(instruction); }}
986
+ >
987
+ <FiFilm size={13} aria-hidden="true" />
988
+ </button>}
989
+ {/* While a turn runs, Stop is still the way out of it; on a surface
990
+ that can be steered, Send becomes the way INTO it. */}
991
+ {streaming
992
+ ? <>
993
+ <button type="button" className="agent-send stop agent-stop" onClick={stopTurn}>Stop</button>
994
+ {presentation.steer && <button type="button" className="agent-send agent-steer" disabled={!draft.trim()} onClick={() => steerTurn(draft)}>Steer</button>}
995
+ </>
996
+ : <button type="button" className="agent-send" aria-label="Send" title="Send (Enter)" disabled={composerDisabled || !draft.trim()} onClick={() => runTurn(draft)}><FiArrowUp size={14} aria-hidden="true" /></button>}
997
+ </div>
983
998
  </div>
984
999
  </div>}
985
1000
  {/* The hint exists to warn about image spend; a surface that cannot
@@ -148,7 +148,7 @@ const CANVAS_TOOL_LABELS = {
148
148
  add_reference_node: "Add reference image",
149
149
  };
150
150
 
151
- /** The nine Studio families, named for what they do to the scene. The Studio
151
+ /** The Studio families, named for what they do to the scene. The Studio
152
152
  * is bilingual, so these go through ko() like every other Studio label. */
153
153
  const STUDIO_TOOL_LABELS = {
154
154
  inspect_studio: ko("Read the scene", "\uC7A5\uBA74 \uC77D\uAE30"),
@@ -160,6 +160,7 @@ const STUDIO_TOOL_LABELS = {
160
160
  generate_motion: ko("Generate motion", "\uBAA8\uC158 \uC0DD\uC131"),
161
161
  verify_result: ko("Verify the result", "\uACB0\uACFC \uAC80\uC99D"),
162
162
  undo_edit: ko("Undo an edit", "\uD3B8\uC9D1 \uB418\uB3CC\uB9AC\uAE30"),
163
+ run_action: ko("Run an editor action", "\uD3B8\uC9D1\uAE30 \uB3D9\uC791 \uC2E4\uD589"),
163
164
  };
164
165
 
165
166
  /**
@@ -1349,13 +1350,22 @@ export function createAgentChatStore({
1349
1350
  const restore = (transcript, sessionId = state.sessionId) => {
1350
1351
  seenReceipts.clear();
1351
1352
  settledActions.clear();
1353
+ const restoredReceiptIds = new Set();
1352
1354
  const restored = (Array.isArray(transcript) ? transcript : []).flatMap((item) => {
1353
1355
  if (item?.kind === "user" || item?.kind === "assistant") {
1354
1356
  const attachments = Array.isArray(item.attachments) ? item.attachments.filter((entry) => typeof entry?.dataUrl === "string") : [];
1355
1357
  return [{ kind: item.kind, id: newId(), text: String(item.text ?? ""), ...(attachments.length ? { attachments } : {}) }];
1356
1358
  }
1357
1359
  if (item?.kind === "tool") return [{ kind: "tool", id: newId(), callId: newId(), name: item.name || "tool", label: item.label, ok: item.ok, elapsedMs: item.elapsedMs, status: item.ok === false ? "failed" : "done" }];
1358
- if (item?.kind === "receipt") return [{ kind: "receipt", id: `receipt:${item.receiptId || newId()}`, receiptId: item.receiptId, summary: item.summary || "Receipt", receipt: { status: "applied", warnings: [], verification: { status: "verified" } } }];
1360
+ if (item?.kind === "receipt") {
1361
+ if (item.receiptId && restoredReceiptIds.has(item.receiptId)) return [];
1362
+ if (item.receiptId) {
1363
+ restoredReceiptIds.add(item.receiptId);
1364
+ seenReceipts.add(item.receiptId);
1365
+ }
1366
+ return [{ kind: "receipt", id: `receipt:${item.receiptId || newId()}`, receiptId: item.receiptId, summary: item.summary || "Receipt", receipt: { status: "applied", warnings: [], verification: { status: "verified" } } }];
1367
+ }
1368
+ 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 } } }];
1359
1369
  return [];
1360
1370
  });
1361
1371
  const lastPrompt = [...restored].reverse().find((item) => item.kind === "user")?.text || "";