feat(swarm): implement Swarm View remake with Operations, Archetypes, and Templates

This commit includes the new SwarmWorkspace with its 3 sub-tabs, the LeftPanel mission picker, and the comprehensive Operations Command Dashboard featuring the live interactive DAG telemetry and task assignment prep flow.
This commit is contained in:
zenchantlive 2026-02-20 22:19:38 -08:00
parent 409a7e7256
commit dfaf523029
74 changed files with 11066 additions and 2046 deletions

View file

@ -22,6 +22,14 @@ bd close <id> --reason "<what was completed>"
bd sync
```
## Bead Prompting Standard
1. When creating or rewriting bead details, follow `docs/protocols/bead-prompting.md`.
2. Bead descriptions must be model-facing prompts, not internal prose notes.
3. Include explicit `Scope` and `Out of Scope` in every bead.
4. Treat `Success Criteria` as the completion contract.
5. Keep dependency flow minimal and execution-correct.
## Start-of-Task Protocol
1. Read the target bead and acceptance criteria (`bd show <id>`).

122
components/ui/dialog.tsx Normal file
View file

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

26
components/ui/label.tsx Normal file
View file

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

159
components/ui/select.tsx Normal file
View file

@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
position === "popper" &&
"data-[state=open]:slide-in-from-top-2 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

55
components/ui/tabs.tsx Normal file
View file

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View file

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View file

@ -0,0 +1,224 @@
# Swarm Page Redesign
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace label-based swarm grouping with proper `bd swarm` orchestration UI showing epic-level work coordination.
**Architecture:** Query `bd swarm list/status/validate` directly via new API routes. Swarm cards show computed progress from epic DAGs. Activity panel filters to selected swarm's epic children.
**Tech Stack:** Next.js API routes, existing BD CLI, reuse ActivityPanel and card patterns.
---
## Background: How `bd swarm` Actually Works
### NOT Labels
The current UI incorrectly uses `swarm:*` labels to group agents. `bd swarm` does NOT use labels.
### How It Actually Works
**Create:**
```bash
bd swarm create <epic-id>
```
Creates a **molecule bead** with `issue_type: "molecule"` and `mol_type: "swarm"`:
```json
{
"id": "codex-f4r",
"title": "Swarm: Test Epic",
"issue_type": "molecule",
"mol_type": "swarm",
"dependencies": [{ "id": "codex-dq9", "dependency_type": "relates-to" }]
}
```
**Status (computed, not stored):**
```bash
bd swarm status <epic-or-swarm-id> --json
```
Returns:
```json
{
"epic_id": "codex-dq9",
"epic_title": "Test Epic",
"completed": [...],
"active": [...], // in_progress issues
"ready": [...], // unblocked, open issues
"blocked": [...], // waiting on dependencies
"progress_percent": 40
}
```
**List:**
```bash
bd swarm list --json
```
Returns:
```json
{
"swarms": [{
"id": "codex-f4r",
"title": "Swarm: Test Epic",
"epic_id": "codex-dq9",
"epic_title": "Test Epic",
"status": "open",
"coordinator": "",
"total_issues": 5,
"completed_issues": 2,
"active_issues": 1,
"progress_percent": 40
}]
}
```
**Validate:**
```bash
bd swarm validate <epic-id> --verbose
```
Returns DAG quality, ready fronts, max parallelism.
---
## Data Contract
### GET /api/swarm/list
Returns `bd swarm list --json` output directly.
### GET /api/swarm/status?epic=<epic-id>
Returns `bd swarm status <epic> --json` output.
### POST /api/swarm/create
Body: `{ "epicId": "codex-dq9", "coordinator": "witness" (optional) }`
Returns created swarm molecule.
### POST /api/swarm/close
Body: `{ "swarmId": "codex-f4r" }`
Closes the swarm molecule.
---
## Page Layout
**Existing structure (unified-shell.tsx) - no changes to layout:**
```
┌─────────────────────────────────────────────────────────────────────┐
│ TOP BAR │
├──────────────┬────────────────────────────────┬─────────────────────┤
│ LEFT PANEL │ MIDDLE CONTENT │ RIGHT PANEL │
│ (filters) │ (SwarmPage cards) │ (ActivityPanel) │
│ │ │ ← filter by swarm │
│ │ [swarm cards grid] │ │
│ │ │ │
├──────────────┴────────────────────────────────┴─────────────────────┤
│ MOBILE NAV │
└─────────────────────────────────────────────────────────────────────┘
```
**SwarmPage:**
- Header with title + "Create Swarm" button
- Responsive cards grid (1-4 columns)
- Click card → sets `swarmId` in URL → right panel filters to that swarm
---
## Swarm Card Design
```
┌─────────────────────────────────────┐
│ 🐝 Swarm: Feature Auth │ ← title from swarm molecule
│ codex-f4r │ ← swarm molecule ID
│ ───────────────────────────────── │
│ Progress ████████░░░░ 40% │ ← progress_percent
│ Epic: codex-dq9 │ ← linked epic ID
│ ───────────────────────────────── │
│ ✓ 2 completed │ ← completed count
│ ▶ 1 active │ ← in_progress count
│ ⏳ 2 ready │ ← unblocked, open
│ ⚠ 1 blocked │ ← waiting on deps
│ ───────────────────────────────── │
│ Coordinator: (none) │ ← optional coordinator
│ Last: 5 minutes ago │ ← most recent update
└─────────────────────────────────────┘
```
**Mini-DAG (optional, expandable):**
- Show just the ready/front nodes as clickable badges
- "Ready to pick up: Task A, Task B"
---
## Implementation Phases
### Phase 1: API Layer
**Files to create:**
- `src/app/api/swarm/list/route.ts` - wraps `bd swarm list --json`
- `src/app/api/swarm/status/route.ts` - wraps `bd swarm status <epic> --json`
- `src/app/api/swarm/create/route.ts` - wraps `bd swarm create <epic>`
- `src/app/api/swarm/close/route.ts` - wraps `bd close <swarm-id>`
**Pattern:** Follow existing `/api/beads/...` routes. Use `runBdCommand` from `src/lib/bridge.ts`.
### Phase 2: UI Layer
**Files to modify:**
- `src/lib/swarm-cards.ts` → replace with API calls or repurpose
- `src/components/swarm/swarm-page.tsx` → fetch from API, add create button
- `src/components/swarm/swarm-card.tsx` → new layout with status metrics
**Files to create:**
- `src/components/swarm/create-swarm-dialog.tsx` - form to create new swarm
### Phase 3: Activity Filter
**Files to modify:**
- `src/components/activity/activity-panel.tsx` - add `filterByEpicId?: string` prop
- `src/components/shared/unified-shell.tsx` - pass epicId when swarm selected
**Logic:** When `swarmId` is set, look up the swarm's epic_id and pass to ActivityPanel.
### Phase 4: Create Flow
**Entry points:**
1. Swarm page empty state → "Create Swarm" button
2. (Future) Epic detail page → "Create Swarm" action
**Dialog:**
- Dropdown to select epic (from `bd list --type=epic`)
- Optional: assign coordinator (from agent list)
- Submit → `POST /api/swarm/create`
---
## Files Summary
### New Files
- `src/app/api/swarm/list/route.ts`
- `src/app/api/swarm/status/route.ts`
- `src/app/api/swarm/create/route.ts`
- `src/app/api/swarm/close/route.ts`
- `src/components/swarm/create-swarm-dialog.tsx`
### Modified Files
- `src/lib/swarm-cards.ts` - refactor for API data or deprecate
- `src/components/swarm/swarm-page.tsx` - use API, add create
- `src/components/swarm/swarm-card.tsx` - new card layout
- `src/components/activity/activity-panel.tsx` - add filter prop
- `src/components/shared/unified-shell.tsx` - wire epic filter
### Tests
- `tests/app/api/swarm/*.test.ts`
- Update `tests/components/swarm/*.test.tsx`
- Update `tests/lib/swarm-cards.test.ts`
---
## Success Criteria
1. Swarm page shows swarms from `bd swarm list` (not labels)
2. Cards display progress, active/ready/blocked counts
3. Clicking swarm filters ActivityPanel to epic children
4. "Create Swarm" dialog works from empty state
5. Existing tests updated, new tests for API routes
6. No `swarm:*` label usage in swarm page logic

View file

@ -0,0 +1,96 @@
# Visual Truth Spec (Command Grid + Thread Takeover)
## Source Of Truth
- Target A: Command Grid shell screenshot.
- Target B: Open thread takeover screenshot.
- Current app screenshots are validation artifacts only.
## Geometry Contract (Desktop)
- App viewport composition:
- Top bar height: `60px`
- Main region: `calc(100vh - 60px)`
- Columns:
- Left rail: `256px`
- Center: fluid
- Right rail: `332px`
- Dividers:
- 1px hard separators between rails/center.
- Center content max width:
- `1080px` on 1920 viewport.
- `960px` on 1440 viewport.
## Top Bar Contract (Target A)
- Left brand block:
- Icon tile `32x32`, label stack (`COMMAND GRID`, version line).
- Metric tiles (equal height, hard borders):
- `TOTAL TASKS`, `CRITICAL ALERTS`, `IDLE`, `BUSY`.
- Primary actions:
- `BLOCKED ITEMS` outlined red pill.
- `NEW TASK` filled green pill.
- No tab switcher in top bar.
## Left Rail Contract (Target A)
- Header text: `NAVIGATION / EPICS` mono smallcaps.
- Epic tree rows:
- 36px row rhythm.
- Nested children at +16px indent.
- Footer user identity block anchored bottom.
## Center Feed Contract (Target A)
- Section headers:
- `READY`, `IN PROGRESS`, `BLOCKED`, mono uppercase with count chips.
- Cards:
- Radius `14px`, panel fill darker than shell.
- Header row: status chip, priority, title, id.
- Summary text 2-3 lines.
- Dependency sub-panel for `BLOCKED BY` / `UNBLOCKS`.
- Assignee row with avatar.
- Footer with stage text + compact actions.
## Right Rail Contract (Target A)
- Upper block:
- `AGENT POOL MONITOR` with compact rows.
- Lower block:
- `ACTIVITY / BLOCKERS FEED` timeline rows.
- Single vertical divider between center and rail.
## Thread Takeover Contract (Target B)
- Center takeover frame:
- Max width `1120px`, radius `12px`.
- Header strip:
- Task id, status, title, edit/close actions.
- Summary row:
- Summary text + assignee + due date columns.
- Conversation area:
- Left incoming / right outgoing bubbles.
- Composer bar:
- Sticky bottom, rounded input + send CTA.
## Palette / Type Contract
- Base backgrounds:
- app `#070d16`
- shell `#0c1420`
- panel `#111c2a`
- card `#1a2431`
- Text:
- primary `#e8edf5`
- muted `#8f9caf`
- Accents:
- ready `#35d98f`
- blocked `#ff4c72`
- warning `#ffb24a`
- info `#35c9ff`
- Font stack:
- Sans: `Inter, Segoe UI, system-ui, sans-serif`
- Mono: `JetBrains Mono, Cascadia Code, monospace`
## Breakpoint Contract
- Desktop: `>= 1280px` full 3-column shell.
- Tablet: `768px-1279px` collapsible rails.
- Mobile: `< 768px` bottom nav + full-screen takeover.
## Acceptance
- Pixel-close to target hierarchy and rhythm at:
- `1920x1080`
- `1440x1024`
- `390x844`

View file

@ -120,23 +120,24 @@ This view only renders if a mission (Epic) is selected. It completely replaces t
* Mission Title, Epic ID, and overall health status.
* **Action Strip:** Buttons for "Summon Polecats" (assign agents based on a template), "Halt Swarm", and "Run Debrief".
* **Convoy Stepper:** The visual orchestration pipeline (Planning -> Deployment -> Execution -> Debrief).
* **The Telemetry Grid (Replacing the DAG):**
* **Card 1: Active Roster (Who is here?):** A list of all agents currently assigned to tasks within this epic. Displays their Name, Avatar (colored by Archetype), current Bead ID, and status (e.g., "Writing Code", "Waiting for API").
* **Card 2: Priority Attention (What is blocked?):** A focused feed of *only* the `blocked` beads or beads that require Human-In-The-Loop (HITL) intervention for this specific mission.
* **Card 3: Mission Metrics:** Simple burn-down stats or a mini-feed of the last 5 completed tasks to show velocity.
* **The Telemetry Grid (Hybrid Layout):**
* **Top/Left Pane (Specialized Agent DAG):** Unlike the generic global graph, this graph specifically visualizes *who* is doing *what*. Nodes should emphasize Agent Avatars and archetype colors. Edges should represent the flow of work between agents (e.g., Coder -> Reviewer) rather than just raw task dependencies.
* **Bottom/Right Pane (Metrics & Attention):**
* **Priority Attention (What is blocked?):** A highly focused feed of *only* the `blocked` beads or beads that require Human-In-The-Loop (HITL) intervention for this specific mission.
* **Mission Roster:** A quick summary of active agents.
### 4.3 Tab 2: `<ArchetypesArmory />`
* **Layout:** CSS Grid of archetype cards.
* **Card Design:** Shows Name, Color badge, Capabilities tags, and truncated description.
* **Interaction:**
* Cards are highly interactive. Clicking a card opens a `<ArchetypeEditor />` sheet/modal to edit the metadata.
* Cards are highly interactive. Clicking a card opens a central popup (reusing the app's existing Dialog/Popup pattern) to edit the metadata.
* Includes a focused text area to edit the raw `systemPrompt`.
* A primary "Create New Archetype" button exists at the top.
### 4.4 Tab 3: `<TemplatesManager />`
* **Layout:** List or Grid view of templates.
* **Interaction:**
* Cards are highly interactive. Clicking a card opens a `<TemplateEditor />` sheet/modal.
* Cards are highly interactive. Clicking a card opens a central `<TemplateEditor />` popup.
* **Key UI Feature:** An intuitive interface to build the `team` array (e.g., click "Add Role", select "Architect" from the Archetypes list, set count to "1").
* A primary "Create New Template" button exists at the top.

View file

@ -0,0 +1,285 @@
# Prompt: Fix CGO-Enabled Release Builds for steveyegge/beads
## Mission
Submit a PR to https://github.com/steveyegge/beads that fixes issue #1856: "Install script binary has CGO_ENABLED=0 — Dolt embedded mode fails"
## Background Context
### The Problem
1. All release builds run on `ubuntu-latest` (see `.github/workflows/release.yml`)
2. Windows/macOS builds use cross-compilation:
- Windows: `x86_64-w64-mingw32-gcc`
- macOS: Zig wrappers (`/tmp/zigcc/cc-x86_64-macos`)
3. Only Linux ICU is installed: `sudo apt-get install libicu-dev`
4. CGO requires target-platform ICU headers/libraries
5. When CGO can't find ICU for the target platform, Go silently produces a non-CGO binary
6. Users get: `Error: failed to open database: dolt backend requires CGO`
### Evidence
```bash
# From installed binary on macOS:
strings ~/.local/bin/bd | grep CGO_ENABLED
CGO_ENABLED=0 # <-- PROBLEM
# The .goreleaser.yml SAYS CGO_ENABLED=1, but cross-compilation fails silently
```
### Affected Platforms
- Windows AMD64 (cross-compiled with mingw)
- Windows ARM64 (cross-compiled with Zig)
- macOS AMD64 (cross-compiled with Zig)
- macOS ARM64 (cross-compiled with Zig)
Linux builds work because they're native.
## The Fix: Native Builds Per Platform
### Approach
Instead of cross-compiling from Ubuntu, use native runners:
| Platform | Runner | CGO Support |
|----------|--------|-------------|
| Linux AMD64 | ubuntu-latest | Native gcc + libicu-dev |
| Linux ARM64 | ubuntu-latest | Cross-compile (aarch64-linux-gnu-gcc) |
| macOS AMD64 | macos-latest | Native clang + icu4c (brew) |
| macOS ARM64 | macos-latest | Native clang + icu4c (brew) |
| Windows AMD64 | windows-latest | Native gcc via MSYS2 UCRT64 |
| Windows ARM64 | windows-latest | Cross-compile with Zig |
### Why This Works
- Native builds have native ICU available via package managers
- CGO "just works" when the compiler can find headers/libs
- No fragile cross-compilation setup needed
## TDD Approach (REQUIRED)
### Step 1: Write Failing Test FIRST
Add a verification step in release.yml that checks for CGO:
```yaml
- name: Verify CGO enabled in binary
run: |
# For each built binary, verify CGO is enabled
for binary in dist/*/bd; do
if strings "$binary" | grep -q "CGO_ENABLED=0"; then
echo "ERROR: $binary was built without CGO support"
exit 1
fi
echo "OK: $binary has CGO support"
done
```
**This test will FAIL on current main branch** - proving the bug exists.
### Step 2: Implement Fix
Split the release workflow into platform-specific jobs.
### Step 3: Test Passes
After fix, the verification step passes on all platforms.
## Files to Modify
### 1. `.github/workflows/release.yml`
Split into 3 jobs:
```yaml
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with: { fetch-depth: 0 }
- uses: actions/setup-go@v6
with: { go-version-file: 'go.mod' }
- run: sudo apt-get update && sudo apt-get install -y gcc libicu-dev
- uses: goreleaser/goreleaser-action@v6
with:
args: build --single-target --config .goreleaser-linux.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify CGO enabled
run: |
if strings dist/bd_linux_amd64/bd | grep -q "CGO_ENABLED=0"; then
echo "ERROR: Linux binary lacks CGO"
exit 1
fi
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
with: { fetch-depth: 0 }
- uses: actions/setup-go@v6
with: { go-version-file: 'go.mod' }
- run: brew install icu4c
- uses: goreleaser/goreleaser-action@v6
with:
args: build --single-target --config .goreleaser-macos.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CGO_CFLAGS: "-I$(brew --prefix icu4c)/include"
CGO_LDFLAGS: "-L$(brew --prefix icu4c)/lib"
- name: Verify CGO enabled
run: |
for binary in dist/*/bd; do
if strings "$binary" | grep -q "CGO_ENABLED=0"; then
echo "ERROR: $binary lacks CGO"
exit 1
fi
fi
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
with: { fetch-depth: 0 }
- uses: actions/setup-go@v6
with: { go-version-file: 'go.mod' }
- uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-icu
- uses: goreleaser/goreleaser-action@v6
with:
args: build --single-target --config .goreleaser-windows.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CGO_ENABLED: 1
CC: C:/msys64/ucrt64/bin/gcc.exe
PATH: C:/msys64/ucrt64/bin;${{ env.PATH }}
- name: Verify CGO enabled
run: |
if (Select-String -Path "dist\bd_windows_amd64\bd.exe" -Pattern "CGO_ENABLED=0" -Quiet) {
Write-Error "ERROR: Windows binary lacks CGO"
exit 1
}
release:
needs: [build-linux, build-macos, build-windows]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Download all artifacts
uses: actions/download-artifact@v4
- uses: goreleaser/goreleaser-action@v6
with:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
### 2. Split `.goreleaser.yml` (optional, or use build IDs)
Either split into separate config files, or use build IDs with `goos`/`goarch` filters.
## Best Practices for PR Acceptance
### 1. Be Respectful and Clear
- Open with: "Thank you for beads! It's an excellent tool."
- Acknowledge existing work: "I see the goreleaser config already specifies CGO_ENABLED=1"
- Frame as collaborative: "This PR proposes a fix for #1856"
### 2. Keep It Minimal
- Don't refactor unrelated code
- Don't add features
- Only change what's needed to fix the CGO issue
### 3. Explain the "Why"
In PR description:
- Cross-compilation + CGO + ICU is fragile
- Native builds are simpler and guaranteed to work
- This aligns with how Homebrew builds beads (native on macOS)
### 4. Reference Existing Issue
```
Fixes #1856
```
### 5. Test Evidence
Include in PR:
- Link to your fork's CI run showing test pass
- Or screenshots of `strings bd.exe | grep CGO` showing no CGO_ENABLED=0
## PR Description Template
```markdown
## Summary
Fixes #1856 - Release binaries were built with CGO_ENABLED=0, causing "Dolt backend requires CGO" errors for users.
## Problem
Cross-compilation from `ubuntu-latest` for Windows/macOS targets cannot find ICU headers for the target platform. Go silently produces non-CGO binaries when CGO dependencies are missing.
## Solution
Use native runners for each platform:
- `ubuntu-latest` for Linux (existing)
- `macos-latest` for macOS (native CGO with icu4c)
- `windows-latest` for Windows (native CGO with MSYS2/UCRT64)
## Testing
Added CI verification step that fails if binary contains `CGO_ENABLED=0`:
```yaml
- name: Verify CGO enabled
run: |
if strings dist/bd | grep -q "CGO_ENABLED=0"; then
echo "ERROR: Binary built without CGO"
exit 1
fi
```
This test currently fails on main; passes with this fix.
## Files Changed
- `.github/workflows/release.yml` - Split into platform-native jobs
## Notes
- Homebrew already builds beads natively on macOS (and it works)
- This approach is more maintainable than fixing cross-compilation ICU paths
- Linux ARM64 still cross-compiles (no arm64 GitHub runner for free tiers)
```
## Fork Setup
1. Fork https://github.com/steveyegge/beads
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/beads.git`
3. Create branch: `git checkout -b fix/cgo-native-builds`
4. Make changes
5. Push: `git push origin fix/cgo-native-builds`
6. Open PR against `steveyegge/beads:main`
## References
- Issue: https://github.com/steveyegge/beads/issues/1856
- Current release.yml: `.github/wo
rkflows/release.yml
- Current goreleaser: `.goreleaser.yml`
- Our local build succeeded with MSYS2 UCRT64 on Windows
## Related Bead
- `bb-zbt`: PR: Fix CGO-enabled release builds for beads (beadboard project)
---
Good luck! Be excellent to the maintainers.

View file

@ -0,0 +1,166 @@
# Bead Prompting Pack (Model-Facing)
Use this file when authoring bead descriptions.
Descriptions should be written as prompts for another AI model, not as internal notes.
## 0) Critical Rule
Do not paste the full system prompt text into bead descriptions.
Use it as authoring guidance, then write a bead-specific prompt with concrete task details.
## 1) Canonical Prompting Strategy (Reference)
```text
You are an expert autonomous assistant designed for deep, multi-step problem solving and real-world execution.
OVERALL BEHAVIOR
- Think in clear, explicit steps: understand -> plan -> gather context -> act -> verify.
- Prefer concrete action over endless analysis, but never skip critical checks.
- Keep answers concise and structured, even for complex tasks.
- Optimize for correctness, stability over long horizons, and usefulness to the user.
GOALS AND SUCCESS
- Always start by restating the users goal in your own words.
- Ask clarifying questions only when absolutely necessary to proceed safely or correctly.
- Define what “success” looks like for the task (acceptance criteria).
- Treat acceptance criteria as the contract you must satisfy before you consider the task complete.
PLANNING
- Before detailed work, produce a short, numbered plan of 37 steps.
- Break large tasks into manageable sub-tasks with clear dependencies.
- As you work, update the plan if you discover new constraints or information.
- Surface tradeoffs explicitly when there are multiple viable approaches.
CONTEXT AND INFORMATION
- Use only the minimal context needed to perform the task correctly.
- When you have access to retrieval or external data, follow this pattern:
1) Identify what you need to know.
2) Retrieve or inspect only the most relevant items.
3) Summarize and deduplicate what you found.
4) Stop searching as soon as you can act effectively.
- Do not repeat the same query or reread the same large context unnecessarily.
- When context is huge, summarize it into key points before detailed reasoning.
TOOL AND API USAGE
- Treat tools as powerful but optional aids.
- Use tools when information is missing, when verification is required, or when an action must be taken in an external system.
- Before calling a tool, briefly state what you are trying to achieve with it.
- After each tool call, summarize what the result means and how it changes your plan.
- Avoid redundant or looping tool calls; each call should move you closer to the goal.
REASONING DEPTH AND SPEED
- Choose effort level based on task complexity:
- Fast: for simple, routine questions; minimize chain-of-thought and focus on direct, correct answers.
- Balanced: for multi-step but bounded tasks; give a brief plan and short explanations.
- Deep: for complex, high-stakes, or architectural work; provide detailed reasoning, careful tradeoff analysis, and explicit checks.
- Do not over-think trivial tasks, and do not under-think complex, risky, or ambiguous ones.
OUTPUT STRUCTURE
- Always structure your response so it is easy to scan and act on.
- By default, organize your response into the following sections:
1) Goal: what you are solving.
2) Plan: brief, numbered list of steps.
3) Execution: what you did, step by step.
4) Result: the final answer, artifact, or decision.
5) Verification: checks performed, remaining risks, and suggested next actions.
- When asked for specific formats (JSON, YAML, schema, code diff), follow the requested format exactly and avoid extra commentary inside structured blocks.
CODING AND TECHNICAL WORK
- When editing code:
- Preserve existing style, patterns, and conventions.
- Keep changes as small as possible while solving the problem.
- Prefer targeted edits over large rewrites, unless explicitly asked for a redesign.
- When generating new code:
- Start from a clear specification of inputs, outputs, and constraints.
- Consider edge cases, error handling, performance, and security.
- Provide minimal but meaningful docstrings or comments only where they clarify non-obvious logic.
- For debugging:
- Reproduce or restate the bug clearly.
- Form a hypothesis, then methodically test or reason through it.
- Explain what changed and why your fix addresses the root cause, not just the symptom.
COMMUNICATION STYLE
- Use clear, direct language and avoid unnecessary jargon.
- Prefer short paragraphs and bullet lists when they improve readability.
- Make important decisions, assumptions, and tradeoffs explicit.
- When something is uncertain, say what you do and do not know and propose how to reduce the uncertainty.
SELF-VERIFICATION AND QUALITY
- Before finalizing any answer:
1) Re-read the users request and your restated goal.
2) Check that each acceptance criterion is satisfied.
3) Scan for internal inconsistencies or obvious mistakes.
4) Note any remaining open questions, assumptions, or limitations.
- If an issue can be fixed with a small additional step, perform that step instead of leaving an avoidable gap.
SAFETY, LIMITS, AND SCOPE
- Stay strictly within the requested scope unless broadening is clearly necessary to avoid errors.
- If the users request conflicts with higher-level instructions or constraints you must follow, explain the conflict briefly and offer the closest acceptable alternative.
- When a task is impossible or severely under-specified, say so plainly and redirect to the most useful next steps.
DEFAULT ANSWER TEMPLATE
Unless the user requests a different format, follow this layout:
1) Goal
- One or two sentences summarizing what youre doing.
2) Plan
- 37 concise bullets describing your intended steps.
3) Execution
- Brief notes on how you carried out each step, focusing on decisions and key reasoning.
4) Result
- The final answer, artifact, code, or recommendation, presented cleanly.
5) Verification
- What you checked, any limitations, and suggested next actions or improvements.
Always prioritize being accurate, actionable, and easy to work with over being verbose.
```
## 2) Bead Task Prompt Template (Fill For This Bead)
```text
TASK CONTEXT
- Bead ID: <bead-id>
- Title: <bead-title>
- Parent/Epic: <parent-id-or-none>
- Dependencies (must be done first): <comma-separated-bead-ids-or-none>
TASK CONTRACT
- Goal: <1-2 sentence goal>
- Success Criteria:
- <criterion 1>
- <criterion 2>
- Scope:
- <in-scope item 1>
- <in-scope item 2>
- Out of Scope:
- <non-goal 1>
- <non-goal 2>
IMPLEMENTATION CONSTRAINTS
- Preserve existing backend/API contracts unless explicitly stated otherwise.
- Reuse shared components and logic; avoid one-off forks.
- Keep changes targeted and minimal for this bead.
VERIFICATION REQUIREMENTS
- Required commands:
- npm run typecheck
- npm run lint
- npm run test
- Required artifacts:
- <screenshots/audit/report paths>
- Report any remaining risks and follow-up beads explicitly.
```
## 3) Bead Description Authoring Rules
1. Write the bead description as a filled, bead-specific prompt.
2. Do not include "copy this verbatim" instructions in bead descriptions.
3. Do not include the full boilerplate system prompt in bead descriptions.
4. Include `Scope` and `Out of Scope` in every bead.
5. Make acceptance criteria observable and testable.
6. Keep dependency flow minimal and execution-correct.
7. Avoid vague verbs without measurable outcomes.

View file

@ -1,11 +1,21 @@
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
import nextTypeScript from 'eslint-config-next/typescript';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({ baseDirectory: __dirname });
const eslintConfig = [
...nextCoreWebVitals,
...nextTypeScript,
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
ignores: ['nul'],
ignores: [
'nul',
'.next/**',
'.agents/**',
'skills/**',
'next-env.d.ts',
],
},
{
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'],
@ -14,6 +24,7 @@ const eslintConfig = [
'prefer-const': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
},
},
];

4879
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,10 +16,14 @@
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@remotion/google-fonts": "^4.0.422",
"@remotion/tailwind": "^4.0.422",
@ -48,7 +52,7 @@
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.24",
"eslint": "9.39.2",
"eslint-config-next": "16.1.6",
"eslint-config-next": "^15.5.7",
"playwright": "^1.58.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",

View file

@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { listAgents } from '../../../../lib/agent-registry';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
}
const result = await listAgents({}, { projectRoot });
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error?.message }, { status: 500 });
}
return NextResponse.json({ ok: true, data: result.data });
}

View file

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { readInteractionsViaBd } from '../../../../../lib/read-interactions';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse> {
const { id } = await params;
const projectRoot = request.nextUrl.searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json(
{ ok: false, error: { message: 'projectRoot is required' } },
{ status: 400 }
);
}
try {
const comments = await readInteractionsViaBd(projectRoot, id);
return NextResponse.json({ ok: true, comments });
} catch (error) {
console.error('[API] Failed to fetch comments:', error);
return NextResponse.json(
{ ok: false, error: { message: 'Failed to fetch comments' } },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../../lib/bridge';
export const dynamic = 'force-dynamic';
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
}
try {
const result = await runBdCommand({
projectRoot,
args: ['swarm', 'status', id, '--json'],
});
if (!result.success) {
return NextResponse.json({ ok: false, error: result.error }, { status: 500 });
}
const topology = JSON.parse(result.stdout);
return NextResponse.json({ ok: true, data: topology });
} catch (e) {
console.error(`Failed to fetch topology for ${id}:`, e);
return NextResponse.json({ ok: false, error: 'Internal server error' }, { status: 500 });
}
}

View file

@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { joinSwarm, leaveSwarm } from '../../../../lib/swarm-molecules';
export async function POST(request: Request) {
try {
const body = await request.json();
const { projectRoot, agentId, missionId, action } = body;
if (!projectRoot || !agentId || !missionId || !action) {
return NextResponse.json(
{ ok: false, error: 'Missing required fields' },
{ status: 400 }
);
}
let result;
if (action === 'join') {
result = await joinSwarm({ agent: agentId, epicId: missionId }, { projectRoot });
} else if (action === 'leave') {
result = await leaveSwarm({ agent: agentId, projectRoot });
} else {
return NextResponse.json({ ok: false, error: 'Invalid action' }, { status: 400 });
}
if (!result.ok) {
return NextResponse.json(
{ ok: false, error: result.error?.message },
{ status: 500 }
);
}
return NextResponse.json({ ok: true, data: result.data });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
const id = searchParams.get('id');
if (!projectRoot || !id) {
return NextResponse.json({ ok: false, error: 'projectRoot and id are required' }, { status: 400 });
}
// 1. Get the mission/epic bead itself
const headResult = await runBdCommand({
projectRoot,
args: ['show', id, '--json'],
});
// 2. Get children
const childrenResult = await runBdCommand({
projectRoot,
args: ['list', '--parent', id, '--json'],
});
if (!headResult.success) {
return NextResponse.json({ ok: false, error: 'Failed to fetch mission head' }, { status: 500 });
}
try {
const head = JSON.parse(headResult.stdout);
let children = [];
if (childrenResult.success && childrenResult.stdout.trim()) {
children = JSON.parse(childrenResult.stdout);
}
const headObj = Array.isArray(head) ? head[0] : head;
// Transform for graph view (if needed, or just return raw issues and let UI handle it)
// The WorkflowGraph component expects BeadIssue[]
const nodes = [headObj, ...children];
return NextResponse.json({ ok: true, data: { nodes } });
} catch (e) {
return NextResponse.json({ ok: false, error: 'Failed to parse graph data' }, { status: 500 });
}
}

View file

@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
import { listAgents, type AgentRecord } from '../../../../lib/agent-registry';
export const dynamic = 'force-dynamic';
interface SwarmTopology {
completed: { id: string; title: string }[];
active: { id: string; title: string; assignee?: string }[];
ready: { id: string; title: string }[];
blocked: { id: string; title: string; blocked_by: string[] }[];
}
interface Mission {
id: string;
title: string;
description?: string;
status: 'planning' | 'active' | 'blocked' | 'completed';
stats: {
total: number;
done: number;
blocked: number;
active: number;
};
topology?: SwarmTopology;
agents: AgentRecord[];
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
}
// 1. Fetch Swarms (Molecules)
const swarmResult = await runBdCommand({
projectRoot,
args: ['swarm', 'list', '--json'],
});
if (!swarmResult.success) {
console.warn('Swarm list failed, returning empty:', swarmResult.error);
return NextResponse.json({ ok: true, data: { missions: [] } });
}
// 2. Fetch All Agents
const agentResult = await listAgents({}, { projectRoot });
const allAgents = agentResult.ok ? agentResult.data! : [];
try {
const rawData = JSON.parse(swarmResult.stdout);
const rawSwarms = rawData.swarms || [];
// 3. Transform & Merge
const missions: Mission[] = rawSwarms
.filter((s: any) => !s.title.startsWith('Agent:'))
.map((s: any) => {
const assignedAgents = allAgents.filter(a => a.swarm_id === s.epic_id || a.swarm_id === s.id);
// Map status
let status: Mission['status'] = 'planning';
if (s.status === 'closed') status = 'completed';
else if (assignedAgents.length > 0) status = 'active';
else status = 'planning';
return {
id: s.id,
title: s.title,
description: s.description || s.epic_description || '',
status,
stats: {
total: s.total_issues || 0,
done: s.completed_issues || 0,
active: s.active_issues || 0,
blocked: 0
},
agents: assignedAgents
};
});
return NextResponse.json({ ok: true, data: { missions } });
} catch (e) {
console.error('Mission list parsing error:', e);
return NextResponse.json({ ok: false, error: 'Failed to parse mission data' }, { status: 500 });
}
}

View file

@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
interface CloseSwarmPayload {
projectRoot: string;
swarmId: string;
reason?: string;
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'Invalid JSON body' } },
{ status: 400 },
);
}
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'Payload must be a JSON object' } },
{ status: 400 },
);
}
const data = body as Record<string, unknown>;
if (typeof data.projectRoot !== 'string' || !data.projectRoot.trim()) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'projectRoot is required' } },
{ status: 400 },
);
}
if (typeof data.swarmId !== 'string' || !data.swarmId.trim()) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'swarmId is required' } },
{ status: 400 },
);
}
const payload: CloseSwarmPayload = {
projectRoot: data.projectRoot.trim(),
swarmId: data.swarmId.trim(),
reason: typeof data.reason === 'string' ? data.reason.trim() : undefined,
};
const args = ['close', payload.swarmId, '--json'];
if (payload.reason) {
args.push('--reason', payload.reason);
}
const result = await runBdCommand({
projectRoot: payload.projectRoot,
args,
});
if (!result.success) {
return NextResponse.json(
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
{ status: 400 },
);
}
return NextResponse.json({ ok: true, swarmId: payload.swarmId });
}

View file

@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
interface CreateSwarmPayload {
projectRoot: string;
epicId: string;
coordinator?: string;
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'Invalid JSON body' } },
{ status: 400 },
);
}
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'Payload must be a JSON object' } },
{ status: 400 },
);
}
const data = body as Record<string, unknown>;
if (typeof data.projectRoot !== 'string' || !data.projectRoot.trim()) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'projectRoot is required' } },
{ status: 400 },
);
}
if (typeof data.epicId !== 'string' || !data.epicId.trim()) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'epicId is required' } },
{ status: 400 },
);
}
const payload: CreateSwarmPayload = {
projectRoot: data.projectRoot.trim(),
epicId: data.epicId.trim(),
coordinator: typeof data.coordinator === 'string' ? data.coordinator.trim() : undefined,
};
const args = ['swarm', 'create', payload.epicId, '--json'];
if (payload.coordinator) {
args.push('--coordinator', payload.coordinator);
}
const result = await runBdCommand({
projectRoot: payload.projectRoot,
args,
});
if (!result.success) {
return NextResponse.json(
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
{ status: 400 },
);
}
try {
const swarm = JSON.parse(result.stdout);
return NextResponse.json({ ok: true, swarm });
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'unknown', message: 'Failed to parse swarm create output' } },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
}
// bd formula list --json
const result = await runBdCommand({
projectRoot,
args: ['formula', 'list', '--json', '--allow-stale'],
});
if (!result.success) {
return NextResponse.json({ ok: false, error: result.error }, { status: 500 });
}
try {
// If output is empty or not JSON array, handle gracefully
const json = JSON.parse(result.stdout || '[]');
return NextResponse.json({ ok: true, data: json });
} catch (e) {
return NextResponse.json({ ok: false, error: 'Failed to parse formulas' }, { status: 500 });
}
}

View file

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
const epicId = searchParams.get('epic');
if (!projectRoot || !epicId) {
return NextResponse.json({ ok: false, error: 'projectRoot and epic are required' }, { status: 400 });
}
// 1. Get the epic itself
const epicResult = await runBdCommand({
projectRoot,
args: ['show', epicId, '--json'],
});
// 2. Get children
const childrenResult = await runBdCommand({
projectRoot,
args: ['list', '--parent', epicId, '--json'],
});
if (!epicResult.success) {
return NextResponse.json({ ok: false, error: 'Failed to fetch epic' }, { status: 500 });
}
try {
const epic = JSON.parse(epicResult.stdout);
// Handle list returning empty or error gracefully
let children = [];
if (childrenResult.success && childrenResult.stdout.trim()) {
children = JSON.parse(childrenResult.stdout);
// bd list returns array, bd show returns object (or array of 1)
}
const epicObj = Array.isArray(epic) ? epic[0] : epic;
const issues = [epicObj, ...children];
return NextResponse.json({ ok: true, data: issues });
} catch (e) {
return NextResponse.json({ ok: false, error: 'Failed to parse graph data' }, { status: 500 });
}
}

View file

@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { joinSwarm } from '../../../../lib/swarm-molecules';
export async function POST(request: Request) {
try {
const body = await request.json();
const { projectRoot, agentId, swarmId } = body;
if (!projectRoot || !agentId || !swarmId) {
return NextResponse.json(
{ ok: false, error: 'Missing required fields' },
{ status: 400 }
);
}
const result = await joinSwarm(
{ agent: agentId, epicId: swarmId },
{ projectRoot }
);
if (!result.ok) {
return NextResponse.json(
{ ok: false, error: result.error?.message },
{ status: 500 }
);
}
return NextResponse.json({ ok: true, data: result.data });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function POST(request: Request) {
try {
const body = await request.json();
const { projectRoot, title, proto } = body;
if (!projectRoot || !title || !proto) {
return NextResponse.json(
{ ok: false, error: 'Missing required fields: projectRoot, title, proto' },
{ status: 400 }
);
}
// bd mol pour "Title" --proto <proto> --json
const args = ['mol', 'pour', title, '--proto', proto, '--json'];
// Safety: Ensure proto doesn't contain shell injection
if (!/^[a-zA-Z0-9_\-.]+$/.test(proto)) {
return NextResponse.json({ ok: false, error: 'Invalid proto name' }, { status: 400 });
}
const result = await runBdCommand({
projectRoot,
args,
});
if (!result.success) {
return NextResponse.json({ ok: false, error: result.error || result.stderr }, { status: 500 });
}
const data = JSON.parse(result.stdout);
return NextResponse.json({ ok: true, data });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { leaveSwarm } from '../../../../lib/swarm-molecules';
export async function POST(request: Request) {
try {
const body = await request.json();
const { projectRoot, agentId } = body;
if (!projectRoot || !agentId) {
return NextResponse.json(
{ ok: false, error: 'Missing required fields' },
{ status: 400 }
);
}
const result = await leaveSwarm(
{ agent: agentId },
{ projectRoot }
);
if (!result.ok) {
return NextResponse.json(
{ ok: false, error: result.error?.message },
{ status: 500 }
);
}
return NextResponse.json({ ok: true, data: result.data });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request): Promise<Response> {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'projectRoot is required' } },
{ status: 400 },
);
}
const result = await runBdCommand({
projectRoot,
args: ['swarm', 'list', '--json'],
});
if (!result.success) {
return NextResponse.json(
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
{ status: 400 },
);
}
try {
const rawData = JSON.parse(result.stdout);
// Filter out items that look like agents (start with "Agent:" or have gt:agent style IDs if discernible)
// Real swarms/molecules usually don't start with "Agent:".
const swarms = (rawData.swarms || []).filter((s: any) =>
!s.title.startsWith('Agent: ') &&
!s.title.startsWith('Agent:')
);
return NextResponse.json({ ok: true, data: { swarms } });
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'unknown', message: 'Failed to parse swarm list output' } },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export async function POST(request: Request) {
try {
const { beadId, archetypeId } = await request.json();
if (!beadId || !archetypeId) {
return NextResponse.json({ error: 'Missing beadId or archetypeId' }, { status: 400 });
}
// Use bd CLI to add the archetype label. We leave it 'open' because Prep just assigns the agent.
const cmd = `bd label add ${beadId} "agent:${archetypeId}"`;
const { stdout, stderr } = await execAsync(cmd);
if (stderr && !stderr.includes('Warning')) {
console.error('bd edit stderr:', stderr);
}
return NextResponse.json({ success: true, message: `Prepped ${beadId} for ${archetypeId}`, output: stdout });
} catch (error: any) {
console.error('Prep task failed:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { runBdCommand } from '../../../../lib/bridge';
export async function GET(request: Request): Promise<Response> {
const { searchParams } = new URL(request.url);
const projectRoot = searchParams.get('projectRoot');
const epicId = searchParams.get('epic');
if (!projectRoot) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'projectRoot is required' } },
{ status: 400 },
);
}
if (!epicId) {
return NextResponse.json(
{ ok: false, error: { classification: 'bad_args', message: 'epic is required' } },
{ status: 400 },
);
}
const result = await runBdCommand({
projectRoot,
args: ['swarm', 'status', epicId, '--json'],
});
if (!result.success) {
return NextResponse.json(
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
{ status: 400 },
);
}
try {
const data = JSON.parse(result.stdout);
return NextResponse.json({ ok: true, data });
} catch {
return NextResponse.json(
{ ok: false, error: { classification: 'unknown', message: 'Failed to parse swarm status output' } },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { getTemplates } from '../../../../lib/server/beads-fs';
export async function GET() {
const data = await getTemplates();
return NextResponse.json(data);
}

View file

@ -3,45 +3,64 @@
@tailwind utilities;
:root {
/* ========== EARTHY-DARK DESIGN SYSTEM TOKENS (PRD v2.0) ========== */
/* Backgrounds */
--color-bg-base: #2D2D2D;
--color-bg-card: #363636;
--color-bg-input: #404040;
/* ========== VISUAL-TRUTH UI TOKEN CONTRACT (bb-vt.1.1) ========== */
--ui-bg-app: #070d16;
--ui-bg-shell: #0c1420;
--ui-bg-panel: #111c2a;
--ui-bg-card: #1a2431;
/* Accents */
--color-accent-green: #7CB97A;
--color-accent-amber: #D4A574;
--color-accent-teal: #5BA8A0;
--ui-border-soft: rgba(153, 171, 190, 0.2);
--ui-border-strong: rgba(153, 171, 190, 0.34);
/* Text */
--color-text-primary: #FFFFFF;
--color-text-secondary: #B8B8B8;
--color-text-muted: #888888;
--color-text-on-primary: #1A1A1A;
--ui-text-primary: #e8edf5;
--ui-text-muted: #8f9caf;
/* Borders */
--color-border-default: #4A4A4A;
--color-border-subtle: #3A3A3A;
--ui-accent-ready: #35d98f;
--ui-accent-blocked: #ff4c72;
--ui-accent-warning: #ffb24a;
--ui-accent-info: #35c9ff;
--ui-accent-action-green: #35d98f;
--ui-accent-action-red: #ff4c72;
--ui-font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
--ui-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
--ui-tracking-tight: -0.011em;
--ui-numeric-tabular: tabular-nums;
/* ========== LEGACY-COMPATIBLE MAPPINGS ========== */
--color-bg-base: var(--ui-bg-app);
--color-bg-card: var(--ui-bg-shell);
--color-bg-input: var(--ui-bg-panel);
--color-accent-green: var(--ui-accent-ready);
--color-accent-amber: var(--ui-accent-warning);
--color-accent-teal: var(--ui-accent-info);
--color-text-primary: var(--ui-text-primary);
--color-text-secondary: #c4cfdb;
--color-text-muted: var(--ui-text-muted);
--color-text-on-primary: #10161d;
--color-border-default: var(--ui-border-strong);
--color-border-subtle: var(--ui-border-soft);
/* Status colors */
--status-open: #5BA8A0;
--status-ready: #5BA8A0;
--status-in-progress: #7CB97A;
--status-progress: #7CB97A;
--status-blocked: #D4A574;
--status-blocked-earthy: #D4A574;
--status-closed: #888888;
--status-closed-earthy: #888888;
--status-deferred: #888888;
--status-open: var(--ui-accent-info);
--status-ready: var(--ui-accent-ready);
--status-in-progress: var(--ui-accent-warning);
--status-progress: var(--ui-accent-warning);
--status-blocked: var(--ui-accent-blocked);
--status-blocked-earthy: var(--ui-accent-blocked);
--status-closed: #7f8b98;
--status-closed-earthy: #7f8b98;
--status-deferred: #7f8b98;
/* Liveness colors */
--liveness-active: #7CB97A;
--liveness-stale: #D4A574;
--liveness-stuck: #C97A7A;
--liveness-dead: #C97A7A;
--liveness-idle: #888888;
--liveness-active: var(--ui-accent-ready);
--liveness-stale: var(--ui-accent-warning);
--liveness-stuck: var(--ui-accent-action-red);
--liveness-dead: var(--ui-accent-action-red);
--liveness-idle: #7f8b98;
/* Agent Role Colors */
--agent-role-ui: #6B9BD2;
@ -77,8 +96,8 @@
--shadow-soft-xl: 0 20px 40px -10px rgba(0, 0, 0, 0.4);
/* ========== TYPOGRAPHY ========== */
--font-ui-stack: 'Segoe UI', system-ui, -apple-system, sans-serif;
--font-mono-stack: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
--font-ui-stack: var(--ui-font-sans);
--font-mono-stack: var(--ui-font-mono);
--font-size-h1: 2rem;
--font-size-h2: 1.5rem;
@ -110,7 +129,17 @@
/* ========== LAYOUT ========== */
--sidebar-left-width: 13.75rem;
--sidebar-right-width: 17.5rem;
--topbar-height: 3rem;
--topbar-height: 3.75rem;
--glass-base: linear-gradient(
180deg,
color-mix(in srgb, var(--ui-bg-card) 72%, black),
color-mix(in srgb, var(--ui-bg-panel) 78%, black)
);
--edge-top: color-mix(in srgb, var(--ui-border-strong) 80%, white 20%);
--edge-bottom: color-mix(in srgb, var(--ui-border-soft) 75%, black 25%);
--elevation-ambient: 0 20px 40px -16px rgba(0, 0, 0, 0.78);
--elevation-tight: 0 10px 24px -12px rgba(0, 0, 0, 0.7);
/* ========== LEGACY COMPATIBILITY TOKENS ========== */
/* For existing components that reference these */
@ -134,11 +163,10 @@ body {
}
body {
/* Earthy-dark base from PRD (replaces Aero Chrome) */
background-color: var(--color-bg-base);
color: var(--color-text-secondary);
font-family: var(--font-ui-stack);
letter-spacing: -0.011em;
background-color: var(--ui-bg-app);
color: var(--ui-text-primary);
font-family: var(--ui-font-sans);
letter-spacing: var(--ui-tracking-tight);
position: relative;
isolation: isolate;
}
@ -266,24 +294,52 @@ body {
.ui-select option,
.ui-option {
background-color: #10141d;
color: #e2e8f0;
background-color: var(--ui-bg-panel);
color: var(--ui-text-primary);
}
.ui-text {
font-family: var(--font-ui-stack);
font-family: var(--ui-font-sans);
font-weight: 500;
letter-spacing: -0.01em;
letter-spacing: var(--ui-tracking-tight);
line-height: 1.35;
}
.system-data {
font-family: var(--font-mono-stack);
font-variant-numeric: tabular-nums;
font-family: var(--ui-font-mono);
font-variant-numeric: var(--ui-numeric-tabular);
font-weight: 450;
letter-spacing: 0.015em;
}
.ui-shell-app {
background: var(--ui-bg-app);
color: var(--ui-text-primary);
font-family: var(--ui-font-sans);
letter-spacing: var(--ui-tracking-tight);
}
.ui-shell-topbar {
background: linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-panel) 92%, black), var(--ui-bg-shell));
border-bottom: 1px solid color-mix(in srgb, var(--ui-accent-info) 22%, var(--ui-border-soft));
box-shadow: 0 10px 24px -20px rgba(0, 0, 0, 0.9);
}
.ui-shell-middle {
background: linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-app) 74%, black), color-mix(in srgb, var(--ui-bg-app) 90%, black));
border-left: 1px solid color-mix(in srgb, var(--ui-accent-info) 20%, var(--ui-border-soft));
border-right: 1px solid color-mix(in srgb, var(--ui-accent-info) 20%, var(--ui-border-soft));
}
.ui-shell-panel {
background: linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-shell) 86%, black), color-mix(in srgb, var(--ui-bg-panel) 84%, black));
border-left: 1px solid color-mix(in srgb, var(--ui-accent-info) 30%, var(--ui-border-soft));
}
.ui-tabular-nums {
font-variant-numeric: var(--ui-numeric-tabular);
}
.workflow-graph-legend {
backdrop-filter: blur(12px);

View file

@ -1,78 +1,5 @@
import { SessionsPage } from '../../components/sessions/sessions-page';
import type { SwarmGroup } from '../../components/sessions/sessions-header';
import { readIssuesForScope } from '../../lib/aggregate-read';
import { resolveProjectScope } from '../../lib/project-scope';
import { listProjects } from '../../lib/registry';
import { listAgents } from '../../lib/agent-registry';
import { getSwarmMembers } from '../../lib/swarm-molecules';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}
export default async function Page({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
const registryProjects = await listProjects();
const agentsResult = await listAgents({});
const agents = agentsResult.data ?? [];
const scope = resolveProjectScope({
currentProjectRoot: process.cwd(),
registryProjects,
requestedProjectKey,
requestedMode,
});
const issues = await readIssuesForScope({
mode: scope.mode,
selected: scope.selected,
scopeOptions: scope.options,
preferBd: true,
});
const epics = issues.filter(i => i.issue_type === 'epic');
const epicsWithSwarm = epics.filter(
i => (i.labels || []).some(l => l.startsWith('swarm:'))
);
const swarmGroups: SwarmGroup[] = [];
const assignedAgentIds = new Set<string>();
for (const epic of epicsWithSwarm) {
const swarmLabel = epic.labels?.find(l => l.startsWith('swarm:'));
if (!swarmLabel) continue;
const swarmId = swarmLabel.replace('swarm:', '');
const memberIds = await getSwarmMembers({ swarmId }, { projectRoot: scope.selected.root });
const members = agents.filter(a => memberIds.includes(a.agent_id));
members.forEach(a => assignedAgentIds.add(a.agent_id));
if (members.length > 0) {
swarmGroups.push({
swarmId,
swarmLabel: epic.id,
members,
});
}
}
const unassignedAgents = agents.filter(a => !assignedAgentIds.has(a.agent_id));
return (
<SessionsPage
issues={issues}
agents={agents}
projectRoot={scope.selected.root}
projectScopeKey={scope.selected.key}
projectScopeOptions={scope.options}
projectScopeMode={scope.mode}
swarmGroups={swarmGroups}
unassignedAgents={unassignedAgents}
/>
);
export default function SessionsRedirectPage() {
redirect('/?view=social');
}

View file

@ -110,25 +110,25 @@ function formatRelativeTime(timestamp: string): string {
function getAgentTone(status: AgentStatus): AgentTone {
const tones: Record<AgentStatus, AgentTone> = {
active: {
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(124,185,122,0.28),transparent_58%),rgba(45,64,47,0.74)]',
cardClass: 'bg-[#173126]',
labelClass: 'text-[#7CB97A]',
ringClass: 'ring-[#7CB97A]/45',
glowClass: 'bg-[#7CB97A]/30',
},
stale: {
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(212,165,116,0.28),transparent_58%),rgba(73,61,46,0.74)]',
cardClass: 'bg-[#322817]',
labelClass: 'text-[#D4A574]',
ringClass: 'ring-[#D4A574]/45',
glowClass: 'bg-[#D4A574]/30',
},
stuck: {
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(201,122,122,0.28),transparent_58%),rgba(74,52,54,0.76)]',
cardClass: 'bg-[#341a1f]',
labelClass: 'text-[#C97A7A]',
ringClass: 'ring-[#C97A7A]/45',
glowClass: 'bg-[#C97A7A]/30',
},
dead: {
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(136,104,112,0.26),transparent_58%),rgba(60,55,60,0.74)]',
cardClass: 'bg-[#2b232b]',
labelClass: 'text-[#A78A94]',
ringClass: 'ring-[#A78A94]/40',
glowClass: 'bg-[#A78A94]/25',
@ -146,84 +146,84 @@ function getEventTone(kind: string): EventTone {
label: 'Created',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(124,185,122,0.26),transparent_55%),rgba(42,62,44,0.68)]',
cardClass: 'bg-[#182f25]',
idClass: 'text-[#9ACB98]',
},
opened: {
label: 'Opened',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(124,185,122,0.26),transparent_55%),rgba(42,62,44,0.68)]',
cardClass: 'bg-[#182f25]',
idClass: 'text-[#9ACB98]',
},
closed: {
label: 'Closed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.28),transparent_55%),rgba(66,56,44,0.7)]',
cardClass: 'bg-[#332716]',
idClass: 'text-[#DAB891]',
},
reopened: {
label: 'Reopened',
labelClass: 'text-[#5B95E8]',
dotClass: 'bg-[#5B95E8]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,149,232,0.3),transparent_55%),rgba(42,51,66,0.7)]',
cardClass: 'bg-[#1b2b43]',
idClass: 'text-[#8DB4EF]',
},
status_changed: {
label: 'Status changed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
cardClass: 'bg-[#2f2518]',
idClass: 'text-[#DAB891]',
},
priority_changed: {
label: 'Priority changed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
cardClass: 'bg-[#2f2518]',
idClass: 'text-[#DAB891]',
},
assignee_changed: {
label: 'Assigned',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
cardClass: 'bg-[#2f2518]',
idClass: 'text-[#DAB891]',
},
dependency_added: {
label: 'Dependency added',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
cardClass: 'bg-[#2f2518]',
idClass: 'text-[#DAB891]',
},
dependency_removed: {
label: 'Dependency removed',
labelClass: 'text-[#C97A7A]',
dotClass: 'bg-[#C97A7A]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(201,122,122,0.24),transparent_55%),rgba(65,47,50,0.7)]',
cardClass: 'bg-[#321b21]',
idClass: 'text-[#D9A9A9]',
},
heartbeat: {
label: 'Heartbeat',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.26),transparent_55%),rgba(42,58,60,0.7)]',
cardClass: 'bg-[#173034]',
idClass: 'text-[#8BC9C1]',
},
commented: {
label: 'Commented',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.26),transparent_55%),rgba(42,58,60,0.7)]',
cardClass: 'bg-[#173034]',
idClass: 'text-[#8BC9C1]',
},
comment_added: {
label: 'Commented',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.26),transparent_55%),rgba(42,58,60,0.7)]',
cardClass: 'bg-[#173034]',
idClass: 'text-[#8BC9C1]',
},
};
@ -233,7 +233,7 @@ function getEventTone(kind: string): EventTone {
label: normalized.replace(/_/g, ' '),
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.24),transparent_55%),rgba(42,58,60,0.68)]',
cardClass: 'bg-[#173034]',
idClass: 'text-[#8BC9C1]',
}
);
@ -335,9 +335,9 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
}
return (
<div className="flex flex-col h-full bg-[radial-gradient(circle_at_8%_5%,rgba(91,168,160,0.16),transparent_30%),radial-gradient(circle_at_94%_88%,rgba(212,165,116,0.14),transparent_34%),rgba(26,26,28,0.96)] backdrop-blur-xl">
<div className="flex flex-col h-full bg-[#070f19] backdrop-blur-xl">
{/* AGENT ROSTER SECTION */}
<div className="flex-shrink-0 p-4 bg-black/10 shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)]">
<div className="flex-shrink-0 p-4 bg-[#0b1625] shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
@ -354,7 +354,7 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
<div className="grid grid-cols-1 gap-2">
{agentRoster.map(agent => (
<div key={agent.beadId} className={cn(
'group flex items-center gap-3 p-2 rounded-xl transition-all duration-300 shadow-[0_12px_22px_-14px_rgba(0,0,0,0.85)]',
'group flex items-center gap-3 p-2 rounded-xl transition-all duration-300 shadow-[0_14px_24px_-14px_rgba(0,0,0,0.92)]',
getAgentTone(agent.status).cardClass,
)}>
<div className="relative">
@ -412,7 +412,7 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
return (
<div key={activity.id} className="group relative">
<div className={cn(
"p-3 rounded-xl transition-all duration-300 shadow-[0_12px_22px_-14px_rgba(0,0,0,0.88)]",
"p-3 rounded-xl transition-all duration-300 shadow-[0_14px_24px_-14px_rgba(0,0,0,0.94)]",
eventTone.cardClass
)}>
<div className="flex items-center gap-2 mb-1.5">

View file

@ -22,13 +22,17 @@ export function GraphView({
hideClosed = false,
}: GraphViewProps) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2 bg-white/[0.02]">
<div className="flex items-center gap-1">
<div className="flex h-full flex-col bg-[var(--ui-bg-app)]">
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] px-4 py-2.5">
<div className="flex items-center gap-3">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">
Graph View
</p>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onGraphTabChange('flow')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-all ${
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-bg-shell)] ${
graphTab === 'flow'
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
@ -39,7 +43,7 @@ export function GraphView({
<button
type="button"
onClick={() => onGraphTabChange('overview')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-all ${
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-bg-shell)] ${
graphTab === 'overview'
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
@ -48,11 +52,12 @@ export function GraphView({
Overview
</button>
</div>
</div>
<span className="text-[10px] text-text-muted/50">
{beads.length} beads
</span>
</div>
<div className="flex-1 min-h-0">
<div className="min-h-0 flex-1">
<WorkflowGraph
beads={beads}
selectedId={selectedId}

View file

@ -0,0 +1,148 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Users, AlertTriangle, Activity, CheckCircle2, Circle } from 'lucide-react';
import { AgentAvatar } from '../shared/agent-avatar';
import type { AgentRecord } from '../../lib/agent-registry';
import { SwarmGraph } from './swarm-graph';
import { useSwarmTopology } from '../../hooks/use-swarm-topology';
export interface MissionCardProps {
id: string;
projectRoot: string;
title: string;
description?: string;
status: 'planning' | 'active' | 'blocked' | 'completed';
stats: {
total: number;
done: number;
blocked: number;
};
agents: AgentRecord[];
onDeploy: () => void;
onClick: () => void;
}
const STATUS_CONFIG = {
planning: {
color: 'text-blue-400',
border: 'border-blue-500/30',
bg: 'bg-blue-500/5',
label: 'PLANNING',
icon: Circle
},
active: {
color: 'text-emerald-400',
border: 'border-emerald-500/30',
bg: 'bg-emerald-500/5',
label: 'ACTIVE',
icon: Activity
},
blocked: {
color: 'text-rose-400',
border: 'border-rose-500/30',
bg: 'bg-rose-500/5',
label: 'BLOCKED',
icon: AlertTriangle
},
completed: {
color: 'text-slate-400',
border: 'border-slate-500/30',
bg: 'bg-slate-500/5',
label: 'COMPLETE',
icon: CheckCircle2
},
};
export function MissionCard({ id, projectRoot, title, description, status, stats, agents, onDeploy, onClick }: MissionCardProps) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.planning;
const StatusIcon = config.icon;
const { topology, isLoading } = useSwarmTopology(projectRoot, id);
const isUnstaffed = agents.length === 0;
const isWorking = agents.some(a => a.status === 'working');
const showPulse = status === 'active' || isWorking;
return (
<Card
onClick={onClick}
className="group relative flex flex-col h-[320px] cursor-pointer overflow-hidden rounded-2xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-card)] hover:border-[var(--ui-accent-info)] hover:shadow-xl hover:shadow-black/20 transition-all duration-300"
>
{/* Decorative Top Glow */}
<div className={cn("absolute top-0 left-0 right-0 h-1 opacity-50 group-hover:opacity-100 transition-opacity", config.bg.replace('/5', '/40'))} />
{/* HEADER */}
<div className="p-5 flex flex-col gap-3 min-h-0">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] tracking-wider text-slate-500">{id}</span>
<Badge variant="outline" className={cn("text-[9px] px-2 py-0.5 border h-5 flex items-center gap-1.5", config.color, config.border, config.bg)}>
<StatusIcon className="h-3 w-3" />
{config.label}
</Badge>
</div>
{showPulse && (
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
</div>
)}
</div>
<div className="space-y-2">
<h3 className="font-bold text-lg text-slate-100 leading-tight group-hover:text-white transition-colors line-clamp-2">
{title}
</h3>
<p className="text-xs text-slate-400 line-clamp-2 leading-relaxed">
{description || "No mission brief available."}
</p>
</div>
</div>
{/* GRAPH VISUALIZATION */}
<div className="px-5 py-2 flex-1 flex flex-col justify-end">
<SwarmGraph topology={topology} isLoading={isLoading} />
</div>
{/* FOOTER: SQUAD */}
<div className="px-5 py-3 border-t border-white/5 flex items-center justify-between bg-[var(--ui-bg-shell)] mt-auto">
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{agents.slice(0, 4).map(agent => (
<div key={agent.agent_id} className="ring-2 ring-[var(--ui-bg-shell)] rounded-full transition-transform hover:scale-110 z-0 hover:z-10 relative" title={`${agent.display_name} (${agent.role})`}>
<AgentAvatar name={agent.display_name} status={agent.status as any} size="sm" />
</div>
))}
{agents.length === 0 && (
<div className="h-7 w-7 rounded-full bg-slate-800 border border-slate-700 border-dashed flex items-center justify-center text-slate-600">
<Users className="h-3 w-3" />
</div>
)}
</div>
{agents.length === 0 && (
<span className="text-[10px] font-medium text-slate-500 uppercase tracking-wide">Unstaffed</span>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={(e) => { e.stopPropagation(); onDeploy(); }}
className={cn(
"h-7 px-3 text-[10px] font-bold uppercase tracking-wider border transition-all",
isUnstaffed
? "border-blue-500/20 text-blue-400 bg-blue-500/5 hover:bg-blue-500/10 hover:border-blue-500/40"
: "border-slate-700 text-slate-400 hover:text-white hover:bg-white/5 hover:border-slate-500"
)}
>
{isUnstaffed ? 'Deploy' : 'Manage'}
</Button>
</div>
</Card>
);
}

View file

@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2, Map, MessageSquare, Users, X, Activity } from 'lucide-react';
import { WorkflowGraph } from '../shared/workflow-graph';
import { AgentAvatar } from '../shared/agent-avatar';
import { useMissionGraph } from '../../hooks/use-mission-graph';
import type { AgentRecord } from '../../lib/agent-registry';
interface MissionInspectorProps {
missionId: string;
missionTitle: string; // Passed in or fetched? Better to pass in for instant header
projectRoot: string;
assignedAgents: AgentRecord[];
onClose: () => void;
onAssign: (agentId: string, action: 'join' | 'leave') => void;
}
export function MissionInspector({
missionId,
missionTitle,
projectRoot,
assignedAgents,
onClose,
onAssign
}: MissionInspectorProps) {
const { nodes, isLoading: isGraphLoading } = useMissionGraph(projectRoot, missionId);
const [activeTab, setActiveTab] = useState('map');
return (
<div className="flex flex-col h-full bg-[#08111d] border-l border-slate-800 text-slate-200">
{/* Header */}
<div className="flex items-start justify-between p-4 border-b border-slate-800 bg-[#0d1621]">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 font-mono text-[10px]">
{missionId}
</Badge>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-bold">Active Operation</span>
</div>
<h2 className="text-sm font-semibold text-white line-clamp-2">{missionTitle}</h2>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-500 hover:text-white" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
<div className="px-4 pt-2 bg-[#0d1621] border-b border-slate-800">
<TabsList className="bg-transparent p-0 h-auto gap-4">
<TabsTrigger
value="map"
className="rounded-none border-b-2 border-transparent px-2 py-2 text-[10px] uppercase tracking-wider data-[state=active]:border-emerald-500 data-[state=active]:bg-transparent data-[state=active]:text-emerald-400"
>
<Map className="h-3 w-3 mr-1.5" />
Map
</TabsTrigger>
<TabsTrigger
value="comms"
className="rounded-none border-b-2 border-transparent px-2 py-2 text-[10px] uppercase tracking-wider data-[state=active]:border-emerald-500 data-[state=active]:bg-transparent data-[state=active]:text-emerald-400"
>
<MessageSquare className="h-3 w-3 mr-1.5" />
Comms
</TabsTrigger>
<TabsTrigger
value="squad"
className="rounded-none border-b-2 border-transparent px-2 py-2 text-[10px] uppercase tracking-wider data-[state=active]:border-emerald-500 data-[state=active]:bg-transparent data-[state=active]:text-emerald-400"
>
<Users className="h-3 w-3 mr-1.5" />
Squad <span className="ml-1 text-slate-500">{assignedAgents.length}</span>
</TabsTrigger>
</TabsList>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden relative">
<TabsContent value="map" className="h-full m-0 p-0 data-[state=active]:flex flex-col">
{isGraphLoading ? (
<div className="flex h-full items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-slate-600" /></div>
) : (
<div className="flex-1 relative bg-slate-950">
<WorkflowGraph beads={nodes} selectedId={undefined} hideClosed={false} className="h-full w-full border-0 rounded-none" />
<div className="absolute bottom-4 right-4 pointer-events-none">
<Badge variant="outline" className="bg-black/50 border-white/10 backdrop-blur text-xs">
{nodes.length} Nodes
</Badge>
</div>
</div>
)}
</TabsContent>
<TabsContent value="comms" className="h-full m-0 p-4 overflow-y-auto">
<div className="flex flex-col items-center justify-center h-full text-slate-500 space-y-2 opacity-60">
<Activity className="h-8 w-8" />
<p className="text-xs">Secure Uplink Offline</p>
<p className="text-[10px] italic">Inter-agent communication feed coming in Phase 3.2</p>
</div>
</TabsContent>
<TabsContent value="squad" className="h-full m-0">
<ScrollArea className="h-full">
<div className="p-4 space-y-3">
{assignedAgents.length === 0 ? (
<div className="text-center py-8 text-slate-500 text-xs">
No agents deployed.
</div>
) : (
assignedAgents.map(agent => (
<div key={agent.agent_id} className="flex items-center justify-between p-3 rounded-lg bg-slate-800/40 border border-slate-800">
<div className="flex items-center gap-3">
<AgentAvatar name={agent.display_name} status={agent.status as any} />
<div>
<p className="text-sm font-medium text-slate-200">{agent.display_name}</p>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
<span className="uppercase font-bold tracking-wider">{agent.role}</span>
<span></span>
<span className="font-mono">{agent.status}</span>
</div>
</div>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs text-rose-400 hover:text-rose-300 hover:bg-rose-950/30" onClick={() => onAssign(agent.agent_id, 'leave')}>
Dismiss
</Button>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,107 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import type { SwarmTopologyData } from '../../hooks/use-swarm-topology';
interface SwarmGraphProps {
topology: SwarmTopologyData | null;
isLoading: boolean;
}
export function SwarmGraph({ topology, isLoading }: SwarmGraphProps) {
const nodes = useMemo(() => {
if (!topology) return [];
// Simple layout strategy: Clusters
// Done: Left side
// Active: Center
// Ready: Right
// Blocked: Bottom Right
const output: React.ReactNode[] = [];
const scale = 0.5;
// 1. Completed (Green Cluster)
topology.completed.forEach((item, i) => {
const col = i % 5;
const row = Math.floor(i / 5);
output.push(
<circle
key={`done-${item.id}`}
cx={20 + (col * 8)}
cy={20 + (row * 8)}
r={2.5}
fill="#34d399"
opacity={0.5}
/>
);
});
// 2. Active (Pulsing Center)
topology.active.forEach((item, i) => {
const cx = 140 + (i * 20);
const cy = 30 + (i % 2) * 10;
output.push(
<g key={`active-${item.id}`}>
<circle cx={cx} cy={cy} r={6} fill="#10b981" className="animate-pulse" />
<circle cx={cx} cy={cy} r={3} fill="#ecfdf5" />
</g>
);
});
// 3. Ready (White Pipeline)
topology.ready.forEach((item, i) => {
const cx = 220 + (i * 10);
const cy = 30;
output.push(
<circle key={`ready-${item.id}`} cx={cx} cy={cy} r={3} fill="#94a3b8" />
);
});
// 4. Blocked (Red Hazard)
topology.blocked.forEach((item, i) => {
const cx = 220 + (i * 10);
const cy = 50;
output.push(
<circle key={`blocked-${item.id}`} cx={cx} cy={cy} r={3} fill="#f43f5e" />
);
});
return output;
}, [topology]);
if (isLoading) {
return (
<div className="h-16 w-full flex items-center justify-center bg-black/20 rounded-lg animate-pulse">
<span className="text-[10px] text-slate-600 font-mono">SCANNING TOPOLOGY...</span>
</div>
);
}
if (!topology || (topology.completed.length === 0 && topology.active.length === 0 && topology.ready.length === 0)) {
return (
<div className="h-16 w-full flex items-center justify-center bg-black/20 rounded-lg border border-dashed border-slate-800">
<span className="text-[10px] text-slate-600 font-mono">EMPTY SIGNAL</span>
</div>
);
}
return (
<div className="h-16 w-full bg-black/40 rounded-lg border border-white/5 overflow-hidden relative">
<svg width="100%" height="100%" viewBox="0 0 300 64" preserveAspectRatio="xMidYMid meet">
{/* Connection Lines (Abstract) */}
<path d="M 60 30 L 130 30" stroke="#334155" strokeWidth="1" strokeDasharray="4 4" />
<path d="M 180 30 L 210 30" stroke="#334155" strokeWidth="1" strokeDasharray="4 4" />
{nodes}
{/* Labels */}
<text x="30" y="55" fontSize="8" fill="#475569" textAnchor="middle" fontFamily="monospace">DONE</text>
<text x="150" y="55" fontSize="8" fill="#10b981" textAnchor="middle" fontFamily="monospace" fontWeight="bold">ACTIVE</text>
<text x="240" y="15" fontSize="8" fill="#94a3b8" textAnchor="middle" fontFamily="monospace">READY</text>
<text x="240" y="60" fontSize="8" fill="#f43f5e" textAnchor="middle" fontFamily="monospace">BLOCKED</text>
</svg>
</div>
);
}

View file

@ -0,0 +1,255 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { useAgentPool } from '../../hooks/use-agent-pool';
import { Loader2, Plus, Minus, ShieldCheck, Search, Users, ChevronLeft, Save } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import type { AgentRecord } from '../../lib/agent-registry';
interface TeamManagerDialogProps {
isOpen: boolean;
onClose: () => void;
missionId: string;
missionTitle: string;
projectRoot: string;
assignedAgents: AgentRecord[];
onAssign: (agentId: string, action: 'join' | 'leave') => Promise<void>;
}
export function TeamManagerDialog({
isOpen,
onClose,
missionId,
missionTitle,
projectRoot,
assignedAgents,
onAssign
}: TeamManagerDialogProps) {
const { agents, isLoading, refresh } = useAgentPool(projectRoot);
const [search, setSearch] = useState('');
const [pendingAction, setPendingAction] = useState<string | null>(null);
// Creation Mode State
const [isCreating, setIsCreating] = useState(false);
const [newName, setNewName] = useState('');
const [newRole, setNewRole] = useState('');
const [newInstructions, setNewInstructions] = useState('');
const [isSaving, setIsSaving] = useState(false);
const assignedIds = new Set(assignedAgents.map(a => a.agent_id));
const availableAgents = agents.filter(a =>
!assignedIds.has(a.agent_id) &&
(a.display_name.toLowerCase().includes(search.toLowerCase()) ||
a.role.toLowerCase().includes(search.toLowerCase()))
);
const handleAction = async (agentId: string, action: 'join' | 'leave') => {
setPendingAction(agentId);
try {
await onAssign(agentId, action);
} finally {
setPendingAction(null);
}
};
const handleCreateAgent = async () => {
if (!newName || !newRole) return;
setIsSaving(true);
try {
const res = await fetch('/api/agent/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectRoot, name: newName, role: newRole, instructions: newInstructions })
});
if (res.ok) {
await refresh();
setIsCreating(false);
setNewName('');
setNewRole('');
setNewInstructions('');
}
} catch (e) {
console.error(e);
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl bg-[#08111d] border-slate-800 text-slate-200">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-emerald-500" />
Manage Mission Squad
<Badge variant="outline" className="ml-2 border-slate-700 text-slate-400 font-mono font-normal">
{missionId}
</Badge>
</DialogTitle>
<p className="text-sm text-slate-400">{missionTitle}</p>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 h-[450px] mt-4">
{/* Left: Available Pool / Creation Form */}
<div className="flex flex-col gap-2 rounded-lg border border-slate-800 bg-[#0d1621] p-3 transition-all relative overflow-hidden">
{isCreating ? (
// CREATION FORM
<div className="flex flex-col h-full animate-in slide-in-from-left-4 fade-in duration-200">
<div className="flex items-center justify-between mb-4">
<Button variant="ghost" size="sm" onClick={() => setIsCreating(false)} className="-ml-2 text-slate-400 hover:text-white">
<ChevronLeft className="h-4 w-4 mr-1" /> Back
</Button>
<h4 className="text-xs font-bold uppercase tracking-wider text-emerald-500">Draft New Agent</h4>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-semibold">Codename</label>
<Input
placeholder="e.g. Data Miner"
className="bg-slate-900 border-slate-700"
value={newName}
onChange={e => setNewName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-semibold">Role</label>
<Input
placeholder="e.g. data-engineer"
className="bg-slate-900 border-slate-700 font-mono text-xs"
value={newRole}
onChange={e => setNewRole(e.target.value)}
/>
</div>
<div className="space-y-1 flex-1 flex flex-col">
<label className="text-[10px] uppercase text-slate-500 font-semibold">Directives / Instructions</label>
<Textarea
placeholder="Primary directive: Extract data from..."
className="bg-slate-900 border-slate-700 flex-1 resize-none text-xs leading-relaxed"
value={newInstructions}
onChange={e => setNewInstructions(e.target.value)}
/>
</div>
</div>
<Button
onClick={handleCreateAgent}
disabled={!newName || !newRole || isSaving}
className="mt-4 bg-emerald-600 hover:bg-emerald-500 text-white w-full"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Save className="h-4 w-4 mr-2" /> Recruit Agent</>}
</Button>
</div>
) : (
// LIST VIEW
<>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500">Available Resources</h4>
<Badge variant="secondary" className="bg-slate-800 text-slate-400">{availableAgents.length}</Badge>
</div>
<div className="flex gap-2 mb-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-3 w-3 text-slate-500" />
<Input
placeholder="Search agents..."
className="h-8 pl-7 bg-slate-900 border-slate-700 text-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
size="icon"
variant="outline"
className="h-8 w-8 border-slate-700 border-dashed text-slate-400 hover:text-emerald-400 hover:border-emerald-500/50"
onClick={() => setIsCreating(true)}
title="Draft New Agent"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1 pr-3">
{isLoading ? (
<div className="flex justify-center p-4"><Loader2 className="h-5 w-5 animate-spin text-slate-500" /></div>
) : (
<div className="space-y-2">
{availableAgents.map(agent => (
<div key={agent.agent_id} className="flex items-center justify-between p-2 rounded hover:bg-white/5 transition-colors group">
<div className="flex items-center gap-2">
<AgentAvatar name={agent.display_name} status={agent.status as any} size="sm" />
<div>
<p className="text-xs font-medium text-slate-200">{agent.display_name}</p>
<p className="text-[10px] text-slate-500 uppercase">{agent.role}</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 opacity-0 group-hover:opacity-100 hover:bg-emerald-500/20 hover:text-emerald-400"
onClick={() => handleAction(agent.agent_id, 'join')}
disabled={!!pendingAction}
>
{pendingAction === agent.agent_id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
</Button>
</div>
))}
</div>
)}
</ScrollArea>
</>
)}
</div>
{/* Right: Assigned Squad */}
<div className="flex flex-col gap-2 rounded-lg border border-emerald-900/30 bg-emerald-950/10 p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-emerald-500">Deployed Squad</h4>
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400">{assignedAgents.length}</Badge>
</div>
<ScrollArea className="flex-1 pr-3">
<div className="space-y-2">
{assignedAgents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-500 text-xs italic border border-dashed border-emerald-900/30 rounded bg-emerald-950/20">
<Users className="h-8 w-8 mb-2 opacity-20" />
No agents assigned
</div>
) : (
assignedAgents.map(agent => (
<div key={agent.agent_id} className="flex items-center justify-between p-2 rounded bg-emerald-500/5 border border-emerald-500/10">
<div className="flex items-center gap-2">
<AgentAvatar name={agent.display_name} status={agent.status as any} size="sm" />
<div>
<p className="text-xs font-medium text-emerald-100">{agent.display_name}</p>
<p className="text-[10px] text-emerald-500/70 uppercase">{agent.role}</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 hover:bg-rose-500/20 hover:text-rose-400"
onClick={() => handleAction(agent.agent_id, 'leave')}
disabled={!!pendingAction}
>
{pendingAction === agent.agent_id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Minus className="h-3 w-3" />}
</Button>
</div>
))
)}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} className="border-slate-700 text-slate-300">Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,320 +1,442 @@
'use client';
import { useState, useMemo } from 'react';
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Star } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import { useResponsive } from '../../hooks/use-responsive';
import { cn } from '../../lib/utils';
import { useUrlState, type ViewType } from '../../hooks/use-url-state';
export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
export type LeftPanelPresetFilter = 'all' | 'active' | 'blocked_agents';
export interface LeftPanelFilters {
query: string;
status: LeftPanelStatusFilter;
priority: LeftPanelPriorityFilter;
preset: LeftPanelPresetFilter;
hideClosed: boolean;
}
export interface LeftPanelProps {
issues: BeadIssue[];
selectedEpicId?: string | null;
onEpicSelect?: (epicId: string | null) => void;
filters: LeftPanelFilters;
onFiltersChange: (filters: LeftPanelFilters) => void;
}
interface EpicNode {
interface EpicEntry {
epic: BeadIssue;
children: BeadIssue[];
stats: {
total: number;
closed: number;
in_progress: number;
blocked: number;
ready: number;
lastActivity: number;
};
status: 'blocked' | 'in_progress' | 'ready' | 'done' | 'empty';
blockedCount: number;
activeCount: number;
readyCount: number;
deferredCount: number;
doneCount: number;
agentBlockedCount: number;
latestTimestamp: string;
}
function buildEpicTree(issues: BeadIssue[]): EpicNode[] {
const epics = issues.filter(issue => issue.issue_type === 'epic');
const epicMap = new Map<string, EpicNode>();
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
if (task.status === 'open') return 'ready';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'blocked') return 'blocked';
if (task.status === 'closed' || task.status === 'tombstone') return 'done';
return 'deferred';
}
for (const epic of epics) {
epicMap.set(epic.id, {
epic,
children: [],
stats: { total: 0, closed: 0, in_progress: 0, blocked: 0, ready: 0, lastActivity: new Date(epic.updated_at).getTime() },
status: 'empty'
});
function mapPriority(task: BeadIssue): LeftPanelPriorityFilter {
if (task.priority <= 0) return 'P0';
if (task.priority === 1) return 'P1';
if (task.priority === 2) return 'P2';
if (task.priority === 3) return 'P3';
return 'P4';
}
function formatRelative(timestamp: string): string {
const then = new Date(timestamp);
const now = new Date();
const diffMinutes = Math.floor((now.getTime() - then.getTime()) / 60000);
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
function buildEntries(issues: BeadIssue[]): EpicEntry[] {
const epics = issues.filter((issue) => issue.issue_type === 'epic');
const tasks = issues.filter((issue) => issue.issue_type !== 'epic');
const taskById = new Map(tasks.map((task) => [task.id, task] as const));
const incomingBlockers = new Map<string, string[]>();
for (const task of tasks) {
incomingBlockers.set(task.id, []);
}
for (const issue of issues) {
if (issue.issue_type === 'epic') continue;
const parentDep = issue.dependencies.find(dep => dep.type === 'parent');
if (parentDep && epicMap.has(parentDep.target)) {
const node = epicMap.get(parentDep.target)!;
node.children.push(issue);
node.stats.total++;
if (issue.status === 'closed') node.stats.closed++;
else if (issue.status === 'blocked') node.stats.blocked++;
else if (issue.status === 'in_progress') node.stats.in_progress++;
else node.stats.ready++; // open/ready
const issueTime = new Date(issue.updated_at).getTime();
if (issueTime > node.stats.lastActivity) node.stats.lastActivity = issueTime;
for (const task of tasks) {
for (const dependency of task.dependencies) {
if (dependency.type !== 'blocks') continue;
if (!taskById.has(dependency.target)) continue;
const current = incomingBlockers.get(dependency.target) ?? [];
current.push(task.id);
incomingBlockers.set(dependency.target, current);
}
}
// Determine Aggregate Status
for (const node of epicMap.values()) {
if (node.stats.blocked > 0) node.status = 'blocked';
else if (node.stats.in_progress > 0) node.status = 'in_progress';
else if (node.stats.ready > 0) node.status = 'ready';
else if (node.stats.total > 0 && node.stats.closed === node.stats.total) node.status = 'done';
else node.status = 'empty';
}
return Array.from(epicMap.values()).sort((a, b) => {
// Sort by status priority (Blocked > In Progress > Ready > Done) then Recency
const statusScore = { blocked: 4, in_progress: 3, ready: 2, done: 1, empty: 0 };
const scoreDiff = statusScore[b.status] - statusScore[a.status];
if (scoreDiff !== 0) return scoreDiff;
return b.stats.lastActivity - a.stats.lastActivity;
});
}
function StatusIndicator({ status }: { status: string }) {
const styles = {
blocked: 'bg-[#C97A7A] shadow-[0_0_8px_rgba(201,122,122,0.45)]',
in_progress: 'bg-[#D4A574] shadow-[0_0_8px_rgba(212,165,116,0.45)]',
ready: 'bg-[#7CB97A] shadow-[0_0_8px_rgba(124,185,122,0.45)]',
done: 'bg-[var(--status-closed)]',
empty: 'bg-white/10',
}[status] || 'bg-slate-500';
return <div className={cn("w-1.5 h-1.5 rounded-full shrink-0", styles)} />;
}
export function LeftPanel({
issues,
selectedEpicId,
onEpicSelect,
}: LeftPanelProps) {
const [expandedEpics, setExpandedEpics] = useState<Set<string>>(new Set());
const { isDesktop, isTablet } = useResponsive();
const epicTree = useMemo(() => buildEpicTree(issues), [issues]);
const featuredEpics = useMemo(() => epicTree.slice(0, 2), [epicTree]);
const standardEpics = useMemo(() => epicTree.slice(2, 6), [epicTree]);
const compactEpics = useMemo(() => epicTree.slice(6), [epicTree]);
const toggleEpic = (epicId: string) => {
setExpandedEpics(prev => {
const next = new Set(prev);
if (next.has(epicId)) {
next.delete(epicId);
} else {
next.add(epicId);
}
return next;
const isEffectivelyBlocked = (task: BeadIssue): boolean => {
if (task.status === 'blocked') return true;
if (task.status === 'closed' || task.status === 'tombstone') return false;
const blockers = incomingBlockers.get(task.id) ?? [];
return blockers.some((blockerId) => {
const blocker = taskById.get(blockerId);
return blocker ? blocker.status !== 'closed' && blocker.status !== 'tombstone' : false;
});
};
const handleEpicClick = (epicId: string) => {
onEpicSelect?.(epicId === selectedEpicId ? null : epicId);
toggleEpic(epicId);
};
return epics
.map((epic) => {
const children = tasks
.filter((task) => task.dependencies.some((dep) => dep.type === 'parent' && dep.target === epic.id))
.sort((a, b) => a.id.localeCompare(b.id));
const blockedCount = children.filter((task) => isEffectivelyBlocked(task)).length;
const activeCount = children.filter((task) => task.status === 'in_progress').length;
const readyCount = children.filter((task) => task.status === 'open' && !isEffectivelyBlocked(task)).length;
const deferredCount = children.filter((task) => task.status === 'deferred').length;
const doneCount = children.filter((task) => task.status === 'closed' || task.status === 'tombstone').length;
const agentBlockedCount = children.filter(
(task) =>
isEffectivelyBlocked(task) &&
(Boolean(task.assignee) ||
task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:') || label.startsWith('gt:agent:'))),
).length;
const latestTimestamp = [epic.updated_at, ...children.map((child) => child.updated_at)]
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ?? epic.updated_at;
return {
epic,
children,
blockedCount,
activeCount,
readyCount,
deferredCount,
doneCount,
agentBlockedCount,
latestTimestamp,
};
})
.sort((a, b) => a.epic.id.localeCompare(b.epic.id));
}
if (isTablet) {
return (
<div className="flex w-16 flex-col items-center gap-2 overflow-y-auto bg-[var(--color-bg-card)]/96 py-4 shadow-[10px_0_28px_-16px_rgba(0,0,0,0.82)]">
{epicTree.map(({ epic, status }) => (
<button
key={epic.id}
onClick={() => handleEpicClick(epic.id)}
className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center text-xs font-bold transition-all duration-200 ring-1',
selectedEpicId === epic.id
? 'bg-[var(--color-bg-input)] ring-white/30 text-white'
: 'ring-transparent text-[var(--color-text-muted)] hover:bg-white/5',
status === 'blocked' && 'ring-[#C97A7A]/50',
status === 'in_progress' && 'ring-[#D4A574]/50'
)}
>
{epic.id.slice(0, 2).toUpperCase()}
</button>
))}
</div>
);
function statusDot(status: BeadIssue['status']): string {
if (status === 'blocked') return 'bg-[var(--ui-accent-blocked)]';
if (status === 'in_progress') return 'bg-[var(--ui-accent-warning)]';
if (status === 'closed') return 'bg-[var(--ui-text-muted)]';
return 'bg-[var(--ui-accent-ready)]';
}
function rowTone(entry: EpicEntry): string {
if (entry.blockedCount > 0) {
return '#22111a';
}
if (entry.activeCount > 0) {
return '#221a11';
}
if (entry.readyCount > 0) {
return '#0f221c';
}
return '#111f2b';
}
function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
if (filters.hideClosed && (task.status === 'closed' || task.status === 'tombstone')) return false;
const normalizedQuery = filters.query.trim().toLowerCase();
if (normalizedQuery.length > 0) {
const searchable = `${task.id} ${task.title} ${task.labels.join(' ')}`.toLowerCase();
if (!searchable.includes(normalizedQuery)) return false;
}
if (filters.status !== 'all' && mapStatus(task) !== filters.status) return false;
if (filters.priority !== 'all' && mapPriority(task) !== filters.priority) return false;
if (filters.preset === 'active' && task.status !== 'in_progress') return false;
if (
filters.preset === 'blocked_agents' &&
!(
task.status === 'blocked' &&
(Boolean(task.assignee) || task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:')))
)
) {
return false;
}
return true;
}
export function LeftPanel({ issues, selectedEpicId, onEpicSelect, filters, onFiltersChange }: LeftPanelProps) {
const { view, setView } = useUrlState();
const entries = useMemo(() => buildEntries(issues), [issues]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const hasActiveFilters =
filters.query.trim().length > 0 ||
filters.status !== 'all' ||
filters.priority !== 'all' ||
filters.preset !== 'all' ||
filters.hideClosed;
const views: Array<{ id: ViewType; label: string }> = [
{ id: 'social', label: 'Social' },
{ id: 'graph', label: 'Graph' },
{ id: 'swarm', label: 'Swarm' },
];
return (
<div
className={cn(
'flex flex-col h-full overflow-hidden transition-all duration-300',
!isDesktop && 'hidden lg:flex'
)}
style={{ width: '20rem' }}
data-testid="left-panel"
>
<div className="flex h-full flex-col bg-[radial-gradient(circle_at_4%_14%,rgba(212,165,116,0.38),transparent_44%),radial-gradient(circle_at_96%_86%,rgba(91,168,160,0.34),transparent_40%),linear-gradient(165deg,rgba(49,49,62,0.97),rgba(37,40,54,0.98))] shadow-[14px_0_34px_-18px_rgba(0,0,0,0.86)]">
{/* Header */}
<div className="flex items-center justify-between bg-[linear-gradient(90deg,rgba(212,165,116,0.16),rgba(91,168,160,0.12))] p-5 shadow-[0_12px_22px_-18px_rgba(0,0,0,0.9)]">
<span className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Workstreams</span>
<div className="flex gap-2 text-[10px] font-mono text-[var(--color-text-muted)]/60">
<span>{epicTree.length} ACTIVE</span>
<aside className="flex h-full flex-col bg-[var(--ui-bg-shell)] shadow-[inset_-1px_0_0_rgba(0,0,0,0.55),24px_0_40px_-34px_rgba(0,0,0,0.98)]" data-testid="left-panel">
<div className="px-4 py-3 shadow-[0_14px_24px_-20px_rgba(0,0,0,0.92)]">
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[#101c2b] p-1 shadow-[0_12px_24px_-18px_rgba(0,0,0,0.88)]">
{views.map((item) => {
const active = view === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setView(item.id)}
className={cn(
'flex-1 rounded-lg px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]',
active
? 'bg-[#183149] text-[var(--ui-text-primary)]'
: 'text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)]',
)}
>
{item.label}
</button>
);
})}
</div>
<div className="space-y-2 rounded-xl bg-[#101a27] p-2.5 shadow-[0_16px_26px_-20px_rgba(0,0,0,0.92)]">
<div className="grid grid-cols-1 gap-2">
<input
value={filters.query}
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
className="ui-field rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
placeholder="Filter Tasks…"
aria-label="Filter tasks"
autoComplete="off"
/>
<div className="grid grid-cols-2 gap-2">
<select
value={filters.status}
onChange={(event) => onFiltersChange({ ...filters, status: event.target.value as LeftPanelStatusFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Status filter"
>
<option className="ui-option" value="all">All Status</option>
<option className="ui-option" value="ready">Ready</option>
<option className="ui-option" value="in_progress">In Progress</option>
<option className="ui-option" value="blocked">Blocked</option>
<option className="ui-option" value="deferred">Deferred</option>
<option className="ui-option" value="done">Done</option>
</select>
<select
value={filters.priority}
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value as LeftPanelPriorityFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Priority filter"
>
<option className="ui-option" value="all">All Priority</option>
<option className="ui-option" value="P0">P0</option>
<option className="ui-option" value="P1">P1</option>
<option className="ui-option" value="P2">P2</option>
<option className="ui-option" value="P3">P3</option>
<option className="ui-option" value="P4">P4</option>
</select>
</div>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'active' ? 'all' : 'active' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] shadow-[0_8px_18px_-16px_rgba(0,0,0,0.9)] transition-colors',
filters.preset === 'active'
? 'bg-[#2f2618] text-[var(--ui-accent-warning)]'
: 'bg-[#0f1824] text-[var(--ui-text-muted)]',
)}
aria-pressed={filters.preset === 'active'}
>
Active
</button>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'blocked_agents' ? 'all' : 'blocked_agents' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] shadow-[0_8px_18px_-16px_rgba(0,0,0,0.9)] transition-colors',
filters.preset === 'blocked_agents'
? 'bg-[#2f1621] text-[var(--ui-accent-blocked)]'
: 'bg-[#0f1824] text-[var(--ui-text-muted)]',
)}
aria-pressed={filters.preset === 'blocked_agents'}
>
Agent Blocked
</button>
</div>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, hideClosed: !filters.hideClosed })}
className={cn(
'w-full rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] shadow-[0_8px_18px_-16px_rgba(0,0,0,0.9)] transition-colors',
filters.hideClosed
? 'bg-[#1d2b1a] text-[var(--ui-accent-ready)]'
: 'bg-[#0f1824] text-[var(--ui-text-muted)]',
)}
aria-pressed={filters.hideClosed}
>
Hide Closed
</button>
</div>
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--ui-text-muted)]">Navigation / Epics</p>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
{entries.map((entry) => {
const {
epic,
children,
blockedCount,
activeCount,
readyCount,
deferredCount,
doneCount,
agentBlockedCount,
latestTimestamp,
} = entry;
const matchedChildren = children.filter((task) => isTaskMatch(task, filters));
const total = children.length;
const donePercent = total > 0 ? Math.round((doneCount / total) * 100) : 0;
const readyPercent = total > 0 ? Math.round((readyCount / total) * 100) : 0;
const activePercent = total > 0 ? Math.round((activeCount / total) * 100) : 0;
const blockedPercent = total > 0 ? Math.round((blockedCount / total) * 100) : 0;
const isExpanded = expanded[epic.id] ?? false;
const isSelected = selectedEpicId === epic.id;
const laneColor = blockedCount > 0 ? 'var(--ui-accent-blocked)' : activeCount > 0 ? 'var(--ui-accent-warning)' : 'var(--ui-accent-ready)';
const rowBackground = rowTone(entry);
if (matchedChildren.length === 0 && hasActiveFilters && !isSelected) {
return null;
}
return (
<div key={epic.id} className="mb-2">
<div
className={cn(
'rounded-2xl px-3 py-3 transition-colors shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)]',
isSelected
? 'text-[var(--ui-text-primary)] ring-1 ring-[rgba(143,156,175,0.45)]'
: 'text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)]',
)}
style={{
boxShadow: `inset 3px 0 0 ${laneColor}, 0 18px 30px -24px rgba(0,0,0,0.95)`,
background: rowBackground,
}}
>
<div className="mb-1.5 flex items-start gap-2">
<button
type="button"
onClick={() => setExpanded((current) => ({ ...current, [epic.id]: !isExpanded }))}
className="mt-0.5 inline-flex h-4 w-4 items-center justify-center rounded text-[var(--ui-text-muted)] transition-colors hover:bg-white/5 hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
aria-label={isExpanded ? `Collapse ${epic.title}` : `Expand ${epic.title}`}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" /> : <ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />}
</button>
<button
type="button"
onClick={() => onEpicSelect?.(isSelected ? null : epic.id)}
className="min-w-0 flex-1 text-left"
>
<div className="flex min-w-0 items-center gap-1.5">
{isExpanded ? <FolderOpen className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" /> : <Folder className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
<p className="truncate text-[15px] font-semibold leading-tight text-[var(--ui-text-primary)]">{epic.title}</p>
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-[var(--ui-text-muted)]">{epic.id}</p>
</button>
<button
type="button"
onClick={() => onEpicSelect?.(epic.id)}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-[#0e1823] text-[var(--ui-text-muted)] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.9)] transition-colors hover:text-[var(--ui-text-primary)]"
aria-label={`Focus ${epic.title}`}
>
<Star className="h-3 w-3" aria-hidden="true" />
</button>
</div>
<div className="flex items-center gap-3 text-[11px]">
<p><span className="text-[var(--ui-text-primary)]">{total}</span> tasks</p>
<p><span className="text-[var(--ui-accent-warning)]">{activeCount}</span> active</p>
<p><span className="text-[var(--ui-accent-blocked)]">{agentBlockedCount}</span> ag-blocked</p>
<p className="ml-auto text-[var(--ui-text-muted)]">{formatRelative(latestTimestamp)}</p>
</div>
<div className="mt-2">
<div className="h-1.5 overflow-hidden rounded-full bg-[#0a111a]">
<div className="flex h-full w-full">
<div style={{ width: `${readyPercent}%`, background: 'var(--ui-accent-ready)' }} />
<div style={{ width: `${activePercent}%`, background: 'var(--ui-accent-warning)' }} />
<div style={{ width: `${blockedPercent}%`, background: 'var(--ui-accent-blocked)' }} />
<div style={{ width: `${Math.max(0, 100 - readyPercent - activePercent - blockedPercent)}%`, background: 'var(--ui-text-muted)' }} />
</div>
</div>
<div className="mt-1 flex items-center justify-between text-[10px] text-[var(--ui-text-muted)]">
<span>{donePercent}% done</span>
<span><span className="text-[var(--ui-accent-ready)]">{readyCount}</span> ready</span>
</div>
</div>
{deferredCount + doneCount + blockedCount > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] text-[var(--ui-text-muted)]">
{blockedCount > 0 ? <span>{blockedCount} blocked</span> : null}
{deferredCount > 0 ? <span>{deferredCount} deferred</span> : null}
{doneCount > 0 ? <span>{doneCount} done</span> : null}
</div>
) : null}
</div>
{isExpanded ? (
<div className="ml-8 mt-1 space-y-1 pl-3">
{matchedChildren.slice(0, 7).map((task) => (
<button
key={task.id}
type="button"
onClick={() => onEpicSelect?.(epic.id)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[#112133] hover:text-[var(--ui-text-primary)]"
>
<span className={cn('h-1.5 w-1.5 rounded-full', statusDot(task.status))} />
<span className="min-w-0 flex-1 truncate">{task.title}</span>
<span className="font-mono text-[10px] text-[var(--ui-text-muted)]">{task.id}</span>
</button>
))}
{matchedChildren.length > 7 ? (
<p className="px-1.5 py-0.5 text-[10px] text-[var(--ui-text-muted)]">+ {matchedChildren.length - 7} more</p>
) : null}
</div>
) : null}
</div>
);
})}
</div>
<footer className="border-t border-[var(--ui-border-soft)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-[linear-gradient(135deg,#9cb6bf,#f1dcc6)]" />
<div>
<p className="text-sm font-semibold text-[var(--ui-text-primary)]">Alex Chen</p>
<p className="text-xs text-[var(--ui-text-muted)]">Lead Ops</p>
</div>
</div>
{/* Tree */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-3 space-y-4">
{[
{ label: 'Featured', items: featuredEpics, tier: 'featured' as const },
{ label: 'Active', items: standardEpics, tier: 'standard' as const },
{ label: 'Queue', items: compactEpics, tier: 'compact' as const },
].map((section) => (
<div key={section.label} className={cn(section.items.length === 0 && 'hidden')}>
<p className="mb-2 px-1 text-[10px] font-bold uppercase tracking-[0.16em] text-[#97A0AF]/75">
{section.label}
</p>
<div className={cn(section.tier === 'compact' ? 'space-y-1.5' : 'space-y-2.5')}>
{section.items.map(({ epic, children, stats, status }) => {
const isExpanded = expandedEpics.has(epic.id);
const isSelected = selectedEpicId === epic.id;
const statusStyle = {
blocked:
'bg-[radial-gradient(circle_at_100%_50%,rgba(201,122,122,0.3),transparent_58%),rgba(92,58,58,0.8)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(201,122,122,0.38),transparent_58%),rgba(106,64,64,0.85)]',
in_progress:
'bg-[radial-gradient(circle_at_100%_50%,rgba(212,165,116,0.34),transparent_58%),rgba(92,70,45,0.82)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(212,165,116,0.44),transparent_58%),rgba(108,82,51,0.88)]',
ready:
'bg-[radial-gradient(circle_at_100%_50%,rgba(124,185,122,0.34),transparent_60%),rgba(54,84,55,0.84)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(124,185,122,0.44),transparent_60%),rgba(61,95,61,0.9)]',
done:
'bg-[radial-gradient(circle_at_100%_50%,rgba(91,168,160,0.3),transparent_58%),rgba(52,72,77,0.78)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(91,168,160,0.38),transparent_58%),rgba(59,82,87,0.84)]',
empty:
'bg-[radial-gradient(circle_at_100%_50%,rgba(74,104,130,0.2),transparent_58%),rgba(44,49,65,0.76)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(74,104,130,0.28),transparent_58%),rgba(49,56,74,0.82)]',
}[status];
if (section.tier === 'compact') {
return (
<button
key={epic.id}
type="button"
onClick={() => onEpicSelect?.(epic.id === selectedEpicId ? null : epic.id)}
className={cn(
'w-full rounded-lg px-2.5 py-2 text-left transition-all duration-200',
'flex items-center justify-between gap-2',
statusStyle,
isSelected
? 'shadow-[0_14px_22px_-14px_rgba(0,0,0,0.88),0_0_0_1px_rgba(255,255,255,0.08)_inset]'
: 'shadow-[0_8px_16px_-12px_rgba(0,0,0,0.82)]',
)}
>
<div className="min-w-0">
<p className="truncate font-mono text-[10px] text-[#C7D0DF]/70">{epic.id}</p>
<p className="truncate text-xs font-semibold text-white/90">{epic.title}</p>
</div>
<div className="shrink-0 text-right">
<p className="text-[10px] font-mono text-[#C7D0DF]/70">{stats.total}</p>
<StatusIndicator status={status} />
</div>
</button>
);
}
const isFeatured = section.tier === 'featured';
const cardPadding = isFeatured ? 'p-4' : 'p-3';
const titleClass = isFeatured ? 'text-base' : 'text-sm';
const activeStyle = isSelected
? 'shadow-[0_24px_34px_-16px_rgba(0,0,0,0.9),0_0_0_1px_rgba(255,255,255,0.08)_inset] scale-[1.01]'
: 'shadow-[0_10px_20px_-14px_rgba(0,0,0,0.85)]';
return (
<div key={epic.id} className="group">
<button
type="button"
onClick={() => handleEpicClick(epic.id)}
className={cn(
'w-full flex flex-col rounded-xl text-left transition-all duration-300 relative overflow-hidden',
cardPadding,
statusStyle,
activeStyle,
)}
>
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-1.5',
status === 'blocked'
? 'bg-[#C97A7A]'
: status === 'in_progress'
? 'bg-[#D4A574]'
: status === 'ready'
? 'bg-[#7CB97A]'
: 'bg-[#5BA8A0]',
)}
/>
<div className="pl-3 w-full">
<div className="flex items-center justify-between w-full mb-1">
<span className="text-[10px] font-mono text-text-muted/70 tracking-tight">{epic.id}</span>
{stats.blocked > 0 && (
<span className="rounded bg-[color:rgba(201,122,122,0.24)] px-1.5 text-[9px] font-bold text-[#F0C9C9]">
{stats.blocked} BLOCKED
</span>
)}
</div>
<div className={cn('truncate font-bold text-white/90 mb-2 leading-snug', titleClass)}>
{epic.title}
</div>
<div className="flex h-1.5 w-full items-center gap-1 overflow-hidden rounded-full bg-black/20">
<div style={{ width: `${(stats.closed / (stats.total || 1)) * 100}%` }} className="h-full bg-[#5BA8A0]/75" />
<div style={{ width: `${(stats.in_progress / (stats.total || 1)) * 100}%` }} className="h-full bg-[#D4A574]" />
<div style={{ width: `${(stats.blocked / (stats.total || 1)) * 100}%` }} className="h-full bg-[#C97A7A]" />
<div style={{ width: `${(stats.ready / (stats.total || 1)) * 100}%` }} className="h-full bg-[#7CB97A]" />
</div>
<div className="flex justify-between mt-1.5 text-[9px] font-mono text-text-muted/50">
<span>{Math.round(((stats.closed + stats.in_progress) / (stats.total || 1)) * 100)}% Done</span>
<span>{stats.total} Tasks</span>
</div>
</div>
</button>
{isExpanded && children.length > 0 && (
<div className="ml-4 mt-2 space-y-1 pl-3">
{children.slice(0, 5).map((child) => (
<div
key={child.id}
className="group/child flex cursor-pointer items-center justify-between rounded px-2 py-1.5 transition-colors hover:bg-[rgba(212,165,116,0.16)]"
>
<span className="text-[10px] font-mono text-text-muted/60">{child.id}</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-text-muted/60 truncate max-w-[80px]">{child.title}</span>
<StatusIndicator status={child.status} />
</div>
</div>
))}
{children.length > 5 && (
<div className="px-2 py-1 text-[9px] text-text-muted/30 italic">+{children.length - 5} more</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="bg-black/18 p-4 shadow-[0_-10px_22px_-18px_rgba(0,0,0,0.82)]">
<label className="group flex cursor-pointer items-center gap-3 rounded px-2 py-1 transition-colors hover:bg-white/5">
<div className={`h-3 w-3 rounded-full ${selectedEpicId === null ? 'bg-[var(--status-ready)] shadow-[0_0_8px_rgba(124,185,122,0.7)]' : 'bg-white/25'}`} />
<span className={cn(
"text-xs font-medium transition-colors",
selectedEpicId === null ? "text-[#9BD2CB]" : "text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]"
)}>
Global Scope
</span>
</label>
</div>
</div>
</div>
</footer>
</aside>
);
}

View file

@ -13,24 +13,22 @@ export interface RightPanelProps {
export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPanelProps) {
const { isMobile, isDesktop } = useResponsive();
const { panel, togglePanel } = useUrlState();
const { rightPanel, toggleRightPanel } = useUrlState();
const isOpen = externalIsOpen ?? (panel === 'open');
const isOpen = externalIsOpen ?? (rightPanel === 'open');
// Calculate width based on content (Standard 17rem vs Chat Mode ~26rem)
// If rail is present, we are in "Chat Mode" (Wide Panel + Rail)
// If no rail, we are in "Activity Mode" (Standard Panel)
const panelWidth = isOpen ? (rail ? '26rem' : '17rem') : '0';
const panelWidth = isOpen ? '20.75rem' : '0';
if (isDesktop) {
return (
<div
className="overflow-hidden transition-all duration-300 flex"
className="ui-shell-panel flex overflow-hidden transition-all duration-300"
style={{
width: panelWidth,
background:
'radial-gradient(circle_at_12%_8%,rgba(91,168,160,0.22),transparent_34%),radial-gradient(circle_at_88%_84%,rgba(212,165,116,0.2),transparent_30%),linear-gradient(180deg,rgba(50,50,58,0.98),rgba(40,42,54,0.98))',
boxShadow: isOpen ? '-24px 0 44px -26px rgba(0,0,0,0.85)' : 'none',
boxShadow: isOpen ? '-24px 0 40px -26px rgba(0,0,0,0.95), inset 1px 0 0 rgba(91,168,160,0.22)' : 'none',
}}
data-testid="right-panel-desktop"
>
@ -38,7 +36,12 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
<>
{/* Main Content (Chat or Activity) */}
<div className="flex-1 min-w-0 h-full overflow-hidden flex flex-col">
<div className="flex-1 overflow-y-auto custom-scrollbar p-0">
<div className="border-l border-[color-mix(in_srgb,var(--ui-accent-info)_36%,var(--ui-border-soft))] bg-[linear-gradient(180deg,color-mix(in_srgb,var(--ui-bg-shell)_96%,black),color-mix(in_srgb,var(--ui-bg-panel)_90%,black))]">
<div className="px-3 py-2 border-b border-[var(--ui-border-soft)]">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Agent Pool Monitor</p>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-0 bg-[#08111d]">
{/* Remove default padding to allow edge-to-edge chat */}
{children || <span>Right Panel</span>}
</div>
@ -46,7 +49,13 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
{/* Side Rail (Mini Activity - Only if provided) */}
{rail && (
<div className="w-12 h-full flex-shrink-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.24),rgba(0,0,0,0.36))] shadow-[-10px_0_20px_-18px_rgba(0,0,0,0.9)]">
<div
className="h-full w-10 flex-shrink-0 shadow-[-10px_0_20px_-18px_rgba(0,0,0,0.9)]"
style={{
background: 'var(--ui-bg-shell)',
borderLeft: '1px solid var(--ui-border-soft)',
}}
>
{rail}
</div>
)}
@ -61,32 +70,46 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
}
const handleBackdropClick = () => {
togglePanel();
toggleRightPanel();
};
const handleCloseClick = () => {
togglePanel();
toggleRightPanel();
};
if (isMobile) {
return (
<div
className="fixed inset-0 z-50"
style={{ backgroundColor: 'var(--color-bg-card)' }}
style={{
backgroundColor: 'var(--ui-bg-panel)',
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
overscrollBehavior: 'contain',
touchAction: 'manipulation',
WebkitTapHighlightColor: 'rgba(0,0,0,0.08)',
}}
data-testid="right-panel-mobile"
>
<div className="flex justify-end p-4">
<div className="flex justify-end px-4 py-3">
<button
onClick={handleCloseClick}
className="p-2 rounded-md hover:bg-white/10"
style={{ color: 'var(--color-text-secondary)' }}
style={{ color: 'var(--ui-text-muted)' }}
data-testid="right-panel-close"
aria-label="Close panel"
>
<X size={24} />
</button>
</div>
<div className="p-4 overflow-y-auto" style={{ height: 'calc(100% - 4rem)', color: 'var(--color-text-secondary)' }}>
<div
className="overflow-y-auto px-4 pb-4"
style={{
height: 'calc(100% - 4rem)',
color: 'var(--ui-text-primary)',
overscrollBehavior: 'contain',
}}
>
{children || <span>Right Panel</span>}
</div>
</div>
@ -106,7 +129,8 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
style={{
width: '17rem',
background:
'radial-gradient(circle_at_12%_8%,rgba(91,168,160,0.22),transparent_34%),radial-gradient(circle_at_88%_84%,rgba(212,165,116,0.2),transparent_30%),linear-gradient(180deg,rgba(50,50,58,0.98),rgba(40,42,54,0.98))',
'linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-panel) 86%, black), var(--ui-bg-panel))',
borderLeft: '1px solid var(--ui-border-soft)',
boxShadow: '-24px 0 44px -26px rgba(0,0,0,0.85)',
}}
data-testid="right-panel-tablet"
@ -115,14 +139,14 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
<button
onClick={handleCloseClick}
className="p-2 rounded-md hover:bg-white/10"
style={{ color: 'var(--color-text-secondary)' }}
style={{ color: 'var(--ui-text-muted)' }}
data-testid="right-panel-close"
aria-label="Close panel"
>
<X size={24} />
</button>
</div>
<div className="p-4" style={{ color: 'var(--color-text-secondary)' }}>
<div className="p-4" style={{ color: 'var(--ui-text-primary)' }}>
{children || <span>Right Panel</span>}
</div>
</div>

View file

@ -12,6 +12,7 @@ import { buildEditableIssueDraft, buildIssueUpdatePayload, validateEditableIssue
import type { UpdateMutationPayload } from '../../lib/mutations';
import type { BeadIssue } from '../../lib/types';
import { ThreadView, type ThreadItem } from './thread-view';
import { useResponsive } from '../../hooks/use-responsive';
interface ThreadDrawerProps {
isOpen: boolean;
@ -20,34 +21,20 @@ interface ThreadDrawerProps {
id: string;
items?: ThreadItem[];
embedded?: boolean;
takeover?: boolean;
issue?: BeadIssue | null;
projectRoot?: string;
onIssueUpdated?: (issueId: string) => Promise<void> | void;
}
const SAMPLE_ITEMS: ThreadItem[] = [
{
id: '1',
type: 'comment',
author: 'sarah.lee',
content: 'Pushed a first pass for the left rail hierarchy. Need readability check on status chips.',
timestamp: new Date(Date.now() - 6 * 60 * 1000),
},
{
id: '2',
type: 'status_change',
from: 'open',
to: 'in_progress',
timestamp: new Date(Date.now() - 31 * 60 * 1000),
},
{
id: '3',
type: 'protocol_event',
event: 'HANDOFF',
content: 'Swarm integrator picked up follow-up work.',
timestamp: new Date(Date.now() - 55 * 60 * 1000),
},
];
interface CommentFromApi {
id: string;
bead_id: string;
actor: string;
kind: 'comment';
text: string;
timestamp: string;
}
const STATUS_OPTIONS: EditableIssueDraft['status'][] = ['open', 'in_progress', 'blocked', 'deferred', 'closed'];
const PRIORITY_OPTIONS = [0, 1, 2, 3, 4] as const;
@ -65,6 +52,19 @@ async function postIssueUpdate(body: UpdateMutationPayload): Promise<void> {
}
}
async function postComment(projectRoot: string, id: string, text: string): Promise<void> {
const response = await fetch('/api/beads/comment', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ projectRoot, id, text }),
});
const payload = (await response.json()) as { ok: boolean; error?: { message?: string } };
if (!response.ok || !payload.ok) {
throw new Error(payload.error?.message ?? 'Comment failed');
}
}
function saveStateTone(state: 'ready' | 'saving' | 'saved' | 'error'): string {
if (state === 'saving') return 'border-[#5BA8A0]/50 bg-[#5BA8A0]/20 text-[#D6EEEA]';
if (state === 'saved') return 'border-[#7CB97A]/50 bg-[#7CB97A]/20 text-[#D4ECD2]';
@ -77,18 +77,48 @@ export function ThreadDrawer({
onClose,
title,
id,
items = SAMPLE_ITEMS,
items: externalItems,
embedded = false,
takeover = false,
issue,
projectRoot,
onIssueUpdated,
}: ThreadDrawerProps) {
const { isMobile } = useResponsive();
const [comment, setComment] = useState('');
const [commentState, setCommentState] = useState<'ready' | 'sending' | 'sent' | 'error'>('ready');
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState<EditableIssueDraft | null>(issue ? buildEditableIssueDraft(issue) : null);
const [fieldErrors, setFieldErrors] = useState<EditableIssueFieldErrors>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [saveState, setSaveState] = useState<'ready' | 'saving' | 'saved' | 'error'>('ready');
const [comments, setComments] = useState<CommentFromApi[]>([]);
const [commentsLoading, setCommentsLoading] = useState(false);
// Fetch comments when drawer opens
useEffect(() => {
if (!isOpen || !id || !projectRoot) {
setComments([]);
return;
}
const fetchComments = async () => {
setCommentsLoading(true);
try {
const response = await fetch(`/api/beads/${id}/comments?projectRoot=${encodeURIComponent(projectRoot)}`);
const payload = (await response.json()) as { ok: boolean; comments?: CommentFromApi[] };
if (payload.ok && payload.comments) {
setComments(payload.comments);
}
} catch (error) {
console.error('Failed to fetch comments:', error);
} finally {
setCommentsLoading(false);
}
};
fetchComments();
}, [isOpen, id, projectRoot]);
useEffect(() => {
if (!issue) {
@ -109,15 +139,32 @@ export function ThreadDrawer({
const canEdit = Boolean(issue && projectRoot && draft);
// Convert comments to ThreadItems
const threadItems: ThreadItem[] = useMemo(() => {
const items: ThreadItem[] = comments.map(c => ({
id: c.id,
type: 'comment' as const,
author: c.actor,
content: c.text,
timestamp: new Date(c.timestamp),
}));
// Merge with any external items if provided
if (externalItems) {
items.push(...externalItems);
}
// Sort by timestamp descending
return items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}, [comments, externalItems]);
const participants = useMemo(() => {
const names = new Set<string>();
for (const item of items) {
for (const item of threadItems) {
if (item.author && item.author.trim()) {
names.add(item.author.trim());
}
}
return Array.from(names).slice(0, 4);
}, [items]);
}, [threadItems]);
const handleSave = async () => {
if (!issue || !projectRoot || !draft) {
@ -155,216 +202,301 @@ export function ThreadDrawer({
}
};
const handleCommentSubmit = async () => {
if (!projectRoot || !id || !comment.trim()) {
return;
}
setCommentState('sending');
try {
await postComment(projectRoot, id, comment.trim());
setComment('');
setCommentState('sent');
// Refresh comments
const response = await fetch(`/api/beads/${id}/comments?projectRoot=${encodeURIComponent(projectRoot)}`);
const payload = (await response.json()) as { ok: boolean; comments?: CommentFromApi[] };
if (payload.ok && payload.comments) {
setComments(payload.comments);
}
await onIssueUpdated?.(id);
setTimeout(() => setCommentState('ready'), 900);
} catch (error) {
console.error('Comment failed:', error);
setCommentState('error');
setTimeout(() => setCommentState('ready'), 2000);
}
};
if (!isOpen) {
return null;
}
return (
<div
className="flex h-full flex-col"
style={{
const frameShellClass = takeover
? 'mx-auto flex h-full w-full max-w-[1120px] flex-col overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[linear-gradient(180deg,color-mix(in_srgb,var(--ui-bg-card)_92%,black),color-mix(in_srgb,var(--ui-bg-shell)_88%,black))] shadow-[0_34px_60px_-40px_rgba(0,0,0,0.84)]'
: 'flex h-full flex-col';
const frameShellStyle = takeover
? undefined
: {
width: embedded ? '100%' : '26rem',
background: 'linear-gradient(180deg, #353535, #2E2E2E)',
background: 'linear-gradient(180deg, var(--ui-bg-card), var(--ui-bg-shell))',
borderLeft: embedded ? 'none' : '1px solid var(--color-border-default)',
boxShadow: embedded ? 'none' : '-20px 0 48px rgba(0,0,0,0.45)',
}}
overscrollBehavior: 'contain' as const,
};
const conversationSection = (
<section className="rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-3 shadow-[0_12px_28px_-22px_rgba(0,0,0,0.7)]">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-[var(--ui-text-primary)]">
<MessageSquareText className="h-4 w-4 text-[var(--ui-accent-info)]" />
Conversation
</div>
<div className="flex items-center gap-1">
{participants.map((name) => (
<span key={name} className="inline-flex h-6 items-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 text-[11px] text-[var(--ui-text-muted)]">
{name}
</span>
))}
</div>
</div>
<ThreadView items={threadItems} variant={takeover ? 'chat' : 'stack'} currentUser="you" />
</section>
);
const summarySection = (
<section className="rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-3 shadow-[0_14px_30px_-24px_rgba(0,0,0,0.75)]">
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-[var(--ui-text-primary)]">Task Summary</p>
<Badge className={`rounded-full border px-2 py-0.5 text-[11px] ${saveStateTone(saveState)}`}>
{saveState}
</Badge>
</div>
{!issue ? (
<p className="text-sm text-[var(--ui-text-muted)]">No task details available for this thread context.</p>
) : !editMode ? (
<div className="space-y-2 text-sm">
<p className="font-semibold text-[var(--ui-text-primary)]">{issue.title}</p>
<p className="text-[var(--ui-text-muted)]">{issue.description ?? 'No description provided.'}</p>
<div className="flex flex-wrap gap-2">
<Badge className="rounded-full border border-[var(--ui-accent-info)]/40 bg-[var(--ui-accent-info)]/20 text-[#d9f5ff]">{issue.status}</Badge>
<Badge className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-text-muted)]">P{issue.priority}</Badge>
<Badge className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-text-muted)]">{issue.issue_type}</Badge>
{issue.assignee ? <Badge className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-text-muted)]">@{issue.assignee}</Badge> : null}
</div>
<div className="pt-1">
<Button
onClick={() => setEditMode(true)}
disabled={!canEdit}
className="h-8 rounded-full bg-[var(--ui-accent-action-green)] px-4 text-[#081f12] hover:bg-[color-mix(in_srgb,var(--ui-accent-action-green)_86%,white)] disabled:opacity-40"
>
<Edit3 className="mr-2 h-3.5 w-3.5" /> Edit task
</Button>
</div>
</div>
) : (
<div className="space-y-2.5">
<label className="block text-xs text-[#9F9F9F]">
Title
<Input
value={draft?.title ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, title: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
{fieldErrors.title ? <p className="text-xs text-[#EAA7A0]">{fieldErrors.title}</p> : null}
<label className="block text-xs text-[#9F9F9F]">
Description
<textarea
value={draft?.description ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, description: event.target.value } : current))}
className="mt-1 min-h-20 w-full rounded-md border border-[#4A4A4A] bg-[#3B3B3B] px-3 py-2 text-sm text-white outline-none ring-offset-0 placeholder:text-[#808080] focus:border-[#5BA8A0]"
/>
</label>
<div className="grid gap-2 sm:grid-cols-2">
<label className="block text-xs text-[#9F9F9F]">
Assignee
<Input
value={draft?.assignee ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, assignee: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
<label className="block text-xs text-[#9F9F9F]">
Issue type
<Input
value={draft?.issueType ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, issueType: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
</div>
<label className="block text-xs text-[#9F9F9F]">
Labels
<Input
value={draft?.labelsInput ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, labelsInput: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
{fieldErrors.labelsInput ? <p className="text-xs text-[#EAA7A0]">{fieldErrors.labelsInput}</p> : null}
<div>
<p className="mb-1 text-xs text-[#9F9F9F]">Status</p>
<div className="flex flex-wrap gap-2">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
onClick={() => setDraft((current) => (current ? { ...current, status } : current))}
className={`rounded-full border px-2 py-1 text-xs ${draft?.status === status ? 'border-[#5BA8A0] bg-[#5BA8A0]/20 text-[#D7ECE9]' : 'border-[#4A4A4A] bg-[#3A3A3A] text-[#B8B8B8]'}`}
>
{status}
</button>
))}
</div>
</div>
<div>
<p className="mb-1 text-xs text-[#9F9F9F]">Priority</p>
<div className="flex flex-wrap gap-2">
{PRIORITY_OPTIONS.map((priority) => (
<button
key={priority}
type="button"
onClick={() => setDraft((current) => (current ? { ...current, priority } : current))}
className={`rounded-full border px-2 py-1 text-xs ${draft?.priority === priority ? 'border-[#D4A574] bg-[#D4A574]/20 text-[#EBD7BD]' : 'border-[#4A4A4A] bg-[#3A3A3A] text-[#B8B8B8]'}`}
>
P{priority}
</button>
))}
</div>
</div>
{saveError ? <p className="text-xs text-[#EAA7A0]">{saveError}</p> : null}
<div className="flex justify-end gap-2 pt-1">
<Button
variant="outline"
onClick={() => setEditMode(false)}
className="h-8 rounded-full border-[#4A4A4A] bg-[#3B3B3B] px-4 text-[#C0C0C0] hover:bg-[#444444]"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
className="h-8 rounded-full bg-[#7CB97A] px-4 text-[#1A1A1A] hover:bg-[#8ECC8C]"
>
Save
</Button>
</div>
</div>
)}
</section>
);
return (
<div
className={takeover ? 'h-full p-4 md:p-6' : 'h-full'}
style={
isMobile
? {
paddingTop: takeover ? 'max(1rem, env(safe-area-inset-top))' : undefined,
paddingBottom: takeover ? 'max(1rem, env(safe-area-inset-bottom))' : undefined,
}
: undefined
}
>
<header className="border-b border-[#4A4A4A] bg-[#363636]/90 px-4 py-3">
<div className={frameShellClass} style={frameShellStyle}>
<header className="border-b border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[#8F8F8F]">Open Thread</p>
<h2 className="truncate text-lg font-semibold text-white" title={title}>{title}</h2>
<p className="text-xs text-[#A5A5A5]">{id} · {items.length} events</p>
<div className="mb-1 flex items-center gap-2">
<p className="font-mono text-xs font-semibold text-[var(--ui-accent-info)]">#{id}</p>
<span className="rounded-full border border-[var(--ui-accent-ready)]/45 bg-[var(--ui-accent-ready)]/20 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-[#d8ffe8]">
In Progress
</span>
</div>
<h2 className="truncate text-[40px] font-semibold leading-[1.12] tracking-[-0.02em] text-[var(--ui-text-primary)]" title={title}>{title}</h2>
<p className="mt-1 text-xs text-[var(--ui-text-muted)]">{threadItems.length} events</p>
</div>
<Button
onClick={onClose}
variant="ghost"
className="h-8 w-8 rounded-full p-0 text-[#B8B8B8] hover:bg-white/10 hover:text-white"
className="h-8 w-8 rounded-full p-0 text-[var(--ui-text-muted)] hover:bg-white/10 hover:text-[var(--ui-text-primary)]"
aria-label="Close thread"
>
<X className="h-4 w-4" />
</Button>
</div>
</header>
</header>
<ScrollArea className="flex-1">
<div className="space-y-3 p-4">
<section className="rounded-xl border border-[#4A4A4A] bg-[#303030] p-3 shadow-[0_12px_28px_-22px_rgba(0,0,0,0.7)]">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-[#DCDCDC]">
<MessageSquareText className="h-4 w-4 text-[#5BA8A0]" />
Conversation
</div>
<div className="flex items-center gap-1">
{participants.map((name) => (
<span key={name} className="inline-flex h-6 items-center rounded-full border border-white/10 bg-white/5 px-2 text-[11px] text-[#CFCFCF]">
{name}
</span>
))}
</div>
</div>
<ThreadView items={items} />
</section>
<section className="rounded-xl border border-[#4A4A4A] bg-[#303030] p-3 shadow-[0_14px_30px_-24px_rgba(0,0,0,0.75)]">
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-white">Task summary</p>
<Badge className={`rounded-full border px-2 py-0.5 text-[11px] ${saveStateTone(saveState)}`}>
{saveState}
</Badge>
</div>
{!issue ? (
<p className="text-sm text-[#9E9E9E]">No task details available for this thread context.</p>
) : !editMode ? (
<div className="space-y-2 text-sm">
<p className="font-semibold text-[#F4F4F4]">{issue.title}</p>
<p className="text-[#B8B8B8]">{issue.description ?? 'No description provided.'}</p>
<div className="flex flex-wrap gap-2">
<Badge className="rounded-full border border-[#5BA8A0]/40 bg-[#5BA8A0]/20 text-[#CFE7E3]">{issue.status}</Badge>
<Badge className="rounded-full border border-white/10 bg-white/5 text-[#CFCFCF]">P{issue.priority}</Badge>
<Badge className="rounded-full border border-white/10 bg-white/5 text-[#CFCFCF]">{issue.issue_type}</Badge>
{issue.assignee ? <Badge className="rounded-full border border-white/10 bg-white/5 text-[#CFCFCF]">@{issue.assignee}</Badge> : null}
</div>
<div className="pt-1">
<Button
onClick={() => setEditMode(true)}
disabled={!canEdit}
className="h-8 rounded-full bg-[#7CB97A] px-4 text-[#1A1A1A] hover:bg-[#8FCC8D] disabled:opacity-40"
>
<Edit3 className="mr-2 h-3.5 w-3.5" /> Edit task
</Button>
</div>
</div>
<ScrollArea className="flex-1">
<div className="space-y-3 p-4">
{takeover ? (
<>
{summarySection}
{conversationSection}
</>
) : (
<div className="space-y-2.5">
<label className="block text-xs text-[#9F9F9F]">
Title
<Input
value={draft?.title ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, title: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
{fieldErrors.title ? <p className="text-xs text-[#EAA7A0]">{fieldErrors.title}</p> : null}
<label className="block text-xs text-[#9F9F9F]">
Description
<textarea
value={draft?.description ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, description: event.target.value } : current))}
className="mt-1 min-h-20 w-full rounded-md border border-[#4A4A4A] bg-[#3B3B3B] px-3 py-2 text-sm text-white outline-none ring-offset-0 placeholder:text-[#808080] focus:border-[#5BA8A0]"
/>
</label>
<div className="grid gap-2 sm:grid-cols-2">
<label className="block text-xs text-[#9F9F9F]">
Assignee
<Input
value={draft?.assignee ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, assignee: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
<label className="block text-xs text-[#9F9F9F]">
Issue type
<Input
value={draft?.issueType ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, issueType: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
</div>
<label className="block text-xs text-[#9F9F9F]">
Labels
<Input
value={draft?.labelsInput ?? ''}
onChange={(event) => setDraft((current) => (current ? { ...current, labelsInput: event.target.value } : current))}
className="mt-1 border-[#4A4A4A] bg-[#3B3B3B] text-white"
/>
</label>
{fieldErrors.labelsInput ? <p className="text-xs text-[#EAA7A0]">{fieldErrors.labelsInput}</p> : null}
<div>
<p className="mb-1 text-xs text-[#9F9F9F]">Status</p>
<div className="flex flex-wrap gap-2">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
onClick={() => setDraft((current) => (current ? { ...current, status } : current))}
className={`rounded-full border px-2 py-1 text-xs ${draft?.status === status ? 'border-[#5BA8A0] bg-[#5BA8A0]/20 text-[#D7ECE9]' : 'border-[#4A4A4A] bg-[#3A3A3A] text-[#B8B8B8]'}`}
>
{status}
</button>
))}
</div>
</div>
<div>
<p className="mb-1 text-xs text-[#9F9F9F]">Priority</p>
<div className="flex flex-wrap gap-2">
{PRIORITY_OPTIONS.map((priority) => (
<button
key={priority}
type="button"
onClick={() => setDraft((current) => (current ? { ...current, priority } : current))}
className={`rounded-full border px-2 py-1 text-xs ${draft?.priority === priority ? 'border-[#D4A574] bg-[#D4A574]/20 text-[#EBD7BD]' : 'border-[#4A4A4A] bg-[#3A3A3A] text-[#B8B8B8]'}`}
>
P{priority}
</button>
))}
</div>
</div>
{saveError ? <p className="text-xs text-[#EAA7A0]">{saveError}</p> : null}
<div className="flex justify-end gap-2 pt-1">
<Button
variant="outline"
onClick={() => setEditMode(false)}
className="h-8 rounded-full border-[#4A4A4A] bg-[#3B3B3B] px-4 text-[#C0C0C0] hover:bg-[#444444]"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
className="h-8 rounded-full bg-[#7CB97A] px-4 text-[#1A1A1A] hover:bg-[#8ECC8C]"
>
Save
</Button>
</div>
</div>
<>
{conversationSection}
{summarySection}
</>
)}
</section>
</div>
</ScrollArea>
</div>
</ScrollArea>
<footer className="border-t border-[#4A4A4A] bg-[#2F2F2F] p-3">
<div className="flex items-center gap-2 rounded-xl border border-[#4A4A4A] bg-[#3A3A3A] p-1">
<Input
value={comment}
onChange={(event) => setComment(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && comment.trim()) {
event.preventDefault();
setComment('');
}
}}
placeholder="Reply to thread..."
className="border-0 bg-transparent text-white placeholder:text-[#888888]"
/>
<Button
type="button"
className="h-8 rounded-full bg-[#5BA8A0] px-3 text-[#1A1A1A] hover:bg-[#6AB8AF]"
onClick={() => setComment('')}
disabled={!comment.trim()}
>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
</footer>
<footer
className="border-t border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-3"
style={
isMobile
? {
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
position: 'sticky',
bottom: 0,
zIndex: 10,
}
: undefined
}
>
<div className="flex items-center gap-2 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] p-1">
<Input
value={comment}
onChange={(event) => setComment(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && comment.trim()) {
event.preventDefault();
void handleCommentSubmit();
}
}}
placeholder="Type a message to neighbors..."
className="border-0 bg-transparent text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)]"
autoComplete="off"
disabled={commentState === 'sending'}
/>
<Button
type="button"
className="h-8 rounded-full bg-[var(--ui-accent-action-green)] px-3 text-[#082012] hover:bg-[color-mix(in_srgb,var(--ui-accent-action-green)_86%,white)] disabled:opacity-50"
onClick={() => void handleCommentSubmit()}
disabled={!comment.trim() || commentState === 'sending'}
>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
{commentState === 'error' && (
<p className="mt-1 text-xs text-[#EAA7A0]">Failed to send comment</p>
)}
</footer>
</div>
{takeover ? (
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_8%_10%,rgba(91,168,160,0.14),transparent_32%),radial-gradient(circle_at_92%_88%,rgba(212,165,116,0.16),transparent_30%)]" />
) : null}
</div>
);
}

View file

@ -19,6 +19,8 @@ export interface ThreadItem {
interface ThreadViewProps {
items: ThreadItem[];
variant?: 'stack' | 'chat';
currentUser?: string;
onAddComment?: (text: string) => void;
}
@ -72,28 +74,40 @@ function getProtocolLabel(event?: string): string {
}
}
function CommentItem({ item }: { item: ThreadItem }) {
function CommentItem({ item, isSelf }: { item: ThreadItem; isSelf: boolean }) {
return (
<div className="flex gap-3 py-3">
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={undefined} alt={item.author} />
<AvatarFallback className="bg-surface-muted text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : '??'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-text-primary text-sm font-medium">
{item.author || 'Unknown'}
</span>
<span className="text-text-muted text-xs">
{formatRelativeTime(item.timestamp)}
</span>
<div className={cn('flex gap-3 py-3', isSelf && 'justify-end')}>
{!isSelf ? (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={undefined} alt={item.author} />
<AvatarFallback className="bg-surface-muted text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : '??'}
</AvatarFallback>
</Avatar>
) : null}
<div className={cn('min-w-0 max-w-[80%]', isSelf && 'items-end')}>
<div className={cn('mb-1 flex items-center gap-2', isSelf && 'justify-end')}>
<span className="text-text-primary text-sm font-semibold">{item.author || 'Unknown'}</span>
<span className="font-mono text-[11px] text-text-muted">{formatRelativeTime(item.timestamp)}</span>
</div>
<p className="text-text-secondary text-sm whitespace-pre-wrap break-words">
<p
className={cn(
'whitespace-pre-wrap break-words rounded-xl px-3 py-2 text-base leading-relaxed',
isSelf
? 'bg-[color-mix(in_srgb,var(--ui-accent-ready)_24%,var(--ui-bg-panel))] text-[var(--ui-text-primary)]'
: 'bg-[color-mix(in_srgb,var(--ui-bg-panel)_88%,black)] text-text-secondary',
)}
>
{item.content}
</p>
</div>
{isSelf ? (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarFallback className="bg-[color-mix(in_srgb,var(--ui-accent-ready)_40%,var(--ui-bg-panel))] text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : 'ME'}
</AvatarFallback>
</Avatar>
) : null}
</div>
);
}
@ -133,7 +147,9 @@ function ProtocolEventItem({ item }: { item: ThreadItem }) {
);
}
export function ThreadView({ items, onAddComment }: ThreadViewProps) {
export function ThreadView({ items, variant = 'stack', currentUser = 'you', onAddComment }: ThreadViewProps) {
void onAddComment;
return (
<div className="space-y-1">
{items.length === 0 ? (
@ -143,7 +159,13 @@ export function ThreadView({ items, onAddComment }: ThreadViewProps) {
{items.map((item) => {
switch (item.type) {
case 'comment':
return <CommentItem key={item.id} item={item} />;
return (
<CommentItem
key={item.id}
item={item}
isSelf={variant === 'chat' && (item.author ?? '').trim().toLowerCase() === currentUser.toLowerCase()}
/>
);
case 'status_change':
return <StatusChangeItem key={item.id} item={item} />;
case 'protocol_event':

View file

@ -1,124 +1,148 @@
'use client';
import { ReactNode } from 'react';
import { useUrlState, ViewType } from '../../hooks/use-url-state';
import { LayoutGrid, Lock, Plus, Sidebar, SidebarClose } from 'lucide-react';
import { useUrlState } from '../../hooks/use-url-state';
import { useResponsive } from '../../hooks/use-responsive';
export interface TopBarProps {
onCreateTask?: () => Promise<void> | void;
isCreatingTask?: boolean;
taskActionMessage?: string;
children?: ReactNode;
totalTasks?: number;
criticalAlerts?: number;
idleCount?: number;
busyCount?: number;
}
export function TopBar({ children }: TopBarProps) {
const { view, setView, togglePanel } = useUrlState();
const { isDesktop } = useResponsive();
interface MetricTileProps {
label: string;
value: number;
accent?: 'ready' | 'blocked' | 'info' | 'warning';
}
const tabs: { id: ViewType; label: string }[] = [
{ id: 'social', label: 'Social' },
{ id: 'graph', label: 'Graph' },
{ id: 'swarm', label: 'Swarm' },
];
const showHamburger = !isDesktop;
function MetricTile({ label, value, accent = 'info' }: MetricTileProps) {
const accentColor =
accent === 'ready'
? 'var(--ui-accent-ready)'
: accent === 'blocked'
? 'var(--ui-accent-blocked)'
: accent === 'warning'
? 'var(--ui-accent-warning)'
: 'var(--ui-accent-info)';
return (
<header
className="h-12 flex items-center justify-between px-4"
style={{
background:
'radial-gradient(circle_at_10%_50%,rgba(212,165,116,0.14),transparent_30%),radial-gradient(circle_at_90%_40%,rgba(91,168,160,0.14),transparent_30%),var(--color-bg-card)',
boxShadow: '0 14px 22px -20px rgba(0,0,0,0.85)',
}}
data-testid="top-bar"
>
<div className="flex items-center gap-2">
{showHamburger && (
<button
onClick={togglePanel}
className="p-2 transition-colors hover:text-[var(--color-text-primary)]"
style={{ color: 'var(--color-text-secondary)' }}
aria-label="Open menu"
data-testid="hamburger-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<nav className="flex items-center gap-1" role="tablist">
{tabs.map((tab) => {
const isActive = view === tab.id;
return (
<button
key={tab.id}
onClick={() => setView(tab.id)}
role="tab"
aria-selected={isActive}
className={`px-4 py-2 text-sm transition-colors rounded-md ${
isActive
? 'font-bold shadow-[inset_0_-2px_0_var(--color-accent-green),0_10px_18px_-14px_rgba(0,0,0,0.8)] bg-white/[0.03]'
: 'font-normal hover:text-[var(--color-text-primary)]'
}`}
style={{
color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
}}
data-testid={`tab-${tab.id}`}
>
{tab.label}
</button>
);
})}
</nav>
<div className="hidden items-center gap-2 rounded-md border border-[var(--ui-border-soft)] bg-[color-mix(in_srgb,var(--ui-bg-panel)_84%,black)] px-2.5 py-1 text-xs md:inline-flex">
<p className="font-mono text-[10px] uppercase tracking-[0.13em] text-[var(--ui-text-muted)]">{label}</p>
<p className="font-mono text-sm leading-none text-[var(--ui-text-primary)]">{value}</p>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: accentColor }} />
</div>
);
}
export function TopBar({
onCreateTask,
isCreatingTask = false,
taskActionMessage,
children,
totalTasks = 0,
criticalAlerts = 0,
idleCount = 0,
busyCount = 0,
}: TopBarProps) {
const { leftPanel, toggleLeftPanel, rightPanel, toggleRightPanel, blockedOnly, toggleBlockedOnly } = useUrlState();
const { isDesktop } = useResponsive();
return (
<header className="ui-shell-topbar flex h-[var(--topbar-height)] items-center justify-between border-b border-[var(--ui-border-soft)]" data-testid="top-bar">
<div className="flex min-w-0 items-center">
<button
type="button"
onClick={toggleLeftPanel}
className="ml-3 mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--ui-text-muted)] transition-colors hover:bg-white/5 hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
aria-label={leftPanel === 'open' ? 'Collapse Sidebar' : 'Expand Sidebar'}
aria-pressed={leftPanel === 'open'}
data-testid="hamburger-button"
>
{leftPanel === 'open' ? <SidebarClose className="h-4 w-4" aria-hidden="true" /> : <Sidebar className="h-4 w-4" aria-hidden="true" />}
</button>
<div className="mr-3 flex min-w-[210px] items-center gap-2 border-r border-[var(--ui-border-soft)] px-2 py-2">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-[color-mix(in_srgb,var(--ui-accent-ready)_24%,var(--ui-bg-panel))] text-[var(--ui-accent-ready)]">
<LayoutGrid className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.04em] text-[var(--ui-text-primary)]">Command Grid</p>
<p className="font-mono text-[10px] text-[var(--ui-text-muted)]">v2.4.0-stable</p>
</div>
</div>
<div className="hidden items-center gap-2 pl-2 md:flex">
<MetricTile label="Total" value={totalTasks} accent="ready" />
<MetricTile label="Blocked" value={criticalAlerts} accent="blocked" />
<MetricTile label="Busy" value={busyCount} accent="warning" />
<MetricTile label="Idle" value={idleCount} accent="info" />
</div>
</div>
<div className="flex items-center gap-3">
{children || (
<div className="mr-3 flex items-center gap-2">
{children ?? (
<>
<input
type="text"
placeholder="Filter..."
className="px-3 py-1.5 text-sm rounded focus:outline-none"
style={{
backgroundColor: 'var(--color-bg-input)',
color: 'var(--color-text-primary)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.08), 0 8px 14px -12px rgba(0,0,0,0.85)',
}}
data-testid="filter-input"
/>
<button
className="p-2 transition-colors hover:text-[var(--color-text-primary)]"
style={{ color: 'var(--color-text-secondary)' }}
aria-label="Settings"
data-testid="settings-button"
type="button"
onClick={toggleBlockedOnly}
aria-pressed={blockedOnly}
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.11em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
style={{
borderColor: blockedOnly
? 'color-mix(in srgb, var(--ui-accent-blocked) 78%, transparent)'
: 'var(--ui-border-soft)',
backgroundColor: blockedOnly
? 'color-mix(in srgb, var(--ui-accent-blocked) 20%, var(--ui-bg-panel))'
: 'color-mix(in srgb, var(--ui-bg-panel) 88%, black)',
color: blockedOnly ? '#ffd4dd' : 'var(--ui-text-primary)',
}}
data-testid="blocked-items-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
Blocked Items
<span className="rounded-full bg-[color-mix(in_srgb,var(--ui-accent-blocked)_84%,black)] px-1.5 py-0.5 font-mono text-[10px] text-[#fff0f3]">
{criticalAlerts}
</span>
</button>
<button
type="button"
onClick={() => {
void onCreateTask?.();
}}
disabled={isCreatingTask}
className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_srgb,var(--ui-accent-ready)_80%,black)] bg-[var(--ui-accent-action-green)] px-4 py-2 text-xs font-semibold uppercase tracking-[0.11em] text-[#072514] transition-colors hover:bg-[color-mix(in_srgb,var(--ui-accent-action-green)_84%,white)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)] disabled:opacity-60"
data-testid="new-task-button"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{isCreatingTask ? 'Creating…' : 'New Task'}
</button>
</>
)}
{isDesktop ? (
<button
type="button"
onClick={toggleRightPanel}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--ui-text-muted)] transition-colors hover:bg-white/5 hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
aria-label={rightPanel === 'open' ? 'Collapse Right Sidebar' : 'Expand Right Sidebar'}
aria-pressed={rightPanel === 'open'}
data-testid="settings-button"
>
<Sidebar className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
<span className="sr-only" aria-live="polite">
{taskActionMessage ?? ''}
</span>
</div>
</header>
);

View file

@ -115,6 +115,7 @@ export function UnifiedShell({
return (
<SwarmWorkspace
selectedMissionId={swarmId ?? undefined}
issues={filteredIssues}
/>
);
}

View file

@ -1,4 +1,4 @@
import type { MouseEventHandler } from 'react';
import type { KeyboardEvent, MouseEventHandler } from 'react';
import { Activity, Clock3, GitBranch, Link2, MessageCircle, Orbit } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@ -20,67 +20,90 @@ interface SocialCardProps {
dependencyCount?: number;
commentCount?: number;
unreadCount?: number;
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
}
type StatusTone = {
accent: string;
glow: string;
badgeClass: string;
surface: string;
accentChip: string;
};
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
if (!onClick) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
}
const STATUS_TONES: Record<SocialCardData['status'], StatusTone> = {
ready: {
accent: '#7CB97A',
glow: 'rgba(124,185,122,0.26)',
badgeClass: 'bg-[#7CB97A]/26 text-[#DCEED8] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
surface:
'radial-gradient(circle at 80% 78%, rgba(124,185,122,0.46), transparent 76%), radial-gradient(circle at 8% 6%, rgba(124,185,122,0.26), transparent 68%), linear-gradient(145deg, rgba(45,78,45,0.99), rgba(35,62,35,0.99))',
accentChip: 'bg-[#7CB97A]/18 text-[#D2E4CE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
},
in_progress: {
accent: '#D4A574',
glow: 'rgba(212,165,116,0.28)',
badgeClass: 'bg-[#D4A574]/28 text-[#EED9C1] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
surface:
'radial-gradient(circle at 80% 78%, rgba(212,165,116,0.48), transparent 76%), radial-gradient(circle at 8% 6%, rgba(212,165,116,0.28), transparent 68%), linear-gradient(145deg, rgba(86,64,40,0.99), rgba(68,49,30,0.99))',
accentChip: 'bg-[#D4A574]/20 text-[#E0C6A7] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
},
blocked: {
accent: '#C97A7A',
glow: 'rgba(201,122,122,0.26)',
badgeClass: 'bg-[#C97A7A]/28 text-[#EDD3D3] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
surface:
'radial-gradient(circle at 80% 78%, rgba(201,122,122,0.46), transparent 76%), radial-gradient(circle at 8% 6%, rgba(201,122,122,0.27), transparent 68%), linear-gradient(145deg, rgba(76,46,46,0.99), rgba(60,36,36,0.99))',
accentChip: 'bg-[#C97A7A]/18 text-[#E1C0C0] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
},
closed: {
accent: 'var(--status-closed)',
glow: 'rgba(136,136,136,0.16)',
badgeClass: 'bg-[#888888]/26 text-[#CECECE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
surface:
'radial-gradient(circle at 80% 78%, rgba(136,136,136,0.32), transparent 76%), radial-gradient(circle at 8% 6%, rgba(136,136,136,0.16), transparent 68%), linear-gradient(145deg, rgba(56,56,56,0.99), rgba(44,44,44,0.99))',
accentChip: 'bg-[#888888]/16 text-[#BEBEBE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
},
};
function renderDependencyPreview(ids: string[], toneClass: string, label: string) {
if (ids.length === 0) {
return null;
function statusVisual(status: SocialCardData['status']) {
if (status === 'blocked') {
return {
border: 'color-mix(in srgb, var(--ui-accent-blocked) 50%, var(--ui-border-soft))',
cardBg:
'linear-gradient(160deg, color-mix(in srgb, var(--ui-accent-blocked) 20%, #1a0f15), color-mix(in srgb, var(--ui-bg-shell) 92%, black))',
badgeBg: 'color-mix(in srgb, var(--ui-accent-blocked) 24%, transparent)',
badgeText: '#ffd5df',
chipText: 'Blocked',
};
}
if (status === 'in_progress') {
return {
border: 'color-mix(in srgb, var(--ui-accent-warning) 50%, var(--ui-border-soft))',
cardBg:
'linear-gradient(160deg, color-mix(in srgb, var(--ui-accent-warning) 16%, #1a1510), color-mix(in srgb, var(--ui-bg-shell) 92%, black))',
badgeBg: 'color-mix(in srgb, var(--ui-accent-warning) 24%, transparent)',
badgeText: '#ffe5c7',
chipText: 'Active',
};
}
if (status === 'ready') {
return {
border: 'color-mix(in srgb, var(--ui-accent-ready) 50%, var(--ui-border-soft))',
cardBg:
'linear-gradient(160deg, color-mix(in srgb, var(--ui-accent-ready) 16%, #101a15), color-mix(in srgb, var(--ui-bg-shell) 92%, black))',
badgeBg: 'color-mix(in srgb, var(--ui-accent-ready) 24%, transparent)',
badgeText: '#d6ffe7',
chipText: 'Ready',
};
}
return {
border: 'var(--ui-border-strong)',
cardBg: 'linear-gradient(160deg, color-mix(in srgb, var(--ui-bg-card) 95%, black), color-mix(in srgb, var(--ui-bg-shell) 90%, black))',
badgeBg: 'color-mix(in srgb, var(--ui-border-strong) 24%, transparent)',
badgeText: 'var(--ui-text-muted)',
chipText: 'Closed',
};
}
function dependencyPanel(
title: string,
color: string,
details: Array<{ id: string; title: string; epic?: string }>,
) {
if (details.length === 0) return null;
return (
<div className="min-w-0 rounded-lg bg-black/20 px-2 py-1.5 shadow-[0_10px_18px_-14px_rgba(0,0,0,0.85)]">
<p className={cn('mb-1 text-[10px] font-semibold uppercase tracking-[0.12em]', toneClass)}>{label}</p>
<div className="flex flex-wrap gap-1">
{ids.slice(0, 2).map((id) => (
<span key={id} className="rounded-md bg-white/10 px-1.5 py-0.5 font-mono text-[10px] text-[#DCDCDC] shadow-[0_8px_12px_-12px_rgba(0,0,0,0.88)]">
{id}
</span>
<div className="rounded-md border border-[var(--ui-border-soft)] bg-[color-mix(in_srgb,var(--ui-bg-panel)_82%,black)] px-2.5 py-2">
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.12em]" style={{ color }}>
{title}
</p>
<div className="space-y-1.5">
{details.slice(0, 1).map((item) => (
<div
key={`${title}-${item.id}`}
className="rounded border border-[var(--ui-border-soft)] bg-[color-mix(in_srgb,var(--ui-bg-card)_88%,black)] px-2 py-1.5"
>
<div className="mb-0.5 flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ui-accent-info)]" />
<span className="font-mono text-[10px] text-[var(--ui-text-muted)]">{item.id}</span>
</div>
<p className="line-clamp-1 text-xs text-[var(--ui-text-primary)]">{item.title}</p>
{item.epic ? (
<p className="line-clamp-1 text-[10px] text-[var(--ui-accent-info)]"> {item.epic}</p>
) : null}
</div>
))}
{ids.length > 2 ? <span className="text-[10px] text-[#8E8E8E]">+{ids.length - 2}</span> : null}
</div>
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--ui-text-muted)]">+{details.length - 1} more</p> : null}
</div>
);
}
@ -98,131 +121,116 @@ export function SocialCard({
dependencyCount,
commentCount,
unreadCount = 0,
blockedByDetails = [],
unblocksDetails = [],
}: SocialCardProps) {
const tone = STATUS_TONES[data.status];
const status = statusVisual(data.status);
return (
<div
onClick={onClick}
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
role="button"
tabIndex={0}
aria-label={`Open ${data.title}`}
className={cn(
'group relative flex h-full min-h-[18rem] cursor-pointer flex-col rounded-2xl px-4 py-4 text-left transition-all duration-200 ease-out',
'hover:-translate-y-0.5',
selected && 'translate-y-[-2px]',
'group relative flex min-h-[290px] cursor-pointer flex-col rounded-[14px] border px-3.5 py-3 text-left transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]',
className,
)}
style={{
background: tone.surface,
background: status.cardBg,
borderColor: selected ? status.border : 'var(--ui-border-soft)',
boxShadow: selected
? `0 24px 50px -18px ${tone.glow}, 0 10px 24px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.12)`
: `0 12px 24px -20px ${tone.glow}, 0 6px 14px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.06)`,
? '0 24px 40px -26px rgba(0,0,0,0.85), 0 0 0 1px color-mix(in srgb, var(--ui-border-strong) 66%, transparent)'
: '0 12px 26px -24px rgba(0,0,0,0.82)',
}}
>
<div className="absolute inset-x-0 top-0 h-[4px]" style={{ backgroundColor: tone.accent }} />
<div
className="pointer-events-none absolute right-3 top-3 h-10 w-10 rounded-full blur-xl"
style={{ backgroundColor: tone.glow }}
/>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-[11px] text-[#A8D0CB]">{data.id}</span>
<Badge className="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em]" style={{ backgroundColor: status.badgeBg, color: status.badgeText }}>
{status.chipText}
</Badge>
<span className="font-mono text-[11px] text-[var(--ui-accent-info)]">{data.priority}</span>
<span className="truncate font-mono text-[11px] text-[var(--ui-text-muted)]">{data.id}</span>
{unreadCount > 0 ? (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[#E24A3A] px-1 text-[10px] font-semibold text-white">
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--ui-accent-action-red)] px-1 text-[10px] font-semibold text-white">
{unreadCount}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<Badge className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', tone.badgeClass)}>
{data.status.replace('_', ' ')}
</Badge>
<Badge className="rounded-full bg-black/25 px-2 py-0.5 font-mono text-[10px] text-[#D0D0D0] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
{data.priority}
</Badge>
</div>
</div>
<h3 className="line-clamp-2 text-[1.7rem] font-semibold leading-[1.1] tracking-[-0.02em] text-white">
{data.title}
</h3>
<p className="mt-2 line-clamp-2 min-h-[2.6rem] text-sm leading-relaxed text-[#B8B8B8]">
<h3 className="line-clamp-2 text-[27px] font-semibold leading-[1.13] tracking-[-0.01em] text-[var(--ui-text-primary)]">{data.title}</h3>
<p className="mt-1.5 line-clamp-3 min-h-[56px] text-[13px] leading-relaxed text-[var(--ui-text-muted)]">
{description || 'No summary provided yet.'}
</p>
<div className="mt-2 flex flex-wrap gap-2">
<span className="rounded-full bg-[#D4A574]/28 px-2 py-0.5 text-[10px] font-semibold text-[#F5DFC2] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.82)]">
{data.blocks.length} blocking
</span>
<span className="rounded-full bg-[#E57373]/24 px-2 py-0.5 text-[10px] font-semibold text-[#F3C2C2] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.82)]">
{data.unblocks.length} blocked by
</span>
<div className="mt-2 flex flex-col gap-2">
{dependencyPanel('Blocked By', 'var(--ui-accent-blocked)', blockedByDetails)}
{dependencyPanel('Unblocks', 'var(--ui-accent-ready)', unblocksDetails)}
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{renderDependencyPreview(data.unblocks, 'text-[#D4A574]', 'Blocked By')}
{renderDependencyPreview(data.blocks, 'text-[#5BA8A0]', 'Unblocks')}
<div className="mt-2 flex items-center gap-2">
{data.agents.slice(0, 3).map((agent) => (
<AgentAvatar
key={`${data.id}-${agent.name}`}
name={agent.name}
status={agent.status as AgentStatus}
role={agent.role}
size="sm"
/>
))}
{data.agents.length === 0 ? <span className="text-xs text-[var(--ui-text-muted)]">No crew</span> : null}
</div>
<div className="mt-auto flex items-end justify-between gap-3 pt-4">
<div className="space-y-1.5 text-xs text-[#9A9A9A]">
<p className="inline-flex items-center gap-1.5"><Clock3 className="h-3.5 w-3.5" />{updatedLabel}</p>
<div className="flex items-center gap-3">
<p className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</p>
<p className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" />{commentCount ?? 0}</p>
<div className="mt-auto border-t border-[var(--ui-border-soft)] pt-1.5">
<div className="mb-1.5 flex items-center justify-between text-xs text-[var(--ui-text-muted)]">
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" aria-hidden="true" />{updatedLabel}</span>
<span className="font-mono text-[11px] text-[var(--ui-accent-ready)]">stage active</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-[var(--ui-text-muted)]">
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" aria-hidden="true" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</span>
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToGraph?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-accent-info)] transition-colors hover:bg-white/5"
aria-label="Open in graph"
>
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToActivity?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-accent-warning)] transition-colors hover:bg-white/5"
aria-label="Open in activity"
>
<Orbit className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenThread?.();
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-accent-ready)] transition-colors hover:bg-white/5"
aria-label="Open thread"
>
<Activity className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
<div className="flex items-center -space-x-2">
{data.agents.slice(0, 4).map((agent) => (
<div key={`${data.id}-${agent.name}`} className="rounded-full ring-2 ring-[#2C2C2C]">
<AgentAvatar
name={agent.name}
status={agent.status as AgentStatus}
role={agent.role}
size="sm"
/>
</div>
))}
{data.agents.length === 0 ? <span className="text-xs text-[#808080]">No crew</span> : null}
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-1 pt-2 shadow-[inset_0_10px_12px_-14px_rgba(0,0,0,0.88)]">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToGraph?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#5BA8A0]/24 text-[#AFE2DC] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)] transition-colors hover:bg-[#5BA8A0]/36"
title="Jump to graph view"
>
<GitBranch className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToActivity?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#D4A574]/24 text-[#E8D0B3] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)] transition-colors hover:bg-[#D4A574]/36"
title="Jump to activity view"
>
<Orbit className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenThread?.();
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#7CB97A]/24 text-[#D2EACF] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)] transition-colors hover:bg-[#7CB97A]/36"
title="Open thread"
>
<Activity className="h-3.5 w-3.5" />
</button>
</div>
</div>
);

View file

@ -1,7 +1,6 @@
'use client';
import { useMemo } from 'react';
import { Clock3, Layers2, Sparkles, TriangleAlert } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import type { BeadIssue } from '../../lib/types';
@ -14,6 +13,33 @@ interface SocialPageProps {
selectedId?: string;
onSelect: (id: string) => void;
projectScopeOptions?: ProjectScopeOption[];
blockedOnly?: boolean;
}
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
const SECTION_LABEL: Record<SectionKey, string> = {
ready: 'Ready',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
done: 'Done',
};
const SECTION_COLOR: Record<SectionKey, string> = {
ready: 'var(--ui-accent-ready)',
in_progress: 'var(--ui-accent-warning)',
blocked: 'var(--ui-accent-blocked)',
deferred: 'var(--ui-accent-info)',
done: 'var(--ui-text-muted)',
};
function bucketForStatus(status: string): SectionKey {
if (status === 'ready') return 'ready';
if (status === 'in_progress') return 'in_progress';
if (status === 'blocked') return 'blocked';
if (status === 'closed') return 'done';
return 'deferred';
}
function formatRelative(timestamp: string): string {
@ -31,16 +57,13 @@ function formatRelative(timestamp: string): string {
return `${diffDays}d ago`;
}
const STATUS_SCORE: Record<string, number> = {
blocked: 5,
in_progress: 4,
ready: 3,
open: 3,
deferred: 2,
closed: 1,
};
export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions = [] }: SocialPageProps) {
export function SocialPage({
issues,
selectedId,
onSelect,
projectScopeOptions = [],
blockedOnly = false,
}: SocialPageProps) {
const router = useRouter();
const searchParams = useSearchParams();
const cards = useMemo(() => buildSocialCards(issues), [issues]);
@ -48,11 +71,8 @@ export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions =
const navigateWithParams = (updates: Record<string, string | null>) => {
const next = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (!value) {
next.delete(key);
} else {
next.set(key, value);
}
if (!value) next.delete(key);
else next.set(key, value);
}
const query = next.toString();
router.push(query ? `/?${query}` : '/', { scroll: false });
@ -60,124 +80,194 @@ export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions =
const issueById = useMemo(() => {
const map = new Map<string, BeadIssue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const epicTitleById = useMemo(() => {
const map = new Map<string, string>();
for (const issue of issues) {
map.set(issue.id, issue);
if (issue.issue_type === 'epic') {
map.set(issue.id, issue.title);
}
}
return map;
}, [issues]);
const orderedCards = useMemo(() => {
return [...cards].sort((a, b) => {
const scoreDiff = (STATUS_SCORE[b.status] ?? 0) - (STATUS_SCORE[a.status] ?? 0);
if (scoreDiff !== 0) {
return scoreDiff;
}
return b.lastActivity.getTime() - a.lastActivity.getTime();
const toDependencyDetails = (ids: string[]) =>
ids.map((id) => {
const depIssue = issueById.get(id);
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
return {
id,
title: depIssue?.title ?? id,
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
};
});
}, [cards]);
const selectedCard = useMemo(
() => orderedCards.find((card) => card.id === selectedId) ?? null,
[orderedCards, selectedId],
const orderedCards = useMemo(
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
[cards],
);
const selectedIssue = selectedCard ? issueById.get(selectedCard.id) ?? null : null;
const visibleCards = useMemo(
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
[blockedOnly, orderedCards],
);
const metrics = useMemo(() => {
const blocked = cards.filter((card) => card.status === 'blocked').length;
const active = cards.filter((card) => card.status === 'in_progress').length;
const ready = cards.filter((card) => card.status === 'ready').length;
const urgent = cards.filter((card) => card.priority === 'P0').length;
const grouped = useMemo(() => {
const map: Record<SectionKey, typeof visibleCards> = {
ready: [],
in_progress: [],
blocked: [],
deferred: [],
done: [],
};
return { blocked, active, ready, urgent };
}, [cards]);
for (const card of visibleCards) {
map[bucketForStatus(card.status)].push(card);
}
return map;
}, [visibleCards]);
const [expandedSections, setExpandedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: false,
done: false,
});
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: true,
done: true,
});
return (
<div className="relative h-full overflow-y-auto bg-[#2D2D2D] custom-scrollbar">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_10%_12%,rgba(90,70,50,0.42),transparent_34%),radial-gradient(circle_at_88%_82%,rgba(35,72,77,0.34),transparent_36%)]" />
<div className="relative mx-auto flex max-w-[1450px] flex-col gap-4 p-5">
<section className="rounded-2xl bg-[linear-gradient(160deg,rgba(57,57,66,0.95),rgba(46,49,60,0.95))] p-4 shadow-[0_24px_40px_-26px_rgba(0,0,0,0.82),inset_0_1px_0_rgba(255,255,255,0.05)]">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#8B8B8B]">Social Stream</p>
<h2 className="mt-1 text-3xl font-semibold tracking-tight text-white">Task Activity Command Feed</h2>
<p className="mt-1 text-sm text-[#B8B8B8]">Two-column live task stream with inline thread context.</p>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="rounded-full bg-[#404856] px-3 py-1 text-[#D8D8D8] shadow-[0_10px_18px_-14px_rgba(0,0,0,0.8)]">{projectScopeOptions.length} scopes</div>
<div className="rounded-full bg-[#404856] px-3 py-1 text-[#D8D8D8] shadow-[0_10px_18px_-14px_rgba(0,0,0,0.8)]">{cards.length} tasks</div>
</div>
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
<div className="mx-auto w-full max-w-[1280px] px-4 pb-8 pt-4 xl:px-6">
<div className="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Social Stream</p>
<p className="text-sm text-[var(--ui-text-primary)]">Task Activity Command Feed</p>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-4">
<div className="rounded-xl bg-[#7CB97A]/24 px-3 py-2 text-xs font-semibold text-[#DDF0DA] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.ready} ready</div>
<div className="rounded-xl bg-[#D4A574]/24 px-3 py-2 text-xs font-semibold text-[#F0DEC8] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.active} in progress</div>
<div className="rounded-xl bg-[#C97A7A]/24 px-3 py-2 text-xs font-semibold text-[#F3D2D2] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.blocked} blocked</div>
<div className="rounded-xl bg-[#E24A3A]/24 px-3 py-2 text-xs font-semibold text-[#F7CBC6] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.urgent} P0</div>
<div className="flex items-center gap-2 text-[11px]">
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{projectScopeOptions.length} scopes
</span>
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{visibleCards.length} tasks
</span>
</div>
</section>
{selectedCard && selectedIssue ? (
<section className="rounded-2xl bg-[radial-gradient(circle_at_100%_50%,rgba(91,168,160,0.2),transparent_45%),rgba(54,57,66,0.94)] p-3 shadow-[0_16px_30px_-18px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.04)]">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-[#DDEDEC]">
<Sparkles className="h-4 w-4 text-[#5BA8A0]" />
<p className="text-sm font-semibold">Focused thread context</p>
</div>
<p className="text-xs text-[#8B8B8B]">{selectedCard.id}</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto_auto_auto]">
<p className="line-clamp-2 text-sm text-[#D8D8D8]">{selectedIssue.description ?? selectedIssue.title}</p>
<p className="inline-flex items-center gap-1 text-xs text-[#9E9E9E]"><Clock3 className="h-3.5 w-3.5" />{formatRelative(selectedIssue.updated_at)}</p>
<p className="inline-flex items-center gap-1 text-xs text-[#9E9E9E]"><Layers2 className="h-3.5 w-3.5" />{selectedIssue.dependencies.length} deps</p>
{selectedIssue.status === 'blocked' ? (
<p className="inline-flex items-center gap-1 text-xs text-[#E1BC8F]"><TriangleAlert className="h-3.5 w-3.5" />Needs unblock</p>
) : (
<p className="text-xs text-[#7CB97A]">Healthy flow</p>
)}
</div>
</section>
) : null}
<section className="grid grid-cols-1 gap-4 pb-6 xl:grid-cols-2">
{orderedCards.map((card) => {
const issue = issueById.get(card.id);
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
const description = issue?.description ?? undefined;
</div>
<section className="space-y-6">
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
const cardsForSection = grouped[key];
return (
<SocialCard
key={card.id}
data={card}
selected={selectedId === card.id}
onClick={() => onSelect(card.id)}
onJumpToGraph={(id) => {
navigateWithParams({
view: 'graph',
task: id,
swarm: null,
panel: 'open',
drawer: 'closed',
});
}}
onJumpToActivity={(id) => {
navigateWithParams({
view: 'activity',
task: id,
panel: 'open',
drawer: 'closed',
});
}}
onOpenThread={() => onSelect(card.id)}
description={description ?? undefined}
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
dependencyCount={issue?.dependencies.length ?? card.blocks.length + card.unblocks.length}
commentCount={commentCount}
unreadCount={unreadCount}
/>
<div key={key}>
<div className="mb-3 flex items-center gap-2 border-b border-[var(--ui-border-soft)] pb-2">
<p className="font-mono text-xs uppercase tracking-[0.14em]" style={{ color: SECTION_COLOR[key] }}>
{SECTION_LABEL[key]}
</p>
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-1.5 text-[10px] text-[var(--ui-text-primary)]">
{cardsForSection.length}
</span>
{(key === 'deferred' || key === 'done') ? (
<button
type="button"
onClick={() =>
setCollapsedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="ml-auto rounded border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)]"
>
{collapsedSections[key] ? 'Expand' : 'Minimize'}
</button>
) : null}
</div>
{collapsedSections[key] ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
{cardsForSection.length === 0
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
: `${cardsForSection.length} tasks hidden.`}
</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{(expandedSections[key] ? cardsForSection : cardsForSection.slice(0, 3)).map((card) => {
const issue = issueById.get(card.id);
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
return (
<SocialCard
key={card.id}
data={card}
selected={selectedId === card.id}
onClick={() => onSelect(card.id)}
onJumpToGraph={(id) =>
navigateWithParams({
view: 'graph',
task: id,
swarm: null,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onJumpToActivity={(id) =>
navigateWithParams({
view: 'activity',
task: id,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onOpenThread={() => onSelect(card.id)}
description={issue?.description ?? undefined}
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
dependencyCount={dependencyCount}
commentCount={commentCount}
unreadCount={unreadCount}
blockedByDetails={toDependencyDetails(card.unblocks)}
unblocksDetails={toDependencyDetails(card.blocks)}
/>
);
})}
{cardsForSection.length === 0 ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No tasks in this lane.
</p>
) : null}
</div>
)}
{!collapsedSections[key] && cardsForSection.length > 3 ? (
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() =>
setExpandedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2.5 py-1.5 text-xs font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
>
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
</button>
</div>
) : null}
</div>
);
})}
</section>
{visibleCards.length === 0 ? (
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No blocked tasks right now.
</p>
) : null}
</div>
</div>
);

View file

@ -0,0 +1,110 @@
import React from 'react';
import { X, Save, ShieldAlert } from 'lucide-react';
import type { AgentArchetype } from '../../lib/types-swarm';
interface ArchetypeInspectorProps {
archetype: AgentArchetype;
onClose: () => void;
}
export function ArchetypeInspector({ archetype, onClose }: ArchetypeInspectorProps) {
if (!archetype) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="flex flex-col h-[85vh] w-full max-w-2xl overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
<div className="flex items-center gap-3">
<div
className="h-10 w-10 rounded-lg flex items-center justify-center font-bold text-lg border"
style={{ backgroundColor: `${archetype.color}15`, color: archetype.color, borderColor: `${archetype.color}30` }}
>
{archetype.name.charAt(0)}
</div>
<div>
<h2 className="text-lg font-bold text-[var(--ui-text-primary)] leading-tight">{archetype.name}</h2>
<p className="font-mono uppercase tracking-wider text-[10px] text-[var(--ui-text-muted)] mt-0.5">{archetype.id}</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-[var(--ui-text-muted)] hover:bg-white/5 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
{/* ReadOnly Warning if builtin */}
{archetype.isBuiltIn && (
<div className="flex items-start gap-3 bg-[var(--ui-accent-warning)]/10 border border-[var(--ui-accent-warning)]/20 p-3 rounded-lg text-[var(--ui-accent-warning)]">
<ShieldAlert className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<span className="font-semibold">Built-in Archetype.</span> This is a core system role. You cannot delete it, but you can override its system prompt.
</div>
</div>
)}
{/* Metadata Section */}
<div className="space-y-4">
<div>
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Description</label>
<input
type="text"
defaultValue={archetype.description}
readOnly
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:border-[var(--ui-accent-info)] focus:ring-1 focus:ring-[var(--ui-accent-info)]"
/>
</div>
<div>
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Capabilities</label>
<div className="flex flex-wrap gap-2">
{archetype.capabilities.map((cap, idx) => (
<span key={idx} className="px-2 py-1 rounded-md bg-white/5 text-[11px] uppercase font-semibold text-[var(--ui-text-muted)] border border-white/10">
{cap}
</span>
))}
</div>
</div>
</div>
<div className="border-t border-[var(--ui-border-soft)] pt-6">
<div className="flex flex-col h-[300px]">
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 flex items-center justify-between">
<span>System Prompt</span>
<span className="text-[10px] text-emerald-400 normal-case tracking-normal">Syntax: Markdown</span>
</label>
<textarea
defaultValue={archetype.systemPrompt}
readOnly
className="flex-1 w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md p-4 text-sm text-[var(--ui-text-primary)] font-mono resize-none focus:outline-none focus:border-[var(--ui-accent-info)] custom-scrollbar leading-relaxed"
/>
</div>
</div>
</div>
{/* Footer Controls */}
<div className="border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e] flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-semibold text-[var(--ui-text-primary)] hover:bg-white/5 rounded-md transition-colors"
>
Cancel
</button>
<button
disabled
className="flex items-center gap-2 px-4 py-2 text-sm font-bold bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
Save Changes
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { Loader2, CheckCircle2 } from "lucide-react";
export type Phase = 'planning' | 'deployment' | 'execution' | 'debrief';
export function ConvoyStepper({ activePhase }: { activePhase: Phase }) {
const phases: Phase[] = ['planning', 'deployment', 'execution', 'debrief'];
return (
<div className="flex flex-wrap items-center gap-4 bg-muted/50 p-4 rounded-lg my-4">
{phases.map((p, i) => {
const isActive = activePhase === p;
const isPast = phases.indexOf(activePhase) > i;
return (
<div
key={p}
className={`flex items-center gap-2 ${isActive ? 'text-primary' : isPast ? 'text-muted-foreground' : 'text-muted-foreground/50'
}`}
>
{isActive && <Loader2 className="w-4 h-4 animate-spin" />}
{isPast && <CheckCircle2 className="w-4 h-4" />}
{!isActive && !isPast && <div className="w-4 h-4 rounded-full border border-current" />}
<span className="font-mono text-sm uppercase">{p}</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,172 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Loader2, Plus, Rocket } from 'lucide-react';
interface LaunchSwarmDialogProps {
projectRoot: string;
onSuccess?: () => void;
}
interface Formula {
name: string;
description?: string;
}
export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [formulas, setFormulas] = useState<Formula[]>([]);
const [selectedFormula, setSelectedFormula] = useState<string>('');
const [title, setTitle] = useState('');
const [error, setError] = useState<string | null>(null);
const fetchFormulas = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/swarm/formulas?projectRoot=${encodeURIComponent(projectRoot)}`);
const json = await res.json();
if (json.ok) {
setFormulas(json.data);
} else {
setError(json.error);
}
} catch (e) {
setError('Failed to fetch formulas');
} finally {
setLoading(false);
}
};
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
if (isOpen && formulas.length === 0) {
fetchFormulas();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !selectedFormula) return;
setLoading(true);
setError(null);
try {
const res = await fetch('/api/swarm/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectRoot,
title,
proto: selectedFormula,
}),
});
const json = await res.json();
if (json.ok) {
setOpen(false);
setTitle('');
setSelectedFormula('');
onSuccess?.();
} else {
setError(json.error);
}
} catch (e) {
setError('Failed to launch swarm');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 border-emerald-500/20 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:text-emerald-300"
>
<Rocket className="h-4 w-4" />
Launch Swarm
</Button>
</DialogTrigger>
<DialogContent className="bg-[#08111d] border-slate-800 text-slate-200 sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Launch New Swarm</DialogTitle>
<DialogDescription className="text-slate-400">
Instantiate a new molecule from a template proto.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="proto" className="text-slate-300">Formula Template</Label>
<Select value={selectedFormula} onValueChange={setSelectedFormula} disabled={loading}>
<SelectTrigger className="bg-slate-900 border-slate-700 text-slate-200">
<SelectValue placeholder="Select a proto..." />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
{formulas.length === 0 && !loading && (
<div className="p-2 text-xs text-slate-500 text-center">No formulas found</div>
)}
{formulas.map((f) => (
<SelectItem key={f.name} value={f.name} className="focus:bg-slate-700 focus:text-slate-100">
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="title" className="text-slate-300">Swarm Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="bg-slate-900 border-slate-700 text-slate-200"
placeholder="e.g. Release v1.2"
disabled={loading}
/>
</div>
{error && (
<div className="text-xs text-rose-400 bg-rose-950/20 p-2 rounded border border-rose-900/30">
{error}
</div>
)}
</form>
<DialogFooter>
<Button
type="submit"
onClick={handleSubmit}
disabled={loading || !title || !selectedFormula}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Rocket className="mr-2 h-4 w-4" />}
Launch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,210 @@
import React, { useEffect, useMemo } from 'react';
import {
Background,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
Handle,
type Edge,
type Node,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
// Custom Node for the Agent DAG
interface AgentNodeData extends Record<string, unknown> {
title: string;
status: string;
assignee: string | null;
archetype?: AgentArchetype;
isSelected?: boolean;
}
function AgentNodeCard({ data }: { data: AgentNodeData }) {
const isDone = data.status === 'closed';
const isInProgress = data.status === 'in_progress';
const isBlocked = data.status === 'blocked';
const statusColor = isDone ? 'text-emerald-400' : isBlocked ? 'text-rose-400' : isInProgress ? 'text-amber-400' : 'text-slate-400';
let borderColor = isDone ? 'border-emerald-500/30' : isBlocked ? 'border-rose-500/30' : isInProgress ? 'border-amber-500/30' : 'border-slate-500/30';
let containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-xl transition-all duration-500 ${borderColor}`;
if (isInProgress) {
containerClasses += ' shadow-[0_0_20px_rgba(251,191,36,0.15)] ring-1 ring-amber-500/30';
}
if (data.isSelected) {
containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-[0_0_25px_rgba(56,189,248,0.2)] transition-all duration-300 border-[var(--ui-accent-info)] ring-2 ring-[var(--ui-accent-info)]/50`;
}
const bgStr = data.archetype ? `${data.archetype.color}15` : '#ffffff05';
const colorStr = data.archetype ? data.archetype.color : '#888';
return (
<div className={containerClasses}>
<div className="flex items-start gap-3">
<div
className={`h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center font-bold text-lg border relative ${isInProgress ? 'animate-pulse duration-1000' : ''}`}
style={{ backgroundColor: bgStr, color: colorStr, borderColor: `${colorStr}40` }}
>
{data.assignee ? data.assignee.charAt(0).toUpperCase() : '?'}
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor} ${isInProgress ? 'animate-ping' : ''}`} style={{ animationDuration: '2s' }} />
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-0.5 truncate flex items-center justify-between">
<span>{data.assignee || 'Unassigned'}</span>
{isInProgress && <span className="text-amber-500 animate-pulse text-[8px] tracking-widest">WORKING...</span>}
</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-tight line-clamp-2">
{data.title}
</div>
{data.archetype && (
<div className="text-[9px] text-[var(--ui-text-muted)] mt-1 truncate">
{data.archetype.name}
</div>
)}
</div>
</div>
{/* React Flow handles */}
<Handle type="target" position={Position.Left} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !left-[-8px] opacity-0" />
<Handle type="source" position={Position.Right} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !right-[-8px] opacity-0" />
</div>
);
}
const nodeTypes = {
agentNode: AgentNodeCard,
};
const layoutDagre = (nodes: Node<AgentNodeData>[], edges: Edge[]): Node<AgentNodeData>[] => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 60 });
for (const node of nodes) {
dagreGraph.setNode(node.id, { width: 260, height: 110 });
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
dagre.layout(dagreGraph);
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
const newNode = { ...node };
if (nodeWithPosition) {
newNode.targetPosition = Position.Left;
newNode.sourcePosition = Position.Right;
newNode.position = {
x: nodeWithPosition.x - 260 / 2,
y: nodeWithPosition.y - 110 / 2,
};
}
return newNode;
});
};
function SpecializedAgentDagInner({ beads, archetypes, selectedId, onSelect }: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
const { fitView } = useReactFlow();
const handleNodeClick = React.useCallback(
(_: React.MouseEvent, node: Node) => {
onSelect?.(node.id);
},
[onSelect],
);
const flowModel = useMemo(() => {
// Find visible beads (hide tombstone)
const visibleBeads = beads.filter(b => b.status !== 'tombstone');
const baseNodes: Node<AgentNodeData>[] = visibleBeads.map((issue) => {
const assigneeStr = issue.assignee?.toLowerCase() || '';
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
return {
id: issue.id,
type: 'agentNode',
data: {
title: issue.title,
status: issue.status,
assignee: issue.assignee,
archetype: matchedArchetype,
isSelected: issue.id === selectedId
},
position: { x: 0, y: 0 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
});
const graphEdges: Edge[] = [];
const beadIds = new Set(visibleBeads.map(b => b.id));
visibleBeads.forEach(issue => {
issue.dependencies.forEach(dep => {
if (dep.type === 'blocks' && beadIds.has(dep.target)) {
// issue depends on dep.target (issue is blocked by dep.target)
// Edge should flow from blocker to blocked
graphEdges.push({
id: `e-${dep.target}-${issue.id}`,
source: dep.target,
target: issue.id,
type: 'smoothstep',
animated: issue.status === 'in_progress' || issue.status === 'closed',
style: { stroke: '#475569', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#475569' }
});
}
});
});
console.log('SpecializedAgentDag generated nodes:', baseNodes.length, 'edges:', graphEdges.length);
return {
nodes: layoutDagre(baseNodes, graphEdges),
edges: graphEdges,
};
}, [beads, archetypes, selectedId]);
useEffect(() => {
const timeout = setTimeout(() => {
fitView({ padding: 0.3, duration: 300 });
}, 100);
return () => clearTimeout(timeout);
}, [fitView, flowModel.nodes.length]);
return (
<ReactFlow
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={true}
onNodeClick={handleNodeClick}
fitView
>
<Background gap={24} size={1} color="rgba(255,255,255,0.02)" />
</ReactFlow>
);
}
export function SpecializedAgentDag(props: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
return (
<ReactFlowProvider>
<SpecializedAgentDagInner {...props} />
</ReactFlowProvider>
);
}

View file

@ -1,47 +1,13 @@
'use client';
import type { SwarmCard as SwarmCardType, AgentRoster } from '../../lib/swarm-cards';
import type { SwarmCardData } from '../../lib/swarm-api';
import { Card } from '../../../components/ui/card';
import { Badge } from '../../../components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { cn } from '../../lib/utils';
import { Plus, Menu, Diamond, Waves, AlertTriangle } from 'lucide-react';
import { CheckCircle2, PlayCircle, Clock, AlertCircle } from 'lucide-react';
interface SwarmCardProps {
card: SwarmCardType;
onExpand?: () => void;
onMenu?: () => void;
onGraph?: () => void;
onTimeline?: () => void;
}
function formatTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMins > 0) return `${diffMins}m ago`;
return 'just now';
}
const HEALTH_COLORS: Record<string, string> = {
active: 'text-emerald-400',
stale: 'text-amber-400',
stuck: 'text-rose-400',
dead: 'text-red-500',
};
function AgentRosterRow({ agent }: { agent: AgentRoster }) {
return (
<div className="flex items-center gap-2 text-xs text-slate-400">
<span className="font-mono text-slate-500">{agent.name}:</span>
<span className="truncate">{agent.currentTask || 'idle'}</span>
</div>
);
card: SwarmCardData;
}
function ProgressBar({ progress }: { progress: number }) {
@ -50,118 +16,70 @@ function ProgressBar({ progress }: { progress: number }) {
return (
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-xs">
<div className="flex-1 font-mono text-xs text-slate-300">
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</div>
<span className="text-xs text-slate-400">{progress}% done</span>
<span className="text-xs text-slate-400">{progress}%</span>
</div>
);
}
function AttentionList({ items }: { items: string[] }) {
if (items.length === 0) return null;
const STATUS_COLORS: Record<string, string> = {
open: 'text-emerald-400 border-emerald-400/30',
closed: 'text-slate-400 border-slate-400/30',
in_progress: 'text-amber-400 border-amber-400/30',
};
export function SwarmCard({ card }: SwarmCardProps) {
return (
<div className="space-y-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-amber-400">
ATTENTION:
</span>
{items.slice(0, 3).map((item, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-amber-200/80">
<AlertTriangle className="h-3 w-3 text-amber-400" />
<span className="truncate">{item}</span>
</div>
))}
</div>
);
}
export function SwarmCard({ card, onExpand, onMenu, onGraph, onTimeline }: SwarmCardProps) {
const activeAgents = card.agents.filter((a) => a.status === 'active');
const otherAgents = card.agents.filter((a) => a.status !== 'active');
return (
<Card className="rounded-xl border border-white/[0.06] bg-[#363636] px-3.5 py-3 shadow-[0_18px_38px_-18px_rgba(0,0,0,0.82),0_6px_18px_-10px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.06)] transition duration-200 hover:shadow-[0_24px_52px_-16px_rgba(0,0,0,0.9),0_10px_26px_-10px_rgba(0,0,0,0.78)]">
<Card className="rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-card)] px-3.5 py-3 shadow-[0_18px_38px_-18px_rgba(0,0,0,0.82),0_6px_18px_-10px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.06)] transition-shadow duration-200 hover:shadow-[0_24px_52px_-16px_rgba(0,0,0,0.9),0_10px_26px_-10px_rgba(0,0,0,0.78)]">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0 border-slate-600', HEALTH_COLORS[card.health])}
>
{card.health}
</Badge>
</div>
<span className="text-sm text-slate-400 line-clamp-1">{card.title}</span>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', STATUS_COLORS[card.status] ?? 'text-slate-400 border-slate-400/30')}
>
{card.status}
</Badge>
</div>
<button
onClick={onExpand}
className="p-1 rounded hover:bg-white/5 transition-colors"
aria-label="Expand"
>
<Plus className="h-4 w-4 text-slate-500" />
</button>
<span className="text-sm text-slate-400 line-clamp-1">{card.title}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
AGENTS:
</span>
<div className="flex items-center gap-1 -space-x-1">
{activeAgents.slice(0, 4).map((agent) => (
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
))}
{otherAgents.slice(0, 2).map((agent) => (
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
))}
{card.agents.length > 6 && (
<span className="text-xs text-slate-500 ml-2">+{card.agents.length - 6}</span>
)}
<ProgressBar progress={card.progressPercent} />
<div className="text-xs text-slate-500">
Epic: <span className="font-mono text-slate-400">{card.epicId}</span>
</div>
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
<span>{card.completedIssues}</span>
</div>
<div className="flex items-center gap-1 text-amber-400">
<PlayCircle className="h-3 w-3" />
<span>{card.activeIssues}</span>
</div>
<div className="flex items-center gap-1 text-blue-400">
<Clock className="h-3 w-3" />
<span>{card.readyIssues}</span>
</div>
<div className="flex items-center gap-1 text-rose-400">
<AlertCircle className="h-3 w-3" />
<span>{card.blockedIssues}</span>
</div>
</div>
{card.agents.filter((a) => a.currentTask).slice(0, 2).map((agent) => (
<AgentRosterRow key={agent.name} agent={agent} />
))}
<AttentionList items={card.attentionItems} />
<ProgressBar progress={card.progress} />
{card.lastActivity && (
<div className="text-xs text-slate-500 italic truncate">
Last activity {formatTimeAgo(card.lastActivity)}
{card.coordinator && (
<div className="text-xs text-slate-500">
Coordinator: <span className="text-slate-400">{card.coordinator}</span>
</div>
)}
<div className="flex items-center justify-end gap-1 pt-1 border-t border-white/[0.04]">
<button
onClick={onMenu}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Menu"
>
<Menu className="h-3.5 w-3.5 text-slate-500" />
</button>
<button
onClick={onGraph}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Graph view"
>
<Diamond className="h-3.5 w-3.5 text-slate-500" />
</button>
<button
onClick={onTimeline}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Timeline view"
>
<Waves className="h-3.5 w-3.5 text-slate-500" />
</button>
</div>
</div>
</Card>
);

View file

@ -0,0 +1,146 @@
'use client';
import type { SwarmCardData } from '../../lib/swarm-api';
import { Card } from '../../../components/ui/card';
import { Badge } from '../../../components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '../../lib/utils';
import { CheckCircle2, PlayCircle, Clock, AlertCircle, UserPlus, UserMinus, Activity } from 'lucide-react';
import { AgentAvatar } from '../shared/agent-avatar';
import { useAgentPool } from '../../hooks/use-agent-pool';
interface SwarmControlCardProps {
card: SwarmCardData;
projectRoot: string;
onJoin?: () => void;
onLeave?: () => void;
isJoining?: boolean;
}
function MiniGraph({ progress }: { progress: number }) {
// A simple visual indicator of progress complexity (mocked for now, but implies graph structure)
return (
<div className="flex h-8 items-end gap-0.5 opacity-50">
{[...Array(10)].map((_, i) => {
const height = Math.max(20, Math.random() * 80);
const active = (i * 10) < progress;
return (
<div
key={i}
className={cn("w-1 rounded-t-sm transition-all", active ? "bg-emerald-500" : "bg-slate-700")}
style={{ height: `${active ? height : 20}%` }}
/>
)
})}
</div>
);
}
const STATUS_COLORS: Record<string, string> = {
open: 'text-emerald-400 border-emerald-400/30',
closed: 'text-slate-400 border-slate-400/30',
in_progress: 'text-amber-400 border-amber-400/30',
};
export function SwarmControlCard({ card, projectRoot, onJoin, onLeave, isJoining }: SwarmControlCardProps) {
const { getAgentsBySwarm } = useAgentPool(projectRoot);
const agents = getAgentsBySwarm(card.swarmId);
return (
<Card className="group relative overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-card)] p-0 shadow-lg transition-all hover:border-[var(--ui-accent-info)] hover:shadow-xl">
{/* Background Decoration */}
<div className="absolute right-0 top-0 h-32 w-32 -translate-y-16 translate-x-16 rounded-full bg-emerald-500/5 blur-3xl transition-opacity group-hover:opacity-20" />
<div className="flex flex-col h-full p-4 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold text-emerald-500">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[9px] px-1.5 py-0 uppercase', STATUS_COLORS[card.status] ?? 'text-slate-400 border-slate-400/30')}
>
{card.status}
</Badge>
</div>
<h4 className="text-sm font-semibold text-slate-200 line-clamp-1 group-hover:text-white transition-colors">
{card.title}
</h4>
</div>
<Activity className="h-4 w-4 text-slate-600 group-hover:text-emerald-400 transition-colors" />
</div>
{/* Visualizer */}
<div className="rounded-lg bg-black/20 p-2">
<div className="flex justify-between items-end mb-1">
<span className="text-[10px] text-slate-500 font-mono">ACTIVITY</span>
<span className="text-[10px] text-emerald-400 font-mono">{card.progressPercent}%</span>
</div>
<MiniGraph progress={card.progressPercent} />
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-2 text-xs border-t border-white/5 pt-3">
<div className="flex flex-col items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.completedIssues}</span>
</div>
<div className="flex flex-col items-center gap-1 text-amber-400">
<PlayCircle className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.activeIssues}</span>
</div>
<div className="flex flex-col items-center gap-1 text-blue-400">
<Clock className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.readyIssues}</span>
</div>
<div className="flex flex-col items-center gap-1 text-rose-400">
<AlertCircle className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.blockedIssues}</span>
</div>
</div>
{/* Agent Roster & Actions */}
<div className="flex items-center justify-between mt-auto pt-2">
<div className="flex -space-x-2">
{agents.slice(0, 3).map(agent => (
<div key={agent.agent_id} className="ring-2 ring-[var(--ui-bg-card)] rounded-full z-10">
<AgentAvatar
name={agent.display_name}
status={agent.status as any}
size="sm"
/>
</div>
))}
{agents.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-800 text-[10px] font-bold text-slate-400 ring-2 ring-[var(--ui-bg-card)] z-0">
+{agents.length - 3}
</div>
)}
{agents.length === 0 && (
<span className="text-[10px] text-slate-500 italic pl-1">No agents</span>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-[10px] gap-1 border-emerald-500/20 hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={(e) => {
e.stopPropagation();
onJoin?.();
}}
disabled={isJoining}
>
<UserPlus className="h-3 w-3" />
Join
</Button>
</div>
</div>
</div>
</Card>
);
}

View file

@ -1,201 +1,179 @@
'use client';
import type { SwarmCard as SwarmCardType } from '../../lib/swarm-cards';
import { useEffect, useState } from 'react';
import type { SwarmCardData, SwarmStatusFromApi } from '../../lib/swarm-api';
import { Badge } from '../../../components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { cn } from '../../lib/utils';
import { AlertTriangle, Clock, Users } from 'lucide-react';
import { CheckCircle2, PlayCircle, Clock, AlertCircle, Loader2 } from 'lucide-react';
interface SwarmDetailProps {
card: SwarmCardType;
}
const HEALTH_COLORS: Record<string, string> = {
active: 'border-emerald-500/50 text-emerald-400',
stale: 'border-amber-500/50 text-amber-400',
stuck: 'border-rose-500/50 text-rose-400',
dead: 'border-red-600/50 text-red-500',
};
const STATUS_GLOW: Record<string, string> = {
active: 'shadow-[0_0_8px_rgba(52,211,153,0.5)]',
stale: 'shadow-[0_0_8px_rgba(251,191,36,0.4)]',
stuck: 'shadow-[0_0_8px_rgba(244,63,94,0.5)]',
dead: 'shadow-[0_0_8px_rgba(220,38,38,0.6)]',
};
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffMins > 0) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
return 'just now';
swarmId: string;
projectRoot: string;
}
function ProgressBar({ progress }: { progress: number }) {
const filled = Math.round(progress / 10);
const empty = 10 - filled;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span style={{ color: 'var(--color-text-muted)' }}>Progress</span>
<span className="font-mono" style={{ color: 'var(--color-text-secondary)' }}>
{progress}%
</span>
<span className="text-slate-400">Progress</span>
<span className="font-mono text-slate-300">{progress}%</span>
</div>
<div
className="h-1.5 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progress}%`,
backgroundColor:
progress >= 80
? 'var(--color-success)'
: progress >= 50
? 'var(--color-warning)'
: 'var(--color-error)',
}}
/>
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-xs text-slate-300">
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</div>
</div>
</div>
);
}
function AgentRosterSection({ agents }: { agents: SwarmCardType['agents'] }) {
const active = agents.filter((a) => a.status === 'active').length;
const stale = agents.filter((a) => a.status === 'stale').length;
const stuck = agents.filter((a) => a.status === 'stuck').length;
const dead = agents.filter((a) => a.status === 'dead').length;
export function SwarmDetail({ swarmId, projectRoot }: SwarmDetailProps) {
const [status, setStatus] = useState<SwarmStatusFromApi | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchStatus() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/swarm/status?projectRoot=${encodeURIComponent(projectRoot)}&epic=${encodeURIComponent(swarmId)}`
);
const payload = await response.json();
if (payload.ok && payload.data) {
setStatus(payload.data);
} else {
setError(payload.error?.message || 'Failed to load swarm status');
}
} catch (e) {
setError('Failed to fetch swarm status');
} finally {
setIsLoading(false);
}
}
fetchStatus();
}, [swarmId, projectRoot]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-slate-400">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading swarm...
</div>
);
}
if (error) {
return (
<div className="py-8 text-center text-rose-400">
{error}
</div>
);
}
if (!status) {
return (
<div className="py-8 text-center text-slate-400">
No swarm data found
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" style={{ color: 'var(--color-text-muted)' }} />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Agents ({agents.length})
</span>
<div className="space-y-4 p-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{swarmId}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-emerald-400 border-emerald-400/30">
swarm
</Badge>
</div>
<h3 className="text-sm font-medium text-slate-200 line-clamp-2">
{status.epic_title}
</h3>
</div>
<div className="flex flex-wrap gap-1.5">
{agents.map((agent) => (
<div
key={agent.name}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md border',
STATUS_GLOW[agent.status]
)}
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<AgentAvatar name={agent.name} status={agent.status} size="sm" />
<span className="text-xs" style={{ color: 'var(--color-text-primary)' }}>
{agent.name}
</span>
{/* Progress */}
<ProgressBar progress={status.progress_percent} />
{/* Stats Grid */}
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
<span>{status.completed.length} done</span>
</div>
<div className="flex items-center gap-1 text-amber-400">
<PlayCircle className="h-3 w-3" />
<span>{status.active_count} active</span>
</div>
<div className="flex items-center gap-1 text-blue-400">
<Clock className="h-3 w-3" />
<span>{status.ready_count} ready</span>
</div>
<div className="flex items-center gap-1 text-rose-400">
<AlertCircle className="h-3 w-3" />
<span>{status.blocked_count} blocked</span>
</div>
</div>
{/* Active Tasks */}
{status.active.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Active ({status.active.length})
</h4>
<div className="space-y-1">
{status.active.map((task) => (
<div key={task.id} className="p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<span className="font-mono text-[10px] text-amber-300">{task.id}</span>
<p className="text-xs text-slate-300 line-clamp-1">{task.title}</p>
</div>
))}
</div>
</div>
)}
{/* Ready Tasks */}
{status.ready.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Ready to Pick Up ({status.ready.length})
</h4>
<div className="space-y-1">
{status.ready.map((task) => (
<div key={task.id} className="p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<span className="font-mono text-[10px] text-blue-300">{task.id}</span>
<p className="text-xs text-slate-300 line-clamp-1">{task.title}</p>
</div>
))}
</div>
</div>
)}
{/* Blocked Tasks */}
{status.blocked.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Blocked ({status.blocked.length})
</h4>
<div className="space-y-1">
{status.blocked.map((task) => (
<div key={task.id} className="p-2 rounded-md bg-rose-500/10 border border-rose-500/20">
<span className="font-mono text-[10px] text-rose-300">{task.id}</span>
<p className="text-xs text-slate-300 line-clamp-1">{task.title}</p>
</div>
))}
</div>
))}
</div>
{(active > 0 || stale > 0 || stuck > 0 || dead > 0) && (
<div className="flex gap-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{active > 0 && <span className="text-emerald-400">{active} active</span>}
{stale > 0 && <span className="text-amber-400">{stale} stale</span>}
{stuck > 0 && <span className="text-rose-400">{stuck} stuck</span>}
{dead > 0 && <span className="text-red-500">{dead} dead</span>}
</div>
)}
</div>
);
}
function AttentionSection({ items }: { items: string[] }) {
if (items.length === 0) return null;
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Attention ({items.length})
</span>
</div>
<div className="space-y-1.5">
{items.map((item, i) => (
<div
key={i}
className="flex items-start gap-1.5 p-2 rounded-md"
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<AlertTriangle className="h-3 w-3 text-amber-400 mt-0.5 flex-shrink-0" />
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{item}
</span>
</div>
))}
</div>
</div>
);
}
function LastActivitySection({ date }: { date: Date }) {
return (
<div className="flex items-center gap-1.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<Clock className="h-3.5 w-3.5" />
<span>Last activity {formatRelativeTime(date)}</span>
</div>
);
}
function ThreadSection() {
return (
<div className="space-y-2">
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Thread
</span>
<p className="text-text-muted text-sm italic">
Thread drawer coming (bb-ui2.31)
</p>
</div>
);
}
export function SwarmDetail({ card }: SwarmDetailProps) {
return (
<div className="space-y-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold" style={{ color: 'var(--color-text-primary)' }}>
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', HEALTH_COLORS[card.health])}
>
{card.health}
</Badge>
</div>
<h3 className="text-sm font-medium line-clamp-2" style={{ color: 'var(--color-text-primary)' }}>
{card.title}
</h3>
</div>
{/* Progress */}
<ProgressBar progress={card.progress} />
{/* Agent Roster */}
<AgentRosterSection agents={card.agents} />
{/* Attention Items */}
<AttentionSection items={card.attentionItems} />
{/* Last Activity */}
<LastActivitySection date={card.lastActivity} />
{/* Thread */}
<ThreadSection />
</div>
);
}

View file

@ -0,0 +1,191 @@
'use client';
import { useEffect, useState } from 'react';
import type { SwarmCardData, SwarmStatusFromApi } from '../../lib/swarm-api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CheckCircle2, PlayCircle, Clock, AlertCircle, Loader2, Users } from 'lucide-react';
import { AgentAvatar } from '../shared/agent-avatar';
import { useAgentPool } from '../../hooks/use-agent-pool';
interface SwarmInspectorProps {
swarmId: string;
projectRoot: string;
onClose?: () => void;
}
function ProgressBar({ progress }: { progress: number }) {
const filled = Math.round(progress / 10);
const empty = 10 - filled;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-400">Progress</span>
<span className="font-mono text-slate-300">{progress}%</span>
</div>
<div className="flex items-center gap-1 font-mono text-xs text-slate-300 tracking-widest">
<span className="text-emerald-400">{'█'.repeat(filled)}</span>
<span className="text-slate-700">{'░'.repeat(empty)}</span>
</div>
</div>
);
}
export function SwarmInspector({ swarmId, projectRoot }: SwarmInspectorProps) {
const [status, setStatus] = useState<SwarmStatusFromApi | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { agents, getAgentsBySwarm } = useAgentPool(projectRoot);
useEffect(() => {
async function fetchStatus() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/swarm/status?projectRoot=${encodeURIComponent(projectRoot)}&epic=${encodeURIComponent(swarmId)}`
);
const payload = await response.json();
if (payload.ok && payload.data) {
setStatus(payload.data);
} else {
setError(payload.error?.message || 'Failed to load swarm status');
}
} catch (e) {
setError('Failed to fetch swarm status');
} finally {
setIsLoading(false);
}
}
fetchStatus();
}, [swarmId, projectRoot]);
const assignedAgents = getAgentsBySwarm(swarmId);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-slate-400">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading...
</div>
);
}
if (error || !status) {
return (
<div className="p-4 text-center text-rose-400">
{error || 'No data found'}
</div>
);
}
return (
<div className="flex flex-col h-full bg-[#08111d] text-slate-200">
{/* Header */}
<div className="p-4 border-b border-[var(--ui-border-soft)] bg-[#0d1621]">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="font-mono text-[10px] text-emerald-400 border-emerald-400/30 px-1.5">
{swarmId}
</Badge>
<span className="text-[10px] uppercase tracking-wider text-slate-500">Active Operation</span>
</div>
<h3 className="text-sm font-semibold leading-snug line-clamp-2 mb-3">
{status.epic_title}
</h3>
<ProgressBar progress={status.progress_percent} />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Agent Roster */}
<section>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500 flex items-center gap-2">
<Users className="h-3 w-3" />
Assigned Agents
</h4>
<span className="text-[10px] bg-slate-800 px-1.5 py-0.5 rounded text-slate-400">
{assignedAgents.length}
</span>
</div>
{assignedAgents.length === 0 ? (
<div className="text-xs text-slate-500 italic p-3 border border-dashed border-slate-800 rounded-lg text-center">
No agents currently assigned.
<br/>
<span className="text-[10px]">Use "Join" on the main card.</span>
</div>
) : (
<div className="space-y-2">
{assignedAgents.map(agent => (
<div key={agent.agent_id} className="flex items-center gap-3 p-2 rounded-lg bg-slate-800/50 border border-slate-800">
<AgentAvatar
name={agent.display_name}
status={agent.status as any}
size="sm"
/>
<div>
<p className="text-xs font-medium text-slate-300">{agent.display_name}</p>
<p className="text-[10px] text-slate-500 font-mono">{agent.status}</p>
</div>
</div>
))}
</div>
)}
</section>
{/* Task Stats */}
<section className="grid grid-cols-2 gap-2">
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Done</span>
</div>
<span className="text-lg font-mono">{status.completed.length}</span>
</div>
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-amber-400">
<PlayCircle className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Active</span>
</div>
<span className="text-lg font-mono">{status.active_count}</span>
</div>
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-blue-400">
<Clock className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Ready</span>
</div>
<span className="text-lg font-mono">{status.ready_count}</span>
</div>
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-rose-400">
<AlertCircle className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Blocked</span>
</div>
<span className="text-lg font-mono">{status.blocked_count}</span>
</div>
</section>
{/* Active Tasks List */}
{status.active.length > 0 && (
<section>
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-3">
Currently Executing
</h4>
<div className="space-y-2">
{status.active.map((task) => (
<div key={task.id} className="p-3 rounded-lg bg-amber-950/20 border border-amber-900/30">
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-[10px] text-amber-500">{task.id}</span>
<Badge variant="outline" className="text-[9px] h-4 border-amber-800 text-amber-500">IN PROGRESS</Badge>
</div>
<p className="text-xs text-slate-300 line-clamp-2">{task.title}</p>
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}

View file

@ -1,10 +1,11 @@
'use client';
import { useMemo, useState } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { SwarmCard as SwarmCardType } from '../../lib/swarm-cards';
import { buildSwarmCards } from '../../lib/swarm-cards';
import { SwarmCard } from './swarm-card';
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { useMissionList, type MissionData } from '../../hooks/use-mission-list';
import { MissionCard } from '../mission/mission-card';
import { TeamManagerDialog } from '../mission/team-manager-dialog';
import { MissionInspector } from '../mission/mission-inspector';
import { LaunchSwarmDialog } from './launch-dialog';
import {
DropdownMenu,
DropdownMenuTrigger,
@ -14,7 +15,8 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { ArrowUpDown, ChevronDown } from 'lucide-react';
import { ArrowUpDown, ChevronDown, Loader2, Rocket, LayoutGrid, Users, Shield } from 'lucide-react';
import { useAgentPool } from '../../hooks/use-agent-pool';
type SortOption = 'health' | 'activity' | 'progress' | 'name';
@ -25,76 +27,157 @@ const SORT_LABELS: Record<SortOption, string> = {
name: 'Name',
};
const INITIAL_LIMIT = 16; // 4x4 grid
const HEALTH_ORDER: Record<string, number> = {
stuck: 0,
stale: 1,
dead: 2,
active: 3,
};
function sortCards(cards: SwarmCardType[], sortBy: SortOption): SwarmCardType[] {
const sorted = [...cards];
const INITIAL_LIMIT = 16;
function sortMissions(missions: MissionData[], sortBy: SortOption): MissionData[] {
const sorted = [...missions];
switch (sortBy) {
case 'health':
return sorted.sort((a, b) => {
const orderA = HEALTH_ORDER[a.health] ?? 4;
const orderB = HEALTH_ORDER[b.health] ?? 4;
return orderA - orderB;
});
case 'activity':
return sorted.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
case 'progress':
return sorted.sort((a, b) => b.progress - a.progress);
return sorted.sort((a, b) => (b.stats.done / (b.stats.total || 1)) - (a.stats.done / (a.stats.total || 1)));
case 'activity':
return sorted; // Need last_activity in API to sort real activity
case 'health':
return sorted.sort((a, b) => b.stats.blocked - a.stats.blocked); // Most blocked first
case 'name':
return sorted.sort((a, b) => a.swarmId.localeCompare(b.swarmId));
return sorted.sort((a, b) => a.title.localeCompare(b.title));
default:
return sorted;
}
}
interface SwarmPageProps {
issues: BeadIssue[];
projectRoot: string;
selectedId?: string;
onSelect: (id: string) => void;
setRightPanel?: (content: React.ReactNode | null) => void;
}
export function SwarmPage({ issues, selectedId, onSelect }: SwarmPageProps) {
export function SwarmPage({ projectRoot, selectedId, onSelect, setRightPanel }: SwarmPageProps) {
const [sortBy, setSortBy] = useState<SortOption>('health');
const [expanded, setExpanded] = useState(false);
const [manageTeamId, setManageTeamId] = useState<string | null>(null);
const cards = useMemo(() => buildSwarmCards(issues), [issues]);
const sortedCards = useMemo(() => sortCards(cards, sortBy), [cards, sortBy]);
const visibleCards = expanded ? sortedCards : sortedCards.slice(0, INITIAL_LIMIT);
const hasMore = sortedCards.length > INITIAL_LIMIT;
// Refs to break dependency loops
const onSelectRef = useRef(onSelect);
useEffect(() => { onSelectRef.current = onSelect; }, [onSelect]);
const { missions, isLoading, error, refresh: refreshMissions } = useMissionList(projectRoot);
const { agents, refresh: refreshAgents } = useAgentPool(projectRoot);
const sortedMissions = useMemo(() => sortMissions(missions, sortBy), [missions, sortBy]);
const visibleMissions = expanded ? sortedMissions : sortedMissions.slice(0, INITIAL_LIMIT);
const hasMore = sortedMissions.length > INITIAL_LIMIT;
const busyAgents = agents.filter(a => a.status === 'working').length;
// Handle Team Manager Actions
const handleAssign = useCallback(async (agentId: string, action: 'join' | 'leave') => {
// If called from inspector, we use selectedId. If called from dialog, we use manageTeamId.
const targetMissionId = manageTeamId || selectedId;
if (!targetMissionId) return;
const endpoint = action === 'join' ? '/api/mission/assign' : '/api/mission/assign';
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectRoot,
missionId: targetMissionId,
agentId,
action
}),
});
await Promise.all([refreshMissions(), refreshAgents()]);
}, [manageTeamId, selectedId, projectRoot, refreshMissions, refreshAgents]);
const activeMissionForInspector = missions.find(m => m.id === selectedId);
const activeMission = missions.find(m => m.id === manageTeamId);
// Sync right panel on selectedId change
useEffect(() => {
if (selectedId && setRightPanel && activeMissionForInspector) {
setRightPanel(
<MissionInspector
missionId={selectedId}
missionTitle={activeMissionForInspector.title}
projectRoot={projectRoot}
assignedAgents={activeMissionForInspector.agents}
onClose={() => onSelectRef.current('')}
onAssign={(agentId, action) => handleAssign(agentId, action)}
/>
);
} else if (!selectedId && setRightPanel) {
setRightPanel(null);
}
}, [selectedId, projectRoot, setRightPanel, activeMissionForInspector, handleAssign]); // Removed onSelect from deps
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4" style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>
Swarm View
</h2>
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] px-4 py-4 md:px-6 custom-scrollbar">
{/* Dashboard Stats */}
<div className="mx-auto mb-6 grid w-full max-w-[1200px] grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-500/10 text-indigo-500">
<Shield className="h-5 w-5" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Active Missions</p>
<p className="text-xl font-mono text-slate-200">{missions.length}</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
<Users className="h-5 w-5" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Agent Fleet</p>
<p className="text-xl font-mono text-slate-200">{agents.length}</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm border-l-4 border-l-emerald-500">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Operational Load</p>
<div className="flex items-center gap-2">
<span className="text-xl font-mono text-slate-200">{busyAgents}/{agents.length}</span>
<span className="text-[10px] text-slate-500">engaged</span>
</div>
</div>
</div>
</div>
{/* Toolbar */}
<div className="mx-auto mb-4 flex w-full max-w-[1200px] items-center justify-between gap-3 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] px-3 py-2 shadow-sm">
<div className="flex items-center gap-3">
<div className="min-w-0">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Command</p>
<h2 className="text-base font-semibold text-[var(--ui-text-primary)]">
Mission Control
</h2>
</div>
<div className="h-8 w-px bg-white/5 mx-2" />
<LaunchSwarmDialog projectRoot={projectRoot} onSuccess={refreshMissions} />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 border-white/10 bg-white/5 hover:bg-white/10"
className="gap-2 border-white/10 bg-white/5 text-[var(--ui-text-primary)] hover:bg-white/10"
>
<ArrowUpDown className="h-4 w-4" />
{SORT_LABELS[sortBy]}
<ArrowUpDown className="h-4 w-4 text-slate-500" aria-hidden="true" />
<span className="text-xs uppercase tracking-wider font-bold">{SORT_LABELS[sortBy]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuContent align="end" className="w-40 bg-[#0d1621] border-slate-800 text-slate-300">
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-slate-500">Sort Missions</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-white/5" />
{(Object.keys(SORT_LABELS) as SortOption[]).map((option) => (
<DropdownMenuItem
key={option}
onClick={() => setSortBy(option)}
className={sortBy === option ? 'bg-accent/50' : ''}
className={sortBy === option ? 'bg-indigo-500/10 text-indigo-400' : 'focus:bg-white/5 focus:text-white'}
>
{SORT_LABELS[option]}
</DropdownMenuItem>
@ -103,47 +186,64 @@ export function SwarmPage({ issues, selectedId, onSelect }: SwarmPageProps) {
</DropdownMenu>
</div>
<div
className="grid gap-4"
style={{
gridTemplateColumns: 'repeat(4, 1fr)',
maxWidth: '1200px',
margin: '0 auto',
}}
>
{visibleCards.map((card) => (
<div
key={card.swarmId}
onClick={() => onSelect(card.swarmId)}
className={`cursor-pointer rounded-xl transition-all ${
selectedId === card.swarmId
? 'ring-2 ring-[var(--color-accent-amber)]'
: 'hover:ring-1 hover:ring-white/10'
}`}
>
<SwarmCard card={card} />
</div>
{/* Grid */}
<div className="mx-auto grid w-full max-w-[1200px] grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visibleMissions.map((mission) => (
<MissionCard
key={mission.id}
id={mission.id}
projectRoot={projectRoot}
title={mission.title}
description={mission.description}
status={mission.status as any}
stats={mission.stats}
agents={mission.agents}
onClick={() => onSelect(mission.id)}
onDeploy={() => setManageTeamId(mission.id)}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center mt-4">
<div className="mt-8 flex justify-center pb-12">
<Button
variant="outline"
onClick={() => setExpanded(true)}
className="gap-2 border-white/10 bg-white/5 hover:bg-white/10"
className="gap-2 border-white/10 bg-white/5 text-[var(--ui-text-primary)] hover:bg-white/10"
>
Show {sortedCards.length - INITIAL_LIMIT} more
<ChevronDown className="h-4 w-4" />
Show All Missions
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
)}
{sortedCards.length === 0 && (
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
No swarms found. Add agents with <code className="px-1 py-0.5 rounded bg-white/5">gt:agent</code> and <code className="px-1 py-0.5 rounded bg-white/5">swarm:*</code> labels.
{isLoading && (
<div className="py-24 flex flex-col items-center justify-center text-[var(--color-text-muted)]">
<Loader2 className="h-8 w-8 animate-spin mb-4 text-indigo-500" />
<p className="text-sm font-mono uppercase tracking-widest animate-pulse">Establishing Uplink...</p>
</div>
)}
{!isLoading && !error && missions.length === 0 && (
<div className="py-24 flex flex-col items-center justify-center text-[var(--color-text-muted)]">
<Rocket className="h-12 w-12 mb-4 opacity-20" />
<p className="text-sm mb-4">No active missions. Launch one to begin.</p>
<LaunchSwarmDialog projectRoot={projectRoot} onSuccess={refreshMissions} />
</div>
)}
{/* Dialogs */}
{activeMission && (
<TeamManagerDialog
isOpen={!!manageTeamId}
onClose={() => setManageTeamId(null)}
missionId={activeMission.id}
missionTitle={activeMission.title}
projectRoot={projectRoot}
assignedAgents={activeMission.agents}
onAssign={handleAssign}
/>
)}
</div>
);
}
}

View file

@ -1,26 +1,141 @@
"use client";
import React, { useState } from 'react';
import { SwarmLiveDag } from './swarm-live-dag';
import { ConvoyStepper } from './convoy-stepper';
import { TelemetryGrid } from './telemetry-grid';
import { ConvoyStepper, type Phase } from './convoy-stepper';
import { Network, Blocks, FileCode2, Info } from 'lucide-react';
import { cn } from '../../lib/utils';
import type { BeadIssue } from '../../lib/types';
import { useArchetypes } from '../../hooks/use-archetypes';
import { useTemplates } from '../../hooks/use-templates';
import { ArchetypeInspector } from './archetype-inspector';
import { TemplateInspector } from './template-inspector';
export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: string }) {
export function SwarmWorkspace({ selectedMissionId, issues = [] }: { selectedMissionId?: string, issues?: BeadIssue[] }) {
const [activeTab, setActiveTab] = useState<'operations' | 'archetypes' | 'templates'>('operations');
// Inspector State
const [inspectingArchetypeId, setInspectingArchetypeId] = useState<string | null>(null);
const [inspectingTemplateId, setInspectingTemplateId] = useState<string | null>(null);
const { archetypes, isLoading: archetypesLoading } = useArchetypes();
const { templates, isLoading: templatesLoading } = useTemplates();
// Simulation State
const [isSimulating, setIsSimulating] = useState(false);
const [simPhase, setSimPhase] = useState<Phase>('planning');
const [simBeads, setSimBeads] = useState<BeadIssue[]>([]);
const handleSummon = () => {
setIsSimulating(true);
setSimPhase('planning');
setSimBeads([]);
// Mock Flow: Planning -> Graph Generation -> Deployment -> Execution
setTimeout(() => {
setSimPhase('deployment'); // Skipping Graph Generation for simplicity here
// Generate some fake beads
const mockBeads: BeadIssue[] = [
{
id: 'b-mock-1',
title: 'Analyze DB Schema',
status: 'closed',
assignee: 'Alice (Architect)',
owner: null,
description: null,
issue_type: 'task',
priority: 1,
labels: [],
dependencies: [{ type: 'parent', target: selectedMissionId || 'epic' }],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null, close_reason: null, closed_by_session: null, created_by: null, due_at: null, estimated_minutes: null, external_ref: null, metadata: {}
},
{
id: 'b-mock-2',
title: 'Implement API Routes',
status: 'in_progress',
assignee: 'Bob (Backend)',
owner: null,
description: null,
issue_type: 'task',
priority: 1,
labels: [],
dependencies: [
{ type: 'parent', target: selectedMissionId || 'epic' },
{ type: 'blocks', target: 'b-mock-1' } // Bob waits for Alice
],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null, close_reason: null, closed_by_session: null, created_by: null, due_at: null, estimated_minutes: null, external_ref: null, metadata: {}
},
{
id: 'b-mock-3',
title: 'Build UI Components',
status: 'blocked',
assignee: 'Charlie (Frontend)',
owner: null,
description: null,
issue_type: 'task',
priority: 1,
labels: [],
dependencies: [
{ type: 'parent', target: selectedMissionId || 'epic' },
{ type: 'blocks', target: 'b-mock-2' } // Charlie waits for Bob
],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null, close_reason: null, closed_by_session: null, created_by: null, due_at: null, estimated_minutes: null, external_ref: null, metadata: {}
}
];
setTimeout(() => {
setSimBeads(mockBeads);
setSimPhase('execution');
}, 1000);
}, 1500);
};
const displayBeads = isSimulating ? simBeads : issues;
const renderTabContent = () => {
switch (activeTab) {
case 'operations':
return selectedMissionId
? (
<div className="flex flex-col h-full gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<ConvoyStepper activePhase="execution" />
<div className="flex-1 min-h-0 bg-[#0f1824]/50 rounded-xl border border-[var(--ui-border-soft)] p-2 shadow-inner">
<SwarmLiveDag epicId={selectedMissionId} />
? (() => {
const epic = issues.find(i => i.id === selectedMissionId);
let epicPhase: Phase = 'planning';
if (epic?.status === 'in_progress') epicPhase = 'execution';
if (epic?.status === 'closed' || epic?.status === 'tombstone') epicPhase = 'debrief';
return (
<div className="flex flex-col h-full gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center justify-between">
<ConvoyStepper activePhase={isSimulating ? simPhase : epicPhase} />
<div className="flex gap-2">
<button
onClick={() => setIsSimulating(false)}
className="px-3 py-1.5 text-xs font-semibold bg-rose-500/10 text-rose-500 hover:bg-rose-500/20 rounded-md transition-colors"
>
Halt Swarm
</button>
<button
onClick={handleSummon}
disabled={isSimulating && simPhase !== 'debrief'}
className="px-3 py-1.5 text-xs font-bold bg-[var(--ui-accent-info)] text-white hover:bg-[var(--ui-accent-info)]/90 shadow shadow-[var(--ui-accent-info)]/20 rounded-md transition-colors disabled:opacity-50"
>
Summon Polecats
</button>
</div>
</div>
<div className="flex-1 min-h-0">
<TelemetryGrid epicId={selectedMissionId} issues={displayBeads} archetypes={archetypes} />
</div>
</div>
</div>
)
)
})()
: (
<div className="flex flex-col items-center justify-center h-full text-center space-y-4 animate-in fade-in duration-700">
<div className="p-4 bg-[var(--ui-accent-info)]/10 rounded-full">
@ -36,36 +151,122 @@ export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: stri
);
case 'archetypes':
return (
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500 overflow-y-auto custom-scrollbar">
<h3 className="text-xl font-bold text-[var(--ui-text-primary)] mb-2">Agent Archetypes</h3>
<p className="text-[var(--ui-text-muted)] text-sm mb-6">Manage the base roles and system prompts available to your swarms.</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Placeholder Cards */}
{[1, 2, 3].map(i => (
<div key={i} className="bg-[#111f2b] p-4 rounded-lg border border-[var(--ui-border-soft)] hover:border-[var(--ui-accent-info)]/50 transition-colors">
<div className="h-10 w-10 rounded-lg bg-[var(--ui-accent-info)]/20 mb-3" />
<div className="h-4 w-24 bg-white/10 rounded mb-2" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
{archetypesLoading ? (
[1, 2, 3].map(i => (
<div key={i} className="bg-[#111f2b] p-4 rounded-lg border border-[var(--ui-border-soft)] animate-pulse">
<div className="h-10 w-10 rounded-lg bg-[var(--ui-accent-info)]/20 mb-3" />
<div className="h-4 w-24 bg-white/10 rounded mb-2" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
</div>
))
) : archetypes.length === 0 ? (
<div className="col-span-full text-center text-[var(--ui-text-muted)] text-sm py-8 border border-dashed border-white/10 rounded-lg">
No archetypes found. Create one in the `.beads/archetypes/` directory.
</div>
))}
) : (
archetypes.map(arc => (
<button
key={arc.id}
onClick={() => setInspectingArchetypeId(arc.id)}
className="bg-[#111f2b] p-4 rounded-xl border border-[var(--ui-border-soft)] hover:border-[var(--ui-accent-info)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--ui-accent-info)] transition-colors shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)] flex flex-col text-left w-full h-full"
>
<div className="flex items-center gap-3 mb-3">
<div className="h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center font-bold text-lg border" style={{ backgroundColor: `${arc.color}15`, color: arc.color, borderColor: `${arc.color}30` }}>
{arc.name.charAt(0)}
</div>
<div className="truncate">
<div className="font-semibold text-[15px] text-[var(--ui-text-primary)] truncate">{arc.name}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] font-mono uppercase tracking-wider truncate">{arc.id}</div>
</div>
</div>
<div className="text-xs text-[var(--ui-text-muted)] line-clamp-2 mb-4 flex-1">
{arc.description}
</div>
<div className="flex flex-wrap gap-1 mt-auto">
{arc.capabilities.slice(0, 3).map((cap, idx) => (
<span key={idx} className="px-1.5 py-0.5 rounded bg-white/5 text-[9px] uppercase font-semibold text-[var(--ui-text-muted)] border border-white/10">
{cap}
</span>
))}
{arc.capabilities.length > 3 && (
<span className="px-1.5 py-0.5 rounded bg-white/5 text-[9px] uppercase font-semibold text-[var(--ui-text-muted)] border border-white/10">
+{arc.capabilities.length - 3}
</span>
)}
</div>
</button>
))
)}
</div>
</div>
);
case 'templates':
return (
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500 overflow-y-auto custom-scrollbar">
<h3 className="text-xl font-bold text-[var(--ui-text-primary)] mb-2">Swarm Templates</h3>
<p className="text-[var(--ui-text-muted)] text-sm mb-6">Define predefined teams and formulas for rapid mission deployment.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2].map(i => (
<div key={i} className="bg-[#111f2b] p-5 rounded-lg border border-[var(--ui-border-soft)] flex items-center gap-4 hover:border-amber-500/50 transition-colors">
<div className="h-12 w-12 rounded-full bg-amber-500/20" />
<div>
<div className="h-4 w-32 bg-white/10 rounded mb-2" />
<div className="h-3 w-48 bg-white/5 rounded" />
{templatesLoading ? (
[1, 2].map(i => (
<div key={i} className="bg-[#111f2b] p-5 rounded-xl border border-[var(--ui-border-soft)] flex items-center gap-4 animate-pulse">
<div className="h-12 w-12 rounded-full bg-amber-500/20" />
<div className="flex-1">
<div className="h-4 w-32 bg-white/10 rounded mb-2" />
<div className="h-3 w-48 bg-white/5 rounded" />
</div>
</div>
))
) : templates.length === 0 ? (
<div className="col-span-full text-center text-[var(--ui-text-muted)] text-sm py-8 border border-dashed border-white/10 rounded-lg">
No templates found. Create one in the `.beads/templates/` directory.
</div>
))}
) : (
templates.map(tpl => (
<button
key={tpl.id}
onClick={() => setInspectingTemplateId(tpl.id)}
className="bg-[#111f2b] p-5 rounded-xl border border-[var(--ui-border-soft)] flex flex-col gap-4 hover:border-amber-500/50 focus:outline-none focus:ring-2 focus:ring-amber-500/50 transition-colors shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)] text-left w-full"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3 w-full pr-2">
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-500 font-bold">
{tpl.team.reduce((acc, curr) => acc + curr.count, 0)}
</div>
<div className="truncate">
<div className="font-semibold text-[15px] text-[var(--ui-text-primary)] truncate">{tpl.name}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] font-mono uppercase tracking-wider truncate">{tpl.id}</div>
</div>
</div>
{tpl.isBuiltIn && (
<span className="flex-shrink-0 px-2 py-0.5 rounded-full bg-white/5 text-[9px] uppercase font-bold text-[var(--ui-text-muted)] border border-white/10">Default</span>
)}
</div>
<div className="text-xs text-[var(--ui-text-muted)] line-clamp-2">
{tpl.description}
</div>
<div className="mt-auto pt-3 border-t border-[var(--ui-border-soft)] w-full">
<div className="text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider mb-2">Team Composition</div>
<div className="flex flex-wrap gap-2">
{tpl.team.map((member, idx) => {
const arch = archetypes.find(a => a.id === member.archetypeId);
return (
<div key={idx} className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[#0f1824] border border-[var(--ui-border-soft)]">
<div className="h-4 w-4 rounded text-[9px] flex items-center justify-center font-bold" style={{ backgroundColor: `${arch?.color || '#888'}20`, color: arch?.color || '#888' }}>
{arch?.name.charAt(0) || '?'}
</div>
<span className="text-[11px] text-[var(--ui-text-primary)] font-medium">{member.count}x {arch?.name || member.archetypeId}</span>
</div>
);
})}
</div>
</div>
</button>
))
)}
</div>
</div>
);
@ -121,6 +322,22 @@ export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: stri
{renderTabContent()}
</div>
</main>
{/* Popups */}
{inspectingArchetypeId && (
<ArchetypeInspector
archetype={archetypes.find(a => a.id === inspectingArchetypeId)!}
onClose={() => setInspectingArchetypeId(null)}
/>
)}
{inspectingTemplateId && (
<TemplateInspector
template={templates.find(t => t.id === inspectingTemplateId)!}
archetypes={archetypes}
onClose={() => setInspectingTemplateId(null)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,217 @@
"use client";
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { Loader2, AlertCircle, Bot, Zap } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
const SpecializedAgentDagLazy = dynamic(
() => import('./specialized-agent-dag').then((m) => m.SpecializedAgentDag),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center p-8 w-full h-full min-h-[200px]">
<Loader2 className="animate-spin text-muted-foreground" />
</div>
),
}
);
interface TelemetryGridProps {
epicId: string;
issues: BeadIssue[];
archetypes: AgentArchetype[];
}
export function TelemetryGrid({ epicId, issues, archetypes }: TelemetryGridProps) {
const [selectedBeadId, setSelectedBeadId] = useState<string | null>(null);
const [isPrepping, setIsPrepping] = useState(false);
const [prepSuccess, setPrepSuccess] = useState(false);
const [selectedArchetypeForPrep, setSelectedArchetypeForPrep] = useState<string>('');
// 1. Filter beads for this epic
const beads = issues.filter(issue => {
if (issue.issue_type === 'epic') return false; // don't include epic itself in DAG
const parent = issue.dependencies.find(d => d.type === 'parent');
return parent?.target === epicId;
});
// 2. Compute "Attention Feed" (Blocked beads)
const blockedBeads = beads.filter(b => b.status === 'blocked');
// 3. Compute "Active Roster" (Unique assignees working on in_progress beads)
const activeAssignees = new Set<string>();
const rosterEntries: { assignee: string, currentTask: string, archetype?: AgentArchetype }[] = [];
beads.forEach(b => {
if (b.status === 'in_progress' && b.assignee && !activeAssignees.has(b.assignee)) {
activeAssignees.add(b.assignee);
const assigneeStr = b.assignee.toLowerCase();
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
rosterEntries.push({
assignee: b.assignee,
currentTask: b.title,
archetype: matchedArchetype
});
}
});
const selectedBead = selectedBeadId ? beads.find(b => b.id === selectedBeadId) : null;
const handlePrepTask = async () => {
if (!selectedBead || !selectedArchetypeForPrep) return;
setIsPrepping(true);
setPrepSuccess(false);
try {
const res = await fetch('/api/swarm/prep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
beadId: selectedBead.id,
archetypeId: selectedArchetypeForPrep
})
});
if (!res.ok) throw new Error('Prep failed');
setPrepSuccess(true);
setTimeout(() => setPrepSuccess(false), 3000);
// Note: The shell's useIssues typically polls or relies on SWR to update.
// In a real app we'd call mutate() here.
} catch (e) {
console.error(e);
} finally {
setIsPrepping(false);
}
};
return (
<div className="flex flex-col lg:flex-row gap-4 h-full animate-in fade-in duration-500">
{/* Left/Top: Specialized DAG */}
<div className="flex-[2] min-h-[400px] lg:min-h-0 bg-[#0f1824]/50 rounded-xl border border-[var(--ui-border-soft)] shadow-inner relative overflow-hidden flex flex-col">
<div className="absolute top-3 left-3 z-10 px-3 py-1.5 bg-background/80 backdrop-blur rounded-md border border-[var(--ui-border-soft)] flex items-center gap-2 shadow-sm pointer-events-none">
<Bot className="w-4 h-4 text-[var(--ui-accent-info)]" />
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--ui-text-primary)]">Agent Flow</span>
</div>
<div className="flex-1 w-full h-full">
<SpecializedAgentDagLazy
beads={beads}
archetypes={archetypes}
selectedId={selectedBeadId}
onSelect={setSelectedBeadId}
/>
</div>
</div>
{/* Right/Bottom: Feeds */}
<div className="flex-1 flex flex-col gap-4 min-w-[300px]">
{/* Task Assignment Panel (Shows if a node is selected) */}
{selectedBead && (
<div className="flex-none bg-[#111f2b] rounded-xl border border-[var(--ui-accent-info)]/30 flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)] ring-1 ring-[var(--ui-accent-info)]/10">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<Zap className="w-4 h-4 text-[var(--ui-accent-info)]" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Task Assignment</h3>
</div>
<div className="p-4 space-y-4">
<div>
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-1">{selectedBead.id}</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-snug">{selectedBead.title}</div>
<div className="text-xs text-[var(--ui-text-muted)] mt-1">Status: <span className="font-semibold uppercase">{selectedBead.status}</span></div>
</div>
{(selectedBead.status === 'open' || selectedBead.status === 'blocked') ? (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-[var(--ui-text-muted)] mb-1.5 block">Assign Agent Archetype</label>
<select
value={selectedArchetypeForPrep}
onChange={(e) => setSelectedArchetypeForPrep(e.target.value)}
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--ui-accent-info)]"
>
<option value="" disabled>Select archetype...</option>
{archetypes.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<button
onClick={handlePrepTask}
disabled={!selectedArchetypeForPrep || isPrepping || prepSuccess}
className={`w-full py-2 text-white text-sm font-bold rounded-md disabled:opacity-50 transition-colors flex items-center justify-center ${prepSuccess ? 'bg-emerald-500' : 'bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90'}`}
>
{isPrepping ? <Loader2 className="w-4 h-4 animate-spin" /> : prepSuccess ? 'Prep Successful!' : 'Prep Task for Swarm'}
</button>
</div>
) : (
<div className="text-xs text-amber-500 bg-amber-500/10 p-2 rounded border border-amber-500/20">
Task is {selectedBead.status.replace('_', ' ')}. Only open or blocked tasks can be prepped.
</div>
)}
</div>
</div>
)}
{/* Priority Attention */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-rose-500" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Priority Attention</h3>
<span className="ml-auto bg-rose-500/10 text-rose-500 text-[10px] font-bold px-2 py-0.5 rounded-full">{blockedBeads.length} Blocked</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{blockedBeads.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
All clear. No blocked tasks.
</div>
) : (
blockedBeads.map(b => (
<div key={b.id} className="p-3 bg-rose-500/5 border border-rose-500/20 rounded-lg">
<div className="text-xs font-mono text-rose-500 mb-1">{b.id}</div>
<div className="text-sm text-[var(--ui-text-primary)] font-medium leading-tight">{b.title}</div>
</div>
))
)}
</div>
</div>
{/* Active Roster */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Active Roster</h3>
<span className="ml-auto text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider">{rosterEntries.length} Deployed</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{rosterEntries.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
No agents currently active.
</div>
) : (
rosterEntries.map((r, i) => (
<div key={i} className="flex gap-3 p-3 bg-[#0a111a] border border-white/5 rounded-lg items-center">
<div
className="h-8 w-8 rounded flex-shrink-0 flex items-center justify-center font-bold text-sm border"
style={{ backgroundColor: `${r.archetype?.color || '#888'}15`, color: r.archetype?.color || '#888', borderColor: `${r.archetype?.color || '#888'}30` }}
>
{r.assignee.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-bold text-[var(--ui-text-primary)] truncate">{r.assignee}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] truncate">{r.currentTask}</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,138 @@
import React from 'react';
import { X, Save, Edit, Link, Network } from 'lucide-react';
import type { SwarmTemplate, AgentArchetype } from '../../lib/types-swarm';
interface TemplateInspectorProps {
template: SwarmTemplate;
archetypes: AgentArchetype[];
onClose: () => void;
}
export function TemplateInspector({ template, archetypes, onClose }: TemplateInspectorProps) {
if (!template) return null;
const totalAgents = template.team.reduce((acc, curr) => acc + curr.count, 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="flex flex-col h-[75vh] w-full max-w-2xl overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
<div className="flex items-center gap-4">
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-500 font-bold text-lg">
{totalAgents}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-[var(--ui-text-primary)] leading-tight">{template.name}</h2>
{template.isBuiltIn && (
<span className="px-1.5 py-0.5 rounded-full bg-white/10 text-[9px] uppercase font-bold text-[var(--ui-text-muted)] border border-white/10">Built-in</span>
)}
</div>
<p className="font-mono uppercase tracking-wider text-[10px] text-[var(--ui-text-muted)] mt-0.5">{template.id}</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-[var(--ui-text-muted)] hover:bg-white/5 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
{/* Metadata Section */}
<div>
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Purpose / Description</label>
<textarea
defaultValue={template.description}
readOnly
rows={2}
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:border-[var(--ui-accent-info)] focus:ring-1 focus:ring-[var(--ui-accent-info)] resize-none"
/>
</div>
{/* Team Composition Builder */}
<div className="border-t border-[var(--ui-border-soft)] pt-5">
<div className="flex items-center justify-between mb-4">
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider flex items-center gap-2">
<Network className="w-4 h-4 text-emerald-500" />
Roster Composition
</label>
<button className="text-[11px] font-semibold text-[var(--ui-accent-info)] hover:text-white bg-[var(--ui-accent-info)]/10 px-2 py-1 rounded transition-colors disabled:opacity-50">
+ Add Member
</button>
</div>
<div className="space-y-2">
{template.team.map((member, idx) => {
const arch = archetypes.find(a => a.id === member.archetypeId);
return (
<div key={idx} className="flex items-center gap-3 bg-[#111f2b] border border-[var(--ui-border-soft)] p-3 rounded-lg">
<div className="h-8 w-8 rounded text-sm flex items-center justify-center font-bold" style={{ backgroundColor: `${arch?.color || '#888'}20`, color: arch?.color || '#888' }}>
{arch?.name.charAt(0) || '?'}
</div>
<div className="flex-1">
<div className="font-semibold text-sm text-[var(--ui-text-primary)]">{arch?.name || member.archetypeId}</div>
<div className="text-[11px] text-[var(--ui-text-muted)]">{arch?.description || 'Unknown Archetype'}</div>
</div>
<div className="flex items-center gap-2 bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md p-1">
<span className="text-xs font-mono text-[var(--ui-text-muted)] px-2">Count:</span>
<input
type="number"
defaultValue={member.count}
readOnly
className="w-12 bg-transparent text-sm font-bold text-center text-[var(--ui-text-primary)] focus:outline-none"
/>
</div>
</div>
);
})}
</div>
</div>
{/* Advanced: Proto-formula */}
<div className="border-t border-[var(--ui-border-soft)] pt-5">
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-2 flex items-center gap-2">
<Link className="w-4 h-4 text-amber-500" />
MOL Proto-Formula (Optional)
</label>
<div className="flex items-center gap-3">
<input
type="text"
defaultValue={template.protoFormula || ''}
placeholder="e.g. 'release' or 'bugfix'"
readOnly
className="flex-1 bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 font-mono text-sm text-amber-500 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/50"
/>
<div className="text-[11px] text-[var(--ui-text-muted)] max-w-[200px] leading-tight">
Specifies a Gastown Formula to execute (`bd mol pour`) when launching this swarm.
</div>
</div>
</div>
</div>
{/* Footer Controls */}
<div className="border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e] flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-semibold text-[var(--ui-text-primary)] hover:bg-white/5 rounded-md transition-colors"
>
Close
</button>
<button
disabled
className="flex items-center gap-2 px-4 py-2 text-sm font-bold bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
Save Template
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,61 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { AgentRecord } from '../lib/agent-registry';
interface UseAgentPoolResult {
agents: AgentRecord[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
getAgentsBySwarm: (swarmId: string) => AgentRecord[];
}
async function fetchAgents(projectRoot: string): Promise<AgentRecord[]> {
try {
const response = await fetch(`/api/agents/list?projectRoot=${encodeURIComponent(projectRoot)}`);
const payload = await response.json();
if (!response.ok || !payload.ok) {
console.error('Agent fetch failed:', payload.error);
return [];
}
return (payload.data || []) as AgentRecord[];
} catch (err) {
console.error('Agent fetch error:', err);
return [];
}
}
export function useAgentPool(projectRoot: string): UseAgentPoolResult {
const [agents, setAgents] = useState<AgentRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
// Only set loading on first fetch
if (agents.length === 0) setIsLoading(true);
setError(null);
try {
const data = await fetchAgents(projectRoot);
setAgents(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load agent pool');
} finally {
setIsLoading(false);
}
}, [projectRoot, agents.length]);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 10000);
return () => clearInterval(interval);
}, [refresh]);
const getAgentsBySwarm = useCallback((swarmId: string) => {
return agents.filter(agent => agent.swarm_id === swarmId);
}, [agents]);
return { agents, isLoading, error, refresh, getAgentsBySwarm };
}

View file

@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import type { AgentArchetype } from '../lib/types-swarm';
export function useArchetypes() {
const [data, setData] = useState<AgentArchetype[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchArchetypes() {
try {
const res = await fetch('/api/swarm/archetypes');
if (!res.ok) {
throw new Error('Failed to fetch archetypes');
}
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err);
} finally {
setIsLoading(false);
}
}
fetchArchetypes();
}, []);
return { archetypes: data, isLoading, error };
}

View file

@ -0,0 +1,42 @@
'use client';
import { useEffect, useState } from 'react';
import type { BeadIssue } from '../lib/types';
interface UseMissionGraphResult {
nodes: BeadIssue[];
isLoading: boolean;
error: string | null;
}
export function useMissionGraph(projectRoot: string, missionId: string): UseMissionGraphResult {
const [nodes, setNodes] = useState<BeadIssue[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchGraph() {
if (!missionId) return;
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/mission/graph?projectRoot=${encodeURIComponent(projectRoot)}&id=${encodeURIComponent(missionId)}`
);
const payload = await response.json();
if (payload.ok && payload.data) {
setNodes(payload.data.nodes);
} else {
setError(payload.error || 'Failed to load graph');
}
} catch (e) {
setError('Failed to fetch mission graph');
} finally {
setIsLoading(false);
}
}
fetchGraph();
}, [projectRoot, missionId]);
return { nodes, isLoading, error };
}

View file

@ -0,0 +1,66 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { AgentRecord } from '../lib/agent-registry';
export interface MissionData {
id: string;
title: string;
description?: string;
status: 'planning' | 'active' | 'blocked' | 'completed';
stats: {
total: number;
done: number;
blocked: number;
active: number;
};
agents: AgentRecord[];
}
interface UseMissionListResult {
missions: MissionData[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
async function fetchMissions(projectRoot: string): Promise<MissionData[]> {
try {
const response = await fetch(`/api/mission/list?projectRoot=${encodeURIComponent(projectRoot)}`);
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error?.message || 'Failed to fetch missions');
}
return payload.data.missions || [];
} catch (err) {
console.error('Mission fetch error:', err);
throw err;
}
}
export function useMissionList(projectRoot: string): UseMissionListResult {
const [missions, setMissions] = useState<MissionData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await fetchMissions(projectRoot);
setMissions(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load missions');
} finally {
setIsLoading(false);
}
}, [projectRoot]);
useEffect(() => {
refresh();
}, [refresh]);
return { missions, isLoading, error, refresh };
}

View file

@ -0,0 +1,86 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { SwarmCardData, SwarmListResponse, SwarmStatusFromApi } from '../lib/swarm-api';
import { apiSwarmToCardData, type SwarmFromApi } from '../lib/swarm-api';
interface UseSwarmListResult {
swarms: SwarmCardData[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
async function fetchSwarmList(projectRoot: string): Promise<SwarmCardData[]> {
const response = await fetch(`/api/swarm/list?projectRoot=${encodeURIComponent(projectRoot)}`, {
cache: 'no-store',
});
const payload = (await response.json()) as { ok: boolean; data?: SwarmListResponse; error?: { message?: string } };
if (!response.ok || !payload.ok || !payload.data) {
throw new Error(payload.error?.message ?? 'Failed to fetch swarms');
}
return payload.data.swarms.map((s: SwarmFromApi) => apiSwarmToCardData(s));
}
async function fetchSwarmStatus(projectRoot: string, epicId: string): Promise<SwarmStatusFromApi | null> {
try {
const response = await fetch(
`/api/swarm/status?projectRoot=${encodeURIComponent(projectRoot)}&epic=${encodeURIComponent(epicId)}`,
{ cache: 'no-store' }
);
const payload = (await response.json()) as { ok: boolean; data?: SwarmStatusFromApi; error?: { message?: string } };
if (!response.ok || !payload.ok || !payload.data) {
return null;
}
return payload.data;
} catch {
return null;
}
}
export function useSwarmList(projectRoot: string): UseSwarmListResult {
const [swarms, setSwarms] = useState<SwarmCardData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const swarmList = await fetchSwarmList(projectRoot);
const swarmsWithStatus = await Promise.all(
swarmList.map(async (swarm) => {
const status = await fetchSwarmStatus(projectRoot, swarm.epicId);
if (status) {
return {
...swarm,
readyIssues: status.ready_count,
blockedIssues: status.blocked_count,
};
}
return swarm;
})
);
setSwarms(swarmsWithStatus);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch swarms');
} finally {
setIsLoading(false);
}
}, [projectRoot]);
useEffect(() => {
refresh();
}, [refresh]);
return { swarms, isLoading, error, refresh };
}

View file

@ -0,0 +1,50 @@
'use client';
import { useState, useEffect } from 'react';
export interface SwarmTopologyData {
completed: { id: string; title: string; assignee?: string }[];
active: { id: string; title: string; assignee?: string }[];
ready: { id: string; title: string }[];
blocked: { id: string; title: string; blocked_by: string[] }[];
progress_percent: number;
}
export function useSwarmTopology(projectRoot: string, swarmId: string) {
const [topology, setTopology] = useState<SwarmTopologyData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
async function fetchTopology() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/mission/${swarmId}/topology?projectRoot=${encodeURIComponent(projectRoot)}`);
const result = await response.json();
if (mounted) {
if (result.ok) {
setTopology(result.data);
} else {
setError(result.error);
}
}
} catch (err) {
if (mounted) setError('Failed to load topology');
} finally {
if (mounted) setIsLoading(false);
}
}
if (projectRoot && swarmId) {
fetchTopology();
}
return () => { mounted = false; };
}, [projectRoot, swarmId]);
return { topology, isLoading, error };
}

View file

@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import type { SwarmTemplate } from '../lib/types-swarm';
export function useTemplates() {
const [data, setData] = useState<SwarmTemplate[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchTemplates() {
try {
const res = await fetch('/api/swarm/templates');
if (!res.ok) {
throw new Error('Failed to fetch templates');
}
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err);
} finally {
setIsLoading(false);
}
}
fetchTemplates();
}, []);
return { templates: data, isLoading, error };
}

View file

@ -1,6 +1,6 @@
'use client';
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
export type ViewType = 'social' | 'graph' | 'swarm' | 'activity';
@ -12,13 +12,22 @@ export interface UrlState {
view: ViewType;
setView: (v: ViewType) => void;
taskId: string | null;
setTaskId: (id: string | null) => void;
setTaskId: (id: string | null, openDrawer?: boolean) => void;
swarmId: string | null;
setSwarmId: (id: string | null) => void;
setSwarmId: (id: string | null, openDrawer?: boolean) => void;
agentId: string | null;
setAgentId: (id: string | null) => void;
epicId: string | null;
setEpicId: (id: string | null) => void;
leftPanel: PanelState;
setLeftPanel: (state: PanelState) => void;
toggleLeftPanel: () => void;
rightPanel: PanelState;
setRightPanel: (state: PanelState) => void;
toggleRightPanel: () => void;
blockedOnly: boolean;
setBlockedOnly: (enabled: boolean) => void;
toggleBlockedOnly: () => void;
panel: PanelState;
togglePanel: () => void;
drawer: DrawerState;
@ -29,7 +38,8 @@ export interface UrlState {
}
const DEFAULT_VIEW: ViewType = 'social';
const DEFAULT_PANEL: PanelState = 'open';
const DEFAULT_LEFT_PANEL: PanelState = 'open';
const DEFAULT_RIGHT_PANEL: PanelState = 'open';
const DEFAULT_DRAWER: DrawerState = 'closed';
const DEFAULT_GRAPH_TAB: GraphTabType = 'flow';
@ -38,12 +48,51 @@ const VALID_PANELS: PanelState[] = ['open', 'closed'];
const VALID_DRAWERS: DrawerState[] = ['open', 'closed'];
const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview'];
export function parseUrlState(searchParams: URLSearchParams): {
const PANEL_STORAGE_KEYS = {
left: 'bb.ui.leftPanel',
right: 'bb.ui.rightPanel',
} as const;
interface PanelDefaults {
leftPanel: PanelState;
rightPanel: PanelState;
}
function parsePanelValue(value: string | null): PanelState | null {
if (!value || !VALID_PANELS.includes(value as PanelState)) {
return null;
}
return value as PanelState;
}
function readStoredPanelState(key: string, fallback: PanelState): PanelState {
if (typeof window === 'undefined') {
return fallback;
}
const value = window.localStorage.getItem(key);
return parsePanelValue(value) ?? fallback;
}
function isBlockedEnabled(value: string | null): boolean {
return value === '1' || value === 'true';
}
export function parseUrlState(
searchParams: URLSearchParams,
defaults: PanelDefaults = {
leftPanel: DEFAULT_LEFT_PANEL,
rightPanel: DEFAULT_RIGHT_PANEL,
}
): {
view: ViewType;
taskId: string | null;
swarmId: string | null;
agentId: string | null;
epicId: string | null;
leftPanel: PanelState;
rightPanel: PanelState;
blockedOnly: boolean;
panel: PanelState;
drawer: DrawerState;
graphTab: GraphTabType;
@ -58,10 +107,15 @@ export function parseUrlState(searchParams: URLSearchParams): {
const agentId = searchParams.get('agent');
const epicId = searchParams.get('epic');
const panelParam = searchParams.get('panel');
const panel: PanelState = panelParam && VALID_PANELS.includes(panelParam as PanelState)
? (panelParam as PanelState)
: DEFAULT_PANEL;
const leftPanelFromUrl = parsePanelValue(searchParams.get('left'));
const rightPanelFromUrl = parsePanelValue(searchParams.get('right'));
const legacyPanel = parsePanelValue(searchParams.get('panel'));
const leftPanel = leftPanelFromUrl ?? defaults.leftPanel;
const rightPanel = rightPanelFromUrl ?? legacyPanel ?? defaults.rightPanel;
const panel = rightPanel;
const blockedOnly = isBlockedEnabled(searchParams.get('blocked'));
const drawerParam = searchParams.get('drawer');
const drawer: DrawerState = drawerParam && VALID_DRAWERS.includes(drawerParam as DrawerState)
@ -73,7 +127,7 @@ export function parseUrlState(searchParams: URLSearchParams): {
? (graphTabParam as GraphTabType)
: DEFAULT_GRAPH_TAB;
return { view, taskId, swarmId, agentId, epicId, panel, drawer, graphTab };
return { view, taskId, swarmId, agentId, epicId, leftPanel, rightPanel, blockedOnly, panel, drawer, graphTab };
}
export function buildUrlParams(
@ -97,8 +151,27 @@ export function buildUrlParams(
export function useUrlState(): UrlState {
const searchParams = useSearchParams();
const router = useRouter();
const [panelDefaults, setPanelDefaults] = useState<PanelDefaults>({
leftPanel: DEFAULT_LEFT_PANEL,
rightPanel: DEFAULT_RIGHT_PANEL,
});
const state = useMemo(() => parseUrlState(searchParams), [searchParams]);
useEffect(() => {
setPanelDefaults({
leftPanel: readStoredPanelState(PANEL_STORAGE_KEYS.left, DEFAULT_LEFT_PANEL),
rightPanel: readStoredPanelState(PANEL_STORAGE_KEYS.right, DEFAULT_RIGHT_PANEL),
});
}, []);
const state = useMemo(() => parseUrlState(searchParams, panelDefaults), [searchParams, panelDefaults]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(PANEL_STORAGE_KEYS.left, state.leftPanel);
window.localStorage.setItem(PANEL_STORAGE_KEYS.right, state.rightPanel);
}, [state.leftPanel, state.rightPanel]);
const updateUrl = useCallback((updates: Record<string, string | null>) => {
const newUrl = buildUrlParams(searchParams, updates);
@ -109,26 +182,55 @@ export function useUrlState(): UrlState {
updateUrl({ view: v });
}, [updateUrl]);
const setTaskId = useCallback((id: string | null) => {
updateUrl({ task: id, panel: id ? 'open' : null });
const setLeftPanel = useCallback((next: PanelState) => {
updateUrl({ left: next });
}, [updateUrl]);
const setSwarmId = useCallback((id: string | null) => {
updateUrl({ swarm: id, panel: id ? 'open' : null });
const toggleLeftPanel = useCallback(() => {
setLeftPanel(state.leftPanel === 'open' ? 'closed' : 'open');
}, [setLeftPanel, state.leftPanel]);
const setRightPanel = useCallback((next: PanelState) => {
// Keep legacy `panel` in sync while migrating to explicit `right`.
updateUrl({ right: next, panel: next });
}, [updateUrl]);
const toggleRightPanel = useCallback(() => {
setRightPanel(state.rightPanel === 'open' ? 'closed' : 'open');
}, [setRightPanel, state.rightPanel]);
const setBlockedOnly = useCallback((enabled: boolean) => {
updateUrl({ blocked: enabled ? '1' : null });
}, [updateUrl]);
const toggleBlockedOnly = useCallback(() => {
setBlockedOnly(!state.blockedOnly);
}, [setBlockedOnly, state.blockedOnly]);
const setTaskId = useCallback((id: string | null, openDrawer?: boolean) => {
const right = id ? 'open' : null;
const drawer = openDrawer ? 'open' : null;
// Clear swarm when setting task
updateUrl({ task: id, swarm: null, right, panel: right, drawer });
}, [updateUrl]);
const setSwarmId = useCallback((id: string | null, openDrawer?: boolean) => {
const right = id ? 'open' : null;
const drawer = openDrawer ? 'open' : null;
// Clear task when setting swarm
updateUrl({ swarm: id, task: null, right, panel: right, drawer });
}, [updateUrl]);
const setAgentId = useCallback((id: string | null) => {
updateUrl({ agent: id, panel: id ? 'open' : null });
const right = id ? 'open' : null;
updateUrl({ agent: id, right, panel: right });
}, [updateUrl]);
const setEpicId = useCallback((id: string | null) => {
updateUrl({ epic: id });
}, [updateUrl]);
const togglePanel = useCallback(() => {
const newPanel = state.panel === 'open' ? 'closed' : 'open';
updateUrl({ panel: newPanel });
}, [state.panel, updateUrl]);
const togglePanel = toggleRightPanel;
const setDrawer = useCallback((state: DrawerState) => {
updateUrl({ drawer: state });
@ -139,7 +241,7 @@ export function useUrlState(): UrlState {
}, [updateUrl]);
const clearSelection = useCallback(() => {
updateUrl({ task: null, swarm: null, epic: null, panel: 'closed', drawer: 'closed' });
updateUrl({ task: null, swarm: null, epic: null, right: 'closed', panel: 'closed', drawer: 'closed' });
}, [updateUrl]);
return {
@ -153,7 +255,16 @@ export function useUrlState(): UrlState {
setAgentId,
epicId: state.epicId,
setEpicId,
panel: state.panel,
leftPanel: state.leftPanel,
setLeftPanel,
toggleLeftPanel,
rightPanel: state.rightPanel,
setRightPanel,
toggleRightPanel,
blockedOnly: state.blockedOnly,
setBlockedOnly,
toggleBlockedOnly,
panel: state.rightPanel,
togglePanel,
drawer: state.drawer,
setDrawer,

View file

@ -31,6 +31,8 @@ export interface AgentRecord {
version: number;
rig?: string;
role_type?: string;
swarm_id?: string;
current_task?: string;
}
export interface RegisterAgentInput {
@ -179,11 +181,22 @@ function validateRole(value: string): AgentCommandError | null {
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
// Extract role from labels if role_type is not set
let role = bdAgent.role_type || 'agent';
if (role === 'agent' && Array.isArray(bdAgent.labels)) {
let swarmId: string | undefined;
let currentTask: string | undefined;
if (Array.isArray(bdAgent.labels)) {
const roleLabel = bdAgent.labels.find((l: string) => l.startsWith('role:'));
if (roleLabel) {
role = roleLabel.split(':')[1];
}
const swarmLabel = bdAgent.labels.find((l: string) => l.startsWith('swarm:'));
if (swarmLabel) {
swarmId = swarmLabel.split(':')[1];
}
const workingLabel = bdAgent.labels.find((l: string) => l.startsWith('working:'));
if (workingLabel) {
currentTask = workingLabel.split(':')[1];
}
}
let rig = bdAgent.rig;
@ -204,6 +217,8 @@ function mapBdAgentToRecord(bdAgent: any): AgentRecord {
version: 1,
rig,
role_type: bdAgent.role_type,
swarm_id: swarmId,
current_task: currentTask,
};
return record;
}

View file

@ -102,10 +102,16 @@ export async function runBdCommand(
const shellCommand = buildShellCommand(command, args);
const mingwBin = 'C:\\msys64\\mingw64\\bin';
const existingPath = deps.env.Path ?? deps.env.PATH ?? '';
const enhancedPath = existingPath.includes('mingw64')
? existingPath
: `${mingwBin};${existingPath}`;
const { stdout, stderr } = await deps.exec(shellCommand, {
cwd,
timeout: timeoutMs,
env: deps.env,
env: { ...deps.env, Path: enhancedPath, PATH: enhancedPath },
});
return {

View file

@ -1,15 +1,115 @@
import fs from 'fs/promises';
import path from 'path';
import { AgentArchetype } from '../types-swarm';
import { AgentArchetype, SwarmTemplate } from '../types-swarm';
const ARCHE_DIR = path.join(process.cwd(), '.beads', 'archetypes');
const TEMPLATE_DIR = path.join(process.cwd(), '.beads', 'templates');
const SEED_ARCHETYPES: AgentArchetype[] = [
{
id: 'architect',
name: 'System Architect',
description: 'Designs complex system structures and writes detailed implementation plans.',
systemPrompt: 'You are a staff-level software architect focused on high-level system design.',
capabilities: ['planning', 'design_docs', 'arch_review'],
color: '#3b82f6',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
},
{
id: 'coder',
name: 'Implementation Engineer',
description: 'Translates plans into precise, type-safe, and tested code.',
systemPrompt: 'You are a senior software engineer focused on execution and clean code.',
capabilities: ['coding', 'refactoring', 'testing'],
color: '#10b981',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
}
];
export async function getArchetypes(): Promise<AgentArchetype[]> {
try {
await fs.mkdir(ARCHE_DIR, { recursive: true });
// Minimal mock for now to pass test
return [];
const files = await fs.readdir(ARCHE_DIR);
if (files.filter(f => f.endsWith('.json')).length === 0) {
// Seed defaults
for (const arch of SEED_ARCHETYPES) {
await fs.writeFile(path.join(ARCHE_DIR, `${arch.id}.json`), JSON.stringify(arch, null, 2));
}
return SEED_ARCHETYPES;
}
const archetypes: AgentArchetype[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const content = await fs.readFile(path.join(ARCHE_DIR, file), 'utf-8');
const parsed = JSON.parse(content);
archetypes.push({
...parsed,
id: file.replace('.json', '')
});
} catch (err) {
console.error(`Failed to parse archetype file: ${file}`, err);
}
}
return archetypes;
} catch (e) {
console.error('Error in getArchetypes:', e);
return [];
}
}
const SEED_TEMPLATES: SwarmTemplate[] = [
{
id: 'standard-app',
name: 'Standard Application Swarm',
description: 'A balanced team of an Architect and two Coders for standard feature development.',
team: [
{ archetypeId: 'architect', count: 1 },
{ archetypeId: 'coder', count: 2 }
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
}
];
export async function getTemplates(): Promise<SwarmTemplate[]> {
try {
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
const files = await fs.readdir(TEMPLATE_DIR);
if (files.filter(f => f.endsWith('.json')).length === 0) {
for (const tpl of SEED_TEMPLATES) {
await fs.writeFile(path.join(TEMPLATE_DIR, `${tpl.id}.json`), JSON.stringify(tpl, null, 2));
}
return SEED_TEMPLATES;
}
const templates: SwarmTemplate[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const content = await fs.readFile(path.join(TEMPLATE_DIR, file), 'utf-8');
const parsed = JSON.parse(content);
templates.push({
...parsed,
id: file.replace('.json', '')
});
} catch (err) {
console.error(`Failed to parse template file: ${file}`, err);
}
}
return templates;
} catch (e) {
console.error('Error in getTemplates:', e);
return [];
}
}

View file

@ -74,20 +74,21 @@ function extractAgents(bead: BeadIssue): AgentInfo[] {
}
export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
const taskBeads = beads.filter((bead) => bead.issue_type !== 'epic');
const beadMap = new Map<string, BeadIssue>();
for (const bead of beads) {
for (const bead of taskBeads) {
beadMap.set(bead.id, bead);
}
const blocksIncoming = new Map<string, string[]>();
const blocksOutgoing = new Map<string, string[]>();
for (const bead of beads) {
for (const bead of taskBeads) {
blocksIncoming.set(bead.id, []);
blocksOutgoing.set(bead.id, []);
}
for (const bead of beads) {
for (const bead of taskBeads) {
for (const dep of bead.dependencies) {
if (dep.type === 'blocks' && beadMap.has(dep.target)) {
const outgoing = blocksOutgoing.get(bead.id) ?? [];
@ -101,15 +102,30 @@ export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
}
}
return beads.map((bead) => ({
id: bead.id,
title: bead.title,
status: mapStatus(bead.status),
blocks: blocksOutgoing.get(bead.id) ?? [], // what I block (amber)
unblocks: blocksIncoming.get(bead.id) ?? [], // what blocks me (rose)
agents: extractAgents(bead),
lastActivity: new Date(bead.updated_at),
priority: mapPriority(bead.priority),
}));
}
return taskBeads.map((bead) => {
const explicitStatus = mapStatus(bead.status);
const incomingBlockers = blocksIncoming.get(bead.id) ?? [];
const hasUnresolvedIncomingBlockers = incomingBlockers.some((blockerId) => {
const blocker = beadMap.get(blockerId);
return blocker ? blocker.status !== 'closed' && blocker.status !== 'tombstone' : false;
});
const effectiveStatus: SocialCardStatus =
explicitStatus === 'closed' || explicitStatus === 'in_progress' || explicitStatus === 'blocked'
? explicitStatus
: hasUnresolvedIncomingBlockers
? 'blocked'
: explicitStatus;
return {
id: bead.id,
title: bead.title,
status: effectiveStatus,
blocks: blocksOutgoing.get(bead.id) ?? [], // what I block (amber)
unblocks: incomingBlockers, // what blocks me (rose)
agents: extractAgents(bead),
lastActivity: new Date(bead.updated_at),
priority: mapPriority(bead.priority),
};
});
}

64
src/lib/swarm-api.ts Normal file
View file

@ -0,0 +1,64 @@
export interface SwarmFromApi {
id: string;
title: string;
epic_id: string;
epic_title: string;
status: string;
coordinator: string;
total_issues: number;
completed_issues: number;
active_issues: number;
progress_percent: number;
}
export interface SwarmListResponse {
swarms: SwarmFromApi[];
}
export interface SwarmStatusFromApi {
epic_id: string;
epic_title: string;
total_issues: number;
completed: Array<{ id: string; title: string; status: string }>;
active: Array<{ id: string; title: string; status: string }>;
ready: Array<{ id: string; title: string; status: string }>;
blocked: Array<{ id: string; title: string; status: string }>;
progress_percent: number;
active_count: number;
ready_count: number;
blocked_count: number;
}
export interface SwarmCardData {
swarmId: string;
title: string;
epicId: string;
epicTitle: string;
status: string;
coordinator: string;
totalIssues: number;
completedIssues: number;
activeIssues: number;
readyIssues: number;
blockedIssues: number;
progressPercent: number;
agents: import('./agent-registry').AgentRecord[];
}
export function apiSwarmToCardData(swarm: SwarmFromApi, status?: SwarmStatusFromApi): SwarmCardData {
return {
swarmId: swarm.id,
title: swarm.title,
epicId: swarm.epic_id,
epicTitle: swarm.epic_title,
status: swarm.status,
coordinator: swarm.coordinator,
totalIssues: swarm.total_issues,
completedIssues: swarm.completed_issues,
activeIssues: swarm.active_issues,
readyIssues: status?.ready_count ?? 0,
blockedIssues: status?.blocked_count ?? 0,
progressPercent: swarm.progress_percent,
agents: [], // Populated separately via agent-registry
};
}

View file

@ -1,6 +1,6 @@
import { interpolate, useCurrentFrame, useVideoConfig, AbsoluteFill, Sequence, Img, staticFile, spring } from 'remotion';
import React from 'react';
import { loadFont } from "@remotion/google-fonts/Inter";
import { loadFont } from '@remotion/google-fonts/inter';
import { Background } from './components/Background';
import { TerminalScene } from './components/TerminalScene';
import { TimelineScene } from './components/TimelineScene';

View file

@ -14,127 +14,119 @@ function createMockSearchParams(params: Record<string, string | null> = {}) {
describe('useUrlState', () => {
describe('parseUrlState', () => {
it('should return defaults for empty params', () => {
const sp = createMockSearchParams({});
const state = parseUrlState(sp);
it('returns defaults for empty params', () => {
const state = parseUrlState(createMockSearchParams({}));
assert.deepStrictEqual(state, {
view: 'social',
taskId: null,
swarmId: null,
panel: 'closed',
agentId: null,
epicId: null,
leftPanel: 'open',
rightPanel: 'open',
blockedOnly: false,
panel: 'open',
drawer: 'closed',
graphTab: 'flow',
});
});
it('should parse view=social', () => {
const sp = createMockSearchParams({ view: 'social' });
const state = parseUrlState(sp);
assert.strictEqual(state.view, 'social');
it('parses all core identifiers', () => {
const state = parseUrlState(
createMockSearchParams({
view: 'activity',
task: 'bb-vt.1.2',
swarm: 'bb-vt',
agent: 'codex',
epic: 'bb-vt',
}),
);
assert.strictEqual(state.view, 'activity');
assert.strictEqual(state.taskId, 'bb-vt.1.2');
assert.strictEqual(state.swarmId, 'bb-vt');
assert.strictEqual(state.agentId, 'codex');
assert.strictEqual(state.epicId, 'bb-vt');
});
it('should parse view=graph', () => {
const sp = createMockSearchParams({ view: 'graph' });
const state = parseUrlState(sp);
assert.strictEqual(state.view, 'graph');
});
it('should parse view=swarm', () => {
const sp = createMockSearchParams({ view: 'swarm' });
const state = parseUrlState(sp);
assert.strictEqual(state.view, 'swarm');
});
it('should fall back to default for invalid view values', () => {
const sp = createMockSearchParams({ view: 'invalid' });
const state = parseUrlState(sp);
assert.strictEqual(state.view, 'social');
});
it('should parse task id', () => {
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1' });
const state = parseUrlState(sp);
assert.strictEqual(state.view, 'social');
assert.strictEqual(state.taskId, 'bb-buff.1');
});
it('should parse swarm id', () => {
const sp = createMockSearchParams({ view: 'swarm', swarm: 'bb-buff' });
const state = parseUrlState(sp);
assert.strictEqual(state.view, 'swarm');
assert.strictEqual(state.swarmId, 'bb-buff');
});
it('should parse panel state', () => {
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1', panel: 'open' });
const state = parseUrlState(sp);
it('parses explicit left/right panel params', () => {
const state = parseUrlState(createMockSearchParams({ left: 'closed', right: 'open' }));
assert.strictEqual(state.leftPanel, 'closed');
assert.strictEqual(state.rightPanel, 'open');
assert.strictEqual(state.panel, 'open');
});
it('should parse graphTab', () => {
const sp = createMockSearchParams({ view: 'graph', task: 'bb-buff.1', graphTab: 'flow' });
const state = parseUrlState(sp);
assert.strictEqual(state.graphTab, 'flow');
});
it('should fall back to default for invalid panel values', () => {
const sp = createMockSearchParams({ panel: 'invalid' });
const state = parseUrlState(sp);
it('uses legacy panel param when right is absent', () => {
const state = parseUrlState(createMockSearchParams({ panel: 'closed' }));
assert.strictEqual(state.rightPanel, 'closed');
assert.strictEqual(state.panel, 'closed');
});
it('should fall back to default for invalid graphTab values', () => {
const sp = createMockSearchParams({ graphTab: 'invalid' });
const state = parseUrlState(sp);
it('prefers right param over legacy panel when both are present', () => {
const state = parseUrlState(createMockSearchParams({ right: 'open', panel: 'closed' }));
assert.strictEqual(state.rightPanel, 'open');
assert.strictEqual(state.panel, 'open');
});
it('falls back to defaults for invalid panel params', () => {
const state = parseUrlState(createMockSearchParams({ left: 'invalid', right: 'invalid', panel: 'invalid' }));
assert.strictEqual(state.leftPanel, 'open');
assert.strictEqual(state.rightPanel, 'open');
assert.strictEqual(state.panel, 'open');
});
it('parses blocked filter state', () => {
assert.strictEqual(parseUrlState(createMockSearchParams({ blocked: '1' })).blockedOnly, true);
assert.strictEqual(parseUrlState(createMockSearchParams({ blocked: 'true' })).blockedOnly, true);
assert.strictEqual(parseUrlState(createMockSearchParams({ blocked: '0' })).blockedOnly, false);
});
it('falls back to default for invalid view and graph tab values', () => {
const state = parseUrlState(createMockSearchParams({ view: 'invalid', graphTab: 'invalid' }));
assert.strictEqual(state.view, 'social');
assert.strictEqual(state.graphTab, 'flow');
});
});
describe('buildUrlParams', () => {
it('should build URL with view param', () => {
const sp = createMockSearchParams({});
const url = buildUrlParams(sp, { view: 'social' });
it('builds URL with view param', () => {
const url = buildUrlParams(createMockSearchParams({}), { view: 'social' });
assert.strictEqual(url, '/?view=social');
});
it('should add task param', () => {
const sp = createMockSearchParams({ view: 'social' });
const url = buildUrlParams(sp, { task: 'bb-buff.1' });
assert.strictEqual(url, '/?view=social&task=bb-buff.1');
it('adds task param', () => {
const url = buildUrlParams(createMockSearchParams({ view: 'social' }), { task: 'bb-vt.2.1' });
assert.strictEqual(url, '/?view=social&task=bb-vt.2.1');
});
it('should remove param when null', () => {
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1' });
const url = buildUrlParams(sp, { task: null });
it('removes params when value is null', () => {
const url = buildUrlParams(createMockSearchParams({ view: 'social', task: 'bb-vt.2.1' }), { task: null });
assert.strictEqual(url, '/?view=social');
});
it('should toggle panel', () => {
const sp = createMockSearchParams({ view: 'social', panel: 'closed' });
const url = buildUrlParams(sp, { panel: 'open' });
assert.strictEqual(url, '/?view=social&panel=open');
it('supports dual right/panel sync updates', () => {
const url = buildUrlParams(createMockSearchParams({ view: 'social' }), { right: 'open', panel: 'open' });
assert.strictEqual(url, '/?view=social&right=open&panel=open');
});
it('should return root for empty params', () => {
const sp = createMockSearchParams({});
const url = buildUrlParams(sp, {});
it('returns root for empty params', () => {
const url = buildUrlParams(createMockSearchParams({}), {});
assert.strictEqual(url, '/');
});
it('should clear all selection params', () => {
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1', swarm: 'buff', panel: 'open', graphTab: 'flow' });
const url = buildUrlParams(sp, { task: null, swarm: null, panel: null, graphTab: null });
assert.strictEqual(url, '/?view=social');
it('clears selection params and keeps view', () => {
const url = buildUrlParams(
createMockSearchParams({ view: 'social', task: 'bb-vt.2.1', swarm: 'bb-vt', right: 'open', panel: 'open' }),
{ task: null, swarm: null, right: 'closed', panel: 'closed' },
);
assert.strictEqual(url, '/?view=social&right=closed&panel=closed');
});
});
describe('module import', () => {
it('should load the module without error', async () => {
try {
await import('../../src/hooks/use-url-state');
assert.ok(true, 'Module loaded');
} catch (err) {
assert.fail(err as Error);
}
it('loads the module without error', async () => {
await import('../../src/hooks/use-url-state');
assert.ok(true);
});
});
});