diff --git a/examples/personal-hub/hub/plugins/subagents.ts b/examples/personal-hub/hub/plugins/subagents.ts index 7695db2..d363019 100644 --- a/examples/personal-hub/hub/plugins/subagents.ts +++ b/examples/personal-hub/hub/plugins/subagents.ts @@ -98,7 +98,7 @@ export const subagents: AgentPlugin = { if (Number(remaining[0]?.c ?? 0) === 0) { ctx.agent.runState.status = "running"; ctx.agent.runState.reason = undefined; - ctx.agent.emit(AgentEventType.RUN_RESUMED, {}); + ctx.agent.emit(AgentEventType.AGENT_RESUMED, {}); await ctx.agent.ensureScheduled(); } @@ -252,7 +252,7 @@ export const subagents: AgentPlugin = { if (runState && runState.status === "running") { runState.status = "paused"; runState.reason = "subagent"; - ctx.agent.emit(AgentEventType.RUN_PAUSED, { + ctx.agent.emit(AgentEventType.AGENT_PAUSED, { reason: "subagent", }); } @@ -324,7 +324,7 @@ The agentId is returned in the result object of the task tool (e.g., {"agentId": if (runState && runState.status === "running") { runState.status = "paused"; runState.reason = "subagent"; - ctx.agent.emit(AgentEventType.RUN_PAUSED, { reason: "subagent" }); + ctx.agent.emit(AgentEventType.AGENT_PAUSED, { reason: "subagent" }); } return null; // Result comes via subagent_result action diff --git a/examples/personal-hub/ui/App.tsx b/examples/personal-hub/ui/App.tsx index 802482c..95507e5 100644 --- a/examples/personal-hub/ui/App.tsx +++ b/examples/personal-hub/ui/App.tsx @@ -1,39 +1,35 @@ import { useState, useMemo, useEffect, useCallback, StrictMode } from "react"; -import { useLocation, useRoute } from "wouter"; +import { useLocation } from "wouter"; import { - Sidebar, ContentHeader, ChatView, TraceView, FilesView, - TodosView, SettingsView, ConfirmModal, - MindPanel, - HomeView, - type TabId, + ErrorBoundary, + ToastProvider, + useToast, + TopHeader, + TabBar, + CommandPalette, + AgentPanel, + BottomPanel, type Message, - type Todo, + type OpenTab, } from "./components"; import { useAgencies, useAgency, useAgent, usePlugins, - useActivityFeed, - useAgencyMetrics, setStoredSecret, QueryClient, QueryClientProvider, } from "./hooks"; -import type { AgentBlueprint, ChatMessage, ToolCall as APIToolCall } from "agents-hub/client"; +import type { AgentBlueprint, ChatMessage, ToolCall as APIToolCall, AgentSummary } from "agents-hub/client"; import { createRoot } from "react-dom/client"; -import { - type ScheduleSummary, - type DashboardMetrics, - convertChatMessages, - isSystemBlueprint, -} from "./components/shared"; +import { convertChatMessages } from "./components/shared"; const queryClient = new QueryClient({ defaultOptions: { @@ -48,11 +44,6 @@ const queryClient = new QueryClient({ // Helper Functions // ============================================================================ -// System blueprints start with _ and are hidden from the picker -function isSystemBlueprintLocal(bp: AgentBlueprint): boolean { - return bp.name.startsWith("_"); -} - // Blueprint picker component function BlueprintPicker({ blueprints, @@ -63,8 +54,7 @@ function BlueprintPicker({ onSelect: (bp: AgentBlueprint) => void; onClose: () => void; }) { - // Filter out system blueprints - const visibleBlueprints = blueprints.filter((bp) => !isSystemBlueprintLocal(bp)); + // All blueprints are visible (no filtering) return (
@@ -81,13 +71,13 @@ function BlueprintPicker({
- {visibleBlueprints.length === 0 ? ( + {blueprints.length === 0 ? (

// NO BLUEPRINTS AVAILABLE

) : (
- {visibleBlueprints.map((bp) => ( + {blueprints.map((bp) => ( - )} -

+
+

AGENCY_CONFIG

-

+

ID: {agency?.name || "UNKNOWN"}

@@ -751,7 +713,6 @@ function SettingsRoute({ void; -}) { - const { agencies } = useAgencies(); - const { agents, blueprints, spawnAgent, getOrCreateMind, sendMessageToAgent } = useAgency(agencyId); - const { items: activityItems, addUserMessage, subscribeToAgent } = useActivityFeed(agencyId); - const { metrics } = useAgencyMetrics(agencyId); - const [, navigate] = useLocation(); - - const agency = agencies.find((a) => a.id === agencyId); - - // Build dashboard metrics - const dashboardMetrics: DashboardMetrics = useMemo(() => { - const activeAgents = agents.filter((a) => !a.agentType.startsWith("_")).length; - return { - agents: { - total: activeAgents, - active: metrics.runsCompleted > 0 ? Math.min(activeAgents, metrics.runsCompleted) : 0, - idle: activeAgents, - error: metrics.runsErrored, - }, - runs: { - today: metrics.runsCompleted + metrics.runsErrored, - week: metrics.runsCompleted + metrics.runsErrored, - successRate: - metrics.runsCompleted + metrics.runsErrored > 0 - ? Math.round((metrics.runsCompleted / (metrics.runsCompleted + metrics.runsErrored)) * 100) - : 100, - hourlyData: Array.from(metrics.tokensByDay.values()).slice(-12), - }, - schedules: { - total: 0, // Will be populated from useAgency - active: 0, - paused: 0, - }, - tokens: metrics.totalTokens > 0 ? { - today: metrics.totalTokens, - week: metrics.totalTokens, - dailyData: Array.from(metrics.tokensByDay.values()), - } : undefined, - responseTime: metrics.responseTimes.length > 0 ? { - avg: Math.round(metrics.responseTimes.reduce((a, b) => a + b, 0) / metrics.responseTimes.length), - p95: Math.round(metrics.responseTimes.sort((a, b) => a - b)[Math.floor(metrics.responseTimes.length * 0.95)] || 0), - recentData: metrics.responseTimes.slice(-12), - } : undefined, - }; - }, [agents, metrics]); - - // Handle sending message to agent - stays in command center view - const handleSendMessage = useCallback( - async (target: string, message: string) => { - // Find or create the target agent - let targetAgentId: string; - let agentType = target; - - if (target === "_agency-mind") { - // Get or create the agency mind - targetAgentId = await getOrCreateMind(); - agentType = "_agency-mind"; - } else { - // Find existing agent - const agent = agents.find((a) => a.id === target); - if (!agent) { - console.error("Agent not found:", target); - return; - } - targetAgentId = agent.id; - agentType = agent.agentType; - } - - // Register agent type for activity feed display - subscribeToAgent(targetAgentId, agentType); - - // Add optimistic update to activity feed - addUserMessage(target, message, targetAgentId); - - // Actually send the message to the agent - // Response will come via agency WebSocket - no polling needed - await sendMessageToAgent(targetAgentId, message); - }, - [agents, getOrCreateMind, addUserMessage, sendMessageToAgent, subscribeToAgent] - ); - - // Handle creating new agent from blueprint - // If message is provided, send it to the agent and stay in command center - const handleCreateAgent = useCallback( - async (blueprintName: string, message?: string) => { - const agent = await spawnAgent(blueprintName); - - if (message) { - // Register agent type for activity feed display - subscribeToAgent(agent.id, blueprintName); - - // Send message and stay in command center - addUserMessage(blueprintName, message, agent.id); - - // Send the message - response will come via agency WebSocket - await sendMessageToAgent(agent.id, message); - } else { - // Navigate to agent page when no initial message - navigate(`/${agencyId}/agent/${agent.id}`); - } - }, - [agencyId, spawnAgent, navigate, addUserMessage, sendMessageToAgent, subscribeToAgent] - ); - - return ( - - ); -} - -// ============================================================================ -// Main Content Router -// ============================================================================ - -function MainContent({ - agencyId, - onMenuClick, -}: { - agencyId: string; - onMenuClick: () => void; -}) { - // Match routes - const [matchAgent, paramsAgent] = useRoute("/:agencyId/agent/:agentId"); - const [matchAgentTab, paramsAgentTab] = useRoute("/:agencyId/agent/:agentId/:tab"); - const [matchSettings] = useRoute("/:agencyId/settings"); - const [matchHome] = useRoute("/:agencyId"); - - if (matchSettings) { - return ; - } - - if (matchAgentTab && paramsAgentTab) { - return ( - - ); - } - - if (matchAgent && paramsAgent) { - return ( - - ); - } - - // Default to home/dashboard view - return ( - - ); -} - -// ============================================================================ -// App Component +// App Component - IDE-style layout with tabs // ============================================================================ export default function App() { const [location, navigate] = useLocation(); + const { showError } = useToast(); // Auth state const [isLocked, setIsLocked] = useState(false); const [authError, setAuthError] = useState(); - // Mobile menu state - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(() => { - if (typeof window !== "undefined") { - return window.innerWidth >= 768; - } - return true; - }); - - // Modal state - const [showBlueprintPicker, setShowBlueprintPicker] = useState(false); + // UI state + const [isPanelOpen, setIsPanelOpen] = useState(true); + const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [showAgencyModal, setShowAgencyModal] = useState(false); const [newAgencyName, setNewAgencyName] = useState(""); - // Agency Mind panel state - const [isMindOpen, setIsMindOpen] = useState(false); - const [mindAgentId, setMindAgentId] = useState(null); - const [isMindLoading, setIsMindLoading] = useState(false); + // Tab state - tracks open agents + const [openTabs, setOpenTabs] = useState([]); - // Parse agencyId from URL + // Parse agencyId and agentId from URL const pathParts = location.split("/").filter(Boolean); const agencyId = pathParts[0] || null; const agentId = pathParts[1] === "agent" ? pathParts[2] || null : null; + const isOnSettings = location.endsWith("/settings"); // Data hooks const { @@ -1031,21 +805,63 @@ export default function App() { error: agenciesError, hasFetched: agenciesFetched, } = useAgencies(); - const { agents, blueprints, schedules, spawnAgent, getOrCreateMind } = useAgency(agencyId); + const { agents, blueprints, spawnAgent } = useAgency(agencyId); const { run: runState } = useAgent(agencyId, agentId); - // Agency Mind agent state - const { - state: mindState, - run: mindRunState, - connected: mindConnected, - loading: mindAgentLoading, - sendMessage: sendMindMessage, - cancel: cancelMind, - } = useAgent(agencyId, mindAgentId); - const isUnauthorized = agenciesError?.message.includes("401") ?? false; + // Current agency + const currentAgency = agencies.find((a) => a.id === agencyId); + + // Track running agents + const runningAgentIds = useMemo(() => { + const ids = new Set(); + if (agentId && runState?.status === "running") { + ids.add(agentId); + } + return ids; + }, [agentId, runState]); + + // Sync tabs with agents - add tab when navigating to agent + useEffect(() => { + if (!agentId || !agencyId) return; + + const agent = agents.find((a) => a.id === agentId); + if (!agent) return; + + // Check if tab already exists + const existingTab = openTabs.find((t) => t.agentId === agentId); + if (!existingTab) { + // Add new tab + setOpenTabs((prev) => [ + ...prev, + { + id: `tab-${agentId}`, + agentId: agent.id, + agentType: agent.agentType, + isRunning: runState?.status === "running", + }, + ]); + } + }, [agentId, agencyId, agents, runState?.status]); + + // Update tab running state + useEffect(() => { + if (!agentId) return; + setOpenTabs((prev) => + prev.map((tab) => + tab.agentId === agentId + ? { ...tab, isRunning: runState?.status === "running" } + : tab + ) + ); + }, [agentId, runState?.status]); + + // Clear tabs when agency changes + useEffect(() => { + setOpenTabs([]); + }, [agencyId]); + // Check if we got a 401 error useEffect(() => { if (agenciesError && agenciesError.message.includes("401")) { @@ -1060,106 +876,155 @@ export default function App() { window.location.reload(); }, []); - // Reset mind agent when agency changes + // Auto-select agency if only one exists useEffect(() => { - setMindAgentId(null); - setIsMindOpen(false); - }, [agencyId]); + if (!agenciesFetched || isLocked || isUnauthorized) return; + if (!agencyId && agencies.length === 1) { + navigate(`/${agencies[0].id}`); + } + }, [agenciesFetched, isLocked, isUnauthorized, agencyId, agencies, navigate]); - // Handle opening the Agency Mind panel - const handleOpenMind = useCallback(async () => { - if (!agencyId) return; + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+K or Cmd+K - Open command palette + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + setIsCommandPaletteOpen(true); + return; + } - if (mindAgentId) { - setIsMindOpen(true); - return; - } + // Ctrl+B - Toggle panel + if ((e.ctrlKey || e.metaKey) && e.key === "b") { + e.preventDefault(); + setIsPanelOpen((prev) => !prev); + return; + } - setIsMindLoading(true); - try { - const id = await getOrCreateMind(); - setMindAgentId(id); - setIsMindOpen(true); - } catch (err) { - console.error("Failed to get or create mind:", err); - } finally { - setIsMindLoading(false); - } - }, [agencyId, mindAgentId, getOrCreateMind]); - - // Derive agent status - const agentStatus = useMemo(() => { - const status: Record = {}; - agents.forEach((a) => { - if (a.id === agentId && runState) { - status[a.id] = - runState.status === "running" - ? "running" - : runState.status === "completed" - ? "done" - : runState.status === "error" - ? "error" - : "idle"; - } else { - status[a.id] = "idle"; + // Ctrl+W - Close current tab + if ((e.ctrlKey || e.metaKey) && e.key === "w" && agentId) { + e.preventDefault(); + handleCloseTab(`tab-${agentId}`); + return; + } + + // Ctrl+1-9 - Switch to tab by index + if ((e.ctrlKey || e.metaKey) && e.key >= "1" && e.key <= "9") { + e.preventDefault(); + const index = parseInt(e.key) - 1; + if (openTabs[index]) { + navigate(`/${agencyId}/agent/${openTabs[index].agentId}`); + } + return; } - }); - return status; - }, [agents, agentId, runState]); - - // Convert schedules to summary format - const scheduleSummaries: ScheduleSummary[] = useMemo(() => { - return schedules.map((s) => ({ - id: s.id, - name: s.name, - agentType: s.agentType, - status: (s.status === "paused" ? "paused" : "active") as "active" | "paused", - type: s.type as "once" | "cron" | "interval", - })); - }, [schedules]); + + // Ctrl+Tab - Next tab + if (e.ctrlKey && e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + const currentIndex = openTabs.findIndex((t) => t.agentId === agentId); + const nextIndex = (currentIndex + 1) % openTabs.length; + if (openTabs[nextIndex]) { + navigate(`/${agencyId}/agent/${openTabs[nextIndex].agentId}`); + } + return; + } + + // Ctrl+Shift+Tab - Previous tab + if (e.ctrlKey && e.key === "Tab" && e.shiftKey) { + e.preventDefault(); + const currentIndex = openTabs.findIndex((t) => t.agentId === agentId); + const prevIndex = currentIndex <= 0 ? openTabs.length - 1 : currentIndex - 1; + if (openTabs[prevIndex]) { + navigate(`/${agencyId}/agent/${openTabs[prevIndex].agentId}`); + } + return; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [agencyId, agentId, openTabs, navigate]); // Handlers + const handleSelectAgency = (id: string) => { + navigate(`/${id}`); + }; + const handleCreateAgency = async (name?: string) => { if (name) { - const agency = await createAgency(name); - navigate(`/${agency.id}`); - setShowAgencyModal(false); - setNewAgencyName(""); + try { + const agency = await createAgency(name); + navigate(`/${agency.id}`); + setShowAgencyModal(false); + setNewAgencyName(""); + } catch (err) { + console.error("[App] Failed to create agency:", err); + showError("Failed to create agency. Please try again."); + } } else { setShowAgencyModal(true); } }; - const handleCreateAgent = async (agentType?: string) => { - if (agentType && agencyId) { - const agent = await spawnAgent(agentType); + const handleSelectAgent = (agent: { id: string; agentType: string }) => { + if (agencyId) { navigate(`/${agencyId}/agent/${agent.id}`); - setShowBlueprintPicker(false); - } else { - setShowBlueprintPicker(true); } }; - // Auto-select agency if only one exists, or navigate away from invalid agency - useEffect(() => { - if (!agenciesFetched || isLocked || isUnauthorized) return; + const handleCreateFromBlueprint = async (blueprint: AgentBlueprint) => { + if (!agencyId) return; + try { + const agent = await spawnAgent(blueprint.name); + navigate(`/${agencyId}/agent/${agent.id}`); + } catch (err) { + console.error("[App] Failed to create agent:", err); + showError("Failed to create agent. Please try again."); + } + }; - // If no agency selected and exactly one exists, auto-select it - if (!agencyId && agencies.length === 1) { - navigate(`/${agencies[0].id}`); + const handleSelectTab = (tabId: string) => { + const tab = openTabs.find((t) => t.id === tabId); + if (tab && agencyId) { + navigate(`/${agencyId}/agent/${tab.agentId}`); } - }, [agenciesFetched, isLocked, isUnauthorized, agencyId, agencies, navigate]); + }; + const handleCloseTab = (tabId: string) => { + const tabIndex = openTabs.findIndex((t) => t.id === tabId); + const tab = openTabs[tabIndex]; + + setOpenTabs((prev) => prev.filter((t) => t.id !== tabId)); + + // Navigate to adjacent tab or home + if (tab && tab.agentId === agentId) { + const remainingTabs = openTabs.filter((t) => t.id !== tabId); + if (remainingTabs.length > 0) { + const nextTab = remainingTabs[Math.min(tabIndex, remainingTabs.length - 1)]; + navigate(`/${agencyId}/agent/${nextTab.agentId}`); + } else { + navigate(`/${agencyId}`); + } + } + }; + + const handleOpenSettings = () => { + if (agencyId) { + navigate(`/${agencyId}/settings`); + } + }; + + // Loading state if (!agenciesFetched) { return ; } + // Auth required if (isLocked || isUnauthorized) { return ; } - // No agency selected - show agency select/create modal - // Skip if exactly one agency (will auto-navigate via useEffect) + // No agency selected if (!agencyId && agencies.length !== 1) { return ( - {/* Sidebar */} - + {/* Top Header */} + handleCreateAgent()} - schedules={scheduleSummaries} - agentStatus={agentStatus} - isOpen={isMobileMenuOpen} - onClose={() => setIsMobileMenuOpen(false)} - onOpenMind={handleOpenMind} - isMindActive={isMindOpen} + selectedAgencyName={currentAgency?.name} + onSelectAgency={handleSelectAgency} + onCreateAgency={() => handleCreateAgency()} + onOpenSettings={handleOpenSettings} + onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} + onTogglePanel={() => setIsPanelOpen((prev) => !prev)} + isPanelOpen={isPanelOpen} /> - {/* Blueprint picker modal */} - {showBlueprintPicker && ( - handleCreateAgent(bp.name)} - onClose={() => setShowBlueprintPicker(false)} + {/* Tab Bar */} + {agencyId && !isOnSettings && ( + setIsCommandPaletteOpen(true)} /> )} + {/* Main content area */} +
+ {/* Agent Panel (sidebar) */} + {agencyId && ( + + )} + + {/* Content */} +
+ {isOnSettings && agencyId ? ( + + ) : agentId && agencyId ? ( + + ) : agencyId ? ( + setIsCommandPaletteOpen(true)} /> + ) : null} +
+
+ + {/* Command Palette */} + setIsCommandPaletteOpen(false)} + agents={agents} + blueprints={blueprints} + onSelectAgent={handleSelectAgent} + onCreateFromBlueprint={handleCreateFromBlueprint} + /> + {/* Agency creation modal */} {showAgencyModal && ( )} +

+ ); +} - {/* Main content */} -
- {agencyId && ( - setIsMobileMenuOpen(true)} - /> - )} +// Empty state when no agent is selected +function EmptyState({ onOpenCommandPalette }: { onOpenCommandPalette: () => void }) { + return ( +
+
+
_
+

+ NO AGENT SELECTED +

+

+ Open an agent or create a new one to get started. +

+
- - {/* Agency Mind Panel */} - {agencyId && ( - setIsMindOpen(false)} - agencyId={agencyId} - agencyName={agencies.find((a) => a.id === agencyId)?.name} - mindState={mindState} - runState={mindRunState} - connected={mindConnected} - loading={isMindLoading || mindAgentLoading} - onSendMessage={sendMindMessage} - onStop={cancelMind} - /> - )}
); } createRoot(document.getElementById("root")!).render( - - - + + + + + + + ); diff --git a/examples/personal-hub/ui/components/AgentPanel.tsx b/examples/personal-hub/ui/components/AgentPanel.tsx new file mode 100644 index 0000000..3e65ab5 --- /dev/null +++ b/examples/personal-hub/ui/components/AgentPanel.tsx @@ -0,0 +1,137 @@ +/** + * AgentPanel - Toggleable sidebar showing agents and blueprints + */ +import { useState } from "react"; +import { cn } from "../lib/utils"; +import type { AgentBlueprint, AgentSummary } from "./shared"; +import { shortId, formatRelativeTime } from "./shared"; + +interface AgentPanelProps { + isOpen: boolean; + agents: AgentSummary[]; + blueprints: AgentBlueprint[]; + runningAgentIds?: Set; + onSelectAgent: (agent: AgentSummary) => void; + onCreateFromBlueprint: (blueprint: AgentBlueprint) => void; +} + +export function AgentPanel({ + isOpen, + agents, + blueprints, + runningAgentIds = new Set(), + onSelectAgent, + onCreateFromBlueprint, +}: AgentPanelProps) { + const [agentsExpanded, setAgentsExpanded] = useState(true); + const [blueprintsExpanded, setBlueprintsExpanded] = useState(true); + + if (!isOpen) return null; + + return ( +
+ {/* Agents section */} +
+ + + {agentsExpanded && ( +
+ {agents.length === 0 ? ( +

+ No agents +

+ ) : ( +
+ {agents.map((agent) => { + const isRunning = runningAgentIds.has(agent.id); + return ( + + ); + })} +
+ )} +
+ )} +
+ + {/* Blueprints section */} +
+ + + {blueprintsExpanded && ( +
+ {blueprints.length === 0 ? ( +

+ No blueprints +

+ ) : ( +
+ {blueprints.map((blueprint) => ( + + ))} +
+ )} +
+ )} +
+ + {/* Footer hint */} +
+ Ctrl+B to toggle +
+
+ ); +} diff --git a/examples/personal-hub/ui/components/BottomPanel.tsx b/examples/personal-hub/ui/components/BottomPanel.tsx new file mode 100644 index 0000000..d353c8b --- /dev/null +++ b/examples/personal-hub/ui/components/BottomPanel.tsx @@ -0,0 +1,199 @@ +/** + * BottomPanel - Collapsible & resizable panel for Trace and Files views + * + * Sits at the bottom of the chat view, can be expanded/collapsed. + * When collapsed, shows a thin bar with tab buttons. + * When expanded, shows the selected view (Trace or Files). + * Drag the top edge to resize. + */ +import { useState, useCallback, useEffect, useRef } from "react"; +import { cn } from "../lib/utils"; + +type PanelTab = "trace" | "files"; + +const MIN_HEIGHT = 150; +const MAX_HEIGHT = 600; +const DEFAULT_HEIGHT = 300; + +interface BottomPanelProps { + traceContent: React.ReactNode; + filesContent: React.ReactNode; + /** Initial expanded state */ + defaultExpanded?: boolean; + /** Initial active tab */ + defaultTab?: PanelTab; +} + +export function BottomPanel({ + traceContent, + filesContent, + defaultExpanded = false, + defaultTab = "trace", +}: BottomPanelProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [activeTab, setActiveTab] = useState(defaultTab); + const [height, setHeight] = useState(DEFAULT_HEIGHT); + const [isDragging, setIsDragging] = useState(false); + const panelRef = useRef(null); + + // Handle tab click - expand if collapsed, or switch tab if already expanded + const handleTabClick = useCallback((tab: PanelTab) => { + if (!isExpanded) { + setActiveTab(tab); + setIsExpanded(true); + } else if (activeTab === tab) { + // Clicking active tab collapses + setIsExpanded(false); + } else { + setActiveTab(tab); + } + }, [isExpanded, activeTab]); + + // Keyboard shortcut: Escape to collapse + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isExpanded) { + setIsExpanded(false); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isExpanded]); + + // Drag resize handlers + const handleDragStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!panelRef.current) return; + + const parentRect = panelRef.current.parentElement?.getBoundingClientRect(); + if (!parentRect) return; + + // Calculate new height based on mouse position from bottom + const newHeight = parentRect.bottom - e.clientY; + setHeight(Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, newHeight))); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging]); + + return ( +
+ {/* Resize handle - only when expanded */} + {isExpanded && ( +
+
+
+ )} + + {/* Tab bar - always visible */} +
+
+ handleTabClick("trace")} + > + [~] + TRACE + + handleTabClick("files")} + > + [/] + FILES + +
+ + {/* Expand/collapse toggle */} + +
+ + {/* Content area - only when expanded */} + {isExpanded && ( +
+
+ {traceContent} +
+
+ {filesContent} +
+
+ )} +
+ ); +} + +// Tab button component +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/examples/personal-hub/ui/components/ChatView.tsx b/examples/personal-hub/ui/components/ChatView.tsx index 49b3a91..0c5e0ae 100644 --- a/examples/personal-hub/ui/components/ChatView.tsx +++ b/examples/personal-hub/ui/components/ChatView.tsx @@ -1,23 +1,37 @@ -import { useState, useRef, useEffect, useMemo } from "react"; +import { useState, useRef, useEffect } from "react"; import { cn } from "../lib/utils"; import { Button } from "./Button"; +import { formatTime, type Message, type ToolCall } from "./shared"; -// Types -interface ToolCall { - id: string; - name: string; - args: Record; - result?: unknown; - status: "pending" | "running" | "done" | "error"; +// ASCII spinner frames +const SPINNER_FRAMES = ["|", "/", "—", "\\"]; + +function useAsciiSpinner(interval = 100) { + const [frame, setFrame] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setFrame((f) => (f + 1) % SPINNER_FRAMES.length); + }, interval); + return () => clearInterval(timer); + }, [interval]); + + return SPINNER_FRAMES[frame]; } -interface Message { - id: string; - role: "user" | "assistant" | "system"; - content: string; - timestamp: string; - toolCalls?: ToolCall[]; - reasoning?: string; +// Thinking indicator shown while agent is processing +function ThinkingIndicator() { + const spinner = useAsciiSpinner(120); + + return ( +
+ [AI] +
+ {spinner} + THINKING +
+
+ ); } interface ChatViewProps { @@ -26,24 +40,8 @@ interface ChatViewProps { onStop?: () => void; isLoading?: boolean; placeholder?: string; -} - -function formatTime(timestamp: string): string { - const date = new Date(timestamp); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - const isYesterday = date.toDateString() === yesterday.toDateString(); - - const time = date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit" - }); - - if (isToday) return time; - if (isYesterday) return `Yesterday ${time}`; - return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " + time; + /** Unique ID for scroll position memory (e.g., agentId) */ + scrollKey?: string; } function ReasoningBlock({ reasoning }: { reasoning: string }) { @@ -209,21 +207,84 @@ function ToolCallCard({ toolCall }: { toolCall: ToolCall }) { ); } +// Store scroll positions per scrollKey +const scrollPositions = new Map(); + export function ChatView({ messages, onSendMessage, onStop, isLoading = false, - placeholder = "ENTER COMMAND..." + placeholder = "ENTER COMMAND...", + scrollKey, }: ChatViewProps) { const [input, setInput] = useState(""); const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); const textareaRef = useRef(null); + + // Track which scrollKey we last processed + const processedScrollKeyRef = useRef(undefined); + const prevMessagesLengthRef = useRef(messages.length); + const prevIsLoadingRef = useRef(isLoading); + // Ignore initial data load after tab switch + const ignoreNextChangeRef = useRef(true); - // Auto-scroll to bottom on new messages + // Save scroll position on scroll + const handleScroll = () => { + if (!scrollKey || !messagesContainerRef.current) return; + scrollPositions.set(scrollKey, messagesContainerRef.current.scrollTop); + }; + + // Single combined effect for scroll management useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + const isNewTab = processedScrollKeyRef.current !== scrollKey; + + if (isNewTab) { + // New tab - restore scroll, ignore next data load + processedScrollKeyRef.current = scrollKey; + prevMessagesLengthRef.current = messages.length; + prevIsLoadingRef.current = isLoading; + ignoreNextChangeRef.current = true; + + if (scrollKey) { + const savedPosition = scrollPositions.get(scrollKey); + if (savedPosition !== undefined) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = savedPosition; + } + }); + }); + } + } + return; + } + + // Same tab - check if this is initial data load to ignore + const messagesChanged = messages.length !== prevMessagesLengthRef.current; + const loadingChanged = isLoading !== prevIsLoadingRef.current; + + if (ignoreNextChangeRef.current && (messagesChanged || loadingChanged)) { + // Initial data load after tab switch - ignore and clear flag + ignoreNextChangeRef.current = false; + prevMessagesLengthRef.current = messages.length; + prevIsLoadingRef.current = isLoading; + return; + } + + // Real new messages or loading start + const hasNewMessages = messages.length > prevMessagesLengthRef.current; + const loadingJustStarted = isLoading && !prevIsLoadingRef.current; + + prevMessagesLengthRef.current = messages.length; + prevIsLoadingRef.current = isLoading; + + if (hasNewMessages || loadingJustStarted) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [scrollKey, messages.length, isLoading]); const handleSubmit = () => { if (!input.trim() || isLoading) return; @@ -241,7 +302,11 @@ export function ChatView({ return (
{/* Messages */} -
+
{messages.length === 0 ? (
@@ -262,6 +327,7 @@ export function ChatView({ {messages.map((msg) => ( ))} + {isLoading && }
)} @@ -297,7 +363,7 @@ export function ChatView({ {isLoading && onStop ? ( ) : (
- {isLoading && ( -
- - PROCESSING... -
- )}
); } - -export type { Message, ToolCall }; diff --git a/examples/personal-hub/ui/components/CommandPalette.tsx b/examples/personal-hub/ui/components/CommandPalette.tsx new file mode 100644 index 0000000..0a8cfe2 --- /dev/null +++ b/examples/personal-hub/ui/components/CommandPalette.tsx @@ -0,0 +1,294 @@ +/** + * CommandPalette - Fuzzy search for agents and blueprints + */ +import { useState, useEffect, useRef, useMemo } from "react"; +import { cn } from "../lib/utils"; +import type { AgentBlueprint, AgentSummary } from "./shared"; + +interface CommandPaletteProps { + isOpen: boolean; + onClose: () => void; + agents: AgentSummary[]; + blueprints: AgentBlueprint[]; + onSelectAgent: (agent: AgentSummary) => void; + onCreateFromBlueprint: (blueprint: AgentBlueprint) => void; +} + +type CommandItem = + | { type: "agent"; agent: AgentSummary } + | { type: "blueprint"; blueprint: AgentBlueprint }; + +export function CommandPalette({ + isOpen, + onClose, + agents, + blueprints, + onSelectAgent, + onCreateFromBlueprint, +}: CommandPaletteProps) { + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Build searchable items + const items: CommandItem[] = useMemo(() => { + const result: CommandItem[] = []; + + // Agents first + agents.forEach((agent) => { + result.push({ type: "agent", agent }); + }); + + // Then blueprints + blueprints.forEach((blueprint) => { + result.push({ type: "blueprint", blueprint }); + }); + + return result; + }, [agents, blueprints]); + + // Filter items based on query + const filteredItems = useMemo(() => { + if (!query.trim()) return items; + + const lowerQuery = query.toLowerCase(); + return items.filter((item) => { + if (item.type === "agent") { + return ( + item.agent.agentType.toLowerCase().includes(lowerQuery) || + item.agent.id.toLowerCase().includes(lowerQuery) + ); + } else { + return ( + item.blueprint.name.toLowerCase().includes(lowerQuery) || + item.blueprint.description?.toLowerCase().includes(lowerQuery) + ); + } + }); + }, [items, query]); + + // Reset selection when filtered items change + useEffect(() => { + setSelectedIndex(0); + }, [filteredItems.length]); + + // Focus input when opened + useEffect(() => { + if (isOpen) { + setQuery(""); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [isOpen]); + + // Scroll selected item into view + useEffect(() => { + if (listRef.current) { + const selected = listRef.current.querySelector("[data-selected=true]"); + selected?.scrollIntoView({ block: "nearest" }); + } + }, [selectedIndex]); + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, filteredItems.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + break; + case "Enter": + e.preventDefault(); + const selected = filteredItems[selectedIndex]; + if (selected) { + if (selected.type === "agent") { + onSelectAgent(selected.agent); + } else { + onCreateFromBlueprint(selected.blueprint); + } + onClose(); + } + break; + case "Escape": + e.preventDefault(); + onClose(); + break; + } + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Palette */} +
+ {/* Search input */} +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search agents and blueprints..." + className="w-full px-4 py-3 bg-transparent text-white text-sm placeholder:text-white/30 focus:outline-none" + /> +
+ + {/* Results */} +
+ {filteredItems.length === 0 ? ( +
+

+ No results found +

+
+ ) : ( + <> + {/* Section: Agents */} + {filteredItems.some((i) => i.type === "agent") && ( +
+ Agents +
+ )} + {filteredItems + .filter((i) => i.type === "agent") + .map((item, idx) => { + const realIndex = filteredItems.indexOf(item); + const agent = (item as { type: "agent"; agent: AgentSummary }).agent; + return ( + + ); + })} + + {/* Section: Blueprints */} + {filteredItems.some((i) => i.type === "blueprint") && ( +
+ Blueprints (spawn new) +
+ )} + {filteredItems + .filter((i) => i.type === "blueprint") + .map((item) => { + const realIndex = filteredItems.indexOf(item); + const blueprint = (item as { type: "blueprint"; blueprint: AgentBlueprint }).blueprint; + return ( + + ); + })} + + )} +
+ + {/* Footer with shortcuts */} +
+ ↑↓ Navigate + ↵ Select + Esc Close +
+
+
+ ); +} diff --git a/examples/personal-hub/ui/components/ContentHeader.tsx b/examples/personal-hub/ui/components/ContentHeader.tsx index 0749477..baba0db 100644 --- a/examples/personal-hub/ui/components/ContentHeader.tsx +++ b/examples/personal-hub/ui/components/ContentHeader.tsx @@ -1,27 +1,14 @@ +/** + * ContentHeader - Agent header with status and actions + * + * Simplified version without tabs - just shows agent info, status, and action menu. + */ import { useState, useRef, useEffect } from "react"; -import { Link } from "wouter"; import { cn } from "../lib/utils"; -export type TabId = "chat" | "trace" | "files" | "todos"; - -interface Tab { - id: TabId; - label: string; - icon: React.ReactNode; -} - -const TABS: Tab[] = [ - { id: "chat", label: "CHAT", icon: [>] }, - { id: "trace", label: "TRACE", icon: [~] }, - { id: "files", label: "FILES", icon: [/] }, - { id: "todos", label: "TASKS", icon: [*] } -]; - interface ContentHeaderProps { threadName: string; threadId: string; - agencyId: string; - activeTab: TabId; status?: "running" | "paused" | "done" | "error" | "idle"; onRestart?: () => void; onStop?: () => void; @@ -29,7 +16,7 @@ interface ContentHeaderProps { onMenuClick?: () => void; } -const STATUS_LABELS: Record = { +const STATUS_CONFIG: Record = { running: { label: "RUNNING", color: "text-[#00aaff]", borderColor: "border-[#00aaff]" }, paused: { label: "PAUSED", color: "text-[#ffaa00]", borderColor: "border-[#ffaa00]" }, done: { label: "COMPLETE", color: "text-[#00ff00]", borderColor: "border-[#00ff00]" }, @@ -40,8 +27,6 @@ const STATUS_LABELS: Record(null); - // Build base path for tab links - const basePath = `/${agencyId}/agent/${threadId}`; - const statusInfo = STATUS_LABELS[status]; + const statusInfo = STATUS_CONFIG[status]; const isRunning = status === "running"; const copyId = async () => { @@ -77,7 +60,7 @@ export function ContentHeader({ }, [showMenu]); return ( -
+
{/* Mobile menu button */} {onMenuClick && (
- {/* Tabs and actions */} + {/* Actions menu */}
-
- {TABS.map((tab) => ( - - {tab.icon} - {tab.label} - - ))} -
- - {/* Actions dropdown */} -
+ {/* Quick stop button when running */} + {isRunning && onStop && ( + + )} + + {/* Dropdown menu */} +
{showMenu && (
- {isRunning && onStop ? ( - - ) : !isRunning && onRestart ? ( + {!isRunning && onRestart && ( - ) : !onDelete ? ( -
- // NO ACTIONS -
- ) : null} + )} + {isRunning && onStop && ( + + )} {onDelete && ( <> - {(isRunning && onStop) || (!isRunning && onRestart) ? ( -
- ) : null} + {(onRestart || onStop) &&
} )} + {!onRestart && !onStop && !onDelete && ( +
+ // NO ACTIONS +
+ )}
)}
@@ -196,3 +171,6 @@ export function ContentHeader({
); } + +// Keep TabId export for backwards compatibility during transition +export type TabId = "chat" | "trace" | "files" | "todos"; diff --git a/examples/personal-hub/ui/components/ErrorBoundary.tsx b/examples/personal-hub/ui/components/ErrorBoundary.tsx new file mode 100644 index 0000000..bf56ed0 --- /dev/null +++ b/examples/personal-hub/ui/components/ErrorBoundary.tsx @@ -0,0 +1,100 @@ +import { Component, type ReactNode } from "react"; +import { Button } from "./Button"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * Error boundary component that catches rendering errors and displays a fallback UI. + * Prevents the entire app from crashing due to errors in child components. + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error("[ErrorBoundary] Caught error:", error); + console.error("[ErrorBoundary] Error info:", errorInfo); + } + + handleRetry = (): void => { + this.setState({ hasError: false, error: null }); + }; + + handleReload = (): void => { + window.location.reload(); + }; + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+
+
[!]
+

+ SYSTEM_ERROR +

+

+ SOMETHING WENT WRONG +

+
+ + {this.state.error && ( +
+

+ ERROR_MESSAGE: +

+

+ {this.state.error.message || "Unknown error"} +

+
+ )} + +
+ + +
+ +
+ SYS.STATUS: ERROR | RECOVERY: AVAILABLE +
+
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/examples/personal-hub/ui/components/HomeView.tsx b/examples/personal-hub/ui/components/HomeView.tsx deleted file mode 100644 index 00968a8..0000000 --- a/examples/personal-hub/ui/components/HomeView.tsx +++ /dev/null @@ -1,765 +0,0 @@ -/** - * HomeView - Dashboard home view with metrics, activity feed, and command input - * - * This is the landing page when an agency is selected but no agent. - * Combines the best of Command Center into the classic layout. - */ -import { useState, useRef, useEffect, useCallback, useMemo } from "react"; -import { useLocation } from "wouter"; -import { cn } from "../lib/utils"; -import { - type DashboardMetrics, - type ActivityItem, - type MentionTarget, - type AgentBlueprint, - type AgentSummary, - isSystemBlueprint, - isSystemAgent, - formatNumber, -} from "./shared"; - -// ============================================================================ -// Dashboard Component (adapted from command-center/Dashboard.tsx) -// ============================================================================ - -function AsciiBar({ value, max, width = 10 }: { value: number; max: number; width?: number }) { - const filled = Math.round((value / Math.max(max, 1)) * width); - const empty = width - filled; - return ( - - {"█".repeat(Math.max(0, filled))} - {"░".repeat(Math.max(0, empty))} - - ); -} - -function AsciiSparkline({ data }: { data: number[] }) { - if (data.length === 0) return ; - - const blocks = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; - const max = Math.max(...data, 1); - - return ( - - {data.slice(-12).map((value, i) => { - const level = Math.floor((value / max) * 7); - return {blocks[Math.min(level, 7)]}; - })} - - ); -} - -function Percentage({ value }: { value: number }) { - const color = - value >= 90 - ? "text-emerald-400/70" - : value >= 70 - ? "text-amber-400/70" - : "text-red-400/70"; - return {value}%; -} - -function Duration({ ms }: { ms: number }) { - if (ms < 1000) return {ms}ms; - return {(ms / 1000).toFixed(1)}s; -} - -function StatBox({ - label, - value, - subValue, - children, - compact = false, -}: { - label: string; - value: React.ReactNode; - subValue?: string; - children?: React.ReactNode; - compact?: boolean; -}) { - return ( -
-
- {label} -
-
- {value} -
- {subValue &&
{subValue}
} - {children &&
{children}
} -
- ); -} - -function Dashboard({ metrics }: { metrics: DashboardMetrics }) { - const { agents, runs, schedules, tokens, responseTime, memory } = metrics; - - const agentBreakdown = - agents.total > 0 ? `${agents.active}↑ ${agents.idle}○ ${agents.error}✕` : "—"; - - return ( -
-
- DASHBOARD -
- -
- - - active - - - - - - - — - ) : ( - - ) - } - compact - > - {runs.today === 0 && runs.week === 0 ? ( - NO RUNS - ) : ( - - )} - - - - - - {schedules.active} - | - - {schedules.paused} - - - - {responseTime && ( - } - subValue={`p95: ${responseTime.p95}ms`} - compact - > - - - )} - - {tokens && ( - - - - )} - - {memory && ( - - )} -
-
- ); -} - -// ============================================================================ -// Activity Feed Component (adapted from command-center/ActivityFeed.tsx) -// ============================================================================ - -function formatActivityTime(timestamp: string): string { - const date = new Date(timestamp); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - - const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - - if (isToday) return time; - return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " + time; -} - -function getSourceName(item: ActivityItem): string { - if (item.from === "you") return "YOU"; - if (item.type === "system") return "SYSTEM"; - - const agentType = item.agentType || ""; - if (agentType === "_agency-mind") return "AGENCY MIND"; - if (agentType === "_hub-mind") return "HUB MIND"; - if (agentType.startsWith("_")) - return agentType.slice(1).toUpperCase().replace(/-/g, " "); - - return agentType.toUpperCase(); -} - -function getTargetName(target: string): string { - if (target === "_agency-mind") return "AGENCY MIND"; - if (target === "_hub-mind") return "HUB MIND"; - if (target.startsWith("_")) return target.slice(1).toUpperCase().replace(/-/g, " "); - return target.toUpperCase(); -} - -function isMind(item: ActivityItem): boolean { - const agentType = item.agentType || ""; - return agentType === "_agency-mind" || agentType === "_hub-mind"; -} - -function SystemMessage({ item }: { item: ActivityItem }) { - return ( -
- - // {item.content || item.event} - -
- ); -} - -function UserMessage({ - item, - onClick, -}: { - item: ActivityItem; - onClick?: () => void; -}) { - const target = item.to ? getTargetName(item.to) : ""; - - return ( -
-
- {target && ( - {target} {'<-'} - )} - - YOU - -
- - - {item.content && ( -
-

{item.content}

-
- )} - -
- - {formatActivityTime(item.timestamp)} - -
-
- ); -} - -function AgentMessage({ - item, - onClick, -}: { - item: ActivityItem; - onClick?: () => void; -}) { - const sourceName = getSourceName(item); - const isRunning = item.status === "running"; - const isMindAgent = isMind(item); - const agentId = (item.agentId || "").slice(0, 6); - - return ( -
-
- - {sourceName} - - {agentId && ( - {agentId} - )} - {isRunning && ( - - running - - )} -
- - {item.content && ( -
-

{item.content}

-
- )} - - {item.type === "agent_event" && item.event && !item.content && ( -
- {item.event} - {item.details && : {item.details}} -
- )} - -
- - {formatActivityTime(item.timestamp)} - -
-
- ); -} - -function ActivityFeed({ - items, - onItemClick, -}: { - items: ActivityItem[]; - onItemClick?: (item: ActivityItem) => void; -}) { - const containerRef = useRef(null); - const shouldAutoScroll = useRef(true); - - useEffect(() => { - if (shouldAutoScroll.current && containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } - }, [items]); - - const handleScroll = () => { - if (!containerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = containerRef.current; - shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 50; - }; - - return ( -
- {items.length === 0 ? ( -
-
-
_
-

- NO ACTIVITY YET -

-

- Type a message below. Use @ to mention agents. -

-
-
- ) : ( -
- {items.map((item) => { - if (item.type === "system") { - return ; - } - if (item.from === "you") { - return ( - onItemClick?.(item) : undefined} - /> - ); - } - return ( - onItemClick?.(item) : undefined} - /> - ); - })} -
- )} -
- ); -} - -// ============================================================================ -// Command Input Component (adapted from command-center/CommandInput.tsx) -// ============================================================================ - -function CommandInput({ - targets, - defaultTarget, - onSubmit, - disabled = false, - placeholder = "Type a message... (@ to mention)", -}: { - targets: MentionTarget[]; - defaultTarget: string; - onSubmit: (target: string, message: string) => Promise; - disabled?: boolean; - placeholder?: string; -}) { - const [input, setInput] = useState(""); - const [selectedTarget, setSelectedTarget] = useState(defaultTarget); - const [showMentions, setShowMentions] = useState(false); - const [mentionFilter, setMentionFilter] = useState(""); - const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); - const [isSending, setIsSending] = useState(false); - const inputRef = useRef(null); - const mentionListRef = useRef(null); - - // Update selected target when default changes - useEffect(() => { - setSelectedTarget(defaultTarget); - }, [defaultTarget]); - - const filteredTargets = useMemo(() => { - if (!mentionFilter) return targets; - const lower = mentionFilter.toLowerCase(); - return targets.filter((t) => t.label.toLowerCase().includes(lower)); - }, [targets, mentionFilter]); - - // Reset selection when filter changes - useEffect(() => { - setSelectedMentionIndex(0); - }, [filteredTargets.length]); - - // Scroll selected item into view - useEffect(() => { - if (showMentions && mentionListRef.current) { - const selected = mentionListRef.current.querySelector("[data-selected=true]"); - selected?.scrollIntoView({ block: "nearest" }); - } - }, [selectedMentionIndex, showMentions]); - - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setInput(value); - - // Check for @ mention - const lastAtIndex = value.lastIndexOf("@"); - if (lastAtIndex >= 0 && lastAtIndex === value.length - 1) { - setShowMentions(true); - setMentionFilter(""); - } else if (lastAtIndex >= 0 && showMentions) { - const afterAt = value.slice(lastAtIndex + 1); - if (!afterAt.includes(" ")) { - setMentionFilter(afterAt); - } else { - setShowMentions(false); - } - } else if (!value.includes("@")) { - setShowMentions(false); - } - }; - - const selectMention = useCallback( - (target: MentionTarget) => { - const lastAtIndex = input.lastIndexOf("@"); - const beforeAt = lastAtIndex >= 0 ? input.slice(0, lastAtIndex) : input; - setInput(beforeAt); - setSelectedTarget(target.id); - setShowMentions(false); - setMentionFilter(""); - inputRef.current?.focus(); - }, - [input] - ); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (showMentions) { - if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { - e.preventDefault(); - setSelectedMentionIndex((i) => Math.min(i + 1, filteredTargets.length - 1)); - } else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { - e.preventDefault(); - setSelectedMentionIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === "Tab" || e.key === "Enter") { - e.preventDefault(); - if (filteredTargets[selectedMentionIndex]) { - selectMention(filteredTargets[selectedMentionIndex]); - } - } else if (e.key === "Escape") { - e.preventDefault(); - setShowMentions(false); - } - return; - } - - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }; - - const handleSubmit = async () => { - if (!input.trim() || isSending || disabled) return; - - setIsSending(true); - try { - await onSubmit(selectedTarget, input.trim()); - setInput(""); - } finally { - setIsSending(false); - } - }; - - const selectedTargetLabel = useMemo(() => { - const target = targets.find((t) => t.id === selectedTarget); - if (!target) return "MIND"; - if (target.type === "mind") return "MIND"; - if (target.id.startsWith("new:")) return `NEW ${target.label.replace("new ", "")}`; - return target.label; - }, [selectedTarget, targets]); - - return ( -
- {/* Mention autocomplete */} - {showMentions && filteredTargets.length > 0 && ( -
- {filteredTargets.map((target, i) => ( - - ))} -
- )} - - {/* Input row */} -
- {/* Target indicator */} -
- @ - - {selectedTargetLabel} - -
- - {/* Text input */} -
-