feat(ux): consolidate Launch Swarm + telemetry UX with minimized strip
- Removed broken LaunchSwarmDialog (formula-based) from TopBar/LeftPanel - All Rocket buttons (TopBar, LeftPanel, DAG nodes, social cards) now open AssignmentPanel (archetype-based) which actually works - Every Rocket clears taskId first so assignMode && !taskId condition passes - Conversation button priority: taskId always shows conversation, not assign panel - Added TelemetryStrip: minimized right sidebar with status dots when non-telemetry panel (conversation/assignment) is active - Live feed has minimize button → restores last taskId or assignMode - DAG nodes: Signal icon → restores telemetry feed - Social button on DAG nodes: single router.push to avoid race (setView + setTaskId) - Fixed social card message button: opens right panel with drawer:closed (no popup) Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
parent
65d69ecbbc
commit
c246ceaf21
165 changed files with 13730 additions and 1132 deletions
|
|
@ -111,25 +111,25 @@ export function formatRelativeTime(timestamp: string): string {
|
|||
function getAgentTone(status: AgentStatus): AgentTone {
|
||||
const tones: Record<AgentStatus, AgentTone> = {
|
||||
active: {
|
||||
cardClass: 'bg-[#173126]',
|
||||
cardClass: 'bg-[var(--status-ready)]',
|
||||
labelClass: 'text-[#7CB97A]',
|
||||
ringClass: 'ring-[#7CB97A]/45',
|
||||
glowClass: 'bg-[#7CB97A]/30',
|
||||
},
|
||||
stale: {
|
||||
cardClass: 'bg-[#322817]',
|
||||
cardClass: 'bg-[var(--status-in-progress)]',
|
||||
labelClass: 'text-[#D4A574]',
|
||||
ringClass: 'ring-[#D4A574]/45',
|
||||
glowClass: 'bg-[#D4A574]/30',
|
||||
},
|
||||
stuck: {
|
||||
cardClass: 'bg-[#341a1f]',
|
||||
cardClass: 'bg-[var(--status-blocked)]',
|
||||
labelClass: 'text-[#C97A7A]',
|
||||
ringClass: 'ring-[#C97A7A]/45',
|
||||
glowClass: 'bg-[#C97A7A]/30',
|
||||
},
|
||||
dead: {
|
||||
cardClass: 'bg-[#2b232b]',
|
||||
cardClass: 'bg-[var(--surface-primary)]',
|
||||
labelClass: 'text-[#A78A94]',
|
||||
ringClass: 'ring-[#A78A94]/40',
|
||||
glowClass: 'bg-[#A78A94]/25',
|
||||
|
|
@ -147,84 +147,84 @@ export function getEventTone(kind: string): EventTone {
|
|||
label: 'Created',
|
||||
labelClass: 'text-[#7CB97A]',
|
||||
dotClass: 'bg-[#7CB97A]',
|
||||
cardClass: 'bg-[#182f25]',
|
||||
cardClass: 'bg-[var(--status-ready)]',
|
||||
idClass: 'text-[#9ACB98]',
|
||||
},
|
||||
opened: {
|
||||
label: 'Opened',
|
||||
labelClass: 'text-[#7CB97A]',
|
||||
dotClass: 'bg-[#7CB97A]',
|
||||
cardClass: 'bg-[#182f25]',
|
||||
cardClass: 'bg-[var(--status-ready)]',
|
||||
idClass: 'text-[#9ACB98]',
|
||||
},
|
||||
closed: {
|
||||
label: 'Closed',
|
||||
labelClass: 'text-[#D4A574]',
|
||||
dotClass: 'bg-[#D4A574]',
|
||||
cardClass: 'bg-[#332716]',
|
||||
cardClass: 'bg-[var(--status-in-progress)]',
|
||||
idClass: 'text-[#DAB891]',
|
||||
},
|
||||
reopened: {
|
||||
label: 'Reopened',
|
||||
labelClass: 'text-[#5B95E8]',
|
||||
dotClass: 'bg-[#5B95E8]',
|
||||
cardClass: 'bg-[#1b2b43]',
|
||||
cardClass: 'bg-[var(--surface-primary)]',
|
||||
idClass: 'text-[#8DB4EF]',
|
||||
},
|
||||
status_changed: {
|
||||
label: 'Status changed',
|
||||
labelClass: 'text-[#D4A574]',
|
||||
dotClass: 'bg-[#D4A574]',
|
||||
cardClass: 'bg-[#2f2518]',
|
||||
cardClass: 'bg-[var(--status-in-progress)]',
|
||||
idClass: 'text-[#DAB891]',
|
||||
},
|
||||
priority_changed: {
|
||||
label: 'Priority changed',
|
||||
labelClass: 'text-[#D4A574]',
|
||||
dotClass: 'bg-[#D4A574]',
|
||||
cardClass: 'bg-[#2f2518]',
|
||||
cardClass: 'bg-[var(--status-in-progress)]',
|
||||
idClass: 'text-[#DAB891]',
|
||||
},
|
||||
assignee_changed: {
|
||||
label: 'Assigned',
|
||||
labelClass: 'text-[#D4A574]',
|
||||
dotClass: 'bg-[#D4A574]',
|
||||
cardClass: 'bg-[#2f2518]',
|
||||
cardClass: 'bg-[var(--status-in-progress)]',
|
||||
idClass: 'text-[#DAB891]',
|
||||
},
|
||||
dependency_added: {
|
||||
label: 'Dependency added',
|
||||
labelClass: 'text-[#D4A574]',
|
||||
dotClass: 'bg-[#D4A574]',
|
||||
cardClass: 'bg-[#2f2518]',
|
||||
cardClass: 'bg-[var(--status-in-progress)]',
|
||||
idClass: 'text-[#DAB891]',
|
||||
},
|
||||
dependency_removed: {
|
||||
label: 'Dependency removed',
|
||||
labelClass: 'text-[#C97A7A]',
|
||||
dotClass: 'bg-[#C97A7A]',
|
||||
cardClass: 'bg-[#321b21]',
|
||||
cardClass: 'bg-[var(--status-blocked)]',
|
||||
idClass: 'text-[#D9A9A9]',
|
||||
},
|
||||
heartbeat: {
|
||||
label: 'Heartbeat',
|
||||
labelClass: 'text-[#5BA8A0]',
|
||||
dotClass: 'bg-[#5BA8A0]',
|
||||
cardClass: 'bg-[#173034]',
|
||||
cardClass: 'bg-[var(--surface-primary)]',
|
||||
idClass: 'text-[#8BC9C1]',
|
||||
},
|
||||
commented: {
|
||||
label: 'Commented',
|
||||
labelClass: 'text-[#5BA8A0]',
|
||||
dotClass: 'bg-[#5BA8A0]',
|
||||
cardClass: 'bg-[#173034]',
|
||||
cardClass: 'bg-[var(--surface-primary)]',
|
||||
idClass: 'text-[#8BC9C1]',
|
||||
},
|
||||
comment_added: {
|
||||
label: 'Commented',
|
||||
labelClass: 'text-[#5BA8A0]',
|
||||
dotClass: 'bg-[#5BA8A0]',
|
||||
cardClass: 'bg-[#173034]',
|
||||
cardClass: 'bg-[var(--surface-primary)]',
|
||||
idClass: 'text-[#8BC9C1]',
|
||||
},
|
||||
};
|
||||
|
|
@ -234,7 +234,7 @@ export function getEventTone(kind: string): EventTone {
|
|||
label: normalized.replace(/_/g, ' '),
|
||||
labelClass: 'text-[#5BA8A0]',
|
||||
dotClass: 'bg-[#5BA8A0]',
|
||||
cardClass: 'bg-[#173034]',
|
||||
cardClass: 'bg-[var(--surface-primary)]',
|
||||
idClass: 'text-[#8BC9C1]',
|
||||
}
|
||||
);
|
||||
|
|
@ -299,7 +299,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
|||
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[linear-gradient(180deg,rgba(0,0,0,0.2),rgba(0,0,0,0.36))] shadow-[inset_10px_0_22px_-20px_rgba(0,0,0,0.9)]">
|
||||
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[var(--surface-secondary)]">
|
||||
{/* Collapsed Agent Icons with ZFC Rings */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{agentRoster.slice(0, 6).map(agent => (
|
||||
|
|
@ -316,7 +316,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
|||
agent.status === 'stale' ? 'ring-[#D4A574]/45' :
|
||||
agent.status === 'stuck' ? 'ring-[#C97A7A]/45' : 'ring-[#A78A94]/40'
|
||||
)}>
|
||||
<AvatarFallback className="text-[10px] font-bold bg-[#1a1a1a] text-text-muted">
|
||||
<AvatarFallback className="text-[10px] font-bold bg-[var(--surface-primary)] text-text-muted">
|
||||
{getInitials(agent.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
|
@ -340,9 +340,9 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#070f19] backdrop-blur-xl">
|
||||
<div className="flex flex-col h-full bg-[var(--surface-secondary)]">
|
||||
{/* AGENT ROSTER SECTION */}
|
||||
<div className="flex-shrink-0 p-4 bg-[#0b1625] shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)]">
|
||||
<div className="flex-shrink-0 p-4 bg-[var(--surface-primary)] border-b border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
|
||||
|
|
@ -368,7 +368,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
|||
getAgentTone(agent.status).glowClass
|
||||
)} />
|
||||
<Avatar className={cn("h-8 w-8 relative z-10 ring-1", getAgentTone(agent.status).ringClass)}>
|
||||
<AvatarFallback className="text-[10px] font-bold bg-[#252525]">
|
||||
<AvatarFallback className="text-[10px] font-bold bg-[var(--surface-primary)]">
|
||||
{getInitials(agent.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import { ActivityPanel } from './activity-panel';
|
||||
import { SwarmCommandFeed } from './swarm-command-feed';
|
||||
|
|
@ -15,9 +16,11 @@ export interface ContextualRightPanelProps {
|
|||
swarmId?: string | null;
|
||||
issues: BeadIssue[];
|
||||
projectRoot: string;
|
||||
actor?: string;
|
||||
onMinimize?: () => void;
|
||||
}
|
||||
|
||||
export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectRoot }: ContextualRightPanelProps) {
|
||||
export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectRoot, actor, onMinimize }: ContextualRightPanelProps) {
|
||||
const { setTaskId } = useUrlState();
|
||||
|
||||
// Task conversation takes priority — user explicitly clicked the conversation icon
|
||||
|
|
@ -32,6 +35,7 @@ export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectR
|
|||
id={taskId}
|
||||
issue={selectedIssue}
|
||||
projectRoot={projectRoot}
|
||||
actor={actor}
|
||||
onIssueUpdated={async () => {}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -58,10 +62,28 @@ export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectR
|
|||
|
||||
// Fallback to Global feed
|
||||
return (
|
||||
<ActivityPanel
|
||||
issues={issues}
|
||||
projectRoot={projectRoot}
|
||||
/>
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-primary)]">
|
||||
{onMinimize && (
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--border-subtle)] px-3 py-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--text-tertiary)]">Live Activity Feed</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMinimize}
|
||||
className="rounded p-1 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)]"
|
||||
aria-label="Minimize to telemetry"
|
||||
title="Minimize to telemetry"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ActivityPanel
|
||||
issues={issues}
|
||||
projectRoot={projectRoot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,24 @@ export function SwarmCommandFeed({ epicId, issues, projectRoot }: SwarmCommandFe
|
|||
return entries;
|
||||
}, [contextBeads, archetypes]);
|
||||
|
||||
// 3. Subscribe to real-time activity, filtering ONLY for this epic's children
|
||||
// 3. Load historical activity filtered to this epic's children
|
||||
useEffect(() => {
|
||||
if (contextBeadIds.size === 0) return;
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const res = await fetch(`/api/activity?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as ActivityEvent[];
|
||||
const filtered = data.filter(e => e?.beadId && contextBeadIds.has(e.beadId));
|
||||
setActivities(filtered.slice(0, 100));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
void loadHistory();
|
||||
}, [projectRoot, contextBeadIds]);
|
||||
|
||||
// 4. Subscribe to real-time activity, filtering ONLY for this epic's children
|
||||
useEffect(() => {
|
||||
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
|
||||
|
|
@ -76,9 +93,9 @@ export function SwarmCommandFeed({ epicId, issues, projectRoot }: SwarmCommandFe
|
|||
}, [projectRoot, contextBeadIds]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#050a10] border-l border-[var(--ui-border-soft)]">
|
||||
<div className="flex flex-col h-full bg-[var(--surface-secondary)] border-l border-[var(--ui-border-soft)]">
|
||||
{/* SQUAD ROSTER SECTION */}
|
||||
<div className="flex-shrink-0 p-4 bg-[#0a111a] shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)] z-10">
|
||||
<div className="flex-shrink-0 p-4 bg-[var(--surface-primary)] shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)] z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
|
||||
|
|
@ -96,7 +113,7 @@ export function SwarmCommandFeed({ epicId, issues, projectRoot }: SwarmCommandFe
|
|||
) : (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{rosterEntries.map((agent, i) => (
|
||||
<div key={i} className="flex gap-3 p-2.5 bg-[#0f1824] border border-[var(--ui-border-soft)] rounded-xl items-center shadow-lg transition-all hover:border-[var(--ui-accent-info)]/30">
|
||||
<div key={i} className="flex gap-3 p-2.5 bg-[var(--surface-elevated)] border border-[var(--ui-border-soft)] rounded-xl items-center shadow-lg transition-all hover:border-[var(--ui-accent-info)]/30">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-0.5 rounded-full blur-[2px] opacity-70 bg-emerald-500/20" />
|
||||
<Avatar className="h-9 w-9 relative z-10 ring-2 ring-emerald-500/40">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
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 { Columns2, Loader2, ChevronDown, Rocket, Signal, UserPlus, X, MessageSquare } from 'lucide-react';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||
|
||||
|
|
@ -39,6 +39,12 @@ export interface GraphNodeData {
|
|||
selectedTaskId?: string;
|
||||
/** Opens the conversation panel for this node. Passed from UnifiedShell via WorkflowGraph. */
|
||||
onConversationOpen?: (id: string) => void;
|
||||
/** Navigates to the Social view with this task selected. */
|
||||
onViewInSocial?: (id: string) => void;
|
||||
/** Opens the Swarm Assignment panel for this task. */
|
||||
onAssignMode?: (id: string) => void;
|
||||
/** Restores the live telemetry feed in the right panel. */
|
||||
onViewTelemetry?: (id: string) => void;
|
||||
}
|
||||
|
||||
function getAssignedArchetypes(labels: string[], archetypes: AgentArchetype[]): AgentArchetype[] {
|
||||
|
|
@ -89,6 +95,9 @@ function nodeStyle(kind: GraphNodeData['kind']): string {
|
|||
*/
|
||||
export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeData>>) {
|
||||
const onConversationOpen = data.onConversationOpen as ((id: string) => void) | undefined;
|
||||
const onViewInSocial = data.onViewInSocial as ((id: string) => void) | undefined;
|
||||
const onAssignMode = data.onAssignMode as ((id: string) => void) | undefined;
|
||||
const onViewTelemetry = data.onViewTelemetry as ((id: string) => void) | undefined;
|
||||
const isConvOpen = (data.selectedTaskId as string | undefined) === id;
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
|
|
@ -255,6 +264,46 @@ export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeDa
|
|||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
</button>
|
||||
{onViewInSocial ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onViewInSocial(id); }}
|
||||
className="rounded p-0.5 text-[var(--text-tertiary)]/40 transition-colors hover:text-[var(--accent-success)] hover:bg-[var(--alpha-white-low)]"
|
||||
title="View in Social"
|
||||
>
|
||||
<Columns2 className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
{onAssignMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onAssignMode(id); }}
|
||||
className="rounded p-0.5 text-emerald-400/50 transition-colors hover:text-emerald-400 hover:bg-[var(--alpha-white-low)]"
|
||||
title="Launch Swarm"
|
||||
>
|
||||
<Rocket className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
{onViewTelemetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onViewTelemetry(id); }}
|
||||
className="rounded p-0.5 text-[var(--accent-info)]/50 transition-colors hover:text-[var(--accent-info)] hover:bg-[var(--alpha-white-low)]"
|
||||
title="Live feed"
|
||||
>
|
||||
<Signal className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
{onViewTelemetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onViewTelemetry(id); }}
|
||||
className="rounded p-0.5 text-[var(--accent-info)]/50 transition-colors hover:text-[var(--accent-info)] hover:bg-[var(--alpha-white-low)]"
|
||||
title="Live feed"
|
||||
>
|
||||
<Signal className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{assignedArchetypes.map((archetype) => (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Filter, UserPlus } from 'lucide-react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useUrlState, buildUrlParams } from '../../hooks/use-url-state';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { GraphHopDepth } from '../../lib/graph-view';
|
||||
|
|
@ -18,6 +20,7 @@ export interface SmartDagProps {
|
|||
onSelectTask?: (id: string) => void;
|
||||
projectRoot: string;
|
||||
hideClosed?: boolean;
|
||||
initialTab?: WorkflowTab;
|
||||
onAssignModeChange?: (assignMode: boolean) => void;
|
||||
onSelectedIssueChange?: (issue: BeadIssue | null) => void;
|
||||
swarmId?: string;
|
||||
|
|
@ -32,14 +35,37 @@ export function SmartDag({
|
|||
onSelectTask,
|
||||
projectRoot,
|
||||
hideClosed: hideClosedProp = false,
|
||||
initialTab,
|
||||
onAssignModeChange,
|
||||
onSelectedIssueChange,
|
||||
swarmId,
|
||||
}: SmartDagProps) {
|
||||
const { archetypes } = useArchetypes(projectRoot);
|
||||
const { setTaskId } = useUrlState();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// Single router.push so view AND task are set atomically — two separate calls
|
||||
// would each rebuild from the same stale searchParams and the second would win.
|
||||
const handleViewInSocial = useCallback((id: string) => {
|
||||
const url = buildUrlParams(searchParams, { view: 'social', task: id, swarm: null, right: 'open', panel: 'open', drawer: null });
|
||||
router.push(url, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
const handleNodeAssignMode = useCallback((_id: string) => {
|
||||
setTaskId(null); // must clear task first so assignMode && !taskId renders AssignmentPanel
|
||||
setAssignMode(true);
|
||||
onAssignModeChange?.(true);
|
||||
}, [onAssignModeChange, setTaskId]);
|
||||
|
||||
const handleNodeTelemetry = useCallback((_id: string) => {
|
||||
setTaskId(null);
|
||||
setAssignMode(false);
|
||||
onAssignModeChange?.(false);
|
||||
}, [onAssignModeChange, setTaskId]);
|
||||
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<WorkflowTab>('tasks');
|
||||
const [activeTab, setActiveTab] = useState<WorkflowTab>(initialTab ?? 'tasks');
|
||||
const [assignMode, setAssignMode] = useState(false);
|
||||
|
||||
const [hideClosed, setHideClosed] = useState(true);
|
||||
|
|
@ -262,6 +288,9 @@ export function SmartDag({
|
|||
beads={sortedTasks}
|
||||
selectedId={selectedTaskId}
|
||||
onSelect={onSelectTask}
|
||||
onViewInSocial={handleViewInSocial}
|
||||
onAssignMode={handleNodeAssignMode}
|
||||
onViewTelemetry={handleNodeTelemetry}
|
||||
hideClosed={hideClosed}
|
||||
archetypes={archetypes}
|
||||
assignMode={assignMode}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export function KanbanPage({
|
|||
);
|
||||
const graphHref = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('view', 'graph');
|
||||
if (projectScopeMode !== 'single') {
|
||||
params.set('mode', projectScopeMode);
|
||||
}
|
||||
|
|
@ -114,7 +115,7 @@ export function KanbanPage({
|
|||
params.set('project', projectScopeKey);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/graph?${query}` : '/graph';
|
||||
return query ? `/?${query}` : '/?view=graph';
|
||||
}, [projectScopeKey, projectScopeMode]);
|
||||
const allowMutations = projectScopeMode === 'single';
|
||||
const blockedTree = useMemo(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,47 @@ interface ThreadItem {
|
|||
data: any;
|
||||
}
|
||||
|
||||
export type CoordMessageAction = 'read' | 'ack';
|
||||
|
||||
export function buildCoordMessageActionEvent(params: {
|
||||
action: CoordMessageAction;
|
||||
message: AgentMessage;
|
||||
beadId: string;
|
||||
projectRoot: string;
|
||||
nowIso?: string;
|
||||
}): Record<string, unknown> {
|
||||
const now = params.nowIso ?? new Date().toISOString();
|
||||
const eventType = params.action === 'read' ? 'READ' : 'ACK';
|
||||
const compactNow = now.replace(/[-:.TZ]/g, '');
|
||||
return {
|
||||
version: 'coord.v1',
|
||||
kind: 'coord_event',
|
||||
issue_id: params.beadId,
|
||||
actor: params.message.to_agent,
|
||||
timestamp: now,
|
||||
data: {
|
||||
event_type: eventType,
|
||||
event_id: `evt_${eventType.toLowerCase()}_${compactNow}_${params.message.message_id}`,
|
||||
event_ref: params.message.message_id,
|
||||
project_root: params.projectRoot,
|
||||
payload: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCommentMutationBody(params: {
|
||||
projectRoot: string;
|
||||
text: string;
|
||||
actor?: string;
|
||||
}): Record<string, unknown> {
|
||||
const actor = params.actor?.trim();
|
||||
return {
|
||||
projectRoot: params.projectRoot,
|
||||
text: params.text,
|
||||
...(actor ? { actor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface ConversationDrawerProps {
|
||||
beadId: string | null;
|
||||
bead: BeadIssue | null;
|
||||
|
|
@ -26,6 +67,7 @@ interface ConversationDrawerProps {
|
|||
onBackToAgent?: () => void;
|
||||
embedded?: boolean;
|
||||
refreshTrigger?: number;
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
export function ConversationDrawer({
|
||||
|
|
@ -39,11 +81,13 @@ export function ConversationDrawer({
|
|||
showAgentContext,
|
||||
onBackToAgent,
|
||||
embedded = false,
|
||||
refreshTrigger = 0
|
||||
refreshTrigger = 0,
|
||||
actor = '',
|
||||
}: ConversationDrawerProps) {
|
||||
const [thread, setThread] = useState<ThreadItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [commentActor, setCommentActor] = useState(actor);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [metrics, setMetrics] = useState<AgentMetrics | null>(null);
|
||||
const [showSummary, setShowSummary] = useState(false);
|
||||
|
|
@ -77,6 +121,10 @@ export function ConversationDrawer({
|
|||
}
|
||||
}, [agentId, projectRoot]);
|
||||
|
||||
useEffect(() => {
|
||||
setCommentActor(actor);
|
||||
}, [actor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (beadId) fetchConversation({ silent: refreshTrigger > 0 });
|
||||
|
|
@ -107,7 +155,7 @@ export function ConversationDrawer({
|
|||
const res = await fetch(`/api/sessions/${beadId}/comment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectRoot, text: commentText })
|
||||
body: JSON.stringify(buildCommentMutationBody({ projectRoot, text: commentText, actor: commentActor })),
|
||||
});
|
||||
if (res.ok) {
|
||||
setCommentText('');
|
||||
|
|
@ -127,8 +175,16 @@ export function ConversationDrawer({
|
|||
if (!message) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${beadId}/messages/${messageId}/${action}?agent=${encodeURIComponent(message.to_agent)}`, {
|
||||
method: 'POST'
|
||||
const event = buildCoordMessageActionEvent({
|
||||
action,
|
||||
message,
|
||||
beadId,
|
||||
projectRoot,
|
||||
});
|
||||
const res = await fetch('/api/coord/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectRoot, event }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchConversation();
|
||||
|
|
@ -259,6 +315,12 @@ export function ConversationDrawer({
|
|||
{beadId && !showSummary && (
|
||||
<footer className="border-t border-white/5 bg-white/[0.01] p-6 flex-none shadow-[0_-12px_32px_rgba(0,0,0,0.2)]">
|
||||
<form onSubmit={handleAddComment} className="space-y-4">
|
||||
<input
|
||||
value={commentActor}
|
||||
onChange={(e) => setCommentActor(e.target.value)}
|
||||
placeholder="Comment as (username)"
|
||||
className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-xs text-text-body outline-none transition-all focus:border-sky-500/50 focus:ring-1 focus:ring-sky-500/20 placeholder:text-text-muted/30"
|
||||
/>
|
||||
<textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
|
|
@ -420,4 +482,4 @@ function ThreadRow({ item, onRead, onAck }: {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Star, Rocket } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Rocket, Star } from 'lucide-react';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useUrlState, type ViewType } from '../../hooks/use-url-state';
|
||||
import { LaunchSwarmDialog } from '../swarm/launch-dialog';
|
||||
|
||||
export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
|
||||
export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
||||
|
|
@ -27,7 +26,7 @@ export interface LeftPanelProps {
|
|||
onEpicEdit?: (epicId: string) => void;
|
||||
filters: LeftPanelFilters;
|
||||
onFiltersChange: (filters: LeftPanelFilters) => void;
|
||||
projectRoot: string;
|
||||
onAssignMode?: (epicId: string) => void;
|
||||
}
|
||||
|
||||
interface EpicEntry {
|
||||
|
|
@ -175,11 +174,10 @@ function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, filters, onFiltersChange, projectRoot }: LeftPanelProps) {
|
||||
export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, filters, onFiltersChange, onAssignMode }: LeftPanelProps) {
|
||||
const { view, setView } = useUrlState();
|
||||
const entries = useMemo(() => buildEntries(issues), [issues]);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [launchSwarmEpicId, setLaunchSwarmEpicId] = useState<string | null>(null);
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.query.trim().length > 0 ||
|
||||
|
|
@ -375,10 +373,7 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, fi
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLaunchSwarmEpicId(epic.id);
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); onEpicSelect?.(epic.id); onAssignMode?.(epic.id); }}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 transition-colors hover:bg-emerald-500/20"
|
||||
aria-label={`Launch Swarm for ${epic.title}`}
|
||||
title="Launch Swarm"
|
||||
|
|
@ -464,14 +459,6 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, fi
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
{launchSwarmEpicId && (
|
||||
<LaunchSwarmDialog
|
||||
projectRoot={projectRoot}
|
||||
onSuccess={() => {
|
||||
setLaunchSwarmEpicId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BeadStatus } from '@/lib/types';
|
||||
import type { BeadStatus } from '@/src/lib/types';
|
||||
import type { SocialCardStatus } from '@/src/lib/social-cards';
|
||||
|
||||
type BadgeSize = 'sm' | 'md';
|
||||
type StatusType = BeadStatus | SocialCardStatus;
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: BeadStatus;
|
||||
status: StatusType;
|
||||
size?: BadgeSize;
|
||||
}
|
||||
|
||||
const STATUS_CLASSES: Partial<Record<BeadStatus, string>> = {
|
||||
const STATUS_CLASSES: Partial<Record<StatusType, string>> = {
|
||||
open: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
|
||||
ready: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
|
||||
in_progress: 'border-green-500/30 bg-green-500/15 text-green-200',
|
||||
blocked: 'border-amber-500/30 bg-amber-500/15 text-amber-200',
|
||||
deferred: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
|
||||
|
|
@ -24,8 +27,9 @@ const SIZE_CLASSES: Record<BadgeSize, string> = {
|
|||
md: 'text-xs px-2.5 py-0.5',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Partial<Record<BeadStatus, string>> = {
|
||||
const STATUS_LABELS: Partial<Record<StatusType, string>> = {
|
||||
open: 'Open',
|
||||
ready: 'Ready',
|
||||
in_progress: 'In Progress',
|
||||
blocked: 'Blocked',
|
||||
deferred: 'Deferred',
|
||||
|
|
|
|||
61
src/components/shared/telemetry-strip.tsx
Normal file
61
src/components/shared/telemetry-strip.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use client';
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
interface TelemetryStripProps {
|
||||
issues: BeadIssue[];
|
||||
onMaximize: () => void;
|
||||
}
|
||||
|
||||
interface Dot {
|
||||
color: string;
|
||||
glow: string;
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function TelemetryStrip({ issues, onMaximize }: TelemetryStripProps) {
|
||||
const tasks = issues.filter((i) => i.issue_type !== 'epic');
|
||||
const blocked = tasks.filter((i) => i.status === 'blocked').length;
|
||||
const active = tasks.filter((i) => i.status === 'in_progress').length;
|
||||
const ready = tasks.filter((i) => i.status === 'open').length;
|
||||
const done = tasks.filter((i) => i.status === 'closed').length;
|
||||
|
||||
const dots: Dot[] = [
|
||||
{ color: 'var(--accent-danger)', glow: 'rgba(255,76,114,0.4)', count: blocked, label: 'blocked' },
|
||||
{ color: 'var(--accent-warning)', glow: 'rgba(255,178,74,0.4)', count: active, label: 'active' },
|
||||
{ color: 'var(--accent-success)', glow: 'rgba(53,217,143,0.4)', count: ready, label: 'ready' },
|
||||
{ color: 'var(--text-tertiary)', glow: 'transparent', count: done, label: 'done' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-9 flex-shrink-0 flex-col items-center border-l border-[var(--border-subtle)] bg-[var(--surface-primary)] py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMaximize}
|
||||
className="mb-3 rounded p-1 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)]"
|
||||
title="Restore live feed"
|
||||
aria-label="Restore live feed"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{dots.map((dot) => (
|
||||
<div key={dot.label} className="flex flex-col items-center gap-0.5" title={`${dot.count} ${dot.label}`}>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full transition-all"
|
||||
style={{
|
||||
backgroundColor: dot.color,
|
||||
boxShadow: dot.count > 0 ? `0 0 6px 1px ${dot.glow}` : 'none',
|
||||
opacity: dot.count > 0 ? 1 : 0.25,
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-[8px] text-[var(--text-tertiary)]">{dot.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ interface ThreadDrawerProps {
|
|||
issue?: BeadIssue | null;
|
||||
projectRoot?: string;
|
||||
onIssueUpdated?: (issueId: string) => Promise<void> | void;
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
interface CommentFromApi {
|
||||
|
|
@ -52,11 +53,11 @@ async function postIssueUpdate(body: UpdateMutationPayload): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function postComment(projectRoot: string, id: string, text: string): Promise<void> {
|
||||
async function postComment(projectRoot: string, id: string, text: string, actor?: string): Promise<void> {
|
||||
const response = await fetch('/api/beads/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ projectRoot, id, text }),
|
||||
body: JSON.stringify({ projectRoot, id, text, ...(actor?.trim() ? { actor: actor.trim() } : {}) }),
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as { ok: boolean; error?: { message?: string } };
|
||||
|
|
@ -83,6 +84,7 @@ export function ThreadDrawer({
|
|||
issue,
|
||||
projectRoot,
|
||||
onIssueUpdated,
|
||||
actor,
|
||||
}: ThreadDrawerProps) {
|
||||
const { isMobile } = useResponsive();
|
||||
const [comment, setComment] = useState('');
|
||||
|
|
@ -206,7 +208,7 @@ export function ThreadDrawer({
|
|||
setCommentState('sending');
|
||||
|
||||
try {
|
||||
await postComment(projectRoot, targetIssueId, comment.trim());
|
||||
await postComment(projectRoot, targetIssueId, comment.trim(), actor);
|
||||
setComment('');
|
||||
setCommentState('sent');
|
||||
// Refresh comments
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { LayoutGrid, Lock, Plus, Sidebar, SidebarClose, Rocket } from 'lucide-react';
|
||||
import { LayoutGrid, Lock, Plus, Rocket, Sidebar, SidebarClose } from 'lucide-react';
|
||||
import { useUrlState } from '../../hooks/use-url-state';
|
||||
import { useResponsive } from '../../hooks/use-responsive';
|
||||
import { ThemeToggle } from './theme-toggle';
|
||||
import { LaunchSwarmDialog } from '../swarm/launch-dialog';
|
||||
|
||||
export interface TopBarProps {
|
||||
onCreateTask?: () => Promise<void> | void;
|
||||
|
|
@ -18,7 +17,7 @@ export interface TopBarProps {
|
|||
busyCount?: number;
|
||||
actor?: string;
|
||||
onActorChange?: (name: string) => void;
|
||||
projectRoot?: string;
|
||||
onLaunchSwarm?: () => void;
|
||||
}
|
||||
|
||||
interface MetricTileProps {
|
||||
|
|
@ -87,7 +86,7 @@ export function TopBar({
|
|||
busyCount = 0,
|
||||
actor = '',
|
||||
onActorChange,
|
||||
projectRoot,
|
||||
onLaunchSwarm,
|
||||
}: TopBarProps) {
|
||||
const { leftPanel, toggleLeftPanel, rightPanel, toggleRightPanel, blockedOnly, toggleBlockedOnly } = useUrlState();
|
||||
const { isDesktop } = useResponsive();
|
||||
|
|
@ -150,10 +149,6 @@ export function TopBar({
|
|||
</span>
|
||||
</button>
|
||||
|
||||
{projectRoot && (
|
||||
<LaunchSwarmDialog projectRoot={projectRoot} />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -169,6 +164,17 @@ export function TopBar({
|
|||
</>
|
||||
)}
|
||||
|
||||
{onLaunchSwarm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLaunchSwarm}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.11em] text-emerald-400 transition-colors hover:bg-emerald-500/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
|
||||
aria-label="Launch Swarm"
|
||||
>
|
||||
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Launch Swarm
|
||||
</button>
|
||||
) : null}
|
||||
{onActorChange ? <IdentityChip actor={actor} onActorChange={onActorChange} /> : null}
|
||||
|
||||
<ThemeToggle />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
|
|
@ -17,6 +18,7 @@ import { SocialPage } from '../social/social-page';
|
|||
import { buildSocialCards } from '../../lib/social-cards';
|
||||
import { ContextualRightPanel } from '../activity/contextual-right-panel';
|
||||
import { AssignmentPanel } from '../graph/assignment-panel';
|
||||
import { TelemetryStrip } from './telemetry-strip';
|
||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
|
||||
import { useBdHealth } from '../../hooks/use-bd-health';
|
||||
|
|
@ -66,6 +68,10 @@ export function UnifiedShell({
|
|||
// Assign mode state for graph view
|
||||
const [assignMode, setAssignMode] = useState(false);
|
||||
const [selectedAssignIssue, setSelectedAssignIssue] = useState<BeadIssue | null>(null);
|
||||
|
||||
// Remember last non-telemetry state for minimize button
|
||||
const [lastTaskId, setLastTaskId] = useState<string | null>(null);
|
||||
const [lastAssignMode, setLastAssignMode] = useState(false);
|
||||
|
||||
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||
const { swarms: swarmCards } = useSwarmList(projectRoot);
|
||||
|
|
@ -108,6 +114,35 @@ export function UnifiedShell({
|
|||
setSelectedAssignIssue(issue);
|
||||
}, []);
|
||||
|
||||
// Social card Rocket: clear task and open AssignmentPanel in right panel
|
||||
const handleSocialRocket = useCallback(() => {
|
||||
setTaskId(null);
|
||||
setAssignMode(true);
|
||||
}, [setTaskId]);
|
||||
|
||||
// Minimize: restore last clicked thing (task or assign mode)
|
||||
const handleMinimize = useCallback(() => {
|
||||
if (lastTaskId) {
|
||||
setTaskId(lastTaskId);
|
||||
setAssignMode(false);
|
||||
} else if (lastAssignMode) {
|
||||
setTaskId(null);
|
||||
setAssignMode(true);
|
||||
}
|
||||
}, [lastTaskId, lastAssignMode, setTaskId]);
|
||||
|
||||
// Track last non-telemetry state changes
|
||||
useEffect(() => {
|
||||
if (taskId) setLastTaskId(taskId);
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (assignMode) setLastAssignMode(true);
|
||||
}, [assignMode]);
|
||||
|
||||
// Non-telemetry: conversation or assignment panel is active → show mini telemetry strip
|
||||
const isNonTelemetry = !!taskId || assignMode;
|
||||
|
||||
// Chat Mode Logic: If a card is selected (drawer='open'), we show Chat popup
|
||||
const isChatOpen = drawer === 'open' && (!!taskId || !!swarmId || !!epicId);
|
||||
const selectedEpic = epicId ? issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic') ?? null : null;
|
||||
|
|
@ -136,7 +171,7 @@ export function UnifiedShell({
|
|||
selectedTaskId={taskId ?? undefined}
|
||||
onSelectTask={handleGraphSelect}
|
||||
projectRoot={projectRoot}
|
||||
hideClosed={graphTab !== 'flow'}
|
||||
initialTab={graphTab === 'flow' ? 'dependencies' : 'tasks'}
|
||||
onAssignModeChange={handleAssignModeChange}
|
||||
onSelectedIssueChange={handleSelectedIssueChange}
|
||||
swarmId={swarmId ?? undefined}
|
||||
|
|
@ -146,7 +181,7 @@ export function UnifiedShell({
|
|||
|
||||
if (view === 'social') {
|
||||
return (
|
||||
<SocialPage
|
||||
<SocialPage
|
||||
issues={filteredIssues}
|
||||
selectedId={taskId ?? undefined}
|
||||
onSelect={handleCardSelect}
|
||||
|
|
@ -154,6 +189,7 @@ export function UnifiedShell({
|
|||
blockedOnly={blockedOnly}
|
||||
projectRoot={projectRoot}
|
||||
swarmId={swarmId ?? undefined}
|
||||
onRocketClick={handleSocialRocket}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -167,21 +203,36 @@ export function UnifiedShell({
|
|||
return customRightPanel;
|
||||
}
|
||||
|
||||
// Show AssignmentPanel when in graph view with assign mode enabled
|
||||
if (view === 'graph' && assignMode) {
|
||||
// Show AssignmentPanel when assign mode is enabled and no task conversation is active
|
||||
if (assignMode && !taskId) {
|
||||
return (
|
||||
<AssignmentPanel
|
||||
selectedIssue={selectedAssignIssue}
|
||||
projectRoot={projectRoot}
|
||||
issues={issues}
|
||||
epicId={epicId ?? undefined}
|
||||
onIssueUpdated={async () => { router.refresh(); }}
|
||||
/>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--border-subtle)] px-3 py-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--text-tertiary)]">Swarm Assignment</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAssignMode(false)}
|
||||
className="rounded p-1 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)]"
|
||||
aria-label="Close assignment panel"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<AssignmentPanel
|
||||
selectedIssue={selectedAssignIssue}
|
||||
projectRoot={projectRoot}
|
||||
issues={issues}
|
||||
epicId={epicId ?? undefined}
|
||||
onIssueUpdated={async () => { router.refresh(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: ContextualRightPanel
|
||||
return <ContextualRightPanel epicId={epicId} taskId={taskId} swarmId={swarmId} issues={issues} projectRoot={projectRoot} actor={actor} />;
|
||||
return <ContextualRightPanel epicId={epicId} taskId={taskId} swarmId={swarmId} issues={issues} projectRoot={projectRoot} actor={actor} onMinimize={handleMinimize} />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -194,7 +245,7 @@ export function UnifiedShell({
|
|||
idleCount={0}
|
||||
actor={actor}
|
||||
onActorChange={handleActorChange}
|
||||
projectRoot={projectRoot}
|
||||
onLaunchSwarm={() => { setTaskId(null); setAssignMode(true); }}
|
||||
/>
|
||||
{!bdHealth.loading && !bdHealth.healthy ? (
|
||||
<div className="border-b border-amber-500/35 bg-amber-500/12 px-4 py-2 text-xs text-amber-100">
|
||||
|
|
@ -216,7 +267,7 @@ export function UnifiedShell({
|
|||
onEpicEdit={(id) => { setEpicId(id); setDrawer('open'); }}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
projectRoot={projectRoot}
|
||||
onAssignMode={(epicId) => { setEpicId(epicId); setTaskId(null); setAssignMode(true); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -232,10 +283,18 @@ export function UnifiedShell({
|
|||
<ResizeHandle direction="right" onResize={handleRightResize} />
|
||||
|
||||
{/* RIGHT PANEL: always visible, content adapts to selection */}
|
||||
<div style={{ width: rightWidth }} className="flex-shrink-0 overflow-hidden">
|
||||
<RightPanel isOpen={true}>
|
||||
{renderRightPanelContent()}
|
||||
</RightPanel>
|
||||
<div style={{ width: rightWidth }} className="flex flex-shrink-0 overflow-hidden">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<RightPanel isOpen={true}>
|
||||
{renderRightPanelContent()}
|
||||
</RightPanel>
|
||||
</div>
|
||||
{isNonTelemetry && (
|
||||
<TelemetryStrip
|
||||
issues={issues}
|
||||
onMaximize={() => { setTaskId(null); setAssignMode(false); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export interface WorkflowGraphProps {
|
|||
beads: BeadIssue[];
|
||||
selectedId?: string;
|
||||
onSelect?: (id: string) => void;
|
||||
onViewInSocial?: (id: string) => void;
|
||||
onAssignMode?: (id: string) => void;
|
||||
onViewTelemetry?: (id: string) => void;
|
||||
className?: string;
|
||||
hideClosed?: boolean;
|
||||
archetypes?: AgentArchetype[];
|
||||
|
|
@ -65,6 +68,9 @@ function WorkflowGraphInner({
|
|||
beads,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onViewInSocial,
|
||||
onAssignMode,
|
||||
onViewTelemetry,
|
||||
className = '',
|
||||
hideClosed = false,
|
||||
archetypes = [],
|
||||
|
|
@ -119,6 +125,9 @@ function WorkflowGraphInner({
|
|||
archetypes: archetypes,
|
||||
selectedTaskId: selectedId,
|
||||
onConversationOpen: onSelect,
|
||||
onViewInSocial: onViewInSocial,
|
||||
onAssignMode: onAssignMode,
|
||||
onViewTelemetry: onViewTelemetry,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
sourcePosition: Position.Right,
|
||||
|
|
@ -181,7 +190,7 @@ function WorkflowGraphInner({
|
|||
nodes: layoutDagre(baseNodes, graphEdges),
|
||||
edges: graphEdges,
|
||||
};
|
||||
}, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, archetypes, assignMode, onSelect]);
|
||||
}, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, archetypes, assignMode, onSelect, onViewInSocial, onAssignMode, onViewTelemetry]);
|
||||
|
||||
const nodeTypes: NodeTypes = useMemo(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { KeyboardEvent, MouseEventHandler } from 'react';
|
||||
import { Activity, Clock3, GitBranch, Link2, MessageCircle, Orbit, UserPlus } from 'lucide-react';
|
||||
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, Rocket, UserPlus } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -26,6 +26,7 @@ interface SocialCardProps {
|
|||
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
|
||||
archetypes?: AgentArchetype[];
|
||||
swarmId?: string;
|
||||
onLaunchSwarm?: () => void;
|
||||
}
|
||||
|
||||
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
|
||||
|
|
@ -122,6 +123,7 @@ export function SocialCard({
|
|||
unblocksDetails = [],
|
||||
archetypes = [],
|
||||
swarmId,
|
||||
onLaunchSwarm,
|
||||
}: SocialCardProps) {
|
||||
const status = statusVisual(data.status);
|
||||
const { selectedArchetype, setSelectedArchetype, isAssigning, assignSuccess, handleAssign } = useArchetypePicker();
|
||||
|
|
@ -232,7 +234,8 @@ export function SocialCard({
|
|||
onJumpToGraph?.(data.id);
|
||||
}}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-info)] transition-colors hover:bg-[var(--alpha-white-low)]"
|
||||
aria-label="Open in graph"
|
||||
aria-label="View dependency graph"
|
||||
title="View dependency graph"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
|
@ -242,22 +245,26 @@ export function SocialCard({
|
|||
event.stopPropagation();
|
||||
onJumpToActivity?.(data.id);
|
||||
}}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-warning)] transition-colors hover:bg-[var(--alpha-white-low)]"
|
||||
aria-label="Open in activity"
|
||||
>
|
||||
<Orbit className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenThread?.();
|
||||
}}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-success)] transition-colors hover:bg-[var(--alpha-white-low)]"
|
||||
aria-label="Open thread"
|
||||
aria-label="View details"
|
||||
title="View details"
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{onLaunchSwarm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onLaunchSwarm();
|
||||
}}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-emerald-500/20 bg-emerald-500/10 text-emerald-400 transition-colors hover:bg-emerald-500/20"
|
||||
aria-label="Launch Swarm"
|
||||
title="Launch Swarm"
|
||||
>
|
||||
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface SocialPageProps {
|
|||
blockedOnly?: boolean;
|
||||
projectRoot: string;
|
||||
swarmId?: string;
|
||||
onRocketClick?: () => void;
|
||||
}
|
||||
|
||||
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
|
||||
|
|
@ -68,6 +69,7 @@ export function SocialPage({
|
|||
blockedOnly = false,
|
||||
projectRoot,
|
||||
swarmId,
|
||||
onRocketClick,
|
||||
}: SocialPageProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -216,6 +218,7 @@ export function SocialPage({
|
|||
onJumpToGraph={(id) =>
|
||||
navigateWithParams({
|
||||
view: 'graph',
|
||||
graphTab: 'flow',
|
||||
task: id,
|
||||
swarm: null,
|
||||
right: 'open',
|
||||
|
|
@ -241,6 +244,7 @@ export function SocialPage({
|
|||
unblocksDetails={toDependencyDetails(card.blocks)}
|
||||
archetypes={archetypes}
|
||||
swarmId={swarmId}
|
||||
onLaunchSwarm={onRocketClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue