merge: resolve conflict in issue-detail breadcrumb

Keep identifier removed from breadcrumbs per design decision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-29 17:45:53 +08:00
commit 3a8aec7d08
34 changed files with 1606 additions and 1191 deletions

View file

@ -65,6 +65,7 @@ import { Label } from "@/components/ui/label";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useRuntimeStore } from "@/features/runtimes";
// ---------------------------------------------------------------------------
@ -1130,21 +1131,15 @@ export default function AgentsPage() {
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const [selectedId, setSelectedId] = useState<string>("");
const [showCreate, setShowCreate] = useState(false);
const [runtimes, setRuntimes] = useState<RuntimeDevice[]>([]);
const runtimes = useRuntimeStore((s) => s.runtimes);
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_agents_layout",
});
useEffect(() => {
if (!workspace) {
setRuntimes([]);
return;
}
api
.listRuntimes({ workspace_id: workspace.id })
.then(setRuntimes)
.catch(() => setRuntimes([]));
}, [workspace]);
if (workspace) fetchRuntimes();
}, [workspace, fetchRuntimes]);
// Select first agent on initial load
useEffect(() => {

View file

@ -134,6 +134,8 @@ vi.mock("@/shared/api", () => ({
const mockIssue: Issue = {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Implement authentication",
description: "Add JWT auth to the backend",
status: "in_progress",

View file

@ -211,6 +211,8 @@ const mockIssues: Issue[] = [
...issueDefaults,
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Implement auth",
description: "Add JWT authentication",
status: "todo",
@ -227,6 +229,8 @@ const mockIssues: Issue[] = [
...issueDefaults,
id: "issue-2",
workspace_id: "ws-1",
number: 2,
identifier: "TES-2",
title: "Design landing page",
description: null,
status: "in_progress",
@ -243,6 +247,8 @@ const mockIssues: Issue[] = [
...issueDefaults,
id: "issue-3",
workspace_id: "ws-1",
number: 3,
identifier: "TES-3",
title: "Write tests",
description: null,
status: "backlog",

View file

@ -20,7 +20,7 @@ export function ListRow({ issue }: { issue: Issue }) {
>
<PriorityIcon priority={issue.priority} />
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{issue.id.slice(0, 8)}
{issue.identifier}
</span>
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
{issue.due_date && (

View file

@ -0,0 +1,161 @@
import { useMemo } from "react";
import type { RuntimeUsage } from "@/shared/types";
import { formatTokens } from "../../utils";
const HEATMAP_WEEKS = 13;
const CELL_SIZE = 11;
const CELL_GAP = 2;
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
function getHeatmapColor(level: number): string {
const colors = [
"var(--color-muted, hsl(var(--muted)))",
"hsl(var(--chart-3) / 0.3)",
"hsl(var(--chart-3) / 0.5)",
"hsl(var(--chart-3) / 0.75)",
"hsl(var(--chart-3) / 1)",
];
return colors[level] ?? colors[0]!;
}
export function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) {
const { cells, monthLabels } = useMemo(() => {
const dateTokens = new Map<string, number>();
for (const u of usage) {
const total =
u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
dateTokens.set(u.date, (dateTokens.get(u.date) ?? 0) + total);
}
const today = new Date();
const todayDay = today.getDay();
const startOffset = todayDay + (HEATMAP_WEEKS - 1) * 7;
const startDate = new Date(today);
startDate.setDate(today.getDate() - startOffset);
const allCells: {
date: string;
dayOfWeek: number;
week: number;
tokens: number;
}[] = [];
const d = new Date(startDate);
for (let i = 0; i <= startOffset; i++) {
const dateStr = d.toISOString().slice(0, 10);
const dayOfWeek = d.getDay();
const week = Math.floor(i / 7);
allCells.push({
date: dateStr,
dayOfWeek,
week,
tokens: dateTokens.get(dateStr) ?? 0,
});
d.setDate(d.getDate() + 1);
}
const nonZero = allCells
.filter((c) => c.tokens > 0)
.map((c) => c.tokens);
nonZero.sort((a, b) => a - b);
const getLevel = (tokens: number) => {
if (tokens === 0) return 0;
if (nonZero.length <= 1) return 4;
const p = nonZero.indexOf(tokens) / (nonZero.length - 1);
if (p <= 0.25) return 1;
if (p <= 0.5) return 2;
if (p <= 0.75) return 3;
return 4;
};
const cellsWithLevel = allCells.map((c) => ({
...c,
level: getLevel(c.tokens),
}));
const months: { label: string; week: number }[] = [];
let lastMonth = -1;
for (const c of cellsWithLevel) {
const month = new Date(c.date + "T00:00:00").getMonth();
if (month !== lastMonth && c.dayOfWeek === 0) {
months.push({
label: new Date(c.date + "T00:00:00").toLocaleString("en", {
month: "short",
}),
week: c.week,
});
lastMonth = month;
}
}
return { cells: cellsWithLevel, monthLabels: months };
}, [usage]);
const labelWidth = 28;
const svgWidth = labelWidth + HEATMAP_WEEKS * (CELL_SIZE + CELL_GAP);
const svgHeight = 14 + 7 * (CELL_SIZE + CELL_GAP);
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Activity</h4>
<div className="overflow-x-auto">
<svg width={svgWidth} height={svgHeight} className="block">
{monthLabels.map((m) => (
<text
key={`${m.label}-${m.week}`}
x={labelWidth + m.week * (CELL_SIZE + CELL_GAP)}
y={10}
className="fill-muted-foreground"
fontSize={9}
>
{m.label}
</text>
))}
{DAY_LABELS.map((label, i) =>
label ? (
<text
key={i}
x={0}
y={14 + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1}
className="fill-muted-foreground"
fontSize={9}
>
{label}
</text>
) : null,
)}
{cells.map((c) => (
<rect
key={c.date}
x={labelWidth + c.week * (CELL_SIZE + CELL_GAP)}
y={14 + c.dayOfWeek * (CELL_SIZE + CELL_GAP)}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
fill={getHeatmapColor(c.level)}
className="transition-colors"
>
<title>
{c.date}:{" "}
{c.tokens > 0
? formatTokens(c.tokens) + " tokens"
: "No activity"}
</title>
</rect>
))}
</svg>
</div>
{/* Legend */}
<div className="mt-2 flex items-center justify-end gap-1 text-[10px] text-muted-foreground">
<span>Less</span>
{[0, 1, 2, 3, 4].map((level) => (
<div
key={level}
className="h-[10px] w-[10px] rounded-[2px]"
style={{ backgroundColor: getHeatmapColor(level) }}
/>
))}
<span>More</span>
</div>
</div>
);
}

View file

@ -0,0 +1,57 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import type { DailyCostData } from "../../utils";
const costChartConfig = {
cost: { label: "Cost", color: "hsl(var(--chart-1))" },
} satisfies ChartConfig;
export function DailyCostChart({ data }: { data: DailyCostData[] }) {
if (data.every((d) => d.cost === 0)) return null;
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Daily Estimated Cost</h4>
<ChartContainer config={costChartConfig} className="aspect-[2.5/1] w-full">
<BarChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(v: number) => `$${v}`}
width={50}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) =>
typeof value === "number" ? `$${value.toFixed(2)}` : String(value)
}
/>
}
/>
<Bar dataKey="cost" fill="var(--color-cost)" radius={[3, 3, 0, 0]} />
</BarChart>
</ChartContainer>
</div>
);
}

View file

@ -0,0 +1,93 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@/components/ui/chart";
import type { DailyTokenData } from "../../utils";
import { formatTokens } from "../../utils";
const tokenChartConfig = {
input: { label: "Input", color: "hsl(var(--chart-1))" },
output: { label: "Output", color: "hsl(var(--chart-2))" },
cacheRead: { label: "Cache Read", color: "hsl(var(--chart-3))" },
cacheWrite: { label: "Cache Write", color: "hsl(var(--chart-4))" },
} satisfies ChartConfig;
export function DailyTokenChart({ data }: { data: DailyTokenData[] }) {
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Daily Token Usage</h4>
<ChartContainer config={tokenChartConfig} className="aspect-[2.5/1] w-full">
<AreaChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(v: number) => formatTokens(v)}
width={50}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) =>
typeof value === "number" ? formatTokens(value) : String(value)
}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Area
type="monotone"
dataKey="input"
stackId="1"
stroke="var(--color-input)"
fill="var(--color-input)"
fillOpacity={0.4}
/>
<Area
type="monotone"
dataKey="output"
stackId="1"
stroke="var(--color-output)"
fill="var(--color-output)"
fillOpacity={0.4}
/>
<Area
type="monotone"
dataKey="cacheRead"
stackId="1"
stroke="var(--color-cacheRead)"
fill="var(--color-cacheRead)"
fillOpacity={0.4}
/>
<Area
type="monotone"
dataKey="cacheWrite"
stackId="1"
stroke="var(--color-cacheWrite)"
fill="var(--color-cacheWrite)"
fillOpacity={0.4}
/>
</AreaChart>
</ChartContainer>
</div>
);
}

View file

@ -0,0 +1,85 @@
import { useState, useEffect, useMemo } from "react";
import { BarChart3 } from "lucide-react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { api } from "@/shared/api";
import type { RuntimeHourlyActivity } from "@/shared/types";
const hourlyChartConfig = {
count: { label: "Tasks", color: "hsl(var(--chart-2))" },
} satisfies ChartConfig;
export function HourlyActivityChart({ runtimeId }: { runtimeId: string }) {
const [data, setData] = useState<RuntimeHourlyActivity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
api
.getRuntimeTaskActivity(runtimeId)
.then(setData)
.catch(() => setData([]))
.finally(() => setLoading(false));
}, [runtimeId]);
const chartData = useMemo(() => {
const map = new Map(data.map((d) => [d.hour, d.count]));
return Array.from({ length: 24 }, (_, i) => ({
hour: i,
label: `${i.toString().padStart(2, "0")}:00`,
count: map.get(i) ?? 0,
}));
}, [data]);
const hasData = chartData.some((d) => d.count > 0);
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Hourly Distribution</h4>
{loading ? (
<div className="flex h-[140px] items-center justify-center text-xs text-muted-foreground">
Loading...
</div>
) : !hasData ? (
<div className="flex h-[140px] flex-col items-center justify-center">
<BarChart3 className="h-5 w-5 text-muted-foreground/40" />
<p className="mt-2 text-xs text-muted-foreground">No task data yet</p>
</div>
) : (
<ChartContainer config={hourlyChartConfig} className="aspect-[2.5/1] w-full">
<BarChart data={chartData} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
interval={2}
fontSize={10}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={30}
allowDecimals={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="count" fill="var(--color-count)" radius={[2, 2, 0, 0]} />
</BarChart>
</ChartContainer>
)}
</div>
);
}

