Please wait while the workspace is loading...
-
+ 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..1ebccb7f5
--- /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: () => mockUseRunnerTypes(),
+}));
+
+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..d28c82a7b
--- /dev/null
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/sessions-sidebar.test.tsx
@@ -0,0 +1,175 @@
+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}
+ ),
+ sessionPhaseLabel: (phase: string) => phase || 'Unknown',
+}));
+
+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..114c869ff
--- /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: [],
+ onAddRepository: 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..470dc5cb5
--- /dev/null
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/context-tab.tsx
@@ -0,0 +1,311 @@
+"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) => {
+ 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..80731798a
--- /dev/null
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/explorer/explorer-panel.tsx
@@ -0,0 +1,143 @@
+"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..ed2d9efcf
--- /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.path}`
+ : `${selectedDirectory.path}/${node.path}`;
+ 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({