feat(ui): onboarding wizard, comment thread, markdown editor, and UX polish

Refactor onboarding wizard with ASCII art animation and expanded adapter
support. Enhance markdown editor with code block, table, and CodeMirror
plugins. Improve comment thread layout. Add activity charts to agent
detail page. Polish metric cards, issue detail reassignment, and new
issue dialog. Simplify agent detail page structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-26 16:33:48 -06:00
parent c2709687b8
commit ad19bc921d
9 changed files with 1014 additions and 844 deletions

View File

@@ -31,6 +31,7 @@ import {
help,
adapterLabels,
} from "./agent-config-primitives";
import { defaultCreateValues } from "./agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
import { MarkdownEditor } from "./MarkdownEditor";
@@ -210,8 +211,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
if (overlay.adapterType !== undefined) {
patch.adapterType = overlay.adapterType;
}
if (Object.keys(overlay.adapterConfig).length > 0) {
// When adapter type changes, send only the new config — don't merge
// with old config since old adapter fields are meaningless for the new type
patch.adapterConfig = overlay.adapterConfig;
} else if (Object.keys(overlay.adapterConfig).length > 0) {
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
}
@@ -432,12 +435,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
value={adapterType}
onChange={(t) => {
if (isCreate) {
set!({ adapterType: t, model: "", thinkingEffort: "" });
// Reset all adapter-specific fields to defaults when switching adapter type
const { adapterType: _at, ...defaults } = defaultCreateValues;
set!({ ...defaults, adapterType: t });
} else {
// Clear all adapter config and explicitly blank out model + both effort keys
// so the old adapter's values don't bleed through via eff()
setOverlay((prev) => ({
...prev,
adapterType: t,
adapterConfig: {}, // clear adapter config when type changes
adapterConfig: {
model: "",
effort: "",
modelReasoningEffort: "",
},
}));
}
}}
@@ -794,10 +805,10 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";
const statusClass =
result.status === "pass"
? "text-green-300 border-green-500/40 bg-green-500/10"
? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10"
: result.status === "warn"
? "text-amber-300 border-amber-500/40 bg-amber-500/10"
: "text-red-300 border-red-500/40 bg-red-500/10";
? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10"
: "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10";
return (
<div className={`rounded-md border px-3 py-2 text-xs ${statusClass}`}>
@@ -1154,37 +1165,39 @@ function ModelDropdown({
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
)}
onClick={() => {
onChange("");
onOpenChange(false);
}}
>
Default
</button>
{filteredModels.map((m) => (
<div className="max-h-[240px] overflow-y-auto">
<button
key={m.id}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent",
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
)}
onClick={() => {
onChange(m.id);
onChange("");
onOpenChange(false);
}}
>
<span>{m.label}</span>
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
Default
</button>
))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
)}
{filteredModels.map((m) => (
<button
key={m.id}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent",
)}
onClick={() => {
onChange(m.id);
onOpenChange(false);
}}
>
<span>{m.label}</span>
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
</button>
))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
)}
</div>
</PopoverContent>
</Popover>
</Field>

View File

@@ -0,0 +1,199 @@
import { useEffect, useRef } from "react";
const CHARS = "░▒▓█▄▀■□▪▫●○◆◇◈◉★☆✦✧·.";
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
char: string;
life: number;
maxLife: number;
phase: number;
}
function measureChar(container: HTMLElement): { w: number; h: number } {
const span = document.createElement("span");
span.textContent = "M";
span.style.cssText =
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
container.appendChild(span);
const rect = span.getBoundingClientRect();
container.removeChild(span);
return { w: rect.width, h: rect.height };
}
export function AsciiArtAnimation() {
const preRef = useRef<HTMLPreElement>(null);
const frameRef = useRef(0);
const particlesRef = useRef<Particle[]>([]);
useEffect(() => {
if (!preRef.current) return;
const preEl: HTMLPreElement = preRef.current;
const charSize = measureChar(preEl);
let charW = charSize.w;
let charH = charSize.h;
let cols = Math.ceil(preEl.clientWidth / charW);
let rows = Math.ceil(preEl.clientHeight / charH);
let particles = particlesRef.current;
function spawnParticle() {
const edge = Math.random();
let x: number, y: number, vx: number, vy: number;
if (edge < 0.5) {
x = -1;
y = Math.random() * rows;
vx = 0.3 + Math.random() * 0.5;
vy = (Math.random() - 0.5) * 0.2;
} else {
x = Math.random() * cols;
y = rows + 1;
vx = (Math.random() - 0.5) * 0.2;
vy = -(0.2 + Math.random() * 0.4);
}
const maxLife = 60 + Math.random() * 120;
particles.push({
x, y, vx, vy,
char: CHARS[Math.floor(Math.random() * CHARS.length)],
life: 0,
maxLife,
phase: Math.random() * Math.PI * 2,
});
}
function render(time: number) {
const t = time * 0.001;
// Spawn particles
const targetCount = Math.floor((cols * rows) / 12);
while (particles.length < targetCount) {
spawnParticle();
}
// Build grid
const grid: string[][] = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => " ")
);
const opacity: number[][] = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => 0)
);
// Background wave pattern
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const wave =
Math.sin(c * 0.08 + t * 0.7 + r * 0.04) *
Math.sin(r * 0.06 - t * 0.5) *
Math.cos((c + r) * 0.03 + t * 0.3);
if (wave > 0.65) {
grid[r][c] = wave > 0.85 ? "·" : ".";
opacity[r][c] = Math.min(1, (wave - 0.65) * 3);
}
}
}
// Update and render particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life++;
// Flow field influence
const angle =
Math.sin(p.x * 0.05 + t * 0.3) * Math.cos(p.y * 0.07 - t * 0.2) *
Math.PI;
p.vx += Math.cos(angle) * 0.02;
p.vy += Math.sin(angle) * 0.02;
// Damping
p.vx *= 0.98;
p.vy *= 0.98;
p.x += p.vx;
p.y += p.vy;
// Life fade
const lifeFrac = p.life / p.maxLife;
const alpha = lifeFrac < 0.1
? lifeFrac / 0.1
: lifeFrac > 0.8
? (1 - lifeFrac) / 0.2
: 1;
// Remove dead or out-of-bounds particles
if (
p.life >= p.maxLife ||
p.x < -2 || p.x > cols + 2 ||
p.y < -2 || p.y > rows + 2
) {
particles.splice(i, 1);
continue;
}
const col = Math.round(p.x);
const row = Math.round(p.y);
if (row >= 0 && row < rows && col >= 0 && col < cols) {
if (alpha > opacity[row][col]) {
// Cycle through characters based on life
const charIdx = Math.floor(
(lifeFrac + Math.sin(p.phase + t)) * CHARS.length
) % CHARS.length;
grid[row][col] = CHARS[Math.abs(charIdx)];
opacity[row][col] = alpha;
}
}
}
// Render to string
let output = "";
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const a = opacity[r][c];
if (a > 0 && grid[r][c] !== " ") {
const o = Math.round(a * 60 + 40);
output += `<span style="opacity:${o}%">${grid[r][c]}</span>`;
} else {
output += " ";
}
}
if (r < rows - 1) output += "\n";
}
preEl.innerHTML = output;
frameRef.current = requestAnimationFrame(render);
}
// Handle resize
const observer = new ResizeObserver(() => {
const size = measureChar(preEl);
charW = size.w;
charH = size.h;
cols = Math.ceil(preEl.clientWidth / charW);
rows = Math.ceil(preEl.clientHeight / charH);
// Cull out-of-bounds particles on resize
particles = particles.filter(
(p) => p.x >= -2 && p.x <= cols + 2 && p.y >= -2 && p.y <= rows + 2
);
particlesRef.current = particles;
});
observer.observe(preEl);
frameRef.current = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(frameRef.current);
observer.disconnect();
};
}, []);
return (
<pre
ref={preRef}
className="w-full h-full m-0 p-0 overflow-hidden text-muted-foreground/60 select-none leading-none"
style={{ fontSize: "11px", fontFamily: "monospace" }}
aria-hidden="true"
/>
);
}

