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, help,
adapterLabels, adapterLabels,
} from "./agent-config-primitives"; } from "./agent-config-primitives";
import { defaultCreateValues } from "./agent-config-defaults";
import { getUIAdapter } from "../adapters"; import { getUIAdapter } from "../adapters";
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
@@ -210,8 +211,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
} }
if (overlay.adapterType !== undefined) { if (overlay.adapterType !== undefined) {
patch.adapterType = overlay.adapterType; patch.adapterType = overlay.adapterType;
} // When adapter type changes, send only the new config — don't merge
if (Object.keys(overlay.adapterConfig).length > 0) { // 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>; const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
} }
@@ -432,12 +435,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
value={adapterType} value={adapterType}
onChange={(t) => { onChange={(t) => {
if (isCreate) { 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 { } 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) => ({ setOverlay((prev) => ({
...prev, ...prev,
adapterType: t, 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"; result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";
const statusClass = const statusClass =
result.status === "pass" 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" : result.status === "warn"
? "text-amber-300 border-amber-500/40 bg-amber-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-300 border-red-500/40 bg-red-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 ( return (
<div className={`rounded-md border px-3 py-2 text-xs ${statusClass}`}> <div className={`rounded-md border px-3 py-2 text-xs ${statusClass}`}>
@@ -1154,37 +1165,39 @@ function ModelDropdown({
onChange={(e) => setModelSearch(e.target.value)} onChange={(e) => setModelSearch(e.target.value)}
autoFocus autoFocus
/> />
<button <div className="max-h-[240px] overflow-y-auto">
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) => (
<button <button
key={m.id}
className={cn( className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50", "flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent", !value && "bg-accent",
)} )}
onClick={() => { onClick={() => {
onChange(m.id); onChange("");
onOpenChange(false); onOpenChange(false);
}} }}
> >
<span>{m.label}</span> Default
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
</button> </button>
))} {filteredModels.map((m) => (
{filteredModels.length === 0 && ( <button
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p> 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> </PopoverContent>
</Popover> </Popover>
</Field> </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"; } from "react";
import { import {
MDXEditor, MDXEditor,
codeBlockPlugin,
codeMirrorPlugin,
type MDXEditorMethods, type MDXEditorMethods,
headingsPlugin, headingsPlugin,
imagePlugin, imagePlugin,
@@ -18,6 +20,7 @@ import {
listsPlugin, listsPlugin,
markdownShortcutPlugin, markdownShortcutPlugin,
quotePlugin, quotePlugin,
tablePlugin,
thematicBreakPlugin, thematicBreakPlugin,
type RealmPlugin, type RealmPlugin,
} from "@mdxeditor/editor"; } from "@mdxeditor/editor";
@@ -62,6 +65,26 @@ interface MentionState {
endPos: number; 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 { function detectMention(container: HTMLElement): MentionState | null {
const sel = window.getSelection(); const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
@@ -174,9 +197,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
headingsPlugin(), headingsPlugin(),
listsPlugin(), listsPlugin(),
quotePlugin(), quotePlugin(),
tablePlugin(),
linkPlugin(), linkPlugin(),
linkDialogPlugin(), linkDialogPlugin(),
thematicBreakPlugin(), thematicBreakPlugin(),
codeBlockPlugin(),
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
markdownShortcutPlugin(), markdownShortcutPlugin(),
]; ];
if (imageHandler) { if (imageHandler) {

View File

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

View File

@@ -42,6 +42,16 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
const DRAFT_KEY = "paperclip:issue-draft"; const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800; 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 { interface IssueDraft {
title: string; title: string;
description: string; description: string;
@@ -51,6 +61,7 @@ interface IssueDraft {
projectId: string; projectId: string;
assigneeModelOverride: string; assigneeModelOverride: string;
assigneeThinkingEffort: string; assigneeThinkingEffort: string;
assigneeChrome: boolean;
assigneeUseProjectWorkspace: boolean; assigneeUseProjectWorkspace: boolean;
} }
@@ -76,6 +87,7 @@ function buildAssigneeAdapterOverrides(input: {
adapterType: string | null | undefined; adapterType: string | null | undefined;
modelOverride: string; modelOverride: string;
thinkingEffortOverride: string; thinkingEffortOverride: string;
chrome: boolean;
useProjectWorkspace: boolean; useProjectWorkspace: boolean;
}): Record<string, unknown> | null { }): Record<string, unknown> | null {
const adapterType = input.adapterType ?? null; const adapterType = input.adapterType ?? null;
@@ -92,6 +104,9 @@ function buildAssigneeAdapterOverrides(input: {
adapterConfig.effort = input.thinkingEffortOverride; adapterConfig.effort = input.thinkingEffortOverride;
} }
} }
if (adapterType === "claude_local" && input.chrome) {
adapterConfig.chrome = true;
}
const overrides: Record<string, unknown> = {}; const overrides: Record<string, unknown> = {};
if (Object.keys(adapterConfig).length > 0) { if (Object.keys(adapterConfig).length > 0) {
@@ -138,7 +153,7 @@ const priorities = [
export function NewIssueDialog() { export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany(); const { companies, selectedCompanyId, selectedCompany } = useCompany();
const { pushToast } = useToast(); const { pushToast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@@ -150,29 +165,35 @@ export function NewIssueDialog() {
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
const [assigneeModelOverride, setAssigneeModelOverride] = useState(""); const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState(""); const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
const [assigneeChrome, setAssigneeChrome] = useState(false);
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true); const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | 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 // Popover states
const [statusOpen, setStatusOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false); const [priorityOpen, setPriorityOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
const [companyOpen, setCompanyOpen] = useState(false);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null); const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null); const attachInputRef = useRef<HTMLInputElement | null>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null); const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null); const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const { data: agents } = useQuery({ const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(effectiveCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!), queryFn: () => agentsApi.list(effectiveCompanyId!),
enabled: !!selectedCompanyId && newIssueOpen, enabled: !!effectiveCompanyId && newIssueOpen,
}); });
const { data: projects } = useQuery({ const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!), queryKey: queryKeys.projects.list(effectiveCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!), queryFn: () => projectsApi.list(effectiveCompanyId!),
enabled: !!selectedCompanyId && newIssueOpen, enabled: !!effectiveCompanyId && newIssueOpen,
}); });
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null; const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
@@ -183,14 +204,14 @@ export function NewIssueDialog() {
const { data: assigneeAdapterModels } = useQuery({ const { data: assigneeAdapterModels } = useQuery({
queryKey: ["adapter-models", assigneeAdapterType], queryKey: ["adapter-models", assigneeAdapterType],
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!), queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
enabled: !!selectedCompanyId && newIssueOpen && supportsAssigneeOverrides, enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
}); });
const createIssue = useMutation({ const createIssue = useMutation({
mutationFn: (data: Record<string, unknown>) => mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
issuesApi.create(selectedCompanyId!, data), issuesApi.create(companyId, data),
onSuccess: (issue) => { onSuccess: (issue) => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
if (draftTimer.current) clearTimeout(draftTimer.current); if (draftTimer.current) clearTimeout(draftTimer.current);
clearDraft(); clearDraft();
reset(); reset();
@@ -207,8 +228,8 @@ export function NewIssueDialog() {
const uploadDescriptionImage = useMutation({ const uploadDescriptionImage = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected"); if (!effectiveCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(selectedCompanyId, file, "issues/drafts"); return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
}, },
}); });
@@ -235,6 +256,7 @@ export function NewIssueDialog() {
projectId, projectId,
assigneeModelOverride, assigneeModelOverride,
assigneeThinkingEffort, assigneeThinkingEffort,
assigneeChrome,
assigneeUseProjectWorkspace, assigneeUseProjectWorkspace,
}); });
}, [ }, [
@@ -246,6 +268,7 @@ export function NewIssueDialog() {
projectId, projectId,
assigneeModelOverride, assigneeModelOverride,
assigneeThinkingEffort, assigneeThinkingEffort,
assigneeChrome,
assigneeUseProjectWorkspace, assigneeUseProjectWorkspace,
newIssueOpen, newIssueOpen,
scheduleSave, scheduleSave,
@@ -254,6 +277,7 @@ export function NewIssueDialog() {
// Restore draft or apply defaults when dialog opens // Restore draft or apply defaults when dialog opens
useEffect(() => { useEffect(() => {
if (!newIssueOpen) return; if (!newIssueOpen) return;
setDialogCompanyId(selectedCompanyId);
const draft = loadDraft(); const draft = loadDraft();
if (draft && draft.title.trim()) { if (draft && draft.title.trim()) {
@@ -265,6 +289,7 @@ export function NewIssueDialog() {
setProjectId(newIssueDefaults.projectId ?? draft.projectId); setProjectId(newIssueDefaults.projectId ?? draft.projectId);
setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? ""); setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeChrome(draft.assigneeChrome ?? false);
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true); setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
} else { } else {
setStatus(newIssueDefaults.status ?? "todo"); setStatus(newIssueDefaults.status ?? "todo");
@@ -273,6 +298,7 @@ export function NewIssueDialog() {
setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true); setAssigneeUseProjectWorkspace(true);
} }
}, [newIssueOpen, newIssueDefaults]); }, [newIssueOpen, newIssueDefaults]);
@@ -282,6 +308,7 @@ export function NewIssueDialog() {
setAssigneeOptionsOpen(false); setAssigneeOptionsOpen(false);
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true); setAssigneeUseProjectWorkspace(true);
return; return;
} }
@@ -312,8 +339,22 @@ export function NewIssueDialog() {
setAssigneeOptionsOpen(false); setAssigneeOptionsOpen(false);
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true); setAssigneeUseProjectWorkspace(true);
setExpanded(false); 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() { function discardDraft() {
@@ -323,14 +364,16 @@ export function NewIssueDialog() {
} }
function handleSubmit() { function handleSubmit() {
if (!selectedCompanyId || !title.trim()) return; if (!effectiveCompanyId || !title.trim()) return;
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({ const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType, adapterType: assigneeAdapterType,
modelOverride: assigneeModelOverride, modelOverride: assigneeModelOverride,
thinkingEffortOverride: assigneeThinkingEffort, thinkingEffortOverride: assigneeThinkingEffort,
chrome: assigneeChrome,
useProjectWorkspace: assigneeUseProjectWorkspace, useProjectWorkspace: assigneeUseProjectWorkspace,
}); });
createIssue.mutate({ createIssue.mutate({
companyId: effectiveCompanyId,
title: title.trim(), title: title.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
status, status,
@@ -429,11 +472,59 @@ export function NewIssueDialog() {
{/* Header bar */} {/* 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 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"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedCompany && ( <Popover open={companyOpen} onOpenChange={setCompanyOpen}>
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium"> <PopoverTrigger asChild>
{selectedCompany.name.slice(0, 3).toUpperCase()} <button
</span> 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 className="text-muted-foreground/60">&rsaquo;</span>
<span>New issue</span> <span>New issue</span>
</div> </div>
@@ -604,6 +695,25 @@ export function NewIssueDialog() {
))} ))}
</div> </div>
</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="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> <div className="text-xs text-muted-foreground">Use project workspace</div>
<button <button

View File

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

View File

@@ -133,8 +133,11 @@ function describeIssueUpdate(details: Record<string, unknown> | null): string |
const changes: string[] = []; const changes: string[] = [];
if (typeof details.status === "string") changes.push(`status -> ${details.status.replace(/_/g, " ")}`); 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.priority === "string") changes.push(`priority -> ${details.priority}`);
if (typeof details.assigneeAgentId === "string") changes.push("reassigned"); if (typeof details.assigneeAgentId === "string" || typeof details.assigneeUserId === "string") {
else if (details.assigneeAgentId === null) changes.push("unassigned"); changes.push("reassigned");
} else if (details.assigneeAgentId === null || details.assigneeUserId === null) {
changes.push("unassigned");
}
if (details.reopened === true) { if (details.reopened === true) {
const from = readString(details.reopenedFrom); const from = readString(details.reopenedFrom);
changes.push(from ? `reopened from ${from.replace(/_/g, " ")}` : "reopened"); 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { activityApi } from "../api/activity"; import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
@@ -56,12 +57,12 @@ import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared"; import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = { const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
succeeded: { icon: CheckCircle2, color: "text-green-400" }, succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
failed: { icon: XCircle, color: "text-red-400" }, failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
running: { icon: Loader2, color: "text-cyan-400" }, running: { icon: Loader2, color: "text-cyan-600 dark:text-cyan-400" },
queued: { icon: Clock, color: "text-yellow-400" }, queued: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" },
timed_out: { icon: Timer, color: "text-orange-400" }, timed_out: { icon: Timer, color: "text-orange-600 dark:text-orange-400" },
cancelled: { icon: Slash, color: "text-neutral-400" }, cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" },
}; };
const REDACTED_ENV_VALUE = "***REDACTED***"; 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="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 className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span> </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> </Link>
)} )}
@@ -631,18 +632,18 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <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 && ( {isLive && (
<span className="relative flex h-2 w-2"> <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="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 className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
</span> </span>
)} )}
<h3 className="text-sm font-medium">{isLive ? "Live Run" : "Latest Run"}</h3> {isLive ? "Live Run" : "Latest Run"}
</div> </h3>
<Link <Link
to={`/agents/${agentId}/runs/${run.id}`} 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; View details &rarr;
</Link> </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="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
<span className={cn( <span className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium", "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 === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-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-900/50 text-cyan-300" : run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
: "bg-neutral-800 text-neutral-400" : "bg-muted text-muted-foreground"
)}> )}>
{sourceLabels[run.invocationSource] ?? run.invocationSource} {sourceLabels[run.invocationSource] ?? run.invocationSource}
</span> </span>
@@ -765,263 +766,7 @@ function AgentOverview({
); );
} }
/* ---- Chart Components ---- */ /* Chart components imported from ../components/ActivityCharts */
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>
);
}
/* ---- Configuration Summary ---- */ /* ---- Configuration Summary ---- */
@@ -1091,7 +836,7 @@ function ConfigSummary({
{reportsToAgent ? ( {reportsToAgent ? (
<Link <Link
to={`/agents/${reportsToAgent.id}`} 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" /> <Identity name={reportsToAgent.name} size="sm" />
</Link> </Link>
@@ -1108,7 +853,7 @@ function ConfigSummary({
<Link <Link
key={r.id} key={r.id}
to={`/agents/${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="relative flex h-2 w-2">
<span className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[r.status] ?? agentStatusDotDefault}`} /> <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>
<span className={cn( <span className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0", "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 === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-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-900/50 text-cyan-300" : run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
: "bg-neutral-800 text-neutral-400" : "bg-muted text-muted-foreground"
)}> )}>
{sourceLabels[run.invocationSource] ?? run.invocationSource} {sourceLabels[run.invocationSource] ?? run.invocationSource}
</span> </span>
@@ -1682,7 +1427,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
)} )}
{run.error && ( {run.error && (
<div className="text-xs"> <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>} {run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
</div> </div>
)} )}
@@ -1709,7 +1454,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
Login URL: Login URL:
<a <a
href={claudeLoginResult.loginUrl} 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" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
@@ -1720,12 +1465,12 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{claudeLoginResult && ( {claudeLoginResult && (
<> <>
{!!claudeLoginResult.stdout && ( {!!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} {claudeLoginResult.stdout}
</pre> </pre>
)} )}
{!!claudeLoginResult.stderr && ( {!!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} {claudeLoginResult.stderr}
</pre> </pre>
)} )}
@@ -1734,7 +1479,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
</div> </div>
)} )}
{hasNonZeroExit && ( {hasNonZeroExit && (
<div className="text-xs text-red-400"> <div className="text-xs text-red-600 dark:text-red-400">
Exit code {run.exitCode} Exit code {run.exitCode}
{run.signal && <span className="text-muted-foreground ml-1">(signal: {run.signal})</span>} {run.signal && <span className="text-muted-foreground ml-1">(signal: {run.signal})</span>}
</div> </div>
@@ -1848,8 +1593,8 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{/* stderr excerpt for failed runs */} {/* stderr excerpt for failed runs */}
{run.stderrExcerpt && ( {run.stderrExcerpt && (
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs font-medium text-red-400">stderr</span> <span className="text-xs font-medium text-red-600 dark: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> <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> </div>
)} )}
@@ -1857,7 +1602,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{run.stdoutExcerpt && !run.logRef && ( {run.stdoutExcerpt && !run.logRef && (
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">stdout</span> <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> </div>
)} )}
@@ -2119,14 +1864,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const levelColors: Record<string, string> = { const levelColors: Record<string, string> = {
info: "text-foreground", info: "text-foreground",
warn: "text-yellow-400", warn: "text-yellow-600 dark:text-yellow-400",
error: "text-red-400", error: "text-red-600 dark:text-red-400",
}; };
const streamColors: Record<string, string> = { const streamColors: Record<string, string> = {
stdout: "text-foreground", stdout: "text-foreground",
stderr: "text-red-300", stderr: "text-red-600 dark:text-red-300",
system: "text-blue-300", system: "text-blue-600 dark:text-blue-300",
}; };
return ( return (
@@ -2156,7 +1901,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{adapterInvokePayload.prompt !== undefined && ( {adapterInvokePayload.prompt !== undefined && (
<div> <div>
<div className="text-xs text-muted-foreground mb-1">Prompt</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" {typeof adapterInvokePayload.prompt === "string"
? adapterInvokePayload.prompt ? adapterInvokePayload.prompt
: JSON.stringify(adapterInvokePayload.prompt, null, 2)} : JSON.stringify(adapterInvokePayload.prompt, null, 2)}
@@ -2166,7 +1911,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{adapterInvokePayload.context !== undefined && ( {adapterInvokePayload.context !== undefined && (
<div> <div>
<div className="text-xs text-muted-foreground mb-1">Context</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)} {JSON.stringify(adapterInvokePayload.context, null, 2)}
</pre> </pre>
</div> </div>
@@ -2174,7 +1919,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{adapterInvokePayload.env !== undefined && ( {adapterInvokePayload.env !== undefined && (
<div> <div>
<div className="text-xs text-muted-foreground mb-1">Environment</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)} {formatEnvForDisplay(adapterInvokePayload.env)}
</pre> </pre>
</div> </div>
@@ -2213,14 +1958,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
)} )}
</div> </div>
</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 && ( {transcript.length === 0 && !run.logRef && (
<div className="text-neutral-500">No persisted transcript for this run.</div> <div className="text-neutral-500">No persisted transcript for this run.</div>
)} )}
{transcript.map((entry, idx) => { {transcript.map((entry, idx) => {
const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false }); 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 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 lblCell = "w-14 sm:w-20 text-[10px] sm:text-xs";
const contentCell = "min-w-0 whitespace-pre-wrap break-words overflow-hidden"; 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"; 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 ( return (
<div key={`${entry.ts}-assistant-${idx}`} className={cn(grid, "py-0.5")}> <div key={`${entry.ts}-assistant-${idx}`} className={cn(grid, "py-0.5")}>
<span className={tsCell}>{time}</span> <span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-green-300")}>assistant</span> <span className={cn(lblCell, "text-green-700 dark:text-green-300")}>assistant</span>
<span className={cn(contentCell, "text-green-100")}>{entry.text}</span> <span className={cn(contentCell, "text-green-900 dark:text-green-100")}>{entry.text}</span>
</div> </div>
); );
} }
@@ -2239,8 +1984,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return ( return (
<div key={`${entry.ts}-thinking-${idx}`} className={cn(grid, "py-0.5")}> <div key={`${entry.ts}-thinking-${idx}`} className={cn(grid, "py-0.5")}>
<span className={tsCell}>{time}</span> <span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-green-300/60")}>thinking</span> <span className={cn(lblCell, "text-green-600/60 dark:text-green-300/60")}>thinking</span>
<span className={cn(contentCell, "text-green-100/60 italic")}>{entry.text}</span> <span className={cn(contentCell, "text-green-800/60 dark:text-green-100/60 italic")}>{entry.text}</span>
</div> </div>
); );
} }
@@ -2249,8 +1994,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return ( return (
<div key={`${entry.ts}-user-${idx}`} className={cn(grid, "py-0.5")}> <div key={`${entry.ts}-user-${idx}`} className={cn(grid, "py-0.5")}>
<span className={tsCell}>{time}</span> <span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-neutral-400")}>user</span> <span className={cn(lblCell, "text-neutral-500 dark:text-neutral-400")}>user</span>
<span className={cn(contentCell, "text-neutral-300")}>{entry.text}</span> <span className={cn(contentCell, "text-neutral-700 dark:text-neutral-300")}>{entry.text}</span>
</div> </div>
); );
} }
@@ -2259,9 +2004,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return ( return (
<div key={`${entry.ts}-tool-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}> <div key={`${entry.ts}-tool-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
<span className={tsCell}>{time}</span> <span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-yellow-300")}>tool_call</span> <span className={cn(lblCell, "text-yellow-700 dark:text-yellow-300")}>tool_call</span>
<span className="text-yellow-100 min-w-0">{entry.name}</span> <span className="text-yellow-900 dark: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")}> <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)} {JSON.stringify(entry.input, null, 2)}
</pre> </pre>
</div> </div>
@@ -2272,9 +2017,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return ( return (
<div key={`${entry.ts}-toolres-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}> <div key={`${entry.ts}-toolres-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
<span className={tsCell}>{time}</span> <span className={tsCell}>{time}</span>
<span className={cn(lblCell, entry.isError ? "text-red-300" : "text-purple-300")}>tool_result</span> <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-400 min-w-0">error</span> : <span />} {entry.isError ? <span className="text-red-600 dark: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")}> <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; } })()} {(() => { try { return JSON.stringify(JSON.parse(entry.content), null, 2); } catch { return entry.content; } })()}
</pre> </pre>
</div> </div>
@@ -2285,8 +2030,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return ( return (
<div key={`${entry.ts}-init-${idx}`} className={grid}> <div key={`${entry.ts}-init-${idx}`} className={grid}>
<span className={tsCell}>{time}</span> <span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-blue-300")}>init</span> <span className={cn(lblCell, "text-blue-700 dark:text-blue-300")}>init</span>
<span className={cn(contentCell, "text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span> <span className={cn(contentCell, "text-blue-900 dark:text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span>
</div> </div>
); );
} }
@@ -2295,18 +2040,18 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return ( return (
<div key={`${entry.ts}-result-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}> <div key={`${entry.ts}-result-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
<span className={tsCell}>{time}</span> <span className={tsCell}>{time}</span>
<span className={cn(lblCell, "text-cyan-300")}>result</span> <span className={cn(lblCell, "text-cyan-700 dark:text-cyan-300")}>result</span>
<span className={cn(contentCell, "text-cyan-100")}> <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)} tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)}
</span> </span>
{(entry.subtype || entry.isError || entry.errors.length > 0) && ( {(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"} subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"}
{entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""} {entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""}
</div> </div>
)} )}
{entry.text && ( {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> </div>
); );
@@ -2318,8 +2063,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
entry.kind === "system" ? "system" : entry.kind === "system" ? "system" :
"stdout"; "stdout";
const color = const color =
entry.kind === "stderr" ? "text-red-300" : entry.kind === "stderr" ? "text-red-600 dark:text-red-300" :
entry.kind === "system" ? "text-blue-300" : entry.kind === "system" ? "text-blue-600 dark:text-blue-300" :
"text-neutral-500"; "text-neutral-500";
return ( return (
<div key={`${entry.ts}-raw-${idx}`} className={grid}> <div key={`${entry.ts}-raw-${idx}`} className={grid}>
@@ -2329,39 +2074,39 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
</div> </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 ref={logEndRef} />
</div> </div>
{(run.status === "failed" || run.status === "timed_out") && ( {(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="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-300">Failure details</div> <div className="text-xs font-medium text-red-700 dark:text-red-300">Failure details</div>
{run.error && ( {run.error && (
<div className="text-xs text-red-200"> <div className="text-xs text-red-600 dark:text-red-200">
<span className="text-red-300">Error: </span> <span className="text-red-700 dark:text-red-300">Error: </span>
{run.error} {run.error}
</div> </div>
)} )}
{run.stderrExcerpt && run.stderrExcerpt.trim() && ( {run.stderrExcerpt && run.stderrExcerpt.trim() && (
<div> <div>
<div className="text-xs text-red-300 mb-1">stderr excerpt</div> <div className="text-xs text-red-700 dark: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"> <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} {run.stderrExcerpt}
</pre> </pre>
</div> </div>
)} )}
{run.resultJson && ( {run.resultJson && (
<div> <div>
<div className="text-xs text-red-300 mb-1">adapter result JSON</div> <div className="text-xs text-red-700 dark: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"> <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)} {JSON.stringify(run.resultJson, null, 2)}
</pre> </pre>
</div> </div>
)} )}
{run.stdoutExcerpt && run.stdoutExcerpt.trim() && !run.resultJson && ( {run.stdoutExcerpt && run.stdoutExcerpt.trim() && !run.resultJson && (
<div> <div>
<div className="text-xs text-red-300 mb-1">stdout excerpt</div> <div className="text-xs text-red-700 dark: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"> <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} {run.stdoutExcerpt}
</pre> </pre>
</div> </div>
@@ -2372,7 +2117,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{events.length > 0 && ( {events.length > 0 && (
<div> <div>
<div className="mb-2 text-xs font-medium text-muted-foreground">Events ({events.length})</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) => { {events.map((evt) => {
const color = evt.color const color = evt.color
?? (evt.level ? levelColors[evt.level] : null) ?? (evt.level ? levelColors[evt.level] : null)
@@ -2381,7 +2126,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return ( return (
<div key={evt.id} className="flex gap-2"> <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 })} {new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })}
</span> </span>
<span className={cn("shrink-0 w-14", evt.stream ? (streamColors[evt.stream] ?? "text-neutral-500") : "text-neutral-500")}> <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"> <div className="space-y-6">
{/* New token banner */} {/* New token banner */}
{newToken && ( {newToken && (
<div className="border border-yellow-600/40 bg-yellow-500/5 rounded-lg p-4 space-y-2"> <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-400"> <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. API key created copy it now, it will not be shown again.
</p> </p>
<div className="flex items-center gap-2"> <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, "•")} {tokenVisible ? newToken : newToken.replace(/./g, "•")}
</code> </code>
<Button <Button

View File

@@ -5,6 +5,7 @@ import { issuesApi } from "../api/issues";
import { activityApi } from "../api/activity"; import { activityApi } from "../api/activity";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
@@ -43,6 +44,11 @@ import {
import type { ActivityEvent } from "@paperclip/shared"; import type { ActivityEvent } from "@paperclip/shared";
import type { Agent, IssueAttachment } from "@paperclip/shared"; import type { Agent, IssueAttachment } from "@paperclip/shared";
type CommentReassignment = {
assigneeAgentId: string | null;
assigneeUserId: string | null;
};
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<string, string> = {
"issue.created": "created the issue", "issue.created": "created the issue",
"issue.updated": "updated 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)}` : `changed the priority to ${humanizeValue(details.priority)}`
); );
} }
if (details.assigneeAgentId !== undefined) { if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
parts.push(details.assigneeAgentId ? "assigned the issue" : "unassigned the issue"); parts.push(
details.assigneeAgentId || details.assigneeUserId
? "assigned the issue"
: "unassigned the issue",
);
} }
if (details.title !== undefined) parts.push("updated the title"); if (details.title !== undefined) parts.push("updated the title");
if (details.description !== undefined) parts.push("updated the description"); if (details.description !== undefined) parts.push("updated the description");
@@ -144,7 +154,6 @@ export function IssueDetail() {
const [detailTab, setDetailTab] = useState("comments"); const [detailTab, setDetailTab] = useState("comments");
const [secondaryOpen, setSecondaryOpen] = useState({ const [secondaryOpen, setSecondaryOpen] = useState({
approvals: false, approvals: false,
runs: false,
cost: false, cost: false,
}); });
const [attachmentError, setAttachmentError] = useState<string | null>(null); const [attachmentError, setAttachmentError] = useState<string | null>(null);
@@ -208,6 +217,11 @@ export function IssueDetail() {
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: projects } = useQuery({ const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!), queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.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()); .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}, [allIssues, issue]); }, [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 commentsWithRunMeta = useMemo(() => {
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>(); const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
const agentIdByRunId = new Map<string, string>(); 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({ const uploadAttachment = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected"); 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> <span className="text-sm font-mono text-muted-foreground shrink-0">{issue.identifier ?? issue.id.slice(0, 8)}</span>
{hasLiveRuns && ( {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="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="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" /> <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"> <TabsContent value="comments">
<CommentThread <CommentThread
comments={commentsWithRunMeta} comments={commentsWithRunMeta}
linkedRuns={linkedRuns ?? []}
issueStatus={issue.status} issueStatus={issue.status}
agentMap={agentMap} agentMap={agentMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`} 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 }); await addComment.mutateAsync({ body, reopen });
}} }}
imageUploadHandler={async (file) => { imageUploadHandler={async (file) => {
@@ -740,39 +818,6 @@ export function IssueDetail() {
</Collapsible> </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 && ( {linkedRuns && linkedRuns.length > 0 && (
<Collapsible <Collapsible
open={secondaryOpen.cost} open={secondaryOpen.cost}