diff --git a/src/__tests__/e2e/specs/calls/calls-navigation.spec.ts b/src/__tests__/e2e/specs/calls/calls-navigation.spec.ts index 38f5129834..a9b2a2610a 100644 --- a/src/__tests__/e2e/specs/calls/calls-navigation.spec.ts +++ b/src/__tests__/e2e/specs/calls/calls-navigation.spec.ts @@ -31,25 +31,20 @@ test.describe('Calls Navigation', () => { }); test('should navigate to calls page via sidebar', async ({ page }) => { - // Expand Context section first if needed + // Click the single Context sidebar link const contextButton = page.locator('[data-testid="nav-context"]'); - const callsLink = page.locator(selectors.navigation.callsLink); + await contextButton.click(); + await page.waitForURL(/\/w\/.*\/context\/learn/, { timeout: 30000 }); - const isCallsVisible = await callsLink.isVisible().catch(() => false); - if (!isCallsVisible) { - await contextButton.click(); - await callsLink.waitFor({ state: 'visible', timeout: 5000 }); - } - - // Click the calls navigation link - await callsLink.click(); + // Click the Calls tab + await page.locator('a[href*="/context/calls"]').first().click(); // Wait for URL to change to calls page - await page.waitForURL(/\/w\/.*\/calls/, { timeout: 10000 }); + await page.waitForURL(/\/w\/.*\/context\/calls/, { timeout: 10000 }); // Verify we're on the calls page await expect(page.locator(selectors.pageTitle.calls)).toBeVisible(); - expect(page.url()).toContain('/calls'); + expect(page.url()).toContain('/context/calls'); }); test('should display calls page title', async ({ page }) => { diff --git a/src/__tests__/e2e/specs/capture-learn.spec.ts b/src/__tests__/e2e/specs/capture-learn.spec.ts index 7fd8b57a9b..a120842dce 100644 --- a/src/__tests__/e2e/specs/capture-learn.spec.ts +++ b/src/__tests__/e2e/specs/capture-learn.spec.ts @@ -12,7 +12,7 @@ test('Capture /learn documentation viewer UI', async ({ page }) => { console.log('Workspace slug:', workspaceSlug); // Navigate to learn page - await page.goto(`http://localhost:3000/w/${workspaceSlug}/learn`); + await page.goto(`http://localhost:3000/w/${workspaceSlug}/context/learn`); await page.waitForLoadState('networkidle'); await page.waitForTimeout(3000); diff --git a/src/__tests__/e2e/specs/context/context-learn-message.spec.ts b/src/__tests__/e2e/specs/context/context-learn-message.spec.ts index cccd63ab5b..d9386741a0 100644 --- a/src/__tests__/e2e/specs/context/context-learn-message.spec.ts +++ b/src/__tests__/e2e/specs/context/context-learn-message.spec.ts @@ -36,7 +36,7 @@ test.describe('Context Learn Documentation Viewer', () => { await contextLearnPage.navigateViaNavigation(); // Verify we're on the Context Learn page - await expect(page).toHaveURL(/\/w\/.*\/learn/, { timeout: 30000 }); + await expect(page).toHaveURL(/\/w\/.*\/context\/learn/, { timeout: 30000 }); // Verify the page loaded (either docs or concepts section visible) const isLoaded = await contextLearnPage.isLoaded(); @@ -91,7 +91,7 @@ test.describe('Context Learn Documentation Viewer', () => { await contextLearnPage.navigateViaNavigation(); // Verify we're on Context Learn page - await expect(page).toHaveURL(/\/w\/.*\/learn/); + await expect(page).toHaveURL(/\/w\/.*\/context\/learn/); // Verify docs section is present await expect(page.locator(selectors.learn.docsSection)).toBeVisible(); diff --git a/src/__tests__/e2e/support/page-objects/CallsPage.ts b/src/__tests__/e2e/support/page-objects/CallsPage.ts index fad58b7386..a26788c9f6 100644 --- a/src/__tests__/e2e/support/page-objects/CallsPage.ts +++ b/src/__tests__/e2e/support/page-objects/CallsPage.ts @@ -12,7 +12,7 @@ export class CallsPage { * Navigate to calls page for a specific workspace */ async goto(workspaceSlug: string): Promise { - await this.page.goto(`http://localhost:3000/w/${workspaceSlug}/calls`); + await this.page.goto(`http://localhost:3000/w/${workspaceSlug}/context/calls`); await this.waitForLoad(); } @@ -56,19 +56,14 @@ export class CallsPage { * Navigate to calls page via sidebar */ async navigateViaNavigation(): Promise { - // First, expand Context section if it's not already expanded - const contextButton = this.page.locator(selectors.navigation.contextButton); - const callsLink = this.page.locator(selectors.navigation.callsLink); + // Click the single Context link in the sidebar — it redirects to /context/learn, + // then navigate directly to /context/calls via the tab bar + const contextLink = this.page.locator(selectors.navigation.contextButton); + await contextLink.click(); + await this.page.waitForURL(/\/w\/.*\/context\/learn/, { timeout: 10000 }); - // Check if calls link is visible, if not, click context to expand - const isCallsVisible = await callsLink.isVisible().catch(() => false); - if (!isCallsVisible) { - await contextButton.click(); - // Wait for calls link to become visible after expanding - await callsLink.waitFor({ state: 'visible', timeout: 5000 }); - } - - await callsLink.click(); - await this.page.waitForURL(/\/w\/.*\/calls/, { timeout: 10000 }); + // Click the Calls tab + await this.page.locator('a[href*="/context/calls"]').first().click(); + await this.page.waitForURL(/\/w\/.*\/context\/calls/, { timeout: 10000 }); } } diff --git a/src/__tests__/e2e/support/page-objects/ContextLearnPage.ts b/src/__tests__/e2e/support/page-objects/ContextLearnPage.ts index 8c6d193c92..71501acc70 100644 --- a/src/__tests__/e2e/support/page-objects/ContextLearnPage.ts +++ b/src/__tests__/e2e/support/page-objects/ContextLearnPage.ts @@ -12,7 +12,7 @@ export class ContextLearnPage { * Navigate to Context Learn page for a specific workspace */ async goto(workspaceSlug: string): Promise { - await this.page.goto(`http://localhost:3000/w/${workspaceSlug}/learn`); + await this.page.goto(`http://localhost:3000/w/${workspaceSlug}/context/learn`); await this.waitForLoad(); } @@ -32,35 +32,22 @@ export class ContextLearnPage { * Navigate to Context Learn page via sidebar navigation */ async navigateViaNavigation(): Promise { - // First expand the Context section if not already expanded - const contextButton = this.page.locator(selectors.navigation.contextButton); - const learnLink = this.page.locator(selectors.navigation.learnLink).first(); - - // Check if learn link is visible, if not, click Context to expand - const isLearnVisible = await learnLink.isVisible().catch(() => false); - if (!isLearnVisible) { - await contextButton.click(); - await learnLink.waitFor({ state: 'visible', timeout: 15000 }); - } + // Click the single Context link in the sidebar + const contextLink = this.page.locator(selectors.navigation.contextButton); // Ensure page is fully loaded and network is idle before navigation await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => { - // If networkidle times out, fall back to domcontentloaded return this.page.waitForLoadState('domcontentloaded'); }); - - // Wait for the link to be attached and stable - await learnLink.waitFor({ state: 'attached', timeout: 15000 }); - - // Small delay to ensure link is fully interactive (reduces race conditions) - await this.page.waitForTimeout(100); - - // Wait for navigation to complete after clicking (using Promise.all for coordination) + + await contextLink.waitFor({ state: 'attached', timeout: 15000 }); + + // Wait for navigation to complete after clicking await Promise.all([ - this.page.waitForURL(/\/w\/.*\/learn/, { timeout: 30000 }), - learnLink.click() + this.page.waitForURL(/\/w\/.*\/context\/learn/, { timeout: 30000 }), + contextLink.click() ]); - + await this.waitForLoad(); } diff --git a/src/__tests__/unit/components/Sidebar.test.tsx b/src/__tests__/unit/components/Sidebar.test.tsx index 0125965be1..6103236e53 100644 --- a/src/__tests__/unit/components/Sidebar.test.tsx +++ b/src/__tests__/unit/components/Sidebar.test.tsx @@ -238,17 +238,7 @@ describe('Sidebar - Graph Explorer Context item', () => { } as any); }); - // Helper: find the Context child "Graph" link (href = /context/graph) - // Note: there are 2 sidebars rendered (mobile + desktop) so use getAllBy - function findContextGraphLinks() { - return screen.queryAllByRole('link', { name: /^Graph$/i }).filter( - (el) => el.getAttribute('href') === '/w/test-workspace/context/graph', - ); - } - - it('shows Graph item under Context when canAdmin is true', async () => { - const user = userEvent.setup(); - + it('Context is a direct link to /w/[slug]/context (no expandable children in sidebar)', () => { vi.mocked(useWorkspaceAccessModule.useWorkspaceAccess).mockReturnValue({ canRead: true, canWrite: true, @@ -260,18 +250,15 @@ describe('Sidebar - Graph Explorer Context item', () => { render(); - // Expand Context section (first sidebar instance = desktop) - const contextButtons = screen.getAllByTestId('nav-context'); - await user.click(contextButtons[0]); - - await waitFor(() => { - expect(findContextGraphLinks().length).toBeGreaterThan(0); - }); + // nav-context should be an link, not an expandable button + const contextItems = screen.getAllByTestId('nav-context'); + expect(contextItems.length).toBeGreaterThan(0); + // Since it has no children it renders as a Link (asChild Button wrapping an ) + const contextLink = contextItems[0].querySelector('a'); + expect(contextLink).toHaveAttribute('href', '/w/test-workspace/context'); }); - it('Graph item href points to /context/graph', async () => { - const user = userEvent.setup(); - + it('Context link href points to /w/[slug]/context for admin users', () => { vi.mocked(useWorkspaceAccessModule.useWorkspaceAccess).mockReturnValue({ canRead: true, canWrite: true, @@ -283,19 +270,12 @@ describe('Sidebar - Graph Explorer Context item', () => { render(); - const contextButtons = screen.getAllByTestId('nav-context'); - await user.click(contextButtons[0]); - - await waitFor(() => { - const links = findContextGraphLinks(); - expect(links.length).toBeGreaterThan(0); - expect(links[0]).toHaveAttribute('href', '/w/test-workspace/context/graph'); - }); + const contextItems = screen.getAllByTestId('nav-context'); + const contextLink = contextItems[0].querySelector('a'); + expect(contextLink).toHaveAttribute('href', '/w/test-workspace/context'); }); - it('hides Graph item under Context when canAdmin is false', async () => { - const user = userEvent.setup(); - + it('Context link is present for non-admin users', () => { vi.mocked(useWorkspaceAccessModule.useWorkspaceAccess).mockReturnValue({ canRead: true, canWrite: true, @@ -307,21 +287,15 @@ describe('Sidebar - Graph Explorer Context item', () => { render(); - const contextButtons = screen.getAllByTestId('nav-context'); - await user.click(contextButtons[0]); - - // Wait for other context children to appear - await waitFor(() => { - expect(screen.getAllByTestId('nav-learn').length).toBeGreaterThan(0); - }); - - // Context/graph link must NOT be present - expect(findContextGraphLinks()).toHaveLength(0); + const contextItems = screen.getAllByTestId('nav-context'); + expect(contextItems.length).toBeGreaterThan(0); + // No sub-items (Learn/Calls/Agent Logs) rendered in sidebar — they live in the context layout + expect(screen.queryByTestId('nav-learn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('nav-calls')).not.toBeInTheDocument(); + expect(screen.queryByTestId('nav-agent-logs')).not.toBeInTheDocument(); }); - it('still shows Learn, Calls, Agent Logs for non-admin users', async () => { - const user = userEvent.setup(); - + it('Context sidebar link is present for viewer role', () => { vi.mocked(useWorkspaceAccessModule.useWorkspaceAccess).mockReturnValue({ canRead: true, canWrite: false, @@ -333,14 +307,10 @@ describe('Sidebar - Graph Explorer Context item', () => { render(); - const contextButtons = screen.getAllByTestId('nav-context'); - await user.click(contextButtons[0]); - - await waitFor(() => { - expect(screen.getAllByTestId('nav-learn').length).toBeGreaterThan(0); - expect(screen.getAllByTestId('nav-calls').length).toBeGreaterThan(0); - expect(screen.getAllByTestId('nav-agent-logs').length).toBeGreaterThan(0); - }); + const contextItems = screen.getAllByTestId('nav-context'); + expect(contextItems.length).toBeGreaterThan(0); + const contextLink = contextItems[0].querySelector('a'); + expect(contextLink).toHaveAttribute('href', '/w/test-workspace/context'); }); }); @@ -1026,85 +996,56 @@ describe('Sidebar - Graph Explorer Nav Item', () => { } as any); }); - // Context child "Graph" is an with href containing /context/graph - function contextGraphLinks() { - return screen.queryAllByRole('link').filter( - (el) => el.getAttribute('href') === '/w/test-workspace/context/graph', - ); - } - - it('should show Graph item under Context when user is admin', async () => { - const user = userEvent.setup(); - + it('should render Context as a direct sidebar link to /context for admin users', () => { vi.mocked(useWorkspaceAccessModule.useWorkspaceAccess).mockReturnValue({ canRead: true, canWrite: true, canAdmin: true, isOwner: false, hasAccess: true, role: 'ADMIN', } as any); render(); - // Both mobile + desktop sidebars render; click the first Context button - const contextButtons = screen.getAllByTestId('nav-context'); - await user.click(contextButtons[0]); - - await waitFor(() => { - expect(contextGraphLinks().length).toBeGreaterThan(0); - }); + const contextItems = screen.getAllByTestId('nav-context'); + expect(contextItems.length).toBeGreaterThan(0); + const contextLink = contextItems[0].querySelector('a'); + expect(contextLink).toHaveAttribute('href', '/w/test-workspace/context'); }); - it('should NOT show Graph item under Context when user is not admin', async () => { - const user = userEvent.setup(); - + it('should render Context as a direct sidebar link to /context for non-admin users', () => { vi.mocked(useWorkspaceAccessModule.useWorkspaceAccess).mockReturnValue({ canRead: true, canWrite: true, canAdmin: false, isOwner: false, hasAccess: true, role: 'DEVELOPER', } as any); render(); - const contextButtons = screen.getAllByTestId('nav-context'); - await user.click(contextButtons[0]); - - // Wait for other children to render - await waitFor(() => { - expect(screen.getAllByTestId('nav-learn').length).toBeGreaterThan(0); - }); - - expect(contextGraphLinks()).toHaveLength(0); + const contextItems = screen.getAllByTestId('nav-context'); + expect(contextItems.length).toBeGreaterThan(0); + const contextLink = contextItems[0].querySelector('a'); + expect(contextLink).toHaveAttribute('href', '/w/test-workspace/context'); }); - it('should show Graph item for OWNER role', async () => { - const user = userEvent.setup(); - + it('should render Context as a direct sidebar link for OWNER role', () => { vi.mocked(useWorkspaceAccessModule.useWorkspaceAccess).mockReturnValue({ canRead: true, canWrite: true, canAdmin: true, isOwner: true, hasAccess: true, role: 'OWNER', } as any); render(); - const contextButtons = screen.getAllByTestId('nav-context'); - await user.click(contextButtons[0]); - - await waitFor(() => { - expect(contextGraphLinks().length).toBeGreaterThan(0); - }); + const contextItems = screen.getAllByTestId('nav-context'); + expect(contextItems.length).toBeGreaterThan(0); + const contextLink = contextItems[0].querySelector('a'); + expect(contextLink).toHaveAttribute('href', '/w/test-workspace/context'); }); - it('should NOT show Graph item for VIEWER role', async () => { - const user = userEvent.setup(); - + it('should render Context as a direct sidebar link for VIEWER role', () => { vi.mocked(useWorkspaceAccessModule.useWorkspaceAccess).mockReturnValue({ canRead: true, canWrite: false, canAdmin: false, isOwner: false, hasAccess: true, role: 'VIEWER', } as any); render(); - const contextButtons = screen.getAllByTestId('nav-context'); - await user.click(contextButtons[0]); - - await waitFor(() => { - expect(screen.getAllByTestId('nav-learn').length).toBeGreaterThan(0); - }); - - expect(contextGraphLinks()).toHaveLength(0); + const contextItems = screen.getAllByTestId('nav-context'); + expect(contextItems.length).toBeGreaterThan(0); + const contextLink = contextItems[0].querySelector('a'); + expect(contextLink).toHaveAttribute('href', '/w/test-workspace/context'); }); }); diff --git a/src/__tests__/unit/pages/agent-logs.test.tsx b/src/__tests__/unit/pages/agent-logs.test.tsx index 9668726f8f..e288fae089 100644 --- a/src/__tests__/unit/pages/agent-logs.test.tsx +++ b/src/__tests__/unit/pages/agent-logs.test.tsx @@ -110,7 +110,7 @@ vi.mock("lucide-react", () => ({ })); // --- Import page after all mocks are set up --- -import AgentLogsPage from "@/app/w/[slug]/agent-logs/page"; +import AgentLogsPage from "@/app/w/[slug]/context/agent-logs/page"; // Helper: reset fetch to return an empty log list function mockEmptyFetch() { diff --git a/src/app/w/[slug]/agent-logs/page.tsx b/src/app/w/[slug]/agent-logs/page.tsx index 70e08c7da1..400e5824f7 100644 --- a/src/app/w/[slug]/agent-logs/page.tsx +++ b/src/app/w/[slug]/agent-logs/page.tsx @@ -1,367 +1,10 @@ -"use client"; +import { redirect } from "next/navigation"; -import React, { useEffect, useRef, useState, useMemo, useCallback } from "react"; -import { useParams, useRouter, useSearchParams, usePathname } from "next/navigation"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, -} from "@/components/ui/pagination"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { PageHeader } from "@/components/ui/page-header"; -import { AgentLogsTable } from "@/components/agent-logs"; -import { LogDetailDialog } from "@/components/agent-logs/LogDetailDialog"; -import { useWorkspace } from "@/hooks/useWorkspace"; -import type { AgentLogRecord, AgentLogsResponse } from "@/types/agent-logs"; -import { FileText, Search, ChevronLeft, ChevronRight, MessageSquare } from "lucide-react"; -import { buttonVariants } from "@/components/ui/button"; -import Link from "next/link"; - -const LOGS_PER_PAGE = 20; - -type TimeRange = "24h" | "7d" | "30d" | "all"; - -function calculateDateRange(range: TimeRange): { start?: string; end?: string } { - const now = new Date(); - const end = now.toISOString(); - - switch (range) { - case "24h": - return { - start: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(), - end, - }; - case "7d": - return { - start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), - end, - }; - case "30d": - return { - start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), - end, - }; - case "all": - default: - return {}; - } +interface AgentLogsPageProps { + params: Promise<{ slug: string }>; } -export default function AgentLogsPage() { - const params = useParams(); - const router = useRouter(); - const searchParams = useSearchParams(); - const pathname = usePathname(); - const slug = params.slug as string; - const { workspace, id: workspaceId } = useWorkspace(); - - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [page, setPage] = useState(() => parseInt(searchParams?.get("page") ?? "1", 10) || 1); - const [hasMore, setHasMore] = useState(false); - const [timeRange, setTimeRange] = useState("all"); - const [searchKeyword, setSearchKeyword] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - - // Dialog state — initialise from URL so deep-links auto-open the modal - const [selectedLogId, setSelectedLogId] = useState( - () => searchParams?.get("logId") ?? null - ); - const [dialogOpen, setDialogOpen] = useState( - () => !!searchParams?.get("logId") - ); - - // Navigate to a specific page and update URL - const goToPage = useCallback((n: number) => { - setPage(n); - const params = new URLSearchParams(searchParams?.toString() || ""); - if (n <= 1) { - params.delete("page"); - } else { - params.set("page", n.toString()); - } - const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; - router.replace(newUrl, { scroll: false }); - }, [pathname, router, searchParams]); - - // Keep a ref to the latest goToPage to avoid it being a dep in the debounce effect - const goToPageRef = useRef(goToPage); - useEffect(() => { - goToPageRef.current = goToPage; - }); // no dep array — keeps ref current after every render - - // Track previous search keyword so we only reset page when it actually changes - const prevSearchKeyword = useRef(""); - - // Debounce search input - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(searchKeyword); - if (searchKeyword !== prevSearchKeyword.current) { - goToPageRef.current(1); // only reset page when search actually changed - } - prevSearchKeyword.current = searchKeyword; - }, 500); - - return () => clearTimeout(timer); - }, [searchKeyword]); // goToPage intentionally omitted — accessed via ref - - // Fetch logs - useEffect(() => { - if (!workspaceId) return; - - const fetchLogs = async () => { - setLoading(true); - setError(null); - - try { - const dateRange = calculateDateRange(timeRange); - const skip = (page - 1) * LOGS_PER_PAGE; - - const params = new URLSearchParams({ - workspace_id: workspaceId, - limit: LOGS_PER_PAGE.toString(), - skip: skip.toString(), - }); - - if (dateRange.start) params.append("start_date", dateRange.start); - if (dateRange.end) params.append("end_date", dateRange.end); - if (debouncedSearch) params.append("search", debouncedSearch); - - const response = await fetch(`/api/agent-logs?${params.toString()}`); - - if (!response.ok) { - throw new Error("Failed to fetch agent logs"); - } - - const data: AgentLogsResponse = await response.json(); - setLogs(data.data); - setHasMore(data.hasMore); - } catch (err) { - console.error("Error fetching agent logs:", err); - setError( - err instanceof Error ? err.message : "Failed to fetch agent logs" - ); - } finally { - setLoading(false); - } - }; - - fetchLogs(); - }, [workspaceId, page, timeRange, debouncedSearch]); - - const handleRowClick = (logId: string) => { - setSelectedLogId(logId); - setDialogOpen(true); - const p = new URLSearchParams(searchParams?.toString() || ""); - p.set("logId", logId); - router.replace(`${pathname}?${p.toString()}`, { scroll: false }); - }; - - const handleDialogOpenChange = (open: boolean) => { - setDialogOpen(open); - if (!open) { - setSelectedLogId(null); - const p = new URLSearchParams(searchParams?.toString() || ""); - p.delete("logId"); - const newUrl = p.toString() ? `${pathname}?${p.toString()}` : pathname; - router.replace(newUrl, { scroll: false }); - } - }; - - return ( -
-
- - {/* {(slug === "hive" || slug === "stakwork") && ( */} - - {/* )} */} -
- - - -
- Execution Logs -
-
- - setSearchKeyword(e.target.value)} - className="pl-9" - /> -
- -
-
-
- - - {loading && ( -
- - - - Timestamp - Agent Name - Feature - - - - {[1, 2, 3, 4, 5].map((i) => ( - - - - - - - - - - - - ))} - -
-
- )} - - {error && !loading && ( -
-

Error loading agent logs

-

{error}

-
- )} - - {!loading && !error && ( - - )} - - {!loading && !error && logs.length > 0 && ( -
- - - - - - - {page > 1 && ( - - - - )} - - {page > 2 && ( - - - - )} - - - - - - {hasMore && ( - <> - - - - - - - - )} - - -
- )} -
-
- - -
- ); +export default async function AgentLogsPage({ params }: AgentLogsPageProps) { + const { slug } = await params; + redirect(`/w/${slug}/context/agent-logs`); } diff --git a/src/app/w/[slug]/calls/page.tsx b/src/app/w/[slug]/calls/page.tsx index 378fd65854..560f620443 100644 --- a/src/app/w/[slug]/calls/page.tsx +++ b/src/app/w/[slug]/calls/page.tsx @@ -1,325 +1,10 @@ -"use client"; +import { redirect } from "next/navigation"; -import { useEffect, useState, useCallback } from "react"; -import { useRouter, useSearchParams, usePathname } from "next/navigation"; -import { useWorkspace } from "@/hooks/useWorkspace"; -import { useVoiceStore } from "@/stores/useVoiceStore"; -import { CallRecording, CallsResponse } from "@/types/calls"; -import { CallsTable } from "@/components/calls/CallsTable"; -import { VoiceMessagesDrawer } from "@/components/voice/VoiceMessagesDrawer"; -import { PoolLaunchBanner } from "@/components/pool-launch-banner"; -import { PageHeader } from "@/components/ui/page-header"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem } from "@/components/ui/pagination"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { ChevronLeft, ChevronRight, Loader2, Phone, Mic, MicOff, MessageSquare, Unplug } from "lucide-react"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { toast } from "sonner"; - -export default function CallsPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const pathname = usePathname(); - const { workspace, slug } = useWorkspace(); - const [calls, setCalls] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [page, setPage] = useState(() => parseInt(searchParams?.get("page") ?? "1", 10) || 1); - const [hasMore, setHasMore] = useState(false); - const [generatingLink, setGeneratingLink] = useState(false); - const [drawerOpen, setDrawerOpen] = useState(false); - const limit = 10; - - // Voice agent state (LiveKit) - const isConnected = useVoiceStore((s) => s.isConnected); - const isConnecting = useVoiceStore((s) => s.isConnecting); - const isMicEnabled = useVoiceStore((s) => s.isMicEnabled); - const voiceError = useVoiceStore((s) => s.error); - const messages = useVoiceStore((s) => s.messages); - const connect = useVoiceStore((s) => s.connect); - const disconnect = useVoiceStore((s) => s.disconnect); - const toggleMic = useVoiceStore((s) => s.toggleMic); - const clearError = useVoiceStore((s) => s.clearError); - - // Show toast on voice connection errors - useEffect(() => { - if (voiceError) { - toast.error("Voice connection failed", { description: voiceError }); - clearError(); - } - }, [voiceError, clearError]); - - const handleConnectVoice = () => { - if (!slug) return; - if (isConnected) { - disconnect(); - } else { - connect(slug); - } - }; - - const handleStartCall = async () => { - if (!slug) return; - - setGeneratingLink(true); - try { - const response = await fetch(`/api/workspaces/${slug}/calls/generate-link`, { - method: "POST", - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to generate call link"); - } - - const data = await response.json(); - window.open(data.url, "_blank", "noopener,noreferrer"); - } catch (err) { - console.error("Error generating call link:", err); - alert(err instanceof Error ? err.message : "Failed to start call"); - } finally { - setGeneratingLink(false); - } - }; - - // Navigate to a specific page and update URL - const goToPage = useCallback((n: number) => { - setPage(n); - const params = new URLSearchParams(searchParams?.toString() || ""); - if (n <= 1) { - params.delete("page"); - } else { - params.set("page", n.toString()); - } - const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; - router.replace(newUrl, { scroll: false }); - }, [pathname, router, searchParams]); - - useEffect(() => { - if (!slug || workspace?.poolState !== "COMPLETE") { - setLoading(false); - return; - } - - const fetchCalls = async () => { - setLoading(true); - setError(null); - - try { - const skip = (page - 1) * limit; - const response = await fetch(`/api/workspaces/${slug}/calls?limit=${limit}&skip=${skip}`); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to fetch calls"); - } - - const data: CallsResponse = await response.json(); - setCalls(data.calls); - setHasMore(data.hasMore); - } catch (err) { - console.error("Error fetching calls:", err); - setError(err instanceof Error ? err.message : "Failed to load call recordings"); - } finally { - setLoading(false); - } - }; - - fetchCalls(); - }, [slug, workspace?.poolState, page]); - - if (workspace?.poolState !== "COMPLETE") { - return ( -
- - -
- ); - } - - return ( -
- - - {isConnected && ( - <> - - - - )} - -
- ) : null - } - /> - - - - - - Call Recordings - - - {loading && ( -
- - - - Title - Date Added - - - - {[1, 2, 3, 4, 5].map((i) => ( - - - - - - - - - ))} - -
-
- )} - - {error && !loading && ( -
-

Error loading calls

-

{error}

-
- )} - - {!loading && !error && } - - {!loading && !error && calls.length > 0 && ( -
- - - - - - - {page > 1 && ( - - - - )} - - {page > 2 && ( - - - - )} - - - - +interface CallsPageProps { + params: Promise<{ slug: string }>; +} - {hasMore && ( - <> - - - - - - - - )} - - -
- )} -
-
- - ); +export default async function CallsPage({ params }: CallsPageProps) { + const { slug } = await params; + redirect(`/w/${slug}/context/calls`); } diff --git a/src/app/w/[slug]/context/agent-logs/chat/page.tsx b/src/app/w/[slug]/context/agent-logs/chat/page.tsx new file mode 100644 index 0000000000..514177a9d8 --- /dev/null +++ b/src/app/w/[slug]/context/agent-logs/chat/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { LogsChat } from "@/components/logs-chat"; + +export default function LogsChatPage() { + const params = useParams(); + const slug = params.slug as string; + + return ( +
+ +
+ ); +} diff --git a/src/app/w/[slug]/context/agent-logs/page.tsx b/src/app/w/[slug]/context/agent-logs/page.tsx new file mode 100644 index 0000000000..6637ddddab --- /dev/null +++ b/src/app/w/[slug]/context/agent-logs/page.tsx @@ -0,0 +1,367 @@ +"use client"; + +import React, { useEffect, useRef, useState, useMemo, useCallback } from "react"; +import { useParams, useRouter, useSearchParams, usePathname } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, +} from "@/components/ui/pagination"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { PageHeader } from "@/components/ui/page-header"; +import { AgentLogsTable } from "@/components/agent-logs"; +import { LogDetailDialog } from "@/components/agent-logs/LogDetailDialog"; +import { useWorkspace } from "@/hooks/useWorkspace"; +import type { AgentLogRecord, AgentLogsResponse } from "@/types/agent-logs"; +import { FileText, Search, ChevronLeft, ChevronRight, MessageSquare } from "lucide-react"; +import { buttonVariants } from "@/components/ui/button"; +import Link from "next/link"; + +const LOGS_PER_PAGE = 20; + +type TimeRange = "24h" | "7d" | "30d" | "all"; + +function calculateDateRange(range: TimeRange): { start?: string; end?: string } { + const now = new Date(); + const end = now.toISOString(); + + switch (range) { + case "24h": + return { + start: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(), + end, + }; + case "7d": + return { + start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), + end, + }; + case "30d": + return { + start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), + end, + }; + case "all": + default: + return {}; + } +} + +export default function AgentLogsPage() { + const params = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const slug = params.slug as string; + const { workspace, id: workspaceId } = useWorkspace(); + + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(() => parseInt(searchParams?.get("page") ?? "1", 10) || 1); + const [hasMore, setHasMore] = useState(false); + const [timeRange, setTimeRange] = useState("all"); + const [searchKeyword, setSearchKeyword] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + // Dialog state — initialise from URL so deep-links auto-open the modal + const [selectedLogId, setSelectedLogId] = useState( + () => searchParams?.get("logId") ?? null + ); + const [dialogOpen, setDialogOpen] = useState( + () => !!searchParams?.get("logId") + ); + + // Navigate to a specific page and update URL + const goToPage = useCallback((n: number) => { + setPage(n); + const params = new URLSearchParams(searchParams?.toString() || ""); + if (n <= 1) { + params.delete("page"); + } else { + params.set("page", n.toString()); + } + const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; + router.replace(newUrl, { scroll: false }); + }, [pathname, router, searchParams]); + + // Keep a ref to the latest goToPage to avoid it being a dep in the debounce effect + const goToPageRef = useRef(goToPage); + useEffect(() => { + goToPageRef.current = goToPage; + }); // no dep array — keeps ref current after every render + + // Track previous search keyword so we only reset page when it actually changes + const prevSearchKeyword = useRef(""); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchKeyword); + if (searchKeyword !== prevSearchKeyword.current) { + goToPageRef.current(1); // only reset page when search actually changed + } + prevSearchKeyword.current = searchKeyword; + }, 500); + + return () => clearTimeout(timer); + }, [searchKeyword]); // goToPage intentionally omitted — accessed via ref + + // Fetch logs + useEffect(() => { + if (!workspaceId) return; + + const fetchLogs = async () => { + setLoading(true); + setError(null); + + try { + const dateRange = calculateDateRange(timeRange); + const skip = (page - 1) * LOGS_PER_PAGE; + + const params = new URLSearchParams({ + workspace_id: workspaceId, + limit: LOGS_PER_PAGE.toString(), + skip: skip.toString(), + }); + + if (dateRange.start) params.append("start_date", dateRange.start); + if (dateRange.end) params.append("end_date", dateRange.end); + if (debouncedSearch) params.append("search", debouncedSearch); + + const response = await fetch(`/api/agent-logs?${params.toString()}`); + + if (!response.ok) { + throw new Error("Failed to fetch agent logs"); + } + + const data: AgentLogsResponse = await response.json(); + setLogs(data.data); + setHasMore(data.hasMore); + } catch (err) { + console.error("Error fetching agent logs:", err); + setError( + err instanceof Error ? err.message : "Failed to fetch agent logs" + ); + } finally { + setLoading(false); + } + }; + + fetchLogs(); + }, [workspaceId, page, timeRange, debouncedSearch]); + + const handleRowClick = (logId: string) => { + setSelectedLogId(logId); + setDialogOpen(true); + const p = new URLSearchParams(searchParams?.toString() || ""); + p.set("logId", logId); + router.replace(`${pathname}?${p.toString()}`, { scroll: false }); + }; + + const handleDialogOpenChange = (open: boolean) => { + setDialogOpen(open); + if (!open) { + setSelectedLogId(null); + const p = new URLSearchParams(searchParams?.toString() || ""); + p.delete("logId"); + const newUrl = p.toString() ? `${pathname}?${p.toString()}` : pathname; + router.replace(newUrl, { scroll: false }); + } + }; + + return ( +
+
+ + {/* {(slug === "hive" || slug === "stakwork") && ( */} + + {/* )} */} +
+ + + +
+ Execution Logs +
+
+ + setSearchKeyword(e.target.value)} + className="pl-9" + /> +
+ +
+
+
+ + + {loading && ( +
+ + + + Timestamp + Agent Name + Feature + + + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + + + + + + + + ))} + +
+
+ )} + + {error && !loading && ( +
+

Error loading agent logs

+

{error}

+
+ )} + + {!loading && !error && ( + + )} + + {!loading && !error && logs.length > 0 && ( +
+ + + + + + + {page > 1 && ( + + + + )} + + {page > 2 && ( + + + + )} + + + + + + {hasMore && ( + <> + + + + + + + + )} + + +
+ )} +
+
+ + +
+ ); +} diff --git a/src/app/w/[slug]/context/calls/TranscriptTooltip.tsx b/src/app/w/[slug]/context/calls/TranscriptTooltip.tsx new file mode 100644 index 0000000000..80efc02dda --- /dev/null +++ b/src/app/w/[slug]/context/calls/TranscriptTooltip.tsx @@ -0,0 +1,30 @@ +import { ReactNode, useState } from "react"; + +interface TranscriptTooltipProps { + children: ReactNode; + transcript: string; + show: boolean; +} + +export function TranscriptTooltip({ children, transcript, show }: TranscriptTooltipProps) { + const [isHovered, setIsHovered] = useState(false); + + const displayText = transcript.length > 200 ? `...${transcript.slice(-170)}` : transcript; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {children} + {show && isHovered && displayText.trim() && ( +
+
+

{displayText}

+
+
+ )} +
+ ); +} diff --git a/src/app/w/[slug]/context/calls/[ref_id]/page.tsx b/src/app/w/[slug]/context/calls/[ref_id]/page.tsx new file mode 100644 index 0000000000..114657efa7 --- /dev/null +++ b/src/app/w/[slug]/context/calls/[ref_id]/page.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { MarkdownRenderer } from "@/components/MarkdownRenderer"; +import { MediaPlayer } from "@/components/calls/MediaPlayer"; +import { SynchronizedGraphComponent } from "@/components/calls/SynchronizedGraphComponent"; +import { TranscriptPanel, TranscriptSegment } from "@/components/calls/TranscriptPanel"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useWorkspace } from "@/hooks/useWorkspace"; +import { StoreProvider } from "@/stores/StoreProvider"; +import { CallRecording } from "@/types/calls"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + + +export default function CallPage() { + const params = useParams(); + const router = useRouter(); + const { slug, id: workspaceId } = useWorkspace(); + const ref_id = params.ref_id as string; + + const storeId = `workspace-${workspaceId}-calls-${ref_id}`; + + const [call, setCall] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentTime, setCurrentTime] = useState(0); + const [seekToTime, setSeekToTime] = useState(undefined); + const [transcript, setTranscript] = useState([]); + + const handleBackClick = () => { + router.push(`/w/${slug}/context/calls`); + }; + + const handleTimeUpdate = (time: number) => { + setCurrentTime(time); + }; + + const handleTranscriptSegmentClick = (startTime: number) => { + setSeekToTime(startTime); + // Clear the seek request after a short delay + setTimeout(() => setSeekToTime(undefined), 100); + }; + + const handleTimeMarkerClick = (time: number) => { + setSeekToTime(time); + // Clear the seek request after a short delay + setTimeout(() => setSeekToTime(undefined), 100); + }; + + + + useEffect(() => { + if (!workspaceId || !ref_id) { + setLoading(false); + return; + } + + const fetchCallData = async () => { + setLoading(true); + setError(null); + + try { + // Fetch the subgraph data for this specific call + const response = await fetch( + `/api/swarm/jarvis/nodes?id=${workspaceId}&endpoint=${encodeURIComponent(`/graph/subgraph?start_node=${ref_id}&node_type=["Episode","Call","Clip","Video"]&depth=2&include_properties=true`)}` + ); + + if (!response.ok) { + throw new Error("Failed to fetch call data"); + } + + const data = await response.json(); + + if (!data.success || !data.data?.nodes) { + throw new Error("Invalid response data"); + } + + // Find the main call node + const callNode = data.data.nodes.find((node: any) => + (node.node_type === "Episode" || node.node_type === "Call") && node.ref_id === ref_id + ); + + if (!callNode) { + throw new Error("Call not found"); + } + + // Extract call data + const callData: CallRecording = { + ref_id: callNode.ref_id, + episode_title: callNode.properties?.episode_title || "Untitled Call", + date_added_to_graph: callNode.date_added_to_graph || 0, + description: callNode.properties?.description, + source_link: callNode.properties?.source_link, + media_url: callNode.properties?.media_url, + image_url: callNode.properties?.image_url, + }; + + setCall(callData); + + // Extract transcript from video nodes + const videoNodes = data.data.nodes.filter((node: any) => + (node.node_type === "Video" || node.node_type === "Clip") && node.properties?.text && node.properties?.timestamp + ); + + const transcriptSegments = videoNodes.map((node: any) => { + const timestampStr = node.properties.timestamp || "0-0"; + const [startStr, endStr] = timestampStr.split('-'); + const startTime = Number.parseInt(startStr) / 1000; + const endTime = Number.parseInt(endStr) / 1000; + + return { + id: node.ref_id, + text: node.properties.text || "", + startTime: Number.isNaN(startTime) ? 0 : startTime, + endTime: Number.isNaN(endTime) ? startTime + 10 : endTime, + }; + }).sort((a: any, b: any) => a.startTime - b.startTime); + + setTranscript(transcriptSegments); + + } catch (err) { + console.error("Error fetching call data:", err); + setError(err instanceof Error ? err.message : "Failed to load call data"); + } finally { + setLoading(false); + } + }; + + fetchCallData(); + }, [workspaceId, ref_id]); + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp * 1000); + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + if (loading) { + return ( +
+
+ + + + Loading... + +
+ + + + + + + + + + +
+ ); + } + + if (error || !call) { + return ( +
+ + + +

Error

+
+ +

{error || "Call not found"}

+
+
+
+ ); + } + + console.log(transcript, "transcript") + + return ( +
+ {/* Main viewport content - slightly less than screen height */} +
+ {/* Header */} +
+
+ +
+

{call.episode_title}

+

+ Added {formatDate(call.date_added_to_graph)} +

+
+
+
+ + {/* Main Content Area */} +
+ {/* Left Sidebar - Player and Transcript */} +
+ {/* Media Player */} +
+ +
+ + {/* Transcript Panel */} +
+
+ +
+
+
+ + {/* Right Side - Synchronized Knowledge Graph */} +
+
+ + + +
+
+
+
+ + {/* Description section - below the fold, accessible by scrolling */} + {call.description && ( +
+

Description

+
+ {call.description} +
+
+ )} +
+ ); +} diff --git a/src/app/w/[slug]/context/calls/page.tsx b/src/app/w/[slug]/context/calls/page.tsx new file mode 100644 index 0000000000..378fd65854 --- /dev/null +++ b/src/app/w/[slug]/context/calls/page.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useWorkspace } from "@/hooks/useWorkspace"; +import { useVoiceStore } from "@/stores/useVoiceStore"; +import { CallRecording, CallsResponse } from "@/types/calls"; +import { CallsTable } from "@/components/calls/CallsTable"; +import { VoiceMessagesDrawer } from "@/components/voice/VoiceMessagesDrawer"; +import { PoolLaunchBanner } from "@/components/pool-launch-banner"; +import { PageHeader } from "@/components/ui/page-header"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem } from "@/components/ui/pagination"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { ChevronLeft, ChevronRight, Loader2, Phone, Mic, MicOff, MessageSquare, Unplug } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { toast } from "sonner"; + +export default function CallsPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const { workspace, slug } = useWorkspace(); + const [calls, setCalls] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(() => parseInt(searchParams?.get("page") ?? "1", 10) || 1); + const [hasMore, setHasMore] = useState(false); + const [generatingLink, setGeneratingLink] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const limit = 10; + + // Voice agent state (LiveKit) + const isConnected = useVoiceStore((s) => s.isConnected); + const isConnecting = useVoiceStore((s) => s.isConnecting); + const isMicEnabled = useVoiceStore((s) => s.isMicEnabled); + const voiceError = useVoiceStore((s) => s.error); + const messages = useVoiceStore((s) => s.messages); + const connect = useVoiceStore((s) => s.connect); + const disconnect = useVoiceStore((s) => s.disconnect); + const toggleMic = useVoiceStore((s) => s.toggleMic); + const clearError = useVoiceStore((s) => s.clearError); + + // Show toast on voice connection errors + useEffect(() => { + if (voiceError) { + toast.error("Voice connection failed", { description: voiceError }); + clearError(); + } + }, [voiceError, clearError]); + + const handleConnectVoice = () => { + if (!slug) return; + if (isConnected) { + disconnect(); + } else { + connect(slug); + } + }; + + const handleStartCall = async () => { + if (!slug) return; + + setGeneratingLink(true); + try { + const response = await fetch(`/api/workspaces/${slug}/calls/generate-link`, { + method: "POST", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to generate call link"); + } + + const data = await response.json(); + window.open(data.url, "_blank", "noopener,noreferrer"); + } catch (err) { + console.error("Error generating call link:", err); + alert(err instanceof Error ? err.message : "Failed to start call"); + } finally { + setGeneratingLink(false); + } + }; + + // Navigate to a specific page and update URL + const goToPage = useCallback((n: number) => { + setPage(n); + const params = new URLSearchParams(searchParams?.toString() || ""); + if (n <= 1) { + params.delete("page"); + } else { + params.set("page", n.toString()); + } + const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; + router.replace(newUrl, { scroll: false }); + }, [pathname, router, searchParams]); + + useEffect(() => { + if (!slug || workspace?.poolState !== "COMPLETE") { + setLoading(false); + return; + } + + const fetchCalls = async () => { + setLoading(true); + setError(null); + + try { + const skip = (page - 1) * limit; + const response = await fetch(`/api/workspaces/${slug}/calls?limit=${limit}&skip=${skip}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to fetch calls"); + } + + const data: CallsResponse = await response.json(); + setCalls(data.calls); + setHasMore(data.hasMore); + } catch (err) { + console.error("Error fetching calls:", err); + setError(err instanceof Error ? err.message : "Failed to load call recordings"); + } finally { + setLoading(false); + } + }; + + fetchCalls(); + }, [slug, workspace?.poolState, page]); + + if (workspace?.poolState !== "COMPLETE") { + return ( +
+ + +
+ ); + } + + return ( +
+ + + {isConnected && ( + <> + + + + )} + +
+ ) : null + } + /> + + + + + + Call Recordings + + + {loading && ( +
+ + + + Title + Date Added + + + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + + + + + ))} + +
+
+ )} + + {error && !loading && ( +
+

Error loading calls

+

{error}

+
+ )} + + {!loading && !error && } + + {!loading && !error && calls.length > 0 && ( +
+ + + + + + + {page > 1 && ( + + + + )} + + {page > 2 && ( + + + + )} + + + + + + {hasMore && ( + <> + + + + + + + + )} + + +
+ )} +
+
+ + ); +} diff --git a/src/app/w/[slug]/context/layout.tsx b/src/app/w/[slug]/context/layout.tsx new file mode 100644 index 0000000000..d8a2a5154b --- /dev/null +++ b/src/app/w/[slug]/context/layout.tsx @@ -0,0 +1,79 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { useWorkspace } from "@/hooks/useWorkspace"; +import { useWorkspaceAccess } from "@/hooks/useWorkspaceAccess"; + +const TABS = [ + { label: "Learn", value: "learn" }, + { label: "Calls", value: "calls" }, + { label: "Agent Logs", value: "agent-logs" }, + { label: "Graph", value: "graph", adminOnly: true }, +] as const; + +// Sub-routes where we hide the tab bar and show a back button instead +const SUB_ROUTE_PATTERNS = [ + /\/context\/calls\/.+/, + /\/context\/agent-logs\/chat/, +]; + +export default function ContextLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const { slug } = useWorkspace(); + const { canAdmin } = useWorkspaceAccess(); + + const isSubRoute = SUB_ROUTE_PATTERNS.some((pattern) => pattern.test(pathname)); + + // Derive active tab from pathname (e.g. /w/slug/context/agent-logs -> "agent-logs") + const activeTab = (() => { + const match = pathname.match(/\/context\/([^/]+)/); + return match ? match[1] : "learn"; + })(); + + const visibleTabs = TABS.filter((tab) => !("adminOnly" in tab && tab.adminOnly) || canAdmin); + + if (!slug) return <>{children}; + + if (isSubRoute) { + // Determine back destination + const backHref = pathname.includes("/context/calls/") + ? `/w/${slug}/context/calls` + : `/w/${slug}/context/agent-logs`; + + return ( +
+
+ +
+
{children}
+
+ ); + } + + return ( +
+
+ + + {visibleTabs.map((tab) => ( + + {tab.label} + + ))} + + +
+
{children}
+
+ ); +} diff --git a/src/app/w/[slug]/context/learn/components/CreateDiagramModal.tsx b/src/app/w/[slug]/context/learn/components/CreateDiagramModal.tsx new file mode 100644 index 0000000000..3d0200fd97 --- /dev/null +++ b/src/app/w/[slug]/context/learn/components/CreateDiagramModal.tsx @@ -0,0 +1,156 @@ +"use client"; + +import React, { useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +interface CreateDiagramModalProps { + isOpen: boolean; + onClose: () => void; + workspaceSlug: string; + onDiagramCreated: () => void; + editMode?: boolean; + diagramId?: string; + initialName?: string; +} + +export function CreateDiagramModal({ + isOpen, + onClose, + workspaceSlug, + onDiagramCreated, + editMode = false, + diagramId, + initialName, +}: CreateDiagramModalProps) { + const [name, setName] = useState(""); + const [prompt, setPrompt] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + const handleClose = () => { + if (isCreating) return; + setName(""); + setPrompt(""); + setError(null); + onClose(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!prompt.trim()) return; + if (!editMode && !name.trim()) return; + + setIsCreating(true); + setError(null); + + try { + let response: Response; + if (editMode) { + response = await fetch("/api/learnings/diagrams/edit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspace: workspaceSlug, diagramId, prompt: prompt.trim() }), + }); + } else { + response = await fetch("/api/learnings/diagrams/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspace: workspaceSlug, name: name.trim(), prompt: prompt.trim() }), + }); + } + + const data = await response.json(); + + if (!response.ok) { + setError(data?.error ?? (editMode ? "Failed to edit diagram" : "Failed to create diagram")); + return; + } + + setName(""); + setPrompt(""); + onDiagramCreated(); + onClose(); + } catch { + setError("An unexpected error occurred. Please try again."); + } finally { + setIsCreating(false); + } + }; + + return ( + + + + {editMode ? "Edit Diagram" : "New Diagram"} + + +
+
+ + { if (!editMode) setName(e.target.value); }} + disabled={isCreating || editMode} + /> +
+ +
+ +