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:
@@ -31,6 +31,7 @@ import {
|
||||
help,
|
||||
adapterLabels,
|
||||
} from "./agent-config-primitives";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
@@ -210,8 +211,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}
|
||||
if (overlay.adapterType !== undefined) {
|
||||
patch.adapterType = overlay.adapterType;
|
||||
}
|
||||
if (Object.keys(overlay.adapterConfig).length > 0) {
|
||||
// When adapter type changes, send only the new config — don't merge
|
||||
// with old config since old adapter fields are meaningless for the new type
|
||||
patch.adapterConfig = overlay.adapterConfig;
|
||||
} else if (Object.keys(overlay.adapterConfig).length > 0) {
|
||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
|
||||
}
|
||||
@@ -432,12 +435,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
value={adapterType}
|
||||
onChange={(t) => {
|
||||
if (isCreate) {
|
||||
set!({ adapterType: t, model: "", thinkingEffort: "" });
|
||||
// Reset all adapter-specific fields to defaults when switching adapter type
|
||||
const { adapterType: _at, ...defaults } = defaultCreateValues;
|
||||
set!({ ...defaults, adapterType: t });
|
||||
} else {
|
||||
// Clear all adapter config and explicitly blank out model + both effort keys
|
||||
// so the old adapter's values don't bleed through via eff()
|
||||
setOverlay((prev) => ({
|
||||
...prev,
|
||||
adapterType: t,
|
||||
adapterConfig: {}, // clear adapter config when type changes
|
||||
adapterConfig: {
|
||||
model: "",
|
||||
effort: "",
|
||||
modelReasoningEffort: "",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
@@ -794,10 +805,10 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
||||
result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";
|
||||
const statusClass =
|
||||
result.status === "pass"
|
||||
? "text-green-300 border-green-500/40 bg-green-500/10"
|
||||
? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10"
|
||||
: result.status === "warn"
|
||||
? "text-amber-300 border-amber-500/40 bg-amber-500/10"
|
||||
: "text-red-300 border-red-500/40 bg-red-500/10";
|
||||
? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10"
|
||||
: "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10";
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border px-3 py-2 text-xs ${statusClass}`}>
|
||||
@@ -1154,6 +1165,7 @@ function ModelDropdown({
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
@@ -1185,6 +1197,7 @@ function ModelDropdown({
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Field>
|
||||
|
||||
199
ui/src/components/AsciiArtAnimation.tsx
Normal file
199
ui/src/components/AsciiArtAnimation.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
MDXEditor,
|
||||
codeBlockPlugin,
|
||||
codeMirrorPlugin,
|
||||
type MDXEditorMethods,
|
||||
headingsPlugin,
|
||||
imagePlugin,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
listsPlugin,
|
||||
markdownShortcutPlugin,
|
||||
quotePlugin,
|
||||
tablePlugin,
|
||||
thematicBreakPlugin,
|
||||
type RealmPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
@@ -62,6 +65,26 @@ interface MentionState {
|
||||
endPos: number;
|
||||
}
|
||||
|
||||
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
||||
txt: "Text",
|
||||
md: "Markdown",
|
||||
js: "JavaScript",
|
||||
jsx: "JavaScript (JSX)",
|
||||
ts: "TypeScript",
|
||||
tsx: "TypeScript (TSX)",
|
||||
json: "JSON",
|
||||
bash: "Bash",
|
||||
sh: "Shell",
|
||||
python: "Python",
|
||||
go: "Go",
|
||||
rust: "Rust",
|
||||
sql: "SQL",
|
||||
html: "HTML",
|
||||
css: "CSS",
|
||||
yaml: "YAML",
|
||||
yml: "YAML",
|
||||
};
|
||||
|
||||
function detectMention(container: HTMLElement): MentionState | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
||||
@@ -174,9 +197,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
headingsPlugin(),
|
||||
listsPlugin(),
|
||||
quotePlugin(),
|
||||
tablePlugin(),
|
||||
linkPlugin(),
|
||||
linkDialogPlugin(),
|
||||
thematicBreakPlugin(),
|
||||
codeBlockPlugin(),
|
||||
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
|
||||
markdownShortcutPlugin(),
|
||||
];
|
||||
if (imageHandler) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: LucideIcon;
|
||||
@@ -16,26 +15,22 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
|
||||
const isClickable = !!(to || onClick);
|
||||
|
||||
const inner = (
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-3 sm:p-4 h-full">
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
<div className={`h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-lg sm:text-2xl font-bold${isClickable ? " cursor-pointer" : ""}`}>
|
||||
<p className="text-2xl sm:text-3xl font-semibold tracking-tight">
|
||||
{value}
|
||||
</p>
|
||||
<p className={`text-sm text-muted-foreground${isClickable ? " cursor-pointer" : ""}`}>
|
||||
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">
|
||||
{label}
|
||||
</p>
|
||||
{description && (
|
||||
<div className="text-xs sm:text-sm text-muted-foreground mt-1 hidden sm:block">{description}</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-1.5 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" />
|
||||
<Icon className="h-4 w-4 text-muted-foreground/50 shrink-0 mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (to) {
|
||||
@@ -48,7 +43,7 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className="cursor-pointer h-full" onClick={onClick}>
|
||||
<div className="h-full" onClick={onClick}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,16 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
|
||||
const DRAFT_KEY = "paperclip:issue-draft";
|
||||
const DEBOUNCE_MS = 800;
|
||||
|
||||
/** Return black or white hex based on background luminance (WCAG perceptual weights). */
|
||||
function getContrastTextColor(hexColor: string): string {
|
||||
const hex = hexColor.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? "#000000" : "#ffffff";
|
||||
}
|
||||
|
||||
interface IssueDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -51,6 +61,7 @@ interface IssueDraft {
|
||||
projectId: string;
|
||||
assigneeModelOverride: string;
|
||||
assigneeThinkingEffort: string;
|
||||
assigneeChrome: boolean;
|
||||
assigneeUseProjectWorkspace: boolean;
|
||||
}
|
||||
|
||||
@@ -76,6 +87,7 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
adapterType: string | null | undefined;
|
||||
modelOverride: string;
|
||||
thinkingEffortOverride: string;
|
||||
chrome: boolean;
|
||||
useProjectWorkspace: boolean;
|
||||
}): Record<string, unknown> | null {
|
||||
const adapterType = input.adapterType ?? null;
|
||||
@@ -92,6 +104,9 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
adapterConfig.effort = input.thinkingEffortOverride;
|
||||
}
|
||||
}
|
||||
if (adapterType === "claude_local" && input.chrome) {
|
||||
adapterConfig.chrome = true;
|
||||
}
|
||||
|
||||
const overrides: Record<string, unknown> = {};
|
||||
if (Object.keys(adapterConfig).length > 0) {
|
||||
@@ -138,7 +153,7 @@ const priorities = [
|
||||
|
||||
export function NewIssueDialog() {
|
||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -150,29 +165,35 @@ export function NewIssueDialog() {
|
||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
||||
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
||||
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
||||
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
||||
|
||||
// Popover states
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [companyOpen, setCompanyOpen] = useState(false);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newIssueOpen,
|
||||
queryKey: queryKeys.agents.list(effectiveCompanyId!),
|
||||
queryFn: () => agentsApi.list(effectiveCompanyId!),
|
||||
enabled: !!effectiveCompanyId && newIssueOpen,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newIssueOpen,
|
||||
queryKey: queryKeys.projects.list(effectiveCompanyId!),
|
||||
queryFn: () => projectsApi.list(effectiveCompanyId!),
|
||||
enabled: !!effectiveCompanyId && newIssueOpen,
|
||||
});
|
||||
|
||||
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
|
||||
@@ -183,14 +204,14 @@ export function NewIssueDialog() {
|
||||
const { data: assigneeAdapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", assigneeAdapterType],
|
||||
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
|
||||
enabled: !!selectedCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
||||
enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
||||
});
|
||||
|
||||
const createIssue = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
issuesApi.create(selectedCompanyId!, data),
|
||||
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
|
||||
issuesApi.create(companyId, data),
|
||||
onSuccess: (issue) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
clearDraft();
|
||||
reset();
|
||||
@@ -207,8 +228,8 @@ export function NewIssueDialog() {
|
||||
|
||||
const uploadDescriptionImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(selectedCompanyId, file, "issues/drafts");
|
||||
if (!effectiveCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -235,6 +256,7 @@ export function NewIssueDialog() {
|
||||
projectId,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
assigneeUseProjectWorkspace,
|
||||
});
|
||||
}, [
|
||||
@@ -246,6 +268,7 @@ export function NewIssueDialog() {
|
||||
projectId,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
assigneeUseProjectWorkspace,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
@@ -254,6 +277,7 @@ export function NewIssueDialog() {
|
||||
// Restore draft or apply defaults when dialog opens
|
||||
useEffect(() => {
|
||||
if (!newIssueOpen) return;
|
||||
setDialogCompanyId(selectedCompanyId);
|
||||
|
||||
const draft = loadDraft();
|
||||
if (draft && draft.title.trim()) {
|
||||
@@ -265,6 +289,7 @@ export function NewIssueDialog() {
|
||||
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
|
||||
} else {
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
@@ -273,6 +298,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults]);
|
||||
@@ -282,6 +308,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeOptionsOpen(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
return;
|
||||
}
|
||||
@@ -312,8 +339,22 @@ export function NewIssueDialog() {
|
||||
setAssigneeOptionsOpen(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setCompanyOpen(false);
|
||||
}
|
||||
|
||||
function handleCompanyChange(companyId: string) {
|
||||
if (companyId === effectiveCompanyId) return;
|
||||
setDialogCompanyId(companyId);
|
||||
setAssigneeId("");
|
||||
setProjectId("");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
@@ -323,14 +364,16 @@ export function NewIssueDialog() {
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !title.trim()) return;
|
||||
if (!effectiveCompanyId || !title.trim()) return;
|
||||
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
||||
adapterType: assigneeAdapterType,
|
||||
modelOverride: assigneeModelOverride,
|
||||
thinkingEffortOverride: assigneeThinkingEffort,
|
||||
chrome: assigneeChrome,
|
||||
useProjectWorkspace: assigneeUseProjectWorkspace,
|
||||
});
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
@@ -429,11 +472,59 @@ export function NewIssueDialog() {
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedCompany && (
|
||||
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
||||
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
||||
!dialogCompany?.brandColor && "bg-muted",
|
||||
)}
|
||||
style={
|
||||
dialogCompany?.brandColor
|
||||
? {
|
||||
backgroundColor: dialogCompany.brandColor,
|
||||
color: getContrastTextColor(dialogCompany.brandColor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(dialogCompany?.name ?? "").slice(0, 3).toUpperCase()}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="start">
|
||||
{companies.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
c.id === effectiveCompanyId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
handleCompanyChange(c.id);
|
||||
setCompanyOpen(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"px-1 py-0.5 rounded text-[10px] font-semibold leading-none",
|
||||
!c.brandColor && "bg-muted",
|
||||
)}
|
||||
style={
|
||||
c.brandColor
|
||||
? {
|
||||
backgroundColor: c.brandColor,
|
||||
color: getContrastTextColor(c.brandColor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{c.name.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">{c.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="text-muted-foreground/60">›</span>
|
||||
<span>New issue</span>
|
||||
</div>
|
||||
@@ -604,6 +695,25 @@ export function NewIssueDialog() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{assigneeAdapterType === "claude_local" && (
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
assigneeChrome ? "bg-green-600" : "bg-muted"
|
||||
)}
|
||||
onClick={() => setAssigneeChrome((value) => !value)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
assigneeChrome ? "translate-x-4.5" : "translate-x-0.5"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||
<div className="text-xs text-muted-foreground">Use project workspace</div>
|
||||
<button
|
||||
|
||||
@@ -8,7 +8,7 @@ import { goalsApi } from "../api/goals";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -18,9 +18,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||
import {
|
||||
Building2,
|
||||
Bot,
|
||||
Code,
|
||||
ListTodo,
|
||||
Rocket,
|
||||
ArrowLeft,
|
||||
@@ -32,10 +34,11 @@ import {
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
type AdapterType = "claude_local" | "process" | "http";
|
||||
type AdapterType = "claude_local" | "codex_local" | "process" | "http" | "openclaw";
|
||||
|
||||
export function OnboardingWizard() {
|
||||
const { onboardingOpen, closeOnboarding } = useDialog();
|
||||
@@ -98,6 +101,11 @@ export function OnboardingWizard() {
|
||||
setCreatedAgentId(null);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
reset();
|
||||
closeOnboarding();
|
||||
}
|
||||
|
||||
function buildAdapterConfig(): Record<string, unknown> {
|
||||
const adapter = getUIAdapter(adapterType);
|
||||
return adapter.buildAdapterConfig({
|
||||
@@ -217,33 +225,41 @@ export function OnboardingWizard() {
|
||||
}
|
||||
}
|
||||
|
||||
const stepIcons = [Building2, Bot, ListTodo, Rocket];
|
||||
if (!onboardingOpen) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={onboardingOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
closeOnboarding();
|
||||
}
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="p-0 gap-0 overflow-hidden sm:max-w-lg"
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-background" />
|
||||
<div
|
||||
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">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 left-4 z-10 rounded-sm p-1.5 text-muted-foreground/60 hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
|
||||
{/* Left half — form */}
|
||||
<div className="w-full md:w-1/2 flex flex-col overflow-y-auto">
|
||||
<div className="w-full max-w-md mx-auto my-auto px-8 py-12">
|
||||
{/* Progress indicators */}
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Get Started</span>
|
||||
<span className="text-muted-foreground/60">
|
||||
<span className="text-sm font-medium">Get Started</span>
|
||||
<span className="text-sm text-muted-foreground/60">
|
||||
Step {step} of 4
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
@@ -260,11 +276,10 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto max-h-[60vh]">
|
||||
{/* Step content */}
|
||||
{step === 1 && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
@@ -302,8 +317,8 @@ export function OnboardingWizard() {
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-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>
|
||||
@@ -332,7 +347,7 @@ export function OnboardingWizard() {
|
||||
<label className="text-xs text-muted-foreground mb-2 block">
|
||||
Adapter type
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{
|
||||
value: "claude_local" as const,
|
||||
@@ -340,6 +355,18 @@ export function OnboardingWizard() {
|
||||
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",
|
||||
@@ -374,7 +401,7 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
|
||||
{/* Conditional adapter fields */}
|
||||
{adapterType === "claude_local" && (
|
||||
{(adapterType === "claude_local" || adapterType === "codex_local") && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
@@ -496,7 +523,7 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adapterType === "http" && (
|
||||
{(adapterType === "http" || adapterType === "openclaw") && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Webhook URL
|
||||
@@ -513,8 +540,8 @@ export function OnboardingWizard() {
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<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>
|
||||
@@ -553,8 +580,8 @@ export function OnboardingWizard() {
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<Rocket className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
@@ -595,17 +622,16 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
|
||||
{/* Footer navigation */}
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<Button
|
||||
@@ -674,7 +700,15 @@ export function OnboardingWizard() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right half — ASCII art (hidden on mobile) */}
|
||||
<div className="hidden md:block w-1/2 overflow-hidden">
|
||||
<AsciiArtAnimation />
|
||||
</div>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,8 +133,11 @@ function describeIssueUpdate(details: Record<string, unknown> | null): string |
|
||||
const changes: string[] = [];
|
||||
if (typeof details.status === "string") changes.push(`status -> ${details.status.replace(/_/g, " ")}`);
|
||||
if (typeof details.priority === "string") changes.push(`priority -> ${details.priority}`);
|
||||
if (typeof details.assigneeAgentId === "string") changes.push("reassigned");
|
||||
else if (details.assigneeAgentId === null) changes.push("unassigned");
|
||||
if (typeof details.assigneeAgentId === "string" || typeof details.assigneeUserId === "string") {
|
||||
changes.push("reassigned");
|
||||
} else if (details.assigneeAgentId === null || details.assigneeUserId === null) {
|
||||
changes.push("unassigned");
|
||||
}
|
||||
if (details.reopened === true) {
|
||||
const from = readString(details.reopenedFrom);
|
||||
changes.push(from ? `reopened from ${from.replace(/_/g, " ")}` : "reopened");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
@@ -56,12 +57,12 @@ import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
||||
|
||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||
succeeded: { icon: CheckCircle2, color: "text-green-400" },
|
||||
failed: { icon: XCircle, color: "text-red-400" },
|
||||
running: { icon: Loader2, color: "text-cyan-400" },
|
||||
queued: { icon: Clock, color: "text-yellow-400" },
|
||||
timed_out: { icon: Timer, color: "text-orange-400" },
|
||||
cancelled: { icon: Slash, color: "text-neutral-400" },
|
||||
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
||||
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
|
||||
running: { icon: Loader2, color: "text-cyan-600 dark:text-cyan-400" },
|
||||
queued: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" },
|
||||
timed_out: { icon: Timer, color: "text-orange-600 dark:text-orange-400" },
|
||||
cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" },
|
||||
};
|
||||
|
||||
const REDACTED_ENV_VALUE = "***REDACTED***";
|
||||
@@ -453,7 +454,7 @@ export function AgentDetail() {
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">Live</span>
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -631,18 +632,18 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
|
||||
</span>
|
||||
)}
|
||||
<h3 className="text-sm font-medium">{isLive ? "Live Run" : "Latest Run"}</h3>
|
||||
</div>
|
||||
{isLive ? "Live Run" : "Latest Run"}
|
||||
</h3>
|
||||
<Link
|
||||
to={`/agents/${agentId}/runs/${run.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
|
||||
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
|
||||
>
|
||||
View details →
|
||||
</Link>
|
||||
@@ -658,10 +659,10 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||
<span className="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium",
|
||||
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
|
||||
: "bg-neutral-800 text-neutral-400"
|
||||
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||
</span>
|
||||
@@ -765,263 +766,7 @@ function AgentOverview({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Chart Components ---- */
|
||||
|
||||
function getLast14Days(): string[] {
|
||||
return Array.from({ length: 14 }, (_, i) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - (13 - i));
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDayLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T12:00:00");
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
function DateLabels({ days }: { days: string[] }) {
|
||||
return (
|
||||
<div className="flex gap-[3px] mt-1.5">
|
||||
{days.map((day, i) => (
|
||||
<div key={day} className="flex-1 text-center">
|
||||
{(i === 0 || i === 6 || i === 13) ? (
|
||||
<span className="text-[9px] text-muted-foreground tabular-nums">{formatDayLabel(day)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ items }: { items: { color: string; label: string }[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-2.5 gap-y-0.5 mt-2">
|
||||
{items.map(item => (
|
||||
<span key={item.label} className="flex items-center gap-1 text-[9px] text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground">{title}</h3>
|
||||
{subtitle && <span className="text-[10px] text-muted-foreground/60">{subtitle}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
|
||||
const days = getLast14Days();
|
||||
|
||||
const grouped = new Map<string, { succeeded: number; failed: number; other: number }>();
|
||||
for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 });
|
||||
for (const run of runs) {
|
||||
const day = new Date(run.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
if (run.status === "succeeded") entry.succeeded++;
|
||||
else if (run.status === "failed" || run.status === "timed_out") entry.failed++;
|
||||
else entry.other++;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => v.succeeded + v.failed + v.other), 1);
|
||||
const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0);
|
||||
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const total = entry.succeeded + entry.failed + entry.other;
|
||||
const heightPct = (total / maxValue) * 100;
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}>
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
|
||||
{entry.succeeded > 0 && <div className="bg-emerald-500" style={{ flex: entry.succeeded }} />}
|
||||
{entry.failed > 0 && <div className="bg-red-500" style={{ flex: entry.failed }} />}
|
||||
{entry.other > 0 && <div className="bg-neutral-500" style={{ flex: entry.other }} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: "#ef4444",
|
||||
high: "#f97316",
|
||||
medium: "#eab308",
|
||||
low: "#6b7280",
|
||||
};
|
||||
|
||||
const priorityOrder = ["critical", "high", "medium", "low"] as const;
|
||||
|
||||
function PriorityChart({ issues }: { issues: { priority: string; createdAt: Date }[] }) {
|
||||
const days = getLast14Days();
|
||||
const grouped = new Map<string, Record<string, number>>();
|
||||
for (const day of days) grouped.set(day, { critical: 0, high: 0, medium: 0, low: 0 });
|
||||
for (const issue of issues) {
|
||||
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
if (issue.priority in entry) entry[issue.priority]++;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
|
||||
const hasData = Array.from(grouped.values()).some(v => Object.values(v).reduce((a, b) => a + b, 0) > 0);
|
||||
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const total = Object.values(entry).reduce((a, b) => a + b, 0);
|
||||
const heightPct = (total / maxValue) * 100;
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
|
||||
{priorityOrder.map(p => entry[p] > 0 ? (
|
||||
<div key={p} style={{ flex: entry[p], backgroundColor: priorityColors[p] }} />
|
||||
) : null)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
<ChartLegend items={priorityOrder.map(p => ({ color: priorityColors[p], label: p.charAt(0).toUpperCase() + p.slice(1) }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
todo: "#3b82f6",
|
||||
in_progress: "#8b5cf6",
|
||||
in_review: "#a855f7",
|
||||
done: "#10b981",
|
||||
blocked: "#ef4444",
|
||||
cancelled: "#6b7280",
|
||||
backlog: "#64748b",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
todo: "To Do",
|
||||
in_progress: "In Progress",
|
||||
in_review: "In Review",
|
||||
done: "Done",
|
||||
blocked: "Blocked",
|
||||
cancelled: "Cancelled",
|
||||
backlog: "Backlog",
|
||||
};
|
||||
|
||||
function IssueStatusChart({ issues }: { issues: { status: string; createdAt: Date }[] }) {
|
||||
const days = getLast14Days();
|
||||
const allStatuses = new Set<string>();
|
||||
const grouped = new Map<string, Record<string, number>>();
|
||||
for (const day of days) grouped.set(day, {});
|
||||
for (const issue of issues) {
|
||||
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
entry[issue.status] = (entry[issue.status] ?? 0) + 1;
|
||||
allStatuses.add(issue.status);
|
||||
}
|
||||
|
||||
const statusOrder = ["todo", "in_progress", "in_review", "done", "blocked", "cancelled", "backlog"].filter(s => allStatuses.has(s));
|
||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
|
||||
const hasData = allStatuses.size > 0;
|
||||
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const total = Object.values(entry).reduce((a, b) => a + b, 0);
|
||||
const heightPct = (total / maxValue) * 100;
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
|
||||
{statusOrder.map(s => (entry[s] ?? 0) > 0 ? (
|
||||
<div key={s} style={{ flex: entry[s], backgroundColor: statusColors[s] ?? "#6b7280" }} />
|
||||
) : null)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
<ChartLegend items={statusOrder.map(s => ({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) {
|
||||
const days = getLast14Days();
|
||||
const grouped = new Map<string, { succeeded: number; total: number }>();
|
||||
for (const day of days) grouped.set(day, { succeeded: 0, total: 0 });
|
||||
for (const run of runs) {
|
||||
const day = new Date(run.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
entry.total++;
|
||||
if (run.status === "succeeded") entry.succeeded++;
|
||||
}
|
||||
|
||||
const hasData = Array.from(grouped.values()).some(v => v.total > 0);
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const rate = entry.total > 0 ? entry.succeeded / entry.total : 0;
|
||||
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${entry.total > 0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}>
|
||||
{entry.total > 0 ? (
|
||||
<div style={{ height: `${rate * 100}%`, minHeight: 2, backgroundColor: color }} />
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* Chart components imported from ../components/ActivityCharts */
|
||||
|
||||
/* ---- Configuration Summary ---- */
|
||||
|
||||
@@ -1091,7 +836,7 @@ function ConfigSummary({
|
||||
{reportsToAgent ? (
|
||||
<Link
|
||||
to={`/agents/${reportsToAgent.id}`}
|
||||
className="text-blue-400 hover:underline"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
<Identity name={reportsToAgent.name} size="sm" />
|
||||
</Link>
|
||||
@@ -1108,7 +853,7 @@ function ConfigSummary({
|
||||
<Link
|
||||
key={r.id}
|
||||
to={`/agents/${r.id}`}
|
||||
className="flex items-center gap-2 text-sm text-blue-400 hover:underline"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[r.status] ?? agentStatusDotDefault}`} />
|
||||
@@ -1419,10 +1164,10 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
|
||||
</span>
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0",
|
||||
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
|
||||
: "bg-neutral-800 text-neutral-400"
|
||||
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||
</span>
|
||||
@@ -1682,7 +1427,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
)}
|
||||
{run.error && (
|
||||
<div className="text-xs">
|
||||
<span className="text-red-400">{run.error}</span>
|
||||
<span className="text-red-600 dark:text-red-400">{run.error}</span>
|
||||
{run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
|
||||
</div>
|
||||
)}
|
||||
@@ -1709,7 +1454,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
Login URL:
|
||||
<a
|
||||
href={claudeLoginResult.loginUrl}
|
||||
className="text-blue-400 underline underline-offset-2 ml-1 break-all"
|
||||
className="text-blue-600 underline underline-offset-2 ml-1 break-all dark:text-blue-400"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -1720,12 +1465,12 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{claudeLoginResult && (
|
||||
<>
|
||||
{!!claudeLoginResult.stdout && (
|
||||
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
|
||||
{claudeLoginResult.stdout}
|
||||
</pre>
|
||||
)}
|
||||
{!!claudeLoginResult.stderr && (
|
||||
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-300 overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
|
||||
{claudeLoginResult.stderr}
|
||||
</pre>
|
||||
)}
|
||||
@@ -1734,7 +1479,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
</div>
|
||||
)}
|
||||
{hasNonZeroExit && (
|
||||
<div className="text-xs text-red-400">
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Exit code {run.exitCode}
|
||||
{run.signal && <span className="text-muted-foreground ml-1">(signal: {run.signal})</span>}
|
||||
</div>
|
||||
@@ -1848,8 +1593,8 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{/* stderr excerpt for failed runs */}
|
||||
{run.stderrExcerpt && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-red-400">stderr</span>
|
||||
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-300 overflow-x-auto whitespace-pre-wrap">{run.stderrExcerpt}</pre>
|
||||
<span className="text-xs font-medium text-red-600 dark:text-red-400">stderr</span>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">{run.stderrExcerpt}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1857,7 +1602,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{run.stdoutExcerpt && !run.logRef && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">stdout</span>
|
||||
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">{run.stdoutExcerpt}</pre>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">{run.stdoutExcerpt}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2119,14 +1864,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: "text-foreground",
|
||||
warn: "text-yellow-400",
|
||||
error: "text-red-400",
|
||||
warn: "text-yellow-600 dark:text-yellow-400",
|
||||
error: "text-red-600 dark:text-red-400",
|
||||
};
|
||||
|
||||
const streamColors: Record<string, string> = {
|
||||
stdout: "text-foreground",
|
||||
stderr: "text-red-300",
|
||||
system: "text-blue-300",
|
||||
stderr: "text-red-600 dark:text-red-300",
|
||||
system: "text-blue-600 dark:text-blue-300",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -2156,7 +1901,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{adapterInvokePayload.prompt !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{typeof adapterInvokePayload.prompt === "string"
|
||||
? adapterInvokePayload.prompt
|
||||
: JSON.stringify(adapterInvokePayload.prompt, null, 2)}
|
||||
@@ -2166,7 +1911,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{adapterInvokePayload.context !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Context</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(adapterInvokePayload.context, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2174,7 +1919,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{adapterInvokePayload.env !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Environment</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{formatEnvForDisplay(adapterInvokePayload.env)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2213,14 +1958,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5 overflow-x-hidden">
|
||||
<div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5 overflow-x-hidden">
|
||||
{transcript.length === 0 && !run.logRef && (
|
||||
<div className="text-neutral-500">No persisted transcript for this run.</div>
|
||||
)}
|
||||
{transcript.map((entry, idx) => {
|
||||
const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false });
|
||||
const grid = "grid grid-cols-[auto_auto_1fr] gap-x-2 sm:gap-x-3 items-baseline";
|
||||
const tsCell = "text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs";
|
||||
const tsCell = "text-neutral-400 dark:text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs";
|
||||
const lblCell = "w-14 sm:w-20 text-[10px] sm:text-xs";
|
||||
const contentCell = "min-w-0 whitespace-pre-wrap break-words overflow-hidden";
|
||||
const expandCell = "col-span-full md:col-start-3 md:col-span-1";
|
||||
@@ -2229,8 +1974,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-assistant-${idx}`} className={cn(grid, "py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-green-300")}>assistant</span>
|
||||
<span className={cn(contentCell, "text-green-100")}>{entry.text}</span>
|
||||
<span className={cn(lblCell, "text-green-700 dark:text-green-300")}>assistant</span>
|
||||
<span className={cn(contentCell, "text-green-900 dark:text-green-100")}>{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2239,8 +1984,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-thinking-${idx}`} className={cn(grid, "py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-green-300/60")}>thinking</span>
|
||||
<span className={cn(contentCell, "text-green-100/60 italic")}>{entry.text}</span>
|
||||
<span className={cn(lblCell, "text-green-600/60 dark:text-green-300/60")}>thinking</span>
|
||||
<span className={cn(contentCell, "text-green-800/60 dark:text-green-100/60 italic")}>{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2249,8 +1994,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-user-${idx}`} className={cn(grid, "py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-neutral-400")}>user</span>
|
||||
<span className={cn(contentCell, "text-neutral-300")}>{entry.text}</span>
|
||||
<span className={cn(lblCell, "text-neutral-500 dark:text-neutral-400")}>user</span>
|
||||
<span className={cn(contentCell, "text-neutral-700 dark:text-neutral-300")}>{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2259,9 +2004,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-tool-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-yellow-300")}>tool_call</span>
|
||||
<span className="text-yellow-100 min-w-0">{entry.name}</span>
|
||||
<pre className={cn(expandCell, "bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-200")}>
|
||||
<span className={cn(lblCell, "text-yellow-700 dark:text-yellow-300")}>tool_call</span>
|
||||
<span className="text-yellow-900 dark:text-yellow-100 min-w-0">{entry.name}</span>
|
||||
<pre className={cn(expandCell, "bg-neutral-200 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-800 dark:text-neutral-200")}>
|
||||
{JSON.stringify(entry.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2272,9 +2017,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-toolres-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, entry.isError ? "text-red-300" : "text-purple-300")}>tool_result</span>
|
||||
{entry.isError ? <span className="text-red-400 min-w-0">error</span> : <span />}
|
||||
<pre className={cn(expandCell, "bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-300 max-h-60 overflow-y-auto")}>
|
||||
<span className={cn(lblCell, entry.isError ? "text-red-600 dark:text-red-300" : "text-purple-600 dark:text-purple-300")}>tool_result</span>
|
||||
{entry.isError ? <span className="text-red-600 dark:text-red-400 min-w-0">error</span> : <span />}
|
||||
<pre className={cn(expandCell, "bg-neutral-100 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-700 dark:text-neutral-300 max-h-60 overflow-y-auto")}>
|
||||
{(() => { try { return JSON.stringify(JSON.parse(entry.content), null, 2); } catch { return entry.content; } })()}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2285,8 +2030,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-init-${idx}`} className={grid}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-blue-300")}>init</span>
|
||||
<span className={cn(contentCell, "text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span>
|
||||
<span className={cn(lblCell, "text-blue-700 dark:text-blue-300")}>init</span>
|
||||
<span className={cn(contentCell, "text-blue-900 dark:text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2295,18 +2040,18 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-result-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-cyan-300")}>result</span>
|
||||
<span className={cn(contentCell, "text-cyan-100")}>
|
||||
<span className={cn(lblCell, "text-cyan-700 dark:text-cyan-300")}>result</span>
|
||||
<span className={cn(contentCell, "text-cyan-900 dark:text-cyan-100")}>
|
||||
tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)}
|
||||
</span>
|
||||
{(entry.subtype || entry.isError || entry.errors.length > 0) && (
|
||||
<div className={cn(expandCell, "text-red-300 whitespace-pre-wrap break-words")}>
|
||||
<div className={cn(expandCell, "text-red-600 dark:text-red-300 whitespace-pre-wrap break-words")}>
|
||||
subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"}
|
||||
{entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{entry.text && (
|
||||
<div className={cn(expandCell, "whitespace-pre-wrap break-words text-neutral-100")}>{entry.text}</div>
|
||||
<div className={cn(expandCell, "whitespace-pre-wrap break-words text-neutral-800 dark:text-neutral-100")}>{entry.text}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -2318,8 +2063,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
entry.kind === "system" ? "system" :
|
||||
"stdout";
|
||||
const color =
|
||||
entry.kind === "stderr" ? "text-red-300" :
|
||||
entry.kind === "system" ? "text-blue-300" :
|
||||
entry.kind === "stderr" ? "text-red-600 dark:text-red-300" :
|
||||
entry.kind === "system" ? "text-blue-600 dark:text-blue-300" :
|
||||
"text-neutral-500";
|
||||
return (
|
||||
<div key={`${entry.ts}-raw-${idx}`} className={grid}>
|
||||
@@ -2329,39 +2074,39 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{logError && <div className="text-red-300">{logError}</div>}
|
||||
{logError && <div className="text-red-600 dark:text-red-300">{logError}</div>}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
|
||||
{(run.status === "failed" || run.status === "timed_out") && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-950/20 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-red-300">Failure details</div>
|
||||
<div className="rounded-lg border border-red-300 dark:border-red-500/30 bg-red-50 dark:bg-red-950/20 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-red-700 dark:text-red-300">Failure details</div>
|
||||
{run.error && (
|
||||
<div className="text-xs text-red-200">
|
||||
<span className="text-red-300">Error: </span>
|
||||
<div className="text-xs text-red-600 dark:text-red-200">
|
||||
<span className="text-red-700 dark:text-red-300">Error: </span>
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
|
||||
<div>
|
||||
<div className="text-xs text-red-300 mb-1">stderr excerpt</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-100">
|
||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
|
||||
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
|
||||
{run.stderrExcerpt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{run.resultJson && (
|
||||
<div>
|
||||
<div className="text-xs text-red-300 mb-1">adapter result JSON</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-100">
|
||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
|
||||
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
|
||||
{JSON.stringify(run.resultJson, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{run.stdoutExcerpt && run.stdoutExcerpt.trim() && !run.resultJson && (
|
||||
<div>
|
||||
<div className="text-xs text-red-300 mb-1">stdout excerpt</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-100">
|
||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
|
||||
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
|
||||
{run.stdoutExcerpt}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2372,7 +2117,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{events.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Events ({events.length})</div>
|
||||
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
|
||||
<div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
|
||||
{events.map((evt) => {
|
||||
const color = evt.color
|
||||
?? (evt.level ? levelColors[evt.level] : null)
|
||||
@@ -2381,7 +2126,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
|
||||
return (
|
||||
<div key={evt.id} className="flex gap-2">
|
||||
<span className="text-neutral-600 shrink-0 select-none w-16">
|
||||
<span className="text-neutral-400 dark:text-neutral-600 shrink-0 select-none w-16">
|
||||
{new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })}
|
||||
</span>
|
||||
<span className={cn("shrink-0 w-14", evt.stream ? (streamColors[evt.stream] ?? "text-neutral-500") : "text-neutral-500")}>
|
||||
@@ -2445,12 +2190,12 @@ function KeysTab({ agentId }: { agentId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* New token banner */}
|
||||
{newToken && (
|
||||
<div className="border border-yellow-600/40 bg-yellow-500/5 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-yellow-400">
|
||||
<div className="border border-yellow-300 dark:border-yellow-600/40 bg-yellow-50 dark:bg-yellow-500/5 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400">
|
||||
API key created — copy it now, it will not be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-neutral-950 rounded px-3 py-1.5 text-xs font-mono text-green-300 truncate">
|
||||
<code className="flex-1 bg-neutral-100 dark:bg-neutral-950 rounded px-3 py-1.5 text-xs font-mono text-green-700 dark:text-green-300 truncate">
|
||||
{tokenVisible ? newToken : newToken.replace(/./g, "•")}
|
||||
</code>
|
||||
<Button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { issuesApi } from "../api/issues";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
@@ -43,6 +44,11 @@ import {
|
||||
import type { ActivityEvent } from "@paperclip/shared";
|
||||
import type { Agent, IssueAttachment } from "@paperclip/shared";
|
||||
|
||||
type CommentReassignment = {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
"issue.created": "created the issue",
|
||||
"issue.updated": "updated the issue",
|
||||
@@ -109,8 +115,12 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
|
||||
: `changed the priority to ${humanizeValue(details.priority)}`
|
||||
);
|
||||
}
|
||||
if (details.assigneeAgentId !== undefined) {
|
||||
parts.push(details.assigneeAgentId ? "assigned the issue" : "unassigned the issue");
|
||||
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
||||
parts.push(
|
||||
details.assigneeAgentId || details.assigneeUserId
|
||||
? "assigned the issue"
|
||||
: "unassigned the issue",
|
||||
);
|
||||
}
|
||||
if (details.title !== undefined) parts.push("updated the title");
|
||||
if (details.description !== undefined) parts.push("updated the description");
|
||||
@@ -144,7 +154,6 @@ export function IssueDetail() {
|
||||
const [detailTab, setDetailTab] = useState("comments");
|
||||
const [secondaryOpen, setSecondaryOpen] = useState({
|
||||
approvals: false,
|
||||
runs: false,
|
||||
cost: false,
|
||||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
@@ -208,6 +217,11 @@ export function IssueDetail() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
@@ -227,6 +241,33 @@ export function IssueDetail() {
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}, [allIssues, issue]);
|
||||
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
|
||||
const canReassignFromComment = Boolean(
|
||||
issue?.assigneeUserId &&
|
||||
(issue.assigneeUserId === "local-board" || (currentUserId && issue.assigneeUserId === currentUserId)),
|
||||
);
|
||||
|
||||
const commentReassignOptions = useMemo(() => {
|
||||
const options: Array<{ value: string; label: string }> = [{ value: "__none__", label: "No assignee" }];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({ value: `agent:${agent.id}`, label: agent.name });
|
||||
}
|
||||
if (issue?.createdByUserId && issue.createdByUserId !== issue.assigneeUserId) {
|
||||
const requesterLabel =
|
||||
issue.createdByUserId === "local-board"
|
||||
? "Board"
|
||||
: currentUserId && issue.createdByUserId === currentUserId
|
||||
? "Me"
|
||||
: issue.createdByUserId.slice(0, 8);
|
||||
options.push({ value: `user:${issue.createdByUserId}`, label: `Requester (${requesterLabel})` });
|
||||
}
|
||||
return options;
|
||||
}, [agents, currentUserId, issue?.assigneeUserId, issue?.createdByUserId]);
|
||||
|
||||
const commentsWithRunMeta = useMemo(() => {
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
||||
const agentIdByRunId = new Map<string, string>();
|
||||
@@ -335,6 +376,36 @@ export function IssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const addCommentAndReassign = useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
reopen,
|
||||
reassignment,
|
||||
}: {
|
||||
body: string;
|
||||
reopen?: boolean;
|
||||
reassignment: CommentReassignment;
|
||||
}) =>
|
||||
issuesApi.update(issueId!, {
|
||||
comment: body,
|
||||
assigneeAgentId: reassignment.assigneeAgentId,
|
||||
assigneeUserId: reassignment.assigneeUserId,
|
||||
...(reopen ? { status: "todo" } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
const issueRef = updated.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
|
||||
pushToast({
|
||||
dedupeKey: `activity:issue.reassigned:${updated.id}`,
|
||||
title: `${issueRef} reassigned`,
|
||||
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
||||
tone: "success",
|
||||
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uploadAttachment = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
@@ -445,7 +516,7 @@ export function IssueDetail() {
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
|
||||
{hasLiveRuns && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-400 shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
|
||||
@@ -638,10 +709,17 @@ export function IssueDetail() {
|
||||
<TabsContent value="comments">
|
||||
<CommentThread
|
||||
comments={commentsWithRunMeta}
|
||||
linkedRuns={linkedRuns ?? []}
|
||||
issueStatus={issue.status}
|
||||
agentMap={agentMap}
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
onAdd={async (body, reopen) => {
|
||||
enableReassign={canReassignFromComment}
|
||||
reassignOptions={commentReassignOptions}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
return;
|
||||
}
|
||||
await addComment.mutateAsync({ body, reopen });
|
||||
}}
|
||||
imageUploadHandler={async (file) => {
|
||||
@@ -740,39 +818,6 @@ export function IssueDetail() {
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{linkedRuns && linkedRuns.length > 0 && (
|
||||
<Collapsible
|
||||
open={secondaryOpen.runs}
|
||||
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, runs: open }))}
|
||||
className="rounded-lg border border-border"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
||||
<span className="text-sm font-medium text-muted-foreground">Linked Runs ({linkedRuns.length})</span>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.runs && "rotate-180")}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-border divide-y divide-border">
|
||||
{linkedRuns.map((run) => (
|
||||
<Link
|
||||
key={run.runId}
|
||||
to={`/agents/${run.agentId}/runs/${run.runId}`}
|
||||
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Identity name={agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8)} size="sm" />
|
||||
<StatusBadge status={run.status} />
|
||||
<span className="font-mono text-muted-foreground">{run.runId.slice(0, 8)}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{relativeTime(run.createdAt)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{linkedRuns && linkedRuns.length > 0 && (
|
||||
<Collapsible
|
||||
open={secondaryOpen.cost}
|
||||
|
||||
Reference in New Issue
Block a user