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
|
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
|
## Start-of-Task Protocol
|
||||||
|
|
||||||
1. Read the target bead and acceptance criteria (`bd show <id>`).
|
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.
|
* 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".
|
* **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).
|
* **Convoy Stepper:** The visual orchestration pipeline (Planning -> Deployment -> Execution -> Debrief).
|
||||||
* **The Telemetry Grid (Replacing the DAG):**
|
* **The Telemetry Grid (Hybrid Layout):**
|
||||||
* **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").
|
* **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.
|
||||||
* **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.
|
* **Bottom/Right Pane (Metrics & Attention):**
|
||||||
* **Card 3: Mission Metrics:** Simple burn-down stats or a mini-feed of the last 5 completed tasks to show velocity.
|
* **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 />`
|
### 4.3 Tab 2: `<ArchetypesArmory />`
|
||||||
* **Layout:** CSS Grid of archetype cards.
|
* **Layout:** CSS Grid of archetype cards.
|
||||||
* **Card Design:** Shows Name, Color badge, Capabilities tags, and truncated description.
|
* **Card Design:** Shows Name, Color badge, Capabilities tags, and truncated description.
|
||||||
* **Interaction:**
|
* **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`.
|
* Includes a focused text area to edit the raw `systemPrompt`.
|
||||||
* A primary "Create New Archetype" button exists at the top.
|
* A primary "Create New Archetype" button exists at the top.
|
||||||
|
|
||||||
### 4.4 Tab 3: `<TemplatesManager />`
|
### 4.4 Tab 3: `<TemplatesManager />`
|
||||||
* **Layout:** List or Grid view of templates.
|
* **Layout:** List or Grid view of templates.
|
||||||
* **Interaction:**
|
* **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").
|
* **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.
|
* 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 path from 'node:path';
|
||||||
import nextTypeScript from 'eslint-config-next/typescript';
|
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 = [
|
const eslintConfig = [
|
||||||
...nextCoreWebVitals,
|
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||||
...nextTypeScript,
|
|
||||||
{
|
{
|
||||||
ignores: ['nul'],
|
ignores: [
|
||||||
|
'nul',
|
||||||
|
'.next/**',
|
||||||
|
'.agents/**',
|
||||||
|
'skills/**',
|
||||||
|
'next-env.d.ts',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'],
|
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'],
|
||||||
|
|
@ -14,6 +24,7 @@ const eslintConfig = [
|
||||||
'prefer-const': 'off',
|
'prefer-const': 'off',
|
||||||
'@typescript-eslint/no-empty-object-type': 'off',
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': '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": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@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-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@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-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@remotion/google-fonts": "^4.0.422",
|
"@remotion/google-fonts": "^4.0.422",
|
||||||
"@remotion/tailwind": "^4.0.422",
|
"@remotion/tailwind": "^4.0.422",
|
||||||
|
|
@ -48,7 +52,7 @@
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "^15.5.7",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "^1.58.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"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;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* ========== EARTHY-DARK DESIGN SYSTEM TOKENS (PRD v2.0) ========== */
|
/* ========== 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;
|
||||||
|
|
||||||
/* Backgrounds */
|
--ui-border-soft: rgba(153, 171, 190, 0.2);
|
||||||
--color-bg-base: #2D2D2D;
|
--ui-border-strong: rgba(153, 171, 190, 0.34);
|
||||||
--color-bg-card: #363636;
|
|
||||||
--color-bg-input: #404040;
|
|
||||||
|
|
||||||
/* Accents */
|
--ui-text-primary: #e8edf5;
|
||||||
--color-accent-green: #7CB97A;
|
--ui-text-muted: #8f9caf;
|
||||||
--color-accent-amber: #D4A574;
|
|
||||||
--color-accent-teal: #5BA8A0;
|
|
||||||
|
|
||||||
/* Text */
|
--ui-accent-ready: #35d98f;
|
||||||
--color-text-primary: #FFFFFF;
|
--ui-accent-blocked: #ff4c72;
|
||||||
--color-text-secondary: #B8B8B8;
|
--ui-accent-warning: #ffb24a;
|
||||||
--color-text-muted: #888888;
|
--ui-accent-info: #35c9ff;
|
||||||
--color-text-on-primary: #1A1A1A;
|
--ui-accent-action-green: #35d98f;
|
||||||
|
--ui-accent-action-red: #ff4c72;
|
||||||
|
|
||||||
/* Borders */
|
--ui-font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
--color-border-default: #4A4A4A;
|
--ui-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||||
--color-border-subtle: #3A3A3A;
|
--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 colors */
|
||||||
--status-open: #5BA8A0;
|
--status-open: var(--ui-accent-info);
|
||||||
--status-ready: #5BA8A0;
|
--status-ready: var(--ui-accent-ready);
|
||||||
--status-in-progress: #7CB97A;
|
--status-in-progress: var(--ui-accent-warning);
|
||||||
--status-progress: #7CB97A;
|
--status-progress: var(--ui-accent-warning);
|
||||||
--status-blocked: #D4A574;
|
--status-blocked: var(--ui-accent-blocked);
|
||||||
--status-blocked-earthy: #D4A574;
|
--status-blocked-earthy: var(--ui-accent-blocked);
|
||||||
--status-closed: #888888;
|
--status-closed: #7f8b98;
|
||||||
--status-closed-earthy: #888888;
|
--status-closed-earthy: #7f8b98;
|
||||||
--status-deferred: #888888;
|
--status-deferred: #7f8b98;
|
||||||
|
|
||||||
/* Liveness colors */
|
/* Liveness colors */
|
||||||
--liveness-active: #7CB97A;
|
--liveness-active: var(--ui-accent-ready);
|
||||||
--liveness-stale: #D4A574;
|
--liveness-stale: var(--ui-accent-warning);
|
||||||
--liveness-stuck: #C97A7A;
|
--liveness-stuck: var(--ui-accent-action-red);
|
||||||
--liveness-dead: #C97A7A;
|
--liveness-dead: var(--ui-accent-action-red);
|
||||||
--liveness-idle: #888888;
|
--liveness-idle: #7f8b98;
|
||||||
|
|
||||||
/* Agent Role Colors */
|
/* Agent Role Colors */
|
||||||
--agent-role-ui: #6B9BD2;
|
--agent-role-ui: #6B9BD2;
|
||||||
|
|
@ -77,8 +96,8 @@
|
||||||
--shadow-soft-xl: 0 20px 40px -10px rgba(0, 0, 0, 0.4);
|
--shadow-soft-xl: 0 20px 40px -10px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
/* ========== TYPOGRAPHY ========== */
|
/* ========== TYPOGRAPHY ========== */
|
||||||
--font-ui-stack: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
--font-ui-stack: var(--ui-font-sans);
|
||||||
--font-mono-stack: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
--font-mono-stack: var(--ui-font-mono);
|
||||||
|
|
||||||
--font-size-h1: 2rem;
|
--font-size-h1: 2rem;
|
||||||
--font-size-h2: 1.5rem;
|
--font-size-h2: 1.5rem;
|
||||||
|
|
@ -110,7 +129,17 @@
|
||||||
/* ========== LAYOUT ========== */
|
/* ========== LAYOUT ========== */
|
||||||
--sidebar-left-width: 13.75rem;
|
--sidebar-left-width: 13.75rem;
|
||||||
--sidebar-right-width: 17.5rem;
|
--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 ========== */
|
/* ========== LEGACY COMPATIBILITY TOKENS ========== */
|
||||||
/* For existing components that reference these */
|
/* For existing components that reference these */
|
||||||
|
|
@ -134,11 +163,10 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
/* Earthy-dark base from PRD (replaces Aero Chrome) */
|
background-color: var(--ui-bg-app);
|
||||||
background-color: var(--color-bg-base);
|
color: var(--ui-text-primary);
|
||||||
color: var(--color-text-secondary);
|
font-family: var(--ui-font-sans);
|
||||||
font-family: var(--font-ui-stack);
|
letter-spacing: var(--ui-tracking-tight);
|
||||||
letter-spacing: -0.011em;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
@ -266,24 +294,52 @@ body {
|
||||||
|
|
||||||
.ui-select option,
|
.ui-select option,
|
||||||
.ui-option {
|
.ui-option {
|
||||||
background-color: #10141d;
|
background-color: var(--ui-bg-panel);
|
||||||
color: #e2e8f0;
|
color: var(--ui-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-text {
|
.ui-text {
|
||||||
font-family: var(--font-ui-stack);
|
font-family: var(--ui-font-sans);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: var(--ui-tracking-tight);
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-data {
|
.system-data {
|
||||||
font-family: var(--font-mono-stack);
|
font-family: var(--ui-font-mono);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: var(--ui-numeric-tabular);
|
||||||
font-weight: 450;
|
font-weight: 450;
|
||||||
letter-spacing: 0.015em;
|
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 {
|
.workflow-graph-legend {
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,5 @@
|
||||||
import { SessionsPage } from '../../components/sessions/sessions-page';
|
import { redirect } from 'next/navigation';
|
||||||
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';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export default function SessionsRedirectPage() {
|
||||||
|
redirect('/?view=social');
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,25 +110,25 @@ function formatRelativeTime(timestamp: string): string {
|
||||||
function getAgentTone(status: AgentStatus): AgentTone {
|
function getAgentTone(status: AgentStatus): AgentTone {
|
||||||
const tones: Record<AgentStatus, AgentTone> = {
|
const tones: Record<AgentStatus, AgentTone> = {
|
||||||
active: {
|
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]',
|
labelClass: 'text-[#7CB97A]',
|
||||||
ringClass: 'ring-[#7CB97A]/45',
|
ringClass: 'ring-[#7CB97A]/45',
|
||||||
glowClass: 'bg-[#7CB97A]/30',
|
glowClass: 'bg-[#7CB97A]/30',
|
||||||
},
|
},
|
||||||
stale: {
|
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]',
|
labelClass: 'text-[#D4A574]',
|
||||||
ringClass: 'ring-[#D4A574]/45',
|
ringClass: 'ring-[#D4A574]/45',
|
||||||
glowClass: 'bg-[#D4A574]/30',
|
glowClass: 'bg-[#D4A574]/30',
|
||||||
},
|
},
|
||||||
stuck: {
|
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]',
|
labelClass: 'text-[#C97A7A]',
|
||||||
ringClass: 'ring-[#C97A7A]/45',
|
ringClass: 'ring-[#C97A7A]/45',
|
||||||
glowClass: 'bg-[#C97A7A]/30',
|
glowClass: 'bg-[#C97A7A]/30',
|
||||||
},
|
},
|
||||||
dead: {
|
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]',
|
labelClass: 'text-[#A78A94]',
|
||||||
ringClass: 'ring-[#A78A94]/40',
|
ringClass: 'ring-[#A78A94]/40',
|
||||||
glowClass: 'bg-[#A78A94]/25',
|
glowClass: 'bg-[#A78A94]/25',
|
||||||
|
|
@ -146,84 +146,84 @@ function getEventTone(kind: string): EventTone {
|
||||||
label: 'Created',
|
label: 'Created',
|
||||||
labelClass: 'text-[#7CB97A]',
|
labelClass: 'text-[#7CB97A]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#9ACB98]',
|
||||||
},
|
},
|
||||||
opened: {
|
opened: {
|
||||||
label: 'Opened',
|
label: 'Opened',
|
||||||
labelClass: 'text-[#7CB97A]',
|
labelClass: 'text-[#7CB97A]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#9ACB98]',
|
||||||
},
|
},
|
||||||
closed: {
|
closed: {
|
||||||
label: 'Closed',
|
label: 'Closed',
|
||||||
labelClass: 'text-[#D4A574]',
|
labelClass: 'text-[#D4A574]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#DAB891]',
|
||||||
},
|
},
|
||||||
reopened: {
|
reopened: {
|
||||||
label: 'Reopened',
|
label: 'Reopened',
|
||||||
labelClass: 'text-[#5B95E8]',
|
labelClass: 'text-[#5B95E8]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#8DB4EF]',
|
||||||
},
|
},
|
||||||
status_changed: {
|
status_changed: {
|
||||||
label: 'Status changed',
|
label: 'Status changed',
|
||||||
labelClass: 'text-[#D4A574]',
|
labelClass: 'text-[#D4A574]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#DAB891]',
|
||||||
},
|
},
|
||||||
priority_changed: {
|
priority_changed: {
|
||||||
label: 'Priority changed',
|
label: 'Priority changed',
|
||||||
labelClass: 'text-[#D4A574]',
|
labelClass: 'text-[#D4A574]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#DAB891]',
|
||||||
},
|
},
|
||||||
assignee_changed: {
|
assignee_changed: {
|
||||||
label: 'Assigned',
|
label: 'Assigned',
|
||||||
labelClass: 'text-[#D4A574]',
|
labelClass: 'text-[#D4A574]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#DAB891]',
|
||||||
},
|
},
|
||||||
dependency_added: {
|
dependency_added: {
|
||||||
label: 'Dependency added',
|
label: 'Dependency added',
|
||||||
labelClass: 'text-[#D4A574]',
|
labelClass: 'text-[#D4A574]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#DAB891]',
|
||||||
},
|
},
|
||||||
dependency_removed: {
|
dependency_removed: {
|
||||||
label: 'Dependency removed',
|
label: 'Dependency removed',
|
||||||
labelClass: 'text-[#C97A7A]',
|
labelClass: 'text-[#C97A7A]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#D9A9A9]',
|
||||||
},
|
},
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
label: 'Heartbeat',
|
label: 'Heartbeat',
|
||||||
labelClass: 'text-[#5BA8A0]',
|
labelClass: 'text-[#5BA8A0]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#8BC9C1]',
|
||||||
},
|
},
|
||||||
commented: {
|
commented: {
|
||||||
label: 'Commented',
|
label: 'Commented',
|
||||||
labelClass: 'text-[#5BA8A0]',
|
labelClass: 'text-[#5BA8A0]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#8BC9C1]',
|
||||||
},
|
},
|
||||||
comment_added: {
|
comment_added: {
|
||||||
label: 'Commented',
|
label: 'Commented',
|
||||||
labelClass: 'text-[#5BA8A0]',
|
labelClass: 'text-[#5BA8A0]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#8BC9C1]',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -233,7 +233,7 @@ function getEventTone(kind: string): EventTone {
|
||||||
label: normalized.replace(/_/g, ' '),
|
label: normalized.replace(/_/g, ' '),
|
||||||
labelClass: 'text-[#5BA8A0]',
|
labelClass: 'text-[#5BA8A0]',
|
||||||
dotClass: 'bg-[#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]',
|
idClass: 'text-[#8BC9C1]',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -335,9 +335,9 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<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]" />
|
<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">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
{agentRoster.map(agent => (
|
{agentRoster.map(agent => (
|
||||||
<div key={agent.beadId} className={cn(
|
<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,
|
getAgentTone(agent.status).cardClass,
|
||||||
)}>
|
)}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -412,7 +412,7 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
return (
|
return (
|
||||||
<div key={activity.id} className="group relative">
|
<div key={activity.id} className="group relative">
|
||||||
<div className={cn(
|
<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
|
eventTone.cardClass
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,17 @@ export function GraphView({
|
||||||
hideClosed = false,
|
hideClosed = false,
|
||||||
}: GraphViewProps) {
|
}: GraphViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex h-full flex-col bg-[var(--ui-bg-app)]">
|
||||||
<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 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-1">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onGraphTabChange('flow')}
|
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'
|
graphTab === 'flow'
|
||||||
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
|
? '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]'
|
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
|
||||||
|
|
@ -39,7 +43,7 @@ export function GraphView({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onGraphTabChange('overview')}
|
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'
|
graphTab === 'overview'
|
||||||
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
|
? '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]'
|
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
|
||||||
|
|
@ -48,11 +52,12 @@ export function GraphView({
|
||||||
Overview
|
Overview
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<span className="text-[10px] text-text-muted/50">
|
<span className="text-[10px] text-text-muted/50">
|
||||||
{beads.length} beads
|
{beads.length} beads
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="min-h-0 flex-1">
|
||||||
<WorkflowGraph
|
<WorkflowGraph
|
||||||
beads={beads}
|
beads={beads}
|
||||||
selectedId={selectedId}
|
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';
|
'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 type { BeadIssue } from '../../lib/types';
|
||||||
import { useResponsive } from '../../hooks/use-responsive';
|
|
||||||
import { cn } from '../../lib/utils';
|
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 {
|
export interface LeftPanelProps {
|
||||||
issues: BeadIssue[];
|
issues: BeadIssue[];
|
||||||
selectedEpicId?: string | null;
|
selectedEpicId?: string | null;
|
||||||
onEpicSelect?: (epicId: string | null) => void;
|
onEpicSelect?: (epicId: string | null) => void;
|
||||||
|
filters: LeftPanelFilters;
|
||||||
|
onFiltersChange: (filters: LeftPanelFilters) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EpicNode {
|
interface EpicEntry {
|
||||||
epic: BeadIssue;
|
epic: BeadIssue;
|
||||||
children: BeadIssue[];
|
children: BeadIssue[];
|
||||||
stats: {
|
blockedCount: number;
|
||||||
total: number;
|
activeCount: number;
|
||||||
closed: number;
|
readyCount: number;
|
||||||
in_progress: number;
|
deferredCount: number;
|
||||||
blocked: number;
|
doneCount: number;
|
||||||
ready: number;
|
agentBlockedCount: number;
|
||||||
lastActivity: number;
|
latestTimestamp: string;
|
||||||
};
|
|
||||||
status: 'blocked' | 'in_progress' | 'ready' | 'done' | 'empty';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEpicTree(issues: BeadIssue[]): EpicNode[] {
|
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
|
||||||
const epics = issues.filter(issue => issue.issue_type === 'epic');
|
if (task.status === 'open') return 'ready';
|
||||||
const epicMap = new Map<string, EpicNode>();
|
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) {
|
function mapPriority(task: BeadIssue): LeftPanelPriorityFilter {
|
||||||
epicMap.set(epic.id, {
|
if (task.priority <= 0) return 'P0';
|
||||||
epic,
|
if (task.priority === 1) return 'P1';
|
||||||
children: [],
|
if (task.priority === 2) return 'P2';
|
||||||
stats: { total: 0, closed: 0, in_progress: 0, blocked: 0, ready: 0, lastActivity: new Date(epic.updated_at).getTime() },
|
if (task.priority === 3) return 'P3';
|
||||||
status: 'empty'
|
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) {
|
for (const task of tasks) {
|
||||||
if (issue.issue_type === 'epic') continue;
|
for (const dependency of task.dependencies) {
|
||||||
|
if (dependency.type !== 'blocks') continue;
|
||||||
const parentDep = issue.dependencies.find(dep => dep.type === 'parent');
|
if (!taskById.has(dependency.target)) continue;
|
||||||
if (parentDep && epicMap.has(parentDep.target)) {
|
const current = incomingBlockers.get(dependency.target) ?? [];
|
||||||
const node = epicMap.get(parentDep.target)!;
|
current.push(task.id);
|
||||||
node.children.push(issue);
|
incomingBlockers.set(dependency.target, current);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine Aggregate Status
|
const isEffectivelyBlocked = (task: BeadIssue): boolean => {
|
||||||
for (const node of epicMap.values()) {
|
if (task.status === 'blocked') return true;
|
||||||
if (node.stats.blocked > 0) node.status = 'blocked';
|
if (task.status === 'closed' || task.status === 'tombstone') return false;
|
||||||
else if (node.stats.in_progress > 0) node.status = 'in_progress';
|
const blockers = incomingBlockers.get(task.id) ?? [];
|
||||||
else if (node.stats.ready > 0) node.status = 'ready';
|
return blockers.some((blockerId) => {
|
||||||
else if (node.stats.total > 0 && node.stats.closed === node.stats.total) node.status = 'done';
|
const blocker = taskById.get(blockerId);
|
||||||
else node.status = 'empty';
|
return blocker ? blocker.status !== 'closed' && blocker.status !== 'tombstone' : false;
|
||||||
}
|
|
||||||
|
|
||||||
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 handleEpicClick = (epicId: string) => {
|
return epics
|
||||||
onEpicSelect?.(epicId === selectedEpicId ? null : epicId);
|
.map((epic) => {
|
||||||
toggleEpic(epicId);
|
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) {
|
function statusDot(status: BeadIssue['status']): string {
|
||||||
return (
|
if (status === 'blocked') return 'bg-[var(--ui-accent-blocked)]';
|
||||||
<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)]">
|
if (status === 'in_progress') return 'bg-[var(--ui-accent-warning)]';
|
||||||
{epicTree.map(({ epic, status }) => (
|
if (status === 'closed') return 'bg-[var(--ui-text-muted)]';
|
||||||
<button
|
return 'bg-[var(--ui-accent-ready)]';
|
||||||
key={epic.id}
|
}
|
||||||
onClick={() => handleEpicClick(epic.id)}
|
|
||||||
className={cn(
|
function rowTone(entry: EpicEntry): string {
|
||||||
'w-10 h-10 rounded-xl flex items-center justify-center text-xs font-bold transition-all duration-200 ring-1',
|
if (entry.blockedCount > 0) {
|
||||||
selectedEpicId === epic.id
|
return '#22111a';
|
||||||
? '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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<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">
|
||||||
className={cn(
|
<div className="px-4 py-3 shadow-[0_14px_24px_-20px_rgba(0,0,0,0.92)]">
|
||||||
'flex flex-col h-full overflow-hidden transition-all duration-300',
|
<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)]">
|
||||||
!isDesktop && 'hidden lg:flex'
|
{views.map((item) => {
|
||||||
)}
|
const active = view === item.id;
|
||||||
style={{ width: '20rem' }}
|
return (
|
||||||
data-testid="left-panel"
|
<button
|
||||||
>
|
key={item.id}
|
||||||
<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)]">
|
type="button"
|
||||||
{/* Header */}
|
onClick={() => setView(item.id)}
|
||||||
<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)]">
|
className={cn(
|
||||||
<span className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Workstreams</span>
|
'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)]',
|
||||||
<div className="flex gap-2 text-[10px] font-mono text-[var(--color-text-muted)]/60">
|
active
|
||||||
<span>{epicTree.length} ACTIVE</span>
|
? '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>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
{/* Tree */}
|
</aside>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,24 +13,22 @@ export interface RightPanelProps {
|
||||||
|
|
||||||
export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPanelProps) {
|
export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPanelProps) {
|
||||||
const { isMobile, isDesktop } = useResponsive();
|
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)
|
// Calculate width based on content (Standard 17rem vs Chat Mode ~26rem)
|
||||||
// If rail is present, we are in "Chat Mode" (Wide Panel + Rail)
|
// If rail is present, we are in "Chat Mode" (Wide Panel + Rail)
|
||||||
// If no rail, we are in "Activity Mode" (Standard Panel)
|
// 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) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="overflow-hidden transition-all duration-300 flex"
|
className="ui-shell-panel flex overflow-hidden transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
width: panelWidth,
|
width: panelWidth,
|
||||||
background:
|
boxShadow: isOpen ? '-24px 0 40px -26px rgba(0,0,0,0.95), inset 1px 0 0 rgba(91,168,160,0.22)' : 'none',
|
||||||
'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',
|
|
||||||
}}
|
}}
|
||||||
data-testid="right-panel-desktop"
|
data-testid="right-panel-desktop"
|
||||||
>
|
>
|
||||||
|
|
@ -38,7 +36,12 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
|
||||||
<>
|
<>
|
||||||
{/* Main Content (Chat or Activity) */}
|
{/* Main Content (Chat or Activity) */}
|
||||||
<div className="flex-1 min-w-0 h-full overflow-hidden flex flex-col">
|
<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 */}
|
{/* Remove default padding to allow edge-to-edge chat */}
|
||||||
{children || <span>Right Panel</span>}
|
{children || <span>Right Panel</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -46,7 +49,13 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
|
||||||
|
|
||||||
{/* Side Rail (Mini Activity - Only if provided) */}
|
{/* Side Rail (Mini Activity - Only if provided) */}
|
||||||
{rail && (
|
{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}
|
{rail}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -61,32 +70,46 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBackdropClick = () => {
|
const handleBackdropClick = () => {
|
||||||
togglePanel();
|
toggleRightPanel();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseClick = () => {
|
const handleCloseClick = () => {
|
||||||
togglePanel();
|
toggleRightPanel();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50"
|
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"
|
data-testid="right-panel-mobile"
|
||||||
>
|
>
|
||||||
<div className="flex justify-end p-4">
|
<div className="flex justify-end px-4 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleCloseClick}
|
onClick={handleCloseClick}
|
||||||
className="p-2 rounded-md hover:bg-white/10"
|
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"
|
data-testid="right-panel-close"
|
||||||
aria-label="Close panel"
|
aria-label="Close panel"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>}
|
{children || <span>Right Panel</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,7 +129,8 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
|
||||||
style={{
|
style={{
|
||||||
width: '17rem',
|
width: '17rem',
|
||||||
background:
|
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)',
|
boxShadow: '-24px 0 44px -26px rgba(0,0,0,0.85)',
|
||||||
}}
|
}}
|
||||||
data-testid="right-panel-tablet"
|
data-testid="right-panel-tablet"
|
||||||
|
|
@ -115,14 +139,14 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
|
||||||
<button
|
<button
|
||||||
onClick={handleCloseClick}
|
onClick={handleCloseClick}
|
||||||
className="p-2 rounded-md hover:bg-white/10"
|
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"
|
data-testid="right-panel-close"
|
||||||
aria-label="Close panel"
|
aria-label="Close panel"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>}
|
{children || <span>Right Panel</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { buildEditableIssueDraft, buildIssueUpdatePayload, validateEditableIssue
|
||||||
import type { UpdateMutationPayload } from '../../lib/mutations';
|
import type { UpdateMutationPayload } from '../../lib/mutations';
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import { ThreadView, type ThreadItem } from './thread-view';
|
import { ThreadView, type ThreadItem } from './thread-view';
|
||||||
|
import { useResponsive } from '../../hooks/use-responsive';
|
||||||
|
|
||||||
interface ThreadDrawerProps {
|
interface ThreadDrawerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -20,34 +21,20 @@ interface ThreadDrawerProps {
|
||||||
id: string;
|
id: string;
|
||||||
items?: ThreadItem[];
|
items?: ThreadItem[];
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
takeover?: boolean;
|
||||||
issue?: BeadIssue | null;
|
issue?: BeadIssue | null;
|
||||||
projectRoot?: string;
|
projectRoot?: string;
|
||||||
onIssueUpdated?: (issueId: string) => Promise<void> | void;
|
onIssueUpdated?: (issueId: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAMPLE_ITEMS: ThreadItem[] = [
|
interface CommentFromApi {
|
||||||
{
|
id: string;
|
||||||
id: '1',
|
bead_id: string;
|
||||||
type: 'comment',
|
actor: string;
|
||||||
author: 'sarah.lee',
|
kind: 'comment';
|
||||||
content: 'Pushed a first pass for the left rail hierarchy. Need readability check on status chips.',
|
text: string;
|
||||||
timestamp: new Date(Date.now() - 6 * 60 * 1000),
|
timestamp: string;
|
||||||
},
|
}
|
||||||
{
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_OPTIONS: EditableIssueDraft['status'][] = ['open', 'in_progress', 'blocked', 'deferred', 'closed'];
|
const STATUS_OPTIONS: EditableIssueDraft['status'][] = ['open', 'in_progress', 'blocked', 'deferred', 'closed'];
|
||||||
const PRIORITY_OPTIONS = [0, 1, 2, 3, 4] as const;
|
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 {
|
function saveStateTone(state: 'ready' | 'saving' | 'saved' | 'error'): string {
|
||||||
if (state === 'saving') return 'border-[#5BA8A0]/50 bg-[#5BA8A0]/20 text-[#D6EEEA]';
|
if (state === 'saving') return 'border-[#5BA8A0]/50 bg-[#5BA8A0]/20 text-[#D6EEEA]';
|
||||||
if (state === 'saved') return 'border-[#7CB97A]/50 bg-[#7CB97A]/20 text-[#D4ECD2]';
|
if (state === 'saved') return 'border-[#7CB97A]/50 bg-[#7CB97A]/20 text-[#D4ECD2]';
|
||||||
|
|
@ -77,18 +77,48 @@ export function ThreadDrawer({
|
||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
id,
|
id,
|
||||||
items = SAMPLE_ITEMS,
|
items: externalItems,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
takeover = false,
|
||||||
issue,
|
issue,
|
||||||
projectRoot,
|
projectRoot,
|
||||||
onIssueUpdated,
|
onIssueUpdated,
|
||||||
}: ThreadDrawerProps) {
|
}: ThreadDrawerProps) {
|
||||||
|
const { isMobile } = useResponsive();
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
|
const [commentState, setCommentState] = useState<'ready' | 'sending' | 'sent' | 'error'>('ready');
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
const [draft, setDraft] = useState<EditableIssueDraft | null>(issue ? buildEditableIssueDraft(issue) : null);
|
const [draft, setDraft] = useState<EditableIssueDraft | null>(issue ? buildEditableIssueDraft(issue) : null);
|
||||||
const [fieldErrors, setFieldErrors] = useState<EditableIssueFieldErrors>({});
|
const [fieldErrors, setFieldErrors] = useState<EditableIssueFieldErrors>({});
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [saveState, setSaveState] = useState<'ready' | 'saving' | 'saved' | 'error'>('ready');
|
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(() => {
|
useEffect(() => {
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
|
|
@ -109,15 +139,32 @@ export function ThreadDrawer({
|
||||||
|
|
||||||
const canEdit = Boolean(issue && projectRoot && draft);
|
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 participants = useMemo(() => {
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const item of items) {
|
for (const item of threadItems) {
|
||||||
if (item.author && item.author.trim()) {
|
if (item.author && item.author.trim()) {
|
||||||
names.add(item.author.trim());
|
names.add(item.author.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(names).slice(0, 4);
|
return Array.from(names).slice(0, 4);
|
||||||
}, [items]);
|
}, [threadItems]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!issue || !projectRoot || !draft) {
|
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) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const frameShellClass = takeover
|
||||||
<div
|
? '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)]'
|
||||||
className="flex h-full flex-col"
|
: 'flex h-full flex-col';
|
||||||
style={{
|
|
||||||
|
const frameShellStyle = takeover
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
width: embedded ? '100%' : '26rem',
|
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)',
|
borderLeft: embedded ? 'none' : '1px solid var(--color-border-default)',
|
||||||
boxShadow: embedded ? 'none' : '-20px 0 48px rgba(0,0,0,0.45)',
|
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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[#8F8F8F]">Open Thread</p>
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<h2 className="truncate text-lg font-semibold text-white" title={title}>{title}</h2>
|
<p className="font-mono text-xs font-semibold text-[var(--ui-accent-info)]">#{id}</p>
|
||||||
<p className="text-xs text-[#A5A5A5]">{id} · {items.length} events</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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="ghost"
|
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"
|
aria-label="Close thread"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-3 p-4">
|
<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)]">
|
{takeover ? (
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<>
|
||||||
<div className="flex items-center gap-2 text-sm text-[#DCDCDC]">
|
{summarySection}
|
||||||
<MessageSquareText className="h-4 w-4 text-[#5BA8A0]" />
|
{conversationSection}
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2.5">
|
<>
|
||||||
<label className="block text-xs text-[#9F9F9F]">
|
{conversationSection}
|
||||||
Title
|
{summarySection}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<footer className="border-t border-[#4A4A4A] bg-[#2F2F2F] p-3">
|
<footer
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-[#4A4A4A] bg-[#3A3A3A] p-1">
|
className="border-t border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-3"
|
||||||
<Input
|
style={
|
||||||
value={comment}
|
isMobile
|
||||||
onChange={(event) => setComment(event.target.value)}
|
? {
|
||||||
onKeyDown={(event) => {
|
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
|
||||||
if (event.key === 'Enter' && !event.shiftKey && comment.trim()) {
|
position: 'sticky',
|
||||||
event.preventDefault();
|
bottom: 0,
|
||||||
setComment('');
|
zIndex: 10,
|
||||||
}
|
}
|
||||||
}}
|
: undefined
|
||||||
placeholder="Reply to thread..."
|
}
|
||||||
className="border-0 bg-transparent text-white placeholder:text-[#888888]"
|
>
|
||||||
/>
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] p-1">
|
||||||
<Button
|
<Input
|
||||||
type="button"
|
value={comment}
|
||||||
className="h-8 rounded-full bg-[#5BA8A0] px-3 text-[#1A1A1A] hover:bg-[#6AB8AF]"
|
onChange={(event) => setComment(event.target.value)}
|
||||||
onClick={() => setComment('')}
|
onKeyDown={(event) => {
|
||||||
disabled={!comment.trim()}
|
if (event.key === 'Enter' && !event.shiftKey && comment.trim()) {
|
||||||
>
|
event.preventDefault();
|
||||||
<Send className="h-3.5 w-3.5" />
|
void handleCommentSubmit();
|
||||||
</Button>
|
}
|
||||||
</div>
|
}}
|
||||||
</footer>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export interface ThreadItem {
|
||||||
|
|
||||||
interface ThreadViewProps {
|
interface ThreadViewProps {
|
||||||
items: ThreadItem[];
|
items: ThreadItem[];
|
||||||
|
variant?: 'stack' | 'chat';
|
||||||
|
currentUser?: string;
|
||||||
onAddComment?: (text: string) => void;
|
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 (
|
return (
|
||||||
<div className="flex gap-3 py-3">
|
<div className={cn('flex gap-3 py-3', isSelf && 'justify-end')}>
|
||||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
{!isSelf ? (
|
||||||
<AvatarImage src={undefined} alt={item.author} />
|
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||||
<AvatarFallback className="bg-surface-muted text-text-body text-xs font-semibold">
|
<AvatarImage src={undefined} alt={item.author} />
|
||||||
{item.author ? getInitials(item.author) : '??'}
|
<AvatarFallback className="bg-surface-muted text-text-body text-xs font-semibold">
|
||||||
</AvatarFallback>
|
{item.author ? getInitials(item.author) : '??'}
|
||||||
</Avatar>
|
</AvatarFallback>
|
||||||
<div className="flex-1 min-w-0">
|
</Avatar>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
) : null}
|
||||||
<span className="text-text-primary text-sm font-medium">
|
<div className={cn('min-w-0 max-w-[80%]', isSelf && 'items-end')}>
|
||||||
{item.author || 'Unknown'}
|
<div className={cn('mb-1 flex items-center gap-2', isSelf && 'justify-end')}>
|
||||||
</span>
|
<span className="text-text-primary text-sm font-semibold">{item.author || 'Unknown'}</span>
|
||||||
<span className="text-text-muted text-xs">
|
<span className="font-mono text-[11px] text-text-muted">{formatRelativeTime(item.timestamp)}</span>
|
||||||
{formatRelativeTime(item.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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}
|
{item.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</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 (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
|
|
@ -143,7 +159,13 @@ export function ThreadView({ items, onAddComment }: ThreadViewProps) {
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'comment':
|
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':
|
case 'status_change':
|
||||||
return <StatusChangeItem key={item.id} item={item} />;
|
return <StatusChangeItem key={item.id} item={item} />;
|
||||||
case 'protocol_event':
|
case 'protocol_event':
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,148 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode } from 'react';
|
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';
|
import { useResponsive } from '../../hooks/use-responsive';
|
||||||
|
|
||||||
export interface TopBarProps {
|
export interface TopBarProps {
|
||||||
|
onCreateTask?: () => Promise<void> | void;
|
||||||
|
isCreatingTask?: boolean;
|
||||||
|
taskActionMessage?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
totalTasks?: number;
|
||||||
|
criticalAlerts?: number;
|
||||||
|
idleCount?: number;
|
||||||
|
busyCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({ children }: TopBarProps) {
|
interface MetricTileProps {
|
||||||
const { view, setView, togglePanel } = useUrlState();
|
label: string;
|
||||||
const { isDesktop } = useResponsive();
|
value: number;
|
||||||
|
accent?: 'ready' | 'blocked' | 'info' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
const tabs: { id: ViewType; label: string }[] = [
|
function MetricTile({ label, value, accent = 'info' }: MetricTileProps) {
|
||||||
{ id: 'social', label: 'Social' },
|
const accentColor =
|
||||||
{ id: 'graph', label: 'Graph' },
|
accent === 'ready'
|
||||||
{ id: 'swarm', label: 'Swarm' },
|
? 'var(--ui-accent-ready)'
|
||||||
];
|
: accent === 'blocked'
|
||||||
|
? 'var(--ui-accent-blocked)'
|
||||||
const showHamburger = !isDesktop;
|
: accent === 'warning'
|
||||||
|
? 'var(--ui-accent-warning)'
|
||||||
|
: 'var(--ui-accent-info)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<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">
|
||||||
className="h-12 flex items-center justify-between px-4"
|
<p className="font-mono text-[10px] uppercase tracking-[0.13em] text-[var(--ui-text-muted)]">{label}</p>
|
||||||
style={{
|
<p className="font-mono text-sm leading-none text-[var(--ui-text-primary)]">{value}</p>
|
||||||
background:
|
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: accentColor }} />
|
||||||
'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)',
|
</div>
|
||||||
boxShadow: '0 14px 22px -20px rgba(0,0,0,0.85)',
|
);
|
||||||
}}
|
}
|
||||||
data-testid="top-bar"
|
|
||||||
>
|
export function TopBar({
|
||||||
<div className="flex items-center gap-2">
|
onCreateTask,
|
||||||
{showHamburger && (
|
isCreatingTask = false,
|
||||||
<button
|
taskActionMessage,
|
||||||
onClick={togglePanel}
|
children,
|
||||||
className="p-2 transition-colors hover:text-[var(--color-text-primary)]"
|
totalTasks = 0,
|
||||||
style={{ color: 'var(--color-text-secondary)' }}
|
criticalAlerts = 0,
|
||||||
aria-label="Open menu"
|
idleCount = 0,
|
||||||
data-testid="hamburger-button"
|
busyCount = 0,
|
||||||
>
|
}: TopBarProps) {
|
||||||
<svg
|
const { leftPanel, toggleLeftPanel, rightPanel, toggleRightPanel, blockedOnly, toggleBlockedOnly } = useUrlState();
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
const { isDesktop } = useResponsive();
|
||||||
width="20"
|
|
||||||
height="20"
|
return (
|
||||||
viewBox="0 0 24 24"
|
<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">
|
||||||
fill="none"
|
<div className="flex min-w-0 items-center">
|
||||||
stroke="currentColor"
|
<button
|
||||||
strokeWidth="2"
|
type="button"
|
||||||
strokeLinecap="round"
|
onClick={toggleLeftPanel}
|
||||||
strokeLinejoin="round"
|
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'}
|
||||||
<line x1="3" y1="12" x2="21" y2="12" />
|
aria-pressed={leftPanel === 'open'}
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
data-testid="hamburger-button"
|
||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
>
|
||||||
</svg>
|
{leftPanel === 'open' ? <SidebarClose className="h-4 w-4" aria-hidden="true" /> : <Sidebar className="h-4 w-4" aria-hidden="true" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<nav className="flex items-center gap-1" role="tablist">
|
<div className="mr-3 flex min-w-[210px] items-center gap-2 border-r border-[var(--ui-border-soft)] px-2 py-2">
|
||||||
{tabs.map((tab) => {
|
<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)]">
|
||||||
const isActive = view === tab.id;
|
<LayoutGrid className="h-5 w-5" aria-hidden="true" />
|
||||||
return (
|
</div>
|
||||||
<button
|
<div>
|
||||||
key={tab.id}
|
<p className="text-sm font-semibold uppercase tracking-[0.04em] text-[var(--ui-text-primary)]">Command Grid</p>
|
||||||
onClick={() => setView(tab.id)}
|
<p className="font-mono text-[10px] text-[var(--ui-text-muted)]">v2.4.0-stable</p>
|
||||||
role="tab"
|
</div>
|
||||||
aria-selected={isActive}
|
</div>
|
||||||
className={`px-4 py-2 text-sm transition-colors rounded-md ${
|
|
||||||
isActive
|
<div className="hidden items-center gap-2 pl-2 md:flex">
|
||||||
? '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]'
|
<MetricTile label="Total" value={totalTasks} accent="ready" />
|
||||||
: 'font-normal hover:text-[var(--color-text-primary)]'
|
<MetricTile label="Blocked" value={criticalAlerts} accent="blocked" />
|
||||||
}`}
|
<MetricTile label="Busy" value={busyCount} accent="warning" />
|
||||||
style={{
|
<MetricTile label="Idle" value={idleCount} accent="info" />
|
||||||
color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
</div>
|
||||||
}}
|
|
||||||
data-testid={`tab-${tab.id}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="mr-3 flex items-center gap-2">
|
||||||
{children || (
|
{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
|
<button
|
||||||
className="p-2 transition-colors hover:text-[var(--color-text-primary)]"
|
type="button"
|
||||||
style={{ color: 'var(--color-text-secondary)' }}
|
onClick={toggleBlockedOnly}
|
||||||
aria-label="Settings"
|
aria-pressed={blockedOnly}
|
||||||
data-testid="settings-button"
|
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
|
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
Blocked Items
|
||||||
width="18"
|
<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]">
|
||||||
height="18"
|
{criticalAlerts}
|
||||||
viewBox="0 0 24 24"
|
</span>
|
||||||
fill="none"
|
</button>
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
<button
|
||||||
strokeLinecap="round"
|
type="button"
|
||||||
strokeLinejoin="round"
|
onClick={() => {
|
||||||
>
|
void onCreateTask?.();
|
||||||
<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" />
|
disabled={isCreatingTask}
|
||||||
</svg>
|
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>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ export function UnifiedShell({
|
||||||
return (
|
return (
|
||||||
<SwarmWorkspace
|
<SwarmWorkspace
|
||||||
selectedMissionId={swarmId ?? undefined}
|
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 { Activity, Clock3, GitBranch, Link2, MessageCircle, Orbit } from 'lucide-react';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
@ -20,67 +20,90 @@ interface SocialCardProps {
|
||||||
dependencyCount?: number;
|
dependencyCount?: number;
|
||||||
commentCount?: number;
|
commentCount?: number;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
|
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
|
||||||
|
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusTone = {
|
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
|
||||||
accent: string;
|
if (!onClick) return;
|
||||||
glow: string;
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
badgeClass: string;
|
event.preventDefault();
|
||||||
surface: string;
|
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
|
||||||
accentChip: string;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_TONES: Record<SocialCardData['status'], StatusTone> = {
|
function statusVisual(status: SocialCardData['status']) {
|
||||||
ready: {
|
if (status === 'blocked') {
|
||||||
accent: '#7CB97A',
|
return {
|
||||||
glow: 'rgba(124,185,122,0.26)',
|
border: 'color-mix(in srgb, var(--ui-accent-blocked) 50%, var(--ui-border-soft))',
|
||||||
badgeClass: 'bg-[#7CB97A]/26 text-[#DCEED8] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
cardBg:
|
||||||
surface:
|
'linear-gradient(160deg, color-mix(in srgb, var(--ui-accent-blocked) 20%, #1a0f15), color-mix(in srgb, var(--ui-bg-shell) 92%, black))',
|
||||||
'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))',
|
badgeBg: 'color-mix(in srgb, var(--ui-accent-blocked) 24%, transparent)',
|
||||||
accentChip: 'bg-[#7CB97A]/18 text-[#D2E4CE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
badgeText: '#ffd5df',
|
||||||
},
|
chipText: 'Blocked',
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
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)]">
|
<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={cn('mb-1 text-[10px] font-semibold uppercase tracking-[0.12em]', toneClass)}>{label}</p>
|
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.12em]" style={{ color }}>
|
||||||
<div className="flex flex-wrap gap-1">
|
{title}
|
||||||
{ids.slice(0, 2).map((id) => (
|
</p>
|
||||||
<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)]">
|
<div className="space-y-1.5">
|
||||||
{id}
|
{details.slice(0, 1).map((item) => (
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--ui-text-muted)]">+{details.length - 1} more</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -98,131 +121,116 @@ export function SocialCard({
|
||||||
dependencyCount,
|
dependencyCount,
|
||||||
commentCount,
|
commentCount,
|
||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
|
blockedByDetails = [],
|
||||||
|
unblocksDetails = [],
|
||||||
}: SocialCardProps) {
|
}: SocialCardProps) {
|
||||||
const tone = STATUS_TONES[data.status];
|
const status = statusVisual(data.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
aria-label={`Open ${data.title}`}
|
||||||
className={cn(
|
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',
|
'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)]',
|
||||||
'hover:-translate-y-0.5',
|
|
||||||
selected && 'translate-y-[-2px]',
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
background: tone.surface,
|
background: status.cardBg,
|
||||||
|
borderColor: selected ? status.border : 'var(--ui-border-soft)',
|
||||||
boxShadow: selected
|
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 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 24px -20px ${tone.glow}, 0 6px 14px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.06)`,
|
: '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="mb-2 flex items-center justify-between gap-2">
|
||||||
<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="flex min-w-0 items-center 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 ? (
|
{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}
|
{unreadCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<h3 className="line-clamp-2 text-[1.7rem] font-semibold leading-[1.1] tracking-[-0.02em] text-white">
|
<h3 className="line-clamp-2 text-[27px] font-semibold leading-[1.13] tracking-[-0.01em] text-[var(--ui-text-primary)]">{data.title}</h3>
|
||||||
{data.title}
|
<p className="mt-1.5 line-clamp-3 min-h-[56px] text-[13px] leading-relaxed text-[var(--ui-text-muted)]">
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="mt-2 line-clamp-2 min-h-[2.6rem] text-sm leading-relaxed text-[#B8B8B8]">
|
|
||||||
{description || 'No summary provided yet.'}
|
{description || 'No summary provided yet.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-col 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)]">
|
{dependencyPanel('Blocked By', 'var(--ui-accent-blocked)', blockedByDetails)}
|
||||||
{data.blocks.length} blocking
|
{dependencyPanel('Unblocks', 'var(--ui-accent-ready)', unblocksDetails)}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
{renderDependencyPreview(data.unblocks, 'text-[#D4A574]', 'Blocked By')}
|
{data.agents.slice(0, 3).map((agent) => (
|
||||||
{renderDependencyPreview(data.blocks, 'text-[#5BA8A0]', 'Unblocks')}
|
<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>
|
||||||
|
|
||||||
<div className="mt-auto flex items-end justify-between gap-3 pt-4">
|
<div className="mt-auto border-t border-[var(--ui-border-soft)] pt-1.5">
|
||||||
<div className="space-y-1.5 text-xs text-[#9A9A9A]">
|
<div className="mb-1.5 flex items-center justify-between text-xs text-[var(--ui-text-muted)]">
|
||||||
<p className="inline-flex items-center gap-1.5"><Clock3 className="h-3.5 w-3.5" />{updatedLabel}</p>
|
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" aria-hidden="true" />{updatedLabel}</span>
|
||||||
<div className="flex items-center gap-3">
|
<span className="font-mono text-[11px] text-[var(--ui-accent-ready)]">stage active</span>
|
||||||
<p className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</p>
|
</div>
|
||||||
<p className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" />{commentCount ?? 0}</p>
|
|
||||||
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Clock3, Layers2, Sparkles, TriangleAlert } from 'lucide-react';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
|
|
@ -14,6 +13,33 @@ interface SocialPageProps {
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
projectScopeOptions?: ProjectScopeOption[];
|
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 {
|
function formatRelative(timestamp: string): string {
|
||||||
|
|
@ -31,16 +57,13 @@ function formatRelative(timestamp: string): string {
|
||||||
return `${diffDays}d ago`;
|
return `${diffDays}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_SCORE: Record<string, number> = {
|
export function SocialPage({
|
||||||
blocked: 5,
|
issues,
|
||||||
in_progress: 4,
|
selectedId,
|
||||||
ready: 3,
|
onSelect,
|
||||||
open: 3,
|
projectScopeOptions = [],
|
||||||
deferred: 2,
|
blockedOnly = false,
|
||||||
closed: 1,
|
}: SocialPageProps) {
|
||||||
};
|
|
||||||
|
|
||||||
export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions = [] }: SocialPageProps) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const cards = useMemo(() => buildSocialCards(issues), [issues]);
|
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 navigateWithParams = (updates: Record<string, string | null>) => {
|
||||||
const next = new URLSearchParams(searchParams.toString());
|
const next = new URLSearchParams(searchParams.toString());
|
||||||
for (const [key, value] of Object.entries(updates)) {
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
if (!value) {
|
if (!value) next.delete(key);
|
||||||
next.delete(key);
|
else next.set(key, value);
|
||||||
} else {
|
|
||||||
next.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const query = next.toString();
|
const query = next.toString();
|
||||||
router.push(query ? `/?${query}` : '/', { scroll: false });
|
router.push(query ? `/?${query}` : '/', { scroll: false });
|
||||||
|
|
@ -60,124 +80,194 @@ export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions =
|
||||||
|
|
||||||
const issueById = useMemo(() => {
|
const issueById = useMemo(() => {
|
||||||
const map = new Map<string, BeadIssue>();
|
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) {
|
for (const issue of issues) {
|
||||||
map.set(issue.id, issue);
|
if (issue.issue_type === 'epic') {
|
||||||
|
map.set(issue.id, issue.title);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [issues]);
|
}, [issues]);
|
||||||
|
|
||||||
const orderedCards = useMemo(() => {
|
const toDependencyDetails = (ids: string[]) =>
|
||||||
return [...cards].sort((a, b) => {
|
ids.map((id) => {
|
||||||
const scoreDiff = (STATUS_SCORE[b.status] ?? 0) - (STATUS_SCORE[a.status] ?? 0);
|
const depIssue = issueById.get(id);
|
||||||
if (scoreDiff !== 0) {
|
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
|
||||||
return scoreDiff;
|
return {
|
||||||
}
|
id,
|
||||||
return b.lastActivity.getTime() - a.lastActivity.getTime();
|
title: depIssue?.title ?? id,
|
||||||
|
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}, [cards]);
|
|
||||||
|
|
||||||
const selectedCard = useMemo(
|
const orderedCards = useMemo(
|
||||||
() => orderedCards.find((card) => card.id === selectedId) ?? null,
|
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
|
||||||
[orderedCards, selectedId],
|
[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 grouped = useMemo(() => {
|
||||||
const blocked = cards.filter((card) => card.status === 'blocked').length;
|
const map: Record<SectionKey, typeof visibleCards> = {
|
||||||
const active = cards.filter((card) => card.status === 'in_progress').length;
|
ready: [],
|
||||||
const ready = cards.filter((card) => card.status === 'ready').length;
|
in_progress: [],
|
||||||
const urgent = cards.filter((card) => card.priority === 'P0').length;
|
blocked: [],
|
||||||
|
deferred: [],
|
||||||
|
done: [],
|
||||||
|
};
|
||||||
|
|
||||||
return { blocked, active, ready, urgent };
|
for (const card of visibleCards) {
|
||||||
}, [cards]);
|
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 (
|
return (
|
||||||
<div className="relative h-full overflow-y-auto bg-[#2D2D2D] custom-scrollbar">
|
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] 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="mx-auto w-full max-w-[1280px] px-4 pb-8 pt-4 xl:px-6">
|
||||||
<div className="relative mx-auto flex max-w-[1450px] flex-col gap-4 p-5">
|
<div className="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
|
||||||
<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>
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Social Stream</p>
|
||||||
<div>
|
<p className="text-sm text-[var(--ui-text-primary)]">Task Activity Command Feed</p>
|
||||||
<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>
|
</div>
|
||||||
<div className="mt-3 grid gap-2 sm:grid-cols-4">
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
<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>
|
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
|
||||||
<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>
|
{projectScopeOptions.length} scopes
|
||||||
<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>
|
</span>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{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;
|
|
||||||
|
|
||||||
|
<section className="space-y-6">
|
||||||
|
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
|
||||||
|
const cardsForSection = grouped[key];
|
||||||
return (
|
return (
|
||||||
<SocialCard
|
<div key={key}>
|
||||||
key={card.id}
|
<div className="mb-3 flex items-center gap-2 border-b border-[var(--ui-border-soft)] pb-2">
|
||||||
data={card}
|
<p className="font-mono text-xs uppercase tracking-[0.14em]" style={{ color: SECTION_COLOR[key] }}>
|
||||||
selected={selectedId === card.id}
|
{SECTION_LABEL[key]}
|
||||||
onClick={() => onSelect(card.id)}
|
</p>
|
||||||
onJumpToGraph={(id) => {
|
<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)]">
|
||||||
navigateWithParams({
|
{cardsForSection.length}
|
||||||
view: 'graph',
|
</span>
|
||||||
task: id,
|
{(key === 'deferred' || key === 'done') ? (
|
||||||
swarm: null,
|
<button
|
||||||
panel: 'open',
|
type="button"
|
||||||
drawer: 'closed',
|
onClick={() =>
|
||||||
});
|
setCollapsedSections((current) => ({ ...current, [key]: !current[key] }))
|
||||||
}}
|
}
|
||||||
onJumpToActivity={(id) => {
|
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)]"
|
||||||
navigateWithParams({
|
>
|
||||||
view: 'activity',
|
{collapsedSections[key] ? 'Expand' : 'Minimize'}
|
||||||
task: id,
|
</button>
|
||||||
panel: 'open',
|
) : null}
|
||||||
drawer: 'closed',
|
</div>
|
||||||
});
|
|
||||||
}}
|
{collapsedSections[key] ? (
|
||||||
onOpenThread={() => onSelect(card.id)}
|
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||||
description={description ?? undefined}
|
{cardsForSection.length === 0
|
||||||
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
|
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
|
||||||
dependencyCount={issue?.dependencies.length ?? card.blocks.length + card.unblocks.length}
|
: `${cardsForSection.length} tasks hidden.`}
|
||||||
commentCount={commentCount}
|
</p>
|
||||||
unreadCount={unreadCount}
|
) : (
|
||||||
/>
|
<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>
|
</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>
|
||||||
</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';
|
'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 { Card } from '../../../components/ui/card';
|
||||||
import { Badge } from '../../../components/ui/badge';
|
import { Badge } from '../../../components/ui/badge';
|
||||||
import { AgentAvatar } from '../shared/agent-avatar';
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { Plus, Menu, Diamond, Waves, AlertTriangle } from 'lucide-react';
|
import { CheckCircle2, PlayCircle, Clock, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface SwarmCardProps {
|
interface SwarmCardProps {
|
||||||
card: SwarmCardType;
|
card: SwarmCardData;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressBar({ progress }: { progress: number }) {
|
function ProgressBar({ progress }: { progress: number }) {
|
||||||
|
|
@ -50,118 +16,70 @@ function ProgressBar({ progress }: { progress: number }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<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(filled)}
|
||||||
{'░'.repeat(empty)}
|
{'░'.repeat(empty)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-400">{progress}% done</span>
|
<span className="text-xs text-slate-400">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AttentionList({ items }: { items: string[] }) {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
if (items.length === 0) return null;
|
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 (
|
return (
|
||||||
<div className="space-y-1">
|
<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)]">
|
||||||
<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)]">
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="space-y-1">
|
||||||
<div className="space-y-0.5">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className="font-mono text-sm font-semibold text-slate-200">
|
||||||
<span className="font-mono text-sm font-semibold text-slate-200">
|
{card.swarmId}
|
||||||
{card.swarmId}
|
</span>
|
||||||
</span>
|
<Badge
|
||||||
<Badge
|
variant="outline"
|
||||||
variant="outline"
|
className={cn('text-[10px] px-1.5 py-0', STATUS_COLORS[card.status] ?? 'text-slate-400 border-slate-400/30')}
|
||||||
className={cn('text-[10px] px-1.5 py-0 border-slate-600', HEALTH_COLORS[card.health])}
|
>
|
||||||
>
|
{card.status}
|
||||||
{card.health}
|
</Badge>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-slate-400 line-clamp-1">{card.title}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<span className="text-sm text-slate-400 line-clamp-1">{card.title}</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<ProgressBar progress={card.progressPercent} />
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
AGENTS:
|
<div className="text-xs text-slate-500">
|
||||||
</span>
|
Epic: <span className="font-mono text-slate-400">{card.epicId}</span>
|
||||||
<div className="flex items-center gap-1 -space-x-1">
|
</div>
|
||||||
{activeAgents.slice(0, 4).map((agent) => (
|
|
||||||
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
|
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||||
))}
|
<div className="flex items-center gap-1 text-emerald-400">
|
||||||
{otherAgents.slice(0, 2).map((agent) => (
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
|
<span>{card.completedIssues}</span>
|
||||||
))}
|
</div>
|
||||||
{card.agents.length > 6 && (
|
<div className="flex items-center gap-1 text-amber-400">
|
||||||
<span className="text-xs text-slate-500 ml-2">+{card.agents.length - 6}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{card.agents.filter((a) => a.currentTask).slice(0, 2).map((agent) => (
|
{card.coordinator && (
|
||||||
<AgentRosterRow key={agent.name} agent={agent} />
|
<div className="text-xs text-slate-500">
|
||||||
))}
|
Coordinator: <span className="text-slate-400">{card.coordinator}</span>
|
||||||
|
|
||||||
<AttentionList items={card.attentionItems} />
|
|
||||||
|
|
||||||
<ProgressBar progress={card.progress} />
|
|
||||||
|
|
||||||
{card.lastActivity && (
|
|
||||||
<div className="text-xs text-slate-500 italic truncate">
|
|
||||||
Last activity {formatTimeAgo(card.lastActivity)}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</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';
|
'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 { Badge } from '../../../components/ui/badge';
|
||||||
import { AgentAvatar } from '../shared/agent-avatar';
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { AlertTriangle, Clock, Users } from 'lucide-react';
|
import { CheckCircle2, PlayCircle, Clock, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface SwarmDetailProps {
|
interface SwarmDetailProps {
|
||||||
card: SwarmCardType;
|
swarmId: string;
|
||||||
}
|
projectRoot: string;
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressBar({ progress }: { progress: number }) {
|
function ProgressBar({ progress }: { progress: number }) {
|
||||||
|
const filled = Math.round(progress / 10);
|
||||||
|
const empty = 10 - filled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>Progress</span>
|
<span className="text-slate-400">Progress</span>
|
||||||
<span className="font-mono" style={{ color: 'var(--color-text-secondary)' }}>
|
<span className="font-mono text-slate-300">{progress}%</span>
|
||||||
{progress}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="h-1.5 rounded-full overflow-hidden"
|
<div className="flex-1 font-mono text-xs text-slate-300">
|
||||||
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
|
{'█'.repeat(filled)}
|
||||||
>
|
{'░'.repeat(empty)}
|
||||||
<div
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentRosterSection({ agents }: { agents: SwarmCardType['agents'] }) {
|
export function SwarmDetail({ swarmId, projectRoot }: SwarmDetailProps) {
|
||||||
const active = agents.filter((a) => a.status === 'active').length;
|
const [status, setStatus] = useState<SwarmStatusFromApi | null>(null);
|
||||||
const stale = agents.filter((a) => a.status === 'stale').length;
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const stuck = agents.filter((a) => a.status === 'stuck').length;
|
const [error, setError] = useState<string | null>(null);
|
||||||
const dead = agents.filter((a) => a.status === 'dead').length;
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4 p-4">
|
||||||
<div className="flex items-center gap-1.5">
|
{/* Header */}
|
||||||
<Users className="h-3.5 w-3.5" style={{ color: 'var(--color-text-muted)' }} />
|
<div className="space-y-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="flex items-center gap-2">
|
||||||
Agents ({agents.length})
|
<span className="font-mono text-sm font-semibold text-slate-200">
|
||||||
</span>
|
{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>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{agents.map((agent) => (
|
{/* Progress */}
|
||||||
<div
|
<ProgressBar progress={status.progress_percent} />
|
||||||
key={agent.name}
|
|
||||||
className={cn(
|
{/* Stats Grid */}
|
||||||
'flex items-center gap-1.5 px-2 py-1 rounded-md border',
|
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||||
STATUS_GLOW[agent.status]
|
<div className="flex items-center gap-1 text-emerald-400">
|
||||||
)}
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
|
<span>{status.completed.length} done</span>
|
||||||
>
|
</div>
|
||||||
<AgentAvatar name={agent.name} status={agent.status} size="sm" />
|
<div className="flex items-center gap-1 text-amber-400">
|
||||||
<span className="text-xs" style={{ color: 'var(--color-text-primary)' }}>
|
<PlayCircle className="h-3 w-3" />
|
||||||
{agent.name}
|
<span>{status.active_count} active</span>
|
||||||
</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>
|
||||||
))}
|
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</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';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import { useMissionList, type MissionData } from '../../hooks/use-mission-list';
|
||||||
import type { SwarmCard as SwarmCardType } from '../../lib/swarm-cards';
|
import { MissionCard } from '../mission/mission-card';
|
||||||
import { buildSwarmCards } from '../../lib/swarm-cards';
|
import { TeamManagerDialog } from '../mission/team-manager-dialog';
|
||||||
import { SwarmCard } from './swarm-card';
|
import { MissionInspector } from '../mission/mission-inspector';
|
||||||
|
import { LaunchSwarmDialog } from './launch-dialog';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
|
@ -14,7 +15,8 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
type SortOption = 'health' | 'activity' | 'progress' | 'name';
|
||||||
|
|
||||||
|
|
@ -25,76 +27,157 @@ const SORT_LABELS: Record<SortOption, string> = {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
};
|
};
|
||||||
|
|
||||||
const INITIAL_LIMIT = 16; // 4x4 grid
|
const INITIAL_LIMIT = 16;
|
||||||
|
|
||||||
const HEALTH_ORDER: Record<string, number> = {
|
|
||||||
stuck: 0,
|
|
||||||
stale: 1,
|
|
||||||
dead: 2,
|
|
||||||
active: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
function sortCards(cards: SwarmCardType[], sortBy: SortOption): SwarmCardType[] {
|
|
||||||
const sorted = [...cards];
|
|
||||||
|
|
||||||
|
function sortMissions(missions: MissionData[], sortBy: SortOption): MissionData[] {
|
||||||
|
const sorted = [...missions];
|
||||||
switch (sortBy) {
|
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':
|
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':
|
case 'name':
|
||||||
return sorted.sort((a, b) => a.swarmId.localeCompare(b.swarmId));
|
return sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
default:
|
default:
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SwarmPageProps {
|
interface SwarmPageProps {
|
||||||
issues: BeadIssue[];
|
projectRoot: string;
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
onSelect: (id: string) => void;
|
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 [sortBy, setSortBy] = useState<SortOption>('health');
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [manageTeamId, setManageTeamId] = useState<string | null>(null);
|
||||||
|
|
||||||
const cards = useMemo(() => buildSwarmCards(issues), [issues]);
|
// Refs to break dependency loops
|
||||||
const sortedCards = useMemo(() => sortCards(cards, sortBy), [cards, sortBy]);
|
const onSelectRef = useRef(onSelect);
|
||||||
const visibleCards = expanded ? sortedCards : sortedCards.slice(0, INITIAL_LIMIT);
|
useEffect(() => { onSelectRef.current = onSelect; }, [onSelect]);
|
||||||
const hasMore = sortedCards.length > INITIAL_LIMIT;
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="p-4">
|
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] px-4 py-4 md:px-6 custom-scrollbar">
|
||||||
<div className="flex items-center justify-between mb-4" style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
{/* Dashboard Stats */}
|
||||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>
|
<div className="mx-auto mb-6 grid w-full max-w-[1200px] grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
Swarm View
|
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm">
|
||||||
</h2>
|
<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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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" />
|
<ArrowUpDown className="h-4 w-4 text-slate-500" aria-hidden="true" />
|
||||||
{SORT_LABELS[sortBy]}
|
<span className="text-xs uppercase tracking-wider font-bold">{SORT_LABELS[sortBy]}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-40">
|
<DropdownMenuContent align="end" className="w-40 bg-[#0d1621] border-slate-800 text-slate-300">
|
||||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-slate-500">Sort Missions</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator className="bg-white/5" />
|
||||||
{(Object.keys(SORT_LABELS) as SortOption[]).map((option) => (
|
{(Object.keys(SORT_LABELS) as SortOption[]).map((option) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={option}
|
key={option}
|
||||||
onClick={() => setSortBy(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]}
|
{SORT_LABELS[option]}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -103,47 +186,64 @@ export function SwarmPage({ issues, selectedId, onSelect }: SwarmPageProps) {
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{/* Grid */}
|
||||||
className="grid gap-4"
|
<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">
|
||||||
style={{
|
{visibleMissions.map((mission) => (
|
||||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
<MissionCard
|
||||||
maxWidth: '1200px',
|
key={mission.id}
|
||||||
margin: '0 auto',
|
id={mission.id}
|
||||||
}}
|
projectRoot={projectRoot}
|
||||||
>
|
title={mission.title}
|
||||||
{visibleCards.map((card) => (
|
description={mission.description}
|
||||||
<div
|
status={mission.status as any}
|
||||||
key={card.swarmId}
|
stats={mission.stats}
|
||||||
onClick={() => onSelect(card.swarmId)}
|
agents={mission.agents}
|
||||||
className={`cursor-pointer rounded-xl transition-all ${
|
onClick={() => onSelect(mission.id)}
|
||||||
selectedId === card.swarmId
|
onDeploy={() => setManageTeamId(mission.id)}
|
||||||
? 'ring-2 ring-[var(--color-accent-amber)]'
|
/>
|
||||||
: 'hover:ring-1 hover:ring-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SwarmCard card={card} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="flex justify-center mt-4">
|
<div className="mt-8 flex justify-center pb-12">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setExpanded(true)}
|
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
|
Show All Missions
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedCards.length === 0 && (
|
{isLoading && (
|
||||||
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="py-24 flex flex-col items-center justify-center text-[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.
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,26 +1,141 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { SwarmLiveDag } from './swarm-live-dag';
|
import { TelemetryGrid } from './telemetry-grid';
|
||||||
import { ConvoyStepper } from './convoy-stepper';
|
import { ConvoyStepper, type Phase } from './convoy-stepper';
|
||||||
import { Network, Blocks, FileCode2, Info } from 'lucide-react';
|
import { Network, Blocks, FileCode2, Info } from 'lucide-react';
|
||||||
import { cn } from '../../lib/utils';
|
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');
|
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 = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'operations':
|
case 'operations':
|
||||||
return selectedMissionId
|
return selectedMissionId
|
||||||
? (
|
? (() => {
|
||||||
<div className="flex flex-col h-full gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
const epic = issues.find(i => i.id === selectedMissionId);
|
||||||
<ConvoyStepper activePhase="execution" />
|
let epicPhase: Phase = 'planning';
|
||||||
<div className="flex-1 min-h-0 bg-[#0f1824]/50 rounded-xl border border-[var(--ui-border-soft)] p-2 shadow-inner">
|
if (epic?.status === 'in_progress') epicPhase = 'execution';
|
||||||
<SwarmLiveDag epicId={selectedMissionId} />
|
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>
|
)
|
||||||
)
|
})()
|
||||||
: (
|
: (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center space-y-4 animate-in fade-in duration-700">
|
<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">
|
<div className="p-4 bg-[var(--ui-accent-info)]/10 rounded-full">
|
||||||
|
|
@ -36,36 +151,122 @@ export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: stri
|
||||||
);
|
);
|
||||||
case 'archetypes':
|
case 'archetypes':
|
||||||
return (
|
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>
|
<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>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{/* Placeholder Cards */}
|
{archetypesLoading ? (
|
||||||
{[1, 2, 3].map(i => (
|
[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 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-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-4 w-24 bg-white/10 rounded mb-2" />
|
||||||
<div className="h-3 w-3/4 bg-white/5 rounded" />
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'templates':
|
case 'templates':
|
||||||
return (
|
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>
|
<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>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{[1, 2].map(i => (
|
{templatesLoading ? (
|
||||||
<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">
|
[1, 2].map(i => (
|
||||||
<div className="h-12 w-12 rounded-full bg-amber-500/20" />
|
<div key={i} className="bg-[#111f2b] p-5 rounded-xl border border-[var(--ui-border-soft)] flex items-center gap-4 animate-pulse">
|
||||||
<div>
|
<div className="h-12 w-12 rounded-full bg-amber-500/20" />
|
||||||
<div className="h-4 w-32 bg-white/10 rounded mb-2" />
|
<div className="flex-1">
|
||||||
<div className="h-3 w-48 bg-white/5 rounded" />
|
<div className="h-4 w-32 bg-white/10 rounded mb-2" />
|
||||||
|
<div className="h-3 w-48 bg-white/5 rounded" />
|
||||||
|
</div>
|
||||||
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -121,6 +322,22 @@ export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: stri
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export type ViewType = 'social' | 'graph' | 'swarm' | 'activity';
|
export type ViewType = 'social' | 'graph' | 'swarm' | 'activity';
|
||||||
|
|
@ -12,13 +12,22 @@ export interface UrlState {
|
||||||
view: ViewType;
|
view: ViewType;
|
||||||
setView: (v: ViewType) => void;
|
setView: (v: ViewType) => void;
|
||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
setTaskId: (id: string | null) => void;
|
setTaskId: (id: string | null, openDrawer?: boolean) => void;
|
||||||
swarmId: string | null;
|
swarmId: string | null;
|
||||||
setSwarmId: (id: string | null) => void;
|
setSwarmId: (id: string | null, openDrawer?: boolean) => void;
|
||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
setAgentId: (id: string | null) => void;
|
setAgentId: (id: string | null) => void;
|
||||||
epicId: string | null;
|
epicId: string | null;
|
||||||
setEpicId: (id: string | null) => void;
|
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;
|
panel: PanelState;
|
||||||
togglePanel: () => void;
|
togglePanel: () => void;
|
||||||
drawer: DrawerState;
|
drawer: DrawerState;
|
||||||
|
|
@ -29,7 +38,8 @@ export interface UrlState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_VIEW: ViewType = 'social';
|
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_DRAWER: DrawerState = 'closed';
|
||||||
const DEFAULT_GRAPH_TAB: GraphTabType = 'flow';
|
const DEFAULT_GRAPH_TAB: GraphTabType = 'flow';
|
||||||
|
|
||||||
|
|
@ -38,12 +48,51 @@ const VALID_PANELS: PanelState[] = ['open', 'closed'];
|
||||||
const VALID_DRAWERS: DrawerState[] = ['open', 'closed'];
|
const VALID_DRAWERS: DrawerState[] = ['open', 'closed'];
|
||||||
const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview'];
|
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;
|
view: ViewType;
|
||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
swarmId: string | null;
|
swarmId: string | null;
|
||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
epicId: string | null;
|
epicId: string | null;
|
||||||
|
leftPanel: PanelState;
|
||||||
|
rightPanel: PanelState;
|
||||||
|
blockedOnly: boolean;
|
||||||
panel: PanelState;
|
panel: PanelState;
|
||||||
drawer: DrawerState;
|
drawer: DrawerState;
|
||||||
graphTab: GraphTabType;
|
graphTab: GraphTabType;
|
||||||
|
|
@ -58,10 +107,15 @@ export function parseUrlState(searchParams: URLSearchParams): {
|
||||||
const agentId = searchParams.get('agent');
|
const agentId = searchParams.get('agent');
|
||||||
const epicId = searchParams.get('epic');
|
const epicId = searchParams.get('epic');
|
||||||
|
|
||||||
const panelParam = searchParams.get('panel');
|
const leftPanelFromUrl = parsePanelValue(searchParams.get('left'));
|
||||||
const panel: PanelState = panelParam && VALID_PANELS.includes(panelParam as PanelState)
|
const rightPanelFromUrl = parsePanelValue(searchParams.get('right'));
|
||||||
? (panelParam as PanelState)
|
const legacyPanel = parsePanelValue(searchParams.get('panel'));
|
||||||
: DEFAULT_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 drawerParam = searchParams.get('drawer');
|
||||||
const drawer: DrawerState = drawerParam && VALID_DRAWERS.includes(drawerParam as DrawerState)
|
const drawer: DrawerState = drawerParam && VALID_DRAWERS.includes(drawerParam as DrawerState)
|
||||||
|
|
@ -73,7 +127,7 @@ export function parseUrlState(searchParams: URLSearchParams): {
|
||||||
? (graphTabParam as GraphTabType)
|
? (graphTabParam as GraphTabType)
|
||||||
: DEFAULT_GRAPH_TAB;
|
: 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(
|
export function buildUrlParams(
|
||||||
|
|
@ -97,8 +151,27 @@ export function buildUrlParams(
|
||||||
export function useUrlState(): UrlState {
|
export function useUrlState(): UrlState {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
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 updateUrl = useCallback((updates: Record<string, string | null>) => {
|
||||||
const newUrl = buildUrlParams(searchParams, updates);
|
const newUrl = buildUrlParams(searchParams, updates);
|
||||||
|
|
@ -109,26 +182,55 @@ export function useUrlState(): UrlState {
|
||||||
updateUrl({ view: v });
|
updateUrl({ view: v });
|
||||||
}, [updateUrl]);
|
}, [updateUrl]);
|
||||||
|
|
||||||
const setTaskId = useCallback((id: string | null) => {
|
const setLeftPanel = useCallback((next: PanelState) => {
|
||||||
updateUrl({ task: id, panel: id ? 'open' : null });
|
updateUrl({ left: next });
|
||||||
}, [updateUrl]);
|
}, [updateUrl]);
|
||||||
|
|
||||||
const setSwarmId = useCallback((id: string | null) => {
|
const toggleLeftPanel = useCallback(() => {
|
||||||
updateUrl({ swarm: id, panel: id ? 'open' : null });
|
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]);
|
}, [updateUrl]);
|
||||||
|
|
||||||
const setAgentId = useCallback((id: string | null) => {
|
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]);
|
}, [updateUrl]);
|
||||||
|
|
||||||
const setEpicId = useCallback((id: string | null) => {
|
const setEpicId = useCallback((id: string | null) => {
|
||||||
updateUrl({ epic: id });
|
updateUrl({ epic: id });
|
||||||
}, [updateUrl]);
|
}, [updateUrl]);
|
||||||
|
|
||||||
const togglePanel = useCallback(() => {
|
const togglePanel = toggleRightPanel;
|
||||||
const newPanel = state.panel === 'open' ? 'closed' : 'open';
|
|
||||||
updateUrl({ panel: newPanel });
|
|
||||||
}, [state.panel, updateUrl]);
|
|
||||||
|
|
||||||
const setDrawer = useCallback((state: DrawerState) => {
|
const setDrawer = useCallback((state: DrawerState) => {
|
||||||
updateUrl({ drawer: state });
|
updateUrl({ drawer: state });
|
||||||
|
|
@ -139,7 +241,7 @@ export function useUrlState(): UrlState {
|
||||||
}, [updateUrl]);
|
}, [updateUrl]);
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
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]);
|
}, [updateUrl]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -153,7 +255,16 @@ export function useUrlState(): UrlState {
|
||||||
setAgentId,
|
setAgentId,
|
||||||
epicId: state.epicId,
|
epicId: state.epicId,
|
||||||
setEpicId,
|
setEpicId,
|
||||||
panel: state.panel,
|
leftPanel: state.leftPanel,
|
||||||
|
setLeftPanel,
|
||||||
|
toggleLeftPanel,
|
||||||
|
rightPanel: state.rightPanel,
|
||||||
|
setRightPanel,
|
||||||
|
toggleRightPanel,
|
||||||
|
blockedOnly: state.blockedOnly,
|
||||||
|
setBlockedOnly,
|
||||||
|
toggleBlockedOnly,
|
||||||
|
panel: state.rightPanel,
|
||||||
togglePanel,
|
togglePanel,
|
||||||
drawer: state.drawer,
|
drawer: state.drawer,
|
||||||
setDrawer,
|
setDrawer,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export interface AgentRecord {
|
||||||
version: number;
|
version: number;
|
||||||
rig?: string;
|
rig?: string;
|
||||||
role_type?: string;
|
role_type?: string;
|
||||||
|
swarm_id?: string;
|
||||||
|
current_task?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterAgentInput {
|
export interface RegisterAgentInput {
|
||||||
|
|
@ -179,11 +181,22 @@ function validateRole(value: string): AgentCommandError | null {
|
||||||
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
|
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
|
||||||
// Extract role from labels if role_type is not set
|
// Extract role from labels if role_type is not set
|
||||||
let role = bdAgent.role_type || 'agent';
|
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:'));
|
const roleLabel = bdAgent.labels.find((l: string) => l.startsWith('role:'));
|
||||||
if (roleLabel) {
|
if (roleLabel) {
|
||||||
role = roleLabel.split(':')[1];
|
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;
|
let rig = bdAgent.rig;
|
||||||
|
|
@ -204,6 +217,8 @@ function mapBdAgentToRecord(bdAgent: any): AgentRecord {
|
||||||
version: 1,
|
version: 1,
|
||||||
rig,
|
rig,
|
||||||
role_type: bdAgent.role_type,
|
role_type: bdAgent.role_type,
|
||||||
|
swarm_id: swarmId,
|
||||||
|
current_task: currentTask,
|
||||||
};
|
};
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,16 @@ export async function runBdCommand(
|
||||||
|
|
||||||
const shellCommand = buildShellCommand(command, args);
|
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, {
|
const { stdout, stderr } = await deps.exec(shellCommand, {
|
||||||
cwd,
|
cwd,
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
env: deps.env,
|
env: { ...deps.env, Path: enhancedPath, PATH: enhancedPath },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,115 @@
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
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 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[]> {
|
export async function getArchetypes(): Promise<AgentArchetype[]> {
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(ARCHE_DIR, { recursive: true });
|
await fs.mkdir(ARCHE_DIR, { recursive: true });
|
||||||
// Minimal mock for now to pass test
|
const files = await fs.readdir(ARCHE_DIR);
|
||||||
return [];
|
|
||||||
|
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) {
|
} 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,20 +74,21 @@ function extractAgents(bead: BeadIssue): AgentInfo[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
|
export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
|
||||||
|
const taskBeads = beads.filter((bead) => bead.issue_type !== 'epic');
|
||||||
const beadMap = new Map<string, BeadIssue>();
|
const beadMap = new Map<string, BeadIssue>();
|
||||||
for (const bead of beads) {
|
for (const bead of taskBeads) {
|
||||||
beadMap.set(bead.id, bead);
|
beadMap.set(bead.id, bead);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocksIncoming = new Map<string, string[]>();
|
const blocksIncoming = new Map<string, string[]>();
|
||||||
const blocksOutgoing = new Map<string, string[]>();
|
const blocksOutgoing = new Map<string, string[]>();
|
||||||
|
|
||||||
for (const bead of beads) {
|
for (const bead of taskBeads) {
|
||||||
blocksIncoming.set(bead.id, []);
|
blocksIncoming.set(bead.id, []);
|
||||||
blocksOutgoing.set(bead.id, []);
|
blocksOutgoing.set(bead.id, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const bead of beads) {
|
for (const bead of taskBeads) {
|
||||||
for (const dep of bead.dependencies) {
|
for (const dep of bead.dependencies) {
|
||||||
if (dep.type === 'blocks' && beadMap.has(dep.target)) {
|
if (dep.type === 'blocks' && beadMap.has(dep.target)) {
|
||||||
const outgoing = blocksOutgoing.get(bead.id) ?? [];
|
const outgoing = blocksOutgoing.get(bead.id) ?? [];
|
||||||
|
|
@ -101,15 +102,30 @@ export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return beads.map((bead) => ({
|
return taskBeads.map((bead) => {
|
||||||
id: bead.id,
|
const explicitStatus = mapStatus(bead.status);
|
||||||
title: bead.title,
|
const incomingBlockers = blocksIncoming.get(bead.id) ?? [];
|
||||||
status: mapStatus(bead.status),
|
const hasUnresolvedIncomingBlockers = incomingBlockers.some((blockerId) => {
|
||||||
blocks: blocksOutgoing.get(bead.id) ?? [], // what I block (amber)
|
const blocker = beadMap.get(blockerId);
|
||||||
unblocks: blocksIncoming.get(bead.id) ?? [], // what blocks me (rose)
|
return blocker ? blocker.status !== 'closed' && blocker.status !== 'tombstone' : false;
|
||||||
agents: extractAgents(bead),
|
});
|
||||||
lastActivity: new Date(bead.updated_at),
|
|
||||||
priority: mapPriority(bead.priority),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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 { interpolate, useCurrentFrame, useVideoConfig, AbsoluteFill, Sequence, Img, staticFile, spring } from 'remotion';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
import { loadFont } from '@remotion/google-fonts/inter';
|
||||||
import { Background } from './components/Background';
|
import { Background } from './components/Background';
|
||||||
import { TerminalScene } from './components/TerminalScene';
|
import { TerminalScene } from './components/TerminalScene';
|
||||||
import { TimelineScene } from './components/TimelineScene';
|
import { TimelineScene } from './components/TimelineScene';
|
||||||
|
|
|
||||||
|
|
@ -14,127 +14,119 @@ function createMockSearchParams(params: Record<string, string | null> = {}) {
|
||||||
|
|
||||||
describe('useUrlState', () => {
|
describe('useUrlState', () => {
|
||||||
describe('parseUrlState', () => {
|
describe('parseUrlState', () => {
|
||||||
it('should return defaults for empty params', () => {
|
it('returns defaults for empty params', () => {
|
||||||
const sp = createMockSearchParams({});
|
const state = parseUrlState(createMockSearchParams({}));
|
||||||
const state = parseUrlState(sp);
|
|
||||||
assert.deepStrictEqual(state, {
|
assert.deepStrictEqual(state, {
|
||||||
view: 'social',
|
view: 'social',
|
||||||
taskId: null,
|
taskId: null,
|
||||||
swarmId: null,
|
swarmId: null,
|
||||||
panel: 'closed',
|
agentId: null,
|
||||||
|
epicId: null,
|
||||||
|
leftPanel: 'open',
|
||||||
|
rightPanel: 'open',
|
||||||
|
blockedOnly: false,
|
||||||
|
panel: 'open',
|
||||||
|
drawer: 'closed',
|
||||||
graphTab: 'flow',
|
graphTab: 'flow',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parse view=social', () => {
|
it('parses all core identifiers', () => {
|
||||||
const sp = createMockSearchParams({ view: 'social' });
|
const state = parseUrlState(
|
||||||
const state = parseUrlState(sp);
|
createMockSearchParams({
|
||||||
assert.strictEqual(state.view, 'social');
|
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', () => {
|
it('parses explicit left/right panel params', () => {
|
||||||
const sp = createMockSearchParams({ view: 'graph' });
|
const state = parseUrlState(createMockSearchParams({ left: 'closed', right: 'open' }));
|
||||||
const state = parseUrlState(sp);
|
assert.strictEqual(state.leftPanel, 'closed');
|
||||||
assert.strictEqual(state.view, 'graph');
|
assert.strictEqual(state.rightPanel, 'open');
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
assert.strictEqual(state.panel, 'open');
|
assert.strictEqual(state.panel, 'open');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parse graphTab', () => {
|
it('uses legacy panel param when right is absent', () => {
|
||||||
const sp = createMockSearchParams({ view: 'graph', task: 'bb-buff.1', graphTab: 'flow' });
|
const state = parseUrlState(createMockSearchParams({ panel: 'closed' }));
|
||||||
const state = parseUrlState(sp);
|
assert.strictEqual(state.rightPanel, 'closed');
|
||||||
assert.strictEqual(state.graphTab, 'flow');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fall back to default for invalid panel values', () => {
|
|
||||||
const sp = createMockSearchParams({ panel: 'invalid' });
|
|
||||||
const state = parseUrlState(sp);
|
|
||||||
assert.strictEqual(state.panel, 'closed');
|
assert.strictEqual(state.panel, 'closed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fall back to default for invalid graphTab values', () => {
|
it('prefers right param over legacy panel when both are present', () => {
|
||||||
const sp = createMockSearchParams({ graphTab: 'invalid' });
|
const state = parseUrlState(createMockSearchParams({ right: 'open', panel: 'closed' }));
|
||||||
const state = parseUrlState(sp);
|
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');
|
assert.strictEqual(state.graphTab, 'flow');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildUrlParams', () => {
|
describe('buildUrlParams', () => {
|
||||||
it('should build URL with view param', () => {
|
it('builds URL with view param', () => {
|
||||||
const sp = createMockSearchParams({});
|
const url = buildUrlParams(createMockSearchParams({}), { view: 'social' });
|
||||||
const url = buildUrlParams(sp, { view: 'social' });
|
|
||||||
assert.strictEqual(url, '/?view=social');
|
assert.strictEqual(url, '/?view=social');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add task param', () => {
|
it('adds task param', () => {
|
||||||
const sp = createMockSearchParams({ view: 'social' });
|
const url = buildUrlParams(createMockSearchParams({ view: 'social' }), { task: 'bb-vt.2.1' });
|
||||||
const url = buildUrlParams(sp, { task: 'bb-buff.1' });
|
assert.strictEqual(url, '/?view=social&task=bb-vt.2.1');
|
||||||
assert.strictEqual(url, '/?view=social&task=bb-buff.1');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove param when null', () => {
|
it('removes params when value is null', () => {
|
||||||
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1' });
|
const url = buildUrlParams(createMockSearchParams({ view: 'social', task: 'bb-vt.2.1' }), { task: null });
|
||||||
const url = buildUrlParams(sp, { task: null });
|
|
||||||
assert.strictEqual(url, '/?view=social');
|
assert.strictEqual(url, '/?view=social');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should toggle panel', () => {
|
it('supports dual right/panel sync updates', () => {
|
||||||
const sp = createMockSearchParams({ view: 'social', panel: 'closed' });
|
const url = buildUrlParams(createMockSearchParams({ view: 'social' }), { right: 'open', panel: 'open' });
|
||||||
const url = buildUrlParams(sp, { panel: 'open' });
|
assert.strictEqual(url, '/?view=social&right=open&panel=open');
|
||||||
assert.strictEqual(url, '/?view=social&panel=open');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return root for empty params', () => {
|
it('returns root for empty params', () => {
|
||||||
const sp = createMockSearchParams({});
|
const url = buildUrlParams(createMockSearchParams({}), {});
|
||||||
const url = buildUrlParams(sp, {});
|
|
||||||
assert.strictEqual(url, '/');
|
assert.strictEqual(url, '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear all selection params', () => {
|
it('clears selection params and keeps view', () => {
|
||||||
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1', swarm: 'buff', panel: 'open', graphTab: 'flow' });
|
const url = buildUrlParams(
|
||||||
const url = buildUrlParams(sp, { task: null, swarm: null, panel: null, graphTab: null });
|
createMockSearchParams({ view: 'social', task: 'bb-vt.2.1', swarm: 'bb-vt', right: 'open', panel: 'open' }),
|
||||||
assert.strictEqual(url, '/?view=social');
|
{ task: null, swarm: null, right: 'closed', panel: 'closed' },
|
||||||
|
);
|
||||||
|
assert.strictEqual(url, '/?view=social&right=closed&panel=closed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('module import', () => {
|
describe('module import', () => {
|
||||||
it('should load the module without error', async () => {
|
it('loads the module without error', async () => {
|
||||||
try {
|
await import('../../src/hooks/use-url-state');
|
||||||
await import('../../src/hooks/use-url-state');
|
assert.ok(true);
|
||||||
assert.ok(true, 'Module loaded');
|
|
||||||
} catch (err) {
|
|
||||||
assert.fail(err as Error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue