cozyclay 3.1.4 → 3.1.6

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 (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/INSTALL_FOR_AGENTS.md +3 -0
  3. package/README.md +5 -0
  4. package/bin/agent/agent-failure-codes.mjs +31 -0
  5. package/bin/agent/agent-routes.mjs +5 -10
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/cozyclay.mjs +16 -64
  8. package/bin/studio-routes.mjs +69 -0
  9. package/bin/update-check.mjs +53 -5
  10. package/bin/update-install.mjs +136 -0
  11. package/bin/update-routes.mjs +80 -0
  12. package/dist/ai-camera-control/index.html +1 -1
  13. package/dist/app/index.html +5 -6
  14. package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
  15. package/dist/assets/app-BLS1Zaru.js +5144 -0
  16. package/dist/assets/app-CEP7jzXB.css +1 -0
  17. package/dist/assets/compile-worker-or7djI2v.js +6 -0
  18. package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
  19. package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
  20. package/dist/assets/task-board-CkLZGPkv.js +15 -0
  21. package/dist/assets/workflow-DJwOUzqJ.js +179 -0
  22. package/dist/cozyclay-package.json +1 -1
  23. package/dist/greybox-to-video/index.html +3 -3
  24. package/dist/index.html +533 -264
  25. package/dist/media/cozyclay-mark.png +0 -0
  26. package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
  27. package/dist/media/step-move-poster.jpg +0 -0
  28. package/dist/media/step-shoot-poster.jpg +0 -0
  29. package/dist/media/step-stage-poster.jpg +0 -0
  30. package/dist/media/truck-greybox-poster.jpg +0 -0
  31. package/dist/media/truck-seedance-poster.jpg +0 -0
  32. package/dist/previs-software/index.html +4 -4
  33. package/dist/privacy/index.html +46 -19
  34. package/dist/seedance-camera-control/index.html +3 -3
  35. package/dist/sitemap.xml +6 -6
  36. package/dist/workflow/index.html +3 -4
  37. package/mcp/project-camera.mjs +43 -0
  38. package/package.json +2 -1
  39. package/src/App.jsx +59 -70
  40. package/src/analytics-controls.js +17 -0
  41. package/src/analytics-friction.js +124 -0
  42. package/src/analytics-strict.js +139 -0
  43. package/src/analytics-values.js +49 -0
  44. package/src/analytics.js +37 -133
  45. package/src/asset-pane.jsx +4 -5
  46. package/src/click-friction.js +101 -0
  47. package/src/command-bus-telemetry.js +155 -0
  48. package/src/command-bus.js +10 -8
  49. package/src/commands/motion3.js +7 -2
  50. package/src/controls.jsx +3 -2
  51. package/src/crash-telemetry.js +45 -0
  52. package/src/domains/cast.js +2 -11
  53. package/src/domains/motion.js +49 -38
  54. package/src/domains/objects.js +3 -0
  55. package/src/domains/scene-undo.js +27 -0
  56. package/src/domains/scenes.js +1 -1
  57. package/src/error-boundary.jsx +2 -0
  58. package/src/export-attempt.js +100 -0
  59. package/src/export-workspace.js +12 -1
  60. package/src/first-success-guide.jsx +11 -7
  61. package/src/guide-telemetry.js +60 -0
  62. package/src/hierarchy-panel.jsx +2 -1
  63. package/src/live-control.js +2 -9
  64. package/src/locale-zh/domains.js +1 -0
  65. package/src/main.jsx +5 -0
  66. package/src/motion/timeline.jsx +9 -5
  67. package/src/motion-draft-telemetry.js +62 -0
  68. package/src/motion3/compiler/channels.js +7 -5
  69. package/src/motion3/compiler/compile.js +6 -2
  70. package/src/motion3/compiler/finger-close.js +84 -7
  71. package/src/motion3/compiler/head-lead.js +70 -0
  72. package/src/motion3/compiler/head-steady.js +1 -1
  73. package/src/motion3/compiler/limbs.js +10 -3
  74. package/src/motion3/compiler/pose.js +1 -1
  75. package/src/motion3/compiler/validate.js +5 -3
  76. package/src/motion3/pipeline.js +4 -0
  77. package/src/motion3/qa/explain.js +2 -1
  78. package/src/motion3/qa/finger-checks.js +80 -0
  79. package/src/motion3/qa/gait-observe.js +88 -0
  80. package/src/motion3/qa/grasp-checks.js +3 -0
  81. package/src/motion3/qa/hints-grasp.js +8 -0
  82. package/src/motion3/qa/index.js +2 -1
  83. package/src/motion3/qa/motion-life.js +26 -3
  84. package/src/motion3/schema/topics.js +1 -1
  85. package/src/playback-telemetry.js +107 -0
  86. package/src/post-turn-edits.js +62 -0
  87. package/src/search-friction.js +49 -0
  88. package/src/semantic-edit.js +19 -6
  89. package/src/shell/BoardSlot.jsx +3 -3
  90. package/src/shell/BottomDock.jsx +1 -1
  91. package/src/shell/DetailsSlot.jsx +1 -1
  92. package/src/shell/MenuBar.jsx +13 -13
  93. package/src/shell/StudioShell.jsx +7 -1
  94. package/src/shell/TopBar.jsx +2 -0
  95. package/src/shell/ViewportToolbar.jsx +2 -2
  96. package/src/shortcut-friction.js +122 -0
  97. package/src/stage-canvas-rigs.jsx +3 -4
  98. package/src/studio-telemetry.js +97 -0
  99. package/src/thrash-friction.js +90 -0
  100. package/src/update/UpdateChip.jsx +86 -0
  101. package/src/update/UpdateDialog.jsx +147 -0
  102. package/src/update/update-client.js +188 -0
  103. package/src/update/update-telemetry.js +78 -0
  104. package/src/update/update.css +94 -0
  105. package/src/use-webgl-available.js +35 -0
  106. package/src/workflow/AgentPanel.jsx +29 -30
  107. package/src/workflow/CozySceneNode.jsx +1 -1
  108. package/src/workflow/LoginPanel.jsx +15 -5
  109. package/src/workflow/agent-client.js +10 -91
  110. package/src/workflow/agent-failure-codes.js +47 -0
  111. package/src/workflow/agent-turn-telemetry.js +222 -0
  112. package/src/workflow/connect-telemetry.js +86 -0
  113. package/src/workflow/prompt-telemetry.js +105 -0
  114. package/tools/dev/pages/privacy.html +44 -17
  115. package/tools/loc-baseline.json +10 -10
  116. package/tools/run-tests.mjs +8 -8
  117. package/tools/verify-package-tarball.mjs +29 -1
  118. package/dist/assets/app-BdRlMf4J.css +0 -1
  119. package/dist/assets/app-KXdgBC-S.js +0 -5144
  120. package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
  121. package/dist/assets/landing-y3EqDgSZ.js +0 -1
  122. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
  123. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
  124. package/dist/assets/task-board-DFfNa0xg.js +0 -15
  125. package/dist/assets/workflow-BMJGd_gZ.js +0 -179
  126. package/dist/media/playground-poster.jpg +0 -0
@@ -0,0 +1,86 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { ko } from "../locale.js";
3
+ import { createUpdatePoller } from "./update-client.js";
4
+ import { noteNoticeShown, noteUpdateClicked, noteUpdatePolled } from "./update-telemetry.js";
5
+ import UpdateDialog from "./UpdateDialog.jsx";
6
+ import "./update.css";
7
+
8
+ // The update notice (U1): a quiet top-bar chip, never a toast and never a dialog
9
+ // that opens by itself. It renders nothing on a hosted page (the poller is a
10
+ // no-op without window.__COZYCLAY_RUNTIME__) and nothing until a poll says so.
11
+ function chipLabel(state) {
12
+ const v = state.latest;
13
+ switch (state.view) {
14
+ case "updating": return ko("Updating…", "업데이트 중…", "正在更新…");
15
+ case "restart_needed": return ko("Restart to finish", "다시 시작하면 끝나요", "重启以完成");
16
+ case "failed": return ko("Update failed", "업데이트 실패", "更新失败");
17
+ default: return ko(`Update ${v}`, `${v} 업데이트`, `更新到 ${v}`);
18
+ }
19
+ }
20
+
21
+ export default function UpdateChip() {
22
+ const [state, setState] = useState(null);
23
+ const [open, setOpen] = useState(false);
24
+ const poller = useRef(null);
25
+ useEffect(() => {
26
+ const instance = createUpdatePoller({
27
+ onState: ({ state: next, info }) => {
28
+ setState(next);
29
+ noteNoticeShown(next);
30
+ noteUpdatePolled(info);
31
+ },
32
+ });
33
+ poller.current = instance;
34
+ return () => { instance.stop(); if (poller.current === instance) poller.current = null; };
35
+ }, []);
36
+ const view = state?.view ?? "hidden";
37
+ const close = useCallback(() => setOpen(false), []);
38
+ const dismiss = useCallback((action) => {
39
+ if (!state?.latest) return;
40
+ noteUpdateClicked(action, state.kind);
41
+ poller.current?.dismiss(state.latest);
42
+ setOpen(false);
43
+ }, [state]);
44
+ const update = useCallback(() => {
45
+ if (state) noteUpdateClicked("update_now", state.kind);
46
+ void poller.current?.update();
47
+ }, [state]);
48
+ if (view === "hidden") return null;
49
+ return (
50
+ <span className="update-chip-wrap">
51
+ <button
52
+ type="button"
53
+ className="update-chip"
54
+ data-testid="update-chip"
55
+ data-update-view={view}
56
+ aria-haspopup="dialog"
57
+ aria-expanded={open}
58
+ onClick={() => setOpen((value) => !value)}
59
+ >
60
+ <i className="update-chip-dot" aria-hidden="true" />
61
+ <span className="update-chip-text">{chipLabel(state)}</span>
62
+ </button>
63
+ {view === "available" && (
64
+ <button
65
+ type="button"
66
+ className="update-chip-dismiss"
67
+ data-testid="update-dismiss"
68
+ aria-label={ko("Dismiss this version", "이 버전 알림 닫기", "忽略此版本")}
69
+ title={ko("Dismiss this version", "이 버전 알림 닫기", "忽略此版本")}
70
+ onClick={() => dismiss("dismiss")}
71
+ >
72
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
73
+ </button>
74
+ )}
75
+ {open && (
76
+ <UpdateDialog
77
+ state={state}
78
+ onClose={close}
79
+ onLater={() => dismiss("later")}
80
+ onUpdate={update}
81
+ onAction={(action) => noteUpdateClicked(action, state.kind)}
82
+ />
83
+ )}
84
+ </span>
85
+ );
86
+ }
@@ -0,0 +1,147 @@
1
+ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { useStudioShell } from "../shell/studio-shell-context.js";
4
+ import { agentTurnRunning, subscribeAgentTurns } from "../workflow/agent-turn-telemetry.js";
5
+ import { ko } from "../locale.js";
6
+ import { updateBlocked } from "./update-client.js";
7
+
8
+ // The update dialog: the MenuDialog markup (src/shell/MenuBar.jsx) and its
9
+ // menubar-dialog-* classes, copied rather than shared (that file has no LOC room).
10
+ // Every string passes its Chinese text: interpolated strings never match the
11
+ // English-keyed ZH dictionary (src/locale.js).
12
+ const failureCopy = {
13
+ permission: () => ko("npm can't write to its global folder. Copy the command and run it yourself, or fix the folder permissions.", "npm이 전역 폴더에 쓸 수 없어요. 명령을 복사해 직접 실행하거나 폴더 권한을 고치세요.", "npm 无法写入其全局文件夹。请复制命令自行运行,或修复文件夹权限。"),
14
+ network: () => ko("Couldn't reach the npm registry. Check your connection and try again.", "npm 레지스트리에 연결하지 못했어요. 연결을 확인하고 다시 시도하세요.", "无法连接 npm 注册表。请检查网络连接后重试。"),
15
+ node_version: () => ko("This version needs Node.js 22.19 or newer. Update Node, then run the command.", "이 버전은 Node.js 22.19 이상이 필요해요. Node를 업데이트한 뒤 명령을 실행하세요.", "此版本需要 Node.js 22.19 或更高版本。请先更新 Node,再运行该命令。"),
16
+ npm_missing: () => ko("npm isn't on your PATH. Copy the command and run it in a terminal that has npm.", "PATH에서 npm을 찾을 수 없어요. npm이 있는 터미널에서 명령을 실행하세요.", "PATH 中找不到 npm。请复制命令,在装有 npm 的终端中运行。"),
17
+ other: () => ko("The update didn't finish. Copy the command and run it in a terminal.", "업데이트가 끝나지 않았어요. 명령을 복사해 터미널에서 실행하세요.", "更新未完成。请复制命令并在终端中运行。"),
18
+ };
19
+
20
+ function titleOf(state) {
21
+ const v = state.latest;
22
+ if (state.view === "updating") return ko("Updating…", "업데이트 중…", "正在更新…");
23
+ if (state.view === "restart_needed") return ko("Restart to finish", "다시 시작하면 끝나요", "重启以完成");
24
+ if (state.view === "failed") return ko("Update failed", "업데이트 실패", "更新失败");
25
+ return ko(`CozyClay ${v} is available`, `CozyClay ${v} 버전이 나왔어요`, `CozyClay ${v} 已发布`);
26
+ }
27
+
28
+ function bodyOf(state) {
29
+ const v = state.latest;
30
+ if (state.view === "updating") return ko(`Installing ${v}. This takes about a minute.`, `${v} 설치 중이에요. 1분쯤 걸려요.`, `正在安装 ${v},大约需要一分钟。`);
31
+ 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
+ }
36
+ if (state.view === "failed") return (failureCopy[state.errorClass] ?? failureCopy.other)();
37
+ if (state.kind === "npx") {
38
+ return ko("Quit CozyClay in the terminal and run `npx cozyclay` again. npx fetches the newest version each time (internet needed).",
39
+ "터미널에서 CozyClay를 종료하고 `npx cozyclay`를 다시 실행하세요. npx가 매번 최신 버전을 받아요 (인터넷 필요).",
40
+ "请在终端中退出 CozyClay,然后重新运行 `npx cozyclay`。npx 每次都会获取最新版本(需要联网)。");
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** navigator.clipboard, else a hidden textarea and execCommand("copy"). Resolves true when copied. */
46
+ async function copyText(text) {
47
+ try {
48
+ await navigator.clipboard.writeText(text);
49
+ return true;
50
+ } catch {
51
+ try {
52
+ const area = document.createElement("textarea");
53
+ area.value = text;
54
+ area.setAttribute("readonly", "");
55
+ area.style.position = "fixed";
56
+ area.style.opacity = "0";
57
+ document.body.appendChild(area);
58
+ area.select();
59
+ const ok = document.execCommand("copy");
60
+ area.remove();
61
+ return ok;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+ }
67
+
68
+ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onAction }) {
69
+ const closeRef = useRef(null);
70
+ const titleId = useId();
71
+ // "Copied" belongs to the view it was pressed in: a later phase shows its own command afresh.
72
+ const [copiedView, setCopiedView] = useState(null);
73
+ const copied = copiedView === state.view;
74
+ const { exportStatus, recState } = useStudioShell();
75
+ const agentRunning = useSyncExternalStore(subscribeAgentTurns, agentTurnRunning, () => false);
76
+ const blocked = updateBlocked({ agentRunning, exportStatus, recState });
77
+ useEffect(() => {
78
+ closeRef.current?.focus();
79
+ const onKey = (event) => {
80
+ if (event.key !== "Escape") return;
81
+ event.preventDefault();
82
+ onClose();
83
+ };
84
+ document.addEventListener("keydown", onKey);
85
+ return () => document.removeEventListener("keydown", onKey);
86
+ }, [onClose]);
87
+ const copy = async () => {
88
+ onAction("copy_command");
89
+ const view = state.view;
90
+ setCopiedView((await copyText(state.command)) ? view : null);
91
+ };
92
+ const startsInstall = state.actions.includes("update_now") || state.actions.includes("retry");
93
+ const body = bodyOf(state);
94
+ return createPortal(
95
+ <div className="menubar-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
96
+ <div className="menubar-dialog update-dialog" role="dialog" aria-modal="true" aria-labelledby={titleId} data-dialog="update" data-testid="update-dialog" data-update-view={state.view}>
97
+ <header className="menubar-dialog-head">
98
+ <h2 id={titleId}>{titleOf(state)}</h2>
99
+ <button type="button" ref={closeRef} className="menubar-dialog-close" aria-label={ko("Close", "닫기", "关闭")} onClick={onClose}>
100
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
101
+ </button>
102
+ </header>
103
+ <div className="menubar-dialog-body">
104
+ {state.view === "available" && state.current && (
105
+ <p className="menubar-dialog-meta">{ko(`You have ${state.current}.`, `지금은 ${state.current} 버전이에요.`, `当前版本为 ${state.current}。`)}</p>
106
+ )}
107
+ {body && <p className="update-dialog-text">{body}</p>}
108
+ {state.actions.includes("copy_command") && <code className="update-dialog-command">{state.command}</code>}
109
+ {state.view === "available" && state.notesUrl && (
110
+ <p>
111
+ <a className="update-dialog-notes" data-testid="update-notes" href={state.notesUrl} target="_blank" rel="noopener noreferrer" onClick={() => onAction("release_notes")}>
112
+ {ko("What's new", "바뀐 내용 보기", "更新内容")}
113
+ </a>
114
+ </p>
115
+ )}
116
+ {startsInstall && blocked && (
117
+ <p className="update-dialog-busy" data-testid="update-busy">{ko("Finish the current turn or export first.", "진행 중인 턴이나 내보내기를 먼저 마치세요.", "请先完成当前的回合或导出。")}</p>
118
+ )}
119
+ <div className="update-dialog-actions">
120
+ {state.actions.includes("update_now") && (
121
+ <button type="button" className="update-dialog-primary" data-testid="update-now" disabled={blocked} onClick={onUpdate}>
122
+ {ko("Update now", "지금 업데이트", "立即更新")}
123
+ </button>
124
+ )}
125
+ {state.actions.includes("retry") && (
126
+ <button type="button" className="update-dialog-primary" data-testid="update-retry" disabled={blocked} onClick={onUpdate}>
127
+ {ko("Try again", "다시 시도", "重试")}
128
+ </button>
129
+ )}
130
+ {state.actions.includes("copy_command") && (
131
+ <button type="button" data-testid="update-copy" data-copied={copied || undefined} onClick={() => void copy()}>
132
+ {copied ? ko("Copied", "복사했어요", "已复制") : ko("Copy command", "명령 복사", "复制命令")}
133
+ </button>
134
+ )}
135
+ {state.actions.includes("later") && (
136
+ <button type="button" data-testid="update-later" onClick={onLater}>{ko("Later", "나중에", "稍后")}</button>
137
+ )}
138
+ {state.view === "restart_needed" && (
139
+ <button type="button" onClick={onClose}>{ko("Close", "닫기", "关闭")}</button>
140
+ )}
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>,
145
+ document.body,
146
+ );
147
+ }
@@ -0,0 +1,188 @@
1
+ // The in-Studio update notice (U1): the launcher's GET/POST /__cozyclay/update
2
+ // (bin/update-routes.mjs) read and driven from the page. Pure apart from the
3
+ // injected fetch, timers, storage and window, so test/verify-update-client.mjs
4
+ // drives it under Node. A page without window.__COZYCLAY_RUNTIME__ (the hosted
5
+ // site, Vite dev) makes no request at all.
6
+ export const UPDATE_URL = "/__cozyclay/update";
7
+ export const DISMISS_KEY = "cozyclay:update-dismissed";
8
+ export const POLL_MS = 6 * 60 * 60 * 1000;
9
+ export const VISIBLE_STALE_MS = 60 * 60 * 1000;
10
+ export const UPDATING_POLL_MS = 2000;
11
+
12
+ const VERSION = /^\d+\.\d+\.\d+$/;
13
+ const KINDS = new Set(["global", "npx", "clone"]);
14
+ const STATUSES = new Set(["latest", "outdated", "unknown"]);
15
+ const PHASES = new Set(["idle", "updating", "restart_needed", "failed"]);
16
+ const ERROR_CLASSES = new Set(["permission", "network", "node_version", "npm_missing", "other"]);
17
+ const NOTES_PREFIX = "https://github.com/NomaDamas/CozyClay/releases/tag/v";
18
+ const GLOBAL_COMMAND = "npm install -g cozyclay@latest";
19
+ const NPX_COMMAND = "npx cozyclay";
20
+ const RESTART_COMMAND = "cclay";
21
+
22
+ /** True on a page the npm launcher served (it injects window.__COZYCLAY_RUNTIME__). */
23
+ function hasLauncherRuntime(win = globalThis.window) {
24
+ try { return Boolean(win?.__COZYCLAY_RUNTIME__); } catch { return false; }
25
+ }
26
+
27
+ const version = (value) => (typeof value === "string" && VERSION.test(value) ? value : null);
28
+
29
+ /** The route body as the client trusts it: closed values only; anything else reads as "unknown". */
30
+ export function normalizeUpdateInfo(body) {
31
+ if (!body || typeof body !== "object") return null;
32
+ const kind = KINDS.has(body.kind) ? body.kind : "clone";
33
+ const latest = version(body.latest);
34
+ const phase = PHASES.has(body.phase) ? body.phase : "idle";
35
+ return {
36
+ enabled: body.enabled === true,
37
+ current: version(body.current),
38
+ latest,
39
+ status: STATUSES.has(body.status) ? body.status : "unknown",
40
+ kind,
41
+ canSelfUpdate: body.canSelfUpdate === true,
42
+ phase,
43
+ errorClass: phase === "failed" ? (ERROR_CLASSES.has(body.errorClass) ? body.errorClass : "other") : null,
44
+ durationMs: Number.isFinite(body.durationMs) && body.durationMs >= 0 ? body.durationMs : null,
45
+ // Opened in a new tab, so only the project's own release page is accepted.
46
+ notesUrl: latest && body.notesUrl === `${NOTES_PREFIX}${latest}` ? body.notesUrl : null,
47
+ command: kind === "npx" ? NPX_COMMAND : GLOBAL_COMMAND,
48
+ };
49
+ }
50
+
51
+ async function request(fetchImpl, init, win) {
52
+ if (!hasLauncherRuntime(win) || typeof fetchImpl !== "function") return null;
53
+ try {
54
+ const response = await fetchImpl(UPDATE_URL, { cache: "no-store", credentials: "same-origin", ...init });
55
+ if (!response?.ok) return null;
56
+ return normalizeUpdateInfo(await response.json());
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** GET the launcher's update state; null on a hosted page or any failure (the notice fails silently). */
63
+ export const fetchUpdateInfo = (fetchImpl = globalThis.fetch?.bind(globalThis), win = globalThis.window) => request(fetchImpl, { method: "GET" }, win);
64
+
65
+ /** POST: ask the launcher to install `latest`. The 202 body, or null (403/409/network). No body is sent. */
66
+ export const startUpdate = (fetchImpl = globalThis.fetch?.bind(globalThis), win = globalThis.window) => request(fetchImpl, { method: "POST" }, win);
67
+
68
+ /** patch | minor | major: how far `current` is behind `latest`. */
69
+ export function behindOf(current, latest) {
70
+ const [a, b] = [current, latest].map((value) => (version(value) ?? "0.0.0").split(".").map(Number));
71
+ if (a[0] !== b[0]) return "major";
72
+ return a[1] !== b[1] ? "minor" : "patch";
73
+ }
74
+
75
+ // The in-progress export phases (App.jsx updateExportStatus; StatusBar.jsx BUSY). completed, failed and
76
+ // cancelled stay in exportStatus after the job ends, so a non-null status alone is not "exporting".
77
+ const EXPORT_BUSY_PHASES = new Set(["preparing", "encoding", "finalizing"]);
78
+
79
+ /** "Update now" waits while an agent turn, an export or a recording runs: they would cross the file swap. */
80
+ export function updateBlocked({ agentRunning = false, exportStatus = null, recState = "idle" } = {}) {
81
+ return Boolean(agentRunning) || EXPORT_BUSY_PHASES.has(exportStatus?.phase) || recState === "recording";
82
+ }
83
+
84
+ const HIDDEN = Object.freeze({ view: "hidden", actions: [] });
85
+
86
+ /** The chip and dialog view for one poll result and the local dismiss state ({ dismissed }). */
87
+ export function nextUpdateState(info, local = {}) {
88
+ if (!info || !info.enabled) return HIDDEN;
89
+ const base = {
90
+ current: info.current, latest: info.latest, kind: info.kind, notesUrl: info.notesUrl,
91
+ command: info.command, canSelfUpdate: info.canSelfUpdate, errorClass: null,
92
+ };
93
+ 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"] };
96
+ if (info.phase === "failed") {
97
+ return { ...base, view: "failed", errorClass: info.errorClass ?? "other", command: GLOBAL_COMMAND,
98
+ actions: oneClick ? ["retry", "copy_command"] : ["copy_command"] };
99
+ }
100
+ const shown = info.status === "outdated" && (info.kind === "global" || info.kind === "npx") && info.latest && local.dismissed !== info.latest;
101
+ if (!shown) return HIDDEN;
102
+ return { ...base, view: "available", behind: behindOf(info.current, info.latest), actions: [oneClick ? "update_now" : "copy_command", "later"] };
103
+ }
104
+
105
+ // Reading window.localStorage itself throws when site data is blocked, so even the default is guarded.
106
+ const safeStorage = () => { try { return globalThis.localStorage; } catch { return null; } };
107
+
108
+ /** The dismissed version, or null. Storage that throws reads as "nothing dismissed". */
109
+ export function readDismissed(storage = safeStorage()) {
110
+ try { return version(storage?.getItem(DISMISS_KEY)); } catch { return null; }
111
+ }
112
+
113
+ /** Hide `latest` for good (per version). False when storage refused; the notice still hides for this page. */
114
+ export function writeDismissed(latest, storage = safeStorage()) {
115
+ try { storage.setItem(DISMISS_KEY, latest); return true; } catch { return false; }
116
+ }
117
+
118
+ const defaultTimers = {
119
+ set: (fn, ms) => setTimeout(fn, ms),
120
+ clear: (timer) => clearTimeout(timer),
121
+ now: () => Date.now(),
122
+ // After the Studio's first frame, then idle: the check never competes with boot.
123
+ afterFirstFrame(fn) {
124
+ const idle = globalThis.requestIdleCallback ?? ((run) => setTimeout(run, 200));
125
+ const frame = globalThis.requestAnimationFrame ?? ((run) => setTimeout(run, 16));
126
+ frame(() => idle(fn));
127
+ },
128
+ };
129
+
130
+ /**
131
+ * The poll loop: once after the first frame and idle, every 6 h, on a visible
132
+ * tab whose last check is over 1 h old, every 2 s while installing. `onState`
133
+ * gets { state, info } after every change. Returns { dismiss, update, stop }.
134
+ */
135
+ export function createUpdatePoller({
136
+ fetchImpl = globalThis.fetch?.bind(globalThis), onState = () => {}, timers = defaultTimers,
137
+ storage = safeStorage(), win = globalThis.window, doc = globalThis.document,
138
+ } = {}) {
139
+ const noop = { dismiss() {}, update: async () => null, stop() {} };
140
+ 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 }); };
144
+ const schedule = () => {
145
+ if (stopped) return;
146
+ if (timer !== null) timers.clear(timer);
147
+ timer = timers.set(check, info?.phase === "updating" ? UPDATING_POLL_MS : POLL_MS);
148
+ };
149
+ const apply = (next) => { if (next) { info = next; emit(); } };
150
+ function check() {
151
+ if (stopped) return Promise.resolve(info);
152
+ if (inflight) return inflight;
153
+ // An early check (a visible tab) replaces the pending poll; the schedule never doubles.
154
+ if (timer !== null) { timers.clear(timer); timer = null; }
155
+ lastCheck = timers.now();
156
+ inflight = fetchUpdateInfo(fetchImpl, win);
157
+ return inflight.then((next) => { inflight = null; apply(next); schedule(); return info; });
158
+ }
159
+ const onVisible = () => {
160
+ if (doc?.visibilityState === "visible" && timers.now() - lastCheck > VISIBLE_STALE_MS) void check();
161
+ };
162
+ const onStorage = (event) => {
163
+ if (event?.key !== DISMISS_KEY && event?.key !== null) return;
164
+ dismissed = readDismissed(storage);
165
+ emit();
166
+ };
167
+ try { doc?.addEventListener?.("visibilitychange", onVisible); win?.addEventListener?.("storage", onStorage); } catch { /* no listeners: the 6 h poll still runs */ }
168
+ timers.afterFirstFrame(() => { if (!stopped) void check(); });
169
+ return {
170
+ dismiss(latest) {
171
+ if (!version(latest)) return;
172
+ writeDismissed(latest, storage);
173
+ dismissed = latest;
174
+ emit();
175
+ },
176
+ async update() {
177
+ const next = await startUpdate(fetchImpl, win);
178
+ apply(next);
179
+ schedule();
180
+ return next;
181
+ },
182
+ stop() {
183
+ stopped = true;
184
+ if (timer !== null) timers.clear(timer);
185
+ try { doc?.removeEventListener?.("visibilitychange", onVisible); win?.removeEventListener?.("storage", onStorage); } catch { /* best effort */ }
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,78 @@
1
+ // update:notice_shown, update:clicked and update:finished (the U1 update notice).
2
+ // Only closed values leave: the install kind, how far behind (patch/minor/major),
3
+ // the button, the outcome, the npm error class and a duration bucket. Never a
4
+ // version string, a path, a command or npm's output. All three are passive
5
+ // (analytics-strict.js), so none of them moves action_count_bucket.
6
+ import { initAnalytics, track } from "../analytics.js";
7
+ import { admit } from "../analytics-friction.js";
8
+ import { bucketLong } from "../analytics-strict.js";
9
+
10
+ const CLICKS_PER_SESSION = 10;
11
+ const FINISHED_PER_SESSION = 3;
12
+ const NOTICE_KINDS = new Set(["global", "npx"]);
13
+ const CLICK_ACTIONS = new Set(["update_now", "copy_command", "later", "dismiss", "release_notes"]);
14
+ // The install this browser already reported, shared by the tabs of one profile:
15
+ // several tabs (and several polls) see the same terminal phase, the event leaves once.
16
+ const REPORTED_KEY = "cozyclay:update-reported";
17
+
18
+ let send = (event, props) => {
19
+ initAnalytics().then(() => track(event, props), () => {});
20
+ };
21
+ let storage = () => globalThis.localStorage;
22
+ const reportedHere = new Set();
23
+
24
+ /** The chip became visible in the "available" view: once per session. */
25
+ export function noteNoticeShown(state) {
26
+ try {
27
+ if (state?.view !== "available" || !NOTICE_KINDS.has(state.kind)) return;
28
+ if (admit("update:notice_shown", 1)) send("update:notice_shown", { kind: state.kind, behind: state.behind });
29
+ } catch {
30
+ // Telemetry must never affect the notice.
31
+ }
32
+ }
33
+
34
+ /** A notice button: update_now (also Try again), copy_command, later, dismiss or release_notes. */
35
+ export function noteUpdateClicked(action, kind) {
36
+ try {
37
+ if (!CLICK_ACTIONS.has(action) || !NOTICE_KINDS.has(kind)) return;
38
+ if (admit("update:clicked", CLICKS_PER_SESSION)) send("update:clicked", { action, kind });
39
+ } catch {
40
+ // Telemetry must never affect the notice.
41
+ }
42
+ }
43
+
44
+ function claim(key) {
45
+ if (reportedHere.has(key)) return false;
46
+ reportedHere.add(key);
47
+ try {
48
+ const store = storage();
49
+ if (store?.getItem(REPORTED_KEY) === key) return false;
50
+ store?.setItem(REPORTED_KEY, key);
51
+ } catch {
52
+ // No storage: this page's own memory still keeps it to once.
53
+ }
54
+ return true;
55
+ }
56
+
57
+ /** A poll result. A terminal install phase with its duration sends update:finished, once per install. */
58
+ export function noteUpdatePolled(info) {
59
+ try {
60
+ if (!info || (info.phase !== "restart_needed" && info.phase !== "failed") || !Number.isFinite(info.durationMs)) return;
61
+ // The launcher holds one install at a time; its version, phase and wall time name it.
62
+ if (!claim(`${info.latest}|${info.phase}|${info.durationMs}`)) return;
63
+ if (!admit("update:finished", FINISHED_PER_SESSION)) return;
64
+ const failed = info.phase === "failed";
65
+ send("update:finished", { outcome: failed ? "failed" : "ok", error_class: failed ? info.errorClass ?? "other" : "none", duration: bucketLong(info.durationMs) });
66
+ } catch {
67
+ // Telemetry must never affect the poll.
68
+ }
69
+ }
70
+
71
+ /** Tests only: capture what is sent and substitute the storage; returns the restore. */
72
+ export function updateTelemetrySeam(capture, store = null) {
73
+ const previous = { send, storage };
74
+ send = capture;
75
+ storage = () => store;
76
+ reportedHere.clear();
77
+ return () => { ({ send, storage } = previous); reportedHere.clear(); };
78
+ }
@@ -0,0 +1,94 @@
1
+ /* The update notice (U1): a quiet top-bar chip beside the MCP state, and the
2
+ dialog that reuses the Help dialog's menubar-dialog-* frame (topbar.css). */
3
+ @layer regions {
4
+ .v2-topbar .update-chip-wrap {
5
+ display: inline-flex;
6
+ flex: none;
7
+ align-items: center;
8
+ gap: 2px;
9
+ }
10
+ .v2-topbar .update-chip {
11
+ display: inline-flex;
12
+ align-items: center;
13
+ gap: var(--sp-1, 8px);
14
+ height: var(--h-row, 24px);
15
+ padding: 0 var(--sp-1, 8px);
16
+ border: 0;
17
+ border-radius: var(--r-1, 4px);
18
+ background: transparent;
19
+ color: var(--text-2, #9b9ba1);
20
+ font: 500 var(--secondary-size, 11px) var(--sans);
21
+ white-space: nowrap;
22
+ cursor: pointer;
23
+ }
24
+ .v2-topbar .update-chip:hover,
25
+ .v2-topbar .update-chip[aria-expanded="true"] { background: var(--surface-raised, #1e1e21); color: var(--text-1, #e4e4e7); }
26
+ .v2-topbar .update-chip-dot {
27
+ flex: none;
28
+ width: var(--topbar-dot, 6px);
29
+ height: var(--topbar-dot, 6px);
30
+ border-radius: 50%;
31
+ background: var(--primary, #7258a0);
32
+ }
33
+ .v2-topbar .update-chip[data-update-view="failed"] .update-chip-dot { background: var(--danger, #e5484d); }
34
+ .v2-topbar .update-chip[data-update-view="restart_needed"] .update-chip-dot { background: var(--ok, #4fbf7a); }
35
+ .v2-topbar .update-chip-dismiss {
36
+ display: grid;
37
+ place-items: center;
38
+ width: 18px;
39
+ height: 18px;
40
+ padding: 0;
41
+ border: 0;
42
+ border-radius: var(--r-1, 4px);
43
+ background: transparent;
44
+ color: var(--text-3, #636369);
45
+ cursor: pointer;
46
+ }
47
+ .v2-topbar .update-chip-dismiss:hover { background: var(--surface-raised, #1e1e21); color: var(--text-1, #e4e4e7); }
48
+ .v2-topbar .update-chip-dismiss svg {
49
+ width: 10px;
50
+ height: 10px;
51
+ fill: none;
52
+ stroke: currentColor;
53
+ stroke-width: 2.5;
54
+ stroke-linecap: round;
55
+ }
56
+
57
+ .update-dialog .update-dialog-text { color: var(--text-1, #e4e4e7); line-height: 1.45; }
58
+ .update-dialog .update-dialog-command {
59
+ display: block;
60
+ margin: 0 0 var(--sp-1, 8px);
61
+ padding: 6px var(--sp-1, 8px);
62
+ border-radius: var(--r-1, 4px);
63
+ background: var(--surface-bar, #121214);
64
+ color: var(--text-1, #e4e4e7);
65
+ font: 400 var(--secondary-size, 11px) var(--mono);
66
+ user-select: all;
67
+ }
68
+ .update-dialog .update-dialog-notes { color: var(--camera, #a78bfa); font-size: var(--secondary-size, 11px); }
69
+ .update-dialog .update-dialog-busy { color: var(--select, #e8a33d); font-size: var(--secondary-size, 11px); }
70
+ .update-dialog .update-dialog-actions {
71
+ display: flex;
72
+ flex-wrap: wrap;
73
+ justify-content: flex-end;
74
+ gap: var(--sp-1, 8px);
75
+ margin-top: var(--sp-2, 12px);
76
+ }
77
+ .update-dialog .update-dialog-actions button {
78
+ height: var(--topbar-primary-height, 30px);
79
+ padding: 0 var(--sp-2, 12px);
80
+ border: 0;
81
+ border-radius: var(--r-1, 4px);
82
+ background: var(--surface-raised, #1e1e21);
83
+ color: var(--text-1, #e4e4e7);
84
+ font: 500 var(--ui-size, 12px) var(--sans);
85
+ cursor: pointer;
86
+ }
87
+ .update-dialog .update-dialog-actions button:hover:not(:disabled) { background: var(--surface-active, #26262a); }
88
+ .update-dialog .update-dialog-actions .update-dialog-primary { background: var(--primary, #7258a0); color: var(--text-selected, #fff); }
89
+ .update-dialog .update-dialog-actions .update-dialog-primary:hover:not(:disabled) {
90
+ background: color-mix(in srgb, var(--primary, #7258a0) 86%, var(--text-selected, #fff));
91
+ }
92
+ .update-dialog .update-dialog-actions button:disabled { color: var(--text-off, #4f4f55); cursor: default; }
93
+ .update-dialog .update-dialog-actions .update-dialog-primary:disabled { background: var(--surface-raised, #1e1e21); }
94
+ }
@@ -0,0 +1,35 @@
1
+ // The stage's one-time WebGL check. It runs in a mount effect, never in render:
2
+ // probing creates a throwaway canvas and a GL context. The result is cached for
3
+ // the page, so a remount does not probe again.
4
+ import { useEffect, useState } from "react";
5
+ import { reportGlLost } from "./crash-telemetry.js";
6
+
7
+ let cached = null;
8
+
9
+ function probe() {
10
+ if (cached !== null) return cached;
11
+ try {
12
+ const canvas = document.createElement("canvas");
13
+ cached = Boolean(canvas.getContext("webgl") || canvas.getContext("experimental-webgl"));
14
+ } catch {
15
+ cached = false;
16
+ }
17
+ return cached;
18
+ }
19
+
20
+ /** False once the mount check found no WebGL; true before it runs and when WebGL works. */
21
+ export function useWebGlAvailable() {
22
+ const [available, setAvailable] = useState(cached ?? true);
23
+ useEffect(() => {
24
+ if (typeof document === "undefined") return undefined;
25
+ // A task after the first commit keeps the throwaway GL context off the
26
+ // commit path; a StrictMode remount clears the first timer.
27
+ const timer = setTimeout(() => {
28
+ const ok = probe();
29
+ setAvailable(ok);
30
+ if (!ok) reportGlLost("unavailable");
31
+ }, 0);
32
+ return () => clearTimeout(timer);
33
+ }, []);
34
+ return available;
35
+ }