fix: wire conversation panel to DAG nodes with toggle support
- Add MessageSquare icon to GraphNodeCard; prop-thread onConversationOpen and selectedTaskId through WorkflowGraph node data (no useUrlState inside ReactFlow nodes — avoids context/timing issues) - Fix ContextualRightPanel: check taskId before epicId so clicking the conversation icon always opens ThreadDrawer even when an epic filter is active - setEpicId now clears task from URL so selecting an epic resets any open conversation thread - handleGraphSelect toggles: second click on same node calls setTaskId(null) closing the right panel - Add onSelect to WorkflowGraph flowModel deps to prevent stale callbacks - Fix ContextualRightPanel onClose no-ops: wired to setTaskId(null) / setSwarmId(null) so back button works - Right panel always visible (removed panel==='open' gate in UnifiedShell) - SmartDag task grid: horizontal scroll, fixed-width cards, hideClosed=true - Add <Suspense> in page.tsx for useSearchParams compatibility - Enable dolt auto-start in .beads/config.yaml - Add 14 static analysis tests (graph-node-conversation.test.tsx) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cb83fd92a9
commit
861ae89491
11 changed files with 1279 additions and 1174 deletions
|
|
@ -1,67 +1,5 @@
|
||||||
# Beads Configuration File
|
sync:
|
||||||
# This file configures default behavior for all bd commands in this repository
|
mode: dolt-native
|
||||||
# All settings can also be set via environment variables (BD_* prefix)
|
|
||||||
# or overridden with command-line flags
|
|
||||||
|
|
||||||
# Issue prefix for this repository (used by bd init)
|
dolt:
|
||||||
# If not set, bd init will auto-detect from directory name
|
auto-start: true
|
||||||
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
|
|
||||||
# issue-prefix: ""
|
|
||||||
|
|
||||||
# Use no-db mode: load from JSONL, no SQLite, write back after each command
|
|
||||||
# When true, bd will use .beads/issues.jsonl as the source of truth
|
|
||||||
# instead of SQLite database
|
|
||||||
# no-db: false
|
|
||||||
|
|
||||||
# Disable daemon for RPC communication (forces direct database access)
|
|
||||||
# no-daemon: false
|
|
||||||
|
|
||||||
# Disable auto-flush of database to JSONL after mutations
|
|
||||||
# no-auto-flush: false
|
|
||||||
|
|
||||||
# Disable auto-import from JSONL when it's newer than database
|
|
||||||
# no-auto-import: false
|
|
||||||
|
|
||||||
# Enable JSON output by default
|
|
||||||
# json: false
|
|
||||||
|
|
||||||
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
|
|
||||||
# actor: ""
|
|
||||||
|
|
||||||
# Path to database (overridden by BEADS_DB or --db)
|
|
||||||
# db: ""
|
|
||||||
|
|
||||||
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
|
|
||||||
# auto-start-daemon: true
|
|
||||||
|
|
||||||
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
|
|
||||||
# flush-debounce: "5s"
|
|
||||||
|
|
||||||
# Export events (audit trail) to .beads/events.jsonl on each flush/sync
|
|
||||||
# When enabled, new events are appended incrementally using a high-water mark.
|
|
||||||
# Use 'bd export --events' to trigger manually regardless of this setting.
|
|
||||||
# events-export: false
|
|
||||||
|
|
||||||
# Git branch for beads commits (bd sync will commit to this branch)
|
|
||||||
# IMPORTANT: Set this for team projects so all clones use the same sync branch.
|
|
||||||
# This setting persists across clones (unlike database config which is gitignored).
|
|
||||||
# Can also use BEADS_SYNC_BRANCH env var for local override.
|
|
||||||
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
|
|
||||||
# sync-branch: "beads-sync"
|
|
||||||
|
|
||||||
# Multi-repo configuration (experimental - bd-307)
|
|
||||||
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
|
||||||
# repos:
|
|
||||||
# primary: "." # Primary repo (where this database lives)
|
|
||||||
# additional: # Additional repos to hydrate from (read-only)
|
|
||||||
# - ~/beads-planning # Personal planning repo
|
|
||||||
# - ~/work-planning # Work planning repo
|
|
||||||
|
|
||||||
# Integration settings (access with 'bd config get/set')
|
|
||||||
# These are stored in the database, not in this file:
|
|
||||||
# - jira.url
|
|
||||||
# - jira.project
|
|
||||||
# - linear.url
|
|
||||||
# - linear.api-key
|
|
||||||
# - github.org
|
|
||||||
# - github.repo
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx",
|
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/components/graph/graph-node-conversation.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx",
|
||||||
"video": "remotion preview src/video/index.ts",
|
"video": "remotion preview src/video/index.ts",
|
||||||
"video:render": "remotion render src/video/index.ts Main out/video.mp4",
|
"video:render": "remotion render src/video/index.ts Main out/video.mp4",
|
||||||
"video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60"
|
"video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { UnifiedShell } from '../components/shared/unified-shell';
|
import { UnifiedShell } from '../components/shared/unified-shell';
|
||||||
import { readIssuesForScope } from '../lib/aggregate-read';
|
import { readIssuesForScope } from '../lib/aggregate-read';
|
||||||
import { resolveProjectScope } from '../lib/project-scope';
|
import { resolveProjectScope } from '../lib/project-scope';
|
||||||
|
|
@ -28,12 +29,14 @@ export default async function Page({ searchParams }: PageProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnifiedShell
|
<Suspense>
|
||||||
issues={issues}
|
<UnifiedShell
|
||||||
projectRoot={scope.selected.root}
|
issues={issues}
|
||||||
projectScopeKey={scope.selected.key}
|
projectRoot={scope.selected.root}
|
||||||
projectScopeOptions={scope.options}
|
projectScopeKey={scope.selected.key}
|
||||||
projectScopeMode={scope.mode}
|
projectScopeOptions={scope.options}
|
||||||
/>
|
projectScopeMode={scope.mode}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { SwarmCommandFeed } from './swarm-command-feed';
|
||||||
import { ThreadDrawer } from '../shared/thread-drawer';
|
import { ThreadDrawer } from '../shared/thread-drawer';
|
||||||
import { MissionInspector } from '../mission/mission-inspector';
|
import { MissionInspector } from '../mission/mission-inspector';
|
||||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||||
|
import { useUrlState } from '../../hooks/use-url-state';
|
||||||
|
|
||||||
export interface ContextualRightPanelProps {
|
export interface ContextualRightPanelProps {
|
||||||
epicId?: string | null;
|
epicId?: string | null;
|
||||||
|
|
@ -17,23 +18,16 @@ export interface ContextualRightPanelProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectRoot }: ContextualRightPanelProps) {
|
export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectRoot }: ContextualRightPanelProps) {
|
||||||
if (epicId) {
|
const { setTaskId } = useUrlState();
|
||||||
return (
|
|
||||||
<SwarmCommandFeed
|
|
||||||
epicId={epicId}
|
|
||||||
issues={issues}
|
|
||||||
projectRoot={projectRoot}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Task conversation takes priority — user explicitly clicked the conversation icon
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
const selectedIssue = issues.find(i => i.id === taskId) ?? null;
|
const selectedIssue = issues.find(i => i.id === taskId) ?? null;
|
||||||
return (
|
return (
|
||||||
<ThreadDrawer
|
<ThreadDrawer
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
embedded={true}
|
embedded={true}
|
||||||
onClose={() => {}}
|
onClose={() => setTaskId(null)}
|
||||||
title={selectedIssue?.title ?? taskId}
|
title={selectedIssue?.title ?? taskId}
|
||||||
id={taskId}
|
id={taskId}
|
||||||
issue={selectedIssue}
|
issue={selectedIssue}
|
||||||
|
|
@ -43,6 +37,16 @@ export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectR
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (epicId) {
|
||||||
|
return (
|
||||||
|
<SwarmCommandFeed
|
||||||
|
epicId={epicId}
|
||||||
|
issues={issues}
|
||||||
|
projectRoot={projectRoot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (swarmId) {
|
if (swarmId) {
|
||||||
return (
|
return (
|
||||||
<SwarmIdBranch
|
<SwarmIdBranch
|
||||||
|
|
@ -63,6 +67,7 @@ export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectR
|
||||||
|
|
||||||
// Inner component so hooks can be called conditionally via component boundary
|
// Inner component so hooks can be called conditionally via component boundary
|
||||||
function SwarmIdBranch({ swarmId, projectRoot }: { swarmId: string; projectRoot: string }) {
|
function SwarmIdBranch({ swarmId, projectRoot }: { swarmId: string; projectRoot: string }) {
|
||||||
|
const { setSwarmId } = useUrlState();
|
||||||
const { swarms } = useSwarmList(projectRoot);
|
const { swarms } = useSwarmList(projectRoot);
|
||||||
const swarm = swarms.find(s => s.swarmId === swarmId);
|
const swarm = swarms.find(s => s.swarmId === swarmId);
|
||||||
// Fall back to swarmId as title while swarm list loads
|
// Fall back to swarmId as title while swarm list loads
|
||||||
|
|
@ -76,7 +81,7 @@ function SwarmIdBranch({ swarmId, projectRoot }: { swarmId: string; projectRoot:
|
||||||
missionTitle={missionTitle}
|
missionTitle={missionTitle}
|
||||||
projectRoot={projectRoot}
|
projectRoot={projectRoot}
|
||||||
assignedAgents={assignedAgents}
|
assignedAgents={assignedAgents}
|
||||||
onClose={() => {}}
|
onClose={() => setSwarmId(null)}
|
||||||
onAssign={async () => {}}
|
onAssign={async () => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,421 +1,441 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import { Loader2, ChevronDown, UserPlus, X } from 'lucide-react';
|
import { Loader2, ChevronDown, UserPlus, X, MessageSquare } from 'lucide-react';
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import type { AgentArchetype } from '../../lib/types-swarm';
|
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||||
|
|
||||||
/** Data payload for each custom ReactFlow node. */
|
/** Data payload for each custom ReactFlow node. */
|
||||||
export interface GraphNodeData {
|
export interface GraphNodeData {
|
||||||
/** Index signature required by ReactFlow's Node<Record<string, unknown>> constraint. */
|
/** Index signature required by ReactFlow's Node<Record<string, unknown>> constraint. */
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
/** Display title of the task/epic. */
|
/** Display title of the task/epic. */
|
||||||
title: string;
|
title: string;
|
||||||
/** Whether this is an epic or a regular issue. */
|
/** Whether this is an epic or a regular issue. */
|
||||||
kind: 'epic' | 'issue';
|
kind: 'epic' | 'issue';
|
||||||
/** Current workflow status. */
|
/** Current workflow status. */
|
||||||
status: BeadIssue['status'];
|
status: BeadIssue['status'];
|
||||||
/** Priority level (0 = highest). */
|
/** Priority level (0 = highest). */
|
||||||
priority: number;
|
priority: number;
|
||||||
/** Number of issues blocking this node. */
|
/** Number of issues blocking this node. */
|
||||||
blockedBy: number;
|
blockedBy: number;
|
||||||
/** Number of issues this node blocks. */
|
/** Number of issues this node blocks. */
|
||||||
blocks: number;
|
blocks: number;
|
||||||
/** Whether this node has zero open blockers and is actionable. */
|
/** Whether this node has zero open blockers and is actionable. */
|
||||||
isActionable: boolean;
|
isActionable: boolean;
|
||||||
/** Whether this node is part of a dependency cycle. */
|
/** Whether this node is part of a dependency cycle. */
|
||||||
isCycleNode: boolean;
|
isCycleNode: boolean;
|
||||||
/** Whether this node should appear dimmed (not in selected chain). */
|
/** Whether this node should appear dimmed (not in selected chain). */
|
||||||
isDimmed: boolean;
|
isDimmed: boolean;
|
||||||
/** Tooltip lines describing blocker details for hover display. */
|
/** Tooltip lines describing blocker details for hover display. */
|
||||||
blockerTooltipLines: string[];
|
blockerTooltipLines: string[];
|
||||||
/** Labels attached to this node, including agent assignments (agent:archetype-id). */
|
/** Labels attached to this node, including agent assignments (agent:archetype-id). */
|
||||||
labels: string[];
|
labels: string[];
|
||||||
/** Available agent archetypes for assignment. */
|
/** Available agent archetypes for assignment. */
|
||||||
archetypes?: AgentArchetype[];
|
archetypes?: AgentArchetype[];
|
||||||
}
|
/** ID of the currently selected task (for conversation icon highlight). */
|
||||||
|
selectedTaskId?: string;
|
||||||
function getAssignedArchetypes(labels: string[], archetypes: AgentArchetype[]): AgentArchetype[] {
|
/** Opens the conversation panel for this node. Passed from UnifiedShell via WorkflowGraph. */
|
||||||
const ids = labels.filter(l => l.startsWith('agent:')).map(l => l.replace('agent:', ''));
|
onConversationOpen?: (id: string) => void;
|
||||||
return archetypes.filter(a => ids.includes(a.id));
|
}
|
||||||
}
|
|
||||||
|
function getAssignedArchetypes(labels: string[], archetypes: AgentArchetype[]): AgentArchetype[] {
|
||||||
/**
|
const ids = labels.filter(l => l.startsWith('agent:')).map(l => l.replace('agent:', ''));
|
||||||
* Returns the Tailwind background color class for a status dot indicator.
|
return archetypes.filter(a => ids.includes(a.id));
|
||||||
*/
|
}
|
||||||
function statusDot(status: BeadIssue['status']): string {
|
|
||||||
switch (status) {
|
/**
|
||||||
case 'open':
|
* Returns the Tailwind background color class for a status dot indicator.
|
||||||
return 'bg-sky-400';
|
*/
|
||||||
case 'in_progress':
|
function statusDot(status: BeadIssue['status']): string {
|
||||||
return 'bg-amber-400';
|
switch (status) {
|
||||||
case 'blocked':
|
case 'open':
|
||||||
return 'bg-rose-500';
|
return 'bg-sky-400';
|
||||||
case 'deferred':
|
case 'in_progress':
|
||||||
return 'bg-slate-400';
|
return 'bg-amber-400';
|
||||||
case 'closed':
|
case 'blocked':
|
||||||
return 'bg-emerald-400';
|
return 'bg-rose-500';
|
||||||
case 'pinned':
|
case 'deferred':
|
||||||
return 'bg-violet-400';
|
return 'bg-slate-400';
|
||||||
case 'hooked':
|
case 'closed':
|
||||||
return 'bg-orange-400';
|
return 'bg-emerald-400';
|
||||||
default:
|
case 'pinned':
|
||||||
return 'bg-zinc-500';
|
return 'bg-violet-400';
|
||||||
}
|
case 'hooked':
|
||||||
}
|
return 'bg-orange-400';
|
||||||
|
default:
|
||||||
/**
|
return 'bg-zinc-500';
|
||||||
* Returns the base card style class based on the node kind (epic vs issue).
|
}
|
||||||
*/
|
}
|
||||||
function nodeStyle(kind: GraphNodeData['kind']): string {
|
|
||||||
return kind === 'epic'
|
/**
|
||||||
? 'bg-[var(--graph-node-epic)] border-[var(--accent-info)]/30'
|
* Returns the base card style class based on the node kind (epic vs issue).
|
||||||
: 'bg-[var(--graph-node-default)] border-[var(--border-subtle)]';
|
*/
|
||||||
}
|
function nodeStyle(kind: GraphNodeData['kind']): string {
|
||||||
|
return kind === 'epic'
|
||||||
/**
|
? 'bg-[var(--graph-node-epic)] border-[var(--accent-info)]/30'
|
||||||
* Custom ReactFlow node component with:
|
: 'bg-[var(--graph-node-default)] border-[var(--border-subtle)]';
|
||||||
* - Status-aware styling (green glow for actionable, red ring for cycles)
|
}
|
||||||
* - Hover tooltip showing blocker details or "Ready to work"
|
|
||||||
* - Pulse animation on selection
|
/**
|
||||||
* - Dim effect when not in the selected dependency chain
|
* Custom ReactFlow node component with:
|
||||||
* - Agent archetype assignment badges and dropdown
|
* - Status-aware styling (green glow for actionable, red ring for cycles)
|
||||||
*/
|
* - Hover tooltip showing blocker details or "Ready to work"
|
||||||
export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeData>>) {
|
* - Pulse animation on selection
|
||||||
const [hovered, setHovered] = useState(false);
|
* - Dim effect when not in the selected dependency chain
|
||||||
const [isAssigning, setIsAssigning] = useState(false);
|
* - Agent archetype assignment badges and dropdown
|
||||||
const [assignError, setAssignError] = useState<string | null>(null);
|
*/
|
||||||
const [assignSuccess, setAssignSuccess] = useState<string | null>(null);
|
export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeData>>) {
|
||||||
|
const onConversationOpen = data.onConversationOpen as ((id: string) => void) | undefined;
|
||||||
// Local state for labels with optimistic updates
|
const isConvOpen = (data.selectedTaskId as string | undefined) === id;
|
||||||
const [localLabels, setLocalLabels] = useState<string[]>(data.labels ?? []);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [isAssigning, setIsAssigning] = useState(false);
|
||||||
// Track pending optimistic labels to prevent SSE overwrites
|
const [assignError, setAssignError] = useState<string | null>(null);
|
||||||
const pendingOptimisticLabels = useRef<Set<string>>(new Set());
|
const [assignSuccess, setAssignSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
// Sync local labels when parent data changes, but preserve pending optimistic updates
|
// Local state for labels with optimistic updates
|
||||||
useEffect(() => {
|
const [localLabels, setLocalLabels] = useState<string[]>(data.labels ?? []);
|
||||||
const serverLabels = data.labels ?? [];
|
|
||||||
const pending = pendingOptimisticLabels.current;
|
// Track pending optimistic labels to prevent SSE overwrites
|
||||||
if (pending.size === 0) {
|
const pendingOptimisticLabels = useRef<Set<string>>(new Set());
|
||||||
setLocalLabels(serverLabels);
|
|
||||||
} else {
|
// Sync local labels when parent data changes, but preserve pending optimistic updates
|
||||||
// Merge: include pending labels that aren't yet in server data
|
useEffect(() => {
|
||||||
const merged = new Set([...serverLabels, ...pending]);
|
const serverLabels = data.labels ?? [];
|
||||||
setLocalLabels(Array.from(merged));
|
const pending = pendingOptimisticLabels.current;
|
||||||
}
|
if (pending.size === 0) {
|
||||||
}, [data.labels]);
|
setLocalLabels(serverLabels);
|
||||||
|
} else {
|
||||||
const archetypes = data.archetypes ?? [];
|
// Merge: include pending labels that aren't yet in server data
|
||||||
const assignedArchetypes = getAssignedArchetypes(localLabels, archetypes);
|
const merged = new Set([...serverLabels, ...pending]);
|
||||||
const isClosed = data.status === 'closed';
|
setLocalLabels(Array.from(merged));
|
||||||
|
}
|
||||||
const handleAssignAgent = async (archetypeId: string) => {
|
}, [data.labels]);
|
||||||
// Don't do anything if this archetype is already assigned
|
|
||||||
const labelToAdd = `agent:${archetypeId}`;
|
const archetypes = data.archetypes ?? [];
|
||||||
if (assignedArchetypes.some(a => a.id === archetypeId)) {
|
const assignedArchetypes = getAssignedArchetypes(localLabels, archetypes);
|
||||||
return;
|
const isClosed = data.status === 'closed';
|
||||||
}
|
|
||||||
|
const handleAssignAgent = async (archetypeId: string) => {
|
||||||
setIsAssigning(true);
|
// Don't do anything if this archetype is already assigned
|
||||||
setAssignError(null);
|
const labelToAdd = `agent:${archetypeId}`;
|
||||||
setAssignSuccess(null);
|
if (assignedArchetypes.some(a => a.id === archetypeId)) {
|
||||||
|
return;
|
||||||
// Track the new label as pending
|
}
|
||||||
pendingOptimisticLabels.current.add(labelToAdd);
|
|
||||||
|
setIsAssigning(true);
|
||||||
// Get current agent labels to remove (single archetype constraint)
|
setAssignError(null);
|
||||||
const currentAgentLabels = localLabels.filter(l => l.startsWith('agent:'));
|
setAssignSuccess(null);
|
||||||
|
|
||||||
// Optimistic update: remove all agent: labels, add new one
|
// Track the new label as pending
|
||||||
const previousLabels = localLabels;
|
pendingOptimisticLabels.current.add(labelToAdd);
|
||||||
setLocalLabels(prev => [...prev.filter(l => !l.startsWith('agent:')), labelToAdd]);
|
|
||||||
|
// Get current agent labels to remove (single archetype constraint)
|
||||||
try {
|
const currentAgentLabels = localLabels.filter(l => l.startsWith('agent:'));
|
||||||
// First remove existing agent labels (if any)
|
|
||||||
if (currentAgentLabels.length > 0) {
|
// Optimistic update: remove all agent: labels, add new one
|
||||||
for (const existingLabel of currentAgentLabels) {
|
const previousLabels = localLabels;
|
||||||
const existingArchetypeId = existingLabel.replace('agent:', '');
|
setLocalLabels(prev => [...prev.filter(l => !l.startsWith('agent:')), labelToAdd]);
|
||||||
await fetch('/api/swarm/prep', {
|
|
||||||
method: 'DELETE',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// First remove existing agent labels (if any)
|
||||||
body: JSON.stringify({ beadId: id, archetypeId: existingArchetypeId }),
|
if (currentAgentLabels.length > 0) {
|
||||||
});
|
for (const existingLabel of currentAgentLabels) {
|
||||||
}
|
const existingArchetypeId = existingLabel.replace('agent:', '');
|
||||||
}
|
await fetch('/api/swarm/prep', {
|
||||||
|
method: 'DELETE',
|
||||||
// Then add the new label
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const response = await fetch('/api/swarm/prep', {
|
body: JSON.stringify({ beadId: id, archetypeId: existingArchetypeId }),
|
||||||
method: 'POST',
|
});
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}
|
||||||
body: JSON.stringify({ beadId: id, archetypeId }),
|
}
|
||||||
});
|
|
||||||
|
// Then add the new label
|
||||||
if (!response.ok) {
|
const response = await fetch('/api/swarm/prep', {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
method: 'POST',
|
||||||
throw new Error(errorData.error ?? 'Failed to assign agent');
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
body: JSON.stringify({ beadId: id, archetypeId }),
|
||||||
|
});
|
||||||
const archetype = archetypes.find(a => a.id === archetypeId);
|
|
||||||
setAssignSuccess(`Assigned ${archetype?.name ?? archetypeId}`);
|
if (!response.ok) {
|
||||||
setTimeout(() => setAssignSuccess(null), 2000);
|
const errorData = await response.json().catch(() => ({}));
|
||||||
} catch (err) {
|
throw new Error(errorData.error ?? 'Failed to assign agent');
|
||||||
// Revert on error - restore previous labels
|
}
|
||||||
pendingOptimisticLabels.current.delete(labelToAdd);
|
|
||||||
setLocalLabels(previousLabels);
|
const archetype = archetypes.find(a => a.id === archetypeId);
|
||||||
setAssignError(err instanceof Error ? err.message : 'Failed to assign agent');
|
setAssignSuccess(`Assigned ${archetype?.name ?? archetypeId}`);
|
||||||
setTimeout(() => setAssignError(null), 3000);
|
setTimeout(() => setAssignSuccess(null), 2000);
|
||||||
} finally {
|
} catch (err) {
|
||||||
pendingOptimisticLabels.current.delete(labelToAdd);
|
// Revert on error - restore previous labels
|
||||||
setIsAssigning(false);
|
pendingOptimisticLabels.current.delete(labelToAdd);
|
||||||
}
|
setLocalLabels(previousLabels);
|
||||||
};
|
setAssignError(err instanceof Error ? err.message : 'Failed to assign agent');
|
||||||
|
setTimeout(() => setAssignError(null), 3000);
|
||||||
const handleUnassignAgent = async (archetypeId: string) => {
|
} finally {
|
||||||
setIsAssigning(true);
|
pendingOptimisticLabels.current.delete(labelToAdd);
|
||||||
setAssignError(null);
|
setIsAssigning(false);
|
||||||
setAssignSuccess(null);
|
}
|
||||||
|
};
|
||||||
// Optimistic update
|
|
||||||
const labelToRemove = `agent:${archetypeId}`;
|
const handleUnassignAgent = async (archetypeId: string) => {
|
||||||
setLocalLabels(prev => prev.filter(l => l !== labelToRemove));
|
setIsAssigning(true);
|
||||||
|
setAssignError(null);
|
||||||
try {
|
setAssignSuccess(null);
|
||||||
const response = await fetch('/api/swarm/prep', {
|
|
||||||
method: 'DELETE',
|
// Optimistic update
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const labelToRemove = `agent:${archetypeId}`;
|
||||||
body: JSON.stringify({ beadId: id, archetypeId }),
|
setLocalLabels(prev => prev.filter(l => l !== labelToRemove));
|
||||||
});
|
|
||||||
|
try {
|
||||||
if (!response.ok) {
|
const response = await fetch('/api/swarm/prep', {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
method: 'DELETE',
|
||||||
throw new Error(errorData.error ?? 'Failed to unassign agent');
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
body: JSON.stringify({ beadId: id, archetypeId }),
|
||||||
|
});
|
||||||
const archetype = archetypes.find(a => a.id === archetypeId);
|
|
||||||
setAssignSuccess(`Unassigned ${archetype?.name ?? archetypeId}`);
|
if (!response.ok) {
|
||||||
setTimeout(() => setAssignSuccess(null), 2000);
|
const errorData = await response.json().catch(() => ({}));
|
||||||
} catch (err) {
|
throw new Error(errorData.error ?? 'Failed to unassign agent');
|
||||||
// Revert on error
|
}
|
||||||
setLocalLabels(prev => [...prev, labelToRemove]);
|
|
||||||
setAssignError(err instanceof Error ? err.message : 'Failed to unassign agent');
|
const archetype = archetypes.find(a => a.id === archetypeId);
|
||||||
setTimeout(() => setAssignError(null), 3000);
|
setAssignSuccess(`Unassigned ${archetype?.name ?? archetypeId}`);
|
||||||
} finally {
|
setTimeout(() => setAssignSuccess(null), 2000);
|
||||||
setIsAssigning(false);
|
} catch (err) {
|
||||||
}
|
// Revert on error
|
||||||
};
|
setLocalLabels(prev => [...prev, labelToRemove]);
|
||||||
|
setAssignError(err instanceof Error ? err.message : 'Failed to unassign agent');
|
||||||
return (
|
setTimeout(() => setAssignError(null), 3000);
|
||||||
<div
|
} finally {
|
||||||
className="relative"
|
setIsAssigning(false);
|
||||||
onMouseEnter={() => setHovered(true)}
|
}
|
||||||
onMouseLeave={() => setHovered(false)}
|
};
|
||||||
>
|
|
||||||
<Handle type="target" position={Position.Left} className="!opacity-0" />
|
return (
|
||||||
|
<div
|
||||||
<div
|
className="relative"
|
||||||
className={`group w-[18.5rem] rounded-xl border px-3 py-3 text-left transition-all duration-300 ${nodeStyle(data.kind)} ${
|
onMouseEnter={() => setHovered(true)}
|
||||||
data.status === 'in_progress' ? 'border-l-2 border-l-amber-400/60' :
|
onMouseLeave={() => setHovered(false)}
|
||||||
data.status === 'blocked' ? 'border-l-2 border-l-rose-500/60' :
|
>
|
||||||
data.status === 'closed' ? 'border-l-2 border-l-emerald-400/40 opacity-60' : ''
|
<Handle type="target" position={Position.Left} className="!opacity-0" />
|
||||||
} ${
|
|
||||||
data.isCycleNode ? 'ring-2 ring-rose-400/55' : ''
|
<div
|
||||||
} ${
|
className={`group w-[18.5rem] rounded-xl border px-3 py-3 text-left transition-all duration-300 ${nodeStyle(data.kind)} ${
|
||||||
data.isActionable && !selected
|
data.status === 'in_progress' ? 'border-l-2 border-l-amber-400/60' :
|
||||||
? 'ring-1 ring-[var(--accent-success)]/30 shadow-[var(--glow-success)]'
|
data.status === 'blocked' ? 'border-l-2 border-l-rose-500/60' :
|
||||||
: ''
|
data.status === 'closed' ? 'border-l-2 border-l-emerald-400/40 opacity-60' : ''
|
||||||
} ${
|
} ${
|
||||||
selected
|
data.isCycleNode ? 'ring-2 ring-rose-400/55' : ''
|
||||||
? 'border-[var(--accent-info)]/50 shadow-[var(--shadow-lg)] ring-1 ring-[var(--accent-info)]/20 node-select-pulse'
|
} ${
|
||||||
: 'hover:border-[var(--border-default)] hover:shadow-[var(--shadow-md)]'
|
data.isActionable && !selected
|
||||||
} ${
|
? 'ring-1 ring-[var(--accent-success)]/30 shadow-[var(--glow-success)]'
|
||||||
data.isDimmed ? 'opacity-30' : 'opacity-100'
|
: ''
|
||||||
}`}
|
} ${
|
||||||
>
|
selected
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-[var(--border-subtle)] pb-1.5 mb-1.5">
|
? 'border-[var(--accent-info)]/50 shadow-[var(--shadow-lg)] ring-1 ring-[var(--accent-info)]/20 node-select-pulse'
|
||||||
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]/60">{id}</span>
|
: 'hover:border-[var(--border-default)] hover:shadow-[var(--shadow-md)]'
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
} ${
|
||||||
{assignedArchetypes.map((archetype) => (
|
data.isDimmed ? 'opacity-30' : 'opacity-100'
|
||||||
<span
|
}`}
|
||||||
key={archetype.id}
|
>
|
||||||
className="rounded-md px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--text-inverse)] ring-1"
|
<div className="flex items-center justify-between gap-2 border-b border-[var(--border-subtle)] pb-1.5 mb-1.5">
|
||||||
style={{
|
<div className="flex items-center gap-1.5">
|
||||||
backgroundColor: `${archetype.color}20`,
|
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]/60">{id}</span>
|
||||||
borderColor: `${archetype.color}40`,
|
<button
|
||||||
color: archetype.color,
|
type="button"
|
||||||
}}
|
onClick={(e) => { e.stopPropagation(); onConversationOpen?.(id); }}
|
||||||
>
|
className={`rounded p-0.5 transition-colors ${
|
||||||
{archetype.name}
|
isConvOpen
|
||||||
</span>
|
? 'text-[var(--accent-info)] bg-[var(--accent-info)]/15 ring-1 ring-[var(--accent-info)]/30'
|
||||||
))}
|
: 'text-sky-400/50 hover:text-[var(--accent-info)] hover:bg-[var(--alpha-white-low)]'
|
||||||
{data.isActionable ? (
|
}`}
|
||||||
<span className="rounded-md bg-[var(--status-ready)] px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--accent-success)] ring-1 ring-[var(--accent-success)]/20">
|
title={isConvOpen ? 'Close conversation' : 'Open conversation'}
|
||||||
Ready
|
>
|
||||||
</span>
|
<MessageSquare className="h-3 w-3" />
|
||||||
) : null}
|
</button>
|
||||||
{data.status === 'in_progress' ? (
|
</div>
|
||||||
<span className="rounded-md bg-[var(--status-in-progress)] px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--accent-warning)]">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
In Progress
|
{assignedArchetypes.map((archetype) => (
|
||||||
</span>
|
<span
|
||||||
) : data.status === 'blocked' ? (
|
key={archetype.id}
|
||||||
<span className="rounded-md bg-[var(--status-blocked)] px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--accent-danger)]">
|
className="rounded-md px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--text-inverse)] ring-1"
|
||||||
Blocked
|
style={{
|
||||||
</span>
|
backgroundColor: `${archetype.color}20`,
|
||||||
) : data.status === 'closed' ? (
|
borderColor: `${archetype.color}40`,
|
||||||
<span className="rounded-md bg-[var(--status-closed)] px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--accent-success)]">
|
color: archetype.color,
|
||||||
Done
|
}}
|
||||||
</span>
|
>
|
||||||
) : null}
|
{archetype.name}
|
||||||
<span className="text-[9px] font-bold uppercase tracking-wider text-[var(--text-tertiary)]/40">p{data.priority}</span>
|
</span>
|
||||||
<span className={`h-2 w-2 rounded-full ring-2 ring-[var(--alpha-black-medium)] ${statusDot(data.status)}`} />
|
))}
|
||||||
</div>
|
{data.isActionable ? (
|
||||||
</div>
|
<span className="rounded-md bg-[var(--status-ready)] px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--accent-success)] ring-1 ring-[var(--accent-success)]/20">
|
||||||
|
Ready
|
||||||
<p className={`text-[15px] font-bold leading-[1.2] tracking-tight text-[var(--text-primary)] group-hover:text-[var(--accent-info)] transition-colors ${data.status === 'closed' ? 'line-through opacity-70' : ''}`}>
|
</span>
|
||||||
{data.title}
|
) : null}
|
||||||
</p>
|
{data.status === 'in_progress' ? (
|
||||||
|
<span className="rounded-md bg-[var(--status-in-progress)] px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--accent-warning)]">
|
||||||
{data.blockerTooltipLines.length > 0 ? (
|
In Progress
|
||||||
<div className="mt-2 border-t border-[var(--border-subtle)] pt-1.5">
|
</span>
|
||||||
<p className="text-[8px] font-bold uppercase tracking-widest text-[var(--accent-danger)]/70 mb-0.5">Waiting on</p>
|
) : data.status === 'blocked' ? (
|
||||||
{data.blockerTooltipLines.slice(0, 2).map((line) => (
|
<span className="rounded-md bg-[var(--status-blocked)] px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--accent-danger)]">
|
||||||
<p key={line} className="text-[9px] text-[var(--text-tertiary)]/70 truncate leading-tight">
|
Blocked
|
||||||
{line}
|
</span>
|
||||||
</p>
|
) : data.status === 'closed' ? (
|
||||||
))}
|
<span className="rounded-md bg-[var(--status-closed)] px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-[var(--accent-success)]">
|
||||||
{data.blockerTooltipLines.length > 2 ? (
|
Done
|
||||||
<p className="text-[8px] text-[var(--text-tertiary)]/50">
|
</span>
|
||||||
+{data.blockerTooltipLines.length - 2} more
|
) : null}
|
||||||
</p>
|
<span className="text-[9px] font-bold uppercase tracking-wider text-[var(--text-tertiary)]/40">p{data.priority}</span>
|
||||||
) : null}
|
<span className={`h-2 w-2 rounded-full ring-2 ring-[var(--alpha-black-medium)] ${statusDot(data.status)}`} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
|
|
||||||
{!isClosed && archetypes.length > 0 ? (
|
<p className={`text-[15px] font-bold leading-[1.2] tracking-tight text-[var(--text-primary)] group-hover:text-[var(--accent-info)] transition-colors ${data.status === 'closed' ? 'line-through opacity-70' : ''}`}>
|
||||||
<div className="mt-2 border-t border-[var(--border-subtle)] pt-2">
|
{data.title}
|
||||||
{assignSuccess ? (
|
</p>
|
||||||
<div className="text-[9px] text-[var(--accent-success)] font-medium mb-1.5">
|
|
||||||
{assignSuccess}
|
{data.blockerTooltipLines.length > 0 ? (
|
||||||
</div>
|
<div className="mt-2 border-t border-[var(--border-subtle)] pt-1.5">
|
||||||
) : null}
|
<p className="text-[8px] font-bold uppercase tracking-widest text-[var(--accent-danger)]/70 mb-0.5">Waiting on</p>
|
||||||
{assignError ? (
|
{data.blockerTooltipLines.slice(0, 2).map((line) => (
|
||||||
<div className="text-[9px] text-[var(--accent-danger)] font-medium mb-1.5">
|
<p key={line} className="text-[9px] text-[var(--text-tertiary)]/70 truncate leading-tight">
|
||||||
{assignError}
|
{line}
|
||||||
</div>
|
</p>
|
||||||
) : null}
|
))}
|
||||||
{assignedArchetypes.length > 0 ? (
|
{data.blockerTooltipLines.length > 2 ? (
|
||||||
<div className="mb-2 flex flex-wrap gap-1">
|
<p className="text-[8px] text-[var(--text-tertiary)]/50">
|
||||||
{assignedArchetypes.map((archetype) => (
|
+{data.blockerTooltipLines.length - 2} more
|
||||||
<div
|
</p>
|
||||||
key={archetype.id}
|
) : null}
|
||||||
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[8px] font-medium ring-1"
|
</div>
|
||||||
style={{
|
) : null}
|
||||||
backgroundColor: `${archetype.color}15`,
|
|
||||||
borderColor: `${archetype.color}30`,
|
{!isClosed && archetypes.length > 0 ? (
|
||||||
color: archetype.color,
|
<div className="mt-2 border-t border-[var(--border-subtle)] pt-2">
|
||||||
}}
|
{assignSuccess ? (
|
||||||
>
|
<div className="text-[9px] text-[var(--accent-success)] font-medium mb-1.5">
|
||||||
<span>{archetype.name}</span>
|
{assignSuccess}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
) : null}
|
||||||
onClick={(e) => {
|
{assignError ? (
|
||||||
e.stopPropagation();
|
<div className="text-[9px] text-[var(--accent-danger)] font-medium mb-1.5">
|
||||||
handleUnassignAgent(archetype.id);
|
{assignError}
|
||||||
}}
|
</div>
|
||||||
disabled={isAssigning}
|
) : null}
|
||||||
className="hover:opacity-70 transition-opacity"
|
{assignedArchetypes.length > 0 ? (
|
||||||
>
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
<X className="h-2.5 w-2.5" />
|
{assignedArchetypes.map((archetype) => (
|
||||||
</button>
|
<div
|
||||||
</div>
|
key={archetype.id}
|
||||||
))}
|
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[8px] font-medium ring-1"
|
||||||
</div>
|
style={{
|
||||||
) : null}
|
backgroundColor: `${archetype.color}15`,
|
||||||
<DropdownMenu.Root>
|
borderColor: `${archetype.color}30`,
|
||||||
<DropdownMenu.Trigger asChild>
|
color: archetype.color,
|
||||||
<button
|
}}
|
||||||
type="button"
|
>
|
||||||
disabled={isAssigning}
|
<span>{archetype.name}</span>
|
||||||
className="flex items-center gap-1.5 w-full rounded-md bg-[var(--alpha-white-low)] hover:bg-[var(--alpha-white-medium)] px-2 py-1.5 text-[9px] font-medium text-[var(--text-tertiary)]/80 transition-colors disabled:opacity-50"
|
<button
|
||||||
>
|
type="button"
|
||||||
{isAssigning ? (
|
onClick={(e) => {
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
e.stopPropagation();
|
||||||
) : (
|
handleUnassignAgent(archetype.id);
|
||||||
<UserPlus className="h-3 w-3" />
|
}}
|
||||||
)}
|
disabled={isAssigning}
|
||||||
<span>Assign Agent</span>
|
className="hover:opacity-70 transition-opacity"
|
||||||
<ChevronDown className="h-3 w-3 ml-auto" />
|
>
|
||||||
</button>
|
<X className="h-2.5 w-2.5" />
|
||||||
</DropdownMenu.Trigger>
|
</button>
|
||||||
<DropdownMenu.Portal>
|
</div>
|
||||||
<DropdownMenu.Content
|
))}
|
||||||
className="min-w-[180px] rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-overlay)] p-1 shadow-[var(--shadow-lg)] backdrop-blur-lg z-50"
|
</div>
|
||||||
sideOffset={4}
|
) : null}
|
||||||
>
|
<DropdownMenu.Root>
|
||||||
{archetypes.map((archetype) => {
|
<DropdownMenu.Trigger asChild>
|
||||||
const isAssigned = assignedArchetypes.some(a => a.id === archetype.id);
|
<button
|
||||||
return (
|
type="button"
|
||||||
<DropdownMenu.Item
|
disabled={isAssigning}
|
||||||
key={archetype.id}
|
className="flex items-center gap-1.5 w-full rounded-md bg-[var(--alpha-white-low)] hover:bg-[var(--alpha-white-medium)] px-2 py-1.5 text-[9px] font-medium text-[var(--text-tertiary)]/80 transition-colors disabled:opacity-50"
|
||||||
disabled={isAssigned || isAssigning}
|
>
|
||||||
onClick={() => handleAssignAgent(archetype.id)}
|
{isAssigning ? (
|
||||||
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[11px] font-medium outline-none cursor-pointer transition-colors ${
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
isAssigned
|
) : (
|
||||||
? 'opacity-50 cursor-not-allowed'
|
<UserPlus className="h-3 w-3" />
|
||||||
: 'text-[var(--text-primary)] hover:bg-[var(--alpha-white-low)] focus:bg-[var(--alpha-white-low)]'
|
)}
|
||||||
}`}
|
<span>Assign Agent</span>
|
||||||
>
|
<ChevronDown className="h-3 w-3 ml-auto" />
|
||||||
<span
|
</button>
|
||||||
className="h-2 w-2 rounded-full"
|
</DropdownMenu.Trigger>
|
||||||
style={{ backgroundColor: archetype.color }}
|
<DropdownMenu.Portal>
|
||||||
/>
|
<DropdownMenu.Content
|
||||||
<span>{archetype.name}</span>
|
className="min-w-[180px] rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-overlay)] p-1 shadow-[var(--shadow-lg)] backdrop-blur-lg z-50"
|
||||||
{isAssigned && (
|
sideOffset={4}
|
||||||
<span className="ml-auto text-[9px] text-[var(--text-tertiary)]/60">Assigned</span>
|
>
|
||||||
)}
|
{archetypes.map((archetype) => {
|
||||||
</DropdownMenu.Item>
|
const isAssigned = assignedArchetypes.some(a => a.id === archetype.id);
|
||||||
);
|
return (
|
||||||
})}
|
<DropdownMenu.Item
|
||||||
</DropdownMenu.Content>
|
key={archetype.id}
|
||||||
</DropdownMenu.Portal>
|
disabled={isAssigned || isAssigning}
|
||||||
</DropdownMenu.Root>
|
onClick={() => handleAssignAgent(archetype.id)}
|
||||||
</div>
|
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[11px] font-medium outline-none cursor-pointer transition-colors ${
|
||||||
) : null}
|
isAssigned
|
||||||
</div>
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: 'text-[var(--text-primary)] hover:bg-[var(--alpha-white-low)] focus:bg-[var(--alpha-white-low)]'
|
||||||
{hovered ? (
|
}`}
|
||||||
<div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 animate-fade-in">
|
>
|
||||||
<div className="max-w-xs rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-overlay)] px-3 py-2 shadow-[var(--shadow-lg)] backdrop-blur-lg">
|
<span
|
||||||
{data.isActionable ? (
|
className="h-2 w-2 rounded-full"
|
||||||
<>
|
style={{ backgroundColor: archetype.color }}
|
||||||
<p className="text-[10px] font-bold text-[var(--accent-success)]">Ready to work</p>
|
/>
|
||||||
<p className="mt-0.5 text-[10px] text-[var(--text-tertiary)]/80">
|
<span>{archetype.name}</span>
|
||||||
No open blockers. {data.blocks} task{data.blocks === 1 ? '' : 's'} depend{data.blocks === 1 ? 's' : ''} on this.
|
{isAssigned && (
|
||||||
</p>
|
<span className="ml-auto text-[9px] text-[var(--text-tertiary)]/60">Assigned</span>
|
||||||
</>
|
)}
|
||||||
) : (
|
</DropdownMenu.Item>
|
||||||
<>
|
);
|
||||||
<p className="text-[10px] font-bold text-[var(--accent-danger)]">
|
})}
|
||||||
Blocked by {data.blockedBy} task{data.blockedBy === 1 ? '' : 's'}
|
</DropdownMenu.Content>
|
||||||
</p>
|
</DropdownMenu.Portal>
|
||||||
{data.blockerTooltipLines.length > 0 ? (
|
</DropdownMenu.Root>
|
||||||
<ul className="mt-1 space-y-0.5">
|
</div>
|
||||||
{data.blockerTooltipLines.map((line) => (
|
) : null}
|
||||||
<li key={line} className="text-[9px] text-[var(--text-tertiary)]/80">
|
</div>
|
||||||
• {line}
|
|
||||||
</li>
|
{hovered ? (
|
||||||
))}
|
<div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 animate-fade-in">
|
||||||
</ul>
|
<div className="max-w-xs rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-overlay)] px-3 py-2 shadow-[var(--shadow-lg)] backdrop-blur-lg">
|
||||||
) : null}
|
{data.isActionable ? (
|
||||||
</>
|
<>
|
||||||
)}
|
<p className="text-[10px] font-bold text-[var(--accent-success)]">Ready to work</p>
|
||||||
</div>
|
<p className="mt-0.5 text-[10px] text-[var(--text-tertiary)]/80">
|
||||||
</div>
|
No open blockers. {data.blocks} task{data.blocks === 1 ? '' : 's'} depend{data.blocks === 1 ? 's' : ''} on this.
|
||||||
) : null}
|
</p>
|
||||||
|
</>
|
||||||
<Handle type="source" position={Position.Right} className="!opacity-0" />
|
) : (
|
||||||
</div>
|
<>
|
||||||
);
|
<p className="text-[10px] font-bold text-[var(--accent-danger)]">
|
||||||
}
|
Blocked by {data.blockedBy} task{data.blockedBy === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
{data.blockerTooltipLines.length > 0 ? (
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{data.blockerTooltipLines.map((line) => (
|
||||||
|
<li key={line} className="text-[9px] text-[var(--text-tertiary)]/80">
|
||||||
|
• {line}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Handle type="source" position={Position.Right} className="!opacity-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,271 +1,271 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Filter, UserPlus } from 'lucide-react';
|
import { Filter, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import type { GraphHopDepth } from '../../lib/graph-view';
|
import type { GraphHopDepth } from '../../lib/graph-view';
|
||||||
import { WorkflowGraph } from '../shared/workflow-graph';
|
import { WorkflowGraph } from '../shared/workflow-graph';
|
||||||
import { WorkflowTabs, type WorkflowTab } from './workflow-tabs';
|
import { WorkflowTabs, type WorkflowTab } from './workflow-tabs';
|
||||||
import { TaskCardGrid, type BlockerDetail } from './task-card-grid';
|
import { TaskCardGrid, type BlockerDetail } from './task-card-grid';
|
||||||
import { useArchetypes } from '../../hooks/use-archetypes';
|
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||||
import { useGraphAnalysis } from '../../hooks/use-graph-analysis';
|
import { useGraphAnalysis } from '../../hooks/use-graph-analysis';
|
||||||
|
|
||||||
export interface SmartDagProps {
|
export interface SmartDagProps {
|
||||||
issues: BeadIssue[];
|
issues: BeadIssue[];
|
||||||
epicId?: string | null;
|
epicId?: string | null;
|
||||||
selectedTaskId?: string;
|
selectedTaskId?: string;
|
||||||
onSelectTask?: (id: string) => void;
|
onSelectTask?: (id: string) => void;
|
||||||
projectRoot: string;
|
projectRoot: string;
|
||||||
hideClosed?: boolean;
|
hideClosed?: boolean;
|
||||||
onAssignModeChange?: (assignMode: boolean) => void;
|
onAssignModeChange?: (assignMode: boolean) => void;
|
||||||
onSelectedIssueChange?: (issue: BeadIssue | null) => void;
|
onSelectedIssueChange?: (issue: BeadIssue | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full'];
|
const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full'];
|
||||||
|
|
||||||
export function SmartDag({
|
export function SmartDag({
|
||||||
issues,
|
issues,
|
||||||
epicId,
|
epicId,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
onSelectTask,
|
onSelectTask,
|
||||||
projectRoot,
|
projectRoot,
|
||||||
hideClosed: hideClosedProp = false,
|
hideClosed: hideClosedProp = false,
|
||||||
onAssignModeChange,
|
onAssignModeChange,
|
||||||
onSelectedIssueChange,
|
onSelectedIssueChange,
|
||||||
}: SmartDagProps) {
|
}: SmartDagProps) {
|
||||||
const { archetypes } = useArchetypes(projectRoot);
|
const { archetypes } = useArchetypes(projectRoot);
|
||||||
|
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<WorkflowTab>('tasks');
|
const [activeTab, setActiveTab] = useState<WorkflowTab>('tasks');
|
||||||
const [assignMode, setAssignMode] = useState(false);
|
const [assignMode, setAssignMode] = useState(false);
|
||||||
|
|
||||||
const [hideClosed, setHideClosed] = useState(hideClosedProp);
|
const [hideClosed, setHideClosed] = useState(true);
|
||||||
const [depth, setDepth] = useState<GraphHopDepth>('full');
|
const [depth, setDepth] = useState<GraphHopDepth>('full');
|
||||||
const [blockingOnly, setBlockingOnly] = useState(false);
|
const [blockingOnly, setBlockingOnly] = useState(false);
|
||||||
const [sortReadyFirst, setSortReadyFirst] = useState(true);
|
const [sortReadyFirst, setSortReadyFirst] = useState(true);
|
||||||
|
|
||||||
const displayBeads = useMemo(() => {
|
const displayBeads = useMemo(() => {
|
||||||
if (!epicId) return issues;
|
if (!epicId) return issues;
|
||||||
return issues.filter(issue => {
|
return issues.filter(issue => {
|
||||||
if (issue.issue_type === 'epic') return false;
|
if (issue.issue_type === 'epic') return false;
|
||||||
const parent = issue.dependencies.find(d => d.type === 'parent');
|
const parent = issue.dependencies.find(d => d.type === 'parent');
|
||||||
return parent?.target === epicId;
|
return parent?.target === epicId;
|
||||||
});
|
});
|
||||||
}, [issues, epicId]);
|
}, [issues, epicId]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
signalById,
|
signalById,
|
||||||
cycleNodeIdSet,
|
cycleNodeIdSet,
|
||||||
actionableNodeIds,
|
actionableNodeIds,
|
||||||
blockerTooltipMap,
|
blockerTooltipMap,
|
||||||
} = useGraphAnalysis(issues, projectRoot, selectedTaskId ?? null);
|
} = useGraphAnalysis(issues, projectRoot, selectedTaskId ?? null);
|
||||||
|
|
||||||
const blockerDetailsMap = useMemo(() => {
|
const blockerDetailsMap = useMemo(() => {
|
||||||
const map = new Map<string, BlockerDetail[]>();
|
const map = new Map<string, BlockerDetail[]>();
|
||||||
for (const issue of displayBeads) {
|
for (const issue of displayBeads) {
|
||||||
const blockers: BlockerDetail[] = [];
|
const blockers: BlockerDetail[] = [];
|
||||||
for (const dep of issue.dependencies) {
|
for (const dep of issue.dependencies) {
|
||||||
if (dep.type === 'blocks') {
|
if (dep.type === 'blocks') {
|
||||||
const blocker = issues.find(i => i.id === dep.target);
|
const blocker = issues.find(i => i.id === dep.target);
|
||||||
if (blocker && blocker.status !== 'closed') {
|
if (blocker && blocker.status !== 'closed') {
|
||||||
blockers.push({
|
blockers.push({
|
||||||
id: blocker.id,
|
id: blocker.id,
|
||||||
title: blocker.title,
|
title: blocker.title,
|
||||||
status: blocker.status,
|
status: blocker.status,
|
||||||
priority: blocker.priority,
|
priority: blocker.priority,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (blockers.length > 0) {
|
if (blockers.length > 0) {
|
||||||
map.set(issue.id, blockers);
|
map.set(issue.id, blockers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [displayBeads, issues]);
|
}, [displayBeads, issues]);
|
||||||
|
|
||||||
const blocksDetailsMap = useMemo(() => {
|
const blocksDetailsMap = useMemo(() => {
|
||||||
const map = new Map<string, BlockerDetail[]>();
|
const map = new Map<string, BlockerDetail[]>();
|
||||||
for (const issue of displayBeads) {
|
for (const issue of displayBeads) {
|
||||||
const blocking: BlockerDetail[] = [];
|
const blocking: BlockerDetail[] = [];
|
||||||
for (const other of issues) {
|
for (const other of issues) {
|
||||||
for (const dep of other.dependencies) {
|
for (const dep of other.dependencies) {
|
||||||
if (dep.type === 'blocks' && dep.target === issue.id) {
|
if (dep.type === 'blocks' && dep.target === issue.id) {
|
||||||
if (other.status !== 'closed') {
|
if (other.status !== 'closed') {
|
||||||
blocking.push({
|
blocking.push({
|
||||||
id: other.id,
|
id: other.id,
|
||||||
title: other.title,
|
title: other.title,
|
||||||
status: other.status,
|
status: other.status,
|
||||||
priority: other.priority,
|
priority: other.priority,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (blocking.length > 0) {
|
if (blocking.length > 0) {
|
||||||
map.set(issue.id, blocking);
|
map.set(issue.id, blocking);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [displayBeads, issues]);
|
}, [displayBeads, issues]);
|
||||||
|
|
||||||
const sortedTasks = useMemo(() => {
|
const sortedTasks = useMemo(() => {
|
||||||
let tasks = displayBeads.filter(issue =>
|
let tasks = displayBeads.filter(issue =>
|
||||||
hideClosed ? issue.status !== 'closed' : true
|
hideClosed ? issue.status !== 'closed' : true
|
||||||
);
|
);
|
||||||
|
|
||||||
if (blockingOnly && activeTab === 'dependencies') {
|
if (blockingOnly && activeTab === 'dependencies') {
|
||||||
tasks = tasks.filter(issue => {
|
tasks = tasks.filter(issue => {
|
||||||
const blockers = blockerDetailsMap.get(issue.id) ?? [];
|
const blockers = blockerDetailsMap.get(issue.id) ?? [];
|
||||||
return blockers.length > 0 || issue.status === 'blocked';
|
return blockers.length > 0 || issue.status === 'blocked';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortReadyFirst && activeTab === 'tasks') {
|
if (sortReadyFirst && activeTab === 'tasks') {
|
||||||
tasks = [...tasks].sort((a, b) => {
|
tasks = [...tasks].sort((a, b) => {
|
||||||
const aReady = actionableNodeIds.has(a.id) && a.status !== 'closed';
|
const aReady = actionableNodeIds.has(a.id) && a.status !== 'closed';
|
||||||
const bReady = actionableNodeIds.has(b.id) && b.status !== 'closed';
|
const bReady = actionableNodeIds.has(b.id) && b.status !== 'closed';
|
||||||
if (aReady && !bReady) return -1;
|
if (aReady && !bReady) return -1;
|
||||||
if (!aReady && bReady) return 1;
|
if (!aReady && bReady) return 1;
|
||||||
return a.priority - b.priority;
|
return a.priority - b.priority;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return tasks;
|
return tasks;
|
||||||
}, [displayBeads, hideClosed, blockingOnly, blockerDetailsMap, sortReadyFirst, actionableNodeIds, activeTab]);
|
}, [displayBeads, hideClosed, blockingOnly, blockerDetailsMap, sortReadyFirst, actionableNodeIds, activeTab]);
|
||||||
|
|
||||||
const handleAssignModeToggle = useCallback(() => {
|
const handleAssignModeToggle = useCallback(() => {
|
||||||
const newMode = !assignMode;
|
const newMode = !assignMode;
|
||||||
setAssignMode(newMode);
|
setAssignMode(newMode);
|
||||||
onAssignModeChange?.(newMode);
|
onAssignModeChange?.(newMode);
|
||||||
}, [assignMode, onAssignModeChange]);
|
}, [assignMode, onAssignModeChange]);
|
||||||
|
|
||||||
const handleTaskSelect = useCallback((id: string, shouldOpenDrawer?: boolean) => {
|
const handleTaskSelect = useCallback((id: string, shouldOpenDrawer?: boolean) => {
|
||||||
onSelectTask?.(id);
|
onSelectTask?.(id);
|
||||||
const selectedIssue = issues.find(i => i.id === id) ?? null;
|
const selectedIssue = issues.find(i => i.id === id) ?? null;
|
||||||
onSelectedIssueChange?.(selectedIssue);
|
onSelectedIssueChange?.(selectedIssue);
|
||||||
}, [onSelectTask, issues, onSelectedIssueChange]);
|
}, [onSelectTask, issues, onSelectedIssueChange]);
|
||||||
|
|
||||||
const selectedIssue = useMemo(() =>
|
const selectedIssue = useMemo(() =>
|
||||||
issues.find(i => i.id === selectedTaskId) ?? null,
|
issues.find(i => i.id === selectedTaskId) ?? null,
|
||||||
[issues, selectedTaskId]
|
[issues, selectedTaskId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col animate-in fade-in duration-500 relative bg-[var(--surface-secondary)]">
|
<div className="w-full h-full flex flex-col animate-in fade-in duration-500 relative bg-[var(--surface-secondary)]">
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-[var(--border-subtle)] px-4 py-3 bg-[var(--surface-tertiary)]">
|
<div className="flex items-center justify-between gap-4 border-b border-[var(--border-subtle)] px-4 py-3 bg-[var(--surface-tertiary)]">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowFilters(current => !current)}
|
onClick={() => setShowFilters(current => !current)}
|
||||||
className={`flex items-center gap-2 rounded-xl border px-3 py-1.5 text-xs font-bold transition-all ${
|
className={`flex items-center gap-2 rounded-xl border px-3 py-1.5 text-xs font-bold transition-all ${
|
||||||
showFilters
|
showFilters
|
||||||
? 'border-[var(--accent-info)]/30 bg-[var(--accent-info)]/10 text-[var(--accent-info)]'
|
? 'border-[var(--accent-info)]/30 bg-[var(--accent-info)]/10 text-[var(--accent-info)]'
|
||||||
: 'border-[var(--border-subtle)] bg-[var(--surface-quaternary)] text-[var(--text-tertiary)] hover:bg-[var(--surface-hover)]'
|
: 'border-[var(--border-subtle)] bg-[var(--surface-quaternary)] text-[var(--text-tertiary)] hover:bg-[var(--surface-hover)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Filter className="w-3.5 h-3.5" />
|
<Filter className="w-3.5 h-3.5" />
|
||||||
Filters {showFilters ? '▴' : '▾'}
|
Filters {showFilters ? '▴' : '▾'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAssignModeToggle}
|
onClick={handleAssignModeToggle}
|
||||||
className={`flex items-center gap-2 rounded-xl border px-3 py-1.5 text-xs font-bold transition-all ${
|
className={`flex items-center gap-2 rounded-xl border px-3 py-1.5 text-xs font-bold transition-all ${
|
||||||
assignMode
|
assignMode
|
||||||
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||||
: 'border-white/10 bg-white/5 text-text-muted hover:bg-white/10'
|
: 'border-white/10 bg-white/5 text-text-muted hover:bg-white/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-3.5 h-3.5" />
|
<UserPlus className="w-3.5 h-3.5" />
|
||||||
Assign
|
Assign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkflowTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
<WorkflowTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showFilters ? (
|
{showFilters ? (
|
||||||
<div className="flex flex-wrap items-center gap-4 border-b border-white/5 px-4 py-3 bg-white/[0.01]">
|
<div className="flex flex-wrap items-center gap-4 border-b border-white/5 px-4 py-3 bg-white/[0.01]">
|
||||||
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500"
|
className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500"
|
||||||
checked={hideClosed}
|
checked={hideClosed}
|
||||||
onChange={(e) => setHideClosed(e.target.checked)}
|
onChange={(e) => setHideClosed(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Hide closed
|
Hide closed
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{activeTab === 'tasks' ? (
|
{activeTab === 'tasks' ? (
|
||||||
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500"
|
className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500"
|
||||||
checked={sortReadyFirst}
|
checked={sortReadyFirst}
|
||||||
onChange={(e) => setSortReadyFirst(e.target.checked)}
|
onChange={(e) => setSortReadyFirst(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Ready first
|
Ready first
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === 'dependencies' ? (
|
{activeTab === 'dependencies' ? (
|
||||||
<>
|
<>
|
||||||
<div className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/40 px-3 py-1.5 text-xs font-medium text-text-body">
|
<div className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/40 px-3 py-1.5 text-xs font-medium text-text-body">
|
||||||
<span className="text-text-muted">Depth:</span>
|
<span className="text-text-muted">Depth:</span>
|
||||||
<select
|
<select
|
||||||
className="bg-transparent text-text-body focus:outline-none"
|
className="bg-transparent text-text-body focus:outline-none"
|
||||||
value={depth}
|
value={depth}
|
||||||
onChange={(e) => setDepth(e.target.value as GraphHopDepth)}
|
onChange={(e) => setDepth(e.target.value as GraphHopDepth)}
|
||||||
>
|
>
|
||||||
{DEPTH_OPTIONS.map(opt => (
|
{DEPTH_OPTIONS.map(opt => (
|
||||||
<option key={String(opt)} value={opt} className="bg-zinc-900">
|
<option key={String(opt)} value={opt} className="bg-zinc-900">
|
||||||
{opt === 'full' ? 'Full' : `${opt} hop${opt === 1 ? '' : 's'}`}
|
{opt === 'full' ? 'Full' : `${opt} hop${opt === 1 ? '' : 's'}`}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500"
|
className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500"
|
||||||
checked={blockingOnly}
|
checked={blockingOnly}
|
||||||
onChange={(e) => setBlockingOnly(e.target.checked)}
|
onChange={(e) => setBlockingOnly(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Blocking only
|
Blocking only
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{activeTab === 'tasks' ? (
|
{activeTab === 'tasks' ? (
|
||||||
<div className="h-full overflow-y-auto p-4">
|
<div className="h-full overflow-x-auto p-4">
|
||||||
<TaskCardGrid
|
<TaskCardGrid
|
||||||
tasks={sortedTasks}
|
tasks={sortedTasks}
|
||||||
selectedId={selectedTaskId ?? null}
|
selectedId={selectedTaskId ?? null}
|
||||||
blockerDetailsMap={blockerDetailsMap}
|
blockerDetailsMap={blockerDetailsMap}
|
||||||
blocksDetailsMap={blocksDetailsMap}
|
blocksDetailsMap={blocksDetailsMap}
|
||||||
actionableIds={actionableNodeIds}
|
actionableIds={actionableNodeIds}
|
||||||
onSelect={handleTaskSelect}
|
onSelect={handleTaskSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full p-4">
|
<div className="h-full p-4">
|
||||||
<WorkflowGraph
|
<WorkflowGraph
|
||||||
beads={sortedTasks}
|
beads={sortedTasks}
|
||||||
selectedId={selectedTaskId}
|
selectedId={selectedTaskId}
|
||||||
onSelect={onSelectTask}
|
onSelect={onSelectTask}
|
||||||
hideClosed={hideClosed}
|
hideClosed={hideClosed}
|
||||||
archetypes={archetypes}
|
archetypes={archetypes}
|
||||||
assignMode={assignMode}
|
assignMode={assignMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,382 +1,382 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
|
|
||||||
/** Props for an individual task card in the grid. */
|
/** Props for an individual task card in the grid. */
|
||||||
/** Details for a blocker task shown on the card. */
|
/** Details for a blocker task shown on the card. */
|
||||||
export interface BlockerDetail {
|
export interface BlockerDetail {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: BeadIssue['status'];
|
status: BeadIssue['status'];
|
||||||
priority: BeadIssue['priority'];
|
priority: BeadIssue['priority'];
|
||||||
epicTitle?: string;
|
epicTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for an individual task card in the grid. */
|
/** Props for an individual task card in the grid. */
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
/** The issue data for this card. */
|
/** The issue data for this card. */
|
||||||
issue: BeadIssue;
|
issue: BeadIssue;
|
||||||
/** Whether this card is the currently selected task. */
|
/** Whether this card is the currently selected task. */
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
/** List of issues blocking this task. */
|
/** List of issues blocking this task. */
|
||||||
blockers: BlockerDetail[];
|
blockers: BlockerDetail[];
|
||||||
/** List of issues this task blocks. */
|
/** List of issues this task blocks. */
|
||||||
blocking: BlockerDetail[];
|
blocking: BlockerDetail[];
|
||||||
/** Whether this task is actionable (unblocked). */
|
/** Whether this task is actionable (unblocked). */
|
||||||
isActionable: boolean;
|
isActionable: boolean;
|
||||||
/** Callback fired when the user clicks this card (or a blocker). */
|
/** Callback fired when the user clicks this card (or a blocker). */
|
||||||
onSelect: (id: string, shouldOpenDrawer?: boolean) => void;
|
onSelect: (id: string, shouldOpenDrawer?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for the TaskCardGrid component. */
|
/** Props for the TaskCardGrid component. */
|
||||||
interface TaskCardGridProps {
|
interface TaskCardGridProps {
|
||||||
/** List of tasks to display in the grid. */
|
/** List of tasks to display in the grid. */
|
||||||
tasks: BeadIssue[];
|
tasks: BeadIssue[];
|
||||||
/** ID of the currently selected task, or null. */
|
/** ID of the currently selected task, or null. */
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
/** Map of issue ID to detailed blocker info. */
|
/** Map of issue ID to detailed blocker info. */
|
||||||
blockerDetailsMap: Map<string, BlockerDetail[]>;
|
blockerDetailsMap: Map<string, BlockerDetail[]>;
|
||||||
/** Map of issue ID to detailed downstream blocking info. */
|
/** Map of issue ID to detailed downstream blocking info. */
|
||||||
blocksDetailsMap: Map<string, BlockerDetail[]>;
|
blocksDetailsMap: Map<string, BlockerDetail[]>;
|
||||||
/** Set of actionable (unblocked) task IDs. */
|
/** Set of actionable (unblocked) task IDs. */
|
||||||
actionableIds: Set<string>;
|
actionableIds: Set<string>;
|
||||||
/** Callback fired when the user selects a task. */
|
/** Callback fired when the user selects a task. */
|
||||||
onSelect: (id: string, shouldOpenDrawer?: boolean) => void;
|
onSelect: (id: string, shouldOpenDrawer?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the Tailwind background color class for a status dot indicator.
|
* Returns the Tailwind background color class for a status dot indicator.
|
||||||
* Mirrors the statusDot function from the original monolith.
|
* Mirrors the statusDot function from the original monolith.
|
||||||
*/
|
*/
|
||||||
function statusDot(status: BeadIssue['status']): string {
|
function statusDot(status: BeadIssue['status']): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'open':
|
case 'open':
|
||||||
return 'bg-emerald-400';
|
return 'bg-emerald-400';
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return 'bg-amber-400';
|
return 'bg-amber-400';
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return 'bg-rose-500';
|
return 'bg-rose-500';
|
||||||
case 'deferred':
|
case 'deferred':
|
||||||
return 'bg-slate-400';
|
return 'bg-slate-400';
|
||||||
case 'closed':
|
case 'closed':
|
||||||
return 'bg-slate-400';
|
return 'bg-slate-400';
|
||||||
case 'pinned':
|
case 'pinned':
|
||||||
return 'bg-violet-400';
|
return 'bg-violet-400';
|
||||||
case 'hooked':
|
case 'hooked':
|
||||||
return 'bg-orange-400';
|
return 'bg-orange-400';
|
||||||
default:
|
default:
|
||||||
return 'bg-zinc-500';
|
return 'bg-zinc-500';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns status-tinted gradient background for simplified flat styling.
|
* Returns status-tinted gradient background for simplified flat styling.
|
||||||
*/
|
*/
|
||||||
function statusGradient(status: BeadIssue['status']): string {
|
function statusGradient(status: BeadIssue['status']): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'open':
|
case 'open':
|
||||||
return 'border-l-2 border-emerald-400 bg-emerald-500/15';
|
return 'border-l-2 border-emerald-400 bg-emerald-500/15';
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return 'border-l-2 border-amber-400 bg-amber-500/15';
|
return 'border-l-2 border-amber-400 bg-amber-500/15';
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return 'border-l-2 border-rose-400 bg-rose-500/15';
|
return 'border-l-2 border-rose-400 bg-rose-500/15';
|
||||||
case 'closed':
|
case 'closed':
|
||||||
return 'border-l-2 border-slate-400/60 bg-slate-500/10 opacity-80';
|
return 'border-l-2 border-slate-400/60 bg-slate-500/10 opacity-80';
|
||||||
case 'deferred':
|
case 'deferred':
|
||||||
return 'border-l-2 border-slate-400/60 bg-slate-500/10';
|
return 'border-l-2 border-slate-400/60 bg-slate-500/10';
|
||||||
default:
|
default:
|
||||||
return 'border-l-2 border-slate-400/60 bg-slate-500/10';
|
return 'border-l-2 border-slate-400/60 bg-slate-500/10';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns status-colored border for Aero Chrome styling.
|
* Returns status-colored border for Aero Chrome styling.
|
||||||
*/
|
*/
|
||||||
function statusBorder(status: BeadIssue['status']): string {
|
function statusBorder(status: BeadIssue['status']): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'open':
|
case 'open':
|
||||||
return 'border-emerald-500/20';
|
return 'border-emerald-500/20';
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return 'border-amber-500/20';
|
return 'border-amber-500/20';
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return 'border-rose-500/20';
|
return 'border-rose-500/20';
|
||||||
case 'closed':
|
case 'closed':
|
||||||
return 'border-rose-500/30';
|
return 'border-rose-500/30';
|
||||||
case 'deferred':
|
case 'deferred':
|
||||||
return 'border-slate-500/20';
|
return 'border-slate-500/20';
|
||||||
default:
|
default:
|
||||||
return 'border-white/[0.06]';
|
return 'border-white/[0.06]';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns title text color class - greyed out for closed status.
|
* Returns title text color class - greyed out for closed status.
|
||||||
*/
|
*/
|
||||||
function titleColorClass(status: BeadIssue['status']): string {
|
function titleColorClass(status: BeadIssue['status']): string {
|
||||||
return status === 'closed' ? 'text-text-muted/70' : 'text-text-strong';
|
return status === 'closed' ? 'text-text-muted/70' : 'text-text-strong';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a human-friendly label and text color class for a status.
|
* Returns a human-friendly label and text color class for a status.
|
||||||
*/
|
*/
|
||||||
function statusBadge(status: BeadIssue['status'], isActionable: boolean, hasBlockers: boolean): { label: string; textColor: string; bgColor: string } {
|
function statusBadge(status: BeadIssue['status'], isActionable: boolean, hasBlockers: boolean): { label: string; textColor: string; bgColor: string } {
|
||||||
// Actual blocked status always shows as Blocked in red
|
// Actual blocked status always shows as Blocked in red
|
||||||
if (status === 'blocked') {
|
if (status === 'blocked') {
|
||||||
return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' };
|
return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// If effectively blocked (has open blockers), show Blocked (unless closed/done)
|
// If effectively blocked (has open blockers), show Blocked (unless closed/done)
|
||||||
if (hasBlockers && status !== 'closed' && status !== 'in_progress') {
|
if (hasBlockers && status !== 'closed' && status !== 'in_progress') {
|
||||||
return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' };
|
return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' };
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return { label: 'In Progress', textColor: 'text-amber-400', bgColor: 'bg-amber-400/10' };
|
return { label: 'In Progress', textColor: 'text-amber-400', bgColor: 'bg-amber-400/10' };
|
||||||
case 'closed':
|
case 'closed':
|
||||||
return { label: 'Done', textColor: 'text-emerald-400', bgColor: 'bg-emerald-400/10' };
|
return { label: 'Done', textColor: 'text-emerald-400', bgColor: 'bg-emerald-400/10' };
|
||||||
case 'deferred':
|
case 'deferred':
|
||||||
return { label: 'Deferred', textColor: 'text-slate-400', bgColor: 'bg-slate-400/10' };
|
return { label: 'Deferred', textColor: 'text-slate-400', bgColor: 'bg-slate-400/10' };
|
||||||
case 'open':
|
case 'open':
|
||||||
// Open with no blockers -> Ready
|
// Open with no blockers -> Ready
|
||||||
return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' };
|
return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' };
|
||||||
default:
|
default:
|
||||||
return { label: status, textColor: 'text-zinc-400', bgColor: 'bg-zinc-400/10' };
|
return { label: status, textColor: 'text-zinc-400', bgColor: 'bg-zinc-400/10' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single task card displaying the issue ID, title, priority, type, assignee,
|
* A single task card displaying the issue ID, title, priority, type, assignee,
|
||||||
* and detailed blocker list (interactive).
|
* and detailed blocker list (interactive).
|
||||||
*/
|
*/
|
||||||
function TaskCard({ issue, selected, blockers, blocking, isActionable, onSelect }: TaskCardProps) {
|
function TaskCard({ issue, selected, blockers, blocking, isActionable, onSelect }: TaskCardProps) {
|
||||||
const hasBlockers = blockers.length > 0; // Note: blockers list only contains OPEN blockers (computed in page)
|
const hasBlockers = blockers.length > 0; // Note: blockers list only contains OPEN blockers (computed in page)
|
||||||
const badge = statusBadge(issue.status, isActionable, hasBlockers);
|
const badge = statusBadge(issue.status, isActionable, hasBlockers);
|
||||||
const projectName = (issue as BeadIssue & { project?: { name?: string } }).project?.name ?? null;
|
const projectName = (issue as BeadIssue & { project?: { name?: string } }).project?.name ?? null;
|
||||||
|
|
||||||
// Determine effective status: in_progress always shows as in_progress, blocked always blocked, otherwise check blockers
|
// Determine effective status: in_progress always shows as in_progress, blocked always blocked, otherwise check blockers
|
||||||
const effectiveStatus: BeadIssue['status'] = issue.status === 'in_progress' ? 'in_progress' :
|
const effectiveStatus: BeadIssue['status'] = issue.status === 'in_progress' ? 'in_progress' :
|
||||||
issue.status === 'blocked' ? 'blocked' :
|
issue.status === 'blocked' ? 'blocked' :
|
||||||
hasBlockers ? 'blocked' :
|
hasBlockers ? 'blocked' :
|
||||||
issue.status;
|
issue.status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onSelect(issue.id, false)}
|
onClick={() => onSelect(issue.id, false)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSelect(issue.id, false);
|
onSelect(issue.id, false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`group relative flex w-full flex-col rounded-xl border ${statusBorder(effectiveStatus)} ${statusGradient(effectiveStatus)} px-4 py-4 text-left transition duration-200 shadow-[0_4px_24px_rgba(0,0,0,0.35),inset_0_1px_0_rgba(255,255,255,0.06)] ${selected
|
className={`group relative flex w-72 flex-shrink-0 flex-col rounded-xl border ${statusBorder(effectiveStatus)} ${statusGradient(effectiveStatus)} px-4 py-4 text-left transition duration-200 shadow-[0_4px_24px_rgba(0,0,0,0.35),inset_0_1px_0_rgba(255,255,255,0.06)] ${selected
|
||||||
? 'ring-1 ring-amber-200/30 shadow-[0_0_20px_rgba(251,191,36,0.15)]'
|
? 'ring-1 ring-amber-200/30 shadow-[0_0_20px_rgba(251,191,36,0.15)]'
|
||||||
: 'hover:shadow-[0_8px_30px_rgba(0,0,0,0.4)]'
|
: 'hover:shadow-[0_8px_30px_rgba(0,0,0,0.4)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Expand / Open Drawer Button */}
|
{/* Expand / Open Drawer Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-2 top-2 z-10 rounded p-1.5 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
className="absolute right-2 top-2 z-10 rounded p-1.5 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(issue.id, true);
|
onSelect(issue.id, true);
|
||||||
}}
|
}}
|
||||||
title="Open Details"
|
title="Open Details"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex w-full items-start justify-between gap-3 pr-6">
|
<div className="flex w-full items-start justify-between gap-3 pr-6">
|
||||||
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
|
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`h-2 w-2 rounded-full ${statusDot(effectiveStatus)} ring-1 ring-white/10`} />
|
<span className={`h-2 w-2 rounded-full ${statusDot(effectiveStatus)} ring-1 ring-white/10`} />
|
||||||
<span className="font-mono text-[10px] text-text-muted">{issue.id}</span>
|
<span className="font-mono text-[10px] text-text-muted">{issue.id}</span>
|
||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
<span className={`rounded px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${badge.textColor} ${badge.bgColor}`}>
|
<span className={`rounded px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${badge.textColor} ${badge.bgColor}`}>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{projectName ? (
|
{projectName ? (
|
||||||
<div className="inline-flex w-fit rounded border border-sky-300/25 bg-sky-500/10 px-1.5 py-0.5 font-mono text-[9px] text-sky-200">
|
<div className="inline-flex w-fit rounded border border-sky-300/25 bg-sky-500/10 px-1.5 py-0.5 font-mono text-[9px] text-sky-200">
|
||||||
project: {projectName}
|
project: {projectName}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<h3 className={`line-clamp-3 text-sm font-medium leading-snug ${titleColorClass(issue.status)}`}>
|
<h3 className={`line-clamp-3 text-sm font-medium leading-snug ${titleColorClass(issue.status)}`}>
|
||||||
{issue.title}
|
{issue.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
{issue.labels?.length > 0 ? (
|
{issue.labels?.length > 0 ? (
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
{issue.labels.map((label) => (
|
{issue.labels.map((label) => (
|
||||||
<span key={label} className="rounded bg-white/5 px-1.5 py-0.5 text-[9px] font-medium text-text-muted/80 backdrop-blur-sm border border-white/5">
|
<span key={label} className="rounded bg-white/5 px-1.5 py-0.5 text-[9px] font-medium text-text-muted/80 backdrop-blur-sm border border-white/5">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* "Unlocks" section for blockers */}
|
{/* "Unlocks" section for blockers */}
|
||||||
{blockers.length > 0 ? (
|
{blockers.length > 0 ? (
|
||||||
<div className="mt-auto pt-2 w-full">
|
<div className="mt-auto pt-2 w-full">
|
||||||
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
||||||
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-rose-400/80">Unlocks</p>
|
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-rose-400/80">Unlocks</p>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{blockers.map((blocker) => (
|
{blockers.map((blocker) => (
|
||||||
<div
|
<div
|
||||||
key={blocker.id}
|
key={blocker.id}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(blocker.id, false);
|
onSelect(blocker.id, false);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(blocker.id, false);
|
onSelect(blocker.id, false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors"
|
className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors"
|
||||||
>
|
>
|
||||||
{/* Expand Button */}
|
{/* Expand Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-1 top-1 z-10 rounded p-1 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
className="absolute right-1 top-1 z-10 rounded p-1 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(blocker.id, true);
|
onSelect(blocker.id, true);
|
||||||
}}
|
}}
|
||||||
title="Open Details"
|
title="Open Details"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pr-5">
|
<div className="flex items-center gap-2 pr-5">
|
||||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${statusDot(blocker.status)}`} />
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${statusDot(blocker.status)}`} />
|
||||||
<span className="font-mono text-[9px] text-text-muted">{blocker.id}</span>
|
<span className="font-mono text-[9px] text-text-muted">{blocker.id}</span>
|
||||||
<span className="line-clamp-1 text-[10px] font-medium text-text-body">{blocker.title}</span>
|
<span className="line-clamp-1 text-[10px] font-medium text-text-body">{blocker.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{blocker.epicTitle ? (
|
{blocker.epicTitle ? (
|
||||||
<div className="pl-3.5 text-[9px] text-text-muted/60 truncate max-w-full pr-5">
|
<div className="pl-3.5 text-[9px] text-text-muted/60 truncate max-w-full pr-5">
|
||||||
<span className="group-hover:text-sky-300/70 transition-colors">↳ {blocker.epicTitle}</span>
|
<span className="group-hover:text-sky-300/70 transition-colors">↳ {blocker.epicTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* "Blocks" section (downstream) */}
|
{/* "Blocks" section (downstream) */}
|
||||||
{blocking.length > 0 ? (
|
{blocking.length > 0 ? (
|
||||||
<div className={`${blockers.length > 0 ? 'mt-2' : 'mt-auto'} w-full`}>
|
<div className={`${blockers.length > 0 ? 'mt-2' : 'mt-auto'} w-full`}>
|
||||||
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
||||||
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-amber-400/80">Blocks</p>
|
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-amber-400/80">Blocks</p>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{blocking.map((item) => (
|
{blocking.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(item.id, false);
|
onSelect(item.id, false);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(item.id, false);
|
onSelect(item.id, false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors"
|
className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors"
|
||||||
>
|
>
|
||||||
{/* Expand Button */}
|
{/* Expand Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-1 top-1 z-10 rounded p-1 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
className="absolute right-1 top-1 z-10 rounded p-1 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(item.id, true);
|
onSelect(item.id, true);
|
||||||
}}
|
}}
|
||||||
title="Open Details"
|
title="Open Details"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pr-5">
|
<div className="flex items-center gap-2 pr-5">
|
||||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${statusDot(item.status)}`} />
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${statusDot(item.status)}`} />
|
||||||
<span className="font-mono text-[9px] text-text-muted">{item.id}</span>
|
<span className="font-mono text-[9px] text-text-muted">{item.id}</span>
|
||||||
<span className="line-clamp-1 text-[10px] font-medium text-text-body">{item.title}</span>
|
<span className="line-clamp-1 text-[10px] font-medium text-text-body">{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.epicTitle ? (
|
{item.epicTitle ? (
|
||||||
<div className="pl-3.5 text-[9px] text-text-muted/60 truncate max-w-full pr-5">
|
<div className="pl-3.5 text-[9px] text-text-muted/60 truncate max-w-full pr-5">
|
||||||
<span className="group-hover:text-sky-300/70 transition-colors">↳ {item.epicTitle}</span>
|
<span className="group-hover:text-sky-300/70 transition-colors">↳ {item.epicTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Footer Metadata: Assignee, Due Date */}
|
{/* Footer Metadata: Assignee, Due Date */}
|
||||||
<div className={`mt-3 flex w-full items-center justify-between border-t border-white/5 pt-3 text-[10px] text-text-muted/60`}>
|
<div className={`mt-3 flex w-full items-center justify-between border-t border-white/5 pt-3 text-[10px] text-text-muted/60`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Assignee */}
|
{/* Assignee */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="i-lucide-user h-3 w-3 opacity-70" />
|
<span className="i-lucide-user h-3 w-3 opacity-70" />
|
||||||
<span>{issue.assignee ?? 'Unassigned'}</span>
|
<span>{issue.assignee ?? 'Unassigned'}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Due Date (if exists) */}
|
{/* Due Date (if exists) */}
|
||||||
{issue.due_at ? (
|
{issue.due_at ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="i-lucide-calendar h-3 w-3 opacity-70" />
|
<span className="i-lucide-calendar h-3 w-3 opacity-70" />
|
||||||
<span>{new Date(issue.due_at as string).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>
|
<span>{new Date(issue.due_at as string).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a responsive grid of task cards.
|
* Renders a responsive grid of task cards.
|
||||||
* Uses auto-fill with minmax to prevent cards from being too narrow to read.
|
* Uses auto-fill with minmax to prevent cards from being too narrow to read.
|
||||||
*/
|
*/
|
||||||
export function TaskCardGrid({ tasks, selectedId, blockerDetailsMap, blocksDetailsMap, actionableIds, onSelect }: TaskCardGridProps) {
|
export function TaskCardGrid({ tasks, selectedId, blockerDetailsMap, blocksDetailsMap, actionableIds, onSelect }: TaskCardGridProps) {
|
||||||
// Show an empty state when no tasks exist in the selected epic
|
// Show an empty state when no tasks exist in the selected epic
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-6 py-12 text-center">
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-6 py-12 text-center">
|
||||||
<p className="text-xs font-medium uppercase tracking-widest text-text-muted/50">No tasks in this epic</p>
|
<p className="text-xs font-medium uppercase tracking-widest text-text-muted/50">No tasks in this epic</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 overflow-y-auto overscroll-contain pr-1 custom-scrollbar grid-cols-[repeat(auto-fill,minmax(18rem,1fr))]">
|
<div className="flex gap-3 overflow-x-auto overscroll-contain pb-2 custom-scrollbar" style={{ flexWrap: 'nowrap' }}>
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
issue={task}
|
issue={task}
|
||||||
selected={selectedId === task.id}
|
selected={selectedId === task.id}
|
||||||
blockers={blockerDetailsMap?.get(task.id) ?? []}
|
blockers={blockerDetailsMap?.get(task.id) ?? []}
|
||||||
blocking={blocksDetailsMap?.get(task.id) ?? []}
|
blocking={blocksDetailsMap?.get(task.id) ?? []}
|
||||||
isActionable={actionableIds?.has(task.id) ?? false}
|
isActionable={actionableIds?.has(task.id) ?? false}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,14 @@ export function UnifiedShell({
|
||||||
const selectedIssue = taskId ? issues.find((issue) => issue.id === taskId) ?? null : null;
|
const selectedIssue = taskId ? issues.find((issue) => issue.id === taskId) ?? null : null;
|
||||||
|
|
||||||
const handleGraphSelect = useMemo(() => (id: string) => {
|
const handleGraphSelect = useMemo(() => (id: string) => {
|
||||||
setTaskId(id);
|
// Toggle: clicking the same node again closes the conversation panel
|
||||||
setCustomRightPanel(null); // Reset when switching context
|
if (taskId === id) {
|
||||||
}, [setTaskId]);
|
setTaskId(null);
|
||||||
|
} else {
|
||||||
|
setTaskId(id);
|
||||||
|
}
|
||||||
|
setCustomRightPanel(null);
|
||||||
|
}, [taskId, setTaskId]);
|
||||||
|
|
||||||
const handleCardSelect = useMemo(() => (id: string) => {
|
const handleCardSelect = useMemo(() => (id: string) => {
|
||||||
if (view === 'social') {
|
if (view === 'social') {
|
||||||
|
|
@ -203,17 +208,15 @@ export function UnifiedShell({
|
||||||
{renderMiddleContent()}
|
{renderMiddleContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RESIZE HANDLE: Right (only when panel open) */}
|
{/* RESIZE HANDLE: Right */}
|
||||||
{panel === 'open' && <ResizeHandle direction="right" onResize={handleRightResize} />}
|
<ResizeHandle direction="right" onResize={handleRightResize} />
|
||||||
|
|
||||||
{/* RIGHT PANEL */}
|
{/* RIGHT PANEL: always visible, content adapts to selection */}
|
||||||
{panel === 'open' && (
|
<div style={{ width: rightWidth }} className="flex-shrink-0 overflow-hidden">
|
||||||
<div style={{ width: rightWidth }} className="flex-shrink-0 overflow-hidden">
|
<RightPanel isOpen={true}>
|
||||||
<RightPanel isOpen={true}>
|
{renderRightPanelContent()}
|
||||||
{renderRightPanelContent()}
|
</RightPanel>
|
||||||
</RightPanel>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* THREAD DRAWER: Popup overlay when a task is selected */}
|
{/* THREAD DRAWER: Popup overlay when a task is selected */}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,8 @@ function WorkflowGraphInner({
|
||||||
isAssignMode: assignMode,
|
isAssignMode: assignMode,
|
||||||
labels: issue.labels,
|
labels: issue.labels,
|
||||||
archetypes: archetypes,
|
archetypes: archetypes,
|
||||||
|
selectedTaskId: selectedId,
|
||||||
|
onConversationOpen: onSelect,
|
||||||
},
|
},
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
sourcePosition: Position.Right,
|
sourcePosition: Position.Right,
|
||||||
|
|
@ -178,7 +180,7 @@ function WorkflowGraphInner({
|
||||||
nodes: layoutDagre(baseNodes, graphEdges),
|
nodes: layoutDagre(baseNodes, graphEdges),
|
||||||
edges: graphEdges,
|
edges: graphEdges,
|
||||||
};
|
};
|
||||||
}, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, archetypes, assignMode]);
|
}, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, archetypes, assignMode, onSelect]);
|
||||||
|
|
||||||
const nodeTypes: NodeTypes = useMemo(
|
const nodeTypes: NodeTypes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,8 @@ export function useUrlState(): UrlState {
|
||||||
}, [updateUrl]);
|
}, [updateUrl]);
|
||||||
|
|
||||||
const setEpicId = useCallback((id: string | null) => {
|
const setEpicId = useCallback((id: string | null) => {
|
||||||
updateUrl({ epic: id });
|
// Selecting an epic clears any active task conversation so SwarmCommandFeed shows
|
||||||
|
updateUrl({ epic: id, task: null });
|
||||||
}, [updateUrl]);
|
}, [updateUrl]);
|
||||||
|
|
||||||
const togglePanel = toggleRightPanel;
|
const togglePanel = toggleRightPanel;
|
||||||
|
|
|
||||||
133
tests/components/graph/graph-node-conversation.test.tsx
Normal file
133
tests/components/graph/graph-node-conversation.test.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const NODE_CARD = path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx');
|
||||||
|
const RIGHT_PANEL = path.join(process.cwd(), 'src/components/activity/contextual-right-panel.tsx');
|
||||||
|
const SHELL = path.join(process.cwd(), 'src/components/shared/unified-shell.tsx');
|
||||||
|
const WORKFLOW_GRAPH = path.join(process.cwd(), 'src/components/shared/workflow-graph.tsx');
|
||||||
|
const PAGE = path.join(process.cwd(), 'src/app/page.tsx');
|
||||||
|
|
||||||
|
// ── GraphNodeCard: conversation icon ────────────────────────────────────────
|
||||||
|
|
||||||
|
test('GraphNodeCard - has MessageSquare conversation icon', async () => {
|
||||||
|
const src = await fs.readFile(NODE_CARD, 'utf-8');
|
||||||
|
assert.ok(src.includes('MessageSquare'), 'must import and render MessageSquare');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GraphNodeCard - does NOT use useUrlState (ReactFlow context/timing issues)', async () => {
|
||||||
|
const src = await fs.readFile(NODE_CARD, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
!src.includes('useUrlState'),
|
||||||
|
'GraphNodeCard must NOT call useUrlState — hooks inside ReactFlow node renderers have context issues; use prop-threading through data instead'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GraphNodeCard - reads onConversationOpen callback from node data', async () => {
|
||||||
|
const src = await fs.readFile(NODE_CARD, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('onConversationOpen'),
|
||||||
|
'GraphNodeCard must read onConversationOpen from data (not from hooks)'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
src.includes('onConversationOpen?.(id)') || src.includes('onConversationOpen(id)'),
|
||||||
|
'onConversationOpen must be called with the node id on icon click'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GraphNodeCard - stops event propagation on icon click', async () => {
|
||||||
|
const src = await fs.readFile(NODE_CARD, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('stopPropagation'),
|
||||||
|
'must call e.stopPropagation() to prevent ReactFlow from swallowing the click'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GraphNodeCard - highlights icon when selectedTaskId matches this node', async () => {
|
||||||
|
const src = await fs.readFile(NODE_CARD, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('selectedTaskId') || src.includes('isConvOpen'),
|
||||||
|
'must use selectedTaskId from data for icon highlight, not local URL state'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WorkflowGraph: threads callbacks into node data ─────────────────────────
|
||||||
|
|
||||||
|
test('WorkflowGraph - passes onConversationOpen into node data', async () => {
|
||||||
|
const src = await fs.readFile(WORKFLOW_GRAPH, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('onConversationOpen'),
|
||||||
|
'WorkflowGraph must pass onConversationOpen into node data so GraphNodeCard can call it without hooks'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WorkflowGraph - passes selectedTaskId into node data for icon highlight', async () => {
|
||||||
|
const src = await fs.readFile(WORKFLOW_GRAPH, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('selectedTaskId'),
|
||||||
|
'WorkflowGraph must pass selectedTaskId into node data so GraphNodeCard can highlight the active conversation icon'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ContextualRightPanel: back button wired ──────────────────────────────────
|
||||||
|
|
||||||
|
test('ContextualRightPanel - task branch onClose is NOT a no-op', async () => {
|
||||||
|
const src = await fs.readFile(RIGHT_PANEL, 'utf-8');
|
||||||
|
const hasNoOp = /onClose=\{.*\(\)\s*=>\s*\{\s*\}\s*\}/.test(src);
|
||||||
|
assert.ok(
|
||||||
|
!hasNoOp,
|
||||||
|
'onClose must not be a no-op () => {} — the back button in ThreadDrawer would do nothing'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ContextualRightPanel - task branch onClose clears taskId', async () => {
|
||||||
|
const src = await fs.readFile(RIGHT_PANEL, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('setTaskId(null)') || src.includes('clearSelection'),
|
||||||
|
'onClose must call setTaskId(null) so the back button navigates back to the activity feed'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ContextualRightPanel - swarm branch onClose clears swarmId', async () => {
|
||||||
|
const src = await fs.readFile(RIGHT_PANEL, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('setSwarmId(null)') || src.includes('clearSelection'),
|
||||||
|
'SwarmIdBranch onClose must call setSwarmId(null)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ContextualRightPanel - taskId if-branch appears before epicId if-branch', async () => {
|
||||||
|
const src = await fs.readFile(RIGHT_PANEL, 'utf-8');
|
||||||
|
const taskIfIdx = src.indexOf('if (taskId)');
|
||||||
|
const epicIfIdx = src.indexOf('if (epicId)');
|
||||||
|
assert.ok(taskIfIdx !== -1, 'must have an if (taskId) branch');
|
||||||
|
assert.ok(epicIfIdx !== -1, 'must have an if (epicId) branch');
|
||||||
|
assert.ok(
|
||||||
|
taskIfIdx < epicIfIdx,
|
||||||
|
'if (taskId) check must come before if (epicId) check — task conversation takes priority over epic feed when user clicks conversation icon in graph'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── UnifiedShell: right panel always visible ─────────────────────────────────
|
||||||
|
|
||||||
|
test('UnifiedShell - right panel NOT gated behind panel === open', async () => {
|
||||||
|
const src = await fs.readFile(SHELL, 'utf-8');
|
||||||
|
const hasGate = /panel\s*===\s*['"]open['"]\s*&&\s*[\s\S]{0,60}<div[^>]*rightWidth/.test(src);
|
||||||
|
assert.ok(!hasGate, 'Right panel div must render unconditionally');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UnifiedShell - passes taskId to ContextualRightPanel', async () => {
|
||||||
|
const src = await fs.readFile(SHELL, 'utf-8');
|
||||||
|
assert.ok(src.includes('taskId={taskId}'), 'must pass taskId so right panel shows conversation on selection');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── page.tsx: Suspense boundary for useSearchParams ──────────────────────────
|
||||||
|
|
||||||
|
test('page.tsx - wraps UnifiedShell in Suspense for useSearchParams', async () => {
|
||||||
|
const src = await fs.readFile(PAGE, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('Suspense'),
|
||||||
|
'page.tsx must wrap UnifiedShell in <Suspense> — without it, useSearchParams updates from deep components (like inside ReactFlow nodes) may not propagate correctly'
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue