Compare commits

...

9 Commits

Author SHA1 Message Date
Lambda
199942ee1e feat(web): refine newhome landing demos
Co-authored-by: multica-agent <github@multica.ai>
2026-06-07 19:21:05 +08:00
Lambda
1f7447ad10 feat(web): rebuild value #2 delegate demo as the real delegation flow
Replace the static conversation with a scripted, looping flow that plays the actual delegation loop: compose a new issue and pick Claude Code from the Assignee dropdown -> create (assigned) -> the issue page with the agent working (reads/edits + spinner) -> the agent posts its result as a comment and the status moves to In Review. Adds newhome-fade / newhome-pop scene+popover entrances.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 16:19:29 +08:00
Lambda
10123ee41c feat(web): add values #2-#4 with alternating left/right layout
Build the remaining three value cards, each with its own focused auto-playing demo (same approach as value #1): #2 Delegation — a delegation conversation (person @mentions an agent, it works, then replies with a PR); #3 Accountability — a streaming run transcript (thinking/reads/edits/tests); #4 Leverage — a skills library with a highlight cycling across agents. Cards alternate sides (text/demo -> demo/text -> ...) via a ValueCard reverse prop. The content-light demos share a scale frame (ValueDemoFrame) at the same DEMO_ZOOM and are sized to fit the demo half without bleeding.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:56:28 +08:00
Lambda
c7bdf30d07 style(web): give controls their own radius (8px), keep surfaces at 6px
Radius is role-based, not one global value. Restore an 8px control radius (the design system's --radius-md) on buttons, nav links, the GitHub chip, and the demo browser tabs; keep surfaces (cards, demo windows, panel rows) at the restrained 6px. Flattening buttons to the container radius made them read boxy and off-brand versus the real product, whose Button uses rounded-lg.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:35:59 +08:00
Lambda
3c928abfeb style(web): tighten landing border radii to one restrained 6px tier
The value card's 24px corner was too large. Unify all landing-authored chrome (value card, demo window frames, buttons, nav links, chips, browser tabs, panel rows) to a single small 6px radius. The embedded real-product component radii (board columns/cards) are left as-is so the live demo still matches the actual product.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:30:55 +08:00
Lambda
d51d6628cd feat(web): make value section a self-contained bordered card
Drop the big section heading and the hero->features divider/background tint; each value is now a rounded, tinted, bordered card that supplies its own framing. The card's overflow-hidden border is the boundary that clips the demo, so it bleeds to the card edge instead of past the browser edge. Keep the value title on one line (lg:whitespace-nowrap).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:26:44 +08:00
Lambda
064cbf80ff feat(web): side-by-side value layout (text left, demo bleeds right)
Replace the stacked text-above-board value layout with a two-column row: a compact claim on the left (vertically centered) and the live demo on the right at its real shared-zoom size, bleeding off the right page edge (section uses overflow-x-clip so there's no horizontal scrollbar). Matches the requested reference layout.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:15:49 +08:00
Lambda
6cfe42a4f2 feat(web): add /newhome landing V2 sandbox with interactive demos
Sandbox landing at /newhome (production / untouched): hero with an embedded, interactive product demo (real board/issue-detail/create-issue/transcripts over mock data) and a Values section whose first pillar is an auto-playing board built from the real product components. All embedded demos render at one shared DEMO_ZOOM, and popups portal into the scaled box so they share the zoom.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 03:17:43 +08:00
Lambda
6a5150bb01 feat(ui): add opt-in PortalContainerProvider for portaled popups
Popups (DropdownMenu, Popover, Dialog, HoverCard, Tooltip) portal to document.body by default. Add a PortalContainerProvider/usePortalContainer context (default undefined -> body, so production behavior is unchanged) and wire each Portal to it, so an embedded surface inside a CSS transform can redirect popups into its own scaled box.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 03:17:43 +08:00
30 changed files with 2793 additions and 33 deletions

View File

@@ -1,4 +1,4 @@
import { Instrument_Serif, Noto_Serif_SC } from "next/font/google";
import { Instrument_Serif, Noto_Serif_SC, Caveat } from "next/font/google";
import { LocaleProvider } from "@/features/landing/i18n";
import { getRequestLocale } from "@/lib/request-locale";
@@ -14,6 +14,15 @@ const notoSerifSC = Noto_Serif_SC({
variable: "--font-serif-zh",
});
// Handwritten face for the newhome demo's "this is live, try it" hint. Kept in
// this server layout (not the client newhome component) — next/font in a client
// component fails to resolve @swc/helpers under pnpm.
const caveat = Caveat({
subsets: ["latin"],
weight: "600",
variable: "--font-hand",
});
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
@@ -52,7 +61,7 @@ export default async function LandingLayout({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} landing-light h-full overflow-x-hidden overflow-y-auto bg-white`}>
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} ${caveat.variable} landing-light h-full overflow-x-hidden overflow-y-auto bg-white`}>
<LocaleProvider initialLocale={initialLocale}>{children}</LocaleProvider>
</div>
</>

View File

@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { NewHomeLanding } from "@/features/landing/newhome/newhome-landing";
export const metadata: Metadata = {
title: "Multica — Landing V2 (sandbox)",
description: "Work-in-progress rebuild of the Multica landing page.",
robots: { index: false, follow: false },
alternates: {
canonical: "/newhome",
},
};
export default function NewHomePage() {
return <NewHomeLanding />;
}

View File

@@ -43,3 +43,205 @@
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;
}
/* newhome value #1 board demo: a card softly "lands" when it advances a
* column, so the auto-play reads as movement rather than a teleport. */
@keyframes newhome-card-land {
from {
opacity: 0;
transform: translateY(-8px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
.newhome-card-land {
animation: newhome-card-land 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
.newhome-card-land {
animation: none;
}
}
/* "Agent is working" typing dots (value #2 delegate demo). */
@keyframes newhome-typing {
0%,
60%,
100% {
opacity: 0.25;
}
30% {
opacity: 0.9;
}
}
.newhome-typing > span {
animation: newhome-typing 1.1s ease-in-out infinite;
}
.newhome-typing > span:nth-child(2) {
animation-delay: 0.18s;
}
.newhome-typing > span:nth-child(3) {
animation-delay: 0.36s;
}
@media (prefers-reduced-motion: reduce) {
.newhome-typing > span {
animation: none;
}
}
/* Scene / popover entrances for the scripted delegate demo (value #2). */
@keyframes newhome-fade {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: none;
}
}
.newhome-fade {
animation: newhome-fade 0.4s ease-out;
}
@keyframes newhome-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(-3px);
}
to {
opacity: 1;
transform: none;
}
}
.newhome-pop {
transform-origin: top left;
animation: newhome-pop 0.18s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.newhome-fade,
.newhome-pop {
animation: none;
}
}
/* newhome "this is live, try it" hint: the arrow draws itself in once, then the
* whole annotation gently floats to draw the eye. Respects reduced-motion. */
@keyframes newhome-hint-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-7px);
}
}
.newhome-hint {
animation: newhome-hint-bob 2.8s ease-in-out infinite;
}
@keyframes newhome-hint-draw {
from {
stroke-dashoffset: 90;
}
to {
stroke-dashoffset: 0;
}
}
.newhome-hint-arrow path {
stroke-dasharray: 90;
animation: newhome-hint-draw 0.9s ease-out 0.25s both;
}
@media (prefers-reduced-motion: reduce) {
.newhome-hint {
animation: none;
}
.newhome-hint-arrow path {
animation: none;
stroke-dashoffset: 0;
}
}
/* newhome demo: darken --brand so the selected "working" chip (white text on
* bg-brand) has readable contrast. Scoped to the embedded product demo only. */
.landing-demo {
--brand: oklch(0.46 0.17 256);
}
/* Hide every scrollbar inside the embedded product demo — on a marketing page
* native scrollbars read as jarring. Scroll/drag still works; only the bar is
* hidden. Scoped to the demo so the rest of the site keeps its scrollbars. */
.landing-demo,
.landing-demo * {
scrollbar-width: none;
-ms-overflow-style: none;
}
.landing-demo::-webkit-scrollbar,
.landing-demo *::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
/* Suppress the board's "Hidden columns" side panel in the demo. We hide the
* backlog/blocked columns via the status allowlist, which would otherwise list
* them in this panel (w-[240px] is unique to that panel — columns use an inline
* width). Keeps the demo board to a clean four columns. */
.landing-demo [class~="w-[240px]"] {
display: none;
}
/* Popups (menus, dialogs, hover cards, tooltips) portal INTO the scaled demo
* box (see PortalContainerProvider) so they inherit the demo's zoom instead of
* rendering at 1:1 over the page. Full-screen modals like the transcript are
* sized in viewport units (100vh/100vw), which overflow the small demo window;
* cap them to the window (their fixed containing block is the transformed box)
* so the header/footer stay visible. Auto-height modals stay smaller than the
* cap, so they are unaffected.
*
* Scoped into @layer utilities so this wins the cascade against Tailwind's own
* `!`-important sizing utilities (e.g. the transcript's !h-[calc(100vh-4rem)]):
* same layer + higher specificity. Height is the only axis that overflows the
* window; width already fits, so leave each modal's own max-width intact. */
@layer utilities {
.landing-demo [data-slot="dialog-content"] {
max-height: calc(100% - 1.5rem) !important;
}
}
/* newhome (Landing V2 sandbox) — auto-scrolling agent marquee. Pure CSS, no
* motion library: the track holds two identical groups and slides left by one
* group width for a seamless loop. The wrapper is overflow-hidden, so there is
* no horizontal scrollbar. Pauses on hover; respects reduced-motion. */
@keyframes newhome-marquee {
to {
transform: translateX(-50%);
}
}
.newhome-marquee {
-webkit-mask-image: linear-gradient(
to right,
transparent,
#000 6%,
#000 94%,
transparent
);
mask-image: linear-gradient(
to right,
transparent,
#000 6%,
#000 94%,
transparent
);
}
.newhome-marquee-track {
animation: newhome-marquee 45s linear infinite;
}
.newhome-marquee:hover .newhome-marquee-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.newhome-marquee-track {
animation: none;
}
}

View File