View file

@ -0,0 +1,5 @@
export { DailyTokenChart } from "./daily-token-chart";
export { DailyCostChart } from "./daily-cost-chart";
export { ModelDistributionChart } from "./model-distribution-chart";
export { ActivityHeatmap } from "./activity-heatmap";
export { HourlyActivityChart } from "./hourly-activity-chart";

View file

@ -0,0 +1,99 @@
import { PieChart, Pie, Cell, Label } from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import type { ModelDistribution } from "../../utils";
import { formatTokens } from "../../utils";
const MODEL_COLORS = [
"hsl(var(--chart-1))",
"hsl(var(--chart-2))",
"hsl(var(--chart-3))",
"hsl(var(--chart-4))",
"hsl(var(--chart-5))",
];
export function ModelDistributionChart({ data }: { data: ModelDistribution[] }) {
if (data.length === 0) return null;
const totalTokens = data.reduce((sum, d) => sum + d.tokens, 0);
const chartConfig = Object.fromEntries(
data.map((d, i) => [
d.model,
{ label: d.model, color: MODEL_COLORS[i % MODEL_COLORS.length] },
]),
) satisfies ChartConfig;
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Token Usage by Model</h4>
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[200px]">
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) =>
typeof value === "number" ? formatTokens(value) : String(value)
}
nameKey="model"
/>
}
/>
<Pie
data={data}
dataKey="tokens"
nameKey="model"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
>
{data.map((entry, i) => (
<Cell
key={entry.model}
fill={MODEL_COLORS[i % MODEL_COLORS.length]}
/>
))}
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-lg font-bold">
{formatTokens(totalTokens)}
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 18} className="fill-muted-foreground text-xs">
tokens
</tspan>
</text>
);
}
return null;
}}
/>
</Pie>
</PieChart>
</ChartContainer>
{/* Model legend with cost */}
<div className="mt-3 space-y-1.5">
{data.map((d, i) => (
<div key={d.model} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2 min-w-0">
<div
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: MODEL_COLORS[i % MODEL_COLORS.length] }}
/>
<span className="truncate font-mono">{d.model}</span>
</div>
<div className="flex items-center gap-3 shrink-0 text-muted-foreground tabular-nums">
<span>{formatTokens(d.tokens)}</span>
{d.cost > 0 && <span>${d.cost.toFixed(2)}</span>}
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Loader2, CheckCircle2, XCircle, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/shared/api";
import type { RuntimePingStatus } from "@/shared/types";
const pingStatusConfig: Record<
RuntimePingStatus,
{ label: string; icon: typeof Loader2; color: string }
> = {
pending: { label: "Waiting for daemon...", icon: Loader2, color: "text-muted-foreground" },
running: { label: "Running test...", icon: Loader2, color: "text-info" },
completed: { label: "Connected", icon: CheckCircle2, color: "text-success" },
failed: { label: "Failed", icon: XCircle, color: "text-destructive" },
timeout: { label: "Timeout", icon: XCircle, color: "text-warning" },
};
export function PingSection({ runtimeId }: { runtimeId: string }) {
const [status, setStatus] = useState<RuntimePingStatus | null>(null);
const [output, setOutput] = useState("");
const [error, setError] = useState("");
const [durationMs, setDurationMs] = useState<number | null>(null);
const [testing, setTesting] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cleanup = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
useEffect(() => cleanup, [cleanup]);
const handleTest = async () => {
cleanup();
setTesting(true);
setStatus("pending");
setOutput("");
setError("");
setDurationMs(null);
try {
const ping = await api.pingRuntime(runtimeId);
pollRef.current = setInterval(async () => {
try {
const result = await api.getPingResult(runtimeId, ping.id);
setStatus(result.status as RuntimePingStatus);
if (result.status === "completed") {
setOutput(result.output ?? "");
setDurationMs(result.duration_ms ?? null);
setTesting(false);
cleanup();
} else if (result.status === "failed" || result.status === "timeout") {
setError(result.error ?? "Unknown error");
setDurationMs(result.duration_ms ?? null);
setTesting(false);
cleanup();
}
} catch {
// ignore poll errors
}
}, 2000);
} catch {
setStatus("failed");
setError("Failed to initiate test");
setTesting(false);
}
};
const config = status ? pingStatusConfig[status] : null;
const Icon = config?.icon;
const isActive = status === "pending" || status === "running";
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
onClick={handleTest}
disabled={testing}
>
{testing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Zap className="h-3 w-3" />
)}
{testing ? "Testing..." : "Test Connection"}
</Button>
{config && Icon && (
<span className={`inline-flex items-center gap-1 text-xs ${config.color}`}>
<Icon className={`h-3 w-3 ${isActive ? "animate-spin" : ""}`} />
{config.label}
{durationMs != null && (
<span className="text-muted-foreground">
({(durationMs / 1000).toFixed(1)}s)
</span>
)}
</span>
)}
</div>
{status === "completed" && output && (
<div className="rounded-lg border bg-success/5 px-3 py-2">
<pre className="text-xs font-mono whitespace-pre-wrap">{output}</pre>
</div>
)}
{(status === "failed" || status === "timeout") && error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
<p className="text-xs text-destructive">{error}</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,90 @@
import type { AgentRuntime } from "@/shared/types";
import { formatLastSeen } from "../utils";
import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared";
import { PingSection } from "./ping-section";
import { UsageSection } from "./usage-section";
export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex min-w-0 items-center gap-2">
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md ${
runtime.status === "online" ? "bg-success/10" : "bg-muted"
}`}
>
<RuntimeModeIcon mode={runtime.runtime_mode} />
</div>
<div className="min-w-0">
<h2 className="text-sm font-semibold truncate">{runtime.name}</h2>
</div>
</div>
<StatusBadge status={runtime.status} />
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Info grid */}
<div className="grid grid-cols-2 gap-4">
<InfoField label="Runtime Mode" value={runtime.runtime_mode} />
<InfoField label="Provider" value={runtime.provider} />
<InfoField label="Status" value={runtime.status} />
<InfoField
label="Last Seen"
value={formatLastSeen(runtime.last_seen_at)}
/>
{runtime.device_info && (
<InfoField label="Device" value={runtime.device_info} />
)}
{runtime.daemon_id && (
<InfoField label="Daemon ID" value={runtime.daemon_id} mono />
)}
</div>
{/* Connection Test */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-3">
Connection Test
</h3>
<PingSection runtimeId={runtime.id} />
</div>
{/* Usage */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-3">
Token Usage
</h3>
<UsageSection runtimeId={runtime.id} />
</div>
{/* Metadata */}
{runtime.metadata && Object.keys(runtime.metadata).length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2">
Metadata
</h3>
<div className="rounded-lg border bg-muted/30 p-3">
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
{JSON.stringify(runtime.metadata, null, 2)}
</pre>
</div>
</div>
)}
{/* Timestamps */}
<div className="grid grid-cols-2 gap-4 border-t pt-4">
<InfoField
label="Created"
value={new Date(runtime.created_at).toLocaleString()}
/>
<InfoField
label="Updated"
value={new Date(runtime.updated_at).toLocaleString()}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,89 @@
import { Server } from "lucide-react";
import type { AgentRuntime } from "@/shared/types";
import { RuntimeModeIcon } from "./shared";
function RuntimeListItem({
runtime,
isSelected,
onClick,
}: {
runtime: AgentRuntime;
isSelected: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors ${
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
runtime.status === "online" ? "bg-success/10" : "bg-muted"
}`}
>
<RuntimeModeIcon mode={runtime.runtime_mode} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{runtime.name}</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{runtime.provider} &middot; {runtime.runtime_mode}
</div>
</div>
<div
className={`h-2 w-2 shrink-0 rounded-full ${
runtime.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</button>
);
}
export function RuntimeList({
runtimes,
selectedId,
onSelect,
}: {
runtimes: AgentRuntime[];
selectedId: string;
onSelect: (id: string) => void;
}) {
return (
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Runtimes</h1>
<span className="text-xs text-muted-foreground">
{runtimes.filter((r) => r.status === "online").length}/
{runtimes.length} online
</span>
</div>
{runtimes.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Server className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">
No runtimes registered
</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Run{" "}
<code className="rounded bg-muted px-1 py-0.5">
multica daemon start
</code>{" "}
to register a local runtime.
</p>
</div>
) : (
<div className="divide-y">
{runtimes.map((runtime) => (
<RuntimeListItem
key={runtime.id}
runtime={runtime}
isSelected={runtime.id === selectedId}
onClick={() => onSelect(runtime.id)}
/>
))}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
import { Monitor, Cloud, Wifi, WifiOff } from "lucide-react";
import { Badge } from "@/components/ui/badge";
export function RuntimeModeIcon({ mode }: { mode: string }) {
return mode === "cloud" ? (
<Cloud className="h-3.5 w-3.5" />
) : (
<Monitor className="h-3.5 w-3.5" />
);
}
export function StatusBadge({ status }: { status: string }) {
const isOnline = status === "online";
return (
<Badge
variant="secondary"
className={isOnline ? "bg-success/10 text-success" : ""}
>
{isOnline ? (
<Wifi className="h-3 w-3" />
) : (
<WifiOff className="h-3 w-3" />
)}
{isOnline ? "Online" : "Offline"}
</Badge>
);
}
export function InfoField({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div>
<div className="text-xs text-muted-foreground">{label}</div>
<div
className={`mt-0.5 text-sm truncate ${mono ? "font-mono text-xs" : ""}`}
>
{value}
</div>
</div>
);
}
export function TokenCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">{value}</div>
</div>
);
}

View file

@ -0,0 +1,172 @@
"use client";
import { useState, useEffect } from "react";
import { BarChart3 } from "lucide-react";
import type { RuntimeUsage } from "@/shared/types";
import { api } from "@/shared/api";
import { formatTokens, estimateCost, aggregateByDate } from "../utils";
import { TokenCard } from "./shared";
import {
ActivityHeatmap,
HourlyActivityChart,
DailyTokenChart,
DailyCostChart,
ModelDistributionChart,
} from "./charts";
const TIME_RANGES = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
] as const;
type TimeRange = (typeof TIME_RANGES)[number]["days"];
export function UsageSection({ runtimeId }: { runtimeId: string }) {
const [usage, setUsage] = useState<RuntimeUsage[]>([]);
const [loading, setLoading] = useState(true);
const [days, setDays] = useState<TimeRange>(30);
useEffect(() => {
setLoading(true);
api
.getRuntimeUsage(runtimeId, { days: 90 }) // always fetch 90d, filter client-side
.then(setUsage)
.catch(() => setUsage([]))
.finally(() => setLoading(false));
}, [runtimeId]);
if (loading) {
return (
<div className="text-xs text-muted-foreground">Loading usage...</div>
);
}
if (usage.length === 0) {
return (
<div className="flex flex-col items-center rounded-lg border border-dashed py-6">
<BarChart3 className="h-5 w-5 text-muted-foreground/40" />
<p className="mt-2 text-xs text-muted-foreground">No usage data yet</p>
</div>
);
}
// Filter by selected time range
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const cutoff = cutoffDate.toISOString().slice(0, 10);
const filtered = usage.filter((u) => u.date >= cutoff);
// Compute totals
const totals = filtered.reduce(
(acc, u) => ({
input: acc.input + u.input_tokens,
output: acc.output + u.output_tokens,
cacheRead: acc.cacheRead + u.cache_read_tokens,
cacheWrite: acc.cacheWrite + u.cache_write_tokens,
cost: acc.cost + estimateCost(u),
}),
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
);
const { dailyTokens, dailyCost, modelDist } = aggregateByDate(filtered);
// Group by date for the table
const byDate = new Map<string, RuntimeUsage[]>();
for (const u of filtered) {
const existing = byDate.get(u.date) ?? [];
existing.push(u);
byDate.set(u.date, existing);
}
return (
<div className="space-y-4">
{/* Time range selector */}
<div className="flex items-center gap-1">
{TIME_RANGES.map((range) => (
<button
key={range.days}
onClick={() => setDays(range.days)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
days === range.days
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
{range.label}
</button>
))}
</div>
{/* Summary cards */}
<div className="grid grid-cols-4 gap-3">
<TokenCard label="Input" value={formatTokens(totals.input)} />
<TokenCard label="Output" value={formatTokens(totals.output)} />
<TokenCard label="Cache Read" value={formatTokens(totals.cacheRead)} />
<TokenCard label="Cache Write" value={formatTokens(totals.cacheWrite)} />
</div>
{totals.cost > 0 && (
<div className="rounded-lg border bg-muted/30 px-3 py-2">
<span className="text-xs text-muted-foreground">
Estimated cost ({days}d):{" "}
</span>
<span className="text-sm font-semibold">
${totals.cost.toFixed(2)}
</span>
</div>
)}
{/* Heatmap + Hourly */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<ActivityHeatmap usage={usage} />
<HourlyActivityChart runtimeId={runtimeId} />
</div>
{/* Token & Cost charts */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<DailyTokenChart data={dailyTokens} />
<DailyCostChart data={dailyCost} />
</div>
<ModelDistributionChart data={modelDist} />
{/* Daily breakdown table */}
<div className="rounded-lg border">
<div className="grid grid-cols-[100px_1fr_80px_80px_80px_80px] gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
<div>Date</div>
<div>Model</div>
<div className="text-right">Input</div>
<div className="text-right">Output</div>
<div className="text-right">Cache R</div>
<div className="text-right">Cache W</div>
</div>
<div className="max-h-64 overflow-y-auto divide-y">
{[...byDate.entries()].map(([date, rows]) =>
rows.map((row, i) => (
<div
key={`${date}-${row.model}-${i}`}
className="grid grid-cols-[100px_1fr_80px_80px_80px_80px] gap-2 px-3 py-1.5 text-xs"
>
<div className="text-muted-foreground">{date}</div>
<div className="truncate font-mono">{row.model}</div>
<div className="text-right tabular-nums">
{formatTokens(row.input_tokens)}
</div>
<div className="text-right tabular-nums">
{formatTokens(row.output_tokens)}
</div>
<div className="text-right tabular-nums">
{formatTokens(row.cache_read_tokens)}
</div>
<div className="text-right tabular-nums">
{formatTokens(row.cache_write_tokens)}
</div>
</div>
)),
)}
</div>
</div>
</div>
);
}

View file

@ -1 +1,2 @@
export { RuntimesPage } from "./components";
export { useRuntimeStore } from "./store";

View file

@ -0,0 +1,70 @@
"use client";
import { create } from "zustand";
import type { AgentRuntime } from "@/shared/types";
import { api } from "@/shared/api";
import { useWorkspaceStore } from "@/features/workspace";
interface RuntimeState {
runtimes: AgentRuntime[];
selectedId: string;
fetching: boolean;
}
interface RuntimeActions {
fetchRuntimes: () => Promise<void>;
setSelectedId: (id: string) => void;
/** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */
patchRuntime: (id: string, updates: Partial<AgentRuntime>) => void;
/** Replace the full runtimes list (used on daemon:register events). */
setRuntimes: (runtimes: AgentRuntime[]) => void;
}
type RuntimeStore = RuntimeState & RuntimeActions;
export const useRuntimeStore = create<RuntimeStore>((set, get) => ({
// State
runtimes: [],
selectedId: "",
fetching: true,
// Actions
fetchRuntimes: async () => {
const workspace = useWorkspaceStore.getState().workspace;
if (!workspace) return;
try {
const data = await api.listRuntimes({ workspace_id: workspace.id });
const { selectedId } = get();
set({
runtimes: data,
fetching: false,
// Auto-select first if nothing selected
selectedId: selectedId && data.some((r) => r.id === selectedId)
? selectedId
: data[0]?.id ?? "",
});
} catch {
set({ fetching: false });
}
},
setSelectedId: (id) => set({ selectedId: id }),
patchRuntime: (id, updates) => {
set((state) => ({
runtimes: state.runtimes.map((r) =>
r.id === id ? { ...r, ...updates } : r,
),
}));
},
setRuntimes: (runtimes) => {
const { selectedId } = get();
set({
runtimes,
selectedId: selectedId && runtimes.some((r) => r.id === selectedId)
? selectedId
: runtimes[0]?.id ?? "",
});
},
}));

View file

@ -0,0 +1,141 @@
import type { RuntimeUsage } from "@/shared/types";
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
export function formatLastSeen(lastSeenAt: string | null): string {
if (!lastSeenAt) return "Never";
const diff = Date.now() - new Date(lastSeenAt).getTime();
if (diff < 60_000) return "Just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
export function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Cost estimation
// ---------------------------------------------------------------------------
// Pricing per million tokens (USD)
const MODEL_PRICING: Record<
string,
{ input: number; output: number; cacheRead: number; cacheWrite: number }
> = {
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
export function estimateCost(usage: RuntimeUsage): number {
const model = usage.model;
let pricing = MODEL_PRICING[model];
if (!pricing) {
for (const [key, p] of Object.entries(MODEL_PRICING)) {
if (model.startsWith(key)) {
pricing = p;
break;
}
}
}
if (!pricing) return 0;
return (
(usage.input_tokens * pricing.input +
usage.output_tokens * pricing.output +
usage.cache_read_tokens * pricing.cacheRead +
usage.cache_write_tokens * pricing.cacheWrite) /
1_000_000
);
}
// ---------------------------------------------------------------------------
// Data aggregation
// ---------------------------------------------------------------------------
export interface DailyTokenData {
date: string;
label: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
export interface DailyCostData {
date: string;
label: string;
cost: number;
}
export interface ModelDistribution {
model: string;
tokens: number;
cost: number;
}
export function aggregateByDate(usage: RuntimeUsage[]): {
dailyTokens: DailyTokenData[];
dailyCost: DailyCostData[];
modelDist: ModelDistribution[];
} {
const dateMap = new Map<string, Omit<DailyTokenData, "label">>();
const costMap = new Map<string, number>();
const modelMap = new Map<string, { tokens: number; cost: number }>();
for (const u of usage) {
const existing = dateMap.get(u.date) ?? {
date: u.date,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
existing.input += u.input_tokens;
existing.output += u.output_tokens;
existing.cacheRead += u.cache_read_tokens;
existing.cacheWrite += u.cache_write_tokens;
dateMap.set(u.date, existing);
const dayCost = (costMap.get(u.date) ?? 0) + estimateCost(u);
costMap.set(u.date, dayCost);
const modelName = u.model || u.provider;
const m = modelMap.get(modelName) ?? { tokens: 0, cost: 0 };
m.tokens +=
u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
m.cost += estimateCost(u);
modelMap.set(modelName, m);
}
const formatLabel = (d: string) => {
const date = new Date(d + "T00:00:00");
return `${date.getMonth() + 1}/${date.getDate()}`;
};
const dailyTokens = [...dateMap.values()]
.sort((a, b) => a.date.localeCompare(b.date))
.map((d) => ({ ...d, label: formatLabel(d.date) }));
const dailyCost = [...costMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, cost]) => ({
date,
label: formatLabel(date),
cost: Math.round(cost * 100) / 100,
}));
const modelDist = [...modelMap.entries()]
.map(([model, data]) => ({ model, ...data }))
.sort((a, b) => b.tokens - a.tokens);
return { dailyTokens, dailyCost, modelDist };
}

View file

@ -14,6 +14,8 @@ export type IssueAssigneeType = "member" | "agent";
export interface Issue {
id: string;
workspace_id: string;
number: number;
identifier: string;
title: string;
description: string | null;
status: IssueStatus;

View file

@ -13,6 +13,7 @@ export interface Workspace {
context: string | null;
settings: Record<string, unknown>;
repos: WorkspaceRepo[];
issue_prefix: string;
created_at: string;
updated_at: string;
}

View file

@ -22,6 +22,7 @@ export const mockWorkspace: Workspace = {
context: null,
settings: {},
repos: [],
issue_prefix: "TES",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};

View file

@ -179,6 +179,14 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue
return db.Issue{}, false
}
// Try identifier format first (e.g., "JIA-42").
if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, resolveWorkspaceID(r)); ok {
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "issue not found"); !ok {
return db.Issue{}, false
}
return issue, true
}
issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID))
if err != nil {
writeError(w, http.StatusNotFound, "issue not found")
@ -192,6 +200,64 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue
return issue, true
}
// resolveIssueByIdentifier tries to look up an issue by "PREFIX-NUMBER" format.
func (h *Handler) resolveIssueByIdentifier(ctx context.Context, id, workspaceID string) (db.Issue, bool) {
parts := splitIdentifier(id)
if parts == nil {
return db.Issue{}, false
}
if workspaceID == "" {
return db.Issue{}, false
}
issue, err := h.Queries.GetIssueByNumber(ctx, db.GetIssueByNumberParams{
WorkspaceID: parseUUID(workspaceID),
Number: parts.number,
})
if err != nil {
return db.Issue{}, false
}
return issue, true
}
type identifierParts struct {
prefix string
number int32
}
func splitIdentifier(id string) *identifierParts {
idx := -1
for i := len(id) - 1; i >= 0; i-- {
if id[i] == '-' {
idx = i
break
}
}
if idx <= 0 || idx >= len(id)-1 {
return nil
}
numStr := id[idx+1:]
num := 0
for _, c := range numStr {
if c < '0' || c > '9' {
return nil
}
num = num*10 + int(c-'0')
}
if num <= 0 {
return nil
}
return &identifierParts{prefix: id[:idx], number: int32(num)}
}
// getIssuePrefix fetches the issue_prefix for a workspace.
func (h *Handler) getIssuePrefix(ctx context.Context, workspaceID pgtype.UUID) string {
ws, err := h.Queries.GetWorkspace(ctx, workspaceID)
if err != nil {
return ""
}
return ws.IssuePrefix
}
func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agentID string) (db.Agent, bool) {
if _, ok := requireUserID(w, r); !ok {
return db.Agent{}, false

View file

@ -90,10 +90,10 @@ func setupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, s
var workspaceID string
if err := pool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description)
VALUES ($1, $2, $3)
INSERT INTO workspace (name, slug, description, issue_prefix)
VALUES ($1, $2, $3, $4)
RETURNING id
`, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests").Scan(&workspaceID); err != nil {
`, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests", "HAN").Scan(&workspaceID); err != nil {
return "", "", err
}

View file

@ -20,6 +20,8 @@ import (
type IssueResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Number int32 `json:"number"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
@ -41,10 +43,13 @@ type agentTriggerSnapshot struct {
Config map[string]any `json:"config"`
}
func issueToResponse(i db.Issue) IssueResponse {
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
return IssueResponse{
ID: uuidToString(i.ID),
WorkspaceID: uuidToString(i.WorkspaceID),
Number: i.Number,
Identifier: identifier,
Title: i.Title,
Description: textToPtr(i.Description),
Status: i.Status,
@ -109,9 +114,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
return
}
prefix := h.getIssuePrefix(ctx, parseUUID(workspaceID))
resp := make([]IssueResponse, len(issues))
for i, issue := range issues {
resp[i] = issueToResponse(issue)
resp[i] = issueToResponse(issue, prefix)
}
writeJSON(w, http.StatusOK, map[string]any{
@ -126,7 +132,8 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
writeJSON(w, http.StatusOK, issueToResponse(issue))
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
writeJSON(w, http.StatusOK, issueToResponse(issue, prefix))
}
type CreateIssueRequest struct {
@ -196,7 +203,24 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
dueDate = pgtype.Timestamptz{Time: t, Valid: true}
}
issue, err := h.Queries.CreateIssue(r.Context(), db.CreateIssueParams{
// Use a transaction to atomically increment the workspace issue counter
// and create the issue with the assigned number.
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create issue")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
issueNumber, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(workspaceID))
if err != nil {
slog.Warn("increment issue counter failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to create issue")
return
}
issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
WorkspaceID: parseUUID(workspaceID),
Title: req.Title,
Description: ptrToText(req.Description),
@ -209,6 +233,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
ParentIssueID: parentIssueID,
Position: 0,
DueDate: dueDate,
Number: issueNumber,
})
if err != nil {
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
@ -216,7 +241,13 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
return
}
resp := issueToResponse(issue)
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create issue")
return
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
resp := issueToResponse(issue, prefix)
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
h.publish(protocol.EventIssueCreated, workspaceID, "member", creatorID, map[string]any{"issue": resp})
@ -326,7 +357,8 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
return
}
resp := issueToResponse(issue)
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
resp := issueToResponse(issue, prefix)
slog.Info("issue updated", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", workspaceID)...)
assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) &&

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
@ -13,6 +14,22 @@ import (
"github.com/multica-ai/multica/server/pkg/protocol"
)
var nonAlpha = regexp.MustCompile(`[^a-zA-Z]`)
// generateIssuePrefix produces a 2-5 char uppercase prefix from a workspace name.
// Examples: "Jiayuan's Workspace" → "JIA", "My Team" → "MYT", "AB" → "AB".
func generateIssuePrefix(name string) string {
letters := nonAlpha.ReplaceAllString(name, "")
if len(letters) == 0 {
return "WS"
}
letters = strings.ToUpper(letters)
if len(letters) > 3 {
letters = letters[:3]
}
return letters
}
type WorkspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
@ -21,6 +38,7 @@ type WorkspaceResponse struct {
Context *string `json:"context"`
Settings any `json:"settings"`
Repos any `json:"repos"`
IssuePrefix string `json:"issue_prefix"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@ -48,6 +66,7 @@ func workspaceToResponse(w db.Workspace) WorkspaceResponse {
Context: textToPtr(w.Context),
Settings: settings,
Repos: repos,
IssuePrefix: w.IssuePrefix,
CreatedAt: timestampToString(w.CreatedAt),
UpdatedAt: timestampToString(w.UpdatedAt),
}
@ -110,6 +129,7 @@ type CreateWorkspaceRequest struct {
Slug string `json:"slug"`
Description *string `json:"description"`
Context *string `json:"context"`
IssuePrefix *string `json:"issue_prefix"`
}
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
@ -138,12 +158,18 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback(r.Context())
issuePrefix := generateIssuePrefix(req.Name)
if req.IssuePrefix != nil && strings.TrimSpace(*req.IssuePrefix) != "" {
issuePrefix = strings.ToUpper(strings.TrimSpace(*req.IssuePrefix))
}
qtx := h.Queries.WithTx(tx)
ws, err := qtx.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
Name: req.Name,
Slug: req.Slug,
Description: ptrToText(req.Description),
Context: ptrToText(req.Context),
IssuePrefix: issuePrefix,
})
if err != nil {
if isUniqueViolation(err) {
@ -179,6 +205,7 @@ type UpdateWorkspaceRequest struct {
Context *string `json:"context"`
Settings any `json:"settings"`
Repos any `json:"repos"`
IssuePrefix *string `json:"issue_prefix"`
}
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
@ -218,6 +245,12 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
reposJSON, _ := json.Marshal(req.Repos)
params.Repos = reposJSON
}
if req.IssuePrefix != nil {
prefix := strings.ToUpper(strings.TrimSpace(*req.IssuePrefix))
if prefix != "" {
params.IssuePrefix = pgtype.Text{String: prefix, Valid: true}
}
}
ws, err := h.Queries.UpdateWorkspace(r.Context(), params)
if err != nil {

View file

@ -7,6 +7,8 @@ import (
"fmt"
"log/slog"
"strconv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
@ -367,15 +369,24 @@ func (s *TaskService) broadcastTaskEvent(ctx context.Context, eventType string,
}
func (s *TaskService) broadcastIssueUpdated(issue db.Issue) {
prefix := s.getIssuePrefix(issue.WorkspaceID)
s.Bus.Publish(events.Event{
Type: protocol.EventIssueUpdated,
WorkspaceID: util.UUIDToString(issue.WorkspaceID),
ActorType: "system",
ActorID: "",
Payload: map[string]any{"issue": issueToMap(issue)},
Payload: map[string]any{"issue": issueToMap(issue, prefix)},
})
}
func (s *TaskService) getIssuePrefix(workspaceID pgtype.UUID) string {
ws, err := s.Queries.GetWorkspace(context.Background(), workspaceID)
if err != nil {
return ""
}
return ws.IssuePrefix
}
func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID pgtype.UUID, content, commentType string) {
if content == "" {
return
@ -416,23 +427,25 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p
})
}
func issueToMap(issue db.Issue) map[string]any {
func issueToMap(issue db.Issue, issuePrefix string) map[string]any {
return map[string]any{
"id": util.UUIDToString(issue.ID),
"workspace_id": util.UUIDToString(issue.WorkspaceID),
"title": issue.Title,
"description": util.TextToPtr(issue.Description),
"status": issue.Status,
"priority": issue.Priority,
"assignee_type": util.TextToPtr(issue.AssigneeType),
"assignee_id": util.UUIDToPtr(issue.AssigneeID),
"creator_type": issue.CreatorType,
"creator_id": util.UUIDToString(issue.CreatorID),
"id": util.UUIDToString(issue.ID),
"workspace_id": util.UUIDToString(issue.WorkspaceID),
"number": issue.Number,
"identifier": issuePrefix + "-" + strconv.Itoa(int(issue.Number)),
"title": issue.Title,
"description": util.TextToPtr(issue.Description),
"status": issue.Status,
"priority": issue.Priority,
"assignee_type": util.TextToPtr(issue.AssigneeType),
"assignee_id": util.UUIDToPtr(issue.AssigneeID),
"creator_type": issue.CreatorType,
"creator_id": util.UUIDToString(issue.CreatorID),
"parent_issue_id": util.UUIDToPtr(issue.ParentIssueID),
"position": issue.Position,
"due_date": util.TimestampToPtr(issue.DueDate),
"created_at": util.TimestampToString(issue.CreatedAt),
"updated_at": util.TimestampToString(issue.UpdatedAt),
"position": issue.Position,
"due_date": util.TimestampToPtr(issue.DueDate),
"created_at": util.TimestampToString(issue.CreatedAt),
"updated_at": util.TimestampToString(issue.UpdatedAt),
}
}

View file

@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_issue_workspace_number;
ALTER TABLE issue DROP CONSTRAINT IF EXISTS uq_issue_workspace_number;
ALTER TABLE issue DROP COLUMN IF EXISTS number;
ALTER TABLE workspace DROP COLUMN IF EXISTS issue_prefix;
ALTER TABLE workspace DROP COLUMN IF EXISTS issue_counter;

View file

@ -0,0 +1,36 @@
-- Add issue_prefix and issue_counter to workspace for human-readable issue IDs.
ALTER TABLE workspace
ADD COLUMN issue_prefix TEXT NOT NULL DEFAULT '',
ADD COLUMN issue_counter INT NOT NULL DEFAULT 0;
-- Add per-workspace issue number.
ALTER TABLE issue
ADD COLUMN number INT NOT NULL DEFAULT 0;
-- Backfill: generate issue_prefix from workspace name (first 3 uppercase chars).
UPDATE workspace SET issue_prefix = UPPER(
LEFT(REGEXP_REPLACE(name, '[^a-zA-Z]', '', 'g'), 3)
);
-- Fallback for workspaces with empty prefix after cleanup.
UPDATE workspace SET issue_prefix = 'WS' WHERE issue_prefix = '';
-- Backfill: assign sequential numbers to existing issues per workspace.
WITH numbered AS (
SELECT id, workspace_id,
ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC) AS rn
FROM issue
)
UPDATE issue SET number = numbered.rn
FROM numbered WHERE issue.id = numbered.id;
-- Update workspace counters to match.
UPDATE workspace SET issue_counter = COALESCE(
(SELECT MAX(number) FROM issue WHERE issue.workspace_id = workspace.id), 0
);
-- Add unique constraint.
ALTER TABLE issue ADD CONSTRAINT uq_issue_workspace_number UNIQUE (workspace_id, number);
-- Index for fast lookup by workspace + number.
CREATE INDEX idx_issue_workspace_number ON issue(workspace_id, number);

View file

@ -15,10 +15,10 @@ const createIssue = `-- name: CreateIssue :one
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date
parent_issue_id, position, due_date, number
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
`
type CreateIssueParams struct {
@ -34,6 +34,7 @@ type CreateIssueParams struct {
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
DueDate pgtype.Timestamptz `json:"due_date"`
Number int32 `json:"number"`
}
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
@ -50,6 +51,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
arg.ParentIssueID,
arg.Position,
arg.DueDate,
arg.Number,
)
var i Issue
err := row.Scan(
@ -70,6 +72,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
)
return i, err
}
@ -84,7 +87,7 @@ func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error {
}
const getIssue = `-- name: GetIssue :one
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
WHERE id = $1
`
@ -109,12 +112,49 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) {
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
)
return i, err
}
const getIssueByNumber = `-- name: GetIssueByNumber :one
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
WHERE workspace_id = $1 AND number = $2
`
type GetIssueByNumberParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Number int32 `json:"number"`
}
func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberParams) (Issue, error) {
row := q.db.QueryRow(ctx, getIssueByNumber, arg.WorkspaceID, arg.Number)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
)
return i, err
}
const listIssues = `-- name: ListIssues :many
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
WHERE workspace_id = $1
AND ($4::text IS NULL OR status = $4)
AND ($5::text IS NULL OR priority = $5)
@ -166,6 +206,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
); err != nil {
return nil, err
}
@ -189,7 +230,7 @@ UPDATE issue SET
due_date = $9,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
`
type UpdateIssueParams struct {
@ -235,6 +276,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
)
return i, err
}
@ -244,7 +286,7 @@ UPDATE issue SET
status = $2,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
`
type UpdateIssueStatusParams struct {
@ -273,6 +315,7 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
)
return i, err
}

View file

@ -152,6 +152,7 @@ type Issue struct {
DueDate pgtype.Timestamptz `json:"due_date"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Number int32 `json:"number"`
}
type IssueDependency struct {
@ -256,13 +257,15 @@ type VerificationCode struct {
}
type Workspace struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description pgtype.Text `json:"description"`
Settings []byte `json:"settings"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Context pgtype.Text `json:"context"`
Repos []byte `json:"repos"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description pgtype.Text `json:"description"`
Settings []byte `json:"settings"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Context pgtype.Text `json:"context"`
Repos []byte `json:"repos"`
IssuePrefix string `json:"issue_prefix"`
IssueCounter int32 `json:"issue_counter"`
}

View file

@ -12,9 +12,9 @@ import (
)
const createWorkspace = `-- name: CreateWorkspace :one
INSERT INTO workspace (name, slug, description, context)
VALUES ($1, $2, $3, $4)
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos
INSERT INTO workspace (name, slug, description, context, issue_prefix)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter
`
type CreateWorkspaceParams struct {
@ -22,6 +22,7 @@ type CreateWorkspaceParams struct {
Slug string `json:"slug"`
Description pgtype.Text `json:"description"`
Context pgtype.Text `json:"context"`
IssuePrefix string `json:"issue_prefix"`
}
func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
@ -30,6 +31,7 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams
arg.Slug,
arg.Description,
arg.Context,
arg.IssuePrefix,
)
var i Workspace
err := row.Scan(
@ -42,6 +44,8 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
)
return i, err
}
@ -56,7 +60,7 @@ func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error {
}
const getWorkspace = `-- name: GetWorkspace :one
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter FROM workspace
WHERE id = $1
`
@ -73,12 +77,14 @@ func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace,
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
)
return i, err
}
const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter FROM workspace
WHERE slug = $1
`
@ -95,12 +101,27 @@ func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspac
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
)
return i, err
}
const incrementIssueCounter = `-- name: IncrementIssueCounter :one
UPDATE workspace SET issue_counter = issue_counter + 1
WHERE id = $1
RETURNING issue_counter
`
func (q *Queries) IncrementIssueCounter(ctx context.Context, id pgtype.UUID) (int32, error) {
row := q.db.QueryRow(ctx, incrementIssueCounter, id)
var issue_counter int32
err := row.Scan(&issue_counter)
return issue_counter, err
}
const listWorkspaces = `-- name: ListWorkspaces :many
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context, w.repos FROM workspace w
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context, w.repos, w.issue_prefix, w.issue_counter FROM workspace w
JOIN member m ON m.workspace_id = w.id
WHERE m.user_id = $1
ORDER BY w.created_at ASC
@ -125,6 +146,8 @@ func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Wor
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
); err != nil {
return nil, err
}
@ -143,9 +166,10 @@ UPDATE workspace SET
context = COALESCE($4, context),
settings = COALESCE($5, settings),
repos = COALESCE($6, repos),
issue_prefix = COALESCE($7, issue_prefix),
updated_at = now()
WHERE id = $1
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter
`
type UpdateWorkspaceParams struct {
@ -155,6 +179,7 @@ type UpdateWorkspaceParams struct {
Context pgtype.Text `json:"context"`
Settings []byte `json:"settings"`
Repos []byte `json:"repos"`
IssuePrefix pgtype.Text `json:"issue_prefix"`
}
func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) {
@ -165,6 +190,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams
arg.Context,
arg.Settings,
arg.Repos,
arg.IssuePrefix,
)
var i Workspace
err := row.Scan(
@ -177,6 +203,8 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
)
return i, err
}

View file

@ -15,11 +15,15 @@ WHERE id = $1;
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date
parent_issue_id, position, due_date, number
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
) RETURNING *;
-- name: GetIssueByNumber :one
SELECT * FROM issue
WHERE workspace_id = $1 AND number = $2;
-- name: UpdateIssue :one
UPDATE issue SET
title = COALESCE(sqlc.narg('title'), title),

View file

@ -13,8 +13,8 @@ SELECT * FROM workspace
WHERE slug = $1;
-- name: CreateWorkspace :one
INSERT INTO workspace (name, slug, description, context)
VALUES ($1, $2, $3, $4)
INSERT INTO workspace (name, slug, description, context, issue_prefix)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: UpdateWorkspace :one
@ -24,9 +24,15 @@ UPDATE workspace SET
context = COALESCE(sqlc.narg('context'), context),
settings = COALESCE(sqlc.narg('settings'), settings),
repos = COALESCE(sqlc.narg('repos'), repos),
issue_prefix = COALESCE(sqlc.narg('issue_prefix'), issue_prefix),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: IncrementIssueCounter :one
UPDATE workspace SET issue_counter = issue_counter + 1
WHERE id = $1
RETURNING issue_counter;
-- name: DeleteWorkspace :exec
DELETE FROM workspace WHERE id = $1;