profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

T14: folder browser

GET /api/fs/browse (defaults to the OS home directory, lists
subdirectories, hides dotfiles, reports the parent) and POST
/api/fs/mkdir (creates one subdirectory, 4xx on a duplicate name).
FolderBrowser is a modal that navigates the tree and can create a new
folder in place; a "Browse…" button next to the project-folder input
opens it, while the text input stays available for anyone who wants to
type or paste a path directly.

Manually verified in a headless browser: opening the browser, creating
a folder, navigating into and out of it, and selecting it all fill the
project-folder field correctly.
commit f93035d

9 changed files with +410 and −7

Jump to a changed file
  1. client/src/App.css +117 −0
  2. client/src/App.tsx +23 −6
  3. client/src/api.ts +14 −0
  4. client/src/components/FolderBrowser.tsx +104 −0
  5. server/app.ts +2 −0
  6. server/routes/fs.test.ts +78 −0
  7. server/routes/fs.ts +47 −0
  8. shared/types.ts +24 −0
  9. 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.