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
37
src/app/api/bd/health/route.ts
Normal file
37
src/app/api/bd/health/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { runBdCommand } from '../../../../lib/bridge';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectRoot = searchParams.get('projectRoot') ?? process.cwd();
|
||||
|
||||
const result = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['--version'],
|
||||
timeoutMs: 8_000,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const status = result.classification === 'not_found' ? 503 : 500;
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: result.classification ?? 'unknown',
|
||||
message: result.error ?? result.stderr ?? 'bd health check failed',
|
||||
},
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
data: {
|
||||
version: result.stdout,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export async function handleMutationRequest(request: Request, operation: Mutatio
|
|||
const payload = validateMutationPayload(operation, body);
|
||||
const result = await executeMutation(operation, payload);
|
||||
|
||||
const status = result.ok ? 200 : result.error?.classification === 'not_found' ? 404 : 400;
|
||||
const status = result.ok ? 200 : result.error?.classification === 'not_found' ? 503 : 400;
|
||||
return NextResponse.json(result, { status });
|
||||
} catch (error) {
|
||||
if (error instanceof MutationValidationError) {
|
||||
|
|
|
|||
56
src/app/api/coord/events/route.ts
Normal file
56
src/app/api/coord/events/route.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { writeCoordEvent } from '../../../../lib/coord-events';
|
||||
|
||||
interface CoordEventsDeps {
|
||||
writeCoordEvent: typeof writeCoordEvent;
|
||||
}
|
||||
|
||||
function parseBody(data: unknown): { projectRoot: string; event: unknown } | null {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const record = data as Record<string, unknown>;
|
||||
if (typeof record.projectRoot !== 'string' || !record.projectRoot.trim()) return null;
|
||||
return {
|
||||
projectRoot: record.projectRoot.trim(),
|
||||
event: record.event,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleCoordEventsPost(
|
||||
request: Request,
|
||||
deps?: Partial<CoordEventsDeps>,
|
||||
): Promise<Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { classification: 'bad_args', message: 'Invalid JSON body' } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseBody(body);
|
||||
if (!parsed) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { classification: 'bad_args', message: 'projectRoot and event are required' } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const writer = deps?.writeCoordEvent ?? writeCoordEvent;
|
||||
const result = await writer(parsed.event, { projectRoot: parsed.projectRoot });
|
||||
if (!result.ok) {
|
||||
const status = result.error.classification === 'bad_args' ? 400 : 500;
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { classification: result.error.classification, message: result.error.message } },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, eventId: result.eventId });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleCoordEventsPost(request);
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ export async function GET(
|
|||
const activity = history.filter((e: ActivityEvent) => e.beadId === beadId);
|
||||
|
||||
// 2. Get communication for this bead
|
||||
const summary = await getCommunicationSummary();
|
||||
const summary = await getCommunicationSummary(projectRoot);
|
||||
const messages = summary.messages.filter((m: AgentMessage) => m.bead_id === beadId);
|
||||
|
||||
// 3. Get local bd interactions via CLI
|
||||
|
|
@ -55,4 +55,4 @@ export async function GET(
|
|||
console.error('[API/Sessions/Conversation] Failed:', error);
|
||||
return NextResponse.json({ ok: false, error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ export async function GET(request: Request): Promise<Response> {
|
|||
try {
|
||||
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||
const activity = activityEventBus.getHistory(projectRoot);
|
||||
const communication = await getCommunicationSummary();
|
||||
const communication = await getCommunicationSummary(projectRoot);
|
||||
const livenessMap = await getAgentLivenessMap(projectRoot, activity);
|
||||
const incursions = await calculateIncursions();
|
||||
const incursions = await calculateIncursions(projectRoot, livenessMap);
|
||||
const agentsResult = await listAgents({}, { projectRoot });
|
||||
|
||||
const feed = buildSessionTaskFeed(issues, activity, communication, livenessMap);
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ export async function GET(request: Request): Promise<Response> {
|
|||
});
|
||||
|
||||
if (!result.success) {
|
||||
const status = result.classification === 'not_found' ? 503 : 400;
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
|
||||
{ status: 400 },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,10 @@ export async function GET(request: Request): Promise<Response> {
|
|||
});
|
||||
|
||||
if (!result.success) {
|
||||
const status = result.classification === 'not_found' ? 503 : 400;
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { classification: result.classification ?? 'unknown', message: result.error ?? result.stderr } },
|
||||
{ status: 400 },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,27 +6,27 @@
|
|||
|
||||
:root {
|
||||
/* ========== VISUAL-TRUTH UI TOKEN CONTRACT (bb-vt.1.1) ========== */
|
||||
/* Warm charcoal aurora palette - DISTINCT LAYERS */
|
||||
--ui-bg-app: #181716; /* Darkest - page background */
|
||||
--ui-bg-header: #131211; /* Header - darker than sidebar */
|
||||
--ui-bg-shell: #1f1e1d; /* Sidebar - distinct from main */
|
||||
--ui-bg-panel: #282725; /* Panels/cards within sidebar */
|
||||
--ui-bg-main: #242322; /* Main content area - distinct */
|
||||
--ui-bg-card: #302e2c; /* Cards - lightest layer */
|
||||
--ui-bg-elevated: #3a3836; /* Elevated/selected elements */
|
||||
/* Map legacy --ui-* tokens to theme tokens so all themes work consistently */
|
||||
--ui-bg-app: var(--surface-backdrop);
|
||||
--ui-bg-header: var(--surface-primary);
|
||||
--ui-bg-shell: var(--surface-primary);
|
||||
--ui-bg-panel: var(--surface-secondary);
|
||||
--ui-bg-main: var(--surface-secondary);
|
||||
--ui-bg-card: var(--surface-elevated);
|
||||
--ui-bg-elevated: var(--surface-elevated);
|
||||
|
||||
--ui-border-soft: rgba(180, 175, 165, 0.2);
|
||||
--ui-border-strong: rgba(180, 175, 165, 0.35);
|
||||
--ui-border-soft: var(--border-subtle);
|
||||
--ui-border-strong: var(--border-default);
|
||||
|
||||
--ui-text-primary: #f0eeea;
|
||||
--ui-text-muted: #a8a49a;
|
||||
--ui-text-primary: var(--text-primary);
|
||||
--ui-text-muted: var(--text-tertiary);
|
||||
|
||||
--ui-accent-ready: #35d98f;
|
||||
--ui-accent-blocked: #ff4c72;
|
||||
--ui-accent-warning: #ffb24a;
|
||||
--ui-accent-info: #35c9ff;
|
||||
--ui-accent-action-green: #35d98f;
|
||||
--ui-accent-action-red: #ff4c72;
|
||||
--ui-accent-ready: var(--accent-success);
|
||||
--ui-accent-blocked: var(--accent-danger);
|
||||
--ui-accent-warning: var(--accent-warning);
|
||||
--ui-accent-info: var(--accent-info);
|
||||
--ui-accent-action-green: var(--accent-success);
|
||||
--ui-accent-action-red: var(--accent-danger);
|
||||
|
||||
--ui-font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
--ui-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
|
|
@ -43,30 +43,25 @@
|
|||
--color-accent-teal: var(--ui-accent-info);
|
||||
|
||||
--color-text-primary: var(--ui-text-primary);
|
||||
--color-text-secondary: #c4cfdb;
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-muted: var(--ui-text-muted);
|
||||
--color-text-on-primary: #10161d;
|
||||
--color-text-on-primary: var(--text-inverse);
|
||||
|
||||
--color-border-default: var(--ui-border-strong);
|
||||
--color-border-subtle: var(--ui-border-soft);
|
||||
|
||||
/* Status colors */
|
||||
--status-open: var(--ui-accent-info);
|
||||
--status-ready: var(--ui-accent-ready);
|
||||
--status-in-progress: var(--ui-accent-warning);
|
||||
--status-progress: var(--ui-accent-warning);
|
||||
--status-blocked: var(--ui-accent-blocked);
|
||||
--status-blocked-earthy: var(--ui-accent-blocked);
|
||||
--status-closed: #7f8b98;
|
||||
--status-closed-earthy: #7f8b98;
|
||||
--status-deferred: #7f8b98;
|
||||
--status-open: var(--accent-info);
|
||||
--status-progress: var(--status-in-progress);
|
||||
--status-blocked-earthy: var(--status-blocked);
|
||||
--status-closed-earthy: var(--status-closed);
|
||||
|
||||
/* Liveness colors */
|
||||
--liveness-active: var(--ui-accent-ready);
|
||||
--liveness-stale: var(--ui-accent-warning);
|
||||
--liveness-stuck: var(--ui-accent-action-red);
|
||||
--liveness-dead: var(--ui-accent-action-red);
|
||||
--liveness-idle: #7f8b98;
|
||||
--liveness-idle: var(--text-tertiary);
|
||||
|
||||
/* Agent Role Colors */
|
||||
--agent-role-ui: #6B9BD2;
|
||||
|
|
@ -83,10 +78,10 @@
|
|||
--priority-p4: #64748b;
|
||||
|
||||
/* Blocks/Unlocks Section Colors */
|
||||
--color-blocks-bg: rgba(212, 165, 116, 0.1);
|
||||
--color-unlocks-bg: rgba(229, 115, 115, 0.1);
|
||||
--color-blocks-border: rgba(212, 165, 116, 0.2);
|
||||
--color-unlocks-border: rgba(229, 115, 115, 0.2);
|
||||
--color-blocks-bg: var(--status-in-progress);
|
||||
--color-unlocks-bg: var(--status-blocked);
|
||||
--color-blocks-border: color-mix(in srgb, var(--accent-warning) 35%, transparent);
|
||||
--color-unlocks-border: color-mix(in srgb, var(--accent-danger) 35%, transparent);
|
||||
|
||||
/* ========== RADI ========== */
|
||||
--radius-sm: 0.375rem;
|
||||
|
|
@ -98,10 +93,8 @@
|
|||
--radius-pill: 9999px;
|
||||
|
||||
/* ========== SHADOWS ========== */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
--shadow-soft-lg: 0 10px 30px -10px rgba(0, 0, 0, 0.3);
|
||||
--shadow-soft-xl: 0 20px 40px -10px rgba(0, 0, 0, 0.4);
|
||||
--shadow-soft-lg: var(--shadow-md);
|
||||
--shadow-soft-xl: var(--shadow-lg);
|
||||
|
||||
/* ========== TYPOGRAPHY ========== */
|
||||
--font-ui-stack: var(--ui-font-sans);
|
||||
|
|
@ -139,13 +132,12 @@
|
|||
--sidebar-right-width: 17.5rem;
|
||||
--topbar-height: 3.75rem;
|
||||
|
||||
--glass-base: linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--ui-bg-card) 72%, black),
|
||||
color-mix(in srgb, var(--ui-bg-panel) 78%, black));
|
||||
--edge-top: color-mix(in srgb, var(--ui-border-strong) 80%, white 20%);
|
||||
--edge-bottom: color-mix(in srgb, var(--ui-border-soft) 75%, black 25%);
|
||||
--elevation-ambient: 0 20px 40px -16px rgba(0, 0, 0, 0.78);
|
||||
--elevation-tight: 0 10px 24px -12px rgba(0, 0, 0, 0.7);
|
||||
--glass-base: linear-gradient(180deg, var(--surface-elevated), var(--surface-secondary));
|
||||
--edge-side: var(--ui-border-soft);
|
||||
--edge-top: color-mix(in srgb, var(--ui-border-strong) 88%, white 12%);
|
||||
--edge-bottom: color-mix(in srgb, var(--ui-border-soft) 88%, black 12%);
|
||||
--elevation-ambient: var(--shadow-lg);
|
||||
--elevation-tight: var(--shadow-md);
|
||||
|
||||
/* ========== LEGACY COMPATIBILITY TOKENS ========== */
|
||||
/* For existing components that reference these */
|
||||
|
|
@ -181,7 +173,7 @@ body {
|
|||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.35) rgba(255, 255, 255, 0.02);
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
|
|
@ -190,18 +182,18 @@ body {
|
|||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--scrollbar-track);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(156, 163, 175, 0.55), rgba(107, 114, 128, 0.45));
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--scrollbar-thumb);
|
||||
border: 1px solid color-mix(in srgb, var(--scrollbar-thumb) 70%, transparent);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(186, 194, 209, 0.72), rgba(124, 136, 156, 0.62));
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
|
|
@ -210,12 +202,12 @@ body {
|
|||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
|
|
@ -229,14 +221,14 @@ body {
|
|||
|
||||
|
||||
.workflow-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top-color: rgba(255, 255, 255, 0.24);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.9);
|
||||
background: linear-gradient(180deg, rgba(42, 44, 52, 0.6) 0%, rgba(22, 23, 28, 0.6) 100%);
|
||||
border: 1px solid var(--border-default);
|
||||
border-top-color: color-mix(in srgb, var(--border-strong) 88%, white 12%);
|
||||
border-bottom-color: color-mix(in srgb, var(--border-subtle) 88%, black 12%);
|
||||
background: linear-gradient(180deg, var(--surface-quaternary) 0%, var(--surface-secondary) 100%);
|
||||
box-shadow:
|
||||
var(--elevation-ambient),
|
||||
var(--elevation-tight),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.15);
|
||||
inset 0 1px 1px color-mix(in srgb, var(--alpha-white-high) 40%, transparent);
|
||||
backdrop-filter: blur(24px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(120%);
|
||||
transform: translateZ(0);
|
||||
|
|
@ -244,14 +236,14 @@ body {
|
|||
}
|
||||
|
||||
.workflow-card-selected {
|
||||
border-color: rgba(96, 165, 250, 0.42);
|
||||
border-top-color: rgba(96, 165, 250, 0.58);
|
||||
background: linear-gradient(180deg, rgba(60, 68, 88, 0.7) 0%, rgba(35, 40, 52, 0.7) 100%);
|
||||
border-color: color-mix(in srgb, var(--accent-info) 42%, var(--border-default));
|
||||
border-top-color: color-mix(in srgb, var(--accent-info) 58%, var(--border-strong));
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-active) 55%, var(--surface-elevated)), var(--surface-secondary));
|
||||
box-shadow:
|
||||
0 20px 48px -8px rgba(0, 0, 0, 0.9),
|
||||
0 0 0 1px rgba(96, 165, 250, 0.25),
|
||||
0 0 40px rgba(96, 165, 250, 0.15),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||
var(--shadow-lg),
|
||||
0 0 0 1px color-mix(in srgb, var(--accent-info) 25%, transparent),
|
||||
var(--glow-info),
|
||||
inset 0 1px 1px color-mix(in srgb, var(--alpha-white-high) 50%, transparent);
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
|
|
@ -265,19 +257,19 @@ body {
|
|||
}
|
||||
|
||||
.bg-earthy-gradient {
|
||||
background: linear-gradient(to bottom right, #2D2D2D, #363636);
|
||||
background: linear-gradient(to bottom right, var(--surface-secondary), var(--surface-tertiary));
|
||||
}
|
||||
|
||||
/* Shared dark form controls to avoid white-on-white browser defaults */
|
||||
/* Shared themed form controls */
|
||||
.ui-field {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid var(--border-default);
|
||||
border-top-color: var(--edge-top);
|
||||
border-bottom-color: var(--edge-bottom);
|
||||
background: linear-gradient(180deg, rgba(32, 34, 42, 0.72), rgba(17, 19, 26, 0.72));
|
||||
background: linear-gradient(180deg, var(--surface-input), var(--surface-secondary));
|
||||
color: var(--color-text-strong);
|
||||
box-shadow:
|
||||
0 8px 20px -12px rgba(0, 0, 0, 0.85),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
var(--shadow-sm),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--alpha-white-high) 22%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
|
@ -288,10 +280,10 @@ body {
|
|||
|
||||
.ui-field:focus-visible {
|
||||
outline: none;
|
||||
border-color: rgba(96, 165, 250, 0.48);
|
||||
border-color: color-mix(in srgb, var(--accent-info) 48%, var(--border-default));
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(96, 165, 250, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
0 0 0 2px color-mix(in srgb, var(--accent-info) 20%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--alpha-white-high) 22%, transparent);
|
||||
}
|
||||
|
||||
.ui-select {
|
||||
|
|
@ -326,19 +318,19 @@ body {
|
|||
}
|
||||
|
||||
.ui-shell-topbar {
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-panel) 92%, black), var(--ui-bg-shell));
|
||||
background: linear-gradient(180deg, var(--ui-bg-panel), var(--ui-bg-shell));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--ui-accent-info) 22%, var(--ui-border-soft));
|
||||
box-shadow: 0 10px 24px -20px rgba(0, 0, 0, 0.9);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ui-shell-middle {
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-app) 74%, black), color-mix(in srgb, var(--ui-bg-app) 90%, black));
|
||||
background: linear-gradient(180deg, var(--ui-bg-app), var(--ui-bg-main));
|
||||
border-left: 1px solid color-mix(in srgb, var(--ui-accent-info) 20%, var(--ui-border-soft));
|
||||
border-right: 1px solid color-mix(in srgb, var(--ui-accent-info) 20%, var(--ui-border-soft));
|
||||
}
|
||||
|
||||
.ui-shell-panel {
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-shell) 86%, black), color-mix(in srgb, var(--ui-bg-panel) 84%, black));
|
||||
background: linear-gradient(180deg, var(--ui-bg-shell), var(--ui-bg-panel));
|
||||
border-left: 1px solid color-mix(in srgb, var(--ui-accent-info) 30%, var(--ui-border-soft));
|
||||
}
|
||||
|
||||
|
|
@ -349,8 +341,8 @@ body {
|
|||
|
||||
.workflow-graph-legend {
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(20, 23, 31, 0.72);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
background: color-mix(in srgb, var(--surface-overlay) 78%, transparent);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--alpha-white-high) 22%, transparent), var(--shadow-md);
|
||||
}
|
||||
|
||||
.workflow-graph-flow .react-flow__viewport {
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { DependencyGraphPage } from '../../components/graph/dependency-graph-page';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { readIssuesForScope } from '../../lib/aggregate-read';
|
||||
import { resolveProjectScope } from '../../lib/project-scope';
|
||||
import { listProjects } from '../../lib/registry';
|
||||
|
||||
interface GraphPageProps {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function GraphPage({ searchParams }: GraphPageProps) {
|
||||
const params = (await searchParams) ?? {};
|
||||
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
|
||||
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
|
||||
const registryProjects = await listProjects();
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: process.cwd(),
|
||||
registryProjects,
|
||||
requestedProjectKey,
|
||||
requestedMode,
|
||||
});
|
||||
|
||||
const issues = await readIssuesForScope({
|
||||
mode: scope.mode,
|
||||
selected: scope.selected,
|
||||
scopeOptions: scope.options,
|
||||
preferBd: true,
|
||||
});
|
||||
return (
|
||||
<DependencyGraphPage
|
||||
issues={issues}
|
||||
projectRoot={scope.selected.root}
|
||||
projectScopeKey={scope.selected.key}
|
||||
projectScopeOptions={scope.options}
|
||||
projectScopeMode={scope.mode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,556 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { ArrowLeft, ArrowUpRight, Clock3, Link2, MessageCircle, TriangleAlert, X } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type TaskStatus = "open" | "in_progress" | "blocked" | "deferred" | "closed"
|
||||
|
||||
type Task = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
priority: 0 | 1 | 2 | 3 | 4
|
||||
issueType: string
|
||||
assignee: string
|
||||
owner: string
|
||||
labels: string[]
|
||||
blockedReason: string
|
||||
updatedAgo: string
|
||||
dependencyCount: number
|
||||
blockedByCount: number
|
||||
commentCount: number
|
||||
unread: boolean
|
||||
}
|
||||
|
||||
type Epic = {
|
||||
id: string
|
||||
name: string
|
||||
progress: number
|
||||
openCount: number
|
||||
tasks: Task[]
|
||||
}
|
||||
|
||||
const palette = {
|
||||
primary: "#F2A62F",
|
||||
secondary: "#00D1A8",
|
||||
accent: "#0FC5AE",
|
||||
eggplant: "#4A2F63",
|
||||
bg: "#2D2E3C",
|
||||
surface: "#333341",
|
||||
border: "#4A4D5C",
|
||||
text: "#EDEBE5",
|
||||
textSecondary: "#B8B7B1",
|
||||
mutedBg: "#2F2F3E",
|
||||
success: "#0FC5AE",
|
||||
warning: "#D28A2C",
|
||||
error: "#D64545",
|
||||
info: "#00D1A8",
|
||||
atmosphereWarm: "#5A4632",
|
||||
atmosphereCool: "#23484D",
|
||||
}
|
||||
|
||||
const initialEpics: Epic[] = [
|
||||
{
|
||||
id: "bb-ui2",
|
||||
name: "Unified UX - Earthy Dark Shell",
|
||||
progress: 69,
|
||||
openCount: 11,
|
||||
tasks: [
|
||||
{ id: "bb-atf", title: "Agent swarm-view-integrator", description: "Integrate swarm view into social workroom shell.", status: "open", priority: 1, issueType: "task", assignee: "sarah.lee", owner: "swarm-team", labels: ["social", "swarm"], blockedReason: "", updatedAgo: "8m", dependencyCount: 1, blockedByCount: 0, commentCount: 3, unread: true },
|
||||
{ id: "bb-z6s", title: "Agent social-view-integrator", description: "Wire social stream cards and panel routing.", status: "in_progress", priority: 0, issueType: "feature", assignee: "alex.chen", owner: "social-team", labels: ["social", "ui"], blockedReason: "", updatedAgo: "14m", dependencyCount: 2, blockedByCount: 0, commentCount: 7, unread: true },
|
||||
{ id: "bb-nuy", title: "Agent swarm-card-builder", description: "Build consistent swarm card visuals and metadata.", status: "blocked", priority: 0, issueType: "bug", assignee: "alex.chen", owner: "swarm-team", labels: ["swarm", "cards"], blockedReason: "Waiting on dependency bb-ui2.0", updatedAgo: "35m", dependencyCount: 3, blockedByCount: 1, commentCount: 5, unread: true },
|
||||
{ id: "bb-3ha", title: "Agent sessions-integrator", description: "Session metrics panel integrated and verified.", status: "closed", priority: 2, issueType: "chore", assignee: "alex.chen", owner: "sessions-team", labels: ["sessions"], blockedReason: "", updatedAgo: "2h", dependencyCount: 0, blockedByCount: 0, commentCount: 4, unread: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bb-xhm",
|
||||
name: "Timeline and Activity Feed",
|
||||
progress: 80,
|
||||
openCount: 5,
|
||||
tasks: [
|
||||
{ id: "bb-3dv", title: "Agent rightpanel-builder", description: "Implement right rail card stack and compact activity.", status: "open", priority: 2, issueType: "task", assignee: "alex.chen", owner: "layout-team", labels: ["layout", "right-panel"], blockedReason: "", updatedAgo: "11m", dependencyCount: 1, blockedByCount: 0, commentCount: 1, unread: true },
|
||||
{ id: "bb-dwz", title: "Agent leftpanel-builder", description: "Epic->task navigation with search and metadata icons.", status: "in_progress", priority: 1, issueType: "feature", assignee: "sarah.lee", owner: "layout-team", labels: ["layout", "left-panel"], blockedReason: "", updatedAgo: "19m", dependencyCount: 0, blockedByCount: 0, commentCount: 6, unread: true },
|
||||
{ id: "bb-5am", title: "Agent topbar-builder", description: "Topbar controls and filter sync.", status: "blocked", priority: 1, issueType: "bug", assignee: "agent-007", owner: "layout-team", labels: ["topbar"], blockedReason: "Navigation contract mismatch", updatedAgo: "41m", dependencyCount: 2, blockedByCount: 1, commentCount: 2, unread: false },
|
||||
{ id: "bb-z2l", title: "Agent mobile-nav-builder", description: "Mobile drawer flow for three-pane shell.", status: "deferred", priority: 1, issueType: "task", assignee: "sarah.lee", owner: "mobile-team", labels: ["mobile", "navigation"], blockedReason: "", updatedAgo: "52m", dependencyCount: 0, blockedByCount: 0, commentCount: 2, unread: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function statusClasses(status: TaskStatus) {
|
||||
if (status === "in_progress") return "border-l-[3px] border-l-[#0FC5AE] bg-[linear-gradient(145deg,#333341,#2F2F3E)]"
|
||||
if (status === "blocked") return "border-l-[3px] border-l-[#D64545] bg-[linear-gradient(145deg,#333341,#302B31)]"
|
||||
if (status === "deferred") return "border-l-[3px] border-l-[#D28A2C] bg-[linear-gradient(145deg,#333341,#342F29)]"
|
||||
if (status === "closed") return "border-l-[3px] border-l-[#6D6F7B] bg-[linear-gradient(145deg,#333341,#2F3039)]"
|
||||
return "border-l-[3px] border-l-[#00D1A8] bg-[linear-gradient(145deg,#333341,#2D313D)]"
|
||||
}
|
||||
|
||||
function statusBadge(status: TaskStatus) {
|
||||
if (status === "in_progress") return "bg-[#0FC5AE] text-[#0E2220]"
|
||||
if (status === "blocked") return "bg-[#D64545] text-white"
|
||||
if (status === "deferred") return "bg-[#D28A2C] text-[#24190C]"
|
||||
if (status === "closed") return "bg-[#5A5D6A] text-[#D4D6DE]"
|
||||
return "bg-[#00D1A8] text-[#07221C]"
|
||||
}
|
||||
|
||||
const panelClass = "rounded-2xl border shadow-[0_16px_40px_rgba(0,0,0,0.28)] backdrop-blur-[2px]"
|
||||
const subPanelClass = "rounded-xl border"
|
||||
|
||||
function updateQuery(searchParams: URLSearchParams, updates: Record<string, string | null>) {
|
||||
const next = new URLSearchParams(searchParams.toString())
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!value) next.delete(key)
|
||||
else next.set(key, value)
|
||||
}
|
||||
const qs = next.toString()
|
||||
return qs ? `?${qs}` : "?"
|
||||
}
|
||||
|
||||
export default function MockupPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const [epics, setEpics] = useState(initialEpics)
|
||||
const [query, setQuery] = useState("")
|
||||
const [leftMode, setLeftMode] = useState<"epics" | "tasks">("epics")
|
||||
|
||||
const urlEpic = searchParams.get("epic")
|
||||
const urlTask = searchParams.get("task")
|
||||
const urlThread = searchParams.get("thread") === "open"
|
||||
|
||||
const initialEpic = epics.find((epic) => epic.id === urlEpic) ?? epics[0]
|
||||
const [selectedEpicId, setSelectedEpicId] = useState(initialEpic.id)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState(urlTask ?? initialEpic.tasks[0].id)
|
||||
const [threadOpen, setThreadOpen] = useState(urlThread)
|
||||
const [threadEditMode, setThreadEditMode] = useState(false)
|
||||
|
||||
const [draftTitle, setDraftTitle] = useState("")
|
||||
const [draftDescription, setDraftDescription] = useState("")
|
||||
const [draftStatus, setDraftStatus] = useState<TaskStatus>("open")
|
||||
const [draftPriority, setDraftPriority] = useState<0 | 1 | 2 | 3 | 4>(2)
|
||||
const [draftIssueType, setDraftIssueType] = useState("")
|
||||
const [draftAssignee, setDraftAssignee] = useState("")
|
||||
const [draftOwner, setDraftOwner] = useState("")
|
||||
const [draftLabels, setDraftLabels] = useState("")
|
||||
const [draftBlockedReason, setDraftBlockedReason] = useState("")
|
||||
const [savePulse, setSavePulse] = useState(false)
|
||||
|
||||
const closeThread = useCallback(() => {
|
||||
setThreadOpen(false)
|
||||
setThreadEditMode(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const next = updateQuery(searchParams, {
|
||||
epic: selectedEpicId,
|
||||
task: selectedTaskId,
|
||||
thread: threadOpen ? "open" : null,
|
||||
})
|
||||
router.replace(next, { scroll: false })
|
||||
}, [router, searchParams, selectedEpicId, selectedTaskId, threadOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadOpen) {
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closeThread()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [threadOpen, closeThread])
|
||||
|
||||
const selectedEpic = epics.find((epic) => epic.id === selectedEpicId) ?? epics[0]
|
||||
const filteredTasks = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return selectedEpic.tasks.filter((task) => `${task.id} ${task.title}`.toLowerCase().includes(q))
|
||||
}, [query, selectedEpic.tasks])
|
||||
const selectedTask = filteredTasks.find((task) => task.id === selectedTaskId) ?? filteredTasks[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask) return
|
||||
setDraftTitle(selectedTask.title)
|
||||
setDraftDescription(selectedTask.description)
|
||||
setDraftStatus(selectedTask.status)
|
||||
setDraftPriority(selectedTask.priority)
|
||||
setDraftIssueType(selectedTask.issueType)
|
||||
setDraftAssignee(selectedTask.assignee)
|
||||
setDraftOwner(selectedTask.owner)
|
||||
setDraftLabels(selectedTask.labels.join(", "))
|
||||
setDraftBlockedReason(selectedTask.blockedReason)
|
||||
setThreadEditMode(false)
|
||||
}, [selectedTask, selectedTask?.id])
|
||||
|
||||
const saveTaskChanges = () => {
|
||||
if (!selectedTask) return
|
||||
const nextLabels = draftLabels
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
setEpics((current) =>
|
||||
current.map((epic) =>
|
||||
epic.id !== selectedEpicId
|
||||
? epic
|
||||
: {
|
||||
...epic,
|
||||
tasks: epic.tasks.map((task) =>
|
||||
task.id !== selectedTask.id
|
||||
? task
|
||||
: {
|
||||
...task,
|
||||
title: draftTitle,
|
||||
description: draftDescription,
|
||||
status: draftStatus,
|
||||
priority: draftPriority,
|
||||
issueType: draftIssueType,
|
||||
assignee: draftAssignee,
|
||||
owner: draftOwner,
|
||||
labels: nextLabels,
|
||||
blockedReason: draftBlockedReason,
|
||||
updatedAgo: "now",
|
||||
blockedByCount: draftStatus === "blocked" ? Math.max(task.blockedByCount, 1) : 0,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
setSavePulse(true)
|
||||
setTimeout(() => setSavePulse(false), 900)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen" style={{ backgroundColor: palette.bg, color: palette.text }}>
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{
|
||||
backgroundImage:
|
||||
`radial-gradient(circle at 12% 16%, rgba(90,70,50,0.55), transparent 34%), radial-gradient(circle at 88% 82%, rgba(35,72,77,0.50), transparent 32%)`,
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-[1500px] px-4 py-6 md:px-8 md:py-8">
|
||||
<header className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold tracking-tight leading-[1.02] md:text-5xl">Social Workroom</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: palette.textSecondary }}>Task-first center. Epic drill-in. Live awareness rail.</p>
|
||||
</div>
|
||||
<Badge className="rounded-full px-3 py-1 text-xs text-white" style={{ backgroundColor: palette.eggplant }}>mockup route</Badge>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[24%_52%_24%]">
|
||||
<Card className={panelClass} style={{ backgroundColor: palette.surface, borderColor: palette.border }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{leftMode === "tasks" ? (
|
||||
<Button variant="ghost" className="h-8 px-2" onClick={() => setLeftMode("epics")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to epics
|
||||
</Button>
|
||||
) : (
|
||||
<CardTitle className="text-lg">Epics</CardTitle>
|
||||
)}
|
||||
<Badge className="rounded-full" style={{ backgroundColor: palette.mutedBg, color: palette.textSecondary }}>{selectedEpic.openCount} open</Badge>
|
||||
</div>
|
||||
<CardDescription style={{ color: palette.textSecondary }}>Select an epic, then choose a task.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={leftMode === "epics" ? "Search epics" : "Search tasks"}
|
||||
className="mb-3"
|
||||
style={{ backgroundColor: palette.mutedBg, borderColor: palette.border }}
|
||||
/>
|
||||
<ScrollArea className="h-[520px] pr-2">
|
||||
<div className="space-y-2">
|
||||
{leftMode === "epics"
|
||||
? epics
|
||||
.filter((epic) => epic.name.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((epic) => (
|
||||
<button
|
||||
key={epic.id}
|
||||
type="button"
|
||||
className={`${subPanelClass} w-full p-3 text-left transition duration-200 hover:-translate-y-[1px] hover:shadow-[0_10px_24px_rgba(0,0,0,0.35)]`}
|
||||
style={{ backgroundColor: palette.surface, borderColor: palette.border }}
|
||||
onClick={() => {
|
||||
setSelectedEpicId(epic.id)
|
||||
setSelectedTaskId(epic.tasks[0]?.id ?? "")
|
||||
setLeftMode("tasks")
|
||||
closeThread()
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-semibold">{epic.name}</p>
|
||||
<p className="mt-1 text-xs" style={{ color: palette.textSecondary }}>{epic.id}</p>
|
||||
</button>
|
||||
))
|
||||
: filteredTasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedTaskId(task.id)
|
||||
closeThread()
|
||||
}}
|
||||
className={`${subPanelClass} w-full p-3 text-left transition duration-200 ${selectedTask?.id === task.id
|
||||
? "shadow-[0_12px_26px_rgba(0,0,0,0.4)]"
|
||||
: "hover:-translate-y-[1px] hover:shadow-[0_10px_22px_rgba(0,0,0,0.33)]"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: selectedTask?.id === task.id ? palette.mutedBg : palette.surface,
|
||||
borderColor: selectedTask?.id === task.id ? palette.primary : palette.border,
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-semibold">{task.id}</p>
|
||||
<p className="mt-1 line-clamp-1 text-xs" style={{ color: palette.textSecondary }}>{task.title}</p>
|
||||
<div className="mt-2 flex items-center gap-3 text-[11px]" style={{ color: palette.textSecondary }}>
|
||||
<span className="inline-flex items-center gap-1"><Clock3 className="h-3 w-3" />{task.updatedAgo}</span>
|
||||
<span className="inline-flex items-center gap-1"><Link2 className="h-3 w-3" />{task.dependencyCount}</span>
|
||||
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3 w-3" />{task.commentCount}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={panelClass} style={{ backgroundColor: palette.surface, borderColor: palette.border }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{selectedEpic.name}</CardTitle>
|
||||
<CardDescription style={{ color: palette.textSecondary }}>Task cards + thread context</CardDescription>
|
||||
</div>
|
||||
<Button className="h-8 rounded-full px-4 text-white" style={{ backgroundColor: palette.primary }}>New update</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ScrollArea className="h-[430px] pr-2">
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{filteredTasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
className={`rounded-xl border p-4 text-left transition duration-200 hover:-translate-y-[1px] hover:shadow-[0_14px_28px_rgba(0,0,0,0.35)] ${statusClasses(task.status)}`}
|
||||
style={{ borderColor: selectedTask?.id === task.id ? palette.primary : palette.border }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold" style={{ color: palette.eggplant }}>{task.id}</span>
|
||||
<Badge className={`rounded-full px-2 py-0.5 text-[11px] ${statusBadge(task.status)}`}>{task.status.replace("_", " ")}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-[1.7rem] font-semibold leading-[1.15]">{task.title}</p>
|
||||
<p className="mt-2 line-clamp-2 text-sm" style={{ color: palette.textSecondary }}>{task.description}</p>
|
||||
<div className="mt-4 flex items-center gap-3 text-xs" style={{ color: palette.textSecondary }}>
|
||||
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" />{task.updatedAgo}</span>
|
||||
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" />{task.dependencyCount}</span>
|
||||
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" />{task.commentCount}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Separator className="my-4" />
|
||||
<div className={`${subPanelClass} p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]`} style={{ borderColor: palette.border, backgroundColor: palette.mutedBg }}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">Conversation: {selectedTask?.id}</p>
|
||||
<Button variant="ghost" className="h-7 px-2" style={{ color: palette.secondary }} onClick={() => setThreadOpen(true)}>
|
||||
Open thread <ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border px-3 py-2 text-sm" style={{ borderColor: "#5A5D6A", backgroundColor: "#2A2B37" }}>
|
||||
<span className="font-semibold" style={{ color: palette.success }}>alex.chen</span>
|
||||
<span className="mx-1 text-xs" style={{ color: "#8F92A3" }}>2m</span>
|
||||
<span style={{ color: palette.textSecondary }}>Need confirmation that detail strip stays sticky while card grid scrolls.</span>
|
||||
</div>
|
||||
<div className="rounded-lg border px-3 py-2 text-sm" style={{ borderColor: "#5A5D6A", backgroundColor: "#2A2B37" }}>
|
||||
<span className="font-semibold" style={{ color: palette.secondary }}>sarah.lee</span>
|
||||
<span className="mx-1 text-xs" style={{ color: "#8F92A3" }}>1m</span>
|
||||
<span style={{ color: palette.textSecondary }}>Approved if right rail remains visible at 1280px breakpoint.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={panelClass} style={{ backgroundColor: palette.surface, borderColor: palette.border }}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">Live Context</CardTitle>
|
||||
<CardDescription style={{ color: palette.textSecondary }}>Persistent awareness while working tasks.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<div className={`${subPanelClass} p-3 shadow-[0_8px_20px_rgba(0,0,0,0.25)]`} style={{ borderColor: palette.border, backgroundColor: palette.mutedBg }}>
|
||||
<p className="mb-2 text-sm font-semibold">Live Agents</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="flex items-center justify-between"><span>swarm-view-integrator</span><span style={{ color: palette.success }}>online</span></p>
|
||||
<p className="flex items-center justify-between"><span>social-view-integrator</span><span style={{ color: palette.warning }}>away</span></p>
|
||||
<p className="flex items-center justify-between"><span>graph-integrator</span><span style={{ color: palette.info }}>busy</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${subPanelClass} p-3 shadow-[0_8px_20px_rgba(0,0,0,0.25)]`} style={{ borderColor: palette.border, backgroundColor: palette.mutedBg }}>
|
||||
<p className="mb-2 text-sm font-semibold">Recent Activity</p>
|
||||
<div className="space-y-1 text-xs" style={{ color: palette.textSecondary }}>
|
||||
<p>5m · bb-z6s moved to in progress</p>
|
||||
<p>11m · bb-atf received 2 comments</p>
|
||||
<p>18m · bb-3ha marked closed</p>
|
||||
<p>33m · bb-nuy dependency changed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${subPanelClass} p-3 shadow-[0_8px_20px_rgba(0,0,0,0.22)]`} style={{ borderColor: "#6A4E2F", backgroundColor: "#3A332B" }}>
|
||||
<p className="mb-2 text-sm font-semibold">Attention</p>
|
||||
<p className="flex items-center gap-2 text-sm" style={{ color: "#F2C684" }}><TriangleAlert className="h-4 w-4" /> 2 blocked tasks in selected epic</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{threadOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/35 p-2 md:items-center md:p-4" onClick={closeThread}>
|
||||
<div
|
||||
className={`${panelClass} w-full max-w-[980px] p-4 md:p-5`}
|
||||
style={{
|
||||
borderColor: "#5B5E71",
|
||||
background: "linear-gradient(180deg,#323342,#2A2B38)",
|
||||
color: palette.text,
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-semibold">Thread · {selectedTask?.id}</p>
|
||||
<p className="text-xs" style={{ color: palette.textSecondary }}>Bead summary and inline edit mode</p>
|
||||
</div>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-white/10" aria-label="Close thread" onClick={closeThread}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border p-4" style={{ borderColor: "#55586A", backgroundColor: "#2A2B37" }}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">{threadEditMode ? "Edit task" : "Task summary"}</p>
|
||||
<Badge className="rounded-full px-2 py-0.5 text-[11px]" style={{ backgroundColor: savePulse ? palette.success : "#3C3E4E", color: savePulse ? "#0E2220" : "#B8B7B1" }}>
|
||||
{savePulse ? "saved" : "ready"}
|
||||
</Badge>
|
||||
</div>
|
||||
{!threadEditMode ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-lg border p-3" style={{ borderColor: "#585B6D", backgroundColor: "#323342" }}>
|
||||
<p className="text-xs mb-1" style={{ color: "#A4A7B7" }}>{selectedTask?.id}</p>
|
||||
<p className="font-semibold text-base text-[#ECEBE5]">{selectedTask?.title}</p>
|
||||
<p className="mt-1" style={{ color: palette.textSecondary }}>{selectedTask?.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg border p-2" style={{ borderColor: "#585B6D", backgroundColor: "#323342" }}>Status: {selectedTask?.status}</div>
|
||||
<div className="rounded-lg border p-2" style={{ borderColor: "#585B6D", backgroundColor: "#323342" }}>Priority: P{selectedTask?.priority}</div>
|
||||
<div className="rounded-lg border p-2" style={{ borderColor: "#585B6D", backgroundColor: "#323342" }}>Assignee: {selectedTask?.assignee || "-"}</div>
|
||||
<div className="rounded-lg border p-2" style={{ borderColor: "#585B6D", backgroundColor: "#323342" }}>Owner: {selectedTask?.owner || "-"}</div>
|
||||
<div className="rounded-lg border p-2 col-span-2" style={{ borderColor: "#585B6D", backgroundColor: "#323342" }}>
|
||||
Labels: {selectedTask?.labels.join(", ") || "-"}
|
||||
</div>
|
||||
<div className="rounded-lg border p-2 col-span-2" style={{ borderColor: "#585B6D", backgroundColor: "#323342" }}>
|
||||
Blocked reason: {selectedTask?.blockedReason || "None"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button className="rounded-full px-4 text-[#24190C]" style={{ backgroundColor: palette.primary }} onClick={() => setThreadEditMode(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-1 text-xs" style={{ color: palette.textSecondary }}>Title</p>
|
||||
<Input value={draftTitle} onChange={(event) => setDraftTitle(event.target.value)} style={{ backgroundColor: "#323342", borderColor: "#585B6D", color: palette.text }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs" style={{ color: palette.textSecondary }}>Assignee</p>
|
||||
<Input value={draftAssignee} onChange={(event) => setDraftAssignee(event.target.value)} style={{ backgroundColor: "#323342", borderColor: "#585B6D", color: palette.text }} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="mb-1 text-xs" style={{ color: palette.textSecondary }}>Description</p>
|
||||
<Input value={draftDescription} onChange={(event) => setDraftDescription(event.target.value)} style={{ backgroundColor: "#323342", borderColor: "#585B6D", color: palette.text }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs" style={{ color: palette.textSecondary }}>Issue type</p>
|
||||
<Input value={draftIssueType} onChange={(event) => setDraftIssueType(event.target.value)} style={{ backgroundColor: "#323342", borderColor: "#585B6D", color: palette.text }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs" style={{ color: palette.textSecondary }}>Owner</p>
|
||||
<Input value={draftOwner} disabled style={{ backgroundColor: "#323342", borderColor: "#585B6D", color: palette.text, opacity: 0.7 }} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="mb-1 text-xs" style={{ color: palette.textSecondary }}>Labels (comma separated)</p>
|
||||
<Input value={draftLabels} onChange={(event) => setDraftLabels(event.target.value)} style={{ backgroundColor: "#323342", borderColor: "#585B6D", color: palette.text }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs" style={{ color: palette.textSecondary }}>Status</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["open", "in_progress", "blocked", "deferred", "closed"] as TaskStatus[]).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className={`rounded-full border px-2 py-1 text-xs ${draftStatus === status ? statusBadge(status) : ""}`}
|
||||
style={{
|
||||
borderColor: draftStatus === status ? "transparent" : palette.border,
|
||||
backgroundColor: draftStatus === status ? undefined : "#323342",
|
||||
color: draftStatus === status ? undefined : palette.textSecondary,
|
||||
}}
|
||||
onClick={() => setDraftStatus(status)}
|
||||
>
|
||||
{status.replace("_", " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs" style={{ color: palette.textSecondary }}>Priority</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{([0, 1, 2, 3, 4] as const).map((priority) => (
|
||||
<button
|
||||
key={priority}
|
||||
type="button"
|
||||
onClick={() => setDraftPriority(priority)}
|
||||
className="rounded-full border px-2 py-1 text-xs"
|
||||
style={{
|
||||
borderColor: draftPriority === priority ? palette.eggplant : palette.border,
|
||||
backgroundColor: draftPriority === priority ? "#F4EAFE" : palette.surface,
|
||||
color: draftPriority === priority ? palette.eggplant : palette.textSecondary,
|
||||
}}
|
||||
>
|
||||
P{priority}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs" style={{ color: palette.textSecondary }}>Blocked reason</p>
|
||||
<Input value={draftBlockedReason} onChange={(event) => setDraftBlockedReason(event.target.value)} style={{ backgroundColor: "#323342", borderColor: "#585B6D", color: palette.text }} />
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="outline" className="rounded-full px-4 border-[#585B6D] bg-[#323342] text-[#B8B7B1] hover:bg-[#3A3B49]" onClick={() => setThreadEditMode(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="rounded-full px-4 text-[#24190C]" style={{ backgroundColor: palette.primary }} onClick={() => { saveTaskChanges(); setThreadEditMode(false) }}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { KanbanPage } from '../components/kanban/kanban-page';
|
||||
import { readIssuesForScope } from '../lib/aggregate-read';
|
||||
import { resolveProjectScope } from '../lib/project-scope';
|
||||
import { listProjects } from '../lib/registry';
|
||||
|
||||
interface PageProps {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const params = (await searchParams) ?? {};
|
||||
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
|
||||
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
|
||||
const registryProjects = await listProjects();
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: process.cwd(),
|
||||
registryProjects,
|
||||
requestedProjectKey,
|
||||
requestedMode,
|
||||
});
|
||||
|
||||
const issues = await readIssuesForScope({
|
||||
mode: scope.mode,
|
||||
selected: scope.selected,
|
||||
scopeOptions: scope.options,
|
||||
preferBd: true,
|
||||
});
|
||||
return (
|
||||
<KanbanPage
|
||||
issues={issues}
|
||||
projectRoot={scope.selected.root}
|
||||
projectScopeKey={scope.selected.key}
|
||||
projectScopeOptions={scope.options}
|
||||
projectScopeMode={scope.mode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function SessionsRedirectPage() {
|
||||
redirect('/?view=social');
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { TimelineFeed } from '../../components/timeline/timeline-feed';
|
||||
import { useTimelineStore } from '../../components/timeline/timeline-store';
|
||||
|
||||
export default function TimelinePage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-text-strong">Activity Timeline</h1>
|
||||
<p className="text-text-muted">Real-time stream of project mutations.</p>
|
||||
</header>
|
||||
|
||||
<TimelineControls />
|
||||
<TimelineSubscription />
|
||||
<TimelineFeed />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineControls() {
|
||||
return (
|
||||
<div className="mb-6 flex gap-2">
|
||||
{/* Placeholder for future filters */}
|
||||
<div className="text-sm text-text-muted">Showing all activity</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineSubscription() {
|
||||
const { addEvent, setHistory } = useTimelineStore();
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Fetch history
|
||||
fetch('/api/activity')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('History fetch failed');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => setHistory(data))
|
||||
.catch(err => console.error('Failed to load history', err));
|
||||
|
||||
// 2. Subscribe to SSE
|
||||
const es = new EventSource('/api/events');
|
||||
|
||||
es.addEventListener('activity', (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
addEvent(event);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse activity event', err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => es.close();
|
||||
}, [setHistory, addEvent]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
70
src/hooks/use-bd-health.ts
Normal file
70
src/hooks/use-bd-health.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface BdHealthError {
|
||||
classification?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface BdHealthResponse {
|
||||
ok: boolean;
|
||||
error?: BdHealthError;
|
||||
data?: { version?: string };
|
||||
}
|
||||
|
||||
export interface UseBdHealthResult {
|
||||
healthy: boolean;
|
||||
loading: boolean;
|
||||
message: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export function useBdHealth(projectRoot: string): UseBdHealthResult {
|
||||
const [healthy, setHealthy] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function checkHealth() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/bd/health?projectRoot=${encodeURIComponent(projectRoot)}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = (await response.json()) as BdHealthResponse;
|
||||
if (cancelled) return;
|
||||
|
||||
if (response.ok && payload.ok) {
|
||||
setHealthy(true);
|
||||
setMessage(null);
|
||||
setVersion(payload.data?.version ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
setHealthy(false);
|
||||
setVersion(null);
|
||||
setMessage(payload.error?.message ?? 'bd command not found in PATH. Install @beads/bd.');
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setHealthy(false);
|
||||
setVersion(null);
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to check bd health.');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void checkHealth();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectRoot]);
|
||||
|
||||
return { healthy, loading, message, version };
|
||||
}
|
||||
|
|
@ -10,25 +10,35 @@ export const MIN_LEFT_WIDTH = 192;
|
|||
export const MIN_RIGHT_WIDTH = 256;
|
||||
|
||||
export function usePanelResize() {
|
||||
const [leftWidth, setLeftWidth] = useState(() => {
|
||||
if (typeof window === 'undefined') return DEFAULT_LEFT_WIDTH;
|
||||
const saved = localStorage.getItem(LEFT_PANEL_KEY);
|
||||
return saved ? parseInt(saved, 10) : DEFAULT_LEFT_WIDTH;
|
||||
});
|
||||
|
||||
const [rightWidth, setRightWidth] = useState(() => {
|
||||
if (typeof window === 'undefined') return DEFAULT_RIGHT_WIDTH;
|
||||
const saved = localStorage.getItem(RIGHT_PANEL_KEY);
|
||||
return saved ? parseInt(saved, 10) : DEFAULT_RIGHT_WIDTH;
|
||||
});
|
||||
const [leftWidth, setLeftWidth] = useState(DEFAULT_LEFT_WIDTH);
|
||||
const [rightWidth, setRightWidth] = useState(DEFAULT_RIGHT_WIDTH);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LEFT_PANEL_KEY, String(leftWidth));
|
||||
}, [leftWidth]);
|
||||
const savedLeft = localStorage.getItem(LEFT_PANEL_KEY);
|
||||
const savedRight = localStorage.getItem(RIGHT_PANEL_KEY);
|
||||
|
||||
if (savedLeft) {
|
||||
setLeftWidth(parseInt(savedLeft, 10));
|
||||
}
|
||||
if (savedRight) {
|
||||
setRightWidth(parseInt(savedRight, 10));
|
||||
}
|
||||
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(RIGHT_PANEL_KEY, String(rightWidth));
|
||||
}, [rightWidth]);
|
||||
if (mounted) {
|
||||
localStorage.setItem(LEFT_PANEL_KEY, String(leftWidth));
|
||||
}
|
||||
}, [leftWidth, mounted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
localStorage.setItem(RIGHT_PANEL_KEY, String(rightWidth));
|
||||
}
|
||||
}, [rightWidth, mounted]);
|
||||
|
||||
const clampLeftWidth = useCallback((width: number) => {
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.30);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const DEFAULT_VIEW: ViewType = 'social';
|
|||
const DEFAULT_LEFT_PANEL: PanelState = 'open';
|
||||
const DEFAULT_RIGHT_PANEL: PanelState = 'open';
|
||||
const DEFAULT_DRAWER: DrawerState = 'closed';
|
||||
const DEFAULT_GRAPH_TAB: GraphTabType = 'flow';
|
||||
const DEFAULT_GRAPH_TAB: GraphTabType = 'overview';
|
||||
|
||||
const VALID_VIEWS: ViewType[] = ['social', 'graph', 'activity'];
|
||||
const VALID_PANELS: PanelState[] = ['open', 'closed'];
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import type { ActivityEvent } from './activity';
|
||||
import type { BeadIssue } from './types';
|
||||
import { listAgents, deriveLiveness } from './agent-registry';
|
||||
import { inboxAgentMessages, type AgentMessage } from './agent-mail';
|
||||
import { statusAgentReservations, classifyOverlap } from './agent-reservations';
|
||||
import type { AgentMessage } from './agent-mail';
|
||||
import {
|
||||
calculateReservationIncursions,
|
||||
projectInboxFromDisk,
|
||||
projectReservations,
|
||||
readCoordEventsFromDisk,
|
||||
} from './coord-projections';
|
||||
|
||||
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle' | 'stuck' | 'dead';
|
||||
|
||||
|
|
@ -148,56 +153,21 @@ export interface Incursion {
|
|||
/**
|
||||
* Calculates global incursions by comparing all active reservations.
|
||||
*/
|
||||
export async function calculateIncursions(): Promise<Incursion[]> {
|
||||
const statusResult = await statusAgentReservations({});
|
||||
if (!statusResult.ok || !statusResult.data) return [];
|
||||
|
||||
const reservations = statusResult.data.reservations;
|
||||
const incursions: Incursion[] = [];
|
||||
const processedPairs = new Set<string>();
|
||||
|
||||
for (let i = 0; i < reservations.length; i++) {
|
||||
for (let j = i + 1; j < reservations.length; j++) {
|
||||
const resA = reservations[i];
|
||||
const resB = reservations[j];
|
||||
|
||||
// Don't compare an agent against themselves
|
||||
if (resA.agent_id === resB.agent_id) continue;
|
||||
|
||||
const overlap = classifyOverlap(resA.scope, resB.scope);
|
||||
if (overlap !== 'disjoint') {
|
||||
const key = [resA.agent_id, resB.agent_id].sort().join(':') + ':' + [resA.scope, resB.scope].sort().join('|');
|
||||
if (processedPairs.has(key)) continue;
|
||||
processedPairs.add(key);
|
||||
|
||||
incursions.push({
|
||||
scope: overlap === 'exact' ? resA.scope : `${resA.scope} ↔ ${resB.scope}`,
|
||||
agents: [resA.agent_id, resB.agent_id],
|
||||
severity: overlap
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return incursions;
|
||||
export async function calculateIncursions(
|
||||
projectRoot: string = process.cwd(),
|
||||
agentLivenessMap: Record<string, string> = {},
|
||||
): Promise<Incursion[]> {
|
||||
const events = await readCoordEventsFromDisk(projectRoot);
|
||||
const reservations = projectReservations(events, agentLivenessMap);
|
||||
return calculateReservationIncursions(reservations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers all relevant communication for all agents to build a summary for aggregation.
|
||||
*/
|
||||
export async function getCommunicationSummary(): Promise<CommunicationSummary> {
|
||||
const agentsResult = await listAgents({});
|
||||
const agents = agentsResult.data ?? [];
|
||||
const allMessages: AgentMessage[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
const inbox = await inboxAgentMessages({ agent: agent.agent_id });
|
||||
if (inbox.data) {
|
||||
allMessages.push(...inbox.data);
|
||||
}
|
||||
}
|
||||
|
||||
return { messages: allMessages };
|
||||
export async function getCommunicationSummary(projectRoot: string = process.cwd()): Promise<CommunicationSummary> {
|
||||
const coordMessages = await projectInboxFromDisk(projectRoot);
|
||||
return { messages: coordMessages };
|
||||
}
|
||||
|
||||
export interface AgentMetrics {
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ResolveBdExecutableOptions {
|
||||
explicitPath?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface BdExecutableResolution {
|
||||
executable: string;
|
||||
source: 'config' | 'path';
|
||||
}
|
||||
|
||||
export class BdExecutableNotFoundError extends Error {
|
||||
readonly code = 'BD_NOT_FOUND';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BdExecutableNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function splitEnvPath(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const value = env.Path ?? env.PATH ?? '';
|
||||
if (!value.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.split(';').map((segment) => segment.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function executableCandidates(directory: string): string[] {
|
||||
return ['bd.exe', 'bd.cmd', 'bd.bat', 'bd'].map((name) => path.join(directory, name));
|
||||
}
|
||||
|
||||
function buildNotFoundMessage(explicitPath?: string | null): string {
|
||||
const lines = [
|
||||
'bd.exe was not found.',
|
||||
'Install it with: npm install -g @beads/bd',
|
||||
'Or configure an explicit executable path in request payload/config.',
|
||||
];
|
||||
|
||||
if (explicitPath) {
|
||||
lines.push(`Configured path was not found: ${explicitPath}`);
|
||||
}
|
||||
|
||||
return lines.join(' ');
|
||||
}
|
||||
|
||||
export async function resolveBdExecutable(options: ResolveBdExecutableOptions = {}): Promise<BdExecutableResolution> {
|
||||
if (options.explicitPath && options.explicitPath.trim()) {
|
||||
const explicit = path.resolve(options.explicitPath);
|
||||
if (await fileExists(explicit)) {
|
||||
return { executable: explicit, source: 'config' };
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage(options.explicitPath));
|
||||
}
|
||||
|
||||
for (const dir of splitEnvPath(options.env)) {
|
||||
for (const candidate of executableCandidates(dir)) {
|
||||
if (await fileExists(candidate)) {
|
||||
return { executable: candidate, source: 'path' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage());
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
import { exec as nodeExec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path';
|
||||
|
||||
const execAsync = promisify(nodeExec);
|
||||
import { normalizeProjectRootForRuntime } from './project-root';
|
||||
|
||||
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
|
||||
|
||||
|
|
@ -12,7 +9,9 @@ export interface RunBdCommandOptions {
|
|||
projectRoot: string;
|
||||
args: string[];
|
||||
timeoutMs?: number;
|
||||
// Deprecated: accepted for payload compatibility, ignored by runner.
|
||||
explicitBdPath?: string | null;
|
||||
stdinText?: string;
|
||||
}
|
||||
|
||||
export interface RunBdCommandResult {
|
||||
|
|
@ -29,8 +28,10 @@ export interface RunBdCommandResult {
|
|||
}
|
||||
|
||||
interface RunBdCommandDeps {
|
||||
resolveBdExecutable: typeof resolveBdExecutable;
|
||||
exec: (command: string, options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv }) => Promise<{ stdout: string; stderr: string }>;
|
||||
exec: (
|
||||
command: string,
|
||||
options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv; stdinText?: string },
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
|
|
@ -39,29 +40,51 @@ function normalizeOutput(text: unknown): string {
|
|||
return text.replaceAll('\r\n', '\n').trim();
|
||||
}
|
||||
|
||||
function getExitCode(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
const value = (error as { exitCode?: unknown }).exitCode;
|
||||
return typeof value === 'number' ? value : null;
|
||||
}
|
||||
|
||||
function toErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error) return value.message;
|
||||
return String(value ?? 'Unknown error');
|
||||
}
|
||||
|
||||
function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification {
|
||||
const exitCode = getExitCode(error);
|
||||
if (error.code === 'ENOENT') return 'not_found';
|
||||
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') return 'timeout';
|
||||
const stderr = normalizeOutput(error.stderr);
|
||||
if (typeof error.code === 'number') {
|
||||
if (
|
||||
/not recognized as an internal or external command/i.test(stderr) ||
|
||||
/command not found/i.test(stderr) ||
|
||||
/["']bd["'] is not recognized/i.test(stderr) ||
|
||||
/bd: not found/i.test(stderr)
|
||||
) {
|
||||
return 'not_found';
|
||||
}
|
||||
if (typeof error.code === 'number' || exitCode !== null) {
|
||||
if (/(unknown|invalid|required|usage)/i.test(stderr)) return 'bad_args';
|
||||
return 'non_zero_exit';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function buildBdNotFoundMessage(): string {
|
||||
return 'bd command not found in PATH. Install with: npm install -g @beads/bd';
|
||||
}
|
||||
|
||||
function buildShellCommand(executable: string, args: string[]): string {
|
||||
const sanitizedExecutable = executable.replace(/^['"]+|['"]+$/g, '');
|
||||
// Normalize to forward slashes for Windows shell compatibility
|
||||
const normalizedExe = executable.split(path.sep).join('/');
|
||||
const normalizedExe = sanitizedExecutable.split(path.sep).join('/');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: quote the executable path, leave simple args unquoted
|
||||
const quotedExe = `"${normalizedExe}"`;
|
||||
// Windows: do not quote plain command tokens like `bd`; quote only when needed.
|
||||
const quotedExe = /[\s&|<>()^"]/.test(normalizedExe)
|
||||
? `"${normalizedExe.replace(/"/g, '""')}"`
|
||||
: normalizedExe;
|
||||
const quotedArgs = args.map(a => {
|
||||
if (/[\s&|<>()^"]/.test(a)) return `"${a.replace(/"/g, '""')}"`;
|
||||
return a;
|
||||
|
|
@ -73,45 +96,117 @@ function buildShellCommand(executable: string, args: string[]): string {
|
|||
}
|
||||
}
|
||||
|
||||
async function execShellCommand(
|
||||
command: string,
|
||||
options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv; stdinText?: string },
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
|
||||
const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
|
||||
|
||||
const child = spawn(shell, shellArgs, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, options.timeout);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
const wrapped = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
|
||||
wrapped.stdout = stdout;
|
||||
wrapped.stderr = stderr;
|
||||
reject(wrapped);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0 && !timedOut) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
const error = new Error(`Command failed with code ${code ?? 'null'}`) as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
error.code = timedOut ? 'ETIMEDOUT' : 'BD_EXIT';
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
error.killed = timedOut;
|
||||
error.signal = signal ?? undefined;
|
||||
(error as { exitCode?: number }).exitCode = code ?? 1;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (options.stdinText !== undefined) {
|
||||
child.stdin.write(options.stdinText);
|
||||
}
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function runBdCommand(
|
||||
options: RunBdCommandOptions,
|
||||
injectedDeps?: Partial<RunBdCommandDeps>,
|
||||
): Promise<RunBdCommandResult> {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
const cwd = options.projectRoot;
|
||||
const cwd = normalizeProjectRootForRuntime(options.projectRoot);
|
||||
const args = [...options.args];
|
||||
if (process.env.BD_NO_DAEMON === 'true') {
|
||||
args.unshift('--no-daemon');
|
||||
}
|
||||
|
||||
const deps: RunBdCommandDeps = {
|
||||
resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable,
|
||||
exec: injectedDeps?.exec ?? execAsync,
|
||||
exec: injectedDeps?.exec ?? execShellCommand,
|
||||
env: injectedDeps?.env ?? process.env,
|
||||
};
|
||||
|
||||
let command = options.explicitBdPath ?? 'bd';
|
||||
const command = 'bd';
|
||||
|
||||
try {
|
||||
const resolved = await deps.resolveBdExecutable({
|
||||
explicitPath: options.explicitBdPath,
|
||||
env: deps.env,
|
||||
});
|
||||
command = resolved.executable;
|
||||
|
||||
const shellCommand = buildShellCommand(command, args);
|
||||
|
||||
const mingwBin = 'C:\\msys64\\mingw64\\bin';
|
||||
const existingPath = deps.env.Path ?? deps.env.PATH ?? '';
|
||||
const enhancedPath = existingPath.includes('mingw64')
|
||||
? existingPath
|
||||
: `${mingwBin};${existingPath}`;
|
||||
let env = deps.env;
|
||||
if (process.platform === 'win32') {
|
||||
const mingwBin = 'C:\\msys64\\mingw64\\bin';
|
||||
const existingPath = deps.env.Path ?? deps.env.PATH ?? '';
|
||||
const enhancedPath = existingPath.includes('mingw64')
|
||||
? existingPath
|
||||
: `${mingwBin};${existingPath}`;
|
||||
env = { ...deps.env, Path: enhancedPath, PATH: enhancedPath };
|
||||
} else {
|
||||
// Ensure ~/.local/bin is in PATH so bd is found regardless of how the server was started
|
||||
const home = deps.env.HOME ?? '';
|
||||
const localBin = `${home}/.local/bin`;
|
||||
const existingPath = deps.env.PATH ?? '';
|
||||
if (home && !existingPath.includes(localBin)) {
|
||||
env = { ...deps.env, PATH: `${localBin}:${existingPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await deps.exec(shellCommand, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
env: { ...deps.env, Path: enhancedPath, PATH: enhancedPath },
|
||||
env,
|
||||
stdinText: options.stdinText,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -127,39 +222,25 @@ export async function runBdCommand(
|
|||
error: null,
|
||||
};
|
||||
} catch (rawError) {
|
||||
if (rawError instanceof BdExecutableNotFoundError) {
|
||||
return {
|
||||
success: false,
|
||||
classification: 'not_found',
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: rawError.message,
|
||||
};
|
||||
}
|
||||
|
||||
const error = rawError as NodeJS.ErrnoException & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
const classification = classifyFailure(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
classification: classifyFailure(error),
|
||||
classification,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(error.stdout),
|
||||
stderr: normalizeOutput(error.stderr),
|
||||
code: typeof error.code === 'number' ? error.code : null,
|
||||
code: typeof error.code === 'number' ? error.code : getExitCode(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: toErrorMessage(error),
|
||||
error: classification === 'not_found' ? buildBdNotFoundMessage() : toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
src/lib/coord-events.ts
Normal file
73
src/lib/coord-events.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { runBdCommand, type RunBdCommandResult } from './bridge';
|
||||
import { validateCoordEventEnvelope, type CoordEventEnvelope } from './coord-schema';
|
||||
|
||||
export interface WriteCoordEventOptions {
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export interface WriteCoordEventError {
|
||||
classification: 'bad_args' | 'non_zero_exit' | 'not_found' | 'timeout' | 'unknown';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type WriteCoordEventResult =
|
||||
| { ok: true; eventId: string; commandResult: RunBdCommandResult }
|
||||
| { ok: false; error: WriteCoordEventError };
|
||||
|
||||
interface WriteCoordEventDeps {
|
||||
runBdCommand: typeof runBdCommand;
|
||||
}
|
||||
|
||||
function buildAuditEntry(event: CoordEventEnvelope): Record<string, unknown> {
|
||||
return {
|
||||
version: event.version,
|
||||
kind: event.kind,
|
||||
issue_id: event.issue_id,
|
||||
actor: event.actor,
|
||||
timestamp: event.timestamp,
|
||||
data: event.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCoordEvent(
|
||||
input: unknown,
|
||||
options: WriteCoordEventOptions,
|
||||
deps?: Partial<WriteCoordEventDeps>,
|
||||
): Promise<WriteCoordEventResult> {
|
||||
const validated = validateCoordEventEnvelope(input);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'bad_args',
|
||||
message: validated.error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const event = validated.value;
|
||||
const auditEntry = buildAuditEntry(event);
|
||||
const runner = deps?.runBdCommand ?? runBdCommand;
|
||||
|
||||
const commandResult = await runner({
|
||||
projectRoot: options.projectRoot,
|
||||
args: ['audit', 'record', '--stdin', '--json'],
|
||||
stdinText: `${JSON.stringify(auditEntry)}\n`,
|
||||
});
|
||||
|
||||
if (!commandResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
classification: commandResult.classification ?? 'unknown',
|
||||
message: commandResult.error ?? commandResult.stderr ?? 'Failed to record coordination event',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
eventId: event.data.event_id,
|
||||
commandResult,
|
||||
};
|
||||
}
|
||||
272
src/lib/coord-projections.ts
Normal file
272
src/lib/coord-projections.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AgentMessage } from './agent-mail';
|
||||
import { classifyOverlap, normalizePath } from './agent-reservations';
|
||||
import type { CoordEventEnvelope } from './coord-schema';
|
||||
|
||||
export type CoordProtocolEvent = CoordEventEnvelope;
|
||||
export type TakeoverMode = 'stale' | 'evicted';
|
||||
|
||||
export interface ProjectedCoordMessage {
|
||||
message_id: string;
|
||||
thread_id: string;
|
||||
bead_id: string;
|
||||
from_agent: string;
|
||||
to_agent: string;
|
||||
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
|
||||
subject: string;
|
||||
body: string;
|
||||
state: 'unread' | 'read' | 'acked';
|
||||
requires_ack: boolean;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
acked_at: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectedReservation {
|
||||
scope: string;
|
||||
normalized_scope: string;
|
||||
agent_id: string;
|
||||
bead_id: string;
|
||||
state: 'active';
|
||||
created_at: string;
|
||||
takeover_mode: TakeoverMode | null;
|
||||
}
|
||||
|
||||
export interface ProjectedReservationIncursion {
|
||||
scope: string;
|
||||
agents: string[];
|
||||
severity: 'exact' | 'partial';
|
||||
}
|
||||
|
||||
type MessageState = 'unread' | 'read' | 'acked';
|
||||
type EventRefMap = Map<string, { state: MessageState; readAt: string | null; ackedAt: string | null }>;
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function toCategory(value: unknown): ProjectedCoordMessage['category'] {
|
||||
if (value === 'HANDOFF' || value === 'BLOCKED' || value === 'DECISION' || value === 'INFO') {
|
||||
return value;
|
||||
}
|
||||
return 'INFO';
|
||||
}
|
||||
|
||||
function requiresAck(category: ProjectedCoordMessage['category']): boolean {
|
||||
return category === 'HANDOFF' || category === 'BLOCKED';
|
||||
}
|
||||
|
||||
export async function readCoordEventsFromDisk(projectRoot: string): Promise<CoordProtocolEvent[]> {
|
||||
const filePath = path.join(projectRoot, '.beads', 'interactions.jsonl');
|
||||
let raw = '';
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const events: CoordProtocolEvent[] = [];
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!isObject(parsed)) continue;
|
||||
if (parsed.version !== 'coord.v1' || parsed.kind !== 'coord_event') continue;
|
||||
events.push(parsed as unknown as CoordProtocolEvent);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export function projectMessageState(events: CoordProtocolEvent[]): Map<string, MessageState> {
|
||||
const stateMap = new Map<string, MessageState>();
|
||||
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
|
||||
for (const event of sorted) {
|
||||
if (event.data.event_type === 'SEND') {
|
||||
stateMap.set(event.data.event_id, 'unread');
|
||||
continue;
|
||||
}
|
||||
const ref = event.data.event_ref;
|
||||
if (!ref || !stateMap.has(ref)) continue;
|
||||
if (event.data.event_type === 'READ') {
|
||||
stateMap.set(ref, 'read');
|
||||
}
|
||||
if (event.data.event_type === 'ACK') {
|
||||
stateMap.set(ref, 'acked');
|
||||
}
|
||||
}
|
||||
|
||||
return stateMap;
|
||||
}
|
||||
|
||||
function projectMessageStateDetails(events: CoordProtocolEvent[]): EventRefMap {
|
||||
const details: EventRefMap = new Map();
|
||||
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
|
||||
for (const event of sorted) {
|
||||
if (event.data.event_type === 'SEND') {
|
||||
details.set(event.data.event_id, { state: 'unread', readAt: null, ackedAt: null });
|
||||
continue;
|
||||
}
|
||||
const ref = event.data.event_ref;
|
||||
if (!ref) continue;
|
||||
const current = details.get(ref);
|
||||
if (!current) continue;
|
||||
if (event.data.event_type === 'READ' && current.state === 'unread') {
|
||||
current.state = 'read';
|
||||
current.readAt = event.timestamp;
|
||||
}
|
||||
if (event.data.event_type === 'ACK') {
|
||||
current.state = 'acked';
|
||||
if (!current.readAt) current.readAt = event.timestamp;
|
||||
current.ackedAt = event.timestamp;
|
||||
}
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
export function projectInbox(
|
||||
events: CoordProtocolEvent[],
|
||||
beadId?: string,
|
||||
agentId?: string,
|
||||
): ProjectedCoordMessage[] {
|
||||
const messageState = projectMessageStateDetails(events);
|
||||
const messages: ProjectedCoordMessage[] = [];
|
||||
const sorted = [...events].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
|
||||
for (const event of sorted) {
|
||||
if (event.data.event_type !== 'SEND') continue;
|
||||
if (beadId && event.issue_id !== beadId) continue;
|
||||
if (agentId && event.data.to_agent !== agentId) continue;
|
||||
const payload = isObject(event.data.payload) ? event.data.payload : {};
|
||||
const category = toCategory(payload.category);
|
||||
const state = messageState.get(event.data.event_id) ?? { state: 'unread', readAt: null, ackedAt: null };
|
||||
|
||||
messages.push({
|
||||
message_id: event.data.event_id,
|
||||
thread_id: `bead:${event.issue_id}`,
|
||||
bead_id: event.issue_id,
|
||||
from_agent: event.actor,
|
||||
to_agent: typeof event.data.to_agent === 'string' ? event.data.to_agent : 'unknown',
|
||||
category,
|
||||
subject: typeof payload.subject === 'string' ? payload.subject : '',
|
||||
body: typeof payload.body === 'string' ? payload.body : '',
|
||||
state: state.state,
|
||||
requires_ack: requiresAck(category),
|
||||
created_at: event.timestamp,
|
||||
read_at: state.readAt,
|
||||
acked_at: state.ackedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export async function projectInboxFromDisk(projectRoot: string, beadId?: string, agentId?: string): Promise<AgentMessage[]> {
|
||||
const events = await readCoordEventsFromDisk(projectRoot);
|
||||
return projectInbox(events, beadId, agentId);
|
||||
}
|
||||
|
||||
export function isTakeoverAllowed(ownerLiveness: string, mode: TakeoverMode): boolean {
|
||||
if (ownerLiveness === 'active') return false;
|
||||
if (ownerLiveness === 'stale') return mode === 'stale';
|
||||
if (ownerLiveness === 'evicted') return mode === 'stale' || mode === 'evicted';
|
||||
return false;
|
||||
}
|
||||
|
||||
export function projectReservations(
|
||||
events: CoordProtocolEvent[],
|
||||
livenessMap: Record<string, string> = {},
|
||||
): ProjectedReservation[] {
|
||||
const activeByScope = new Map<string, ProjectedReservation>();
|
||||
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
|
||||
for (const event of sorted) {
|
||||
const type = event.data.event_type;
|
||||
if (type !== 'RESERVE' && type !== 'RELEASE' && type !== 'TAKEOVER') continue;
|
||||
|
||||
const rawScope = typeof event.data.scope === 'string' ? event.data.scope : '';
|
||||
if (!rawScope) continue;
|
||||
const normalizedScope = normalizePath(rawScope.replace(/\*$/, ''));
|
||||
const key = `${event.data.project_root}:${normalizedScope}`;
|
||||
|
||||
if (type === 'RELEASE') {
|
||||
activeByScope.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'RESERVE') {
|
||||
activeByScope.set(key, {
|
||||
scope: rawScope,
|
||||
normalized_scope: normalizedScope,
|
||||
agent_id: event.actor,
|
||||
bead_id: event.issue_id,
|
||||
state: 'active',
|
||||
created_at: event.timestamp,
|
||||
takeover_mode: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'TAKEOVER') {
|
||||
const mode = event.data.takeover_mode;
|
||||
if (mode !== 'stale' && mode !== 'evicted') continue;
|
||||
const existing = activeByScope.get(key);
|
||||
if (existing) {
|
||||
const ownerLiveness = livenessMap[existing.agent_id] ?? 'active';
|
||||
if (!isTakeoverAllowed(ownerLiveness, mode)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
activeByScope.set(key, {
|
||||
scope: rawScope,
|
||||
normalized_scope: normalizedScope,
|
||||
agent_id: event.actor,
|
||||
bead_id: event.issue_id,
|
||||
state: 'active',
|
||||
created_at: event.timestamp,
|
||||
takeover_mode: mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...activeByScope.values()];
|
||||
}
|
||||
|
||||
export function calculateReservationIncursions(reservations: ProjectedReservation[]): ProjectedReservationIncursion[] {
|
||||
const incursions: ProjectedReservationIncursion[] = [];
|
||||
const processedPairs = new Set<string>();
|
||||
|
||||
for (let i = 0; i < reservations.length; i++) {
|
||||
for (let j = i + 1; j < reservations.length; j++) {
|
||||
const left = reservations[i];
|
||||
const right = reservations[j];
|
||||
if (left.agent_id === right.agent_id) continue;
|
||||
|
||||
const overlap = classifyOverlap(left.scope, right.scope);
|
||||
if (overlap === 'disjoint') continue;
|
||||
|
||||
const key = [left.agent_id, right.agent_id].sort().join(':') + ':' + [left.scope, right.scope].sort().join('|');
|
||||
if (processedPairs.has(key)) continue;
|
||||
processedPairs.add(key);
|
||||
|
||||
incursions.push({
|
||||
scope: overlap === 'exact' ? left.scope : `${left.scope} ↔ ${right.scope}`,
|
||||
agents: [left.agent_id, right.agent_id],
|
||||
severity: overlap,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return incursions;
|
||||
}
|
||||
115
src/lib/coord-schema.ts
Normal file
115
src/lib/coord-schema.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
export const COORD_SCHEMA_VERSION = 'coord.v1' as const;
|
||||
export const COORD_EVENT_KIND = 'coord_event' as const;
|
||||
|
||||
export type CoordEventType =
|
||||
| 'SEND'
|
||||
| 'READ'
|
||||
| 'ACK'
|
||||
| 'RESERVE'
|
||||
| 'RELEASE'
|
||||
| 'TAKEOVER'
|
||||
| 'RESUME'
|
||||
| 'BLOCKED'
|
||||
| 'HANDOFF'
|
||||
| 'INCURSION';
|
||||
|
||||
export type TakeoverMode = 'stale' | 'evicted';
|
||||
|
||||
export interface CoordEventData {
|
||||
event_type: CoordEventType;
|
||||
event_id: string;
|
||||
project_root: string;
|
||||
payload: Record<string, unknown>;
|
||||
to_agent?: string;
|
||||
scope?: string;
|
||||
state?: 'unread' | 'read' | 'acked';
|
||||
event_ref?: string;
|
||||
takeover_mode?: TakeoverMode;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CoordEventEnvelope {
|
||||
version: typeof COORD_SCHEMA_VERSION;
|
||||
kind: typeof COORD_EVENT_KIND;
|
||||
issue_id: string;
|
||||
actor: string;
|
||||
timestamp: string;
|
||||
data: CoordEventData;
|
||||
}
|
||||
|
||||
export type CoordValidationResult =
|
||||
| { ok: true; value: CoordEventEnvelope }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isEventType(value: unknown): value is CoordEventType {
|
||||
if (!nonEmptyString(value)) return false;
|
||||
return (
|
||||
value === 'SEND' ||
|
||||
value === 'READ' ||
|
||||
value === 'ACK' ||
|
||||
value === 'RESERVE' ||
|
||||
value === 'RELEASE' ||
|
||||
value === 'TAKEOVER' ||
|
||||
value === 'RESUME' ||
|
||||
value === 'BLOCKED' ||
|
||||
value === 'HANDOFF' ||
|
||||
value === 'INCURSION'
|
||||
);
|
||||
}
|
||||
|
||||
function fail(error: string): CoordValidationResult {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
export function validateCoordEventEnvelope(input: unknown): CoordValidationResult {
|
||||
if (!isObject(input)) return fail('Envelope must be an object');
|
||||
|
||||
if (input.version !== COORD_SCHEMA_VERSION) {
|
||||
return fail(`version must be "${COORD_SCHEMA_VERSION}"`);
|
||||
}
|
||||
if (input.kind !== COORD_EVENT_KIND) {
|
||||
return fail(`kind must be "${COORD_EVENT_KIND}"`);
|
||||
}
|
||||
if (!nonEmptyString(input.issue_id)) return fail('issue_id is required');
|
||||
if (!nonEmptyString(input.actor)) return fail('actor is required');
|
||||
if (!nonEmptyString(input.timestamp)) return fail('timestamp is required');
|
||||
|
||||
if (!isObject(input.data)) return fail('data object is required');
|
||||
|
||||
const data = input.data;
|
||||
if (!isEventType(data.event_type)) return fail('data.event_type is invalid');
|
||||
if (!nonEmptyString(data.event_id)) return fail('data.event_id is required');
|
||||
if (!nonEmptyString(data.project_root)) return fail('data.project_root is required');
|
||||
if (!isObject(data.payload)) return fail('data.payload must be an object');
|
||||
|
||||
if ((data.event_type === 'READ' || data.event_type === 'ACK') && !nonEmptyString(data.event_ref)) {
|
||||
return fail('data.event_ref is required for READ/ACK');
|
||||
}
|
||||
|
||||
if (data.event_type === 'TAKEOVER') {
|
||||
if (!nonEmptyString(data.scope)) return fail('data.scope is required for TAKEOVER');
|
||||
if (data.takeover_mode !== 'stale' && data.takeover_mode !== 'evicted') {
|
||||
return fail('data.takeover_mode must be stale or evicted');
|
||||
}
|
||||
if (!nonEmptyString(data.reason)) return fail('data.reason is required for TAKEOVER');
|
||||
}
|
||||
|
||||
if (data.event_type === 'SEND') {
|
||||
if (!nonEmptyString(data.to_agent)) return fail('data.to_agent is required for SEND');
|
||||
if (data.state !== 'unread' && data.state !== 'read' && data.state !== 'acked') {
|
||||
return fail('data.state must be unread/read/acked for SEND');
|
||||
}
|
||||
if (!nonEmptyString(data.payload.subject)) return fail('data.payload.subject is required for SEND');
|
||||
if (!nonEmptyString(data.payload.body)) return fail('data.payload.body is required for SEND');
|
||||
}
|
||||
|
||||
return { ok: true, value: input as unknown as CoordEventEnvelope };
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | '
|
|||
interface MutationBasePayload {
|
||||
projectRoot: string;
|
||||
bdPath?: string;
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
export interface CreateMutationPayload extends MutationBasePayload {
|
||||
|
|
@ -155,6 +156,7 @@ function parseBasePayload(raw: unknown): MutationBasePayload {
|
|||
return {
|
||||
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
|
||||
bdPath: asOptionalString(data.bdPath),
|
||||
actor: asOptionalString(data.actor),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +237,7 @@ function pushOptionalArg(args: string[], flag: string, value: string | undefined
|
|||
|
||||
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
|
||||
if (labels && labels.length > 0) {
|
||||
args.push('-l', labels.join(','));
|
||||
args.push('--set-labels', labels.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -267,7 +269,7 @@ export function buildBdMutationArgs(operation: MutationOperation, payload: Mutat
|
|||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
if (data.metadata) {
|
||||
args.push('--metadata', JSON.stringify(data.metadata));
|
||||
args.push(`--metadata=${JSON.stringify(data.metadata)}`);
|
||||
}
|
||||
args.push('--json');
|
||||
return args;
|
||||
|
|
@ -303,11 +305,12 @@ export async function executeMutation(
|
|||
deps: Partial<ExecuteMutationDeps> = {},
|
||||
): Promise<MutationResponse> {
|
||||
const runner = deps.runBdCommand ?? runBdCommand;
|
||||
const args = buildBdMutationArgs(operation, payload);
|
||||
const args = payload.actor
|
||||
? ['--actor', payload.actor, ...buildBdMutationArgs(operation, payload)]
|
||||
: buildBdMutationArgs(operation, payload);
|
||||
const command = await runner({
|
||||
projectRoot: payload.projectRoot,
|
||||
args,
|
||||
explicitBdPath: payload.bdPath,
|
||||
});
|
||||
|
||||
if (!command.success) {
|
||||
|
|
@ -317,7 +320,7 @@ export async function executeMutation(
|
|||
command,
|
||||
error: {
|
||||
classification: command.classification ?? 'unknown',
|
||||
message: command.error ?? (command.stderr || 'Mutation command failed.'),
|
||||
message: command.stderr || command.error || 'Mutation command failed.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
26
src/lib/project-root.ts
Normal file
26
src/lib/project-root.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import path from 'node:path';
|
||||
|
||||
function isWindowsAbsolute(input: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(input);
|
||||
}
|
||||
|
||||
function windowsToPosixMount(input: string): string {
|
||||
const drive = input[0].toLowerCase();
|
||||
const tail = input.slice(2).replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
return `/mnt/${drive}/${tail}`;
|
||||
}
|
||||
|
||||
export function normalizeProjectRootForRuntime(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
if (isWindowsAbsolute(trimmed)) {
|
||||
return path.resolve(windowsToPosixMount(trimmed));
|
||||
}
|
||||
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
|
@ -8,104 +8,135 @@
|
|||
/* ==========================================================================
|
||||
1. SURFACE LAYERS - Softer greys, not pure white
|
||||
========================================================================== */
|
||||
--surface-backdrop: #e2e8f0;
|
||||
--surface-elevated: #f1f5f9;
|
||||
--surface-primary: #e8edf5;
|
||||
--surface-secondary: #f8fafc;
|
||||
--surface-tertiary: #cbd5e1;
|
||||
--surface-quaternary: #ffffff;
|
||||
--surface-overlay: #f1f5f9;
|
||||
--surface-input: #ffffff;
|
||||
--surface-hover: rgba(15, 23, 42, 0.06);
|
||||
--surface-active: rgba(59, 130, 246, 0.15);
|
||||
--surface-backdrop: #e8edf3;
|
||||
--surface-elevated: #f7f9fc;
|
||||
--surface-primary: #dce4ee;
|
||||
--surface-secondary: #f1f5f9;
|
||||
--surface-tertiary: #e5ebf3;
|
||||
--surface-quaternary: #f8fafc;
|
||||
--surface-overlay: #eef3f8;
|
||||
--surface-input: #fcfdff;
|
||||
--surface-hover: rgba(15, 23, 42, 0.08);
|
||||
--surface-active: rgba(37, 99, 235, 0.22);
|
||||
--surface-tooltip: #1e293b;
|
||||
|
||||
/* ==========================================================================
|
||||
2. BORDERS - Visible grey
|
||||
========================================================================== */
|
||||
--border-subtle: rgba(71, 85, 105, 0.2);
|
||||
--border-default: rgba(71, 85, 105, 0.35);
|
||||
--border-strong: rgba(51, 65, 85, 0.5);
|
||||
--border-accent: rgba(37, 99, 235, 0.6);
|
||||
--border-subtle: rgba(51, 65, 85, 0.28);
|
||||
--border-default: rgba(51, 65, 85, 0.44);
|
||||
--border-strong: rgba(30, 41, 59, 0.62);
|
||||
--border-accent: rgba(29, 78, 216, 0.72);
|
||||
|
||||
/* ==========================================================================
|
||||
3. TEXT - Dark slate (NOT white!)
|
||||
========================================================================== */
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-tertiary: #64748b;
|
||||
--text-disabled: #94a3b8;
|
||||
--text-primary: #0b1324;
|
||||
--text-secondary: #1f344e;
|
||||
--text-tertiary: #4b6078;
|
||||
--text-disabled: #7d92a9;
|
||||
--text-inverse: #f8fafc;
|
||||
|
||||
/* ==========================================================================
|
||||
4. ACCENTS - Vibrant but not neon
|
||||
========================================================================== */
|
||||
--accent-info: #2563eb;
|
||||
--accent-success: #16a34a;
|
||||
--accent-warning: #d97706;
|
||||
--accent-danger: #dc2626;
|
||||
--accent-info: #1d4ed8;
|
||||
--accent-success: #15803d;
|
||||
--accent-warning: #b45309;
|
||||
--accent-danger: #b91c1c;
|
||||
|
||||
/* ==========================================================================
|
||||
5. ACCENT GLOWS - Subtle on light
|
||||
========================================================================== */
|
||||
--glow-info: 0 0 16px rgba(37, 99, 235, 0.2);
|
||||
--glow-success: 0 0 16px rgba(22, 163, 74, 0.2);
|
||||
--glow-warning: 0 0 16px rgba(217, 119, 6, 0.2);
|
||||
--glow-danger: 0 0 16px rgba(220, 38, 38, 0.2);
|
||||
--glow-info: 0 0 18px rgba(29, 78, 216, 0.24);
|
||||
--glow-success: 0 0 18px rgba(21, 128, 61, 0.22);
|
||||
--glow-warning: 0 0 18px rgba(180, 83, 9, 0.24);
|
||||
--glow-danger: 0 0 18px rgba(185, 28, 28, 0.22);
|
||||
|
||||
/* ==========================================================================
|
||||
6. GRAPH COLORS
|
||||
========================================================================== */
|
||||
--graph-node-default: rgba(255, 255, 255, 0.95);
|
||||
--graph-node-epic: rgba(37, 99, 235, 0.15);
|
||||
--graph-edge-default: rgba(71, 85, 105, 0.4);
|
||||
--graph-edge-selected: #2563eb;
|
||||
--graph-edge-cycle: #d97706;
|
||||
--graph-node-default: rgba(248, 250, 252, 0.96);
|
||||
--graph-node-epic: rgba(29, 78, 216, 0.18);
|
||||
--graph-edge-default: rgba(51, 65, 85, 0.48);
|
||||
--graph-edge-selected: #1d4ed8;
|
||||
--graph-edge-cycle: #b45309;
|
||||
|
||||
/* ==========================================================================
|
||||
7. SEMANTIC ALPHAS - Dark overlays for light theme
|
||||
========================================================================== */
|
||||
--alpha-white-low: rgba(255, 255, 255, 0.6);
|
||||
--alpha-white-medium: rgba(255, 255, 255, 0.8);
|
||||
--alpha-white-high: rgba(255, 255, 255, 0.95);
|
||||
--alpha-black-low: rgba(15, 23, 42, 0.06);
|
||||
--alpha-black-medium: rgba(15, 23, 42, 0.12);
|
||||
--alpha-black-high: rgba(15, 23, 42, 0.2);
|
||||
--alpha-white-low: rgba(255, 255, 255, 0.55);
|
||||
--alpha-white-medium: rgba(255, 255, 255, 0.75);
|
||||
--alpha-white-high: rgba(255, 255, 255, 0.9);
|
||||
--alpha-black-low: rgba(15, 23, 42, 0.08);
|
||||
--alpha-black-medium: rgba(15, 23, 42, 0.15);
|
||||
--alpha-black-high: rgba(15, 23, 42, 0.24);
|
||||
|
||||
/* ==========================================================================
|
||||
8. STATUS COLORS - More opaque for visibility
|
||||
========================================================================== */
|
||||
--status-ready: rgba(22, 163, 74, 0.2);
|
||||
--status-in-progress: rgba(217, 119, 6, 0.2);
|
||||
--status-blocked: rgba(220, 38, 38, 0.2);
|
||||
--status-closed: rgba(100, 116, 139, 0.15);
|
||||
--status-deferred: rgba(100, 116, 139, 0.1);
|
||||
--status-ready: rgba(21, 128, 61, 0.22);
|
||||
--status-in-progress: rgba(180, 83, 9, 0.22);
|
||||
--status-blocked: rgba(185, 28, 28, 0.22);
|
||||
--status-closed: rgba(71, 85, 105, 0.18);
|
||||
--status-deferred: rgba(71, 85, 105, 0.13);
|
||||
|
||||
/* ==========================================================================
|
||||
9. SHADOWS - Softer on light
|
||||
========================================================================== */
|
||||
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.1);
|
||||
--shadow-md: 0 4px 6px rgba(15, 23, 42, 0.12);
|
||||
--shadow-lg: 0 10px 15px rgba(15, 23, 42, 0.15);
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.14);
|
||||
--shadow-md: 0 6px 14px rgba(15, 23, 42, 0.16);
|
||||
--shadow-lg: 0 14px 26px rgba(15, 23, 42, 0.2);
|
||||
|
||||
/* ==========================================================================
|
||||
10. AGENT ROLE COLORS
|
||||
========================================================================== */
|
||||
--agent-role-ui: #2563eb;
|
||||
--agent-role-graph: #16a34a;
|
||||
--agent-role-ui: #1d4ed8;
|
||||
--agent-role-graph: #15803d;
|
||||
--agent-role-orchestrator: #7c3aed;
|
||||
--agent-role-researcher: #ea580c;
|
||||
|
||||
/* ==========================================================================
|
||||
11. SCROLLBARS
|
||||
========================================================================== */
|
||||
--scrollbar-track: rgba(15, 23, 42, 0.05);
|
||||
--scrollbar-thumb: rgba(100, 116, 139, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(71, 85, 105, 0.45);
|
||||
--scrollbar-track: rgba(15, 23, 42, 0.08);
|
||||
--scrollbar-thumb: rgba(71, 85, 105, 0.38);
|
||||
--scrollbar-thumb-hover: rgba(51, 65, 85, 0.52);
|
||||
|
||||
/* ==========================================================================
|
||||
12. CODE/SYNTAX
|
||||
========================================================================== */
|
||||
--code-background: #e2e8f0;
|
||||
--code-text: #334155;
|
||||
--code-background: #dbe5f1;
|
||||
--code-text: #1f344e;
|
||||
|
||||
/* ==========================================================================
|
||||
14. LEGACY UI MAPPINGS - For components using --ui-* variables
|
||||
========================================================================== */
|
||||
--ui-bg-app: var(--surface-backdrop);
|
||||
--ui-bg-header: var(--surface-primary);
|
||||
--ui-bg-shell: var(--surface-primary);
|
||||
--ui-bg-panel: var(--surface-tertiary);
|
||||
--ui-bg-main: var(--surface-backdrop);
|
||||
--ui-bg-card: var(--surface-quaternary);
|
||||
--ui-bg-elevated: var(--surface-elevated);
|
||||
|
||||
--ui-border-soft: var(--border-subtle);
|
||||
--ui-border-strong: var(--border-default);
|
||||
|
||||
--ui-text-primary: var(--text-primary);
|
||||
--ui-text-muted: var(--text-tertiary);
|
||||
|
||||
--ui-accent-ready: var(--accent-success);
|
||||
--ui-accent-blocked: var(--accent-danger);
|
||||
--ui-accent-warning: var(--accent-warning);
|
||||
--ui-accent-info: var(--accent-info);
|
||||
}
|
||||
|
||||
[data-theme="light"] body {
|
||||
background-color: var(--ui-bg-app);
|
||||
background-image:
|
||||
radial-gradient(1200px 520px at 24% -12%, rgba(29, 78, 216, 0.09), transparent 62%),
|
||||
radial-gradient(980px 420px at 82% 18%, rgba(21, 128, 61, 0.07), transparent 58%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--ui-bg-app) 92%, white), var(--ui-bg-main));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,197 +0,0 @@
|
|||
import { interpolate, useCurrentFrame, useVideoConfig, AbsoluteFill, Sequence, Img, staticFile, spring } from 'remotion';
|
||||
import React from 'react';
|
||||
import { loadFont } from '@remotion/google-fonts/inter';
|
||||
import { Background } from './components/Background';
|
||||
import { TerminalScene } from './components/TerminalScene';
|
||||
import { TimelineScene } from './components/TimelineScene';
|
||||
|
||||
loadFont();
|
||||
|
||||
const COLORS = {
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#B8B8B8',
|
||||
accentGreen: '#7CB97A',
|
||||
accentTeal: '#5BA8A0',
|
||||
accentAmber: '#D4A574',
|
||||
};
|
||||
|
||||
const Logo: React.FC<{ scale?: number }> = ({ scale = 1 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const dots = [
|
||||
{ color: COLORS.accentGreen, delay: 0 },
|
||||
{ color: COLORS.accentTeal, delay: 5 },
|
||||
{ color: COLORS.accentAmber, delay: 10 },
|
||||
{ color: COLORS.accentTeal, delay: 15 },
|
||||
{ color: COLORS.accentGreen, delay: 20 },
|
||||
{ color: COLORS.accentAmber, delay: 25 },
|
||||
{ color: COLORS.accentGreen, delay: 30 },
|
||||
{ color: COLORS.accentTeal, delay: 35 },
|
||||
{ color: COLORS.accentAmber, delay: 40 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3 p-4" style={{ transform: `scale(${scale})` }}>
|
||||
{dots.map((dot, i) => {
|
||||
const spr = spring({
|
||||
frame: frame - dot.delay,
|
||||
fps,
|
||||
config: { damping: 10 }
|
||||
});
|
||||
const s = interpolate(spr, [0, 1], [0, 1]);
|
||||
const opacity = interpolate(spr, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-4 h-4 rounded-full shadow-[0_0_15px_rgba(255,255,255,0.3)]"
|
||||
style={{
|
||||
backgroundColor: dot.color,
|
||||
opacity,
|
||||
transform: `scale(${s})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GlassCard: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-xl border border-[rgba(255,255,255,0.1)] bg-[#363636]/40 shadow-2xl ${className}`}
|
||||
style={{
|
||||
backdropFilter: 'blur(16px)',
|
||||
boxShadow: '0 24px 56px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.15)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TitleScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 20], [0, 1]);
|
||||
const y = interpolate(frame, [0, 20], [30, 0]);
|
||||
const blur = interpolate(frame, [0, 20], [10, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill className="items-center justify-center flex-col z-10">
|
||||
<div style={{ opacity, transform: `translateY(${y}px)`, filter: `blur(${blur}px)` }} className="flex flex-col items-center gap-8">
|
||||
<Logo scale={3} />
|
||||
<div className="text-center mt-10">
|
||||
<h1 className="text-8xl font-bold tracking-tight mb-6 drop-shadow-2xl" style={{ color: COLORS.textPrimary, fontFamily: 'Inter' }}>
|
||||
Beadboard
|
||||
</h1>
|
||||
<p className="text-3xl font-medium tracking-wide drop-shadow-md" style={{ color: COLORS.textSecondary, fontFamily: 'Inter' }}>
|
||||
Agent-Driven Project Orchestration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const ShowcaseScene: React.FC<{ src: string; title: string; subtitle: string }> = ({ src, title, subtitle }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const spr = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 14, mass: 0.8 },
|
||||
});
|
||||
|
||||
const opacity = interpolate(frame, [0, 15], [0, 1]);
|
||||
const scale = interpolate(spr, [0, 1], [0.92, 1]);
|
||||
const y = interpolate(spr, [0, 1], [60, 0]);
|
||||
|
||||
// Continuous floating animation
|
||||
const floatY = Math.sin(frame / 40) * 8;
|
||||
|
||||
return (
|
||||
<AbsoluteFill className="items-center justify-center p-20 z-10">
|
||||
<div className="w-full max-w-6xl flex flex-col items-center gap-10" style={{ opacity, transform: `translateY(${y}px)` }}>
|
||||
<div className="text-center drop-shadow-lg">
|
||||
<h2 className="text-6xl font-bold mb-4" style={{ color: COLORS.textPrimary, fontFamily: 'Inter' }}>{title}</h2>
|
||||
<p className="text-2xl font-medium" style={{ color: COLORS.textSecondary, fontFamily: 'Inter' }}>{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ transform: `translateY(${floatY}px)` }} className="w-full">
|
||||
<GlassCard className="w-full aspect-video flex items-center justify-center group">
|
||||
<div style={{ transform: `scale(${scale})`, width: '100%', height: '100%' }}>
|
||||
<Img src={staticFile(src)} className="w-full h-full object-cover opacity-90" />
|
||||
{/* Shine effect */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-tr from-white/0 via-white/5 to-white/0"
|
||||
style={{ transform: `translateX(${Math.sin(frame / 60) * 10}%)` }}
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const OutroScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 20], [0, 1]);
|
||||
const scale = interpolate(frame, [0, 100], [1, 1.1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill className="items-center justify-center z-10">
|
||||
<div style={{ opacity, transform: `scale(${scale})` }} className="text-center">
|
||||
<h2 className="text-8xl font-bold mb-8 drop-shadow-2xl" style={{ color: COLORS.textPrimary, fontFamily: 'Inter' }}>
|
||||
Build Faster.
|
||||
</h2>
|
||||
<p className="text-4xl font-semibold drop-shadow-lg" style={{ color: COLORS.accentTeal, fontFamily: 'Inter' }}>
|
||||
Deploy with Confidence.
|
||||
</p>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
|
||||
export const Main: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Background />
|
||||
|
||||
<Sequence durationInFrames={90}>
|
||||
<TitleScene />
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={90} durationInFrames={100}>
|
||||
<ShowcaseScene
|
||||
src="graph-hero.png"
|
||||
title="Visual Workflow"
|
||||
subtitle="Orchestrate complex agent behaviors with intuitive graphs."
|
||||
/>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={190} durationInFrames={100}>
|
||||
<ShowcaseScene
|
||||
src="kanban-hero.png"
|
||||
title="Agent Kanban"
|
||||
subtitle="Track autonomous tasks and parallel execution."
|
||||
/>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={290} durationInFrames={300}>
|
||||
<TerminalScene />
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={590} durationInFrames={140}>
|
||||
<TimelineScene />
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={730} durationInFrames={80}>
|
||||
<OutroScene />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { Composition, Still } from 'remotion';
|
||||
import { Main } from './Main';
|
||||
import './style.css';
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="Main"
|
||||
component={Main}
|
||||
durationInFrames={810}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
<Still
|
||||
id="Thumbnail"
|
||||
component={Main}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { interpolate, useCurrentFrame, useVideoConfig, AbsoluteFill } from 'remotion';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
const COLORS = {
|
||||
bgBase: '#2D2D2D',
|
||||
accentGreen: '#7CB97A',
|
||||
accentAmber: '#D4A574',
|
||||
accentTeal: '#5BA8A0',
|
||||
};
|
||||
|
||||
const AnimatedGradient: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
|
||||
// Create smooth looping motion for the blobs
|
||||
const offset1 = Math.sin(frame / 60) * 10;
|
||||
const offset2 = Math.cos(frame / 50) * 10;
|
||||
const scale1 = interpolate(Math.sin(frame / 80), [-1, 1], [0.8, 1.2]);
|
||||
const scale2 = interpolate(Math.cos(frame / 70), [-1, 1], [0.8, 1.2]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: COLORS.bgBase, overflow: 'hidden' }}>
|
||||
<div
|
||||
className="absolute top-[-20%] left-[-10%] w-[60%] h-[60%] rounded-full opacity-20 blur-[140px]"
|
||||
style={{
|
||||
background: COLORS.accentGreen,
|
||||
transform: `translate(${offset1}%, ${offset2}%) scale(${scale1})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-[-20%] right-[-10%] w-[60%] h-[60%] rounded-full opacity-20 blur-[140px]"
|
||||
style={{
|
||||
background: COLORS.accentAmber,
|
||||
transform: `translate(${-offset2}%, ${-offset1}%) scale(${scale2})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[30%] left-[30%] w-[40%] h-[40%] rounded-full opacity-10 blur-[120px]"
|
||||
style={{
|
||||
background: COLORS.accentTeal,
|
||||
transform: `translate(${offset2 * 0.5}%, ${offset1 * 0.5}%) scale(${scale1})`,
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const DotGrid: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
// Generate a static grid of dots
|
||||
// Only calculate once
|
||||
const dots = useMemo(() => {
|
||||
const d = [];
|
||||
const spacing = 80;
|
||||
const cols = Math.ceil(width / spacing);
|
||||
const rows = Math.ceil(height / spacing);
|
||||
|
||||
for (let i = 0; i < cols; i++) {
|
||||
for (let j = 0; j < rows; j++) {
|
||||
d.push({ x: i * spacing, y: j * spacing, delay: (i + j) * 2 });
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}, [width, height]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill className="items-center justify-center">
|
||||
<svg width="100%" height="100%">
|
||||
{dots.map((dot, i) => {
|
||||
// Subtle fade in/out ripple effect based on position
|
||||
const wave = Math.sin((frame - dot.delay) / 20);
|
||||
const opacity = interpolate(wave, [-1, 1], [0.03, 0.15]);
|
||||
const scale = interpolate(wave, [-1, 1], [0.5, 1.2]);
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={dot.x + 40}
|
||||
cy={dot.y + 40}
|
||||
r={2 * scale}
|
||||
fill="white"
|
||||
opacity={opacity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const Background: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<AnimatedGradient />
|
||||
<DotGrid />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { interpolate, useCurrentFrame, useVideoConfig, AbsoluteFill, Sequence, spring } from 'remotion';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
const TerminalLine: React.FC<{ text: string; delay: number; color?: string }> = ({ text, delay, color = '#d1d5db' }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const chars = text.split('');
|
||||
|
||||
return (
|
||||
<div className="font-mono text-xl mb-2 flex">
|
||||
{chars.map((char, i) => {
|
||||
const show = frame > delay + i * 1.5;
|
||||
return (
|
||||
<span key={i} style={{ opacity: show ? 1 : 0, color }}>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const JSONLine: React.FC<{ data: object; delay: number }> = ({ data, delay }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const str = JSON.stringify(data, null, 2);
|
||||
const lines = str.split('\n');
|
||||
|
||||
const show = frame > delay;
|
||||
const opacity = interpolate(frame, [delay, delay + 10], [0, 1]);
|
||||
const y = interpolate(frame, [delay, delay + 10], [10, 0]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div style={{ opacity, transform: `translateY(${y}px)` }} className="font-mono text-sm text-green-400/90 bg-black/20 p-4 rounded-md border border-green-500/20 my-2">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const TerminalScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const opacity = interpolate(frame, [0, 15], [0, 1]);
|
||||
const scale = interpolate(frame, [0, 15], [0.95, 1]);
|
||||
|
||||
// Header animation
|
||||
const headerY = interpolate(frame, [0, 20], [20, 0]);
|
||||
const headerOpacity = interpolate(frame, [0, 20], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill className="items-center justify-center bg-transparent p-20 z-10">
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ transform: `translateY(${headerY}px)`, opacity: headerOpacity }} className="absolute top-20 text-center w-full">
|
||||
<h2 className="text-6xl font-bold text-white mb-2 font-['Inter'] drop-shadow-lg">Protocol v1</h2>
|
||||
<p className="text-xl text-teal-400 font-mono tracking-widest uppercase">Safe Coordination Contract</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full max-w-5xl bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl border border-gray-700/50"
|
||||
style={{ opacity, transform: `scale(${scale})` }}
|
||||
>
|
||||
{/* Terminal Header */}
|
||||
<div className="bg-[#2d2d2d] px-4 py-3 flex items-center gap-2 border-b border-gray-700">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<div className="ml-4 text-xs text-gray-400 font-mono">beadboard-agent — -zsh — 80x24</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Body */}
|
||||
<div className="p-6 h-[600px] font-mono text-gray-300 overflow-hidden relative">
|
||||
<Sequence from={20}>
|
||||
<TerminalLine text="> bb agent heartbeat --agent amber-otter --json" delay={0} color="#a5f3fc" />
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={60}>
|
||||
<JSONLine
|
||||
delay={0}
|
||||
data={{
|
||||
status: "ok",
|
||||
agent_id: "amber-otter",
|
||||
last_seen: "2026-02-16T10:42:15Z",
|
||||
liveness: "active"
|
||||
}}
|
||||
/>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={110}>
|
||||
<TerminalLine text="> bb protocol emit HANDOFF --to cobalt-harbor" delay={0} color="#a5f3fc" />
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={150}>
|
||||
<div className="mt-4 p-4 border-l-4 border-blue-500 bg-blue-500/10">
|
||||
<TerminalLine text="[EVENT] HANDOFF DETECTED" delay={0} color="#60a5fa" />
|
||||
<TerminalLine text="Scope: src/components/sessions/*" delay={10} />
|
||||
<TerminalLine text="From: amber-otter -> To: cobalt-harbor" delay={20} />
|
||||
<TerminalLine text="Reason: Implementation complete, ready for review." delay={30} />
|
||||
</div>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={250}>
|
||||
<div className="mt-4 p-4 border-l-4 border-yellow-500 bg-yellow-500/10">
|
||||
<TerminalLine text="[WARN] INCURSION PREVENTED" delay={0} color="#fbbf24" />
|
||||
<TerminalLine text="Target: src/lib/parser.ts (Locked by: obsidian-fox)" delay={10} />
|
||||
<TerminalLine text="Action: Write blocked. Queueing request." delay={20} />
|
||||
</div>
|
||||
</Sequence>
|
||||
|
||||
{/* Scanlines / CRT Effect Overlay */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-5 bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,255,0.06))]" style={{ backgroundSize: "100% 2px, 3px 100%" }} />
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { interpolate, useCurrentFrame, useVideoConfig, AbsoluteFill, Sequence, spring } from 'remotion';
|
||||
import React from 'react';
|
||||
|
||||
const COLORS = {
|
||||
bgBase: '#2D2D2D',
|
||||
cardBg: '#363636',
|
||||
accentGreen: '#7CB97A',
|
||||
accentAmber: '#D4A574',
|
||||
accentTeal: '#5BA8A0',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#B8B8B8',
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
};
|
||||
|
||||
const TimelineCard: React.FC<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
time: string;
|
||||
type: 'commit' | 'issue' | 'alert';
|
||||
index: number;
|
||||
}> = ({ title, subtitle, time, type, index }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const delay = index * 5;
|
||||
const spr = spring({
|
||||
frame: frame - delay,
|
||||
fps,
|
||||
config: { damping: 14, mass: 0.8 },
|
||||
});
|
||||
|
||||
const y = interpolate(spr, [0, 1], [50, 0]);
|
||||
const opacity = interpolate(spr, [0, 1], [0, 1]);
|
||||
|
||||
let iconColor = COLORS.textSecondary;
|
||||
if (type === 'commit') iconColor = COLORS.accentTeal;
|
||||
if (type === 'issue') iconColor = COLORS.accentGreen;
|
||||
if (type === 'alert') iconColor = COLORS.accentAmber;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ opacity, transform: `translateY(${y}px)` }}
|
||||
className="flex items-start gap-4 p-4 rounded-lg border bg-[#363636] shadow-lg mb-4 w-full max-w-2xl"
|
||||
// className="flex items-start gap-4 p-4 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[#363636] shadow-lg mb-4 w-full max-w-2xl"
|
||||
>
|
||||
<div className="mt-1 w-3 h-3 rounded-full" style={{ backgroundColor: iconColor }} />
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<span className="text-xs text-gray-500 font-mono">{time}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TimelineScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleOpacity = interpolate(frame, [0, 20], [0, 1]);
|
||||
const titleY = interpolate(frame, [0, 20], [20, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill className="items-center justify-center p-10 z-10 flex-col">
|
||||
<div style={{ opacity: titleOpacity, transform: `translateY(${titleY}px)` }} className="mb-12 text-center">
|
||||
<h2 className="text-6xl font-bold text-white mb-2 font-['Inter'] drop-shadow-lg">Live Activity Feed</h2>
|
||||
<p className="text-xl text-teal-400 font-mono tracking-widest uppercase">Real-time Project Pulse</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-full max-w-2xl">
|
||||
<div className="text-xs font-mono text-gray-500 mb-4 uppercase tracking-wider ml-2">Today</div>
|
||||
<Sequence from={10}>
|
||||
<TimelineCard
|
||||
index={0} type="commit" title="feat: Implement Session Protocol v1"
|
||||
subtitle="amber-otter pushed to main" time="10:42 AM"
|
||||
/>
|
||||
</Sequence>
|
||||
<Sequence from={25}>
|
||||
<TimelineCard
|
||||
index={1} type="issue" title="Docs: Update RFC-001"
|
||||
subtitle="cobalt-harbor commented on #23" time="10:45 AM"
|
||||
/>
|
||||
</Sequence>
|
||||
<Sequence from={40}>
|
||||
<TimelineCard
|
||||
index={2} type="alert" title="Incursion Alert"
|
||||
subtitle="obsidian-fox attempted write to locked scope" time="11:02 AM"
|
||||
/>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={60}>
|
||||
<div className="text-xs font-mono text-gray-500 mt-6 mb-4 uppercase tracking-wider ml-2">Yesterday</div>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={70}>
|
||||
<TimelineCard
|
||||
index={3} type="issue" title="Refactor: Agent Registry"
|
||||
subtitle="emerald-wolf closed issue #19" time="4:20 PM"
|
||||
/>
|
||||
</Sequence>
|
||||
<Sequence from={85}>
|
||||
<TimelineCard
|
||||
index={4} type="commit" title="fix: Graph layout rendering"
|
||||
subtitle="amber-otter pushed to feature/graph-v2" time="3:15 PM"
|
||||
/>
|
||||
</Sequence>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { registerRoot } from 'remotion';
|
||||
import { RemotionRoot } from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Loading…
Add table
Add a link
Reference in a new issue