mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 00:49:22 +02:00
Compare commits
6 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8945fd9482 | ||
|
|
4561b2aef8 | ||
|
|
5c7865f304 | ||
|
|
a4067c72c5 | ||
|
|
eef7d8aed1 | ||
|
|
28b29ec5ee |
@@ -12,7 +12,7 @@ import {
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { ChatFab, ChatWindow, QuickCreateStack } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
@@ -162,6 +162,7 @@ export function DesktopShell() {
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <QuickCreateStack />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { ChatFab, ChatWindow, QuickCreateStack } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
@@ -15,6 +15,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<>
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<QuickCreateStack />
|
||||
<ChatFab />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -12,11 +12,29 @@ import { defaultStorage } from "../../platform/storage";
|
||||
// scoping comes for free from localStorage being browser-profile-local —
|
||||
// matches how draft-store / issues-scope-store / comment-collapse-store
|
||||
// already namespace themselves.
|
||||
|
||||
export interface QuickCreatePendingTask {
|
||||
taskId: string;
|
||||
prompt: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
export type QuickCreateResult =
|
||||
| { type: "done"; issueId: string; identifier: string; title: string }
|
||||
| { type: "failed"; error: string; originalPrompt: string };
|
||||
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
keepOpen: boolean;
|
||||
setKeepOpen: (v: boolean) => void;
|
||||
|
||||
// In-flight quick-create tasks (not persisted — ephemeral per session).
|
||||
// Keyed by task_id so the WS handler can resolve them.
|
||||
pendingTasks: Record<string, QuickCreatePendingTask>;
|
||||
addPendingTask: (task: QuickCreatePendingTask) => void;
|
||||
removePendingTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
@@ -26,10 +44,25 @@ export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
keepOpen: false,
|
||||
setKeepOpen: (v) => set({ keepOpen: v }),
|
||||
|
||||
pendingTasks: {},
|
||||
addPendingTask: (task) =>
|
||||
set((s) => ({
|
||||
pendingTasks: { ...s.pendingTasks, [task.taskId]: task },
|
||||
})),
|
||||
removePendingTask: (taskId) =>
|
||||
set((s) => {
|
||||
const { [taskId]: _, ...rest } = s.pendingTasks;
|
||||
return { pendingTasks: rest };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_quick_create",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({
|
||||
lastAgentId: state.lastAgentId,
|
||||
keepOpen: state.keepOpen,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -40,9 +40,16 @@ function lessThan(a: [number, number, number], b: [number, number, number]) {
|
||||
* Check a daemon-reported CLI version string against the minimum. Returns
|
||||
* `"missing"` for empty/unparsable input (fail closed — same policy as the
|
||||
* server) and `"too_old"` for a parsable version below the threshold.
|
||||
*
|
||||
* `"dev"` (from `go run` / untagged builds) is treated as always-ok: dev
|
||||
* builds are built from HEAD and are at least as new as any released version.
|
||||
*/
|
||||
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
|
||||
const current = (detected ?? "").trim();
|
||||
// Dev builds (go run, untagged) report "dev" — always pass the gate.
|
||||
if (current === "dev") {
|
||||
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
const parsed = current ? parseSemver(current) : null;
|
||||
if (!parsed) {
|
||||
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
|
||||
@@ -14,6 +14,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
position="bottom-center"
|
||||
theme={resolvedTheme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
|
||||
215
packages/views/chat/components/quick-create-stack.tsx
Normal file
215
packages/views/chat/components/quick-create-stack.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, X as XIcon } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useWSEvent } from "@multica/core/realtime";
|
||||
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { stripQuickCreatePrefix } from "../../inbox/components/inbox-display";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import type { InboxNewPayload } from "@multica/core/types";
|
||||
|
||||
interface ResolvedItem {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
result:
|
||||
| { type: "done"; issueId: string; identifier: string; title: string }
|
||||
| { type: "failed"; error: string };
|
||||
exiting: boolean;
|
||||
}
|
||||
|
||||
const DONE_VISIBLE_MS = 3000;
|
||||
const FAILED_VISIBLE_MS = 5000;
|
||||
const EXIT_ANIMATION_MS = 300;
|
||||
|
||||
/**
|
||||
* Stacked circular indicators above the Chat FAB showing in-flight
|
||||
* quick-create tasks. Each pill shows the agent avatar with a spinning
|
||||
* ring while pending, and transitions to a success/failure state when
|
||||
* the `inbox:new` WS event arrives.
|
||||
*
|
||||
* Hover expands the pill to reveal the agent name / result text.
|
||||
*/
|
||||
export function QuickCreateStack() {
|
||||
const pendingTasks = useQuickCreateStore((s) => s.pendingTasks);
|
||||
const removePendingTask = useQuickCreateStore((s) => s.removePendingTask);
|
||||
const paths = useWorkspacePaths();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [resolved, setResolved] = useState<Record<string, ResolvedItem>>({});
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
// Schedule auto-removal of a resolved item.
|
||||
const scheduleRemoval = useCallback((taskId: string, delayMs: number) => {
|
||||
// Phase 1: mark as exiting (triggers fade-out animation)
|
||||
const exitTimer = setTimeout(() => {
|
||||
setResolved((prev) => {
|
||||
const item = prev[taskId];
|
||||
if (!item) return prev;
|
||||
return { ...prev, [taskId]: { ...item, exiting: true } };
|
||||
});
|
||||
// Phase 2: remove from state after animation completes
|
||||
const removeTimer = setTimeout(() => {
|
||||
setResolved((prev) => {
|
||||
const { [taskId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
timersRef.current.delete(taskId);
|
||||
}, EXIT_ANIMATION_MS);
|
||||
timersRef.current.set(taskId, removeTimer);
|
||||
}, delayMs);
|
||||
timersRef.current.set(taskId, exitTimer);
|
||||
}, []);
|
||||
|
||||
// Clean up timers on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timersRef.current.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Listen for quick-create inbox events.
|
||||
const handler = useCallback(
|
||||
(payload: unknown) => {
|
||||
const { item } = payload as InboxNewPayload;
|
||||
if (!item) return;
|
||||
|
||||
const taskId = item.details?.task_id;
|
||||
if (!taskId) return;
|
||||
|
||||
const pending = useQuickCreateStore.getState().pendingTasks[taskId];
|
||||
if (!pending) return;
|
||||
|
||||
if (item.type === "quick_create_done") {
|
||||
const identifier = item.details?.identifier ?? "";
|
||||
const title = stripQuickCreatePrefix(item.title, identifier);
|
||||
setResolved((prev) => ({
|
||||
...prev,
|
||||
[taskId]: {
|
||||
taskId,
|
||||
agentId: pending.agentId,
|
||||
agentName: pending.agentName,
|
||||
result: {
|
||||
type: "done",
|
||||
issueId: item.issue_id ?? "",
|
||||
identifier,
|
||||
title: title || "Issue created",
|
||||
},
|
||||
exiting: false,
|
||||
},
|
||||
}));
|
||||
removePendingTask(taskId);
|
||||
scheduleRemoval(taskId, DONE_VISIBLE_MS);
|
||||
} else if (item.type === "quick_create_failed") {
|
||||
const error =
|
||||
item.details?.error || item.body || "Quick create did not finish";
|
||||
setResolved((prev) => ({
|
||||
...prev,
|
||||
[taskId]: {
|
||||
taskId,
|
||||
agentId: pending.agentId,
|
||||
agentName: pending.agentName,
|
||||
result: { type: "failed", error },
|
||||
exiting: false,
|
||||
},
|
||||
}));
|
||||
removePendingTask(taskId);
|
||||
scheduleRemoval(taskId, FAILED_VISIBLE_MS);
|
||||
}
|
||||
},
|
||||
[removePendingTask, scheduleRemoval],
|
||||
);
|
||||
|
||||
useWSEvent("inbox:new", handler);
|
||||
|
||||
// Merge pending + resolved into a single render list.
|
||||
const pendingItems = Object.values(pendingTasks);
|
||||
const resolvedItems = Object.values(resolved);
|
||||
const allItems = [...pendingItems.map((t) => ({ ...t, resolved: null as ResolvedItem | null })),
|
||||
...resolvedItems.map((r) => ({ taskId: r.taskId, agentId: r.agentId, agentName: r.agentName, prompt: "", resolved: r as ResolvedItem | null }))];
|
||||
|
||||
if (allItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-14 right-2 z-50 flex flex-col gap-2 items-end pointer-events-none">
|
||||
{allItems.map((item) => {
|
||||
const isDone = item.resolved?.result.type === "done";
|
||||
const isFailed = item.resolved?.result.type === "failed";
|
||||
const isPending = !item.resolved;
|
||||
const isExiting = item.resolved?.exiting ?? false;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isDone && item.resolved?.result.type === "done") {
|
||||
const issueId = item.resolved.result.issueId;
|
||||
if (issueId) navigation.push(paths.issueDetail(issueId));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.taskId}
|
||||
className={cn(
|
||||
"pointer-events-auto transition-all duration-300 ease-out",
|
||||
isExiting && "opacity-0 translate-y-2",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role={isDone ? "button" : undefined}
|
||||
onClick={isDone ? handleClick : undefined}
|
||||
className={cn(
|
||||
"group/pill relative flex items-center rounded-full bg-card shadow-sm overflow-hidden transition-all duration-200 ease-out h-8",
|
||||
"max-w-8 hover:max-w-72 hover:pr-3",
|
||||
isDone && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{/* Avatar circle */}
|
||||
<div className="relative size-8 shrink-0 flex items-center justify-center">
|
||||
<ActorAvatar actorType="agent" actorId={item.agentId} size={20} />
|
||||
|
||||
{/* Subtle pulsing ring for pending */}
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 rounded-full ring-2 ring-brand/40 animate-pulse pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Success ring + icon */}
|
||||
{isDone && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full ring-2 ring-emerald-500/60 pointer-events-none" />
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-emerald-500/20 pointer-events-none">
|
||||
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Failure ring + icon */}
|
||||
{isFailed && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full ring-2 ring-destructive/60 pointer-events-none" />
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-destructive/20 pointer-events-none">
|
||||
<XIcon className="size-3.5 text-destructive" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded label (visible on hover) */}
|
||||
<span className="text-xs whitespace-nowrap text-muted-foreground opacity-0 group-hover/pill:opacity-100 transition-opacity duration-150 ml-1.5 select-none">
|
||||
{isPending && `${item.agentName} is creating…`}
|
||||
{isDone && item.resolved?.result.type === "done" && (
|
||||
<span>
|
||||
<span className="text-foreground font-medium">{item.resolved.result.identifier}</span>
|
||||
{" created"}
|
||||
</span>
|
||||
)}
|
||||
{isFailed && "Failed to create issue"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { ChatFab } from "./components/chat-fab";
|
||||
export { ChatWindow } from "./components/chat-window";
|
||||
export { QuickCreateStack } from "./components/quick-create-stack";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeftRight, Check, ChevronRight, X as XIcon } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DialogTitle } from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -164,11 +164,14 @@ export function AgentCreatePanel({
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.quickCreateIssue({ agent_id: agentId, prompt: md });
|
||||
const { task_id } = await api.quickCreateIssue({ agent_id: agentId, prompt: md });
|
||||
setLastAgentId(agentId);
|
||||
setLastMode("agent");
|
||||
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
|
||||
duration: 4000,
|
||||
useQuickCreateStore.getState().addPendingTask({
|
||||
taskId: task_id,
|
||||
prompt: md,
|
||||
agentId: agentId,
|
||||
agentName: selectedAgent?.name ?? "Agent",
|
||||
});
|
||||
if (keepOpen) {
|
||||
// Stay open for continuous creation — clear the editor so the
|
||||
|
||||
388
packages/views/runtimes/components/connect-remote-dialog.tsx
Normal file
388
packages/views/runtimes/components/connect-remote-dialog.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Loader2,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeKeys } from "@multica/core/runtimes/queries";
|
||||
import { useWSEvent } from "@multica/core/realtime";
|
||||
import { paths, useWorkspaceSlug } from "@multica/core/paths";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useNavigation } from "../../navigation";
|
||||
|
||||
type Step = "instructions" | "waiting" | "success";
|
||||
|
||||
export function ConnectRemoteDialog({ onClose }: { onClose: () => void }) {
|
||||
const [step, setStep] = useState<Step>("instructions");
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const wsId = useWorkspaceId();
|
||||
const slug = useWorkspaceSlug();
|
||||
const qc = useQueryClient();
|
||||
const navigation = useNavigation();
|
||||
const newRuntimeIdRef = useRef<string | null>(null);
|
||||
|
||||
// Listen for a new runtime registration while the dialog is open
|
||||
const handleDaemonRegister = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (step === "waiting" || step === "instructions") {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
const p = payload as Record<string, unknown> | null;
|
||||
if (p?.runtime_id && typeof p.runtime_id === "string") {
|
||||
newRuntimeIdRef.current = p.runtime_id;
|
||||
}
|
||||
setStep("success");
|
||||
}
|
||||
},
|
||||
[step, qc, wsId],
|
||||
);
|
||||
useWSEvent("daemon:register", handleDaemonRegister);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const t = setTimeout(() => setCopied(null), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [copied]);
|
||||
|
||||
const handleGoToAgents = () => {
|
||||
onClose();
|
||||
if (slug) {
|
||||
navigation.push(paths.workspace(slug).agents());
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToRuntime = () => {
|
||||
onClose();
|
||||
if (slug && newRuntimeIdRef.current) {
|
||||
navigation.push(
|
||||
paths.workspace(slug).runtimeDetail(newRuntimeIdRef.current),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-xl">
|
||||
{step === "instructions" && (
|
||||
<InstructionsStep
|
||||
copied={copied}
|
||||
onCopy={copyToClipboard}
|
||||
onNext={() => setStep("waiting")}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{step === "waiting" && (
|
||||
<WaitingStep onBack={() => setStep("instructions")} />
|
||||
)}
|
||||
{step === "success" && (
|
||||
<SuccessStep
|
||||
onGoToAgents={handleGoToAgents}
|
||||
onGoToRuntime={
|
||||
newRuntimeIdRef.current ? handleGoToRuntime : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1: Installation instructions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INSTALL_CMD = "curl -fsSL https://multica.ai/install.sh | sh";
|
||||
|
||||
const CONFIGURE_CMD = `multica config set server_url https://api.multica.ai
|
||||
multica config set app_url https://multica.ai`;
|
||||
|
||||
const LOGIN_CMD = "multica login --token <YOUR_TOKEN>";
|
||||
|
||||
const START_CMD = `multica daemon start --device-name "my-ec2-instance"
|
||||
multica daemon status`;
|
||||
|
||||
function CodeBlock({
|
||||
code,
|
||||
copyKey,
|
||||
copied,
|
||||
onCopy,
|
||||
}: {
|
||||
code: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
}) {
|
||||
const isCopied = copied === copyKey;
|
||||
return (
|
||||
<div className="relative rounded-md border bg-muted/50">
|
||||
<pre className="overflow-x-auto p-2.5 pr-10 font-mono text-xs leading-relaxed text-foreground">
|
||||
{code}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(code, copyKey)}
|
||||
className="absolute top-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded border bg-background text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-3 w-3 text-success" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstructionsStep({
|
||||
copied,
|
||||
onCopy,
|
||||
onNext,
|
||||
onClose,
|
||||
}: {
|
||||
copied: string | null;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect a remote machine</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run these commands on your remote machine (e.g. AWS EC2) to install the
|
||||
Multica CLI and register it as a runtime.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="-mx-4 min-h-0 flex-1 overflow-y-auto px-4">
|
||||
<div className="space-y-3">
|
||||
{/* Step 1: Install */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
1. Install the CLI
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={INSTALL_CMD}
|
||||
copyKey="install"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Configure */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
2. Configure
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={CONFIGURE_CMD}
|
||||
copyKey="config"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Login */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
3. Login with a personal access token
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={LOGIN_CMD}
|
||||
copyKey="login"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Create one in{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Settings → Tokens
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Start daemon */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
4. Start the daemon
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={START_CMD}
|
||||
copyKey="start"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Security tips */}
|
||||
<div className="rounded-md border border-warning/30 bg-warning/5 p-2.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
<div className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Security: </span>
|
||||
Use an EC2 IAM role or least-privilege credentials. Never put
|
||||
root keys into agent{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
custom_env
|
||||
</code>
|
||||
. The daemon uses outbound connections only — no inbound ports
|
||||
needed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<details className="group pb-1">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground">
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Troubleshooting
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
</summary>
|
||||
<ul className="mt-1.5 list-disc space-y-0.5 pl-8 text-[11px] text-muted-foreground">
|
||||
<li>
|
||||
Check status:{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
multica daemon status
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
View logs:{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
multica daemon logs -f
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Verify provider:{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
claude --version
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Desktop auto-scans only your local machine. Remote machines must
|
||||
run{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
multica daemon
|
||||
</code>{" "}
|
||||
separately.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onNext}>
|
||||
I've started the daemon
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2: Waiting for registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function WaitingStep({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Waiting for runtime…</DialogTitle>
|
||||
<DialogDescription>
|
||||
Listening for your remote daemon to register. This page updates
|
||||
automatically — no need to refresh.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
multica daemon status
|
||||
</code>{" "}
|
||||
on the remote machine to verify it's running.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3: Success
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SuccessStep({
|
||||
onGoToAgents,
|
||||
onGoToRuntime,
|
||||
}: {
|
||||
onGoToAgents: () => void;
|
||||
onGoToRuntime?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Runtime connected!</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your remote machine has registered as a runtime. You can now create an
|
||||
agent that dispatches tasks to it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-success/10">
|
||||
<Check className="h-6 w-6 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onGoToRuntime && (
|
||||
<Button variant="ghost" onClick={onGoToRuntime}>
|
||||
View runtime
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onGoToAgents}>
|
||||
Create an agent
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Search, Server } from "lucide-react";
|
||||
import { Plus, Search, Server } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { ConnectRemoteDialog } from "./connect-remote-dialog";
|
||||
import { RuntimeList } from "./runtime-list";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
@@ -92,6 +93,7 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
const [scope, setScope] = useState<RuntimeFilter>("mine");
|
||||
const [healthFilter, setHealthFilter] = useState<HealthFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showConnectDialog, setShowConnectDialog] = useState(false);
|
||||
|
||||
// One unified cache per workspace: scope (Mine/All) is a view filter, not
|
||||
// a fetch dimension. Splitting on owner used to give us two TanStack cache
|
||||
@@ -154,14 +156,17 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<PageHeaderBar totalCount={totalCount} />
|
||||
<PageHeaderBar
|
||||
totalCount={totalCount}
|
||||
onConnectRemote={() => setShowConnectDialog(true)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-4 p-6">
|
||||
{topSlot}
|
||||
|
||||
{showEmpty ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<EmptyState />
|
||||
<EmptyState onConnectRemote={() => setShowConnectDialog(true)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
|
||||
@@ -189,6 +194,10 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showConnectDialog && (
|
||||
<ConnectRemoteDialog onClose={() => setShowConnectDialog(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,9 +207,15 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
// Page-level actions (Search, scope, filter) live in the card below.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PageHeaderBar({ totalCount }: { totalCount: number }) {
|
||||
function PageHeaderBar({
|
||||
totalCount,
|
||||
onConnectRemote,
|
||||
}: {
|
||||
totalCount: number;
|
||||
onConnectRemote: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PageHeader className="px-5">
|
||||
<PageHeader className="justify-between px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<h1 className="text-sm font-medium">Runtimes</h1>
|
||||
@@ -209,9 +224,6 @@ function PageHeaderBar({ totalCount }: { totalCount: number }) {
|
||||
{totalCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Tagline sits right next to the title — same flex group, single
|
||||
sentence + docs link. Hidden below md so it never collides with
|
||||
the title on narrow screens. */}
|
||||
<p className="ml-2 hidden text-xs text-muted-foreground md:block">
|
||||
Machines and cloud workers running CLI sessions for your agents.{" "}
|
||||
<a
|
||||
@@ -224,6 +236,10 @@ function PageHeaderBar({ totalCount }: { totalCount: number }) {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={onConnectRemote}>
|
||||
<Plus className="h-3 w-3" />
|
||||
Connect remote machine
|
||||
</Button>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -413,7 +429,7 @@ function HealthChip({
|
||||
// workspace. Different from "filter matches nothing" (NoMatchesState).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyState() {
|
||||
function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
@@ -421,12 +437,18 @@ function EmptyState() {
|
||||
</div>
|
||||
<h2 className="mt-4 text-base font-semibold">No runtimes yet</h2>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
Runtimes register automatically when a daemon connects. Run{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
multica daemon start
|
||||
</code>{" "}
|
||||
on your machine, or invite a teammate whose daemon is already running.
|
||||
Desktop auto-scans your local machine. For AWS EC2 or other remote
|
||||
machines, connect them using the setup wizard.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onConnectRemote}
|
||||
className="mt-5"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Connect remote machine
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,22 +32,21 @@ func BuildPrompt(task Task) string {
|
||||
|
||||
// buildQuickCreatePrompt constructs a prompt for quick-create tasks. The
|
||||
// user typed a single natural-language sentence in the create-issue modal;
|
||||
// the agent's job is to translate it into one `multica issue create` CLI
|
||||
// invocation, using its judgment to decide whether fetching referenced URLs
|
||||
// would produce a better issue. No issue exists yet, so the agent must NOT
|
||||
// call `multica issue get` or attempt to comment — there's nothing to read
|
||||
// or reply to.
|
||||
// the agent's only job is to translate it into one `multica issue create`
|
||||
// CLI invocation. No issue exists yet, so the agent must NOT call
|
||||
// `multica issue get` or attempt to comment — there's nothing to read or
|
||||
// reply to.
|
||||
func buildQuickCreatePrompt(task Task) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a quick-create assistant for a Multica workspace.\n\n")
|
||||
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your job is to create a well-formed issue from the user's input with a single `multica issue create` command.\n\n")
|
||||
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your only job is to translate the description into a single `multica issue create` command and run it.\n\n")
|
||||
fmt.Fprintf(&b, "User input:\n> %s\n\n", task.QuickCreatePrompt)
|
||||
b.WriteString("Field rules:\n")
|
||||
b.WriteString("- title: required. A concise but semantically rich summary that lets a reader understand what the issue is about at a glance. If the user input references external resources (PRs, issues, URLs, etc.), use your judgment to decide whether fetching the resource would produce a meaningfully better title — if so, fetch it and incorporate the relevant context. For example, \"review PR #123\" is much less useful than \"Review PR #123: Refactor auth module to OAuth2\". Strip filler words but preserve key semantic information.\n")
|
||||
b.WriteString("- description: always provide a rich, self-contained description. The created issue will be picked up and executed by an agent — the more accurate context the description contains, the better the agent will understand and execute the task. Use your judgment to gather context: if the user input contains URLs or references, fetch them and summarize the relevant parts. Spell out what needs to be done, what the background is, and any constraints or details that would help the executing agent avoid misunderstandings. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n")
|
||||
b.WriteString("- title: required. A short, imperative summary extracted from the user input (e.g. \"fix inbox loading\"). Strip filler words.\n")
|
||||
b.WriteString("- description: optional. Include only if the user supplied detail beyond the title; otherwise omit. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low. If unspecified, omit.\n")
|
||||
b.WriteString("- assignee:\n")
|
||||
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `Unrecognized assignee: X`.\n")
|
||||
b.WriteString(" - When the user names someone (\"分给 X\" / \"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `未识别 assignee: X`.\n")
|
||||
agentName := ""
|
||||
if task.Agent != nil {
|
||||
agentName = task.Agent.Name
|
||||
|
||||
@@ -41,6 +41,10 @@ func CheckMinCLIVersion(detected string) error {
|
||||
if d == "" {
|
||||
return ErrCLIVersionMissing
|
||||
}
|
||||
// Dev builds (go run, untagged) report "dev" — always pass the gate.
|
||||
if d == "dev" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := parseSemver(d)
|
||||
if err != nil {
|
||||
return ErrCLIVersionMissing
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,30 @@ func TestSemverLessThan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMinCLIVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
detected string
|
||||
wantErr error
|
||||
}{
|
||||
{"0.2.20", nil},
|
||||
{"0.2.21", nil},
|
||||
{"1.0.0", nil},
|
||||
{"dev", nil}, // dev builds always pass
|
||||
{"0.2.19", ErrCLIVersionTooOld},
|
||||
{"0.1.0", ErrCLIVersionTooOld},
|
||||
{"", ErrCLIVersionMissing},
|
||||
{"invalid", ErrCLIVersionMissing},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
err := CheckMinCLIVersion(tt.detected)
|
||||
if tt.wantErr == nil && err != nil {
|
||||
t.Errorf("CheckMinCLIVersion(%q) unexpected error: %v", tt.detected, err)
|
||||
} else if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
|
||||
t.Errorf("CheckMinCLIVersion(%q) = %v, want %v", tt.detected, err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMinVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
agentType string
|
||||
|
||||
Reference in New Issue
Block a user