LocalLens

9. The browser UI

Plain HTML, JS, and CSS — no build step, no framework.

src/ui/ is three files:

  • index.html — the page skeleton
  • app.js — client state and event handlers
  • styles.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.

On this page