9. The browser UI
Plain HTML, JS, and CSS — no build step, no framework.
src/ui/ is three files:
index.html— the page skeletonapp.js— client state and event handlersstyles.css— the visual layer
No framework, no bundler, no transpilation. The file picker hands
us folder contents, fetch talks to the server, and the DOM gets
updated by hand.
The page
index.html defines:
- a sidebar that lists brains;
- a topbar showing the active brain;
- a chat panel with a message thread and an input form;
- a brain-creation panel with a name input, a Choose folder button, and a Create and index submit button.
It loads styles.css and app.js from /assets/….
The state model
app.js keeps a single state object:
const state = {
brains: [],
activeBrainId: undefined,
messages: [],
selectedFolder: undefined,
};Render functions read from state and update the DOM. Event
handlers mutate state and call the relevant render function.
No virtual DOM — for a UI this small, it would cost more than
it'd save.
Reading a folder
elements.browseFolder.addEventListener("click", () => {
elements.folderPicker.click();
});
elements.folderPicker.addEventListener("change", async () => {
const files = elements.folderPicker.files;
state.selectedFolder = await readSelectedFolder(files);
/* … render … */
});<input type="file" webkitdirectory> hands back a flat list of
File objects with webkitRelativePath set. readSelectedFolder
filters by the same supported extensions as the server, drops
big files, and turns each File into a BrowserDocumentInput:
function shouldReadBrowserFile(relativePath, size) {
if (size > MAX_BROWSER_FILE_BYTES) return false;
const segments = relativePath.split("/");
if (segments.some((s) => s.startsWith(".") || IGNORED.has(s))) return false;
return SUPPORTED.has(extname(relativePath).toLowerCase());
}These constants mirror src/files.ts. They have to agree, or the
server rejects what the UI accepted.
Creating a brain
elements.brainForm.addEventListener("submit", async (event) => {
event.preventDefault();
const body = {
name: elements.brainName.value,
folderName: state.selectedFolder.rootName,
documents: state.selectedFolder.documents,
};
const { brain } = await api("/api/brains/from-files", {
method: "POST",
body: JSON.stringify(body),
});
await refreshBrains();
state.activeBrainId = brain.id;
state.messages = [];
renderActiveBrain();
});api() is a thin fetch wrapper that throws on non-2xx
responses, surfacing the server's AppError messages to the
user as toasts.
Asking a question
elements.chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const question = elements.chatInput.value.trim();
if (!question || !state.activeBrainId) return;
state.messages.push({ role: "user", content: question });
renderChat();
const { answer, citations } = await api(
`/api/brains/${state.activeBrainId}/chat`,
{ method: "POST", body: JSON.stringify({ question }) },
);
state.messages.push({ role: "assistant", content: answer, citations });
renderChat();
});The chat thread renders as Markdown via a tiny in-file renderer
(renderMarkdown, formatInlineMarkdown, escapeHtml). It
handles bold, italic, code, code blocks, and bracket citations —
enough for what the model produces, no more.
Why no framework
For a UI this small, the trade-off is clear:
- No build step. Edit
app.js, refresh the browser. That's the dev loop. - Easy to read end to end. The whole client is one file.
- No version churn. It keeps working without us.
If the UI grew to several screens, a framework would start paying for itself. It hasn't.
Adding a feature here
To extend the UI, add a small render function, give it a piece of
state, and call it from the existing event handlers. Don't
introduce a framework just to add a button.
That closes the walkthrough. From here, the Code structure section explains why the boundaries land where they do, and the Extend section shows where to plug new features in.