mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +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>
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* ReadonlyContent — lightweight markdown renderer for readonly content display.
|
|
*
|
|
* Replaces <ContentEditor editable={false}> for comment cards and other
|
|
* read-only surfaces. Uses react-markdown instead of a full Tiptap/ProseMirror
|
|
* instance, eliminating EditorView, Plugin, and NodeView overhead.
|
|
*
|
|
* Visual parity with ContentEditor is achieved by:
|
|
* - Wrapping output in <div class="rich-text-editor readonly"> so the same
|
|
* content-editor.css rules apply to standard HTML tags
|
|
* - Using the same preprocessMarkdown pipeline (mention shortcodes + linkify)
|
|
* - Using lowlight for code highlighting (same engine as Tiptap's CodeBlockLowlight)
|
|
* so .hljs-* CSS rules from content-editor.css produce identical colors
|
|
* - Rendering mentions with the same IssueMentionCard component and .mention class
|
|
*/
|
|
|
|
import { useMemo, useRef, useState } from "react";
|
|
import ReactMarkdown, {
|
|
defaultUrlTransform,
|
|
type Components,
|
|
} from "react-markdown";
|
|
import rehypeKatex from "rehype-katex";
|
|
import remarkBreaks from "remark-breaks";
|
|
import remarkGfm from "remark-gfm";
|
|
import remarkMath from "remark-math";
|
|
import rehypeRaw from "rehype-raw";
|
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
import { createLowlight, common } from "lowlight";
|
|
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
|
|
import { toHtml } from "hast-util-to-html";
|
|
import { Maximize2, Download, Link as LinkIcon, FileText } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
|
|
import { useNavigation } from "../navigation";
|
|
import { IssueMentionCard } from "../issues/components/issue-mention-card";
|
|
import { ImageLightbox } from "./extensions/image-view";
|
|
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
|
import { openLink, isMentionHref } from "./utils/link-handler";
|
|
import { preprocessMarkdown } from "./utils/preprocess";
|
|
import "katex/dist/katex.min.css";
|
|
import "./content-editor.css";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lowlight — same engine + language set as Tiptap's CodeBlockLowlight
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const lowlight = createLowlight(common);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const sanitizeSchema = {
|
|
...defaultSchema,
|
|
protocols: {
|
|
...defaultSchema.protocols,
|
|
href: [...(defaultSchema.protocols?.href ?? []), "mention"],
|
|
},
|
|
attributes: {
|
|
...defaultSchema.attributes,
|
|
div: [
|
|
...(defaultSchema.attributes?.div ?? []),
|
|
"dataType",
|
|
"dataHref",
|
|
"dataFilename",
|
|
],
|
|
code: [
|
|
...(defaultSchema.attributes?.code ?? []),
|
|
["className", /^language-/],
|
|
["className", /^math-/],
|
|
["className", /^hljs/],
|
|
],
|
|
img: [
|
|
...(defaultSchema.attributes?.img ?? []),
|
|
"alt",
|
|
],
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URL transform — allow mention:// protocol through react-markdown's sanitizer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function urlTransform(url: string): string {
|
|
if (url.startsWith("mention://")) return url;
|
|
return defaultUrlTransform(url);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom react-markdown components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
|
|
const { push, openInNewTab } = useNavigation();
|
|
const p = useWorkspacePaths();
|
|
const path = p.issueDetail(issueId);
|
|
return (
|
|
<span
|
|
className="inline align-middle"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
|
if (openInNewTab) {
|
|
openInNewTab(path, label);
|
|
}
|
|
return;
|
|
}
|
|
push(path);
|
|
}}
|
|
>
|
|
<IssueMentionCard issueId={issueId} fallbackLabel={label} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Named component so it can call useWorkspaceSlug() — arrow function inlined
|
|
// inside `components` below would still work, but extracting it keeps the
|
|
// hook usage explicit and avoids hook-in-object-literal surprises.
|
|
function ReadonlyLink({
|
|
href,
|
|
children,
|
|
}: {
|
|
href?: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const slug = useWorkspaceSlug();
|
|
|
|
if (isMentionHref(href)) {
|
|
const match = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/);
|
|
if (match?.[1] === "issue" && match[2]) {
|
|
const label =
|
|
typeof children === "string"
|
|
? children
|
|
: Array.isArray(children)
|
|
? children.join("")
|
|
: undefined;
|
|
return <IssueMentionLink issueId={match[2]} label={label} />;
|
|
}
|
|
// Member / agent / all mentions
|
|
return <span className="mention">{children}</span>;
|
|
}
|
|
|
|
// Regular links — open directly on click
|
|
return (
|
|
<a
|
|
href={href}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (href) openLink(href, slug);
|
|
}}
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
const components: Partial<Components> = {
|
|
// Links — route mention:// to mention components, others show preview card
|
|
a: ReadonlyLink,
|
|
|
|
// Images — centered with toolbar + lightbox (matches Tiptap ImageView NodeView)
|
|
img: function ReadonlyImage({ src, alt }) {
|
|
const [lightbox, setLightbox] = useState(false);
|
|
const imgSrc = typeof src === "string" ? src : "";
|
|
const imgAlt = alt ?? "";
|
|
|
|
const handleView = () => setLightbox(true);
|
|
const handleDownload = () => {
|
|
window.open(imgSrc, "_blank", "noopener,noreferrer");
|
|
};
|
|
const handleCopyLink = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(imgSrc);
|
|
toast.success("Link copied");
|
|
} catch {
|
|
toast.error("Failed to copy link");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<span className="image-node">
|
|
<span className="image-figure" onClick={handleView}>
|
|
<img src={imgSrc} alt={imgAlt} className="image-content" draggable={false} />
|
|
<span
|
|
className="image-toolbar"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button type="button" onClick={handleView} title="View image">
|
|
<Maximize2 className="size-3.5" />
|
|
</button>
|
|
<button type="button" onClick={handleDownload} title="Download">
|
|
<Download className="size-3.5" />
|
|
</button>
|
|
<button type="button" onClick={handleCopyLink} title="Copy link">
|
|
<LinkIcon className="size-3.5" />
|
|
</button>
|
|
</span>
|
|
</span>
|
|
{lightbox && (
|
|
<ImageLightbox src={imgSrc} alt={imgAlt} onClose={() => setLightbox(false)} />
|
|
)}
|
|
</span>
|
|
);
|
|
},
|
|
|
|
// FileCard — intercept <div data-type="fileCard"> from preprocessMarkdown
|
|
div: ({ node, children, ...props }) => {
|
|
const dataType = node?.properties?.dataType as string | undefined;
|
|
if (dataType === "fileCard") {
|
|
const rawHref = (node?.properties?.dataHref as string) || "";
|
|
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
|
|
const href = /^https?:\/\//i.test(rawHref) ? rawHref : "";
|
|
const filename = (node?.properties?.dataFilename as string) || "";
|
|
return (
|
|
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
|
|
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm">{filename}</p>
|
|
</div>
|
|
{href && (
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
|
onClick={() => window.open(href, "_blank", "noopener,noreferrer")}
|
|
>
|
|
<Download className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return <div {...props}>{children}</div>;
|
|
},
|
|
|
|
// Tables — wrap in tableWrapper div for border/radius/scroll (matches Tiptap)
|
|
table: ({ children }) => (
|
|
<div className="tableWrapper">
|
|
<table>{children}</table>
|
|
</div>
|
|
),
|
|
|
|
// Code — lowlight highlighting for blocks, plain render for inline
|
|
code: ({ className, children, node, ...props }) => {
|
|
const lang = /language-(\w+)/.exec(className || "")?.[1];
|
|
const isBlock =
|
|
node?.position &&
|
|
node.position.start.line !== node.position.end.line;
|
|
|
|
if (!isBlock && !lang) {
|
|
// Inline code — CSS handles styling via .rich-text-editor code
|
|
return <code {...props}>{children}</code>;
|
|
}
|
|
|
|
// Block code — highlight with lowlight, output hljs classes
|
|
const code = String(children).replace(/\n$/, "");
|
|
try {
|
|
const tree = lang
|
|
? lowlight.highlight(lang, code)
|
|
: lowlight.highlightAuto(code);
|
|
return (
|
|
<code
|
|
className={cn("hljs", lang && `language-${lang}`)}
|
|
dangerouslySetInnerHTML={{ __html: toHtml(tree) }}
|
|
/>
|
|
);
|
|
} catch {
|
|
// Fallback — render without highlighting
|
|
return (
|
|
<code className={className} {...props}>
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
},
|
|
|
|
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
|
|
pre: ({ children }) => <pre>{children}</pre>,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ReadonlyContentProps {
|
|
content: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
|
|
const processed = useMemo(() => preprocessMarkdown(content), [content]);
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const hover = useLinkHover(wrapperRef);
|
|
|
|
return (
|
|
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkMath, remarkBreaks, [remarkGfm, { singleTilde: false }]]}
|
|
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
|
|
urlTransform={urlTransform}
|
|
components={components}
|
|
>
|
|
{processed}
|
|
</ReactMarkdown>
|
|
<LinkHoverCard {...hover} />
|
|
</div>
|
|
);
|
|
}
|