Skip to content

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

make gui-smoke

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

make gui-selftest

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)), ![p](data:...), 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/systray v1.12 (Apache-2.0, maintained by the Fyne project). Compared: github.com/getlantern/systray uses GTK/libappindicator on Linux (a second GTK main loop next to Wails' one) and is unmaintained; github.com/energye/systray is 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.Run runs in its own goroutine locked to an OS thread (its hidden message window and its message loop must share a thread; RunWithExternalLoop starts the loop in an unlocked goroutine, which can't work on Windows). This coexists with Wails' UI thread. Icon: tray-dark.ico when the taskbar is light (HKCU\...\Themes\Personalize\SystemUsesLightTheme = 1), else tray-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). trayAvailable asks D-Bus whether org.kde.StatusNotifierWatcher has 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 NSApp loop 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: HideWindowOnClose hides 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 .app bundle 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 calls Lifecycle.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 shows gui.TrayNotice as 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 under HKCU\Software\Classes), and records trayNoticeShown in gui.json. Without a tray, or once a quit was requested, it hands over to gui.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 final runtime.Quit re-enters OnBeforeClose and is let through. After wails.Run returns the tray icon is removed.
  • Logins stay up: sshx.Keeper holds a sshx.Hold connection 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 answers logged-in@labmill with 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 than ErrNoHelper (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 (singleInstanceID in main.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: Windows HKCU\Software\Microsoft\Windows\CurrentVersion\Run\labmill = "<exe>" --tray (turning it off deletes that value); macOS a plist in ~/Library/LaunchAgents named after the entry's reverse-DNS label (RunAtLoad; off rewrites it with Disabled); Linux ~/.config/autostart/labmill.desktop (off rewrites it with Hidden=true). The checkbox shows whether the entry is registered; startInTray in gui.json records the choice. enabled accepts whatever command the entry runs (Windows: the Run value exists; Linux: the file exists without Hidden=true; macOS: no Disabled key). 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 (startTray gives 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.json next to clusters.yaml (gui.Prefs: trayNoticeShown, startInTray, window, jobs_width), written atomically with mode 0600.
  • Window geometry (internal/gui/window.go, windowwatch.go). gui.json window holds WindowGeom (x, y, width, height, maximized). WindowWatcher polls the window about once a second through a WindowProbe (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/RestoreScaled run once, at start (applyGeom in 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 with SetWindowPos (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-valuenow in percent) sits between .chat and aside.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 in gui.json jobs_width (Prefs.JobsWidth, omitted when default) through SettingsAPI.SetJobsWidth (debounced by the page) and read at start with SettingsAPI.JobsWidth (0 = default). It is stored apart from window, 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.Workspace holds the chosen code and data folders and the last 5 of each. app.CheckFolder decides 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 ~/Library on macOS or AppData on 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/c with 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 to workspace.json next to clusters.yaml (0600, atomic), shared by the terminal and the desktop app; launch.Build loads it (a saved folder that no longer passes is dropped with a startup warning). Each process keeps its own copy in memory; SetFolder re-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>/Library is refused outside home, and macOS's hidden flag counts as hidden. PageAPI.SetFolder accepts only "" or a folder from that kind's recent list.
  • Conversation context. agent.Agent.SystemExtra (new) is called once per Send and appended to the fixed system prompt; launch.Build sets it to Workspace.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 adds app.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 LocalRoots are added). find_local_data without under searches them (else every root); preview_file resolves a relative path in the code folder, then the data folder; stage_data without root stages 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…" (both PageAPI.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 with textContent. Refusals show as an error line just above the box. gui.FolderHost (Env, Pick, Open) connects gui.App to 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 with DisableWebViewDrop (it sets AllowExternalDrag(false)). Only the chat input box carries --wails-drop-target: drop (custom properties inherit, so its children count too), the page registers runtime.OnFileDrop(handler, true), and its handler also requires the element under the drop point to be inside the box; a dragover elsewhere gets dropEffect = 'none', and every dragover and drop is preventDefaulted, 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 calls PageAPI.DropFolder(kind, [path]), which uses only the first path, through the same CheckFolder. A real drop needs a desktop session; the self-test simulates the handler call.
  • Open folder. gui.Bridge remembers the job_id of a fetch_results call and puts it on the successful result's ToolEvent (jobId); the page then adds an "Open folder" button. Fetched jobs with a local folder get the action open in the job list. Both call PageAPI.OpenJobFolder(id): gui.App takes the folder from the job store (state fetched), checks it with app.Env.OpenableFolder (an existing directory inside the results folder or an allowed root that passes the privacy rules) and calls gui.OpenFolder, which starts explorer.exe (from %SystemRoot%; names with , or " are refused because Explorer parses them), /usr/bin/open or xdg-open with the folder as the only argument. No shell and no file:// 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; /data shows 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 /data first 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.html itself 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 an aria-live="polite" status. It is visible on the first paint, before app.js or the Wails runtime load; the CSP stays strict (no inline style or script). main.go builds the runtime with launch.BuildWithProgress, whose Progress callback reports launch.StartLoadingSettings ("Loading your settings…"), StartOpeningDatabase, StartLoadingSkills and StartReady. Each step is emitted as labmill:startup ({step}) and stored in State.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 in gate.Send (a running turn) and is forwarded once. labmill:ready sets the status line. The runtime starts before the window on Windows (a second launch is detected with otherInstanceRunning first). 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 main starts a native splash right after the helper-subcommand check and before the tray and wails.Run (startSplash in splash_windows.go; splash_other.go is a no-op). It is a borderless popup (WS_POPUP|WS_BORDER, WS_EX_TOOLWINDOW so no taskbar button, NOACTIVATE, not topmost (the new main window covers it), shown with SW_SHOWNOACTIVATE so it never takes focus), 360x120 at 96 DPI scaled by the system DPI, centered on the rectangle the saved window geometry (read alone from gui.json by savedWindowGeom) 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: posts WM_CLOSE) is called from OnDomReady, showWindow (tray open), OnShutdown and after wails.Run returns; the window also closes itself after 60 s. Skipped (splashWanted): --tray starts (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 inside Run, so main probes 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 checks splashStarts == 0 and 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 line gui.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 becomes gui.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. #working sits under the conversation (outside the role="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:turn restarts it. labmill:step ({text}, from agent.EventStep via gui.Bridge) and a tool call ("Running …") set its text; labmill:text hides it (a later step or tool call shows it again); labmill:done hides 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 #progress now shows only the latest labmill:notify line (the tool activity moved into the row).
  • Where the steps come from. agent.Agent emits EventStep agent.StepWaitingForClaude before every model call. Below the agent, launch's stepTracker emits "Connecting to …" while connCache.client opens an SSH login (not for a cached client, and never for the background job monitor, which uses existingClient). 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 wrapping Agent.OnEvent. Approvals are unchanged: steps never ask or decide anything.
  • Reduced motion. @media (prefers-reduced-motion: reduce) sets animation: none on 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…", the EventStep texts, "Running …") and the elapsed seconds after 5 s; while a turn runs a shorter key hint makes room for it, and a NotifyMsg follows 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.js calls labmillSettings.enable()), the tray menu's "Settings…" and, on macOS, File > Settings… (Cmd+,) both show the window and emit labmill:open-settings. frontend/settings.js hides #main (or #fatal) and shows #settings; "Back to chat" or Esc outside a form returns and focuses the message box. The section list is a role=tablist (automatic activation: ArrowUp/ArrowDown/Home/End move and open, only the selected tab is in the Tab order); the section is the role=tabpanel. Everything is built with createElement/textContent; the documentation address is a <code> field, never a link. Approval, login and sign-in dialogs still cover it (#app is inert under the overlay).
  • Binding. SettingsAPI (cmd/labmill-gui/settings.go, window.go.main.SettingsAPI) is a separate bound struct from PageAPI, 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 reuses PageAPI.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 the desktop tag). Get reads local files only (no network, no login). Saves return a settings.Result: errors ({field, message}, the clusters.yaml key such as ssh_host or s3.bucket, or alias/hostName/user/port/proxyJump) shown under their fields with aria-invalid, or error, or message. Clusters are checked with config.FieldErrors (the rules of config.Validate, now tied to cluster index and field), the site with site.Get, SSH connections field by field with sshconfig.Host.Validate plus "host name required".
  • Writes. clusters.yaml is 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 pass config.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.PutLabmillHost rewrites the HostName/User/Port/ProxyJump lines of one Host block and leaves every other line, or appends a Render block (then EnsureInclude); RemoveLabmillHost drops one block. The user's ~/.ssh/config is read with sshconfig.ListHosts and 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.yaml and 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" clears Prefs.TrayNoticeShown and calls Lifecycle.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.js copyText). "Open logs folder" uses gui.OpenFolder on the folder of labmill.log.
  • Terminal. /settings (tui.Options.Settings) prints settings.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.yaml with comments, Labmill's and a user SSH file) under labmill-selftest-folders/settings next 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.js opens Settings with the gear, walks the section list with the keyboard, saves an invalid ssh_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

page (frontend/*)  <->  gui.App / gui.Bridge (internal/gui)  <->  launch.Runtime (internal/launch)
  • Decide(id string, approve bool, edited, reason string) error answers an approval; reason is 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) error answers a labmill:choice dialog ({id, question, options: [{label, description}], allowOther}, sent for the ask_user tool): skip skips; otherwise a non-empty other is the typed answer (cleaned like a decline reason, needs allowOther); otherwise index is 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 with window.runtime.EventsOn. PageAPI is a small struct that forwards to gui.App; gui.App itself is not bound, because Wails binds every exported method and would expose Shutdown to the page. PageAPI.Status returns the runtime's status line once it is ready.
  • index.html carries 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.js with DOM nodes only (no innerHTML; links and images become plain "text (url)" with no href/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.Send starts 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 after Cancel. Unqueue drops it, and Shutdown drops it too. The page doesn't guess what happened from Send'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, and labmill:done when a turn ends. labmill:queued is emitted under the App's lock, so it always comes before the labmill:turn that sends that text. While busy, the Send button reads "Queue". Esc in the chat input does nothing special.
  • Job notices: gui.Bridge.JobNotice (part of launch.UI, called by the background job monitor) emits labmill:job ({text}, cleaned). The page shows it as a small .msg.system line, placed above a reply that is still streaming so the reply stays whole, and refreshes the job list at once.
  • Job buttons: JobView.Actions lists the buttons for each job. Status and Logs appear once the job is submitted (it has a remote ID). Fetch appears when the job is completed or failed and has a remote folder. Cancel appears while it is submitted, pending or running. Open folder appears once it is fetched with 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, with textContent labels and an aria-label naming the job. A click calls PageAPI.JobAction(id, action), which checks the action against the job's current actions and sends a plain chat message through Send: "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:dismiss event closes dialogs that Go abandoned. When altered is set the page warns that the text was cleaned for display.
  • gui.Bridge implements launch.UI: approvals, choices and login questions become labmill:approval, labmill:choice and labmill:prompt events with an ID, and wait for exactly one Decide, Choose or Answer; the turn ending answers them (declined, skipped, canceled) and sends labmill:dismiss. Text is cleaned with textsafe.Clean before it leaves Go, and the page uses textContent only.
  • main.go builds the runtime with launch.BuildWithProgress (startup steps, see "Busy feedback") in a goroutine after startup. On first run (and when clusters.yaml names no known home institution, launch.NeedsHomeChoice, which also sets State.Note to launch.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 with launch.ValidUsername). It then sends labmill:ready or labmill:fatal; Setup.State lets 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 Jobs returns 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 through gui.Closer: OnBeforeClose returns at once (Windows calls it on the UI thread), the window is hidden, and App.Shutdown runs 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".