diff --git a/apps/web/src/app/(dashboard)/@header/default.tsx b/apps/web/src/app/(dashboard)/@header/default.tsx index 58ebe2e..6455a66 100644 --- a/apps/web/src/app/(dashboard)/@header/default.tsx +++ b/apps/web/src/app/(dashboard)/@header/default.tsx @@ -3,6 +3,7 @@ import { usePathname } from "next/navigation"; import { Separator } from "@clawe/ui/components/separator"; import { SidebarToggle } from "@dashboard/sidebar-toggle"; +import { ChatPanelToggle } from "@dashboard/chat-panel-toggle"; import { isLockedSidebarRoute } from "@dashboard/sidebar-config"; import { AgencyStatus } from "@/components/agency-status"; @@ -25,7 +26,14 @@ const DefaultHeaderContent = () => { )} - +
+ + + +
); }; diff --git a/apps/web/src/app/(dashboard)/_components/chat-panel-toggle.tsx b/apps/web/src/app/(dashboard)/_components/chat-panel-toggle.tsx new file mode 100644 index 0000000..266ccc2 --- /dev/null +++ b/apps/web/src/app/(dashboard)/_components/chat-panel-toggle.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { BotMessageSquare } from "lucide-react"; +import { cn } from "@clawe/ui/lib/utils"; +import { Button } from "@clawe/ui/components/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@clawe/ui/components/tooltip"; +import { useChatPanel } from "@/providers/chat-panel-provider"; + +export const ChatPanelToggle = () => { + const { isOpen, toggle } = useChatPanel(); + + return ( + + + + + + {isOpen ? "Close chat" : "Open chat"} + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/_components/chat-panel.tsx b/apps/web/src/app/(dashboard)/_components/chat-panel.tsx new file mode 100644 index 0000000..3c74048 --- /dev/null +++ b/apps/web/src/app/(dashboard)/_components/chat-panel.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { cn } from "@clawe/ui/lib/utils"; +import { Chat } from "@/components/chat"; +import { useChatPanel } from "@/providers/chat-panel-provider"; + +const CLAWE_SESSION_KEY = "agent:main:main"; + +export const ChatPanel = () => { + const { isOpen, close } = useChatPanel(); + + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-header.tsx b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-header.tsx new file mode 100644 index 0000000..da100ff --- /dev/null +++ b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-header.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Users } from "lucide-react"; +import { cn } from "@clawe/ui/lib/utils"; + +export type AgentsPanelHeaderProps = { + total: number; + active: number; + collapsed?: boolean; +}; + +export const AgentsPanelHeader = ({ + total, + active, + collapsed = false, +}: AgentsPanelHeaderProps) => { + return ( +
+
+
+ + {active > 0 && ( + + + + )} +
+ + {!collapsed && ( +

Agents

+ )} +
+ + {!collapsed && ( + + {total} + + )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-item.tsx b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-item.tsx new file mode 100644 index 0000000..9e07b5c --- /dev/null +++ b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-item.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { cn } from "@clawe/ui/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@clawe/ui/components/tooltip"; + +type AgentStatus = "idle" | "active" | "blocked"; + +const statusConfig: Record< + AgentStatus, + { dotColor: string; bgColor: string; textColor: string; text: string } +> = { + active: { + dotColor: "bg-emerald-500", + bgColor: "bg-emerald-50 dark:bg-emerald-950/50", + textColor: "text-emerald-600 dark:text-emerald-400", + text: "Working", + }, + idle: { + dotColor: "bg-gray-400", + bgColor: "bg-gray-100 dark:bg-gray-800/50", + textColor: "text-gray-500 dark:text-gray-400", + text: "Idle", + }, + blocked: { + dotColor: "bg-amber-500", + bgColor: "bg-amber-50 dark:bg-amber-950/50", + textColor: "text-amber-600 dark:text-amber-400", + text: "Blocked", + }, +}; + +// Format relative time like "4 hours ago", "Just now", etc. +const formatRelativeTime = (timestamp?: number): string => { + if (!timestamp) return "Never"; + + const now = Date.now(); + const diff = now - timestamp; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days === 1) return "Yesterday"; + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +}; + +// Generate a consistent background color from agent name +const getAvatarColor = (name: string) => { + const colors = [ + "bg-violet-100 dark:bg-violet-900/40", + "bg-rose-100 dark:bg-rose-900/40", + "bg-amber-100 dark:bg-amber-900/40", + "bg-emerald-100 dark:bg-emerald-900/40", + "bg-sky-100 dark:bg-sky-900/40", + "bg-fuchsia-100 dark:bg-fuchsia-900/40", + ]; + const index = name.charCodeAt(0) % colors.length; + return colors[index]; +}; + +export type AgentsPanelItemProps = { + agent: { + _id: string; + name: string; + emoji?: string; + role: string; + status: string; + lastSeen?: number; + }; + collapsed?: boolean; + selected?: boolean; + onToggle?: () => void; +}; + +export const AgentsPanelItem = ({ + agent, + collapsed = false, + selected = false, + onToggle, +}: AgentsPanelItemProps) => { + const status = (agent.status as AgentStatus) || "idle"; + const { dotColor, bgColor, textColor, text } = statusConfig[status]; + const avatarColor = getAvatarColor(agent.name); + + const avatar = ( +
+ {agent.emoji || agent.name.charAt(0)} +
+ ); + + if (collapsed) { + return ( + + + + + +

{agent.name}

+

{agent.role}

+

{text}

+
+
+ ); + } + + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-list.tsx b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-list.tsx new file mode 100644 index 0000000..9bdd743 --- /dev/null +++ b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel-list.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { ScrollArea } from "@clawe/ui/components/scroll-area"; +import { cn } from "@clawe/ui/lib/utils"; +import { AgentsPanelItem } from "./agents-panel-item"; + +type Agent = { + _id: string; + name: string; + emoji?: string; + role: string; + status: string; + lastSeen?: number; +}; + +export type AgentsPanelListProps = { + agents: Agent[]; + collapsed?: boolean; + selectedAgentIds?: string[]; + onToggleAgent?: (agentId: string) => void; +}; + +export const AgentsPanelList = ({ + agents, + collapsed = false, + selectedAgentIds = [], + onToggleAgent, +}: AgentsPanelListProps) => { + return ( + +
+ {agents.map((agent) => ( + onToggleAgent?.(agent._id)} + /> + ))} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx new file mode 100644 index 0000000..180a0bb --- /dev/null +++ b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useMemo } from "react"; +import { useQuery } from "convex/react"; +import { api } from "@clawe/backend"; +import { cn } from "@clawe/ui/lib/utils"; +import { Loader2 } from "lucide-react"; +import { AgentsPanelHeader } from "./agents-panel-header"; +import { AgentsPanelList } from "./agents-panel-list"; + +export type AgentsPanelProps = { + className?: string; + collapsed?: boolean; + selectedAgentIds?: string[]; + onSelectionChange?: (agentIds: string[]) => void; +}; + +export const AgentsPanel = ({ + className, + collapsed = false, + selectedAgentIds = [], + onSelectionChange, +}: AgentsPanelProps) => { + const agents = useQuery(api.agents.squad); + + const { total, active } = useMemo(() => { + if (!agents) return { total: 0, active: 0 }; + return { + total: agents.length, + active: agents.filter((a) => a.status === "active").length, + }; + }, [agents]); + + const handleToggleAgent = (agentId: string) => { + if (!onSelectionChange) return; + + if (selectedAgentIds.includes(agentId)) { + onSelectionChange(selectedAgentIds.filter((id) => id !== agentId)); + } else { + onSelectionChange([...selectedAgentIds, agentId]); + } + }; + + return ( +
+ + + {!agents ? ( +
+ +
+ ) : agents.length === 0 ? ( +
+ {!collapsed && "No agents yet"} +
+ ) : ( + + )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/board/_components/agents-panel/index.ts b/apps/web/src/app/(dashboard)/board/_components/agents-panel/index.ts new file mode 100644 index 0000000..c078780 --- /dev/null +++ b/apps/web/src/app/(dashboard)/board/_components/agents-panel/index.ts @@ -0,0 +1,2 @@ +export { AgentsPanel } from "./agents-panel"; +export type { AgentsPanelProps } from "./agents-panel"; diff --git a/apps/web/src/app/(dashboard)/board/page.tsx b/apps/web/src/app/(dashboard)/board/page.tsx index 9af6987..86622f2 100644 --- a/apps/web/src/app/(dashboard)/board/page.tsx +++ b/apps/web/src/app/(dashboard)/board/page.tsx @@ -1,18 +1,29 @@ "use client"; +import { useState } from "react"; import { useQuery } from "convex/react"; import { api } from "@clawe/backend"; +import { Bell } from "lucide-react"; +import { Button } from "@clawe/ui/components/button"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@clawe/ui/components/resizable"; import { PageHeader, PageHeaderRow, PageHeaderTitle, + PageHeaderActions, } from "@dashboard/page-header"; import { KanbanBoard, type KanbanTask, type KanbanColumnDef, } from "@/components/kanban"; -import { LiveFeed } from "@/components/live-feed"; +import { LiveFeed, LiveFeedTitle } from "@/components/live-feed"; +import { useDrawer } from "@/providers/drawer-provider"; +import { AgentsPanel } from "./_components/agents-panel"; // Map priority from Convex to Kanban format function mapPriority(priority?: string): "low" | "medium" | "high" { @@ -27,18 +38,20 @@ function mapPriority(priority?: string): "low" | "medium" | "high" { } } -// Map Convex task to Kanban task format -function mapTask(task: { +type ConvexTask = { _id: string; title: string; description?: string; priority?: string; - assignees?: { name: string; emoji?: string }[]; + assignees?: { _id: string; name: string; emoji?: string }[]; subtasks?: { title: string; description?: string; done: boolean }[]; -}): KanbanTask { +}; + +// Map Convex task to Kanban task format +function mapTask(task: ConvexTask): KanbanTask { const subtasks: KanbanTask[] = task.subtasks - ?.filter((st) => !st.done) // Only show incomplete subtasks + ?.filter((st) => !st.done) .map((st, i) => ({ id: `${task._id}-${i}`, title: st.title, @@ -67,8 +80,33 @@ function isValidStatus(status: string): status is TaskStatus { ); } +// Panel sizes in pixels +const COLLAPSED_SIZE = "48px"; +const DEFAULT_SIZE = "220px"; +const MIN_SIZE = "180px"; // Must be > COLLAPSED_SIZE for expand to work +const MAX_SIZE = "280px"; + +const STORAGE_KEY = "board-agents-panel-collapsed"; + +// Get initial collapsed state from localStorage (runs once on module load) +const getInitialCollapsed = () => { + if (typeof window === "undefined") return false; + return localStorage.getItem(STORAGE_KEY) === "true"; +}; + const BoardPage = () => { + const { openDrawer } = useDrawer(); const tasks = useQuery(api.tasks.list, {}); + const [selectedAgentIds, setSelectedAgentIds] = useState([]); + const [isCollapsed, setIsCollapsed] = useState(getInitialCollapsed); + + // Filter tasks by selected agents + const filteredTasks = tasks?.filter((task) => { + // If no agents selected, show all tasks + if (selectedAgentIds.length === 0) return true; + // Show task if any of its assignees is selected + return task.assignees?.some((a) => selectedAgentIds.includes(a._id)); + }); // Group tasks by status const groupedTasks: Record = { @@ -79,9 +117,8 @@ const BoardPage = () => { done: [], }; - // Add real tasks from Convex - if (tasks) { - for (const task of tasks) { + if (filteredTasks) { + for (const task of filteredTasks) { if (isValidStatus(task.status)) { groupedTasks[task.status].push(mapTask(task)); } @@ -113,34 +150,65 @@ const BoardPage = () => { variant: "review", tasks: groupedTasks.review, }, - { - id: "done", - title: "Done", - variant: "done", - tasks: groupedTasks.done, - }, + { id: "done", title: "Done", variant: "done", tasks: groupedTasks.done }, ]; + const handleOpenFeed = () => { + openDrawer(, ); + }; + + const handlePanelResize = (size: { + asPercentage: number; + inPixels: number; + }) => { + // Panel is collapsed when size is at or near collapsedSize (48px) + const collapsed = size.inPixels <= 60; + setIsCollapsed(collapsed); + localStorage.setItem(STORAGE_KEY, String(collapsed)); + }; + return ( - <> - - - Board - - - -
- {/* Kanban Board */} -
- -
+ + {/* Agents Panel */} + + + + + + + {/* Main Content */} + +
+ + + Board + + + + + - {/* Live Feed Sidebar */} -
- +
+ +
-
- +
+
); }; diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 1c5639e..066ca83 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -7,6 +7,9 @@ import { SidebarInset, SidebarProvider } from "@clawe/ui/components/sidebar"; import { DashboardSidebar } from "@dashboard/dashboard-sidebar"; import { isLockedSidebarRoute } from "@dashboard/sidebar-config"; import { SquadProvider } from "@/providers/squad-provider"; +import { DrawerProvider } from "@/providers/drawer-provider"; +import { ChatPanelProvider } from "@/providers/chat-panel-provider"; +import { ChatPanel } from "@dashboard/chat-panel"; import { useRequireOnboarding } from "@/hooks/use-onboarding-guard"; type DashboardLayoutProps = { @@ -18,6 +21,9 @@ type DashboardLayoutProps = { const isFullHeightRoute = (path: string) => path === "/board" || path === "/chat"; +// Routes that handle their own padding +const isNoPaddingRoute = (path: string) => path === "/board"; + const DashboardLayout = ({ children, header }: DashboardLayoutProps) => { const pathname = usePathname(); const { isLoading } = useRequireOnboarding(); @@ -26,6 +32,7 @@ const DashboardLayout = ({ children, header }: DashboardLayoutProps) => { ); const fullHeight = isFullHeightRoute(pathname); + const noPadding = isNoPaddingRoute(pathname); // Update sidebar state when route changes useEffect(() => { @@ -42,28 +49,35 @@ const DashboardLayout = ({ children, header }: DashboardLayoutProps) => { return ( - - - -
- {header} -
- {fullHeight ? ( -
- {children} -
- ) : ( - -
{children}
-
- )} -
-
+ + + + +
+ {header} +
+ + {fullHeight ? ( +
+ {children} +
+ ) : ( + +
{children}
+
+ )} +
+
+ +
+
); }; diff --git a/apps/web/src/components/chat/chat-input-textarea.tsx b/apps/web/src/components/chat/chat-input-textarea.tsx index 526e66c..66019c7 100644 --- a/apps/web/src/components/chat/chat-input-textarea.tsx +++ b/apps/web/src/components/chat/chat-input-textarea.tsx @@ -31,14 +31,21 @@ export const ChatInputTextarea = ({ const textarea = textareaRef.current; if (!textarea) return; + // Calculate heights (text-sm with leading-relaxed + py-2 padding) + const lineHeight = 24; + const verticalPadding = 16; // py-2 = 8px top + 8px bottom + const minHeight = lineHeight * minRows + verticalPadding; + const maxHeight = lineHeight * maxRows + verticalPadding; + + // When empty, use fixed minimum height (avoids scrollHeight issues with placeholder) + if (!value.trim()) { + textarea.style.height = `${minHeight}px`; + return; + } + // Reset height to auto to get the correct scrollHeight textarea.style.height = "auto"; - // Calculate line height (approximately 24px for text-sm with leading-relaxed) - const lineHeight = 24; - const minHeight = lineHeight * minRows; - const maxHeight = lineHeight * maxRows; - // Set the height based on content, clamped between min and max const newHeight = Math.min( Math.max(textarea.scrollHeight, minHeight), diff --git a/apps/web/src/components/kanban/kanban-board.tsx b/apps/web/src/components/kanban/kanban-board.tsx index b5951fe..539c0bf 100644 --- a/apps/web/src/components/kanban/kanban-board.tsx +++ b/apps/web/src/components/kanban/kanban-board.tsx @@ -27,7 +27,7 @@ export const KanbanBoard = ({ columns, className }: KanbanBoardProps) => { className={cn("h-full w-full", className)} data-kanban-board > -
+
{columns.map((column) => ( = { - inbox: , - assigned: , - "in-progress": , - review: , - done: , +const columnIconComponents: Record< + KanbanColumnDef["variant"], + React.ComponentType<{ className?: string; strokeWidth?: number }> +> = { + inbox: Inbox, + assigned: CircleDot, + "in-progress": Play, + review: Eye, + done: CircleCheck, +}; + +const emptyStateIcons: Record< + KanbanColumnDef["variant"], + React.ComponentType<{ className?: string; strokeWidth?: number }> +> = { + inbox: Mail, + assigned: Moon, + "in-progress": Moon, + review: Moon, + done: Target, +}; + +const EmptyState = ({ variant }: { variant: KanbanColumnDef["variant"] }) => { + const EmptyIcon = emptyStateIcons[variant]; + const variantStyles = columnVariants[variant]; + + return ( +
+ + Empty +
+ ); }; export type KanbanColumnProps = { @@ -21,38 +59,45 @@ export type KanbanColumnProps = { export const KanbanColumn = ({ column, onTaskClick }: KanbanColumnProps) => { const variant = columnVariants[column.variant]; - const icon = columnIcons[column.variant]; + const IconComponent = columnIconComponents[column.variant]; return (
{/* Header */}
+ - {icon} {column.title} - + {column.tasks.length}
{/* Task list */} - -
- {column.tasks.map((task) => ( - - ))} -
+ + {column.tasks.length === 0 ? ( + + ) : ( +
+ {column.tasks.map((task) => ( + + ))} +
+ )}
); diff --git a/apps/web/src/components/kanban/types.ts b/apps/web/src/components/kanban/types.ts index 500bd4c..e06d307 100644 --- a/apps/web/src/components/kanban/types.ts +++ b/apps/web/src/components/kanban/types.ts @@ -31,26 +31,31 @@ export type KanbanBoardProps = { // Variant styles (used internally by KanbanColumn) export const columnVariants: Record< ColumnVariant, - { badge: string; column: string } + { badge: string; column: string; icon: string } > = { inbox: { - badge: "bg-gray-200 text-gray-600 dark:bg-gray-800 dark:text-gray-300", - column: "bg-gray-100 dark:bg-zinc-900", + badge: "bg-gray-800 text-white dark:bg-gray-700", + column: "bg-rose-50/50 dark:bg-rose-950/20", + icon: "text-gray-600 dark:text-gray-400", }, assigned: { - badge: "bg-amber-500 text-white", - column: "bg-amber-50 dark:bg-amber-950/30", + badge: "bg-gray-800 text-white dark:bg-gray-700", + column: "bg-orange-50/50 dark:bg-orange-950/20", + icon: "text-gray-600 dark:text-gray-400", }, "in-progress": { - badge: "bg-blue-500 text-white", - column: "bg-blue-50 dark:bg-blue-950/30", + badge: "bg-gray-800 text-white dark:bg-gray-700", + column: "bg-blue-50/50 dark:bg-blue-950/20", + icon: "text-gray-600 dark:text-gray-400", }, review: { - badge: "bg-purple-500 text-white", - column: "bg-purple-50 dark:bg-purple-950/30", + badge: "bg-gray-800 text-white dark:bg-gray-700", + column: "bg-purple-50/50 dark:bg-purple-950/20", + icon: "text-gray-600 dark:text-gray-400", }, done: { - badge: "bg-green-500 text-white", - column: "bg-green-50 dark:bg-green-950/30", + badge: "bg-gray-800 text-white dark:bg-gray-700", + column: "bg-green-50/50 dark:bg-green-950/20", + icon: "text-gray-600 dark:text-gray-400", }, }; diff --git a/apps/web/src/components/live-feed/index.ts b/apps/web/src/components/live-feed/index.ts index b63e381..74756ed 100644 --- a/apps/web/src/components/live-feed/index.ts +++ b/apps/web/src/components/live-feed/index.ts @@ -1,3 +1,3 @@ -export { LiveFeed } from "./live-feed"; +export { LiveFeed, LiveFeedTitle } from "./live-feed"; export { LiveFeedItem } from "./live-feed-item"; export type * from "./types"; diff --git a/apps/web/src/components/live-feed/live-feed-item.tsx b/apps/web/src/components/live-feed/live-feed-item.tsx index 753201d..df745b7 100644 --- a/apps/web/src/components/live-feed/live-feed-item.tsx +++ b/apps/web/src/components/live-feed/live-feed-item.tsx @@ -26,26 +26,18 @@ const formatRelativeTime = (timestamp: number): string => { return `${days}d ago`; }; -// Generate a consistent color based on agent name +// Generate a consistent color based on agent name (matches agents panel) const getAgentColor = (name: string): string => { - // Light, colorful pastels const colors = [ - "bg-violet-300 dark:bg-violet-400", - "bg-blue-300 dark:bg-blue-400", - "bg-emerald-300 dark:bg-emerald-400", - "bg-amber-300 dark:bg-amber-400", - "bg-rose-300 dark:bg-rose-400", - "bg-cyan-300 dark:bg-cyan-400", - "bg-fuchsia-300 dark:bg-fuchsia-400", - "bg-teal-300 dark:bg-teal-400", - ] as const; - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - return ( - colors[Math.abs(hash) % colors.length] ?? "bg-violet-300 dark:bg-violet-400" - ); + "bg-violet-100 dark:bg-violet-900/40", + "bg-rose-100 dark:bg-rose-900/40", + "bg-amber-100 dark:bg-amber-900/40", + "bg-emerald-100 dark:bg-emerald-900/40", + "bg-sky-100 dark:bg-sky-900/40", + "bg-fuchsia-100 dark:bg-fuchsia-900/40", + ]; + const index = name.charCodeAt(0) % colors.length; + return colors[index] || "bg-violet-100 dark:bg-violet-900/40"; }; // Get activity type config @@ -126,29 +118,34 @@ export const LiveFeedItem = ({
{/* Timeline line */} {!isLast && ( -
+
)} {/* Avatar with emoji */}
- {agentEmoji} + {agentEmoji}
{/* Content */}
-
- {agentName} - +
+ {agentName} + {config.icon} {config.verb} {taskTitle && ( - + {taskTitle} )} diff --git a/apps/web/src/components/live-feed/live-feed.tsx b/apps/web/src/components/live-feed/live-feed.tsx index 3b01eab..8bfa193 100644 --- a/apps/web/src/components/live-feed/live-feed.tsx +++ b/apps/web/src/components/live-feed/live-feed.tsx @@ -5,10 +5,31 @@ import { useQuery } from "convex/react"; import { api } from "@clawe/backend"; import { cn } from "@clawe/ui/lib/utils"; import { ScrollArea } from "@clawe/ui/components/scroll-area"; -import { Activity, Loader2 } from "lucide-react"; +import { Bell, BellOff, Loader2 } from "lucide-react"; import { LiveFeedItem } from "./live-feed-item"; import type { FeedActivity, FeedFilter } from "./types"; +/** Title component for use in drawer header */ +export const LiveFeedTitle = ({ limit = 50 }: { limit?: number }) => { + const activities = useQuery(api.activities.feed, { limit }); + const count = activities?.length ?? 0; + + return ( + <> +
+ + + + +
+ Activity + + {count} + + + ); +}; + const FILTER_CONFIG: { id: FeedFilter; label: string; @@ -83,25 +104,9 @@ export const LiveFeed = ({ className, limit = 50 }: LiveFeedProps) => {
- {/* Header */} -
-
-
-
- - - - -
-

Activity

-
- - {filteredActivities.length} - -
- - {/* Filter tabs */} -
+ {/* Filter tabs */} +
+
{FILTER_CONFIG.map((filter) => { const count = filterCounts[filter.id] ?? 0; const isActive = activeFilter === filter.id; @@ -146,7 +151,7 @@ export const LiveFeed = ({ className, limit = 50 }: LiveFeedProps) => {
) : filteredActivities.length === 0 ? (
- + No activity yet diff --git a/apps/web/src/providers/chat-panel-provider.tsx b/apps/web/src/providers/chat-panel-provider.tsx new file mode 100644 index 0000000..04cfc9b --- /dev/null +++ b/apps/web/src/providers/chat-panel-provider.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { createContext, useContext, useState, useCallback } from "react"; + +type ChatPanelContextValue = { + isOpen: boolean; + toggle: () => void; + open: () => void; + close: () => void; +}; + +const ChatPanelContext = createContext(null); + +export const useChatPanel = () => { + const context = useContext(ChatPanelContext); + if (!context) { + throw new Error("useChatPanel must be used within ChatPanelProvider"); + } + return context; +}; + +export const ChatPanelProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [isOpen, setIsOpen] = useState(false); + + const toggle = useCallback(() => setIsOpen((prev) => !prev), []); + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + + return ( + + {children} + + ); +}; diff --git a/apps/web/src/providers/drawer-provider.tsx b/apps/web/src/providers/drawer-provider.tsx new file mode 100644 index 0000000..8a56f7b --- /dev/null +++ b/apps/web/src/providers/drawer-provider.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useCallback, + useEffect, +} from "react"; +import { X } from "lucide-react"; +import { cn } from "@clawe/ui/lib/utils"; +import { Button } from "@clawe/ui/components/button"; + +type DrawerState = { + isOpen: boolean; + title?: React.ReactNode; + content: React.ReactNode; +}; + +type DrawerContextValue = { + isOpen: boolean; + openDrawer: (content: React.ReactNode, title?: React.ReactNode) => void; + closeDrawer: () => void; +}; + +const DrawerContext = createContext(null); + +export const useDrawer = () => { + const context = useContext(DrawerContext); + if (!context) { + throw new Error("useDrawer must be used within DrawerProvider"); + } + return context; +}; + +export const DrawerProvider = ({ children }: { children: React.ReactNode }) => { + const [state, setState] = useState({ + isOpen: false, + content: null, + }); + + const openDrawer = useCallback( + (content: React.ReactNode, title?: React.ReactNode) => { + setState({ isOpen: true, content, title }); + }, + [], + ); + + const closeDrawer = useCallback(() => { + setState((prev) => ({ ...prev, isOpen: false })); + }, []); + + // Handle escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && state.isOpen) { + closeDrawer(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [state.isOpen, closeDrawer]); + + return ( + +
+ {children} + + {/* Overlay */} + + + ); +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 5312da2..ac6681e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,7 +43,9 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-resizable-panels": "^4", "tailwind-merge": "^3.4.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2" } } diff --git a/packages/ui/src/components/drawer.tsx b/packages/ui/src/components/drawer.tsx new file mode 100644 index 0000000..3d9a162 --- /dev/null +++ b/packages/ui/src/components/drawer.tsx @@ -0,0 +1,135 @@ +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "@clawe/ui/lib/utils"; + +function Drawer({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/packages/ui/src/components/resizable.tsx b/packages/ui/src/components/resizable.tsx new file mode 100644 index 0000000..f12fc8b --- /dev/null +++ b/packages/ui/src/components/resizable.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { GripVerticalIcon } from "lucide-react"; +import * as ResizablePrimitive from "react-resizable-panels"; + +import { cn } from "@clawe/ui/lib/utils"; + +function ResizablePanelGroup({ + className, + ...props +}: ResizablePrimitive.GroupProps) { + return ( + + ); +} + +function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { + return ; +} + +function ResizableHandle({ + withHandle, + className, + ...props +}: ResizablePrimitive.SeparatorProps & { + withHandle?: boolean; +}) { + return ( + div]:rotate-90", + className, + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+ ); +} + +export { ResizableHandle, ResizablePanel, ResizablePanelGroup }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 765970b..63b9a7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,12 +351,18 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.0(react@19.2.0) + react-resizable-panels: + specifier: ^4 + version: 4.6.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@clawe/eslint-config': specifier: workspace:* @@ -4057,6 +4063,12 @@ packages: '@types/react': optional: true + react-resizable-panels@4.6.2: + resolution: {integrity: sha512-d6hyD6s7ewNAI+oINrZznR/08GUyAszrowXouUDztePEn/tQ2z/LEI2qRvrizYBe3TpgBi0cCjc10pXTTOc4jw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -4540,6 +4552,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -8751,6 +8769,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + react-resizable-panels@4.6.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-style-singleton@2.2.3(@types/react@19.2.2)(react@19.2.0): dependencies: get-nonce: 1.0.1 @@ -9365,6 +9388,15 @@ snapshots: dependencies: react: 19.2.0 + vaul@1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3