Commit
T14: folder browser
commit
f93035d
9 changed files with +410 and −7
Jump to a changed file
- client/src/App.css +117 −0
- client/src/App.tsx +23 −6
- client/src/api.ts +14 −0
- client/src/components/FolderBrowser.tsx +104 −0
- server/app.ts +2 −0
- server/routes/fs.test.ts +78 −0
- server/routes/fs.ts +47 −0
- shared/types.ts +24 −0
- spec/TASKS.md +1 −1
modified client/src/App.css +117 −0
| @@ -63,6 +63,123 @@ | ||
| 63 | 63 | cursor: default; |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | +.folder-field { | |
| 67 | + display: flex; | |
| 68 | + gap: 8px; | |
| 69 | +} | |
| 70 | + | |
| 71 | +.folder-field input { | |
| 72 | + flex: 1; | |
| 73 | +} | |
| 74 | + | |
| 75 | +.folder-field button { | |
| 76 | + align-self: auto; | |
| 77 | + background: var(--bg); | |
| 78 | + color: var(--text-h); | |
| 79 | + border: 1px solid var(--border); | |
| 80 | + white-space: nowrap; | |
| 81 | +} | |
| 82 | + | |
| 83 | +/* Folder browser modal */ | |
| 84 | + | |
| 85 | +.folder-browser-overlay { | |
| 86 | + position: fixed; | |
| 87 | + inset: 0; | |
| 88 | + background: rgba(0, 0, 0, 0.4); | |
| 89 | + display: flex; | |
| 90 | + align-items: center; | |
| 91 | + justify-content: center; | |
| 92 | + z-index: 10; | |
| 93 | + padding: 16px; | |
| 94 | +} | |
| 95 | + | |
| 96 | +.folder-browser { | |
| 97 | + background: var(--bg); | |
| 98 | + border-radius: 10px; | |
| 99 | + padding: 20px; | |
| 100 | + width: 100%; | |
| 101 | + max-width: 480px; | |
| 102 | + max-height: 80vh; | |
| 103 | + display: flex; | |
| 104 | + flex-direction: column; | |
| 105 | + gap: 12px; | |
| 106 | +} | |
| 107 | + | |
| 108 | +.folder-browser-path { | |
| 109 | + font-size: 13px; | |
| 110 | + color: var(--text); | |
| 111 | + opacity: 0.8; | |
| 112 | + word-break: break-all; | |
| 113 | + background: var(--bg-alt); | |
| 114 | + padding: 6px 10px; | |
| 115 | + border-radius: 6px; | |
| 116 | +} | |
| 117 | + | |
| 118 | +.folder-browser-list { | |
| 119 | + overflow-y: auto; | |
| 120 | + max-height: 40vh; | |
| 121 | + display: flex; | |
| 122 | + flex-direction: column; | |
| 123 | + gap: 4px; | |
| 124 | + border: 1px solid var(--border); | |
| 125 | + border-radius: 8px; | |
| 126 | + padding: 6px; | |
| 127 | +} | |
| 128 | + | |
| 129 | +.folder-browser-entry { | |
| 130 | + text-align: left; | |
| 131 | + padding: 8px 10px; | |
| 132 | + border: none; | |
| 133 | + background: none; | |
| 134 | + border-radius: 6px; | |
| 135 | + cursor: pointer; | |
| 136 | + color: var(--text-h); | |
| 137 | +} | |
| 138 | + | |
| 139 | +.folder-browser-entry:hover { | |
| 140 | + background: var(--bg-alt); | |
| 141 | +} | |
| 142 | + | |
| 143 | +.folder-browser-new { | |
| 144 | + display: flex; | |
| 145 | + gap: 8px; | |
| 146 | +} | |
| 147 | + | |
| 148 | +.folder-browser-new input { | |
| 149 | + flex: 1; | |
| 150 | + padding: 8px 10px; | |
| 151 | + border: 1px solid var(--border); | |
| 152 | + border-radius: 6px; | |
| 153 | + background: var(--bg); | |
| 154 | + color: var(--text-h); | |
| 155 | +} | |
| 156 | + | |
| 157 | +.folder-browser-actions { | |
| 158 | + display: flex; | |
| 159 | + justify-content: flex-end; | |
| 160 | + gap: 8px; | |
| 161 | +} | |
| 162 | + | |
| 163 | +.folder-browser button { | |
| 164 | + padding: 8px 14px; | |
| 165 | + border-radius: 6px; | |
| 166 | + border: 1px solid var(--border); | |
| 167 | + background: var(--bg-alt); | |
| 168 | + color: var(--text-h); | |
| 169 | + cursor: pointer; | |
| 170 | +} | |
| 171 | + | |
| 172 | +.folder-browser button.primary { | |
| 173 | + background: var(--accent); | |
| 174 | + color: white; | |
| 175 | + border-color: var(--accent); | |
| 176 | +} | |
| 177 | + | |
| 178 | +.folder-browser button:disabled { | |
| 179 | + opacity: 0.6; | |
| 180 | + cursor: default; | |
| 181 | +} | |
| 182 | + | |
| 66 | 183 | .session-list ul { |
| 67 | 184 | list-style: none; |
| 68 | 185 | padding: 0; |
modified client/src/App.tsx +23 −6
| @@ -4,6 +4,7 @@import * as api from './api' | ||
| 4 | 4 | import './App.css' |
| 5 | 5 | import { BlockerImport } from './components/BlockerImport' |
| 6 | 6 | import { CoveragePanel } from './components/CoveragePanel' |
| 7 | +import { FolderBrowser } from './components/FolderBrowser' | |
| 7 | 8 | import { GeneratePanel } from './components/GeneratePanel' |
| 8 | 9 | import { PushToTalkButton } from './components/PushToTalkButton' |
| 9 | 10 | import { QuestionCard } from './components/QuestionCard' |
| @@ -28,6 +29,7 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { | ||
| 28 | 29 | const [targetDir, setTargetDir] = useState('') |
| 29 | 30 | const [error, setError] = useState<string | null>(null) |
| 30 | 31 | const [creating, setCreating] = useState(false) |
| 32 | + const [browsingFolder, setBrowsingFolder] = useState(false) | |
| 31 | 33 | |
| 32 | 34 | useEffect(() => { |
| 33 | 35 | api.listSessions().then(setSessions).catch((err: Error) => setError(err.message)) |
| @@ -63,18 +65,33 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { | ||
| 63 | 65 | </label> |
| 64 | 66 | <label> |
| 65 | 67 | Project folder |
| 66 | - <input | |
| 67 | - value={targetDir} | |
| 68 | - onChange={(e) => setTargetDir(e.target.value)} | |
| 69 | - placeholder="Where should we save this?" | |
| 70 | - required | |
| 71 | - /> | |
| 68 | + <div className="folder-field"> | |
| 69 | + <input | |
| 70 | + value={targetDir} | |
| 71 | + onChange={(e) => setTargetDir(e.target.value)} | |
| 72 | + placeholder="Where should we save this?" | |
| 73 | + required | |
| 74 | + /> | |
| 75 | + <button type="button" onClick={() => setBrowsingFolder(true)}> | |
| 76 | + Browse… | |
| 77 | + </button> | |
| 78 | + </div> | |
| 72 | 79 | </label> |
| 73 | 80 | <button type="submit" disabled={creating}> |
| 74 | 81 | {creating ? 'Getting started…' : 'Get started'} |
| 75 | 82 | </button> |
| 76 | 83 | </form> |
| 77 | 84 | |
| 85 | + {browsingFolder && ( | |
| 86 | + <FolderBrowser | |
| 87 | + onSelect={(path) => { | |
| 88 | + setTargetDir(path) | |
| 89 | + setBrowsingFolder(false) | |
| 90 | + }} | |
| 91 | + onClose={() => setBrowsingFolder(false)} | |
| 92 | + /> | |
| 93 | + )} | |
| 94 | + | |
| 78 | 95 | {error && <p className="error">{error}</p>} |
| 79 | 96 | |
| 80 | 97 | <div className="session-list"> |
modified client/src/api.ts +14 −0
| @@ -3,6 +3,8 @@import type { | ||
| 3 | 3 | AnswerResponse, |
| 4 | 4 | BlockersResponse, |
| 5 | 5 | CreateSessionRequest, |
| 6 | + FsBrowseResponse, | |
| 7 | + FsMkdirResponse, | |
| 6 | 8 | GenerateRequest, |
| 7 | 9 | GenerateResponse, |
| 8 | 10 | Session, |
| @@ -66,3 +68,15 @@export function importBlockers(id: string): Promise<BlockersResponse> { | ||
| 66 | 68 | body: JSON.stringify({}), |
| 67 | 69 | }) |
| 68 | 70 | } |
| 71 | + | |
| 72 | +export function browseFolder(path?: string): Promise<FsBrowseResponse> { | |
| 73 | + const query = path ? `?path=${encodeURIComponent(path)}` : '' | |
| 74 | + return requestJson<FsBrowseResponse>(`${BASE}/fs/browse${query}`) | |
| 75 | +} | |
| 76 | + | |
| 77 | +export function createFolder(path: string, name: string): Promise<FsMkdirResponse> { | |
| 78 | + return requestJson<FsMkdirResponse>(`${BASE}/fs/mkdir`, { | |
| 79 | + method: 'POST', | |
| 80 | + body: JSON.stringify({ path, name }), | |
| 81 | + }) | |
| 82 | +} |
added client/src/components/FolderBrowser.tsx +104 −0
| @@ -0,0 +1,104 @@ | ||
| 1 | +import { useEffect, useState } from 'react' | |
| 2 | +import type { FsBrowseResponse } from 'shared/types' | |
| 3 | +import * as api from '../api' | |
| 4 | + | |
| 5 | +interface FolderBrowserProps { | |
| 6 | + onSelect: (path: string) => void | |
| 7 | + onClose: () => void | |
| 8 | +} | |
| 9 | + | |
| 10 | +export function FolderBrowser({ onSelect, onClose }: FolderBrowserProps) { | |
| 11 | + const [listing, setListing] = useState<FsBrowseResponse | null>(null) | |
| 12 | + const [error, setError] = useState<string | null>(null) | |
| 13 | + const [newFolderName, setNewFolderName] = useState('') | |
| 14 | + const [creating, setCreating] = useState(false) | |
| 15 | + | |
| 16 | + function load(path?: string) { | |
| 17 | + setError(null) | |
| 18 | + api.browseFolder(path).then(setListing).catch((err: Error) => setError(err.message)) | |
| 19 | + } | |
| 20 | + | |
| 21 | + useEffect(() => { | |
| 22 | + load(undefined) | |
| 23 | + }, []) | |
| 24 | + | |
| 25 | + async function handleCreateFolder() { | |
| 26 | + if (!listing || !newFolderName.trim()) return | |
| 27 | + setCreating(true) | |
| 28 | + setError(null) | |
| 29 | + try { | |
| 30 | + const created = await api.createFolder(listing.path, newFolderName.trim()) | |
| 31 | + setNewFolderName('') | |
| 32 | + load(created.path) | |
| 33 | + } catch (err) { | |
| 34 | + setError(err instanceof Error ? err.message : String(err)) | |
| 35 | + } finally { | |
| 36 | + setCreating(false) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <div className="folder-browser-overlay" onClick={onClose}> | |
| 42 | + <div className="folder-browser" onClick={(e) => e.stopPropagation()}> | |
| 43 | + <h2>Choose a folder</h2> | |
| 44 | + <p className="folder-browser-path">{listing?.path ?? 'Loading…'}</p> | |
| 45 | + | |
| 46 | + <div className="folder-browser-list"> | |
| 47 | + {listing?.parent && ( | |
| 48 | + <button type="button" className="folder-browser-entry" onClick={() => load(listing.parent!)}> | |
| 49 | + ⬆ Up one level | |
| 50 | + </button> | |
| 51 | + )} | |
| 52 | + {listing?.directories.map((dir) => ( | |
| 53 | + <button | |
| 54 | + type="button" | |
| 55 | + key={dir.path} | |
| 56 | + className="folder-browser-entry" | |
| 57 | + onClick={() => load(dir.path)} | |
| 58 | + > | |
| 59 | + 📁 {dir.name} | |
| 60 | + </button> | |
| 61 | + ))} | |
| 62 | + {listing && listing.directories.length === 0 && !listing.parent && ( | |
| 63 | + <p className="muted">No folders here.</p> | |
| 64 | + )} | |
| 65 | + {listing && listing.directories.length === 0 && listing.parent && ( | |
| 66 | + <p className="muted">No subfolders here.</p> | |
| 67 | + )} | |
| 68 | + </div> | |
| 69 | + | |
| 70 | + <div className="folder-browser-new"> | |
| 71 | + <input | |
| 72 | + value={newFolderName} | |
| 73 | + onChange={(e) => setNewFolderName(e.target.value)} | |
| 74 | + placeholder="New folder name" | |
| 75 | + disabled={creating} | |
| 76 | + /> | |
| 77 | + <button | |
| 78 | + type="button" | |
| 79 | + onClick={() => void handleCreateFolder()} | |
| 80 | + disabled={creating || !newFolderName.trim()} | |
| 81 | + > | |
| 82 | + Create | |
| 83 | + </button> | |
| 84 | + </div> | |
| 85 | + | |
| 86 | + {error && <p className="error">{error}</p>} | |
| 87 | + | |
| 88 | + <div className="folder-browser-actions"> | |
| 89 | + <button type="button" onClick={onClose}> | |
| 90 | + Cancel | |
| 91 | + </button> | |
| 92 | + <button | |
| 93 | + type="button" | |
| 94 | + className="primary" | |
| 95 | + disabled={!listing} | |
| 96 | + onClick={() => listing && onSelect(listing.path)} | |
| 97 | + > | |
| 98 | + Use this folder | |
| 99 | + </button> | |
| 100 | + </div> | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | + ) | |
| 104 | +} |
modified server/app.ts +2 −0
| @@ -5,6 +5,7 @@import { createInterviewLlm, createSttProvider } from './providers/factory' | ||
| 5 | 5 | import type { InterviewLlm, SttProvider } from './providers/types' |
| 6 | 6 | import { registerAudioRoutes } from './routes/audio' |
| 7 | 7 | import { registerBlockerRoutes } from './routes/blockers' |
| 8 | +import { registerFsRoutes } from './routes/fs' | |
| 8 | 9 | import { registerGenerateRoutes } from './routes/generate' |
| 9 | 10 | import { registerSessionRoutes } from './routes/sessions' |
| 10 | 11 | import { createDefaultSessionStore, SessionStore } from './store/sessionStore' |
| @@ -31,6 +32,7 @@export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance { | ||
| 31 | 32 | registerAudioRoutes(app, resolved) |
| 32 | 33 | registerGenerateRoutes(app, resolved) |
| 33 | 34 | registerBlockerRoutes(app, resolved) |
| 35 | + registerFsRoutes(app) | |
| 34 | 36 | |
| 35 | 37 | return app |
| 36 | 38 | } |
added server/routes/fs.test.ts +78 −0
| @@ -0,0 +1,78 @@ | ||
| 1 | +import { mkdir, mkdtemp, rm } from 'node:fs/promises' | |
| 2 | +import { tmpdir } from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import type { FastifyInstance } from 'fastify' | |
| 5 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 6 | +import type { FsBrowseResponse, FsMkdirResponse } from '../../shared/types' | |
| 7 | +import { buildApp } from '../app' | |
| 8 | +import { createLlmMock } from '../providers/llmMock' | |
| 9 | +import { SessionStore } from '../store/sessionStore' | |
| 10 | + | |
| 11 | +describe('fs routes', () => { | |
| 12 | + let storeDir: string | |
| 13 | + let baseDir: string | |
| 14 | + let app: FastifyInstance | |
| 15 | + | |
| 16 | + beforeEach(async () => { | |
| 17 | + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-')) | |
| 18 | + baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-fs-')) | |
| 19 | + await mkdir(path.join(baseDir, 'zeta')) | |
| 20 | + await mkdir(path.join(baseDir, 'alpha')) | |
| 21 | + await mkdir(path.join(baseDir, '.hidden')) | |
| 22 | + app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() }) | |
| 23 | + }) | |
| 24 | + | |
| 25 | + afterEach(async () => { | |
| 26 | + await app.close() | |
| 27 | + await rm(storeDir, { recursive: true, force: true }) | |
| 28 | + await rm(baseDir, { recursive: true, force: true }) | |
| 29 | + }) | |
| 30 | + | |
| 31 | + it('lists subdirectories sorted, hides dotfiles, and reports the parent', async () => { | |
| 32 | + const res = await app.inject({ method: 'GET', url: `/api/fs/browse?path=${encodeURIComponent(baseDir)}` }) | |
| 33 | + expect(res.statusCode).toBe(200) | |
| 34 | + const body = res.json<FsBrowseResponse>() | |
| 35 | + expect(body.path).toBe(baseDir) | |
| 36 | + expect(body.parent).toBe(path.dirname(baseDir)) | |
| 37 | + expect(body.directories.map((d) => d.name)).toEqual(['alpha', 'zeta']) | |
| 38 | + expect(body.directories[0].path).toBe(path.join(baseDir, 'alpha')) | |
| 39 | + }) | |
| 40 | + | |
| 41 | + it('defaults to the home directory when no path is given', async () => { | |
| 42 | + const res = await app.inject({ method: 'GET', url: '/api/fs/browse' }) | |
| 43 | + expect(res.statusCode).toBe(200) | |
| 44 | + expect(res.json<FsBrowseResponse>().path.length).toBeGreaterThan(0) | |
| 45 | + }) | |
| 46 | + | |
| 47 | + it('returns a 4xx for a path that cannot be read', async () => { | |
| 48 | + const res = await app.inject({ | |
| 49 | + method: 'GET', | |
| 50 | + url: `/api/fs/browse?path=${encodeURIComponent(path.join(baseDir, 'does-not-exist'))}`, | |
| 51 | + }) | |
| 52 | + expect(res.statusCode).toBeGreaterThanOrEqual(400) | |
| 53 | + expect(res.statusCode).toBeLessThan(500) | |
| 54 | + }) | |
| 55 | + | |
| 56 | + it('creates a new directory', async () => { | |
| 57 | + const res = await app.inject({ | |
| 58 | + method: 'POST', | |
| 59 | + url: '/api/fs/mkdir', | |
| 60 | + payload: { path: baseDir, name: 'my-project' }, | |
| 61 | + }) | |
| 62 | + expect(res.statusCode).toBe(200) | |
| 63 | + const body = res.json<FsMkdirResponse>() | |
| 64 | + expect(body.path).toBe(path.join(baseDir, 'my-project')) | |
| 65 | + | |
| 66 | + const listing = await app | |
| 67 | + .inject({ method: 'GET', url: `/api/fs/browse?path=${encodeURIComponent(baseDir)}` }) | |
| 68 | + .then((r) => r.json<FsBrowseResponse>()) | |
| 69 | + expect(listing.directories.map((d) => d.name)).toContain('my-project') | |
| 70 | + }) | |
| 71 | + | |
| 72 | + it('returns a 4xx when creating a directory that already exists', async () => { | |
| 73 | + await app.inject({ method: 'POST', url: '/api/fs/mkdir', payload: { path: baseDir, name: 'alpha' } }) | |
| 74 | + const res = await app.inject({ method: 'POST', url: '/api/fs/mkdir', payload: { path: baseDir, name: 'alpha' } }) | |
| 75 | + expect(res.statusCode).toBeGreaterThanOrEqual(400) | |
| 76 | + expect(res.statusCode).toBeLessThan(500) | |
| 77 | + }) | |
| 78 | +}) |
added server/routes/fs.ts +47 −0
| @@ -0,0 +1,47 @@ | ||
| 1 | +import { mkdir, readdir } from 'node:fs/promises' | |
| 2 | +import { homedir } from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import type { FastifyInstance } from 'fastify' | |
| 5 | +import { FsMkdirRequestSchema, type FsBrowseResponse, type FsMkdirResponse } from '../../shared/types' | |
| 6 | + | |
| 7 | +export function registerFsRoutes(app: FastifyInstance): void { | |
| 8 | + app.get('/api/fs/browse', async (request, reply) => { | |
| 9 | + const query = request.query as { path?: string } | |
| 10 | + const target = query.path && query.path.trim().length > 0 ? query.path : homedir() | |
| 11 | + | |
| 12 | + let entries | |
| 13 | + try { | |
| 14 | + entries = await readdir(target, { withFileTypes: true }) | |
| 15 | + } catch (err) { | |
| 16 | + return reply.code(400).send({ error: err instanceof Error ? err.message : "can't read that folder" }) | |
| 17 | + } | |
| 18 | + | |
| 19 | + const directories = entries | |
| 20 | + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) | |
| 21 | + .map((entry) => ({ name: entry.name, path: path.join(target, entry.name) })) | |
| 22 | + .sort((a, b) => a.name.localeCompare(b.name)) | |
| 23 | + | |
| 24 | + const parentDir = path.dirname(target) | |
| 25 | + const parent = parentDir === target ? null : parentDir | |
| 26 | + | |
| 27 | + const response: FsBrowseResponse = { path: target, parent, directories } | |
| 28 | + return reply.send(response) | |
| 29 | + }) | |
| 30 | + | |
| 31 | + app.post('/api/fs/mkdir', async (request, reply) => { | |
| 32 | + const parsed = FsMkdirRequestSchema.safeParse(request.body) | |
| 33 | + if (!parsed.success) { | |
| 34 | + return reply.code(400).send({ error: parsed.error.message }) | |
| 35 | + } | |
| 36 | + | |
| 37 | + const newPath = path.join(parsed.data.path, parsed.data.name) | |
| 38 | + try { | |
| 39 | + await mkdir(newPath) | |
| 40 | + } catch (err) { | |
| 41 | + return reply.code(400).send({ error: err instanceof Error ? err.message : "can't create that folder" }) | |
| 42 | + } | |
| 43 | + | |
| 44 | + const response: FsMkdirResponse = { path: newPath } | |
| 45 | + return reply.send(response) | |
| 46 | + }) | |
| 47 | +} |
modified shared/types.ts +24 −0
| @@ -112,3 +112,27 @@export const ErrorResponseSchema = z.object({ | ||
| 112 | 112 | error: z.string(), |
| 113 | 113 | }) |
| 114 | 114 | export type ErrorResponse = z.infer<typeof ErrorResponseSchema> |
| 115 | + | |
| 116 | +export const FsDirectoryEntrySchema = z.object({ | |
| 117 | + name: z.string(), | |
| 118 | + path: z.string(), | |
| 119 | +}) | |
| 120 | +export type FsDirectoryEntry = z.infer<typeof FsDirectoryEntrySchema> | |
| 121 | + | |
| 122 | +export const FsBrowseResponseSchema = z.object({ | |
| 123 | + path: z.string(), | |
| 124 | + parent: z.string().nullable(), | |
| 125 | + directories: z.array(FsDirectoryEntrySchema), | |
| 126 | +}) | |
| 127 | +export type FsBrowseResponse = z.infer<typeof FsBrowseResponseSchema> | |
| 128 | + | |
| 129 | +export const FsMkdirRequestSchema = z.object({ | |
| 130 | + path: z.string().min(1), | |
| 131 | + name: z.string().min(1), | |
| 132 | +}) | |
| 133 | +export type FsMkdirRequest = z.infer<typeof FsMkdirRequestSchema> | |
| 134 | + | |
| 135 | +export const FsMkdirResponseSchema = z.object({ | |
| 136 | + path: z.string(), | |
| 137 | +}) | |
| 138 | +export type FsMkdirResponse = z.infer<typeof FsMkdirResponseSchema> |
modified spec/TASKS.md +1 −1
| @@ -67,7 +67,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 67 | 67 | - Depends: T7 |
| 68 | 68 | - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: every category in CoveragePanel shows a plain-language label, not a `CategoryId`. |
| 69 | 69 | |
| 70 | -- [ ] T14 Folder browser | |
| 70 | +- [x] T14 Folder browser | |
| 71 | 71 | - `server/routes/fs.ts`: `GET /api/fs/browse` (defaults to the OS home directory, lists subdirectories, returns `{path, parent, directories}`), `POST /api/fs/mkdir` (creates one subdirectory). Shared zod schemas for both. `client/src/components/FolderBrowser.tsx`: modal that navigates the tree, creates a folder, and returns the chosen path to the session-create form; the raw text input stays available alongside it. |
| 72 | 72 | - Depends: T7 |
| 73 | 73 | - Verify: `npm test` (route tests: browse lists directories and a parent, browsing an unreadable path returns a 4xx, mkdir creates a directory and 4xxs on a duplicate name) and `npm run typecheck`. Manual: browsing, creating a folder, and selecting it fills the target directory field. |