diff --git a/minions-clone/.env.example b/minions-clone/.env.example new file mode 100644 index 0000000..6eabb69 --- /dev/null +++ b/minions-clone/.env.example @@ -0,0 +1,21 @@ +# Your Agent37 API key, from https://www.agent37.com/dashboard/cloud/api-keys +# This is a workspace-scoped secret. It lives only on the server; it never reaches the browser. +AGENT37_API_KEY= + +# Optional: pin the app to an instance you already created. If unset, the app provisions one +# shared instance on first run (one container, billed ~$4.94/mo) and remembers its id in the DB. +# AGENT37_INSTANCE_ID= + +# Local SQLite database file (libSQL). The default is right for local dev. +DATABASE_URL=file:./minions.db + +# Optional overrides; the defaults are right for production. +# AGENT37_API_BASE=https://api.agent37.com +# AGENT37_APP_DOMAIN=agent37.app + +# The agent template to provision when bootstrapping the shared instance. +# agent37-hermes (full), agent37-hermes-small (lean, no browser), agent37-openclaw. +# AGENT37_TEMPLATE=agent37-hermes + +# One-time managed-LLM budget headroom (USD micros) applied when provisioning. $1 = 1000000. +# AGENT37_BUDGET_TOPUP_MICROS=1000000 diff --git a/minions-clone/.gitignore b/minions-clone/.gitignore new file mode 100644 index 0000000..5c81206 --- /dev/null +++ b/minions-clone/.gitignore @@ -0,0 +1,26 @@ +# dependencies +/node_modules + +# next.js +/.next/ +/out/ +next-env.d.ts +*.tsbuildinfo + +# production +/build + +# local database (SQLite via libSQL) +*.db +*.db-journal +*.db-wal +*.db-shm + +# env +.env +.env.local + +# misc +.DS_Store +*.pem +npm-debug.log* diff --git a/minions-clone/README.md b/minions-clone/README.md new file mode 100644 index 0000000..32d3343 --- /dev/null +++ b/minions-clone/README.md @@ -0,0 +1,80 @@ +# minions-clone + +An AI task-management app on the [Agent37](https://www.agent37.com/docs) Agents API. You write a task in plain language under **"What do you need done?"**, pick a model, priority, and mode, and hit go; Agent37 runs an agent that plans, uses tools, and works the task end-to-end while the detail page streams its reasoning, tool calls, and output live. The detail page is a **multi-turn chat thread**: the first prompt and the agent's reply open the conversation, and you send follow-ups in the **same Agent37 session**, so the agent keeps the context of the whole exchange. A task moves through **queued → running → ready_for_review → completed** — when a turn finishes it lands in _Ready for review_, and you click **Mark complete** to close it out. The **Tasks** page is a **3-column Kanban board** — _In Progress_ / _Ready for review_ / _Complete_ — that groups tasks by status, and you **drag cards** between and within columns to move them along; clicking a card opens its chat history. Titles are **auto-generated** from the first exchange and stay editable inline. A left sidebar holds New Task, Tasks, Recurring, Files, and Settings. Files you attach ride along to the agent and show up under Files, joined back to the task that used them. Clean, near-monochrome UI (think Vercel/Geist): white canvas, hairline borders, generous whitespace. + +## Architecture + +- **Next.js 15 (App Router) + TypeScript**, no extra UI framework. Pages that read data are Server Components; only interactive pieces are `'use client'`. +- **SQLite via libSQL + Drizzle** is a thin metadata/lifecycle layer. The DB owns only what Agent37 can't: task board metadata (title, status, priority, sort order) and the `session_id` / `response_id` pointers that join a task to its Agent37 session. **Agent37 owns the real session memory and the chat transcript** — the thread is read **live** from the session, not cached here. +- **Agent37 owns execution.** The whole app shares **one instance**, with **one session per task**. The first task provisions that instance once and remembers its id in the DB (or you pin it via env). +- **Clean layering:** controllers (`app/api/*` route handlers) → services (`server/services/*`) → gateway (`server/agent37/*`) + repositories (`server/db/*`). The browser talks only to our own `/api` routes through `lib/fetcher` (or raw `fetch` for streaming and uploads). +- **The `sk_live_` key lives only on the server.** `server/config.ts` and every `server/*` module import `server-only`, so the key can never be bundled into client code. + +## Folder structure + +``` +app/ App Router: pages (Server Components) + /api route handlers (controllers) +features/ Per-feature client-safe domain types (tasks, files, settings) + UI hooks +components/ ui/ (Button, Input, Select, Badge, EmptyState…) and layout/ (Sidebar, AppShell, PageHeader) +server/ Server-only. config, http error mapping, agent37/ gateway, db/ (schema, repositories), services/ +lib/ fetcher (typed client for our /api) and util (cn, timeAgo, titleFromPrompt) +``` + +## Quickstart + +You need an [Agent37 API key](https://www.agent37.com/dashboard/cloud/api-keys) and a funded [wallet](https://www.agent37.com/dashboard/cloud/billing) — the smallest instance is about **$4.94/mo**, billed a day at a time, so trying this costs cents. + +```bash +cp .env.example .env # paste your sk_live_ key into AGENT37_API_KEY +npm install +npm run dev # http://localhost:3000 +``` + +Open [http://localhost:3000](http://localhost:3000) (it redirects to **/tasks/new**), describe a task, and run it. + +- **First run provisions one shared instance.** Unless `AGENT37_INSTANCE_ID` is set, the first task creates one container and remembers its id in the DB. A cold instance can take a minute to warm up; the stream returns `instance_warming` and the UI retries until the agent answers. Provisioning grants the instance a managed-LLM budget (`AGENT37_BUDGET_TOPUP_MICROS`, default $1) — without it replies come back empty. +- **The database self-creates.** `server/db/index.ts` runs `CREATE TABLE IF NOT EXISTS` for every table on first access (memoized per process), so a fresh clone runs with no migration step or tooling. The local file is `minions.db`, overridable via `DATABASE_URL` (e.g. a `libsql://` Turso URL to move to a hosted DB). Drizzle is used as the typed query builder + schema-as-code; the table definitions in `server/db/schema.ts` are the single source of truth for the row types. + +## Tasks board + +The Tasks page renders a **3-column Kanban board** grouped by status via `columnForStatus`: + +- **In Progress** holds `queued` and `running`, plus the unhappy terminals `failed` / `cancelled` (those carry a small badge so they read as needing attention). +- **Ready for review** holds `ready_for_review`. +- **Complete** holds `completed`. + +Cards are **drag-and-drop** with **native HTML5** DnD (no libraries). Dropping a card into a **different** column is a status change — it `PATCH`es `{ status, sortOrder }`, where the destination maps via `statusForColumnDrop` (and _In Progress_ → `queued`, which **reopens** the task without re-running the agent). Reordering **within** a column persists position only — `PATCH { sortOrder }`. Position is the new `tasks.sort_order` column (descending: higher sits nearer the top), and moves are computed as a fractional value between the neighbouring cards. The board is **optimistic** — it moves the card locally first, then reverts and surfaces an inline error if the `PATCH` fails. Clicking a card opens the task's chat history. + +## API surface + +All routes return data as JSON directly (lists as arrays); errors are `{ error: { code, message, hint? } }` via `jsonError`. + +| Method | Route | Purpose | +| --- | --- | --- | +| `GET` | `/api/tasks` | List tasks (newest first). | +| `POST` | `/api/tasks` | Create a task from `{ prompt, priority, mode, model?, provider?, attachments? }`. | +| `GET` | `/api/tasks/[id]` | Get one task with its attachments and chat messages. | +| `PATCH` | `/api/tasks/[id]` | Update a task from `{ title?, status?, sortOrder? }` — rename it, move it on the board (`status` is `completed` / `ready_for_review` / `queued`, where `queued` **reopens** to _In Progress_ without re-running the agent), and/or reposition it (`sortOrder`); returns the updated task. | +| `DELETE` | `/api/tasks/[id]` | Delete a task. | +| `POST` | `/api/tasks/[id]/stream` | SSE stream of the turn: starts a queued task, or **reattaches** to a running one (replays every event so far, then stays live); persists status/output from the same stream. | +| `POST` | `/api/tasks/[id]/messages` | Send a follow-up turn from `{ input }` in the **same session**; returns the SSE stream of that turn (teed server-side to persist the assistant message and new status). | +| `POST` | `/api/tasks/[id]/title` | Auto-generate the task's title from its first exchange; idempotent (returns the existing title once generated or edited). Called once after the first turn completes. | +| `POST` | `/api/tasks/[id]/cancel` | Cancel a running turn (keeps the partial output). | +| `GET` | `/api/models` | Model options for the composer, from the instance's `GET /v1/models` (falls back to "Default" when no instance exists yet). | +| `GET` | `/api/files` | List uploaded files, joined to their task. | +| `POST` | `/api/files` | Multipart upload, forwarded to the instance; returns `{ path, filename, bytes }`. | +| `GET` | `/api/files/content?path=…` | Stream a file's bytes back from the instance. | +| `GET` | `/api/settings` | App settings (instance id, template, defaults, budget). | +| `PATCH` | `/api/settings` | Update default model/provider. | +| `POST` | `/api/settings/provision` | Provision the shared instance on demand. | + +Routes and pages that touch the DB / Agent37 at request time set `export const dynamic = 'force-dynamic'` (route handlers also `runtime = 'nodejs'`), so `next build` never executes them without a key. + +## Maps to Agent37 vs. v1 stubs + +- **Real:** task execution, live streaming, cancel, models, and files all map to the Agent37 Agent API on your instance (`/v1/responses`, `/v1/responses/{id}/stream`, `/v1/models`, `/v1/files`). Instance lifecycle uses the Hosting API (`/v1/instances`). +- **Stubs:** **Recurring** persists rows and lets the UI manage them, but nothing runs them on a schedule yet — the shape is here so a cron runner is purely additive. **Skills** is a placeholder under ADVANCED. The **Dictate** control cluster is decorative and inert. + +## On the API reference + +The authoritative Agent37 Agents API reference is **hosted, not vendored**: [agent37.com/docs](https://www.agent37.com/docs), and for coding agents [agent37.com/docs/llms-full.txt](https://www.agent37.com/docs/llms-full.txt). This repo carries only what's unique to the example. diff --git a/minions-clone/app/api/files/content/route.ts b/minions-clone/app/api/files/content/route.ts new file mode 100644 index 0000000..f2f4871 --- /dev/null +++ b/minions-clone/app/api/files/content/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { openFileDownload } from '@/server/services/files'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** Stream a file's bytes back to the browser as a download. */ +export async function GET(req: NextRequest) { + try { + const path = req.nextUrl.searchParams.get('path'); + if (!path) { + return NextResponse.json( + { error: { code: 'invalid_request', message: 'No path provided.' } }, + { status: 400 }, + ); + } + const upstream = await openFileDownload(path, req.signal); + const filename = path.split('/').pop() ?? 'download'; + return new Response(upstream.body, { + headers: { + 'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream', + 'content-disposition': `attachment; filename="${filename}"`, + 'cache-control': 'no-store', + }, + }); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/files/route.ts b/minions-clone/app/api/files/route.ts new file mode 100644 index 0000000..cec1f2d --- /dev/null +++ b/minions-clone/app/api/files/route.ts @@ -0,0 +1,33 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { listFiles, uploadToInstance } from '@/server/services/files'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** POST a multipart upload; the 'file' entry is forwarded to the shared instance. */ +export async function POST(req: NextRequest) { + try { + const form = await req.formData(); + if (!form.get('file')) { + return NextResponse.json( + { error: { code: 'invalid_request', message: 'No file provided.' } }, + { status: 400 }, + ); + } + const uploaded = await uploadToInstance(form); + return NextResponse.json(uploaded); + } catch (err) { + return jsonError(err); + } +} + +/** GET the list of uploaded attachments across all tasks. */ +export async function GET() { + try { + const files = await listFiles(); + return NextResponse.json(files); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/models/route.ts b/minions-clone/app/api/models/route.ts new file mode 100644 index 0000000..764f36a --- /dev/null +++ b/minions-clone/app/api/models/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { getModelOptions } from '@/server/services/tasks'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const options = await getModelOptions(); + return NextResponse.json(options); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/settings/provision/route.ts b/minions-clone/app/api/settings/provision/route.ts new file mode 100644 index 0000000..d86253c --- /dev/null +++ b/minions-clone/app/api/settings/provision/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { provisionInstanceNow } from '@/server/services/settings'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST() { + try { + const instanceId = await provisionInstanceNow(); + return NextResponse.json({ instanceId }); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/settings/route.ts b/minions-clone/app/api/settings/route.ts new file mode 100644 index 0000000..2dbbe05 --- /dev/null +++ b/minions-clone/app/api/settings/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getAppSettings, updateAppSettings } from '@/server/services/settings'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const patchSchema = z.object({ + defaultModel: z.string().nullable().optional(), + defaultProvider: z.string().nullable().optional(), +}); + +export async function GET() { + try { + const settings = await getAppSettings(); + return NextResponse.json(settings); + } catch (err) { + return jsonError(err); + } +} + +export async function PATCH(request: Request) { + try { + const body = await request.json().catch(() => ({})); + const parsed = patchSchema.parse(body); + const settings = await updateAppSettings(parsed); + return NextResponse.json(settings); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/tasks/[id]/cancel/route.ts b/minions-clone/app/api/tasks/[id]/cancel/route.ts new file mode 100644 index 0000000..6b9bf50 --- /dev/null +++ b/minions-clone/app/api/tasks/[id]/cancel/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { cancelTask } from '@/server/services/tasks'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const task = await cancelTask(id); + if (!task) { + return NextResponse.json( + { error: { code: 'task_not_found', message: 'No such task.' } }, + { status: 404 }, + ); + } + return NextResponse.json(task); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/tasks/[id]/messages/route.ts b/minions-clone/app/api/tasks/[id]/messages/route.ts new file mode 100644 index 0000000..8a2b437 --- /dev/null +++ b/minions-clone/app/api/tasks/[id]/messages/route.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { sendFollowup } from '@/server/services/tasks'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const BodySchema = z.object({ input: z.string().trim().min(1, 'A message is required.') }); + +// POST a follow-up turn in the task's session. Returns the SSE stream for that turn (the route +// tees it server-side to persist the assistant message and the new status). +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const { input } = BodySchema.parse(await req.json()); + const stream = await sendFollowup(id, input, req.signal); + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-store, no-transform', + connection: 'keep-alive', + }, + }); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/tasks/[id]/route.ts b/minions-clone/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..4a14127 --- /dev/null +++ b/minions-clone/app/api/tasks/[id]/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { deleteTask, getTaskDetail, renameTask, setTaskSortOrder, setTaskStatus } from '@/server/services/tasks'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const notFound = () => + NextResponse.json({ error: { code: 'task_not_found', message: 'No such task.' } }, { status: 404 }); + +const PatchSchema = z + .object({ + title: z.string().trim().min(1).max(120).optional(), + // 'queued' is the board's "reopen / move to In Progress" target (no agent re-run). + status: z.enum(['completed', 'ready_for_review', 'queued']).optional(), + sortOrder: z.number().finite().optional(), + }) + .refine((v) => v.title !== undefined || v.status !== undefined || v.sortOrder !== undefined, { + message: 'Provide a title, status, or sortOrder to update.', + }); + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const task = await getTaskDetail(id); + return task ? NextResponse.json(task) : notFound(); + } catch (err) { + return jsonError(err); + } +} + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const patch = PatchSchema.parse(await req.json()); + if (patch.title !== undefined) { + const renamed = await renameTask(id, patch.title); + if (!renamed) return notFound(); + } + if (patch.status !== undefined) { + // status + optional sortOrder together (a cross-column drag). + const updated = await setTaskStatus(id, patch.status, patch.sortOrder); + if (!updated) return notFound(); + } else if (patch.sortOrder !== undefined) { + // sortOrder alone (an intra-column reorder). + const updated = await setTaskSortOrder(id, patch.sortOrder); + if (!updated) return notFound(); + } + const task = await getTaskDetail(id); + return task ? NextResponse.json(task) : notFound(); + } catch (err) { + return jsonError(err); + } +} + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const deleted = await deleteTask(id); + return deleted ? NextResponse.json({ deleted: true }) : notFound(); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/tasks/[id]/stream/route.ts b/minions-clone/app/api/tasks/[id]/stream/route.ts new file mode 100644 index 0000000..ef652fc --- /dev/null +++ b/minions-clone/app/api/tasks/[id]/stream/route.ts @@ -0,0 +1,21 @@ +import { openTaskStream } from '@/server/services/tasks'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const stream = await openTaskStream(id, req.signal); + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-store, no-transform', + connection: 'keep-alive', + }, + }); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/tasks/[id]/title/route.ts b/minions-clone/app/api/tasks/[id]/title/route.ts new file mode 100644 index 0000000..797485f --- /dev/null +++ b/minions-clone/app/api/tasks/[id]/title/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { generateTaskTitle } from '@/server/services/tasks'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// POST to auto-generate the task's title from its first exchange. Idempotent: if a title was +// already generated or edited, the existing one is returned. The client calls this once after +// the first turn completes. +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const title = await generateTaskTitle(id); + if (title === null) { + return NextResponse.json({ error: { code: 'task_not_found', message: 'No such task.' } }, { status: 404 }); + } + return NextResponse.json({ title }); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/tasks/bulk-delete/route.ts b/minions-clone/app/api/tasks/bulk-delete/route.ts new file mode 100644 index 0000000..7a9ee7a --- /dev/null +++ b/minions-clone/app/api/tasks/bulk-delete/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { deleteTasksInColumn } from '@/server/services/tasks'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const BodySchema = z.object({ + column: z.enum(['in_progress', 'ready_for_review', 'complete']), +}); + +// POST { column } -> delete every task in that board column. Returns { deleted: count }. +export async function POST(req: Request) { + try { + const { column } = BodySchema.parse(await req.json()); + const deleted = await deleteTasksInColumn(column); + return NextResponse.json({ deleted }); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/api/tasks/route.ts b/minions-clone/app/api/tasks/route.ts new file mode 100644 index 0000000..c9d6aa5 --- /dev/null +++ b/minions-clone/app/api/tasks/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createTask, listTasks } from '@/server/services/tasks'; +import { jsonError } from '@/server/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const tasks = await listTasks(); + return NextResponse.json(tasks); + } catch (err) { + return jsonError(err); + } +} + +const attachmentSchema = z.object({ + path: z.string().min(1), + filename: z.string().min(1), + bytes: z.number(), +}); + +const createTaskSchema = z.object({ + prompt: z.string().min(1, 'A prompt is required.'), + priority: z.enum(['low', 'medium', 'high']), + mode: z.enum(['goal', 'ask']), + model: z.string().nullable().optional(), + provider: z.string().nullable().optional(), + attachments: z.array(attachmentSchema).optional(), +}); + +export async function POST(req: Request) { + try { + const body = await req.json(); + const parsed = createTaskSchema.parse(body); + const task = await createTask(parsed); + return NextResponse.json(task, { status: 201 }); + } catch (err) { + return jsonError(err); + } +} diff --git a/minions-clone/app/files/page.tsx b/minions-clone/app/files/page.tsx new file mode 100644 index 0000000..2b8355c --- /dev/null +++ b/minions-clone/app/files/page.tsx @@ -0,0 +1,18 @@ +import { PageHeader } from '@/components/layout/PageHeader'; +import { FilesList } from '@/features/files/components/FilesList'; +import { listFiles } from '@/server/services/files'; + +export const dynamic = 'force-dynamic'; + +export default async function FilesPage() { + const files = await listFiles(); + + return ( + <> + +
+ +
+ + ); +} diff --git a/minions-clone/app/globals.css b/minions-clone/app/globals.css new file mode 100644 index 0000000..4c71f93 --- /dev/null +++ b/minions-clone/app/globals.css @@ -0,0 +1,89 @@ +@import 'tailwindcss'; + +/* + Design tokens for the minions-clone UI. The reference is a restrained, near-monochrome + surface: white canvas, near-black ink, hairline gray borders, generous whitespace, and a + single soft-gray "active" wash on the selected nav row. Everything below is expressed as + Tailwind v4 @theme tokens so it shows up as utilities (bg-background, text-foreground, + border-border, rounded-xl, etc.). +*/ +@theme { + --color-background: #ffffff; + --color-foreground: #0a0a0a; + + --color-card: #ffffff; + --color-card-foreground: #0a0a0a; + + /* Soft gray washes for hover/active rows and chips. */ + --color-muted: #f4f4f5; + --color-muted-foreground: #71717a; + --color-subtle: #a1a1aa; + + --color-accent: #f4f4f5; + --color-accent-foreground: #0a0a0a; + + --color-border: #ececec; + --color-input: #e4e4e7; + --color-ring: #d4d4d8; + + /* Status accents for task chips/badges (kept muted to fit the palette). */ + --color-success: #16a34a; + --color-running: #2563eb; + --color-danger: #dc2626; + --color-warning: #d97706; + + --radius-lg: 0.875rem; + --radius-xl: 1rem; + --radius-2xl: 1.25rem; + + --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, + 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', monospace; +} + +@layer base { + * { + border-color: var(--color-border); + } + + html, + body { + height: 100%; + } + + body { + background-color: var(--color-background); + color: var(--color-foreground); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + + /* Thin, unobtrusive scrollbars to match the minimal aesthetic. */ + * { + scrollbar-width: thin; + scrollbar-color: var(--color-input) transparent; + } + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + *::-webkit-scrollbar-thumb { + background-color: var(--color-input); + border-radius: 9999px; + } +} + +/* A blinking caret used by the streaming view while tokens arrive. */ +@keyframes caret-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} +.animate-caret { + animation: caret-blink 1s step-end infinite; +} diff --git a/minions-clone/app/layout.tsx b/minions-clone/app/layout.tsx new file mode 100644 index 0000000..5142d92 --- /dev/null +++ b/minions-clone/app/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; +import './globals.css'; +import { AppShell } from '@/components/layout/AppShell'; + +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', +}); + +export const metadata: Metadata = { + title: 'Minions', + description: 'AI task management on Agent37', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/minions-clone/app/page.tsx b/minions-clone/app/page.tsx new file mode 100644 index 0000000..4aee057 --- /dev/null +++ b/minions-clone/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function HomePage() { + redirect('/tasks/new'); +} diff --git a/minions-clone/app/recurring/page.tsx b/minions-clone/app/recurring/page.tsx new file mode 100644 index 0000000..4ba65e7 --- /dev/null +++ b/minions-clone/app/recurring/page.tsx @@ -0,0 +1,22 @@ +import { Repeat2 } from 'lucide-react'; +import { PageHeader } from '@/components/layout/PageHeader'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Button } from '@/components/ui/Button'; + +export default function RecurringPage() { + return ( + <> + + + New recurring task + + } + /> + + ); +} diff --git a/minions-clone/app/settings/page.tsx b/minions-clone/app/settings/page.tsx new file mode 100644 index 0000000..d6a6a88 --- /dev/null +++ b/minions-clone/app/settings/page.tsx @@ -0,0 +1,26 @@ +import { PageHeader } from '@/components/layout/PageHeader'; +import { SettingsForm } from '@/features/settings/components/SettingsForm'; +import { getAppSettings } from '@/server/services/settings'; +import { getModelOptions } from '@/server/services/tasks'; +import type { TaskModelOption } from '@/features/tasks/types'; + +export const dynamic = 'force-dynamic'; + +export default async function SettingsPage() { + const settings = await getAppSettings(); + let models: TaskModelOption[] = []; + try { + models = await getModelOptions(); + } catch { + models = []; + } + + return ( + <> + +
+ +
+ + ); +} diff --git a/minions-clone/app/skills/page.tsx b/minions-clone/app/skills/page.tsx new file mode 100644 index 0000000..034c615 --- /dev/null +++ b/minions-clone/app/skills/page.tsx @@ -0,0 +1,16 @@ +import { Sparkles } from 'lucide-react'; +import { PageHeader } from '@/components/layout/PageHeader'; +import { EmptyState } from '@/components/ui/EmptyState'; + +export default function SkillsPage() { + return ( + <> + + + + ); +} diff --git a/minions-clone/app/tasks/[id]/page.tsx b/minions-clone/app/tasks/[id]/page.tsx new file mode 100644 index 0000000..2af4f10 --- /dev/null +++ b/minions-clone/app/tasks/[id]/page.tsx @@ -0,0 +1,18 @@ +import { notFound } from 'next/navigation'; +import { PageHeader } from '@/components/layout/PageHeader'; +import { TaskDetail } from '@/features/tasks/components/TaskDetail'; +import { getTaskDetail } from '@/server/services/tasks'; + +export const dynamic = 'force-dynamic'; + +export default async function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const task = await getTaskDetail(id); + if (!task) notFound(); + return ( +
+ + +
+ ); +} diff --git a/minions-clone/app/tasks/new/page.tsx b/minions-clone/app/tasks/new/page.tsx new file mode 100644 index 0000000..ee2c752 --- /dev/null +++ b/minions-clone/app/tasks/new/page.tsx @@ -0,0 +1,15 @@ +import { PageHeader } from '@/components/layout/PageHeader'; +import { TaskComposer } from '@/features/tasks/components/TaskComposer'; + +export default function NewTaskPage() { + return ( + <> + +
+
+ +
+
+ + ); +} diff --git a/minions-clone/app/tasks/page.tsx b/minions-clone/app/tasks/page.tsx new file mode 100644 index 0000000..ad85118 --- /dev/null +++ b/minions-clone/app/tasks/page.tsx @@ -0,0 +1,14 @@ +import { TaskBoard } from '@/features/tasks/components/TaskBoard'; +import { listTasks } from '@/server/services/tasks'; + +export const dynamic = 'force-dynamic'; + +/** The Tasks page IS the Kanban board — it renders its own "Tasks" title (no breadcrumb header). */ +export default async function TasksPage() { + const tasks = await listTasks(); + return ( +
+ +
+ ); +} diff --git a/minions-clone/components/layout/AppShell.tsx b/minions-clone/components/layout/AppShell.tsx new file mode 100644 index 0000000..f1801fc --- /dev/null +++ b/minions-clone/components/layout/AppShell.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react'; +import { Sidebar } from '@/components/layout/Sidebar'; + +/** The two-pane application frame: fixed sidebar + scrollable main content. */ +export function AppShell({ children }: { children: ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/minions-clone/components/layout/Logo.tsx b/minions-clone/components/layout/Logo.tsx new file mode 100644 index 0000000..f21b9b2 --- /dev/null +++ b/minions-clone/components/layout/Logo.tsx @@ -0,0 +1,38 @@ +/** + * The Minions wordmark mark: two overlapping rounded squares, slightly rotated, + * drawn in currentColor so it inherits the near-black foreground ink. + */ +export function Logo({ className }: { className?: string }) { + return ( + + ); +} diff --git a/minions-clone/components/layout/PageHeader.tsx b/minions-clone/components/layout/PageHeader.tsx new file mode 100644 index 0000000..cc60e61 --- /dev/null +++ b/minions-clone/components/layout/PageHeader.tsx @@ -0,0 +1,53 @@ +import { Fragment, type ReactNode } from 'react'; +import Link from 'next/link'; +import { ChevronRight } from 'lucide-react'; + +export interface BreadcrumbItem { + label: string; + href?: string; +} + +export interface PageHeaderProps { + items: BreadcrumbItem[]; + actions?: ReactNode; +} + +/** The top breadcrumb bar that sits above each page's content. */ +export function PageHeader({ items, actions }: PageHeaderProps) { + return ( +
+ + {actions ?
{actions}
: null} +
+ ); +} diff --git a/minions-clone/components/layout/Sidebar.tsx b/minions-clone/components/layout/Sidebar.tsx new file mode 100644 index 0000000..0285b97 --- /dev/null +++ b/minions-clone/components/layout/Sidebar.tsx @@ -0,0 +1,110 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { + Columns3, + Folder, + PanelLeftClose, + Repeat2, + Settings, + Sparkles, + SquarePen, + type LucideIcon, +} from 'lucide-react'; +import { cn } from '@/lib/util'; +import { IconButton } from '@/components/ui/IconButton'; +import { Logo } from '@/components/layout/Logo'; + +interface NavItem { + href: string; + label: string; + icon: LucideIcon; +} + +const MAIN_ITEMS: NavItem[] = [ + { href: '/tasks/new', label: 'New Task', icon: SquarePen }, + { href: '/tasks', label: 'Tasks', icon: Columns3 }, + { href: '/recurring', label: 'Recurring', icon: Repeat2 }, + { href: '/files', label: 'Files', icon: Folder }, +]; + +const ADVANCED_ITEMS: NavItem[] = [ + { href: '/skills', label: 'Skills', icon: Sparkles }, +]; + +const SETTINGS_ITEM: NavItem = { href: '/settings', label: 'Settings', icon: Settings }; + +/** + * Decide whether a nav row is the active one for the current pathname. + * '/tasks/new' matches only exactly; '/tasks' matches the list and any + * '/tasks/' detail page but never '/tasks/new'; everything else is an + * exact-or-prefix match. + */ +function isActive(href: string, pathname: string): boolean { + if (href === '/tasks/new') return pathname === '/tasks/new'; + if (href === '/tasks') { + return pathname === '/tasks' || (pathname.startsWith('/tasks/') && pathname !== '/tasks/new'); + } + return pathname === href || pathname.startsWith(`${href}/`); +} + +function NavRow({ item, pathname }: { item: NavItem; pathname: string }) { + const active = isActive(item.href, pathname); + const Icon = item.icon; + return ( + + + {item.label} + + ); +} + +/** The fixed left navigation column. */ +export function Sidebar() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/minions-clone/components/ui/Badge.tsx b/minions-clone/components/ui/Badge.tsx new file mode 100644 index 0000000..90a131a --- /dev/null +++ b/minions-clone/components/ui/Badge.tsx @@ -0,0 +1,31 @@ +import { type HTMLAttributes } from 'react'; +import { cn } from '@/lib/util'; + +type Variant = 'muted' | 'success' | 'running' | 'danger' | 'warning' | 'review'; + +export interface BadgeProps extends HTMLAttributes { + variant?: Variant; +} + +const variants: Record = { + muted: 'bg-muted text-muted-foreground', + success: 'bg-success/10 text-success', + running: 'bg-running/10 text-running', + danger: 'bg-danger/10 text-danger', + warning: 'bg-warning/10 text-warning', + review: 'bg-violet-50 text-violet-600', +}; + +/** A small status pill — soft alpha washes for the colored variants. */ +export function Badge({ className, variant = 'muted', ...props }: BadgeProps) { + return ( + + ); +} diff --git a/minions-clone/components/ui/Button.tsx b/minions-clone/components/ui/Button.tsx new file mode 100644 index 0000000..bccf43c --- /dev/null +++ b/minions-clone/components/ui/Button.tsx @@ -0,0 +1,49 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react'; +import { cn } from '@/lib/util'; + +type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'; +type Size = 'sm' | 'md' | 'lg' | 'icon'; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; +} + +const variants: Record = { + primary: 'bg-foreground text-background hover:bg-foreground/90', + secondary: 'border border-input bg-background hover:bg-muted', + ghost: 'hover:bg-muted text-foreground', + danger: 'border border-input bg-background text-danger hover:bg-danger/5', +}; + +const sizes: Record = { + sm: 'h-8 px-3 text-sm rounded-lg gap-1.5', + md: 'h-9 px-4 text-sm rounded-lg gap-2', + lg: 'h-11 px-5 text-base rounded-xl gap-2', + icon: 'h-9 w-9 rounded-lg', +}; + +/** + * The base button. Presentational and unstyled-opinionated to match the monochrome reference: + * a near-black "primary", hairline-bordered "secondary", and a quiet "ghost". + */ +export const Button = forwardRef(function Button( + { className, variant = 'secondary', size = 'md', type = 'button', ...props }, + ref, +) { + return ( + + ); +}); diff --git a/minions-clone/components/ui/Input.tsx b/minions-clone/components/ui/Input.tsx new file mode 100644 index 0000000..aef5c30 --- /dev/null +++ b/minions-clone/components/ui/Input.tsx @@ -0,0 +1,24 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { cn } from '@/lib/util'; + +export type InputProps = InputHTMLAttributes; + +/** A styled single-line text input — hairline border, soft focus ring. */ +export const Input = forwardRef(function Input( + { className, ...props }, + ref, +) { + return ( + + ); +}); diff --git a/minions-clone/components/ui/ModelPicker.tsx b/minions-clone/components/ui/ModelPicker.tsx new file mode 100644 index 0000000..d6cb559 --- /dev/null +++ b/minions-clone/components/ui/ModelPicker.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ChevronDown, Search, type LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/util'; + +export interface ModelPickerOption { + id: string; + label: string; + provider: string | null; + isDefault: boolean; +} + +export interface ModelPickerProps { + value: string; + onChange: (value: string) => void; + options: ModelPickerOption[]; + leadingIcon?: LucideIcon; + ariaLabel: string; + size?: 'sm' | 'md'; + /** Shown as the provider group name for options with no provider of their own. */ + fallbackProvider?: string; +} + +const sizes: Record, string> = { + sm: 'h-8 px-2.5 text-xs', + md: 'h-9 px-3 text-sm', +}; + +/** + * A command-palette-style model picker: a pill trigger (matching the other composer selects) + * that opens a two-pane popover — providers on the left with model counts, a search box and + * the matching models on the right. Closes on select, outside click, or Escape. + */ +export function ModelPicker({ + value, + onChange, + options, + leadingIcon: LeadingIcon, + ariaLabel, + size = 'md', + fallbackProvider = 'Agent37', +}: ModelPickerProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [activeProvider, setActiveProvider] = useState(null); + const rootRef = useRef(null); + const searchRef = useRef(null); + + const selected = options.find((o) => o.id === value) ?? options[0]; + + // Group options under a provider name, falling back for provider-less options. + const groups = useMemo(() => { + const map = new Map(); + for (const opt of options) { + const name = opt.provider ?? fallbackProvider; + const list = map.get(name) ?? []; + list.push(opt); + map.set(name, list); + } + return Array.from(map.entries()).map(([name, items]) => ({ name, items })); + }, [options, fallbackProvider]); + + const q = query.trim().toLowerCase(); + const matches = (opt: ModelPickerOption, providerName: string) => + !q || + opt.id.toLowerCase().includes(q) || + opt.label.toLowerCase().includes(q) || + providerName.toLowerCase().includes(q); + + // Per-provider match counts, then drop empty providers while searching. + const visibleGroups = useMemo( + () => + groups + .map((g) => ({ ...g, items: g.items.filter((opt) => matches(opt, g.name)) })) + .filter((g) => g.items.length > 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [groups, q], + ); + + // Resolve which provider pane is shown: keep the active one if it still has matches, + // otherwise fall back to the selected option's provider, otherwise the first group. + const currentProviderName = + visibleGroups.find((g) => g.name === activeProvider)?.name ?? + visibleGroups.find((g) => g.items.some((o) => o.id === value))?.name ?? + visibleGroups[0]?.name ?? + null; + + const currentItems = visibleGroups.find((g) => g.name === currentProviderName)?.items ?? []; + + useEffect(() => { + if (!open) return; + + function onPointerDown(event: PointerEvent) { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setOpen(false); + } + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setOpen(false); + } + + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + // Reset transient state and focus the search box each time the popover opens. + useEffect(() => { + if (!open) return; + setQuery(''); + setActiveProvider(selected?.provider ?? null); + const id = window.setTimeout(() => searchRef.current?.focus(), 0); + return () => window.clearTimeout(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const choose = (id: string) => { + onChange(id); + setOpen(false); + }; + + return ( +
+ + + {open ? ( +
+ {/* Left: providers with model counts. */} +
    + {visibleGroups.map((g) => { + const isActive = g.name === currentProviderName; + return ( +
  • + +
  • + ); + })} + {visibleGroups.length === 0 ? ( +
  • No matches
  • + ) : null} +
+ + {/* Right: search + matching models. */} +
+
+
+ + setQuery(e.target.value)} + placeholder="Search models or providers..." + className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" + /> +
+
+ +
    + {currentItems.map((opt) => { + const isSelected = opt.id === value; + return ( +
  • + +
  • + ); + })} + {currentItems.length === 0 ? ( +
  • No models found
  • + ) : null} +
+
+
+ ) : null} +
+ ); +} diff --git a/minions-clone/components/ui/Select.tsx b/minions-clone/components/ui/Select.tsx new file mode 100644 index 0000000..752c487 --- /dev/null +++ b/minions-clone/components/ui/Select.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Check, ChevronDown, type LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/util'; + +export interface SelectOption { + value: string; + label: string; + icon?: LucideIcon; +} + +export interface SelectProps { + value: string; + onChange: (value: string) => void; + options: SelectOption[]; + leadingIcon?: LucideIcon; + ariaLabel: string; + size?: 'sm' | 'md'; +} + +const sizes: Record, string> = { + sm: 'h-8 px-2.5 text-xs', + md: 'h-9 px-3 text-sm', +}; + +/** A custom dropdown styled as a pill — opens a popover list, closes on outside click / Escape. */ +export function Select({ + value, + onChange, + options, + leadingIcon: LeadingIcon, + ariaLabel, + size = 'md', +}: SelectProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + const selected = options.find((o) => o.value === value) ?? options[0]; + + useEffect(() => { + if (!open) return; + + function onPointerDown(event: PointerEvent) { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setOpen(false); + } + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setOpen(false); + } + + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + return ( +
+ + + {open ? ( +
+ {options.map((option) => { + const OptionIcon = option.icon; + const isSelected = option.value === value; + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/minions-clone/components/ui/Skeleton.tsx b/minions-clone/components/ui/Skeleton.tsx new file mode 100644 index 0000000..9d40a15 --- /dev/null +++ b/minions-clone/components/ui/Skeleton.tsx @@ -0,0 +1,10 @@ +import { cn } from '@/lib/util'; + +export interface SkeletonProps { + className?: string; +} + +/** A shimmering placeholder block for loading states. */ +export function Skeleton({ className }: SkeletonProps) { + return
; +} diff --git a/minions-clone/components/ui/Spinner.tsx b/minions-clone/components/ui/Spinner.tsx new file mode 100644 index 0000000..424ed22 --- /dev/null +++ b/minions-clone/components/ui/Spinner.tsx @@ -0,0 +1,11 @@ +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/util'; + +export interface SpinnerProps { + className?: string; +} + +/** A quiet spinning loader for inline/pending states. */ +export function Spinner({ className }: SpinnerProps) { + return ; +} diff --git a/minions-clone/components/ui/Textarea.tsx b/minions-clone/components/ui/Textarea.tsx new file mode 100644 index 0000000..5c4bb91 --- /dev/null +++ b/minions-clone/components/ui/Textarea.tsx @@ -0,0 +1,24 @@ +import { forwardRef, type TextareaHTMLAttributes } from 'react'; +import { cn } from '@/lib/util'; + +export type TextareaProps = TextareaHTMLAttributes; + +/** A styled multi-line text input — matches Input, grows to a min height. */ +export const Textarea = forwardRef(function Textarea( + { className, ...props }, + ref, +) { + return ( +