mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Compare commits
1 Commits
agent/lamb
...
NevilleQin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa5dbd2681 |
@@ -19,6 +19,7 @@
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
|
||||
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
|
||||
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
|
||||
* `daemonAPI` (main-process daemon state) into the page so its empty
|
||||
* state can distinguish "no runtime registered" from "runtime is on its
|
||||
* way" — without the bundled daemon's status, the page shows a
|
||||
* misleading "Run multica daemon start" hint during the few seconds
|
||||
* between page load and the daemon's first registration.
|
||||
*
|
||||
* `bootstrapping` is true while the daemon is installing, starting, or
|
||||
* already running but hasn't surfaced as a server-side runtime yet.
|
||||
* RuntimeList only shows the spinner when the runtime list is also
|
||||
* empty, so once the daemon registers (and the list fills) the flag
|
||||
* has no visible effect.
|
||||
*/
|
||||
export function DesktopRuntimesPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
status.state === "installing_cli" ||
|
||||
status.state === "starting" ||
|
||||
status.state === "running";
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,8 @@ import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
@@ -114,7 +113,7 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
element: <DesktopRuntimesPage />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -21,11 +21,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
|
||||
* otherwise fall back to root (proxy / landing picks the user's first ws
|
||||
* or bounces to onboarding if still zero).
|
||||
*
|
||||
* The CLI install card is wired here so its `multica setup` command
|
||||
* points at THIS server — dev landing on localhost gets a localhost
|
||||
* self-host command, prod cloud gets the plain `multica setup`, prod
|
||||
* self-host gets one with explicit URLs. `appUrl` lives in useState
|
||||
* so SSR doesn't error on `window` — it fills in on mount.
|
||||
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
|
||||
* slot so the flow can render it inside the CLI dialog. The commands it
|
||||
* shows are hardcoded — nothing environmental to thread through.
|
||||
*/
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
@@ -36,11 +34,6 @@ export default function OnboardingPage() {
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user && hasOnboarded,
|
||||
});
|
||||
const [appUrl, setAppUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setAppUrl(window.location.origin);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
@@ -72,12 +65,7 @@ export default function OnboardingPage() {
|
||||
router.push(paths.root());
|
||||
}
|
||||
}}
|
||||
runtimeInstructions={
|
||||
<CliInstallInstructions
|
||||
apiUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
appUrl={appUrl}
|
||||
/>
|
||||
}
|
||||
runtimeInstructions={<CliInstallInstructions />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev:web": "turbo dev --filter=@multica/web",
|
||||
"dev:desktop": "turbo dev --filter=@multica/desktop",
|
||||
"dev:desktop:staging": "turbo dev:staging --filter=@multica/desktop",
|
||||
"build": "turbo build",
|
||||
"typecheck": "turbo typecheck",
|
||||
"test": "turbo test",
|
||||
|
||||
@@ -54,6 +54,7 @@ export type WSEventType =
|
||||
| "project:deleted"
|
||||
| "pin:created"
|
||||
| "pin:deleted"
|
||||
| "pin:reordered"
|
||||
| "invitation:created"
|
||||
| "invitation:accepted"
|
||||
| "invitation:declined"
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { useCurrentWorkspace, paths } from "@multica/core/paths";
|
||||
import type { QuestionnaireAnswers } from "@multica/core/onboarding";
|
||||
import { pinKeys } from "@multica/core/pins";
|
||||
import { projectKeys } from "@multica/core/projects";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -39,6 +43,7 @@ export function StarterContentPrompt() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const refreshMe = useAuthStore((s) => s.refreshMe);
|
||||
const { push } = useNavigation();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [submitting, setSubmitting] = useState<"import" | "dismiss" | null>(
|
||||
null,
|
||||
@@ -64,6 +69,17 @@ export function StarterContentPrompt() {
|
||||
});
|
||||
const result = await api.importStarterContent(payload);
|
||||
|
||||
// Mirror the `onSettled` pattern used by other mutations
|
||||
// (useCreatePin / useDeletePin / useReorderPins): the originating
|
||||
// session invalidates locally so the sidebar + board refresh
|
||||
// synchronously, independent of the WS round-trip. The server still
|
||||
// publishes `pin:created` / `project:created` / `issue:created` for
|
||||
// OTHER sessions; on this session both paths run and the second
|
||||
// invalidate is a no-op.
|
||||
qc.invalidateQueries({ queryKey: pinKeys.all(workspace.id, user.id) });
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(workspace.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) });
|
||||
|
||||
// Sync the new starter_content_state into the auth store so this
|
||||
// component unmounts cleanly on the next render.
|
||||
await refreshMe();
|
||||
|
||||
@@ -6,24 +6,7 @@ import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
|
||||
const INSTALL_CMD =
|
||||
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
|
||||
|
||||
const CLOUD_API_URL = "https://api.multica.ai";
|
||||
|
||||
/**
|
||||
* Build the right `multica setup` command for the current deployment.
|
||||
*
|
||||
* - Cloud (api.multica.ai) or no apiUrl hint → plain `multica setup`
|
||||
* (the CLI hardcodes the cloud endpoints inside setupCloud).
|
||||
* - Any other apiUrl → `multica setup self-host --server-url ... --app-url ...`
|
||||
* so dev (localhost) and on-prem both land on THIS server, not the
|
||||
* public cloud. Dev is just the localhost case of self-host — no
|
||||
* separate branch needed.
|
||||
*/
|
||||
function buildSetupCommand(apiUrl?: string, appUrl?: string): string {
|
||||
if (!apiUrl || apiUrl === CLOUD_API_URL) return "multica setup";
|
||||
const appPart = appUrl ? ` --app-url ${appUrl}` : "";
|
||||
return `multica setup self-host --server-url ${apiUrl}${appPart}`;
|
||||
}
|
||||
const SETUP_CMD = "multica setup";
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -50,67 +33,41 @@ function CopyButton({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI install instructions for the runtime step. Web-only by default —
|
||||
* desktop has a bundled daemon that auto-starts, so install guidance is
|
||||
* noise there. Rendered as an `instructions` slot inside the CLI dialog.
|
||||
*
|
||||
* Structure: two numbered steps shown in natural execution order. Install
|
||||
* (step 1) MUST come before setup (step 2) — without the `multica` binary
|
||||
* on PATH, step 2 can't run. A user who already has the CLI can safely
|
||||
* skip step 1, but the numbering stays for the majority case of a fresh
|
||||
* install.
|
||||
*
|
||||
* The `apiUrl` / `appUrl` props point the setup command at the right
|
||||
* server. The web shell passes `process.env.NEXT_PUBLIC_API_URL` and
|
||||
* `window.location.origin`; a self-host / dev deployment gets a
|
||||
* `multica setup self-host --server-url ... --app-url ...` command;
|
||||
* cloud gets the plain `multica setup`.
|
||||
*/
|
||||
export function CliInstallInstructions({
|
||||
apiUrl,
|
||||
appUrl,
|
||||
}: {
|
||||
apiUrl?: string;
|
||||
appUrl?: string;
|
||||
}) {
|
||||
const setupCmd = buildSetupCommand(apiUrl, appUrl);
|
||||
const steps = [
|
||||
{
|
||||
label: "Install the Multica CLI",
|
||||
cmd: INSTALL_CMD,
|
||||
note: null as string | null,
|
||||
},
|
||||
{
|
||||
label: "Start the daemon",
|
||||
cmd: setupCmd,
|
||||
note:
|
||||
"Opens a browser tab to sign you in, then starts a background daemon. The daemon keeps running after you close the terminal — your agents still pick up tasks.",
|
||||
},
|
||||
];
|
||||
function Step({ n, label, cmd }: { n: number; label: string; cmd: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium text-foreground">
|
||||
{n}. {label}
|
||||
</p>
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted px-3 py-2.5 font-mono text-sm">
|
||||
<Terminal className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<code className="min-w-0 flex-1 whitespace-pre-wrap break-all">
|
||||
{cmd}
|
||||
</code>
|
||||
<CopyButton text={cmd} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI install instructions — two copy-and-run commands. Hardcoded because
|
||||
* there's nothing environmental to infer: step 1 is the public install
|
||||
* script, step 2 is the cloud `multica setup` which the CLI itself knows
|
||||
* the endpoints for. Local development tests a self-host variant by
|
||||
* typing the extended command directly in the terminal; no need to
|
||||
* thread env vars through React.
|
||||
*/
|
||||
export function CliInstallInstructions() {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
{steps.map((step, i) => (
|
||||
<div key={i}>
|
||||
<p className="mb-1.5 text-xs font-medium text-foreground">
|
||||
{i + 1}. {step.label}
|
||||
</p>
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted px-3 py-2.5 font-mono text-sm">
|
||||
<Terminal className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<code className="min-w-0 flex-1 whitespace-pre-wrap break-all">
|
||||
{step.cmd}
|
||||
</code>
|
||||
<CopyButton text={step.cmd} />
|
||||
</div>
|
||||
{step.note && (
|
||||
<p className="mt-2 text-xs leading-[1.55] text-muted-foreground">
|
||||
{step.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs leading-[1.55] text-muted-foreground">
|
||||
You'll need a local AI coding tool (Claude Code, Codex,
|
||||
Cursor, …) installed for the runtime to do real work.
|
||||
</p>
|
||||
<Step n={1} label="Install the Multica CLI" cmd={INSTALL_CMD} />
|
||||
<Step n={2} label="Start the daemon" cmd={SETUP_CMD} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -371,16 +371,19 @@ function CliInstallDialog({
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => (o ? null : onClose())}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
{/* max-h + flex column so an unbounded runtime list (N machines)
|
||||
triggers internal scrolling instead of pushing the footer's
|
||||
Connect button below the viewport. */}
|
||||
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install the CLI</DialogTitle>
|
||||
<DialogDescription>
|
||||
Runs the same daemon the desktop app bundles — you install
|
||||
it yourself. This screen watches for it to come online.
|
||||
it yourself.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pt-2">
|
||||
{cliInstructions}
|
||||
|
||||
{/* Live probe. Shows a staged waiting message with elapsed-
|
||||
@@ -395,7 +398,10 @@ function CliInstallDialog({
|
||||
connected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Cap the runtime list at ~4 rows visible, scroll the rest.
|
||||
Keeps the commands above always reachable even when
|
||||
a user has many machines registered. */}
|
||||
<div className="flex max-h-[240px] flex-col gap-2 overflow-y-auto">
|
||||
{runtimes.map((rt) => (
|
||||
<CompactRuntimeRow
|
||||
key={rt.id}
|
||||
@@ -412,12 +418,16 @@ function CliInstallDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between gap-3 sm:justify-between">
|
||||
{/* Hint is only useful AFTER a runtime has registered — "pick
|
||||
one" / "selected X". While still waiting, the body's
|
||||
CliWaitingStatus already conveys the live-listening state,
|
||||
so an additional "Waiting..." footer line is duplication. */}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{canConnect && selectedName
|
||||
? `Selected: ${selectedName}`
|
||||
: hasRuntimes
|
||||
? "Pick a runtime above."
|
||||
: "Waiting for your runtime to come online…"}
|
||||
{hasRuntimes
|
||||
? canConnect && selectedName
|
||||
? `Selected: ${selectedName}`
|
||||
: "Pick a runtime above."
|
||||
: null}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
@@ -577,7 +587,7 @@ function CloudWaitlistDialog({
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => (o ? null : onClose())}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Join the cloud runtime waitlist</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -586,7 +596,7 @@ function CloudWaitlistDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
|
||||
<CloudWaitlistExpand
|
||||
submitted={submitted}
|
||||
onSubmitted={onSubmitted}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function StepQuestionnaire({
|
||||
Before we start
|
||||
</div>
|
||||
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
|
||||
Three questions. Then we tailor the rest.
|
||||
Three questions to get to know you.
|
||||
</h1>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-7">
|
||||
|
||||
@@ -61,9 +61,9 @@ export function StepWelcome({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-onboarding-enter grid h-full min-h-[640px] grid-cols-1 lg:grid-cols-2">
|
||||
<div className="animate-onboarding-enter flex h-full min-h-[640px] flex-col lg:flex-row">
|
||||
{/* Left — prose + CTA */}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col lg:flex-1">
|
||||
<DragStrip />
|
||||
<div className="flex flex-1 flex-col justify-center px-6 pb-12 sm:px-10 md:px-20 lg:px-20 xl:px-24">
|
||||
<div className="flex w-full max-w-[540px] flex-col gap-8">
|
||||
@@ -121,10 +121,15 @@ export function StepWelcome({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — mock issue cards illustration. Hidden on < lg. */}
|
||||
<div className="hidden border-l bg-muted/40 lg:flex lg:flex-col lg:overflow-hidden">
|
||||
{/* Right — mock issue cards illustration. Hidden on < lg.
|
||||
Flex row on lg+ with `items-stretch` (default) makes both
|
||||
columns take the container's full height, so the muted bg
|
||||
fills the viewport edge-to-edge. `justify-center` inside
|
||||
centers the mock cards vertically, mirroring the left
|
||||
column's copy-center layout. */}
|
||||
<div className="hidden border-l bg-muted/40 lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||
<DragStrip />
|
||||
<div className="flex flex-1 flex-col items-center gap-7 px-8 py-8">
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-7 px-8 py-8">
|
||||
<p className="max-w-[440px] text-balance text-center font-serif text-[15px] italic leading-snug text-muted-foreground">
|
||||
Every issue, every thread, every decision — shared by your team and
|
||||
agents.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Server, ArrowUpCircle, ChevronDown, Check } from "lucide-react";
|
||||
import { Server, ArrowUpCircle, ChevronDown, Check, Loader2 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -81,6 +81,7 @@ export function RuntimeList({
|
||||
ownerFilter,
|
||||
onOwnerFilterChange,
|
||||
updatableIds,
|
||||
bootstrapping,
|
||||
}: {
|
||||
runtimes: AgentRuntime[];
|
||||
selectedId: string;
|
||||
@@ -90,6 +91,15 @@ export function RuntimeList({
|
||||
ownerFilter: string | null;
|
||||
onOwnerFilterChange: (ownerId: string | null) => void;
|
||||
updatableIds?: Set<string>;
|
||||
/**
|
||||
* When true and no runtimes are visible, the empty state renders a
|
||||
* "starting" indicator instead of the static "register a runtime"
|
||||
* hint. The desktop shell sets this while its bundled daemon is
|
||||
* still booting / registering — without the hint, users see a
|
||||
* misleading "no runtimes" message during the few seconds between
|
||||
* page load and daemon registration. Web leaves this undefined.
|
||||
*/
|
||||
bootstrapping?: boolean;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
@@ -202,19 +212,31 @@ export function RuntimeList({
|
||||
</div>
|
||||
|
||||
{filteredRuntimes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Server className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{filter === "mine" ? "No runtimes owned by you" : ownerFilter ? "No runtimes for this owner" : "No runtimes registered"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
Run{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">
|
||||
multica daemon start
|
||||
</code>{" "}
|
||||
to register a local runtime.
|
||||
</p>
|
||||
</div>
|
||||
bootstrapping ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/60" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Starting local runtime…
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
This usually takes a few seconds.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Server className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{filter === "mine" ? "No runtimes owned by you" : ownerFilter ? "No runtimes for this owner" : "No runtimes registered"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
Run{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">
|
||||
multica daemon start
|
||||
</code>{" "}
|
||||
to register a local runtime.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredRuntimes.map((runtime) => (
|
||||
|
||||
@@ -23,9 +23,16 @@ type RuntimeFilter = "mine" | "all";
|
||||
interface RuntimesPageProps {
|
||||
/** Desktop-only slot rendered above the runtime list (e.g. local daemon card) */
|
||||
topSlot?: React.ReactNode;
|
||||
/**
|
||||
* Desktop-only signal: the bundled daemon is still booting / hasn't
|
||||
* registered with the server yet. Forwarded to RuntimeList so its
|
||||
* empty state shows a "starting" indicator instead of the static
|
||||
* "register a runtime" hint during the boot window. Web omits this.
|
||||
*/
|
||||
bootstrapping?: boolean;
|
||||
}
|
||||
|
||||
export default function RuntimesPage({ topSlot }: RuntimesPageProps = {}) {
|
||||
export default function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {}) {
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
@@ -115,6 +122,7 @@ export default function RuntimesPage({ topSlot }: RuntimesPageProps = {}) {
|
||||
ownerFilter={ownerFilter}
|
||||
onOwnerFilterChange={setOwnerFilter}
|
||||
updatableIds={updatableIds}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
@@ -244,6 +245,16 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
// Reject malformed UUIDs up front. Without this, `parseUUID` below
|
||||
// silently returns a zero-UUID and the membership check fails with
|
||||
// a misleading 403 "not a member of this workspace" instead of the
|
||||
// true 400. Defense-in-depth: even if the membership check is ever
|
||||
// refactored, a garbage workspace_id never reaches CreateProject /
|
||||
// CreateIssue.
|
||||
if _, err := uuid.Parse(req.WorkspaceID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is invalid")
|
||||
return
|
||||
}
|
||||
if req.Project.Title == "" {
|
||||
writeError(w, http.StatusBadRequest, "project.title is required")
|
||||
return
|
||||
@@ -410,26 +421,36 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- Pin project (and welcome issue if present) ---
|
||||
// Non-fatal: a pin failure shouldn't prevent the onboarding bundle
|
||||
// from landing. We warn and move on.
|
||||
// from landing. We warn and move on. Pointers to the created rows
|
||||
// are kept around for post-commit `pin:created` fan-out so the
|
||||
// sidebar refreshes without a manual reload.
|
||||
pinnedProjectPos := float64(1)
|
||||
if _, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
|
||||
var pinProjectForEvent *db.PinnedItem
|
||||
pinProject, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
|
||||
WorkspaceID: parseUUID(req.WorkspaceID),
|
||||
UserID: parseUUID(userID),
|
||||
ItemType: "project",
|
||||
ItemID: project.ID,
|
||||
Position: pinnedProjectPos,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("import starter content: pin project failed", append(logger.RequestAttrs(r), "error", err)...)
|
||||
} else {
|
||||
pinProjectForEvent = &pinProject
|
||||
}
|
||||
var pinWelcomeIssueForEvent *db.PinnedItem
|
||||
if welcomeIssueForEvent != nil {
|
||||
if _, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
|
||||
pinWelcome, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
|
||||
WorkspaceID: parseUUID(req.WorkspaceID),
|
||||
UserID: parseUUID(userID),
|
||||
ItemType: "issue",
|
||||
ItemID: welcomeIssueForEvent.ID,
|
||||
Position: pinnedProjectPos + 1,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("import starter content: pin welcome issue failed", append(logger.RequestAttrs(r), "error", err)...)
|
||||
} else {
|
||||
pinWelcomeIssueForEvent = &pinWelcome
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +488,15 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
|
||||
subResp := issueToResponse(sub, workspacePrefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": subResp})
|
||||
}
|
||||
// Pin events. Without these, the sidebar's `pinListOptions` query
|
||||
// stays cached on the pre-import snapshot — only a hard refresh
|
||||
// surfaces the new pins. Same payload shape as `POST /pins`.
|
||||
if pinProjectForEvent != nil {
|
||||
h.publish(protocol.EventPinCreated, req.WorkspaceID, "member", userID, map[string]any{"pin": pinnedItemToResponse(*pinProjectForEvent)})
|
||||
}
|
||||
if pinWelcomeIssueForEvent != nil {
|
||||
h.publish(protocol.EventPinCreated, req.WorkspaceID, "member", userID, map[string]any{"pin": pinnedItemToResponse(*pinWelcomeIssueForEvent)})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, importStarterContentResponse{
|
||||
User: userToResponse(updatedUser),
|
||||
|
||||
@@ -219,6 +219,11 @@ func (h *Handler) ReorderPins(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fan out so other sessions (web/desktop, or a second tab) refetch
|
||||
// the pin list and pick up the new order. Without this, reorder is
|
||||
// only consistent on the originating client until a hard refresh.
|
||||
h.publish(protocol.EventPinReordered, workspaceID, "member", userID, map[string]any{"items": req.Items})
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,9 @@ const (
|
||||
EventProjectDeleted = "project:deleted"
|
||||
|
||||
// Pin events
|
||||
EventPinCreated = "pin:created"
|
||||
EventPinDeleted = "pin:deleted"
|
||||
EventPinCreated = "pin:created"
|
||||
EventPinDeleted = "pin:deleted"
|
||||
EventPinReordered = "pin:reordered"
|
||||
|
||||
// Invitation events
|
||||
EventInvitationCreated = "invitation:created"
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"dev:staging": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^typecheck"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user