From 861ae89491e6911b7dd9a2daec5f647ff4c80e84 Mon Sep 17 00:00:00 2001 From: ZenchantLive Date: Sun, 1 Mar 2026 13:51:46 -0800 Subject: [PATCH] fix: wire conversation panel to DAG nodes with toggle support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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 --- .beads/config.yaml | 70 +- package.json | 2 +- src/app/page.tsx | 17 +- .../activity/contextual-right-panel.tsx | 27 +- src/components/graph/graph-node-card.tsx | 862 +++++++++--------- src/components/graph/smart-dag.tsx | 542 +++++------ src/components/graph/task-card-grid.tsx | 764 ++++++++-------- src/components/shared/unified-shell.tsx | 29 +- src/components/shared/workflow-graph.tsx | 4 +- src/hooks/use-url-state.ts | 3 +- .../graph/graph-node-conversation.test.tsx | 133 +++ 11 files changed, 1279 insertions(+), 1174 deletions(-) create mode 100644 tests/components/graph/graph-node-conversation.test.tsx diff --git a/.beads/config.yaml b/.beads/config.yaml index ff8bc92..a5c5a34 100644 --- a/.beads/config.yaml +++ b/.beads/config.yaml @@ -1,67 +1,5 @@ -# Beads Configuration File -# This file configures default behavior for all bd commands in this repository -# All settings can also be set via environment variables (BD_* prefix) -# or overridden with command-line flags +sync: + mode: dolt-native -# Issue prefix for this repository (used by bd init) -# If not set, bd init will auto-detect from directory name -# 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 '. -# 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 +dolt: + auto-start: true diff --git a/package.json b/package.json index 343814e..ac75fa2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "eslint .", "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: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" diff --git a/src/app/page.tsx b/src/app/page.tsx index 8ab5a2c..57019a1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react'; import { UnifiedShell } from '../components/shared/unified-shell'; import { readIssuesForScope } from '../lib/aggregate-read'; import { resolveProjectScope } from '../lib/project-scope'; @@ -28,12 +29,14 @@ export default async function Page({ searchParams }: PageProps) { }); return ( - + + + ); } diff --git a/src/components/activity/contextual-right-panel.tsx b/src/components/activity/contextual-right-panel.tsx index 31dd78c..b1831fd 100644 --- a/src/components/activity/contextual-right-panel.tsx +++ b/src/components/activity/contextual-right-panel.tsx @@ -7,6 +7,7 @@ import { SwarmCommandFeed } from './swarm-command-feed'; import { ThreadDrawer } from '../shared/thread-drawer'; import { MissionInspector } from '../mission/mission-inspector'; import { useSwarmList } from '../../hooks/use-swarm-list'; +import { useUrlState } from '../../hooks/use-url-state'; export interface ContextualRightPanelProps { epicId?: string | null; @@ -17,23 +18,16 @@ export interface ContextualRightPanelProps { } export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectRoot }: ContextualRightPanelProps) { - if (epicId) { - return ( - - ); - } + const { setTaskId } = useUrlState(); + // Task conversation takes priority — user explicitly clicked the conversation icon if (taskId) { const selectedIssue = issues.find(i => i.id === taskId) ?? null; return ( {}} + onClose={() => setTaskId(null)} title={selectedIssue?.title ?? taskId} id={taskId} issue={selectedIssue} @@ -43,6 +37,16 @@ export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectR ); } + if (epicId) { + return ( + + ); + } + if (swarmId) { return ( s.swarmId === swarmId); // Fall back to swarmId as title while swarm list loads @@ -76,7 +81,7 @@ function SwarmIdBranch({ swarmId, projectRoot }: { swarmId: string; projectRoot: missionTitle={missionTitle} projectRoot={projectRoot} assignedAgents={assignedAgents} - onClose={() => {}} + onClose={() => setSwarmId(null)} onAssign={async () => {}} /> ); diff --git a/src/components/graph/graph-node-card.tsx b/src/components/graph/graph-node-card.tsx index bf0907f..b676095 100644 --- a/src/components/graph/graph-node-card.tsx +++ b/src/components/graph/graph-node-card.tsx @@ -1,421 +1,441 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; -import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; -import { Loader2, ChevronDown, UserPlus, X } from 'lucide-react'; -import type { BeadIssue } from '../../lib/types'; -import type { AgentArchetype } from '../../lib/types-swarm'; - -/** Data payload for each custom ReactFlow node. */ -export interface GraphNodeData { - /** Index signature required by ReactFlow's Node> constraint. */ - [key: string]: unknown; - /** Display title of the task/epic. */ - title: string; - /** Whether this is an epic or a regular issue. */ - kind: 'epic' | 'issue'; - /** Current workflow status. */ - status: BeadIssue['status']; - /** Priority level (0 = highest). */ - priority: number; - /** Number of issues blocking this node. */ - blockedBy: number; - /** Number of issues this node blocks. */ - blocks: number; - /** Whether this node has zero open blockers and is actionable. */ - isActionable: boolean; - /** Whether this node is part of a dependency cycle. */ - isCycleNode: boolean; - /** Whether this node should appear dimmed (not in selected chain). */ - isDimmed: boolean; - /** Tooltip lines describing blocker details for hover display. */ - blockerTooltipLines: string[]; - /** Labels attached to this node, including agent assignments (agent:archetype-id). */ - labels: string[]; - /** Available agent archetypes for assignment. */ - archetypes?: AgentArchetype[]; -} - -function getAssignedArchetypes(labels: string[], archetypes: AgentArchetype[]): AgentArchetype[] { - const ids = labels.filter(l => l.startsWith('agent:')).map(l => l.replace('agent:', '')); - return archetypes.filter(a => ids.includes(a.id)); -} - -/** - * Returns the Tailwind background color class for a status dot indicator. - */ -function statusDot(status: BeadIssue['status']): string { - switch (status) { - case 'open': - return 'bg-sky-400'; - case 'in_progress': - return 'bg-amber-400'; - case 'blocked': - return 'bg-rose-500'; - case 'deferred': - return 'bg-slate-400'; - case 'closed': - return 'bg-emerald-400'; - case 'pinned': - 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' - : 'bg-[var(--graph-node-default)] border-[var(--border-subtle)]'; -} - -/** - * Custom ReactFlow node component with: - * - 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 - * - Agent archetype assignment badges and dropdown - */ -export function GraphNodeCard({ id, data, selected }: NodeProps>) { - const [hovered, setHovered] = useState(false); - const [isAssigning, setIsAssigning] = useState(false); - const [assignError, setAssignError] = useState(null); - const [assignSuccess, setAssignSuccess] = useState(null); - - // Local state for labels with optimistic updates - const [localLabels, setLocalLabels] = useState(data.labels ?? []); - - // Track pending optimistic labels to prevent SSE overwrites - const pendingOptimisticLabels = useRef>(new Set()); - - // Sync local labels when parent data changes, but preserve pending optimistic updates - useEffect(() => { - const serverLabels = data.labels ?? []; - const pending = pendingOptimisticLabels.current; - if (pending.size === 0) { - setLocalLabels(serverLabels); - } else { - // Merge: include pending labels that aren't yet in server data - const merged = new Set([...serverLabels, ...pending]); - setLocalLabels(Array.from(merged)); - } - }, [data.labels]); - - const archetypes = data.archetypes ?? []; - const assignedArchetypes = getAssignedArchetypes(localLabels, archetypes); - const isClosed = data.status === 'closed'; - - const handleAssignAgent = async (archetypeId: string) => { - // Don't do anything if this archetype is already assigned - const labelToAdd = `agent:${archetypeId}`; - if (assignedArchetypes.some(a => a.id === archetypeId)) { - return; - } - - setIsAssigning(true); - setAssignError(null); - setAssignSuccess(null); - - // Track the new label as pending - pendingOptimisticLabels.current.add(labelToAdd); - - // Get current agent labels to remove (single archetype constraint) - const currentAgentLabels = localLabels.filter(l => l.startsWith('agent:')); - - // Optimistic update: remove all agent: labels, add new one - const previousLabels = localLabels; - setLocalLabels(prev => [...prev.filter(l => !l.startsWith('agent:')), labelToAdd]); - - try { - // First remove existing agent labels (if any) - if (currentAgentLabels.length > 0) { - for (const existingLabel of currentAgentLabels) { - const existingArchetypeId = existingLabel.replace('agent:', ''); - await fetch('/api/swarm/prep', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ beadId: id, archetypeId: existingArchetypeId }), - }); - } - } - - // Then add the new label - const response = await fetch('/api/swarm/prep', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ beadId: id, archetypeId }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error ?? 'Failed to assign agent'); - } - - const archetype = archetypes.find(a => a.id === archetypeId); - setAssignSuccess(`Assigned ${archetype?.name ?? archetypeId}`); - setTimeout(() => setAssignSuccess(null), 2000); - } catch (err) { - // Revert on error - restore previous labels - pendingOptimisticLabels.current.delete(labelToAdd); - setLocalLabels(previousLabels); - setAssignError(err instanceof Error ? err.message : 'Failed to assign agent'); - setTimeout(() => setAssignError(null), 3000); - } finally { - pendingOptimisticLabels.current.delete(labelToAdd); - setIsAssigning(false); - } - }; - - const handleUnassignAgent = async (archetypeId: string) => { - setIsAssigning(true); - setAssignError(null); - setAssignSuccess(null); - - // Optimistic update - const labelToRemove = `agent:${archetypeId}`; - setLocalLabels(prev => prev.filter(l => l !== labelToRemove)); - - try { - const response = await fetch('/api/swarm/prep', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ beadId: id, archetypeId }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error ?? 'Failed to unassign agent'); - } - - const archetype = archetypes.find(a => a.id === archetypeId); - setAssignSuccess(`Unassigned ${archetype?.name ?? archetypeId}`); - setTimeout(() => setAssignSuccess(null), 2000); - } catch (err) { - // Revert on error - setLocalLabels(prev => [...prev, labelToRemove]); - setAssignError(err instanceof Error ? err.message : 'Failed to unassign agent'); - setTimeout(() => setAssignError(null), 3000); - } finally { - setIsAssigning(false); - } - }; - - return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - - -
-
- {id} -
- {assignedArchetypes.map((archetype) => ( - - {archetype.name} - - ))} - {data.isActionable ? ( - - Ready - - ) : null} - {data.status === 'in_progress' ? ( - - In Progress - - ) : data.status === 'blocked' ? ( - - Blocked - - ) : data.status === 'closed' ? ( - - Done - - ) : null} - p{data.priority} - -
-
- -

