mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* feat(agent-status): add workspace live-tasks endpoint and TaskFailureReason type Lays the API + type contract for the front-end agent presence cache: - New `GET /api/active-tasks` returns active (queued/dispatched/running) tasks plus failed tasks within the last 2 minutes for the current workspace. The 2-minute window powers a UI-side auto-clearing "Failed" agent state without back-end pollers. - `agent_task_queue` has no workspace_id column, so the query JOINs agent; `SELECT atq.*` keeps `failure_reason` (migration 055) on the wire. - Adds `TaskFailureReason` to `AgentTask` so the UI can map the 5 backend classifiers (agent_error / timeout / runtime_offline / runtime_recovery / manual) to copy without parsing free-text errors. - New `api.getActiveTasksForWorkspace()` client method; workspace is resolved server-side from the X-Workspace-Slug header (no path param, matching /api/agents and /api/runtimes conventions). Includes the joint engineering plan and designer brief that scope the broader Agent / Runtime status redesign — Phase 0 is this contract plus the front-end derivation layer landing in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent-status): derive presence/health states with WS sync and desktop IPC bridge Adds the front-end derivation layer that turns raw server data into the user-facing 5-state agent / 4-state runtime enums. UI files are deliberately untouched in this commit — derivation lives behind hooks (useAgentPresence, useRuntimeHealth) that any component can call with zero additional network traffic. Architecture: - Derivation is pure functions in packages/core/{agents,runtimes}; the back-end stays free of UI translation. Agents algorithm: runtime offline > recent failed (2-min window) > running > queued > available. Runtimes algorithm: status + last_seen_at -> online / recently_lost / offline / about_to_gc. - A single workspace-wide active-tasks query backs all per-agent presence reads, eliminating N+1 across hover cards, list rows, and pickers. 30-second tick re-renders the hooks so the failed window expires even when no underlying data changes. - WS task lifecycle events (dispatch / completed / failed / cancelled) invalidate active-tasks via the prefix dispatcher. completed/failed were removed from specificEvents so they go through both the prefix invalidate and the existing chat ws.on() handlers. Reconnect refetch picks up active-tasks too. - Desktop bridges window.daemonAPI.onStatusChange directly into the runtimes cache via setQueryData, giving the local daemon sub-second feedback (vs. 75s server sweep). Bridge is wsId-bound so workspace switches automatically rebind the subscription; daemon_id matching covers the same-daemon-multiple-providers case. 24 derivation unit tests cover all branches plus null/empty/boundary inputs (FAILED_WINDOW_MS edges, null last_seen_at, missing completed_at). Full core suite: 112 tests passing. Typecheck green across all 8 workspace packages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent-status): redesign agent runtime status as two orthogonal dimensions Splits the conflated 5-state agent presence into two independent axes: - AgentAvailability (3-state): online / unstable / offline — drives the dot indicator everywhere a dot appears. Pure runtime reachability; never sticky-red because of a past task outcome. - LastTaskState (5-state): running / completed / failed / cancelled / idle — surfaced as text + icon on focused surfaces (hover card, agent detail page, agents list, runtime detail). Never colours the dot. Major changes: * Domain layer: AgentPresence union → AgentAvailability + LastTaskState. derive-presence split into deriveAgentAvailability + deriveLastTaskState + deriveAgentPresenceDetail orchestrator. Tests reorganised into three groups (availability invariants, last-task invariants, composition). * Visual config: presenceConfig (5 entries) → availabilityConfig (3) + taskStateConfig (5). availabilityOrder + lastTaskOrder for filter chips. * Workspace-level presence prefetch: new useWorkspacePresencePrefetch hook + WorkspacePresencePrefetch mount component, wired into DashboardLayout (web) and WorkspaceRouteLayout (desktop). Hover cards render synchronously with no skeleton flash on first hover. * ActorAvatar hover: flipped default — disableHoverCard removed, enableHoverCard added (default false). Opt-in at ~14 decision-moment surfaces; pickers / decoration sub-chips stay plain. Status dot decoupled (showStatusDot prop) so picker rows can show presence without nesting popovers. * Hover cards: AgentProfileCard simplified — availability dot only, Detail link top-right (logs live on the detail page). New MemberProfileCard mirrors the structure: name + role + email + top-2 owned agents (sorted by 30d run count) with click-through to agent detail. * Agents list: split Status into two columns — availability (3-color dot + label) and Last run (task icon + label, optional running counts). Two independent filter chip groups (Status + Last run); combination acts as intersection ("online + failed" finds broken- but-alive agents). * Other UI surfaces (issue list/board/detail, comments, autopilots, projects, runtimes, mention autocomplete, subscribers picker) updated to the new dot semantics; status dot now strictly 3-color. Server changes accompany the client redesign — workspace-wide agent-task-snapshot endpoint, runtime usage queries, etc. — to feed the derive layer with the data it needs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent-detail): drop last-task chip from detail header + inspector The Recent work section on the agent detail page already shows the same data (with task titles, timestamps, error context) — surfacing "Completed" / "Failed" / etc. up in the header was redundant chrome. Detail surfaces now show only the 3-state availability dot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tables): handle narrow viewports across agents / skills / runtimes Three table layouts were squeezing content into adjacent cells at intermediate widths. Each fix is small and targeted: * runtime-list: the Runtime cell's base name had `shrink-0`, so it refused to truncate when its grid column was narrowed under width pressure — the name visually overflowed into the Health column ("ClaudeOnline" etc). Removed shrink-0, added truncate. The Health column was also a fixed 9.5rem reservation for the worst-case "Recently lost · 2m 14s ago" copy; switched to minmax(0,1fr) so it competes fairly with Runtime. * skills-page: had a single grid template with no responsive breakpoints — all 6 columns were rendered at any width and got visually jammed below md. Added a <md template that drops Source + Updated; the row markup hides those cells via `hidden md:block` / `md:contents`. * agent-list-item: the new Last run column was reserved at minmax(8rem, max-content); on narrow md viewports the 8rem floor pushed the row past available width. Changed to minmax(0,max-content) so the cell shrinks under pressure (its content already truncates). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent-card): hover-only Detail + add Runtime row + breathing room Three small polish tweaks to the agent hover card: - Detail link gets `mr-1` + fades in only on card hover (group-hover). It was visually flush against the popover edge and competing for attention; now it stays out of the way during a quick glance and surfaces only when the user is dwelling on the card. - Runtime row is back, in the meta block (cloud/local icon + runtime name). The earlier removal was over-aggressive — knowing where an agent runs is part of "who is this agent". The wifi badge stays dropped because the availability dot in the header already conveys reachability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(runtime): wifi-style health icon (4-state) for runtime list + agent card Replaces the 6px coloured dot with a wifi-shape icon that carries both state (Wifi vs WifiOff) and severity (success/warning/muted/destructive). Mapping: - online → Wifi (success) - recently_lost → WifiHigh (warning) — transient hiccup, fewer bars - offline → WifiOff (muted) — long unreachable - about_to_gc → WifiOff (destructive) — sweeper coming soon Used in two places: - Runtime list: replaces HealthDot in the dedicated leading-icon column. Bumped the column from 0.5rem (dot-sized) to 0.875rem (icon-sized). - Agent profile card RuntimeRow: derives runtime health from runtime + clock (matching the 4-state semantics) and renders HealthIcon next to the runtime name. Cloud runtimes always read as online. The duplicate signal with the header availability dot is intentional — it confirms WHICH runtime is the one currently in the dot's state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useCreateProject } from "@multica/core/projects/mutations";
|
|
import {
|
|
PROJECT_STATUS_CONFIG,
|
|
PROJECT_STATUS_ORDER,
|
|
PROJECT_PRIORITY_CONFIG,
|
|
PROJECT_PRIORITY_ORDER,
|
|
} from "@multica/core/projects/config";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
|
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
|
import { useActorName } from "@multica/core/workspace/hooks";
|
|
import type { ProjectStatus, ProjectPriority } from "@multica/core/types";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { toast } from "sonner";
|
|
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@multica/ui/components/ui/dropdown-menu";
|
|
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
|
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
|
|
import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor";
|
|
import { PriorityIcon } from "../issues/components/priority-icon";
|
|
import { ActorAvatar } from "../common/actor-avatar";
|
|
import { useNavigation } from "../navigation";
|
|
|
|
function PillButton({
|
|
children,
|
|
className,
|
|
...props
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
|
|
"hover:bg-accent/60 transition-colors cursor-pointer",
|
|
className,
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
|
const router = useNavigation();
|
|
const workspace = useCurrentWorkspace();
|
|
const workspaceName = workspace?.name;
|
|
const wsPaths = useWorkspacePaths();
|
|
const wsId = useWorkspaceId();
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
const { getActorName } = useActorName();
|
|
|
|
const [title, setTitle] = useState("");
|
|
const descEditorRef = useRef<ContentEditorRef>(null);
|
|
const [status, setStatus] = useState<ProjectStatus>("planned");
|
|
const [priority, setPriority] = useState<ProjectPriority>("none");
|
|
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
|
|
const [leadId, setLeadId] = useState<string | undefined>();
|
|
const [icon, setIcon] = useState<string | undefined>();
|
|
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
const [leadOpen, setLeadOpen] = useState(false);
|
|
const [leadFilter, setLeadFilter] = useState("");
|
|
|
|
const leadQuery = leadFilter.toLowerCase();
|
|
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
|
|
const filteredAgents = agents.filter(
|
|
(a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery),
|
|
);
|
|
|
|
const leadLabel = leadType && leadId ? getActorName(leadType, leadId) : "Lead";
|
|
|
|
const createProject = useCreateProject();
|
|
|
|
const handleSubmit = async () => {
|
|
if (!title.trim() || submitting) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const project = await createProject.mutateAsync({
|
|
title: title.trim(),
|
|
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
|
icon,
|
|
status,
|
|
priority,
|
|
lead_type: leadType,
|
|
lead_id: leadId,
|
|
});
|
|
onClose();
|
|
toast.success("Project created");
|
|
router.push(wsPaths.projectDetail(project.id));
|
|
} catch {
|
|
toast.error("Failed to create project");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className={cn(
|
|
"p-0 gap-0 flex flex-col overflow-hidden",
|
|
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
|
"!transition-all !duration-300 !ease-out",
|
|
isExpanded
|
|
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
|
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
|
|
)}
|
|
>
|
|
<DialogTitle className="sr-only">New Project</DialogTitle>
|
|
|
|
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<span className="text-muted-foreground">{workspaceName}</span>
|
|
<ChevronRight className="size-3 text-muted-foreground/50" />
|
|
<span className="font-medium">New project</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
|
>
|
|
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
|
</button>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
|
>
|
|
<XIcon className="size-4" />
|
|
</button>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">Close</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-5 pb-2 shrink-0">
|
|
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
|
|
<PopoverTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
|
|
title="Choose icon"
|
|
>
|
|
{icon || "📁"}
|
|
</button>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-auto p-0">
|
|
<EmojiPicker
|
|
onSelect={(emoji) => {
|
|
setIcon(emoji);
|
|
setIconPickerOpen(false);
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<TitleEditor
|
|
autoFocus
|
|
defaultValue=""
|
|
placeholder="Project title"
|
|
className="text-lg font-semibold"
|
|
onChange={(v) => setTitle(v)}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
|
<ContentEditor
|
|
ref={descEditorRef}
|
|
defaultValue=""
|
|
placeholder="Add description..."
|
|
debounceMs={500}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<PillButton>
|
|
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
|
|
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
|
|
</PillButton>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
{PROJECT_STATUS_ORDER.map((s) => (
|
|
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
|
|
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
|
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<PillButton>
|
|
<PriorityIcon priority={priority} />
|
|
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
|
|
</PillButton>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
{PROJECT_PRIORITY_ORDER.map((pr) => (
|
|
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
|
|
<PriorityIcon priority={pr} />
|
|
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Popover
|
|
open={leadOpen}
|
|
onOpenChange={(v) => {
|
|
setLeadOpen(v);
|
|
if (!v) setLeadFilter("");
|
|
}}
|
|
>
|
|
<PopoverTrigger
|
|
render={
|
|
<PillButton>
|
|
{leadType && leadId ? (
|
|
<>
|
|
<ActorAvatar actorType={leadType} actorId={leadId} size={16} showStatusDot />
|
|
<span>{leadLabel}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Lead</span>
|
|
)}
|
|
</PillButton>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-52 p-0">
|
|
<div className="px-2 py-1.5 border-b">
|
|
<input
|
|
type="text"
|
|
value={leadFilter}
|
|
onChange={(e) => setLeadFilter(e.target.value)}
|
|
placeholder="Assign lead..."
|
|
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
|
/>
|
|
</div>
|
|
<div className="p-1 max-h-60 overflow-y-auto">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setLeadType(undefined);
|
|
setLeadId(undefined);
|
|
setLeadOpen(false);
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
|
>
|
|
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-muted-foreground">No lead</span>
|
|
</button>
|
|
{filteredMembers.length > 0 && (
|
|
<>
|
|
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
Members
|
|
</div>
|
|
{filteredMembers.map((m) => (
|
|
<button
|
|
type="button"
|
|
key={m.user_id}
|
|
onClick={() => {
|
|
setLeadType("member");
|
|
setLeadId(m.user_id);
|
|
setLeadOpen(false);
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
|
>
|
|
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
|
<span>{m.name}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
{filteredAgents.length > 0 && (
|
|
<>
|
|
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
Agents
|
|
</div>
|
|
{filteredAgents.map((a) => (
|
|
<button
|
|
type="button"
|
|
key={a.id}
|
|
onClick={() => {
|
|
setLeadType("agent");
|
|
setLeadId(a.id);
|
|
setLeadOpen(false);
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={a.id} size={16} showStatusDot />
|
|
<span>{a.name}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
{filteredMembers.length === 0 &&
|
|
filteredAgents.length === 0 &&
|
|
leadFilter && (
|
|
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
|
No results
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
|
|
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
|
{submitting ? "Creating..." : "Create Project"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|