View File

@@ -10,6 +10,8 @@ import {
} from "react";
import {
MDXEditor,
codeBlockPlugin,
codeMirrorPlugin,
type MDXEditorMethods,
headingsPlugin,
imagePlugin,
@@ -18,6 +20,7 @@ import {
listsPlugin,
markdownShortcutPlugin,
quotePlugin,
tablePlugin,
thematicBreakPlugin,
type RealmPlugin,
} from "@mdxeditor/editor";
@@ -62,6 +65,26 @@ interface MentionState {
endPos: number;
}
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
txt: "Text",
md: "Markdown",
js: "JavaScript",
jsx: "JavaScript (JSX)",
ts: "TypeScript",
tsx: "TypeScript (TSX)",
json: "JSON",
bash: "Bash",
sh: "Shell",
python: "Python",
go: "Go",
rust: "Rust",
sql: "SQL",
html: "HTML",
css: "CSS",
yaml: "YAML",
yml: "YAML",
};
function detectMention(container: HTMLElement): MentionState | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
@@ -174,9 +197,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
headingsPlugin(),
listsPlugin(),
quotePlugin(),
tablePlugin(),
linkPlugin(),
linkDialogPlugin(),
thematicBreakPlugin(),
codeBlockPlugin(),
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
markdownShortcutPlugin(),
];
if (imageHandler) {

View File

@@ -1,7 +1,6 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { Card, CardContent } from "@/components/ui/card";
interface MetricCardProps {
icon: LucideIcon;
@@ -16,26 +15,22 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
const isClickable = !!(to || onClick);
const inner = (
<Card className="h-full">
<CardContent className="p-3 sm:p-4 h-full">
<div className="flex gap-2 sm:gap-3">
<div className="flex-1 min-w-0">
<p className={`text-lg sm:text-2xl font-bold${isClickable ? " cursor-pointer" : ""}`}>
{value}
</p>
<p className={`text-sm text-muted-foreground${isClickable ? " cursor-pointer" : ""}`}>
{label}
</p>
{description && (
<div className="text-xs sm:text-sm text-muted-foreground mt-1 hidden sm:block">{description}</div>
)}
</div>
<div className="bg-muted p-1.5 sm:p-2 rounded-md h-fit shrink-0">
<Icon className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
</div>
<div className={`h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-2xl sm:text-3xl font-semibold tracking-tight">
{value}
</p>
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">
{label}
</p>
{description && (
<div className="text-xs text-muted-foreground/70 mt-1.5 hidden sm:block">{description}</div>
)}
</div>
</CardContent>
</Card>
<Icon className="h-4 w-4 text-muted-foreground/50 shrink-0 mt-1.5" />
</div>
</div>
);
if (to) {
@@ -48,7 +43,7 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
if (onClick) {
return (
<div className="cursor-pointer h-full" onClick={onClick}>
<div className="h-full" onClick={onClick}>
{inner}
</div>
);

View File

@@ -42,6 +42,16 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800;
/** Return black or white hex based on background luminance (WCAG perceptual weights). */
function getContrastTextColor(hexColor: string): string {
const hex = hexColor.replace("#", "");
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000000" : "#ffffff";
}
interface IssueDraft {
title: string;
description: string;
@@ -51,6 +61,7 @@ interface IssueDraft {
projectId: string;
assigneeModelOverride: string;
assigneeThinkingEffort: string;
assigneeChrome: boolean;
assigneeUseProjectWorkspace: boolean;
}
@@ -76,6 +87,7 @@ function buildAssigneeAdapterOverrides(input: {
adapterType: string | null | undefined;
modelOverride: string;
thinkingEffortOverride: string;
chrome: boolean;
useProjectWorkspace: boolean;
}): Record<string, unknown> | null {
const adapterType = input.adapterType ?? null;
@@ -92,6 +104,9 @@ function buildAssigneeAdapterOverrides(input: {
adapterConfig.effort = input.thinkingEffortOverride;
}
}
if (adapterType === "claude_local" && input.chrome) {
adapterConfig.chrome = true;
}
const overrides: Record<string, unknown> = {};
if (Object.keys(adapterConfig).length > 0) {
@@ -138,7 +153,7 @@ const priorities = [
export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const { companies, selectedCompanyId, selectedCompany } = useCompany();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
@@ -150,29 +165,35 @@ export function NewIssueDialog() {
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
const [assigneeChrome, setAssigneeChrome] = useState(false);
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
const [expanded, setExpanded] = useState(false);
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
// Popover states
const [statusOpen, setStatusOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [companyOpen, setCompanyOpen] = useState(false);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newIssueOpen,
queryKey: queryKeys.agents.list(effectiveCompanyId!),
queryFn: () => agentsApi.list(effectiveCompanyId!),
enabled: !!effectiveCompanyId && newIssueOpen,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newIssueOpen,
queryKey: queryKeys.projects.list(effectiveCompanyId!),
queryFn: () => projectsApi.list(effectiveCompanyId!),
enabled: !!effectiveCompanyId && newIssueOpen,
});
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
@@ -183,14 +204,14 @@ export function NewIssueDialog() {
const { data: assigneeAdapterModels } = useQuery({
queryKey: ["adapter-models", assigneeAdapterType],
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
enabled: !!selectedCompanyId && newIssueOpen && supportsAssigneeOverrides,
enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
});
const createIssue = useMutation({
mutationFn: (data: Record<string, unknown>) =>
issuesApi.create(selectedCompanyId!, data),
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
issuesApi.create(companyId, data),
onSuccess: (issue) => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
if (draftTimer.current) clearTimeout(draftTimer.current);
clearDraft();
reset();
@@ -207,8 +228,8 @@ export function NewIssueDialog() {
const uploadDescriptionImage = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(selectedCompanyId, file, "issues/drafts");
if (!effectiveCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
},
});
@@ -235,6 +256,7 @@ export function NewIssueDialog() {
projectId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
assigneeUseProjectWorkspace,
});
}, [
@@ -246,6 +268,7 @@ export function NewIssueDialog() {
projectId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
assigneeUseProjectWorkspace,
newIssueOpen,
scheduleSave,
@@ -254,6 +277,7 @@ export function NewIssueDialog() {
// Restore draft or apply defaults when dialog opens
useEffect(() => {
if (!newIssueOpen) return;
setDialogCompanyId(selectedCompanyId);
const draft = loadDraft();
if (draft && draft.title.trim()) {
@@ -265,6 +289,7 @@ export function NewIssueDialog() {
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeChrome(draft.assigneeChrome ?? false);
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
} else {
setStatus(newIssueDefaults.status ?? "todo");
@@ -273,6 +298,7 @@ export function NewIssueDialog() {
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
}
}, [newIssueOpen, newIssueDefaults]);
@@ -282,6 +308,7 @@ export function NewIssueDialog() {
setAssigneeOptionsOpen(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
return;
}
@@ -312,8 +339,22 @@ export function NewIssueDialog() {
setAssigneeOptionsOpen(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
setExpanded(false);
setDialogCompanyId(null);
setCompanyOpen(false);
}
function handleCompanyChange(companyId: string) {
if (companyId === effectiveCompanyId) return;
setDialogCompanyId(companyId);
setAssigneeId("");
setProjectId("");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
}
function discardDraft() {
@@ -323,14 +364,16 @@ export function NewIssueDialog() {
}
function handleSubmit() {
if (!selectedCompanyId || !title.trim()) return;
if (!effectiveCompanyId || !title.trim()) return;
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType,
modelOverride: assigneeModelOverride,
thinkingEffortOverride: assigneeThinkingEffort,
chrome: assigneeChrome,
useProjectWorkspace: assigneeUseProjectWorkspace,
});
createIssue.mutate({
companyId: effectiveCompanyId,
title: title.trim(),
description: description.trim() || undefined,
status,
@@ -429,11 +472,59 @@ export function NewIssueDialog() {
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedCompany && (
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
{selectedCompany.name.slice(0, 3).toUpperCase()}
</span>
)}
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
!dialogCompany?.brandColor && "bg-muted",
)}
style={
dialogCompany?.brandColor
? {
backgroundColor: dialogCompany.brandColor,
color: getContrastTextColor(dialogCompany.brandColor),
}
: undefined
}
>
{(dialogCompany?.name ?? "").slice(0, 3).toUpperCase()}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
{companies.map((c) => (
<button
key={c.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
c.id === effectiveCompanyId && "bg-accent",
)}
onClick={() => {
handleCompanyChange(c.id);
setCompanyOpen(false);
}}
>
<span
className={cn(
"px-1 py-0.5 rounded text-[10px] font-semibold leading-none",
!c.brandColor && "bg-muted",
)}
style={
c.brandColor
? {
backgroundColor: c.brandColor,
color: getContrastTextColor(c.brandColor),
}
: undefined
}
>
{c.name.slice(0, 3).toUpperCase()}
</span>
<span className="truncate">{c.name}</span>
</button>
))}
</PopoverContent>
</Popover>
<span className="text-muted-foreground/60">&rsaquo;</span>
<span>New issue</span>
</div>
@@ -604,6 +695,25 @@ export function NewIssueDialog() {
))}
</div>
</div>
{assigneeAdapterType === "claude_local" && (
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
assigneeChrome ? "bg-green-600" : "bg-muted"
)}
onClick={() => setAssigneeChrome((value) => !value)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
assigneeChrome ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
)}
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Use project workspace</div>
<button

View File

@@ -8,7 +8,7 @@ import { goalsApi } from "../api/goals";
import { agentsApi } from "../api/agents";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
@@ -18,9 +18,11 @@ import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
import { getUIAdapter } from "../adapters";
import { defaultCreateValues } from "./agent-config-defaults";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import {
Building2,
Bot,
Code,
ListTodo,
Rocket,
ArrowLeft,
@@ -32,10 +34,11 @@ import {
Loader2,
FolderOpen,
ChevronDown,
X,
} from "lucide-react";
type Step = 1 | 2 | 3 | 4;
type AdapterType = "claude_local" | "process" | "http";
type AdapterType = "claude_local" | "codex_local" | "process" | "http" | "openclaw";
export function OnboardingWizard() {
const { onboardingOpen, closeOnboarding } = useDialog();
@@ -98,6 +101,11 @@ export function OnboardingWizard() {
setCreatedAgentId(null);
}
function handleClose() {
reset();
closeOnboarding();
}
function buildAdapterConfig(): Record<string, unknown> {
const adapter = getUIAdapter(adapterType);
return adapter.buildAdapterConfig({
@@ -217,464 +225,490 @@ export function OnboardingWizard() {
}
}
const stepIcons = [Building2, Bot, ListTodo, Rocket];
if (!onboardingOpen) return null;
return (
<Dialog
open={onboardingOpen}
onOpenChange={(open) => {
if (!open) {
reset();
closeOnboarding();
}
if (!open) handleClose();
}}
>
<DialogContent
showCloseButton={false}
className="p-0 gap-0 overflow-hidden sm:max-w-lg"
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-2 text-sm">
<Sparkles className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Get Started</span>
<span className="text-muted-foreground/60">
Step {step} of 4
</span>
</div>
<div className="flex items-center gap-1.5">
{[1, 2, 3, 4].map((s) => (
<div
key={s}
className={cn(
"h-1.5 w-6 rounded-full transition-colors",
s < step
? "bg-green-500"
: s === step
? "bg-foreground"
: "bg-muted"
)}
/>
))}
</div>
</div>
<DialogPortal>
<DialogOverlay className="bg-background" />
<div
className="fixed inset-0 z-50 flex"
onKeyDown={handleKeyDown}
>
{/* Close button */}
<button
onClick={handleClose}
className="absolute top-4 left-4 z-10 rounded-sm p-1.5 text-muted-foreground/60 hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</button>
{/* Content */}
<div className="overflow-y-auto max-h-[60vh]">
{step === 1 && (
<div className="p-4 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="bg-muted/50 p-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Name your company</h3>
<p className="text-xs text-muted-foreground">
This is the organization your agents will work for.
</p>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Company name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="Acme Corp"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
autoFocus
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Mission / goal (optional)
</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]"
placeholder="What is this company trying to achieve?"
value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)}
/>
</div>
</div>
)}
{step === 2 && (
<div className="p-4 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="bg-muted/50 p-2">
<Bot className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Create your first agent</h3>
<p className="text-xs text-muted-foreground">
Choose how this agent will run tasks.
</p>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Agent name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="CEO"
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
autoFocus
/>
</div>
{/* Adapter type radio cards */}
<div>
<label className="text-xs text-muted-foreground mb-2 block">
Adapter type
</label>
<div className="grid grid-cols-3 gap-2">
{([
{
value: "claude_local" as const,
label: "Claude Code",
icon: Sparkles,
desc: "Local Claude agent",
},
{
value: "process" as const,
label: "Shell Command",
icon: Terminal,
desc: "Run a process",
},
{
value: "http" as const,
label: "HTTP Webhook",
icon: Globe,
desc: "Call an endpoint",
},
] as const).map((opt) => (
<button
key={opt.value}
{/* Left half — form */}
<div className="w-full md:w-1/2 flex flex-col overflow-y-auto">
<div className="w-full max-w-md mx-auto my-auto px-8 py-12">
{/* Progress indicators */}
<div className="flex items-center gap-2 mb-8">
<Sparkles className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Get Started</span>
<span className="text-sm text-muted-foreground/60">
Step {step} of 4
</span>
<div className="flex items-center gap-1.5 ml-auto">
{[1, 2, 3, 4].map((s) => (
<div
key={s}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
adapterType === opt.value
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
"h-1.5 w-6 rounded-full transition-colors",
s < step
? "bg-green-500"
: s === step
? "bg-foreground"
: "bg-muted"
)}
onClick={() => setAdapterType(opt.value)}
>
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.desc}
</span>
</button>
/>
))}
</div>
</div>
{/* Conditional adapter fields */}
{adapterType === "claude_local" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Working directory
</label>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/50"
placeholder="/path/to/project"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={async () => {
try {
setCwdPickerNotice(null);
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
const handle = await window.showDirectoryPicker({ mode: "read" });
const pickedPath =
typeof handle === "object" &&
handle !== null &&
typeof (handle as { path?: unknown }).path === "string"
? String((handle as { path: string }).path)
: "";
if (pickedPath) {
setCwd(pickedPath);
return;
}
const selectedName =
typeof handle === "object" &&
handle !== null &&
typeof (handle as { name?: unknown }).name === "string"
? String((handle as { name: string }).name)
: "selected folder";
setCwdPickerNotice(
`Directory picker only exposed "${selectedName}". Paste the absolute path manually.`,
);
} catch {
// user cancelled or API unsupported
}
}}
>
Choose
</button>
{/* Step content */}
{step === 1 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Name your company</h3>
<p className="text-xs text-muted-foreground">
This is the organization your agents will work for.
</p>
</div>
{cwdPickerNotice && (
<p className="mt-1 text-xs text-amber-400">{cwdPickerNotice}</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Model
Company name
</label>
<Popover open={modelOpen} onOpenChange={setModelOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!model && "text-muted-foreground")}>
{selectedModel ? selectedModel.label : model || "Default"}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="Acme Corp"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
autoFocus
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Mission / goal (optional)
</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]"
placeholder="What is this company trying to achieve?"
value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)}
/>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Bot className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Create your first agent</h3>
<p className="text-xs text-muted-foreground">
Choose how this agent will run tasks.
</p>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Agent name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="CEO"
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
autoFocus
/>
</div>
{/* Adapter type radio cards */}
<div>
<label className="text-xs text-muted-foreground mb-2 block">
Adapter type
</label>
<div className="grid grid-cols-2 gap-2">
{([
{
value: "claude_local" as const,
label: "Claude Code",
icon: Sparkles,
desc: "Local Claude agent",
},
{
value: "codex_local" as const,
label: "Codex",
icon: Code,
desc: "Local Codex agent",
},
{
value: "openclaw" as const,
label: "OpenClaw",
icon: Bot,
desc: "Notify OpenClaw webhook",
},
{
value: "process" as const,
label: "Shell Command",
icon: Terminal,
desc: "Run a process",
},
{
value: "http" as const,
label: "HTTP Webhook",
icon: Globe,
desc: "Call an endpoint",
},
] as const).map((opt) => (
<button
key={opt.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!model && "bg-accent"
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
adapterType === opt.value
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => { setModel(""); setModelOpen(false); }}
onClick={() => setAdapterType(opt.value)}
>
Default
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.desc}
</span>
</button>
{(adapterModels ?? []).map((m) => (
))}
</div>
</div>
{/* Conditional adapter fields */}
{(adapterType === "claude_local" || adapterType === "codex_local") && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Working directory
</label>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/50"
placeholder="/path/to/project"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
<button
key={m.id}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === model && "bg-accent"
)}
onClick={() => { setModel(m.id); setModelOpen(false); }}
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={async () => {
try {
setCwdPickerNotice(null);
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
const handle = await window.showDirectoryPicker({ mode: "read" });
const pickedPath =
typeof handle === "object" &&
handle !== null &&
typeof (handle as { path?: unknown }).path === "string"
? String((handle as { path: string }).path)
: "";
if (pickedPath) {
setCwd(pickedPath);
return;
}
const selectedName =
typeof handle === "object" &&
handle !== null &&
typeof (handle as { name?: unknown }).name === "string"
? String((handle as { name: string }).name)
: "selected folder";
setCwdPickerNotice(
`Directory picker only exposed "${selectedName}". Paste the absolute path manually.`,
);
} catch {
// user cancelled or API unsupported
}
}}
>
<span>{m.label}</span>
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
Choose
</button>
))}
</PopoverContent>
</Popover>
</div>
</div>
{cwdPickerNotice && (
<p className="mt-1 text-xs text-amber-400">{cwdPickerNotice}</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Model
</label>
<Popover open={modelOpen} onOpenChange={setModelOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!model && "text-muted-foreground")}>
{selectedModel ? selectedModel.label : model || "Default"}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!model && "bg-accent"
)}
onClick={() => { setModel(""); setModelOpen(false); }}
>
Default
</button>
{(adapterModels ?? []).map((m) => (
<button
key={m.id}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === model && "bg-accent"
)}
onClick={() => { setModel(m.id); setModelOpen(false); }}
>
<span>{m.label}</span>
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
</button>
))}
</PopoverContent>
</Popover>
</div>
</div>
)}
{adapterType === "process" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Command
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. node, python"
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Args (comma-separated)
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. script.js, --flag"
value={args}
onChange={(e) => setArgs(e.target.value)}
/>
</div>
</div>
)}
{(adapterType === "http" || adapterType === "openclaw") && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Webhook URL
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="https://..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
)}
</div>
)}
{adapterType === "process" && (
<div className="space-y-3">
{step === 3 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<ListTodo className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Give it something to do</h3>
<p className="text-xs text-muted-foreground">
Give your agent a small task to start with a bug fix, a
research question, writing a script.
</p>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Command
Task title
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. node, python"
value={command}
onChange={(e) => setCommand(e.target.value)}
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. Research competitor pricing"
value={taskTitle}
onChange={(e) => setTaskTitle(e.target.value)}
autoFocus
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Args (comma-separated)
Description (optional)
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. script.js, --flag"
value={args}
onChange={(e) => setArgs(e.target.value)}
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[80px]"
placeholder="Add more detail about what the agent should do..."
value={taskDescription}
onChange={(e) => setTaskDescription(e.target.value)}
/>
</div>
</div>
)}
{adapterType === "http" && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Webhook URL
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="https://..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
{step === 4 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Rocket className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Ready to launch</h3>
<p className="text-xs text-muted-foreground">
Everything is set up. Launch your agent and watch it work.
</p>
</div>
</div>
<div className="border border-border divide-y divide-border">
<div className="flex items-center gap-3 px-3 py-2.5">
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{companyName}</p>
<p className="text-xs text-muted-foreground">Company</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />
</div>
<div className="flex items-center gap-3 px-3 py-2.5">
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{agentName}</p>
<p className="text-xs text-muted-foreground">
{getUIAdapter(adapterType).label}
</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />
</div>
<div className="flex items-center gap-3 px-3 py-2.5">
<ListTodo className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{taskTitle}</p>
<p className="text-xs text-muted-foreground">Task</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />
</div>
</div>
</div>
)}
</div>
)}
{step === 3 && (
<div className="p-4 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="bg-muted/50 p-2">
<ListTodo className="h-5 w-5 text-muted-foreground" />
{/* Error */}
{error && (
<div className="mt-3">
<p className="text-xs text-destructive">{error}</p>
</div>
)}
{/* Footer navigation */}
<div className="flex items-center justify-between mt-8">
<div>
<h3 className="font-medium">Give it something to do</h3>
<p className="text-xs text-muted-foreground">
Give your agent a small task to start with a bug fix, a
research question, writing a script.
</p>
{step > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => setStep((step - 1) as Step)}
disabled={loading}
>
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
Back
</Button>
)}
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Task title
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. Research competitor pricing"
value={taskTitle}
onChange={(e) => setTaskTitle(e.target.value)}
autoFocus
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Description (optional)
</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[80px]"
placeholder="Add more detail about what the agent should do..."
value={taskDescription}
onChange={(e) => setTaskDescription(e.target.value)}
/>
</div>
</div>
)}
{step === 4 && (
<div className="p-4 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="bg-muted/50 p-2">
<Rocket className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Ready to launch</h3>
<p className="text-xs text-muted-foreground">
Everything is set up. Launch your agent and watch it work.
</p>
</div>
</div>
<div className="border border-border divide-y divide-border">
<div className="flex items-center gap-3 px-3 py-2.5">
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{companyName}</p>
<p className="text-xs text-muted-foreground">Company</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />
</div>
<div className="flex items-center gap-3 px-3 py-2.5">
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{agentName}</p>
<p className="text-xs text-muted-foreground">
{getUIAdapter(adapterType).label}
</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />
</div>
<div className="flex items-center gap-3 px-3 py-2.5">
<ListTodo className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{taskTitle}</p>
<p className="text-xs text-muted-foreground">Task</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />
<div className="flex items-center gap-2">
{step === 1 && (
<Button
size="sm"
disabled={!companyName.trim() || loading}
onClick={handleStep1Next}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Next"}
</Button>
)}
{step === 2 && (
<Button
size="sm"
disabled={!agentName.trim() || loading}
onClick={handleStep2Next}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Next"}
</Button>
)}
{step === 3 && (
<Button
size="sm"
disabled={!taskTitle.trim() || loading}
onClick={handleStep3Next}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Next"}
</Button>
)}
{step === 4 && (
<Button size="sm" disabled={loading} onClick={handleLaunch}>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Rocket className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Launching..." : "Launch Agent"}
</Button>
)}
</div>
</div>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="px-4 pb-2">
<p className="text-xs text-destructive">{error}</p>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
<div>
{step > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => setStep((step - 1) as Step)}
disabled={loading}
>
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
Back
</Button>
)}
</div>
<div className="flex items-center gap-2">
{step === 1 && (
<Button
size="sm"
disabled={!companyName.trim() || loading}
onClick={handleStep1Next}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Next"}
</Button>
)}
{step === 2 && (
<Button
size="sm"
disabled={!agentName.trim() || loading}
onClick={handleStep2Next}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Next"}
</Button>
)}
{step === 3 && (
<Button
size="sm"
disabled={!taskTitle.trim() || loading}
onClick={handleStep3Next}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Next"}
</Button>
)}
{step === 4 && (
<Button size="sm" disabled={loading} onClick={handleLaunch}>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Rocket className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Launching..." : "Launch Agent"}
</Button>
)}
{/* Right half — ASCII art (hidden on mobile) */}
<div className="hidden md:block w-1/2 overflow-hidden">
<AsciiArtAnimation />
</div>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
);
}

View File

@@ -133,8 +133,11 @@ function describeIssueUpdate(details: Record<string, unknown> | null): string |
const changes: string[] = [];
if (typeof details.status === "string") changes.push(`status -> ${details.status.replace(/_/g, " ")}`);
if (typeof details.priority === "string") changes.push(`priority -> ${details.priority}`);
if (typeof details.assigneeAgentId === "string") changes.push("reassigned");
else if (details.assigneeAgentId === null) changes.push("unassigned");
if (typeof details.assigneeAgentId === "string" || typeof details.assigneeUserId === "string") {
changes.push("reassigned");
} else if (details.assigneeAgentId === null || details.assigneeUserId === null) {
changes.push("unassigned");
}
if (details.reopened === true) {
const from = readString(details.reopenedFrom);
changes.push(from ? `reopened from ${from.replace(/_/g, " ")}` : "reopened");

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext";
@@ -56,12 +57,12 @@ import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
succeeded: { icon: CheckCircle2, color: "text-green-400" },
failed: { icon: XCircle, color: "text-red-400" },
running: { icon: Loader2, color: "text-cyan-400" },
queued: { icon: Clock, color: "text-yellow-400" },
timed_out: { icon: Timer, color: "text-orange-400" },
cancelled: { icon: Slash, color: "text-neutral-400" },
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
running: { icon: Loader2, color: "text-cyan-600 dark:text-cyan-400" },
queued: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" },
timed_out: { icon: Timer, color: "text-orange-600 dark:text-orange-400" },
cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" },
};
const REDACTED_ENV_VALUE = "***REDACTED***";
@@ -453,7 +454,7 @@ export function AgentDetail() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400">Live</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
</Link>
)}
@@ -631,18 +632,18 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="flex items-center gap-2 text-sm font-medium">
{isLive && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
</span>
)}
<h3 className="text-sm font-medium">{isLive ? "Live Run" : "Latest Run"}</h3>
</div>
{isLive ? "Live Run" : "Latest Run"}
</h3>
<Link
to={`/agents/${agentId}/runs/${run.id}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
>
View details &rarr;
</Link>
@@ -658,10 +659,10 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
<span className="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
<span className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium",
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
: "bg-neutral-800 text-neutral-400"
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
: "bg-muted text-muted-foreground"
)}>
{sourceLabels[run.invocationSource] ?? run.invocationSource}
</span>
@@ -765,263 +766,7 @@ function AgentOverview({
);
}
/* ---- Chart Components ---- */
function getLast14Days(): string[] {
return Array.from({ length: 14 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - (13 - i));
return d.toISOString().slice(0, 10);
});
}
function formatDayLabel(dateStr: string): string {
const d = new Date(dateStr + "T12:00:00");
return `${d.getMonth() + 1}/${d.getDate()}`;
}
function DateLabels({ days }: { days: string[] }) {
return (
<div className="flex gap-[3px] mt-1.5">
{days.map((day, i) => (
<div key={day} className="flex-1 text-center">
{(i === 0 || i === 6 || i === 13) ? (
<span className="text-[9px] text-muted-foreground tabular-nums">{formatDayLabel(day)}</span>
) : null}
</div>
))}
</div>
);
}
function ChartLegend({ items }: { items: { color: string; label: string }[] }) {
return (
<div className="flex flex-wrap gap-x-2.5 gap-y-0.5 mt-2">
{items.map(item => (
<span key={item.label} className="flex items-center gap-1 text-[9px] text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
{item.label}
</span>
))}
</div>
);
}
function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg p-4 space-y-3">
<div>
<h3 className="text-xs font-medium text-muted-foreground">{title}</h3>
{subtitle && <span className="text-[10px] text-muted-foreground/60">{subtitle}</span>}
</div>
{children}
</div>
);
}
function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
const days = getLast14Days();
const grouped = new Map<string, { succeeded: number; failed: number; other: number }>();
for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 });
for (const run of runs) {
const day = new Date(run.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
if (!entry) continue;
if (run.status === "succeeded") entry.succeeded++;
else if (run.status === "failed" || run.status === "timed_out") entry.failed++;
else entry.other++;
}
const maxValue = Math.max(...Array.from(grouped.values()).map(v => v.succeeded + v.failed + v.other), 1);
const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0);
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const total = entry.succeeded + entry.failed + entry.other;
const heightPct = (total / maxValue) * 100;
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}>
{total > 0 ? (
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
{entry.succeeded > 0 && <div className="bg-emerald-500" style={{ flex: entry.succeeded }} />}
{entry.failed > 0 && <div className="bg-red-500" style={{ flex: entry.failed }} />}
{entry.other > 0 && <div className="bg-neutral-500" style={{ flex: entry.other }} />}
</div>
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
</div>
);
}
const priorityColors: Record<string, string> = {
critical: "#ef4444",
high: "#f97316",
medium: "#eab308",
low: "#6b7280",
};
const priorityOrder = ["critical", "high", "medium", "low"] as const;
function PriorityChart({ issues }: { issues: { priority: string; createdAt: Date }[] }) {
const days = getLast14Days();
const grouped = new Map<string, Record<string, number>>();
for (const day of days) grouped.set(day, { critical: 0, high: 0, medium: 0, low: 0 });
for (const issue of issues) {
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
if (!entry) continue;
if (issue.priority in entry) entry[issue.priority]++;
}
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
const hasData = Array.from(grouped.values()).some(v => Object.values(v).reduce((a, b) => a + b, 0) > 0);
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const total = Object.values(entry).reduce((a, b) => a + b, 0);
const heightPct = (total / maxValue) * 100;
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
{total > 0 ? (
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
{priorityOrder.map(p => entry[p] > 0 ? (
<div key={p} style={{ flex: entry[p], backgroundColor: priorityColors[p] }} />
) : null)}
</div>
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
<ChartLegend items={priorityOrder.map(p => ({ color: priorityColors[p], label: p.charAt(0).toUpperCase() + p.slice(1) }))} />
</div>
);
}
const statusColors: Record<string, string> = {
todo: "#3b82f6",
in_progress: "#8b5cf6",
in_review: "#a855f7",
done: "#10b981",
blocked: "#ef4444",
cancelled: "#6b7280",
backlog: "#64748b",
};
const statusLabels: Record<string, string> = {
todo: "To Do",
in_progress: "In Progress",
in_review: "In Review",
done: "Done",
blocked: "Blocked",
cancelled: "Cancelled",
backlog: "Backlog",
};
function IssueStatusChart({ issues }: { issues: { status: string; createdAt: Date }[] }) {
const days = getLast14Days();
const allStatuses = new Set<string>();
const grouped = new Map<string, Record<string, number>>();
for (const day of days) grouped.set(day, {});
for (const issue of issues) {
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
if (!entry) continue;
entry[issue.status] = (entry[issue.status] ?? 0) + 1;
allStatuses.add(issue.status);
}
const statusOrder = ["todo", "in_progress", "in_review", "done", "blocked", "cancelled", "backlog"].filter(s => allStatuses.has(s));
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
const hasData = allStatuses.size > 0;
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const total = Object.values(entry).reduce((a, b) => a + b, 0);
const heightPct = (total / maxValue) * 100;
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
{total > 0 ? (
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
{statusOrder.map(s => (entry[s] ?? 0) > 0 ? (
<div key={s} style={{ flex: entry[s], backgroundColor: statusColors[s] ?? "#6b7280" }} />
) : null)}
</div>
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
<ChartLegend items={statusOrder.map(s => ({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} />
</div>
);
}
function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) {
const days = getLast14Days();
const grouped = new Map<string, { succeeded: number; total: number }>();
for (const day of days) grouped.set(day, { succeeded: 0, total: 0 });
for (const run of runs) {
const day = new Date(run.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
if (!entry) continue;
entry.total++;
if (run.status === "succeeded") entry.succeeded++;
}
const hasData = Array.from(grouped.values()).some(v => v.total > 0);
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const rate = entry.total > 0 ? entry.succeeded / entry.total : 0;
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${entry.total > 0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}>
{entry.total > 0 ? (
<div style={{ height: `${rate * 100}%`, minHeight: 2, backgroundColor: color }} />
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
</div>
);
}
/* Chart components imported from ../components/ActivityCharts */
/* ---- Configuration Summary ---- */
@@ -1091,7 +836,7 @@ function ConfigSummary({
{reportsToAgent ? (
<Link
to={`/agents/${reportsToAgent.id}`}
className="text-blue-400 hover:underline"
className="text-blue-600 hover:underline dark:text-blue-400"
>
<Identity name={reportsToAgent.name} size="sm" />
</Link>
@@ -1108,7 +853,7 @@ function ConfigSummary({
<Link
key={r.id}
to={`/agents/${r.id}`}
className="flex items-center gap-2 text-sm text-blue-400 hover:underline"
className="flex items-center gap-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
>
<span className="relative flex h-2 w-2">
<span className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[r.status] ?? agentStatusDotDefault}`} />
@@ -1419,10 +1164,10 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
</span>
<span className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0",
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
: "bg-neutral-800 text-neutral-400"
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
: "bg-muted text-muted-foreground"
)}>
{sourceLabels[run.invocationSource] ?? run.invocationSource}
</span>
@@ -1682,7 +1427,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
)}
{run.error && (
<div className="text-xs">
<span className="text-red-400">{run.error}</span>
<span className="text-red-600 dark:text-red-400">{run.error}</span>
{run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
</div>
)}
@@ -1709,7 +1454,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
Login URL:
<a
href={claudeLoginResult.loginUrl}
className="text-blue-400 underline underline-offset-2 ml-1 break-all"
className="text-blue-600 underline underline-offset-2 ml-1 break-all dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
@@ -1720,12 +1465,12 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{claudeLoginResult && (
<>
{!!claudeLoginResult.stdout && (
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
{claudeLoginResult.stdout}
</pre>
)}
{!!claudeLoginResult.stderr && (
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-300 overflow-x-auto whitespace-pre-wrap">
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
{claudeLoginResult.stderr}
</pre>
)}
@@ -1734,7 +1479,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
</div>
)}
{hasNonZeroExit && (
<div className="text-xs text-red-400">
<div className="text-xs text-red-600 dark:text-red-400">
Exit code {run.exitCode}
{run.signal && <span className="text-muted-foreground ml-1">(signal: {run.signal})</span>}
</div>
@@ -1848,8 +1593,8 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{/* stderr excerpt for failed runs */}
{run.stderrExcerpt && (
<div className="space-y-1">
<span className="text-xs font-medium text-red-400">stderr</span>
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-300 overflow-x-auto whitespace-pre-wrap">{run.stderrExcerpt}</pre>
<span className="text-xs font-medium text-red-600 dark:text-red-400">stderr</span>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">{run.stderrExcerpt}</pre>
</div>
)}
@@ -1857,7 +1602,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{run.stdoutExcerpt && !run.logRef && (
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">stdout</span>
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">{run.stdoutExcerpt}</pre>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">{run.stdoutExcerpt}</pre>
</div>
)}
@@ -2119,14 +1864,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const levelColors: Record<string, string> = {
info: "text-foreground",
warn: "text-yellow-400",
error: "text-red-400",
warn: "text-yellow-600 dark:text-yellow-400",
error: "text-red-600 dark:text-red-400",
};
const streamColors: Record<string, string> = {
stdout: "text-foreground",
stderr: "text-red-300",
system: "text-blue-300",
stderr: "text-red-600 dark:text-red-300",
system: "text-blue-600 dark:text-blue-300",
};
return (
@@ -2156,7 +1901,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{adapterInvokePayload.prompt !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof adapterInvokePayload.prompt === "string"
? adapterInvokePayload.prompt
: JSON.stringify(adapterInvokePayload.prompt, null, 2)}
@@ -2166,7 +1911,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{adapterInvokePayload.context !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(adapterInvokePayload.context, null, 2)}
</pre>
</div>
@@ -2174,7 +1919,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{adapterInvokePayload.env !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Environment</div>
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{formatEnvForDisplay(adapterInvokePayload.env)}
</pre>
</div>
@@ -2213,14 +1958,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
)}
</div>
</div>
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5 overflow-x-hidden">
<div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5 overflow-x-hidden">
{transcript.length === 0 && !run.logRef && (
<div className="text-neutral-500">No persisted transcript for this run.</div>
)}
{transcript.map((entry, idx) => {
const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false });
const grid = "grid grid-cols-[auto_auto_1fr] gap-x-2 sm:gap-x-3 items-baseline";
const tsCell = "text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs";
const tsCell = "text-neutral-400 dark:text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs";
const lblCell = "w-14 sm:w-20 text-[10px] sm:text-xs";
const contentCell = "min-w-0 whitespace-pre-wrap break-words overflow-hidden";
const expandCell = "col-span-full md:col-start-3 md:col-span-1";
@@ -2229,8 +1974,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div key={`${entry.ts}-assistant-${idx}`} className={cn(grid, "py-0.5")}>
<span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-green-300")}>assistant</span>
<span className={cn(contentCell, "text-green-100")}>{entry.text}</span>
<span className={cn(lblCell, "text-green-700 dark:text-green-300")}>assistant</span>
<span className={cn(contentCell, "text-green-900 dark:text-green-100")}>{entry.text}</span>
</div>
);
}
@@ -2239,8 +1984,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div key={`${entry.ts}-thinking-${idx}`} className={cn(grid, "py-0.5")}>
<span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-green-300/60")}>thinking</span>
<span className={cn(contentCell, "text-green-100/60 italic")}>{entry.text}</span>
<span className={cn(lblCell, "text-green-600/60 dark:text-green-300/60")}>thinking</span>
<span className={cn(contentCell, "text-green-800/60 dark:text-green-100/60 italic")}>{entry.text}</span>
</div>
);
}
@@ -2249,8 +1994,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div key={`${entry.ts}-user-${idx}`} className={cn(grid, "py-0.5")}>
<span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-neutral-400")}>user</span>
<span className={cn(contentCell, "text-neutral-300")}>{entry.text}</span>
<span className={cn(lblCell, "text-neutral-500 dark:text-neutral-400")}>user</span>
<span className={cn(contentCell, "text-neutral-700 dark:text-neutral-300")}>{entry.text}</span>
</div>
);
}
@@ -2259,9 +2004,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div key={`${entry.ts}-tool-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
<span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-yellow-300")}>tool_call</span>
<span className="text-yellow-100 min-w-0">{entry.name}</span>
<pre className={cn(expandCell, "bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-200")}>
<span className={cn(lblCell, "text-yellow-700 dark:text-yellow-300")}>tool_call</span>
<span className="text-yellow-900 dark:text-yellow-100 min-w-0">{entry.name}</span>
<pre className={cn(expandCell, "bg-neutral-200 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-800 dark:text-neutral-200")}>
{JSON.stringify(entry.input, null, 2)}
</pre>
</div>
@@ -2272,9 +2017,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div key={`${entry.ts}-toolres-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
<span className={tsCell}>{time}</span>
<span className={cn(lblCell, entry.isError ? "text-red-300" : "text-purple-300")}>tool_result</span>
{entry.isError ? <span className="text-red-400 min-w-0">error</span> : <span />}
<pre className={cn(expandCell, "bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-300 max-h-60 overflow-y-auto")}>
<span className={cn(lblCell, entry.isError ? "text-red-600 dark:text-red-300" : "text-purple-600 dark:text-purple-300")}>tool_result</span>
{entry.isError ? <span className="text-red-600 dark:text-red-400 min-w-0">error</span> : <span />}
<pre className={cn(expandCell, "bg-neutral-100 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-700 dark:text-neutral-300 max-h-60 overflow-y-auto")}>
{(() => { try { return JSON.stringify(JSON.parse(entry.content), null, 2); } catch { return entry.content; } })()}
</pre>
</div>
@@ -2285,8 +2030,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div key={`${entry.ts}-init-${idx}`} className={grid}>
<span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-blue-300")}>init</span>
<span className={cn(contentCell, "text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span>
<span className={cn(lblCell, "text-blue-700 dark:text-blue-300")}>init</span>
<span className={cn(contentCell, "text-blue-900 dark:text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span>
</div>
);
}
@@ -2295,18 +2040,18 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div key={`${entry.ts}-result-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
<span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-cyan-300")}>result</span>
<span className={cn(contentCell, "text-cyan-100")}>
<span className={cn(lblCell, "text-cyan-700 dark:text-cyan-300")}>result</span>
<span className={cn(contentCell, "text-cyan-900 dark:text-cyan-100")}>
tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)}
</span>
{(entry.subtype || entry.isError || entry.errors.length > 0) && (
<div className={cn(expandCell, "text-red-300 whitespace-pre-wrap break-words")}>
<div className={cn(expandCell, "text-red-600 dark:text-red-300 whitespace-pre-wrap break-words")}>
subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"}
{entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""}
</div>
)}
{entry.text && (
<div className={cn(expandCell, "whitespace-pre-wrap break-words text-neutral-100")}>{entry.text}</div>
<div className={cn(expandCell, "whitespace-pre-wrap break-words text-neutral-800 dark:text-neutral-100")}>{entry.text}</div>
)}
</div>
);
@@ -2318,8 +2063,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
entry.kind === "system" ? "system" :
"stdout";
const color =
entry.kind === "stderr" ? "text-red-300" :
entry.kind === "system" ? "text-blue-300" :
entry.kind === "stderr" ? "text-red-600 dark:text-red-300" :
entry.kind === "system" ? "text-blue-600 dark:text-blue-300" :
"text-neutral-500";
return (
<div key={`${entry.ts}-raw-${idx}`} className={grid}>
@@ -2329,39 +2074,39 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
</div>
)
})}
{logError && <div className="text-red-300">{logError}</div>}
{logError && <div className="text-red-600 dark:text-red-300">{logError}</div>}
<div ref={logEndRef} />
</div>
{(run.status === "failed" || run.status === "timed_out") && (
<div className="rounded-lg border border-red-500/30 bg-red-950/20 p-3 space-y-2">
<div className="text-xs font-medium text-red-300">Failure details</div>
<div className="rounded-lg border border-red-300 dark:border-red-500/30 bg-red-50 dark:bg-red-950/20 p-3 space-y-2">
<div className="text-xs font-medium text-red-700 dark:text-red-300">Failure details</div>
{run.error && (
<div className="text-xs text-red-200">
<span className="text-red-300">Error: </span>
<div className="text-xs text-red-600 dark:text-red-200">
<span className="text-red-700 dark:text-red-300">Error: </span>
{run.error}
</div>
)}
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
<div>
<div className="text-xs text-red-300 mb-1">stderr excerpt</div>
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-100">
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{run.stderrExcerpt}
</pre>
</div>
)}
{run.resultJson && (
<div>
<div className="text-xs text-red-300 mb-1">adapter result JSON</div>
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-100">
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{JSON.stringify(run.resultJson, null, 2)}
</pre>
</div>
)}
{run.stdoutExcerpt && run.stdoutExcerpt.trim() && !run.resultJson && (
<div>
<div className="text-xs text-red-300 mb-1">stdout excerpt</div>
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-100">
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{run.stdoutExcerpt}
</pre>
</div>
@@ -2372,7 +2117,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{events.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-muted-foreground">Events ({events.length})</div>
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
<div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
{events.map((evt) => {
const color = evt.color
?? (evt.level ? levelColors[evt.level] : null)
@@ -2381,7 +2126,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div key={evt.id} className="flex gap-2">
<span className="text-neutral-600 shrink-0 select-none w-16">
<span className="text-neutral-400 dark:text-neutral-600 shrink-0 select-none w-16">
{new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })}
</span>
<span className={cn("shrink-0 w-14", evt.stream ? (streamColors[evt.stream] ?? "text-neutral-500") : "text-neutral-500")}>
@@ -2445,12 +2190,12 @@ function KeysTab({ agentId }: { agentId: string }) {
<div className="space-y-6">
{/* New token banner */}
{newToken && (
<div className="border border-yellow-600/40 bg-yellow-500/5 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-yellow-400">
<div className="border border-yellow-300 dark:border-yellow-600/40 bg-yellow-50 dark:bg-yellow-500/5 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400">
API key created copy it now, it will not be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-neutral-950 rounded px-3 py-1.5 text-xs font-mono text-green-300 truncate">
<code className="flex-1 bg-neutral-100 dark:bg-neutral-950 rounded px-3 py-1.5 text-xs font-mono text-green-700 dark:text-green-300 truncate">
{tokenVisible ? newToken : newToken.replace(/./g, "•")}
</code>
<Button

View File

@@ -5,6 +5,7 @@ import { issuesApi } from "../api/issues";
import { activityApi } from "../api/activity";
import { heartbeatsApi } from "../api/heartbeats";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
@@ -43,6 +44,11 @@ import {
import type { ActivityEvent } from "@paperclip/shared";
import type { Agent, IssueAttachment } from "@paperclip/shared";
type CommentReassignment = {
assigneeAgentId: string | null;
assigneeUserId: string | null;
};
const ACTION_LABELS: Record<string, string> = {
"issue.created": "created the issue",
"issue.updated": "updated the issue",
@@ -109,8 +115,12 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
: `changed the priority to ${humanizeValue(details.priority)}`
);
}
if (details.assigneeAgentId !== undefined) {
parts.push(details.assigneeAgentId ? "assigned the issue" : "unassigned the issue");
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
parts.push(
details.assigneeAgentId || details.assigneeUserId
? "assigned the issue"
: "unassigned the issue",
);
}
if (details.title !== undefined) parts.push("updated the title");
if (details.description !== undefined) parts.push("updated the description");
@@ -144,7 +154,6 @@ export function IssueDetail() {
const [detailTab, setDetailTab] = useState("comments");
const [secondaryOpen, setSecondaryOpen] = useState({
approvals: false,
runs: false,
cost: false,
});
const [attachmentError, setAttachmentError] = useState<string | null>(null);
@@ -208,6 +217,11 @@ export function IssueDetail() {
enabled: !!selectedCompanyId,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
@@ -227,6 +241,33 @@ export function IssueDetail() {
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}, [allIssues, issue]);
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const canReassignFromComment = Boolean(
issue?.assigneeUserId &&
(issue.assigneeUserId === "local-board" || (currentUserId && issue.assigneeUserId === currentUserId)),
);
const commentReassignOptions = useMemo(() => {
const options: Array<{ value: string; label: string }> = [{ value: "__none__", label: "No assignee" }];
const activeAgents = [...(agents ?? [])]
.filter((agent) => agent.status !== "terminated")
.sort((a, b) => a.name.localeCompare(b.name));
for (const agent of activeAgents) {
options.push({ value: `agent:${agent.id}`, label: agent.name });
}
if (issue?.createdByUserId && issue.createdByUserId !== issue.assigneeUserId) {
const requesterLabel =
issue.createdByUserId === "local-board"
? "Board"
: currentUserId && issue.createdByUserId === currentUserId
? "Me"
: issue.createdByUserId.slice(0, 8);
options.push({ value: `user:${issue.createdByUserId}`, label: `Requester (${requesterLabel})` });
}
return options;
}, [agents, currentUserId, issue?.assigneeUserId, issue?.createdByUserId]);
const commentsWithRunMeta = useMemo(() => {
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
const agentIdByRunId = new Map<string, string>();
@@ -335,6 +376,36 @@ export function IssueDetail() {
},
});
const addCommentAndReassign = useMutation({
mutationFn: ({
body,
reopen,
reassignment,
}: {
body: string;
reopen?: boolean;
reassignment: CommentReassignment;
}) =>
issuesApi.update(issueId!, {
comment: body,
assigneeAgentId: reassignment.assigneeAgentId,
assigneeUserId: reassignment.assigneeUserId,
...(reopen ? { status: "todo" } : {}),
}),
onSuccess: (updated) => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
const issueRef = updated.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
pushToast({
dedupeKey: `activity:issue.reassigned:${updated.id}`,
title: `${issueRef} reassigned`,
body: issue?.title ? truncate(issue.title, 96) : undefined,
tone: "success",
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
});
},
});
const uploadAttachment = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
@@ -445,7 +516,7 @@ export function IssueDetail() {
<span className="text-sm font-mono text-muted-foreground shrink-0">{issue.identifier ?? issue.id.slice(0, 8)}</span>
{hasLiveRuns && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-400 shrink-0">
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
@@ -638,10 +709,17 @@ export function IssueDetail() {
<TabsContent value="comments">
<CommentThread
comments={commentsWithRunMeta}
linkedRuns={linkedRuns ?? []}
issueStatus={issue.status}
agentMap={agentMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
onAdd={async (body, reopen) => {
enableReassign={canReassignFromComment}
reassignOptions={commentReassignOptions}
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
}}
imageUploadHandler={async (file) => {
@@ -740,39 +818,6 @@ export function IssueDetail() {
</Collapsible>
)}
{linkedRuns && linkedRuns.length > 0 && (
<Collapsible
open={secondaryOpen.runs}
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, runs: open }))}
className="rounded-lg border border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
<span className="text-sm font-medium text-muted-foreground">Linked Runs ({linkedRuns.length})</span>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.runs && "rotate-180")}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border divide-y divide-border">
{linkedRuns.map((run) => (
<Link
key={run.runId}
to={`/agents/${run.agentId}/runs/${run.runId}`}
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2">
<Identity name={agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8)} size="sm" />
<StatusBadge status={run.status} />
<span className="font-mono text-muted-foreground">{run.runId.slice(0, 8)}</span>
</div>
<span className="text-muted-foreground">{relativeTime(run.createdAt)}</span>
</Link>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
{linkedRuns && linkedRuns.length > 0 && (
<Collapsible
open={secondaryOpen.cost}