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:
zenchantlive 2026-03-01 18:17:58 -08:00
parent 65d69ecbbc
commit c246ceaf21
165 changed files with 13730 additions and 1132 deletions

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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">

View file

@ -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) => (

View file

@ -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}

View file

@ -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(

View file

@ -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>
);
}
}

View file

@ -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>
);
}

View file

@ -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',

View 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>
);
}

View file

@ -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

View file

@ -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 />

View file

@ -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>

View file

@ -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(
() => ({

View file

@ -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>

View file

@ -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}
/>
);
})}