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 () => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
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';
|
||||||
|
|
||||||
|
|
@ -35,6 +35,10 @@ export interface GraphNodeData {
|
||||||
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;
|
||||||
|
/** Opens the conversation panel for this node. Passed from UnifiedShell via WorkflowGraph. */
|
||||||
|
onConversationOpen?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAssignedArchetypes(labels: string[], archetypes: AgentArchetype[]): AgentArchetype[] {
|
function getAssignedArchetypes(labels: string[], archetypes: AgentArchetype[]): AgentArchetype[] {
|
||||||
|
|
@ -84,6 +88,8 @@ function nodeStyle(kind: GraphNodeData['kind']): string {
|
||||||
* - Agent archetype assignment badges and dropdown
|
* - Agent archetype assignment badges and dropdown
|
||||||
*/
|
*/
|
||||||
export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeData>>) {
|
export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeData>>) {
|
||||||
|
const onConversationOpen = data.onConversationOpen as ((id: string) => void) | undefined;
|
||||||
|
const isConvOpen = (data.selectedTaskId as string | undefined) === id;
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [isAssigning, setIsAssigning] = useState(false);
|
const [isAssigning, setIsAssigning] = useState(false);
|
||||||
const [assignError, setAssignError] = useState<string | null>(null);
|
const [assignError, setAssignError] = useState<string | null>(null);
|
||||||
|
|
@ -235,7 +241,21 @@ export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeDa
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-[var(--border-subtle)] pb-1.5 mb-1.5">
|
<div className="flex items-center justify-between gap-2 border-b border-[var(--border-subtle)] pb-1.5 mb-1.5">
|
||||||
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]/60">{id}</span>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]/60">{id}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onConversationOpen?.(id); }}
|
||||||
|
className={`rounded p-0.5 transition-colors ${
|
||||||
|
isConvOpen
|
||||||
|
? '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)]'
|
||||||
|
}`}
|
||||||
|
title={isConvOpen ? 'Close conversation' : 'Open conversation'}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{assignedArchetypes.map((archetype) => (
|
{assignedArchetypes.map((archetype) => (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export function SmartDag({
|
||||||
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);
|
||||||
|
|
@ -243,7 +243,7 @@ export function SmartDag({
|
||||||
|
|
||||||
<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}
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ function TaskCard({ issue, selected, blockers, blocking, isActionable, onSelect
|
||||||
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)]'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -365,7 +365,7 @@ export function TaskCardGrid({ tasks, selectedId, blockerDetailsMap, blocksDetai
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
||||||
|
|
|
||||||
|
|
@ -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