mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
90dc528c99
commit
2eebb00ab1
7 changed files with 5090 additions and 25 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
360
apps/arc-web/app/routes/session-detail.tsx
Normal file
360
apps/arc-web/app/routes/session-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
4331
apps/arc-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue