User interface
Marginalia's UI is written in Java with Vaadin Flow 25: the component tree
lives on the server, the browser renders it with Vaadin's web components (Lumo theme, dark color scheme) and sends
events back over HTTP and a push WebSocket. There is no hand-written frontend code apart from one CSS file and a small
connector for the story tree. This page explains how the UI code is organized and the conventions to follow when you
change it. The code is in ui/.
Structure
flowchart TB
subgraph routes["Routes"]
Main["Main /"]
Viewer["Viewer /view, /view/<uuid>"]
end
Main -->|after login| WS["Workspace<br/>HTabSheet with left-hand tabs"]
WS --> MP["ManuscriptPart - Books"]
WS --> LP["LorebookPart - Lorebooks"]
WS --> UP["UserPart - Settings"]
WS --> PP["ProtocolPart - Protocols"]
WS --> AP["AIPart - Inference Providers"]
WS --> RP["ResourcesPart - Resources"]
WS --> TrP["TrashPart - Trash"]
WS --> AdP["AdminPart - Admin (footer button)"]
AdP --> DBP["DatabaseBackupPanel"]
TrP --> TG["TrashGrid"]
AdP --> CP["CleanupPanel"]
CP --> TG
MP -->|open a book| MD["ManuscriptDialog<br/>VTabSheet"]
MD --> Info["ManuscriptInfoPart - About"]
MD --> Prompt["ManuscriptPromptPart - Prompts"]
MD --> Lore["ManuscriptLorebookPart - Lorebook"]
MD --> Story["ManuscriptStoryPart - Story"]
MD --> Tree["ManuscriptTreePart - Story tree"]
MD --> Backup["ManuscriptBackupPart - Backups"]
LP --> LV["LorebookView"]
Lore --> LV
The shell, routes and login
AppShellConfig(AppShellConfigurator) sets up the page once:@Push, Lumo stylesheet,@ColorScheme(DARK), full-viewport body,shared-styles.css, page title, PWA metadata.MarginaliaServletis theVaadinServletmapped to/*(withasyncSupportedfor push).-
There are two routes, both extending
LoginCheckRoute:
LoginCheckRoute runs in the route's constructor (showLogin()):
-
On an empty installation it opens
UserDialogin first time mode (create the administrator) and reloads. -
Otherwise it tries the Save login cookies (
authenticateFromCookie, rotating the secret on success). -
Otherwise it shows a
LoginOverlay(PermissiveLoginOverlay- allows empty passwords to reach the server-side check) with the Save login checkbox. The check passes the client address (LoginCheckRoute.clientAddress()) toUserService.authenticate, which throttles failed logins per address (see Login throttling). -
After a successful login it calls
VaadinService.reinitializeSession(new session id) and the subclass'sproceedWithLogin(login), which fills the session-scopeduserbean, registers the login withSessionManager.userLoggedIn(see Sessions) and builds the content.
Logout deletes the cookies, closes the Vaadin session, invalidates the HTTP session and redirects to the context
root. Cookie names include the context path and the route class (root_Main_llllm_rememberMe_login...), so the workspace
and the viewer keep separate saved logins.
Building blocks
Classes that build components
Most UI classes are not Vaadin components themselves. They are plain classes with a create() method that builds
and returns the component tree, and keep references to the fields they need later:
@Configurable
@Extendable
public class ProtocolPart implements WorkspaceComponent {
@Autowired
private Localization loc;
@Autowired
private ProtocolService protocolService;
private Grid<Protocol> grid;
@Override
public Component create() throws Exception {
VerticalLayout layout = new VerticalLayout();
grid = new Grid<>();
...
return layout;
}
@Override
public void refresh() throws Exception { ... }
}
This shape is deliberate:
-
@Configurable- the class is created withnew, and the AspectJ-woven configurer injects its@Autowiredfields (services,Localization, the sessionUser). See AspectJ weaving. -
@Extendable- the class is instrumented at startup so extensions can run code before and after any of its methods and read its local variables (seeExtensions ). Small methods with descriptive local variable names (menu,toolbar,detailLayout) are what make a class easy to extend.
Dialogs (AIDialog, ProtocolDialog, UserDialog, LorebookDialog, ManuscriptDialog...) extend Vaadin's Dialog
but follow the same pattern: construct with the entity, call create(), then open().
The workspace
Workspace builds an HTabSheet (tabs on the left, a footer with Admin, Change Password, Logout) and creates
all tab components up front. Each tab is a WorkspaceComponent:
Workspace.setNavigationLocked(true) disables the other tabs and the Admin and Logout buttons, so the user can't
leave the active tab. UserPart locks it while the settings have unsaved changes: it listens to every HasValue
in its layout (extension settings panels included) and unlocks on Save, Discard Changes or refresh().
The Admin button is only added for administrators; AdminPart shows the users grid and the
DatabaseBackupPanel, CleanupPanel and extensions tab (with the verification report of each extension). Note that the admin UI being hidden is not a permission
check - the administrator-only services call AdminGuard.requireAdmin() themselves (see
Services).
The book window
Opening a book creates a ManuscriptDialog - a full-screen, strictly modal dialog with a VTabSheet of
ManuscriptDialogParts:
The dialog holds the book (getManuscript(), refreshManuscript() reloads it, save() saves it) and
freeze() / unfreeze(), which disable the tabs, the exit button and every part during generation.
ManuscriptStoryPart is the story editor and the largest UI class: the sidebar with one button per part, a
ChatMessageCard per part (Markdown view, edit mode, menu with Edit / Regenerate / Swipe / Branch / View prompt /
Summary / Delete), the New Turn Instructions popover, starting generations and applying the result, the automatic
book backups, and the menu bar of the bottom bar (see
The story menu bar
The bottom bar of the story editor has a MenuBar with two items, built by small methods so that extensions can add
to it (see Extending the UI):
The summaries overview
SummariesDialog shows the summaries of the active branch (SummaryService.collectTree) in a TreeGrid<SummaryNode>:
the summaries generation uses are the top level, the summaries a meta summary stands in for are its children
(including the summary it replaced, read from replacedSummary, which has no id). Columns: database id (the
hierarchy column), message id, order in the branch, content, tokens, a checkbox and a delete button. Checkboxes,
delete buttons and the edit pencil exist only on the top level - the text of the merged summaries is part of the hash
of their meta summary, so editing them would invalidate it.
-
Content (
SummaryContent): reasoning (aDetailswithMarkdown, reasoning is mostly markdown), badges (Meta summary, Replaced) and the text as plain text (white-space: pre-wrap, summaries are not markdown and the model's single line breaks must show), in a scrollingDivof a fixed height, so all rows have the same height. The pencil is in a gutter next to the scroll area (not over the text); it swaps the text for aTextAreaof the same size and the buttons for a green check (SummaryService.updateSummaryText) and a red trash icon (back to the Markdown). -
Delete goes through
SummaryRemoval.confirmAndRemove, shared with the part menu of the story editor: a normal summary is confirmed with yes/no, a meta summary asks Unwind / Delete / Cancel (ConfirmDialogwith custom button labels). -
Create meta summary takes the ticked summaries, uses the newest as
fromand the oldest astoand opensSummaryDialogwith a meta request (createMetaSummary); it needs at least two. -
The grid is reloaded after every change (
refresh()): editing, deleting, and closing the dialog of a meta summary.
SummaryDialog is the dialog of one summary: it generates it (startGeneration, streaming into a read-only text area,
with a Stop button), or shows the existing one. It is created with the part, and with the oldest part of the range
for a meta summary. It was an inner class of ManuscriptStoryPart; both the story editor and the overview open it now.
Both dialogs are @Configurable(preConstruction = true) (they use injected services in their constructors) and
@Extendable; the overview's menu bar above the grid is empty, populateMenuBar(bar) is where extensions add tools.
Grids
Lists of entities use Vaadin Grid with a lazy data provider. BackendTableProviderBase (in ui.widgets) is the
common base: the owning part searches for the ids to show (ManuscriptService.searchManuscripts(...) returns ids,
already filtered and sorted), passes them with setIds(ids), and the provider loads only the entities of the visible
page (service.find(id)) and wraps them in a BackendTableItem subclass. It also tracks checked rows.
Widgets
Trash
TrashGrid is a self-contained widget (@Configurable, create it with new TrashGrid(allUsers).create()) over
TrashService: a lazy multi-select Grid<TrashItem> (items identified by their EntityKey, so the selection
survives paging), a filter by type (names from Localization.localizeDomainObject(Class)) and, with allUsers, a
filter by owner and an owner column. allUsers only decides what the widget shows; the service decides what the
current user may see and restore. Restore Selected confirms and calls TrashService.restore; when it returns
blockers the widget shows them in a dialog (nothing was restored), otherwise it refreshes and runs the listeners from
addRestoreListener (TrashPart uses it to refresh the whole workspace). The Extended Content link opens the JSON
from TrashService.getExtendedContent in a read-only text area. TrashPart (the Trash tab, allUsers for
administrators) and CleanupPanel (a Details section, created when first opened) both use it.
Generic dialogs: ConfirmDialog.show(message, onYes), TextInputDialog.Builder, ListSelectDialog,
ErrorDialog (message and expandable stack trace), ProgressBarDialog.
Conventions
Text
Never hard-code user-visible text. Add a key to loc/L.java and the English text to LocalizationEN, and use
loc.getValue(L.KEY) (Localization is injected into every UI class). Date formats come from the same bean
(loc.getDateFormat()...). The name of a kind of entity (book, lorebook...) is loc.localizeDomainObject(entityClass); it falls back to the closest known superclass, then to the simple class name, so a new entity type needs a mapping in LocalizationBase.loadMaps().
Errors
Wrap event handlers in try/catch and report unexpected exceptions with
UIUtils.internalServerError(loc, e) - it logs the exception and opens an ErrorDialog with the stack trace.
Expected problems (validation, missing configuration) are shown with Notification.error(...) or a message next to
the field; UIUtils.showValidationErrors(loc, e) formats a binder's ValidationException. Failures of a request to the
model API (InferenceException: wrong key, wrong URL, timeout...) go through UIUtils.inferenceError(loc, e), which
shows the localized cause (InferenceErrors) as a notification and falls back to internalServerError for anything
else. AIDialog has the Test Connection button (InferenceService.testConnection() with the values of the form).
Saving
Most forms save automatically: value change listeners call an autosave() method of the part (guarded so that
loading values into the fields doesn't trigger a save). The story editor saves an edited part when the edit is
closed (autosaveAndSwapToMarkdown) and before a generation starts (autosaveAndSwapAllToMarkdown). Entities held
by the UI are detached copies - reload them through the service before changing them, and keep the object returned
by save.
Background work and push
Vaadin component state may only be touched on a thread holding the session lock. The rules:
-
Long work (generation, summaries, backups, imports) runs on another thread; the UI thread returns immediately.
-
Updates from that thread go through
ui.access(() -> ...), with theUIcaptured on the UI thread (UI.getCurrent()isnullelsewhere).UIPushGuard.push(ui)sends them right away. -
Service calls on that thread need the user's request scope - capture
ThreadCopyRequestAttributes.create()on the UI thread and wrap the work inInRequestScope(see The current user). -
ThreadAccessDialogpackages all of this for dialogs:run()startsrunInThread()on a new thread in the request scope, andvaadinLocked(...)/vaadinLockedSync(...)run UI updates withui.access/ui.accessSynchronouslyand push.ProgressBarDialogbuilds on it.
The story editor is the main example: startGeneration freezes the book window, calls
StoryGenerationService.generateNextTurn with a GenerationListener, and every callback (onNodeCreated,
onResponseChunk, onComplete...) updates the cards inside ui.access(...) and pushes.
Browser-side code
Prefer Java components. When the browser has to do something (scroll positions, element sizes), use
element.executeJs(...) and read the result with .then(...) - see UIUtils.getPositionAndSizeOfElement and the
scroll handling in ManuscriptStoryPart.
The only JavaScript module is src/main/frontend/treant-connector.js, loaded by TreantTree (@JsModule) together
with the npm packages treant-js and raphael (@NpmPackage) and treant's CSS (@CssImport). It draws the story
tree; TreantTree sends the tree as JSON and receives clicks back: node-click (a card was clicked, with the
data-node-id of the card) and node-goto (an element with data-goto-id inside a card was clicked; the card itself
is not clicked then).
Searching the story tree
ManuscriptTreePart has a search bar above the tree: an editable ComboBox (the last 15 searches; typed text is a
custom value that the part sets as the value) and a button. A search calls ChatMessageService.searchFulltext and
keeps the result as lastQuery and hitSnippets (part id → text around the match); renderTree then adds the CSS
class search-hit (shared-styles.css) to the cards of the found parts and, in them, the snippet and a "Show in story"
link (data-goto-id). Stretches of more than 20 parts that would be collapsed stay open when they contain a hit.
The search is kept while the tab is open and repeated on every onTabEnter; load (opening the book) clears it.
The link fires NodeGotoEvent. onNodeGoto resolves the id among the messages of the book only, asks
ChatMessageService.findLeafFor for the end of the branch to show, makes it the book's active leaf when it is not
(ManuscriptDialog.save) and calls ManuscriptDialog.showMessage(id). That tells ManuscriptStoryPart which part to
scroll to (scrollToMessageOnNextRender) and selects the story tab; entering the tab renders the story, which scrolls to
the part (and flashes it) instead of restoring the saved scroll position.
Styles
Global styles are in src/main/frontend/styles/shared-styles.css (imported by AppShellConfig): spacing helpers,
the loading indicator, the story text (.chat-message, and .manuscript-styles .chat-message-markdown for the book
style of the reader and editor) and the story tree (.Treant). Class names used from Java are constants in
SharedStyles; column widths and offsets in UIConstants. Use Lumo's CSS custom properties (--lumo-space-m,
--lumo-contrast-10pct...) instead of fixed colors, so the dark theme stays consistent.
Extension points
Every class annotated @Extendable can be extended by plugins: a decorator registered for a class and method name
runs before and after that method and can read its arguments and local variables. The UI classes that are
@Extendable:
New screens, dialogs and components with their own logic get @Extendable too (a nested class needs its own
annotation). Left out on purpose: classes with only static methods (TemplateHints, SummaryRemoval, UIUtils; static
methods are not instrumented), the generic dialogs and widgets used everywhere (ConfirmDialog, ErrorDialog,
ProgressBarDialog, Notification, HTabSheet, the BackendTable* providers), the plumbing (servlet, app shell,
initializer, ThreadCopyRequestAttributes) and small data wrappers.
The bundled plugins hook into these methods:
Because plugins depend on method names and local variable names, renaming or restructuring these methods breaks
plugins. When you change an @Extendable class, check the plugins in marginalia/plugins/ and mention the change
in the release notes. New UI that should be extensible: put it in an @Extendable class, build it in small,
well-named methods, and keep the components extensions will want (menus, toolbars, layouts) in local variables or
fields with clear names. See Extending the UI.
Viewer
Viewer (/view) is a separate, reader-only route. It requires a login like the workspace (with its own saved-login
cookies). Without a parameter it lists the user's own books, with name and tag filters and sorting by last opened or
name; other users' published books are opened by their link; with /view/<uuid> it shows the book's active branch as Markdown in
the book's reading style. Books are resolved only through ManuscriptService.findViewable(uuid) (owner or
published, never deleted). The layout is made for phones.