feat(ui): onboarding wizard, comment thread, markdown editor, and UX polish
Refactor onboarding wizard with ASCII art animation and expanded adapter support. Enhance markdown editor with code block, table, and CodeMirror plugins. Improve comment thread layout. Add activity charts to agent detail page. Polish metric cards, issue detail reassignment, and new issue dialog. Simplify agent detail page structure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
help,
|
||||
adapterLabels,
|
||||
} from "./agent-config-primitives";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
@@ -210,8 +211,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}
|
||||
if (overlay.adapterType !== undefined) {
|
||||
patch.adapterType = overlay.adapterType;
|
||||
}
|
||||
if (Object.keys(overlay.adapterConfig).length > 0) {
|
||||
// When adapter type changes, send only the new config — don't merge
|
||||
// with old config since old adapter fields are meaningless for the new type
|
||||
patch.adapterConfig = overlay.adapterConfig;
|
||||
} else if (Object.keys(overlay.adapterConfig).length > 0) {
|
||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
|
||||
}
|
||||
@@ -432,12 +435,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
value={adapterType}
|
||||
onChange={(t) => {
|
||||
if (isCreate) {
|
||||
set!({ adapterType: t, model: "", thinkingEffort: "" });
|
||||
// Reset all adapter-specific fields to defaults when switching adapter type
|
||||
const { adapterType: _at, ...defaults } = defaultCreateValues;
|
||||
set!({ ...defaults, adapterType: t });
|
||||
} else {
|
||||
// Clear all adapter config and explicitly blank out model + both effort keys
|
||||
// so the old adapter's values don't bleed through via eff()
|
||||
setOverlay((prev) => ({
|
||||
...prev,
|
||||
adapterType: t,
|
||||
adapterConfig: {}, // clear adapter config when type changes
|
||||
adapterConfig: {
|
||||
model: "",
|
||||
effort: "",
|
||||
modelReasoningEffort: "",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
@@ -794,10 +805,10 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
||||
result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";
|
||||
const statusClass =
|
||||
result.status === "pass"
|
||||
? "text-green-300 border-green-500/40 bg-green-500/10"
|
||||
? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10"
|
||||
: result.status === "warn"
|
||||
? "text-amber-300 border-amber-500/40 bg-amber-500/10"
|
||||
: "text-red-300 border-red-500/40 bg-red-500/10";
|
||||
? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10"
|
||||
: "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10";
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border px-3 py-2 text-xs ${statusClass}`}>
|
||||
@@ -1154,37 +1165,39 @@ function ModelDropdown({
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!value && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{filteredModels.map((m) => (
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
m.id === value && "bg-accent",
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!value && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(m.id);
|
||||
onChange("");
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span>{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
||||
Default
|
||||
</button>
|
||||
))}
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||
)}
|
||||
{filteredModels.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
m.id === value && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(m.id);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span>{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Field>
|
||||
|
||||
199
ui/src/components/AsciiArtAnimation.tsx
Normal file
199
ui/src/components/AsciiArtAnimation.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const CHARS = "░▒▓█▄▀■□▪▫●○◆◇◈◉★☆✦✧·.";
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
char: string;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
function measureChar(container: HTMLElement): { w: number; h: number } {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "M";
|
||||
span.style.cssText =
|
||||
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
|
||||
container.appendChild(span);
|
||||
const rect = span.getBoundingClientRect();
|
||||
container.removeChild(span);
|
||||
return { w: rect.width, h: rect.height };
|
||||
}
|
||||
|
||||
export function AsciiArtAnimation() {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const frameRef = useRef(0);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preRef.current) return;
|
||||
const preEl: HTMLPreElement = preRef.current;
|
||||
|
||||
const charSize = measureChar(preEl);
|
||||
let charW = charSize.w;
|
||||
let charH = charSize.h;
|
||||
let cols = Math.ceil(preEl.clientWidth / charW);
|
||||
let rows = Math.ceil(preEl.clientHeight / charH);
|
||||
let particles = particlesRef.current;
|
||||
|
||||
function spawnParticle() {
|
||||
const edge = Math.random();
|
||||
let x: number, y: number, vx: number, vy: number;
|
||||
if (edge < 0.5) {
|
||||
x = -1;
|
||||
y = Math.random() * rows;
|
||||
vx = 0.3 + Math.random() * 0.5;
|
||||
vy = (Math.random() - 0.5) * 0.2;
|
||||
} else {
|
||||
x = Math.random() * cols;
|
||||
y = rows + 1;
|
||||
vx = (Math.random() - 0.5) * 0.2;
|
||||
vy = -(0.2 + Math.random() * 0.4);
|
||||
}
|
||||
const maxLife = 60 + Math.random() * 120;
|
||||
particles.push({
|
||||
x, y, vx, vy,
|
||||
char: CHARS[Math.floor(Math.random() * CHARS.length)],
|
||||
life: 0,
|
||||
maxLife,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
|
||||
function render(time: number) {
|
||||
const t = time * 0.001;
|
||||
|
||||
// Spawn particles
|
||||
const targetCount = Math.floor((cols * rows) / 12);
|
||||
while (particles.length < targetCount) {
|
||||
spawnParticle();
|
||||
}
|
||||
|
||||
// Build grid
|
||||
const grid: string[][] = Array.from({ length: rows }, () =>
|
||||
Array.from({ length: cols }, () => " ")
|
||||
);
|
||||
const opacity: number[][] = Array.from({ length: rows }, () =>
|
||||
Array.from({ length: cols }, () => 0)
|
||||
);
|
||||
|
||||
// Background wave pattern
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const wave =
|
||||
Math.sin(c * 0.08 + t * 0.7 + r * 0.04) *
|
||||
Math.sin(r * 0.06 - t * 0.5) *
|
||||
Math.cos((c + r) * 0.03 + t * 0.3);
|
||||
if (wave > 0.65) {
|
||||
grid[r][c] = wave > 0.85 ? "·" : ".";
|
||||
opacity[r][c] = Math.min(1, (wave - 0.65) * 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update and render particles
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i];
|
||||
p.life++;
|
||||
|
||||
// Flow field influence
|
||||
const angle =
|
||||
Math.sin(p.x * 0.05 + t * 0.3) * Math.cos(p.y * 0.07 - t * 0.2) *
|
||||
Math.PI;
|
||||
p.vx += Math.cos(angle) * 0.02;
|
||||
p.vy += Math.sin(angle) * 0.02;
|
||||
|
||||
// Damping
|
||||
p.vx *= 0.98;
|
||||
p.vy *= 0.98;
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
// Life fade
|
||||
const lifeFrac = p.life / p.maxLife;
|
||||
const alpha = lifeFrac < 0.1
|
||||
? lifeFrac / 0.1
|
||||
: lifeFrac > 0.8
|
||||
? (1 - lifeFrac) / 0.2
|
||||
: 1;
|
||||
|
||||
// Remove dead or out-of-bounds particles
|
||||
if (
|
||||
p.life >= p.maxLife ||
|
||||
p.x < -2 || p.x > cols + 2 ||
|
||||
p.y < -2 || p.y > rows + 2
|
||||
) {
|
||||
particles.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const col = Math.round(p.x);
|
||||
const row = Math.round(p.y);
|
||||
if (row >= 0 && row < rows && col >= 0 && col < cols) {
|
||||
if (alpha > opacity[row][col]) {
|
||||
// Cycle through characters based on life
|
||||
const charIdx = Math.floor(
|
||||
(lifeFrac + Math.sin(p.phase + t)) * CHARS.length
|
||||
) % CHARS.length;
|
||||
grid[row][col] = CHARS[Math.abs(charIdx)];
|
||||
opacity[row][col] = alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render to string
|
||||
let output = "";
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const a = opacity[r][c];
|
||||
if (a > 0 && grid[r][c] !== " ") {
|
||||
const o = Math.round(a * 60 + 40);
|
||||
output += `<span style="opacity:${o}%">${grid[r][c]}</span>`;
|
||||
} else {
|
||||
output += " ";
|
||||
}
|
||||
}
|
||||
if (r < rows - 1) output += "\n";
|
||||
}
|
||||
|
||||
preEl.innerHTML = output;
|
||||
frameRef.current = requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
const observer = new ResizeObserver(() => {
|
||||
const size = measureChar(preEl);
|
||||
charW = size.w;
|
||||
charH = size.h;
|
||||
cols = Math.ceil(preEl.clientWidth / charW);
|
||||
rows = Math.ceil(preEl.clientHeight / charH);
|
||||
// Cull out-of-bounds particles on resize
|
||||
particles = particles.filter(
|
||||
(p) => p.x >= -2 && p.x <= cols + 2 && p.y >= -2 && p.y <= rows + 2
|
||||
);
|
||||
particlesRef.current = particles;
|
||||
});
|
||||
observer.observe(preEl);
|
||||
|
||||
frameRef.current = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="w-full h-full m-0 p-0 overflow-hidden text-muted-foreground/60 select-none leading-none"
|
||||
style={{ fontSize: "11px", fontFamily: "monospace" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
MDXEditor,
|
||||
codeBlockPlugin,
|
||||
codeMirrorPlugin,
|
||||
type MDXEditorMethods,
|
||||
headingsPlugin,
|
||||
imagePlugin,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
listsPlugin,
|
||||
markdownShortcutPlugin,
|
||||
quotePlugin,
|
||||
tablePlugin,
|
||||
thematicBreakPlugin,
|
||||
type RealmPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
@@ -62,6 +65,26 @@ interface MentionState {
|
||||
endPos: number;
|
||||
}
|
||||
|
||||
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
||||
txt: "Text",
|
||||
md: "Markdown",
|
||||
js: "JavaScript",
|
||||
jsx: "JavaScript (JSX)",
|
||||
ts: "TypeScript",
|
||||
tsx: "TypeScript (TSX)",
|
||||
json: "JSON",
|
||||
bash: "Bash",
|
||||
sh: "Shell",
|
||||
python: "Python",
|
||||
go: "Go",
|
||||
rust: "Rust",
|
||||
sql: "SQL",
|
||||
html: "HTML",
|
||||
css: "CSS",
|
||||
yaml: "YAML",
|
||||
yml: "YAML",
|
||||
};
|
||||
|
||||
function detectMention(container: HTMLElement): MentionState | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
||||
@@ -174,9 +197,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
headingsPlugin(),
|
||||
listsPlugin(),
|
||||
quotePlugin(),
|
||||
tablePlugin(),
|
||||
linkPlugin(),
|
||||
linkDialogPlugin(),
|
||||
thematicBreakPlugin(),
|
||||
codeBlockPlugin(),
|
||||
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
|
||||
markdownShortcutPlugin(),
|
||||
];
|
||||
if (imageHandler) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: LucideIcon;
|
||||
@@ -16,26 +15,22 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
|
||||
const isClickable = !!(to || onClick);
|
||||
|
||||
const inner = (
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-3 sm:p-4 h-full">
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-lg sm:text-2xl font-bold${isClickable ? " cursor-pointer" : ""}`}>
|
||||
{value}
|
||||
</p>
|
||||
<p className={`text-sm text-muted-foreground${isClickable ? " cursor-pointer" : ""}`}>
|
||||
{label}
|
||||
</p>
|
||||
{description && (
|
||||
<div className="text-xs sm:text-sm text-muted-foreground mt-1 hidden sm:block">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-muted p-1.5 sm:p-2 rounded-md h-fit shrink-0">
|
||||
<Icon className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className={`h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-2xl sm:text-3xl font-semibold tracking-tight">
|
||||
{value}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">
|
||||
{label}
|
||||
</p>
|
||||
{description && (
|
||||
<div className="text-xs text-muted-foreground/70 mt-1.5 hidden sm:block">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Icon className="h-4 w-4 text-muted-foreground/50 shrink-0 mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (to) {
|
||||
@@ -48,7 +43,7 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className="cursor-pointer h-full" onClick={onClick}>
|
||||
<div className="h-full" onClick={onClick}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,16 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
|
||||
const DRAFT_KEY = "paperclip:issue-draft";
|
||||
const DEBOUNCE_MS = 800;
|
||||
|
||||
/** Return black or white hex based on background luminance (WCAG perceptual weights). */
|
||||
function getContrastTextColor(hexColor: string): string {
|
||||
const hex = hexColor.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? "#000000" : "#ffffff";
|
||||
}
|
||||
|
||||
interface IssueDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -51,6 +61,7 @@ interface IssueDraft {
|
||||
projectId: string;
|
||||
assigneeModelOverride: string;
|
||||
assigneeThinkingEffort: string;
|
||||
assigneeChrome: boolean;
|
||||
assigneeUseProjectWorkspace: boolean;
|
||||
}
|
||||
|
||||
@@ -76,6 +87,7 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
adapterType: string | null | undefined;
|
||||
modelOverride: string;
|
||||
thinkingEffortOverride: string;
|
||||
chrome: boolean;
|
||||
useProjectWorkspace: boolean;
|
||||
}): Record<string, unknown> | null {
|
||||
const adapterType = input.adapterType ?? null;
|
||||
@@ -92,6 +104,9 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
adapterConfig.effort = input.thinkingEffortOverride;
|
||||
}
|
||||
}
|
||||
if (adapterType === "claude_local" && input.chrome) {
|
||||
adapterConfig.chrome = true;
|
||||
}
|
||||
|
||||
const overrides: Record<string, unknown> = {};
|
||||
if (Object.keys(adapterConfig).length > 0) {
|
||||
@@ -138,7 +153,7 @@ const priorities = [
|
||||
|
||||
export function NewIssueDialog() {
|
||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -150,29 +165,35 @@ export function NewIssueDialog() {
|
||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
||||
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
||||
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
||||
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
||||
|
||||
// Popover states
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [companyOpen, setCompanyOpen] = useState(false);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newIssueOpen,
|
||||
queryKey: queryKeys.agents.list(effectiveCompanyId!),
|
||||
queryFn: () => agentsApi.list(effectiveCompanyId!),
|
||||
enabled: !!effectiveCompanyId && newIssueOpen,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newIssueOpen,
|
||||
queryKey: queryKeys.projects.list(effectiveCompanyId!),
|
||||
queryFn: () => projectsApi.list(effectiveCompanyId!),
|
||||
enabled: !!effectiveCompanyId && newIssueOpen,
|
||||
});
|
||||
|
||||
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
|
||||
@@ -183,14 +204,14 @@ export function NewIssueDialog() {
|
||||
const { data: assigneeAdapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", assigneeAdapterType],
|
||||
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
|
||||
enabled: !!selectedCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
||||
enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
||||
});
|
||||
|
||||
const createIssue = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
issuesApi.create(selectedCompanyId!, data),
|
||||
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
|
||||
issuesApi.create(companyId, data),
|
||||
onSuccess: (issue) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
clearDraft();
|
||||
reset();
|
||||
@@ -207,8 +228,8 @@ export function NewIssueDialog() {
|
||||
|
||||
const uploadDescriptionImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(selectedCompanyId, file, "issues/drafts");
|
||||
if (!effectiveCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -235,6 +256,7 @@ export function NewIssueDialog() {
|
||||
projectId,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
assigneeUseProjectWorkspace,
|
||||
});
|
||||
}, [
|
||||
@@ -246,6 +268,7 @@ export function NewIssueDialog() {
|
||||
projectId,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
assigneeUseProjectWorkspace,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
@@ -254,6 +277,7 @@ export function NewIssueDialog() {
|
||||
// Restore draft or apply defaults when dialog opens
|
||||
useEffect(() => {
|
||||
if (!newIssueOpen) return;
|
||||
setDialogCompanyId(selectedCompanyId);
|
||||
|
||||
const draft = loadDraft();
|
||||
if (draft && draft.title.trim()) {
|
||||
@@ -265,6 +289,7 @@ export function NewIssueDialog() {
|
||||
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
|
||||
} else {
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
@@ -273,6 +298,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults]);
|
||||
@@ -282,6 +308,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeOptionsOpen(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
return;
|
||||
}
|
||||
@@ -312,8 +339,22 @@ export function NewIssueDialog() {
|
||||
setAssigneeOptionsOpen(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setCompanyOpen(false);
|
||||
}
|
||||
|
||||
function handleCompanyChange(companyId: string) {
|
||||
if (companyId === effectiveCompanyId) return;
|
||||
setDialogCompanyId(companyId);
|
||||
setAssigneeId("");
|
||||
setProjectId("");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
@@ -323,14 +364,16 @@ export function NewIssueDialog() {
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !title.trim()) return;
|
||||
if (!effectiveCompanyId || !title.trim()) return;
|
||||
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
||||
adapterType: assigneeAdapterType,
|
||||
modelOverride: assigneeModelOverride,
|
||||
thinkingEffortOverride: assigneeThinkingEffort,
|
||||
chrome: assigneeChrome,
|
||||
useProjectWorkspace: assigneeUseProjectWorkspace,
|
||||
});
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
@@ -429,11 +472,59 @@ export function NewIssueDialog() {
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedCompany && (
|
||||
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
||||
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
||||
!dialogCompany?.brandColor && "bg-muted",
|
||||
)}
|
||||
style={
|
||||
dialogCompany?.brandColor
|
||||
? {
|
||||
backgroundColor: dialogCompany.brandColor,
|
||||
color: getContrastTextColor(dialogCompany.brandColor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(dialogCompany?.name ?? "").slice(0, 3).toUpperCase()}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="start">
|
||||
{companies.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
c.id === effectiveCompanyId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
handleCompanyChange(c.id);
|
||||
setCompanyOpen(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"px-1 py-0.5 rounded text-[10px] font-semibold leading-none",
|
||||
!c.brandColor && "bg-muted",
|
||||
)}
|
||||
style={
|
||||
c.brandColor
|
||||
? {
|
||||
backgroundColor: c.brandColor,
|
||||
color: getContrastTextColor(c.brandColor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{c.name.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">{c.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="text-muted-foreground/60">›</span>
|
||||
<span>New issue</span>
|
||||
</div>
|
||||
@@ -604,6 +695,25 @@ export function NewIssueDialog() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{assigneeAdapterType === "claude_local" && (
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
assigneeChrome ? "bg-green-600" : "bg-muted"
|
||||
)}
|
||||
onClick={() => setAssigneeChrome((value) => !value)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
assigneeChrome ? "translate-x-4.5" : "translate-x-0.5"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||
<div className="text-xs text-muted-foreground">Use project workspace</div>
|
||||
<button
|
||||
|
||||
@@ -8,7 +8,7 @@ import { goalsApi } from "../api/goals";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -18,9 +18,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||
import {
|
||||
Building2,
|
||||
Bot,
|
||||
Code,
|
||||
ListTodo,
|
||||
Rocket,
|
||||
ArrowLeft,
|
||||
@@ -32,10 +34,11 @@ import {
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
type AdapterType = "claude_local" | "process" | "http";
|
||||
type AdapterType = "claude_local" | "codex_local" | "process" | "http" | "openclaw";
|
||||
|
||||
export function OnboardingWizard() {
|
||||
const { onboardingOpen, closeOnboarding } = useDialog();
|
||||
@@ -98,6 +101,11 @@ export function OnboardingWizard() {
|
||||
setCreatedAgentId(null);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
reset();
|
||||
closeOnboarding();
|
||||
}
|
||||
|
||||
function buildAdapterConfig(): Record<string, unknown> {
|
||||
const adapter = getUIAdapter(adapterType);
|
||||
return adapter.buildAdapterConfig({
|
||||
@@ -217,464 +225,490 @@ export function OnboardingWizard() {
|
||||
}
|
||||
}
|
||||
|
||||
const stepIcons = [Building2, Bot, ListTodo, Rocket];
|
||||
if (!onboardingOpen) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={onboardingOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
closeOnboarding();
|
||||
}
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="p-0 gap-0 overflow-hidden sm:max-w-lg"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Get Started</span>
|
||||
<span className="text-muted-foreground/60">
|
||||
Step {step} of 4
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={cn(
|
||||
"h-1.5 w-6 rounded-full transition-colors",
|
||||
s < step
|
||||
? "bg-green-500"
|
||||
: s === step
|
||||
? "bg-foreground"
|
||||
: "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-background" />
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 left-4 z-10 rounded-sm p-1.5 text-muted-foreground/60 hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto max-h-[60vh]">
|
||||
{step === 1 && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">Name your company</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This is the organization your agents will work for.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Company name
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="Acme Corp"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Mission / goal (optional)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]"
|
||||
placeholder="What is this company trying to achieve?"
|
||||
value={companyGoal}
|
||||
onChange={(e) => setCompanyGoal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<Bot className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">Create your first agent</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how this agent will run tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Agent name
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="CEO"
|
||||
value={agentName}
|
||||
onChange={(e) => setAgentName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Adapter type radio cards */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-2 block">
|
||||
Adapter type
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
{
|
||||
value: "claude_local" as const,
|
||||
label: "Claude Code",
|
||||
icon: Sparkles,
|
||||
desc: "Local Claude agent",
|
||||
},
|
||||
{
|
||||
value: "process" as const,
|
||||
label: "Shell Command",
|
||||
icon: Terminal,
|
||||
desc: "Run a process",
|
||||
},
|
||||
{
|
||||
value: "http" as const,
|
||||
label: "HTTP Webhook",
|
||||
icon: Globe,
|
||||
desc: "Call an endpoint",
|
||||
},
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
{/* Left half — form */}
|
||||
<div className="w-full md:w-1/2 flex flex-col overflow-y-auto">
|
||||
<div className="w-full max-w-md mx-auto my-auto px-8 py-12">
|
||||
{/* Progress indicators */}
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Get Started</span>
|
||||
<span className="text-sm text-muted-foreground/60">
|
||||
Step {step} of 4
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
|
||||
adapterType === opt.value
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
"h-1.5 w-6 rounded-full transition-colors",
|
||||
s < step
|
||||
? "bg-green-500"
|
||||
: s === step
|
||||
? "bg-foreground"
|
||||
: "bg-muted"
|
||||
)}
|
||||
onClick={() => setAdapterType(opt.value)}
|
||||
>
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional adapter fields */}
|
||||
{adapterType === "claude_local" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Working directory
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/50"
|
||||
placeholder="/path/to/project"
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setCwdPickerNotice(null);
|
||||
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
|
||||
const handle = await window.showDirectoryPicker({ mode: "read" });
|
||||
const pickedPath =
|
||||
typeof handle === "object" &&
|
||||
handle !== null &&
|
||||
typeof (handle as { path?: unknown }).path === "string"
|
||||
? String((handle as { path: string }).path)
|
||||
: "";
|
||||
if (pickedPath) {
|
||||
setCwd(pickedPath);
|
||||
return;
|
||||
}
|
||||
const selectedName =
|
||||
typeof handle === "object" &&
|
||||
handle !== null &&
|
||||
typeof (handle as { name?: unknown }).name === "string"
|
||||
? String((handle as { name: string }).name)
|
||||
: "selected folder";
|
||||
setCwdPickerNotice(
|
||||
`Directory picker only exposed "${selectedName}". Paste the absolute path manually.`,
|
||||
);
|
||||
} catch {
|
||||
// user cancelled or API unsupported
|
||||
}
|
||||
}}
|
||||
>
|
||||
Choose
|
||||
</button>
|
||||
{/* Step content */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">Name your company</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This is the organization your agents will work for.
|
||||
</p>
|
||||
</div>
|
||||
{cwdPickerNotice && (
|
||||
<p className="mt-1 text-xs text-amber-400">{cwdPickerNotice}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Model
|
||||
Company name
|
||||
</label>
|
||||
<Popover open={modelOpen} onOpenChange={setModelOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className={cn(!model && "text-muted-foreground")}>
|
||||
{selectedModel ? selectedModel.label : model || "Default"}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="Acme Corp"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Mission / goal (optional)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]"
|
||||
placeholder="What is this company trying to achieve?"
|
||||
value={companyGoal}
|
||||
onChange={(e) => setCompanyGoal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<Bot className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">Create your first agent</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how this agent will run tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Agent name
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="CEO"
|
||||
value={agentName}
|
||||
onChange={(e) => setAgentName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Adapter type radio cards */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-2 block">
|
||||
Adapter type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{
|
||||
value: "claude_local" as const,
|
||||
label: "Claude Code",
|
||||
icon: Sparkles,
|
||||
desc: "Local Claude agent",
|
||||
},
|
||||
{
|
||||
value: "codex_local" as const,
|
||||
label: "Codex",
|
||||
icon: Code,
|
||||
desc: "Local Codex agent",
|
||||
},
|
||||
{
|
||||
value: "openclaw" as const,
|
||||
label: "OpenClaw",
|
||||
icon: Bot,
|
||||
desc: "Notify OpenClaw webhook",
|
||||
},
|
||||
{
|
||||
value: "process" as const,
|
||||
label: "Shell Command",
|
||||
icon: Terminal,
|
||||
desc: "Run a process",
|
||||
},
|
||||
{
|
||||
value: "http" as const,
|
||||
label: "HTTP Webhook",
|
||||
icon: Globe,
|
||||
desc: "Call an endpoint",
|
||||
},
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!model && "bg-accent"
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
|
||||
adapterType === opt.value
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => { setModel(""); setModelOpen(false); }}
|
||||
onClick={() => setAdapterType(opt.value)}
|
||||
>
|
||||
Default
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
{(adapterModels ?? []).map((m) => (
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional adapter fields */}
|
||||
{(adapterType === "claude_local" || adapterType === "codex_local") && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Working directory
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/50"
|
||||
placeholder="/path/to/project"
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
m.id === model && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setModel(m.id); setModelOpen(false); }}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setCwdPickerNotice(null);
|
||||
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
|
||||
const handle = await window.showDirectoryPicker({ mode: "read" });
|
||||
const pickedPath =
|
||||
typeof handle === "object" &&
|
||||
handle !== null &&
|
||||
typeof (handle as { path?: unknown }).path === "string"
|
||||
? String((handle as { path: string }).path)
|
||||
: "";
|
||||
if (pickedPath) {
|
||||
setCwd(pickedPath);
|
||||
return;
|
||||
}
|
||||
const selectedName =
|
||||
typeof handle === "object" &&
|
||||
handle !== null &&
|
||||
typeof (handle as { name?: unknown }).name === "string"
|
||||
? String((handle as { name: string }).name)
|
||||
: "selected folder";
|
||||
setCwdPickerNotice(
|
||||
`Directory picker only exposed "${selectedName}". Paste the absolute path manually.`,
|
||||
);
|
||||
} catch {
|
||||
// user cancelled or API unsupported
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
||||
Choose
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
{cwdPickerNotice && (
|
||||
<p className="mt-1 text-xs text-amber-400">{cwdPickerNotice}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Model
|
||||
</label>
|
||||
<Popover open={modelOpen} onOpenChange={setModelOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className={cn(!model && "text-muted-foreground")}>
|
||||
{selectedModel ? selectedModel.label : model || "Default"}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!model && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setModel(""); setModelOpen(false); }}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{(adapterModels ?? []).map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
m.id === model && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setModel(m.id); setModelOpen(false); }}
|
||||
>
|
||||
<span>{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adapterType === "process" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Command
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="e.g. node, python"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Args (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="e.g. script.js, --flag"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(adapterType === "http" || adapterType === "openclaw") && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Webhook URL
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="https://..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adapterType === "process" && (
|
||||
<div className="space-y-3">
|
||||
{step === 3 && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<ListTodo className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">Give it something to do</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Give your agent a small task to start with — a bug fix, a
|
||||
research question, writing a script.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Command
|
||||
Task title
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="e.g. node, python"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="e.g. Research competitor pricing"
|
||||
value={taskTitle}
|
||||
onChange={(e) => setTaskTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Args (comma-separated)
|
||||
Description (optional)
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="e.g. script.js, --flag"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[80px]"
|
||||
placeholder="Add more detail about what the agent should do..."
|
||||
value={taskDescription}
|
||||
onChange={(e) => setTaskDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adapterType === "http" && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Webhook URL
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="https://..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
{step === 4 && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<Rocket className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">Ready to launch</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Everything is set up. Launch your agent and watch it work.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border divide-y divide-border">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{companyName}</p>
|
||||
<p className="text-xs text-muted-foreground">Company</p>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{agentName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getUIAdapter(adapterType).label}
|
||||
</p>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<ListTodo className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{taskTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">Task</p>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<ListTodo className="h-5 w-5 text-muted-foreground" />
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer navigation */}
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<div>
|
||||
<h3 className="font-medium">Give it something to do</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Give your agent a small task to start with — a bug fix, a
|
||||
research question, writing a script.
|
||||
</p>
|
||||
{step > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setStep((step - 1) as Step)}
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Task title
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="e.g. Research competitor pricing"
|
||||
value={taskTitle}
|
||||
onChange={(e) => setTaskTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[80px]"
|
||||
placeholder="Add more detail about what the agent should do..."
|
||||
value={taskDescription}
|
||||
onChange={(e) => setTaskDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<Rocket className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">Ready to launch</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Everything is set up. Launch your agent and watch it work.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border divide-y divide-border">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{companyName}</p>
|
||||
<p className="text-xs text-muted-foreground">Company</p>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{agentName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getUIAdapter(adapterType).label}
|
||||
</p>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<ListTodo className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{taskTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">Task</p>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
<div className="flex items-center gap-2">
|
||||
{step === 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!companyName.trim() || loading}
|
||||
onClick={handleStep1Next}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Creating..." : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!agentName.trim() || loading}
|
||||
onClick={handleStep2Next}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Creating..." : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!taskTitle.trim() || loading}
|
||||
onClick={handleStep3Next}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Creating..." : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<Button size="sm" disabled={loading} onClick={handleLaunch}>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Launching..." : "Launch Agent"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="px-4 pb-2">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setStep((step - 1) as Step)}
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{step === 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!companyName.trim() || loading}
|
||||
onClick={handleStep1Next}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Creating..." : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!agentName.trim() || loading}
|
||||
onClick={handleStep2Next}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Creating..." : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!taskTitle.trim() || loading}
|
||||
onClick={handleStep3Next}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Creating..." : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<Button size="sm" disabled={loading} onClick={handleLaunch}>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Launching..." : "Launch Agent"}
|
||||
</Button>
|
||||
)}
|
||||
{/* Right half — ASCII art (hidden on mobile) */}
|
||||
<div className="hidden md:block w-1/2 overflow-hidden">
|
||||
<AsciiArtAnimation />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,8 +133,11 @@ function describeIssueUpdate(details: Record<string, unknown> | null): string |
|
||||
const changes: string[] = [];
|
||||
if (typeof details.status === "string") changes.push(`status -> ${details.status.replace(/_/g, " ")}`);
|
||||
if (typeof details.priority === "string") changes.push(`priority -> ${details.priority}`);
|
||||
if (typeof details.assigneeAgentId === "string") changes.push("reassigned");
|
||||
else if (details.assigneeAgentId === null) changes.push("unassigned");
|
||||
if (typeof details.assigneeAgentId === "string" || typeof details.assigneeUserId === "string") {
|
||||
changes.push("reassigned");
|
||||
} else if (details.assigneeAgentId === null || details.assigneeUserId === null) {
|
||||
changes.push("unassigned");
|
||||
}
|
||||
if (details.reopened === true) {
|
||||
const from = readString(details.reopenedFrom);
|
||||
changes.push(from ? `reopened from ${from.replace(/_/g, " ")}` : "reopened");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
@@ -56,12 +57,12 @@ import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
||||
|
||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||
succeeded: { icon: CheckCircle2, color: "text-green-400" },
|
||||
failed: { icon: XCircle, color: "text-red-400" },
|
||||
running: { icon: Loader2, color: "text-cyan-400" },
|
||||
queued: { icon: Clock, color: "text-yellow-400" },
|
||||
timed_out: { icon: Timer, color: "text-orange-400" },
|
||||
cancelled: { icon: Slash, color: "text-neutral-400" },
|
||||
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
||||
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
|
||||
running: { icon: Loader2, color: "text-cyan-600 dark:text-cyan-400" },
|
||||
queued: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" },
|
||||
timed_out: { icon: Timer, color: "text-orange-600 dark:text-orange-400" },
|
||||
cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" },
|
||||
};
|
||||
|
||||
const REDACTED_ENV_VALUE = "***REDACTED***";
|
||||
@@ -453,7 +454,7 @@ export function AgentDetail() {
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">Live</span>
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -631,18 +632,18 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
|
||||
</span>
|
||||
)}
|
||||
<h3 className="text-sm font-medium">{isLive ? "Live Run" : "Latest Run"}</h3>
|
||||
</div>
|
||||
{isLive ? "Live Run" : "Latest Run"}
|
||||
</h3>
|
||||
<Link
|
||||
to={`/agents/${agentId}/runs/${run.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
|
||||
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
|
||||
>
|
||||
View details →
|
||||
</Link>
|
||||
@@ -658,10 +659,10 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||
<span className="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium",
|
||||
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
|
||||
: "bg-neutral-800 text-neutral-400"
|
||||
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||
</span>
|
||||
@@ -765,263 +766,7 @@ function AgentOverview({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Chart Components ---- */
|
||||
|
||||
function getLast14Days(): string[] {
|
||||
return Array.from({ length: 14 }, (_, i) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - (13 - i));
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDayLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T12:00:00");
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
function DateLabels({ days }: { days: string[] }) {
|
||||
return (
|
||||
<div className="flex gap-[3px] mt-1.5">
|
||||
{days.map((day, i) => (
|
||||
<div key={day} className="flex-1 text-center">
|
||||
{(i === 0 || i === 6 || i === 13) ? (
|
||||
<span className="text-[9px] text-muted-foreground tabular-nums">{formatDayLabel(day)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ items }: { items: { color: string; label: string }[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-2.5 gap-y-0.5 mt-2">
|
||||
{items.map(item => (
|
||||
<span key={item.label} className="flex items-center gap-1 text-[9px] text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground">{title}</h3>
|
||||
{subtitle && <span className="text-[10px] text-muted-foreground/60">{subtitle}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
|
||||
const days = getLast14Days();
|
||||
|
||||
const grouped = new Map<string, { succeeded: number; failed: number; other: number }>();
|
||||
for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 });
|
||||
for (const run of runs) {
|
||||
const day = new Date(run.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
if (run.status === "succeeded") entry.succeeded++;
|
||||
else if (run.status === "failed" || run.status === "timed_out") entry.failed++;
|
||||
else entry.other++;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => v.succeeded + v.failed + v.other), 1);
|
||||
const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0);
|
||||
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const total = entry.succeeded + entry.failed + entry.other;
|
||||
const heightPct = (total / maxValue) * 100;
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}>
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
|
||||
{entry.succeeded > 0 && <div className="bg-emerald-500" style={{ flex: entry.succeeded }} />}
|
||||
{entry.failed > 0 && <div className="bg-red-500" style={{ flex: entry.failed }} />}
|
||||
{entry.other > 0 && <div className="bg-neutral-500" style={{ flex: entry.other }} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: "#ef4444",
|
||||
high: "#f97316",
|
||||
medium: "#eab308",
|
||||
low: "#6b7280",
|
||||
};
|
||||
|
||||
const priorityOrder = ["critical", "high", "medium", "low"] as const;
|
||||
|
||||
function PriorityChart({ issues }: { issues: { priority: string; createdAt: Date }[] }) {
|
||||
const days = getLast14Days();
|
||||
const grouped = new Map<string, Record<string, number>>();
|
||||
for (const day of days) grouped.set(day, { critical: 0, high: 0, medium: 0, low: 0 });
|
||||
for (const issue of issues) {
|
||||
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
if (issue.priority in entry) entry[issue.priority]++;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
|
||||
const hasData = Array.from(grouped.values()).some(v => Object.values(v).reduce((a, b) => a + b, 0) > 0);
|
||||
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const total = Object.values(entry).reduce((a, b) => a + b, 0);
|
||||
const heightPct = (total / maxValue) * 100;
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
|
||||
{priorityOrder.map(p => entry[p] > 0 ? (
|
||||
<div key={p} style={{ flex: entry[p], backgroundColor: priorityColors[p] }} />
|
||||
) : null)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
<ChartLegend items={priorityOrder.map(p => ({ color: priorityColors[p], label: p.charAt(0).toUpperCase() + p.slice(1) }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
todo: "#3b82f6",
|
||||
in_progress: "#8b5cf6",
|
||||
in_review: "#a855f7",
|
||||
done: "#10b981",
|
||||
blocked: "#ef4444",
|
||||
cancelled: "#6b7280",
|
||||
backlog: "#64748b",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
todo: "To Do",
|
||||
in_progress: "In Progress",
|
||||
in_review: "In Review",
|
||||
done: "Done",
|
||||
blocked: "Blocked",
|
||||
cancelled: "Cancelled",
|
||||
backlog: "Backlog",
|
||||
};
|
||||
|
||||
function IssueStatusChart({ issues }: { issues: { status: string; createdAt: Date }[] }) {
|
||||
const days = getLast14Days();
|
||||
const allStatuses = new Set<string>();
|
||||
const grouped = new Map<string, Record<string, number>>();
|
||||
for (const day of days) grouped.set(day, {});
|
||||
for (const issue of issues) {
|
||||
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
entry[issue.status] = (entry[issue.status] ?? 0) + 1;
|
||||
allStatuses.add(issue.status);
|
||||
}
|
||||
|
||||
const statusOrder = ["todo", "in_progress", "in_review", "done", "blocked", "cancelled", "backlog"].filter(s => allStatuses.has(s));
|
||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
|
||||
const hasData = allStatuses.size > 0;
|
||||
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const total = Object.values(entry).reduce((a, b) => a + b, 0);
|
||||
const heightPct = (total / maxValue) * 100;
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
|
||||
{statusOrder.map(s => (entry[s] ?? 0) > 0 ? (
|
||||
<div key={s} style={{ flex: entry[s], backgroundColor: statusColors[s] ?? "#6b7280" }} />
|
||||
) : null)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
<ChartLegend items={statusOrder.map(s => ({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) {
|
||||
const days = getLast14Days();
|
||||
const grouped = new Map<string, { succeeded: number; total: number }>();
|
||||
for (const day of days) grouped.set(day, { succeeded: 0, total: 0 });
|
||||
for (const run of runs) {
|
||||
const day = new Date(run.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
entry.total++;
|
||||
if (run.status === "succeeded") entry.succeeded++;
|
||||
}
|
||||
|
||||
const hasData = Array.from(grouped.values()).some(v => v.total > 0);
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const rate = entry.total > 0 ? entry.succeeded / entry.total : 0;
|
||||
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${entry.total > 0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}>
|
||||
{entry.total > 0 ? (
|
||||
<div style={{ height: `${rate * 100}%`, minHeight: 2, backgroundColor: color }} />
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* Chart components imported from ../components/ActivityCharts */
|
||||
|
||||
/* ---- Configuration Summary ---- */
|
||||
|
||||
@@ -1091,7 +836,7 @@ function ConfigSummary({
|
||||
{reportsToAgent ? (
|
||||
<Link
|
||||
to={`/agents/${reportsToAgent.id}`}
|
||||
className="text-blue-400 hover:underline"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
<Identity name={reportsToAgent.name} size="sm" />
|
||||
</Link>
|
||||
@@ -1108,7 +853,7 @@ function ConfigSummary({
|
||||
<Link
|
||||
key={r.id}
|
||||
to={`/agents/${r.id}`}
|
||||
className="flex items-center gap-2 text-sm text-blue-400 hover:underline"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[r.status] ?? agentStatusDotDefault}`} />
|
||||
@@ -1419,10 +1164,10 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
|
||||
</span>
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0",
|
||||
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
|
||||
: "bg-neutral-800 text-neutral-400"
|
||||
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||
</span>
|
||||
@@ -1682,7 +1427,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
)}
|
||||
{run.error && (
|
||||
<div className="text-xs">
|
||||
<span className="text-red-400">{run.error}</span>
|
||||
<span className="text-red-600 dark:text-red-400">{run.error}</span>
|
||||
{run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
|
||||
</div>
|
||||
)}
|
||||
@@ -1709,7 +1454,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
Login URL:
|
||||
<a
|
||||
href={claudeLoginResult.loginUrl}
|
||||
className="text-blue-400 underline underline-offset-2 ml-1 break-all"
|
||||
className="text-blue-600 underline underline-offset-2 ml-1 break-all dark:text-blue-400"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -1720,12 +1465,12 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{claudeLoginResult && (
|
||||
<>
|
||||
{!!claudeLoginResult.stdout && (
|
||||
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
|
||||
{claudeLoginResult.stdout}
|
||||
</pre>
|
||||
)}
|
||||
{!!claudeLoginResult.stderr && (
|
||||
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-300 overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
|
||||
{claudeLoginResult.stderr}
|
||||
</pre>
|
||||
)}
|
||||
@@ -1734,7 +1479,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
</div>
|
||||
)}
|
||||
{hasNonZeroExit && (
|
||||
<div className="text-xs text-red-400">
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Exit code {run.exitCode}
|
||||
{run.signal && <span className="text-muted-foreground ml-1">(signal: {run.signal})</span>}
|
||||
</div>
|
||||
@@ -1848,8 +1593,8 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{/* stderr excerpt for failed runs */}
|
||||
{run.stderrExcerpt && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-red-400">stderr</span>
|
||||
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-300 overflow-x-auto whitespace-pre-wrap">{run.stderrExcerpt}</pre>
|
||||
<span className="text-xs font-medium text-red-600 dark:text-red-400">stderr</span>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">{run.stderrExcerpt}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1857,7 +1602,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{run.stdoutExcerpt && !run.logRef && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">stdout</span>
|
||||
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">{run.stdoutExcerpt}</pre>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">{run.stdoutExcerpt}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2119,14 +1864,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: "text-foreground",
|
||||
warn: "text-yellow-400",
|
||||
error: "text-red-400",
|
||||
warn: "text-yellow-600 dark:text-yellow-400",
|
||||
error: "text-red-600 dark:text-red-400",
|
||||
};
|
||||
|
||||
const streamColors: Record<string, string> = {
|
||||
stdout: "text-foreground",
|
||||
stderr: "text-red-300",
|
||||
system: "text-blue-300",
|
||||
stderr: "text-red-600 dark:text-red-300",
|
||||
system: "text-blue-600 dark:text-blue-300",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -2156,7 +1901,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{adapterInvokePayload.prompt !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{typeof adapterInvokePayload.prompt === "string"
|
||||
? adapterInvokePayload.prompt
|
||||
: JSON.stringify(adapterInvokePayload.prompt, null, 2)}
|
||||
@@ -2166,7 +1911,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{adapterInvokePayload.context !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Context</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(adapterInvokePayload.context, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2174,7 +1919,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{adapterInvokePayload.env !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Environment</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{formatEnvForDisplay(adapterInvokePayload.env)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2213,14 +1958,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5 overflow-x-hidden">
|
||||
<div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5 overflow-x-hidden">
|
||||
{transcript.length === 0 && !run.logRef && (
|
||||
<div className="text-neutral-500">No persisted transcript for this run.</div>
|
||||
)}
|
||||
{transcript.map((entry, idx) => {
|
||||
const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false });
|
||||
const grid = "grid grid-cols-[auto_auto_1fr] gap-x-2 sm:gap-x-3 items-baseline";
|
||||
const tsCell = "text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs";
|
||||
const tsCell = "text-neutral-400 dark:text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs";
|
||||
const lblCell = "w-14 sm:w-20 text-[10px] sm:text-xs";
|
||||
const contentCell = "min-w-0 whitespace-pre-wrap break-words overflow-hidden";
|
||||
const expandCell = "col-span-full md:col-start-3 md:col-span-1";
|
||||
@@ -2229,8 +1974,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-assistant-${idx}`} className={cn(grid, "py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-green-300")}>assistant</span>
|
||||
<span className={cn(contentCell, "text-green-100")}>{entry.text}</span>
|
||||
<span className={cn(lblCell, "text-green-700 dark:text-green-300")}>assistant</span>
|
||||
<span className={cn(contentCell, "text-green-900 dark:text-green-100")}>{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2239,8 +1984,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-thinking-${idx}`} className={cn(grid, "py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-green-300/60")}>thinking</span>
|
||||
<span className={cn(contentCell, "text-green-100/60 italic")}>{entry.text}</span>
|
||||
<span className={cn(lblCell, "text-green-600/60 dark:text-green-300/60")}>thinking</span>
|
||||
<span className={cn(contentCell, "text-green-800/60 dark:text-green-100/60 italic")}>{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2249,8 +1994,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-user-${idx}`} className={cn(grid, "py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-neutral-400")}>user</span>
|
||||
<span className={cn(contentCell, "text-neutral-300")}>{entry.text}</span>
|
||||
<span className={cn(lblCell, "text-neutral-500 dark:text-neutral-400")}>user</span>
|
||||
<span className={cn(contentCell, "text-neutral-700 dark:text-neutral-300")}>{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2259,9 +2004,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-tool-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-yellow-300")}>tool_call</span>
|
||||
<span className="text-yellow-100 min-w-0">{entry.name}</span>
|
||||
<pre className={cn(expandCell, "bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-200")}>
|
||||
<span className={cn(lblCell, "text-yellow-700 dark:text-yellow-300")}>tool_call</span>
|
||||
<span className="text-yellow-900 dark:text-yellow-100 min-w-0">{entry.name}</span>
|
||||
<pre className={cn(expandCell, "bg-neutral-200 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-800 dark:text-neutral-200")}>
|
||||
{JSON.stringify(entry.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2272,9 +2017,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-toolres-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, entry.isError ? "text-red-300" : "text-purple-300")}>tool_result</span>
|
||||
{entry.isError ? <span className="text-red-400 min-w-0">error</span> : <span />}
|
||||
<pre className={cn(expandCell, "bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-300 max-h-60 overflow-y-auto")}>
|
||||
<span className={cn(lblCell, entry.isError ? "text-red-600 dark:text-red-300" : "text-purple-600 dark:text-purple-300")}>tool_result</span>
|
||||
{entry.isError ? <span className="text-red-600 dark:text-red-400 min-w-0">error</span> : <span />}
|
||||
<pre className={cn(expandCell, "bg-neutral-100 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-700 dark:text-neutral-300 max-h-60 overflow-y-auto")}>
|
||||
{(() => { try { return JSON.stringify(JSON.parse(entry.content), null, 2); } catch { return entry.content; } })()}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2285,8 +2030,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-init-${idx}`} className={grid}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-blue-300")}>init</span>
|
||||
<span className={cn(contentCell, "text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span>
|
||||
<span className={cn(lblCell, "text-blue-700 dark:text-blue-300")}>init</span>
|
||||
<span className={cn(contentCell, "text-blue-900 dark:text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2295,18 +2040,18 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
return (
|
||||
<div key={`${entry.ts}-result-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-cyan-300")}>result</span>
|
||||
<span className={cn(contentCell, "text-cyan-100")}>
|
||||
<span className={cn(lblCell, "text-cyan-700 dark:text-cyan-300")}>result</span>
|
||||
<span className={cn(contentCell, "text-cyan-900 dark:text-cyan-100")}>
|
||||
tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)}
|
||||
</span>
|
||||
{(entry.subtype || entry.isError || entry.errors.length > 0) && (
|
||||
<div className={cn(expandCell, "text-red-300 whitespace-pre-wrap break-words")}>
|
||||
<div className={cn(expandCell, "text-red-600 dark:text-red-300 whitespace-pre-wrap break-words")}>
|
||||
subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"}
|
||||
{entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{entry.text && (
|
||||
<div className={cn(expandCell, "whitespace-pre-wrap break-words text-neutral-100")}>{entry.text}</div>
|
||||
<div className={cn(expandCell, "whitespace-pre-wrap break-words text-neutral-800 dark:text-neutral-100")}>{entry.text}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -2318,8 +2063,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
entry.kind === "system" ? "system" :
|
||||
"stdout";
|
||||
const color =
|
||||
entry.kind === "stderr" ? "text-red-300" :
|
||||
entry.kind === "system" ? "text-blue-300" :
|
||||
entry.kind === "stderr" ? "text-red-600 dark:text-red-300" :
|
||||
entry.kind === "system" ? "text-blue-600 dark:text-blue-300" :
|
||||
"text-neutral-500";
|
||||
return (
|
||||
<div key={`${entry.ts}-raw-${idx}`} className={grid}>
|
||||
@@ -2329,39 +2074,39 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{logError && <div className="text-red-300">{logError}</div>}
|
||||
{logError && <div className="text-red-600 dark:text-red-300">{logError}</div>}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
|
||||
{(run.status === "failed" || run.status === "timed_out") && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-950/20 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-red-300">Failure details</div>
|
||||
<div className="rounded-lg border border-red-300 dark:border-red-500/30 bg-red-50 dark:bg-red-950/20 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-red-700 dark:text-red-300">Failure details</div>
|
||||
{run.error && (
|
||||
<div className="text-xs text-red-200">
|
||||
<span className="text-red-300">Error: </span>
|
||||
<div className="text-xs text-red-600 dark:text-red-200">
|
||||
<span className="text-red-700 dark:text-red-300">Error: </span>
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
|
||||
<div>
|
||||
<div className="text-xs text-red-300 mb-1">stderr excerpt</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-100">
|
||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
|
||||
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
|
||||
{run.stderrExcerpt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{run.resultJson && (
|
||||
<div>
|
||||
<div className="text-xs text-red-300 mb-1">adapter result JSON</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-100">
|
||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
|
||||
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
|
||||
{JSON.stringify(run.resultJson, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{run.stdoutExcerpt && run.stdoutExcerpt.trim() && !run.resultJson && (
|
||||
<div>
|
||||
<div className="text-xs text-red-300 mb-1">stdout excerpt</div>
|
||||
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-100">
|
||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
|
||||
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
|
||||
{run.stdoutExcerpt}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -2372,7 +2117,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{events.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Events ({events.length})</div>
|
||||
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
|
||||
<div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
|
||||
{events.map((evt) => {
|
||||
const color = evt.color
|
||||
?? (evt.level ? levelColors[evt.level] : null)
|
||||
@@ -2381,7 +2126,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
|
||||
return (
|
||||
<div key={evt.id} className="flex gap-2">
|
||||
<span className="text-neutral-600 shrink-0 select-none w-16">
|
||||
<span className="text-neutral-400 dark:text-neutral-600 shrink-0 select-none w-16">
|
||||
{new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })}
|
||||
</span>
|
||||
<span className={cn("shrink-0 w-14", evt.stream ? (streamColors[evt.stream] ?? "text-neutral-500") : "text-neutral-500")}>
|
||||
@@ -2445,12 +2190,12 @@ function KeysTab({ agentId }: { agentId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* New token banner */}
|
||||
{newToken && (
|
||||
<div className="border border-yellow-600/40 bg-yellow-500/5 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-yellow-400">
|
||||
<div className="border border-yellow-300 dark:border-yellow-600/40 bg-yellow-50 dark:bg-yellow-500/5 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400">
|
||||
API key created — copy it now, it will not be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-neutral-950 rounded px-3 py-1.5 text-xs font-mono text-green-300 truncate">
|
||||
<code className="flex-1 bg-neutral-100 dark:bg-neutral-950 rounded px-3 py-1.5 text-xs font-mono text-green-700 dark:text-green-300 truncate">
|
||||
{tokenVisible ? newToken : newToken.replace(/./g, "•")}
|
||||
</code>
|
||||
<Button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { issuesApi } from "../api/issues";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
@@ -43,6 +44,11 @@ import {
|
||||
import type { ActivityEvent } from "@paperclip/shared";
|
||||
import type { Agent, IssueAttachment } from "@paperclip/shared";
|
||||
|
||||
type CommentReassignment = {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
"issue.created": "created the issue",
|
||||
"issue.updated": "updated the issue",
|
||||
@@ -109,8 +115,12 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
|
||||
: `changed the priority to ${humanizeValue(details.priority)}`
|
||||
);
|
||||
}
|
||||
if (details.assigneeAgentId !== undefined) {
|
||||
parts.push(details.assigneeAgentId ? "assigned the issue" : "unassigned the issue");
|
||||
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
||||
parts.push(
|
||||
details.assigneeAgentId || details.assigneeUserId
|
||||
? "assigned the issue"
|
||||
: "unassigned the issue",
|
||||
);
|
||||
}
|
||||
if (details.title !== undefined) parts.push("updated the title");
|
||||
if (details.description !== undefined) parts.push("updated the description");
|
||||
@@ -144,7 +154,6 @@ export function IssueDetail() {
|
||||
const [detailTab, setDetailTab] = useState("comments");
|
||||
const [secondaryOpen, setSecondaryOpen] = useState({
|
||||
approvals: false,
|
||||
runs: false,
|
||||
cost: false,
|
||||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
@@ -208,6 +217,11 @@ export function IssueDetail() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
@@ -227,6 +241,33 @@ export function IssueDetail() {
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}, [allIssues, issue]);
|
||||
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
|
||||
const canReassignFromComment = Boolean(
|
||||
issue?.assigneeUserId &&
|
||||
(issue.assigneeUserId === "local-board" || (currentUserId && issue.assigneeUserId === currentUserId)),
|
||||
);
|
||||
|
||||
const commentReassignOptions = useMemo(() => {
|
||||
const options: Array<{ value: string; label: string }> = [{ value: "__none__", label: "No assignee" }];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({ value: `agent:${agent.id}`, label: agent.name });
|
||||
}
|
||||
if (issue?.createdByUserId && issue.createdByUserId !== issue.assigneeUserId) {
|
||||
const requesterLabel =
|
||||
issue.createdByUserId === "local-board"
|
||||
? "Board"
|
||||
: currentUserId && issue.createdByUserId === currentUserId
|
||||
? "Me"
|
||||
: issue.createdByUserId.slice(0, 8);
|
||||
options.push({ value: `user:${issue.createdByUserId}`, label: `Requester (${requesterLabel})` });
|
||||
}
|
||||
return options;
|
||||
}, [agents, currentUserId, issue?.assigneeUserId, issue?.createdByUserId]);
|
||||
|
||||
const commentsWithRunMeta = useMemo(() => {
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
||||
const agentIdByRunId = new Map<string, string>();
|
||||
@@ -335,6 +376,36 @@ export function IssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const addCommentAndReassign = useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
reopen,
|
||||
reassignment,
|
||||
}: {
|
||||
body: string;
|
||||
reopen?: boolean;
|
||||
reassignment: CommentReassignment;
|
||||
}) =>
|
||||
issuesApi.update(issueId!, {
|
||||
comment: body,
|
||||
assigneeAgentId: reassignment.assigneeAgentId,
|
||||
assigneeUserId: reassignment.assigneeUserId,
|
||||
...(reopen ? { status: "todo" } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
const issueRef = updated.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
|
||||
pushToast({
|
||||
dedupeKey: `activity:issue.reassigned:${updated.id}`,
|
||||
title: `${issueRef} reassigned`,
|
||||
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
||||
tone: "success",
|
||||
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uploadAttachment = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
@@ -445,7 +516,7 @@ export function IssueDetail() {
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
|
||||
{hasLiveRuns && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-400 shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
|
||||
@@ -638,10 +709,17 @@ export function IssueDetail() {
|
||||
<TabsContent value="comments">
|
||||
<CommentThread
|
||||
comments={commentsWithRunMeta}
|
||||
linkedRuns={linkedRuns ?? []}
|
||||
issueStatus={issue.status}
|
||||
agentMap={agentMap}
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
onAdd={async (body, reopen) => {
|
||||
enableReassign={canReassignFromComment}
|
||||
reassignOptions={commentReassignOptions}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
return;
|
||||
}
|
||||
await addComment.mutateAsync({ body, reopen });
|
||||
}}
|
||||
imageUploadHandler={async (file) => {
|
||||
@@ -740,39 +818,6 @@ export function IssueDetail() {
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{linkedRuns && linkedRuns.length > 0 && (
|
||||
<Collapsible
|
||||
open={secondaryOpen.runs}
|
||||
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, runs: open }))}
|
||||
className="rounded-lg border border-border"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
||||
<span className="text-sm font-medium text-muted-foreground">Linked Runs ({linkedRuns.length})</span>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.runs && "rotate-180")}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-border divide-y divide-border">
|
||||
{linkedRuns.map((run) => (
|
||||
<Link
|
||||
key={run.runId}
|
||||
to={`/agents/${run.agentId}/runs/${run.runId}`}
|
||||
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Identity name={agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8)} size="sm" />
|
||||
<StatusBadge status={run.status} />
|
||||
<span className="font-mono text-muted-foreground">{run.runId.slice(0, 8)}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{relativeTime(run.createdAt)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{linkedRuns && linkedRuns.length > 0 && (
|
||||
<Collapsible
|
||||
open={secondaryOpen.cost}
|
||||
|
||||
Reference in New Issue
Block a user