mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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>
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
|
||||
15
apps/web/app/(landing)/newhome/page.tsx
Normal file
15
apps/web/app/(landing)/newhome/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -43,3 +43,144 @@
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
191
apps/web/features/landing/newhome/demo/demo-board.tsx
Normal file
191
apps/web/features/landing/newhome/demo/demo-board.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { 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.
|
||||
useState(() => {
|
||||
useIssueViewStore.setState({ statusFilters: [] });
|
||||
return true;
|
||||
});
|
||||
|
||||
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-[7px] 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
66
apps/web/features/landing/newhome/demo/demo-panels.tsx
Normal file
66
apps/web/features/landing/newhome/demo/demo-panels.tsx
Normal 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-[10px] 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-[10px] 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>
|
||||
);
|
||||
}
|
||||
125
apps/web/features/landing/newhome/demo/mock-api.ts
Normal file
125
apps/web/features/landing/newhome/demo/mock-api.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// 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,
|
||||
RUNNING_TASKS,
|
||||
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([]),
|
||||
listRuntimes: () => Promise.resolve([]),
|
||||
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([]),
|
||||
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: [] }),
|
||||
listIssuePullRequests: (issueId: string) =>
|
||||
Promise.resolve({ pull_requests: PULL_REQUESTS[issueId] ?? [] }),
|
||||
listTasksByIssue: (issueId: string) =>
|
||||
Promise.resolve(EXEC_LOG[issueId] ?? []),
|
||||
listAgentTasks: () => Promise.resolve([]),
|
||||
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;
|
||||
}
|
||||
433
apps/web/features/landing/newhome/demo/mock-data.ts
Normal file
433
apps/web/features/landing/newhome/demo/mock-data.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
// 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, AgentTask } 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";
|
||||
|
||||
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 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: "rt-demo",
|
||||
runtime_mode: "cloud",
|
||||
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,
|
||||
);
|
||||
|
||||
type Seed = {
|
||||
n: number;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
priority: IssuePriority;
|
||||
at: "member" | "agent";
|
||||
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: 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.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-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: "rt-demo",
|
||||
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,
|
||||
): 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;
|
||||
}
|
||||
|
||||
// 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-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-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: "rt-demo",
|
||||
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-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."),
|
||||
],
|
||||
};
|
||||
|
||||
// Mock skills for the "Skills" tab — reusable workflows agents can run.
|
||||
export const SKILLS: { name: string; description: string }[] = [
|
||||
{ name: "PR Review", description: "Read a diff, flag bugs & style issues, leave inline review comments." },
|
||||
{ name: "Bug Repro", description: "Turn a bug report into a minimal reproduction and a failing test." },
|
||||
{ name: "Release Notes", description: "Summarize merged PRs since the last tag into a changelog." },
|
||||
{ name: "Dependency Sweep", description: "Bump dependencies, run the suite, open a PR with the lockfile diff." },
|
||||
{ name: "Issue Triage", description: "Label, prioritize, and route inbound issues to the right owner." },
|
||||
{ name: "Docs Sync", description: "Keep API docs in lockstep with code changes on every merge." },
|
||||
];
|
||||
274
apps/web/features/landing/newhome/demo/value-board-demo.tsx
Normal file
274
apps/web/features/landing/newhome/demo/value-board-demo.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
// Value #1 — "See every agent on one board". A focused, auto-playing board:
|
||||
// agents are working in In Progress; one by one they finish and their card
|
||||
// advances to In Review / Done while the "working" chip ticks down, then it
|
||||
// loops. Built from the SAME product components the hero demo uses — real
|
||||
// BoardCardContent cards, real per-status column chrome (STATUS_CONFIG), the
|
||||
// real WorkspaceAgentWorkingChip — so it stays visually consistent with the
|
||||
// hero board. Cards are driven by local state (no api mutation), so it's
|
||||
// fully isolated from the hero demo's shared mock data.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { setApiInstance } from "@multica/core/api";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { agentTaskSnapshotKeys } from "@multica/core/agents";
|
||||
import {
|
||||
agentListOptions,
|
||||
memberListOptions,
|
||||
workspaceListOptions,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { useIssueViewStore } from "@multica/core/issues/stores/view-store";
|
||||
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
import {
|
||||
NavigationProvider,
|
||||
type NavigationAdapter,
|
||||
} from "@multica/views/navigation";
|
||||
import {
|
||||
BoardCardContent,
|
||||
StatusHeading,
|
||||
WorkspaceAgentWorkingChip,
|
||||
} from "@multica/views/issues/components";
|
||||
import type { AgentTask, Issue, IssueStatus } from "@multica/core/types";
|
||||
import { AGENTS, MEMBERS, WORKSPACE } from "./mock-data";
|
||||
import { createMockApi } from "./mock-api";
|
||||
import { DEMO_ZOOM } from "./zoom";
|
||||
|
||||
setApiInstance(createMockApi());
|
||||
|
||||
const WS_ID = "ws-demo";
|
||||
|
||||
// Natural (unscaled) size of the board canvas. 4 columns × 280 + 3 gaps × 16 +
|
||||
// p-1 ×2 = 1176 wide; chip row (44) + board (718) = 762 tall. Scaled by the
|
||||
// shared DEMO_ZOOM (0.85) the board ends up ~1000 × 648 — the same height as
|
||||
// the hero demo, so the two boards read as the same product, not a squashed
|
||||
// variant.
|
||||
const NATURAL_W = 1176;
|
||||
const NATURAL_H = 762;
|
||||
const BOARD_H = 718;
|
||||
|
||||
const NOOP_NAV: NavigationAdapter = {
|
||||
push: () => {},
|
||||
replace: () => {},
|
||||
back: () => {},
|
||||
pathname: "/demo/issues",
|
||||
searchParams: new URLSearchParams(),
|
||||
getShareableUrl: (p) => p,
|
||||
};
|
||||
|
||||
// Mirror the real board's column order/width (BOARD_COL_WIDTH = 280).
|
||||
const COLUMNS: IssueStatus[] = ["todo", "in_progress", "in_review", "done"];
|
||||
const COL_WIDTH = 280;
|
||||
const NOW = "2026-06-01T09:00:00Z";
|
||||
const STARTED = "2026-06-04T08:30:00Z";
|
||||
|
||||
function mk(
|
||||
n: number,
|
||||
title: string,
|
||||
status: IssueStatus,
|
||||
at: "member" | "agent",
|
||||
aid: string,
|
||||
priority: Issue["priority"],
|
||||
): Issue {
|
||||
return {
|
||||
id: `vb-${n}`,
|
||||
workspace_id: WS_ID,
|
||||
number: n,
|
||||
identifier: `MUL-${n}`,
|
||||
title,
|
||||
description: null,
|
||||
status,
|
||||
priority,
|
||||
assignee_type: at,
|
||||
assignee_id: aid,
|
||||
creator_type: "member",
|
||||
creator_id: "u-alex",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: n,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
metadata: {},
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
const INITIAL: Issue[] = [
|
||||
// Todo
|
||||
mk(214, "Design pricing page v2", "todo", "member", "u-alex", "high"),
|
||||
mk(218, "Add SSO (SAML) to enterprise plan", "todo", "member", "u-sam", "low"),
|
||||
mk(156, "Refactor billing webhooks handler", "todo", "agent", "a-kimi", "medium"),
|
||||
mk(241, "Audit npm dependencies for CVEs", "todo", "member", "u-alex", "low"),
|
||||
// In Progress — the agents currently working
|
||||
mk(129, "Implement OAuth login flow", "in_progress", "agent", "a-claude", "high"),
|
||||
mk(133, "Migrate analytics events to new schema", "in_progress", "agent", "a-gemini", "medium"),
|
||||
mk(138, "Fix flaky checkout E2E test", "in_progress", "agent", "a-codex", "medium"),
|
||||
mk(147, "Polish onboarding empty states", "in_progress", "member", "u-alex", "medium"),
|
||||
// In Review
|
||||
mk(119, "Write API docs for webhooks", "in_review", "member", "u-sam", "medium"),
|
||||
mk(124, "Weekly dependency upgrade", "in_review", "agent", "a-claude", "medium"),
|
||||
mk(122, "Improve search relevance scoring", "in_review", "member", "u-alex", "low"),
|
||||
// Done
|
||||
mk(108, "Ship dark-mode polish", "done", "member", "u-alex", "medium"),
|
||||
mk(131, "Nightly DB backup job", "done", "agent", "a-gemini", "medium"),
|
||||
mk(116, "Fix mobile nav overflow", "done", "member", "u-sam", "low"),
|
||||
];
|
||||
|
||||
// Each step advances one card. The "working" chip ticks 3 → 2 → 1 as agents
|
||||
// finish (Codex then Claude); Gemini stays working through the loop so the
|
||||
// chip never reads "0", then everything resets.
|
||||
const MOVES: { id: string; to: IssueStatus }[] = [
|
||||
{ id: "vb-138", to: "in_review" },
|
||||
{ id: "vb-138", to: "done" },
|
||||
{ id: "vb-129", to: "in_review" },
|
||||
{ id: "vb-129", to: "done" },
|
||||
];
|
||||
const STEP_MS = 2200;
|
||||
|
||||
// Build the running-task snapshot the real working chip reads from, derived
|
||||
// from whichever agent-assigned cards are currently in progress.
|
||||
function runningTasksFor(issues: Issue[]): AgentTask[] {
|
||||
return issues
|
||||
.filter((i) => i.assignee_type === "agent" && i.status === "in_progress")
|
||||
.map(
|
||||
(i) =>
|
||||
({
|
||||
id: `vt-${i.id}`,
|
||||
agent_id: i.assignee_id,
|
||||
runtime_id: "rt-demo",
|
||||
issue_id: i.id,
|
||||
status: "running",
|
||||
priority: 0,
|
||||
dispatched_at: STARTED,
|
||||
started_at: STARTED,
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
}) as unknown as AgentTask,
|
||||
);
|
||||
}
|
||||
|
||||
export function ValueBoardDemo() {
|
||||
const queryClient = useMemo(() => {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
qc.setQueryData(workspaceListOptions().queryKey, [WORKSPACE]);
|
||||
qc.setQueryData(memberListOptions(WS_ID).queryKey, MEMBERS);
|
||||
qc.setQueryData(agentListOptions(WS_ID).queryKey, AGENTS);
|
||||
qc.setQueryData(agentTaskSnapshotKeys.list(WS_ID), runningTasksFor(INITIAL));
|
||||
return qc;
|
||||
}, []);
|
||||
|
||||
const resources = useMemo(() => ({ en: RESOURCES.en }), []);
|
||||
|
||||
return (
|
||||
// Scale the natural-size board down by the shared DEMO_ZOOM so it renders at
|
||||
// the same scale as the hero demo. The visible box is sized to the scaled
|
||||
// dimensions and centered; the inner box lays out at full size.
|
||||
<div
|
||||
className="mx-auto overflow-hidden"
|
||||
style={{ width: NATURAL_W * DEMO_ZOOM, height: NATURAL_H * DEMO_ZOOM, maxWidth: "100%" }}
|
||||
>
|
||||
<div
|
||||
className="origin-top-left"
|
||||
style={{ width: NATURAL_W, transform: `scale(${DEMO_ZOOM})` }}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider locale="en" resources={resources}>
|
||||
<NavigationProvider value={NOOP_NAV}>
|
||||
<WorkspaceSlugProvider slug="demo">
|
||||
<ViewStoreProvider store={useIssueViewStore}>
|
||||
<Board />
|
||||
</ViewStoreProvider>
|
||||
</WorkspaceSlugProvider>
|
||||
</NavigationProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Board() {
|
||||
const qc = useQueryClient();
|
||||
const [issues, setIssues] = useState<Issue[]>(INITIAL);
|
||||
const [movedId, setMovedId] = useState<string | null>(null);
|
||||
const stepRef = useRef(0);
|
||||
|
||||
// Auto-play: advance one card per tick, loop after the last move.
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const step = stepRef.current;
|
||||
if (step >= MOVES.length) {
|
||||
stepRef.current = 0;
|
||||
setIssues(INITIAL);
|
||||
setMovedId(null);
|
||||
return;
|
||||
}
|
||||
const { id, to } = MOVES[step]!;
|
||||
stepRef.current = step + 1;
|
||||
setIssues((prev) => prev.map((i) => (i.id === id ? { ...i, status: to } : i)));
|
||||
setMovedId(id);
|
||||
window.setTimeout(() => setMovedId((m) => (m === id ? null : m)), 650);
|
||||
};
|
||||
const interval = window.setInterval(tick, STEP_MS);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Keep the working chip's snapshot in sync with the in-progress agents.
|
||||
useEffect(() => {
|
||||
qc.setQueryData(agentTaskSnapshotKeys.list(WS_ID), runningTasksFor(issues));
|
||||
}, [issues, qc]);
|
||||
|
||||
return (
|
||||
// Non-interactive: this is a playing illustration, not the interactive demo.
|
||||
<div className="pointer-events-none select-none">
|
||||
<div className="mb-3 flex items-center px-1">
|
||||
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />
|
||||
</div>
|
||||
{/* Pinned height so the board never reflows as cards move between
|
||||
columns; columns stretch to fill and scroll internally if needed. */}
|
||||
<div className="flex gap-4 overflow-x-auto p-1" style={{ height: BOARD_H }}>
|
||||
{COLUMNS.map((status) => {
|
||||
const cards = issues.filter((i) => i.status === status);
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
style={{ width: COL_WIDTH }}
|
||||
className={`flex shrink-0 flex-col rounded-xl ${cfg?.columnBg ?? "bg-muted/40"} p-2`}
|
||||
>
|
||||
<div className="mb-2 flex items-center px-1.5">
|
||||
<StatusHeading status={status} count={cards.length} />
|
||||
</div>
|
||||
<div className="relative flex-1 rounded-lg">
|
||||
<div className="absolute inset-0 space-y-2 overflow-y-auto rounded-lg p-1">
|
||||
{cards.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className={`group/card ${movedId === issue.id ? "newhome-card-land" : ""}`}
|
||||
>
|
||||
<BoardCardContent issue={issue} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/web/features/landing/newhome/demo/zoom.ts
Normal file
5
apps/web/features/landing/newhome/demo/zoom.ts
Normal 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;
|
||||
405
apps/web/features/landing/newhome/newhome-landing.tsx
Normal file
405
apps/web/features/landing/newhome/newhome-landing.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { 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 { githubUrl } 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>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
// Value #1's auto-playing board micro-demo. Client-only for the same reason as
|
||||
// DemoBoard (mock API singleton + browser-only providers).
|
||||
const ValueBoardDemo = dynamic(
|
||||
() => import("./demo/value-board-demo").then((m) => m.ValueBoardDemo),
|
||||
{
|
||||
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 agents."
|
||||
* 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 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Top-level information architecture (see MUL-2932 menu IA):
|
||||
// homepage · features · enterprise · pricing · resources(docs/changelog/…)
|
||||
const NAV_LINKS = [
|
||||
{ href: "#features", label: "Features" },
|
||||
{ href: "#enterprise", label: "Enterprise" },
|
||||
{ href: "#pricing", label: "Pricing" },
|
||||
{ href: "#resources", label: "Resources" },
|
||||
];
|
||||
|
||||
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-[9px] 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 agents.
|
||||
</h1>
|
||||
<p className="max-w-[440px] text-[16px] leading-7 text-[#0a0d12]/60 sm:text-[17px] lg:pb-2">
|
||||
Assign work, track progress, and automate execution across Claude
|
||||
Code, Codex, and every agent you run.
|
||||
</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 />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// The values section turns the hero's promise into concrete, watchable proof.
|
||||
// Each value pairs a short From→To claim with a focused, auto-playing micro-demo
|
||||
// built from the REAL product components. Value #1 ships first; #2–#4 follow.
|
||||
function ValuesSection() {
|
||||
return (
|
||||
<section id="features" className="border-t border-[#0a0d12]/8 bg-[#0a0d12]/[0.015] py-20 sm:py-24">
|
||||
<div className="mx-auto max-w-[1200px] px-5 sm:px-6 lg:px-8">
|
||||
<h2 className="max-w-[20ch] text-[1.9rem] font-semibold leading-[1.08] tracking-[-0.025em] sm:text-[2.3rem]">
|
||||
From scattered agent runs to work you can actually manage.
|
||||
</h2>
|
||||
|
||||
<ValueBlock
|
||||
eyebrow="Visibility"
|
||||
title="See every agent on one board"
|
||||
from="Agent work scattered across terminals, chats, repos, and scripts."
|
||||
to="Every task — queued, running, in review, done — on one board you can watch in real time."
|
||||
>
|
||||
<ValueBoardDemo />
|
||||
</ValueBlock>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Value layout: a compact text column (eyebrow + title + From→To) above a
|
||||
// full-width canvas, so the board demo gets the room to render at the real
|
||||
// product's size and read as the same board the hero shows.
|
||||
function ValueBlock({
|
||||
eyebrow,
|
||||
title,
|
||||
from,
|
||||
to,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
from: string;
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-12 lg:mt-16">
|
||||
<div className="max-w-[760px]">
|
||||
<p className="text-[12.5px] font-semibold uppercase tracking-[0.08em] text-[#0a0d12]/40">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h3 className="mt-2.5 text-[1.55rem] font-semibold leading-[1.12] tracking-[-0.02em]">
|
||||
{title}
|
||||
</h3>
|
||||
<dl className="mt-4 flex flex-col gap-2.5 sm:flex-row sm:gap-8">
|
||||
<div className="flex gap-3">
|
||||
<dt className="mt-0.5 shrink-0 text-[11px] font-semibold uppercase tracking-[0.06em] text-[#0a0d12]/35">
|
||||
From
|
||||
</dt>
|
||||
<dd className="text-[14.5px] leading-6 text-[#0a0d12]/55">{from}</dd>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<dt className="mt-0.5 shrink-0 text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--brand)]">
|
||||
To
|
||||
</dt>
|
||||
<dd className="text-[14.5px] font-medium leading-6 text-[#0a0d12]/80">{to}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Full-width demo canvas. landing-demo scopes the brand override +
|
||||
scrollbar hiding the product components expect. */}
|
||||
<div className="landing-demo mt-7 overflow-hidden rounded-[14px] border border-[#0a0d12]/10 bg-white p-3 shadow-[0_1px_3px_rgba(10,13,18,0.04)] sm:p-4">
|
||||
{children}
|
||||
</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-[8px] 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>
|
||||
);
|
||||
}
|
||||
|
||||
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-[12px] 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’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]",
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { RuntimesPage, RuntimeDetailPage } from "./components";
|
||||
export { ProviderLogo } from "./components/provider-logo";
|
||||
|
||||
Reference in New Issue
Block a user