@@ -8,10 +8,48 @@ import { captureDownloadIntent } from "@multica/core/analytics";
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
import { useLocale, locales, localeLabels } from "../i18n";
export type LandingFooterGroup = {
label: string;
links: Array<{
label: string;
href: string;
}>;
};
export function LandingFooter() {
const { t, locale, setLocale } = useLocale();
const user = useAuthStore((s) => s.user);
const groups = Object.values(t.footer.groups);
return (
<LandingFooterContent
ctaHref={user ? "/" : "/login"}
ctaLabel={user ? t.header.dashboard : t.footer.cta}
locale={locale}
setLocale={setLocale}
/>
);
}
export function LandingFooterContent({
brandHref = "#product",
ctaHref,
ctaLabel,
groups,
locale,
setLocale,
}: {
brandHref?: string;
ctaHref: string;
ctaLabel?: string;
groups?: LandingFooterGroup[];
locale?: ReturnType<typeof useLocale>["locale"];
setLocale?: ReturnType<typeof useLocale>["setLocale"];
}) {
const localeContext = useLocale();
const activeLocale = locale ?? localeContext.locale;
const setActiveLocale = setLocale ?? localeContext.setLocale;
const footerGroups = groups ?? Object.values(localeContext.t.footer.groups);
const footerCtaLabel = ctaLabel ?? localeContext.t.footer.cta;
return (
<footer className="bg-[#0a0d12] text-white">
@@ -20,14 +58,14 @@ export function LandingFooter() {
<div className="flex flex-col gap-12 border-b border-white/10 py-16 sm:py-20 lg:flex-row lg:gap-20">
{/* Left — newsletter / CTA */}
<div className="lg:w-[340px] lg:shrink-0">
<Link href="#product" className="flex items-center gap-3">
<Link href={brandHref} className="flex items-center gap-3">
<MulticaIcon className="size-5 text-white" noSpin />
<span className="text-[18px] font-semibold tracking-[0.04em] lowercase">
multica
</span>
</Link>
<p className="mt-4 max-w-[300px] text-[14px] leading-[1.7] text-white/50 sm:text-[15px]">
{t.footer.tagline}
{localeContext.t.footer.tagline}
</p>
<div className="mt-4 flex items-center gap-3">
<Link
@@ -49,17 +87,17 @@ export function LandingFooter() {
</div>
<div className="mt-6">
<Link
href={user ? "/" : "/login"}
href={ctaHref}
className="inline-flex items-center justify-center rounded-[11px] bg-white px-5 py-2.5 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-white/88"
>
{user ? t.header.dashboard : t.footer.cta}
{footerCtaLabel}
</Link>
</div>
</div>
{/* Right — link columns */}
<div className="grid flex-1 grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => (
{footerGroups.map((group) => (
<div key={group.label}>
<h4 className="text-[12px] font-semibold uppercase tracking-[0.1em] text-white/40">
{group.label}
@@ -92,7 +130,7 @@ export function LandingFooter() {
{/* Bottom: copyright + language switcher */}
<div className="flex items-center justify-between py-6">
<p className="text-[13px] text-white/36">
{t.footer.copyright.replace(
{localeContext.t.footer.copyright.replace(
"{year}",
String(new Date().getFullYear()),
)}
@@ -102,10 +140,10 @@ export function LandingFooter() {
<button
type="button"
key={l}
onClick={() => setLocale(l)}
onClick={() => setActiveLocale(l)}
className={cn(
"px-1.5 py-1 text-[12px] font-medium transition-colors",
l === locale
l === activeLocale
? "text-white/70"
: "text-white/30 hover:text-white/50",
i > 0 && "border-l border-white/16",

View File

@@ -0,0 +1,190 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, LayoutList, Bot, Sparkles } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { PortalContainerProvider } from "@multica/ui/lib/portal-container";
import { setApiInstance } from "@multica/core/api";
import { I18nProvider } from "@multica/core/i18n/react";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { useIssueViewStore } from "@multica/core/issues/stores/view-store";
import { RESOURCES } from "@multica/views/locales";
import {
NavigationProvider,
type NavigationAdapter,
} from "@multica/views/navigation";
import { IssuesPage, IssueDetail } from "@multica/views/issues/components";
import { ModalRegistry } from "@multica/views/modals/registry";
import { createMockApi } from "./mock-api";
import { WORKSPACE } from "./mock-data";
import { DemoErrorBoundary } from "./demo-error-boundary";
import { AgentsPanel, SkillsPanel } from "./demo-panels";
// Install the mock client globally (the @multica/core/api singleton). This
// module is only ever imported client-side via a dynamic ssr:false import,
// and only on the landing page, so overriding the singleton here is safe.
setApiInstance(createMockApi());
const ISSUE_PATH = /\/issues\/([^/?#]+)/;
const TABS = [
{ id: "issues", label: "Issues", Icon: LayoutList },
{ id: "agents", label: "Agents", Icon: Bot },
{ id: "skills", label: "Skills", Icon: Sparkles },
] as const;
type TabId = (typeof TABS)[number]["id"];
export function DemoBoard() {
const [tab, setTab] = useState<TabId>("issues");
const [detailId, setDetailId] = useState<string | null>(null);
// Keep the latest setter in a ref so the navigation adapter is stable.
const setDetailRef = useRef(setDetailId);
setDetailRef.current = setDetailId;
// Portal mount for popups (menus, dialogs, hover cards, tooltips). It lives
// inside the scaled demo box, so every portaled popup inherits the same zoom
// instead of rendering at 1:1 over the page.
const portalRef = useRef<HTMLDivElement>(null);
const queryClient = useMemo(() => {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 },
mutations: { retry: false },
},
});
qc.setQueryData(workspaceListOptions().queryKey, [WORKSPACE]);
return qc;
}, []);
// No status filter — show every column (backlog … blocked). Reset on mount in
// case a previous session persisted a filter.
useEffect(() => {
useIssueViewStore.setState({ statusFilters: [] });
}, []);
const adapter = useMemo<NavigationAdapter>(() => {
const openFromPath = (path: string) => {
const m = path.match(ISSUE_PATH);
if (m?.[1]) setDetailRef.current(decodeURIComponent(m[1]));
};
return {
push: openFromPath,
replace: openFromPath,
back: () => setDetailRef.current(null),
pathname: "/demo/issues",
searchParams: new URLSearchParams(),
getShareableUrl: (p) => p,
};
}, []);
const resources = useMemo(() => ({ en: RESOURCES.en }), []);
return (
<DemoErrorBoundary>
<QueryClientProvider client={queryClient}>
<I18nProvider locale="en" resources={resources}>
<NavigationProvider value={adapter}>
<WorkspaceSlugProvider slug="demo">
<PortalContainerProvider container={portalRef}>
{/* `landing-demo` darkens --brand so the selected "working" chip
stays readable (white-on-brand). */}
<div className="landing-demo flex h-full w-full flex-col bg-background text-foreground">
<BrowserBar
tab={tab}
onTab={(t) => {
setTab(t);
setDetailId(null);
}}
/>
<div className="min-h-0 flex-1">
{tab === "issues" ? (
detailId ? (
<div className="flex h-full flex-col">
<button
type="button"
onClick={() => setDetailId(null)}
className="flex shrink-0 items-center gap-1.5 px-4 py-2.5 text-[13px] font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" aria-hidden />
Back to board
</button>
<div className="min-h-0 flex-1 overflow-auto [scrollbar-width:thin]">
<IssueDetail
issueId={detailId}
onDone={() => setDetailId(null)}
onDelete={() => setDetailId(null)}
/>
</div>
</div>
) : (
// Hide IssuesPage's own PageHeader — the browser tabs
// above already serve as the app header.
<div className="flex h-full w-full flex-col [&>div>div:first-child]:hidden">
<IssuesPage />
</div>
)
) : tab === "agents" ? (
<AgentsPanel />
) : (
<SkillsPanel />
)}
</div>
{/* Popups (menus, dialogs, hover cards, tooltips) portal here
via PortalContainerProvider, so they share the demo's zoom
instead of rendering at 1:1 over the page. */}
<div ref={portalRef} />
</div>
{/* Real create-issue dialog host — opened by the board's "+"
buttons via the global modal store. Portals into the scaled
box (see PortalContainerProvider) so it matches the demo zoom. */}
<DemoErrorBoundary fallback={null}>
<ModalRegistry />
</DemoErrorBoundary>
</PortalContainerProvider>
</WorkspaceSlugProvider>
</NavigationProvider>
</I18nProvider>
</QueryClientProvider>
</DemoErrorBoundary>
);
}
function BrowserBar({
tab,
onTab,
}: {
tab: TabId;
onTab: (t: TabId) => void;
}) {
return (
<div className="flex h-11 shrink-0 items-center gap-3 border-b border-[#0a0d12]/8 bg-[#f7f8fa] px-3.5">
<div className="flex shrink-0 items-center gap-1.5">
<span className="size-2.5 rounded-full bg-[#0a0d12]/12" />
<span className="size-2.5 rounded-full bg-[#0a0d12]/12" />
<span className="size-2.5 rounded-full bg-[#0a0d12]/12" />
</div>
<div className="flex items-center gap-0.5">
{TABS.map(({ id, label, Icon }) => (
<button
key={id}
type="button"
onClick={() => onTab(id)}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-[8px] px-2.5 text-[13px] font-medium transition-colors",
tab === id
? "bg-white text-[#0a0d12] shadow-[0_1px_2px_rgba(10,13,18,0.08)] ring-1 ring-[#0a0d12]/8"
: "text-[#0a0d12]/55 hover:bg-[#0a0d12]/[0.04] hover:text-[#0a0d12]/80",
)}
>
<Icon className="size-3.5" aria-hidden />
{label}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { Component, type ReactNode } from "react";
import { RotateCcw } from "lucide-react";
interface Props {
children: ReactNode;
// When provided, render this on error instead of the default reset panel.
// Pass `null` to fail silently (used for the portaled modal host).
fallback?: ReactNode;
}
interface State {
hasError: boolean;
}
/**
* Isolates the live product demo. If any interaction throws during render,
* we show a small reset panel instead of letting the error bubble up and
* white-screen the whole landing page. "Reset" remounts the demo subtree.
*/
export class DemoErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch() {
// Swallow — the demo is non-critical marketing UI. Nothing to report.
}
private reset = () => this.setState({ hasError: false });
render() {
if (this.state.hasError) {
if ("fallback" in this.props) return this.props.fallback ?? null;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-white text-center">
<p className="text-[14px] text-[#0a0d12]/55">
The demo hit a snag.
</p>
<button
type="button"
onClick={this.reset}
className="inline-flex items-center gap-1.5 rounded-[8px] border border-[#0a0d12]/14 bg-white px-3.5 py-2 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-[#0a0d12]/[0.04]"
>
<RotateCcw className="size-3.5" aria-hidden />
Reset demo
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useRef } from "react";
import { IssueDetail } from "@multica/views/issues/components";
export function DemoIssueDetail({
issueId,
initialScrollTop = 0,
}: {
issueId: string;
initialScrollTop?: number;
}) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (initialScrollTop <= 0) return;
let cancelled = false;
const applyScroll = () => {
if (cancelled) return;
const root = rootRef.current;
if (!root) return;
const scrollables = Array.from(root.querySelectorAll<HTMLElement>("div"))
.filter((el) => el.scrollHeight - el.clientHeight > 80)
.sort(
(a, b) =>
b.scrollHeight - b.clientHeight - (a.scrollHeight - a.clientHeight),
);
const target =
scrollables.find((el) => {
const className =
typeof el.className === "string" ? el.className : "";
return (
className.includes("relative") &&
className.includes("flex-1") &&
className.includes("overflow-y-auto")
);
}) ?? scrollables[0];
if (!target) return;
const maxScroll = Math.max(0, target.scrollHeight - target.clientHeight);
target.scrollTop = Math.min(initialScrollTop, maxScroll);
};
const frame = window.requestAnimationFrame(applyScroll);
const timers = [
window.setTimeout(applyScroll, 250),
window.setTimeout(applyScroll, 800),
];
return () => {
cancelled = true;
window.cancelAnimationFrame(frame);
timers.forEach((timer) => window.clearTimeout(timer));
};
}, [initialScrollTop, issueId]);
return (
<div ref={rootRef} className="h-full overflow-auto [scrollbar-width:thin]">
<IssueDetail issueId={issueId} onDone={() => {}} onDelete={() => {}} />
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
// Lightweight panels for the Agents / Skills tabs in the demo browser. These
// are bespoke presentational views over the same mock data — not the heavy
// product pages — so the tabs feel real without extra coupling or risk.
import { AGENTS, ISSUES, RUNNING_TASKS, SKILLS } from "./mock-data";
export function AgentsPanel() {
return (
<div className="h-full overflow-auto px-5 py-5 [scrollbar-width:thin]">
<div className="mx-auto grid max-w-[760px] grid-cols-1 gap-2.5 sm:grid-cols-2">
{AGENTS.map((a) => {
const task = RUNNING_TASKS.find((t) => t.agent_id === a.id);
const issue = task && ISSUES.find((i) => i.id === task.issue_id);
return (
<div
key={a.id}
className="flex items-center gap-3 rounded-[6px] border border-[#0a0d12]/8 bg-white px-3.5 py-3"
>
{a.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={a.avatar_url} alt="" className="size-8 shrink-0 rounded-full" />
) : (
<span className="size-8 shrink-0 rounded-full bg-[#0a0d12]/10" />
)}
<div className="min-w-0">
<div className="truncate text-[14px] font-semibold text-[#0a0d12]">
{a.name}
</div>
{issue ? (
<div className="flex items-center gap-1.5 truncate text-[12.5px] text-[#0a0d12]/55">
<span className="inline-block size-1.5 shrink-0 rounded-full bg-emerald-500" />
Working on {issue.identifier}
</div>
) : (
<div className="text-[12.5px] text-[#0a0d12]/45">Idle</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
export function SkillsPanel() {
return (
<div className="h-full overflow-auto px-5 py-5 [scrollbar-width:thin]">
<div className="mx-auto grid max-w-[760px] grid-cols-1 gap-2.5 sm:grid-cols-2">
{SKILLS.map((s) => (
<div
key={s.name}
className="rounded-[6px] border border-[#0a0d12]/8 bg-white px-3.5 py-3"
>
<div className="text-[14px] font-semibold text-[#0a0d12]">{s.name}</div>
<div className="mt-0.5 text-[12.5px] leading-5 text-[#0a0d12]/55">
{s.description}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { useEffect, useMemo, useRef, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
BookOpen,
Bot,
LayoutList,
Server,
Sparkles,
UsersRound,
} from "lucide-react";
import { setApiInstance } from "@multica/core/api";
import { I18nProvider } from "@multica/core/i18n/react";
import { useIssueViewStore } from "@multica/core/issues/stores/view-store";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { agentTaskSnapshotKeys } from "@multica/core/agents";
import { runtimeKeys } from "@multica/core/runtimes";
import {
workspaceKeys,
workspaceListOptions,
} from "@multica/core/workspace/queries";
import { PortalContainerProvider } from "@multica/ui/lib/portal-container";
import { cn } from "@multica/ui/lib/utils";
import { RESOURCES } from "@multica/views/locales";
import {
NavigationProvider,
type NavigationAdapter,
} from "@multica/views/navigation";
import { ModalRegistry } from "@multica/views/modals/registry";
import { createMockApi } from "./mock-api";
import {
AGENTS,
MEMBERS,
RUNTIMES,
RUNNING_TASKS,
SKILLS,
SQUADS,
WORKSPACE,
} from "./mock-data";
import { DemoErrorBoundary } from "./demo-error-boundary";
setApiInstance(createMockApi());
export type DemoProductTab = "issues" | "runtimes" | "agents" | "squads" | "skills";
const PRODUCT_TABS = [
{ id: "issues", label: "Issues", Icon: LayoutList },
{ id: "runtimes", label: "Runtimes", Icon: Server },
{ id: "agents", label: "Agents", Icon: Bot },
{ id: "squads", label: "Squads", Icon: UsersRound },
{ id: "skills", label: "Skills", Icon: BookOpen },
] as const;
export function DemoProductFrame({
activeTab,
pathname,
children,
className,
}: {
activeTab: DemoProductTab;
pathname: string;
children: ReactNode;
className?: string;
}) {
const portalRef = useRef<HTMLDivElement>(null);
const queryClient = useMemo(() => {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 },
mutations: { retry: false },
},
});
seedDemoQueryData(qc);
return qc;
}, []);
const resources = useMemo(() => ({ en: RESOURCES.en }), []);
useEffect(() => {
useIssueViewStore.setState({ statusFilters: [] });
}, []);
const adapter = useMemo<NavigationAdapter>(
() => ({
push: () => {},
replace: () => {},
back: () => {},
pathname,
searchParams: new URLSearchParams(),
getShareableUrl: (p) => p,
}),
[pathname],
);
return (
<DemoErrorBoundary>
<QueryClientProvider client={queryClient}>
<I18nProvider locale="en" resources={resources}>
<NavigationProvider value={adapter}>
<WorkspaceSlugProvider slug="demo">
<PortalContainerProvider container={portalRef}>
<div
className={cn(
"landing-demo flex h-full w-full flex-col bg-background text-foreground",
className,
)}
>
<DemoBrowserBar activeTab={activeTab} />
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
<div ref={portalRef} />
</div>
<DemoErrorBoundary fallback={null}>
<ModalRegistry />
</DemoErrorBoundary>
</PortalContainerProvider>
</WorkspaceSlugProvider>
</NavigationProvider>
</I18nProvider>
</QueryClientProvider>
</DemoErrorBoundary>
);
}
export function DemoBrowserBar({ activeTab }: { activeTab: DemoProductTab }) {
return (
<div className="flex h-11 shrink-0 items-center gap-3 border-b border-[#0a0d12]/8 bg-[#f7f8fa] px-3.5">
<div className="flex shrink-0 items-center gap-1.5">
<span className="size-2.5 rounded-full bg-[#0a0d12]/12" />
<span className="size-2.5 rounded-full bg-[#0a0d12]/12" />
<span className="size-2.5 rounded-full bg-[#0a0d12]/12" />
</div>
<div className="flex min-w-0 items-center gap-0.5 overflow-hidden">
{PRODUCT_TABS.map(({ id, label, Icon }) => (
<span
key={id}
className={cn(
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] px-2.5 text-[13px] font-medium transition-colors",
activeTab === id
? "bg-white text-[#0a0d12] shadow-[0_1px_2px_rgba(10,13,18,0.08)] ring-1 ring-[#0a0d12]/8"
: "text-[#0a0d12]/55",
)}
>
<Icon className="size-3.5" aria-hidden />
{label}
</span>
))}
</div>
<Sparkles
className="ml-auto hidden size-3.5 shrink-0 text-[#0a0d12]/30 sm:block"
aria-hidden
/>
</div>
);
}
function seedDemoQueryData(qc: QueryClient) {
const wsId = WORKSPACE.id;
qc.setQueryData(workspaceListOptions().queryKey, [WORKSPACE]);
qc.setQueryData(workspaceKeys.members(wsId), MEMBERS);
qc.setQueryData(workspaceKeys.agents(wsId), AGENTS);
qc.setQueryData(workspaceKeys.squads(wsId), SQUADS);
qc.setQueryData(workspaceKeys.skills(wsId), SKILLS);
qc.setQueryData(runtimeKeys.list(wsId), RUNTIMES);
qc.setQueryData(agentTaskSnapshotKeys.list(wsId), RUNNING_TASKS);
}

View File

@@ -0,0 +1,171 @@
// A stand-in ApiClient for the landing-page product demo. Returns canned,
// server-shaped responses from mock-data so the real product components run
// with zero backend. Installed via setApiInstance() (the global injection
// seam in @multica/core/api). Any method not implemented here resolves to
// `undefined` via the Proxy fallback, so unanticipated calls never throw.
import type { ApiClient } from "@multica/core/api";
import {
AGENTS,
EXEC_LOG,
ISSUES,
MEMBERS,
PULL_REQUESTS,
RUNTIME_USAGE,
RUNTIME_USAGE_BY_AGENT,
RUNTIME_USAGE_BY_HOUR,
RUNTIMES,
RUNNING_TASKS,
SKILLS,
SQUAD_MEMBER_STATUS,
SQUAD_MEMBERS,
SQUADS,
TIMELINE,
TRANSCRIPT_BY_ISSUE,
WORKSPACE,
createMockIssue,
patchIssue,
} from "./mock-data";
// Every task (snapshot + per-issue execution log), so a transcript request for
// any task id can resolve which issue (and thus which transcript) it belongs to.
const ALL_TASKS = [...RUNNING_TASKS, ...Object.values(EXEC_LOG).flat()];
type AnyParams = Record<string, unknown> | undefined;
const handlers: Record<string, (...args: any[]) => Promise<unknown>> = {
// Keep the demo logged-out: the landing's auth init must not think a user
// is signed in (that would redirect away from the marketing page).
getMe: () => Promise.reject(new Error("demo: unauthenticated")),
getBaseUrl: () => "" as unknown as Promise<unknown>,
listWorkspaces: () => Promise.resolve([WORKSPACE]),
getWorkspace: () => Promise.resolve(WORKSPACE),
listMembers: () => Promise.resolve(MEMBERS),
listAgents: () => Promise.resolve(AGENTS),
listSquads: () => Promise.resolve(SQUADS),
getSquad: (id: string) =>
Promise.resolve(SQUADS.find((s) => s.id === id) ?? SQUADS[0]),
listSquadMembers: (id: string) => Promise.resolve(SQUAD_MEMBERS[id] ?? []),
getSquadMemberStatus: (id: string) =>
Promise.resolve(SQUAD_MEMBER_STATUS[id] ?? { members: [] }),
listRuntimes: () => Promise.resolve(RUNTIMES),
getRuntimeUsage: (runtimeId: string) =>
Promise.resolve(RUNTIME_USAGE.filter((row) => row.runtime_id === runtimeId)),
getRuntimeUsageByAgent: (runtimeId: string) => {
const agentIds = new Set(
AGENTS.filter((agent) => agent.runtime_id === runtimeId).map((agent) => agent.id),
);
return Promise.resolve(
RUNTIME_USAGE_BY_AGENT.filter((row) => agentIds.has(row.agent_id)),
);
},
getRuntimeUsageByHour: () => Promise.resolve(RUNTIME_USAGE_BY_HOUR),
getWorkspaceAgentRunCounts: () =>
Promise.resolve(
AGENTS.map((agent, index) => ({
agent_id: agent.id,
run_count: 12 + index * 4,
})),
),
getWorkspaceAgentActivity30d: () =>
Promise.resolve(
AGENTS.flatMap((agent, agentIndex) =>
Array.from({ length: 7 }, (_, i) => ({
agent_id: agent.id,
bucket_at: new Date(Date.now() - (6 - i) * 24 * 60 * 60 * 1000).toISOString(),
task_count: 1 + ((i + agentIndex) % 4),
failed_count: i === 1 && agentIndex === 1 ? 1 : 0,
})),
),
),
getAgent: (id: string) =>
Promise.resolve(AGENTS.find((a) => a.id === id) ?? AGENTS[0]),
// Agent transcript (live run log) — resolve the task's issue, return its
// transcript with seq / task_id / issue_id stamped on each message.
listTaskMessages: (taskId: string) => {
const task = ALL_TASKS.find((t) => t.id === taskId);
const tmpl = task ? TRANSCRIPT_BY_ISSUE[task.issue_id] : undefined;
if (!task || !tmpl) return Promise.resolve([]);
return Promise.resolve(
tmpl.map((m, i) => ({
...m,
seq: i,
task_id: task.id,
issue_id: task.issue_id,
})),
);
},
listSkills: () => Promise.resolve(SKILLS),
getSkill: (id: string) =>
Promise.resolve(SKILLS.find((skill) => skill.id === id) ?? SKILLS[0]),
getAssigneeFrequency: () => Promise.resolve([]),
listIssues: (params: AnyParams) => {
const status = params?.status as string | undefined;
const issues = status ? ISSUES.filter((i) => i.status === status) : [...ISSUES];
return Promise.resolve({ issues, total: issues.length });
},
listGroupedIssues: () => Promise.resolve({ groups: [] }),
getIssue: (id: string) => {
const issue = ISSUES.find((i) => i.id === id);
return issue
? Promise.resolve(issue)
: Promise.reject(new Error("demo: issue not found"));
},
getChildIssueProgress: () => Promise.resolve({ progress: [] }),
listChildIssues: () => Promise.resolve({ issues: [] }),
listChildrenByParents: () => Promise.resolve({ issues: [] }),
listTimeline: (issueId: string) => Promise.resolve(TIMELINE[issueId] ?? []),
listComments: () => Promise.resolve([]),
listIssueSubscribers: () => Promise.resolve([]),
listAttachments: () => Promise.resolve([]),
getIssueUsage: () =>
Promise.resolve({ total_tokens: 0, total_cost_usd: 0, runs: 0 }),
listProjects: () => Promise.resolve({ projects: [] }),
listLabels: () => Promise.resolve({ labels: [] }),
listLabelsForIssue: () => Promise.resolve({ labels: [] }),
listIssuePullRequests: (issueId: string) =>
Promise.resolve({ pull_requests: PULL_REQUESTS[issueId] ?? [] }),
listTasksByIssue: (issueId: string) =>
Promise.resolve(EXEC_LOG[issueId] ?? []),
listAgentTasks: (agentId: string) =>
Promise.resolve(ALL_TASKS.filter((task) => task.agent_id === agentId)),
getAgentTaskSnapshot: () => Promise.resolve(RUNNING_TASKS),
getActiveTasksForIssue: (issueId: string) =>
Promise.resolve({
tasks: RUNNING_TASKS.filter((t) => t.issue_id === issueId),
}),
// Writes: mutate the in-memory issue so drag-to-change-status persists
// across the refetch that react-query fires after a mutation settles.
updateIssue: (id: string, data: AnyParams) => {
const updated = patchIssue(id, (data ?? {}) as Record<string, never>);
return Promise.resolve(updated ?? ISSUES.find((i) => i.id === id));
},
// Create-issue flow: build a card from the dialog input and add it.
createIssue: (data: AnyParams) =>
Promise.resolve(createMockIssue((data ?? {}) as { title: string })),
quickCreateIssue: (data: AnyParams) => {
const d = (data ?? {}) as { prompt?: string; agent_id?: string };
createMockIssue({
title: (d.prompt || "New agent task").slice(0, 80),
status: "todo",
assignee_type: d.agent_id ? "agent" : undefined,
assignee_id: d.agent_id,
});
return Promise.resolve({ task_id: "task-new" });
},
};
export function createMockApi(): ApiClient {
const target = {} as Record<string, unknown>;
return new Proxy(target, {
get(_t, prop: string) {
if (prop in handlers) return handlers[prop];
// Unknown method → resolve to undefined so no call ever throws.
return () => Promise.resolve(undefined);
},
}) as unknown as ApiClient;
}

View File

@@ -0,0 +1,796 @@
// Mock data for the interactive product demo embedded in the landing hero.
// All ids/shapes follow the real backend contracts so the real product
// components render unchanged. Issues are a MUTABLE module array so demo
// interactions (drag to change status) persist across refetches.
import type {
Agent,
AgentRuntime,
AgentTask,
RuntimeUsage,
RuntimeUsageByAgent,
RuntimeUsageByHour,
Skill,
} from "@multica/core/types/agent";
import type { TaskMessagePayload } from "@multica/core/types/events";
import type { TimelineEntry } from "@multica/core/types/activity";
import type { GitHubPullRequest } from "@multica/core/types/github";
import type { Issue, IssueStatus, IssuePriority } from "@multica/core/types/issue";
import type { MemberWithUser, Workspace } from "@multica/core/types/workspace";
import type {
Squad,
SquadMember,
SquadMemberStatusListResponse,
} from "@multica/core/types/squad";
const NOW = "2026-06-01T09:00:00Z";
export const WORKSPACE = {
id: "ws-demo",
name: "Acme",
slug: "demo",
created_at: NOW,
updated_at: NOW,
} as unknown as Workspace;
export const MEMBERS: MemberWithUser[] = [
{
id: "m-alex",
workspace_id: "ws-demo",
user_id: "u-alex",
role: "admin",
created_at: NOW,
name: "Alex Rivera",
email: "alex@acme.dev",
avatar_url: null,
},
{
id: "m-sam",
workspace_id: "ws-demo",
user_id: "u-sam",
role: "member",
created_at: NOW,
name: "Sam Chen",
email: "sam@acme.dev",
avatar_url: null,
},
] as unknown as MemberWithUser[];
// Each agent carries its provider mark as a data-URI avatar so the real
// ActorAvatar renders the right icon on cards / detail / the working chip.
const svgUri = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
const CLAUDE_SVG = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='-1 -1 18 18'><rect x='-1' y='-1' width='18' height='18' fill='#F4EBE5'/><path fill='#D97757' d='m3.127 10.604 3.135-1.76.053-.153-.053-.085H6.11l-.525-.032-1.791-.048-1.554-.065-1.505-.08-.38-.081L0 7.832l.036-.234.32-.214.455.04 1.009.069 1.513.105 1.097.064 1.626.17h.259l.036-.105-.089-.065-.068-.064-1.566-1.062-1.695-1.121-.887-.646-.48-.327-.243-.306-.104-.67.435-.48.585.04.15.04.593.456 1.267.981 1.654 1.218.242.202.097-.068.012-.049-.109-.181-.9-1.626-.96-1.655-.428-.686-.113-.411a2 2 0 0 1-.068-.484l.496-.674L4.446 0l.662.089.279.242.411.94.666 1.48 1.033 2.014.302.597.162.553.06.17h.105v-.097l.085-1.134.157-1.392.154-1.792.052-.504.25-.605.497-.327.387.186.319.456-.045.294-.19 1.23-.37 1.93-.243 1.29h.142l.161-.16.654-.868 1.097-1.372.484-.545.565-.601.363-.287h.686l.505.751-.226.775-.707.895-.585.759-.839 1.13-.524.904.048.072.125-.012 1.897-.403 1.024-.186 1.223-.21.553.258.06.263-.218.536-1.307.323-1.533.307-2.284.54-.028.02.032.04 1.029.098.44.024h1.077l2.005.15.525.346.315.424-.053.323-.807.411-3.631-.863-.872-.218h-.12v.073l.726.71 1.331 1.202 1.667 1.55.084.383-.214.302-.226-.032-1.464-1.101-.565-.497-1.28-1.077h-.084v.113l.295.432 1.557 2.34.08.718-.112.234-.404.141-.444-.08-.911-1.28-.94-1.44-.759-1.291-.093.053-.448 4.821-.21.246-.484.186-.403-.307-.214-.496.214-.98.258-1.28.21-1.016.19-1.263.112-.42-.008-.028-.092.012-.953 1.307-1.448 1.957-1.146 1.227-.274.109-.477-.247.045-.44.266-.39 1.586-2.018.956-1.25.617-.723-.004-.105h-.036l-4.212 2.736-.75.096-.324-.302.04-.496.154-.162 1.267-.871z'/></svg>`;
const CODEX_SVG = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='-1 -1 18 18'><rect x='-1' y='-1' width='18' height='18' fill='#111827'/><path fill='#ffffff' d='M14.949 6.547a3.94 3.94 0 0 0-.348-3.273 4.11 4.11 0 0 0-4.4-1.934A4.1 4.1 0 0 0 8.423.2 4.15 4.15 0 0 0 6.305.086a4.1 4.1 0 0 0-1.891.948 4.04 4.04 0 0 0-1.158 1.753 4.1 4.1 0 0 0-1.563.679A4 4 0 0 0 .554 4.72a3.99 3.99 0 0 0 .502 4.731 3.94 3.94 0 0 0 .346 3.274 4.11 4.11 0 0 0 4.402 1.933c.382.425.852.764 1.377.995.526.231 1.095.35 1.67.346 1.78.002 3.358-1.132 3.901-2.804a4.1 4.1 0 0 0 1.563-.68 4 4 0 0 0 1.14-1.253 3.99 3.99 0 0 0-.506-4.716m-6.097 8.406a3.05 3.05 0 0 1-1.945-.694l.096-.054 3.23-1.838a.53.53 0 0 0 .265-.455v-4.49l1.366.778q.02.011.025.035v3.722c-.003 1.653-1.361 2.992-3.037 2.996m-6.53-2.75a2.95 2.95 0 0 1-.36-2.01l.095.057L5.29 12.09a.53.53 0 0 0 .527 0l3.949-2.246v1.555a.05.05 0 0 1-.022.041L6.473 13.3c-1.454.826-3.311.335-4.15-1.098m-.85-6.94A3.02 3.02 0 0 1 3.07 3.949v3.785a.51.51 0 0 0 .262.451l3.93 2.237-1.366.779a.05.05 0 0 1-.048 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872v.024Zm11.216 2.571L8.747 5.576l1.362-.776a.05.05 0 0 1 .048 0l3.265 1.86a3 3 0 0 1 1.173 1.207 2.96 2.96 0 0 1-.27 3.2 3.05 3.05 0 0 1-1.36.997V8.279a.52.52 0 0 0-.276-.445m1.36-2.015-.097-.057-3.226-1.855a.53.53 0 0 0-.53 0L6.249 6.153V4.598a.04.04 0 0 1 .019-.04L9.533 2.7a3.07 3.07 0 0 1 3.257.139c.474.325.843.778 1.066 1.303.223.526.289 1.103.191 1.664zM5.503 8.575 4.139 7.8a.05.05 0 0 1-.026-.037V4.049c0-.57.166-1.127.476-1.607s.752-.864 1.275-1.105a3.08 3.08 0 0 1 3.234.41l-.096.054-3.23 1.838a.53.53 0 0 0-.265.455zm.742-1.577 1.758-1 1.762 1v2l-1.755 1-1.762-1z'/></svg>`;
const GEMINI_SVG = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='-3 -3 30 30'><rect x='-3' y='-3' width='30' height='30' fill='#EFEBF6'/><path fill='#8E75B2' d='M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81'/></svg>`;
const KIMI_SVG = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' fill='#1F1147'/><path fill='#ffffff' d='M7.2 6h2.4v5.1l4.3-5.1h2.9l-4.4 5.1L17 18h-2.9l-3.2-5.2-1.3 1.5V18H7.2V6z'/></svg>`;
export const RUNTIMES: AgentRuntime[] = [
{
id: "rt-local-claude",
workspace_id: "ws-demo",
daemon_id: "daemon-local",
name: "Claude Code (Jiayuan MacBook Pro)",
runtime_mode: "local",
provider: "claude",
launch_header: "claude",
status: "online",
device_info: "Jiayuan MacBook Pro · multica 0.3.14 (Claude Code)",
metadata: { cli_version: "v0.3.14", launched_by: "Desktop" },
owner_id: "u-alex",
visibility: "private",
last_seen_at: new Date().toISOString(),
created_at: NOW,
updated_at: NOW,
},
{
id: "rt-local-codex",
workspace_id: "ws-demo",
daemon_id: "daemon-local",
name: "Codex (Jiayuan MacBook Pro)",
runtime_mode: "local",
provider: "codex",
launch_header: "codex",
status: "online",
device_info: "Jiayuan MacBook Pro · multica 0.3.14 (Codex)",
metadata: { cli_version: "v0.3.14", launched_by: "Desktop" },
owner_id: "u-alex",
visibility: "private",
last_seen_at: new Date().toISOString(),
created_at: NOW,
updated_at: NOW,
},
{
id: "rt-mini-gemini",
workspace_id: "ws-demo",
daemon_id: "daemon-office-mini",
name: "Gemini CLI (Office Mac mini)",
runtime_mode: "local",
provider: "gemini",
launch_header: "gemini",
status: "online",
device_info: "Office Mac mini · multica 0.3.13 (Gemini CLI)",
metadata: { cli_version: "v0.3.13", launched_by: "daemon" },
owner_id: "u-sam",
visibility: "public",
last_seen_at: new Date().toISOString(),
created_at: NOW,
updated_at: NOW,
},
{
id: "rt-cloud-kimi",
workspace_id: "ws-demo",
daemon_id: null,
name: "Kimi Cloud Runtime",
runtime_mode: "cloud",
provider: "kimi",
launch_header: "kimi",
status: "online",
device_info: "us-east-1 · autoscaled cloud node",
metadata: {},
owner_id: "u-alex",
visibility: "public",
last_seen_at: new Date().toISOString(),
created_at: NOW,
updated_at: NOW,
},
] as unknown as AgentRuntime[];
function runtimeIdForAgent(agentId: string): string {
switch (agentId) {
case "a-claude":
return "rt-local-claude";
case "a-codex":
return "rt-local-codex";
case "a-gemini":
return "rt-mini-gemini";
case "a-kimi":
return "rt-cloud-kimi";
default:
return "rt-local-claude";
}
}
export const AGENTS: Agent[] = [
{ id: "a-claude", name: "Claude Code", avatar_url: svgUri(CLAUDE_SVG) },
{ id: "a-codex", name: "Codex", avatar_url: svgUri(CODEX_SVG) },
{ id: "a-gemini", name: "Gemini CLI", avatar_url: svgUri(GEMINI_SVG) },
{ id: "a-kimi", name: "Kimi", avatar_url: svgUri(KIMI_SVG) },
].map(
(a) =>
({
...a,
workspace_id: "ws-demo",
description: "Autonomous coding agent — assign it an issue and it runs.",
instructions: "",
// Fields read by the agent hover-card (AgentProfileCard). Without these
// (esp. `skills`) hovering an agent throws.
skills: [],
runtime_id: runtimeIdForAgent(a.id),
runtime_mode: RUNTIMES.find((r) => r.id === runtimeIdForAgent(a.id))?.runtime_mode ?? "local",
runtime_config: {},
custom_args: [],
owner_id: "u-alex",
owner_type: "member",
visibility: "workspace",
archived_at: null,
created_at: NOW,
updated_at: NOW,
}) as unknown as Agent,
);
export const SQUADS: Squad[] = [
{
id: "squad-api",
workspace_id: "ws-demo",
name: "API Squad",
description: "Coordinates API hardening work across implementation, tests, docs, and review.",
instructions:
"Follow the API hardening playbook: inspect the affected route, split implementation and verification, request a human checkpoint when behavior changes, then open a PR with docs and tests.",
avatar_url: null,
leader_id: "a-claude",
creator_id: "u-alex",
created_at: NOW,
updated_at: NOW,
archived_at: null,
archived_by: null,
member_count: 4,
member_preview: [
{ member_type: "agent", member_id: "a-claude", role: "Lead / planner" },
{ member_type: "agent", member_id: "a-codex", role: "Implementation" },
{ member_type: "agent", member_id: "a-gemini", role: "Tests and docs" },
{ member_type: "member", member_id: "u-alex", role: "Human checkpoint" },
],
},
] as unknown as Squad[];
export const SQUAD_MEMBERS: Record<string, SquadMember[]> = {
"squad-api": [
{
id: "sm-api-claude",
squad_id: "squad-api",
member_type: "agent",
member_id: "a-claude",
role: "Lead / planner",
created_at: NOW,
},
{
id: "sm-api-codex",
squad_id: "squad-api",
member_type: "agent",
member_id: "a-codex",
role: "Implementation",
created_at: NOW,
},
{
id: "sm-api-gemini",
squad_id: "squad-api",
member_type: "agent",
member_id: "a-gemini",
role: "Tests and docs",
created_at: NOW,
},
{
id: "sm-api-alex",
squad_id: "squad-api",
member_type: "member",
member_id: "u-alex",
role: "Human checkpoint",
created_at: NOW,
},
] as unknown as SquadMember[],
};
export const SQUAD_MEMBER_STATUS: Record<string, SquadMemberStatusListResponse> = {
"squad-api": {
members: [
{
member_type: "agent",
member_id: "a-claude",
status: "idle",
active_issues: [],
last_active_at: NOW,
},
{
member_type: "agent",
member_id: "a-codex",
status: "idle",
active_issues: [],
last_active_at: NOW,
},
{
member_type: "agent",
member_id: "a-gemini",
status: "working",
active_issues: [
{
issue_id: "issue-137",
identifier: "MUL-137",
title: "Add rate limiting to the public API",
issue_status: "in_review",
},
],
last_active_at: NOW,
},
{
member_type: "member",
member_id: "u-alex",
status: null,
active_issues: [],
last_active_at: null,
},
],
},
};
type Seed = {
n: number;
title: string;
status: IssueStatus;
priority: IssuePriority;
at: "member" | "agent" | "squad";
aid: string;
due?: string;
};
const SEEDS: Seed[] = [
{ n: 160, title: "Add 2FA / TOTP support", status: "backlog", priority: "medium", at: "member", aid: "u-sam" },
{ n: 162, title: "Investigate p95 latency on /search", status: "backlog", priority: "high", at: "agent", aid: "a-codex" },
{ n: 165, title: "Spike: vector search over issues", status: "backlog", priority: "low", at: "agent", aid: "a-gemini" },
{ n: 168, title: "Audit npm dependencies for CVEs", status: "backlog", priority: "medium", at: "member", aid: "u-alex" },
{ n: 142, title: "Design pricing page v2", status: "todo", priority: "high", at: "member", aid: "u-alex" },
{ n: 151, title: "Add SSO (SAML) to enterprise plan", status: "todo", priority: "low", at: "member", aid: "u-sam" },
{ n: 156, title: "Refactor billing webhooks handler", status: "todo", priority: "medium", at: "agent", aid: "a-kimi" },
{ n: 129, title: "Implement OAuth login flow", status: "in_progress", priority: "high", at: "agent", aid: "a-claude", due: "2026-06-08" },
{ n: 133, title: "Migrate analytics events to new schema", status: "in_progress", priority: "medium", at: "agent", aid: "a-gemini" },
{ n: 137, title: "Add rate limiting to the public API", status: "in_review", priority: "high", at: "squad", aid: "squad-api" },
{ n: 138, title: "Fix flaky checkout E2E test", status: "in_progress", priority: "medium", at: "agent", aid: "a-codex" },
{ n: 147, title: "Polish onboarding empty states", status: "in_progress", priority: "medium", at: "member", aid: "u-alex" },
{ n: 124, title: "Weekly dependency upgrade sweep", status: "in_review", priority: "low", at: "agent", aid: "a-claude" },
{ n: 119, title: "Write API docs for webhooks", status: "in_review", priority: "medium", at: "member", aid: "u-sam" },
{ n: 112, title: "Triage inbound bug reports", status: "done", priority: "low", at: "agent", aid: "a-codex" },
{ n: 108, title: "Ship dark-mode polish", status: "done", priority: "medium", at: "member", aid: "u-alex" },
{ n: 103, title: "Nightly DB backup health check", status: "done", priority: "low", at: "agent", aid: "a-gemini" },
{ n: 170, title: "Enable SSO on the staging environment", status: "blocked", priority: "high", at: "member", aid: "u-sam" },
{ n: 172, title: "Migrate CI to the new build runners", status: "blocked", priority: "medium", at: "agent", aid: "a-gemini" },
];
function makeIssue(seed: Seed, index: number): Issue {
return {
id: `issue-${seed.n}`,
workspace_id: "ws-demo",
number: seed.n,
identifier: `MUL-${seed.n}`,
title: seed.title,
description:
seed.at === "agent"
? `Assigned to an agent. ${seed.title}. The agent picks this up, runs it, and reports back here.`
: seed.at === "squad"
? "API hardening playbook: split implementation, verification, docs, and PR review across the squad."
: `${seed.title}.`,
status: seed.status,
priority: seed.priority,
assignee_type: seed.at,
assignee_id: seed.aid,
creator_type: "member",
creator_id: "u-alex",
parent_issue_id: null,
project_id: null,
position: index,
start_date: null,
due_date: seed.due ?? null,
metadata: {},
created_at: NOW,
updated_at: NOW,
};
}
// Mutable so updateIssue (drag) persists across refetches.
export const ISSUES: Issue[] = SEEDS.map(makeIssue);
// Agents currently working — drives the "N working" header chip + avatar
// stack and the agents-working filter. Each points at an in-progress,
// agent-assigned issue above.
const WORKING: { agent: string; issue: string }[] = [
{ agent: "a-claude", issue: "issue-129" },
{ agent: "a-gemini", issue: "issue-133" },
{ agent: "a-gemini", issue: "issue-137" },
{ agent: "a-codex", issue: "issue-138" },
];
// A few minutes ago, so the "agent is working" timers read naturally (e.g.
// "6m") and tick up live, instead of an absurd elapsed value from a fixed date.
const startedAt = (minsAgo: number) =>
new Date(Date.now() - minsAgo * 60_000).toISOString();
export const RUNNING_TASKS: AgentTask[] = WORKING.map(
({ agent, issue }, i) =>
({
id: `task-${i}`,
agent_id: agent,
runtime_id: runtimeIdForAgent(agent),
issue_id: issue,
status: "running",
priority: 0,
dispatched_at: startedAt(4 + i * 3 + 1),
started_at: startedAt(4 + i * 3),
completed_at: null,
result: null,
error: null,
created_at: NOW,
updated_at: NOW,
}) as unknown as AgentTask,
);
// Create-issue flow: build a fresh issue from the dialog input, drop it at the
// top of its column, and return it so the board shows the new card.
let nextNumber = 200;
export function createMockIssue(
input: Partial<Issue> & { title: string },
): Issue {
const n = nextNumber++;
const now = new Date().toISOString();
const issue: Issue = {
id: `issue-new-${n}`,
workspace_id: "ws-demo",
number: n,
identifier: `MUL-${n}`,
title: input.title || "Untitled issue",
description: input.description ?? null,
status: input.status ?? "todo",
priority: input.priority ?? "none",
assignee_type: input.assignee_type ?? null,
assignee_id: input.assignee_id ?? null,
creator_type: "member",
creator_id: "u-alex",
parent_issue_id: input.parent_issue_id ?? null,
project_id: input.project_id ?? null,
position: -1,
start_date: input.start_date ?? null,
due_date: input.due_date ?? null,
metadata: {},
created_at: now,
updated_at: now,
};
ISSUES.unshift(issue);
return issue;
}
export function patchIssue(id: string, patch: Partial<Issue>): Issue | undefined {
const i = ISSUES.findIndex((x) => x.id === id);
if (i === -1) return undefined;
ISSUES[i] = { ...ISSUES[i]!, ...patch, updated_at: NOW };
return ISSUES[i];
}
// ---------------------------------------------------------------------------
// Richer issue detail: comments/discussion, linked PRs, execution history.
// ---------------------------------------------------------------------------
const mins = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
function comment(
id: string,
actorType: "member" | "agent",
actorId: string,
content: string,
minsAgo: number,
parentId: string | null = null,
reactions: TimelineEntry["reactions"] = [],
): TimelineEntry {
return {
type: "comment",
id,
actor_type: actorType,
actor_id: actorId,
content,
comment_type: "comment",
parent_id: parentId,
reactions,
attachments: [],
created_at: mins(minsAgo),
updated_at: mins(minsAgo),
resolved_at: null,
} as unknown as TimelineEntry;
}
function thumbsUp(commentId: string): NonNullable<TimelineEntry["reactions"]>[number] {
return {
id: `r-${commentId}-1`,
comment_id: commentId,
actor_type: "member",
actor_id: "u-alex",
emoji: "👍",
created_at: mins(1),
};
}
// Comment / activity threads, keyed by issue id. Issues without an entry just
// render an empty Activity feed (the real component handles that).
export const TIMELINE: Record<string, TimelineEntry[]> = {
// One conversation thread (root + nested replies) reads cleaner than four
// separate top-level comments each with its own reply box.
"issue-129": [
comment("c-129-1", "member", "u-alex", "Let's use the existing session store for the token refresh — no new tables.", 38),
comment("c-129-2", "agent", "a-claude", "Done. Implemented the redirect flow and token refresh against the session store, and opened a PR (linked on the right). Working through the edge cases now.", 22, "c-129-1"),
comment("c-129-3", "member", "u-alex", "Make sure we validate the state param to prevent CSRF.", 12, "c-129-1"),
comment("c-129-4", "agent", "a-claude", "Good catch — added state validation + a regression test. Re-running CI.", 5, "c-129-1"),
],
"issue-133": [
comment("c-133-1", "member", "u-sam", "Which events are in scope for v1 of the migration?", 90),
comment("c-133-2", "agent", "a-gemini", "Starting with page_view, signup, and checkout; the long tail follows once the new schema is verified in staging.", 64, "c-133-1"),
],
"issue-138": [
comment("c-138-1", "agent", "a-codex", "Reproduced the flake — it's a race on the cart fixture between the checkout poll and the seed step. Adding an explicit wait + idempotent seed.", 30),
comment("c-138-2", "member", "u-alex", "Nice, that's been haunting CI for weeks.", 18, "c-138-1"),
],
"issue-137": [
comment("c-137-1", "member", "u-alex", "Route this through the API Squad. Use the hardening playbook: implementation, tests, docs, and a PR summary.", 52),
comment("c-137-2", "agent", "a-claude", "I split the work: Codex owns the limiter implementation, Gemini owns tests and docs, and I will review the PR before marking it ready.", 38, "c-137-1"),
comment("c-137-3", "agent", "a-codex", "Draft PR is up with the token bucket middleware. Waiting on route-specific limits before final review.", 24, "c-137-1"),
comment("c-137-4", "member", "u-alex", "Please add per-route overrides and document the Retry-After header.", 16, "c-137-1"),
comment("c-137-5", "agent", "a-gemini", "Done. Added route overrides, docs, and regression coverage. PR is ready for review.", 4, "c-137-1", [thumbsUp("c-137-5")]),
],
"issue-124": [
comment("c-124-1", "agent", "a-claude", "Bumped 14 dependencies, 2 majors held back behind a follow-up. Lockfile + changelog in the PR.", 140),
],
};
// Real pull requests from github.com/multica-ai/multica, linked to issues.
function pr(
id: string,
number: number,
title: string,
state: "open" | "merged",
authorLogin: string,
opts: { mergedMinsAgo?: number; checks?: "passed" | "pending" | "failed"; add?: number; del?: number; files?: number } = {},
): GitHubPullRequest {
return {
id,
workspace_id: "ws-demo",
repo_owner: "multica-ai",
repo_name: "multica",
number,
title,
state,
html_url: `https://github.com/multica-ai/multica/pull/${number}`,
branch: null,
author_login: authorLogin,
author_avatar_url: `https://github.com/${authorLogin}.png`,
merged_at: state === "merged" ? mins(opts.mergedMinsAgo ?? 120) : null,
closed_at: null,
pr_created_at: mins(600),
pr_updated_at: mins(opts.mergedMinsAgo ?? 60),
mergeable_state: state === "open" ? "clean" : null,
checks_conclusion: opts.checks ?? (state === "merged" ? "passed" : "pending"),
checks_passed: opts.checks === "failed" ? 11 : 12,
checks_failed: opts.checks === "failed" ? 1 : 0,
checks_pending: opts.checks === "pending" ? 2 : 0,
additions: opts.add ?? 180,
deletions: opts.del ?? 40,
changed_files: opts.files ?? 7,
} as unknown as GitHubPullRequest;
}
export const PULL_REQUESTS: Record<string, GitHubPullRequest[]> = {
"issue-129": [
pr("pr-1", 3717, "refactor(server/lark): collapse HTTP_ENABLED + WS_ENABLED into the SECRET_KEY gate", "merged", "Bohan-J", { mergedMinsAgo: 80, add: 96, del: 120, files: 5 }),
],
"issue-138": [
pr("pr-2", 3712, "test(migrate): concurrent migration race test using real Postgres (MUL-2956)", "open", "ldnvnbl", { checks: "pending", add: 210, del: 8, files: 3 }),
],
"issue-133": [
pr("pr-3", 3716, "fix(execenv): refresh skills in place on reuse instead of accumulating duplicate dirs", "merged", "Bohan-J", { mergedMinsAgo: 200, add: 64, del: 31, files: 4 }),
],
"issue-137": [
pr("pr-5", 3721, "feat(api): add per-route rate limiting middleware", "open", "multica-bot", { checks: "passed", add: 184, del: 22, files: 6 }),
],
"issue-124": [
pr("pr-4", 3718, "fix(lark): use named import for react-qr-code to survive electron-vite interop", "open", "Bohan-J", { checks: "passed", add: 12, del: 6, files: 1 }),
],
};
// Execution-log history per issue (api.listTasksByIssue) — running task(s)
// plus a couple of completed past runs, so the panel isn't empty.
function task(
id: string,
agentId: string,
issueId: string,
status: AgentTask["status"],
summary: string,
startMinsAgo: number,
endMinsAgo: number | null,
): AgentTask {
return {
id,
agent_id: agentId,
runtime_id: runtimeIdForAgent(agentId),
issue_id: issueId,
status,
priority: 0,
dispatched_at: mins(startMinsAgo + 1),
started_at: mins(startMinsAgo),
completed_at: endMinsAgo == null ? null : mins(endMinsAgo),
result: null,
error: null,
trigger_summary: summary,
created_at: mins(startMinsAgo + 1),
updated_at: mins(endMinsAgo ?? 0),
} as unknown as AgentTask;
}
export const EXEC_LOG: Record<string, AgentTask[]> = {
"issue-129": [
task("t-129-run", "a-claude", "issue-129", "running", "Implement OAuth login flow", 4, null),
task("t-129-1", "a-claude", "issue-129", "completed", "Scaffold OAuth routes", 95, 78),
task("t-129-0", "a-claude", "issue-129", "failed", "Initial run", 180, 150),
],
"issue-133": [
task("t-133-run", "a-gemini", "issue-133", "running", "Migrate analytics events to new schema", 7, null),
task("t-133-1", "a-gemini", "issue-133", "completed", "Draft migration plan", 120, 96),
],
"issue-138": [
task("t-138-run", "a-codex", "issue-138", "running", "Fix flaky checkout E2E test", 10, null),
task("t-138-1", "a-codex", "issue-138", "completed", "Reproduce the flake", 60, 44),
],
"issue-137": [
task("t-137-3", "a-gemini", "issue-137", "running", "Verify docs and final PR summary", 3, null),
task("t-137-2", "a-codex", "issue-137", "completed", "Implement token bucket limiter", 34, 20),
task("t-137-1", "a-claude", "issue-137", "completed", "Plan API hardening playbook", 48, 40),
],
"issue-124": [
task("t-124-1", "a-claude", "issue-124", "completed", "Weekly dependency upgrade sweep", 160, 138),
],
"issue-112": [
task("t-112-1", "a-codex", "issue-112", "completed", "Triage inbound bug reports", 1400, 1380),
],
};
// ---------------------------------------------------------------------------
// Agent transcripts (the "running" agent's live log, opened via the transcript
// button). Keyed by issue; listTaskMessages resolves a task id to its issue.
// ---------------------------------------------------------------------------
type Msg = Omit<TaskMessagePayload, "task_id" | "issue_id" | "seq">;
const think = (content: string): Msg => ({ type: "thinking", content });
const say = (content: string): Msg => ({ type: "text", content });
const tool = (t: string, input: Record<string, unknown>): Msg => ({ type: "tool_use", tool: t, input });
const result = (t: string, output: string): Msg => ({ type: "tool_result", tool: t, output });
export const TRANSCRIPT_BY_ISSUE: Record<string, Msg[]> = {
"issue-129": [
think("Let me see how auth is wired today — the session store and the existing login route — so I can reuse them instead of adding new tables."),
tool("Read", { file_path: "server/internal/auth/session.go" }),
result("Read", "// session.go — NewSession, Refresh, store-backed cookie sessions (138 lines)"),
say("Got it. I'll add an OAuth redirect handler and store the token in the existing session store. Writing it now."),
tool("Edit", { file_path: "server/internal/auth/oauth.go", summary: "add Authorize + Callback handlers, token exchange" }),
result("Edit", "Created server/internal/auth/oauth.go (+96 0)"),
think("Need to validate the `state` param on the callback to prevent CSRF."),
tool("Edit", { file_path: "server/internal/auth/oauth.go", summary: "validate state param against the signed cookie" }),
result("Edit", "Updated server/internal/auth/oauth.go (+14 1)"),
tool("Bash", { command: "go test ./internal/auth/..." }),
result("Bash", "ok \tmultica/internal/auth\t1.82s"),
say("Tests pass. Opened a PR with the redirect flow + token refresh + the state-validation test. Working through the last edge cases (expired token re-auth)."),
],
"issue-133": [
think("Mapping the old analytics events to the new schema. Let me read the current event definitions first."),
tool("Read", { file_path: "packages/analytics/events.ts" }),
result("Read", "// 41 event types; page_view / signup / checkout are the high-volume ones"),
say("I'll migrate page_view, signup, and checkout first, then backfill the long tail once staging looks clean."),
tool("Bash", { command: "pnpm migrate:analytics --events page_view,signup,checkout --dry-run" }),
result("Bash", "dry-run: would migrate 3 event types · 1,243,902 rows · est. 4m12s"),
say("Dry-run looks right. Running it against staging now and will diff the row counts before touching prod."),
],
"issue-138": [
think("Reproducing the flake first — running the checkout E2E in a tight loop to catch it."),
tool("Bash", { command: "pnpm exec playwright test checkout --repeat-each=20" }),
result("Bash", "18 passed, 2 failed — timeout waiting for [data-testid=cart-total]"),
say("It's a race between the checkout poll and the cart-seed step. Adding an explicit wait + making the seed idempotent."),
tool("Edit", { file_path: "e2e/tests/checkout.spec.ts", summary: "await cart-ready, dedupe seed" }),
result("Edit", "Updated e2e/tests/checkout.spec.ts (+9 3)"),
tool("Bash", { command: "pnpm exec playwright test checkout --repeat-each=30" }),
result("Bash", "30 passed (0 flaky)"),
say("30/30 green now. Pushing the fix and linking the PR."),
],
"issue-137": [
think("The implementation is ready. I am checking the docs and route override cases before the squad marks the PR ready."),
tool("Read", { file_path: "server/internal/gateway/ratelimit.go" }),
result("Read", "Token bucket middleware, per-route override table, and Retry-After header handling."),
tool("Bash", { command: "go test ./internal/gateway/..." }),
result("Bash", "ok \tmultica/internal/gateway\t2.14s"),
say("Verified the limiter, docs, and regression tests. PR #3721 is ready for review."),
],
};
// Workspace skills — full server-shaped rows so the real SkillsPage and agent
// profile cards can render source, creator, files, and agent assignments.
export const SKILLS: Skill[] = [
{
id: "skill-oauth-flow",
workspace_id: "ws-demo",
name: "OAuth integration checklist",
description: "Repeat the proven OAuth flow: reuse sessions, validate state, test refresh, and open a PR.",
config: {
origin: {
type: "runtime_local",
runtime_id: "rt-local-claude",
provider: "claude",
source_path: "~/.claude/skills/oauth-integration",
},
},
created_by: "u-alex",
content: "# OAuth integration checklist\n\nReuse the existing session store, validate state, cover refresh, and include PR notes.",
files: [
{
id: "sf-oauth-1",
skill_id: "skill-oauth-flow",
path: "fixtures/oauth-state.test.md",
content: "Regression checklist for OAuth state validation.",
created_at: NOW,
updated_at: NOW,
},
],
created_at: NOW,
updated_at: NOW,
},
{
id: "skill-pr-review",
workspace_id: "ws-demo",
name: "PR Review",
description: "Read a diff, flag bugs and style issues, then leave review comments.",
config: {},
created_by: "u-alex",
content: "# PR Review\n\nInspect changed files, run targeted checks, and summarize blocking issues.",
files: [],
created_at: NOW,
updated_at: NOW,
},
{
id: "skill-bug-repro",
workspace_id: "ws-demo",
name: "Bug Repro",
description: "Turn a bug report into a minimal reproduction and a failing test.",
config: {},
created_by: "u-sam",
content: "# Bug Repro\n\nReproduce, minimize, write failing coverage, then attach logs.",
files: [],
created_at: NOW,
updated_at: NOW,
},
{
id: "skill-dependency-sweep",
workspace_id: "ws-demo",
name: "Dependency Sweep",
description: "Bump dependencies, run the suite, and open a PR with the lockfile diff.",
config: {},
created_by: "u-alex",
content: "# Dependency Sweep\n\nUpgrade in batches, run tests, and document held-back majors.",
files: [],
created_at: NOW,
updated_at: NOW,
},
] as unknown as Skill[];
for (const agent of AGENTS) {
if (agent.id === "a-claude") agent.skills = [SKILLS[0]!, SKILLS[1]!];
if (agent.id === "a-codex") agent.skills = [SKILLS[1]!, SKILLS[2]!];
if (agent.id === "a-gemini") agent.skills = [SKILLS[0]!, SKILLS[3]!];
if (agent.id === "a-kimi") agent.skills = [SKILLS[2]!];
}
const USAGE_DATES = Array.from({ length: 21 }, (_, i) => {
const d = new Date(Date.now() - (20 - i) * 24 * 60 * 60 * 1000);
return d.toISOString().slice(0, 10);
});
export const RUNTIME_USAGE: RuntimeUsage[] = RUNTIMES.flatMap((runtime, runtimeIndex) =>
USAGE_DATES.map((date, dayIndex) => ({
runtime_id: runtime.id,
date,
provider: runtime.provider,
model:
runtime.provider === "codex"
? "gpt-5.4"
: runtime.provider === "gemini"
? "gemini-2.5-pro"
: "claude-sonnet-4-5",
input_tokens: 18_000 + runtimeIndex * 4_200 + dayIndex * 900,
output_tokens: 4_200 + runtimeIndex * 1_100 + dayIndex * 240,
cache_read_tokens: 9_000 + runtimeIndex * 1_600 + dayIndex * 520,
cache_write_tokens: 1_500 + runtimeIndex * 280 + dayIndex * 80,
})),
) as RuntimeUsage[];
export const RUNTIME_USAGE_BY_AGENT: RuntimeUsageByAgent[] = AGENTS.map(
(agent, index) =>
({
agent_id: agent.id,
model:
agent.id === "a-codex"
? "gpt-5.4"
: agent.id === "a-gemini"
? "gemini-2.5-pro"
: "claude-sonnet-4-5",
input_tokens: 82_000 + index * 18_000,
output_tokens: 18_000 + index * 4_200,
cache_read_tokens: 38_000 + index * 7_200,
cache_write_tokens: 6_000 + index * 1_200,
task_count: 8 + index * 3,
}) as RuntimeUsageByAgent,
);
export const RUNTIME_USAGE_BY_HOUR: RuntimeUsageByHour[] = Array.from(
{ length: 12 },
(_, index) =>
({
hour: 8 + index,
model: index % 3 === 0 ? "gpt-5.4" : "claude-sonnet-4-5",
input_tokens: 12_000 + index * 1_300,
output_tokens: 2_400 + index * 280,
cache_read_tokens: 5_200 + index * 420,
cache_write_tokens: 900 + index * 90,
task_count: 1 + (index % 4),
}) as RuntimeUsageByHour,
);

View File

@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { RuntimesPage, RuntimeDetailPage } from "@multica/views/runtimes";
import { DemoProductFrame } from "./demo-product-frame";
import { ValueDemoFrame } from "./value-demo-frame";
const DEMO_W = 980;
const DEMO_H = 740;
const PHASE_MS = 5200;
export function ValueBoardDemo() {
const [detail, setDetail] = useState(false);
const reduceMotion = useReducedMotion();
useEffect(() => {
const timer = window.setInterval(() => setDetail((v) => !v), PHASE_MS);
return () => window.clearInterval(timer);
}, []);
return (
<ValueDemoFrame width={DEMO_W} height={DEMO_H}>
<DemoProductFrame
activeTab="runtimes"
pathname={detail ? "/demo/runtimes/rt-local-claude" : "/demo/runtimes"}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={detail ? "runtime-detail" : "runtime-list"}
className="h-full"
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] }}
>
{detail ? (
<RuntimeDetailPage runtimeId="rt-local-claude" />
) : (
<RuntimesPage
localDaemonId="daemon-local"
localMachineName="Jiayuan MacBook Pro"
hasLocalMachine
cloudRuntimeEnabled
/>
)}
</motion.div>
</AnimatePresence>
</DemoProductFrame>
</ValueDemoFrame>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import { DemoIssueDetail } from "./demo-issue-detail";
import { DemoProductFrame } from "./demo-product-frame";
import { ValueDemoFrame } from "./value-demo-frame";
const DEMO_H = 720;
export function ValueDelegateDemo() {
return (
<ValueDemoFrame height={DEMO_H}>
<DemoProductFrame activeTab="issues" pathname="/demo/issues/issue-137">
<DemoIssueDetail issueId="issue-137" initialScrollTop={720} />
</DemoProductFrame>
</ValueDemoFrame>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
// Shared scale frame for the value-section micro-demos (#2#4). Each demo lays
// out at a fixed natural size, then this frame scales it down by the shared
// DEMO_ZOOM so every value card's demo matches the hero board's on-screen scale
// and the cards line up at one height. Value #1 (the board) carries its own
// frame because it also needs providers; the natural size is kept identical
// here so all four cards are the same height.
import { useEffect, useRef, useState } from "react";
import { DEMO_ZOOM } from "./zoom";
// Default natural width for the content-light demos (#2#4). Sized so that,
// scaled by DEMO_ZOOM, the panel fits the demo half of the card at the design
// widths (≥1200px container) without bleeding/clipping. Height is per-demo
// (sized to its content) so panels stay snug.
export const VALUE_DEMO_W = 720;
export function ValueDemoFrame({
width = VALUE_DEMO_W,
height,
children,
}: {
width?: number;
height: number;
children: React.ReactNode;
}) {
const frameRef = useRef<HTMLDivElement>(null);
const [renderedWidth, setRenderedWidth] = useState(width * DEMO_ZOOM);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return;
const measure = () => {
const next = frame.clientWidth || width * DEMO_ZOOM;
setRenderedWidth(Math.min(width * DEMO_ZOOM, next));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(frame);
return () => observer.disconnect();
}, [width]);
const scale = Math.min(DEMO_ZOOM, renderedWidth / width);
return (
<div
ref={frameRef}
className="w-full overflow-hidden"
style={{
width: width * DEMO_ZOOM,
maxWidth: "100%",
height: height * scale,
}}
>
<div
className="origin-top-left"
style={{ width, height, transform: `scale(${scale})` }}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { SkillsPage } from "@multica/views/skills";
import { DemoIssueDetail } from "./demo-issue-detail";
import { DemoProductFrame, type DemoProductTab } from "./demo-product-frame";
import { ValueDemoFrame } from "./value-demo-frame";
const DEMO_H = 624;
const PHASE_MS = 5600;
export function ValueTranscriptDemo() {
const [showSkills, setShowSkills] = useState(false);
const reduceMotion = useReducedMotion();
const activeTab: DemoProductTab = showSkills ? "skills" : "issues";
useEffect(() => {
const timer = window.setInterval(() => setShowSkills((v) => !v), PHASE_MS);
return () => window.clearInterval(timer);
}, []);
return (
<ValueDemoFrame height={DEMO_H}>
<DemoProductFrame
activeTab={activeTab}
pathname={showSkills ? "/demo/skills" : "/demo/issues/issue-129"}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={showSkills ? "skills" : "record"}
className="h-full"
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] }}
>
{showSkills ? (
<SkillsPage />
) : (
<DemoIssueDetail issueId="issue-129" initialScrollTop={640} />
)}
</motion.div>
</AnimatePresence>
</DemoProductFrame>
</ValueDemoFrame>
);
}

View File

@@ -0,0 +1,5 @@
// Single source of truth for the zoom every embedded live demo renders at, so
// the hero board and the value-section boards all shrink by the same factor —
// cards/columns end up the exact same on-screen size across the page. Each demo
// lays out at its natural size, then scales down by DEMO_ZOOM.
export const DEMO_ZOOM = 0.85;

View File

@@ -0,0 +1,730 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowRight, Download, Star } from "lucide-react";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { ProviderLogo } from "@multica/views/runtimes";
import {
LandingFooterContent,
type LandingFooterGroup,
} from "../components/landing-footer";
import { githubUrl, twitterUrl } from "../components/shared";
import { DEMO_ZOOM } from "./demo/zoom";
// The interactive product demo is heavy (the whole issues board subsystem) and
// must stay client-only — it overrides the API singleton with a mock and uses
// browser-only providers, so it can't server-render. Lazy-load it so it never
// blocks the landing's first paint.
const DemoBoard = dynamic(
() => import("./demo/demo-board").then((m) => m.DemoBoard),
{
ssr: false,
loading: () => (
<div className="flex h-full w-full items-center justify-center bg-white text-[14px] text-[#0a0d12]/40">
Loading live demo
</div>
),
},
);
// The value-section micro-demos. Client-only — they auto-play with timers and
// (for the board) use browser-only providers. Lazy-loaded so they never block
// first paint. next/dynamic needs an inline object-literal options arg.
const ValueBoardDemo = dynamic(
() => import("./demo/value-board-demo").then((m) => m.ValueBoardDemo),
{ ssr: false, loading: () => <div className="h-[360px]" /> },
);
const ValueDelegateDemo = dynamic(
() => import("./demo/value-delegate-demo").then((m) => m.ValueDelegateDemo),
{ ssr: false, loading: () => <div className="h-[360px]" /> },
);
const ValueTranscriptDemo = dynamic(
() => import("./demo/value-transcript-demo").then((m) => m.ValueTranscriptDemo),
{ ssr: false, loading: () => <div className="h-[360px]" /> },
);
// GitHub Invertocat (official mark). lucide-react dropped its brand icons, so we
// inline the silhouette here rather than depend on a removed export.
function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
<path d="M12 1C5.9225 1 1 5.9225 1 12C1 16.8675 4.14875 20.9787 8.52125 22.4362C9.07125 22.5325 9.2775 22.2025 9.2775 21.9137C9.2775 21.6525 9.26375 20.7862 9.26375 19.865C6.5 20.3737 5.785 19.1912 5.565 18.5725C5.44125 18.2562 4.905 17.28 4.4375 17.0187C4.0525 16.8125 3.5025 16.3037 4.42375 16.29C5.29 16.2762 5.90875 17.0875 6.115 17.4175C7.105 19.0812 8.68625 18.6137 9.31875 18.325C9.415 17.61 9.70375 17.1287 10.02 16.8537C7.5725 16.5787 5.015 15.63 5.015 11.4225C5.015 10.2262 5.44125 9.23625 6.1425 8.46625C6.0325 8.19125 5.6475 7.06375 6.2525 5.55125C6.2525 5.55125 7.17375 5.2625 9.2775 6.67875C10.1575 6.43125 11.0925 6.3075 12.0275 6.3075C12.9625 6.3075 13.8975 6.43125 14.7775 6.67875C16.8813 5.24875 17.8025 5.55125 17.8025 5.55125C18.4075 7.06375 18.0225 8.19125 17.9125 8.46625C18.6138 9.23625 19.04 10.2125 19.04 11.4225C19.04 15.6437 16.4688 16.5787 14.0213 16.8537C14.42 17.1975 14.7638 17.8575 14.7638 18.8887C14.7638 20.36 14.75 21.5425 14.75 21.9137C14.75 22.2025 14.9563 22.5462 15.5063 22.4362C19.8513 20.9787 23 16.8537 23 12C23 5.9225 18.0775 1 12 1Z" />
</svg>
);
}
// Static for now — the repo currently has ~35k stars. Swap for a live
// GitHub API count later if we want it to self-update.
const GITHUB_STAR_COUNT = "35k";
// Embedded product demo: a slightly-shrunk window (~16:9). transform: scale on
// an inner box sized up by 1/scale, with the window height clamped so the
// un-scaled layout box doesn't leave dead space. (transform handles the board's
// drag better than zoom.) DEMO_ZOOM is shared with the value-section demos so
// every embedded board renders at one uniform scale.
const DEMO_WINDOW_H = 648;
/**
* Multica Landing Page V2 (sandbox) — served at `/newhome`.
*
* Isolated rebuild of the landing hero. Shares nothing with the live landing
* (`/`), so we can iterate freely here and only swap it in once the V2 design
* is finalized.
*
* Hero copy follows the homepage positioning from MUL-2920:
* slogan — "One board for all your agent work."
* subtitle — assign work, track progress, automate execution.
*
* Layout follows the ElevenLabs reference: sans-serif headline on the left,
* description on the right, a single pair of CTAs below, full-width product
* preview (placeholder for now).
*/
export function NewHomeLanding() {
return (
<div className="min-h-screen bg-white font-sans text-[#0a0d12]">
<NewHomeNav />
<NewHomeHero />
<LandingFooterContent
brandHref="/newhome"
ctaHref="/login"
groups={NEWHOME_FOOTER_GROUPS}
/>
</div>
);
}
const NAV_LINKS = [
{ href: "#features", label: "Features" },
{ href: "#proof", label: "Proof" },
{ href: "#changelog", label: "Changelog" },
{ href: "/docs", label: "Docs" },
];
const NEWHOME_FOOTER_GROUPS: LandingFooterGroup[] = [
{
label: "Product",
links: [
{ href: "#features", label: "Features" },
{ href: "#proof", label: "Proof" },
{ href: "#changelog", label: "Changelog" },
{ href: "/download", label: "Download" },
],
},
{
label: "Resources",
links: [
{ href: "/docs", label: "Documentation" },
{ href: githubUrl, label: "GitHub" },
{ href: twitterUrl, label: "X (Twitter)" },
],
},
{
label: "Company",
links: [
{ href: "/about", label: "About" },
{ href: githubUrl, label: "Open Source" },
{ href: "/contact-sales", label: "Contact Sales" },
],
},
];
function NewHomeNav() {
return (
<header className="sticky top-0 z-30 bg-white/80 backdrop-blur-md">
<div className="mx-auto flex h-[72px] max-w-[1200px] items-center justify-between px-5 sm:px-6 lg:px-8">
<div className="flex items-center gap-8">
<Link href="/newhome" className="flex shrink-0 items-center gap-2.5">
<MulticaIcon className="size-5 text-[#0a0d12]" noSpin />
<span className="text-[19px] font-semibold lowercase tracking-[0.04em]">
multica
</span>
</Link>
<nav aria-label="Primary" className="hidden items-center gap-1 md:flex">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="inline-flex h-9 items-center rounded-[8px] px-3 text-[13.5px] font-medium text-[#0a0d12]/62 transition-colors hover:bg-[#0a0d12]/[0.05] hover:text-[#0a0d12]"
>
{link.label}
</Link>
))}
</nav>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<GitHubStars />
<Link href="/login" className={navButton("ghost")}>
Sign in
</Link>
<Link href="/download" className={navButton("solid")}>
Download
</Link>
</div>
</div>
</header>
);
}
function GitHubStars() {
return (
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
aria-label={`Star Multica on GitHub — ${GITHUB_STAR_COUNT} stars`}
className="hidden items-center gap-2 rounded-[8px] px-2.5 py-1.5 text-[13px] font-semibold text-[#0a0d12]/70 transition-colors hover:bg-[#0a0d12]/[0.05] hover:text-[#0a0d12] sm:inline-flex"
>
<GitHubIcon className="size-[18px] text-[#0a0d12]" />
<span className="inline-flex items-center gap-1">
<Star className="size-3.5 fill-[#f5a623] text-[#f5a623]" aria-hidden />
{GITHUB_STAR_COUNT}
</span>
</Link>
);
}
function NewHomeHero() {
return (
<main>
<section className="mx-auto max-w-[1200px] px-5 pb-14 pt-10 sm:px-6 sm:pt-12 lg:px-8 lg:pt-16">
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between lg:gap-16">
<h1 className="max-w-[14ch] text-[2.4rem] font-semibold leading-[1.03] tracking-[-0.03em] sm:text-[3rem] lg:text-[3.55rem]">
One board for all your agent work.
</h1>
<p className="max-w-[440px] text-[16px] leading-7 text-[#0a0d12]/60 sm:text-[17px] lg:pb-2">
Stop babysitting runs across terminals and chats. Assign work,
watch agents coordinate, and turn every run into reusable team
knowledge.
</p>
</div>
<div className="mt-9 flex flex-wrap items-center gap-3">
<Link href="/download" className={heroButton("solid")}>
<Download className="size-4" aria-hidden />
Download Desktop
</Link>
<Link href="/contact-sales" className={heroButton("ghost")}>
Talk to sales
</Link>
</div>
</section>
<section className="mx-auto max-w-[1200px] px-4 pb-16 sm:px-5 lg:px-6">
<ProductPreviewPlaceholder />
</section>
<SupportedAgents />
<ValuesSection />
<ProofSection />
<ChangelogSection />
<FinalCtaSection />
</main>
);
}
// The values section turns the hero's promise into concrete jobs users hire
// Multica to do. Keep runtime, squad, transcript, and skill details as proof
// points under those jobs instead of making the section a feature list.
const VALUES = [
{
eyebrow: "Workspace",
title: "Every agent run has a home",
problem:
"Agents are running on laptops, Mac minis, cloud boxes, and CLI sessions. The work disappears into terminal scrollback, chat updates, and disconnected repos.",
outcome:
"Multica brings the work back into one shared workspace: tasks, runtimes, agents, PRs, statuses, and usage all stay visible.",
proof: ["Local + cloud runtimes", "Agent usage", "One shared board"],
demo: <ValueBoardDemo />,
},
{
eyebrow: "Coordination",
title: "Agents coordinate without you chasing context",
problem:
"Complex tasks should not require a human dispatcher. Today someone still breaks the task down, picks the right agent, forwards context, checks progress, and asks for the next pass.",
outcome:
"Assign work to an agent or squad, give it a playbook, and let agents pick up the right pieces, ask for help, and report back with PR-ready work.",
proof: ["Squads", "Playbooks", "Human checkpoints"],
demo: <ValueDelegateDemo />,
},
{
eyebrow: "Memory",
title: "Every useful run becomes team memory",
problem:
"A good agent run should not disappear into terminal scrollback. The reasoning, files, commands, comments, reactions, and follow-up instructions are the work.",
outcome:
"Multica keeps that record attached to the issue, so repeated workflows can become reusable skills for the whole team.",
proof: ["Execution records", "Issue context", "Reusable skills"],
demo: <ValueTranscriptDemo />,
},
];
function ValuesSection() {
return (
<section id="features" className="py-14 sm:py-20">
<div className="mx-auto flex max-w-[1200px] flex-col gap-6 px-5 sm:gap-8 sm:px-6 lg:px-8">
{VALUES.map((v, i) => (
<ValueCard key={v.title} {...v} reverse={i % 2 === 1}>
{v.demo}
</ValueCard>
))}
</div>
</section>
);
}
// One value card: a tinted, bordered, rounded container. The text column and
// the live demo swap sides based on `reverse`; the demo renders at its real
// shared zoom and bleeds to the card's far edge, where the card's
// `overflow-hidden` clips it — so the border, not the browser, is the boundary.
function ValueCard({
eyebrow,
title,
problem,
outcome,
proof,
reverse = false,
children,
}: {
eyebrow: string;
title: string;
problem: string;
outcome: string;
proof: string[];
reverse?: boolean;
children: React.ReactNode;
}) {
return (
<div className="overflow-hidden rounded-[6px] border border-[#0a0d12]/10 bg-[#0a0d12]/[0.025]">
<div
className={cn(
"grid min-w-0 items-center gap-8",
reverse
? "lg:grid-cols-[minmax(0,1fr)_minmax(0,380px)]"
: "lg:grid-cols-[minmax(0,380px)_minmax(0,1fr)]",
)}
>
<div
className={cn(
"min-w-0 px-7 py-10 sm:px-10 sm:py-12 lg:py-16",
reverse ? "lg:order-2 lg:pl-2 lg:pr-12" : "lg:order-1 lg:pr-2 lg:pl-12",
)}
>
<p className="text-[12.5px] font-semibold uppercase tracking-[0.08em] text-[#0a0d12]/45">
{eyebrow}
</p>
<h3 className="mt-2.5 text-[1.7rem] font-semibold leading-[1.12] tracking-[-0.02em]">
{title}
</h3>
<div className="mt-4 space-y-4">
<div>
<p className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-[#0a0d12]/35">
The problem
</p>
<p className="mt-1.5 text-[14.5px] leading-7 text-[#0a0d12]/55">
{problem}
</p>
</div>
<div>
<p className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-[#0a0d12]/35">
With Multica
</p>
<p className="mt-1.5 text-[14.5px] leading-7 text-[#0a0d12]/62">
{outcome}
</p>
</div>
</div>
<ul className="mt-5 flex flex-wrap gap-2">
{proof.map((item) => (
<li
key={item}
className="max-w-full rounded-[6px] border border-[#0a0d12]/10 bg-white px-2.5 py-1.5 text-[12px] font-medium text-[#0a0d12]/60"
>
{item}
</li>
))}
</ul>
</div>
{/* Demo shrinks to its own width (w-max) and overflows the 1fr track
toward the card's far edge, where overflow-hidden trims it. On a
reversed card it sits on the left and bleeds left (justify-end).
landing-demo scopes the brand override + scrollbar hiding. */}
<div
className={cn(
"min-w-0 px-7 pb-10 sm:px-10 sm:pb-0 lg:py-10",
reverse ? "lg:order-1 lg:flex lg:justify-end lg:pl-0" : "lg:order-2 lg:pr-0",
)}
>
<div className="landing-demo w-full max-w-full overflow-hidden rounded-[6px] border border-[#0a0d12]/10 bg-white p-3 shadow-[0_1px_3px_rgba(10,13,18,0.04)] sm:p-4 lg:w-max">
{children}
</div>
</div>
</div>
</div>
);
}
// Until we have permission to display customer logos, the "logo wall" shows the
// coding agents Multica already supports instead — mirroring the reference
// social-proof band, one agent per card. Keys match the backend provider keys
// (server/pkg/agent/models.go) so ProviderLogo renders the right mark; any key
// without a logo falls back to a generic placeholder icon.
const SUPPORTED_AGENTS = [
{ key: "claude", name: "Claude Code" },
{ key: "codex", name: "Codex" },
{ key: "gemini", name: "Gemini CLI" },
{ key: "cursor", name: "Cursor" },
{ key: "copilot", name: "GitHub Copilot" },
{ key: "opencode", name: "OpenCode" },
{ key: "openclaw", name: "OpenClaw" },
{ key: "hermes", name: "Hermes" },
{ key: "kimi", name: "Kimi" },
{ key: "kiro", name: "Kiro" },
{ key: "pi", name: "Pi" },
{ key: "antigravity", name: "Antigravity" },
];
function SupportedAgents() {
return (
<section id="agents" className="pb-24">
<p className="px-5 text-center text-[15px] text-[#0a0d12]/55 sm:px-6 lg:px-8">
Works with the coding agents you already run
</p>
{/* Auto-scrolling marquee. overflow-hidden = no scrollbar; the track is two
identical groups sliding left by one group width for a seamless loop. */}
<div className="newhome-marquee mt-8 overflow-hidden">
<div className="newhome-marquee-track flex w-max">
<AgentTrackGroup />
<AgentTrackGroup ariaHidden />
</div>
</div>
</section>
);
}
function AgentTrackGroup({ ariaHidden = false }: { ariaHidden?: boolean }) {
return (
<ul
className="flex shrink-0 gap-3 pr-3"
aria-hidden={ariaHidden || undefined}
>
{SUPPORTED_AGENTS.map(({ key, name }) => (
<li
key={key}
className="group flex h-[84px] w-[172px] shrink-0 items-center justify-center gap-2.5 rounded-[6px] bg-[#0a0d12]/[0.03] text-[#0a0d12]/85"
>
{/* Grayscale by default; full brand color on hover of this card. */}
<ProviderLogo
provider={key}
className="size-6 grayscale transition-[filter] duration-200 group-hover:grayscale-0"
/>
<span className="text-[15px] font-semibold tracking-[-0.01em]">
{name}
</span>
</li>
))}
</ul>
);
}
const PROOF_POINTS = [
{
value: `${GITHUB_STAR_COUNT} stars`,
label: "Open source on GitHub",
body: "Built in public, inspectable, and self-hostable by teams that need to understand the system their agents run through.",
href: githubUrl,
},
{
value: `${SUPPORTED_AGENTS.length} agents`,
label: "Vendor-neutral runtime layer",
body: "Bring Claude Code, Codex, Gemini, Cursor, OpenCode, Copilot, and more into one workspace instead of separate silos.",
href: "#agents",
},
{
value: "Self-hostable",
label: "Run it on your own infrastructure",
body: "Keep agent work close to your code, machines, and infrastructure when cloud-only tooling is not enough.",
href: "/docs/getting-started/self-hosting",
},
{
value: "Recorded runs",
label: "Auditable agent execution",
body: "Every issue, comment, PR, command, reaction, and follow-up instruction stays attached to the work.",
href: "#features",
},
];
function ProofSection() {
return (
<section id="proof" className="border-y border-[#0a0d12]/8 bg-[#0a0d12]/[0.025] py-16 sm:py-20">
<div className="mx-auto max-w-[1200px] px-5 sm:px-6 lg:px-8">
<SectionIntro
eyebrow="Proof"
title="Trust comes from records, not promises."
description="Multica is built around the proof teams actually need: open source code, self-hosting, supported runtimes, and agent work that stays on the record."
/>
<div className="mt-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{PROOF_POINTS.map((point) => (
<Link
key={point.label}
href={point.href}
className="group rounded-[6px] border border-[#0a0d12]/10 bg-white px-5 py-5 transition-colors hover:border-[#0a0d12]/22"
>
<p className="text-[1.45rem] font-semibold tracking-[-0.02em] text-[#0a0d12]">
{point.value}
</p>
<p className="mt-2 text-[13px] font-semibold text-[#0a0d12]/70">
{point.label}
</p>
<p className="mt-3 text-[13.5px] leading-6 text-[#0a0d12]/55">
{point.body}
</p>
<span className="mt-4 inline-flex items-center gap-1 text-[12.5px] font-semibold text-[#0a0d12]/45 transition-colors group-hover:text-[#0a0d12]">
View proof
<ArrowRight className="size-3.5" aria-hidden />
</span>
</Link>
))}
</div>
</div>
</section>
);
}
const RECENT_SHIPMENTS = [
{
version: "0.3.14",
date: "June 2, 2026",
title: "Japanese support and /skill command",
points: [
"App, site, and docs now support Japanese.",
"Chat can choose agent Skills with /skill.",
"Teams can add Skills without replacing existing ones.",
],
},
{
version: "0.3.13",
date: "June 1, 2026",
title: "Skill search and CLI updates",
points: [
"CLI can search Skills and list PRs linked to an Issue.",
"Squad member roles can be changed from the CLI.",
"Agent lists can filter by runtime machine.",
],
},
{
version: "0.3.12",
date: "May 29, 2026",
title: "Issue session resume",
points: [
"Agents continuing from an Issue comment resume the prior session.",
"Active agent work stays visible near the Issue title.",
"Agents can scan Issue discussions with richer previews.",
],
},
];
function ChangelogSection() {
return (
<section id="changelog" className="py-16 sm:py-20">
<div className="mx-auto max-w-[1200px] px-5 sm:px-6 lg:px-8">
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<SectionIntro
eyebrow="Recently shipped"
title="Shipping the infrastructure teams need for managed agents."
description="Recent releases keep pushing toward the same goal: more runtime visibility, better agent coordination, and stronger team memory."
/>
<Link
href="/changelog"
className="inline-flex w-fit items-center gap-2 rounded-[8px] border border-[#0a0d12]/14 bg-white px-4 py-2.5 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-[#0a0d12]/[0.04]"
>
View changelog
<ArrowRight className="size-3.5" aria-hidden />
</Link>
</div>
<div className="mt-8 grid gap-4 lg:grid-cols-3">
{RECENT_SHIPMENTS.map((release) => (
<article
key={release.version}
className="rounded-[6px] border border-[#0a0d12]/10 bg-white px-5 py-5"
>
<div className="flex items-center justify-between gap-3">
<span className="rounded-[6px] bg-[#0a0d12]/[0.06] px-2 py-1 text-[12px] font-semibold text-[#0a0d12]/60">
v{release.version}
</span>
<span className="text-[12px] font-medium text-[#0a0d12]/38">
{release.date}
</span>
</div>
<h3 className="mt-4 text-[1.1rem] font-semibold tracking-[-0.01em] text-[#0a0d12]">
{release.title}
</h3>
<ul className="mt-4 space-y-2.5">
{release.points.map((point) => (
<li
key={point}
className="flex gap-2.5 text-[13.5px] leading-6 text-[#0a0d12]/58"
>
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-[#0a0d12]/30" />
{point}
</li>
))}
</ul>
</article>
))}
</div>
</div>
</section>
);
}
function FinalCtaSection() {
return (
<section id="get-started" className="bg-[#0a0d12] py-16 text-white sm:py-20">
<div className="mx-auto flex max-w-[1200px] flex-col gap-7 px-5 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8">
<div>
<p className="text-[12.5px] font-semibold uppercase tracking-[0.1em] text-white/45">
Start here
</p>
<h2 className="mt-3 max-w-[720px] text-[2rem] font-semibold leading-[1.08] tracking-[-0.03em] sm:text-[2.6rem]">
Manage agent work from one workspace.
</h2>
<p className="mt-4 max-w-[560px] text-[15.5px] leading-7 text-white/58">
Bring your agents, runtimes, issues, run records, and reusable
workflows into the same operating layer.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
href="/download"
className="inline-flex items-center justify-center gap-2 rounded-[8px] bg-white px-5 py-3 text-[14px] font-semibold text-[#0a0d12] transition-colors hover:bg-white/90"
>
<Download className="size-4" aria-hidden />
Download Desktop
</Link>
<Link
href="/docs/getting-started/self-hosting"
className="inline-flex items-center justify-center rounded-[8px] border border-white/18 px-5 py-3 text-[14px] font-semibold text-white transition-colors hover:bg-white/[0.08]"
>
Self-host Multica
</Link>
</div>
</div>
</section>
);
}
function SectionIntro({
eyebrow,
title,
description,
}: {
eyebrow: string;
title: string;
description: string;
}) {
return (
<div className="max-w-[760px]">
<p className="text-[12.5px] font-semibold uppercase tracking-[0.1em] text-[#0a0d12]/38">
{eyebrow}
</p>
<h2 className="mt-3 text-[2rem] font-semibold leading-[1.1] tracking-[-0.03em] sm:text-[2.45rem]">
{title}
</h2>
<p className="mt-4 max-w-[660px] text-[15.5px] leading-7 text-[#0a0d12]/58">
{description}
</p>
</div>
);
}
function ProductPreviewPlaceholder() {
return (
// Live, interactive product demo (mock data): browser tabs (Issues /
// Agents / Skills), drag cards, click a card to open its issue page. The
// browser chrome + tabs live inside DemoBoard. No drop shadow by request.
// The demo is laid out on a larger canvas (1/scale) then scaled down so it
// shows more content at a smaller size while still filling the 620px
// window. transform: scale (not zoom) so it clips to its visual bounds and
// never overflows the window.
<div className="relative">
<DemoLiveHint />
<div
className="overflow-hidden rounded-[6px] border border-[#0a0d12]/12 bg-white"
style={{ height: DEMO_WINDOW_H }}
>
<div
className="origin-top-left"
style={{
transform: `scale(${DEMO_ZOOM})`,
width: `${100 / DEMO_ZOOM}%`,
height: `${DEMO_WINDOW_H / DEMO_ZOOM}px`,
}}
>
<DemoBoard />
</div>
</div>
</div>
);
}
// Playful "this is live, try it" annotation in the whitespace above the demo's
// top-right — so the interactive demo doesn't read as a static screenshot. The
// arrow draws itself in and the whole hint gently floats (see custom.css).
function DemoLiveHint() {
return (
<div
aria-hidden
className="newhome-hint pointer-events-none absolute -top-[58px] right-3 z-10 hidden items-end gap-1 lg:flex"
>
<span
className="max-w-[280px] pb-2 text-right font-[family-name:var(--font-hand)] text-[22px] leading-[1.1] text-[#0a0d12]/55"
>
not a screenshot it&rsquo;s live. drag a card, try it!
</span>
<svg
viewBox="0 0 64 72"
fill="none"
className="newhome-hint-arrow size-[56px] shrink-0 text-[#0a0d12]/40"
>
{/* hand-drawn curve sweeping down into the demo's top-right */}
<path
d="M47 7c11 16 7 31-9 41"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
/>
{/* arrowhead pointing down-left */}
<path
d="M38 41l-3 9 10-1"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
function navButton(tone: "solid" | "ghost") {
return cn(
"inline-flex h-9 items-center justify-center rounded-[8px] px-3.5 text-[13.5px] font-semibold transition-colors",
tone === "solid"
? "bg-[#0a0d12] text-white hover:bg-[#0a0d12]/90"
: "border border-[#0a0d12]/14 bg-white text-[#0a0d12] hover:bg-[#0a0d12]/[0.04]",
);
}
function heroButton(tone: "solid" | "ghost") {
return cn(
"inline-flex items-center justify-center gap-2 rounded-[8px] px-5 py-3 text-[14px] font-semibold transition-colors",
tone === "solid"
? "bg-[#0a0d12] text-white hover:bg-[#0a0d12]/90"
: "border border-[#0a0d12]/14 bg-white text-[#0a0d12] hover:bg-[#0a0d12]/[0.04]",
);
}

View File

@@ -53,6 +53,7 @@
"linkify-it": "^5.0.0",
"lowlight": "^3.3.0",
"lucide-react": "catalog:",
"motion": "^12.38.0",
"next": "^16.2.5",
"next-themes": "^0.4.6",
"react": "catalog:",

View File

@@ -206,6 +206,11 @@ export interface ApiClientOptions {
identity?: ApiClientIdentity;
}
type ApiRequestInit = RequestInit & {
extraHeaders?: Record<string, string>;
quietStatuses?: number[];
};
export interface LoginResponse {
token: string;
user: User;
@@ -328,25 +333,23 @@ export class ApiClient {
// structured ApiError, status-aware log level). Returns the raw Response so
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
// path, plain text for the attachment-preview proxy, etc.
private async fetchRaw(
path: string,
init?: RequestInit & { extraHeaders?: Record<string, string> },
): Promise<Response> {
private async fetchRaw(path: string, init?: ApiRequestInit): Promise<Response> {
const rid = createRequestId();
const start = Date.now();
const method = init?.method ?? "GET";
const { extraHeaders, quietStatuses, headers: initHeaders, ...requestInit } = init ?? {};
const method = requestInit.method ?? "GET";
const headers: Record<string, string> = {
"X-Request-ID": rid,
...this.authHeaders(),
...(init?.extraHeaders ?? {}),
...((init?.headers as Record<string, string>) ?? {}),
...(extraHeaders ?? {}),
...((initHeaders as Record<string, string>) ?? {}),
};
this.logger.info(`${method} ${path}`, { rid });
const res = await fetch(`${this.baseUrl}${path}`, {
...init,
...requestInit,
headers,
credentials: "include",
});
@@ -354,7 +357,11 @@ export class ApiClient {
if (!res.ok) {
if (res.status === 401) this.handleUnauthorized();
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
const logLevel = res.status === 404 ? "warn" : "error";
const logLevel = quietStatuses?.includes(res.status)
? "info"
: res.status === 404
? "warn"
: "error";
this.logger[logLevel](`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new ApiError(message, res.status, res.statusText, body);
}
@@ -363,10 +370,13 @@ export class ApiClient {
return res;
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
private async fetch<T>(path: string, init?: ApiRequestInit): Promise<T> {
const res = await this.fetchRaw(path, {
...init,
extraHeaders: { "Content-Type": "application/json" },
extraHeaders: {
"Content-Type": "application/json",
...(init?.extraHeaders ?? {}),
},
});
// Handle 204 No Content
if (res.status === 204) {
@@ -405,8 +415,8 @@ export class ApiClient {
return this.fetch("/api/cli-token", { method: "POST" });
}
async getMe(): Promise<User> {
const raw = await this.fetch<unknown>("/api/me");
async getMe(options?: { quietStatuses?: number[] }): Promise<User> {
const raw = await this.fetch<unknown>("/api/me", options);
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
endpoint: "GET /api/me",
});
@@ -1371,8 +1381,8 @@ export class ApiClient {
}
// Workspaces
async listWorkspaces(): Promise<Workspace[]> {
return this.fetch("/api/workspaces");
async listWorkspaces(options?: { quietStatuses?: number[] }): Promise<Workspace[]> {
return this.fetch("/api/workspaces", options);
}
async getWorkspace(id: string): Promise<Workspace> {

View File

@@ -3,6 +3,7 @@
import { useEffect, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getApi } from "../api";
import { ApiError } from "../api/client";
import { useAuthStore } from "../auth";
import {
captureSignupSource,
@@ -94,13 +95,18 @@ export function AuthInitializer({
// resolve the slug without a second fetch. The active workspace itself
// is derived from the URL by [workspaceSlug]/layout.tsx — no imperative
// selection here.
Promise.all([api.getMe(), api.listWorkspaces()])
Promise.all([
api.getMe({ quietStatuses: [401] }),
api.listWorkspaces({ quietStatuses: [401] }),
])
.then(([user, wsList]) => {
onAuthSuccess(user);
qc.setQueryData(workspaceKeys.list(), wsList);
})
.catch((err) => {
logger.error("cookie auth init failed", err);
if (!(err instanceof ApiError && err.status === 401)) {
logger.error("cookie auth init failed", err);
}
onAuthFailure();
});
return;

View File

@@ -4,6 +4,7 @@ import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@multica/ui/lib/utils"
import { usePortalContainer } from "@multica/ui/lib/portal-container"
import { Button } from "@multica/ui/components/ui/button"
import { XIcon } from "lucide-react"
@@ -48,7 +49,7 @@ function DialogContent({
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogPortal container={usePortalContainer()}>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"

View File

@@ -4,6 +4,7 @@ import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@multica/ui/lib/utils"
import { usePortalContainer } from "@multica/ui/lib/portal-container"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
@@ -37,7 +38,7 @@ function DropdownMenuContent({
// menu item inside a row that's wrapped in <a> (agent / runtime list
// rows) would ALSO fire the row's onClick → unintended navigation.
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Portal container={usePortalContainer()}>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}

View File

@@ -3,6 +3,7 @@
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { cn } from "@multica/ui/lib/utils"
import { usePortalContainer } from "@multica/ui/lib/portal-container"
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
@@ -49,7 +50,7 @@ function HoverCardContent({
forwarded?.(e)
}
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Portal data-slot="hover-card-portal" container={usePortalContainer()}>
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}

View File

@@ -4,6 +4,7 @@ import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@multica/ui/lib/utils"
import { usePortalContainer } from "@multica/ui/lib/portal-container"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
@@ -26,7 +27,7 @@ function PopoverContent({
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Portal container={usePortalContainer()}>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}

View File

@@ -3,6 +3,7 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@multica/ui/lib/utils"
import { usePortalContainer } from "@multica/ui/lib/portal-container"
function TooltipProvider({
delay = 200,
@@ -39,7 +40,7 @@ function TooltipContent({
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Portal container={usePortalContainer()}>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}

View File

@@ -0,0 +1,42 @@
"use client";
import * as React from "react";
/**
* Opt-in container for popup portals (DropdownMenu, Popover, Dialog, HoverCard,
* Tooltip, …).
*
* Defaults to `undefined`, so Base UI portals popups to `document.body` exactly
* as before — production behavior is unchanged. An embedded surface that lives
* inside a CSS `transform` (e.g. the landing page's scaled product demo) can
* provide a node inside its own transformed box, so popups portal there and
* inherit the same scale instead of rendering at 1:1 over the page.
*
* A ref is accepted (resolved lazily by Base UI), so the provider can point at
* a node that mounts in the same render.
*/
export type PortalContainer =
| HTMLElement
| React.RefObject<HTMLElement | null>
| null
| undefined;
const PortalContainerContext = React.createContext<PortalContainer>(undefined);
export function PortalContainerProvider({
container,
children,
}: {
container: PortalContainer;
children: React.ReactNode;
}) {
return (
<PortalContainerContext.Provider value={container}>
{children}
</PortalContainerContext.Provider>
);
}
export function usePortalContainer(): PortalContainer {
return React.useContext(PortalContainerContext);
}

View File

@@ -16,6 +16,7 @@
"./markdown/mentions": "./markdown/mentions.ts",
"./hooks/*": "./hooks/*.ts",
"./lib/utils": "./lib/utils.ts",
"./lib/portal-container": "./lib/portal-container.tsx",
"./lib/data-table": "./lib/data-table.ts",
"./lib/code-style": "./lib/code-style.ts",
"./i18n-types": "./types/i18next.ts",

View File

@@ -1,5 +1,7 @@
export { StatusIcon } from "./status-icon";
export { StatusHeading } from "./status-heading";
export { BoardCardContent } from "./board-card";
export { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, StartDatePicker, DueDatePicker, LabelPicker } from "./pickers";
export { IssueDetail } from "./issue-detail";

View File

@@ -1 +1,2 @@
export { RuntimesPage, RuntimeDetailPage } from "./components";
export { ProviderLogo } from "./components/provider-logo";

3
pnpm-lock.yaml generated
View File

@@ -634,6 +634,9 @@ importers:
lucide-react:
specifier: 'catalog:'
version: 1.0.1(react@19.2.3)
motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next:
specifier: ^16.2.5
version: 16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)