From e98fbb31f7a972a826c67f0cdff2821231ae4b81 Mon Sep 17 00:00:00 2001 From: SinachPat Date: Thu, 30 Apr 2026 16:56:27 +0100 Subject: [PATCH] improved a lot of things --- .../app/src/app/api/agent-bridge/route.ts | 10 +- .../app/src/app/api/design-language/route.ts | 2 +- .../app/src/components/canvas/Artboard.tsx | 25 ++- .../components/shell/ProjectSettingsForm.tsx | 4 +- packages/live-sdk/src/hook.ts | 164 +++++++++++------- packages/renderer/src/protocol.ts | 3 +- 6 files changed, 132 insertions(+), 76 deletions(-) diff --git a/packages/app/src/app/api/agent-bridge/route.ts b/packages/app/src/app/api/agent-bridge/route.ts index 7efd835..1ce3ed8 100644 --- a/packages/app/src/app/api/agent-bridge/route.ts +++ b/packages/app/src/app/api/agent-bridge/route.ts @@ -3,7 +3,7 @@ // Authentication: Bearer (HMAC-SHA256, issued by issueWorkspaceToken). import { NextRequest, NextResponse } from 'next/server'; -import { verifyWorkspaceToken, TOOL_MAP } from '@originmain/agent-bridge'; +import { verifyWorkspaceToken, TOOL_MAP, getToolList } from '@originmain/agent-bridge'; import type { JsonRpcRequest, ToolContext } from '@originmain/agent-bridge'; import { getDiffsByStatus, @@ -47,12 +47,10 @@ export async function POST(req: NextRequest) { } // tools/list — meta-endpoint; return available tools without executing one. + // MCP spec requires each entry to include inputSchema so clients can + // construct valid tool calls without out-of-band documentation. if (body.method === 'tools/list') { - const tools = [...TOOL_MAP.values()].map(t => ({ - name: t.name, - description: t.description, - })); - return NextResponse.json({ jsonrpc: '2.0', id: body.id, result: { tools } }); + return NextResponse.json({ jsonrpc: '2.0', id: body.id, result: { tools: getToolList() } }); } // ── Dispatch ──────────────────────────────────────────────────────────────── diff --git a/packages/app/src/app/api/design-language/route.ts b/packages/app/src/app/api/design-language/route.ts index 76e68ff..86b07f5 100644 --- a/packages/app/src/app/api/design-language/route.ts +++ b/packages/app/src/app/api/design-language/route.ts @@ -17,7 +17,7 @@ export async function GET(req: NextRequest) { try { const db = serverClient(); const file = await getActiveDesignLanguageFile(db, workspaceId); - if (!file) return NextResponse.json(null, { status: 204 }); + if (!file) return NextResponse.json(null); return NextResponse.json(file); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; diff --git a/packages/app/src/components/canvas/Artboard.tsx b/packages/app/src/components/canvas/Artboard.tsx index 6d7e99a..659ab3d 100644 --- a/packages/app/src/components/canvas/Artboard.tsx +++ b/packages/app/src/components/canvas/Artboard.tsx @@ -55,8 +55,12 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard }, [localFiberRoot, selectComponent]); // ── Drag to reposition ───────────────────────────────────────────────────── - const isDragging = useRef(false); - const dragStart = useRef({ mouseX: 0, mouseY: 0, artX: 0, artY: 0 }); + const isDragging = useRef(false); + const dragStart = useRef({ mouseX: 0, mouseY: 0, artX: 0, artY: 0 }); + // dragOffsetRef carries the live offset into the onUp closure, avoiding the + // stale-state problem that occurs when useCallback captures dragOffset before + // any mousemove events have fired. + const dragOffsetRef = useRef({ dx: 0, dy: 0 }); const [dragOffset, setDragOffset] = useState({ dx: 0, dy: 0 }); const onLabelMouseDown = useCallback((e: React.MouseEvent) => { @@ -65,6 +69,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard e.stopPropagation(); e.preventDefault(); isDragging.current = true; + dragOffsetRef.current = { dx: 0, dy: 0 }; const { zoom, panX, panY } = useViewport.getState(); dragStart.current = { mouseX: (e.clientX - panX) / zoom, @@ -79,10 +84,13 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard const { zoom: z, panX: px, panY: py } = useViewport.getState(); const curX = (mv.clientX - px) / z; const curY = (mv.clientY - py) / z; - setDragOffset({ + const offset = { dx: curX - dragStart.current.mouseX, dy: curY - dragStart.current.mouseY, - }); + }; + // Update the ref first so onUp always reads the final position. + dragOffsetRef.current = offset; + setDragOffset(offset); }; const onUp = () => { @@ -91,8 +99,10 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); - const newX = Math.round(dragStart.current.artX + dragOffset.dx); - const newY = Math.round(dragStart.current.artY + dragOffset.dy); + // Read the final offset from the ref — never stale regardless of when + // useCallback last reconstructed onLabelMouseDown. + const newX = Math.round(dragStart.current.artX + dragOffsetRef.current.dx); + const newY = Math.round(dragStart.current.artY + dragOffsetRef.current.dy); // Persist position fetch(`/api/artboards/${id}`, { @@ -104,12 +114,13 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard }).then(() => { queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); setDragOffset({ dx: 0, dy: 0 }); + dragOffsetRef.current = { dx: 0, dy: 0 }; }).catch(console.error); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); - }, [id, x, y, width, height, renderUrl, workspaceId, projectId, queryClient, dragOffset.dx, dragOffset.dy]); + }, [id, x, y, width, height, renderUrl, workspaceId, projectId, queryClient]); // ── Inline rename ────────────────────────────────────────────────────────── const [renaming, setRenaming] = useState(false); diff --git a/packages/app/src/components/shell/ProjectSettingsForm.tsx b/packages/app/src/components/shell/ProjectSettingsForm.tsx index 6aa471c..427c423 100644 --- a/packages/app/src/components/shell/ProjectSettingsForm.tsx +++ b/packages/app/src/components/shell/ProjectSettingsForm.tsx @@ -62,7 +62,7 @@ export function ProjectSettingsForm({ memberRole, }: ProjectSettingsFormProps) { const router = useRouter(); - const isOwnerOrDev = memberRole === 'OWNER' || memberRole === 'DEVELOPER' || memberRole === 'DESIGNER'; + const isOwnerOrDev = memberRole === 'OWNER' || memberRole === 'ENGINEER' || memberRole === 'DESIGNER'; /* ── Form state ── */ const [name, setName] = useState(initialName); @@ -229,7 +229,7 @@ export function ProjectSettingsForm({ border: '1px solid rgba(239,68,68,0.35)', background: 'transparent', color: '#EF4444', cursor: deleteConfirm.trim() === initialName.trim() ? 'pointer' : 'not-allowed', flexShrink: 0, - opacity: deleteConfirm.trim() !== name.trim() || deleting ? 0.4 : 1, + opacity: deleteConfirm.trim() !== initialName.trim() || deleting ? 0.4 : 1, }} > {deleting ? 'Deleting…' : 'Delete project'} diff --git a/packages/live-sdk/src/hook.ts b/packages/live-sdk/src/hook.ts index e8a062b..ae6a066 100644 --- a/packages/live-sdk/src/hook.ts +++ b/packages/live-sdk/src/hook.ts @@ -4,7 +4,7 @@ // import time; any later installation is too late. // // Full bidirectional protocol: -// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, ERROR +// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, COMPONENT_DESELECTED, ERROR // Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT // // Node IDs are stable path strings: "Component:idx/Child:idx/…" @@ -57,8 +57,11 @@ function installFiberHook(): void { } // ── Runtime state ───────────────────────────────────────────────────────── - let currentTree: SerializedNode | null = null; - const nodeMap = new Map(); + // nodeMap: nodeId → { domRect (snapshot), fiber (live reference for re-measurement) } + let nodeMap = new Map(); + // fiberMap: fiber object → nodeId for O(1) hit-test lookup via __reactFiber$ DOM keys. + // A null value means the fiber is unnamed/transparent and not directly selectable. + let fiberMap = new WeakMap(); let selectedNodeId: string | null = null; let highlightEl: HTMLElement | null = null; @@ -93,9 +96,13 @@ function installFiberHook(): void { const root = args[1] as { current: FiberLike } | undefined; if (!root?.current) return; + // Reset both maps before each walk so stale entries from the previous tree + // don't accumulate. fiberMap is a WeakMap so it self-cleans, but nodeMap + // must be rebuilt from scratch on every commit. + nodeMap = new Map(); + fiberMap = new WeakMap(); + const tree = serializeFiber(root.current, ''); - currentTree = tree; - rebuildNodeMap(tree); post({ type: 'FIBER_TREE_UPDATE', root: tree }); // Re-sync the highlight ring after each React commit (component may move). if (selectedNodeId) updateHighlight(); @@ -105,6 +112,12 @@ function installFiberHook(): void { }; // ── Fiber serialization (stable path-based IDs) ─────────────────────────── + // ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..." + // + // IMPORTANT: unnamed fibers (Fragment, Context.Provider, React.memo wrappers) + // are transparent — their children are collected directly into their parent's + // children array. Returning only the first named child (old approach) caused + // entire subtrees to vanish from the tree. function serializeFiber( fiber: FiberLike | null, @@ -114,14 +127,13 @@ function installFiberHook(): void { const name = getDisplayName(fiber); if (!name) { - // Unnamed fiber — skip level but keep walking children. - let child = fiber.child; - while (child) { - const s = serializeFiber(child, parentId); - if (s) return s; - child = child.sibling; - } - return null; + // Unnamed root fiber (HostRoot) — collectChildren handles Fragment recursively. + const children: SerializedNode[] = []; + collectChildren(fiber, parentId, children); + if (children.length === 1) return children[0]; + if (children.length === 0) return null; + // Multiple named children at root — wrap in a synthetic root node. + return { id: '__root__', name: '__root__', props: {}, children }; } const nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(fiber.index); @@ -134,13 +146,31 @@ function installFiberHook(): void { }; if (rect) node.domRect = rect; + // Register in both maps for O(1) lookup. + nodeMap.set(nodeId, { domRect: rect, fiber }); + fiberMap.set(fiber, nodeId); + + collectChildren(fiber, nodeId, node.children); + return node; + } + + // Collect all named descendants of fiber.child into out[], transparently + // flattening unnamed intermediates (Fragments, Providers, wrappers). + function collectChildren(fiber: FiberLike, parentId: string, out: SerializedNode[]): void { let child = fiber.child; while (child) { - const serialized = serializeFiber(child, nodeId); - if (serialized) node.children.push(serialized); + const name = getDisplayName(child); + if (name) { + const serialized = serializeFiber(child, parentId); + if (serialized) out.push(serialized); + } else { + // Unnamed (Fragment / Context / Provider / forwardRef wrapper etc.): + // mark as non-selectable and flatten children directly into our level. + fiberMap.set(child, null); + collectChildren(child, parentId, out); + } child = child.sibling; } - return node; } function getDisplayName(fiber: FiberLike): string | null { @@ -187,25 +217,21 @@ function installFiberHook(): void { return out; } - // ── Node map: flat O(1) lookup by stable ID ─────────────────────────────── - - function rebuildNodeMap(node: SerializedNode | null): void { - nodeMap.clear(); - fillMap(node); - } - - function fillMap(node: SerializedNode | null): void { - if (!node) return; - nodeMap.set(node.id, { domRect: node.domRect ?? null }); - for (const child of node.children) fillMap(child); - } - // ── Highlight overlay (blue ring inside the iframe) ─────────────────────── + // updateHighlight re-measures from the live fiber stateNode so the ring stays + // accurate even after scroll (between React commits). function updateHighlight(): void { const info = selectedNodeId ? nodeMap.get(selectedNodeId) : undefined; - if (info?.domRect) renderHighlight(info.domRect); - else removeHighlight(); + if (!info) { removeHighlight(); return; } + + // Re-measure from the live DOM element for scroll accuracy. + const rect = getDomRect(info.fiber) ?? info.domRect; + if (rect && rect.width > 0 && rect.height > 0) { + renderHighlight(rect); + } else { + removeHighlight(); + } } function renderHighlight(rect: DomRect): void { @@ -235,40 +261,60 @@ function installFiberHook(): void { } } - // ── Click-to-select (capturing phase) ──────────────────────────────────── - - document.addEventListener('click', (event: MouseEvent) => { - const node = findDeepestAt(currentTree, event.clientX, event.clientY); - if (node?.domRect) { - post({ type: 'COMPONENT_SELECTED', nodeId: node.id, rect: node.domRect }); - } + // Re-measure on scroll so the ring follows the element without needing + // a React commit (which only fires on state/prop changes). + window.addEventListener('scroll', () => { + if (selectedNodeId) updateHighlight(); }, true); - function findDeepestAt( - node: SerializedNode | null, - x: number, - y: number, - ): SerializedNode | null { - if (!node) return null; - const r = node.domRect; - const hit = r && x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height; + // ── Click-to-select (capturing phase) ──────────────────────────────────── + // Uses document.elementFromPoint to get the live DOM element at the click + // position (accurate even after scroll), then walks the React fiber tree + // upward via __reactFiber$ keys to find the nearest tracked component. - if (hit) { - for (const child of node.children) { - const deeper = findDeepestAt(child, x, y); - if (deeper) return deeper; - } - return node; - } - - // No hit — still check children for overflow:visible scenarios. - for (const child of node.children) { - const found = findDeepestAt(child, x, y); - if (found) return found; + function getFiberKey(el: Element): string | null { + const keys = Object.keys(el); + for (let i = 0; i < keys.length; i++) { + if (keys[i].startsWith('__reactFiber$')) return keys[i]; } return null; } + document.addEventListener('click', (event: MouseEvent) => { + // Ignore clicks on our own highlight overlay. + if (event.target === highlightEl) return; + + const el = document.elementFromPoint(event.clientX, event.clientY); + + // Walk the DOM upward, trying to find a tracked React fiber at each level. + let current: Element | null = el; + while (current && current !== document.documentElement) { + const fiberKey = getFiberKey(current); + if (fiberKey) { + // Walk the fiber's return (parent) chain to find the nearest tracked node. + let fiber: FiberLike | null = + (current as unknown as Record)[fiberKey] as FiberLike | null; + while (fiber) { + const nodeId = fiberMap.get(fiber); + if (nodeId) { + // Re-measure from the live element for accurate post-scroll rect. + const liveEl = fiber.stateNode; + if (liveEl && typeof liveEl === 'object' && 'getBoundingClientRect' in liveEl) { + const r = (liveEl as Element).getBoundingClientRect(); + post({ type: 'COMPONENT_SELECTED', nodeId, rect: { x: r.x, y: r.y, width: r.width, height: r.height } }); + return; + } + } + fiber = fiber.return; + } + } + current = current.parentElement; + } + + // Nothing found — clear the selection. + post({ type: 'COMPONENT_DESELECTED' }); + }, true); + // ── Host → Renderer message handler ────────────────────────────────────── window.addEventListener('message', (event: MessageEvent) => { @@ -327,6 +373,8 @@ interface FiberLike { index: number; child: FiberLike | null; sibling: FiberLike | null; + /** Parent fiber — needed for click-to-select chain walk via __reactFiber$ keys. */ + return: FiberLike | null; stateNode: unknown; memoizedProps: Record | null; } diff --git a/packages/renderer/src/protocol.ts b/packages/renderer/src/protocol.ts index db9956c..60e1b47 100644 --- a/packages/renderer/src/protocol.ts +++ b/packages/renderer/src/protocol.ts @@ -28,8 +28,7 @@ export type HostMessage = | { type: 'SET_DESIGN_TOKENS'; tokens: Record } | { type: 'NAVIGATE'; path: string } | { type: 'SELECT_COMPONENT'; nodeId: string } - | { type: 'DESELECT' } - | { type: 'INJECT_FIBER_HOOK' }; + | { type: 'DESELECT' }; export interface HostEnvelope { source: typeof HOST_SOURCE;