diff --git a/frontend/src/App.css b/frontend/src/App.css
deleted file mode 100644
index f90339d..0000000
--- a/frontend/src/App.css
+++ /dev/null
@@ -1,184 +0,0 @@
-.counter {
- font-size: 16px;
- padding: 5px 10px;
- border-radius: 5px;
- color: var(--accent);
- background: var(--accent-bg);
- border: 2px solid transparent;
- transition: border-color 0.3s;
- margin-bottom: 24px;
-
- &:hover {
- border-color: var(--accent-border);
- }
- &:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
- }
-}
-
-.hero {
- position: relative;
-
- .base,
- .framework,
- .vite {
- inset-inline: 0;
- margin: 0 auto;
- }
-
- .base {
- width: 170px;
- position: relative;
- z-index: 0;
- }
-
- .framework,
- .vite {
- position: absolute;
- }
-
- .framework {
- z-index: 1;
- top: 34px;
- height: 28px;
- transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
- scale(1.4);
- }
-
- .vite {
- z-index: 0;
- top: 107px;
- height: 26px;
- width: auto;
- transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
- scale(0.8);
- }
-}
-
-#center {
- display: flex;
- flex-direction: column;
- gap: 25px;
- place-content: center;
- place-items: center;
- flex-grow: 1;
-
- @media (max-width: 1024px) {
- padding: 32px 20px 24px;
- gap: 18px;
- }
-}
-
-#next-steps {
- display: flex;
- border-top: 1px solid var(--border);
- text-align: left;
-
- & > div {
- flex: 1 1 0;
- padding: 32px;
- @media (max-width: 1024px) {
- padding: 24px 20px;
- }
- }
-
- .icon {
- margin-bottom: 16px;
- width: 22px;
- height: 22px;
- }
-
- @media (max-width: 1024px) {
- flex-direction: column;
- text-align: center;
- }
-}
-
-#docs {
- border-right: 1px solid var(--border);
-
- @media (max-width: 1024px) {
- border-right: none;
- border-bottom: 1px solid var(--border);
- }
-}
-
-#next-steps ul {
- list-style: none;
- padding: 0;
- display: flex;
- gap: 8px;
- margin: 32px 0 0;
-
- .logo {
- height: 18px;
- }
-
- a {
- color: var(--text-h);
- font-size: 16px;
- border-radius: 6px;
- background: var(--social-bg);
- display: flex;
- padding: 6px 12px;
- align-items: center;
- gap: 8px;
- text-decoration: none;
- transition: box-shadow 0.3s;
-
- &:hover {
- box-shadow: var(--shadow);
- }
- .button-icon {
- height: 18px;
- width: 18px;
- }
- }
-
- @media (max-width: 1024px) {
- margin-top: 20px;
- flex-wrap: wrap;
- justify-content: center;
-
- li {
- flex: 1 1 calc(50% - 8px);
- }
-
- a {
- width: 100%;
- justify-content: center;
- box-sizing: border-box;
- }
- }
-}
-
-#spacer {
- height: 88px;
- border-top: 1px solid var(--border);
- @media (max-width: 1024px) {
- height: 48px;
- }
-}
-
-.ticks {
- position: relative;
- width: 100%;
-
- &::before,
- &::after {
- content: '';
- position: absolute;
- top: -4.5px;
- border: 5px solid transparent;
- }
-
- &::before {
- left: 0;
- border-left-color: var(--border);
- }
- &::after {
- right: 0;
- border-right-color: var(--border);
- }
-}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a66b5ef..fb5dd5f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,121 +1,48 @@
-import { useState } from 'react'
-import reactLogo from './assets/react.svg'
-import viteLogo from './assets/vite.svg'
-import heroImg from './assets/hero.png'
-import './App.css'
+import { useAuth } from './hooks/useAuth'
+import { useSessions } from './hooks/useSessions'
+import { useChat } from './hooks/useChat'
+import { LoginPage } from './components/LoginPage'
+import { SessionSidebar } from './components/SessionSidebar'
+import { ChatPanel } from './components/ChatPanel'
+import { RightPanel } from './components/RightPanel'
function App() {
- const [count, setCount] = useState(0)
+ const { user, loading: authLoading, login, logout } = useAuth()
+ const { sessions, activeSessionId, createNew, switchTo, refresh } = useSessions()
+ const { messages, isStreaming, sendMessage } = useChat(activeSessionId)
+
+ if (authLoading) {
+ return (
+
+ )
+ }
+
+ if (!user) {
+ return
+ }
return (
- <>
-
-
-
-
Get started
-
- Edit src/App.tsx and save to test HMR
-
-
-
-
-
-
-
-
-
-
-
Documentation
-
Your questions, answered
-
-
-
-
-
Connect with us
-
Join the Vite community
-
-
-
-
-
-
- >
+
+ switchTo(id)}
+ onCreate={async () => {
+ await createNew()
+ await refresh()
+ }}
+ onLogout={logout}
+ />
+
+
+
)
}
diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/frontend/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx
index fb249b1..a5ea3c9 100644
--- a/frontend/src/components/LoginPage.tsx
+++ b/frontend/src/components/LoginPage.tsx
@@ -1,4 +1,5 @@
-import { useState, FormEvent } from 'react'
+import { useState } from 'react'
+import type { FormEvent } from 'react'
interface LoginPageProps {
onLogin: (email: string, password: string) => Promise
diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx
new file mode 100644
index 0000000..32635cb
--- /dev/null
+++ b/frontend/src/components/RightPanel.tsx
@@ -0,0 +1,41 @@
+import { useState } from 'react'
+
+interface RightPanelProps {
+ sessionId: string | null
+}
+
+export function RightPanel({ sessionId }: RightPanelProps) {
+ const [activeTab, setActiveTab] = useState<'browser' | 'artifacts'>('browser')
+
+ function renderContent() {
+ if (!sessionId) {
+ return 'Select a session to view the browser/artifacts'
+ }
+ if (activeTab === 'browser') {
+ return 'Browser panel (noVNC) — implemented in Phase 3'
+ }
+ return 'Artifacts panel — implemented in Phase 3'
+ }
+
+ return (
+
+
+
+
+
+
+ {renderContent()}
+
+
+ )
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
deleted file mode 100644
index 5fb3313..0000000
--- a/frontend/src/index.css
+++ /dev/null
@@ -1,111 +0,0 @@
-:root {
- --text: #6b6375;
- --text-h: #08060d;
- --bg: #fff;
- --border: #e5e4e7;
- --code-bg: #f4f3ec;
- --accent: #aa3bff;
- --accent-bg: rgba(170, 59, 255, 0.1);
- --accent-border: rgba(170, 59, 255, 0.5);
- --social-bg: rgba(244, 243, 236, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
-
- --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
- --mono: ui-monospace, Consolas, monospace;
-
- font: 18px/145% var(--sans);
- letter-spacing: 0.18px;
- color-scheme: light dark;
- color: var(--text);
- background: var(--bg);
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-
- @media (max-width: 1024px) {
- font-size: 16px;
- }
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --text: #9ca3af;
- --text-h: #f3f4f6;
- --bg: #16171d;
- --border: #2e303a;
- --code-bg: #1f2028;
- --accent: #c084fc;
- --accent-bg: rgba(192, 132, 252, 0.15);
- --accent-border: rgba(192, 132, 252, 0.5);
- --social-bg: rgba(47, 48, 58, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
- }
-
- #social .button-icon {
- filter: invert(1) brightness(2);
- }
-}
-
-#root {
- width: 1126px;
- max-width: 100%;
- margin: 0 auto;
- text-align: center;
- border-inline: 1px solid var(--border);
- min-height: 100svh;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
-}
-
-h1,
-h2 {
- font-family: var(--heading);
- font-weight: 500;
- color: var(--text-h);
-}
-
-h1 {
- font-size: 56px;
- letter-spacing: -1.68px;
- margin: 32px 0;
- @media (max-width: 1024px) {
- font-size: 36px;
- margin: 20px 0;
- }
-}
-h2 {
- font-size: 24px;
- line-height: 118%;
- letter-spacing: -0.24px;
- margin: 0 0 8px;
- @media (max-width: 1024px) {
- font-size: 20px;
- }
-}
-p {
- margin: 0;
-}
-
-code,
-.counter {
- font-family: var(--mono);
- display: inline-flex;
- border-radius: 4px;
- color: var(--text-h);
-}
-
-code {
- font-size: 15px;
- line-height: 135%;
- padding: 4px 8px;
- background: var(--code-bg);
-}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index bef5202..6e5b0a2 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,7 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
+import App from './App'
+import './styles/globals.css'
createRoot(document.getElementById('root')!).render(
diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx
new file mode 100644
index 0000000..ac873fd
--- /dev/null
+++ b/frontend/src/test/App.test.tsx
@@ -0,0 +1,103 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import App from '../App'
+
+// Mock the hooks to control rendering state
+vi.mock('../hooks/useAuth', () => ({
+ useAuth: vi.fn(),
+}))
+
+vi.mock('../hooks/useSessions', () => ({
+ useSessions: vi.fn(),
+}))
+
+vi.mock('../hooks/useChat', () => ({
+ useChat: vi.fn(),
+}))
+
+import { useAuth } from '../hooks/useAuth'
+import { useSessions } from '../hooks/useSessions'
+import { useChat } from '../hooks/useChat'
+
+const mockUseAuth = vi.mocked(useAuth)
+const mockUseSessions = vi.mocked(useSessions)
+const mockUseChat = vi.mocked(useChat)
+
+function setupMocks({
+ loading = false,
+ user = null as { email: string } | null,
+} = {}) {
+ mockUseAuth.mockReturnValue({
+ user,
+ loading,
+ login: vi.fn(),
+ logout: vi.fn(),
+ })
+
+ mockUseSessions.mockReturnValue({
+ sessions: [],
+ activeSession: undefined,
+ activeSessionId: null,
+ loading: false,
+ createNew: vi.fn(),
+ switchTo: vi.fn(),
+ refresh: vi.fn(),
+ })
+
+ mockUseChat.mockReturnValue({
+ messages: [],
+ isStreaming: false,
+ sendMessage: vi.fn(),
+ cancel: vi.fn(),
+ clearMessages: vi.fn(),
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('App', () => {
+ it('renders Loading state when authLoading is true', () => {
+ setupMocks({ loading: true, user: null })
+ const { container } = render()
+ expect(container.querySelector('.login-page')).toBeTruthy()
+ expect(screen.getByText('Loading...')).toBeTruthy()
+ })
+
+ it('renders LoginPage when user is null and not loading', () => {
+ setupMocks({ loading: false, user: null })
+ render()
+ expect(screen.getByRole('heading', { name: 'Research Workbench' })).toBeTruthy()
+ })
+
+ it('renders app-layout with 3 columns when user is logged in', () => {
+ setupMocks({ loading: false, user: { email: 'user@example.com' } })
+ const { container } = render()
+ expect(container.querySelector('.app-layout')).toBeTruthy()
+ })
+
+ it('renders SessionSidebar when logged in', () => {
+ setupMocks({ loading: false, user: { email: 'user@example.com' } })
+ const { container } = render()
+ expect(container.querySelector('.sidebar')).toBeTruthy()
+ })
+
+ it('renders ChatPanel when logged in', () => {
+ setupMocks({ loading: false, user: { email: 'user@example.com' } })
+ const { container } = render()
+ expect(container.querySelector('.chat-panel')).toBeTruthy()
+ })
+
+ it('renders RightPanel when logged in', () => {
+ setupMocks({ loading: false, user: { email: 'user@example.com' } })
+ const { container } = render()
+ expect(container.querySelector('.right-panel')).toBeTruthy()
+ })
+
+ it('does not show login form when user is logged in', () => {
+ setupMocks({ loading: false, user: { email: 'user@example.com' } })
+ render()
+ expect(screen.queryByRole('heading', { name: 'Research Workbench' })).toBeNull()
+ })
+})
diff --git a/frontend/src/test/RightPanel.test.tsx b/frontend/src/test/RightPanel.test.tsx
new file mode 100644
index 0000000..e7f2301
--- /dev/null
+++ b/frontend/src/test/RightPanel.test.tsx
@@ -0,0 +1,82 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { RightPanel } from '../components/RightPanel'
+
+describe('RightPanel', () => {
+ it('renders .right-panel container', () => {
+ const { container } = render()
+ expect(container.querySelector('.right-panel')).toBeTruthy()
+ })
+
+ it('renders .right-panel-tabs with Browser and Artifacts buttons', () => {
+ const { container } = render()
+ expect(container.querySelector('.right-panel-tabs')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Browser' })).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Artifacts' })).toBeTruthy()
+ })
+
+ it('renders .right-panel-content', () => {
+ const { container } = render()
+ expect(container.querySelector('.right-panel-content')).toBeTruthy()
+ })
+
+ it('Browser tab is active by default', () => {
+ render()
+ const browserBtn = screen.getByRole('button', { name: 'Browser' })
+ expect(browserBtn.classList.contains('active')).toBe(true)
+ })
+
+ it('Artifacts tab is not active by default', () => {
+ render()
+ const artifactsBtn = screen.getByRole('button', { name: 'Artifacts' })
+ expect(artifactsBtn.classList.contains('active')).toBe(false)
+ })
+
+ it('shows "Select a session to view the browser/artifacts" when sessionId is null', () => {
+ render()
+ expect(
+ screen.getByText('Select a session to view the browser/artifacts')
+ ).toBeTruthy()
+ })
+
+ it('shows browser placeholder when sessionId is set and browser tab is active', () => {
+ render()
+ expect(
+ screen.getByText('Browser panel (noVNC) — implemented in Phase 3')
+ ).toBeTruthy()
+ })
+
+ it('shows artifacts placeholder when sessionId is set and artifacts tab is active', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
+ expect(
+ screen.getByText('Artifacts panel — implemented in Phase 3')
+ ).toBeTruthy()
+ })
+
+ it('switches active class to Artifacts tab when clicked', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
+ const artifactsBtn = screen.getByRole('button', { name: 'Artifacts' })
+ const browserBtn = screen.getByRole('button', { name: 'Browser' })
+ expect(artifactsBtn.classList.contains('active')).toBe(true)
+ expect(browserBtn.classList.contains('active')).toBe(false)
+ })
+
+ it('switches back to browser tab when Browser button clicked', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Browser' }))
+ const browserBtn = screen.getByRole('button', { name: 'Browser' })
+ expect(browserBtn.classList.contains('active')).toBe(true)
+ })
+
+ it('still shows no-session placeholder on artifacts tab when sessionId is null', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
+ expect(
+ screen.getByText('Select a session to view the browser/artifacts')
+ ).toBeTruthy()
+ })
+})
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 450add8..3caffa4 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,5 +1,4 @@
-///
-import { defineConfig } from 'vite'
+import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-