- {data.title} -

- - {data.blockerTooltipLines.length > 0 ? ( -
-

Waiting on

- {data.blockerTooltipLines.slice(0, 2).map((line) => ( -

- {line} -

- ))} - {data.blockerTooltipLines.length > 2 ? ( -

- +{data.blockerTooltipLines.length - 2} more -

- ) : null} -
- ) : null} - - {!isClosed && archetypes.length > 0 ? ( -
- {assignSuccess ? ( -
- {assignSuccess} -
- ) : null} - {assignError ? ( -
- {assignError} -
- ) : null} - {assignedArchetypes.length > 0 ? ( -
- {assignedArchetypes.map((archetype) => ( -
- {archetype.name} - -
- ))} -
- ) : null} - - - - - - - {archetypes.map((archetype) => { - const isAssigned = assignedArchetypes.some(a => a.id === archetype.id); - return ( - handleAssignAgent(archetype.id)} - className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[11px] font-medium outline-none cursor-pointer transition-colors ${ - isAssigned - ? 'opacity-50 cursor-not-allowed' - : 'text-[var(--text-primary)] hover:bg-[var(--alpha-white-low)] focus:bg-[var(--alpha-white-low)]' - }`} - > - - {archetype.name} - {isAssigned && ( - Assigned - )} - - ); - })} - - - -
- ) : null} -
- - {hovered ? ( -
-
- {data.isActionable ? ( - <> -

Ready to work

-

- No open blockers. {data.blocks} task{data.blocks === 1 ? '' : 's'} depend{data.blocks === 1 ? 's' : ''} on this. -

- - ) : ( - <> -

- Blocked by {data.blockedBy} task{data.blockedBy === 1 ? '' : 's'} -

- {data.blockerTooltipLines.length > 0 ? ( -
    - {data.blockerTooltipLines.map((line) => ( -
  • - • {line} -
  • - ))} -
- ) : null} - - )} -
-
- ) : null} - - -
- ); -} +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { Loader2, ChevronDown, UserPlus, X, MessageSquare } from 'lucide-react'; +import type { BeadIssue } from '../../lib/types'; +import type { AgentArchetype } from '../../lib/types-swarm'; + +/** Data payload for each custom ReactFlow node. */ +export interface GraphNodeData { + /** Index signature required by ReactFlow's Node> constraint. */ + [key: string]: unknown; + /** Display title of the task/epic. */ + title: string; + /** Whether this is an epic or a regular issue. */ + kind: 'epic' | 'issue'; + /** Current workflow status. */ + status: BeadIssue['status']; + /** Priority level (0 = highest). */ + priority: number; + /** Number of issues blocking this node. */ + blockedBy: number; + /** Number of issues this node blocks. */ + blocks: number; + /** Whether this node has zero open blockers and is actionable. */ + isActionable: boolean; + /** Whether this node is part of a dependency cycle. */ + isCycleNode: boolean; + /** Whether this node should appear dimmed (not in selected chain). */ + isDimmed: boolean; + /** Tooltip lines describing blocker details for hover display. */ + blockerTooltipLines: string[]; + /** Labels attached to this node, including agent assignments (agent:archetype-id). */ + labels: string[]; + /** Available agent archetypes for assignment. */ + archetypes?: AgentArchetype[]; + /** ID of the currently selected task (for conversation icon highlight). */ + selectedTaskId?: string; + /** Opens the conversation panel for this node. Passed from UnifiedShell via WorkflowGraph. */ + onConversationOpen?: (id: string) => void; +} + +function getAssignedArchetypes(labels: string[], archetypes: AgentArchetype[]): AgentArchetype[] { + const ids = labels.filter(l => l.startsWith('agent:')).map(l => l.replace('agent:', '')); + return archetypes.filter(a => ids.includes(a.id)); +} + +/** + * Returns the Tailwind background color class for a status dot indicator. + */ +function statusDot(status: BeadIssue['status']): string { + switch (status) { + case 'open': + return 'bg-sky-400'; + case 'in_progress': + return 'bg-amber-400'; + case 'blocked': + return 'bg-rose-500'; + case 'deferred': + return 'bg-slate-400'; + case 'closed': + return 'bg-emerald-400'; + case 'pinned': + 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' + : 'bg-[var(--graph-node-default)] border-[var(--border-subtle)]'; +} + +/** + * Custom ReactFlow node component with: + * - 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 + * - Agent archetype assignment badges and dropdown + */ +export function GraphNodeCard({ id, data, selected }: NodeProps>) { + const onConversationOpen = data.onConversationOpen as ((id: string) => void) | undefined; + const isConvOpen = (data.selectedTaskId as string | undefined) === id; + const [hovered, setHovered] = useState(false); + const [isAssigning, setIsAssigning] = useState(false); + const [assignError, setAssignError] = useState(null); + const [assignSuccess, setAssignSuccess] = useState(null); + + // Local state for labels with optimistic updates + const [localLabels, setLocalLabels] = useState(data.labels ?? []); + + // Track pending optimistic labels to prevent SSE overwrites + const pendingOptimisticLabels = useRef>(new Set()); + + // Sync local labels when parent data changes, but preserve pending optimistic updates + useEffect(() => { + const serverLabels = data.labels ?? []; + const pending = pendingOptimisticLabels.current; + if (pending.size === 0) { + setLocalLabels(serverLabels); + } else { + // Merge: include pending labels that aren't yet in server data + const merged = new Set([...serverLabels, ...pending]); + setLocalLabels(Array.from(merged)); + } + }, [data.labels]); + + const archetypes = data.archetypes ?? []; + const assignedArchetypes = getAssignedArchetypes(localLabels, archetypes); + const isClosed = data.status === 'closed'; + + const handleAssignAgent = async (archetypeId: string) => { + // Don't do anything if this archetype is already assigned + const labelToAdd = `agent:${archetypeId}`; + if (assignedArchetypes.some(a => a.id === archetypeId)) { + return; + } + + setIsAssigning(true); + setAssignError(null); + setAssignSuccess(null); + + // Track the new label as pending + pendingOptimisticLabels.current.add(labelToAdd); + + // Get current agent labels to remove (single archetype constraint) + const currentAgentLabels = localLabels.filter(l => l.startsWith('agent:')); + + // Optimistic update: remove all agent: labels, add new one + const previousLabels = localLabels; + setLocalLabels(prev => [...prev.filter(l => !l.startsWith('agent:')), labelToAdd]); + + try { + // First remove existing agent labels (if any) + if (currentAgentLabels.length > 0) { + for (const existingLabel of currentAgentLabels) { + const existingArchetypeId = existingLabel.replace('agent:', ''); + await fetch('/api/swarm/prep', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ beadId: id, archetypeId: existingArchetypeId }), + }); + } + } + + // Then add the new label + const response = await fetch('/api/swarm/prep', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ beadId: id, archetypeId }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error ?? 'Failed to assign agent'); + } + + const archetype = archetypes.find(a => a.id === archetypeId); + setAssignSuccess(`Assigned ${archetype?.name ?? archetypeId}`); + setTimeout(() => setAssignSuccess(null), 2000); + } catch (err) { + // Revert on error - restore previous labels + pendingOptimisticLabels.current.delete(labelToAdd); + setLocalLabels(previousLabels); + setAssignError(err instanceof Error ? err.message : 'Failed to assign agent'); + setTimeout(() => setAssignError(null), 3000); + } finally { + pendingOptimisticLabels.current.delete(labelToAdd); + setIsAssigning(false); + } + }; + + const handleUnassignAgent = async (archetypeId: string) => { + setIsAssigning(true); + setAssignError(null); + setAssignSuccess(null); + + // Optimistic update + const labelToRemove = `agent:${archetypeId}`; + setLocalLabels(prev => prev.filter(l => l !== labelToRemove)); + + try { + const response = await fetch('/api/swarm/prep', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ beadId: id, archetypeId }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error ?? 'Failed to unassign agent'); + } + + const archetype = archetypes.find(a => a.id === archetypeId); + setAssignSuccess(`Unassigned ${archetype?.name ?? archetypeId}`); + setTimeout(() => setAssignSuccess(null), 2000); + } catch (err) { + // Revert on error + setLocalLabels(prev => [...prev, labelToRemove]); + setAssignError(err instanceof Error ? err.message : 'Failed to unassign agent'); + setTimeout(() => setAssignError(null), 3000); + } finally { + setIsAssigning(false); + } + }; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + +
+
+
+ {id} + +
+
+ {assignedArchetypes.map((archetype) => ( + + {archetype.name} + + ))} + {data.isActionable ? ( + + Ready + + ) : null} + {data.status === 'in_progress' ? ( + + In Progress + + ) : data.status === 'blocked' ? ( + + Blocked + + ) : data.status === 'closed' ? ( + + Done + + ) : null} + p{data.priority} + +
+
+ +

+ {data.title} +

+ + {data.blockerTooltipLines.length > 0 ? ( +
+

Waiting on

+ {data.blockerTooltipLines.slice(0, 2).map((line) => ( +

+ {line} +

+ ))} + {data.blockerTooltipLines.length > 2 ? ( +

+ +{data.blockerTooltipLines.length - 2} more +

+ ) : null} +
+ ) : null} + + {!isClosed && archetypes.length > 0 ? ( +
+ {assignSuccess ? ( +
+ {assignSuccess} +
+ ) : null} + {assignError ? ( +
+ {assignError} +
+ ) : null} + {assignedArchetypes.length > 0 ? ( +
+ {assignedArchetypes.map((archetype) => ( +
+ {archetype.name} + +
+ ))} +
+ ) : null} + + + + + + + {archetypes.map((archetype) => { + const isAssigned = assignedArchetypes.some(a => a.id === archetype.id); + return ( + handleAssignAgent(archetype.id)} + className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[11px] font-medium outline-none cursor-pointer transition-colors ${ + isAssigned + ? 'opacity-50 cursor-not-allowed' + : 'text-[var(--text-primary)] hover:bg-[var(--alpha-white-low)] focus:bg-[var(--alpha-white-low)]' + }`} + > + + {archetype.name} + {isAssigned && ( + Assigned + )} + + ); + })} + + + +
+ ) : null} +
+ + {hovered ? ( +
+
+ {data.isActionable ? ( + <> +

Ready to work

+

+ No open blockers. {data.blocks} task{data.blocks === 1 ? '' : 's'} depend{data.blocks === 1 ? 's' : ''} on this. +

+ + ) : ( + <> +

+ Blocked by {data.blockedBy} task{data.blockedBy === 1 ? '' : 's'} +

+ {data.blockerTooltipLines.length > 0 ? ( +
    + {data.blockerTooltipLines.map((line) => ( +
  • + • {line} +
  • + ))} +
+ ) : null} + + )} +
+
+ ) : null} + + +
+ ); +} diff --git a/src/components/graph/smart-dag.tsx b/src/components/graph/smart-dag.tsx index f0e2988..331092e 100644 --- a/src/components/graph/smart-dag.tsx +++ b/src/components/graph/smart-dag.tsx @@ -1,271 +1,271 @@ -'use client'; - -import React, { useState, useMemo, useCallback } from 'react'; -import { Filter, UserPlus } from 'lucide-react'; - -import type { BeadIssue } from '../../lib/types'; -import type { GraphHopDepth } from '../../lib/graph-view'; -import { WorkflowGraph } from '../shared/workflow-graph'; -import { WorkflowTabs, type WorkflowTab } from './workflow-tabs'; -import { TaskCardGrid, type BlockerDetail } from './task-card-grid'; -import { useArchetypes } from '../../hooks/use-archetypes'; -import { useGraphAnalysis } from '../../hooks/use-graph-analysis'; - -export interface SmartDagProps { - issues: BeadIssue[]; - epicId?: string | null; - selectedTaskId?: string; - onSelectTask?: (id: string) => void; - projectRoot: string; - hideClosed?: boolean; - onAssignModeChange?: (assignMode: boolean) => void; - onSelectedIssueChange?: (issue: BeadIssue | null) => void; -} - -const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full']; - -export function SmartDag({ - issues, - epicId, - selectedTaskId, - onSelectTask, - projectRoot, - hideClosed: hideClosedProp = false, - onAssignModeChange, - onSelectedIssueChange, -}: SmartDagProps) { - const { archetypes } = useArchetypes(projectRoot); - - const [showFilters, setShowFilters] = useState(false); - const [activeTab, setActiveTab] = useState('tasks'); - const [assignMode, setAssignMode] = useState(false); - - const [hideClosed, setHideClosed] = useState(hideClosedProp); - const [depth, setDepth] = useState('full'); - const [blockingOnly, setBlockingOnly] = useState(false); - const [sortReadyFirst, setSortReadyFirst] = useState(true); - - const displayBeads = useMemo(() => { - if (!epicId) return issues; - return issues.filter(issue => { - if (issue.issue_type === 'epic') return false; - const parent = issue.dependencies.find(d => d.type === 'parent'); - return parent?.target === epicId; - }); - }, [issues, epicId]); - - const { - signalById, - cycleNodeIdSet, - actionableNodeIds, - blockerTooltipMap, - } = useGraphAnalysis(issues, projectRoot, selectedTaskId ?? null); - - const blockerDetailsMap = useMemo(() => { - const map = new Map(); - for (const issue of displayBeads) { - const blockers: BlockerDetail[] = []; - for (const dep of issue.dependencies) { - if (dep.type === 'blocks') { - const blocker = issues.find(i => i.id === dep.target); - if (blocker && blocker.status !== 'closed') { - blockers.push({ - id: blocker.id, - title: blocker.title, - status: blocker.status, - priority: blocker.priority, - }); - } - } - } - if (blockers.length > 0) { - map.set(issue.id, blockers); - } - } - return map; - }, [displayBeads, issues]); - - const blocksDetailsMap = useMemo(() => { - const map = new Map(); - for (const issue of displayBeads) { - const blocking: BlockerDetail[] = []; - for (const other of issues) { - for (const dep of other.dependencies) { - if (dep.type === 'blocks' && dep.target === issue.id) { - if (other.status !== 'closed') { - blocking.push({ - id: other.id, - title: other.title, - status: other.status, - priority: other.priority, - }); - } - } - } - } - if (blocking.length > 0) { - map.set(issue.id, blocking); - } - } - return map; - }, [displayBeads, issues]); - - const sortedTasks = useMemo(() => { - let tasks = displayBeads.filter(issue => - hideClosed ? issue.status !== 'closed' : true - ); - - if (blockingOnly && activeTab === 'dependencies') { - tasks = tasks.filter(issue => { - const blockers = blockerDetailsMap.get(issue.id) ?? []; - return blockers.length > 0 || issue.status === 'blocked'; - }); - } - - if (sortReadyFirst && activeTab === 'tasks') { - tasks = [...tasks].sort((a, b) => { - const aReady = actionableNodeIds.has(a.id) && a.status !== 'closed'; - const bReady = actionableNodeIds.has(b.id) && b.status !== 'closed'; - if (aReady && !bReady) return -1; - if (!aReady && bReady) return 1; - return a.priority - b.priority; - }); - } - - return tasks; - }, [displayBeads, hideClosed, blockingOnly, blockerDetailsMap, sortReadyFirst, actionableNodeIds, activeTab]); - - const handleAssignModeToggle = useCallback(() => { - const newMode = !assignMode; - setAssignMode(newMode); - onAssignModeChange?.(newMode); - }, [assignMode, onAssignModeChange]); - - const handleTaskSelect = useCallback((id: string, shouldOpenDrawer?: boolean) => { - onSelectTask?.(id); - const selectedIssue = issues.find(i => i.id === id) ?? null; - onSelectedIssueChange?.(selectedIssue); - }, [onSelectTask, issues, onSelectedIssueChange]); - - const selectedIssue = useMemo(() => - issues.find(i => i.id === selectedTaskId) ?? null, - [issues, selectedTaskId] - ); - - return ( -
-
-
- - - -
- - -
- - {showFilters ? ( -
- - - {activeTab === 'tasks' ? ( - - ) : null} - - {activeTab === 'dependencies' ? ( - <> -
- Depth: - -
- - - - ) : null} -
- ) : null} - -
- {activeTab === 'tasks' ? ( -
- -
- ) : ( -
- -
- )} -
-
- ); -} +'use client'; + +import React, { useState, useMemo, useCallback } from 'react'; +import { Filter, UserPlus } from 'lucide-react'; + +import type { BeadIssue } from '../../lib/types'; +import type { GraphHopDepth } from '../../lib/graph-view'; +import { WorkflowGraph } from '../shared/workflow-graph'; +import { WorkflowTabs, type WorkflowTab } from './workflow-tabs'; +import { TaskCardGrid, type BlockerDetail } from './task-card-grid'; +import { useArchetypes } from '../../hooks/use-archetypes'; +import { useGraphAnalysis } from '../../hooks/use-graph-analysis'; + +export interface SmartDagProps { + issues: BeadIssue[]; + epicId?: string | null; + selectedTaskId?: string; + onSelectTask?: (id: string) => void; + projectRoot: string; + hideClosed?: boolean; + onAssignModeChange?: (assignMode: boolean) => void; + onSelectedIssueChange?: (issue: BeadIssue | null) => void; +} + +const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full']; + +export function SmartDag({ + issues, + epicId, + selectedTaskId, + onSelectTask, + projectRoot, + hideClosed: hideClosedProp = false, + onAssignModeChange, + onSelectedIssueChange, +}: SmartDagProps) { + const { archetypes } = useArchetypes(projectRoot); + + const [showFilters, setShowFilters] = useState(false); + const [activeTab, setActiveTab] = useState('tasks'); + const [assignMode, setAssignMode] = useState(false); + + const [hideClosed, setHideClosed] = useState(true); + const [depth, setDepth] = useState('full'); + const [blockingOnly, setBlockingOnly] = useState(false); + const [sortReadyFirst, setSortReadyFirst] = useState(true); + + const displayBeads = useMemo(() => { + if (!epicId) return issues; + return issues.filter(issue => { + if (issue.issue_type === 'epic') return false; + const parent = issue.dependencies.find(d => d.type === 'parent'); + return parent?.target === epicId; + }); + }, [issues, epicId]); + + const { + signalById, + cycleNodeIdSet, + actionableNodeIds, + blockerTooltipMap, + } = useGraphAnalysis(issues, projectRoot, selectedTaskId ?? null); + + const blockerDetailsMap = useMemo(() => { + const map = new Map(); + for (const issue of displayBeads) { + const blockers: BlockerDetail[] = []; + for (const dep of issue.dependencies) { + if (dep.type === 'blocks') { + const blocker = issues.find(i => i.id === dep.target); + if (blocker && blocker.status !== 'closed') { + blockers.push({ + id: blocker.id, + title: blocker.title, + status: blocker.status, + priority: blocker.priority, + }); + } + } + } + if (blockers.length > 0) { + map.set(issue.id, blockers); + } + } + return map; + }, [displayBeads, issues]); + + const blocksDetailsMap = useMemo(() => { + const map = new Map(); + for (const issue of displayBeads) { + const blocking: BlockerDetail[] = []; + for (const other of issues) { + for (const dep of other.dependencies) { + if (dep.type === 'blocks' && dep.target === issue.id) { + if (other.status !== 'closed') { + blocking.push({ + id: other.id, + title: other.title, + status: other.status, + priority: other.priority, + }); + } + } + } + } + if (blocking.length > 0) { + map.set(issue.id, blocking); + } + } + return map; + }, [displayBeads, issues]); + + const sortedTasks = useMemo(() => { + let tasks = displayBeads.filter(issue => + hideClosed ? issue.status !== 'closed' : true + ); + + if (blockingOnly && activeTab === 'dependencies') { + tasks = tasks.filter(issue => { + const blockers = blockerDetailsMap.get(issue.id) ?? []; + return blockers.length > 0 || issue.status === 'blocked'; + }); + } + + if (sortReadyFirst && activeTab === 'tasks') { + tasks = [...tasks].sort((a, b) => { + const aReady = actionableNodeIds.has(a.id) && a.status !== 'closed'; + const bReady = actionableNodeIds.has(b.id) && b.status !== 'closed'; + if (aReady && !bReady) return -1; + if (!aReady && bReady) return 1; + return a.priority - b.priority; + }); + } + + return tasks; + }, [displayBeads, hideClosed, blockingOnly, blockerDetailsMap, sortReadyFirst, actionableNodeIds, activeTab]); + + const handleAssignModeToggle = useCallback(() => { + const newMode = !assignMode; + setAssignMode(newMode); + onAssignModeChange?.(newMode); + }, [assignMode, onAssignModeChange]); + + const handleTaskSelect = useCallback((id: string, shouldOpenDrawer?: boolean) => { + onSelectTask?.(id); + const selectedIssue = issues.find(i => i.id === id) ?? null; + onSelectedIssueChange?.(selectedIssue); + }, [onSelectTask, issues, onSelectedIssueChange]); + + const selectedIssue = useMemo(() => + issues.find(i => i.id === selectedTaskId) ?? null, + [issues, selectedTaskId] + ); + + return ( +
+
+
+ + + +
+ + +
+ + {showFilters ? ( +
+ + + {activeTab === 'tasks' ? ( + + ) : null} + + {activeTab === 'dependencies' ? ( + <> +
+ Depth: + +
+ + + + ) : null} +
+ ) : null} + +
+ {activeTab === 'tasks' ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/graph/task-card-grid.tsx b/src/components/graph/task-card-grid.tsx index 87485af..46b0afc 100644 --- a/src/components/graph/task-card-grid.tsx +++ b/src/components/graph/task-card-grid.tsx @@ -1,382 +1,382 @@ -'use client'; - -import type { BeadIssue } from '../../lib/types'; - -/** Props for an individual task card in the grid. */ -/** Details for a blocker task shown on the card. */ -export interface BlockerDetail { - id: string; - title: string; - status: BeadIssue['status']; - priority: BeadIssue['priority']; - epicTitle?: string; -} - -/** Props for an individual task card in the grid. */ -interface TaskCardProps { - /** The issue data for this card. */ - issue: BeadIssue; - /** Whether this card is the currently selected task. */ - selected: boolean; - /** List of issues blocking this task. */ - blockers: BlockerDetail[]; - /** List of issues this task blocks. */ - blocking: BlockerDetail[]; - /** Whether this task is actionable (unblocked). */ - isActionable: boolean; - /** Callback fired when the user clicks this card (or a blocker). */ - onSelect: (id: string, shouldOpenDrawer?: boolean) => void; -} - -/** Props for the TaskCardGrid component. */ -interface TaskCardGridProps { - /** List of tasks to display in the grid. */ - tasks: BeadIssue[]; - /** ID of the currently selected task, or null. */ - selectedId: string | null; - /** Map of issue ID to detailed blocker info. */ - blockerDetailsMap: Map; - /** Map of issue ID to detailed downstream blocking info. */ - blocksDetailsMap: Map; - /** Set of actionable (unblocked) task IDs. */ - actionableIds: Set; - /** Callback fired when the user selects a task. */ - onSelect: (id: string, shouldOpenDrawer?: boolean) => void; -} - -/** - * Returns the Tailwind background color class for a status dot indicator. - * Mirrors the statusDot function from the original monolith. - */ -function statusDot(status: BeadIssue['status']): string { - switch (status) { - case 'open': - return 'bg-emerald-400'; - case 'in_progress': - return 'bg-amber-400'; - case 'blocked': - return 'bg-rose-500'; - case 'deferred': - return 'bg-slate-400'; - case 'closed': - return 'bg-slate-400'; - case 'pinned': - return 'bg-violet-400'; - case 'hooked': - return 'bg-orange-400'; - default: - return 'bg-zinc-500'; - } -} - -/** - * Returns status-tinted gradient background for simplified flat styling. - */ -function statusGradient(status: BeadIssue['status']): string { - switch (status) { - case 'open': - return 'border-l-2 border-emerald-400 bg-emerald-500/15'; - case 'in_progress': - return 'border-l-2 border-amber-400 bg-amber-500/15'; - case 'blocked': - return 'border-l-2 border-rose-400 bg-rose-500/15'; - case 'closed': - return 'border-l-2 border-slate-400/60 bg-slate-500/10 opacity-80'; - case 'deferred': - return 'border-l-2 border-slate-400/60 bg-slate-500/10'; - default: - return 'border-l-2 border-slate-400/60 bg-slate-500/10'; - } -} - -/** - * Returns status-colored border for Aero Chrome styling. - */ -function statusBorder(status: BeadIssue['status']): string { - switch (status) { - case 'open': - return 'border-emerald-500/20'; - case 'in_progress': - return 'border-amber-500/20'; - case 'blocked': - return 'border-rose-500/20'; - case 'closed': - return 'border-rose-500/30'; - case 'deferred': - return 'border-slate-500/20'; - default: - return 'border-white/[0.06]'; - } -} - -/** - * Returns title text color class - greyed out for closed status. - */ -function titleColorClass(status: BeadIssue['status']): string { - return status === 'closed' ? 'text-text-muted/70' : 'text-text-strong'; -} - -/** - * 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 } { - // Actual blocked status always shows as Blocked in red - if (status === 'blocked') { - return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' }; - } - - // If effectively blocked (has open blockers), show Blocked (unless closed/done) - if (hasBlockers && status !== 'closed' && status !== 'in_progress') { - return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' }; - } - - switch (status) { - case 'in_progress': - return { label: 'In Progress', textColor: 'text-amber-400', bgColor: 'bg-amber-400/10' }; - case 'closed': - return { label: 'Done', textColor: 'text-emerald-400', bgColor: 'bg-emerald-400/10' }; - case 'deferred': - return { label: 'Deferred', textColor: 'text-slate-400', bgColor: 'bg-slate-400/10' }; - case 'open': - // Open with no blockers -> Ready - return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' }; - default: - return { label: status, textColor: 'text-zinc-400', bgColor: 'bg-zinc-400/10' }; - } -} - - - -/** - * A single task card displaying the issue ID, title, priority, type, assignee, - * and detailed blocker list (interactive). - */ -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 badge = statusBadge(issue.status, isActionable, hasBlockers); - 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 - const effectiveStatus: BeadIssue['status'] = issue.status === 'in_progress' ? 'in_progress' : - issue.status === 'blocked' ? 'blocked' : - hasBlockers ? 'blocked' : - issue.status; - - return ( -
onSelect(issue.id, false)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - 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 - ? '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)]' - }`} - > - {/* Expand / Open Drawer Button */} - - -
-
-
- - {issue.id} - {/* Status Badge */} - - {badge.label} - -
- {projectName ? ( -
- project: {projectName} -
- ) : null} -

- {issue.title} -

-
-
- - {/* Labels */} - {issue.labels?.length > 0 ? ( -
- {issue.labels.map((label) => ( - - {label} - - ))} -
- ) : null} - - {/* "Unlocks" section for blockers */} - {blockers.length > 0 ? ( -
-
-

Unlocks

-
- {blockers.map((blocker) => ( -
{ - e.stopPropagation(); - onSelect(blocker.id, false); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - 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" - > - {/* Expand Button */} - - -
- - {blocker.id} - {blocker.title} -
- {blocker.epicTitle ? ( -
- ↳ {blocker.epicTitle} -
- ) : null} -
- ))} -
-
-
- ) : null} - - {/* "Blocks" section (downstream) */} - {blocking.length > 0 ? ( -
0 ? 'mt-2' : 'mt-auto'} w-full`}> -
-

