mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[chore] panel: prettier reformat across the codebase
Apply `pnpm format` (prettier 3.8.5, 80-col / double-quote / semi /
trailing-comma-all) to the 223 pre-existing panel files that predated the
prettier infra added in cb5365a4. Pure formatting — no semantic changes:
multi-line arrays/objects collapsed where they fit, trailing newlines added
(.prettierrc.json), import grouping unchanged.
Verified: `pnpm format:check` clean, `pnpm lint` clean, `pnpm typecheck`
clean, `pnpm test` 113/113 pass (7 files).
This commit is contained in:
@@ -2,12 +2,32 @@
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useAgentStatus, useStopAgent, useSpawnAgent, useAgentDefinition } from "@/hooks/use-agents";
|
||||
import {
|
||||
useAgentStatus,
|
||||
useStopAgent,
|
||||
useSpawnAgent,
|
||||
useAgentDefinition,
|
||||
} from "@/hooks/use-agents";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ArrowLeft, Play, Square, AlertTriangle, Clock, RefreshCw, User, Users } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
Square,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
User,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AgentStatusCards,
|
||||
@@ -50,13 +70,19 @@ export default function AgentDetailPage() {
|
||||
|
||||
// Get display values from definition or fallback
|
||||
const displayName = definition?.name || agentId;
|
||||
const roleLabel = definition?.role ? ROLE_LABELS[definition.role] || definition.role : null;
|
||||
const teamLabel = definition?.team ? TEAM_LABELS[definition.team] || definition.team : null;
|
||||
const roleLabel = definition?.role
|
||||
? ROLE_LABELS[definition.role] || definition.role
|
||||
: null;
|
||||
const teamLabel = definition?.team
|
||||
? TEAM_LABELS[definition.team] || definition.team
|
||||
: null;
|
||||
|
||||
const handleStop = async (graceful: boolean) => {
|
||||
try {
|
||||
await stopAgent.mutateAsync({ agentId, graceful });
|
||||
toast.success(graceful ? "Agent stopping gracefully" : "Agent force stopped");
|
||||
toast.success(
|
||||
graceful ? "Agent stopping gracefully" : "Agent force stopped",
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to stop agent");
|
||||
}
|
||||
@@ -97,7 +123,9 @@ export default function AgentDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = agent && ["running", "ready", "starting", "waiting_long"].includes(agent.state);
|
||||
const isActive =
|
||||
agent &&
|
||||
["running", "ready", "starting", "waiting_long"].includes(agent.state);
|
||||
const isWaiting = agent?.state === "waiting_long";
|
||||
|
||||
return (
|
||||
@@ -111,7 +139,9 @@ export default function AgentDetailPage() {
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{displayName}</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{displayName}
|
||||
</h1>
|
||||
{roleLabel && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
@@ -199,7 +229,9 @@ export default function AgentDetailPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-lg font-semibold text-red-600">{agent.error_count} error(s)</p>
|
||||
<p className="text-lg font-semibold text-red-600">
|
||||
{agent.error_count} error(s)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -213,20 +245,23 @@ export default function AgentDetailPage() {
|
||||
Agent Waiting for Input
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This agent is blocked and waiting for human input or external resolution.
|
||||
This agent is blocked and waiting for human input or external
|
||||
resolution.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Use the "Resolve Wait" button above to provide the information or decision
|
||||
the agent needs to continue execution.
|
||||
Use the "Resolve Wait" button above to provide the
|
||||
information or decision the agent needs to continue execution.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Agent Stream Viewer */}
|
||||
{isActive && <AgentStreamViewer agentId={agentId} agentName={displayName} />}
|
||||
{isActive && (
|
||||
<AgentStreamViewer agentId={agentId} agentName={displayName} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -32,11 +32,11 @@ export default function AgentsPage() {
|
||||
const { data: usageRows } = useAgentUsage();
|
||||
|
||||
// Check if it's a connection error (backend not running)
|
||||
const isOffline = error && (
|
||||
error.message?.includes("Network Error") ||
|
||||
error.message?.includes("ECONNREFUSED") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK"
|
||||
);
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
error.message?.includes("ECONNREFUSED") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
// Convert agents array to a record keyed by agent_id for easy lookup
|
||||
const agentStatuses = useMemo(() => {
|
||||
@@ -86,7 +86,9 @@ export default function AgentsPage() {
|
||||
<OrchestratorStatusCards status={status} isLoading={isLoading} />
|
||||
|
||||
{/* Waiting Agents Alert */}
|
||||
{waitingAgents && <WaitingAgentsAlert waitingAgents={waitingAgents} />}
|
||||
{waitingAgents && (
|
||||
<WaitingAgentsAlert waitingAgents={waitingAgents} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ function BusinessPageContent() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Business</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Company goals, your chief-of-staff Secretary, and Board pitches — all in
|
||||
one place.
|
||||
Company goals, your chief-of-staff Secretary, and Board pitches — all
|
||||
in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,9 +37,10 @@ function SessionDetailContent() {
|
||||
const groupId = searchParams.get("group");
|
||||
|
||||
// Build back URL preserving context
|
||||
const backUrl = channelId && groupId
|
||||
? `/communications?channel=${channelId}&group=${groupId}`
|
||||
: "/communications";
|
||||
const backUrl =
|
||||
channelId && groupId
|
||||
? `/communications?channel=${channelId}&group=${groupId}`
|
||||
: "/communications";
|
||||
const queryClient = useQueryClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -52,12 +53,20 @@ function SessionDetailContent() {
|
||||
// is what stops the panel from accumulating a 404 storm across every dead
|
||||
// session it has opened. `refetchMessages` (the manual Refresh button) stays
|
||||
// available for live sessions.
|
||||
const { data: session, isLoading: loadingSession, refetch: refetchSession } = useSession(sessionId);
|
||||
const { data: messagesData, isLoading: loadingMessages, refetch: refetchMessages } = useSessionMessages(sessionId);
|
||||
const {
|
||||
data: session,
|
||||
isLoading: loadingSession,
|
||||
refetch: refetchSession,
|
||||
} = useSession(sessionId);
|
||||
const {
|
||||
data: messagesData,
|
||||
isLoading: loadingMessages,
|
||||
refetch: refetchMessages,
|
||||
} = useSessionMessages(sessionId);
|
||||
|
||||
// Sort messages chronologically (oldest first for chat UI)
|
||||
const messages = [...(messagesData?.items || [])].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
// Track if we've done the initial scroll
|
||||
@@ -73,11 +82,19 @@ function SessionDetailContent() {
|
||||
|
||||
// Send message mutation
|
||||
const sendMessage = useMutation({
|
||||
mutationFn: async ({ content, type }: { content: string; type: string }) => {
|
||||
mutationFn: async ({
|
||||
content,
|
||||
type,
|
||||
}: {
|
||||
content: string;
|
||||
type: string;
|
||||
}) => {
|
||||
return messagesApi.send(sessionId, content, type);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["messages", "list", sessionId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["messages", "list", sessionId],
|
||||
});
|
||||
toast.success("Message sent");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -95,7 +112,8 @@ function SessionDetailContent() {
|
||||
};
|
||||
|
||||
// Get primary task
|
||||
const primaryTask = session?.task_links?.find(t => t.is_primary) || session?.task_links?.[0];
|
||||
const primaryTask =
|
||||
session?.task_links?.find((t) => t.is_primary) || session?.task_links?.[0];
|
||||
|
||||
if (loadingSession) {
|
||||
return (
|
||||
@@ -122,7 +140,8 @@ function SessionDetailContent() {
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">Session Not Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The session you're looking for doesn't exist or has been deleted.
|
||||
The session you're looking for doesn't exist or has
|
||||
been deleted.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -162,7 +181,9 @@ function SessionDetailContent() {
|
||||
<Card className="mb-4 shrink-0">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Badge variant={session.status === "active" ? "default" : "secondary"}>
|
||||
<Badge
|
||||
variant={session.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{session.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
@@ -175,7 +196,8 @@ function SessionDetailContent() {
|
||||
</div>
|
||||
{session.closed_at && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
Closed: {format(new Date(session.closed_at), "MMM d, yyyy h:mm a")}
|
||||
Closed:{" "}
|
||||
{format(new Date(session.closed_at), "MMM d, yyyy h:mm a")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -190,7 +212,8 @@ function SessionDetailContent() {
|
||||
href={`/tasks/${primaryTask.task_id}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{primaryTask.task_title || `Task ${primaryTask.task_id.slice(0, 8)}`}
|
||||
{primaryTask.task_title ||
|
||||
`Task ${primaryTask.task_id.slice(0, 8)}`}
|
||||
</Link>
|
||||
)}
|
||||
{session.task_links.length > 1 && (
|
||||
@@ -232,7 +255,9 @@ function SessionDetailContent() {
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No messages in this session</p>
|
||||
<p className="text-sm">Use the composer below to start the conversation</p>
|
||||
<p className="text-sm">
|
||||
Use the composer below to start the conversation
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -37,12 +37,17 @@ interface ChannelListProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ChannelList({ channels, selectedId, onSelect, isLoading }: ChannelListProps) {
|
||||
function ChannelList({
|
||||
channels,
|
||||
selectedId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: ChannelListProps) {
|
||||
const cellChannels = channels.filter((c) => c.type === "cell");
|
||||
const crossCellChannels = channels.filter((c) => c.type === "cross_cell");
|
||||
const managementChannels = channels.filter((c) => c.type === "management");
|
||||
const otherChannels = channels.filter(
|
||||
(c) => !["cell", "cross_cell", "management"].includes(c.type)
|
||||
(c) => !["cell", "cross_cell", "management"].includes(c.type),
|
||||
);
|
||||
|
||||
const renderGroup = (title: string, items: Channel[]) => {
|
||||
@@ -208,9 +213,10 @@ function SessionList({ channelId, groupId }: SessionListProps) {
|
||||
<div className="font-medium text-sm truncate">
|
||||
{session.task_links?.length > 0 ? (
|
||||
<>
|
||||
{session.task_links.find(l => l.is_primary)?.task_title ||
|
||||
session.task_links[0]?.task_title ||
|
||||
`Task ${session.task_links[0]?.task_id.slice(0, 8)}`}
|
||||
{session.task_links.find((l) => l.is_primary)
|
||||
?.task_title ||
|
||||
session.task_links[0]?.task_title ||
|
||||
`Task ${session.task_links[0]?.task_id.slice(0, 8)}`}
|
||||
</>
|
||||
) : (
|
||||
`Session ${session.id.slice(0, 8)}`
|
||||
@@ -222,7 +228,9 @@ function SessionList({ channelId, groupId }: SessionListProps) {
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Badge
|
||||
variant={session.status === "active" ? "default" : "secondary"}
|
||||
variant={
|
||||
session.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{session.status}
|
||||
@@ -243,7 +251,13 @@ function SessionList({ channelId, groupId }: SessionListProps) {
|
||||
// Empty State Components
|
||||
// =============================================================================
|
||||
|
||||
function EmptyPanel({ icon: Icon, message }: { icon: typeof MessageSquare; message: string }) {
|
||||
function EmptyPanel({
|
||||
icon: Icon,
|
||||
message,
|
||||
}: {
|
||||
icon: typeof MessageSquare;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
@@ -267,10 +281,10 @@ function CommunicationsPageContent() {
|
||||
|
||||
const { data: channels, isLoading, error, refetch } = useChannels();
|
||||
|
||||
const isOffline = error && (
|
||||
error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK"
|
||||
);
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
@@ -285,18 +299,24 @@ function CommunicationsPageContent() {
|
||||
const query = params.toString();
|
||||
router.push(query ? `/communications?${query}` : "/communications");
|
||||
},
|
||||
[router, searchParams]
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSelectChannel = useCallback((id: string) => {
|
||||
updateParams({ channel: id, group: null });
|
||||
}, [updateParams]);
|
||||
const handleSelectChannel = useCallback(
|
||||
(id: string) => {
|
||||
updateParams({ channel: id, group: null });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleSelectGroup = useCallback((id: string) => {
|
||||
updateParams({ group: id });
|
||||
}, [updateParams]);
|
||||
const handleSelectGroup = useCallback(
|
||||
(id: string) => {
|
||||
updateParams({ group: id });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const selectedChannel = channels?.find(c => c.id === channelId);
|
||||
const selectedChannel = channels?.find((c) => c.id === channelId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
|
||||
@@ -347,7 +367,10 @@ function CommunicationsPageContent() {
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Groups</span>
|
||||
{selectedChannel && (
|
||||
<Badge variant="outline" className="ml-auto text-xs font-normal">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto text-xs font-normal"
|
||||
>
|
||||
{selectedChannel.name}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -379,7 +402,11 @@ function CommunicationsPageContent() {
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={MessageSquare}
|
||||
message={channelId ? "Select a group" : "Select a channel and group"}
|
||||
message={
|
||||
channelId
|
||||
? "Select a group"
|
||||
: "Select a channel and group"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -394,33 +421,35 @@ function CommunicationsPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function CommunicationsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64" />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 lg:gap-6">
|
||||
<Card className="col-span-12 lg:col-span-3">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-12 lg:col-span-3">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-12 lg:col-span-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 lg:gap-6">
|
||||
<Card className="col-span-12 lg:col-span-3">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-12 lg:col-span-3">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-12 lg:col-span-6" />
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<CommunicationsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -144,7 +144,10 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
||||
<span>Related Task</span>
|
||||
</div>
|
||||
<Link href={`/tasks/${entry.task_id}`}>
|
||||
<Badge variant="outline" className="hover:bg-muted cursor-pointer">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="hover:bg-muted cursor-pointer"
|
||||
>
|
||||
Task #{entry.task_id.slice(0, 8)}
|
||||
</Badge>
|
||||
</Link>
|
||||
|
||||
@@ -89,37 +89,52 @@ function JournalsPageContent() {
|
||||
}, [selectedAgentId, agentSearch, urlType, taskFilter]);
|
||||
|
||||
// Update URL params
|
||||
const updateParams = useCallback((updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
const query = params.toString();
|
||||
router.push(query ? `/journals?${query}` : "/journals");
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string | null) => {
|
||||
// Only reset filters when changing to a different agent
|
||||
if (agentId !== selectedAgentId) {
|
||||
updateParams({ agent: agentId, type: null, task: null });
|
||||
}
|
||||
});
|
||||
const query = params.toString();
|
||||
router.push(query ? `/journals?${query}` : "/journals");
|
||||
}, [router, searchParams]);
|
||||
},
|
||||
[updateParams, selectedAgentId],
|
||||
);
|
||||
|
||||
const handleSelectAgent = useCallback((agentId: string | null) => {
|
||||
// Only reset filters when changing to a different agent
|
||||
if (agentId !== selectedAgentId) {
|
||||
updateParams({ agent: agentId, type: null, task: null });
|
||||
}
|
||||
}, [updateParams, selectedAgentId]);
|
||||
const handleAgentSearch = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ q: value || null });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleAgentSearch = useCallback((value: string) => {
|
||||
updateParams({ q: value || null });
|
||||
}, [updateParams]);
|
||||
const handleTypeChange = useCallback(
|
||||
(value: JournalEntryType | "all") => {
|
||||
updateParams({ type: value === "all" ? null : value });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback((value: JournalEntryType | "all") => {
|
||||
updateParams({ type: value === "all" ? null : value });
|
||||
}, [updateParams]);
|
||||
|
||||
const handleTaskChange = useCallback((value: string | null) => {
|
||||
updateParams({ task: value });
|
||||
}, [updateParams]);
|
||||
const handleTaskChange = useCallback(
|
||||
(value: string | null) => {
|
||||
updateParams({ task: value });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Filter agents by search
|
||||
const filteredAgents = (agents ?? []).filter((agent) => {
|
||||
@@ -211,35 +226,37 @@ function JournalsPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function JournalsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64" />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 lg:col-span-3">
|
||||
<Card>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-9">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 lg:col-span-3">
|
||||
<Card>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-9">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<JournalsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { DevKanban, QaKanban, PrReviewKanban, PmKanban } from "@/components/kanban";
|
||||
import {
|
||||
DevKanban,
|
||||
QaKanban,
|
||||
PrReviewKanban,
|
||||
PmKanban,
|
||||
} from "@/components/kanban";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Code, TestTube, GitPullRequest, ClipboardList } from "lucide-react";
|
||||
@@ -66,16 +71,18 @@ function KanbanPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function KanbanPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-96 w-full" />
|
||||
))}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-96 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<KanbanPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,10 @@ import {
|
||||
Coins,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { UsageProjection as UP, CacheEfficiencyResponse as CER } from "@/types";
|
||||
import type {
|
||||
UsageProjection as UP,
|
||||
CacheEfficiencyResponse as CER,
|
||||
} from "@/types";
|
||||
|
||||
// ─── Humanized number formatting ─────────────────────────────────────────────
|
||||
|
||||
@@ -71,7 +74,14 @@ interface MetricCardProps {
|
||||
trendValue?: string;
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, subtitle, icon, trend, trendValue }: MetricCardProps) {
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon,
|
||||
trend,
|
||||
trendValue,
|
||||
}: MetricCardProps) {
|
||||
const displayValue = typeof value === "number" ? humanizeCount(value) : value;
|
||||
return (
|
||||
<Card>
|
||||
@@ -87,10 +97,19 @@ function MetricCard({ title, value, subtitle, icon, trend, trendValue }: MetricC
|
||||
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
|
||||
)}
|
||||
{trend && trendValue && (
|
||||
<div className={"flex items-center gap-1 mt-2 text-xs " +
|
||||
(trend === "up" ? "text-green-600" : trend === "down" ? "text-red-600" : "text-gray-500")
|
||||
}>
|
||||
<TrendingUp className={"h-3 w-3 " + (trend === "down" ? "rotate-180" : "")} />
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-1 mt-2 text-xs " +
|
||||
(trend === "up"
|
||||
? "text-green-600"
|
||||
: trend === "down"
|
||||
? "text-red-600"
|
||||
: "text-gray-500")
|
||||
}
|
||||
>
|
||||
<TrendingUp
|
||||
className={"h-3 w-3 " + (trend === "down" ? "rotate-180" : "")}
|
||||
/>
|
||||
{trendValue}
|
||||
</div>
|
||||
)}
|
||||
@@ -106,8 +125,13 @@ interface TeamHealthCardProps {
|
||||
completedToday: number;
|
||||
}
|
||||
|
||||
function TeamHealthCard({ team, activeTasks, blockedTasks, completedToday }: TeamHealthCardProps) {
|
||||
const healthScore = Math.max(0, 100 - (blockedTasks * 20));
|
||||
function TeamHealthCard({
|
||||
team,
|
||||
activeTasks,
|
||||
blockedTasks,
|
||||
completedToday,
|
||||
}: TeamHealthCardProps) {
|
||||
const healthScore = Math.max(0, 100 - blockedTasks * 20);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -144,12 +168,16 @@ function TeamHealthCard({ team, activeTasks, blockedTasks, completedToday }: Tea
|
||||
|
||||
function PerformanceTabContent() {
|
||||
const { data: tasks, error: tasksError, refetch: refetchTasks } = useTasks();
|
||||
const { data: status, error: statusError, refetch: refetchStatus } = useOrchestratorStatus();
|
||||
const {
|
||||
data: status,
|
||||
error: statusError,
|
||||
refetch: refetchStatus,
|
||||
} = useOrchestratorStatus();
|
||||
|
||||
const isOffline = (tasksError || statusError) && (
|
||||
tasksError?.message?.includes("Network Error") ||
|
||||
statusError?.message?.includes("Network Error")
|
||||
);
|
||||
const isOffline =
|
||||
(tasksError || statusError) &&
|
||||
(tasksError?.message?.includes("Network Error") ||
|
||||
statusError?.message?.includes("Network Error"));
|
||||
|
||||
const refetch = () => {
|
||||
refetchTasks();
|
||||
@@ -176,17 +204,35 @@ function PerformanceTabContent() {
|
||||
}).length;
|
||||
|
||||
// Task status counts
|
||||
const pending = taskList.filter((t) => t.status === TaskStatus.PENDING).length;
|
||||
const inProgress = taskList.filter((t) => t.status === TaskStatus.IN_PROGRESS).length;
|
||||
const blocked = taskList.filter((t) => t.status === TaskStatus.BLOCKED).length;
|
||||
const awaitingQa = taskList.filter((t) => t.status === TaskStatus.AWAITING_QA).length;
|
||||
const completed = taskList.filter((t) => t.status === TaskStatus.COMPLETED).length;
|
||||
const pending = taskList.filter(
|
||||
(t) => t.status === TaskStatus.PENDING,
|
||||
).length;
|
||||
const inProgress = taskList.filter(
|
||||
(t) => t.status === TaskStatus.IN_PROGRESS,
|
||||
).length;
|
||||
const blocked = taskList.filter(
|
||||
(t) => t.status === TaskStatus.BLOCKED,
|
||||
).length;
|
||||
const awaitingQa = taskList.filter(
|
||||
(t) => t.status === TaskStatus.AWAITING_QA,
|
||||
).length;
|
||||
const completed = taskList.filter(
|
||||
(t) => t.status === TaskStatus.COMPLETED,
|
||||
).length;
|
||||
|
||||
// Agent counts
|
||||
const runningAgents = status?.by_state?.running || agentList.filter((a) => a.state === "running").length;
|
||||
const idleAgents = status?.by_state?.idle || agentList.filter((a) => a.state === "idle" || a.state === "stopped").length;
|
||||
const waitingAgents = status?.waiting_count || agentList.filter((a) => a.state === "waiting_long").length;
|
||||
const errorAgents = status?.by_state?.error || agentList.filter((a) => a.state === "error").length;
|
||||
const runningAgents =
|
||||
status?.by_state?.running ||
|
||||
agentList.filter((a) => a.state === "running").length;
|
||||
const idleAgents =
|
||||
status?.by_state?.idle ||
|
||||
agentList.filter((a) => a.state === "idle" || a.state === "stopped").length;
|
||||
const waitingAgents =
|
||||
status?.waiting_count ||
|
||||
agentList.filter((a) => a.state === "waiting_long").length;
|
||||
const errorAgents =
|
||||
status?.by_state?.error ||
|
||||
agentList.filter((a) => a.state === "error").length;
|
||||
|
||||
// Team metrics
|
||||
const teamMetrics = Object.values(Team).map((team) => {
|
||||
@@ -194,9 +240,10 @@ function PerformanceTabContent() {
|
||||
return {
|
||||
team,
|
||||
activeTasks: teamTasks.filter((t) =>
|
||||
[TaskStatus.IN_PROGRESS, TaskStatus.CLAIMED].includes(t.status)
|
||||
[TaskStatus.IN_PROGRESS, TaskStatus.CLAIMED].includes(t.status),
|
||||
).length,
|
||||
blockedTasks: teamTasks.filter((t) => t.status === TaskStatus.BLOCKED).length,
|
||||
blockedTasks: teamTasks.filter((t) => t.status === TaskStatus.BLOCKED)
|
||||
.length,
|
||||
completedToday: teamTasks.filter((t) => {
|
||||
if (!t.completed_at) return false;
|
||||
const c = new Date(t.completed_at);
|
||||
@@ -242,7 +289,11 @@ function PerformanceTabContent() {
|
||||
/>
|
||||
<MetricCard
|
||||
title="Completion Rate"
|
||||
value={taskList.length > 0 ? Math.round((completed / taskList.length) * 100) + "%" : "0%"}
|
||||
value={
|
||||
taskList.length > 0
|
||||
? Math.round((completed / taskList.length) * 100) + "%"
|
||||
: "0%"
|
||||
}
|
||||
subtitle="Of all tasks"
|
||||
icon={<Activity className="h-4 w-4 text-purple-500" />}
|
||||
/>
|
||||
@@ -341,7 +392,8 @@ function TokenUsageCostsSection() {
|
||||
const { data: sessions, isLoading: loadingSessions } = useUsageSessions(100);
|
||||
const { data: modelUsage, isLoading: loadingModels } = useModelUsage("24h");
|
||||
const { data: projection, isLoading: loadingProj } = useUsageProjection();
|
||||
const { data: cacheStats, isLoading: loadingCache } = useCacheEfficiency("24h");
|
||||
const { data: cacheStats, isLoading: loadingCache } =
|
||||
useCacheEfficiency("24h");
|
||||
|
||||
const trendUp = (summary?.trend_pct ?? 0) >= 0;
|
||||
|
||||
@@ -369,7 +421,11 @@ function TokenUsageCostsSection() {
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Trend vs Prior"
|
||||
value={summary ? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%" : undefined}
|
||||
value={
|
||||
summary
|
||||
? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%"
|
||||
: undefined
|
||||
}
|
||||
icon={
|
||||
trendUp ? (
|
||||
<TrendingUp className="h-4 w-4 text-red-500" />
|
||||
@@ -387,7 +443,11 @@ function TokenUsageCostsSection() {
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Cache Saved"
|
||||
value={cacheStats ? "$" + cacheStats.cost_saved_by_cache_usd.toFixed(4) : undefined}
|
||||
value={
|
||||
cacheStats
|
||||
? "$" + cacheStats.cost_saved_by_cache_usd.toFixed(4)
|
||||
: undefined
|
||||
}
|
||||
icon={<Sparkles className="h-4 w-4 text-purple-500" />}
|
||||
isLoading={loadingCache}
|
||||
/>
|
||||
@@ -429,11 +489,19 @@ interface SummaryCardProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function SummaryCard({ title, value, icon, trend, isLoading }: SummaryCardProps) {
|
||||
function SummaryCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
isLoading,
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -479,7 +547,9 @@ function ProjectionCard({ projection, isLoading }: ProjectionCardProps) {
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-3xl font-bold">
|
||||
{projection != null ? "$" + projection.projected_monthly_cost_usd.toFixed(2) : "—"}
|
||||
{projection != null
|
||||
? "$" + projection.projected_monthly_cost_usd.toFixed(2)
|
||||
: "—"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Based on {projection?.basis_days ?? 7}-day rolling average ($
|
||||
@@ -497,7 +567,10 @@ interface CacheEfficiencyCardProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps) {
|
||||
function CacheEfficiencyCard({
|
||||
cacheStats,
|
||||
isLoading,
|
||||
}: CacheEfficiencyCardProps) {
|
||||
const pct = cacheStats ? cacheStats.cache_hit_rate * 100 : 0;
|
||||
|
||||
return (
|
||||
@@ -515,8 +588,9 @@ function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{pct.toFixed(1)}%</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache reads ·
|
||||
saved ${cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
|
||||
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache
|
||||
reads · saved $
|
||||
{cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
|
||||
</p>
|
||||
<Progress value={pct} className="mt-2" />
|
||||
</div>
|
||||
@@ -530,7 +604,11 @@ function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps
|
||||
|
||||
type MetricsTab = "performance" | "token-usage" | "delivery";
|
||||
|
||||
const VALID_METRICS_TABS: MetricsTab[] = ["performance", "token-usage", "delivery"];
|
||||
const VALID_METRICS_TABS: MetricsTab[] = [
|
||||
"performance",
|
||||
"token-usage",
|
||||
"delivery",
|
||||
];
|
||||
|
||||
function isValidMetricsTab(value: string | null): value is MetricsTab {
|
||||
return VALID_METRICS_TABS.includes(value as MetricsTab);
|
||||
@@ -544,7 +622,9 @@ function MetricsPageContent() {
|
||||
|
||||
// Read ?tab= from URL, default to "performance"
|
||||
const rawTab = searchParams.get("tab");
|
||||
const activeTab: MetricsTab = isValidMetricsTab(rawTab) ? rawTab : "performance";
|
||||
const activeTab: MetricsTab = isValidMetricsTab(rawTab)
|
||||
? rawTab
|
||||
: "performance";
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -590,32 +670,34 @@ function MetricsPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function MetricsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-32 mb-2" />
|
||||
<Skeleton className="h-5 w-72" />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-32 mb-2" />
|
||||
<Skeleton className="h-5 w-72" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<MetricsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -34,21 +34,38 @@ import { toast } from "sonner";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
|
||||
const typeIcons: Record<NotificationType, React.ReactNode> = {
|
||||
[NotificationType.TASK_ASSIGNMENT]: <ListTodo className="h-4 w-4 text-green-500" />,
|
||||
[NotificationType.PRIORITY_CHANGE]: <ArrowUpCircle className="h-4 w-4 text-orange-500" />,
|
||||
[NotificationType.BLOCKER_ESCALATION]: <AlertTriangle className="h-4 w-4 text-red-500" />,
|
||||
[NotificationType.REVIEW_REQUEST]: <Check className="h-4 w-4 text-purple-500" />,
|
||||
[NotificationType.DOCUMENTATION_REQUEST]: <Info className="h-4 w-4 text-blue-500" />,
|
||||
[NotificationType.ALERT]: <AlertTriangle className="h-4 w-4 text-yellow-500" />,
|
||||
[NotificationType.TASK_ASSIGNMENT]: (
|
||||
<ListTodo className="h-4 w-4 text-green-500" />
|
||||
),
|
||||
[NotificationType.PRIORITY_CHANGE]: (
|
||||
<ArrowUpCircle className="h-4 w-4 text-orange-500" />
|
||||
),
|
||||
[NotificationType.BLOCKER_ESCALATION]: (
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
),
|
||||
[NotificationType.REVIEW_REQUEST]: (
|
||||
<Check className="h-4 w-4 text-purple-500" />
|
||||
),
|
||||
[NotificationType.DOCUMENTATION_REQUEST]: (
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
),
|
||||
[NotificationType.ALERT]: (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
),
|
||||
[NotificationType.BROADCAST]: <Bell className="h-4 w-4 text-gray-500" />,
|
||||
[NotificationType.KNOWLEDGE_SHARE]: <BookOpen className="h-4 w-4 text-cyan-500" />,
|
||||
[NotificationType.KNOWLEDGE_SHARE]: (
|
||||
<BookOpen className="h-4 w-4 text-cyan-500" />
|
||||
),
|
||||
[NotificationType.MENTION]: <AtSign className="h-4 w-4 text-indigo-500" />,
|
||||
};
|
||||
|
||||
const priorityColors: Record<NotificationPriority, string> = {
|
||||
[NotificationPriority.NORMAL]: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
[NotificationPriority.HIGH]: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
||||
[NotificationPriority.URGENT]: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
[NotificationPriority.NORMAL]:
|
||||
"bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
[NotificationPriority.HIGH]:
|
||||
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
||||
[NotificationPriority.URGENT]:
|
||||
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
};
|
||||
|
||||
interface NotificationCardProps {
|
||||
@@ -57,23 +74,37 @@ interface NotificationCardProps {
|
||||
onAcknowledge: () => void;
|
||||
}
|
||||
|
||||
function NotificationCard({ notification, onMarkRead, onAcknowledge }: NotificationCardProps) {
|
||||
function NotificationCard({
|
||||
notification,
|
||||
onMarkRead,
|
||||
onAcknowledge,
|
||||
}: NotificationCardProps) {
|
||||
return (
|
||||
<Card className={notification.is_read ? "opacity-70" : "border-l-4 border-l-primary"}>
|
||||
<Card
|
||||
className={
|
||||
notification.is_read ? "opacity-70" : "border-l-4 border-l-primary"
|
||||
}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">{typeIcons[notification.type]}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{notification.subject}</span>
|
||||
<Badge className={priorityColors[notification.priority] + " text-xs"}>
|
||||
<Badge
|
||||
className={priorityColors[notification.priority] + " text-xs"}
|
||||
>
|
||||
{notification.priority}
|
||||
</Badge>
|
||||
{!notification.is_read && (
|
||||
<Badge variant="secondary" className="text-xs">New</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
{notification.requires_ack && !notification.is_acknowledged && (
|
||||
<Badge variant="destructive" className="text-xs">Needs Ack</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Needs Ack
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
@@ -129,19 +160,21 @@ function NotificationsPageContent() {
|
||||
}
|
||||
|
||||
const { data, isLoading, error, refetch } = useNotifications(
|
||||
activeTab === "unread" ? { unread_only: true } :
|
||||
activeTab === "pending" ? { pending_ack_only: true } :
|
||||
undefined
|
||||
activeTab === "unread"
|
||||
? { unread_only: true }
|
||||
: activeTab === "pending"
|
||||
? { pending_ack_only: true }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const markRead = useMarkNotificationRead();
|
||||
const acknowledge = useAcknowledgeNotification();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
|
||||
const isOffline = error && (
|
||||
error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK"
|
||||
);
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
try {
|
||||
@@ -213,7 +246,9 @@ function NotificationsPageContent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{data.unread_count}</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{data.unread_count}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -224,7 +259,9 @@ function NotificationsPageContent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{data.pending_ack_count}</div>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{data.pending_ack_count}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -244,7 +281,10 @@ function NotificationsPageContent() {
|
||||
Unread {data && data.unread_count > 0 && `(${data.unread_count})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pending">
|
||||
Pending {data && data.pending_ack_count > 0 && `(${data.pending_ack_count})`}
|
||||
Pending{" "}
|
||||
{data &&
|
||||
data.pending_ack_count > 0 &&
|
||||
`(${data.pending_ack_count})`}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -284,41 +324,43 @@ function NotificationsPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function NotificationsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-72" />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-72" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-12" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-12" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<NotificationsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { Team } from "@/types";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { CreateProjectDialog, ProjectFilters, ProjectTable } from "@/components/projects";
|
||||
import {
|
||||
CreateProjectDialog,
|
||||
ProjectFilters,
|
||||
ProjectTable,
|
||||
} from "@/components/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
@@ -19,7 +23,7 @@ function ProjectsPageContent() {
|
||||
const cellFilterParam = searchParams.get("cell");
|
||||
const cellFilter = useMemo(
|
||||
() => (cellFilterParam?.split(",").filter(Boolean) as Team[]) || [],
|
||||
[cellFilterParam]
|
||||
[cellFilterParam],
|
||||
);
|
||||
const showInactive = searchParams.get("inactive") === "true";
|
||||
|
||||
@@ -37,32 +41,37 @@ function ProjectsPageContent() {
|
||||
const query = params.toString();
|
||||
router.push(query ? `/projects?${query}` : "/projects");
|
||||
},
|
||||
[router, searchParams]
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ q: value || null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleCellChange = useCallback(
|
||||
(value: Team[]) => {
|
||||
updateParams({ cell: value.length > 0 ? value.join(",") : null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleShowInactiveChange = useCallback(
|
||||
(value: boolean) => {
|
||||
updateParams({ inactive: value ? "true" : null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Fetch projects
|
||||
const { data: projects, isLoading, error, refetch } = useProjects({
|
||||
const {
|
||||
data: projects,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useProjects({
|
||||
active_only: !showInactive,
|
||||
});
|
||||
|
||||
@@ -72,12 +81,18 @@ function ProjectsPageContent() {
|
||||
|
||||
return projects.filter((project) => {
|
||||
// Search filter
|
||||
if (searchQuery && !project.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
if (
|
||||
searchQuery &&
|
||||
!project.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cell filter (if any selected, project must match one of them)
|
||||
if (cellFilter.length > 0 && !cellFilter.includes(project.assigned_cell)) {
|
||||
if (
|
||||
cellFilter.length > 0 &&
|
||||
!cellFilter.includes(project.assigned_cell)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useUIStore } from "@/store";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,14 +22,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Settings,
|
||||
Palette,
|
||||
Bell,
|
||||
Server,
|
||||
User,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { Settings, Palette, Bell, Server, User, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { API_URL, WS_URL } from "@/lib/constants";
|
||||
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
||||
@@ -69,11 +68,15 @@ export default function SettingsPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-2xl">CEO</span>
|
||||
<span className="text-primary-foreground font-bold text-2xl">
|
||||
CEO
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg">Renzo</p>
|
||||
<p className="text-sm text-muted-foreground">Chief Executive Officer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chief Executive Officer
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Agent ID: 00000000-0000-0000-0000-000000000001
|
||||
</p>
|
||||
@@ -89,7 +92,9 @@ export default function SettingsPage() {
|
||||
<Palette className="h-5 w-5" />
|
||||
Appearance
|
||||
</CardTitle>
|
||||
<CardDescription>Customize the look and feel of the panel</CardDescription>
|
||||
<CardDescription>
|
||||
Customize the look and feel of the panel
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -143,10 +148,7 @@ export default function SettingsPage() {
|
||||
Automatically refresh data periodically
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -224,7 +226,9 @@ export default function SettingsPage() {
|
||||
<Settings className="h-5 w-5" />
|
||||
Connection Info
|
||||
</CardTitle>
|
||||
<CardDescription>Backend API configuration (read-only)</CardDescription>
|
||||
<CardDescription>
|
||||
Backend API configuration (read-only)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -236,7 +240,8 @@ export default function SettingsPage() {
|
||||
<Input value={WS_URL} readOnly className="bg-muted" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These values are configured via environment variables (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL)
|
||||
These values are configured via environment variables
|
||||
(NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -6,7 +6,11 @@ import { useTask, useTaskLifecycle, useUpdateTask } from "@/hooks/use-tasks";
|
||||
import { useProject } from "@/hooks/use-projects";
|
||||
import { useCreateBranch, useCreatePR, useMergePR } from "@/hooks/use-git";
|
||||
import { Team, TaskStatus } from "@/types";
|
||||
import { TaskHeader, TaskMetadata, TaskTabs } from "@/components/tasks/task-detail";
|
||||
import {
|
||||
TaskHeader,
|
||||
TaskMetadata,
|
||||
TaskTabs,
|
||||
} from "@/components/tasks/task-detail";
|
||||
import { ApproveAndStartButton } from "@/components/tasks/approve-and-start-button";
|
||||
import {
|
||||
EscalateToCeoDialog,
|
||||
@@ -42,7 +46,8 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
|
||||
// Dialog states
|
||||
const [escalateDialogOpen, setEscalateDialogOpen] = useState(false);
|
||||
const [approveAndMergeDialogOpen, setApproveAndMergeDialogOpen] = useState(false);
|
||||
const [approveAndMergeDialogOpen, setApproveAndMergeDialogOpen] =
|
||||
useState(false);
|
||||
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
||||
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||
const [branchDialogOpen, setBranchDialogOpen] = useState(false);
|
||||
@@ -51,7 +56,8 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
const [passQaDialogOpen, setPassQaDialogOpen] = useState(false);
|
||||
const [failQaDialogOpen, setFailQaDialogOpen] = useState(false);
|
||||
const [docsCompleteDialogOpen, setDocsCompleteDialogOpen] = useState(false);
|
||||
const [submitPmReviewDialogOpen, setSubmitPmReviewDialogOpen] = useState(false);
|
||||
const [submitPmReviewDialogOpen, setSubmitPmReviewDialogOpen] =
|
||||
useState(false);
|
||||
const [completeDialogOpen, setCompleteDialogOpen] = useState(false);
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
@@ -141,7 +147,10 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
setEscalateDialogOpen(true);
|
||||
return; // Don't refetch yet, dialog will handle it
|
||||
case "request-changes":
|
||||
await lifecycle.failQa.mutateAsync({ taskId: task.id, qaNotes: "Changes requested by PM" });
|
||||
await lifecycle.failQa.mutateAsync({
|
||||
taskId: task.id,
|
||||
qaNotes: "Changes requested by PM",
|
||||
});
|
||||
toast.success("Changes requested");
|
||||
break;
|
||||
case "create-branch":
|
||||
@@ -221,11 +230,21 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
refetch();
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const detail = (err.response?.data as { detail?: string } | undefined)?.detail ?? "";
|
||||
const detail =
|
||||
(err.response?.data as { detail?: string } | undefined)?.detail ?? "";
|
||||
if (typeof detail === "string" && detail.startsWith("NO_PR")) {
|
||||
toast.error("No PR found for this task. Create a pull request before merging.");
|
||||
} else if (typeof detail === "string" && detail.startsWith("Merge failed")) {
|
||||
toast.error("Merge failed: " + (detail.slice("Merge failed".length).replace(/^[: ]+/, "") || "the merge could not be completed"));
|
||||
toast.error(
|
||||
"No PR found for this task. Create a pull request before merging.",
|
||||
);
|
||||
} else if (
|
||||
typeof detail === "string" &&
|
||||
detail.startsWith("Merge failed")
|
||||
) {
|
||||
toast.error(
|
||||
"Merge failed: " +
|
||||
(detail.slice("Merge failed".length).replace(/^[: ]+/, "") ||
|
||||
"the merge could not be completed"),
|
||||
);
|
||||
} else {
|
||||
toast.error("Failed to approve and merge task");
|
||||
}
|
||||
@@ -333,7 +352,12 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
await createBranch.mutateAsync({
|
||||
project_slug: project.slug,
|
||||
task_id: task.id,
|
||||
branch_type: branchType as "feature" | "bug" | "chore" | "docs" | "hotfix",
|
||||
branch_type: branchType as
|
||||
| "feature"
|
||||
| "bug"
|
||||
| "chore"
|
||||
| "docs"
|
||||
| "hotfix",
|
||||
agent_id: "ceo", // CEO is creating the branch from the panel
|
||||
});
|
||||
toast.success("Branch created successfully");
|
||||
@@ -403,7 +427,8 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
<AlertTriangle className="h-16 w-16 mx-auto mb-4 text-destructive" />
|
||||
<h2 className="text-xl font-semibold mb-2">Task Not Found</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{error?.message ?? "The task you're looking for doesn't exist or has been deleted."}
|
||||
{error?.message ??
|
||||
"The task you're looking for doesn't exist or has been deleted."}
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
|
||||
@@ -5,7 +5,10 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useWorkSessions } from "@/hooks/use-work-sessions";
|
||||
import { WorkSessionStatus } from "@/types";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { WorkSessionTable, WorkSessionFilters } from "@/components/work-sessions";
|
||||
import {
|
||||
WorkSessionTable,
|
||||
WorkSessionFilters,
|
||||
} from "@/components/work-sessions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
@@ -18,8 +21,9 @@ function WorkSessionsPageContent() {
|
||||
const searchQuery = searchParams.get("q") || "";
|
||||
const statusParam = searchParams.get("status");
|
||||
const statusFilter = useMemo(
|
||||
() => (statusParam?.split(",").filter(Boolean) as WorkSessionStatus[]) || [],
|
||||
[statusParam]
|
||||
() =>
|
||||
(statusParam?.split(",").filter(Boolean) as WorkSessionStatus[]) || [],
|
||||
[statusParam],
|
||||
);
|
||||
|
||||
// Update URL params
|
||||
@@ -36,21 +40,21 @@ function WorkSessionsPageContent() {
|
||||
const query = params.toString();
|
||||
router.push(query ? `/work-sessions?${query}` : "/work-sessions");
|
||||
},
|
||||
[router, searchParams]
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ q: value || null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(value: WorkSessionStatus[]) => {
|
||||
updateParams({ status: value.length > 0 ? value.join(",") : null });
|
||||
},
|
||||
[updateParams]
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Fetch work sessions
|
||||
|
||||
Reference in New Issue
Block a user