cozyclay 3.1.6 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +25 -17
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/openai-compatible-provider.mjs +24 -1
  10. package/bin/agent/studio-prompt.mjs +2 -2
  11. package/bin/agent/supervisor-followup.mjs +34 -0
  12. package/bin/agent/supervisor.mjs +6 -6
  13. package/bin/cozyclay.mjs +23 -60
  14. package/bin/star-prompt.mjs +15 -14
  15. package/bin/studio-routes.mjs +6 -3
  16. package/bin/telemetry-state.mjs +5 -7
  17. package/bin/update-check.mjs +61 -7
  18. package/bin/update-install.mjs +16 -3
  19. package/bin/update-restart.mjs +95 -0
  20. package/bin/update-routes.mjs +25 -3
  21. package/dist/app/index.html +13 -7
  22. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  23. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  24. package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
  25. package/dist/assets/app-DJt06Oyb.js +5144 -0
  26. package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
  27. package/dist/assets/landing-2Fy7DU41.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  30. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  31. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  32. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  33. package/dist/cozyclay-package.json +1 -1
  34. package/dist/index.html +4 -1
  35. package/dist/privacy/index.html +9 -6
  36. package/dist/sitemap.xml +2 -2
  37. package/dist/workflow/index.html +5 -4
  38. package/mcp/README.md +1 -1
  39. package/mcp/live-hub.mjs +4 -4
  40. package/mcp/tool-handlers.mjs +14 -16
  41. package/package.json +3 -1
  42. package/src/App.jsx +42 -75
  43. package/src/analytics-controls.js +25 -3
  44. package/src/analytics-strict.js +16 -4
  45. package/src/analytics.js +32 -26
  46. package/src/app-version.js +13 -0
  47. package/src/asset-drop.js +39 -0
  48. package/src/asset-pane.css +34 -0
  49. package/src/asset-pane.jsx +4 -35
  50. package/src/bus-tasks.js +1 -1
  51. package/src/character-kind.js +8 -6
  52. package/src/click-friction.js +5 -4
  53. package/src/command-bus-telemetry.js +8 -7
  54. package/src/command-bus.js +2 -16
  55. package/src/commands/cast.js +2 -1
  56. package/src/commands/index.js +2 -1
  57. package/src/commands/motion3.js +3 -1
  58. package/src/commands/objects.js +2 -37
  59. package/src/commands/physics.js +21 -0
  60. package/src/commands/project.js +3 -3
  61. package/src/commands/sculpt-compare.js +53 -0
  62. package/src/content-tile.jsx +36 -0
  63. package/src/domains/cast.js +7 -6
  64. package/src/domains/motion.js +13 -14
  65. package/src/domains/scenes.js +41 -31
  66. package/src/execution-telemetry.js +7 -3
  67. package/src/export-menu-telemetry.js +26 -0
  68. package/src/finger-handles.jsx +128 -0
  69. package/src/friction-hint.js +82 -0
  70. package/src/live-control.js +2 -1
  71. package/src/locale-zh/panels.js +20 -0
  72. package/src/locale-zh/shell.js +4 -0
  73. package/src/locale-zh/workspace.js +6 -0
  74. package/src/motion/center-of-mass.js +5 -23
  75. package/src/motion/contact-project.js +143 -0
  76. package/src/motion/find-bone.js +13 -0
  77. package/src/motion/fingers.js +170 -0
  78. package/src/motion/fingertip-pick.js +21 -0
  79. package/src/motion/fix-collisions.js +1 -1
  80. package/src/motion/ik-drag.js +93 -1
  81. package/src/motion/ik-exposure.js +48 -0
  82. package/src/motion/ik.js +4 -11
  83. package/src/motion/timeline.css +12 -0
  84. package/src/motion/timeline.jsx +2 -2
  85. package/src/motion3/compile-job.js +3 -2
  86. package/src/motion3/compiler/compile.js +3 -3
  87. package/src/motion3/compiler/head-lead.js +4 -2
  88. package/src/motion3/compiler/spring.js +95 -0
  89. package/src/motion3/compiler/validate-objects.js +18 -2
  90. package/src/motion3/compiler/validate-parts.js +1 -0
  91. package/src/motion3/compiler/vocab.js +1 -1
  92. package/src/motion3/fix/index.js +8 -4
  93. package/src/motion3/fix/person.js +110 -0
  94. package/src/motion3/free-motion.js +49 -11
  95. package/src/motion3/hybrid-hint.js +19 -0
  96. package/src/motion3/load.js +19 -12
  97. package/src/motion3/object-pose.js +44 -2
  98. package/src/motion3/people.js +16 -1
  99. package/src/motion3/physics-frames.js +72 -0
  100. package/src/motion3/physics-trail.js +45 -0
  101. package/src/motion3/physics-view.js +79 -0
  102. package/src/motion3/pipeline.js +5 -1
  103. package/src/motion3/qa/ballistic.js +67 -0
  104. package/src/motion3/qa/com.js +140 -0
  105. package/src/motion3/qa/ground.js +10 -3
  106. package/src/motion3/qa/hints-contact.js +21 -7
  107. package/src/motion3/qa/hints-motion.js +38 -1
  108. package/src/motion3/qa/key-checks.js +5 -4
  109. package/src/motion3/qa/patch.js +32 -4
  110. package/src/motion3/qa/people-checks.js +1 -1
  111. package/src/motion3/qa/spring-pivot.js +23 -0
  112. package/src/motion3/qa/style.js +13 -30
  113. package/src/motion3/qa/verdict.js +7 -2
  114. package/src/motion3/repair.js +28 -8
  115. package/src/motion3/schema/fields.js +4 -2
  116. package/src/motion3/schema/topics.js +15 -11
  117. package/src/motion3/schema/types.js +13 -9
  118. package/src/motion3/skills/catch.js +2 -1
  119. package/src/panels/FingerPanel.jsx +38 -0
  120. package/src/panels/RigControlPanel.jsx +9 -1
  121. package/src/panels/SculptPanel.jsx +12 -12
  122. package/src/panels/SculptPartTree.jsx +46 -0
  123. package/src/panels/SubjectsPanel.jsx +1 -1
  124. package/src/panels/sculpt.css +11 -0
  125. package/src/physics-overlay-settings.js +41 -0
  126. package/src/physics-overlay.js +152 -0
  127. package/src/physics-overlay.jsx +54 -0
  128. package/src/physics-toggle.jsx +20 -0
  129. package/src/pose-fade.js +7 -3
  130. package/src/posestudio.jsx +15 -28
  131. package/src/project-browser.css +6 -0
  132. package/src/project-browser.jsx +9 -18
  133. package/src/project-start-choices.jsx +45 -0
  134. package/src/project.js +16 -12
  135. package/src/proxy-figure-shape.js +11 -0
  136. package/src/proxy-figure.jsx +25 -18
  137. package/src/scene-assets.js +17 -13
  138. package/src/scenes.js +22 -11
  139. package/src/sculpt-blueprint.js +56 -0
  140. package/src/sculpt-mode.js +5 -0
  141. package/src/sculpt-part-edit.js +115 -0
  142. package/src/sculpt-part-gizmo.jsx +159 -0
  143. package/src/sculpt-part-outline.jsx +72 -0
  144. package/src/sculpt-pick.js +53 -0
  145. package/src/sculpt-run-text.js +51 -0
  146. package/src/sculpt-run.js +10 -46
  147. package/src/sculpt-shelf-file.js +92 -0
  148. package/src/sculpt-shelf-tiles.jsx +69 -0
  149. package/src/sculpt-shelf.js +108 -0
  150. package/src/sculpt-stage.jsx +39 -2
  151. package/src/sculpt-thumb.js +72 -0
  152. package/src/sculpt-views.js +75 -0
  153. package/src/semantic-edit.js +0 -87
  154. package/src/settings-menu.jsx +2 -2
  155. package/src/shell/DetailsSlot.jsx +5 -6
  156. package/src/shell/LibrarySlot.jsx +8 -10
  157. package/src/shell/MenuBar.jsx +6 -6
  158. package/src/shell/PreferencesDialog.jsx +2 -3
  159. package/src/shell/ViewportToolbar.jsx +15 -18
  160. package/src/shell/add-character-entries.js +13 -0
  161. package/src/shell/pose-controls.js +32 -0
  162. package/src/shell/preference-pages.jsx +2 -11
  163. package/src/shell/storyboard-caption.js +21 -0
  164. package/src/shell/studio-shell-context.js +1 -0
  165. package/src/shortcut-friction.js +2 -1
  166. package/src/site/landing-analytics.js +28 -0
  167. package/src/studio-actions.js +3 -2
  168. package/src/studio-agent-protocol.js +4 -4
  169. package/src/studio-app-binding.js +4 -3
  170. package/src/update/UpdateChip.jsx +21 -2
  171. package/src/update/UpdateDialog.jsx +26 -6
  172. package/src/update/update-client.js +123 -17
  173. package/src/update/update-telemetry.js +15 -3
  174. package/src/use-sculpt-mode.js +62 -8
  175. package/src/workflow/agent-failure-codes.js +28 -9
  176. package/src/workflow/agent-turn-telemetry.js +1 -1
  177. package/src/workflow/task-board.js +2 -2
  178. package/tools/dev/page-shell.py +1 -1
  179. package/tools/dev/pages/greybox-to-video.html +21 -2
  180. package/tools/dev/pages/previs-software.html +3 -3
  181. package/tools/dev/pages/privacy.html +9 -6
  182. package/tools/dev/pages/seedance-camera-control.html +2 -2
  183. package/tools/loc-baseline.json +10 -10
  184. package/tools/run-tests.mjs +53 -84
  185. package/tools/select-tests.mjs +119 -0
  186. package/tools/test-durations.json +455 -0
  187. package/tools/test-graph.mjs +181 -0
  188. package/tools/test-shards.mjs +78 -0
  189. package/tools/tutorial/README.md +74 -0
  190. package/tools/tutorial/assemble.mjs +43 -0
  191. package/tools/tutorial/captions.mjs +43 -0
  192. package/tools/tutorial/cdp.mjs +55 -0
  193. package/tools/tutorial/cursor.mjs +159 -0
  194. package/tools/tutorial/episode.mjs +173 -0
  195. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  196. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  197. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  198. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  199. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  200. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  201. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  202. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  203. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  204. package/tools/tutorial/episodes/cast.mjs +12 -0
  205. package/tools/tutorial/episodes/first-video.mjs +121 -0
  206. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  207. package/tools/tutorial/launch.mjs +63 -0
  208. package/tools/tutorial/mux.mjs +61 -0
  209. package/tools/tutorial/overlay.mjs +142 -0
  210. package/tools/tutorial/record.mjs +156 -0
  211. package/tools/tutorial/scenes/terminal.html +63 -0
  212. package/tools/tutorial/steps.mjs +111 -0
  213. package/tools/tutorial/terminal.mjs +43 -0
  214. package/tools/tutorial/voice.mjs +49 -0
  215. package/tools/update-test-durations.mjs +42 -0
  216. package/tools/verify-package-tarball.mjs +18 -12
  217. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  218. package/dist/assets/app-BLS1Zaru.js +0 -5144
  219. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  220. package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
  221. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  222. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  223. package/dist/assets/task-board-CkLZGPkv.js +0 -15
  224. package/dist/assets/workflow-DJwOUzqJ.js +0 -179
