From fcf21b270696c67e713487cdf55168d2b8840a4c Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 11 Mar 2026 15:00:35 +0200 Subject: [PATCH 001/303] * Small chat hotfix --- apps/chat/src/app/pages/chat-page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/chat/src/app/pages/chat-page.tsx b/apps/chat/src/app/pages/chat-page.tsx index 496051b..975bfbd 100644 --- a/apps/chat/src/app/pages/chat-page.tsx +++ b/apps/chat/src/app/pages/chat-page.tsx @@ -178,7 +178,8 @@ export function ChatPage() { const handlePaste = useCallback( (e: React.ClipboardEvent) => { - const item = e.clipboardData?.items?.find((it) => it.type.startsWith('image/')); + const items = e.clipboardData?.items; + const item = items ? Array.from(items).find((it) => it.type.startsWith('image/')) : undefined; if (!item || pendingImages.length >= MAX_PENDING_IMAGES) return; const file = item.getAsFile(); if (!file) return; From 8b801965bb4739a39ae4a05bcb1b9784d5770151 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 11 Mar 2026 15:09:08 +0200 Subject: [PATCH 002/303] feat(api): add stored-agent credential injection, CI on dev, env docs --- .env.example | 6 +- .github/workflows/ci.yml | 4 + .../strategy-registry.service.test.ts | 5 +- .../strategies/strategy-registry.service.ts | 1 - apps/api/src/credential-injector.test.ts | 100 ++++++++++++++++++ apps/api/src/credential-injector.ts | 75 +++++++++++++ apps/api/src/main.ts | 6 ++ 7 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/credential-injector.test.ts create mode 100644 apps/api/src/credential-injector.ts diff --git a/.env.example b/.env.example index 5d8478e..9f4f462 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,12 @@ MODEL_OPTIONS=flash-lite,flash,pro DATA_DIR= SYSTEM_PROMPT_PATH= -# Optional: Gemini strategy (e.g. in Docker) +# Optional: provider session dir (e.g. in Docker) # SESSION_DIR=/home/node/.gemini +# Stored Agent credentials (injected by Phoenix when attaching a stored Agent) +# AGENT_CREDENTIALS_JSON={"agent_token.txt":"sk-ant-..."} +# SESSION_DIR must be set when using AGENT_CREDENTIALS_JSON + # Chat (Vite) – optional; only if API is on another origin # VITE_API_URL=http://localhost:3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3976bfa..1df1afa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,11 @@ on: push: branches: - main + - dev pull_request: + branches: + - main + - dev permissions: actions: read diff --git a/apps/api/src/app/strategies/strategy-registry.service.test.ts b/apps/api/src/app/strategies/strategy-registry.service.test.ts index 53e1055..dc96c6a 100644 --- a/apps/api/src/app/strategies/strategy-registry.service.test.ts +++ b/apps/api/src/app/strategies/strategy-registry.service.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test'; import { StrategyRegistryService } from './strategy-registry.service'; import { MockStrategy } from './mock.strategy'; +import { ClaudeCodeStrategy } from './claude-code.strategy'; describe('StrategyRegistryService', () => { const envBackup = process.env.AGENT_PROVIDER; @@ -15,10 +16,10 @@ describe('StrategyRegistryService', () => { expect(service.resolveStrategy()).toBeInstanceOf(MockStrategy); }); - test('resolveStrategy returns MockStrategy when AGENT_PROVIDER not set', () => { + test('resolveStrategy returns ClaudeCodeStrategy when AGENT_PROVIDER not set', () => { delete process.env.AGENT_PROVIDER; const service = new StrategyRegistryService(); - expect(service.resolveStrategy()).toBeInstanceOf(MockStrategy); + expect(service.resolveStrategy()).toBeInstanceOf(ClaudeCodeStrategy); }); test('resolveStrategy throws for unknown provider', () => { diff --git a/apps/api/src/app/strategies/strategy-registry.service.ts b/apps/api/src/app/strategies/strategy-registry.service.ts index c531611..4df7444 100644 --- a/apps/api/src/app/strategies/strategy-registry.service.ts +++ b/apps/api/src/app/strategies/strategy-registry.service.ts @@ -14,7 +14,6 @@ const PROVIDER_NAMES = [ 'opencodex', ] as const; -// const DEFAULT_PROVIDER = 'mock'; const DEFAULT_PROVIDER = 'claude-code'; @Injectable() diff --git a/apps/api/src/credential-injector.test.ts b/apps/api/src/credential-injector.test.ts new file mode 100644 index 0000000..2f38e80 --- /dev/null +++ b/apps/api/src/credential-injector.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { loadInjectedCredentials } from './credential-injector'; + +describe('loadInjectedCredentials', () => { + const envBackup: Record = {}; + let tempDir: string; + + beforeEach(() => { + envBackup.AGENT_CREDENTIALS_JSON = process.env.AGENT_CREDENTIALS_JSON; + envBackup.SESSION_DIR = process.env.SESSION_DIR; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cred-inject-')); + }); + + afterEach(() => { + process.env.AGENT_CREDENTIALS_JSON = envBackup.AGENT_CREDENTIALS_JSON; + process.env.SESSION_DIR = envBackup.SESSION_DIR; + try { + fs.rmSync(tempDir, { recursive: true }); + } catch { + // ignore + } + }); + + test('returns false when AGENT_CREDENTIALS_JSON is not set', () => { + delete process.env.AGENT_CREDENTIALS_JSON; + process.env.SESSION_DIR = tempDir; + expect(loadInjectedCredentials()).toBe(false); + }); + + test('returns false when AGENT_CREDENTIALS_JSON is empty', () => { + process.env.AGENT_CREDENTIALS_JSON = ''; + process.env.SESSION_DIR = tempDir; + expect(loadInjectedCredentials()).toBe(false); + }); + + test('returns false when SESSION_DIR is not set', () => { + process.env.AGENT_CREDENTIALS_JSON = '{"token.txt":"abc"}'; + delete process.env.SESSION_DIR; + expect(loadInjectedCredentials()).toBe(false); + }); + + test('returns false for invalid JSON', () => { + process.env.AGENT_CREDENTIALS_JSON = 'not-json'; + process.env.SESSION_DIR = tempDir; + expect(loadInjectedCredentials()).toBe(false); + }); + + test('returns false for non-object JSON (array)', () => { + process.env.AGENT_CREDENTIALS_JSON = '["a","b"]'; + process.env.SESSION_DIR = tempDir; + expect(loadInjectedCredentials()).toBe(false); + }); + + test('returns false for empty object', () => { + process.env.AGENT_CREDENTIALS_JSON = '{}'; + process.env.SESSION_DIR = tempDir; + expect(loadInjectedCredentials()).toBe(false); + }); + + test('writes credential file to SESSION_DIR with 0o600', () => { + process.env.AGENT_CREDENTIALS_JSON = '{"agent_token.txt":"sk-ant-123"}'; + process.env.SESSION_DIR = tempDir; + expect(loadInjectedCredentials()).toBe(true); + const filePath = path.join(tempDir, 'agent_token.txt'); + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.readFileSync(filePath, 'utf8')).toBe('sk-ant-123'); + const mode = fs.statSync(filePath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + test('creates SESSION_DIR if it does not exist', () => { + const subDir = path.join(tempDir, 'nested', 'session'); + process.env.AGENT_CREDENTIALS_JSON = '{"auth.json":"{}"}'; + process.env.SESSION_DIR = subDir; + expect(loadInjectedCredentials()).toBe(true); + expect(fs.existsSync(subDir)).toBe(true); + expect(fs.readFileSync(path.join(subDir, 'auth.json'), 'utf8')).toBe('{}'); + }); + + test('writes multiple credential files', () => { + process.env.AGENT_CREDENTIALS_JSON = JSON.stringify({ + 'oauth_creds.json': '{"token":"abc"}', + 'credentials.json': '{"refresh":"xyz"}', + }); + process.env.SESSION_DIR = tempDir; + expect(loadInjectedCredentials()).toBe(true); + expect(fs.readFileSync(path.join(tempDir, 'oauth_creds.json'), 'utf8')).toBe('{"token":"abc"}'); + expect(fs.readFileSync(path.join(tempDir, 'credentials.json'), 'utf8')).toBe('{"refresh":"xyz"}'); + }); + + test('rejects path traversal in filenames', () => { + process.env.AGENT_CREDENTIALS_JSON = '{"../../../etc/passwd":"malicious"}'; + process.env.SESSION_DIR = tempDir; + expect(loadInjectedCredentials()).toBe(false); + expect(fs.readdirSync(tempDir).length).toBe(0); + }); +}); diff --git a/apps/api/src/credential-injector.ts b/apps/api/src/credential-injector.ts new file mode 100644 index 0000000..5bf1d53 --- /dev/null +++ b/apps/api/src/credential-injector.ts @@ -0,0 +1,75 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Loads pre-authenticated credentials from AGENT_CREDENTIALS_JSON. + * Used when Phoenix attaches a stored Agent to a Playground. + * Writes files into SESSION_DIR (e.g. agent_token.txt, oauth_creds.json). + * No-op if env vars are unset or empty. + */ +export function loadInjectedCredentials(): boolean { + const raw = process.env.AGENT_CREDENTIALS_JSON; + if (!raw?.trim()) { + return false; + } + + const sessionDir = process.env.SESSION_DIR; + if (!sessionDir) { + console.warn( + '[CREDENTIALS] AGENT_CREDENTIALS_JSON is set but SESSION_DIR is not. Skipping injection.' + ); + return false; + } + + let credentialFiles: Record; + try { + credentialFiles = JSON.parse(raw) as Record; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[CREDENTIALS] Failed to parse AGENT_CREDENTIALS_JSON:', message); + return false; + } + + if ( + !credentialFiles || + typeof credentialFiles !== 'object' || + Array.isArray(credentialFiles) + ) { + console.error( + '[CREDENTIALS] AGENT_CREDENTIALS_JSON must be a JSON object { filename: content }' + ); + return false; + } + + const entries = Object.entries(credentialFiles); + if (entries.length === 0) { + console.warn('[CREDENTIALS] AGENT_CREDENTIALS_JSON is empty object. Skipping.'); + return false; + } + + if (!fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }); + console.log(`[CREDENTIALS] Created session directory: ${sessionDir}`); + } + + let injectedCount = 0; + for (const [filename, content] of entries) { + const safeName = path.basename(filename); + if (safeName !== filename) { + console.warn(`[CREDENTIALS] Skipping suspicious filename: ${filename}`); + continue; + } + try { + fs.writeFileSync(path.join(sessionDir, safeName), content, { mode: 0o600 }); + injectedCount++; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[CREDENTIALS] Failed to write ${path.join(sessionDir, safeName)}:`, message); + } + } + + if (injectedCount > 0) { + console.log(`[CREDENTIALS] Injected ${injectedCount} credential file(s) from stored Agent.`); + } + return injectedCount > 0; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 9f61a2e..c70b27d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -8,8 +8,14 @@ import { WebSocketServer } from 'ws'; import { AppModule } from './app/app.module'; import { ConfigService } from './app/config/config.service'; import { OrchestratorService } from './app/orchestrator/orchestrator.service'; +import { loadInjectedCredentials } from './credential-injector'; async function bootstrap() { + const injected = loadInjectedCredentials(); + if (injected) { + Logger.log('Stored agent credentials loaded — skipping manual auth.'); + } + const app = await NestFactory.create( AppModule, new FastifyAdapter() From f935bfaf4a22bc31ee611ed1b7f9b40b5d84ab44 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 11 Mar 2026 16:29:55 +0200 Subject: [PATCH 003/303] feat(chat): add playground file explorer, cleanup layout, add unit tests --- apps/api/src/app/app.module.ts | 4 + .../api/src/app/config/config.service.test.ts | 12 + apps/api/src/app/config/config.service.ts | 4 + .../app/playgrounds/playgrounds.controller.ts | 14 + .../playgrounds/playgrounds.service.test.ts | 70 +++++ .../app/playgrounds/playgrounds.service.ts | 56 ++++ .../app/file-explorer/file-explorer.spec.tsx | 116 +++++++++ .../src/app/file-explorer/file-explorer.tsx | 227 ++++++++++++++++ apps/chat/src/app/layout-constants.ts | 1 + apps/chat/src/app/pages/chat-page.tsx | 246 +++++++++--------- docs/API.md | 3 +- 11 files changed, 635 insertions(+), 118 deletions(-) create mode 100644 apps/api/src/app/playgrounds/playgrounds.controller.ts create mode 100644 apps/api/src/app/playgrounds/playgrounds.service.test.ts create mode 100644 apps/api/src/app/playgrounds/playgrounds.service.ts create mode 100644 apps/chat/src/app/file-explorer/file-explorer.spec.tsx create mode 100644 apps/chat/src/app/file-explorer/file-explorer.tsx create mode 100644 apps/chat/src/app/layout-constants.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 15f32d3..c57e9b2 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -12,6 +12,8 @@ import { OrchestratorService } from './orchestrator/orchestrator.service'; import { StrategyRegistryService } from './strategies/strategy-registry.service'; import { UploadsController } from './uploads/uploads.controller'; import { UploadsService } from './uploads/uploads.service'; +import { PlaygroundsController } from './playgrounds/playgrounds.controller'; +import { PlaygroundsService } from './playgrounds/playgrounds.service'; @Module({ imports: [], @@ -21,6 +23,7 @@ import { UploadsService } from './uploads/uploads.service'; MessagesController, ModelOptionsController, UploadsController, + PlaygroundsController, ], providers: [ AppService, @@ -31,6 +34,7 @@ import { UploadsService } from './uploads/uploads.service'; StrategyRegistryService, OrchestratorService, UploadsService, + PlaygroundsService, ], }) export class AppModule {} diff --git a/apps/api/src/app/config/config.service.test.ts b/apps/api/src/app/config/config.service.test.ts index 6acf664..38e775f 100644 --- a/apps/api/src/app/config/config.service.test.ts +++ b/apps/api/src/app/config/config.service.test.ts @@ -10,6 +10,7 @@ describe('ConfigService', () => { envBackup.MODEL_OPTIONS = process.env.MODEL_OPTIONS; envBackup.DATA_DIR = process.env.DATA_DIR; envBackup.SYSTEM_PROMPT_PATH = process.env.SYSTEM_PROMPT_PATH; + envBackup.PLAYGROUNDS_DIR = process.env.PLAYGROUNDS_DIR; }); afterEach(() => { @@ -17,6 +18,7 @@ describe('ConfigService', () => { process.env.MODEL_OPTIONS = envBackup.MODEL_OPTIONS; process.env.DATA_DIR = envBackup.DATA_DIR; process.env.SYSTEM_PROMPT_PATH = envBackup.SYSTEM_PROMPT_PATH; + process.env.PLAYGROUNDS_DIR = envBackup.PLAYGROUNDS_DIR; }); test('getAgentPassword returns undefined when AGENT_PASSWORD not set', () => { @@ -58,4 +60,14 @@ describe('ConfigService', () => { delete process.env.SYSTEM_PROMPT_PATH; expect(new ConfigService().getSystemPromptPath()).toBe(join(process.cwd(), 'dist', 'assets', 'SYSTEM_PROMPT.md')); }); + + test('getPlaygroundsDir returns PLAYGROUNDS_DIR when set', () => { + process.env.PLAYGROUNDS_DIR = '/custom/playground'; + expect(new ConfigService().getPlaygroundsDir()).toBe('/custom/playground'); + }); + + test('getPlaygroundsDir returns default under cwd when not set', () => { + delete process.env.PLAYGROUNDS_DIR; + expect(new ConfigService().getPlaygroundsDir()).toBe(join(process.cwd(), 'playground')); + }); }); diff --git a/apps/api/src/app/config/config.service.ts b/apps/api/src/app/config/config.service.ts index a54567d..7f56434 100644 --- a/apps/api/src/app/config/config.service.ts +++ b/apps/api/src/app/config/config.service.ts @@ -22,4 +22,8 @@ export class ConfigService { } return join(process.cwd(), 'dist', 'assets', 'SYSTEM_PROMPT.md'); } + + getPlaygroundsDir(): string { + return process.env.PLAYGROUNDS_DIR ?? join(process.cwd(), 'playground'); + } } diff --git a/apps/api/src/app/playgrounds/playgrounds.controller.ts b/apps/api/src/app/playgrounds/playgrounds.controller.ts new file mode 100644 index 0000000..7b29f5b --- /dev/null +++ b/apps/api/src/app/playgrounds/playgrounds.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AgentAuthGuard } from '../auth/agent-auth.guard'; +import { PlaygroundsService } from './playgrounds.service'; + +@Controller() +@UseGuards(AgentAuthGuard) +export class PlaygroundsController { + constructor(private readonly playgrounds: PlaygroundsService) {} + + @Get('playgrounds') + getTree() { + return this.playgrounds.getTree(); + } +} diff --git a/apps/api/src/app/playgrounds/playgrounds.service.test.ts b/apps/api/src/app/playgrounds/playgrounds.service.test.ts new file mode 100644 index 0000000..ac217a1 --- /dev/null +++ b/apps/api/src/app/playgrounds/playgrounds.service.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { PlaygroundsService } from './playgrounds.service'; + +describe('PlaygroundsService', () => { + let playgroundDir: string; + + beforeEach(() => { + playgroundDir = mkdtempSync(join(tmpdir(), 'playground-')); + }); + + afterEach(() => { + rmSync(playgroundDir, { recursive: true, force: true }); + }); + + test('getTree returns empty array when directory is empty', () => { + const config = { getPlaygroundsDir: () => playgroundDir }; + const service = new PlaygroundsService(config as never); + expect(service.getTree()).toEqual([]); + }); + + test('getTree returns directories first then files, each sorted by name', () => { + writeFileSync(join(playgroundDir, 'b.txt'), ''); + writeFileSync(join(playgroundDir, 'a.txt'), ''); + mkdirSync(join(playgroundDir, 'dir')); + const config = { getPlaygroundsDir: () => playgroundDir }; + const service = new PlaygroundsService(config as never); + const tree = service.getTree(); + expect(tree.length).toBe(3); + expect(tree[0].name).toBe('dir'); + expect(tree[0].type).toBe('directory'); + expect(tree[0].children).toEqual([]); + expect(tree[1].name).toBe('a.txt'); + expect(tree[1].type).toBe('file'); + expect(tree[2].name).toBe('b.txt'); + expect(tree[2].type).toBe('file'); + }); + + test('getTree skips dotfiles and dotdirs', () => { + writeFileSync(join(playgroundDir, '.hidden'), ''); + writeFileSync(join(playgroundDir, 'visible'), ''); + mkdirSync(join(playgroundDir, '.dotdir')); + const config = { getPlaygroundsDir: () => playgroundDir }; + const service = new PlaygroundsService(config as never); + const tree = service.getTree(); + expect(tree.length).toBe(1); + expect(tree[0].name).toBe('visible'); + }); + + test('getTree returns nested structure with relative paths', () => { + mkdirSync(join(playgroundDir, 'sub')); + writeFileSync(join(playgroundDir, 'sub', 'file.ts'), ''); + const config = { getPlaygroundsDir: () => playgroundDir }; + const service = new PlaygroundsService(config as never); + const tree = service.getTree(); + expect(tree.length).toBe(1); + expect(tree[0].path).toBe('sub'); + expect(tree[0].children?.length).toBe(1); + expect(tree[0].children?.[0].path).toBe('sub/file.ts'); + expect(tree[0].children?.[0].name).toBe('file.ts'); + }); + + test('getTree returns empty array when directory does not exist', () => { + const config = { getPlaygroundsDir: () => join(playgroundDir, 'nonexistent') }; + const service = new PlaygroundsService(config as never); + expect(service.getTree()).toEqual([]); + }); +}); diff --git a/apps/api/src/app/playgrounds/playgrounds.service.ts b/apps/api/src/app/playgrounds/playgrounds.service.ts new file mode 100644 index 0000000..9c6dd4b --- /dev/null +++ b/apps/api/src/app/playgrounds/playgrounds.service.ts @@ -0,0 +1,56 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '../config/config.service'; + +export interface PlaygroundEntry { + name: string; + path: string; + type: 'file' | 'directory'; + children?: PlaygroundEntry[]; +} + +const HIDDEN_PREFIX = '.'; + +@Injectable() +export class PlaygroundsService { + constructor(private readonly config: ConfigService) {} + + getTree(): PlaygroundEntry[] { + return this.readDir(this.config.getPlaygroundsDir(), ''); + } + + private readDir(absPath: string, relativePath: string): PlaygroundEntry[] { + try { + const entries = readdirSync(absPath, { withFileTypes: true }); + const result: PlaygroundEntry[] = []; + const dirs: { name: string; abs: string; rel: string }[] = []; + const files: { name: string; rel: string }[] = []; + for (const e of entries) { + if (e.name.startsWith(HIDDEN_PREFIX)) continue; + const rel = relativePath ? `${relativePath}/${e.name}` : e.name; + if (e.isDirectory()) { + dirs.push({ name: e.name, abs: join(absPath, e.name), rel }); + } else if (e.isFile()) { + files.push({ name: e.name, rel }); + } + } + dirs.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => a.name.localeCompare(b.name)); + for (const d of dirs) { + result.push({ + name: d.name, + path: d.rel, + type: 'directory', + children: this.readDir(d.abs, d.rel), + }); + } + for (const f of files) { + result.push({ name: f.name, path: f.rel, type: 'file' }); + } + return result; + } catch { + return []; + } + } +} diff --git a/apps/chat/src/app/file-explorer/file-explorer.spec.tsx b/apps/chat/src/app/file-explorer/file-explorer.spec.tsx new file mode 100644 index 0000000..b1e10f0 --- /dev/null +++ b/apps/chat/src/app/file-explorer/file-explorer.spec.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { FileExplorer, type PlaygroundEntry } from './file-explorer'; + +vi.mock('../api-url', () => ({ + getApiUrl: () => '', + getAuthTokenForRequest: () => '', +})); + +describe('FileExplorer', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows playground/ label in header', async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [] as PlaygroundEntry[], + }); + render(); + await waitFor(() => { + expect(screen.getByText('playground/')).toBeTruthy(); + }); + }); + + it('shows loading state initially', () => { + (fetch as ReturnType).mockImplementation( + () => new Promise(() => undefined) + ); + render(); + expect(screen.getByText('Loading…')).toBeTruthy(); + }); + + it('shows empty message when API returns empty array', async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [] as PlaygroundEntry[], + }); + render(); + await waitFor(() => { + expect(screen.getByText(/No files in playground\//)).toBeTruthy(); + }); + }); + + it('shows error when fetch fails', async () => { + (fetch as ReturnType).mockRejectedValueOnce(new Error('Network error')); + render(); + await waitFor(() => { + expect(screen.getByText('Network error')).toBeTruthy(); + }); + }); + + it('shows error when response is not ok', async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + render(); + await waitFor(() => { + expect(screen.getByText('Failed to load playgrounds')).toBeTruthy(); + }); + }); + + it('renders file and directory entries from API', async () => { + const tree: PlaygroundEntry[] = [ + { name: 'readme.md', path: 'readme.md', type: 'file' }, + { + name: 'src', + path: 'src', + type: 'directory', + children: [{ name: 'index.ts', path: 'src/index.ts', type: 'file' }], + }, + ]; + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tree, + }); + render(); + await waitFor(() => { + expect(screen.getByText('readme.md')).toBeTruthy(); + }); + expect(screen.getByText('src')).toBeTruthy(); + }); + + it('expands directory when folder is clicked', async () => { + const tree: PlaygroundEntry[] = [ + { + name: 'lib', + path: 'lib', + type: 'directory', + children: [{ name: 'util.ts', path: 'lib/util.ts', type: 'file' }], + }, + ]; + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tree, + }); + render(); + await waitFor(() => { + expect(screen.getByText('lib')).toBeTruthy(); + }); + expect(screen.queryByText('util.ts')).toBeNull(); + fireEvent.click(screen.getByText('lib')); + await waitFor(() => { + expect(screen.getByText('util.ts')).toBeTruthy(); + }); + }); +}); diff --git a/apps/chat/src/app/file-explorer/file-explorer.tsx b/apps/chat/src/app/file-explorer/file-explorer.tsx new file mode 100644 index 0000000..e09a50f --- /dev/null +++ b/apps/chat/src/app/file-explorer/file-explorer.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getApiUrl, getAuthTokenForRequest } from '../api-url'; +import { SIDEBAR_WIDTH_PX } from '../layout-constants'; + +export interface PlaygroundEntry { + name: string; + path: string; + type: 'file' | 'directory'; + children?: PlaygroundEntry[]; +} + +const PLAYGROUNDS_LABEL = 'playground/'; +const INDENT_BASE_PX = 8; +const INDENT_PER_LEVEL_PX = 12; + +function FolderIcon({ open }: { open: boolean }) { + return ( + + {open ? ( + <> + + + + ) : ( + <> + + + + )} + + ); +} + +function FileIcon() { + return ( + + + + + ); +} + +function TreeNode({ + entry, + depth, + expanded, + onToggle, +}: { + entry: PlaygroundEntry; + depth: number; + expanded: Set; + onToggle: (path: string) => void; +}) { + const isDir = entry.type === 'directory'; + const isOpen = expanded.has(entry.path); + const hasChildren = isDir && (entry.children?.length ?? 0) > 0; + + const handleClick = useCallback(() => { + if (isDir) onToggle(entry.path); + }, [isDir, entry.path, onToggle]); + + return ( +
+ + {isDir && hasChildren && isOpen && ( +
+ {(entry.children ?? []).map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function FileExplorer() { + const [tree, setTree] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>(() => new Set()); + + useEffect(() => { + const ac = new AbortController(); + const base = getApiUrl(); + const url = base ? `${base}/api/playgrounds` : '/api/playgrounds'; + const token = getAuthTokenForRequest(); + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + (async () => { + try { + const res = await fetch(url, { headers, signal: ac.signal }); + if (res.status === 401) { + setTree([]); + return; + } + if (!res.ok) throw new Error('Failed to load playgrounds'); + const data = (await res.json()) as PlaygroundEntry[]; + setTree(Array.isArray(data) ? data : []); + setError(null); + } catch (e) { + if ((e as Error).name === 'AbortError') return; + setTree([]); + setError(e instanceof Error ? e.message : 'Failed to load'); + } finally { + setLoading(false); + } + })(); + return () => ac.abort(); + }, []); + + const handleToggle = useCallback((path: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }, []); + + return ( +
+
+ {PLAYGROUNDS_LABEL} +
+
+ {loading && ( +
+ Loading… +
+ )} + {error && ( +
{error}
+ )} + {!loading && !error && tree.length === 0 && ( +
+ No files in {PLAYGROUNDS_LABEL} +
+ )} + {!loading && !error && tree.length > 0 && ( +
+ {tree.map((entry) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/chat/src/app/layout-constants.ts b/apps/chat/src/app/layout-constants.ts new file mode 100644 index 0000000..9c0ac86 --- /dev/null +++ b/apps/chat/src/app/layout-constants.ts @@ -0,0 +1 @@ +export const SIDEBAR_WIDTH_PX = 240; diff --git a/apps/chat/src/app/pages/chat-page.tsx b/apps/chat/src/app/pages/chat-page.tsx index 975bfbd..aadb4e2 100644 --- a/apps/chat/src/app/pages/chat-page.tsx +++ b/apps/chat/src/app/pages/chat-page.tsx @@ -4,6 +4,8 @@ import { AuthModal } from '../chat/auth-modal'; import { MessageList, type ChatMessage } from '../chat/message-list'; import { ModelSelector } from '../chat/model-selector'; import { ThemeToggle } from '../theme-toggle'; +import { FileExplorer } from '../file-explorer/file-explorer'; +import { SIDEBAR_WIDTH_PX } from '../layout-constants'; import { CHAT_STATES } from '../chat/chat-state'; import { useChatWebSocket } from '../chat/use-chat-websocket'; import type { ServerMessage } from '../chat/chat-state'; @@ -242,137 +244,147 @@ export function ChatPage() { (authModal.authUrl || authModal.deviceCode || authModal.isManualToken); return ( -
+
-
-
-

AI Assistant

-

{STATE_LABELS[state] ?? state}

-
-
- - {(state === CHAT_STATES.UNAUTHENTICATED || state === CHAT_STATES.AUTHENTICATED) && ( - - )} - {(state === CHAT_STATES.AUTHENTICATED || state === CHAT_STATES.AWAITING_RESPONSE) && ( +
+
+
+

AI Assistant

+

{STATE_LABELS[state] ?? state}

+
+
+ + {(state === CHAT_STATES.UNAUTHENTICATED || state === CHAT_STATES.AUTHENTICATED) && ( + + )} + {(state === CHAT_STATES.AUTHENTICATED || state === CHAT_STATES.AWAITING_RESPONSE) && ( + + )} + +
+
+ {errorMessage && state === CHAT_STATES.ERROR && ( +
+ {errorMessage} - )} - -
-
- - {errorMessage && state === CHAT_STATES.ERROR && ( -
- {errorMessage} - -
- )} - -
-
- -
-
-
+
+ )} +
-
-
- {pendingImages.length > 0 && ( -
- {pendingImages.map((dataUrl, i) => ( -
- - +
+ +
+
+
+ +
+
+
+
+
+ {pendingImages.length > 0 && ( +
+ {pendingImages.map((dataUrl, i) => ( +
+ + +
+ ))}
- ))} + )} +
+ +