Blocks

-
- {blocking.map((item) => ( -
{ - e.stopPropagation(); - onSelect(item.id, false); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - 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" - > - {/* Expand Button */} - - -
- - {item.id} - {item.title} -
- {item.epicTitle ? ( -
- ↳ {item.epicTitle} -
- ) : null} -
- ))} -
-
-
- ) : null} - - {/* Footer Metadata: Assignee, Due Date */} -
-
- {/* Assignee */} -
- - {issue.assignee ?? 'Unassigned'} -
- {/* Due Date (if exists) */} - {issue.due_at ? ( -
- - {new Date(issue.due_at as string).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} -
- ) : null} -
-
-
- ); -} - -/** - * Renders a responsive grid of task cards. - * Uses auto-fill with minmax to prevent cards from being too narrow to read. - */ -export function TaskCardGrid({ tasks, selectedId, blockerDetailsMap, blocksDetailsMap, actionableIds, onSelect }: TaskCardGridProps) { - // Show an empty state when no tasks exist in the selected epic - if (tasks.length === 0) { - return ( -
-

No tasks in this epic

-
- ); - } - - return ( -
- {tasks.map((task) => ( - - ))} -
- ); -} +'use client'; + +import type { BeadIssue } from '../../lib/types'; + +/** Props for an individual task card in the grid. */ +/** Details for a blocker task shown on the card. */ +export interface BlockerDetail { + id: string; + title: string; + status: BeadIssue['status']; + priority: BeadIssue['priority']; + epicTitle?: string; +} + +/** Props for an individual task card in the grid. */ +interface TaskCardProps { + /** The issue data for this card. */ + issue: BeadIssue; + /** Whether this card is the currently selected task. */ + selected: boolean; + /** List of issues blocking this task. */ + blockers: BlockerDetail[]; + /** List of issues this task blocks. */ + blocking: BlockerDetail[]; + /** Whether this task is actionable (unblocked). */ + isActionable: boolean; + /** Callback fired when the user clicks this card (or a blocker). */ + onSelect: (id: string, shouldOpenDrawer?: boolean) => void; +} + +/** Props for the TaskCardGrid component. */ +interface TaskCardGridProps { + /** List of tasks to display in the grid. */ + tasks: BeadIssue[]; + /** ID of the currently selected task, or null. */ + selectedId: string | null; + /** Map of issue ID to detailed blocker info. */ + blockerDetailsMap: Map; + /** Map of issue ID to detailed downstream blocking info. */ + blocksDetailsMap: Map; + /** Set of actionable (unblocked) task IDs. */ + actionableIds: Set; + /** Callback fired when the user selects a task. */ + onSelect: (id: string, shouldOpenDrawer?: boolean) => void; +} + +/** + * Returns the Tailwind background color class for a status dot indicator. + * Mirrors the statusDot function from the original monolith. + */ +function statusDot(status: BeadIssue['status']): string { + switch (status) { + case 'open': + return 'bg-emerald-400'; + case 'in_progress': + return 'bg-amber-400'; + case 'blocked': + return 'bg-rose-500'; + case 'deferred': + return 'bg-slate-400'; + case 'closed': + return 'bg-slate-400'; + case 'pinned': + return 'bg-violet-400'; + case 'hooked': + return 'bg-orange-400'; + default: + return 'bg-zinc-500'; + } +} + +/** + * Returns status-tinted gradient background for simplified flat styling. + */ +function statusGradient(status: BeadIssue['status']): string { + switch (status) { + case 'open': + return 'border-l-2 border-emerald-400 bg-emerald-500/15'; + case 'in_progress': + return 'border-l-2 border-amber-400 bg-amber-500/15'; + case 'blocked': + return 'border-l-2 border-rose-400 bg-rose-500/15'; + case 'closed': + return 'border-l-2 border-slate-400/60 bg-slate-500/10 opacity-80'; + case 'deferred': + return 'border-l-2 border-slate-400/60 bg-slate-500/10'; + default: + return 'border-l-2 border-slate-400/60 bg-slate-500/10'; + } +} + +/** + * Returns status-colored border for Aero Chrome styling. + */ +function statusBorder(status: BeadIssue['status']): string { + switch (status) { + case 'open': + return 'border-emerald-500/20'; + case 'in_progress': + return 'border-amber-500/20'; + case 'blocked': + return 'border-rose-500/20'; + case 'closed': + return 'border-rose-500/30'; + case 'deferred': + return 'border-slate-500/20'; + default: + return 'border-white/[0.06]'; + } +} + +/** + * Returns title text color class - greyed out for closed status. + */ +function titleColorClass(status: BeadIssue['status']): string { + return status === 'closed' ? 'text-text-muted/70' : 'text-text-strong'; +} + +/** + * 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 } { + // Actual blocked status always shows as Blocked in red + if (status === 'blocked') { + return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' }; + } + + // If effectively blocked (has open blockers), show Blocked (unless closed/done) + if (hasBlockers && status !== 'closed' && status !== 'in_progress') { + return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' }; + } + + switch (status) { + case 'in_progress': + return { label: 'In Progress', textColor: 'text-amber-400', bgColor: 'bg-amber-400/10' }; + case 'closed': + return { label: 'Done', textColor: 'text-emerald-400', bgColor: 'bg-emerald-400/10' }; + case 'deferred': + return { label: 'Deferred', textColor: 'text-slate-400', bgColor: 'bg-slate-400/10' }; + case 'open': + // Open with no blockers -> Ready + return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' }; + default: + return { label: status, textColor: 'text-zinc-400', bgColor: 'bg-zinc-400/10' }; + } +} + + + +/** + * A single task card displaying the issue ID, title, priority, type, assignee, + * and detailed blocker list (interactive). + */ +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 badge = statusBadge(issue.status, isActionable, hasBlockers); + 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 + const effectiveStatus: BeadIssue['status'] = issue.status === 'in_progress' ? 'in_progress' : + issue.status === 'blocked' ? 'blocked' : + hasBlockers ? 'blocked' : + issue.status; + + return ( +
onSelect(issue.id, false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(issue.id, false); + } + }} + 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)]' + : 'hover:shadow-[0_8px_30px_rgba(0,0,0,0.4)]' + }`} + > + {/* Expand / Open Drawer Button */} + + +
+
+
+ + {issue.id} + {/* Status Badge */} + + {badge.label} + +
+ {projectName ? ( +
+ project: {projectName} +
+ ) : null} +