@@ -137,13 +137,14 @@ export function createStudioAppBinding(ports) {
137
137
  const frameRange = row => ({ startFrame: row.startFrame, endFrameExclusive: row.endFrame + 1 });
138
138
  function context() {
139
139
  const s = refresh(), entities = entityProjection(s), registry = ports.actions?.();
140
- const shot = s.shots.find(row => row.id === s.selectedShotId) ?? shotAtFrame(s.shots, s.view.frame);
140
+ const shot = s.shots.find(row => row.id === s.selectedShotId) ?? shotAtFrame(s.shots, s.view.frame), cast = id => s.characters.some(c => c.id === id);
141
141
  const range = frameRange;
142
142
  return buildStudioContext({ schema: "studio-context-v1", host: { surface: "studio", ...s.host, workspaceHandle: s.workspaceHandle },
143
143
  revision: { scene: s.revision, physics: s.physicsRevision, view: s.viewRevision },
144
144
  units: { distance: "m", angle: "deg", up: "+Y", yawZero: "+Z", yawPositiveToward: "+X", pivot: "base", fps: 24, rangeEnd: "exclusive" },
145
- scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length, ...(s.previsMode ? { previsMode: s.previsMode } : {}) },
146
- selection: s.selection, activeCharacterId: s.activeCharacterId, view: s.view,
145
+ scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length, ...(s.previsMode ? { previsMode: s.previsMode } : {}), ...(s.castStyle ? { castStyle: s.castStyle } : {}) },
146
+ // A removed character (the last one, too) is neither selected nor active.
147
+ selection: ["character", "rig"].includes(s.selection?.kind) && !cast(s.selection.id) ? null : s.selection, activeCharacterId: cast(s.activeCharacterId) ? s.activeCharacterId : null, view: s.view,
147
148
  shot: shot ? { id: shot.id, name: shot.name, range: range(shot), mode: shot.camera?.mode ?? "keys" } : null, camera: s.camera,
148
149
  // buildStudioContext selects the detailed rows and writes the real page.
149
150
  entities, entityPage: { returned: 0, total: 0, truncated: false, nextCursor: null },
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { ko } from "../locale.js";
3
- import { createUpdatePoller } from "./update-client.js";
4
- import { noteNoticeShown, noteUpdateClicked, noteUpdatePolled } from "./update-telemetry.js";
3
+ import { useStudioShell } from "../shell/studio-shell-context.js";
4
+ import { createUpdatePoller, reloadAfterRestart } from "./update-client.js";
5
+ import { noteNoticeShown, noteUpdateClicked, noteUpdatePolled, noteUpdateRestarted } from "./update-telemetry.js";
5
6
  import UpdateDialog from "./UpdateDialog.jsx";
6
7
  import "./update.css";
7
8
 
@@ -13,6 +14,9 @@ function chipLabel(state) {
13
14
  switch (state.view) {
14
15
  case "updating": return ko("Updating…", "업데이트 중…", "正在更新…");
15
16
  case "restart_needed": return ko("Restart to finish", "다시 시작하면 끝나요", "重启以完成");
17
+ case "restarting": return ko("Restarting…", "다시 시작하는 중…", "正在重启…");
18
+ case "restarted": return ko(`Updated to ${v}`, `${v}로 업데이트됨`, `已更新到 ${v}`);
19
+ case "restart_timeout": return ko("Restart didn't finish", "다시 시작하지 못했어요", "重启未完成");
16
20
  case "failed": return ko("Update failed", "업데이트 실패", "更新失败");
17
21
  default: return ko(`Update ${v}`, `${v} 업데이트`, `更新到 ${v}`);
18
22
  }
@@ -22,10 +26,20 @@ export default function UpdateChip() {
22
26
  const [state, setState] = useState(null);
23
27
  const [open, setOpen] = useState(false);
24
28
  const poller = useRef(null);
29
+ const { projectDirty } = useStudioShell() ?? {};
30
+ const dirty = useRef(projectDirty);
31
+ useEffect(() => { dirty.current = projectDirty; }, [projectDirty]);
25
32
  useEffect(() => {
33
+ let reloaded = false;
26
34
  const instance = createUpdatePoller({
27
35
  onState: ({ state: next, info }) => {
28
36
  setState(next);
37
+ // Decided once, when the new launcher answers: a saved project reloads, a dirty one asks first.
38
+ if (next.view === "restarted" && !reloaded) {
39
+ reloaded = true;
40
+ // A task later, so this tab's update:restarted is handed over first.
41
+ if (!reloadAfterRestart({ dirty: dirty.current, reload: () => setTimeout(() => window.location.reload(), 0) })) setOpen(true);
42
+ }
29
43
  noteNoticeShown(next);
30
44
  noteUpdatePolled(info);
31
45
  },
@@ -45,6 +59,10 @@ export default function UpdateChip() {
45
59
  if (state) noteUpdateClicked("update_now", state.kind);
46
60
  void poller.current?.update();
47
61
  }, [state]);
62
+ const restart = useCallback(() => {
63
+ if (state) noteUpdateClicked("restart", state.kind);
64
+ void poller.current?.restart().then((outcome) => { if (outcome) noteUpdateRestarted(outcome); });
65
+ }, [state]);
48
66
  if (view === "hidden") return null;
49
67
  return (
50
68
  <span className="update-chip-wrap">
@@ -78,6 +96,7 @@ export default function UpdateChip() {
78
96
  onClose={close}
79
97
  onLater={() => dismiss("later")}
80
98
  onUpdate={update}
99
+ onRestart={restart}
81
100
  onAction={(action) => noteUpdateClicked(action, state.kind)}
82
101
  />
83
102
  )}
@@ -22,6 +22,9 @@ function titleOf(state) {
22
22
  if (state.view === "updating") return ko("Updating…", "업데이트 중…", "正在更新…");
23
23
  if (state.view === "restart_needed") return ko("Restart to finish", "다시 시작하면 끝나요", "重启以完成");
24
24
  if (state.view === "failed") return ko("Update failed", "업데이트 실패", "更新失败");
25
+ if (state.view === "restarting") return ko("Restarting…", "다시 시작하는 중…", "正在重启…");
26
+ if (state.view === "restarted") return ko(`Updated to ${v}`, `${v}로 업데이트됨`, `已更新到 ${v}`);
27
+ if (state.view === "restart_timeout") return ko("Restart didn't finish", "다시 시작하지 못했어요", "重启未完成");
25
28
  return ko(`CozyClay ${v} is available`, `CozyClay ${v} 버전이 나왔어요`, `CozyClay ${v} 已发布`);
26
29
  }
27
30
 
@@ -29,11 +32,18 @@ function bodyOf(state) {
29
32
  const v = state.latest;
30
33
  if (state.view === "updating") return ko(`Installing ${v}. This takes about a minute.`, `${v} 설치 중이에요. 1분쯤 걸려요.`, `正在安装 ${v},大约需要一分钟。`);
31
34
  if (state.view === "restart_needed") {
32
- return ko(`Updated to ${v}. Quit CozyClay in the terminal (Ctrl+C), then run \`cclay\` again.`,
33
- `${v} 설치 완료. 터미널에서 CozyClay를 종료(Ctrl+C)하고 \`cclay\`를 다시 실행하세요.`,
34
- `已更新到 ${v}。请在终端中退出 CozyClay(Ctrl+C),然后重新运行 \`cclay\`。`);
35
+ return ko(`Updated to ${v}. Restart now, or quit CozyClay in the terminal (Ctrl+C) and run \`cclay\` again.`,
36
+ `${v} 설치 완료. 지금 다시 시작하거나, 터미널에서 CozyClay를 종료(Ctrl+C)하고 \`cclay\`를 다시 실행하세요.`,
37
+ `已更新到 ${v}。请立即重启,或在终端中退出 CozyClay(Ctrl+C)后重新运行 \`cclay\`。`);
35
38
  }
36
39
  if (state.view === "failed") return (failureCopy[state.errorClass] ?? failureCopy.other)();
40
+ if (state.view === "restarting") return ko(`Starting ${v}. The Studio reloads when it answers.`, `${v}를 시작하는 중이에요. 응답하면 Studio가 다시 로드돼요.`, `正在启动 ${v}。它响应后 Studio 会重新加载。`);
41
+ if (state.view === "restarted") return ko("Updated. Save your project, then reload.", "업데이트됐어요. 프로젝트를 저장한 뒤 다시 로드하세요.", "已更新。请先保存项目,然后重新加载。");
42
+ if (state.view === "restart_timeout") {
43
+ return ko("CozyClay didn't come back within 30 seconds. Quit it in the terminal (Ctrl+C), then run `cclay` again.",
44
+ "CozyClay가 30초 안에 돌아오지 않았어요. 터미널에서 종료(Ctrl+C)하고 `cclay`를 다시 실행하세요.",
45
+ "CozyClay 未在 30 秒内恢复。请在终端中退出(Ctrl+C),然后重新运行 `cclay`。");
46
+ }
37
47
  if (state.kind === "npx") {
38
48
  return ko("Quit CozyClay in the terminal and run `npx cozyclay` again. npx fetches the newest version each time (internet needed).",
39
49
  "터미널에서 CozyClay를 종료하고 `npx cozyclay`를 다시 실행하세요. npx가 매번 최신 버전을 받아요 (인터넷 필요).",
@@ -65,7 +75,7 @@ async function copyText(text) {
65
75
  }
66
76
  }
67
77
 
68
- export default function UpdateDialog({ state, onClose, onLater, onUpdate, onAction }) {
78
+ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onRestart, onAction }) {
69
79
  const closeRef = useRef(null);
70
80
  const titleId = useId();
71
81
  // "Copied" belongs to the view it was pressed in: a later phase shows its own command afresh.
@@ -89,7 +99,7 @@ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onActi
89
99
  const view = state.view;
90
100
  setCopiedView((await copyText(state.command)) ? view : null);
91
101
  };
92
- const startsInstall = state.actions.includes("update_now") || state.actions.includes("retry");
102
+ const startsInstall = ["update_now", "retry", "restart"].some((action) => state.actions.includes(action));
93
103
  const body = bodyOf(state);
94
104
  return createPortal(
95
105
  <div className="menubar-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
@@ -127,6 +137,16 @@ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onActi
127
137
  {ko("Try again", "다시 시도", "重试")}
128
138
  </button>
129
139
  )}
140
+ {state.actions.includes("restart") && (
141
+ <button type="button" className="update-dialog-primary" data-testid="update-restart" disabled={blocked} onClick={onRestart}>
142
+ {ko("Restart now", "지금 다시 시작", "立即重启")}
143
+ </button>
144
+ )}
145
+ {state.actions.includes("reload") && (
146
+ <button type="button" className="update-dialog-primary" data-testid="update-reload" onClick={() => window.location.reload()}>
147
+ {ko("Reload", "다시 로드", "重新加载")}
148
+ </button>
149
+ )}
130
150
  {state.actions.includes("copy_command") && (
131
151
  <button type="button" data-testid="update-copy" data-copied={copied || undefined} onClick={() => void copy()}>
132
152
  {copied ? ko("Copied", "복사했어요", "已复制") : ko("Copy command", "명령 복사", "复制命令")}
@@ -135,7 +155,7 @@ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onActi
135
155
  {state.actions.includes("later") && (
136
156
  <button type="button" data-testid="update-later" onClick={onLater}>{ko("Later", "나중에", "稍后")}</button>
137
157
  )}
138
- {state.view === "restart_needed" && (
158
+ {["restart_needed", "restart_timeout"].includes(state.view) && (
139
159
  <button type="button" onClick={onClose}>{ko("Close", "닫기", "关闭")}</button>
140
160
  )}
141
161
  </div>
@@ -2,12 +2,21 @@
2
2
  // (bin/update-routes.mjs) read and driven from the page. Pure apart from the
3
3
  // injected fetch, timers, storage and window, so test/verify-update-client.mjs
4
4
  // drives it under Node. A page without window.__COZYCLAY_RUNTIME__ (the hosted
5
- // site, Vite dev) makes no request at all.
5
+ // site, Vite dev) makes no request at all. Tabs share a dismiss, an install
6
+ // start and a phase change at once (U2): over a BroadcastChannel, or through a
7
+ // storage event where there is none. A hint only makes a tab fetch now; the
8
+ // launcher's answer stays the only phase a tab shows. Restart now (U2b, #960)
9
+ // hands the launcher to the new version and waits for it to answer.
6
10
  export const UPDATE_URL = "/__cozyclay/update";
11
+ export const RESTART_URL = "/__cozyclay/update/restart";
12
+ export const RESTART_WAIT_MS = 30_000;
13
+ export const RESTART_POLL_MS = 1000;
7
14
  export const DISMISS_KEY = "cozyclay:update-dismissed";
8
15
  export const POLL_MS = 6 * 60 * 60 * 1000;
9
16
  export const VISIBLE_STALE_MS = 60 * 60 * 1000;
10
17
  export const UPDATING_POLL_MS = 2000;
18
+ export const CHANNEL_NAME = "cozyclay:update";
19
+ export const HINT_KEY = "cozyclay:update-hint";
11
20
 
12
21
  const VERSION = /^\d+\.\d+\.\d+$/;
13
22
  const KINDS = new Set(["global", "npx", "clone"]);
@@ -42,16 +51,17 @@ export function normalizeUpdateInfo(body) {
42
51
  phase,
43
52
  errorClass: phase === "failed" ? (ERROR_CLASSES.has(body.errorClass) ? body.errorClass : "other") : null,
44
53
  durationMs: Number.isFinite(body.durationMs) && body.durationMs >= 0 ? body.durationMs : null,
54
+ installed: version(body.installed),
45
55
  // Opened in a new tab, so only the project's own release page is accepted.
46
56
  notesUrl: latest && body.notesUrl === `${NOTES_PREFIX}${latest}` ? body.notesUrl : null,
47
57
  command: kind === "npx" ? NPX_COMMAND : GLOBAL_COMMAND,
48
58
  };
49
59
  }
50
60
 
51
- async function request(fetchImpl, init, win) {
61
+ async function request(fetchImpl, init, win, url = UPDATE_URL) {
52
62
  if (!hasLauncherRuntime(win) || typeof fetchImpl !== "function") return null;
53
63
  try {
54
- const response = await fetchImpl(UPDATE_URL, { cache: "no-store", credentials: "same-origin", ...init });
64
+ const response = await fetchImpl(url, { cache: "no-store", credentials: "same-origin", ...init });
55
65
  if (!response?.ok) return null;
56
66
  return normalizeUpdateInfo(await response.json());
57
67
  } catch {
@@ -65,6 +75,16 @@ export const fetchUpdateInfo = (fetchImpl = globalThis.fetch?.bind(globalThis),
65
75
  /** POST: ask the launcher to install `latest`. The 202 body, or null (403/409/network). No body is sent. */
66
76
  export const startUpdate = (fetchImpl = globalThis.fetch?.bind(globalThis), win = globalThis.window) => request(fetchImpl, { method: "POST" }, win);
67
77
 
78
+ /** POST: hand the launcher to the installed version. The 202 body, or null (403/409/network). No body is sent. */
79
+ export const requestRestart = (fetchImpl = globalThis.fetch?.bind(globalThis), win = globalThis.window) => request(fetchImpl, { method: "POST" }, win, RESTART_URL);
80
+
81
+ /** After the new launcher answers: reload only a saved project. True when it reloaded. */
82
+ export function reloadAfterRestart({ dirty, reload }) {
83
+ if (dirty) return false;
84
+ reload();
85
+ return true;
86
+ }
87
+
68
88
  /** patch | minor | major: how far `current` is behind `latest`. */
69
89
  export function behindOf(current, latest) {
70
90
  const [a, b] = [current, latest].map((value) => (version(value) ?? "0.0.0").split(".").map(Number));
@@ -83,16 +103,24 @@ export function updateBlocked({ agentRunning = false, exportStatus = null, recSt
83
103
 
84
104
  const HIDDEN = Object.freeze({ view: "hidden", actions: [] });
85
105
 
86
- /** The chip and dialog view for one poll result and the local dismiss state ({ dismissed }). */
106
+ const RESTART_ACTIONS = { restarting: [], restarted: ["reload"], restart_timeout: ["copy_command"] };
107
+
108
+ /**
109
+ * The chip and dialog view for one poll result and the local state: { dismissed }, and { restart }
110
+ * (restarting | restarted | restart_timeout) once Restart now ran here or in another tab.
111
+ */
87
112
  export function nextUpdateState(info, local = {}) {
88
113
  if (!info || !info.enabled) return HIDDEN;
89
114
  const base = {
90
115
  current: info.current, latest: info.latest, kind: info.kind, notesUrl: info.notesUrl,
91
116
  command: info.command, canSelfUpdate: info.canSelfUpdate, errorClass: null,
92
117
  };
118
+ if (RESTART_ACTIONS[local.restart]) return { ...base, latest: local.target ?? info.latest, view: local.restart, command: RESTART_COMMAND, actions: RESTART_ACTIONS[local.restart] };
93
119
  const oneClick = info.kind === "global" && info.canSelfUpdate;
94
- if (info.phase === "updating") return { ...base, view: "updating", actions: [] };
95
- if (info.phase === "restart_needed") return { ...base, view: "restart_needed", command: RESTART_COMMAND, actions: ["copy_command"] };
120
+ // "Installing X" and "Updated to X" name the version npm installs, which the registry may have passed since.
121
+ const installed = info.installed ?? info.latest;
122
+ if (info.phase === "updating") return { ...base, latest: installed, view: "updating", actions: [] };
123
+ if (info.phase === "restart_needed") return { ...base, latest: installed, view: "restart_needed", command: RESTART_COMMAND, actions: ["restart", "copy_command"] };
96
124
  if (info.phase === "failed") {
97
125
  return { ...base, view: "failed", errorClass: info.errorClass ?? "other", command: GLOBAL_COMMAND,
98
126
  actions: oneClick ? ["retry", "copy_command"] : ["copy_command"] };
@@ -115,6 +143,13 @@ export function writeDismissed(latest, storage = safeStorage()) {
115
143
  try { storage.setItem(DISMISS_KEY, latest); return true; } catch { return false; }
116
144
  }
117
145
 
146
+ const HINTS = new Set(["dismiss", "install", "phase", "restart"]);
147
+
148
+ // A missing or throwing BroadcastChannel is null: hints then travel as a storage event.
149
+ function openUpdateChannel() {
150
+ try { return typeof globalThis.BroadcastChannel === "function" ? new globalThis.BroadcastChannel(CHANNEL_NAME) : null; } catch { return null; }
151
+ }
152
+
118
153
  const defaultTimers = {
119
154
  set: (fn, ms) => setTimeout(fn, ms),
120
155
  clear: (timer) => clearTimeout(timer),
@@ -131,40 +166,97 @@ const defaultTimers = {
131
166
  * The poll loop: once after the first frame and idle, every 6 h, on a visible
132
167
  * tab whose last check is over 1 h old, every 2 s while installing. `onState`
133
168
  * gets { state, info } after every change. Returns { dismiss, update, stop }.
169
+ * `channel` is a BroadcastChannel-shaped object (default: one is opened when
170
+ * the browser has it); null sends the other tabs' hints through `storage`.
171
+ * restart() and a restart hint poll every 1 s, failures included, until the
172
+ * launcher's `current` is the installed version, for up to 30 s.
134
173
  */
135
174
  export function createUpdatePoller({
136
175
  fetchImpl = globalThis.fetch?.bind(globalThis), onState = () => {}, timers = defaultTimers,
137
- storage = safeStorage(), win = globalThis.window, doc = globalThis.document,
176
+ storage = safeStorage(), win = globalThis.window, doc = globalThis.document, channel: injected,
138
177
  } = {}) {
139
- const noop = { dismiss() {}, update: async () => null, stop() {} };
178
+ const noop = { dismiss() {}, update: async () => null, restart: async () => null, stop() {} };
140
179
  if (!hasLauncherRuntime(win)) return noop;
141
- let info = null, timer = null, stopped = false, inflight = null, lastCheck = -Infinity;
142
- let dismissed = readDismissed(storage);
143
- const emit = () => { if (!stopped) onState({ state: nextUpdateState(info, { dismissed }), info }); };
180
+ const channel = injected === undefined ? openUpdateChannel() : injected;
181
+ let info = null, timer = null, stopped = false, inflight = null, lastCheck = -Infinity, hints = 0;
182
+ let dismissed = readDismissed(storage), restart = null, target = null, following = null;
183
+ const emit = () => { if (!stopped) onState({ state: nextUpdateState(info, { dismissed, restart, target }), info }); };
144
184
  const schedule = () => {
145
185
  if (stopped) return;
146
186
  if (timer !== null) timers.clear(timer);
147
187
  timer = timers.set(check, info?.phase === "updating" ? UPDATING_POLL_MS : POLL_MS);
148
188
  };
149
- const apply = (next) => { if (next) { info = next; emit(); } };
150
- function check() {
189
+ // Tell the other tabs; a lost hint only costs them the wait for their next poll.
190
+ const hint = (type, latest = null) => {
191
+ try {
192
+ if (channel) channel.postMessage({ type, latest });
193
+ else if (type !== "dismiss") storage?.setItem(HINT_KEY, `${type}:${timers.now()}:${(hints += 1)}:${version(latest) ?? ""}`);
194
+ } catch { /* best effort */ }
195
+ };
196
+ // Emitted first, so this tab's update:finished claim is in storage before any other tab re-polls.
197
+ // A change a hint led to is not hinted again: the other tabs had the same hint.
198
+ const apply = (next, cause = "phase") => {
199
+ if (!next) return;
200
+ const changed = info !== null && info.phase !== next.phase;
201
+ info = next;
202
+ emit();
203
+ if (cause === "install" || (changed && cause === "phase")) hint(cause);
204
+ };
205
+ function check(cause = "phase") {
151
206
  if (stopped) return Promise.resolve(info);
152
207
  if (inflight) return inflight;
153
208
  // An early check (a visible tab) replaces the pending poll; the schedule never doubles.
154
209
  if (timer !== null) { timers.clear(timer); timer = null; }
155
210
  lastCheck = timers.now();
156
211
  inflight = fetchUpdateInfo(fetchImpl, win);
157
- return inflight.then((next) => { inflight = null; apply(next); schedule(); return info; });
212
+ return inflight.then((next) => { inflight = null; apply(next, cause); schedule(); return info; });
158
213
  }
159
214
  const onVisible = () => {
160
215
  if (doc?.visibilityState === "visible" && timers.now() - lastCheck > VISIBLE_STALE_MS) void check();
161
216
  };
217
+ // A fetch already in flight may predate the hint, so one more follows it.
218
+ const refresh = () => (inflight ? inflight.then(() => check("hinted")) : check("hinted"));
219
+ // The old launcher stops answering for a moment, so a failed fetch is only another try.
220
+ const followRestart = (to) => {
221
+ if (following || !to) return following ?? Promise.resolve(null);
222
+ restart = "restarting";
223
+ target = to;
224
+ emit();
225
+ const deadline = timers.now() + RESTART_WAIT_MS;
226
+ following = (async () => {
227
+ for (;;) {
228
+ await new Promise((done) => timers.set(done, RESTART_POLL_MS));
229
+ if (stopped) return null;
230
+ const next = await fetchUpdateInfo(fetchImpl, win);
231
+ if (next?.current === to) { info = next; restart = "restarted"; break; }
232
+ if (timers.now() >= deadline) { restart = "restart_timeout"; break; }
233
+ }
234
+ emit();
235
+ return restart === "restarted" ? "ok" : "timeout";
236
+ })();
237
+ return following;
238
+ };
239
+ // The version the launcher installed, not the registry's latest: a newer release may appear while it waits.
240
+ const restartTarget = (latest) => version(latest) ?? (info?.phase === "restart_needed" ? info.installed ?? info.latest : null);
241
+ const onHint = (type, latest = null) => {
242
+ if (stopped || !HINTS.has(type)) return;
243
+ if (type === "restart") return void followRestart(restartTarget(latest));
244
+ if (type !== "dismiss") return void refresh();
245
+ dismissed = readDismissed(storage) ?? version(latest) ?? dismissed;
246
+ emit();
247
+ };
248
+ const onMessage = (event) => onHint(event?.data?.type, event?.data?.latest);
162
249
  const onStorage = (event) => {
250
+ if (event?.key === HINT_KEY) { const [type, , , latest] = String(event.newValue ?? "").split(":"); return onHint(type, latest || null); }
163
251
  if (event?.key !== DISMISS_KEY && event?.key !== null) return;
164
252
  dismissed = readDismissed(storage);
165
253
  emit();
166
254
  };
167
- try { doc?.addEventListener?.("visibilitychange", onVisible); win?.addEventListener?.("storage", onStorage); } catch { /* no listeners: the 6 h poll still runs */ }
255
+ try {
256
+ doc?.addEventListener?.("visibilitychange", onVisible);
257
+ win?.addEventListener?.("storage", onStorage);
258
+ channel?.addEventListener?.("message", onMessage);
259
+ } catch { /* no listeners: the 6 h poll still runs */ }
168
260
  timers.afterFirstFrame(() => { if (!stopped) void check(); });
169
261
  return {
170
262
  dismiss(latest) {
@@ -172,17 +264,31 @@ export function createUpdatePoller({
172
264
  writeDismissed(latest, storage);
173
265
  dismissed = latest;
174
266
  emit();
267
+ hint("dismiss", latest);
175
268
  },
176
269
  async update() {
177
270
  const next = await startUpdate(fetchImpl, win);
178
- apply(next);
271
+ apply(next, "install");
179
272
  schedule();
180
273
  return next;
181
274
  },
275
+ // Resolves to the outcome (ok | timeout), or null when the launcher refused.
276
+ async restart() {
277
+ const to = restartTarget(null);
278
+ if (!to || following) return following;
279
+ if (!(await requestRestart(fetchImpl, win))) return null;
280
+ hint("restart", to);
281
+ return followRestart(to);
282
+ },
182
283
  stop() {
183
284
  stopped = true;
184
285
  if (timer !== null) timers.clear(timer);
185
- try { doc?.removeEventListener?.("visibilitychange", onVisible); win?.removeEventListener?.("storage", onStorage); } catch { /* best effort */ }
286
+ try {
287
+ doc?.removeEventListener?.("visibilitychange", onVisible);
288
+ win?.removeEventListener?.("storage", onStorage);
289
+ channel?.removeEventListener?.("message", onMessage);
290
+ channel?.close?.();
291
+ } catch { /* best effort */ }
186
292
  },
187
293
  };
188
294
  }
@@ -1,4 +1,5 @@
1
- // update:notice_shown, update:clicked and update:finished (the U1 update notice).
1
+ // update:notice_shown, update:clicked, update:finished (the U1 update notice) and
2
+ // update:restarted (Restart now, #960).
2
3
  // Only closed values leave: the install kind, how far behind (patch/minor/major),
3
4
  // the button, the outcome, the npm error class and a duration bucket. Never a
4
5
  // version string, a path, a command or npm's output. All three are passive
@@ -10,7 +11,8 @@ import { bucketLong } from "../analytics-strict.js";
10
11
  const CLICKS_PER_SESSION = 10;
11
12
  const FINISHED_PER_SESSION = 3;
12
13
  const NOTICE_KINDS = new Set(["global", "npx"]);
13
- const CLICK_ACTIONS = new Set(["update_now", "copy_command", "later", "dismiss", "release_notes"]);
14
+ const CLICK_ACTIONS = new Set(["update_now", "copy_command", "later", "dismiss", "release_notes", "restart"]);
15
+ const RESTART_OUTCOMES = new Set(["ok", "timeout"]);
14
16
  // The install this browser already reported, shared by the tabs of one profile:
15
17
  // several tabs (and several polls) see the same terminal phase, the event leaves once.
16
18
  const REPORTED_KEY = "cozyclay:update-reported";
@@ -31,7 +33,7 @@ export function noteNoticeShown(state) {
31
33
  }
32
34
  }
33
35
 
34
- /** A notice button: update_now (also Try again), copy_command, later, dismiss or release_notes. */
36
+ /** A notice button: update_now (also Try again), copy_command, later, dismiss, release_notes or restart. */
35
37
  export function noteUpdateClicked(action, kind) {
36
38
  try {
37
39
  if (!CLICK_ACTIONS.has(action) || !NOTICE_KINDS.has(kind)) return;
@@ -41,6 +43,16 @@ export function noteUpdateClicked(action, kind) {
41
43
  }
42
44
  }
43
45
 
46
+ /** Restart now ended in the tab that pressed it: the new launcher answered (ok) or 30 s passed (timeout). Once per session. */
47
+ export function noteUpdateRestarted(outcome) {
48
+ try {
49
+ if (!RESTART_OUTCOMES.has(outcome)) return;
50
+ if (admit("update:restarted", 1)) send("update:restarted", { outcome });
51
+ } catch {
52
+ // Telemetry must never affect the restart.
53
+ }
54
+ }
55
+
44
56
  function claim(key) {
45
57
  if (reportedHere.has(key)) return false;
46
58
  reportedHere.add(key);
@@ -1,11 +1,15 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { createAgentTransport } from "./workflow/agent-client.js";
3
3
  import { requestAgentImage, stylizeErrorMessage } from "./agent-image-client.js";
4
- import { blueprintErrorMessage, blueprintRequest } from "./sculpt-blueprint.js";
5
- import { agentSessionReady, createCommandTap, sculptModelChoice, sculptTargetFromSelection, storeSculptModel } from "./sculpt-mode.js";
4
+ import { blueprintErrorMessage, blueprintRequest, blueprintViewImages } from "./sculpt-blueprint.js";
5
+ import { browserSculptImages } from "./commands/sculpt-images.js";
6
+ import { agentSessionReady, chatgptSignedIn, createCommandTap, sculptModelChoice, sculptTargetFromSelection, storeSculptModel } from "./sculpt-mode.js";
6
7
  import { SCULPT_KIND } from "./scene-objects.js";
8
+ import { overlayOwnsEscape, sculptPartTree } from "./sculpt-pick.js";
9
+ import { GIZMO_HOTKEYS } from "./stage-startup-defaults.js";
10
+ import { ko } from "./locale.js";
7
11
 
8
- export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId }) {
12
+ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId, appContext }) {
9
13
  // Sculpt mode (#739): one sculpt on an isolated turntable, the Sculpt panel
10
14
  // and its agent run. The target is the sculpt selected on entry, or a new one
11
15
  // the first run creates; picking another sculpt while here retargets.
@@ -17,6 +21,8 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
17
21
  const [sculptRun, setSculptRun] = useState(null);
18
22
  const sculptRunRef = useRef(null);
19
23
  const [sculptAgentStatus, setSculptAgentStatus] = useState("unknown");
24
+ // Whether the sidecar holds a ChatGPT token: the blueprint's image model needs one (#970).
25
+ const [sculptChatgpt, setSculptChatgpt] = useState(false);
20
26
  // The model Sculpt runs on (its own pick, remembered apart from the chat model) and the list it comes from.
21
27
  const [sculptModels, setSculptModels] = useState({ state: "loading", providers: [], models: [] });
22
28
  const [sculptModelPick, setSculptModelPick] = useState({ model: "", effort: null });
@@ -47,8 +53,8 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
47
53
  let live = true;
48
54
  const read = () => {
49
55
  sculptTransport.status().then(
50
- (status) => { if (live) setSculptAgentStatus(agentSessionReady(status) ? "ready" : "signed-out"); },
51
- () => { if (live) setSculptAgentStatus("signed-out"); },
56
+ (status) => { if (live) { setSculptAgentStatus(agentSessionReady(status) ? "ready" : "signed-out"); setSculptChatgpt(chatgptSignedIn(status)); } },
57
+ () => { if (live) { setSculptAgentStatus("signed-out"); setSculptChatgpt(false); } },
52
58
  );
53
59
  // The Agent panel's catalogue: a key added or removed elsewhere changes what can run.
54
60
  sculptTransport.models().then(
@@ -65,6 +71,50 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
65
71
  window.addEventListener("focus", read);
66
72
  return () => { live = false; window.removeEventListener("focus", read); };
67
73
  }, [sculptMode, sculptTransport]);
74
+ // Part picking (#740 S7): one part of the target, chosen on the turntable or
75
+ // in the panel's tree. A new target or leaving the mode drops it; Esc drops
76
+ // it before Esc reaches the scene selection, unless an open menu or dialog takes that Esc.
77
+ const [sculptPart, setSculptPart] = useState(null);
78
+ useEffect(() => { setSculptPart(null); }, [sculptMode, sculptTargetId]);
79
+ // The part gizmo (#740 S8): its tool, and the last refusal or notice it shows under the tree.
80
+ const [partTool, setPartTool] = useState("move");
81
+ const [partNotice, setPartNotice] = useState(null);
82
+ useEffect(() => { setPartNotice(null); }, [sculptPart?.partId, sculptPart?.mirrored, sculptTargetId]);
83
+ useEffect(() => {
84
+ if (!sculptMode || !sculptPart) return undefined;
85
+ const onKey = (event) => {
86
+ if (event.target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
87
+ // Delete with a part picked would take the whole sculpt: refused, Esc first.
88
+ if (event.key === "Delete" || event.key === "Backspace") {
89
+ event.stopPropagation();
90
+ event.preventDefault();
91
+ setPartNotice(ko("Delete removes the whole sculpt. Press Esc to deselect the part first.", "Delete는 조형물 전체를 지워요. 먼저 Esc로 부품 선택을 해제하세요.", "Delete 会删除整个雕塑。请先按 Esc 取消部件选择。"));
92
+ return;
93
+ }
94
+ if (event.key !== "Escape" || overlayOwnsEscape()) return;
95
+ event.stopPropagation();
96
+ setSculptPart(null);
97
+ };
98
+ window.addEventListener("keydown", onKey, true);
99
+ return () => window.removeEventListener("keydown", onKey, true);
100
+ }, [sculptMode, sculptPart]);
101
+ const sculptRecipe = sculptTarget?.recipe;
102
+ const sculptTree = useMemo(() => sculptPartTree(sculptRecipe), [sculptRecipe]);
103
+ // A recipe edit that removed the part leaves nothing selected.
104
+ const selectedPart = sculptPart && sculptTree.some((row) => row.id === sculptPart.partId) ? sculptPart : null;
105
+ // W/E/R: the selected part's move, rotate and scale; with no part, the turntable views.
106
+ const onKey = (code) => (selectedPart ? setPartTool(GIZMO_HOTKEYS[code]) : requestSculptView({ KeyW: "front", KeyE: "side", KeyR: "back" }[code]));
107
+ const tools = [["W", "move", ko("Move part", "부품 이동", "移动部件")], ["E", "rotate", ko("Rotate part", "부품 회전", "旋转部件")], ["R", "scale", ko("Scale part (Shift: uniform)", "부품 크기 (Shift: 균등)", "缩放部件(Shift:等比)")]]
108
+ .map(([key, id, name]) => ({ key, id, name, active: partTool === id, pick: () => setPartTool(id) }));
109
+ // One drag, one write: the command refuses what cannot be drawn and its reason is shown.
110
+ const commit = (edit) => {
111
+ if (!edit || !sculptTarget || !appContext) return false;
112
+ let receipt;
113
+ try { receipt = appContext.bus.run("object.sculpt", { id: sculptTarget.id, edits: [edit] }, { origin: "ui" }); } catch (error) { receipt = { ok: false, message: error?.message }; }
114
+ setPartNotice(receipt?.ok === false ? `${ko("Edit refused", "편집 거부", "编辑被拒绝")}: ${receipt.message ?? ""}` : null);
115
+ return receipt?.ok !== false;
116
+ };
117
+ const sculptParts = { tree: sculptTree, selected: selectedPart, onSelect: setSculptPart, tool: partTool, tools, onKey, commit, notice: partNotice };
68
118
  // The blueprint step: the reference turned into a four-view model sheet by one image request.
69
119
  const [sculptBlueprint, setSculptBlueprint] = useState({ state: "idle", pose: "keep", notes: "", sheet: null, error: null, startedAt: null });
70
120
  const blueprintAbortRef = useRef(null);
@@ -78,7 +128,11 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
78
128
  try {
79
129
  const result = await requestAgentImage(blueprintRequest({ reference: sculptReference, pose: sculptBlueprint.pose, notes: sculptBlueprint.notes }), { signal: controller.signal });
80
130
  if (blueprintAbortRef.current !== controller) return;
81
- setSculptBlueprint((current) => ({ ...current, state: "ready", sheet: { dataUrl: result.dataUrl, width: result.width, height: result.height, ms: Date.now() - startedAt } }));
131
+ // Cut into views (#970): side and back go to Start next to the reference; a view whose mask fails is dropped with a notice.
132
+ let cropped;
133
+ try { cropped = await blueprintViewImages(result.dataUrl, browserSculptImages); } catch (error) { cropped = { views: [], notices: [`The sheet could not be cut into views: ${error?.message || error}`] }; }
134
+ if (blueprintAbortRef.current !== controller) return;
135
+ setSculptBlueprint((current) => ({ ...current, state: "ready", sheet: { dataUrl: result.dataUrl, width: result.width, height: result.height, ms: Date.now() - startedAt, views: cropped.views, notices: cropped.notices } }));
82
136
  } catch (error) {
83
137
  if (blueprintAbortRef.current !== controller) return;
84
138
  const cancelled = controller.signal.aborted;
@@ -91,5 +145,5 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
91
145
  controller?.abort();
92
146
  setSculptBlueprint((current) => ({ ...current, state: current.sheet ? "ready" : "idle" }));
93
147
  }
94
- return { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint };
148
+ return { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptChatgpt, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint, sculptParts };
95
149
  }
@@ -1,7 +1,8 @@
1
1
  // The closed `agent:turn_failed.failure_code` vocabulary, browser side. The
2
2
  // sidecar keeps an identical table in bin/agent/agent-failure-codes.mjs (it
3
3
  // cannot import src/); test/verify-agent-execution.mjs holds both to the same
4
- // answers. Only the structured code and HTTP status are read, never a message.
4
+ // answers. Only the error's name, structured code and HTTP status are read,
5
+ // never a message.
5
6
  //
6
7
  // key_refused the model provider rejected the credentials (provider
7
8
  // 401/403, structured or message-only: runner `unauthorized`)
@@ -14,9 +15,15 @@
14
15
  // LIVE_HUB_UNAVAILABLE: no connected editor)
15
16
  // busy a turn is already running, or the target is busy
16
17
  // invalid_request the sidecar rejected the request (400, INVALID_*, other 4xx)
17
- // upstream provider outage, overload, 5xx, network or unclassified
18
- // aborted / tool_failed / unknown are assigned by the turn, not by this table
19
- export const AGENT_FAILURE_CODES = new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "unknown",
18
+ // upstream provider trouble: HTTP 5xx, or the runner's `overloaded`,
19
+ // `upstream`, `truncated` (and the provider's `server_error`)
20
+ // network the request never got an HTTP answer: a fetch TypeError
21
+ // (offline, refused, DNS) or a socket code (ECONNRESET,
22
+ // ENOTFOUND, ...), on the error or its `cause`
23
+ // unknown anything else, including an error whose fields throw
24
+ // aborted / tool_failed are assigned by the turn, not by this table
25
+ // Rows before the network/unknown split carry `upstream` for all three.
26
+ export const AGENT_FAILURE_CODES = new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "network", "unknown",
20
27
  "key_refused", "stale_scene", "capability_missing", "busy", "invalid_request"]);
21
28
 
22
29
  const BY_CODE = {
@@ -24,24 +31,36 @@ const BY_CODE = {
24
31
  STALE_SCENE: "stale_scene", STALE_TARGET: "stale_scene", STALE_ENVIRONMENT: "stale_scene",
25
32
  CAPABILITY_MISSING: "capability_missing", LIVE_HUB_UNAVAILABLE: "capability_missing",
26
33
  busy: "busy", TARGET_BUSY: "busy", SCOPE_BUSY: "busy", TARGET_NOT_READY: "busy",
34
+ // A sidecar frame whose code is already this table's fallback answer passes through.
35
+ network: "network", unknown: "unknown",
27
36
  };
37
+ // The runner's provider codes read as upstream, after the status: a 4xx beside one is the request's fault.
38
+ const PROVIDER_CODES = new Set(["overloaded", "upstream", "truncated", "server_error"]);
39
+ const NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ECONNABORTED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT", "EPIPE",
40
+ "ENETUNREACH", "ENETDOWN", "EHOSTUNREACH", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
41
+ const codeOf = (value) => (typeof value?.code === "string" ? value.code : "");
28
42
 
29
- /** `{ code, status }` -> failure code. Foreign getters fall back to upstream. */
43
+ /** `{ name, code, status }` -> failure code. Foreign getters read as unknown. */
30
44
  export function transportFailureCode(error) {
31
45
  try {
32
- const code = typeof error?.code === "string" ? error.code : "";
46
+ const code = codeOf(error);
33
47
  const status = error?.status;
34
48
  if (Object.hasOwn(BY_CODE, code)) return BY_CODE[code];
35
49
  if (code.startsWith("INVALID_")) return "invalid_request";
36
50
  if (status === 401 || status === 403) return "auth";
37
51
  if (status === 429) return "rate_limited";
38
- return Number.isInteger(status) && status >= 400 && status < 500 ? "invalid_request" : "upstream";
39
- } catch { return "upstream"; }
52
+ if (Number.isInteger(status) && status >= 400 && status < 500) return "invalid_request";
53
+ if (Number.isInteger(status) && status >= 500 && status < 600) return "upstream";
54
+ if (PROVIDER_CODES.has(code)) return "upstream";
55
+ if (Number.isInteger(status)) return "unknown";
56
+ if (NETWORK_CODES.has(code) || NETWORK_CODES.has(codeOf(error?.cause)) || error?.name === "TypeError") return "network";
57
+ return "unknown";
58
+ } catch { return "unknown"; }
40
59
  }
41
60
 
42
61
  /** A refused turn: the sidecar's `{ error: { code } }` body plus the HTTP status. */
43
62
  export async function refusalFailureCode(response) {
44
63
  let code;
45
64
  try { const body = await response.clone().json(); code = typeof body?.error?.code === "string" ? body.error.code : undefined; } catch { /* status alone */ }
46
- try { return transportFailureCode({ code, status: response?.status }); } catch { return "upstream"; }
65
+ try { return transportFailureCode({ code, status: response?.status }); } catch { return "unknown"; }
47
66
  }
@@ -142,7 +142,7 @@ export function startAgentTurn({ surface, capture, now, correlationId = null })
142
142
  return {
143
143
  turnId,
144
144
  cancel: () => finish("cancelled", "aborted"),
145
- fail: (code) => finish("failed", AGENT_FAILURE_CODES.has(code) ? code : "upstream"),
145
+ fail: (code) => finish("failed", AGENT_FAILURE_CODES.has(code) ? code : "unknown"),
146
146
  /** A tool.start / tool.done frame the panel sees (after replay dedupe). */
147
147
  tool(event) {
148
148
  if (!terminal) advisory(() => tally.note(event), null);