From 2e488f9c9e319bf74a9c3187cef4b61a28ec0ce1 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Tue, 17 Mar 2026 09:50:31 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(frontend):=20redesign=20session=20deta?= =?UTF-8?q?il=20page=20=E2=80=94=20remove=20sidebar,=20add=20explorer=20pa?= =?UTF-8?q?nel=20and=20shared=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the left accordion sidebar from the session detail page and redistribute its contents into more natural UI surfaces: - Sessions sidebar (workspace nav + recents) in a shared Next.js layout - Right-side Explorer panel with Files + Context tabs (repos/uploads separated) - Settings modal (Session/MCP/Integrations tabs) via kebab menu - Workflow selector in the chat input toolbar - File tab system with syntax-highlighted viewer - New session page with runner/model selector and repo support - Split nav header (branding in sidebar, actions in content header) - Chat input restyled with unified bordered container RHOAIENG-53563 Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/handlers/sessions.go | 9 + components/frontend/src/app/layout.tsx | 4 +- components/frontend/src/app/page.tsx | 10 +- .../src/app/projects/[name]/keys/page.tsx | 71 +- .../src/app/projects/[name]/layout.tsx | 141 ++ .../src/app/projects/[name]/new/page.tsx | 98 + .../frontend/src/app/projects/[name]/page.tsx | 182 +- .../app/projects/[name]/permissions/page.tsx | 18 +- .../[name]/scheduled-sessions/page.tsx | 17 + .../__tests__/content-tabs.test.tsx | 92 + .../components/__tests__/file-viewer.test.tsx | 97 + .../__tests__/new-session-view.test.tsx | 94 + .../__tests__/runner-model-selector.test.tsx | 107 ++ .../__tests__/session-settings-modal.test.tsx | 84 + .../__tests__/sessions-sidebar.test.tsx | 174 ++ .../__tests__/workflow-selector.test.tsx | 103 + .../[sessionName]/components/content-tabs.tsx | 91 + .../explorer/__tests__/context-tab.test.tsx | 65 + .../__tests__/explorer-panel.test.tsx | 84 + .../components/explorer/context-tab.tsx | 308 +++ .../components/explorer/explorer-panel.tsx | 141 ++ .../components/explorer/files-tab.tsx | 251 +++ .../[sessionName]/components/file-viewer.tsx | 181 ++ .../components/modals/add-context-modal.tsx | 40 +- .../components/new-session-view.tsx | 224 +++ .../components/runner-model-selector.tsx | 126 ++ .../components/session-settings-modal.tsx | 140 ++ .../components/sessions-sidebar.tsx | 334 ++++ .../__tests__/integrations-panel.test.tsx | 70 + .../__tests__/mcp-servers-panel.test.tsx | 89 + .../__tests__/session-details.test.tsx | 69 + .../components/settings/card-skeleton.tsx | 18 + .../settings/integrations-panel.tsx | 135 ++ .../components/settings/mcp-servers-panel.tsx | 231 +++ .../components/settings/session-details.tsx | 82 + .../components/workflow-selector.tsx | 235 +++ .../__tests__/use-explorer-state.test.ts | 114 ++ .../hooks/__tests__/use-file-tabs.test.ts | 107 ++ .../__tests__/use-workflow-selection.test.ts | 161 ++ .../[sessionName]/hooks/use-explorer-state.ts | 26 + .../[sessionName]/hooks/use-file-tabs.ts | 53 + .../hooks/use-workflow-selection.ts | 91 + .../sessions/[sessionName]/lib/types.ts | 18 + .../[name]/sessions/[sessionName]/page.tsx | 1709 ++++------------- .../sessions/[sessionName]/session-header.tsx | 30 +- .../src/app/projects/[name]/sessions/page.tsx | 18 +- .../src/app/projects/[name]/settings/page.tsx | 18 +- .../src/components/chat/ChatInputBox.tsx | 277 +-- .../chat/__tests__/ChatInputBox.test.tsx | 14 +- .../src/components/navigation-wrapper.tsx | 13 + .../frontend/src/components/navigation.tsx | 2 +- .../src/components/session/MessagesTab.tsx | 12 +- .../scheduled-sessions-tab.tsx | 4 - .../workspace-sections/sessions-section.tsx | 19 +- .../workspace-sections/sharing-section.tsx | 4 - .../frontend/src/hooks/use-resize-panel.ts | 71 + e2e/cypress/e2e/sessions.cy.ts | 143 +- 57 files changed, 5175 insertions(+), 1944 deletions(-) create mode 100644 components/frontend/src/app/projects/[name]/layout.tsx create mode 100644 components/frontend/src/app/projects/[name]/new/page.tsx create mode 100644 components/frontend/src/app/projects/[name]/scheduled-sessions/page.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/content-tabs.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/file-viewer.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/runner-model-selector.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/session-settings-modal.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/sessions-sidebar.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/workflow-selector.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/content-tabs.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/__tests__/context-tab.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/__tests__/explorer-panel.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/context-tab.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/explorer-panel.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/files-tab.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/file-viewer.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/runner-model-selector.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/session-settings-modal.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/sessions-sidebar.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/__tests__/integrations-panel.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/__tests__/mcp-servers-panel.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/__tests__/session-details.test.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/card-skeleton.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/integrations-panel.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/mcp-servers-panel.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/session-details.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/workflow-selector.tsx create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/__tests__/use-explorer-state.test.ts create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/__tests__/use-file-tabs.test.ts create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/__tests__/use-workflow-selection.test.ts create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/use-explorer-state.ts create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/use-file-tabs.ts create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/use-workflow-selection.ts create mode 100644 components/frontend/src/components/navigation-wrapper.tsx create mode 100644 components/frontend/src/hooks/use-resize-panel.ts diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 81cfc5503..04a7f1010 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -893,6 +893,15 @@ func CreateSession(c *gin.Context) { // Runner token provisioning is handled by the operator when creating the pod. // This ensures consistent behavior whether sessions are created via API or kubectl. + // Trigger async display name generation when initialPrompt is provided + // but no explicit displayName was set. The AG-UI proxy skips the + // initialPrompt message, so sessions created with only an initialPrompt + // (e.g., from the new-session page) would never get a generated name. + if strings.TrimSpace(req.InitialPrompt) != "" && strings.TrimSpace(req.DisplayName) == "" { + sessionCtx := ExtractSessionContext(created.Object["spec"].(map[string]interface{})) + GenerateDisplayNameAsync(project, name, req.InitialPrompt, sessionCtx) + } + c.JSON(http.StatusCreated, gin.H{ "message": "Agentic session created successfully", "name": name, diff --git a/components/frontend/src/app/layout.tsx b/components/frontend/src/app/layout.tsx index 0044fd26d..763791782 100644 --- a/components/frontend/src/app/layout.tsx +++ b/components/frontend/src/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { GeistSans } from "geist/font/sans"; import { GeistMono } from "geist/font/mono"; import "./globals.css"; -import { Navigation } from "@/components/navigation"; +import { NavigationWrapper } from "@/components/navigation-wrapper"; import { QueryProvider } from "@/components/providers/query-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { SyntaxThemeProvider } from "@/components/providers/syntax-theme-provider"; @@ -44,7 +44,7 @@ export default function RootLayout({ - +
{children}
diff --git a/components/frontend/src/app/page.tsx b/components/frontend/src/app/page.tsx index c0e3f9008..860c3aa6b 100644 --- a/components/frontend/src/app/page.tsx +++ b/components/frontend/src/app/page.tsx @@ -7,8 +7,14 @@ import { Loader2 } from "lucide-react"; export default function HomeRedirect() { const router = useRouter(); useEffect(() => { - // Redirect to RFE workflows as the new main interface - router.replace("/projects"); + const lastProject = typeof window !== "undefined" + ? localStorage.getItem("selectedProject") + : null; + if (lastProject) { + router.replace(`/projects/${encodeURIComponent(lastProject)}`); + } else { + router.replace("/projects"); + } }, [router]); return ( diff --git a/components/frontend/src/app/projects/[name]/keys/page.tsx b/components/frontend/src/app/projects/[name]/keys/page.tsx index 29448a718..c23618873 100644 --- a/components/frontend/src/app/projects/[name]/keys/page.tsx +++ b/components/frontend/src/app/projects/[name]/keys/page.tsx @@ -12,19 +12,9 @@ import { Badge } from '@/components/ui/badge'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { ProjectSubpageHeader } from '@/components/project-subpage-header'; import { ErrorMessage } from '@/components/error-message'; import { EmptyState } from '@/components/empty-state'; import { DestructiveConfirmationDialog } from '@/components/confirmation-dialog'; -import { - Breadcrumb, - BreadcrumbList, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbPage, - BreadcrumbSeparator, -} from '@/components/ui/breadcrumb'; -import Link from 'next/link'; import { useKeys, useCreateKey, useDeleteKey } from '@/services/queries'; import { toast } from 'sonner'; @@ -115,47 +105,7 @@ export default function ProjectKeysPage() { } return ( -
- - - - - Projects - - - - - - {projectName} - - - - - Keys - - - - - - Access Keys - - } - description={<>Create and manage API keys for non-user access} - actions={ - <> - - - - } - /> +
{/* Error state */} {error && refetch()} />} @@ -174,11 +124,20 @@ export default function ProjectKeysPage() { - - - Access Keys ({keys.length}) - - API keys scoped to this project +
+
+ + Access Keys + + Create and manage API keys for non-user access +
+
+ +
+
{keys.length > 0 ? ( diff --git a/components/frontend/src/app/projects/[name]/layout.tsx b/components/frontend/src/app/projects/[name]/layout.tsx new file mode 100644 index 000000000..ea3e94850 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/layout.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { useParams, useRouter, usePathname } from "next/navigation"; +import { PanelLeft, Plug, LogOut } from "lucide-react"; +import Link from "next/link"; +import { useVersion } from "@/services/queries/use-version"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { UserBubble } from "@/components/user-bubble"; +import { cn } from "@/lib/utils"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { SessionsSidebar } from "./sessions/[sessionName]/components/sessions-sidebar"; + +export default function ProjectLayout({ + children, +}: { + children: React.ReactNode; +}) { + const params = useParams(); + const router = useRouter(); + const pathname = usePathname(); + const projectName = params?.name as string; + + // Extract session name from URL: /projects/{name}/sessions/{sessionName} + const currentSessionName = useMemo(() => { + if (!pathname) return ""; + const match = pathname.match(/\/sessions\/([^/]+)/); + return match ? decodeURIComponent(match[1]) : ""; + }, [pathname]); + const [sidebarVisible, setSidebarVisible] = useLocalStorage( + "session-sidebar-visible", + true + ); + const { data: version } = useVersion(); + + const handleLogout = () => { + window.location.href = '/oauth/sign_out'; + }; + + // Persist last visited project for redirect on next visit + useEffect(() => { + if (projectName) { + try { localStorage.setItem("selectedProject", projectName); } catch {} + } + }, [projectName]); + + if (!projectName) return null; + + return ( +
+
+ {/* Left sidebar */} +
+
+ setSidebarVisible(false)} + /> +
+
+ + {/* Main content */} +
+ {/* Content header with nav items */} +
+
+ {/* Left: branding when sidebar is collapsed */} +
+ {!sidebarVisible && ( + <> + + + Ambient Code Platform + {version && ( + + {version} + + )} + + + )} +
+ + {/* Right: nav items */} +
+ + + + + + + + + + Logout + + + +
+
+
+ + {/* Page content */} +
+ {children} +
+
+
+
+ ); +} diff --git a/components/frontend/src/app/projects/[name]/new/page.tsx b/components/frontend/src/app/projects/[name]/new/page.tsx new file mode 100644 index 000000000..9c0ebc6a8 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/new/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { NewSessionView } from "../sessions/[sessionName]/components/new-session-view"; +import { CustomWorkflowDialog } from "../sessions/[sessionName]/components/modals/custom-workflow-dialog"; +import { useCreateSession } from "@/services/queries"; +import { useOOTBWorkflows } from "@/services/queries/use-workflows"; + +export default function NewSessionPage() { + const params = useParams(); + const router = useRouter(); + const projectName = params?.name as string; + + const { data: ootbWorkflows = [] } = useOOTBWorkflows(projectName); + const createSessionMutation = useCreateSession(); + const [customWorkflowDialogOpen, setCustomWorkflowDialogOpen] = useState(false); + const [customWorkflow, setCustomWorkflow] = useState<{ gitUrl: string; branch: string; path: string } | null>(null); + + const handleCreateNewSession = useCallback( + (config: { + prompt: string; + runner: string; + model: string; + workflow?: string; + repos?: Array<{ url: string }>; + }) => { + const workflowConfig = config.workflow === "custom" && customWorkflow + ? { gitUrl: customWorkflow.gitUrl, branch: customWorkflow.branch, path: customWorkflow.path } + : config.workflow + ? ootbWorkflows.find((w) => w.id === config.workflow) + : undefined; + + createSessionMutation.mutate( + { + projectName, + data: { + initialPrompt: config.prompt, + runnerType: config.runner, + llmSettings: { model: config.model }, + ...(workflowConfig + ? { + activeWorkflow: { + gitUrl: workflowConfig.gitUrl, + branch: workflowConfig.branch || "main", + path: workflowConfig.path, + }, + } + : {}), + ...(config.repos && config.repos.length > 0 + ? { + repos: config.repos.map((r) => ({ url: r.url })), + } + : {}), + }, + }, + { + onSuccess: (session) => { + router.push( + `/projects/${encodeURIComponent(projectName)}/sessions/${session.metadata.name}` + ); + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : "Failed to create session" + ); + }, + } + ); + }, + [projectName, ootbWorkflows, customWorkflow, createSessionMutation, router] + ); + + if (!projectName) return null; + + return ( +
+ setCustomWorkflowDialogOpen(true)} + isSubmitting={createSessionMutation.isPending} + /> + { + setCustomWorkflow({ gitUrl: url, branch: branch || "main", path: path || "" }); + setCustomWorkflowDialogOpen(false); + }} + /> +
+ ); +} diff --git a/components/frontend/src/app/projects/[name]/page.tsx b/components/frontend/src/app/projects/[name]/page.tsx index c2e673ba6..4d4a56632 100644 --- a/components/frontend/src/app/projects/[name]/page.tsx +++ b/components/frontend/src/app/projects/[name]/page.tsx @@ -1,180 +1,10 @@ -'use client'; +import { redirect } from "next/navigation"; -import { useState, useEffect } from 'react'; -import { useParams, useSearchParams } from 'next/navigation'; -import { Star, Settings, Users, KeyRound, Loader2, Calendar } from 'lucide-react'; - -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { PageHeader } from '@/components/page-header'; -import Link from 'next/link'; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from '@/components/ui/breadcrumb'; -import { - SidebarProvider, - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupLabel, - SidebarGroupContent, - SidebarMenu, - SidebarMenuItem, - SidebarMenuButton, - SidebarRail, - SidebarInset, - SidebarTrigger, - useSidebar, -} from '@/components/ui/sidebar'; -import { Separator } from '@/components/ui/separator'; - -import { SessionsSection } from '@/components/workspace-sections/sessions-section'; -import { SharingSection } from '@/components/workspace-sections/sharing-section'; -import { SettingsSection } from '@/components/workspace-sections/settings-section'; -import { KeysSection } from '@/components/workspace-sections/keys-section'; -import { SchedulesSection } from '@/components/workspace-sections/scheduled-sessions-tab'; -import { useProject } from '@/services/queries/use-projects'; - -type Section = 'sessions' | 'schedules' | 'sharing' | 'keys' | 'settings'; - -const navItems: { id: Section; label: string; icon: typeof Star }[] = [ - { id: 'sessions', label: 'Sessions', icon: Star }, - { id: 'schedules', label: 'Schedules', icon: Calendar }, - { id: 'sharing', label: 'Sharing', icon: Users }, - { id: 'keys', label: 'Access Keys', icon: KeyRound }, - { id: 'settings', label: 'Workspace Settings', icon: Settings }, -]; - -function WorkspaceSidebar({ - activeSection, - onSectionChange, +export default async function ProjectDetailsPage({ + params, }: { - activeSection: Section; - onSectionChange: (section: Section) => void; + params: Promise<{ name: string }>; }) { - const { isMobile, setOpenMobile } = useSidebar(); - - return ( - - - - Workspace - - - {navItems.map((item) => ( - - { - onSectionChange(item.id); - if (isMobile) setOpenMobile(false); - }} - tooltip={item.label} - > - - {item.label} - - - ))} - - - - - - - ); -} - -export default function ProjectDetailsPage() { - const params = useParams(); - const searchParams = useSearchParams(); - const projectName = params?.name as string; - - // Fetch project data for display name and description - const { data: project, isLoading: projectLoading } = useProject(projectName); - - // Initialize active section from query parameter or default to 'sessions' - const initialSection = (searchParams.get('section') as Section) || 'sessions'; - const [activeSection, setActiveSection] = useState
(initialSection); - - // Update active section when query parameter changes - useEffect(() => { - const sectionParam = searchParams.get('section') as Section; - if (sectionParam && ['sessions', 'schedules', 'sharing', 'keys', 'settings'].includes(sectionParam)) { - setActiveSection(sectionParam); - } - }, [searchParams]); - - // Loading state - if (!projectName || projectLoading) { - return ( -
-
- - - Loading Workspace... - -

Please wait while the workspace is loading...

-
-
-
-
- ); - } - - return ( - <> - {`${project?.displayName || projectName} · Ambient Code Platform`} - - - - {/* Sticky header with breadcrumbs and sidebar trigger */} -
- - - - - - - Workspaces - - - - - {projectName} - - - -
- - {/* Page content */} -
- - -
- - {/* Main Content */} - {activeSection === 'sessions' && } - {activeSection === 'schedules' && } - {activeSection === 'sharing' && } - {activeSection === 'keys' && } - {activeSection === 'settings' && } -
-
-
- - ); + const { name } = await params; + redirect(`/projects/${encodeURIComponent(name)}/new`); } diff --git a/components/frontend/src/app/projects/[name]/permissions/page.tsx b/components/frontend/src/app/projects/[name]/permissions/page.tsx index a07590d5f..adf4c31a8 100644 --- a/components/frontend/src/app/projects/[name]/permissions/page.tsx +++ b/components/frontend/src/app/projects/[name]/permissions/page.tsx @@ -1,19 +1,17 @@ 'use client'; -import { useEffect } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; +import { SharingSection } from '@/components/workspace-sections/sharing-section'; export default function PermissionsPage() { const params = useParams(); - const router = useRouter(); const projectName = params?.name as string; - // Redirect to main workspace page - useEffect(() => { - if (projectName) { - router.replace(`/projects/${projectName}?section=sharing`); - } - }, [projectName, router]); + if (!projectName) return null; - return null; + return ( +
+ +
+ ); } diff --git a/components/frontend/src/app/projects/[name]/scheduled-sessions/page.tsx b/components/frontend/src/app/projects/[name]/scheduled-sessions/page.tsx new file mode 100644 index 000000000..e9d0925f6 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/scheduled-sessions/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { SchedulesSection } from '@/components/workspace-sections/scheduled-sessions-tab'; + +export default function ScheduledSessionsPage() { + const params = useParams(); + const projectName = params?.name as string; + + if (!projectName) return null; + + return ( +
+ +
+ ); +} diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/content-tabs.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/content-tabs.test.tsx new file mode 100644 index 000000000..4e11093b6 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/content-tabs.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ContentTabs } from '../content-tabs'; +import type { FileTab, ActiveTab } from '../../hooks/use-file-tabs'; + +describe('ContentTabs', () => { + const defaultProps = { + openTabs: [] as FileTab[], + activeTab: { type: 'chat' } as ActiveTab, + onSwitchToChat: vi.fn(), + onSwitchToFile: vi.fn(), + onCloseFile: vi.fn(), + }; + + it('renders Chat tab always', () => { + render(); + expect(screen.getByText('Chat')).toBeDefined(); + }); + + it('renders file tabs when openTabs provided', () => { + const tabs: FileTab[] = [ + { path: '/src/index.ts', name: 'index.ts' }, + { path: '/src/app.tsx', name: 'app.tsx' }, + ]; + render(); + + expect(screen.getByText('index.ts')).toBeDefined(); + expect(screen.getByText('app.tsx')).toBeDefined(); + }); + + it('calls onSwitchToChat when Chat tab clicked', () => { + const onSwitchToChat = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Chat')); + expect(onSwitchToChat).toHaveBeenCalledTimes(1); + }); + + it('calls onSwitchToFile when file tab clicked', () => { + const onSwitchToFile = vi.fn(); + const tabs: FileTab[] = [{ path: '/src/index.ts', name: 'index.ts' }]; + render( + + ); + + fireEvent.click(screen.getByText('index.ts')); + expect(onSwitchToFile).toHaveBeenCalledTimes(1); + expect(onSwitchToFile).toHaveBeenCalledWith('/src/index.ts'); + }); + + it('calls onCloseFile when close button clicked', () => { + const onCloseFile = vi.fn(); + const tabs: FileTab[] = [{ path: '/src/index.ts', name: 'index.ts' }]; + render( + + ); + + const closeButton = screen.getByRole('button', { name: 'Close index.ts' }); + fireEvent.click(closeButton); + expect(onCloseFile).toHaveBeenCalledTimes(1); + expect(onCloseFile).toHaveBeenCalledWith('/src/index.ts'); + }); + + it('renders rightActions when provided', () => { + render( + Settings} + /> + ); + + expect(screen.getByText('Settings')).toBeDefined(); + }); + + it('shows no file tabs when openTabs is empty', () => { + const { container } = render(); + + // Only the Chat button should exist as a tab button + const buttons = container.querySelectorAll('button'); + // Should only have the Chat button (no close buttons or file tab buttons) + expect(buttons.length).toBe(1); + expect(screen.getByText('Chat')).toBeDefined(); + }); +}); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/file-viewer.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/file-viewer.test.tsx new file mode 100644 index 000000000..2f71cbc96 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/file-viewer.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { FileViewer } from '../file-viewer'; + +vi.mock('@/services/queries/use-workspace', () => ({ + useWorkspaceFile: vi.fn(), +})); + +vi.mock('highlight.js', () => ({ + default: { + highlightElement: vi.fn(), + }, +})); + +import { useWorkspaceFile } from '@/services/queries/use-workspace'; + +const mockUseWorkspaceFile = vi.mocked(useWorkspaceFile); + +const defaultProps = { + projectName: 'my-project', + sessionName: 'my-session', + filePath: 'src/index.ts', +}; + +describe('FileViewer', () => { + it('renders loading skeleton when isLoading', () => { + mockUseWorkspaceFile.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as ReturnType); + + const { container } = render(); + + // Skeleton elements should be present (the component renders multiple Skeleton divs) + const skeletons = container.querySelectorAll('[class*="animate-pulse"], [data-slot="skeleton"]'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('renders error state when error', () => { + mockUseWorkspaceFile.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('File not found'), + } as ReturnType); + + render(); + + expect(screen.getByText('Failed to load file')).toBeDefined(); + expect(screen.getByText('File not found')).toBeDefined(); + }); + + it('renders file content with line numbers', () => { + const content = 'const a = 1;\nconst b = 2;\nconst c = 3;'; + mockUseWorkspaceFile.mockReturnValue({ + data: content, + isLoading: false, + error: null, + } as ReturnType); + + const { container } = render(); + + // Verify line numbers are rendered + expect(screen.getByText('1')).toBeDefined(); + expect(screen.getByText('2')).toBeDefined(); + expect(screen.getByText('3')).toBeDefined(); + + // Verify the file content is rendered inside + const codeElement = container.querySelector('code'); + expect(codeElement?.textContent).toBe(content); + }); + + it('renders file path and language badge', () => { + mockUseWorkspaceFile.mockReturnValue({ + data: 'const x = 1;', + isLoading: false, + error: null, + } as ReturnType); + + render(); + + expect(screen.getByText('src/app.tsx')).toBeDefined(); + expect(screen.getByText('typescript')).toBeDefined(); + }); + + it('renders download button', () => { + mockUseWorkspaceFile.mockReturnValue({ + data: 'hello', + isLoading: false, + error: null, + } as ReturnType); + + render(); + + expect(screen.getByRole('button', { name: 'Download file' })).toBeDefined(); + }); +}); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx new file mode 100644 index 000000000..fd918b232 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { NewSessionView } from '../new-session-view'; + +vi.mock('../runner-model-selector', () => ({ + RunnerModelSelector: ({ onSelect }: { onSelect: (r: string, m: string) => void }) => ( + + ), + getDefaultModel: () => 'claude-sonnet-4-5', +})); + +vi.mock('@/services/queries/use-runner-types', () => ({ + useRunnerTypes: () => ({ + data: [ + { id: 'claude-agent-sdk', displayName: 'Claude Agent SDK', description: '', framework: '', provider: 'anthropic', auth: { requiredSecretKeys: [], secretKeyLogic: 'any', vertexSupported: false } }, + ], + }), +})); + +vi.mock('@/services/api/runner-types', () => ({ + DEFAULT_RUNNER_TYPE_ID: 'claude-agent-sdk', +})); + +vi.mock('../workflow-selector', () => ({ + WorkflowSelector: () => , +})); + +describe('NewSessionView', () => { + const defaultProps = { + projectName: 'test-project', + onCreateSession: vi.fn(), + ootbWorkflows: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders heading and subtitle', () => { + render(); + expect(screen.getByText('What are you working on?')).toBeDefined(); + expect(screen.getByText(/Start a new session/)).toBeDefined(); + }); + + it('renders textarea with placeholder', () => { + render(); + const textarea = screen.getByPlaceholderText("Describe what you'd like to work on..."); + expect(textarea).toBeDefined(); + }); + + it('renders runner/model selector and workflow selector', () => { + render(); + expect(screen.getByTestId('runner-model-selector')).toBeDefined(); + expect(screen.getByTestId('workflow-selector')).toBeDefined(); + }); + + it('send button is disabled when textarea is empty', () => { + render(); + const allButtons = screen.getAllByRole('button'); + const lastButton = allButtons[allButtons.length - 1]; + expect(lastButton.hasAttribute('disabled')).toBe(true); + }); + + it('calls onCreateSession with prompt when submitted', () => { + render(); + const textarea = screen.getByPlaceholderText("Describe what you'd like to work on..."); + fireEvent.change(textarea, { target: { value: 'Build a REST API' } }); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + expect(defaultProps.onCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'Build a REST API', + runner: 'claude-agent-sdk', + model: 'claude-sonnet-4-5', + }) + ); + }); + + it('does not submit when prompt is empty', () => { + render(); + const textarea = screen.getByPlaceholderText("Describe what you'd like to work on..."); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + expect(defaultProps.onCreateSession).not.toHaveBeenCalled(); + }); + + it('Shift+Enter does not submit (allows newline)', () => { + render(); + const textarea = screen.getByPlaceholderText("Describe what you'd like to work on..."); + fireEvent.change(textarea, { target: { value: 'some text' } }); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true }); + expect(defaultProps.onCreateSession).not.toHaveBeenCalled(); + }); +}); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/runner-model-selector.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/runner-model-selector.test.tsx new file mode 100644 index 000000000..ccb9f84db --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/runner-model-selector.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { RunnerModelSelector, getDefaultModel, getModelsForRunner } from '../runner-model-selector'; +import type { RunnerType } from '@/services/api/runner-types'; + +const mockRunnerTypes: RunnerType[] = [ + { + id: 'claude-code', + displayName: 'Claude Code', + description: 'Claude Code runner', + framework: 'claude', + provider: 'anthropic', + auth: { requiredSecretKeys: [], secretKeyLogic: 'any', vertexSupported: false }, + }, + { + id: 'gemini-cli', + displayName: 'Gemini CLI', + description: 'Gemini CLI runner', + framework: 'gemini', + provider: 'google', + auth: { requiredSecretKeys: [], secretKeyLogic: 'any', vertexSupported: false }, + }, +]; + +const mockUseRunnerTypes = vi.fn(() => ({ data: mockRunnerTypes })); + +vi.mock('@/services/queries/use-runner-types', () => ({ + useRunnerTypes: (projectName: string) => mockUseRunnerTypes(projectName), +})); + +describe('RunnerModelSelector', () => { + const defaultProps = { + projectName: 'test-project', + selectedRunner: 'claude-code', + selectedModel: 'claude-sonnet-4-5', + onSelect: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseRunnerTypes.mockReturnValue({ data: mockRunnerTypes }); + }); + + it('renders trigger button with runner and model name', () => { + render(); + const button = screen.getByRole('button'); + expect(button.textContent).toContain('Claude Code'); + expect(button.textContent).toContain('Claude Sonnet 4.5'); + }); + + it('renders trigger button with unknown runner fallback', () => { + render( + + ); + const button = screen.getByRole('button'); + expect(button.textContent).toContain('unknown-runner'); + }); + + it('renders trigger button when no runners available', () => { + mockUseRunnerTypes.mockReturnValue({ data: [] }); + render(); + expect(screen.getByRole('button')).toBeDefined(); + }); +}); + +describe('getDefaultModel', () => { + it('returns second model for claude-code', () => { + expect(getDefaultModel('claude-code')).toBe('claude-sonnet-4-5'); + }); + + it('returns second model for gemini-cli', () => { + expect(getDefaultModel('gemini-cli')).toBe('gemini-2.5-pro'); + }); + + it('falls back to first model when only one exists', () => { + // amp has two models, second is gpt-4o + expect(getDefaultModel('amp')).toBe('gpt-4o'); + }); + + it('returns "default" for unknown runner', () => { + expect(getDefaultModel('nonexistent')).toBe('default'); + }); +}); + +describe('getModelsForRunner', () => { + it('returns claude models for claude-code', () => { + const models = getModelsForRunner('claude-code'); + expect(models).toHaveLength(3); + expect(models[0].id).toBe('claude-haiku-4-5'); + }); + + it('returns gemini models for gemini-cli', () => { + const models = getModelsForRunner('gemini-cli'); + expect(models).toHaveLength(2); + expect(models[0].id).toBe('gemini-2.0-flash'); + }); + + it('returns fallback for unknown runner', () => { + const models = getModelsForRunner('unknown'); + expect(models).toHaveLength(1); + expect(models[0].id).toBe('default'); + }); +}); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/session-settings-modal.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/session-settings-modal.test.tsx new file mode 100644 index 000000000..c417a0a58 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/session-settings-modal.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SessionSettingsModal } from '../session-settings-modal'; +import type { AgenticSession } from '@/types/agentic-session'; + +vi.mock('@/services/queries/use-mcp', () => ({ + useMcpStatus: vi.fn(() => ({ data: { servers: [] } })), +})); + +vi.mock('@/services/queries/use-integrations', () => ({ + useIntegrationsStatus: vi.fn(() => ({ data: null, isPending: false })), +})); + +vi.mock('../settings/session-details', () => ({ + SessionDetails: () =>
Session Details
, +})); + +vi.mock('../settings/mcp-servers-panel', () => ({ + McpServersPanel: () =>
MCP Panel
, +})); + +vi.mock('../settings/integrations-panel', () => ({ + IntegrationsPanel: () =>
Integrations Panel
, +})); + +function makeSession(): AgenticSession { + return { + metadata: { + name: 'test-session', + namespace: 'default', + uid: '123', + creationTimestamp: '2026-01-01T00:00:00Z', + }, + spec: { + displayName: 'Test Session', + initialPrompt: 'test', + llmSettings: { model: 'claude-sonnet-4-20250514', temperature: 0, maxTokens: 100 }, + timeout: 3600, + }, + status: { phase: 'Running' }, + }; +} + +describe('SessionSettingsModal', () => { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + session: makeSession(), + projectName: 'test-project', + onEditName: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders dialog when open is true', () => { + render(); + expect(screen.getByRole('dialog')).toBeDefined(); + }); + + it('renders Settings title', () => { + render(); + expect(screen.getByText('Settings')).toBeDefined(); + }); + + it('renders sidebar nav tabs (Session, MCP Servers, Integrations)', () => { + render(); + expect(screen.getByText('Session')).toBeDefined(); + expect(screen.getByText('MCP Servers')).toBeDefined(); + expect(screen.getByText('Integrations')).toBeDefined(); + }); + + it('shows Session details by default', () => { + render(); + expect(screen.getByTestId('session-details')).toBeDefined(); + }); + + it('clicking MCP Servers tab shows MCP panel', () => { + render(); + fireEvent.click(screen.getByText('MCP Servers')); + expect(screen.getByTestId('mcp-panel')).toBeDefined(); + }); +}); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/sessions-sidebar.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/sessions-sidebar.test.tsx new file mode 100644 index 000000000..1f1af6e72 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/sessions-sidebar.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SessionsSidebar } from '../sessions-sidebar'; +import type { AgenticSession } from '@/types/api'; + +const mockPush = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => '/projects/test-project/sessions/session-0', +})); + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +const mockUseSessionsPaginated = vi.fn((): { data: { items: Partial[] } | null; isLoading: boolean } => ({ + data: { items: [] }, + isLoading: false, +})); +vi.mock('@/services/queries/use-sessions', () => ({ + useSessionsPaginated: () => mockUseSessionsPaginated(), +})); + +vi.mock('@/services/queries/use-version', () => ({ + useVersion: () => ({ data: '1.0.0' }), +})); + +vi.mock('@/components/session-status-dot', () => ({ + SessionStatusDot: ({ phase }: { phase: string }) => ( + {phase} + ), +})); + +vi.mock('date-fns', () => ({ + formatDistanceToNow: () => '2 hours', +})); + +function makeSessions(count: number) { + return Array.from({ length: count }, (_, i) => ({ + metadata: { + name: `session-${i}`, + namespace: 'default', + uid: `uid-${i}`, + creationTimestamp: '2026-01-01T00:00:00Z', + }, + spec: { + displayName: `Session ${i}`, + initialPrompt: 'test', + llmSettings: { model: 'test', temperature: 0, maxTokens: 100 }, + timeout: 3600, + }, + status: { phase: 'Running' as const }, + })) as unknown as AgenticSession[]; +} + +describe('SessionsSidebar', () => { + const defaultProps = { + projectName: 'test-project', + currentSessionName: 'session-0', + collapsed: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseSessionsPaginated.mockReturnValue({ + data: { items: [] }, + isLoading: false, + }); + }); + + it('returns null when collapsed is true', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders New Session button', () => { + render(); + expect(screen.getByText('New Session')).toBeDefined(); + }); + + it('renders Workspaces back link', () => { + render(); + expect(screen.getByText('Workspaces')).toBeDefined(); + }); + + it('renders workspace navigation links', () => { + render(); + expect(screen.getByText('Sessions')).toBeDefined(); + expect(screen.getByText('Schedules')).toBeDefined(); + expect(screen.getByText('Sharing')).toBeDefined(); + expect(screen.getByText('Access Keys')).toBeDefined(); + expect(screen.getByText('Workspace Settings')).toBeDefined(); + }); + + it('renders RECENTS section header', () => { + render(); + expect(screen.getByText('Recents')).toBeDefined(); + }); + + it('renders loading skeletons when isLoading', () => { + mockUseSessionsPaginated.mockReturnValue({ + data: { items: [] }, + isLoading: true, + }); + const { container } = render(); + const skeletons = container.querySelectorAll('[class*="animate-pulse"], [data-slot="skeleton"]'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('renders "No sessions yet" when no sessions', () => { + render(); + expect(screen.getByText('No sessions yet')).toBeDefined(); + }); + + it('renders session items when data exists', () => { + mockUseSessionsPaginated.mockReturnValue({ + data: { items: makeSessions(3) }, + isLoading: false, + }); + render(); + expect(screen.getByText('Session 0')).toBeDefined(); + expect(screen.getByText('Session 1')).toBeDefined(); + expect(screen.getByText('Session 2')).toBeDefined(); + }); + + it('clicking session navigates to correct URL', () => { + mockUseSessionsPaginated.mockReturnValue({ + data: { items: makeSessions(2) }, + isLoading: false, + }); + render(); + fireEvent.click(screen.getByText('Session 1')); + expect(mockPush).toHaveBeenCalledWith( + '/projects/test-project/sessions/session-1' + ); + }); + + it('calls onSessionSelect when clicking a session', () => { + const onSessionSelect = vi.fn(); + mockUseSessionsPaginated.mockReturnValue({ + data: { items: makeSessions(1) }, + isLoading: false, + }); + render(); + fireEvent.click(screen.getByText('Session 0')); + expect(onSessionSelect).toHaveBeenCalled(); + }); + + it('clicking New Session calls onNewSession callback', () => { + const onNewSession = vi.fn(); + render(); + fireEvent.click(screen.getByText('New Session')); + expect(onNewSession).toHaveBeenCalled(); + }); + + it('clicking New Session navigates to new page when no callback', () => { + render(); + fireEvent.click(screen.getByText('New Session')); + expect(mockPush).toHaveBeenCalledWith('/projects/test-project/new'); + }); + + it('renders collapse button when onCollapse is provided', () => { + const onCollapse = vi.fn(); + render(); + const collapseBtn = screen.getByTitle('Hide sidebar'); + expect(collapseBtn).toBeDefined(); + fireEvent.click(collapseBtn); + expect(onCollapse).toHaveBeenCalled(); + }); +}); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/workflow-selector.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/workflow-selector.test.tsx new file mode 100644 index 000000000..819b40c1e --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/workflow-selector.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { WorkflowSelector } from '../workflow-selector'; +import type { WorkflowConfig } from '@/types/workflow'; + +vi.mock('../../hooks/use-workflow-selection', () => ({ + useWorkflowSelection: vi.fn(), +})); + +import { useWorkflowSelection } from '../../hooks/use-workflow-selection'; + +const mockUseWorkflowSelection = vi.mocked(useWorkflowSelection); + +const sampleWorkflows: WorkflowConfig[] = [ + { + id: 'wf-1', + name: 'Code Review', + description: 'Review pull requests', + gitUrl: 'https://github.com/example/repo', + branch: 'main', + enabled: true, + }, + { + id: 'wf-2', + name: 'Bug Fix', + description: 'Fix bugs in the codebase', + gitUrl: 'https://github.com/example/repo', + branch: 'main', + enabled: true, + }, +]; + +function setupMock(overrides: Partial> = {}) { + mockUseWorkflowSelection.mockReturnValue({ + search: '', + setSearch: vi.fn(), + popoverOpen: false, + searchInputRef: { current: null }, + filteredWorkflows: sampleWorkflows, + showGeneralChat: true, + showCustomWorkflow: true, + selectedLabel: 'No workflow', + isActivating: false, + handleSelect: vi.fn(), + handleOpenChange: vi.fn(), + ...overrides, + }); +} + +const defaultProps = { + sessionPhase: 'Running', + activeWorkflow: null, + selectedWorkflow: 'none', + workflowActivating: false, + ootbWorkflows: sampleWorkflows, + onWorkflowChange: vi.fn(), +}; + +describe('WorkflowSelector', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders button with workflow label', () => { + setupMock({ selectedLabel: 'No workflow' }); + render(); + + expect(screen.getByText('No workflow')).toBeDefined(); + }); + + it('button is disabled when session is Stopped', () => { + setupMock(); + render(); + + const button = screen.getByRole('button'); + expect(button.hasAttribute('disabled')).toBe(true); + }); + + it('shows "Switching..." when workflowActivating is true', () => { + setupMock({ isActivating: true }); + render( + + ); + + expect(screen.getByText('Switching...')).toBeDefined(); + }); + + it('renders correct display label from activeWorkflow', () => { + setupMock({ selectedLabel: 'Code Review' }); + render( + + ); + + // When activeWorkflow is set, the component looks up the name from ootbWorkflows + expect(screen.getByText('Code Review')).toBeDefined(); + }); +}); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/content-tabs.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/content-tabs.tsx new file mode 100644 index 000000000..04d57de3f --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/content-tabs.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { MessageSquare, X } from "lucide-react"; +import type { FileTab, ActiveTab } from "../hooks/use-file-tabs"; + +type ContentTabsProps = { + openTabs: FileTab[]; + activeTab: ActiveTab; + onSwitchToChat: () => void; + onSwitchToFile: (path: string) => void; + onCloseFile: (path: string) => void; + rightActions?: React.ReactNode; +}; + +export function ContentTabs({ + openTabs, + activeTab, + onSwitchToChat, + onSwitchToFile, + onCloseFile, + rightActions, +}: ContentTabsProps) { + const isChatActive = activeTab.type === "chat"; + + return ( +
+
+ {/* Chat tab — always present, not closable */} + + + {/* File tabs — closable */} + {openTabs.map((tab) => { + const isActive = + activeTab.type === "file" && activeTab.path === tab.path; + return ( +
+ + +
+ ); + })} +
+ + {/* Right-side actions (settings, explorer toggle) */} + {rightActions && ( +
+ {rightActions} +
+ )} +
+ ); +} diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/__tests__/context-tab.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/__tests__/context-tab.test.tsx new file mode 100644 index 000000000..3bc8ebc58 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/__tests__/context-tab.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ContextTab } from '../context-tab'; + +describe('ContextTab', () => { + const defaultProps = { + repositories: [] as { + url: string; + name?: string; + branch?: string; + branches?: string[]; + currentActiveBranch?: string; + defaultBranch?: string; + status?: 'Cloning' | 'Ready' | 'Failed' | 'Removing'; + }[], + uploadedFiles: [] as { name: string; path: string; size?: number }[], + onAddRepository: vi.fn(), + onUploadFile: vi.fn(), + onRemoveRepository: vi.fn(), + onRemoveFile: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders empty state when no repos or files', () => { + render(); + expect(screen.getByText('No repositories added')).toBeDefined(); + expect(screen.getByText('No files uploaded')).toBeDefined(); + }); + + it('renders Add button in header', () => { + render(); + expect(screen.getByText('Add')).toBeDefined(); + }); + + it('renders repository items', () => { + const repos = [ + { url: 'https://github.com/org/my-repo.git', name: 'my-repo', branch: 'main' }, + { url: 'https://github.com/org/other-repo.git', name: 'other-repo', branch: 'dev' }, + ]; + render(); + expect(screen.getByText('my-repo')).toBeDefined(); + expect(screen.getByText('other-repo')).toBeDefined(); + }); + + it('renders uploaded file items', () => { + const files = [ + { name: 'readme.txt', path: '/uploads/readme.txt', size: 1024 }, + { name: 'data.csv', path: '/uploads/data.csv', size: 2048 }, + ]; + render(); + expect(screen.getByText('readme.txt')).toBeDefined(); + expect(screen.getByText('data.csv')).toBeDefined(); + }); + + it('shows repo branch badge', () => { + const repos = [ + { url: 'https://github.com/org/repo.git', name: 'repo', branch: 'feature-branch' }, + ]; + render(); + expect(screen.getByText('feature-branch')).toBeDefined(); + }); +}); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/__tests__/explorer-panel.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/__tests__/explorer-panel.test.tsx new file mode 100644 index 000000000..8f79ce2e2 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/__tests__/explorer-panel.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ExplorerPanel } from '../explorer-panel'; + +vi.mock('../files-tab', () => ({ + FilesTab: () =>
Files Content
, +})); + +vi.mock('../context-tab', () => ({ + ContextTab: () =>
Context Content
, +})); + +describe('ExplorerPanel', () => { + const defaultProps = { + visible: true, + activeTab: 'files' as const, + onTabChange: vi.fn(), + onClose: vi.fn(), + // Files tab props + directoryOptions: [], + selectedDirectory: { type: 'artifacts' as const, name: 'Shared Artifacts', path: 'artifacts' }, + onDirectoryChange: vi.fn(), + files: [], + currentSubPath: '', + viewingFile: null, + isLoadingFile: false, + onFileOrFolderSelect: vi.fn(), + onNavigateBack: vi.fn(), + onRefresh: vi.fn(), + onDownloadFile: vi.fn(), + onUploadFile: vi.fn(), + // Context tab props + repositories: [], + uploadedFiles: [], + onAddContext: vi.fn(), + onRemoveRepository: vi.fn(), + onRemoveFile: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders content when visible is false (parent controls visibility via CSS)', () => { + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + }); + + it('renders Files and Context tab buttons', () => { + render(); + expect(screen.getByText('Files')).toBeDefined(); + expect(screen.getByText('Context')).toBeDefined(); + }); + + it('shows FilesTab when activeTab is "files"', () => { + render(); + expect(screen.getByTestId('files-tab')).toBeDefined(); + expect(screen.queryByTestId('context-tab')).toBeNull(); + }); + + it('shows ContextTab when activeTab is "context"', () => { + render(); + expect(screen.getByTestId('context-tab')).toBeDefined(); + expect(screen.queryByTestId('files-tab')).toBeNull(); + }); + + it('calls onTabChange when tab clicked', () => { + render(); + fireEvent.click(screen.getByText('Context')); + expect(defaultProps.onTabChange).toHaveBeenCalledWith('context'); + }); + + it('calls onClose when close button clicked', () => { + render(); + // The close button is the one with the X icon, last button in the header + const buttons = screen.getAllByRole('button'); + // Close button is the last button in the tab header area + const closeButton = buttons[buttons.length - 1]; + fireEvent.click(closeButton); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/context-tab.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/context-tab.tsx new file mode 100644 index 000000000..43644287d --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/context-tab.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useState } from "react"; +import { + GitBranch, + X, + Loader2, + CloudUpload, + ChevronDown, + ChevronRight, + AlertTriangle, + Plus, + Upload, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { Repository, UploadedFile } from "../../lib/types"; + +type ContextTabProps = { + repositories?: Repository[]; + uploadedFiles?: UploadedFile[]; + onAddRepository: () => void; + onUploadFile: () => void; + onRemoveRepository: (repoName: string) => void; + onRemoveFile?: (fileName: string) => void; +}; + +export function ContextTab({ + repositories = [], + uploadedFiles = [], + onAddRepository, + onUploadFile, + onRemoveRepository, + onRemoveFile, +}: ContextTabProps) { + const [removingRepo, setRemovingRepo] = useState(null); + const [removingFile, setRemovingFile] = useState(null); + const [expandedRepos, setExpandedRepos] = useState>(new Set()); + + const handleRemoveRepo = async (repoName: string) => { + if (confirm(`Remove repository ${repoName}?`)) { + setRemovingRepo(repoName); + try { + await onRemoveRepository(repoName); + } finally { + setRemovingRepo(null); + } + } + }; + + const handleRemoveFile = async (fileName: string) => { + if (!onRemoveFile) return; + if (confirm(`Remove file ${fileName}?`)) { + setRemovingFile(fileName); + try { + await onRemoveFile(fileName); + } finally { + setRemovingFile(null); + } + } + }; + + return ( +
+ {/* Repositories section */} +
+
+
+

Repositories

+

+ Git repositories cloned into this session. +

+
+ +
+ +
+ {repositories.length === 0 ? ( +
+
+ +
+

+ No repositories added +

+ +
+ ) : ( +
+ {repositories.map((repo, idx) => { + const repoName = + repo.name || + repo.url.split("/").pop()?.replace(".git", "") || + `repo-${idx}`; + const isRemoving = removingRepo === repoName; + const isExpanded = expandedRepos.has(repoName); + const currentBranch = + repo.currentActiveBranch || repo.branch; + const hasBranches = + repo.branches && repo.branches.length > 0; + + const toggleExpanded = () => { + setExpandedRepos((prev) => { + const next = new Set(prev); + if (next.has(repoName)) { + next.delete(repoName); + } else { + next.add(repoName); + } + return next; + }); + }; + + return ( +
+
+ {hasBranches ? ( + + ) : ( +
+ )} + +
+
+
+ {repoName} +
+ {repo.status === "Cloning" ? ( + + + Cloning... + + ) : repo.status === "Removing" ? ( + + + Removing... + + ) : repo.status === "Failed" ? ( + + + Clone failed + + ) : currentBranch ? ( + + {currentBranch} + + ) : null} +
+
+ +
+ + {isExpanded && hasBranches && ( +
+
+ Available branches: +
+ {repo.branches!.map((branch, branchIdx) => ( +
+ + {branch} + {branch === currentBranch && ( + + active + + )} +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+
+ + {/* Uploads section */} +
+
+
+

Uploads

+

+ Files uploaded to the workspace. +

+
+ +
+ +
+ {uploadedFiles.length === 0 ? ( +
+
+ +
+

+ No files uploaded +

+ +
+ ) : ( +
+ {uploadedFiles.map((file, idx) => { + const isRemoving = removingFile === file.name; + const fileSizeKB = file.size + ? (file.size / 1024).toFixed(1) + : null; + + return ( +
+ +
+
+ {file.name} +
+ {fileSizeKB && ( +
+ {fileSizeKB} KB +
+ )} +
+ {onRemoveFile && ( + + )} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/explorer-panel.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/explorer-panel.tsx new file mode 100644 index 000000000..bf3c4b670 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/explorer-panel.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { X, FolderOpen, Link } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { FilesTab } from "./files-tab"; +import { ContextTab } from "./context-tab"; +import type { FileTreeNode } from "@/components/file-tree"; +import type { DirectoryOption, Repository, UploadedFile, GitStatusSummary } from "../../lib/types"; +import type { WorkspaceItem } from "@/services/api/workspace"; + +type ExplorerPanelProps = { + visible?: boolean; + activeTab: "files" | "context"; + onTabChange: (tab: "files" | "context") => void; + onClose: () => void; + // Files tab props + directoryOptions: DirectoryOption[]; + selectedDirectory: DirectoryOption; + onDirectoryChange: (option: DirectoryOption) => void; + files: WorkspaceItem[]; + currentSubPath: string; + viewingFile: { path: string; content: string } | null; + isLoadingFile: boolean; + onFileOrFolderSelect: (node: FileTreeNode) => void; + onNavigateBack: () => void; + onRefresh: () => void; + onDownloadFile: () => void; + onUploadFile: () => void; + onFileOpen?: (filePath: string) => void; + gitStatus?: GitStatusSummary; + repoBranches?: Record; + // Context tab props + repositories?: Repository[]; + uploadedFiles?: UploadedFile[]; + onAddRepository: () => void; + onRemoveRepository: (repoName: string) => void; + onRemoveFile?: (fileName: string) => void; +}; + +export function ExplorerPanel({ + activeTab, + onTabChange, + onClose, + // Files tab + directoryOptions, + selectedDirectory, + onDirectoryChange, + files, + currentSubPath, + viewingFile, + isLoadingFile, + onFileOrFolderSelect, + onNavigateBack, + onRefresh, + onDownloadFile, + onUploadFile, + onFileOpen, + gitStatus, + repoBranches, + // Context tab + repositories, + uploadedFiles, + onAddRepository, + onRemoveRepository, + onRemoveFile, +}: ExplorerPanelProps) { + return ( +
+ {/* Tab header */} +
+
+ + +
+ +
+ + {/* Tab content */} +
+ {activeTab === "files" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/files-tab.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/files-tab.tsx new file mode 100644 index 000000000..8103c37d8 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/files-tab.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useMemo } from "react"; +import { + Folder, + FolderTree, + GitBranch, + Sparkles, + CloudUpload, + FolderSync, + Download, + Loader2, + Upload, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FileTree, type FileTreeNode } from "@/components/file-tree"; +import type { DirectoryOption, GitStatusSummary } from "../../lib/types"; +import type { WorkspaceItem } from "@/services/api/workspace"; + +type FilesTabProps = { + directoryOptions: DirectoryOption[]; + selectedDirectory: DirectoryOption; + onDirectoryChange: (option: DirectoryOption) => void; + files: WorkspaceItem[]; + currentSubPath: string; + viewingFile: { path: string; content: string } | null; + isLoadingFile: boolean; + onFileOrFolderSelect: (node: FileTreeNode) => void; + onNavigateBack: () => void; + onRefresh: () => void; + onDownloadFile: () => void; + onUploadFile: () => void; + onFileOpen?: (filePath: string) => void; + gitStatus?: GitStatusSummary; + repoBranches?: Record; +}; + +export function FilesTab({ + directoryOptions, + selectedDirectory, + onDirectoryChange, + files, + currentSubPath, + viewingFile, + isLoadingFile, + onFileOrFolderSelect, + onNavigateBack, + onRefresh, + onDownloadFile, + onUploadFile, + onFileOpen, + gitStatus, + repoBranches, +}: FilesTabProps) { + const fileNodes = useMemo( + () => + files.map( + (item): FileTreeNode => ({ + name: item.name, + path: item.path, + type: item.isDir ? "folder" : "file", + sizeKb: item.size ? item.size / 1024 : undefined, + }), + ), + [files], + ); + + const handleSelect = (node: FileTreeNode) => { + if (node.type === "file" && onFileOpen) { + const fullPath = currentSubPath + ? `${selectedDirectory.path}/${currentSubPath}/${node.name}` + : `${selectedDirectory.path}/${node.name}`; + onFileOpen(fullPath); + } else { + onFileOrFolderSelect(node); + } + }; + + return ( +
+ {/* Directory selector */} +
+ +
+ + {/* Action bar */} +
+
+ {(currentSubPath || viewingFile) && ( + + )} + + + {selectedDirectory.path} + {currentSubPath && `/${currentSubPath}`} + {viewingFile && `/${viewingFile.path}`} + +
+ +
+ {viewingFile ? ( + + ) : ( + <> + + + + )} +
+
+ + {/* Git status badges */} + {gitStatus?.hasChanges && ( +
+ {gitStatus.totalAdded > 0 && ( + + +{gitStatus.totalAdded} + + )} + {gitStatus.totalRemoved > 0 && ( + + -{gitStatus.totalRemoved} + + )} +
+ )} + + {/* File tree */} +
+ {isLoadingFile ? ( +
+ +
+ ) : viewingFile ? ( +
+
+              {viewingFile.content}
+            
+
+ ) : files.length === 0 ? ( +
+ +

No files yet

+

Files will appear here

+
+ ) : ( + + )} +
+
+ ); +} diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/file-viewer.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/file-viewer.tsx new file mode 100644 index 000000000..e2ddc4c16 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/file-viewer.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Download, AlertCircle } from "lucide-react"; +import { useWorkspaceFile } from "@/services/queries/use-workspace"; +import { triggerDownload } from "@/utils/export-chat"; +import { cn } from "@/lib/utils"; +import hljs from "highlight.js"; + +type FileViewerProps = { + projectName: string; + sessionName: string; + filePath: string; +}; + +const EXTENSION_TO_LANGUAGE: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + py: "python", + go: "go", + rs: "rust", + rb: "ruby", + java: "java", + kt: "kotlin", + sql: "sql", + sh: "bash", + bash: "bash", + zsh: "bash", + yml: "yaml", + yaml: "yaml", + json: "json", + md: "markdown", + css: "css", + scss: "scss", + html: "html", + xml: "xml", + toml: "toml", + dockerfile: "dockerfile", + makefile: "makefile", + tf: "hcl", + proto: "protobuf", +}; + +function getLanguage(filePath: string): string { + const fileName = filePath.split("/").pop() ?? ""; + const lowerName = fileName.toLowerCase(); + + // Handle special filenames + if (lowerName === "dockerfile") return "dockerfile"; + if (lowerName === "makefile" || lowerName === "gnumakefile") return "makefile"; + + const ext = fileName.split(".").pop()?.toLowerCase() ?? ""; + return EXTENSION_TO_LANGUAGE[ext] ?? ""; +} + +export function FileViewer({ + projectName, + sessionName, + filePath, +}: FileViewerProps) { + const codeRef = useRef(null); + const { + data: content, + isLoading, + error, + } = useWorkspaceFile(projectName, sessionName, filePath); + + const { language, languageLabel } = useMemo(() => { + const lang = getLanguage(filePath); + const label = lang || (filePath.split(".").pop()?.toLowerCase() ?? "text"); + return { language: lang, languageLabel: label }; + }, [filePath]); + + useEffect(() => { + if (codeRef.current && content !== undefined) { + // Reset previous highlighting + codeRef.current.removeAttribute("data-highlighted"); + if (language) { + codeRef.current.className = `language-${language}`; + } else { + codeRef.current.className = ""; + } + hljs.highlightElement(codeRef.current); + } + }, [content, language]); + + const handleDownload = () => { + if (!content) return; + const fileName = filePath.split("/").pop() ?? "file"; + triggerDownload(content, fileName, "text/plain"); + }; + + if (isLoading) { + return ( +
+ + +
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (error) { + return ( +
+ +

Failed to load file

+

+ {error instanceof Error ? error.message : "Unknown error"} +

+
+ ); + } + + const lines = content?.split("\n") ?? []; + + return ( +
+ {/* File header */} +
+
+ + {filePath} + + + {languageLabel} + +
+ +
+ + {/* Code content */} +
+
+ {/* Line numbers */} +
+ {lines.map((_, i) => ( +
+ {i + 1} +
+ ))} +
+ + {/* Code */} +
+            
+              {content}
+            
+          
+
+
+
+ ); +} diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx index 202b0289a..da4982cda 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx @@ -1,14 +1,12 @@ "use client"; import { useState } from "react"; -import { Loader2, Info, Upload } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Separator } from "@/components/ui/separator"; import { InputWithHistory } from "@/components/input-with-history"; import { useInputHistory } from "@/hooks/use-input-history"; @@ -16,7 +14,6 @@ type AddContextModalProps = { open: boolean; onOpenChange: (open: boolean) => void; onAddRepository: (url: string, branch: string, autoPush?: boolean) => Promise; - onUploadFile?: () => void; isLoading?: boolean; autoBranch?: string; // Auto-generated branch from backend (single source of truth) }; @@ -25,7 +22,6 @@ export function AddContextModal({ open, onOpenChange, onAddRepository, - onUploadFile, isLoading = false, autoBranch, }: AddContextModalProps) { @@ -64,20 +60,13 @@ export function AddContextModal({ - Add Context + Add Repository - Add additional context to improve AI responses. + Add a repository to your workspace for code context.
- - - - Note: additional data sources like Jira, Google Drive, files, and MCP Servers are on the roadmap! - - -
- {onUploadFile && ( - <> - -
- -

- Upload files directly to your workspace for use as context -

- -
- - )}
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx new file mode 100644 index 000000000..bc0e04a5e --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { MessageSquarePlus, ArrowUp, Loader2, Plus, GitBranch, Upload, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { RunnerModelSelector, getDefaultModel } from "./runner-model-selector"; +import { WorkflowSelector } from "./workflow-selector"; +import { AddContextModal } from "./modals/add-context-modal"; +import { useRunnerTypes } from "@/services/queries/use-runner-types"; +import { DEFAULT_RUNNER_TYPE_ID } from "@/services/api/runner-types"; +import type { WorkflowConfig } from "../lib/types"; + +type PendingRepo = { + url: string; + name: string; +}; + +type NewSessionViewProps = { + projectName: string; + onCreateSession: (config: { + prompt: string; + runner: string; + model: string; + workflow?: string; + repos?: Array<{ url: string }>; + }) => void; + ootbWorkflows: WorkflowConfig[]; + onLoadCustomWorkflow?: () => void; + isSubmitting?: boolean; +}; + +export function NewSessionView({ + projectName, + onCreateSession, + ootbWorkflows, + onLoadCustomWorkflow, + isSubmitting = false, +}: NewSessionViewProps) { + const { data: runnerTypes } = useRunnerTypes(projectName); + + const [prompt, setPrompt] = useState(""); + const [selectedRunner, setSelectedRunner] = useState(DEFAULT_RUNNER_TYPE_ID); + const [selectedModel, setSelectedModel] = useState(() => + getDefaultModel(DEFAULT_RUNNER_TYPE_ID) + ); + + // Once runner types load, default to the first available if current selection isn't available + useEffect(() => { + if (runnerTypes && runnerTypes.length > 0) { + const isCurrentAvailable = runnerTypes.some((r) => r.id === selectedRunner); + if (!isCurrentAvailable) { + const firstRunner = runnerTypes[0].id; + setSelectedRunner(firstRunner); + setSelectedModel(getDefaultModel(firstRunner)); + } + } + }, [runnerTypes, selectedRunner]); + const [selectedWorkflow, setSelectedWorkflow] = useState("none"); + const [pendingRepos, setPendingRepos] = useState([]); + const [contextModalOpen, setContextModalOpen] = useState(false); + const textareaRef = useRef(null); + + const addPendingRepo = (url: string) => { + if (pendingRepos.some((r) => r.url === url)) return; + const name = url.replace(/\/+$/, "").split("/").pop()?.replace(/\.git$/, "") || url; + setPendingRepos((prev) => [...prev, { url, name }]); + }; + + const removePendingRepo = (url: string) => { + setPendingRepos((prev) => prev.filter((r) => r.url !== url)); + }; + + const handleSubmit = useCallback(() => { + const trimmed = prompt.trim(); + if (!trimmed) return; + + onCreateSession({ + prompt: trimmed, + runner: selectedRunner, + model: selectedModel, + workflow: selectedWorkflow !== "none" ? selectedWorkflow : undefined, + repos: pendingRepos.length > 0 ? pendingRepos.map((r) => ({ url: r.url })) : undefined, + }); + }, [prompt, selectedRunner, selectedModel, selectedWorkflow, pendingRepos, onCreateSession]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleRunnerModelSelect = (runner: string, model: string) => { + setSelectedRunner(runner); + setSelectedModel(model); + }; + + return ( +
+
+ {/* Header */} +
+
+ +
+

+ What are you working on? +

+

+ Start a new session by typing a message. +

+
+ + {/* Input area */} +
+