+ {issue.title} +

+
+
+ + {/* Labels */} + {issue.labels?.length > 0 ? ( +
+ {issue.labels.map((label) => ( + + {label} + + ))} +
+ ) : null} + + {/* "Unlocks" section for blockers */} + {blockers.length > 0 ? ( +
+
+

Unlocks

+
+ {blockers.map((blocker) => ( +
{ + e.stopPropagation(); + onSelect(blocker.id, false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + 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" + > + {/* Expand Button */} + + +
+ + {blocker.id} + {blocker.title} +
+ {blocker.epicTitle ? ( +
+ ↳ {blocker.epicTitle} +
+ ) : null} +
+ ))} +
+
+
+ ) : null} + + {/* "Blocks" section (downstream) */} + {blocking.length > 0 ? ( +
0 ? 'mt-2' : 'mt-auto'} w-full`}> +
+

Blocks

+
+ {blocking.map((item) => ( +
{ + e.stopPropagation(); + onSelect(item.id, false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + 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" + > + {/* Expand Button */} + + +
+ + {item.id} + {item.title} +
+ {item.epicTitle ? ( +
+ ↳ {item.epicTitle} +
+ ) : null} +
+ ))} +
+
+
+ ) : null} + + {/* Footer Metadata: Assignee, Due Date */} +
+
+ {/* Assignee */} +
+ + {issue.assignee ?? 'Unassigned'} +
+ {/* Due Date (if exists) */} + {issue.due_at ? ( +
+ + {new Date(issue.due_at as string).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} +
+ ) : null} +
+
+
+ ); +} + +/** + * Renders a responsive grid of task cards. + * Uses auto-fill with minmax to prevent cards from being too narrow to read. + */ +export function TaskCardGrid({ tasks, selectedId, blockerDetailsMap, blocksDetailsMap, actionableIds, onSelect }: TaskCardGridProps) { + // Show an empty state when no tasks exist in the selected epic + if (tasks.length === 0) { + return ( +
+

No tasks in this epic

+
+ ); + } + + return ( +
+ {tasks.map((task) => ( + + ))} +
+ ); +} diff --git a/src/components/shared/unified-shell.tsx b/src/components/shared/unified-shell.tsx index 1f261f0..212b921 100644 --- a/src/components/shared/unified-shell.tsx +++ b/src/components/shared/unified-shell.tsx @@ -63,9 +63,14 @@ export function UnifiedShell({ const selectedIssue = taskId ? issues.find((issue) => issue.id === taskId) ?? null : null; const handleGraphSelect = useMemo(() => (id: string) => { - setTaskId(id); - setCustomRightPanel(null); // Reset when switching context - }, [setTaskId]); + // Toggle: clicking the same node again closes the conversation panel + if (taskId === id) { + setTaskId(null); + } else { + setTaskId(id); + } + setCustomRightPanel(null); + }, [taskId, setTaskId]); const handleCardSelect = useMemo(() => (id: string) => { if (view === 'social') { @@ -203,17 +208,15 @@ export function UnifiedShell({ {renderMiddleContent()} - {/* RESIZE HANDLE: Right (only when panel open) */} - {panel === 'open' && } + {/* RESIZE HANDLE: Right */} + - {/* RIGHT PANEL */} - {panel === 'open' && ( -
- - {renderRightPanelContent()} - -
- )} + {/* RIGHT PANEL: always visible, content adapts to selection */} +
+ + {renderRightPanelContent()} + +
{/* THREAD DRAWER: Popup overlay when a task is selected */} diff --git a/src/components/shared/workflow-graph.tsx b/src/components/shared/workflow-graph.tsx index 7e2f2d5..7a35868 100644 --- a/src/components/shared/workflow-graph.tsx +++ b/src/components/shared/workflow-graph.tsx @@ -116,6 +116,8 @@ function WorkflowGraphInner({ isAssignMode: assignMode, labels: issue.labels, archetypes: archetypes, + selectedTaskId: selectedId, + onConversationOpen: onSelect, }, position: { x: 0, y: 0 }, sourcePosition: Position.Right, @@ -178,7 +180,7 @@ function WorkflowGraphInner({ nodes: layoutDagre(baseNodes, 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( () => ({ diff --git a/src/hooks/use-url-state.ts b/src/hooks/use-url-state.ts index 0fdad82..7456993 100644 --- a/src/hooks/use-url-state.ts +++ b/src/hooks/use-url-state.ts @@ -227,7 +227,8 @@ export function useUrlState(): UrlState { }, [updateUrl]); 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]); const togglePanel = toggleRightPanel; diff --git a/tests/components/graph/graph-node-conversation.test.tsx b/tests/components/graph/graph-node-conversation.test.tsx new file mode 100644 index 0000000..a9cf30a --- /dev/null +++ b/tests/components/graph/graph-node-conversation.test.tsx @@ -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}]*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 — without it, useSearchParams updates from deep components (like inside ReactFlow nodes) may not propagate correctly' + ); +});