Compare commits

...

5 Commits

Author SHA1 Message Date
Jiayuan
8945fd9482 fix(views): replace spinning border with subtle pulse for quick-create indicators
The animate-spin border-t ring was too visually aggressive. Replace with
a soft ring-2 ring-brand/40 animate-pulse — same calm pulse pattern the
ChatFab already uses for its running state.
2026-04-29 18:54:53 +02:00
Jiayuan
4561b2aef8 feat(views): replace toast with stacked progress indicators above Chat FAB
Replace the sonner toast-based Quick Capture progress with circular
indicators that stack above the Chat FAB button:

- Each pending quick-create shows the agent avatar in a circle with
  a spinning border ring
- Hover expands the pill to show "Agent is creating…"
- On success: green check overlay, identifier shown on hover, click
  to navigate to the new issue. Auto-fades after 3s.
- On failure: red X overlay, error message on hover. Auto-fades after 5s.
- Multiple concurrent tasks stack vertically above the FAB

Architecture:
- New QuickCreateStack component in packages/views/chat/
- Listens to inbox:new WS events to detect completion/failure
- Added agentId to QuickCreatePendingTask for avatar rendering
- Removed QuickCreateToasts and toast.loading() calls
- Mounted alongside ChatFab in both web and desktop layouts
2026-04-29 18:51:28 +02:00
Jiayuan
5c7865f304 fix(ui): move toast position to bottom-center to avoid Chat FAB overlap
The Sonner Toaster defaults to bottom-right which overlaps with the
Chat FAB button (also positioned at bottom-right of the main content
area). Move toasts to bottom-center to avoid the conflict.

The position prop is set before the spread so consumers can still
override it if needed.
2026-04-29 18:29:50 +02:00
Jiayuan
a4067c72c5 fix: treat 'dev' CLI version as always passing quick-create version gate
Dev builds (go run / untagged) report cli_version="dev" which is not a
valid semver string. Both frontend and server-side version checks treated
this as "missing" and blocked the Quick Create flow entirely.

Since dev builds are built from HEAD and are always at least as new as
any released version, "dev" should always pass the gate.

Fixed in both:
- packages/core/runtimes/cli-version.ts (frontend pre-check)
- server/pkg/agent/version.go (server trust boundary)
2026-04-29 18:21:10 +02:00
Jiayuan
eef7d8aed1 feat(views): show persistent progress toast for Quick Capture issue creation
Replace the ephemeral 4-second success toast with a persistent loading
toast that tracks the full lifecycle of a quick-create task:

- On submit: show "Agent is creating your issue…" loading toast
- On success: transition to a success toast with issue title + View action
- On failure: transition to an error toast with the failure reason

The implementation uses three layers:

1. quick-create-store.ts: tracks in-flight task IDs (non-persisted via
   partialize) so the WS handler can match inbox events to pending tasks
2. quick-create-toasts.tsx: global component that subscribes to inbox:new
   WS events and updates sonner toasts when a quick_create_done/failed
   item arrives matching a pending task
3. quick-create-issue.tsx: uses toast.loading() with the task_id as the
   toast key, enabling the tracker to update it in-place

Closes MUL-1597
2026-04-29 18:00:30 +02:00
11 changed files with 298 additions and 7 deletions

View File

@@ -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>

View File

@@ -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 />
</>

View File

@@ -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.

View File

@@ -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,
}),
},
),
);

View File

@@ -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 };

View File

@@ -14,6 +14,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
position="bottom-center"
theme={resolvedTheme as ToasterProps["theme"]}
className="toaster group"
icons={{

View 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>
);
}

View File

@@ -1,2 +1,3 @@
export { ChatFab } from "./components/chat-fab";
export { ChatWindow } from "./components/chat-window";
export { QuickCreateStack } from "./components/quick-create-stack";

View File

@@ -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

View File

@@ -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

View File

@@ -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