- {/* 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 (
-
-
-
-
- {fullHeight ? (
-
- {children}
-
- ) : (
-
- {children}
-
- )}
-
-
+
+
+
+
+
+
+ {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 */}
-
-
-
-
- {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 */}
+
+
+ {/* Drawer Panel */}
+
+ {/* Header */}
+ {state.title && (
+
+
+ {state.title}
+
+
+
+ )}
+
+ {/* Content */}
+
{state.content}
+
+
+
+ );
+};
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