Desktop app (GUI)¶
cmd/labmill-gui is Labmill's desktop app, built with Wails v2 (system webview, plain HTML/CSS/JS, no npm build step). It shares the runtime with the terminal interface.
Build and run¶
make gui-linux # go build -tags desktop,production,webkit2_41 -o bin/labmill-gui ./cmd/labmill-gui
bin/labmill-gui
Linux needs webkit2gtk-4.1 and cgo. macOS and Windows use the same command on those hosts, without webkit2_41. The package is behind the desktop build tag, so make check and the cross builds need no webkit.
Smoke test¶
It builds the app and runs it with a throwaway HOME and XDG_* folders, so it shows the first-run setup page and never touches your real configuration. With LABMILL_GUI_SMOKE=1 the app quits 3 seconds after the page has loaded; the target fails unless the exit status is 0. It works under WSLg. No screenshot tool works there (X get_image fails), so there are no screenshots yet.
Self-test¶
LABMILL_GUI_SELFTEST=1 skips launch.Build and wires a scripted fake session (cmd/labmill-gui/selftest.go) through the real gui.Bridge. The page learns of the mode through PageAPI.SelfTest and loads frontend/selftest.js, which drives the real DOM: a hostile summary (<img src=x onerror=...>) must stay text, Decline must be focused, input during the grace period must send nothing, Approve must work afterwards, the login field must be a password input and be cleared after submit, and labmill:done must arrive. After the login, a choice dialog (ask_user) with markup in its question and an option description must keep that markup as text and focus the first option; a number key and a click inside the grace period must pick nothing, and ArrowDown + Enter afterwards must pick option 2 (Go checks that Choose was called once with index 1 and the session received ChoiceAnswer{Index: 1}). The scripted reply is Markdown streamed in small pieces: <strong>, <em>, <code>, <pre>, a heading, a list and a quote must be rendered, while [x](javascript:alert(1)), , raw <img onerror> and <a href> markup and an unterminated code fence must create no <a>, <img> or <script> element and leave window.__pwned undefined (links show as "x (javascript:alert(1))"). It also checks the chat font size (at least 15 px), monospace code, a collapsed tool line ("load_skill · 40 lines") and that job-table cells don't wrap. The self-test also checks the divider: it is a focusable vertical separator between chat and jobs, the width seeded by Go (30 %) is applied at start, Left widens the panel by 2 %, a drag and Home/End change the column, a double-click resets, the value reaches SetJobsWidth (Go checks that a widened width (at most 60 %) was saved and the last save, from the reset, is 0), squeezing the window and restoring it brings the panel back to the chosen width, and the narrow layout hides it. A job notice sent while the first reply is still open must render as a system line with its markup as text, placed above that reply. Then the page sends "slow" (a turn that waits for Cancel). While it runs, the input and Send must stay enabled, Send must read "Queue", Send queues "queued one", and Enter replaces it with "queued two". Cancel must send "queued two" (shown as a user message, queued line gone), and "queued one" must never be sent. The self-test job list must show Status, Logs and Cancel for the running job, Status, Logs and Fetch for the completed one, and nothing for the draft. Clicking Status must send "Show the status of job #12". Go checks that the turns were exactly "self-test", "slow", "queued two" and "Show the status of job #12". While "slow" runs, Go sends a transfer start, one progress update and (after Cancel) the end notice: the page must show the rclone-style line and a 25% bar in the progress row, the rclone-lib: start line as text, and remove the row when the transfer ends. The page reports through PageAPI.SelfTestReport; a second approval is declined with the reason "use the gpu partition" typed into the reason input (Enter inside the input must send nothing); Go also checks that Decide was called twice (approve=true, then approve=false with that reason, arriving as Decision.Reason) and Answer once with "pw". After the page has reported, Go closes the window the way the X button does (runtime.Quit, which runs OnBeforeClose on every platform): the window must hide (gui.Lifecycle.Hide ran), the app must not shut down or quit within 0.7 s, the first hide must ask for the one-time tray notice, and the notice must be remembered in the prefs file. Where the desktop has no tray host (WSLg) the hide path runs with a simulated tray; the output says whether a real tray icon was up. Then the tray menu's Quit path (gui.Lifecycle.Quit) must run the shutdown and end the app with status 0. The make targets also launch a second copy once the first has hidden its window and written the "ready" marker (labmill-selftest-second; LABMILL_GUI_SELFTEST_SECOND=1 on the first): it must hand over to the running one (single-instance lock, a self-test-only lock name) and exit at once, and the first must see the handover. After the job buttons the page checks the folders: the old buttons above the chat must be gone; the + button (aria-label "Add folder", aria-haspopup="menu") must sit at the upper left inside the input box, which must be the only drop target (--wails-drop-target, not on the conversation). Enter on + must open the menu with "Add data folder…" first and "Add code folder…" second, focus the first item, ArrowDown must move and wrap, and Esc must close it and return the focus to +. Space opens it again and Enter on "Add data folder…" calls PageAPI.PickFolder("data"), whose stub dialog returns labmill-selftest-folders/home/data next to the executable (the code folder for the code title): a "Data: ~/data" chip must appear above the text with the full path in its tooltip and an × labelled "Remove the data folder". The next message ("data only check") must carry the data folder and the data-only sentence (app.DataOnlyPrompt) in the system-prompt addition (Go records Workspace.Prompt() at the start of every turn, as agent.SystemExtra would). Then "Add code folder…" adds a Code chip after the Data chip, and "workspace check" must carry both folders (data first) without the data-only sentence. The Code chip's × removes only the code folder; the menu must offer it as a recent folder labelled "Code: ~/code" (after a separator, not the data folder in use) and set it again; "Remove code folder" removes it, and the Data chip's × leaves no chips and the focus on +. A simulated Wails drop (__labmillTest.drop(x, y, paths), the handler runtime.OnFileDrop calls) onto the conversation must be ignored; onto the input box it must show the choice "Add as data folder" (focused) / "Add as code folder" naming the folder as text; a hidden folder added as data must be refused with a message, Esc must cancel the choice, and the data folder dropped and added as data must be set. The "fetch check" turn then sends a successful fetch_results: the chat must show an "Open folder" button, the fetched job (#15) must offer Status, Logs and Open folder, and both Open folder buttons must reach the stub opener with the job's results folder (Go checks; no chat message is sent). Go also checks the turn list (now ending in "data only check", "workspace check", "fetch check"), that the dialog opened twice with the titles "Choose your data folder" then "Choose your code folder", that "fetch check" carried only the data folder plus the data-only sentence, that the turns before any choice added nothing, and the final workspace (code cleared, data set, each remembered as recent). Busy feedback: before anything else the page checks that the startup splash was visible and animated when app.js first ran (__labmillTest.splashAtLoad, recorded at script start), that index.html itself (fetched and parsed) shows the hourglass and "Starting labmill…" (aria-live polite) with the chat hidden and has no inline script or style, and that app.css has a prefers-reduced-motion rule that sets the hourglass animation to none. At the end the "working check" turn (Go waits 300 ms, sends the step "Connecting to selftest.example…", then "Waiting for Claude…", then the text) must show the "Working…" row within 100 ms of Enter (before Go said anything), with an hourglass and an aria-live polite text, then each step before the first token with Cancel available, the elapsed seconds (the threshold shortened to 1 s through __labmillTest.elapsedAfter), and no row once the text started and after the turn. Then, with navigator.clipboard stubbed, every code block in the chat must have a Copy button (aria-label "Copy", no inline handler, not part of the block's text) that copies the block's text. Exit status: 0 passed, 1 failed, 2 timed out (45 s). Like the smoke test it uses a throwaway HOME under tmp/. The self-test never reads or writes the real gui.json or workspace.json: it starts from default prefs and writes labmill-selftest-gui.json next to its executable (bin/ here, %TEMP%\labmill-selftest on Windows), and creates (never deletes) the folder tree labmill-selftest-folders/ there for the folder checks; its workspace stays in memory.
Self-test in a container¶
A Linux host without Xvfb or webkit2gtk (for example a build server) runs the self-test in a container. tools/gui-selftest/Dockerfile adds xvfb, libgtk-3-dev, libwebkit2gtk-4.1-dev and D-Bus to the official golang:1.26.8-trixie image (Debian 13, amd64 and arm64):
docker build -t labmill-gui-selftest:1 tools/gui-selftest
docker run --rm -u "$(id -u):$(id -g)" -v /etc/passwd:/etc/passwd:ro --tmpfs /tmp:exec -e HOME=/tmp/home \
-e GOMODCACHE=/gomod -e GOCACHE=/gocache \
-v "$(go env GOMODCACHE)":/gomod -v "$HOME/.cache/labmill-gui-selftest-go":/gocache \
-v "$PWD":/src -w /src labmill-gui-selftest:1 dbus-run-session -- make gui-selftest
Create ~/.cache/labmill-gui-selftest-go first. Give the container its own build cache: a cache shared with a host Go of another version gave "could not import encoding/json". dbus-run-session is necessary because the single-instance lock of Wails on Linux uses the D-Bus session bus; without it the second-launch check fails (exit status 124). The bus refuses to start for a user ID that has no passwd entry, so the host's /etc/passwd is mounted read-only. The container has no tray host, so the hide path runs with a simulated tray, as under WSLg. The image needs about 2.5 GB of disk and the build cache about 600 MB; check the free space first. The first build of the image and of the app takes some minutes; later runs take about a minute.
Windows¶
Wails v2 on Windows uses WebView2 through pure Go (no cgo), so the Windows GUI cross-compiles on Linux:
make gui-windows # bin/cross/labmill-gui.exe
make gui-windows-selftest # WSL only: runs the self-test on the Windows host
gui-windows first runs cmd/labmill-winres, which writes cmd/labmill-gui/rsrc_windows_amd64.syso (gitignored, overwritten each build): the Labmill icon (assets/icon/labmill.ico, 16-256 px) as icon group ID 3, which is both the exe's icon in Explorer and the window icon Wails v2 loads, plus version information. It uses github.com/tc-hib/winres (0BSD; new to this module's own imports, it was only an indirect Wails dependency), pure Go, so it runs on Linux; go build links the .syso automatically. A build on a Windows host runs the same go run ./cmd/labmill-winres first.
gui-windows-selftest copies the exe to %TEMP%\labmill-selftest (overwritten each run) and starts it through WSL interop. It passes LABMILL_GUI_SELFTEST with WSLENV. Self-test mode builds no runtime and writes no Labmill files except labmill-selftest-gui.json and the labmill-selftest-folders tree next to the exe; WebView2 creates its usual cache folder and the tray library writes its icon to %TEMP%\systray_temp_icon_<hash>. Its tray icon shows for a few seconds, but the window never does: in self-test mode on Windows the app starts with StartHidden, showWindow (tray Open, the second-launch handover, attention wake-ups) only records the internal "shown" state and shows nothing, so the desktop gets no window, focus change or taskbar button while the test runs. WebView2 loads the page and runs the full DOM script while hidden. The self-test proves it from inside (windowProbe in selftest_windows.go: EnumWindows over the process's top-level windows with IsWindowVisible, GetForegroundWindow, the rect and the tool-window style), before the close and again after the second-launch handover, prints the result as INFO lines and fails if any window is visible or foreground; the handover is asserted through the internal state. Linux needs no such mode (Xvfb), and its hidden window would throttle the page's timers. It passes all checks in real WebView2, including the close sequence (gui.Closer), close-to-tray with a real tray icon, the tray Quit path and the single-instance handover. Not covered yet: the real first-run flow on Windows (setup page plus SSH login with Duo), an installer, Authenticode signing, and voice.
Tray and closing the window¶
Closing the window must not end the SSH logins, so the app stays running in the tray like OneDrive or Teams and quits only from the tray menu.
- Library:
fyne.io/systrayv1.12 (Apache-2.0, maintained by the Fyne project). Compared:github.com/getlantern/systrayuses GTK/libappindicator on Linux (a second GTK main loop next to Wails' one) and is unmaintained;github.com/energye/systrayis a fork of the same code with click handlers but older and less maintained. fyne's version has a pure-Go D-Bus StatusNotifierItem on Linux (no GTK), left-click handling (SetOnTapped) on Windows, and checkbox items. None of the three can show a balloon notification. - Windows (primary):
systray.Runruns in its own goroutine locked to an OS thread (its hidden message window and its message loop must share a thread;RunWithExternalLoopstarts the loop in an unlocked goroutine, which can't work on Windows). This coexists with Wails' UI thread. Icon:tray-dark.icowhen the taskbar is light (HKCU\...\Themes\Personalize\SystemUsesLightTheme= 1), elsetray-light.ico; read once at start. Left click opens the window, right click shows the menu. Verified on Windows 11 with WebView2. - Linux (best effort): the tray needs a StatusNotifierWatcher on the session bus (KDE, most panels, GNOME with the AppIndicator extension).
trayAvailableasks D-Bus whetherorg.kde.StatusNotifierWatcherhas an owner; without one there is no tray and closing the window quits as before. Icon:tray-light-32.png. WSLg has no tray host, so only the simulated hide path is tested here. - macOS: no menu-bar icon yet. fyne's menu-bar item has to be created on the main thread, which Wails v2's
NSApploop owns; that needs a small main-thread shim and a Mac to test it, so it is deferred. Instead the Mac behaves like a Mac app:HideWindowOnClosehides the app on the red close button (Wails then doesn't call OnBeforeClose), File > Close Window (Cmd+W, our menu: Wails' default App/Edit/Window menus plus File) hides it the same way, a click on the Dock icon shows it again, and Quit (Cmd+Q) goes through OnBeforeClose and quits. The bundle icon (assets/icon/labmill.icns) goes into the.appbundle once macOS builds exist. There is no one-time notice on the Mac. The darwin file can't be compiled here (Wails on macOS needs cgo and the SDK); it was type-checked by compiling a copy of the package with it as a Linux file. - Menu: Open Labmill; a disabled status line,
gui.TrayStatus("2 jobs running, 1 waiting · logged in to bigcluster"), from the job store (submitted, pending, running) and the helper's logins (sshx.Keeper), refreshed every 10 s and at once when the runtime is ready ("Labmill is starting…" until then); Start in the tray when I log in (checkbox); Quit Labmill. - Close → hide (
gui.Lifecycle): OnBeforeClose callsLifecycle.BeforeClose. While a tray icon is up it hides the window (in a goroutine; it never blocks the UI thread) and keeps the app running. The first hide ever showsgui.TrayNoticeas a chat line (labmill:notice) and in a native message box shown before the window hides (no tray library offers a balloon; Wails' toast notifications were not used because they register a COM activator underHKCU\Software\Classes), and recordstrayNoticeShowningui.json. Without a tray, or once a quit was requested, it hands over togui.Closer(unchanged: hide, cancel the turn, decline pending dialogs, close the runtime, quit; 30 s watchdog). A shutdown is never turned back into a hide, even if a tray appears meanwhile. - Quit: the tray's Quit calls
Lifecycle.Quit, which starts the Closer; its finalruntime.Quitre-enters OnBeforeClose and is let through. Afterwails.Runreturns the tray icon is removed. - Logins stay up:
sshx.Keeperholds asshx.Holdconnection to the helper while the app runs, shown or hidden. The helper never idle-expires while a client is connected, and a hold client is accepted without any question and opens no channels, so it never logs in or triggers a Duo push; it also answerslogged-in@labmillwith the aliases it holds logins for (the tray's status line). The Keeper only attaches to a helper that is already running (it never starts one) and retries every 15 s; after a handshake error other thanErrNoHelper(an older helper) it backs off 10 min. Quitting cancels it; the helper's normal 10-minute idle expiry then applies. - Single instance:
options.SingleInstanceLock(singleInstanceIDinmain.go): a second launch hands over to the running app, which shows its window (with a short always-on-top so Windows brings it to the front), and exits. Not in smoke mode; the self-test uses its own lock name. - Start in the tray at login: the tray checkbox (off by default) calls
internal/autostart: WindowsHKCU\Software\Microsoft\Windows\CurrentVersion\Run\labmill="<exe>" --tray(turning it off deletes that value); macOS a plist in~/Library/LaunchAgentsnamed after the entry's reverse-DNS label (RunAtLoad; off rewrites it withDisabled); Linux~/.config/autostart/labmill.desktop(off rewrites it withHidden=true). The checkbox shows whether the entry is registered;startInTrayingui.jsonrecords the choice.enabledaccepts whatever command the entry runs (Windows: the Run value exists; Linux: the file exists withoutHidden=true; macOS: noDisabledkey). Started with--tray, the app starts hidden (StartHidden) when a tray is available and no first-run setup is needed; if the tray then never comes up (startTraygives up after 5 s), the window is shown. While hidden,gui.WakeOnAttention(wrapping the Bridge's emitter) shows the window when an approval, login prompt or choice event arrives; job notices never do. - Prefs:
gui.jsonnext toclusters.yaml(gui.Prefs:trayNoticeShown,startInTray,window,jobs_width), written atomically with mode 0600. - Window geometry (
internal/gui/window.go,windowwatch.go).gui.jsonwindowholdsWindowGeom(x, y, width, height, maximized).WindowWatcherpolls the window about once a second through aWindowProbe(Wails v2 has no move or resize event), saves when the geometry has been stable, and saves again when the window hides to the tray and on close; while maximized it keeps the last normal size.Restore/RestoreScaledrun once, at start (applyGeomin OnStartup); a window that starts hidden in the tray gets its saved maximized state at the first show (pendingMax), and hiding and showing it again does not move it. A saved position outside every monitor falls back to centered at the default size, a size below the minimum is raised to it, and on Windows the size is scaled for display scaling. Windows sets the position withSetWindowPos(no activation), Linux uses a small cgo helper, macOS restores only an offset on the same screen. The self-tests restore 820x610 at 120,90. - Jobs panel divider. A vertical divider (
#divider,role="separator",aria-orientation="vertical", focusable,aria-valuenowin percent) sits between.chatandaside.jobs. Pointer drag, Left/Right arrows (2 % per step), Home/End and a double-click (back to the default) move it. The jobs panel's share of the main area defaults to 40 % (gui.DefaultJobsWidth) and is clamped to 15 to 60 % (MinJobsWidth,MaxJobsWidth;gui.ClampJobsWidth, where 0, negative, NaN and Inf mean the default), and the frontend also keeps both sides at a readable width. It is saved as a fraction ingui.jsonjobs_width(Prefs.JobsWidth, omitted when default) throughSettingsAPI.SetJobsWidth(debounced by the page) and read at start withSettingsAPI.JobsWidth(0 = default). It is stored apart fromwindow, so a window move and a divider move are saved independently. The frontend keeps the chosen width (jobsWant) apart from the shown one, so a narrow window never shrinks the choice; a drag saves at once, a key change after 400 ms, and saves are chained so a later one can't be overtaken. Under the@media (max-width: 760px)layout the panel sits below the chat and the divider is hidden.
Folders and "Open folder"¶
The user points Labmill at their code and their data without typing paths.
- Workspace (
internal/app/workspace.go).app.Workspaceholds the chosen code and data folders and the last 5 of each.app.CheckFolderdecides whether a folder may be chosen: it must exist and be a directory, not a drive root, not home's parent or any folder containing home; inside home it must pass the usual privacy rules relative to home (no hidden or secret-looking component, not~/Libraryon macOS orAppDataon Windows); outside home it must not be a system folder (/etc,/usr,/opt,/System,/private,C:\Windows,Program Files,ProgramData,Users, and WSL's/mnt/cwith its Windows system folders) and no component may be hidden./mnt/<x>,/media/<x>,/Volumes/<x>and/run/media/<user>/<x>(mounted drives) are fine. The workspace is saved toworkspace.jsonnext toclusters.yaml(0600, atomic), shared by the terminal and the desktop app;launch.Buildloads it (a saved folder that no longer passes is dropped with a startup warning). Each process keeps its own copy in memory;SetFolderre-reads the file and merges the other kind and the recent lists before saving, so a stale process doesn't overwrite the other's choice, but a change in one shows in the other only after a restart. On macOS and Windows the home and system-folder rules compare case-insensitively,/Users/<name>/Libraryis refused outside home, and macOS's hidden flag counts as hidden.PageAPI.SetFolderaccepts only "" or a folder from that kind's recent list. - Conversation context.
agent.Agent.SystemExtra(new) is called once perSendand appended to the fixed system prompt;launch.Buildsets it toWorkspace.Prompt, which names the folders (data first) and says they are the default roots and that choosing one sent nothing. With a data folder and no code folder it addsapp.DataOnlyPrompt: "The user has data but no code yet: offer to write the analysis code for them (it is shown to the user before anything runs)." Many users have no code; the usual approvals still show any generated script before it runs. The fixed prompt stays prompt-cache friendly. - Tool defaults. The Workspace folders are extra allowed roots (only the ones outside
LocalRootsare added).find_local_datawithoutundersearches them (else every root);preview_fileresolves a relative path in the code folder, then the data folder;stage_datawithoutrootstages from the data folder, else the code folder. The privacy rules and the approvals are unchanged; staging still only builds a list. - Page. A "+" button (aria-label "Add folder",
aria-haspopup="menu") at the upper left inside the chat input box (#composer) opens a menu that follows the menu-button pattern: Enter, Space and ArrowDown open it at the first item, ArrowUp at the last; arrows (wrapping), Home and End move; Esc closes it and returns the focus to +; Tab or a click elsewhere closes it. Items: "Add data folder…" (first, because many users only have data), "Add code folder…" (bothPageAPI.PickFolder→gui.App.PickFolder→FolderHost.Pick=runtime.OpenDirectoryDialog, starting in the current folder or home), a separator and up to 5 recent folders not in use, taken from the data and code lists in turn and labelled "Data: …" / "Code: …" (SetFolder(kind, path), recent-only), and "Remove data folder" / "Remove code folder" when set (SetFolder(kind, "")). Chosen folders show as chips above the text inside the box, data first: "Data: " +FolderView.Short(app.ShortFolder:~for home, "…/last/two" when long), the full path in the tooltip, an × (aria-label "Remove the data folder"). All text is set withtextContent. Refusals show as an error line just above the box.gui.FolderHost(Env,Pick,Open) connectsgui.Appto the runtime; the self-test installs stubs. - Drop. Wails file drop is on (
EnableFileDrop); the webview's own drop also stays on because WebView2 delivers no drop at all withDisableWebViewDrop(it setsAllowExternalDrag(false)). Only the chat input box carries--wails-drop-target: drop(custom properties inherit, so its children count too), the page registersruntime.OnFileDrop(handler, true), and its handler also requires the element under the drop point to be inside the box; a dragover elsewhere getsdropEffect = 'none', and every dragover and drop ispreventDefaulted, so the page never navigates to or opens a dropped file. A drop shows a small inline choice in the box, "Add as data folder" (focused) / "Add as code folder" / × (Esc cancels), naming the folder's last component as text; the choice callsPageAPI.DropFolder(kind, [path]), which uses only the first path, through the sameCheckFolder. A real drop needs a desktop session; the self-test simulates the handler call. - Open folder.
gui.Bridgeremembers thejob_idof afetch_resultscall and puts it on the successful result'sToolEvent(jobId); the page then adds an "Open folder" button. Fetched jobs with a local folder get the actionopenin the job list. Both callPageAPI.OpenJobFolder(id):gui.Apptakes the folder from the job store (statefetched), checks it withapp.Env.OpenableFolder(an existing directory inside the results folder or an allowed root that passes the privacy rules) and callsgui.OpenFolder, which startsexplorer.exe(from%SystemRoot%; names with,or"are refused because Explorer parses them),/usr/bin/openorxdg-openwith the folder as the only argument. No shell and nofile://URL are involved, and nothing is sent to the model. - Terminal.
/data <folder>and/code <folder>(tui.Options.Folders, the same Workspace): a relative folder is taken from the current folder,~is home, one pair of quotes is dropped;/datashows it,/data -clears it. They never start a turn. Tab completes folder names while the input is such a command; the status bar shows "Data: …" then "Code: …", and the welcome tip names/datafirst and says no code is needed.
Busy feedback: the hourglass¶
The app shows activity before anything slow happens, so the user can tell that Labmill is doing something while it loads.
- Startup splash.
index.htmlitself contains#starting(data-splash="1"): a pure-CSS hourglass (.hourglass, two border triangles in a frame; the top one fades while the bottom one fills, then the glass turns over) and "Starting labmill…" in anaria-live="polite"status. It is visible on the first paint, beforeapp.jsor the Wails runtime load; the CSP stays strict (no inline style or script).main.gobuilds the runtime withlaunch.BuildWithProgress, whoseProgresscallback reportslaunch.StartLoadingSettings("Loading your settings…"),StartOpeningDatabase,StartLoadingSkillsandStartReady. Each step is emitted aslabmill:startup({step}) and stored inState.Step, so a page that loads late starts with the current one. After 10 s on the splash a second line says "This is taking a while. Still waiting for: loading skills (14 s)." (before the first step: "Labmill to start"); it counts again from the first-run form's submit. The chat replaces the splash as soon as the page has its Go bindings (phase "starting"): the input works at once, the status line shows the startup step, and a message sent before the runtime exists waits ingate.Send(a running turn) and is forwarded once.labmill:readysets the status line. The runtime starts before the window on Windows (a second launch is detected withotherInstanceRunningfirst). Windows exe flags:-trimpath -s -w(smaller, faster loader). WebView2 uses one fixed user-data folder,%LocalAppData%\labmill\WebView2(webviewUserDataPath); the old default folder (%AppData%\labmill-gui.exe) is left behind, never deleted. - Native start splash on Windows. The page-level splash above only exists once the window and WebView2 are up; before that (loader, antivirus scan of a new exe, Go init, tray, Wails) Windows showed nothing, and the tray's message loop ends the app-starting cursor at once. So
mainstarts a native splash right after the helper-subcommand check and before the tray andwails.Run(startSplashinsplash_windows.go;splash_other.gois a no-op). It is a borderless popup (WS_POPUP|WS_BORDER,WS_EX_TOOLWINDOWso no taskbar button,NOACTIVATE, not topmost (the new main window covers it), shown withSW_SHOWNOACTIVATEso it never takes focus), 360x120 at 96 DPI scaled by the system DPI, centered on the rectangle the saved window geometry (read alone fromgui.jsonbysavedWindowGeom) opens in, else the primary work area (splashRect, clamped into the work area). It shows the app icon (exe resource 3,cmd/labmill-winres), "Starting labmill…", a self-drawn moving bar (a progress control's marquee needs common controls v6, which needs a manifest) and the app-starting cursor (WM_SETCURSOR), in the dark or light colours of the Windows app theme (AppsUseLightTheme). It runs on its own locked OS thread with its own message loop.closeSplash(thread-safe, idempotent: postsWM_CLOSE) is called fromOnDomReady,showWindow(tray open),OnShutdownand afterwails.Runreturns; the window also closes itself after 60 s. Skipped (splashWanted):--traystarts (also when a setup form would show), the self-test and smoke test, and a second launch that only hands over: Wails exits that process insideRun, somainprobes the single-instance mutex first (otherInstanceRunning, the same cheap probe the tray uses) and shows no splash, so there is no flash. Timing marks: "gui: splash shown", "gui: splash closed". The Windows self-test checkssplashStarts == 0and that no splash step is in the timing list; the visible splash itself is not checked by any test. - Tray. The tray icon is started before
wails.Run(it needs nothing from Wails) with the tooltip and status linegui.TrayStarting("Labmill is starting…"); the status loop is refreshed at once when the runtime is ready or failed (not only every 10 s), and the tooltip becomesgui.TrayTooltip("Labmill: no jobs running · …"). A tray click before the window exists is remembered and shown in OnStartup; the tray's Quit does nothing until the window exists. - "Working…" row.
#workingsits under the conversation (outside therole="log"region, so its updates aren't read as chat): a small hourglass,#working-text(role="status",aria-live="polite") and#working-time.send()shows "Working…" synchronously when a new turn starts (not when the message is only queued), so it appears before Go answers;labmill:turnrestarts it.labmill:step({text}, fromagent.EventStepviagui.Bridge) and a tool call ("Running…") set its text; labmill:texthides it (a later step or tool call shows it again);labmill:donehides it. After 5 s it shows the seconds since it appeared (a 250 ms timer), so it never looks frozen. Cancel stays available, transfers keep their own progress row (#xfer), and#progressnow shows only the latestlabmill:notifyline (the tool activity moved into the row). - Where the steps come from.
agent.AgentemitsEventStepagent.StepWaitingForClaudebefore every model call. Below the agent,launch'sstepTrackeremits "Connecting to…" while connCache.clientopens an SSH login (not for a cached client, and never for the background job monitor, which usesexistingClient). When such a part ends the tracker puts the previous step back ("Waiting for Claude…", "Running cluster_info…"), unless a newer step replaced it or the reply text already started; it learns the agent's steps by wrappingAgent.OnEvent. Approvals are unchanged: steps never ask or decide anything. - Reduced motion.
@media (prefers-reduced-motion: reduce)setsanimation: noneon the hourglass and its triangles: a still hourglass with the sand on top. - Terminal. The TUI's status bar shows its Bubbles spinner followed by the same texts (
model.step: "Working…", theEventSteptexts, "Running…") and the elapsed seconds after 5 s; while a turn runs a shorter key hint makes room for it, and a NotifyMsgfollows the step instead of replacing it. Startup in the terminal is not shown step by step (launch.Build).
Settings page¶
The Settings page shows and edits every setting Labmill keeps; see the user page docs/user/settings.md for what each section does.
- Opening. The header's gear button (
#settings-open, shown once the page reaches ready or failed:app.jscallslabmillSettings.enable()), the tray menu's "Settings…" and, on macOS, File > Settings… (Cmd+,) both show the window and emitlabmill:open-settings.frontend/settings.jshides#main(or#fatal) and shows#settings; "Back to chat" or Esc outside a form returns and focuses the message box. The section list is arole=tablist(automatic activation: ArrowUp/ArrowDown/Home/End move and open, only the selected tab is in the Tab order); the section is therole=tabpanel. Everything is built withcreateElement/textContent; the documentation address is a<code>field, never a link. Approval, login and sign-in dialogs still cover it (#appis inert under the overlay). - Binding.
SettingsAPI(cmd/labmill-gui/settings.go,window.go.main.SettingsAPI) is a separate bound struct fromPageAPI, so each script reaches only its own methods:Get,SaveSite,SaveCluster,RemoveCluster,SaveHost,RemoveHost,SetStartInTray,SetRunWithoutAsking,ApproveAll,SetApproveAll,JobsWidth,SetJobsWidth,AutoDownload,SetAutoDownload,SetNotifyJobEnd,SetEmailOnJobEnd,ResetNotices,MoreClusterPlan,AddMoreCluster,OpenLogsFolder,Diagnostics. The Folders section reusesPageAPI.Folders/PickFolder/SetFolder(same rules as the + menu) and refreshes the chat's chips (window.labmillApp.refreshFolders). - Rules. The logic is in
internal/settings(testable without thedesktoptag).Getreads local files only (no network, no login). Saves return asettings.Result:errors({field, message}, the clusters.yaml key such asssh_hostors3.bucket, oralias/hostName/user/port/proxyJump) shown under their fields witharia-invalid, orerror, ormessage. Clusters are checked withconfig.FieldErrors(the rules ofconfig.Validate, now tied to cluster index and field), the site withsite.Get, SSH connections field by field withsshconfig.Host.Validateplus "host name required". - Writes.
clusters.yamlis edited on the YAML node tree (config.SetSiteYAML,PutClusterYAML,RemoveClusterYAML): only touched keys change, comments and order stay, new keys go in the usual order, and the result must passconfig.Parse. Before the first write of a session the file is backed up once (config.Backup:clusters.yaml.labmill-backup-<YYYYMMDD-HHMMSS>[-n],O_EXCL, 0600, never overwriting), then written atomically with 0600 (config.WriteFile). SSH connections go only to Labmill's file:sshconfig.PutLabmillHostrewrites the HostName/User/Port/ProxyJump lines of oneHostblock and leaves every other line, or appends aRenderblock (thenEnsureInclude);RemoveLabmillHostdrops one block. The user's~/.ssh/configis read withsshconfig.ListHostsand never written; adding an alias it defines is refused. An alias can't be renamed, and one used by a cluster or as a jump host can't be removed. "Log out" is not offered: the helper has no request to close one login (logins end 10 minutes after the last client). - Restart. The running session loaded
clusters.yamland the SSH settings at startup, so every save of those says "Restart Labmill to apply" and the page shows a banner (View.restart). There is no restart button: quitting from the tray and starting again is the single-instance-safe path. - App. "Start in the tray when I log in" uses the tray's
startAtLogin(autostart + gui.json) and updates the tray checkbox (tray.SetStartAtLogin); "Show the one-time tips again" clearsPrefs.TrayNoticeShownand callsLifecycle.ResetNotice. - Diagnostics.
settings.View.Diagnostics(): version and platform, the broker URL (if any) without user info, query or fragment, the institution, clusters (no AWS profile or KMS key), connections (no user names) and the file paths. The page shows the exact text it copies (md.jscopyText). "Open logs folder" usesgui.OpenFolderon the folder oflabmill.log. - Terminal.
/settings(tui.Options.Settings) printssettings.View.Text(): the same values without secrets and the file paths; it never starts a turn. - Self-test. The self-test writes a fixture (
clusters.yamlwith comments, Labmill's and a user SSH file) underlabmill-selftest-folders/settingsnext to the executable (overwritten each run; backups accumulate there, nothing is deleted) and stubs every outside effect (autostart, folder opener, broker URL with user info and a query).selftest.jsopens Settings with the gear, walks the section list with the keyboard, saves an invalidssh_host(inline error, field focused, nothing written), fixes and saves it, checks the SSH, Folders (data first), App and About sections and that the copied diagnostics hold nothing secret-looking, then leaves with Esc. Go then checks the saved value, the kept comments, one backup equal to the original, mode 0600 and the untouched user SSH file.
Architecture¶
Decide(id string, approve bool, edited, reason string) erroranswers an approval;reasonis the optional text typed with a decline (trimmed,textsafe.Clean, newlines collapsed to spaces, capped at 500 runes; ignored on approve). The dialog's "Tell Labmill why (optional)" input never takes the initial focus and Enter in it does nothing; Esc declines with whatever is typed.Choose(id string, index int, other string, skip bool) erroranswers alabmill:choicedialog ({id, question, options: [{label, description}], allowOther}, sent for theask_usertool):skipskips; otherwise a non-emptyotheris the typed answer (cleaned like a decline reason, needsallowOther); otherwiseindexis the 0-based option. Go side:gui.Bridge.Pick. A known ID is answered exactly once even on invalid input (answered skipped, with an error). The dialog's first option has the initial focus; Up/Down move between the options and the "Other" input, Enter picks the focused option, 1-9 pick directly, Esc and the Skip button skip.- The page calls bound methods at
window.go.main.PageAPI.*(Send, Cancel, Unqueue, JobAction, Decide, Choose, Answer, Jobs, Status, Folders, PickFolder, SetFolder, DropFolder, OpenJobFolder, SelfTest, SelfTestReport),window.go.main.Setup.*(NeedsSetup, SubmitUsername, State) and, from the Settings page,window.go.main.SettingsAPI.*(see "Settings page"), and listens withwindow.runtime.EventsOn.PageAPIis a small struct that forwards togui.App;gui.Appitself is not bound, because Wails binds every exported method and would exposeShutdownto the page.PageAPI.Statusreturns the runtime's status line once it is ready. index.htmlcarries a Content-Security-Policy (default-src 'none'; script-src 'self'; style-src 'self'; style-src-elem 'self' 'unsafe-inline' (xterm.js writes <style> elements;style-src-elemneeds the WKWebView of macOS 12.3 or newer, older systems lose the terminal's colors and layout); img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'); Wails needed no extra allowance on Linux (WebKitGTK). There is no inline script or style. Dropped file contents never reach the page: only paths, and only from drops onto the chat input box after the user picks data or code, are used (see "Folders and Open folder"; drops elsewhere reach the runtime JS on macOS and Linux but are discarded), and the page cancels every dragover and drop.- Chat: assistant replies are Markdown rendered by
frontend/md.jswith DOM nodes only (noinnerHTML; links and images become plain "text (url)" with nohref/src; raw HTML stays text). While a reply streams it is re-rendered at most every 50 ms, and at once before a tool line and at the end of the turn. Each tool call is one collapsed<details>line ("load_skill · 4.2 KB", "job_logs · 120 lines") that expands to the full text in monospace; errors show their first line. Font sizes are in rem (chat 1rem, code 0.875rem in a monospace stack). The job panel is at least 20rem wide; its cells never wrap and end in an ellipsis, with the full text in a tooltip. - The prompt is always available. The input and Send are never disabled.
gui.App.Sendstarts a turn when idle. While a turn runs, it queues the text instead: one message, and a newer Send replaces it. The queued message starts as the next turn when the running one ends, also afterCancel.Unqueuedrops it, andShutdowndrops it too. The page doesn't guess what happened fromSend's return. It follows events:labmill:turn({text}) when a turn starts (the page then shows the user message and the busy state),labmill:queued({text},""= nothing queued) for the "Queued, sent when this reply ends" line with its Remove button, andlabmill:donewhen a turn ends.labmill:queuedis emitted under the App's lock, so it always comes before thelabmill:turnthat sends that text. While busy, the Send button reads "Queue". Esc in the chat input does nothing special. - Job notices:
gui.Bridge.JobNotice(part oflaunch.UI, called by the background job monitor) emitslabmill:job({text}, cleaned). The page shows it as a small.msg.systemline, placed above a reply that is still streaming so the reply stays whole, and refreshes the job list at once. - Job buttons:
JobView.Actionslists the buttons for each job. Status and Logs appear once the job is submitted (it has a remote ID). Fetch appears when the job iscompletedorfailedand has a remote folder. Cancel appears while it issubmitted,pendingorrunning. Open folder appears once it isfetchedwith a local folder; it opens the folder (PageAPI.OpenJobFolder) instead of sending a message. They are real<button>s in a row under the job, withtextContentlabels and anaria-labelnaming the job. A click callsPageAPI.JobAction(id, action), which checks the action against the job's current actions and sends a plain chat message throughSend: "Show the status of job #12", "Show the last lines of the log of job #12", "Fetch the results of job #12" or "Cancel job #12". The message is queued while busy, so everything goes through the agent and its approvals (Cancel still asks). The table is rebuilt only when the job list changed, and the focused button keeps the focus. - Dialogs: input is ignored for 500 ms after a dialog opens and again after the window regains focus; key repeats and clicks that began during the grace period are ignored. The title and buttons stay fixed while the body scrolls. A
labmill:dismissevent closes dialogs that Go abandoned. Whenalteredis set the page warns that the text was cleaned for display. gui.Bridgeimplementslaunch.UI: approvals, choices and login questions becomelabmill:approval,labmill:choiceandlabmill:promptevents with an ID, and wait for exactly oneDecide,ChooseorAnswer; the turn ending answers them (declined, skipped, canceled) and sendslabmill:dismiss. Text is cleaned withtextsafe.Cleanbefore it leaves Go, and the page usestextContentonly.main.gobuilds the runtime withlaunch.BuildWithProgress(startup steps, see "Busy feedback") in a goroutine after startup. On first run (and whenclusters.yamlnames no known home institution,launch.NeedsHomeChoice, which also setsState.Notetolaunch.HomeInstitutionNote) its setup step blocks until the page submits the form (Setup.SubmitSetup: an empty or unknown institution is refused, a not-listed institution needs a login host,launch.HostsRequired; the account name is validated withlaunch.ValidUsername). It then sendslabmill:readyorlabmill:fatal;Setup.Statelets a page that loaded late catch up.- Until the runtime is ready, a small gate stands in for the session and job store: a turn ends at once with "Labmill is still starting up", and
Jobsreturns the same error. - Closing the window goes through
gui.Lifecycle(see "Tray and closing the window"): with a tray icon up it only hides the window. Quitting (the tray's Quit, or a close without a tray) goes throughgui.Closer: OnBeforeClose returns at once (Windows calls it on the UI thread), the window is hidden, andApp.Shutdownruns in the background (cancel the running turn, wait up to 10 s, decline pending dialogs, close the runtime) before the app quits. Wails' Quit re-enters OnBeforeClose, which then allows the close. If shutdown hangs for 30 s, the hidden process exits. - Terminal. The header's Terminal button, Settings > Clusters and the jobs panel's "Terminal here" open xterm.js tabs wired to a login-node shell (frontend/terminal.js, internal/gui/terminal.go); see ssh.md "Desktop terminal".