mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Fixes cross-host graph rendering and moves widget uploads off the JSON/base64 tool transport. - render graph data from the launcher result without a second tool call - stream multipart uploads through one-time, short-lived upload sessions - remove temporary widget diagnostics and redundant unit tests Tested with Biome, TypeScript, 17 unit tests, a Wrangler deployment dry-run, and live graph rendering in ChatGPT and Claude. Authenticated E2E setup is currently blocked by the saved OAuth refresh session returning `invalid_grant: session not found`.
458 lines
13 KiB
TypeScript
458 lines
13 KiB
TypeScript
import { type ReactNode, useEffect, useState } from "react"
|
|
import { WidgetShell } from "../App"
|
|
import { SpaceCard } from "../components/SpaceCard"
|
|
import {
|
|
ActionGroup,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Chip,
|
|
Field,
|
|
FileUpload,
|
|
Input,
|
|
PageHeader,
|
|
Stack,
|
|
TextArea,
|
|
SpaceSelect,
|
|
} from "../design/ui"
|
|
import { Confirmation } from "../views/Confirmation"
|
|
import { ErrorView } from "../views/Error"
|
|
import { Graph } from "../views/Graph"
|
|
import { Loading } from "../views/Loading"
|
|
import { Picker } from "../views/Picker"
|
|
import { Save } from "../views/Save"
|
|
import { Success } from "../views/Success"
|
|
import { Upload } from "../views/Upload"
|
|
import {
|
|
mockAssignedTags,
|
|
mockContainerTags,
|
|
mockDocuments,
|
|
mockWritableTags,
|
|
} from "./mocks"
|
|
|
|
type Theme = "light" | "dark"
|
|
type Width = "narrow" | "wide" | "cursor"
|
|
|
|
const NARROW = 420
|
|
const WIDE = 720
|
|
const CURSOR = 1120
|
|
|
|
// A no-op handler set for views. In the Studio there is no MCP host, so
|
|
// widget-initiated tool calls would reject; that's fine — we only review
|
|
// layout/spacing here, and onAdvance/onError surface what *would* happen.
|
|
function useStubHandlers(label: string) {
|
|
const [event, setEvent] = useState<string | null>(null)
|
|
return {
|
|
event,
|
|
onAdvance: (msg: { view: string }) =>
|
|
setEvent(`${label}: onAdvance → ${msg.view}`),
|
|
onError: (m: string) => setEvent(`${label}: onError → ${m}`),
|
|
}
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
description,
|
|
children,
|
|
}: {
|
|
title: string
|
|
description?: string
|
|
children: ReactNode
|
|
}) {
|
|
return (
|
|
<section className="flex flex-col gap-(--space-4)">
|
|
<div className="flex flex-col gap-(--space-1)">
|
|
<h2
|
|
className="text-(length:--text-lg) font-semibold text-text-primary"
|
|
style={{ fontFamily: "var(--font-brand)" }}
|
|
>
|
|
{title}
|
|
</h2>
|
|
{description ? (
|
|
<p className="text-(length:--text-sm) text-text-secondary">
|
|
{description}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
{children}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// A device-style frame that mimics the widget iframe so spacing reads true.
|
|
function Frame({
|
|
label,
|
|
width,
|
|
children,
|
|
}: {
|
|
label: string
|
|
width: number
|
|
children: ReactNode
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-(--space-2)">
|
|
<span className="text-(length:--text-xs) font-mono text-text-muted">
|
|
{label}
|
|
</span>
|
|
<div
|
|
className="overflow-hidden rounded-(--radius-lg) border border-border bg-bg-primary shadow-md"
|
|
style={{ width }}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Swatch({ name, varName }: { name: string; varName: string }) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<div
|
|
className="h-12 rounded-(--radius-md) border border-border"
|
|
style={{ background: `var(${varName})` }}
|
|
/>
|
|
<span className="text-(length:--text-xs) text-text-secondary">
|
|
{name}
|
|
</span>
|
|
<span className="text-[10px] font-mono text-text-muted">{varName}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Studio() {
|
|
const [theme, setTheme] = useState<Theme>("dark")
|
|
const [width, setWidth] = useState<Width>("narrow")
|
|
const frameWidth =
|
|
width === "narrow" ? NARROW : width === "wide" ? WIDE : CURSOR
|
|
|
|
const applyTheme = (t: Theme) => {
|
|
setTheme(t)
|
|
document.documentElement.setAttribute("data-theme", t)
|
|
}
|
|
|
|
// Keep the document theme in sync so the gallery matches the toggle,
|
|
// including on first paint.
|
|
useEffect(() => {
|
|
document.documentElement.setAttribute("data-theme", theme)
|
|
}, [theme])
|
|
|
|
const saveStub = useStubHandlers("Save")
|
|
const uploadStub = useStubHandlers("Upload")
|
|
const pickerStub = useStubHandlers("Picker")
|
|
const [selectValue, setSelectValue] = useState<string | null>(
|
|
"sm_project_marketing",
|
|
)
|
|
|
|
return (
|
|
<div className="min-h-screen bg-bg-secondary text-text-primary">
|
|
{/* Toolbar */}
|
|
<div className="sticky top-0 z-50 flex items-center justify-between gap-(--space-4) border-b border-border bg-bg-elevated px-(--space-6) py-(--space-3)">
|
|
<span
|
|
className="text-(length:--text-base) font-semibold"
|
|
style={{ fontFamily: "var(--font-brand)" }}
|
|
>
|
|
Supermemory MCP · Studio
|
|
</span>
|
|
<div className="flex items-center gap-(--space-4)">
|
|
<div className="flex items-center gap-(--space-2)">
|
|
<span className="text-(length:--text-xs) text-text-muted">
|
|
Theme
|
|
</span>
|
|
<Chip
|
|
onClick={() => applyTheme("light")}
|
|
selected={theme === "light"}
|
|
>
|
|
Light
|
|
</Chip>
|
|
<Chip
|
|
onClick={() => applyTheme("dark")}
|
|
selected={theme === "dark"}
|
|
>
|
|
Dark
|
|
</Chip>
|
|
</div>
|
|
<div className="flex items-center gap-(--space-2)">
|
|
<span className="text-(length:--text-xs) text-text-muted">
|
|
Width
|
|
</span>
|
|
<Chip
|
|
onClick={() => setWidth("narrow")}
|
|
selected={width === "narrow"}
|
|
>
|
|
Narrow {NARROW}
|
|
</Chip>
|
|
<Chip onClick={() => setWidth("wide")} selected={width === "wide"}>
|
|
Wide {WIDE}
|
|
</Chip>
|
|
<Chip
|
|
onClick={() => setWidth("cursor")}
|
|
selected={width === "cursor"}
|
|
>
|
|
Cursor {CURSOR}
|
|
</Chip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mx-auto flex max-w-5xl flex-col-reverse gap-(--space-12) px-(--space-6) py-(--space-8)">
|
|
{/* ── Primitives ───────────────────────────────────────── */}
|
|
<Section
|
|
description="Atoms from design/ui — fully interactive, no host needed."
|
|
title="Primitives"
|
|
>
|
|
<Card>
|
|
<Stack gap="lg">
|
|
<div className="flex flex-col gap-(--space-3)">
|
|
<span className="text-(length:--text-xs) uppercase tracking-wide text-text-muted">
|
|
Button variants
|
|
</span>
|
|
<div className="flex flex-wrap items-center gap-(--space-3)">
|
|
<Button variant="primary">Primary</Button>
|
|
<Button variant="secondary">Secondary</Button>
|
|
<Button variant="ghost">Ghost</Button>
|
|
<Button variant="danger">Danger</Button>
|
|
<Button loading variant="primary">
|
|
Loading
|
|
</Button>
|
|
<Button disabled variant="primary">
|
|
Disabled
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-(--space-3)">
|
|
<span className="text-(length:--text-xs) uppercase tracking-wide text-text-muted">
|
|
Badges
|
|
</span>
|
|
<div className="flex flex-wrap items-center gap-(--space-3)">
|
|
<Badge variant="neutral">neutral</Badge>
|
|
<Badge variant="accent">accent</Badge>
|
|
<Badge variant="success">success</Badge>
|
|
<Badge variant="error">error</Badge>
|
|
<Badge variant="warning">warning</Badge>
|
|
<Badge variant="info">info</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-(--space-3)">
|
|
<span className="text-(length:--text-xs) uppercase tracking-wide text-text-muted">
|
|
Chips
|
|
</span>
|
|
<div className="flex flex-wrap items-center gap-(--space-2)">
|
|
<Chip selected>Selected</Chip>
|
|
<Chip>Unselected</Chip>
|
|
<Chip>Marketing</Chip>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-(--space-4) sm:grid-cols-2">
|
|
<Field hint="Single-line input primitive" label="Input">
|
|
<Input placeholder="Type something…" />
|
|
</Field>
|
|
<Field error="This field has an error" label="Input (error)">
|
|
<Input aria-invalid placeholder="Invalid…" />
|
|
</Field>
|
|
</div>
|
|
|
|
<Field label="TextArea">
|
|
<TextArea className="min-h-24" placeholder="Multi-line text…" />
|
|
</Field>
|
|
</Stack>
|
|
</Card>
|
|
</Section>
|
|
|
|
{/* ── Tokens ───────────────────────────────────────────── */}
|
|
<Section
|
|
description="Color tokens resolve from tokens.css. Toggle the theme to compare."
|
|
title="Color tokens"
|
|
>
|
|
<Card>
|
|
<div className="grid grid-cols-2 gap-(--space-4) sm:grid-cols-4 md:grid-cols-6">
|
|
<Swatch name="Accent" varName="--accent" />
|
|
<Swatch name="BG primary" varName="--bg-primary" />
|
|
<Swatch name="BG secondary" varName="--bg-secondary" />
|
|
<Swatch name="BG muted" varName="--bg-muted" />
|
|
<Swatch name="BG elevated" varName="--bg-elevated" />
|
|
<Swatch name="Border" varName="--border" />
|
|
<Swatch name="Success" varName="--success" />
|
|
<Swatch name="Error" varName="--error" />
|
|
<Swatch name="Warning" varName="--warning" />
|
|
<Swatch name="Info" varName="--info" />
|
|
<Swatch name="Danger" varName="--danger" />
|
|
<Swatch name="Accent muted" varName="--accent-muted" />
|
|
</div>
|
|
</Card>
|
|
</Section>
|
|
|
|
{/* ── Composite components ─────────────────────────────── */}
|
|
<Section title="Composite components">
|
|
<div className="flex flex-wrap gap-(--space-4)">
|
|
<div className="w-64">
|
|
<SpaceCard
|
|
access={mockAssignedTags[0]}
|
|
active={false}
|
|
containerTag={mockContainerTags[0]}
|
|
onClick={() => {}}
|
|
/>
|
|
</div>
|
|
<div className="w-64">
|
|
<SpaceCard
|
|
access={mockAssignedTags[1]}
|
|
active
|
|
containerTag={mockContainerTags[1]}
|
|
onClick={() => {}}
|
|
/>
|
|
</div>
|
|
<div className="w-64">
|
|
<SpaceCard
|
|
active={false}
|
|
containerTag={mockContainerTags[3]}
|
|
onClick={() => {}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Card className="max-w-md">
|
|
<PageHeader
|
|
actions={
|
|
<ActionGroup>
|
|
<Button size="sm">Action</Button>
|
|
</ActionGroup>
|
|
}
|
|
description="PageHeader with description and an action."
|
|
title="Page Header"
|
|
/>
|
|
</Card>
|
|
<div className="max-w-md">
|
|
<FileUpload
|
|
accept=".txt,.pdf"
|
|
description="Supports TXT, PDF, PNG, JPG, MP4"
|
|
onFile={() => {}}
|
|
/>
|
|
</div>
|
|
<Card className="max-w-sm">
|
|
<Field
|
|
hint="Searchable Popover — scales to hundreds of spaces."
|
|
label="Space select"
|
|
>
|
|
<SpaceSelect
|
|
onValueChange={setSelectValue}
|
|
options={mockContainerTags.map((t) => ({
|
|
value: t.containerTag,
|
|
label: t.name,
|
|
description: t.containerTag,
|
|
}))}
|
|
value={selectValue}
|
|
/>
|
|
</Field>
|
|
</Card>
|
|
</Section>
|
|
|
|
{/* ── Views ────────────────────────────────────────────── */}
|
|
<Section
|
|
description="The same widget shell Cursor renders, with mock MCP tool data."
|
|
title="Cursor widgets"
|
|
>
|
|
<div className="flex flex-wrap gap-(--space-8)">
|
|
<Frame label="Picker" width={frameWidth}>
|
|
<WidgetShell>
|
|
<Picker
|
|
activeTag="sm_project_marketing"
|
|
assignedTags={mockAssignedTags}
|
|
containerTags={mockContainerTags}
|
|
onAdvance={pickerStub.onAdvance}
|
|
onError={pickerStub.onError}
|
|
/>
|
|
</WidgetShell>
|
|
</Frame>
|
|
|
|
<Frame label="Save" width={frameWidth}>
|
|
<WidgetShell>
|
|
<Save
|
|
activeTag="sm_project_marketing"
|
|
onAdvance={saveStub.onAdvance}
|
|
onError={saveStub.onError}
|
|
prefill="The Q3 launch will target fintech enterprise buyers."
|
|
writableTags={mockWritableTags}
|
|
/>
|
|
</WidgetShell>
|
|
</Frame>
|
|
|
|
<Frame label="Upload" width={frameWidth}>
|
|
<WidgetShell>
|
|
<Upload
|
|
activeTag="sm_project_marketing"
|
|
onAdvance={uploadStub.onAdvance}
|
|
onError={uploadStub.onError}
|
|
writableTags={mockWritableTags}
|
|
/>
|
|
</WidgetShell>
|
|
</Frame>
|
|
|
|
<Frame label="Confirmation" width={frameWidth}>
|
|
<WidgetShell>
|
|
<Confirmation containerTag="sm_project_marketing" />
|
|
</WidgetShell>
|
|
</Frame>
|
|
|
|
<Frame label="Success (save)" width={frameWidth}>
|
|
<WidgetShell>
|
|
<Success containerTag="sm_project_marketing" kind="save" />
|
|
</WidgetShell>
|
|
</Frame>
|
|
|
|
<Frame label="Success (upload)" width={frameWidth}>
|
|
<WidgetShell>
|
|
<Success
|
|
containerTag="sm_project_marketing"
|
|
fileName="q3-brief.pdf"
|
|
kind="upload"
|
|
/>
|
|
</WidgetShell>
|
|
</Frame>
|
|
|
|
<Frame label="Error" width={frameWidth}>
|
|
<WidgetShell>
|
|
<ErrorView message="No write access to container tag 'sm_project_eng_rfcs'." />
|
|
</WidgetShell>
|
|
</Frame>
|
|
|
|
<Frame label="Loading" width={frameWidth}>
|
|
<WidgetShell>
|
|
<Loading />
|
|
</WidgetShell>
|
|
</Frame>
|
|
|
|
<Frame label="Graph (@supermemory/memory-graph)" width={frameWidth}>
|
|
<WidgetShell immersive>
|
|
<Graph
|
|
documents={mockDocuments}
|
|
totalCount={mockDocuments.length}
|
|
/>
|
|
</WidgetShell>
|
|
</Frame>
|
|
</div>
|
|
|
|
{(saveStub.event || uploadStub.event || pickerStub.event) && (
|
|
<Card>
|
|
<Stack gap="xs">
|
|
<span className="text-(length:--text-xs) uppercase tracking-wide text-text-muted">
|
|
Last event
|
|
</span>
|
|
{[pickerStub.event, saveStub.event, uploadStub.event]
|
|
.filter(Boolean)
|
|
.map((e) => (
|
|
<span
|
|
className="text-(length:--text-sm) font-mono text-text-secondary"
|
|
key={e}
|
|
>
|
|
{e}
|
|
</span>
|
|
))}
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
</Section>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|