Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/web/src/app/(dashboard)/@header/default.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -25,7 +26,14 @@ const DefaultHeaderContent = () => {
</>
)}
</div>
<AgencyStatus />
<div className="flex items-center gap-2">
<AgencyStatus />
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-4"
/>
<ChatPanelToggle />
</div>
</div>
);
};
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/app/(dashboard)/_components/chat-panel-toggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", isOpen && "bg-accent")}
onClick={toggle}
>
<BotMessageSquare className="h-4 w-4" />
<span className="sr-only">Toggle chat</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isOpen ? "Close chat" : "Open chat"}
</TooltipContent>
</Tooltip>
);
};
31 changes: 31 additions & 0 deletions apps/web/src/app/(dashboard)/_components/chat-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
"bg-sidebar hidden h-full shrink-0 overflow-hidden py-2 transition-all duration-200 ease-out md:block",
isOpen ? "ml-1 w-96 pr-2 opacity-100" : "w-0 opacity-0",
)}
>
{isOpen && (
<div className="bg-background flex h-full flex-col overflow-hidden rounded-xl border">
<Chat
sessionKey={CLAWE_SESSION_KEY}
mode="panel"
onClose={close}
className="h-full border-l-0"
/>
</div>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
"flex shrink-0 items-center border-b",
collapsed ? "justify-center px-2 py-3" : "justify-between px-4 py-3",
)}
>
<div className="flex items-center gap-2">
<div className="relative">
<Users className="text-foreground h-4 w-4" />
{active > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-500">
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-500 opacity-75" />
</span>
)}
</div>

{!collapsed && (
<h2 className="text-sm font-semibold tracking-wide">Agents</h2>
)}
</div>

{!collapsed && (
<span className="text-muted-foreground rounded-md border px-2 py-0.5 text-xs tabular-nums">
{total}
</span>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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 = (
<div
className={cn(
"flex shrink-0 items-center justify-center text-lg",
collapsed ? "h-9 w-9 rounded-xl" : "h-11 w-11 rounded-2xl",
avatarColor,
)}
>
{agent.emoji || agent.name.charAt(0)}
</div>
);

if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggle}
className={cn(
"flex w-full items-center justify-center rounded-md p-1.5 transition-colors",
selected
? "bg-gray-100 ring ring-gray-200 dark:bg-gray-800/50 dark:ring-gray-700"
: "hover:bg-muted/60",
)}
>
<div className="relative">
{avatar}
<span
className={cn(
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900",
dotColor,
)}
/>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p className="font-medium">{agent.name}</p>
<p className="text-muted-foreground text-xs">{agent.role}</p>
<p className={cn("mt-1 text-xs", textColor)}>{text}</p>
</TooltipContent>
</Tooltip>
);
}

return (
<button
type="button"
onClick={onToggle}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors",
selected
? "bg-gray-100 ring ring-gray-200 dark:bg-gray-800/50 dark:ring-gray-700"
: "hover:bg-muted/60",
)}
>
{/* Avatar */}
{avatar}

{/* Name & Role */}
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{agent.name}</span>
<span className="text-muted-foreground block truncate text-xs">
{agent.role}
</span>
</div>

{/* Status Badge */}
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"flex shrink-0 cursor-default items-center gap-1.5 rounded-full px-2 py-0.5",
bgColor,
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", dotColor)} />
<span
className={cn("text-[10px] font-medium uppercase", textColor)}
>
{text}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<p className="text-xs">
{status === "active"
? "Active now"
: `Last active: ${formatRelativeTime(agent.lastSeen)}`}
</p>
</TooltipContent>
</Tooltip>
</button>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<ScrollArea className="flex-1">
<div className={cn("space-y-1", collapsed ? "px-1 py-2" : "p-2")}>
{agents.map((agent) => (
<AgentsPanelItem
key={agent._id}
agent={agent}
collapsed={collapsed}
selected={selectedAgentIds.includes(agent._id)}
onToggle={() => onToggleAgent?.(agent._id)}
/>
))}
</div>
</ScrollArea>
);
};
Loading