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,
|
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>
|
||||||
|
|||||||
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";
|
} 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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">›</span>
|
<span className="text-muted-foreground/60">›</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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 →
|
View details →
|
||||||
</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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user