feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)

Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
2026-10-09 15:51:19 +07:00
parent ce773a3f5a
commit fa156d6a59
114 changed files with 5831 additions and 885 deletions
+19 -2
View File
@@ -3,12 +3,13 @@
* @description Pins where a pasted diff comment lands: the workspace's pane
* focused last, else any of its panes, never another workspace's (lane 1 is
* not lane 12), nowhere once the pane unmounted, and never into a pane that
* is not on screen (a hidden console behind a full-width Changes panel).
* is not on screen (a hidden console behind a full-width Changes panel); and
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { notePaneFocus, pasteToLane, registerPane } from "../panePaste";
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
describe("panePaste", () => {
it("pastes into the workspace's last-focused pane, and only its own", () => {
@@ -48,4 +49,20 @@ describe("panePaste", () => {
expect(got).toEqual(["y"]);
off();
});
it("interruptRun presses Esc in a visible pane only", () => {
const keys: string[] = [];
let shown = false;
const off = registerPane(
"agenthub-lane-6",
() => {},
() => shown,
(d) => keys.push(d)
);
expect(interruptRun("agenthub-lane-6")).toBe(false);
shown = true;
expect(interruptRun("agenthub-lane-6")).toBe(true);
expect(keys).toEqual(["\x1b"]);
off();
});
});
+50 -8
View File
@@ -644,22 +644,30 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
*/
/**
* What a workspace git call works on: the workspace (its id) or, with
* `session`, that session's own worktree (`claude --worktree`) — the server
* resolves the tree from the session row (`?session=`), never from a path.
* `session`, that session's own worktree (`claude --worktree`), or, with
* `source`, one of the workspace's extra folders — the server resolves the
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
* never from a path.
*/
export type GitTarget = number | { lane: number; session?: string };
export type GitTarget = number | { lane: number; session?: string; source?: number };
/** `/lanes/<id>/git<sub>` plus `q` and, for a session target, `session=`. */
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const params = new URLSearchParams(q);
const lane = typeof target === "number" ? target : target.lane;
if (typeof target !== "number" && target.session) params.set("session", target.session);
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
/** The target for `laneId`, in `session`'s worktree when one is given. */
export function gitTarget(laneId: number, session?: string | null): GitTarget {
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
export function gitTarget(
laneId: number,
session?: string | null,
source?: number | null
): GitTarget {
if (source) return { lane: laneId, source };
return session ? { lane: laneId, session } : laneId;
}
@@ -911,6 +919,9 @@ export const api = {
* @returns {@link SessionStats} for that one session.
*/
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
* and the skills / MCP servers it used (a Chat pane's side panel). */
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
* workspace, deletes nothing). */
patch: (id: string, body: { name?: string; archived?: boolean }) =>
@@ -2437,10 +2448,12 @@ export const api = {
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`. */
files: (cwd: string, q?: string) => {
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
* adds the folders too, as `dir/`. */
files: (cwd: string, q?: string, dirs = false) => {
const qs = new URLSearchParams({ cwd });
if (q) qs.set("q", q);
if (dirs) qs.set("dirs", "1");
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
},
/** POST /api/run/drop-image - save an image dropped on the terminal on the
@@ -2470,6 +2483,19 @@ export const api = {
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
/** GET /api/run/:id - one run's current handle. */
get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
/** GET /api/run/:id/permission-mode - the mode a live claude pane shows now
* (null while a menu hides it). */
permissionMode: (id: string) =>
request<{ mode: PermissionMode | "dontAsk" | null }>(
`/run/${encodeURIComponent(id)}/permission-mode`
),
/** POST /api/run/:id/permission-mode - step the pane's mode (Shift+Tab, each
* step read back) to `mode`; 409 when it cannot be reached. */
setPermissionMode: (id: string, mode: PermissionMode) =>
request<{ mode: PermissionMode }>(`/run/${encodeURIComponent(id)}/permission-mode`, {
method: "POST",
body: JSON.stringify({ mode }),
}),
/** DELETE /api/run/:id - kill the tmux session. */
kill: (id: string) =>
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
@@ -4294,6 +4320,22 @@ export interface CodexMarketplaceActionArgs {
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
/** `GET /api/sessions/:id/outline`. `status` is the agent's own word
* (`pending` / `in_progress` / `completed`). */
export interface SessionOutline {
progress: { id: string; subject: string; status: string }[];
/** Files it wrote or edited, newest first. */
outputs: string[];
/** Files it only read, newest first. */
files: string[];
/** Files attached to its prompts (the composer's drop dir), newest first. */
uploads: string[];
/** Claude Code memory files it read or wrote, newest first. */
memory: string[];
skills: string[];
mcp: string[];
}
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
* crashed, claude exited and closed the pane). Computed fresh from tmux
+11
View File
@@ -0,0 +1,11 @@
/**
* @file brand.ts
* @description SmartGift logo URLs shared by the sidebar and the login page.
* Remote URLs: the logo is owned by the brand, not vendored here — a machine
* with no network shows the alt text instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
export const BRAND_MARK = "https://smartgift.vn/favicon.ico";
+64 -15
View File
@@ -1,11 +1,15 @@
/**
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal.
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to
* any browser and differs between users of one dashboard. Read once per page
* load and shared through `useChatUi`; `saveChatUi` updates every reader at
* once and puts the old value back if the save fails.
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
* chat/terminal button), kept in this browser's localStorage
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
* setting is only the DEFAULT: kept by the server per account
* (`GET/PUT /api/me/prefs`, key `agenthub.chatUi` = "1"; local mode is one
* account), it is written onto a session the first time that session is
* shown, so changing it later leaves every session already seen alone. Read
* once per page load and shared through `useChatUi`; `saveChatUi` updates
* every reader at once and puts the old value back if the save fails.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,21 +18,44 @@ import { api } from "./api";
export const CHAT_UI_KEY = "agenthub.chatUi";
export const VIEWS_KEY = "agenthub.chatUi.sessions";
const MAX_VIEWS = 200;
let chatUi = false;
let loaded = false;
let loading: Promise<void> | null = null;
const listeners = new Set<() => void>();
const subscribe = (fn: () => void) => {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
};
const emit = (next: boolean) => {
chatUi = next;
listeners.forEach((fn) => fn());
};
function readViews(): Record<string, boolean> {
try {
const v: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}");
return v && typeof v === "object" ? (v as Record<string, boolean>) : {};
} catch {
return {};
}
}
let views = readViews();
function load() {
// A failed read (logged out, offline) must never take a page down with it.
loading ??= Promise.resolve()
.then(() => api.me.prefs())
.then(
(r) => emit(r.prefs[CHAT_UI_KEY] === "1"),
(r) => {
loaded = true;
emit(r.prefs[CHAT_UI_KEY] === "1");
},
() => {
loading = null; // the next reader asks again
}
@@ -37,13 +64,32 @@ function load() {
export function useChatUi(): boolean {
useEffect(load, []);
return useSyncExternalStore(
(fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
() => chatUi
);
return useSyncExternalStore(subscribe, () => chatUi);
}
/** Shows `sessionId` as a chat (`true`) or its terminal, for this browser. */
export function setSessionChat(sessionId: string, on: boolean) {
const { [sessionId]: _old, ...rest } = views;
// Newest last, so the cap drops the sessions touched longest ago.
views = Object.fromEntries(Object.entries({ ...rest, [sessionId]: on }).slice(-MAX_VIEWS));
try {
localStorage.setItem(VIEWS_KEY, JSON.stringify(views));
} catch {
// Storage full or blocked: the choice still holds for this page load.
}
listeners.forEach((fn) => fn());
}
/** The session's own view; a session seen for the first time takes the
* account's default (once it is read) and keeps it. */
export function useSessionChat(sessionId: string | null | undefined): boolean {
const fallback = useChatUi();
const own = useSyncExternalStore(subscribe, () => (sessionId ? views[sessionId] : undefined));
const ready = useSyncExternalStore(subscribe, () => loaded);
useEffect(() => {
if (sessionId && own === undefined && ready) setSessionChat(sessionId, chatUi);
}, [sessionId, own, ready]);
return own ?? fallback;
}
export async function saveChatUi(on: boolean): Promise<void> {
@@ -61,5 +107,8 @@ export async function saveChatUi(on: boolean): Promise<void> {
/** Tests: forget what was read. */
export function __resetChatUi() {
chatUi = false;
loaded = false;
loading = null;
views = {};
localStorage.removeItem(VIEWS_KEY);
}
+23
View File
@@ -0,0 +1,23 @@
/**
* @file openInChanges.ts — asks the Workspace page to open a file in its
* Changes panel: a chat's "Edited N files" card names absolute paths deep in
* a pane, the panel lives on the page, so the request travels as a window
* event (like the sidebar's `LIVE_RUNS_EVENT`). The page opens the panel and
* that file there, if the panel shows the same workspace; else nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const OPEN_IN_CHANGES_EVENT = "agenthub:open-in-changes";
/** A file to open: its workspace, and its path relative to the panel's folder. */
export type OpenInChanges = { laneId: number; path: string };
/** Opens `path` (absolute) when it lies under `cwd`, the pane's folder — the
* folder the Changes panel reads for it. False when it does not. */
export function openInChanges(laneId: number, cwd: string, path: string): boolean {
const base = cwd.replace(/\/+$/, "");
if (!base || !path.startsWith(`${base}/`)) return false;
const detail: OpenInChanges = { laneId, path: path.slice(base.length + 1) };
window.dispatchEvent(new CustomEvent(OPEN_IN_CHANGES_EVENT, { detail }));
return true;
}
+17 -5
View File
@@ -9,25 +9,28 @@
* hidden behind a full-width Changes panel or a replay would swallow it
* unseen); the caller says where it went, or that it went nowhere. The one
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
* what the user typed there, so it pastes AND presses Enter.
* what the user typed there, so it pastes AND presses Enter — and its Stop
* button presses Esc through `interruptRun`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
interface Pane {
paste: (text: string, submit?: boolean) => void;
visible: () => boolean;
key?: (data: string) => void;
}
const panes = new Map<string, Pane>();
let lastFocused: string | null = null;
/** Registers a mounted terminal (`visible`: is it on screen now); returns its
* unregister. */
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
* raw input, as a key press); returns its unregister. */
export function registerPane(
runId: string,
paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true
visible: () => boolean = () => true,
key?: (data: string) => void
): () => void {
const entry = { paste, visible };
const entry = { paste, visible, key };
panes.set(runId, entry);
return () => {
if (panes.get(runId) === entry) panes.delete(runId);
@@ -63,6 +66,15 @@ export function submitToRun(runId: string, text: string): boolean {
return true;
}
/** Presses Esc in the pane of `runId` — how `claude` and `codex` stop the turn
* they are running. False when the pane is not mounted or not attached. */
export function interruptRun(runId: string): boolean {
const p = panes.get(runId);
if (!p?.visible() || !p.key) return false;
p.key("\x1b");
return true;
}
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
* last when it is — and returns its run id; null when none is on screen. */
export function pasteToLane(laneId: number, text: string): string | null {
+8 -1
View File
@@ -2,7 +2,7 @@
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
* expanded to their sessions (by lane id), the tool the list is filtered to,
* and — Settings → Worktrees —
* which owner groups an admin/analyst folded, and — Settings → Worktrees —
* whether a workspace home's Add folder suggests the repo's untracked
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
@@ -20,6 +20,8 @@ export interface ProjectTreePrefs {
expanded: Record<string, boolean>;
/** Show only this tool's workspaces; null = every tool. */
tool: string | null;
/** Server mode, admin/analyst: owner key (user id, or "none") → its group is folded. */
collapsedOwners: Record<string, boolean>;
showUntracked: boolean;
hideSources: string[];
}
@@ -27,6 +29,7 @@ export interface ProjectTreePrefs {
const DEFAULTS: ProjectTreePrefs = {
expanded: {},
tool: null,
collapsedOwners: {},
showUntracked: true,
hideSources: [],
};
@@ -50,6 +53,10 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
value = {
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null,
collapsedOwners:
parsed.collapsedOwners && typeof parsed.collapsedOwners === "object"
? parsed.collapsedOwners
: {},
showUntracked: parsed.showUntracked !== false,
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
};
+101
View File
@@ -0,0 +1,101 @@
/**
* @file promptAttach.ts
* @description What a prompt box can attach besides a fresh upload: a project
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
* `PromptEditor`'s drop) and the files uploaded through a prompt box in the
* last day (`recentUploads`, kept in this browser). An attachment is a path
* sent with the prompt, so nothing here touches the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { DragEvent } from "react";
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
export interface Attachment {
path: string;
name: string;
url?: string;
}
export const ATTACH_MIME = "application/x-agenthub-attach";
/** A project path (relative to the box's folder) as Claude Code's `@` mention. */
export function projectAttachment(rel: string): Attachment {
const name = rel.replace(/\/$/, "").split("/").pop() || rel;
return { path: `@${rel}`, name: rel.endsWith("/") ? `${name}/` : name };
}
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
* text too, so a drop elsewhere (a terminal, an editor) gets the path. */
export function dragProjectPath(e: DragEvent, rel: string) {
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(rel)));
e.dataTransfer.setData("text/plain", rel);
e.dataTransfer.effectAllowed = "copy";
}
/** The attachment a drop carries, if it is one of ours. */
export function droppedAttachment(dt: DataTransfer | null | undefined): Attachment | null {
try {
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
const a = v as Attachment | null;
return a && typeof a.path === "string" && typeof a.name === "string"
? { path: a.path, name: a.name }
: null;
} catch {
return null;
}
}
/** `prev` plus whichever of `items` it does not hold yet. */
export function addAttachments<T extends Attachment>(prev: T[], items: T[]): T[] {
const fresh = items.filter(
(a, i) =>
!prev.some((p) => p.path === a.path) && items.findIndex((b) => b.path === a.path) === i
);
return fresh.length ? [...prev, ...fresh] : prev;
}
const RECENT_KEY = "agenthub.prompt.recentUploads";
const RECENT_MAX = 20;
// The server prunes an upload a day after it was written (`saveDrop`).
// ponytail: a client-side guess of that TTL, a few minutes short; a listing
// route instead if uploads must outlive it or follow the user to another browser.
const RECENT_TTL_MS = 23.9 * 60 * 60_000;
interface Recent extends Attachment {
at: number;
}
function readRecent(): Recent[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
const cutoff = Date.now() - RECENT_TTL_MS;
return (Array.isArray(v) ? (v as Recent[]) : []).filter(
(r) => typeof r?.path === "string" && typeof r.name === "string" && r.at > cutoff
);
} catch {
return [];
}
}
export function recentUploads(): Attachment[] {
return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) }));
}
/** Remember uploads just saved, newest first; an image keeps a thumbnail
* through `GET /api/run/drop/:name` (its local blob URL dies with the page). */
export function rememberUploads(items: { path: string; name: string; image: boolean }[]) {
const at = Date.now();
const added: Recent[] = items.map(({ path, name, image }) => ({
path,
name,
at,
...(image ? { url: `/api/run/drop/${encodeURIComponent(path.split("/").pop() || "")}` } : {}),
}));
const kept = readRecent().filter((r) => !added.some((a) => a.path === r.path));
try {
localStorage.setItem(RECENT_KEY, JSON.stringify([...added, ...kept].slice(0, RECENT_MAX)));
} catch {
/* storage full or blocked: the list is a convenience */
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* @file terminalFont — the lane terminal's font, a viewer preference kept in
* `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a
* terminal opens. `jetbrains` (the default) and `maple` (rounded) are bundled
* webfonts that cover every Vietnamese letter; `system` is the browser's own
* terminal opens. `jetbrains` (the default) and the rounded `maple` are
* bundled webfonts that cover every Vietnamese letter; `system` is the browser's own
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
* and those glyphs then fall back to a face of another width).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
+8
View File
@@ -2661,6 +2661,8 @@ export interface Lane {
cwd: string;
/** Server mode: the account that owns this workspace (null = nobody yet). */
owner_id?: number | null;
/** The owner's username (`users.username`); null with no owner. */
owner_name?: string | null;
branch: string | null;
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
@@ -3205,6 +3207,9 @@ export interface TranscriptContent {
output?: string;
/** Present for "tool_result" blocks: whether the tool call errored. */
is_error?: boolean;
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
* removed (from Claude Code's `structuredPatch`). */
diff?: { added: number; removed: number };
}
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
@@ -3239,6 +3244,9 @@ export interface TranscriptMessage {
* Example: `"claude-opus-4-8"`. */
model?: string;
/** Token accounting reported alongside an assistant message; absent otherwise. */
/** Assistant lines: why the model stopped — "end_turn" closes the turn,
* "tool_use" means more work follows. Absent from an older server. */
stop_reason?: string;
usage?: {
/** Fresh (non-cached) input tokens for this message. */
input_tokens: number;
+3
View File
@@ -18,6 +18,7 @@
import { useSyncExternalStore } from "react";
import { api } from "./api";
import i18n from "../i18n";
import { applyStoredTheme } from "../hooks/useTheme";
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
export const PREF_KEYS = [
@@ -100,6 +101,8 @@ function readLocal(key: string): string | null {
/** Make what reads these keys read them again. */
function reapply(prefs: Record<string, string>) {
// Painted now, not only on the next useTheme render: the login page wore the OS's.
applyStoredTheme();
window.dispatchEvent(new Event("agenthub-theme"));
window.dispatchEvent(new Event("sagi:prefs"));
const lng = prefs.i18nextLng;