Add Start page, session chat UI, and filtering across arc-web

- /start page with AI prompt textarea, project/branch pickers, and starter workflow cards
- /sessions/:id page with chat conversation UI (user/assistant/tool turns with progressive disclosure)
- Session sidebar with grouped history (Today, Yesterday, Previous 7 days)
- Start nav tab with sparkles icon added to app shell
- Scheduled workflows with schedule badges and pause buttons on /workflows
- Trigger filter dropdown (All/Scheduled/Manual) on /workflows
- Search bar and repository filter dropdown on /runs kanban board

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-28 23:07:19 -05:00
parent 90dc528c99
commit 2eebb00ab1
7 changed files with 5090 additions and 25 deletions

View file

@ -12,6 +12,7 @@ import {
Cog6ToothIcon,
PlayIcon,
RectangleStackIcon,
SparklesIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation, useMatches } from "react-router";
@ -24,6 +25,7 @@ const user = {
};
const navigation = [
{ name: "Start", href: "/start", icon: SparklesIcon },
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon },
{ name: "Runs", href: "/runs", icon: PlayIcon },
{ name: "Settings", href: "/settings", icon: Cog6ToothIcon },

View file

@ -9,6 +9,7 @@ export default [
index("routes/redirect-home.tsx"),
layout("layouts/app-shell.tsx", [
route("start", "routes/start.tsx"),
route("sessions/:sessionId", "routes/session-detail.tsx"),
route("workflows", "routes/workflows.tsx"),
route("workflows/:name", "routes/workflow-detail.tsx", [
index("routes/workflow-definition.tsx"),

View file

@ -1,4 +1,6 @@
import { useState } from "react";
import { Link } from "react-router";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { columns, ciConfig } from "../data/runs";
import type { CiStatus, RunItem } from "../data/runs";
import type { Route } from "./+types/pipelines";
@ -206,12 +208,57 @@ function BoardColumn({ column }: { column: (typeof columns)[number] }) {
);
}
const allRepos = [...new Set(columns.flatMap((col) => col.items.map((item) => item.repo)))].sort();
export default function Pipelines() {
const [query, setQuery] = useState("");
const [repoFilter, setRepoFilter] = useState("all");
const lowerQuery = query.toLowerCase();
const filteredColumns = columns.map((col) => ({
...col,
items: col.items.filter(
(item) =>
(repoFilter === "all" || item.repo === repoFilter) &&
(!query ||
item.title.toLowerCase().includes(lowerQuery) ||
item.repo.toLowerCase().includes(lowerQuery) ||
(item.number != null && `#${item.number}`.includes(lowerQuery))),
),
}));
return (
<div className="flex gap-5 overflow-x-auto pb-4">
{columns.map((col) => (
<BoardColumn key={col.id} column={col} />
))}
<div className="space-y-4">
<div className="flex gap-3">
<div className="relative flex-1">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
<input
type="text"
placeholder="Search runs..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-9 pr-3 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
/>
</div>
<div className="relative">
<select
value={repoFilter}
onChange={(e) => setRepoFilter(e.target.value)}
className="appearance-none rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-3 pr-8 text-sm text-ice-100 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
>
<option value="all">All repos</option>
{allRepos.map((repo) => (
<option key={repo} value={repo}>{repo}</option>
))}
</select>
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
</div>
</div>
<div className="flex gap-5 overflow-x-auto pb-4">
{filteredColumns.map((col) => (
<BoardColumn key={col.id} column={col} />
))}
</div>
</div>
);
}

View file

@ -0,0 +1,360 @@
import { useState } from "react";
import { Link, useParams } from "react-router";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import {
ChatBubbleLeftIcon,
PencilSquareIcon,
UserIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/outline";
import type { Route } from "./+types/session-detail";
export const handle = { hideHeader: true, wide: true };
export function meta({}: Route.MetaArgs) {
return [{ title: "Session — Arc" }];
}
interface ToolUse {
toolName: string;
args: string;
result: string;
}
type Turn =
| { kind: "user"; content: string }
| { kind: "assistant"; content: string }
| { kind: "tool"; tools: ToolUse[] };
interface Session {
id: string;
title: string;
repo: string;
time: string;
turns: Turn[];
}
const sessions: Record<string, Session> = {
s1: {
id: "s1",
title: "Add rate limiting to auth endpoints",
repo: "api-server",
time: "2h ago",
turns: [
{
kind: "user",
content: "Add rate limiting to the auth endpoints. We're getting hit with brute force attempts on /api/auth/login and /api/auth/register. Use a sliding window approach with Redis, 10 requests per minute per IP.",
},
{
kind: "assistant",
content: "I'll implement sliding window rate limiting using Redis. Let me first look at the existing auth routes and middleware setup.",
},
{
kind: "tool",
tools: [
{
toolName: "read_file",
args: `{ "path": "src/routes/auth.ts" }`,
result: `import { Router } from "express";\nimport { loginHandler } from "../handlers/auth";\nimport { registerHandler } from "../handlers/auth";\n\nconst router = Router();\nrouter.post("/login", loginHandler);\nrouter.post("/register", registerHandler);\n\nexport default router;`,
},
{
toolName: "read_file",
args: `{ "path": "src/middleware/index.ts" }`,
result: `export { authMiddleware } from "./auth";\nexport { corsMiddleware } from "./cors";\nexport { loggingMiddleware } from "./logging";`,
},
{
toolName: "search_files",
args: `{ "pattern": "redis", "path": "src/" }`,
result: `src/lib/redis.ts:1: import Redis from "ioredis";\nsrc/lib/redis.ts:3: export const redis = new Redis(process.env.REDIS_URL);`,
},
],
},
{
kind: "assistant",
content: "Good — there's already a Redis client at `src/lib/redis.ts`. I'll create a rate limiting middleware using the sliding window algorithm and apply it to the auth routes.\n\nHere's the plan:\n1. Create `src/middleware/rate-limit.ts` with sliding window logic\n2. Apply it to `/login` and `/register` routes\n3. Return `429 Too Many Requests` when the limit is exceeded",
},
{
kind: "tool",
tools: [
{
toolName: "write_file",
args: `{ "path": "src/middleware/rate-limit.ts" }`,
result: `File written: src/middleware/rate-limit.ts (47 lines)`,
},
{
toolName: "edit_file",
args: `{ "path": "src/routes/auth.ts", "operations": [{ "type": "insert", "line": 3 }] }`,
result: `Applied 1 edit to src/routes/auth.ts`,
},
{
toolName: "edit_file",
args: `{ "path": "src/middleware/index.ts", "operations": [{ "type": "append" }] }`,
result: `Applied 1 edit to src/middleware/index.ts`,
},
],
},
{
kind: "assistant",
content: "Done. I've created the rate limiter and wired it up:\n\n- **`src/middleware/rate-limit.ts`** — Sliding window rate limiter using Redis sorted sets. Each request adds a timestamped entry, expired entries are pruned, and the count is checked against the limit.\n- **`src/routes/auth.ts`** — Applied `rateLimit({ windowMs: 60_000, max: 10 })` to both `/login` and `/register`.\n- **`src/middleware/index.ts`** — Re-exported the new middleware.\n\nWhen the limit is exceeded, clients get a `429` response with a `Retry-After` header indicating seconds until the window resets.",
},
],
},
s2: {
id: "s2",
title: "Fix config parsing for nested values",
repo: "cli-tools",
time: "4h ago",
turns: [
{
kind: "user",
content: "The CLI crashes when parsing nested TOML config values like [database.connection]. Can you debug and fix this?",
},
{
kind: "assistant",
content: "Let me look at the config parser to understand how nested keys are handled.",
},
{
kind: "tool",
tools: [
{
toolName: "read_file",
args: `{ "path": "src/config/parser.ts" }`,
result: `export function parseConfig(raw: string): Record<string, string> {\n const result: Record<string, string> = {};\n for (const line of raw.split("\\n")) {\n const match = line.match(/^(\\w+)\\s*=\\s*(.+)$/);\n if (match) result[match[1]] = match[2].trim();\n }\n return result;\n}`,
},
],
},
{
kind: "assistant",
content: "Found the issue. The parser uses a flat `Record<string, string>` and the regex `^(\\w+)` only matches simple keys — it can't handle TOML section headers like `[database.connection]` or dotted keys like `database.host`.\n\nI'll rewrite the parser to support nested sections and dotted keys, producing a properly nested object.",
},
{
kind: "tool",
tools: [
{
toolName: "write_file",
args: `{ "path": "src/config/parser.ts" }`,
result: `File written: src/config/parser.ts (62 lines)`,
},
{
toolName: "run_command",
args: `{ "command": "npm test -- --grep 'config parser'" }`,
result: `PASS src/config/__tests__/parser.test.ts\n config parser\n ✓ parses flat keys (2ms)\n ✓ parses section headers (1ms)\n ✓ parses dotted keys (1ms)\n ✓ handles nested sections (1ms)\n\nTest Suites: 1 passed\nTests: 4 passed`,
},
],
},
{
kind: "assistant",
content: "Fixed. The parser now:\n\n1. Tracks the current section header (e.g. `[database.connection]`)\n2. Constructs nested objects from dotted paths\n3. Handles both `key = value` within sections and bare `dotted.key = value` syntax\n\nAll 4 tests pass, including the new nested section case.",
},
],
},
s3: {
id: "s3",
title: "Migrate to React Router v7",
repo: "web-dashboard",
time: "1d ago",
turns: [
{ kind: "user", content: "Help me migrate our app from React Router v6 to v7. We're using createBrowserRouter with data loaders." },
{ kind: "assistant", content: "I'll audit your current router setup and identify what needs to change for v7. Let me scan the codebase." },
{
kind: "tool",
tools: [
{ toolName: "search_files", args: `{ "pattern": "createBrowserRouter" }`, result: `src/main.tsx:5: import { createBrowserRouter } from "react-router-dom";\nsrc/main.tsx:8: const router = createBrowserRouter([` },
{ toolName: "read_file", args: `{ "path": "package.json" }`, result: `"react-router-dom": "^6.22.0"` },
],
},
{ kind: "assistant", content: "You're on React Router 6.22. The migration to v7 involves:\n\n1. Replace `react-router-dom` with `react-router` (single package)\n2. Update `createBrowserRouter` import path\n3. Migrate data loaders to the new `Route.LoaderArgs` type\n4. Update any `useLoaderData` calls to be type-safe\n\nLet me make these changes." },
],
},
};
// Fallback for unknown session IDs
function makeFallbackSession(id: string): Session {
return {
id,
title: "Session",
repo: "unknown",
time: "",
turns: [
{ kind: "user", content: "Hello, let's get started." },
{ kind: "assistant", content: "Sure! What would you like to work on?" },
],
};
}
interface SessionGroup {
label: string;
sessions: { id: string; title: string; repo: string; time: string }[];
}
const sessionGroups: SessionGroup[] = [
{
label: "Today",
sessions: [
{ id: "s1", title: "Add rate limiting to auth endpoints", repo: "api-server", time: "2h ago" },
{ id: "s2", title: "Fix config parsing for nested values", repo: "cli-tools", time: "4h ago" },
],
},
{
label: "Yesterday",
sessions: [
{ id: "s3", title: "Migrate to React Router v7", repo: "web-dashboard", time: "1d ago" },
{ id: "s4", title: "Add dark mode toggle", repo: "web-dashboard", time: "1d ago" },
{ id: "s5", title: "Update OpenAPI spec for v3", repo: "api-server", time: "1d ago" },
],
},
{
label: "Previous 7 days",
sessions: [
{ id: "s6", title: "Terraform module for Redis cluster", repo: "infrastructure", time: "3d ago" },
{ id: "s7", title: "Add pipeline event types", repo: "shared-types", time: "5d ago" },
{ id: "s8", title: "Implement webhook retry logic", repo: "api-server", time: "6d ago" },
],
},
];
function ToolRow({ tool }: { tool: ToolUse }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-white/[0.04] last:border-b-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left transition-colors hover:bg-white/[0.02] cursor-pointer"
>
<ChevronRightIcon className={`size-3 shrink-0 text-navy-600 transition-transform duration-150 ${open ? "rotate-90" : ""}`} />
<WrenchScrewdriverIcon className="size-3.5 shrink-0 text-navy-600" />
<span className="font-mono text-xs text-ice-300">{tool.toolName}</span>
<span className="truncate font-mono text-xs text-navy-600">{tool.args}</span>
</button>
{open && (
<div className="space-y-px bg-white/[0.01] px-2.5 pb-2 pt-1">
<div className="rounded bg-white/[0.02] px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-navy-600">Args</div>
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-ice-300">{tool.args}</pre>
</div>
<div className="rounded bg-white/[0.02] px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-navy-600">Result</div>
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-ice-300">{tool.result}</pre>
</div>
</div>
)}
</div>
);
}
function ToolBlock({ tools }: { tools: ToolUse[] }) {
return (
<div className="rounded-md border border-white/[0.06] bg-white/[0.01] overflow-hidden">
{tools.map((tool, i) => (
<ToolRow key={i} tool={tool} />
))}
</div>
);
}
function UserBlock({ content }: { content: string }) {
return (
<div className="flex gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-navy-800 border border-white/[0.08]">
<UserIcon className="size-3.5 text-ice-300" />
</div>
<div className="min-w-0 flex-1 pt-0.5">
<pre className="whitespace-pre-wrap font-sans text-sm leading-relaxed text-ice-100">{content}</pre>
</div>
</div>
);
}
function AssistantBlock({ content }: { content: string }) {
return (
<div className="flex gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-teal-500/10 border border-teal-500/20">
<ChatBubbleLeftIcon className="size-3.5 text-teal-500" />
</div>
<div className="min-w-0 flex-1 pt-0.5">
<pre className="whitespace-pre-wrap font-sans text-sm leading-relaxed text-ice-300">{content}</pre>
</div>
</div>
);
}
function SessionSidebar({ activeId }: { activeId: string }) {
return (
<aside className="w-64 shrink-0 border-r border-white/[0.06] flex flex-col h-[calc(100vh-4rem)]">
<div className="p-3">
<Link
to="/start"
className="flex w-full items-center gap-2 rounded-lg border border-white/[0.06] bg-navy-800/60 px-3 py-2 text-sm text-ice-100 transition-colors hover:bg-navy-800 hover:border-white/[0.12]"
>
<PencilSquareIcon className="size-4 text-navy-600" />
New session
</Link>
</div>
<nav className="flex-1 overflow-y-auto px-3 pb-4">
{sessionGroups.map((group) => (
<div key={group.label} className="mt-4 first:mt-1">
<p className="px-2 mb-1.5 text-[11px] font-medium uppercase tracking-wider text-navy-600">
{group.label}
</p>
<ul className="space-y-0.5">
{group.sessions.map((session) => (
<li key={session.id}>
<Link
to={`/sessions/${session.id}`}
className={`flex w-full flex-col rounded-lg px-2.5 py-2 text-left transition-colors ${
activeId === session.id
? "bg-white/[0.06] text-ice-100"
: "text-ice-300 hover:bg-white/[0.04]"
}`}
>
<span className="truncate text-sm">{session.title}</span>
<span className="flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-[11px] text-teal-500">{session.repo}</span>
<span className="text-[11px] text-navy-600">{session.time}</span>
</span>
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
);
}
export default function SessionDetail() {
const { sessionId } = useParams();
const session = sessions[sessionId ?? ""] ?? makeFallbackSession(sessionId ?? "");
return (
<div className="flex -mx-4 sm:-mx-6 lg:-mx-8 -my-6">
<SessionSidebar activeId={session.id} />
<div className="flex-1 flex flex-col min-h-[calc(100vh-4rem)]">
<div className="border-b border-white/[0.06] px-6 py-3 flex items-center gap-3">
<h1 className="text-sm font-medium text-ice-100">{session.title}</h1>
<span className="font-mono text-xs text-teal-500">{session.repo}</span>
<span className="text-xs text-navy-600">{session.time}</span>
</div>
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="mx-auto max-w-3xl space-y-5">
{session.turns.map((turn, i) => {
switch (turn.kind) {
case "user":
return <UserBlock key={i} content={turn.content} />;
case "assistant":
return <AssistantBlock key={i} content={turn.content} />;
case "tool":
return <div key={i} className="pl-10"><ToolBlock tools={turn.tools} /></div>;
}
})}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,9 +1,287 @@
import { useState, useRef, useEffect } from "react";
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { ArrowUpIcon } from "@heroicons/react/24/solid";
import {
ChevronUpDownIcon,
FolderIcon,
} from "@heroicons/react/16/solid";
import {
MagnifyingGlassIcon,
BoltIcon,
PencilSquareIcon,
ShieldCheckIcon,
} from "@heroicons/react/24/outline";
import { Link } from "react-router";
import type { Route } from "./+types/start";
export const handle = { hideHeader: true, wide: true };
export function meta({}: Route.MetaArgs) {
return [{ title: "Start — Arc" }];
}
export default function Start() {
return null;
const projects = [
{ id: "arc-web", name: "arc-web" },
{ id: "arc-attractor", name: "arc-attractor" },
{ id: "arc-cli", name: "arc-cli" },
];
const branches = [
{ id: "main", name: "main" },
{ id: "develop", name: "develop" },
{ id: "feature/start-page", name: "feature/start-page" },
];
const sessionGroups = [
{
label: "Today",
sessions: [
{ id: "s1", title: "Add rate limiting to auth endpoints", repo: "api-server", time: "2h ago" },
{ id: "s2", title: "Fix config parsing for nested values", repo: "cli-tools", time: "4h ago" },
],
},
{
label: "Yesterday",
sessions: [
{ id: "s3", title: "Migrate to React Router v7", repo: "web-dashboard", time: "1d ago" },
{ id: "s4", title: "Add dark mode toggle", repo: "web-dashboard", time: "1d ago" },
{ id: "s5", title: "Update OpenAPI spec for v3", repo: "api-server", time: "1d ago" },
],
},
{
label: "Previous 7 days",
sessions: [
{ id: "s6", title: "Terraform module for Redis cluster", repo: "infrastructure", time: "3d ago" },
{ id: "s7", title: "Add pipeline event types", repo: "shared-types", time: "5d ago" },
{ id: "s8", title: "Implement webhook retry logic", repo: "api-server", time: "6d ago" },
],
},
];
function BranchIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" className={className}>
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.5 2.5 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
</svg>
);
}
function SessionSidebar() {
return (
<aside className="w-64 shrink-0 border-r border-white/[0.06] flex flex-col h-[calc(100vh-4rem)]">
<div className="p-3">
<div className="flex w-full items-center gap-2 rounded-lg border border-teal-500/20 bg-navy-800/60 px-3 py-2 text-sm text-ice-100">
<PencilSquareIcon className="size-4 text-teal-500" />
New session
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 pb-4">
{sessionGroups.map((group) => (
<div key={group.label} className="mt-4 first:mt-1">
<p className="px-2 mb-1.5 text-[11px] font-medium uppercase tracking-wider text-navy-600">
{group.label}
</p>
<ul className="space-y-0.5">
{group.sessions.map((session) => (
<li key={session.id}>
<Link
to={`/sessions/${session.id}`}
className="flex w-full flex-col rounded-lg px-2.5 py-2 text-left transition-colors text-ice-300 hover:bg-white/[0.04]"
>
<span className="truncate text-sm">{session.title}</span>
<span className="flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-[11px] text-teal-500">{session.repo}</span>
<span className="text-[11px] text-navy-600">{session.time}</span>
</span>
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
);
}
export default function Start() {
const [prompt, setPrompt] = useState("");
const [project, setProject] = useState(projects[0]);
const [branch, setBranch] = useState(branches[0]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
function autoResize() {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 280) + "px";
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (prompt.trim()) handleSubmit();
}
}
function handleSubmit() {
if (!prompt.trim()) return;
// TODO: wire up submission
}
return (
<div className="flex -mx-4 sm:-mx-6 lg:-mx-8 -my-6">
<SessionSidebar />
<div className="flex-1 flex flex-col items-center pt-[12vh] px-4">
<div className="w-full max-w-2xl">
<h1 className="text-[2rem] font-medium tracking-tight text-ice-100 text-center mb-8">
What do you want to build?
</h1>
<div className="relative group">
<div className="absolute -inset-px rounded-xl bg-gradient-to-b from-teal-500/30 to-mint/20 opacity-0 blur-sm transition-opacity duration-300 group-focus-within:opacity-100" />
<div className="relative rounded-xl bg-navy-800 border border-white/[0.08] group-focus-within:border-teal-500/40 transition-colors duration-300">
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => {
setPrompt(e.target.value);
autoResize();
}}
onKeyDown={handleKeyDown}
placeholder="Describe a workflow, pipeline, or automation..."
rows={3}
className="w-full resize-none bg-transparent px-5 pt-4 pb-14 text-[15px] leading-relaxed text-ice-100 placeholder:text-navy-600 focus:outline-none"
/>
<div className="absolute bottom-3 inset-x-3 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Picker
value={project}
onChange={setProject}
options={projects}
icon={<FolderIcon className="size-3.5 text-navy-600" />}
/>
<Picker
value={branch}
onChange={setBranch}
options={branches}
icon={<BranchIcon className="size-3.5 text-navy-600" />}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-navy-600 select-none">
<kbd className="font-mono">Enter</kbd> to submit
</span>
<button
onClick={handleSubmit}
disabled={!prompt.trim()}
className="flex items-center justify-center size-8 rounded-lg bg-teal-500 text-navy-950 transition-all duration-200 hover:bg-teal-300 disabled:opacity-30 disabled:hover:bg-teal-500 cursor-pointer disabled:cursor-default"
>
<ArrowUpIcon className="size-4" />
</button>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mt-5">
{starters.map((starter) => (
<button
key={starter.title}
onClick={() => {
setPrompt(starter.prompt);
textareaRef.current?.focus();
setTimeout(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 280) + "px";
}, 0);
}}
className="group/card flex flex-col gap-3 rounded-xl border border-white/[0.06] bg-navy-800/50 px-4 py-4 text-left transition-all duration-200 hover:bg-navy-800 hover:border-white/[0.12] cursor-pointer"
>
<div className="flex size-9 items-center justify-center rounded-lg bg-navy-950/60 border border-white/[0.06] group-hover/card:border-teal-500/20 transition-colors">
<starter.icon className="size-4.5 text-teal-500" />
</div>
<div>
<p className="text-sm font-medium text-ice-100">{starter.title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-navy-600">{starter.description}</p>
</div>
</button>
))}
</div>
</div>
</div>
</div>
);
}
const starters = [
{
title: "Code review",
description: "Analyze a PR for bugs, security issues, and style",
prompt: "Review the latest pull request for bugs, security vulnerabilities, and code style issues. Summarize findings and suggest fixes.",
icon: MagnifyingGlassIcon,
},
{
title: "Generate tests",
description: "Scaffold unit and integration tests for a module",
prompt: "Generate comprehensive unit and integration tests for the core module, covering edge cases and error paths.",
icon: ShieldCheckIcon,
},
{
title: "Refactor & optimize",
description: "Improve performance and clean up technical debt",
prompt: "Identify performance bottlenecks and technical debt, then refactor the code for clarity and speed.",
icon: BoltIcon,
},
];
function Picker<T extends { id: string; name: string }>({
value,
onChange,
options,
icon,
}: {
value: T;
onChange: (v: T) => void;
options: T[];
icon: React.ReactNode;
}) {
return (
<Listbox value={value} onChange={onChange}>
<div className="relative">
<ListboxButton className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-ice-300 bg-navy-950/60 border border-white/[0.06] hover:border-white/[0.12] hover:bg-navy-950/80 transition-colors cursor-pointer">
{icon}
<span className="max-w-[120px] truncate">{value.name}</span>
<ChevronUpDownIcon className="size-3.5 text-navy-600" />
</ListboxButton>
<ListboxOptions anchor="top start" className="z-20 w-56 rounded-lg bg-navy-800 border border-white/[0.08] py-1 shadow-xl shadow-black/30 focus:outline-none [--anchor-gap:4px]">
{options.map((option) => (
<ListboxOption
key={option.id}
value={option}
className="flex items-center gap-2 px-3 py-1.5 text-xs text-ice-300 cursor-pointer data-focus:bg-white/[0.06] data-selected:text-teal-300"
>
{option.name}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
);
}

View file

@ -1,11 +1,15 @@
import { useState, type ComponentType } from "react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/20/solid";
import { ChevronDownIcon as ChevronDownOutline } from "@heroicons/react/24/outline";
import {
ArrowsRightLeftIcon,
ClockIcon,
CodeBracketIcon,
MagnifyingGlassIcon,
PauseIcon,
RocketLaunchIcon,
ShieldCheckIcon,
WrenchIcon,
} from "@heroicons/react/24/outline";
import { Link } from "react-router";
@ -62,6 +66,8 @@ interface Workflow {
lastRun: string;
icon: ComponentType<{ className?: string }>;
color: string;
schedule?: string;
nextRun?: string;
}
const workflows: Workflow[] = [
@ -69,6 +75,8 @@ const workflows: Workflow[] = [
{ name: "Implement Feature", slug: "implement", filename: "implement.dot", lastRun: "4 days ago", icon: CodeBracketIcon, color: "#67B2D7" },
{ name: "Sync Drift", slug: "sync_drift", filename: "sync_drift.dot", lastRun: "1 day ago", icon: ArrowsRightLeftIcon, color: "#5AC8A8" },
{ name: "Expand Product", slug: "expand", filename: "expand.dot", lastRun: "2 weeks ago", icon: RocketLaunchIcon, color: "#E86B6B" },
{ name: "Security Scan", slug: "security_scan", filename: "security_scan.dot", lastRun: "9 hours ago", icon: ShieldCheckIcon, color: "#67B2D7", schedule: "Daily at 09:00", nextRun: "Starts in 3 hours" },
{ name: "Dependency Audit", slug: "dep_audit", filename: "dep_audit.dot", lastRun: "1 day ago", icon: ClockIcon, color: "#F0A45B", schedule: "Weekly on Mon 08:00", nextRun: "Starts in 2 days" },
];
function PlayIcon({ className }: { className?: string }) {
@ -103,18 +111,36 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ice-100 group-hover:text-white">{workflow.name}</span>
<span className="font-mono text-xs text-navy-600">{workflow.filename}</span>
{workflow.schedule && (
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/10 border border-teal-500/20 px-2 py-0.5 text-[11px] font-medium text-teal-300">
<ClockIcon className="size-3" />
{workflow.schedule}
</span>
)}
</div>
<p className="mt-1 text-xs text-navy-600">Last run {workflow.lastRun}</p>
<p className="mt-1 text-xs text-navy-600">
{workflow.nextRun ?? `Last run ${workflow.lastRun}`}
</p>
</div>
</Link>
<button
type="button"
title="Run workflow"
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-mint/20 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-white"
>
<PlayIcon className="size-3.5" />
</button>
{workflow.schedule ? (
<button
type="button"
title="Pause schedule"
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-amber/20 text-amber transition-colors hover:border-amber/50 hover:bg-amber/10 hover:text-white"
>
<PauseIcon className="size-3.5" />
</button>
) : (
<button
type="button"
title="Run workflow"
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-mint/20 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-white"
>
<PlayIcon className="size-3.5" />
</button>
)}
<button
type="button"
@ -127,25 +153,45 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
);
}
type TriggerFilter = "all" | "scheduled" | "manual";
export default function Workflows() {
const [query, setQuery] = useState("");
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("all");
const filtered = workflows.filter(
(w) =>
w.name.toLowerCase().includes(query.toLowerCase()) ||
w.filename.toLowerCase().includes(query.toLowerCase()),
(triggerFilter === "all" ||
(triggerFilter === "scheduled" && w.schedule != null) ||
(triggerFilter === "manual" && w.schedule == null)) &&
(w.name.toLowerCase().includes(query.toLowerCase()) ||
w.filename.toLowerCase().includes(query.toLowerCase())),
);
return (
<div className="space-y-4">
<div className="relative">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
<input
type="text"
placeholder="Search workflows..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-9 pr-3 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
/>
<div className="flex gap-3">
<div className="relative flex-1">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
<input
type="text"
placeholder="Search workflows..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-9 pr-3 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
/>
</div>
<div className="relative">
<select
value={triggerFilter}
onChange={(e) => setTriggerFilter(e.target.value as TriggerFilter)}
className="appearance-none rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-3 pr-8 text-sm text-ice-100 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
>
<option value="all">All triggers</option>
<option value="scheduled">Scheduled</option>
<option value="manual">Manual</option>
</select>
<ChevronDownOutline className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
</div>
</div>
<div className="space-y-3">
{filtered.map((workflow) => (

4331
apps/arc-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff