improved a lot of things

This commit is contained in:
SinachPat
2026-04-26 12:31:31 +01:00
parent f9528702c8
commit 7a4c1d3147
30 changed files with 1316 additions and 59 deletions
+33
View File
@@ -0,0 +1,33 @@
# ── Clerk (authentication) ────────────────────────────────────────────────────
# https://dashboard.clerk.com → API Keys
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/app
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/app
# ── Supabase ──────────────────────────────────────────────────────────────────
# https://supabase.com/dashboard → Project Settings → API
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
# ── Anthropic ─────────────────────────────────────────────────────────────────
# https://console.anthropic.com/keys
ANTHROPIC_API_KEY=sk-ant-...
# ── Agent Bridge ─────────────────────────────────────────────────────────────
# Random secret used to sign workspace tokens issued to Cursor / Claude Code.
# Generate with: openssl rand -hex 32
AGENT_BRIDGE_SECRET=
# ── Webhook signing secrets (optional — skip verification in dev if absent) ──
# Linear: Settings → API → Webhooks → signing secret
LINEAR_WEBHOOK_SECRET=
# Slack: App → Basic Information → Signing Secret
SLACK_SIGNING_SECRET=
# GitHub: Repository → Settings → Webhooks → secret
GITHUB_WEBHOOK_SECRET=
# Intercom: Developer Hub → Webhooks → client secret
INTERCOM_WEBHOOK_SECRET=
+13 -1
View File
@@ -1,7 +1,19 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@originmain/ui', '@originmain/diff-engine', '@fluentui/react-components'],
transpilePackages: [
'@originmain/agent-bridge',
'@originmain/ai-layer',
'@originmain/design-language',
'@originmain/diff-engine',
'@originmain/integrations',
'@originmain/multiplayer',
'@originmain/origin-graph',
'@originmain/platform',
'@originmain/renderer',
'@originmain/ui',
'@fluentui/react-components',
],
experimental: {
optimizePackageImports: ['@fluentui/react-components'],
},
+8
View File
@@ -9,15 +9,23 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.56.0",
"@clerk/nextjs": "^7.2.7",
"@fluentui/react-components": "^9.54.0",
"@fluentui/react-icons": "^2.0.0",
"@originmain/agent-bridge": "workspace:*",
"@originmain/ai-layer": "workspace:*",
"@originmain/design-language": "workspace:*",
"@originmain/diff-engine": "workspace:*",
"@originmain/integrations": "workspace:*",
"@originmain/multiplayer": "workspace:*",
"@originmain/origin-graph": "workspace:*",
"@originmain/platform": "workspace:*",
"@originmain/renderer": "workspace:*",
"@originmain/ui": "workspace:*",
"@pierre/diffs": "^1.1.19",
"@pierre/trees": "1.0.0-beta.3",
"@supabase/supabase-js": "^2.0.0",
"@tanstack/react-query": "^5.62.0",
"next": "^15.1.0",
"react": "^19.0.0",
+25 -18
View File
@@ -2022,9 +2022,9 @@ footer {
<div class="int-card reveal"
style="--glow:#5e6ad2;--glow-dim:rgba(94,106,210,.14);--glow-soft:rgba(94,106,210,.06);--glow-ambient:rgba(94,106,210,.04);--ico-bg:rgba(94,106,210,.12);--ico-bd:rgba(94,106,210,.22)">
<div class="int-ico">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
<path d="M4 20L20 4" stroke="#5e6ad2" stroke-width="2.5" stroke-linecap="round"/>
<path d="M20 4v10M20 4H10" stroke="#5e6ad2" stroke-width="2.5" stroke-linecap="round"/>
<!-- Linear official mark — fetched from simple-icons/simple-icons/icons/linear.svg -->
<svg viewBox="0 0 24 24" width="20" height="20" fill="#5e6ad2">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z"/>
</svg>
</div>
<div>
@@ -2041,11 +2041,12 @@ footer {
<div class="int-card reveal"
style="--glow:#2eb67d;--glow-dim:rgba(46,182,125,.12);--glow-soft:rgba(46,182,125,.05);--glow-ambient:rgba(46,182,125,.04);--ico-bg:rgba(46,182,125,.1);--ico-bd:rgba(46,182,125,.18)">
<div class="int-ico">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
<path d="M9.07 3a2.07 2.07 0 00-2.07 2.07v5.79M7 10.86A2.07 2.07 0 113 10.86M16.93 3a2.07 2.07 0 012.07 2.07v5.79" stroke="#2eb67d" stroke-width="1.8" stroke-linecap="round"/>
<path d="M19 10.86a2.07 2.07 0 110 4.14M9.07 21a2.07 2.07 0 002.07-2.07v-5.79M11.14 13.14a2.07 2.07 0 110-4.14" stroke="#e01d5a" stroke-width="1.8" stroke-linecap="round"/>
<path d="M7 13.14h5.79M5 15.21a2.07 2.07 0 104.14 0" stroke="#ecb22e" stroke-width="1.8" stroke-linecap="round"/>
<path d="M13.14 15a2.07 2.07 0 104.14 0v-2.07M13.14 17.07a2.07 2.07 0 100 4.14" stroke="#36c5f0" stroke-width="1.8" stroke-linecap="round"/>
<!-- Slack official mark — paths from gilbarbara/logos slack-icon.svg -->
<svg viewBox="0 0 256 256" width="20" height="20" fill="none">
<path d="M53.841 161.321C53.841 176.152 41.854 188.14 27.022 188.14 12.19 188.14.203 176.152.203 161.321c0-14.832 11.987-26.82 26.819-26.82h26.82v26.82zm13.41 0c0-14.832 11.987-26.82 26.819-26.82 14.832 0 26.819 11.988 26.819 26.82v67.047c0 14.832-11.987 26.82-26.819 26.82-14.832 0-26.819-11.988-26.819-26.82V161.32z" fill="#E01E5A"/>
<path d="M94.07 53.638C79.238 53.638 67.251 41.651 67.251 26.819 67.251 11.987 79.238 0 94.07 0c14.832 0 26.819 11.987 26.819 26.819v26.82H94.07zm0 13.613c14.832 0 26.819 11.987 26.819 26.819 0 14.832-11.987 26.819-26.819 26.819H26.819C11.987 120.889 0 108.902 0 94.07c0-14.832 11.987-26.819 26.819-26.819H94.07z" fill="#36C5F0"/>
<path d="M201.549 94.07c0-14.832 11.987-26.819 26.819-26.819C243.2 67.251 255.187 79.238 255.187 94.07c0 14.832-11.987 26.819-26.819 26.819h-26.819V94.07zm-13.41 0c0 14.832-11.987 26.819-26.819 26.819-14.832 0-26.819-11.987-26.819-26.819V26.819C134.501 11.987 146.488 0 161.32 0c14.832 0 26.819 11.987 26.819 26.819V94.07z" fill="#2EB67D"/>
<path d="M161.321 201.549c14.832 0 26.819 11.987 26.819 26.819 0 14.832-11.987 26.819-26.819 26.819-14.832 0-26.819-11.987-26.819-26.819v-26.819h26.819zm0-13.41c-14.832 0-26.819-11.987-26.819-26.819 0-14.832 11.987-26.819 26.819-26.819h67.25c14.832 0 26.819 11.987 26.819 26.819 0 14.832-11.987 26.819-26.819 26.819h-67.25z" fill="#ECB22E"/>
</svg>
</div>
<div>
@@ -2062,8 +2063,9 @@ footer {
<div class="int-card w2 reveal"
style="--glow:#d4d4d4;--glow-dim:rgba(212,212,212,.09);--glow-soft:rgba(212,212,212,.04);--glow-ambient:rgba(212,212,212,.02);--ico-bg:rgba(255,255,255,.06);--ico-bd:rgba(255,255,255,.1)">
<div class="int-ico">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
<path d="M5 3l14 9.5-8.5 1.5L8 21 5 3z" fill="rgba(255,255,255,.8)" stroke="rgba(255,255,255,.5)" stroke-width="1" stroke-linejoin="round"/>
<!-- Cursor official mark — fetched from simple-icons/simple-icons/icons/cursor.svg -->
<svg viewBox="0 0 24 24" width="20" height="20" fill="rgba(255,255,255,.88)">
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23"/>
</svg>
</div>
<div>
@@ -2080,8 +2082,9 @@ footer {
<div class="int-card reveal"
style="--glow:#007acc;--glow-dim:rgba(0,122,204,.14);--glow-soft:rgba(0,122,204,.06);--glow-ambient:rgba(0,122,204,.04);--ico-bg:rgba(0,122,204,.12);--ico-bd:rgba(0,122,204,.22)">
<div class="int-ico">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
<path d="M17.5 2L3 9.5l4.5 3.5L17.5 6v12L7.5 12 3 15.5 17.5 22l3-1.5V3.5L17.5 2z" fill="#007acc"/>
<!-- VS Code official mark: folded-ribbon square — simpleicons.org/visualstudiocode -->
<svg viewBox="0 0 24 24" width="20" height="20" fill="#007acc">
<path d="M23.15 2.587 18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"/>
</svg>
</div>
<div>
@@ -2098,8 +2101,9 @@ footer {
<div class="int-card reveal"
style="--glow:#f59e0b;--glow-dim:rgba(245,158,11,.13);--glow-soft:rgba(245,158,11,.05);--glow-ambient:rgba(245,158,11,.04);--ico-bg:rgba(245,158,11,.1);--ico-bd:rgba(245,158,11,.2)">
<div class="int-ico">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
<path d="M12 3L5 19h4.5l2.5-5 2.5 5H19L12 3z" fill="#f59e0b"/>
<!-- Claude official mark — fetched from simple-icons/simple-icons/icons/claude.svg -->
<svg viewBox="0 0 24 24" width="20" height="20" fill="#f59e0b">
<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/>
</svg>
</div>
<div>
@@ -2116,9 +2120,13 @@ footer {
<div class="int-card reveal"
style="--glow:#3b82f6;--glow-dim:rgba(59,130,246,.14);--glow-soft:rgba(59,130,246,.06);--glow-ambient:rgba(59,130,246,.04);--ico-bg:#1e3a5f;--ico-bd:rgba(59,130,246,.3)">
<div class="int-ico" style="background:#1d4ed8;border-color:rgba(59,130,246,.4)">
<!-- TypeScript official mark: blue square with correct TS letterforms -->
<svg viewBox="0 0 18 18" width="18" height="18">
<rect width="18" height="18" rx="2" fill="#3178c6"/>
<path d="M3 10.5h4.5M5.25 8v5M10 10.5c.5-1.5 3.5-1.5 3.5.5s-3.5 1-3.5 2.5 3 2 3.5.5" stroke="white" stroke-width="1.4" stroke-linecap="round"/>
<!-- T: crossbar at TOP, stem descends from centre -->
<path d="M2 6.5h6M5 6.5v7" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none"/>
<!-- S: top curve right, mid narrows, bottom curve left -->
<path d="M10 13.5c.5.8 1.4 1.2 2.6 1.2 1.5 0 2.8-.8 2.8-2.2 0-1.1-.7-1.8-2.1-2.3-1.1-.4-1.8-.8-1.8-1.6 0-.7.6-1.3 1.7-1.3.9 0 1.6.3 2.1 1" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
@@ -2135,9 +2143,8 @@ footer {
<div class="int-card reveal"
style="--glow:#ffffff;--glow-dim:rgba(255,255,255,.07);--glow-soft:rgba(255,255,255,.03);--glow-ambient:rgba(255,255,255,.02);--ico-bg:rgba(255,255,255,.06);--ico-bd:rgba(255,255,255,.1)">
<div class="int-ico">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,.5)" stroke-width="1.2"/>
<path d="M8 8v8l8-8v8" stroke="rgba(255,255,255,.85)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
<path d="M5 4v16l14-16v16" stroke="rgba(255,255,255,.88)" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div>
@@ -0,0 +1,102 @@
// POST /api/agent-bridge
// JSON-RPC 2.0 endpoint consumed by the Cursor / Claude Code MCP adapters.
// Authentication: Bearer <workspace_token> (HMAC-SHA256, issued by issueWorkspaceToken).
import { NextRequest, NextResponse } from 'next/server';
import { verifyWorkspaceToken, TOOL_MAP } from '@originmain/agent-bridge';
import type { JsonRpcRequest, ToolContext } from '@originmain/agent-bridge';
import {
getDiffsByStatus,
getDiff,
getArtboard,
getActiveDesignLanguageFile,
updateDiffStatus,
} from '@originmain/origin-graph';
import { AIGateway, answerAgentQuestion } from '@originmain/ai-layer';
import { serverClient } from '@/lib/supabase';
export async function POST(req: NextRequest) {
// ── Auth ────────────────────────────────────────────────────────────────────
const authHeader = req.headers.get('authorization') ?? '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) {
return NextResponse.json(
{ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Missing Bearer token' } },
{ status: 401 },
);
}
const workspaceToken = verifyWorkspaceToken(token);
if (!workspaceToken) {
return NextResponse.json(
{ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid or expired token' } },
{ status: 401 },
);
}
// ── Parse JSON-RPC body ─────────────────────────────────────────────────────
let body: JsonRpcRequest;
try {
body = (await req.json()) as JsonRpcRequest;
} catch {
return NextResponse.json(
{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
{ status: 400 },
);
}
// tools/list — meta-endpoint; return available tools without executing one.
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 } });
}
// ── Dispatch ────────────────────────────────────────────────────────────────
const tool = TOOL_MAP.get(body.method);
if (!tool) {
return NextResponse.json({
jsonrpc: '2.0',
id: body.id,
error: { code: -32601, message: `Method not found: ${body.method}` },
});
}
const db = serverClient();
const { workspaceId } = workspaceToken;
const ctx: ToolContext = {
workspaceId,
db: {
getDiffsByStatus: (wsId, status) => getDiffsByStatus(db, wsId, status),
getDiff: (id) => getDiff(db, id),
getArtboard: (id) => getArtboard(db, id),
getDesignLanguageFile: (wsId) => getActiveDesignLanguageFile(db, wsId),
updateDiffStatus: (id, status, notes) =>
updateDiffStatus(db, id, status, notes).then(() => undefined),
},
ai: {
answerAgentQuestion: (diffId: string, question: string, artboardContext: unknown) =>
answerAgentQuestion(new AIGateway(), {
diffId,
question,
artboardContextJson: JSON.stringify(artboardContext),
}).then(r => r.answer),
},
};
try {
const result = await tool.execute(body.params ?? {}, ctx);
return NextResponse.json({ jsonrpc: '2.0', id: body.id, result });
} catch (err) {
const message = err instanceof Error ? err.message : 'Internal error';
return NextResponse.json({
jsonrpc: '2.0',
id: body.id,
error: { code: -32603, message },
});
}
}
@@ -0,0 +1,23 @@
// POST /api/ai/completion-zone
// Fills a design completion zone given a component tree and user intent.
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { AIGateway, fillCompletionZone } from '@originmain/ai-layer';
import type { CompletionZoneInput } from '@originmain/ai-layer';
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as CompletionZoneInput;
try {
const gateway = new AIGateway();
const result = await fillCompletionZone(gateway, body);
return NextResponse.json({ proposedTree: result.proposedTree, description: result.description });
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,23 @@
// POST /api/ai/drift-report
// Analyzes a screenshot against the active Design Language File for drift violations.
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { AIGateway, generateDriftReport } from '@originmain/ai-layer';
import type { DriftReportInput } from '@originmain/ai-layer';
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as DriftReportInput;
try {
const gateway = new AIGateway();
const result = await generateDriftReport(gateway, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,23 @@
// POST /api/ai/query
// Cross-artboard search: finds artboards matching a natural language query.
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { AIGateway, queryCrossArtboard } from '@originmain/ai-layer';
import type { ArtboardQueryInput } from '@originmain/ai-layer';
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as ArtboardQueryInput;
try {
const gateway = new AIGateway();
const result = await queryCrossArtboard(gateway, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,26 @@
// GET /api/artboards/:id → fetch single artboard
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getArtboard } from '@originmain/origin-graph';
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
try {
const db = serverClient();
const artboard = await getArtboard(db, id);
return NextResponse.json(artboard);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
const status = message.includes('not found') || message.includes('0 rows') ? 404 : 500;
return NextResponse.json({ error: message }, { status });
}
}
@@ -0,0 +1,41 @@
// GET /api/artboards?workspaceId=<uuid> → list artboards for workspace
// POST /api/artboards → create artboard
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getArtboards, createArtboard } from '@originmain/origin-graph';
import type { InsertArtboard } from '@originmain/origin-graph';
export async function GET(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
try {
const db = serverClient();
const artboards = await getArtboards(db, workspaceId);
return NextResponse.json(artboards);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as InsertArtboard;
try {
const db = serverClient();
const artboard = await createArtboard(db, body);
return NextResponse.json(artboard, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
// GET /api/design-language?workspaceId=<uuid> → active design language file
// POST /api/design-language → upsert (new version)
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
import type { InsertDesignLanguageFile } from '@originmain/origin-graph';
export async function GET(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
try {
const db = serverClient();
const file = await getActiveDesignLanguageFile(db, workspaceId);
if (!file) return NextResponse.json(null, { status: 204 });
return NextResponse.json(file);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as InsertDesignLanguageFile;
const db = serverClient();
// Compute the next version: max(existing) + 1.
const existing = await getActiveDesignLanguageFile(db, body.workspace_id);
const version = existing ? existing.version + 1 : 1;
const { data, error } = await db
.from('design_language_files')
.insert({ ...body, version })
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data, { status: 201 });
}
@@ -0,0 +1,48 @@
// GET /api/diffs/:id → fetch single diff
// PATCH /api/diffs/:id → update status + notes
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getDiff, updateDiffStatus } from '@originmain/origin-graph';
import type { DiffStatus } from '@originmain/origin-graph';
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
try {
const db = serverClient();
const diff = await getDiff(db, id);
return NextResponse.json(diff);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
const status = message.includes('0 rows') ? 404 : 500;
return NextResponse.json({ error: message }, { status });
}
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const body = (await req.json()) as { status: DiffStatus; notes?: string };
try {
const db = serverClient();
const updated = await updateDiffStatus(db, id, body.status, body.notes);
return NextResponse.json(updated);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
+42
View File
@@ -0,0 +1,42 @@
// GET /api/diffs?artboardId=<uuid> → list diffs for an artboard
// POST /api/diffs → create draft diff
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getDiffs, createDiff } from '@originmain/origin-graph';
import type { InsertIntentDiff } from '@originmain/origin-graph';
export async function GET(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const artboardId = req.nextUrl.searchParams.get('artboardId');
if (!artboardId) return NextResponse.json({ error: 'artboardId is required' }, { status: 400 });
try {
const db = serverClient();
const diffs = await getDiffs(db, artboardId);
return NextResponse.json(diffs);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as Omit<InsertIntentDiff, 'author_id'>;
const insert: InsertIntentDiff = { ...body, author_id: userId };
try {
const db = serverClient();
const diff = await createDiff(db, insert);
return NextResponse.json(diff, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,161 @@
// POST /api/webhooks/:provider
// Ingests webhook events from Linear, Slack, GitHub, and Intercom.
// workspaceId must be passed as a query param: ?workspace=<uuid>
// Signature verification is skipped gracefully when the secret env var is absent
// (useful for local development), but enforced in production.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import {
linearIngester,
slackIngester,
githubIngester,
intercomIngester,
} from '@originmain/integrations';
import type { OriginIngester } from '@originmain/integrations';
import { serverClient } from '@/lib/supabase';
import { createOrigin, createArtboard } from '@originmain/origin-graph';
// ── Signature helpers ─────────────────────────────────────────────────────────
function hmac(algo: 'sha256' | 'sha1', secret: string, body: Buffer): string {
return createHmac(algo, secret).update(body).digest('hex');
}
function safeEqual(a: string, b: string): boolean {
try {
const ab = Buffer.from(a.padEnd(b.length, '\0'));
const bb = Buffer.from(b.padEnd(a.length, '\0'));
return timingSafeEqual(ab, bb) && a.length === b.length;
} catch {
return false;
}
}
function verifyLinear(rawBody: Buffer, headers: Headers): boolean {
const secret = process.env.LINEAR_WEBHOOK_SECRET;
if (!secret) return true; // skip verification in dev
const sig = headers.get('x-linear-signature') ?? '';
return safeEqual(hmac('sha256', secret, rawBody), sig);
}
function verifySlack(rawBody: Buffer, headers: Headers): boolean {
const secret = process.env.SLACK_SIGNING_SECRET;
if (!secret) return true;
const ts = headers.get('x-slack-request-timestamp') ?? '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const baseString = `v0:${ts}:${rawBody.toString()}`;
const expected = `v0=${hmac('sha256', secret, Buffer.from(baseString))}`;
const provided = headers.get('x-slack-signature') ?? '';
return safeEqual(expected, provided);
}
function verifyGitHub(rawBody: Buffer, headers: Headers): boolean {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) return true;
const expected = `sha256=${hmac('sha256', secret, rawBody)}`;
const provided = headers.get('x-hub-signature-256') ?? '';
return safeEqual(expected, provided);
}
function verifyIntercom(rawBody: Buffer, headers: Headers): boolean {
const secret = process.env.INTERCOM_WEBHOOK_SECRET;
if (!secret) return true;
const expected = `sha1=${hmac('sha1', secret, rawBody)}`;
const provided = headers.get('x-hub-signature') ?? '';
return safeEqual(expected, provided);
}
// ── Route config ──────────────────────────────────────────────────────────────
const INGESTER_MAP: Record<
string,
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ingester: OriginIngester<any>;
verify: (body: Buffer, headers: Headers) => boolean;
}
> = {
linear: { ingester: linearIngester, verify: verifyLinear },
slack: { ingester: slackIngester, verify: verifySlack },
github: { ingester: githubIngester, verify: verifyGitHub },
intercom: { ingester: intercomIngester, verify: verifyIntercom },
};
// ── Handler ───────────────────────────────────────────────────────────────────
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> },
) {
const { provider } = await params;
const entry = INGESTER_MAP[provider];
if (!entry) {
return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 404 });
}
const workspaceId = req.nextUrl.searchParams.get('workspace');
if (!workspaceId) {
return NextResponse.json({ error: 'workspace query param is required' }, { status: 400 });
}
const rawBody = Buffer.from(await req.arrayBuffer());
if (!entry.verify(rawBody, req.headers)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody.toString('utf-8'));
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
// Slack sends a challenge request during webhook setup — respond immediately.
if (
provider === 'slack' &&
typeof parsed === 'object' &&
parsed !== null &&
'type' in parsed &&
(parsed as { type: string }).type === 'url_verification'
) {
return NextResponse.json({ challenge: (parsed as { challenge?: string }).challenge ?? '' });
}
let payload: unknown;
try {
payload = entry.ingester.parsePayload(parsed);
} catch (err) {
const message = err instanceof Error ? err.message : 'Payload validation failed';
return NextResponse.json({ error: message }, { status: 422 });
}
const result = entry.ingester.ingest(payload);
const db = serverClient();
try {
const origin = await createOrigin(db, result.origin);
const meta: Record<string, unknown> = {
x: 120,
y: 100,
width: 360,
height: 240,
};
if (result.renderUrl !== undefined) meta['renderUrl'] = result.renderUrl;
const artboard = await createArtboard(db, {
workspace_id: workspaceId,
name: result.artboardTitle,
origin_id: origin.id,
parent_artboard_id: null,
metadata_jsonb: meta,
});
return NextResponse.json({ originId: origin.id, artboardId: artboard.id }, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Database error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,66 @@
// GET /api/workspace
// Returns the authenticated user's workspace. Auto-creates one on first visit
// (FREE plan, name derived from Clerk user display name or email).
import { auth, currentUser } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import type { Workspace, InsertWorkspace } from '@originmain/origin-graph';
export async function GET() {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const db = serverClient();
// Look up an existing workspace owned by this Clerk user.
const { data: existing, error: findError } = await db
.from('workspaces')
.select('*')
.eq('owner_id', userId)
.limit(1)
.single();
if (findError && findError.code !== 'PGRST116') {
return NextResponse.json({ error: findError.message }, { status: 500 });
}
if (existing) {
return NextResponse.json(existing as Workspace);
}
// No workspace yet — auto-create one. Fetch the Clerk user for a friendly name.
const user = await currentUser();
const name =
user?.fullName ??
user?.emailAddresses[0]?.emailAddress ??
'My Workspace';
const insert: InsertWorkspace = {
owner_id: userId,
name,
plan: 'FREE',
settings_jsonb: {},
};
const { data: created, error: insertError } = await db
.from('workspaces')
.insert(insert)
.select()
.single();
if (insertError) {
return NextResponse.json({ error: insertError.message }, { status: 500 });
}
// Also add the owner as a team member.
await db.from('team_members').insert({
workspace_id: (created as Workspace).id,
user_id: userId,
role: 'OWNER',
});
return NextResponse.json(created as Workspace, { status: 201 });
}
+24 -4
View File
@@ -1,13 +1,33 @@
'use client';
import type { ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import { FluentProvider } from '@fluentui/react-components';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { originmainLightTheme } from '@originmain/ui';
export function Providers({ children }: { children: ReactNode }) {
// TanStack Query v5: create QueryClient inside useState so it's stable across
// re-renders and each client-side navigation gets the same instance. Creating
// it outside the component would cause it to be shared across requests on the
// server (SSR memory leak / cross-request data pollution).
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // treat data as fresh for 30 s
retry: 1, // one automatic retry on transient errors
refetchOnWindowFocus: false,
},
},
}),
);
return (
<FluentProvider theme={originmainLightTheme} style={{ height: '100%' }}>
{children}
</FluentProvider>
<QueryClientProvider client={queryClient}>
<FluentProvider theme={originmainLightTheme} style={{ height: '100%' }}>
{children}
</FluentProvider>
</QueryClientProvider>
);
}
@@ -1,6 +1,10 @@
'use client';
import { useState, useCallback } from 'react';
import { useCanvas } from '@/store/canvas';
import { LiveArtboard } from './LiveArtboard';
import { SelectionOverlay } from './SelectionOverlay';
import type { FiberNode } from '@originmain/renderer';
interface ArtboardProps {
id: string;
@@ -9,11 +13,19 @@ interface ArtboardProps {
y: number;
width: number;
height: number;
renderUrl?: string;
}
export function Artboard({ id, label, x, y, width, height }: ArtboardProps) {
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
const { selectedArtboardId, selectArtboard } = useCanvas();
const selected = selectedArtboardId === id;
const [fiberRoot, setFiberRoot] = useState<FiberNode | undefined>(undefined);
const handleFiberUpdate = useCallback((root: FiberNode) => setFiberRoot(root), []);
const handleComponentSelected = useCallback(
(nodeId: string) => { void nodeId; /* future: highlight in inspector */ },
[]
);
return (
<div
@@ -66,7 +78,26 @@ export function Artboard({ id, label, x, y, width, height }: ArtboardProps) {
)}
{/* Per-artboard content */}
<ArtboardContent id={id} />
{renderUrl ? (
<>
<LiveArtboard
id={id}
src={renderUrl}
width={width}
height={height}
onFiberTreeUpdate={handleFiberUpdate}
onComponentSelected={handleComponentSelected}
/>
<SelectionOverlay
artboardId={id}
{...(fiberRoot !== undefined ? { fiberRoot } : {})}
width={width}
height={height}
/>
</>
) : (
<ArtboardContent id={id} />
)}
</div>
</div>
);
@@ -3,15 +3,10 @@
import { useRef, useEffect, useCallback } from 'react';
import { useViewport } from '@/store/viewport';
import { useCanvas } from '@/store/canvas';
import { useWorkspace } from '@/hooks/useWorkspace';
import { useArtboards } from '@/hooks/useArtboards';
import { Artboard } from './Artboard';
const ARTBOARDS = [
{ id: 'dashboard-card', label: 'DashboardCard', x: 120, y: 100, width: 280, height: 200 },
{ id: 'user-profile', label: 'UserProfile', x: 460, y: 100, width: 200, height: 260 },
{ id: 'nav-sidebar', label: 'NavSidebar', x: 120, y: 360, width: 200, height: 340 },
{ id: 'data-table', label: 'DataTable', x: 380, y: 380, width: 420, height: 280 },
];
export function Canvas() {
const containerRef = useRef<HTMLDivElement>(null);
const panX = useViewport((s) => s.panX);
@@ -19,6 +14,9 @@ export function Canvas() {
const zoom = useViewport((s) => s.zoom);
const { activeTool, selectArtboard } = useCanvas();
const { data: workspace } = useWorkspace();
const { artboards } = useArtboards(workspace?.id);
const isPanning = useRef(false);
const lastPos = useRef({ x: 0, y: 0 });
const spaceDown = useRef(false);
@@ -124,7 +122,7 @@ export function Canvas() {
zIndex: 2,
}}
>
{ARTBOARDS.map((ab) => (
{artboards.map((ab) => (
<Artboard key={ab.id} {...ab} />
))}
</div>
@@ -1,11 +1,33 @@
'use client';
import { useEffect } from 'react';
import { Toolbar } from './Toolbar';
import { ArtboardNavigator } from '../navigator/ArtboardNavigator';
import { Canvas } from '../canvas/Canvas';
import { Inspector } from '../inspector/Inspector';
import { useHistory } from '@/store/history';
import { useCanvas } from '@/store/canvas';
export function AppChrome() {
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (!(e.metaKey || e.ctrlKey)) return;
if (!selectedArtboardId) return;
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
useHistory.getState().undo(selectedArtboardId);
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
e.preventDefault();
useHistory.getState().redo(selectedArtboardId);
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [selectedArtboardId]);
return (
<div
style={{
@@ -1,17 +1,12 @@
'use client';
import { useState } from 'react';
import { useFileTree, FileTree, useFileTreeSelection } from '@pierre/trees/react';
import { useFileTree, FileTree } from '@pierre/trees/react';
import { themeToTreeStyles } from '@pierre/trees';
import { SquareRegular } from '@fluentui/react-icons';
import { useCanvas } from '@/store/canvas';
const ARTBOARDS = [
{ id: 'dashboard-card', label: 'DashboardCard' },
{ id: 'user-profile', label: 'UserProfile' },
{ id: 'nav-sidebar', label: 'NavSidebar' },
{ id: 'data-table', label: 'DataTable' },
];
import { useWorkspace } from '@/hooks/useWorkspace';
import { useArtboards } from '@/hooks/useArtboards';
const FILE_PATHS = [
'src/components/DashboardCard.tsx',
@@ -54,6 +49,8 @@ const treeThemeStyles = themeToTreeStyles({
export function ArtboardNavigator() {
const { selectedArtboardId, selectArtboard } = useCanvas();
const { data: workspace } = useWorkspace();
const { artboards } = useArtboards(workspace?.id);
const { model } = useFileTree({
paths: FILE_PATHS,
@@ -78,7 +75,7 @@ export function ArtboardNavigator() {
{/* ── Artboards ── */}
<SectionLabel>Artboards</SectionLabel>
<div style={{ padding: '2px 6px 0' }}>
{ARTBOARDS.map((ab) => {
{artboards.map((ab) => {
const sel = selectedArtboardId === ab.id;
return (
<NavRow
+53
View File
@@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import type { Artboard } from '@originmain/origin-graph';
export interface CanvasArtboard {
id: string;
label: string;
x: number;
y: number;
width: number;
height: number;
renderUrl?: string;
}
const DEMO_ARTBOARDS: CanvasArtboard[] = [
{ id: 'dashboard-card', label: 'DashboardCard', x: 120, y: 100, width: 280, height: 200 },
{ id: 'user-profile', label: 'UserProfile', x: 460, y: 100, width: 200, height: 260 },
{ id: 'nav-sidebar', label: 'NavSidebar', x: 120, y: 360, width: 200, height: 340 },
{ id: 'data-table', label: 'DataTable', x: 380, y: 380, width: 420, height: 280 },
];
function toCanvasArtboard(ab: Artboard): CanvasArtboard | null {
const meta = ab.metadata_jsonb;
const x = typeof meta['x'] === 'number' ? meta['x'] : null;
const y = typeof meta['y'] === 'number' ? meta['y'] : null;
const width = typeof meta['width'] === 'number' ? meta['width'] : null;
const height = typeof meta['height'] === 'number' ? meta['height'] : null;
if (x === null || y === null || width === null || height === null) return null;
const base: CanvasArtboard = { id: ab.id, label: ab.name, x, y, width, height };
if (typeof meta['renderUrl'] === 'string') base.renderUrl = meta['renderUrl'];
return base;
}
async function fetchArtboards(workspaceId: string): Promise<CanvasArtboard[]> {
const res = await fetch(`/api/artboards?workspaceId=${encodeURIComponent(workspaceId)}`);
if (!res.ok) throw new Error(`Artboard fetch failed: ${res.status}`);
const rows = (await res.json()) as Artboard[];
return rows.map(toCanvasArtboard).filter((ab): ab is CanvasArtboard => ab !== null);
}
export function useArtboards(workspaceId: string | undefined) {
const query = useQuery({
queryKey: ['artboards', workspaceId],
queryFn: () => fetchArtboards(workspaceId!),
enabled: workspaceId !== undefined,
staleTime: 30_000,
});
// Show demo artboards while loading or when workspace has no artboards yet.
const artboards =
!query.data || query.data.length === 0 ? DEMO_ARTBOARDS : query.data;
return { artboards, isLoading: query.isLoading, error: query.error };
}
+17
View File
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import type { Workspace } from '@originmain/origin-graph';
async function fetchWorkspace(): Promise<Workspace> {
const res = await fetch('/api/workspace');
if (!res.ok) throw new Error(`Workspace fetch failed: ${res.status}`);
return res.json() as Promise<Workspace>;
}
export function useWorkspace() {
return useQuery({
queryKey: ['workspace'],
queryFn: fetchWorkspace,
staleTime: 5 * 60_000, // workspace data rarely changes
retry: 2,
});
}
+33
View File
@@ -0,0 +1,33 @@
// ── Supabase client helpers ───────────────────────────────────────────────────
// browserClient() — uses the anon key; safe to call on the client side.
// RLS policies apply (currently: deny all anon).
// serverClient() — uses the service-role key; ONLY call from Server
// Components or API route handlers. Never expose to the
// browser. Service role bypasses RLS entirely.
import { createClient } from '@supabase/supabase-js';
import type { DbClient } from '@originmain/origin-graph';
function requireEnv(name: string): string {
const val = process.env[name];
if (!val) throw new Error(`Missing required environment variable: ${name}`);
return val;
}
/** Browser-safe Supabase client (anon key, RLS enforced). */
export function browserClient(): DbClient {
return createClient(
requireEnv('NEXT_PUBLIC_SUPABASE_URL'),
requireEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY'),
) as unknown as DbClient;
}
/** Server-only Supabase client (service-role key, bypasses RLS). */
export function serverClient(): DbClient {
return createClient(
requireEnv('NEXT_PUBLIC_SUPABASE_URL'),
requireEnv('SUPABASE_SERVICE_ROLE_KEY'),
// Disable the auto-refresh token flow — this client never runs in a browser.
{ auth: { autoRefreshToken: false, persistSession: false } },
) as unknown as DbClient;
}
+12 -12
View File
@@ -62,12 +62,13 @@ export const useHistory = create<HistoryStore>((set, get) => ({
},
undo(artboardId) {
const stack = get().stacks[artboardId] ?? emptyStack();
const last = stack.past[stack.past.length - 1];
if (!last) return undefined;
// Read + write atomically inside set() to avoid TOCTOU between get() and set().
let undone: EditEntry | undefined;
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
const last = current.past[current.past.length - 1];
if (!last) return state; // nothing to undo — no-op
undone = last;
return {
stacks: {
...state.stacks,
@@ -78,17 +79,17 @@ export const useHistory = create<HistoryStore>((set, get) => ({
},
};
});
return last;
return undone;
},
redo(artboardId) {
const stack = get().stacks[artboardId] ?? emptyStack();
const next = stack.future[0];
if (!next) return undefined;
// Read + write atomically inside set() to avoid TOCTOU between get() and set().
let redone: EditEntry | undefined;
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
const next = current.future[0];
if (!next) return state; // nothing to redo — no-op
redone = next;
return {
stacks: {
...state.stacks,
@@ -99,8 +100,7 @@ export const useHistory = create<HistoryStore>((set, get) => ({
},
};
});
return next;
return redone;
},
canUndo(artboardId) {
File diff suppressed because one or more lines are too long
+14
View File
@@ -7,11 +7,13 @@
import type {
Artboard,
Workspace,
Origin,
IntentDiff,
DesignLanguageFile,
AgentSession,
TeamMember,
InsertArtboard,
InsertOrigin,
InsertIntentDiff,
InsertAgentSession,
DiffStatus,
@@ -166,6 +168,18 @@ export async function getActiveDesignLanguageFile(
return data;
}
// ── Origin queries ────────────────────────────────────────────────────────────
export async function createOrigin(db: DbClient, row: InsertOrigin): Promise<Origin> {
const { data, error } = await (db
.from('origins')
.insert(row)
.select()
.single() as Promise<{ data: Origin; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
// ── Agent session queries ─────────────────────────────────────────────────────
export async function createAgentSession(db: DbClient, row: InsertAgentSession): Promise<AgentSession> {