mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
5 Commits
fix/selfho
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8945fd9482 | ||
|
|
4561b2aef8 | ||
|
|
5c7865f304 | ||
|
|
a4067c72c5 | ||
|
|
eef7d8aed1 |
@@ -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
|
||||
|
||||
@@ -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