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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user