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:
parent
409a7e7256
commit
dfaf523029
74 changed files with 11066 additions and 2046 deletions
|
|
@ -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
122
components/ui/dialog.tsx
Normal 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
26
components/ui/label.tsx
Normal 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
159
components/ui/select.tsx
Normal 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
55
components/ui/tabs.tsx
Normal 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 }
|
||||
24
components/ui/textarea.tsx
Normal file
24
components/ui/textarea.tsx
Normal 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 }
|
||||
224
docs/plans/2026-02-19-swarm-page-redesign.md
Normal file
224
docs/plans/2026-02-19-swarm-page-redesign.md
Normal 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
|
||||
96
docs/plans/2026-02-19-visual-truth-spec.md
Normal file
96
docs/plans/2026-02-19-visual-truth-spec.md
Normal 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`
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
285
docs/prompts/2026-02-19-beads-cgo-fix-pr.md
Normal file
285
docs/prompts/2026-02-19-beads-cgo-fix-pr.md
Normal 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.
|
||||
166
docs/protocols/bead-prompting.md
Normal file
166
docs/protocols/bead-prompting.md
Normal 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 user’s 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 3–7 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 user’s 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 user’s 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 you’re doing.
|
||||
|
||||
2) Plan
|
||||
- 3–7 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.
|
||||
|
|
@ -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
4879
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
19
src/app/api/agents/list/route.ts
Normal file
19
src/app/api/agents/list/route.ts
Normal 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 });
|
||||
}
|
||||
28
src/app/api/beads/[id]/comments/route.ts
Normal file
28
src/app/api/beads/[id]/comments/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/mission/[id]/topology/route.ts
Normal file
31
src/app/api/mission/[id]/topology/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
40
src/app/api/mission/assign/route.ts
Normal file
40
src/app/api/mission/assign/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/app/api/mission/graph/route.ts
Normal file
46
src/app/api/mission/graph/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
88
src/app/api/mission/list/route.ts
Normal file
88
src/app/api/mission/list/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
70
src/app/api/swarm/close/route.ts
Normal file
70
src/app/api/swarm/close/route.ts
Normal 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 });
|
||||
}
|
||||
78
src/app/api/swarm/create/route.ts
Normal file
78
src/app/api/swarm/create/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/app/api/swarm/formulas/route.ts
Normal file
29
src/app/api/swarm/formulas/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/swarm/graph/route.ts
Normal file
45
src/app/api/swarm/graph/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
36
src/app/api/swarm/join/route.ts
Normal file
36
src/app/api/swarm/join/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/swarm/launch/route.ts
Normal file
42
src/app/api/swarm/launch/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/app/api/swarm/leave/route.ts
Normal file
36
src/app/api/swarm/leave/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/swarm/list/route.ts
Normal file
44
src/app/api/swarm/list/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/app/api/swarm/prep/route.ts
Normal file
29
src/app/api/swarm/prep/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/swarm/status/route.ts
Normal file
45
src/app/api/swarm/status/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
7
src/app/api/swarm/templates/route.ts
Normal file
7
src/app/api/swarm/templates/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
148
src/components/mission/mission-card.tsx
Normal file
148
src/components/mission/mission-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/mission/mission-inspector.tsx
Normal file
141
src/components/mission/mission-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/components/mission/swarm-graph.tsx
Normal file
107
src/components/mission/swarm-graph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
src/components/mission/team-manager-dialog.tsx
Normal file
255
src/components/mission/team-manager-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export function UnifiedShell({
|
|||
return (
|
||||
<SwarmWorkspace
|
||||
selectedMissionId={swarmId ?? undefined}
|
||||
issues={filteredIssues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
110
src/components/swarm/archetype-inspector.tsx
Normal file
110
src/components/swarm/archetype-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/swarm/convoy-stepper.tsx
Normal file
31
src/components/swarm/convoy-stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/components/swarm/launch-dialog.tsx
Normal file
172
src/components/swarm/launch-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
src/components/swarm/specialized-agent-dag.tsx
Normal file
210
src/components/swarm/specialized-agent-dag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
146
src/components/swarm/swarm-control-card.tsx
Normal file
146
src/components/swarm/swarm-control-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
191
src/components/swarm/swarm-inspector.tsx
Normal file
191
src/components/swarm/swarm-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
217
src/components/swarm/telemetry-grid.tsx
Normal file
217
src/components/swarm/telemetry-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/swarm/template-inspector.tsx
Normal file
138
src/components/swarm/template-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/hooks/use-agent-pool.ts
Normal file
61
src/hooks/use-agent-pool.ts
Normal 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 };
|
||||
}
|
||||
29
src/hooks/use-archetypes.ts
Normal file
29
src/hooks/use-archetypes.ts
Normal 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 };
|
||||
}
|
||||
42
src/hooks/use-mission-graph.ts
Normal file
42
src/hooks/use-mission-graph.ts
Normal 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 };
|
||||
}
|
||||
66
src/hooks/use-mission-list.ts
Normal file
66
src/hooks/use-mission-list.ts
Normal 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 };
|
||||
}
|
||||
86
src/hooks/use-swarm-list.ts
Normal file
86
src/hooks/use-swarm-list.ts
Normal 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 };
|
||||
}
|
||||
50
src/hooks/use-swarm-topology.ts
Normal file
50
src/hooks/use-swarm-topology.ts
Normal 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 };
|
||||
}
|
||||
29
src/hooks/use-templates.ts
Normal file
29
src/hooks/use-templates.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
64
src/lib/swarm-api.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue