version_0_1_0-alpha

This commit is contained in:
王性驊 2026-09-16 16:17:38 +08:00
parent 42c159e42f
commit 8e8174e8bf
31 changed files with 2758 additions and 207 deletions

View File

@ -6,6 +6,8 @@
# make stop # make stop
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.NOTPARALLEL: build start restart
export CARGO_BUILD_JOBS ?= 2
ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
RUNDIR := $(ROOT)/.run RUNDIR := $(ROOT)/.run
@ -108,7 +110,7 @@ wait_listen() {
name="$$3" name="$$3"
log="$$4" log="$$4"
i=0 i=0
while [ $$i -lt 50 ]; do while [ $$i -lt 150 ]; do
if lsof -nP -iTCP:"$$port" -sTCP:LISTEN >/dev/null 2>&1; then if lsof -nP -iTCP:"$$port" -sTCP:LISTEN >/dev/null 2>&1; then
return 0 return 0
fi fi
@ -172,12 +174,13 @@ build-bin:
@cargo build -p lazyboy @cargo build -p lazyboy
build-ui: build-ui:
@if [ ! -d "$(WEBDIR)/node_modules" ]; then \ @if [ ! -f "$(WEBDIR)/node_modules/.lazyboy-lock" ] || ! cmp -s "$(WEBDIR)/package-lock.json" "$(WEBDIR)/node_modules/.lazyboy-lock"; then \
echo "npm install…"; \ echo "installing locked web dependencies…"; \
(cd "$(WEBDIR)" && npm install) || exit 1; \ (cd "$(WEBDIR)" && npm ci) || exit 1; \
cp "$(WEBDIR)/package-lock.json" "$(WEBDIR)/node_modules/.lazyboy-lock"; \
fi fi
@echo "building web UI…" @echo "building web UI…"
@(cd "$(WEBDIR)" && npm run build) || exit 1 @(cd "$(WEBDIR)" && NODE_OPTIONS="$${NODE_OPTIONS:---max-old-space-size=1024}" npm run build) || exit 1
box: box:
@echo "building computer image lazyboy-box:local…" @echo "building computer image lazyboy-box:local…"
@ -237,7 +240,7 @@ start-web:
echo "npm install…"; \ echo "npm install…"; \
(cd "$(WEBDIR)" && npm install) || exit 1; \ (cd "$(WEBDIR)" && npm install) || exit 1; \
fi; \ fi; \
LAZYBOY_WEB_PORT="$(PORT_API)" nohup npm --prefix "$(WEBDIR)" run dev -- --host "$(HOST)" --port "$(PORT_WEB)" > "$(LOG_WEB)" 2>&1 < /dev/null & echo $$! > "$(PID_WEB)"; \ NODE_OPTIONS="$${NODE_OPTIONS:---max-old-space-size=1024}" LAZYBOY_WEB_PORT="$(PORT_API)" nohup npm --prefix "$(WEBDIR)" run dev -- --host "$(HOST)" --port "$(PORT_WEB)" > "$(LOG_WEB)" 2>&1 < /dev/null & echo $$! > "$(PID_WEB)"; \
wait_alive "$(PID_WEB)" web "$(LOG_WEB)" || exit 1; \ wait_alive "$(PID_WEB)" web "$(LOG_WEB)" || exit 1; \
wait_listen "$(PID_WEB)" "$(PORT_WEB)" web "$(LOG_WEB)" || exit 1; \ wait_listen "$(PID_WEB)" "$(PORT_WEB)" web "$(LOG_WEB)" || exit 1; \
echo "started web pid=$$(cat "$(PID_WEB)") :$(PORT_WEB) log=$(LOG_WEB)" echo "started web pid=$$(cat "$(PID_WEB)") :$(PORT_WEB) log=$(LOG_WEB)"

View File

@ -4,6 +4,12 @@ A local, general-purpose **GrokBot-like CLI agent**. It observes the environment
For action tasks the agent briefly explains its approach, then starts. Multi-stage tasks get a short live plan. You can add instructions while it works, answer a question, or interrupt and resume. Simple questions remain simple answers. For action tasks the agent briefly explains its approach, then starts. Multi-stage tasks get a short live plan. You can add instructions while it works, answer a question, or interrupt and resume. Simple questions remain simple answers.
## Remote computer controls
右側電腦採用 Grok 風格的螢幕卡片按「放大」可開啟全頁操作。桌面版不顯示手機控制列直接使用滑鼠鍵盤Ctrl+Alt+ 可縮放Ctrl+Alt+0 重設Ctrl+Alt+B 開啟文字剪貼簿面板。
手機沿用 LazyBoy 的獨立觸控板滑動移動游標、輕點左鍵、雙指捲動輕點右鍵也可按左右鍵、捲動與「拖曳」再按一次放開。可切換直接點選模式。直向控制區在下方橫向在左側鍵盤、常用按鍵、14 倍縮放、移動畫面與雙向文字剪貼簿都在手機控制區。若區網 HTTP 或瀏覽器限制剪貼簿權限,可在面板長按貼上/複製。
## Start ## Start
```bash ```bash
@ -138,3 +144,9 @@ python3 tests/live_team.py # opt-in team workflow, root task ceiling 16
``` ```
Design and Codex references: [CLI flow](docs/CLI-FLOW.md). Product scope: [PRODUCT](docs/PRODUCT.md). Acceptance scenarios: [ACCEPTANCE](docs/ACCEPTANCE.md). Older scenario playbooks remain optional examples, not hardcoded workflows: [scenarios](docs/scenarios/README.md). Design and Codex references: [CLI flow](docs/CLI-FLOW.md). Product scope: [PRODUCT](docs/PRODUCT.md). Acceptance scenarios: [ACCEPTANCE](docs/ACCEPTANCE.md). Older scenario playbooks remain optional examples, not hardcoded workflows: [scenarios](docs/scenarios/README.md).
### Memory and long-running UI
`make start` rebuilds both services and reapplies computer limits to existing containers. Each computer is limited to 1536 MiB RAM (no additional swap), 2 CPUs and 512 processes; new containers use 256 MiB shared memory. The default maximum is three running computers, including computers recovered from previous server runs. Idle computers are stopped when a slot is needed; their workspace and browser profile volumes are retained. Busy computers are not evicted. `LAZYBOY_COMPUTER_MAX` overrides the running count.
Chat history scrolls continuously and only mounts nearby messages. Desktop chrome is hidden by CSS without a DOM mutation feedback loop. Regression checks: `node tests/novnc_memory_ui.mjs` and `node tests/history_memory_ui.mjs` (with the frontend running).

View File

@ -27,6 +27,9 @@ const BROWSER_PROFILE: &str = "/home/box/chrome-profile";
const JOB_OUTPUT_HEAD_CHARS: usize = 16_000; const JOB_OUTPUT_HEAD_CHARS: usize = 16_000;
const JOB_OUTPUT_TAIL_CHARS: usize = 32_000; const JOB_OUTPUT_TAIL_CHARS: usize = 32_000;
const DEFAULT_MAX_RUNNING: usize = 3; const DEFAULT_MAX_RUNNING: usize = 3;
const COMPUTER_MEMORY: &str = "1536m";
const COMPUTER_CPUS: &str = "2";
const COMPUTER_PIDS: &str = "512";
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct BoxSeat { pub struct BoxSeat {
@ -285,6 +288,9 @@ impl BoxHub {
docker_info().await?; docker_info().await?;
let _provision = ProvisionLock::acquire().await?; let _provision = ProvisionLock::acquire().await?;
ensure_image().await?; ensure_image().await?;
let pool = BoxPool::global();
pool.reconcile().await?;
pool.reclaim(self.seat_id()).await?;
let port = ensure_container(&self.seat).await?; let port = ensure_container(&self.seat).await?;
wait_desktop(&self.seat.container).await?; wait_desktop(&self.seat.container).await?;
self.inner.lock().await.host_port = Some(port); self.inner.lock().await.host_port = Some(port);
@ -313,7 +319,11 @@ impl BoxHub {
let result = async { let result = async {
docker_info().await?; docker_info().await?;
let _provision = ProvisionLock::acquire().await?; let _provision = ProvisionLock::acquire().await?;
let pool = BoxPool::global();
pool.reconcile().await?;
pool.reclaim(self.seat_id()).await?;
if docker(["container", "inspect", self.container()]).await.is_ok() { if docker(["container", "inspect", self.container()]).await.is_ok() {
limit_container(self.container()).await?;
docker(["restart", "-t", "20", self.container()]).await?; docker(["restart", "-t", "20", self.container()]).await?;
} else { } else {
ensure_image().await?; ensure_image().await?;
@ -342,6 +352,9 @@ impl BoxHub {
let result = async { let result = async {
docker_info().await?; docker_info().await?;
let _provision = ProvisionLock::acquire().await?; let _provision = ProvisionLock::acquire().await?;
let pool = BoxPool::global();
pool.reconcile().await?;
pool.reclaim(self.seat_id()).await?;
let before = local_image_id().await; let before = local_image_id().await;
build_image().await?; build_image().await?;
let after = local_image_id().await; let after = local_image_id().await;
@ -963,7 +976,17 @@ async fn local_image_id() -> Option<String> {
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
} }
// Apply to existing computers too, without recreating them or discarding data.
async fn limit_container(container: &str) -> Result<()> {
docker(["update", "--memory", COMPUTER_MEMORY, "--memory-swap", COMPUTER_MEMORY,
"--cpus", COMPUTER_CPUS, "--pids-limit", COMPUTER_PIDS, container]).await?;
Ok(())
}
async fn ensure_container(seat: &BoxSeat) -> Result<u16> { async fn ensure_container(seat: &BoxSeat) -> Result<u16> {
if docker(["container", "inspect", &seat.container]).await.is_ok() {
limit_container(&seat.container).await?;
}
if docker_running(&seat.container).await? if docker_running(&seat.container).await?
&& container_image_id(&seat.container).await == local_image_id().await && container_image_id(&seat.container).await == local_image_id().await
{ {
@ -994,7 +1017,11 @@ async fn ensure_container(seat: &BoxSeat) -> Result<u16> {
"create", "create",
"--name", "--name",
&seat.container, &seat.container,
"--shm-size=2g", "--memory", COMPUTER_MEMORY,
"--memory-swap", COMPUTER_MEMORY,
"--cpus", COMPUTER_CPUS,
"--pids-limit", COMPUTER_PIDS,
"--shm-size=256m",
"-p", "-p",
&publish, &publish,
"--stop-timeout=20", "--stop-timeout=20",
@ -1133,12 +1160,14 @@ fn max_running() -> usize {
} }
pub struct BoxPool { pub struct BoxPool {
provisioning: Mutex<()>,
hubs: std::sync::Mutex<HashMap<String, Arc<BoxHub>>>, hubs: std::sync::Mutex<HashMap<String, Arc<BoxHub>>>,
} }
impl BoxPool { impl BoxPool {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
provisioning: Mutex::new(()),
hubs: std::sync::Mutex::new(HashMap::new()), hubs: std::sync::Mutex::new(HashMap::new()),
}) })
} }
@ -1163,8 +1192,30 @@ impl BoxPool {
self.hub(seat_id).inspect().await self.hub(seat_id).inspect().await
} }
/// Recover computers left by a previous server, so limits survive make start.
pub async fn reconcile(&self) -> Result<()> {
let names = docker(["ps", "-a", "--format", "{{.Names}}"]).await?;
for name in names.stdout.lines().rev() {
let id = if name == SESSION_CONTAINER { SESSION_SEAT }
else if let Some(id) = name.strip_prefix("lazyboy-box-") { id }
else { continue };
self.hub(id);
limit_container(name).await?;
}
Ok(())
}
pub async fn recover(&self) -> Result<()> {
self.reconcile().await?;
self.reclaim("").await?;
Ok(())
}
pub async fn start(&self, seat_id: &str) -> Result<Value> { pub async fn start(&self, seat_id: &str) -> Result<Value> {
let crowded = self.reclaim(seat_id).await; // Serialize admission and launch; concurrent tabs cannot exceed the cap.
let _guard = self.provisioning.lock().await;
self.reconcile().await?;
let crowded = self.reclaim(seat_id).await?;
let mut payload = self.hub(seat_id).ensure_ready().await?; let mut payload = self.hub(seat_id).ensure_ready().await?;
if crowded { if crowded {
payload["crowded"] = json!(true); payload["crowded"] = json!(true);
@ -1193,8 +1244,9 @@ impl BoxPool {
Ok(()) Ok(())
} }
async fn reclaim(&self, keep: &str) -> bool { async fn reclaim(&self, keep: &str) -> Result<bool> {
let max = max_running(); let max = max_running();
let reserve_slot = !keep.is_empty();
let keep = BoxSeat::for_id(keep).id; let keep = BoxSeat::for_id(keep).id;
loop { loop {
let hubs: Vec<Arc<BoxHub>> = self.hubs.lock().unwrap().values().cloned().collect(); let hubs: Vec<Arc<BoxHub>> = self.hubs.lock().unwrap().values().cloned().collect();
@ -1204,8 +1256,9 @@ impl BoxPool {
running.push((hub.last_used().await, hub)); running.push((hub.last_used().await, hub));
} }
} }
if running.len() < max { let already_running = running.iter().any(|(_, hub)| hub.seat_id() == keep);
return false; if running.len() < max || ((!reserve_slot || already_running) && running.len() <= max) {
return Ok(false);
} }
running.sort_by_key(|(used, _)| *used); running.sort_by_key(|(used, _)| *used);
let victim = running.into_iter().find(|(_, hub)| { let victim = running.into_iter().find(|(_, hub)| {
@ -1214,10 +1267,10 @@ impl BoxPool {
match victim { match victim {
Some((_, hub)) => { Some((_, hub)) => {
if hub.stop_keep_volumes().await.is_err() { if hub.stop_keep_volumes().await.is_err() {
return true; return Err(anyhow!("Could not suspend an idle computer to free memory"));
} }
} }
None => return true, None => return Err(anyhow!("All computer slots are busy; stop an idle computer before starting another")),
} }
} }
} }

View File

@ -34,41 +34,62 @@ html, body {
height: 0 !important; height: 0 !important;
transform: none !important; transform: none !important;
} }
#noVNC_container, #noVNC_container, #noVNC_container > div, #noVNC_container canvas { border-radius:0!important; clip-path:none!important; }
#noVNC_screen, #noVNC_container { position:absolute!important; inset:0!important; width:100%!important; height:100%!important; overflow:auto!important; background:#111!important; }
#noVNC_container > div { #noVNC_screen { width:100%!important; height:100%!important; }
position: absolute !important; #noVNC_container canvas { flex:none!important; margin:auto!important; touch-action:none; }
inset: 0 !important; #lazyboy-tools { position:absolute;inset:0 0 auto;z-index:100;height:48px;display:flex;align-items:center;gap:4px;padding:0 8px;background:#181818;border-bottom:1px solid #ffffff1f;box-sizing:border-box;overflow-x:auto;font:12px system-ui;color:#eee; }
width: 100% !important; #lazyboy-tools button,#lazyboy-clipboard button { flex-shrink:0;min-height:36px;padding:6px 10px;border:1px solid #ffffff24;border-radius:7px;background:#252525;color:#eee;cursor:pointer;font:inherit;touch-action:manipulation; }
height: 100% !important; #lazyboy-tools button[aria-pressed="true"] { background:#395b50; }
margin: 0 !important; #lazyboy-clipboard { position:absolute;z-index:101;inset:52px 8px auto;padding:12px;background:#202020;border:1px solid #ffffff26;border-radius:10px;color:#eee;font:13px system-ui;box-shadow:0 12px 32px #0008; }
padding: 0 !important; #lazyboy-clipboard[hidden] { display:none; }
border: 0 !important; #lazyboy-clipboard textarea { box-sizing:border-box;width:100%;height:110px;margin:8px 0;padding:10px;background:#111;color:#fff;border:1px solid #555;border-radius:6px;font:16px system-ui;user-select:text; }
border-radius: 0 !important; #lazyboy-clipboard p { margin:8px 0 0;line-height:1.5; }
background: #111 !important; #lazyboy-pan { position:absolute;inset:0;z-index:99;touch-action:none;cursor:grab; }
overflow: hidden !important; #lazyboy-pan[hidden] { display:none; }
display: flex !important; #lazyboy-tools { display:none; }
} html[data-mobile="true"] body { display:grid;grid-template:"screen" minmax(0,1fr) "controls" auto / minmax(0,1fr); }
#noVNC_container canvas, html[data-mobile="true"] #noVNC_container { position:relative!important;grid-area:screen;min-width:0;min-height:0; }
#noVNC_screen canvas { html[data-mobile="true"] #lazyboy-tools { position:relative;inset:auto;grid-area:controls;display:flex;flex-direction:column;align-items:stretch;height:auto;max-height:50dvh;padding:8px 8px max(8px,env(safe-area-inset-bottom));border-top:1px solid #ffffff1f;border-bottom:0;overflow:auto;gap:6px; }
flex: 1 1 auto !important; #lazyboy-tools .buttons { display:flex;gap:5px;overflow-x:auto;flex-shrink:0; }
width: 100% !important; #lazyboy-tools button { min-height:44px;min-width:44px; }
height: 100% !important; #lazyboy-tools .secondary button { font-size:12px; }
max-width: none !important; #lazyboy-trackpad { height:clamp(70px,14dvh,120px);flex-shrink:0;display:grid;place-items:center;border:1px solid #ffffff30;border-radius:10px;background:#ffffff08;color:#aaa;touch-action:none;user-select:none;font-size:12px; }
max-height: none !important; #lazyboy-trackpad[hidden] { display:none; }
margin: 0 !important; #lazyboy-pointer-help { margin:0;color:#999;font-size:11px;line-height:1.4; }
padding: 0 !important; #lazyboy-cursor { display:none;position:fixed;z-index:98;width:16px;height:16px;border:2px solid white;border-radius:50%;box-shadow:0 0 0 1px #000;transform:translate(-50%,-50%);pointer-events:none; }
border: 0 !important; #lazyboy-cursor::after { content:"";position:absolute;inset:4px;background:white;border-radius:50%; }
border-radius: 0 !important; html[data-mobile="true"][data-landscape="true"] body { grid-template:"controls screen" minmax(0,1fr) / 190px minmax(0,1fr); }
object-fit: fill !important; html[data-mobile="true"][data-landscape="true"] #lazyboy-tools { max-height:100%;border-top:0;border-right:1px solid #ffffff1f; }
} html[data-landscape="true"] #lazyboy-tools .buttons { flex-wrap:wrap;overflow:visible; }
html[data-landscape="true"] #lazyboy-trackpad { height:80px; }
html[data-theme="light"] #lazyboy-trackpad { background:#eeeef0;color:#62626a;border-color:#d0d0d6; }
html[data-theme="light"] #lazyboy-pointer-help { color:#62626a; }
html[data-theme="light"] #lazyboy-tools { background:#f7f7f8;color:#18181b;border-color:#d8d8dc; }
html[data-theme="light"] #lazyboy-tools button,
html[data-theme="light"] #lazyboy-clipboard button { background:#fff;color:#18181b;border-color:#d8d8dc; }
html[data-theme="light"] #lazyboy-tools button[aria-pressed="true"] { background:#dceee6; }
html[data-theme="light"] #lazyboy-clipboard { background:#fff;color:#18181b;border-color:#d8d8dc;box-shadow:0 12px 32px #0002; }
html[data-theme="light"] #lazyboy-clipboard textarea { background:#f5f5f6;color:#18181b;border-color:#c9c9ce; }
</style> </style>
<script id="lazyboy-novnc-bridge"> <script id="lazyboy-novnc-bridge">
(function () { (function () {
// The viewer is same-origin; follow the host theme without changing remote pixels.
try {
var hostRoot = window.parent.document.documentElement;
var syncTheme = function() { document.documentElement.dataset.theme = hostRoot.dataset.theme || "dark"; };
syncTheme();
if (window.parent !== window) {
var themeObserver = new MutationObserver(syncTheme);
themeObserver.observe(hostRoot, { attributes:true, attributeFilter:["data-theme"] });
window.addEventListener("pagehide", function() { themeObserver.disconnect(); });
}
} catch (_themeError) {}
var ui = null; var ui = null;
var messages = null;
import("./core/rfb.js").then(function(mod) { messages = mod.default.messages; }).catch(function() {});
var lastHost = ""; var lastHost = "";
var lastBox = ""; var lastBox = "";
var lastGesture = 0;
var lastPasteAt = 0; var lastPasteAt = 0;
import("./app/ui.js").then(function (mod) { import("./app/ui.js").then(function (mod) {
ui = mod && (mod.default || mod.UI || mod); ui = mod && (mod.default || mod.UI || mod);
@ -98,60 +119,46 @@ html, body {
return document.getElementById("noVNC_clipboard_text"); return document.getElementById("noVNC_clipboard_text");
} }
var zoom = 1;
var paintMobilePointer = function() {};
var resetMobilePointer = function() {};
function fillCanvas(session) { function fillCanvas(session) {
if (!session) return; if (!session) return;
var canvas = session._canvas; var canvas = session._canvas;
if (canvas && canvas.style) {
canvas.style.setProperty("width", "100%", "important");
canvas.style.setProperty("height", "100%", "important");
canvas.style.margin = "0";
canvas.style.maxWidth = "none";
canvas.style.maxHeight = "none";
}
var screen = session._screen; var screen = session._screen;
if (screen && screen.style) { var container = document.getElementById("noVNC_container");
screen.style.overflow = "hidden"; if (!canvas || !screen || !container) return;
screen.style.background = "#111";
screen.style.margin = "0";
}
var display = session._display;
if (display && !display.__lazyboyFill) {
display.__lazyboyFill = true;
display._rescale = function () {
this._scale = 1;
var target = this._target;
if (!target || !target.style) return;
target.style.setProperty("width", "100%", "important");
target.style.setProperty("height", "100%", "important");
target.style.margin = "0";
};
display.absX = function (x) {
var el = this._target;
var w = (el && el.clientWidth) || 1;
var fb = this._fbWidth || 1;
return (x / w * fb) | 0;
};
display.absY = function (y) {
var el = this._target;
var h = (el && el.clientHeight) || 1;
var fb = this._fbHeight || 1;
return (y / h * fb) | 0;
};
try { display._rescale(1); } catch (_e) {}
}
try {
session.resizeSession = false; session.resizeSession = false;
session.scaleViewport = false; session.scaleViewport = false;
session.clipViewport = false; session.clipViewport = false;
session.background = "#111"; var display = session._display;
} catch (_e2) {} if (display && !display.__lazyboyFill) {
display.__lazyboyFill = true;
display._rescale = function () { this._scale = 1; };
display.absX = function (x) { return Math.floor(x / (canvas.clientWidth || 1) * this._fbWidth); };
display.absY = function (y) { return Math.floor(y / (canvas.clientHeight || 1) * this._fbHeight); };
}
var w = canvas.width || 1024, h = canvas.height || 768;
var fit = Math.min(container.clientWidth / w, container.clientHeight / h);
canvas.style.setProperty("width", Math.round(w * fit * zoom) + "px", "important");
canvas.style.setProperty("height", Math.round(h * fit * zoom) + "px", "important");
screen.style.overflow = "auto";
screen.style.background = "#111";
paintMobilePointer();
}
function status(text) {
var el = document.getElementById("lazyboy-clipboard-status");
if (el) el.textContent = text;
} }
function writeHost(text) { function writeHost(text) {
if (!text || text === lastHost || text === lastBox) return; if (typeof text !== "string") return;
lastBox = text; lastBox = text;
var output = document.getElementById("lazyboy-clipboard-text");
if (output) output.value = text;
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () {}); navigator.clipboard.writeText(text).catch(function () { status("電腦文字已收到,請按「複製到本機」或手動選取文字。"); });
} }
try { try {
if (window.parent && window.parent !== window) { if (window.parent && window.parent !== window) {
@ -160,6 +167,12 @@ html, body {
} catch (_e) {} } catch (_e) {}
} }
function requestBoxClipboard(session) {
if (messages && session && (session._clipboardServerCapabilitiesActions || {})[1 << 25]) {
messages.extendedClipboardRequest(session._sock, [1]);
}
}
function copyBox(text) { function copyBox(text) {
writeHost(text); writeHost(text);
} }
@ -172,8 +185,17 @@ html, body {
lastHost = text; lastHost = text;
var session = rfb(); var session = rfb();
if (session && typeof session.clipboardPasteFrom === "function") { if (session && typeof session.clipboardPasteFrom === "function") {
// Some servers advertise UTF-8 Provide but not Notify. noVNC otherwise
// falls back to Latin-1 and corrupts Chinese text on these servers.
var formats = session._clipboardServerCapabilitiesFormats || {};
var actions = session._clipboardServerCapabilitiesActions || {};
if (messages && formats[1] && actions[1 << 28] && !actions[1 << 27]) {
session._clipboardText = text;
messages.extendedClipboardProvide(session._sock, [1], [text]);
} else {
session.clipboardPasteFrom(text); session.clipboardPasteFrom(text);
} }
}
var box = clipboardBox(); var box = clipboardBox();
if (box) box.value = text; if (box) box.value = text;
if (typeKey && session && typeof session.sendKey === "function") { if (typeKey && session && typeof session.sendKey === "function") {
@ -194,7 +216,7 @@ html, body {
done(text || ""); done(text || "");
} }
var onMsg = function (event) { var onMsg = function (event) {
if (event.origin !== window.location.origin) return; if (event.origin !== window.location.origin || event.source !== window.parent) return;
if (!event.data || event.data.type !== "lazyboy-clipboard-text") return; if (!event.data || event.data.type !== "lazyboy-clipboard-text") return;
finish(typeof event.data.text === "string" ? event.data.text : ""); finish(typeof event.data.text === "string" ? event.data.text : "");
}; };
@ -242,11 +264,8 @@ html, body {
} }
function pasteHost(typeKey) { function pasteHost(typeKey) {
var now = Date.now();
if (now - lastGesture < 200 && !typeKey) return;
lastGesture = now;
readHostText(function (text) { readHostText(function (text) {
if (!text || text === lastBox) return; if (!text) return;
applyHostPaste(text, typeKey); applyHostPaste(text, typeKey);
}); });
} }
@ -260,7 +279,9 @@ html, body {
var text = event && event.detail && event.detail.text; var text = event && event.detail && event.detail.text;
if (typeof text === "string") copyBox(text); if (typeof text === "string") copyBox(text);
}); });
session.addEventListener("disconnect", function() { resetMobilePointer(); });
session.addEventListener("connect", function () { session.addEventListener("connect", function () {
resetMobilePointer();
hideChrome(); hideChrome();
fillCanvas(session); fillCanvas(session);
}); });
@ -270,23 +291,201 @@ html, body {
hideChrome(); hideChrome();
bindRfb(rfb()); bindRfb(rfb());
var box = clipboardBox(); var box = clipboardBox();
if (box && box.value) copyBox(box.value); if (box && box.value && box.value !== lastBox && box.value !== lastHost) copyBox(box.value);
} }
document.addEventListener("DOMContentLoaded", hideChrome); // Same interaction as LazyBoy's viewer, adapted to this noVNC version's
window.addEventListener("load", hideChrome); // scaled-coordinate _sendMouse(x, y, buttonMask) API.
window.addEventListener("focus", function () { pasteHost(false); }); function mountPointerControls(tools) {
document.addEventListener("mousedown", function () { pasteHost(false); }, true); var pad = tools.querySelector("#lazyboy-trackpad");
document.addEventListener("visibilitychange", function () { var modeButton = tools.querySelector('[data-action="mode"]');
if (document.visibilityState === "visible") pasteHost(false); var dragButton = tools.querySelector('[data-action="drag"]');
}); var cursor = document.createElement("div"); cursor.id = "lazyboy-cursor"; document.body.appendChild(cursor);
if (typeof MutationObserver === "function") { var trackpad = true, dragging = false, pointer = { x:.5, y:.5 };
new MutationObserver(hideChrome).observe(document.documentElement, { var points = new Map(), gesture = null, scrollX = 0, scrollY = 0;
attributes: true, function usable() { var r = rfb(); return r && r._rfbConnectionState === "connected" && !r.viewOnly && !r._viewOnly; }
attributeFilter: ["class"], function geometry() { var r = rfb(); return r && r._canvas && r._canvas.getBoundingClientRect(); }
subtree: true paintMobilePointer = function() {
}); var rect = geometry(), container = document.getElementById("noVNC_container").getBoundingClientRect();
var x = rect ? rect.left + pointer.x * rect.width : 0, y = rect ? rect.top + pointer.y * rect.height : 0;
var visible = document.documentElement.dataset.mobile === "true" && trackpad && usable() && rect && x >= container.left && x < container.right && y >= container.top && y < container.bottom;
cursor.style.display = visible ? "block" : "none";
cursor.style.left = x + "px"; cursor.style.top = y + "px";
};
function send(mask) {
var r = rfb(), rect = geometry();
if (!usable() || !rect || !r._sendMouse) return;
r._sendMouse(Math.max(0, Math.min(rect.width - 1, pointer.x * rect.width)), Math.max(0, Math.min(rect.height - 1, pointer.y * rect.height)), mask);
paintMobilePointer();
} }
function release() {
if (dragging) send(0);
dragging = false; dragButton.setAttribute("aria-pressed", "false");
dragButton.textContent = "拖曳";
}
function click(mask) { release(); send(mask); send(0); }
function update() {
pad.hidden = !trackpad;
modeButton.textContent = trackpad ? "觸控板" : "直接點選";
modeButton.setAttribute("aria-pressed", String(trackpad));
tools.querySelector("#lazyboy-pointer-help").textContent = trackpad ? "輕點左鍵 · 雙指捲動/輕點右鍵 · 拖曳再按一次放開" : "直接點畫面操作,或切回觸控板精準移動";
paintMobilePointer(); fillCanvas(rfb());
}
function center() {
var list = Array.from(points.values());
return { x:list.reduce(function(n,p) { return n+p.x; },0)/list.length, y:list.reduce(function(n,p) { return n+p.y; },0)/list.length };
}
pad.addEventListener("pointerdown", function(e) {
e.preventDefault(); if (!usable()) return;
pad.setPointerCapture(e.pointerId); points.set(e.pointerId, {x:e.clientX,y:e.clientY});
var c = center();
if (!gesture) { gesture = {start:c,last:c,moved:false,fingers:1,time:Date.now()}; scrollX=0;scrollY=0; }
if (points.size > 1) { release();gesture.fingers=Math.max(gesture.fingers,points.size);gesture.start=c;gesture.last=c; }
});
pad.addEventListener("pointermove", function(e) {
if (!points.has(e.pointerId) || !gesture) return;
e.preventDefault(); points.set(e.pointerId,{x:e.clientX,y:e.clientY});
var c=center(), dx=c.x-gesture.last.x, dy=c.y-gesture.last.y; gesture.last=c;
if (Math.hypot(c.x-gesture.start.x,c.y-gesture.start.y)>4) gesture.moved=true;
if (!gesture.moved) return;
if (gesture.fingers === 2 && points.size === 2) {
scrollX-=dx;scrollY-=dy;
while(Math.abs(scrollY)>=24) { click(scrollY<0?8:16);scrollY+=scrollY<0?24:-24; }
while(Math.abs(scrollX)>=24) { click(scrollX<0?32:64);scrollX+=scrollX<0?24:-24; }
} else if (gesture.fingers === 1) {
var rect=geometry(); if (!rect) return;
pointer.x=Math.max(0,Math.min(1,pointer.x+dx/rect.width));
pointer.y=Math.max(0,Math.min(1,pointer.y+dy/rect.height));
send(dragging?1:0);
}
});
pad.addEventListener("pointerup", function(e) {
if (!points.has(e.pointerId)) return;
e.preventDefault();points.delete(e.pointerId);
if (points.size) { if(gesture) gesture.last=center();return; }
if (gesture && !gesture.moved && Date.now()-gesture.time<450 && !dragging) click(gesture.fingers===2?4:1);
gesture=null;
});
function cancel() { points.clear();gesture=null;release(); }
resetMobilePointer = cancel;
pad.addEventListener("pointercancel",cancel);
window.addEventListener("blur",cancel);window.addEventListener("pagehide",cancel);
document.addEventListener("visibilitychange",function() { if(document.hidden)cancel(); });
document.getElementById("noVNC_container").addEventListener("scroll",paintMobilePointer,true);
["touchstart","touchmove","touchend"].forEach(function(type) {
document.addEventListener(type,function(e) {
if(document.documentElement.dataset.mobile!=="true" || !e.target.closest("canvas"))return;
if(trackpad) { e.preventDefault();e.stopImmediatePropagation(); }
else if(e.touches.length) {
var rect=geometry(),point=e.touches[0];
if(rect) { pointer.x=Math.max(0,Math.min(1,(point.clientX-rect.left)/rect.width));pointer.y=Math.max(0,Math.min(1,(point.clientY-rect.top)/rect.height)); }
}
},{capture:true,passive:false});
});
return { release:cancel, action:function(name) {
if(name==="mode") { cancel();trackpad=!trackpad;update();return true; }
if(["left","right","up","down","drag"].indexOf(name)<0)return false;
if(!usable())return true;
if(name==="left")click(1);
if(name==="right")click(4);
if(name==="up")click(8);
if(name==="down")click(16);
if(name==="drag") {
trackpad=true;dragging=!dragging;send(dragging?1:0);
dragButton.setAttribute("aria-pressed",String(dragging));dragButton.textContent=dragging?"放開拖曳":"拖曳";update();
}
return true;
}};
}
function mountTools() {
if (!document.getElementById("noVNC_container")) return;
if (document.getElementById("lazyboy-tools")) return;
var tools = document.createElement("div");
tools.id = "lazyboy-tools";
tools.setAttribute("role", "toolbar");
tools.setAttribute("aria-label", "電腦操作");
tools.innerHTML = '<div class="buttons"><button data-action="mode" aria-pressed="true"></button><button data-action="left"></button><button data-action="right"></button><button data-action="drag" aria-pressed="false"></button><button data-action="up" aria-label="向上捲動"></button><button data-action="down" aria-label="向下捲動"></button></div><div id="lazyboy-trackpad" role="group" aria-label="獨立觸控板"></div><p id="lazyboy-pointer-help"> · </p><div class="buttons secondary"><button data-action="out" aria-label="縮小"></button><button data-action="fit"></button><button data-action="in" aria-label="放大"></button><button data-action="pan" aria-pressed="false"></button><button data-action="clipboard">簿 / </button><button data-action="keyboard"></button><button data-key="tab">Tab</button><button data-key="enter">Enter</button><button data-key="escape">Esc</button><button data-key="backspace"></button><button data-action="copy"></button><button data-action="paste"></button></div>';
document.body.appendChild(tools);
var panel = document.createElement("section");
panel.id = "lazyboy-clipboard";
panel.hidden = true;
panel.innerHTML = '<label for="lazyboy-clipboard-text">簿</label><textarea id="lazyboy-clipboard-text" placeholder="貼上或輸入文字,傳送到電腦"></textarea><button data-action="send"></button> <button data-action="receive"></button> <button data-action="close"></button><p id="lazyboy-clipboard-status" role="status"></p>';
document.body.appendChild(panel);
var pan = document.createElement("div");
pan.id = "lazyboy-pan"; pan.hidden = true;
document.getElementById("noVNC_container").appendChild(pan);
var pointerControls = mountPointerControls(tools);
function syncMobile() {
var host = window;
try { host = window.parent; void host.innerWidth; } catch (_e) { host = window; }
var mobile = host.innerWidth <= 700 || (host.innerWidth <= 1100 && host.matchMedia("(pointer:coarse)").matches);
document.documentElement.dataset.mobile = String(mobile);
document.documentElement.dataset.landscape = String(host.innerWidth > host.innerHeight);
if (!mobile) { pointerControls.release(); pan.hidden = true; }
fillCanvas(rfb());
}
syncMobile();
window.addEventListener("resize", syncMobile);
try { if (window.parent !== window) window.parent.addEventListener("resize", syncMobile); } catch (_e) {}
window.addEventListener("pagehide", function() { try { window.parent.removeEventListener("resize", syncMobile); } catch (_e) {} });
var previous = null;
pan.onpointerdown = function(e) { previous = [e.clientX, e.clientY]; pan.setPointerCapture(e.pointerId); };
pan.onpointermove = function(e) {
var session = rfb();
if (!previous || !session || !session._screen) return;
session._screen.scrollLeft += previous[0] - e.clientX;
session._screen.scrollTop += previous[1] - e.clientY;
previous = [e.clientX, e.clientY];
};
pan.onpointerup = pan.onpointercancel = function() { previous = null; };
function action(e) {
var button = e.target.closest("button");
if (!button) return;
var name = button.dataset.action, session = rfb();
if (pointerControls.action(name)) return;
var text = document.getElementById("lazyboy-clipboard-text");
if (name === "in" || name === "out" || name === "fit") {
zoom = name === "fit" ? 1 : Math.max(1, Math.min(4, zoom + (name === "in" ? .25 : -.25)));
tools.querySelector('[data-action="fit"]').textContent = zoom === 1 ? "適合視窗" : Math.round(zoom * 100) + "% · 重設";
fillCanvas(session);
}
if (name === "pan") { pan.hidden = !pan.hidden; button.setAttribute("aria-pressed", String(!pan.hidden)); }
if (name === "clipboard") panel.hidden = !panel.hidden;
if (name === "close") panel.hidden = true;
if (name === "keyboard") pointerControls.release();
if (name === "keyboard" && ui && typeof ui.toggleVirtualKeyboard === "function") ui.toggleVirtualKeyboard();
if (name === "send") {
if (!session || session._rfbConnectionState !== "connected") { status("電腦尚未連線,請稍後再試。"); return; }
applyHostPaste(text.value, true); status("已貼到電腦。");
}
if (name === "receive") {
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text.value).then(function() { status("已複製到本機。"); }).catch(function() { text.focus(); text.select(); status("請使用系統選單複製選取的文字。"); });
else { text.focus(); text.select(); status("請使用系統選單複製選取的文字。"); }
}
if (name === "paste") readHostText(function(value) { if (value) applyHostPaste(value, true); else { panel.hidden = false; text.focus(); status("請在上方貼上文字,再按「貼到電腦」。"); } });
if (name === "copy" && session) { panel.hidden = false; sendCtrlChord(session, 0x63, "KeyC", false); setTimeout(function() { requestBoxClipboard(session); }, 150); }
var keys = { tab: [0xff09, "Tab"], enter: [0xff0d, "Enter"], escape: [0xff1b, "Escape"], backspace: [0xff08, "Backspace"] };
var key = keys[button.dataset.key];
if (key && session) { session.sendKey(key[0], key[1], true); session.sendKey(key[0], key[1], false); }
}
document.addEventListener("keydown", function(e) {
if (!e.ctrlKey || !e.altKey) return;
var name = { Equal:"in", Minus:"out", Digit0:"fit", KeyB:"clipboard" }[e.code];
if (!name) return;
e.preventDefault(); e.stopImmediatePropagation();
tools.querySelector('[data-action="' + name + '"]').click();
}, true);
tools.addEventListener("click", action);
panel.addEventListener("click", action);
window.addEventListener("resize", function() { fillCanvas(rfb()); });
}
document.addEventListener("DOMContentLoaded", function() { hideChrome(); mountTools(); });
if (document.readyState !== "loading") mountTools();
window.addEventListener("load", hideChrome);
// CSS already hides the controls. Observing class mutations while removing
// classes in the callback feeds the observer itself, starving the browser's
// event loop and allocating mutation records indefinitely.
setInterval(poll, 400); setInterval(poll, 400);
function sendCtrlChord(session, keysym, code, shift) { function sendCtrlChord(session, keysym, code, shift) {
@ -308,6 +507,7 @@ html, body {
} }
document.addEventListener("paste", function (e) { document.addEventListener("paste", function (e) {
if (e.target.closest && e.target.closest("#lazyboy-clipboard")) return;
var text = e.clipboardData && e.clipboardData.getData("text/plain"); var text = e.clipboardData && e.clipboardData.getData("text/plain");
if (!text) return; if (!text) return;
e.preventDefault(); e.preventDefault();
@ -316,6 +516,7 @@ html, body {
}, true); }, true);
document.addEventListener("copy", function (e) { document.addEventListener("copy", function (e) {
if (e.target.closest && e.target.closest("#lazyboy-clipboard")) return;
var box = clipboardBox(); var box = clipboardBox();
var text = box && box.value; var text = box && box.value;
if (!text) return; if (!text) return;
@ -327,6 +528,7 @@ html, body {
}, true); }, true);
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
if (e.target.closest && e.target.closest("#lazyboy-clipboard")) return;
var mac = /Mac/i.test((navigator && navigator.platform) || ""); var mac = /Mac/i.test((navigator && navigator.platform) || "");
var chord = mac ? e.metaKey : e.ctrlKey; var chord = mac ? e.metaKey : e.ctrlKey;
if (!chord) return; if (!chord) return;
@ -343,13 +545,14 @@ html, body {
e.preventDefault(); e.preventDefault();
sendCtrlChord(session, keysym, e.code, e.shiftKey); sendCtrlChord(session, keysym, e.code, e.shiftKey);
if (e.code === "KeyC" || e.code === "KeyX") { if (e.code === "KeyC" || e.code === "KeyX") {
setTimeout(function() { requestBoxClipboard(session); }, 150);
setTimeout(function () { setTimeout(function () {
var box = clipboardBox(); var box = clipboardBox();
if (box && box.value) copyBox(box.value); if (box && box.value && box.value !== lastBox && box.value !== lastHost) copyBox(box.value);
}, 80); }, 80);
setTimeout(function () { setTimeout(function () {
var box = clipboardBox(); var box = clipboardBox();
if (box && box.value) copyBox(box.value); if (box && box.value && box.value !== lastBox && box.value !== lastHost) copyBox(box.value);
}, 220); }, 220);
} }
}, true); }, true);
@ -388,7 +591,7 @@ mod tests {
assert!(out.contains("import(\"./app/ui.js\")")); assert!(out.contains("import(\"./app/ui.js\")"));
assert!(out.contains("lazyboy-clipboard-write")); assert!(out.contains("lazyboy-clipboard-write"));
assert!(out.contains("#noVNC_connect_dlg")); assert!(out.contains("#noVNC_connect_dlg"));
assert!(out.contains("object-fit: fill")); assert!(out.contains("lazyboy-tools"));
assert!(out.ends_with("</html>")); assert!(out.ends_with("</html>"));
} }

View File

@ -84,6 +84,11 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
ca_pem: tls.as_ref().map(|m| m.ca_pem.clone()), ca_pem: tls.as_ref().map(|m| m.ca_pem.clone()),
vnc_client: reqwest::Client::new(), vnc_client: reqwest::Client::new(),
}; };
match tokio::time::timeout(std::time::Duration::from_secs(15), app.box_pool.recover()).await {
Ok(Ok(())) => {},
Ok(Err(error)) => eprintln!("computer resource limits: {error}"),
Err(_) => eprintln!("computer resource limits: Docker did not respond within 15s"),
}
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)
.allow_methods(Any) .allow_methods(Any)

View File

@ -0,0 +1,42 @@
import { chromium } from '../tools/playwright/node_modules/playwright/index.mjs';
import assert from 'node:assert/strict';
const browser=await chromium.launch({headless:true});
try {
for(const theme of ['light','dark']) {
const page=await browser.newPage({viewport:{width:1440,height:900},serviceWorkers:'block'});
await page.addInitScript(theme=>{localStorage.setItem('lazyboy.theme',theme);localStorage.setItem('lazyboy.locale','zh-Hant')},theme);
let task='old-task',state='running',stops=0;
const activity=()=>({state,running:state==='running',queued:false,observed_at_ms:Date.now(),active_task_ids:state==='running'?[task]:[]});
const agent=()=>({id:'fixture',name:'小麻糬',running:true,activity:activity(),avatar_color:'#eec78c'});
const transcript=[{role:'user',content:'幫我整理今天的工作,還有接下來的步驟。',at:'2026-09-16T08:00:00Z'},{role:'assistant',content:'可以,我會先整理重點,再列出待辦事項。\n\n### 今天的進度\n- 電腦畫面恢復完整\n- 新的透明背景圖示\n- 深淺主題都能清楚閱讀\n\n使用 `npm run build` 驗證。\n\n```js\nconst ready = true;\n```',at:'2026-09-16T08:00:01Z'}];
await page.route('**/api/**',async route=>{
const path=new URL(route.request().url()).pathname;
if(path.endsWith('/stop')) {stops++;await new Promise(r=>setTimeout(r,600));return route.fulfill({json:{ok:true}})}
if(path.endsWith('/activity'))return route.fulfill({json:activity()});
if(path.endsWith('/events'))return route.fulfill({contentType:'text/event-stream',body:'data: {"events":[]}\n\n'});
if(path.endsWith('/computer'))return route.fulfill({json:{ready:true,state:'ready',viewer_url:'/fixture-screen'}});
if(path==='/api/agents')return route.fulfill({json:{agents:[agent()]}});
if(path==='/api/agents/fixture')return route.fulfill({json:{...agent(),transcript}});
return route.fulfill({json:{}});
});
await page.route('**/fixture-screen?*',r=>r.fulfill({contentType:'text/html',body:'<body style="margin:0;background:#1b2029;color:white;font:16px system-ui;display:grid;place-items:center;height:100vh">Desktop preview</body>'}));
await page.goto('http://127.0.0.1:5173');
await page.locator('.stop-send').waitFor();
assert.ok(await page.locator('.avatar.thinking').count()>0);
await page.locator('.stop-send').click();
assert.equal(await page.locator('.avatar.thinking').count(),0,'stop must immediately remove all thinking rings');
await page.waitForTimeout(4500); // stale activity + roster poll must not revive stopped work
assert.equal(stops,1);assert.equal(await page.locator('.avatar.thinking').count(),0);
await page.screenshot({path:`.run/appearance-${theme}.png`});
const colors=await page.locator('.message.assistant .message-body').first().evaluate(el=>({bg:getComputedStyle(el).backgroundColor,fg:getComputedStyle(el).color}));
if(theme==='light'){assert.equal(colors.bg,'rgb(240, 241, 243)');assert.equal(colors.fg,'rgb(20, 20, 20)')}
task='new-task';
await page.locator('.stop-send').waitFor({timeout:6000});
assert.ok(await page.locator('.avatar.thinking').count()>0,'new work must animate normally');
state='idle';
await page.locator('.stop-send').waitFor({state:'hidden',timeout:6000});
assert.equal(await page.locator('.bot-row.selected .avatar.thinking').count(),0,'idle activity wins over stale roster');
console.log(`PASS ${theme}: immediate stop, stale activity suppression, new task and idle avatar`);
await page.close();
}
}finally{await browser.close()}

View File

@ -0,0 +1,38 @@
// Opt-in: inspect the real desktop without typing or changing its clipboard.
import { chromium } from '../tools/playwright/node_modules/playwright/index.mjs';
import assert from 'node:assert/strict';
const browser=await chromium.launch({headless:true,args:["--disable-features=LocalNetworkAccessChecks"]});
try {
const page=await browser.newPage({viewport:{width:1440,height:900},hasTouch:true});page.setDefaultTimeout(15000);page.on("pageerror",e=>console.error(e.message));
await page.goto(process.env.LAZYBOY_TEST_UI_URL||'http://127.0.0.1:8787');
const iframe=page.locator('iframe.desktop-frame');await iframe.waitFor({timeout:30000});
const frame=await (await iframe.elementHandle()).contentFrame();
await frame.waitForFunction(async()=>{const {default:ui}=await import('./app/ui.js');return ui.rfb?._rfbConnectionState==='connected'},undefined,{timeout:30000});
await page.waitForTimeout(2000);
const live=page.frameLocator('iframe.desktop-frame');
await page.screenshot({path:'.run/desktop-controls-inspect.png'});
assert.equal(await live.locator('#noVNC_container').evaluate(el=>getComputedStyle(el).borderBottomRightRadius),'0px');
assert.equal(await live.locator('#lazyboy-tools').isVisible(),false);
await page.screenshot({path:'.run/desktop-controls-desktop.png'});
await page.locator('.computer-expand').click();
await page.setViewportSize({width:390,height:844});
const mobile=page.frameLocator('iframe.desktop-frame');
await page.locator('.computer-overlay .computer-hud').waitFor({state:'hidden',timeout:30000});
await mobile.locator('canvas').waitFor();
await mobile.locator('#lazyboy-trackpad').waitFor();
const before=await mobile.locator('canvas').boundingBox();
await mobile.getByRole('button',{name:'放大',exact:true}).click();
const after=await mobile.locator('canvas').boundingBox();assert.ok(after.width>before.width*1.2);
await page.screenshot({path:'.run/desktop-mobile-trackpad.png'});
await mobile.getByRole('button',{name:'剪貼簿 / 輸入'}).click();
await page.screenshot({path:'.run/desktop-controls-mobile.png'});
await page.evaluate(()=>document.documentElement.dataset.theme='light');
await mobile.locator('html[data-theme=light]').waitFor();
assert.equal(await mobile.locator('#lazyboy-tools').evaluate(el=>getComputedStyle(el).backgroundColor),'rgb(247, 247, 248)');
await page.screenshot({path:'.run/desktop-controls-mobile-light.png'});
await mobile.getByRole('button',{name:'關閉',exact:true}).click();
await page.setViewportSize({width:844,height:390});
await mobile.locator('html[data-landscape=true]').waitFor();
await page.screenshot({path:'.run/desktop-mobile-landscape.png'});
console.log('PASS desktop without toolbar, real noVNC zoom, phone trackpad, landscape left controls and theme sync');
}finally{await Promise.all(browser.contexts().map(c=>c.unrouteAll({behavior:"ignoreErrors"})));await browser.close()}

View File

@ -0,0 +1,109 @@
import { chromium } from '../tools/playwright/node_modules/playwright/index.mjs';
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
const source = readFileSync(new URL('../crates/lazyboy-core/src/novnc_skin.rs', import.meta.url), 'utf8');
const skin = source.split('r###"')[1].split('"###;')[0];
const browser = await chromium.launch({headless:true});
try {
for (const mobile of [false,true]) {
const context=await browser.newContext({viewport:mobile?{width:390,height:740}:{width:1100,height:800},hasTouch:mobile,isMobile:mobile});
const page=await context.newPage();page.setDefaultTimeout(10000);
const errors=[];page.on('pageerror',e=>{errors.push(e.message);console.error(e.message)});
await page.route('http://desktop.test/**', route=>route.request().url().endsWith('/host.html')
? route.fulfill({contentType:'text/html',body:'<iframe src="/vnc.html" style="width:380px;height:600px"></iframe>'})
: route.request().url().endsWith('/core/rfb.js')
? route.fulfill({contentType:'application/javascript',body:'export default {messages:{extendedClipboardProvide(sock,formats,text){window.pastes.push(text[0])},extendedClipboardRequest(){}}};'})
: route.request().url().endsWith('/app/ui.js')
? route.fulfill({contentType:'application/javascript',body:`
const session = new EventTarget();
Object.assign(session, {_canvas:document.querySelector('canvas'),_screen:document.querySelector('#screen'),_display:{_fbWidth:1200,_fbHeight:800},_rfbConnectionState:'connected',_sendMouse(x,y,mask){window.pointerEvents.push({x,y,mask})},clipboardPasteFrom(text){window.pastes.push(text)},sendKey(...key){window.keys.push(key)}});
window.session=session; window.pastes=[];window.keys=[];window.pointerEvents=[];
export default {rfb:session,toggleVirtualKeyboard(){window.keyboardOpened=true}};`})
: route.fulfill({contentType:'text/html',body:`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>#noVNC_container{border-bottom-right-radius:800px 600px}</style><body><div id="noVNC_container"><div id="screen" style="display:flex;width:100%;height:100%"><canvas width="1200" height="800"></canvas></div></div><textarea id="noVNC_clipboard_text" hidden></textarea>${skin}</body>`}));
await page.goto('http://desktop.test/vnc.html');
await page.waitForFunction(()=>window.session?.__lazyboyBound);
const bounds=()=>page.locator('canvas').boundingBox();
assert.equal(await page.locator("#noVNC_container").evaluate(el=>getComputedStyle(el).borderBottomRightRadius),"0px");
const initial=await bounds();assert.ok(Math.abs(initial.width/initial.height-1.5)<.01);
assert.equal(await page.locator('#lazyboy-tools').isVisible(),mobile);
if(mobile) await page.getByRole('button',{name:'放大',exact:true}).click();
else await page.keyboard.press('Control+Alt+Equal');
const zoomed=await bounds(); assert.ok(zoomed.width>initial.width*1.2);
const coords=await page.evaluate(()=>[session._display.absX(session._canvas.clientWidth/2),session._display.absY(session._canvas.clientHeight/2)]);
assert.deepEqual(coords,[600,400]);
if(mobile){
await page.getByRole('button',{name:'移動畫面'}).click();
assert.equal(await page.locator('#lazyboy-pan').isVisible(),true);
await page.getByRole('button',{name:'移動畫面'}).click();
}
if(mobile) await page.getByRole('button',{name:'剪貼簿 / 輸入'}).click();
else await page.keyboard.press('Control+Alt+KeyB');
const input=page.locator('#lazyboy-clipboard-text');
await input.fill('手機中文貼上 test');
await page.getByRole('button',{name:'貼到電腦',exact:true}).click();
await page.waitForTimeout(300);
await page.getByRole('button',{name:'貼到電腦',exact:true}).click();
await page.waitForTimeout(100);
assert.deepEqual(await page.evaluate(()=>pastes),['手機中文貼上 test','手機中文貼上 test']);
assert.equal(await page.evaluate(()=>keys.filter(k=>k[1]==='KeyV'&&k[2]).length),2);
await page.evaluate(()=>session.dispatchEvent(new CustomEvent('clipboard',{detail:{text:'電腦回傳 ✓'}})));
assert.equal(await input.inputValue(),'電腦回傳 ✓');
await page.getByRole('button',{name:'複製到本機'}).click();
assert.equal(await input.evaluate(el=>el.selectionEnd-el.selectionStart),'電腦回傳 ✓'.length);
await page.getByRole('button',{name:'關閉',exact:true}).click();
if(mobile) {
await page.getByRole('button',{name:'鍵盤',exact:true}).click();
assert.equal(await page.evaluate(()=>keyboardOpened),true);
await page.getByRole('button',{name:'Enter',exact:true}).click();
assert.ok(await page.evaluate(()=>keys.some(k=>k[1]==='Enter'&&k[2])));
const pad=page.locator('#lazyboy-trackpad');
const box=await pad.boundingBox(), x=box.x+box.width/2,y=box.y+box.height/2;
await page.mouse.move(x,y);await page.mouse.down();await page.mouse.move(x+30,y+10,{steps:4});await page.mouse.up();
assert.ok(await page.evaluate(()=>pointerEvents.some(e=>e.mask===0 && e.x>session._canvas.clientWidth/2)));
await page.getByRole('button',{name:'右鍵',exact:true}).click();
assert.deepEqual(await page.evaluate(()=>pointerEvents.slice(-2).map(e=>e.mask)),[4,0]);
await page.getByRole('button',{name:'拖曳',exact:true}).click();
await page.mouse.move(x,y);await page.mouse.down();await page.mouse.move(x-30,y,{steps:3});await page.mouse.up();
assert.equal(await page.evaluate(()=>pointerEvents.at(-1).mask),1);
await page.getByRole('button',{name:'放開拖曳',exact:true}).click();
assert.equal(await page.evaluate(()=>pointerEvents.at(-1).mask),0);
const cdp=await context.newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:x-20,y},{x:x+20,y}]});
await cdp.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x:x-20,y:y-30},{x:x+20,y:y-30}]});
await cdp.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
assert.ok(await page.evaluate(()=>pointerEvents.some(e=>e.mask===16)),'two fingers scroll');
await page.evaluate(()=>pointerEvents=[]);
await cdp.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:x-20,y},{x:x+20,y}]});
await cdp.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
assert.deepEqual(await page.evaluate(()=>pointerEvents.map(e=>e.mask)),[4,0],'two finger tap is only right click');
await page.getByRole('button',{name:'拖曳',exact:true}).click();
await page.evaluate(()=>window.dispatchEvent(new Event('blur')));
assert.equal(await page.evaluate(()=>pointerEvents.at(-1).mask),0,'blur releases held button');
await page.evaluate(()=>{pointerEvents=[];session._viewOnly=true});
await page.getByRole('button',{name:'左鍵',exact:true}).click();
assert.equal(await page.evaluate(()=>pointerEvents.length),0);
await page.evaluate(()=>session._viewOnly=false);
await page.screenshot({path:'.run/mobile-trackpad-portrait.png'});
await page.setViewportSize({width:844,height:390});
await page.waitForFunction(()=>document.documentElement.dataset.landscape==='true');
const panel=await page.locator('#lazyboy-tools').boundingBox(),screen=await page.locator('#noVNC_container').boundingBox();
assert.ok(panel.x+panel.width<=screen.x+1,'landscape controls belong on the left');
await page.screenshot({path:'.run/mobile-trackpad-landscape.png'});
}
await page.evaluate(()=>{session._clipboardServerCapabilitiesFormats={1:true};session._clipboardServerCapabilitiesActions={[1<<28]:true};});
if(mobile) await page.getByRole('button',{name:'剪貼簿 / 輸入'}).click();
else await page.keyboard.press('Control+Alt+KeyB');
await input.fill('UTF-8 中文 ✓');
await page.getByRole('button',{name:'貼到電腦',exact:true}).click();
assert.equal(await page.evaluate(()=>pastes.at(-1)),'UTF-8 中文 ✓');
assert.deepEqual(errors,[]);
console.log(`PASS ${mobile?'mobile':'desktop'} scaling, coordinates, repeated paste, remote clipboard, fallback and keyboard`);
if(!mobile){
await page.goto('http://desktop.test/host.html');
const inner=page.frameLocator('iframe');
await inner.locator('#lazyboy-tools').waitFor({state:'attached'});
assert.equal(await inner.locator('#lazyboy-tools').isVisible(),false,'narrow desktop iframe is not a phone');
}
await context.close();
}
} finally {await browser.close();}

View File

@ -0,0 +1,47 @@
// Opt-in live desktop soak test: opens the UI, sends no chat/model requests.
import { chromium } from '../tools/playwright/node_modules/playwright/index.mjs';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
const browser=await chromium.launch({headless:true});
try {
const page=await browser.newPage({viewport:{width:1440,height:900}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(process.env.LAZYBOY_TEST_UI_URL || 'http://127.0.0.1:8787');
const iframe=page.locator('iframe.desktop-frame');
await iframe.waitFor({timeout:60000});
const frame=await (await iframe.elementHandle()).contentFrame();
await frame.waitForSelector('canvas',{timeout:30000});
const connected=()=>frame.evaluate(async()=>{
const {default:ui}=await import(new URL('./app/ui.js',location.href).href);
return ui.rfb?._rfbConnectionState;
});
await frame.waitForFunction(async()=>{
const {default:ui}=await import(new URL('./app/ui.js',location.href).href);
return ui.rfb?._rfbConnectionState==='connected';
}, undefined, {timeout:30000});
const cdp=await page.context().newCDPSession(page);
const browserCdp=await browser.newBrowserCDPSession();
const measure=async()=>{
const heap=await cdp.send('Runtime.getHeapUsage');
const dom=await cdp.send('Memory.getDOMCounters');
const {processInfo}=await browserCdp.send('SystemInfo.getProcessInfo');
const rss=execFileSync('ps',['-o','rss=','-p',processInfo.map(p=>p.id).join(',')],{encoding:'utf8'}).trim().split(/\s+/).reduce((sum,n)=>sum+Number(n),0);
return {heapMiB:heap.usedSize/1024/1024,rssMiB:rss/1024,nodes:dom.nodes,listeners:dom.jsEventListeners};
};
await cdp.send('HeapProfiler.collectGarbage');
const initial=await measure();
console.log('desktop initial',JSON.stringify(initial));
for(let i=0;i<Number(process.env.LAZYBOY_SOAK_SAMPLES || 6);i++) {
await page.waitForTimeout(10000);
assert.equal(await connected(),'connected');
console.log(`desktop ${10*(i+1)}s`,JSON.stringify(await measure()));
}
await cdp.send('HeapProfiler.collectGarbage');
const final=await measure();
assert.ok(final.heapMiB-initial.heapMiB<24,'retained heap grew by more than 24 MiB');
assert.ok(final.rssMiB-initial.rssMiB<128,'browser RSS grew by more than 128 MiB');
assert.ok(final.nodes-initial.nodes<500,'DOM nodes accumulated');
assert.ok(final.listeners-initial.listeners<100,'listeners accumulated');
assert.deepEqual(errors,[]);
console.log('PASS live desktop soak',JSON.stringify({initial,final}));
} finally {await browser.close();}

View File

@ -0,0 +1,44 @@
import { chromium } from '../tools/playwright/node_modules/playwright/index.mjs';
import assert from 'node:assert/strict';
const transcript = Array.from({length:5000}, (_, i) => ({role:i%2?'assistant':'user', content:`Message ${i}\n\n${'Variable height **Markdown** content. '.repeat(i%8+1)}`,at:new Date(1700000000000+i*60000).toISOString()}));
const activity={state:'idle',running:false,queued:false,active_task_ids:[]};
const browser=await chromium.launch({headless:true});
try {
const page=await browser.newPage({viewport:{width:1280,height:800}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.addInitScript(()=>{window.sources=[];window.EventSource=class {
constructor(){window.sources.push(this)} close(){} };});
await page.route('**/api/**',route=>{
const path=new URL(route.request().url()).pathname;
if(path==='/api/agents') return route.fulfill({json:{agents:[{id:'owner',name:'Memory test'}]}});
if(path==='/api/agents/owner') return route.fulfill({json:{id:'owner',name:'Memory test',transcript,activity}});
if(path.endsWith('/activity'))return route.fulfill({json:activity});
if(path.endsWith('/computer'))return route.fulfill({json:{ready:false,state:'stopped'}});
return route.fulfill({json:{}});
});
await page.goto(process.env.LAZYBOY_TEST_UI_URL || 'http://127.0.0.1:5173');
await page.locator('[data-message-index="4999"]').waitFor();
const cdp=await page.context().newCDPSession(page);
const heap=async()=>{await cdp.send('HeapProfiler.collectGarbage');return (await cdp.send('Runtime.getHeapUsage')).usedSize;};
const baseline=await heap();
let maxRows=0;
for(let round=0;round<3;round++)for(let step=0;step<30;step++){
await page.locator('.messages').evaluate((el,ratio)=>{el.scrollTop=(el.scrollHeight-el.clientHeight)*ratio;},step/29);
await page.waitForTimeout(40);
maxRows=Math.max(maxRows,await page.locator('.message-block').count());
}
await page.locator('.messages').evaluate(el=>{el.scrollTop=0});
await page.locator('[data-message-index="0"]').waitFor();
const top=await page.locator('.messages').evaluate(el=>el.scrollTop);
transcript.push({role:'assistant',content:'NEW REPLY',at:new Date().toISOString()});
await page.evaluate(()=>window.sources.at(-1).onopen());
await page.waitForTimeout(500);
assert.ok(Math.abs(await page.locator('.messages').evaluate(el=>el.scrollTop)-top)<5,'new reply must not pull reader down');
await page.locator('.messages').evaluate(el=>{el.scrollTop=el.scrollHeight});
await page.getByText('NEW REPLY',{exact:true}).waitFor();
const end=await heap();
assert.ok(maxRows<80,`rendered ${maxRows} rows`);
assert.ok(end-baseline<32*1024*1024,`heap grew ${(end-baseline)/1024/1024} MiB`);
assert.deepEqual(errors,[]);
console.log(JSON.stringify({result:'PASS',messages:transcript.length,maxRows,baselineMiB:baseline/1024/1024,finalMiB:end/1024/1024}));
} finally {await browser.close();}

98
tests/mobile_inbox_ui.mjs Normal file
View File

@ -0,0 +1,98 @@
import { chromium } from '../tools/playwright/node_modules/playwright/index.mjs';
import assert from 'node:assert/strict';
const browser=await chromium.launch({headless:true});
try {
for(const theme of ['dark','light']) {
const page=await browser.newPage({viewport:{width:390,height:844},hasTouch:true,isMobile:true,serviceWorkers:'block'});
page.setDefaultTimeout(8000);
await page.addInitScript(theme=>{localStorage.setItem('lazyboy.theme',theme);localStorage.setItem('lazyboy.locale','zh-Hant');localStorage.setItem('lazyboy.agent','beta')},theme);
const errors=[];page.on('pageerror',e=>errors.push(e.message));
let opened=[],computers=0,globalActions=[];
page.on('dialog',dialog=>dialog.accept());
const activity={state:'idle',running:false,queued:false,active_task_ids:[],observed_at_ms:Date.now()};
const agents=[{id:'alpha',name:'小麻糬',avatar_color:'#edc585',preview:'今天想一起完成什麼?',last_at:'2026-09-16T08:00:00Z'},{id:'beta',name:'程式小幫手',avatar_color:'#a3cfef',preview:'剛才的修改已經完成。',last_at:'2026-09-16T07:55:00Z'}];
await page.route('**/api/**',route=>{
const path=new URL(route.request().url()).pathname;
if(path==='/api/agents')return route.fulfill({json:{agents}});
if(path.endsWith('/activity'))return route.fulfill({json:activity});
if(path.endsWith('/events'))return route.fulfill({contentType:'text/event-stream',body:'data: {"events":[]}\n\n'});
if(path==='/api/computer'){globalActions.push(route.request().postDataJSON().action);return route.fulfill({json:{ready:false,error:'測試電腦回應'}})}
if(path.endsWith('/computer')){computers++;return route.fulfill({json:{ready:false}})}
const a=agents.find(a=>path===`/api/agents/${a.id}`);
if(a){opened.push(a.id);return route.fulfill({json:{...a,activity,transcript:[{role:'assistant',content:`你好,我是${a.name}`,at:'2026-09-16T08:00:00Z'}]}})}
return route.fulfill({json:{}});
});
await page.goto('http://127.0.0.1:5173');
await page.getByRole('button',{name:/小麻糬 今天/}).waitFor();
assert.equal(await page.locator('.chat-panel').isVisible(),false);
assert.deepEqual(opened,[],'remembered agent must not open on mobile');assert.equal(computers,0);
const list=await page.locator('.sidebar').boundingBox();assert.equal(list.x,0);assert.equal(list.width,390);assert.equal(list.height,844);
assert.equal(await page.locator('.mobile-nav-backdrop').count(),0);
assert.equal(await page.locator('.brand-icon').count(),0,'phone header has no brand row');
const heading=await page.locator('.mobile-profile').boundingBox(),add=await page.locator('.brand .icon-button').boundingBox();
assert.ok(Math.abs(heading.y+heading.height/2-add.y-add.height/2)<1);
await page.getByRole('button',{name:'設定',exact:true}).click();
const dialog=page.getByRole('dialog');await dialog.waitFor();
const settings=await dialog.boundingBox();assert.equal(settings.width,374);assert.equal(settings.height,740);assert.equal(settings.x,8);
assert.equal(await page.locator('.lb-settings-nav').count(),0);
await page.screenshot({path:`.run/mobile-settings-${theme}.png`});
await dialog.getByRole('button',{name:/電腦.*更新/}).click();
await dialog.getByRole('button',{name:'更新',exact:true}).click();
await dialog.getByText('測試電腦回應').waitFor();
await dialog.getByRole('button',{name:'重啟',exact:true}).click();
await page.waitForFunction(()=>!document.querySelector('.lb-settings-control button:disabled'));
assert.deepEqual(globalActions,['update','restart']);
await dialog.getByRole('button',{name:'設定',exact:true}).click();
await dialog.getByRole('button',{name:/外觀/}).click();
const themeOption=dialog.getByRole('radio',{name:theme==='dark'?'淺色':'深色',exact:true});
await themeOption.click();assert.equal(await themeOption.getAttribute('aria-checked'),'true');
assert.equal(await page.locator('html').getAttribute('data-theme'),theme==='dark'?'light':'dark');
await dialog.getByRole('radio',{name:theme==='dark'?'深色':'淺色',exact:true}).click();
await page.screenshot({path:`.run/mobile-theme-${theme}.png`});
await dialog.getByRole('button',{name:'設定',exact:true}).click();
await dialog.getByRole('button',{name:/語言/}).click();
await dialog.getByRole('radio',{name:'English',exact:true}).click();
await dialog.getByRole('button',{name:'Settings',exact:true}).click();
await dialog.getByRole('button',{name:/Language/}).click();
await dialog.getByRole('radio',{name:'繁體中文',exact:true}).click();
await dialog.getByRole('button',{name:'設定',exact:true}).click();
await dialog.getByRole('button',{name:/關於/}).click();
await page.locator('.mobile-about-page').waitFor();
await dialog.getByRole('button',{name:'設定',exact:true}).click();
await dialog.getByRole('button',{name:'返回聊天列表',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.getByRole('button',{name:'設定',exact:true}).click();
await dialog.waitFor();await page.goBack();await dialog.waitFor({state:'hidden'});
await page.screenshot({path:`.run/mobile-inbox-${theme}.png`});
await page.getByRole('button',{name:/小麻糬 今天/}).click();
await page.locator('.messages').getByText('你好,我是小麻糬。').waitFor();
assert.equal(await page.locator('.sidebar').isVisible(),false);
assert.equal(await page.locator('.chat-panel').isVisible(),true);assert.equal(computers,0,'chat selection must not boot a remote desktop');
await page.locator('.composer textarea').fill('保留中的草稿');
await page.screenshot({path:`.run/mobile-chat-${theme}.png`});
await page.getByRole('button',{name:'返回聊天列表'}).click();
await page.locator('.sidebar').waitFor({state:'visible'});
await page.getByRole('button',{name:'搜尋',exact:true}).click();
await page.getByRole('textbox',{name:'搜尋'}).fill('程式');
assert.equal(await page.locator('.bot-row').count(),1);
await page.locator('.bot-row').click();
await page.locator('.messages').getByText('你好,我是程式小幫手。').waitFor();
await page.goBack();await page.locator('.sidebar').waitFor({state:'visible'});
await page.getByRole('textbox',{name:'搜尋'}).fill('');
await page.getByRole('button',{name:/小麻糬 今天/}).click();
await page.locator('.chat-panel').waitFor({state:'visible'});
await page.reload();await page.locator('.sidebar').waitFor({state:'visible'});
assert.equal(await page.locator('.chat-panel').isVisible(),false,'reload starts at list');
await page.setViewportSize({width:844,height:390});
assert.equal(await page.locator('.sidebar').isVisible(),true,'phone landscape keeps full-page list');
assert.equal((await page.locator('.sidebar').boundingBox()).width,844);
await page.setViewportSize({width:1440,height:900});
await page.locator('.chat-panel').waitFor({state:'visible'});
assert.equal(await page.locator('.sidebar').isVisible(),true,'desktop keeps both panels');
await page.locator('.messages').getByText('你好,我是小麻糬。').waitFor();
assert.ok(computers>0);
assert.deepEqual(errors,[]);
console.log(`PASS ${theme}: full-page inbox, choose chat, UI/browser back, search, reload, rotation and desktop layout`);
await page.close();
}
}finally{await browser.close()}

30
tests/novnc_memory_ui.mjs Normal file
View File

@ -0,0 +1,30 @@
// Run the actual injected bridge in an isolated browser; never touch user profiles.
import { chromium } from '../tools/playwright/node_modules/playwright/index.mjs';
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
const source = readFileSync(new URL('../crates/lazyboy-core/src/novnc_skin.rs', import.meta.url), 'utf8');
const skin = source.split('r###"')[1].split('"###;')[0];
const browser = await chromium.launch({headless:true});
try {
const page = await browser.newPage();
await page.addInitScript(() => {
window.mutations = 0;
const Native = window.MutationObserver;
window.MutationObserver = class extends Native {
constructor(callback) { super((records, observer) => {
window.mutations += records.length;
// Bound a broken implementation so the regression cannot itself cause OOM.
if (window.mutations > 1000) { observer.disconnect(); return; }
callback(records, observer);
}); }
};
});
await page.route('http://bridge.test/**', route => route.request().url().endsWith('/app/ui.js')
? route.fulfill({contentType:'application/javascript',body:'export default {};'} )
: route.fulfill({contentType:'text/html',body:`<!doctype html><html><head>${skin}</head><body><div id="noVNC_control_bar" class="noVNC_open"></div><div id="noVNC_status"></div></body></html>`}));
await page.goto('http://bridge.test/vnc.html');
await page.waitForTimeout(1200);
const mutations=await page.evaluate(()=>window.mutations);
assert.ok(mutations < 100, `bridge mutation feedback loop: ${mutations} records`);
console.log(`PASS noVNC bridge remains responsive (${mutations} mutation records)`);
} finally { await browser.close(); }

View File

@ -48,13 +48,13 @@ try {
}); });
const url = process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173"; const url = process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173";
await page.goto(url); await page.goto(url);
await page.getByText("這是會留下來的回覆", { exact: true }).waitFor(); await page.locator(".messages").getByText("這是會留下來的回覆", { exact: true }).waitFor();
await page.waitForFunction(() => document.body.innerText.includes("這是會留下來的回覆")); await page.waitForFunction(() => document.body.innerText.includes("這是會留下來的回覆"));
assert.equal(await page.getByText("這是會留下來的回覆", { exact: true }).count(), 1); assert.equal(await page.locator(".messages").getByText("這是會留下來的回覆", { exact: true }).count(), 1);
assert.equal(await page.getByText("這段 scratchpad 不該出現在聊天裡").count(), 0); assert.equal(await page.getByText("這段 scratchpad 不該出現在聊天裡").count(), 0);
await page.reload(); await page.reload();
await page.getByText("這是會留下來的回覆", { exact: true }).waitFor(); await page.locator(".messages").getByText("這是會留下來的回覆", { exact: true }).waitFor();
assert.equal(await page.getByText("這是會留下來的回覆", { exact: true }).count(), 1); assert.equal(await page.locator(".messages").getByText("這是會留下來的回覆", { exact: true }).count(), 1);
assert.equal(await page.getByText("這段 scratchpad 不該出現在聊天裡").count(), 0); assert.equal(await page.getByText("這段 scratchpad 不該出現在聊天裡").count(), 0);
console.log("PASS transcript stays the public snapshot across events and reload"); console.log("PASS transcript stays the public snapshot across events and reload");
} finally { } finally {

View File

@ -10,8 +10,8 @@
<meta name="apple-mobile-web-app-title" content="LazyBoy" /> <meta name="apple-mobile-web-app-title" content="LazyBoy" />
<title>LazyBoy</title> <title>LazyBoy</title>
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" /> <link rel="icon" href="/lazyboy-round.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg" /> <link rel="apple-touch-icon" href="/lazyboy-round.svg" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

1504
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,10 +11,13 @@
"dependencies": { "dependencies": {
"@blobatar/react": "^2.7.0", "@blobatar/react": "^2.7.0",
"@fontsource/huninn": "^5.3.0", "@fontsource/huninn": "^5.3.0",
"@tanstack/react-virtual": "^3.14.13",
"blobatar": "^2.7.0", "blobatar": "^2.7.0",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-useanimations": "^2.10.0" "react-markdown": "^10.1.0",
"react-useanimations": "^2.10.0",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.3.0", "@types/react": "^19.3.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g fill="#F1F2F2"><path d="M84.5 48.88C84.5 66.49 68.81 81.73 50.68 81.73C32.55 81.73 16.87 66.49 16.87 48.88C16.87 31.27 32.55 16.04 50.68 16.04C68.81 16.04 84.5 31.27 84.5 48.88Z"/></g><g fill="#202124"><path d="M46.69 49.66C46.97 55.31 46.74 55.83 43.95 55.97C41.16 56.1 40.88 55.61 40.61 49.96C40.33 44.31 40.56 43.79 43.35 43.65C46.14 43.52 46.42 44.02 46.69 49.66Z"/><path d="M64.06 48.8C64.04 53.96 63.83 54.43 61.47 54.42C59.11 54.42 58.89 53.95 58.91 48.79C58.92 43.62 59.13 43.16 61.49 43.16C63.86 43.17 64.07 43.63 64.06 48.8Z"/></g></svg>

After

Width:  |  Height:  |  Size: 612 B

View File

@ -7,5 +7,5 @@
"orientation": "portrait", "orientation": "portrait",
"background_color": "#141414", "background_color": "#141414",
"theme_color": "#141414", "theme_color": "#141414",
"icons": [{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }] "icons": [{ "src": "/lazyboy-round.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }]
} }

View File

@ -1,6 +1,5 @@
import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FormEvent, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import arrowUp from "react-useanimations/lib/arrowUp"; import { useVirtualizer } from "@tanstack/react-virtual";
import menu from "react-useanimations/lib/menu";
import searchToX from "react-useanimations/lib/searchToX"; import searchToX from "react-useanimations/lib/searchToX";
import { api, type HumanQuestion, type AgentActivity, type AgentRow, type TeamEvent, type TranscriptItem } from "./api"; import { api, type HumanQuestion, type AgentActivity, type AgentRow, type TeamEvent, type TranscriptItem } from "./api";
import { AgentSettingsDialog } from "./agent-settings"; import { AgentSettingsDialog } from "./agent-settings";
@ -27,7 +26,7 @@ function TrashIcon() {
} }
function isPhone() { function isPhone() {
return typeof window !== "undefined" && window.matchMedia("(max-width: 700px)").matches; return typeof window !== "undefined" && window.matchMedia("(max-width: 700px), (max-width: 1100px) and (pointer: coarse)").matches;
} }
function avatarProps(agent: Pick<AgentRow, "id" | "name" | "avatar_color" | "avatar_shape" | "avatar_url">) { function avatarProps(agent: Pick<AgentRow, "id" | "name" | "avatar_color" | "avatar_shape" | "avatar_url">) {
@ -249,7 +248,7 @@ function visibleTranscript(items: TranscriptItem[]): TranscriptItem[] {
} }
const SendArrow = memo(function SendArrow() { const SendArrow = memo(function SendArrow() {
return <UseAnimations animation={arrowUp} size={20} strokeColor="var(--on-send)" />; return <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 19V5m-6 6 6-6 6 6" /></svg>;
}); });
const MessageLog = memo(function MessageLog({ const MessageLog = memo(function MessageLog({
@ -257,37 +256,50 @@ const MessageLog = memo(function MessageLog({
questionPrompt, questionPrompt,
locale, locale,
agent, agent,
scrollRef,
}: { }: {
scrollRef: React.RefObject<HTMLDivElement | null>;
transcript: TranscriptItem[]; transcript: TranscriptItem[];
questionPrompt: string | null; questionPrompt: string | null;
locale: Locale; locale: Locale;
agent: Pick<AgentRow, "id" | "name" | "avatar_color" | "avatar_shape" | "avatar_url"> | null; agent: Pick<AgentRow, "id" | "name" | "avatar_color" | "avatar_shape" | "avatar_url"> | null;
}) { }) {
// A fixed page keeps Markdown trees, images and avatars bounded for long chats. const items = useMemo(() => transcript
const [page, setPage] = useState(0); .map((item, index) => ({ item, index }))
const pageCount = Math.max(1, Math.ceil(transcript.length / 40)); .filter(({ item }) => !(questionPrompt && item.role === "assistant" && item.content.trim() === questionPrompt)), [transcript, questionPrompt]);
const currentPage = Math.min(page, pageCount - 1); const rows = useVirtualizer({
const end = Math.max(0, transcript.length - currentPage * 40); count: items.length,
const items = transcript.slice(Math.max(0, end - 40), end).filter((item) => !(questionPrompt && item.role === "assistant" && item.content.trim() === questionPrompt)); getScrollElement: () => scrollRef.current,
estimateSize: () => 140,
overscan: 6,
getItemKey: useCallback((i: number) => items[i].index, [items]),
anchorTo: "end",
followOnAppend: true,
scrollEndThreshold: 96,
});
const initialized = useRef(false);
useLayoutEffect(() => {
if (!initialized.current && items.length) {
initialized.current = true;
rows.scrollToEnd();
}
}, [items.length, rows]);
return ( return (
<> <div className="message-window" style={{ height: rows.getTotalSize(), position: "relative", width: "100%" }}>
{pageCount > 1 ? <nav className="history-pages" aria-label={locale.startsWith("zh") ? "對話記錄分頁" : "Conversation pages"}> {rows.getVirtualItems().map((row) => {
<button type="button" disabled={currentPage >= pageCount - 1} onClick={() => setPage(currentPage + 1)}> {locale.startsWith("zh") ? "較早訊息" : "Older"}</button> const { item, index } = items[row.index];
<span>{currentPage + 1} / {pageCount}</span> const prev = items[row.index - 1]?.item;
<button type="button" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>{locale.startsWith("zh") ? "較新訊息" : "Newer"} </button> const next = items[row.index + 1]?.item;
{currentPage > 0 ? <button type="button" onClick={() => setPage(0)}>{locale.startsWith("zh") ? "最新訊息" : "Latest"}</button> : null}
</nav> : null}
{items.map((item, i) => {
const prev = items[i - 1];
const next = items[i + 1];
const newDay = Boolean(item.at && !sameLocalDay(item.at, prev?.at)); const newDay = Boolean(item.at && !sameLocalDay(item.at, prev?.at));
const clusterStart = newDay || !sameChatCluster(prev, item); const clusterStart = newDay || !sameChatCluster(prev, item);
const clusterEnd = !sameChatCluster(item, next); const clusterEnd = !sameChatCluster(item, next);
const showTime = Boolean(item.at) && clusterEnd; const showTime = Boolean(item.at) && clusterEnd;
return ( return (
<div <div key={row.key} data-index={row.index} ref={rows.measureElement}
style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${row.start}px)`, paddingBottom: 10 }}>
<div data-message-index={index}
className={`message-block${!clusterStart ? " tight" : ""}${newDay ? " has-day" : ""}`} className={`message-block${!clusterStart ? " tight" : ""}${newDay ? " has-day" : ""}`}
key={`${item.role}-${item.at || i}-${i}`} key={`${item.role}-${item.at || index}-${index}`}
> >
{newDay && item.at ? ( {newDay && item.at ? (
<div className="day-divider"> <div className="day-divider">
@ -321,9 +333,10 @@ const MessageLog = memo(function MessageLog({
) : null} ) : null}
</div> </div>
</div> </div>
</div>
); );
})} })}
</> </div>
); );
}, (prev, next) => ( }, (prev, next) => (
prev.transcript === next.transcript && prev.transcript === next.transcript &&
@ -345,7 +358,17 @@ export function App() {
return () => document.removeEventListener("visibilitychange", update); return () => document.removeEventListener("visibilitychange", update);
}, []); }, []);
const [agents, setAgents] = useState<AgentRow[]>([]); const [agents, setAgents] = useState<AgentRow[]>([]);
const [activeId, setActiveId] = useState(localStorage.getItem("lazyboy.agent") || localStorage.getItem("grokboy.agent") || ""); const [activeId, setActiveId] = useState(() => isPhone() ? "" : localStorage.getItem("lazyboy.agent") || localStorage.getItem("grokboy.agent") || "");
const [phone, setPhone] = useState(isPhone);
const [mobilePage, setMobilePage] = useState<"list" | "chat">("list");
const mobilePageRef = useRef(mobilePage);
mobilePageRef.current = mobilePage;
useEffect(() => {
const media = window.matchMedia("(max-width: 700px), (max-width: 1100px) and (pointer: coarse)");
const update = () => setPhone(media.matches);
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
const [transcript, setTranscript] = useState<TranscriptItem[]>([]); const [transcript, setTranscript] = useState<TranscriptItem[]>([]);
const [workingStep, setWorkingStep] = useState<StepKey | null>(null); const [workingStep, setWorkingStep] = useState<StepKey | null>(null);
const [question, setQuestion] = useState<QuestionCard | null>(null); const [question, setQuestion] = useState<QuestionCard | null>(null);
@ -355,8 +378,7 @@ export function App() {
const handoverSeen = useRef<string | null>(null); const handoverSeen = useRef<string | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [sidebarOpen, setSidebarOpen] = useState(false); const [rightOpen, setRightOpen] = useState(() => !isPhone());
const [rightOpen, setRightOpen] = useState(true);
const [overlayOpen, setOverlayOpen] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false);
const [computerUrl, setComputerUrl] = useState(""); const [computerUrl, setComputerUrl] = useState("");
const [computerStatus, setComputerStatus] = useState(""); const [computerStatus, setComputerStatus] = useState("");
@ -370,6 +392,9 @@ export function App() {
const [typing, setTyping] = useState(false); const [typing, setTyping] = useState(false);
const [activityState, setActivityState] = useState<AgentActivity["state"] | "unknown">("idle"); const [activityState, setActivityState] = useState<AgentActivity["state"] | "unknown">("idle");
const activityGeneration = useRef(0); const activityGeneration = useRef(0);
const stoppingAgents = useRef(new Set<string>());
const stoppedTasks = useRef(new Map<string, Set<string>>());
const activitySnapshots = useRef(new Map<string, AgentActivity>());
const activityPending = useRef<string | null>(null); const activityPending = useRef<string | null>(null);
const transcriptGeneration = useRef(0); const transcriptGeneration = useRef(0);
const handleEventRef = useRef<(event: TeamEvent) => void>(() => undefined); const handleEventRef = useRef<(event: TeamEvent) => void>(() => undefined);
@ -384,6 +409,7 @@ export function App() {
const [agentMenu, setAgentMenu] = useState<{ x: number; y: number; agent: AgentRow } | null>(null); const [agentMenu, setAgentMenu] = useState<{ x: number; y: number; agent: AgentRow } | null>(null);
const [settingsAgent, setSettingsAgent] = useState<AgentRow | null>(null); const [settingsAgent, setSettingsAgent] = useState<AgentRow | null>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [mobileSearch, setMobileSearch] = useState(false);
const accountRef = useRef<HTMLDivElement>(null); const accountRef = useRef<HTMLDivElement>(null);
const createMenuRef = useRef<HTMLDivElement>(null); const createMenuRef = useRef<HTMLDivElement>(null);
const scroller = useRef<HTMLDivElement>(null); const scroller = useRef<HTMLDivElement>(null);
@ -444,7 +470,9 @@ export function App() {
const loadAgents = useCallback(() => { const loadAgents = useCallback(() => {
if (rosterRequest.current) return rosterRequest.current; if (rosterRequest.current) return rosterRequest.current;
const pending = api.agents().then((data) => { const pending = api.agents().then((data) => {
const next = data.agents || []; const next = (data.agents || []).map((agent) =>
stoppedTasks.current.has(agent.id) ? { ...agent, running: false } : agent
);
setAgents((cur) => adoptAgents(cur, next)); setAgents((cur) => adoptAgents(cur, next));
return next; return next;
}).finally(() => { rosterRequest.current = null; }); }).finally(() => { rosterRequest.current = null; });
@ -453,6 +481,19 @@ export function App() {
}, []); }, []);
const applyActivity = useCallback((data: AgentActivity) => { const applyActivity = useCallback((data: AgentActivity) => {
const id = activeIdRef.current;
activitySnapshots.current.set(id, data);
const stopped = stoppedTasks.current.get(id);
if (stopped) {
const newTask = data.active_task_ids.some((task) => !stopped.has(task));
if (!stoppingAgents.current.has(id) && (data.state === "idle" || newTask)) {
stoppedTasks.current.delete(id);
} else {
return;
}
}
setAgents((cur) => cur.map((agent) => agent.id === id && agent.running !== data.running
? { ...agent, running: data.running } : agent));
setActivityState((cur) => (cur === data.state ? cur : data.state)); setActivityState((cur) => (cur === data.state ? cur : data.state));
setTyping(data.state === "running"); setTyping(data.state === "running");
if (data.state !== "running") setWorkingStep((cur) => (cur == null ? cur : null)); if (data.state !== "running") setWorkingStep((cur) => (cur == null ? cur : null));
@ -538,7 +579,6 @@ export function App() {
const runComputer = useCallback(async (action: ComputerAction, id?: string) => { const runComputer = useCallback(async (action: ComputerAction, id?: string) => {
const copy = tRef.current; const copy = tRef.current;
const target = id || activeIdRef.current; const target = id || activeIdRef.current;
if (!target) return;
const op = `${target}:${action}`; const op = `${target}:${action}`;
if (computerOpRef.current === op) return; if (computerOpRef.current === op) return;
if (action === "restart" && !window.confirm(copy.restartConfirm)) return; if (action === "restart" && !window.confirm(copy.restartConfirm)) return;
@ -598,7 +638,11 @@ export function App() {
setActiveId(id); setActiveId(id);
if (!keepChannel) setActiveChannelId(null); if (!keepChannel) setActiveChannelId(null);
localStorage.setItem("lazyboy.agent", id); localStorage.setItem("lazyboy.agent", id);
setSidebarOpen(false); if (isPhone()) {
if (mobilePageRef.current !== "chat") window.history.pushState({ ...window.history.state, lazyboyMobileChat: true }, "");
mobilePageRef.current = "chat";
setMobilePage("chat");
}
setUserProgress(""); setUserProgress("");
setExecutionDetails([]); setExecutionDetails([]);
setError(""); setError("");
@ -606,9 +650,9 @@ export function App() {
setComputerReady(false); setComputerReady(false);
setComputerUrl(""); setComputerUrl("");
setImageFresh(false); setImageFresh(false);
setComputerBusy("start"); setComputerBusy(isPhone() ? null : "start");
setComputerStatus(tRef.current.computerStarting); setComputerStatus(isPhone() ? "" : tRef.current.computerStarting);
void runComputer("start", id); if (!isPhone()) void runComputer("start", id);
try { try {
const data = await api.agent(id, controller.signal); const data = await api.agent(id, controller.signal);
if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return; if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return;
@ -654,6 +698,7 @@ export function App() {
useEffect(() => { useEffect(() => {
loadAgents() loadAgents()
.then((list) => { .then((list) => {
if (isPhone()) return;
const remembered = localStorage.getItem("lazyboy.agent"); const remembered = localStorage.getItem("lazyboy.agent");
const pick = list.find((a) => a.id === remembered || a.name === remembered) || list[0]; const pick = list.find((a) => a.id === remembered || a.name === remembered) || list[0];
if (pick) return openAgent(pick.id); if (pick) return openAgent(pick.id);
@ -661,6 +706,52 @@ export function App() {
.catch((err) => setError(String(err.message))); .catch((err) => setError(String(err.message)));
}, [loadAgents, openAgent]); }, [loadAgents, openAgent]);
useEffect(() => {
if (!pageVisible) return;
const poll = window.setInterval(() => { void loadAgents().catch(() => undefined); }, 4000);
return () => window.clearInterval(poll);
}, [pageVisible, loadAgents]);
useEffect(() => {
if (!phone && !activeId && agents[0]) void openAgent(agents[0].id);
}, [phone, activeId, agents, openAgent]);
useEffect(() => {
// Each phone visit begins at the inbox, including a reload of a chat.
if (isPhone()) window.history.replaceState({ ...window.history.state, lazyboyMobileChat: false }, "");
const onBack = (event: PopStateEvent) => {
if (!isPhone()) return;
const page = event.state?.lazyboyMobileChat && activeIdRef.current ? "chat" : "list";
mobilePageRef.current = page;
setMobilePage(page);
setOverlayOpen(false);
setCreateMenuOpen(false);
setAccountOpen(false);
setAccountOverlay(null);
composerRef.current?.blur();
};
window.addEventListener("popstate", onBack);
return () => window.removeEventListener("popstate", onBack);
}, []);
useEffect(() => {
if (phone && accountOverlay && !window.history.state?.lazyboySettings) {
window.history.pushState({ ...window.history.state, lazyboySettings: true }, "");
}
}, [phone, accountOverlay]);
function closeAccountSettings() {
if (phone && window.history.state?.lazyboySettings) window.history.back();
else setAccountOverlay(null);
}
function backToChats() {
setOverlayOpen(false);
composerRef.current?.blur();
if (window.history.state?.lazyboyMobileChat) window.history.back();
else { mobilePageRef.current = "list"; setMobilePage("list"); }
}
const handover = question?.kind === "handoff" || question?.kind === "box_help"; const handover = question?.kind === "handoff" || question?.kind === "box_help";
useEffect(() => { useEffect(() => {
if (!handover) { if (!handover) {
@ -690,6 +781,24 @@ export function App() {
} }
} }
useEffect(() => {
if (!overlayOpen) return;
const viewport = window.visualViewport;
const update = () => {
document.documentElement.style.setProperty("--visible-height", `${viewport?.height ?? window.innerHeight}px`);
document.documentElement.style.setProperty("--visible-top", `${viewport?.offsetTop ?? 0}px`);
};
update();
viewport?.addEventListener("resize", update);
viewport?.addEventListener("scroll", update);
return () => {
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);
document.documentElement.style.removeProperty("--visible-height");
document.documentElement.style.removeProperty("--visible-top");
};
}, [overlayOpen]);
useEffect(() => { useEffect(() => {
if (rightOpen || overlayOpen) void ensureComputer(); if (rightOpen || overlayOpen) void ensureComputer();
}, [rightOpen, overlayOpen, ensureComputer]); }, [rightOpen, overlayOpen, ensureComputer]);
@ -697,15 +806,16 @@ export function App() {
useEffect(() => { useEffect(() => {
function onMessage(event: MessageEvent) { function onMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return; if (event.origin !== window.location.origin) return;
const desktop = document.querySelector<HTMLIFrameElement>("iframe.desktop-frame");
if (!desktop || event.source !== desktop.contentWindow) return;
const data = event.data as { type?: string; text?: string } | null; const data = event.data as { type?: string; text?: string } | null;
if (!data || typeof data !== "object") return; if (!data || typeof data !== "object") return;
const source = event.source as Window | null; const source = event.source as Window | null;
if (data.type === "lazyboy-clipboard-write" && typeof data.text === "string") { if (data.type === "lazyboy-clipboard-write" && typeof data.text === "string") {
void navigator.clipboard.writeText(data.text).catch(() => undefined); void navigator.clipboard?.writeText(data.text).catch(() => undefined);
} }
if (data.type === "lazyboy-clipboard-read" && source) { if (data.type === "lazyboy-clipboard-read" && source) {
void navigator.clipboard void (navigator.clipboard?.readText() ?? Promise.reject(new Error("Clipboard unavailable")))
.readText()
.then((text) => source.postMessage({ type: "lazyboy-clipboard-text", text }, event.origin)) .then((text) => source.postMessage({ type: "lazyboy-clipboard-text", text }, event.origin))
.catch(() => source.postMessage({ type: "lazyboy-clipboard-text", text: "" }, event.origin)); .catch(() => source.postMessage({ type: "lazyboy-clipboard-text", text: "" }, event.origin));
} }
@ -738,12 +848,10 @@ export function App() {
source.onerror = () => { /* reconnect is normal; activity poll covers gaps */ }; source.onerror = () => { /* reconnect is normal; activity poll covers gaps */ };
void refreshActivity(sourceAgent); void refreshActivity(sourceAgent);
const poll = window.setInterval(() => { void refreshActivity(sourceAgent); }, 2000); const poll = window.setInterval(() => { void refreshActivity(sourceAgent); }, 2000);
const rosterPoll = window.setInterval(() => { loadAgents().catch(() => undefined); }, 4000);
return () => { return () => {
source.close(); source.close();
transcriptRequest.current?.controller.abort(); transcriptRequest.current?.controller.abort();
window.clearInterval(poll); window.clearInterval(poll);
window.clearInterval(rosterPoll);
}; };
}, [activeId, pageVisible, loadAgents, refreshActivity, syncTranscript]); }, [activeId, pageVisible, loadAgents, refreshActivity, syncTranscript]);
@ -824,7 +932,13 @@ export function App() {
if (activeChannelId && !nextChannels.some((channel) => channel.id === activeChannelId)) setActiveChannelId(null); if (activeChannelId && !nextChannels.some((channel) => channel.id === activeChannelId)) setActiveChannelId(null);
if (activeId === agent.id || active?.name === agent.name) { if (activeId === agent.id || active?.name === agent.name) {
const next = list.find((row) => row.id !== agent.id) || list[0]; const next = list.find((row) => row.id !== agent.id) || list[0];
if (next) { if (isPhone()) {
setActiveId("");
setTranscript([]);
setQuestion(null);
setTyping(false);
backToChats();
} else if (next) {
await openAgent(next.id); await openAgent(next.id);
} else { } else {
setActiveId(""); setActiveId("");
@ -854,6 +968,7 @@ export function App() {
setCreateOpen(true); setCreateOpen(true);
return; return;
} }
stoppedTasks.current.delete(id);
if (fromComposer) setDraft(""); if (fromComposer) setDraft("");
stickToBottom.current = true; stickToBottom.current = true;
setTranscript((cur) => { setTranscript((cur) => {
@ -884,13 +999,26 @@ export function App() {
async function onStop() { async function onStop() {
const id = activeId; const id = activeId;
if (!id) return; if (!id || stoppingAgents.current.has(id)) return;
stoppingAgents.current.add(id);
const snapshot = activitySnapshots.current.get(id);
stoppedTasks.current.set(id, new Set([...(snapshot?.active_task_ids || []), ...(snapshot?.queued_task_ids || [])]));
activityGeneration.current += 1;
setTyping(false);
setActivityState("idle");
setWorkingStep(null);
setUserProgress("");
setQuestion(null);
setAgents((cur) => cur.map((agent) => agent.id === id ? { ...agent, running: false } : agent));
try { try {
await api.stop(id); await api.stop(id);
activityGeneration.current += 1;
await refreshActivity(id);
} catch (err) { } catch (err) {
stoppedTasks.current.delete(id);
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally {
stoppingAgents.current.delete(id);
activityGeneration.current += 1;
void refreshActivity(id);
} }
} }
@ -904,7 +1032,7 @@ export function App() {
const hasPayload = draft.trim().length > 0; const hasPayload = draft.trim().length > 0;
const working = typing || activityState === "queued"; const working = typing || activityState === "queued";
const showStage = rightOpen && !isPhone() && !handover; const showStage = rightOpen && !phone && !handover;
const liveStep = userProgress || (workingStep === "stepSearch" ? t.stepSearch : workingStep === "stepFetch" ? t.stepFetch : workingStep ? t.stepWorking : null); const liveStep = userProgress || (workingStep === "stepSearch" ? t.stepSearch : workingStep === "stepFetch" ? t.stepFetch : workingStep ? t.stepWorking : null);
const computerLive = computerBusy const computerLive = computerBusy
? t[computerBusyKey(computerBusy)] ? t[computerBusyKey(computerBusy)]
@ -941,16 +1069,12 @@ export function App() {
return ( return (
<AvatarLookProvider value={looks}> <AvatarLookProvider value={looks}>
<div className={`app-shell ${showStage ? "right-open stage-open" : "right-collapsed"}`}> <div className={`app-shell ${showStage ? "right-open stage-open" : "right-collapsed"} mobile-${mobilePage}`} data-mobile={phone || undefined}>
{sidebarOpen ? <button type="button" className="mobile-nav-backdrop" aria-label={t.close} onClick={() => setSidebarOpen(false)} /> : null}
<aside id="conversation-sidebar" className={`sidebar ${sidebarOpen ? "open" : ""}`}> <aside id="conversation-sidebar" className="sidebar" aria-label={t.chats}>
<div className="brand"> <div className="brand">
<img className="brand-icon" src="/lazyboy-icon.png" alt="" /> {phone ? <button type="button" className="mobile-profile" aria-label={t.settings} title={`${t.localWorkspace} · ${t.settings}`} onClick={() => setAccountOverlay("settings")}><span aria-hidden="true">{Array.from(t.localWorkspace.trim())[0]}</span></button> : <><img className="brand-icon" src="/lazyboy-round.svg" alt="" /><span>LazyBoy</span></>}
<span>LazyBoy</span> {phone ? <button type="button" className="mobile-search-toggle" aria-label={t.search} aria-expanded={mobileSearch} onClick={() => { setMobileSearch(value => !value); setQuery(""); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg></button> : null}
<button type="button" className="icon-button mobile-nav-close" aria-label={t.close} onClick={() => setSidebarOpen(false)}>
<X />
</button>
<div className="create-menu-wrap" ref={createMenuRef} onClick={(event) => event.stopPropagation()}> <div className="create-menu-wrap" ref={createMenuRef} onClick={(event) => event.stopPropagation()}>
<button <button
type="button" type="button"
@ -991,10 +1115,11 @@ export function App() {
) : null} ) : null}
</div> </div>
</div> </div>
<label className="search"> {!phone || mobileSearch ? <label className="search">
<UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)" /> <UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t.search} aria-label={t.search} /> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t.search} aria-label={t.search} />
</label> </label> : null}
{phone && mobilePage === "list" && error ? <p className="error-banner" role="alert">{error}</p> : null}
<div className="bot-list"> <div className="bot-list">
{visibleChannels.length > 0 ? ( {visibleChannels.length > 0 ? (
<section className="bot-group"> <section className="bot-group">
@ -1023,7 +1148,7 @@ export function App() {
}} }}
> >
<span className="avatar-wrap"> <span className="avatar-wrap">
<AvatarStack members={members} online thinkingIds={members.filter((member) => agents.find((row) => row.id === member.id)?.running).map((member) => member.id)} /> <AvatarStack members={members} size={phone ? 44 : 32} online thinkingIds={members.filter((member) => agents.find((row) => row.id === member.id)?.running).map((member) => member.id)} />
</span> </span>
<span className="bot-copy"> <span className="bot-copy">
<strong>{channel.name}</strong> <strong>{channel.name}</strong>
@ -1052,15 +1177,15 @@ export function App() {
}} }}
> >
<span className="avatar-wrap"> <span className="avatar-wrap">
<Avatar {...avatarProps(agent)} active={agent.id === active?.id && !activeChannelId} online thinking={Boolean(agent.running)} /> <Avatar {...avatarProps(agent)} size={phone ? 44 : 32} active={agent.id === active?.id && !activeChannelId} online thinking={agent.id === activeId ? typing : Boolean(agent.running) && !stoppedTasks.current.has(agent.id)} />
</span> </span>
<span className="bot-copy"> <span className="bot-copy">
<strong>{agent.name}</strong> <strong className="bot-name">{agent.name}{phone && agent.tags?.[0] ? <span className="bot-tag">{agent.tags[0]}</span> : null}</strong>
<small>{agent.running ? t.agentWorking : agent.preview || t.newChat}</small> <small>{agent.running ? t.agentWorking : agent.preview || t.newChat}</small>
</span> </span>
<span className="bot-trailing"> <span className="bot-trailing">
{agent.last_at ? <time className="row-time" dateTime={agent.last_at}>{formatInboxTime(agent.last_at, locale)}</time> : null} {agent.last_at ? <time className="row-time" dateTime={agent.last_at}>{formatInboxTime(agent.last_at, locale)}</time> : null}
{agent.tags?.[0] ? <span className="bot-tag side-tag">{agent.tags[0]}</span> : null} {!phone && agent.tags?.[0] ? <span className="bot-tag side-tag">{agent.tags[0]}</span> : null}
</span> </span>
</button> </button>
))} ))}
@ -1097,14 +1222,12 @@ export function App() {
<button <button
type="button" type="button"
className={`account ${accountOpen ? "open" : ""}`} className={`account ${accountOpen ? "open" : ""}`}
title={t.localWorkspace} title={phone ? t.settings : t.localWorkspace}
aria-haspopup="menu" aria-haspopup={phone ? "dialog" : "menu"}
aria-expanded={accountOpen} aria-expanded={phone ? accountOverlay === "settings" : accountOpen}
onClick={() => setAccountOpen((open) => !open)} onClick={() => phone ? setAccountOverlay("settings") : setAccountOpen((open) => !open)}
> >
<span className="workspace-avatar">GB</span> {phone ? <><Settings /><span>{t.settings}</span></> : <><span className="workspace-avatar">GB</span><span>{t.localWorkspace}</span><ChevronDown className="chevron" /></>}
<span>{t.localWorkspace}</span>
<ChevronDown className="chevron" />
</button> </button>
</div> </div>
</div> </div>
@ -1112,8 +1235,8 @@ export function App() {
<main className="chat-panel"> <main className="chat-panel">
<header className="topbar"> <header className="topbar">
<button type="button" className="icon-button mobile-menu" aria-label={t.pickAgent} onClick={() => setSidebarOpen((open) => !open)}> <button type="button" className="icon-button mobile-menu" aria-label={t.backToChats} onClick={backToChats}>
<UseAnimations animation={menu} size={18} strokeColor="var(--ink)" /> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m14 6-6 6 6 6" /></svg>
</button> </button>
{activeChannel && channelMembers.length > 0 ? ( {activeChannel && channelMembers.length > 0 ? (
<> <>
@ -1164,6 +1287,7 @@ export function App() {
) : null} ) : null}
<MessageLog <MessageLog
key={activeId} key={activeId}
scrollRef={scroller}
transcript={transcript} transcript={transcript}
questionPrompt={question?.prompt || null} questionPrompt={question?.prompt || null}
locale={locale} locale={locale}
@ -1245,7 +1369,9 @@ export function App() {
<section className={`meeting-stage ${showStage ? "" : "is-hidden"}`}> <section className={`meeting-stage ${showStage ? "" : "is-hidden"}`}>
<header className="meeting-stage-head"> <header className="meeting-stage-head">
<span className="side-card-title">{t.computer}</span> <span className="side-card-title"><Computer />{t.computer}</span>
<span className={`state-dot ${computerReady ? "running" : computerBusy ? "booting" : ""}`} title={computerLive} />
<button type="button" className="computer-expand" title={t.enlarge} onClick={() => setOverlayOpen(true)}> {t.enlarge}</button>
<button type="button" className="icon-button" title={t.collapse} onClick={() => setRightOpen(false)}> <button type="button" className="icon-button" title={t.collapse} onClick={() => setRightOpen(false)}>
<ChevronsRight /> <ChevronsRight />
</button> </button>
@ -1253,6 +1379,7 @@ export function App() {
<div className="side-card-body"> <div className="side-card-body">
<div className="computer-part"> <div className="computer-part">
<div className="preview">{showStage && !overlayOpen ? frame : null}{hud}</div> <div className="preview">{showStage && !overlayOpen ? frame : null}{hud}</div>
<div className="computer-caption">{active ? format("computerOf", { name: active.name }) : t.computer}<span>{computerLive}</span></div>
</div> </div>
</div> </div>
</section> </section>
@ -1361,17 +1488,20 @@ export function App() {
{accountOverlay === "settings" ? ( {accountOverlay === "settings" ? (
<SettingsDialog <SettingsDialog
onClose={() => setAccountOverlay(null)} mobile={phone}
onAbout={() => setAccountOverlay("about")}
onClose={closeAccountSettings}
computer={{ computer={{
pending: computerBusy, pending: computerBusy,
working, working,
upToDate: imageFresh, upToDate: imageFresh,
status: computerStatus,
onUpdate: () => void runComputer("update"), onUpdate: () => void runComputer("update"),
onRestart: () => void runComputer("restart"), onRestart: () => void runComputer("restart"),
}} }}
/> />
) : null} ) : null}
{accountOverlay === "about" ? <AboutDialog onClose={() => setAccountOverlay(null)} /> : null} {accountOverlay === "about" ? <AboutDialog mobile={phone} onClose={() => phone ? setAccountOverlay("settings") : setAccountOverlay(null)} /> : null}
{createOpen ? ( {createOpen ? (
<AgentSettingsDialog <AgentSettingsDialog
mode="create" mode="create"

View File

@ -10,6 +10,7 @@ export type AgentActivity = {
queued: boolean; queued: boolean;
observed_at_ms: number; observed_at_ms: number;
active_task_ids: string[]; active_task_ids: string[];
queued_task_ids?: string[];
question?: HumanQuestion | null; question?: HumanQuestion | null;
}; };
@ -124,7 +125,7 @@ export const api = {
updated?: boolean; updated?: boolean;
revision?: string; revision?: string;
crowded?: boolean; crowded?: boolean;
}>(`/api/agents/${encodeURIComponent(id)}/computer`, { }>(id ? `/api/agents/${encodeURIComponent(id)}/computer` : "/api/computer", {
method: "POST", method: "POST",
body: JSON.stringify({ action }), body: JSON.stringify({ action }),
}), }),

View File

@ -101,7 +101,9 @@ const zhHant = {
agentWorking: "工作中", agentWorking: "工作中",
newChat: "新對話", newChat: "新對話",
localWorkspace: "本機工作區", localWorkspace: "本機工作區",
pickAgent: "選擇 Agent", chats: "聊天",
backToChats: "返回聊天列表",
pickAgent: "選擇聊天對象",
workTools: "工作工具", workTools: "工作工具",
stop: "停止", stop: "停止",
computer: "電腦", computer: "電腦",
@ -239,7 +241,9 @@ const zhHans: Messages = {
agentWorking: "工作中", agentWorking: "工作中",
newChat: "新对话", newChat: "新对话",
localWorkspace: "本地工作区", localWorkspace: "本地工作区",
pickAgent: "选择 Agent", chats: "聊天",
backToChats: "返回聊天列表",
pickAgent: "选择聊天对象",
workTools: "工作工具", workTools: "工作工具",
stop: "停止", stop: "停止",
computer: "电脑", computer: "电脑",
@ -374,7 +378,9 @@ const en: Messages = {
agentWorking: "Working", agentWorking: "Working",
newChat: "New chat", newChat: "New chat",
localWorkspace: "Local workspace", localWorkspace: "Local workspace",
pickAgent: "Choose an agent", chats: "Chats",
backToChats: "Back to chats",
pickAgent: "Choose who to chat with",
workTools: "Work tools", workTools: "Work tools",
stop: "Stop", stop: "Stop",
computer: "Computer", computer: "Computer",
@ -509,7 +515,9 @@ const ja: Messages = {
agentWorking: "作業中", agentWorking: "作業中",
newChat: "新しい会話", newChat: "新しい会話",
localWorkspace: "ローカルワークスペース", localWorkspace: "ローカルワークスペース",
pickAgent: "Agent を選ぶ", chats: "チャット",
backToChats: "チャット一覧に戻る",
pickAgent: "チャット相手を選ぶ",
workTools: "作業ツール", workTools: "作業ツール",
stop: "停止", stop: "停止",
computer: "コンピュータ", computer: "コンピュータ",

View File

@ -6,7 +6,7 @@
.message>.message-body.md,.message-stack>.message-body.md{min-width:0;white-space:normal} .message>.message-body.md,.message-stack>.message-body.md{min-width:0;white-space:normal}
.message.user{justify-content:flex-end} .message.user{justify-content:flex-end}
.message.user>.message-body{background:var(--cream);color:var(--on-cream)} .message.user>.message-body{background:var(--cream);color:var(--on-cream)}
.message.assistant>.message-body,.message-stack>.message-body{background:#19191c} .message.assistant>.message-body,.message-stack>.message-body{background:var(--bubble-assistant)}
.message-stack{display:flex;flex-direction:column;align-items:flex-start;width:fit-content;max-width:min(32rem,calc(100% - 92px));min-width:0} .message-stack{display:flex;flex-direction:column;align-items:flex-start;width:fit-content;max-width:min(32rem,calc(100% - 92px));min-width:0}
.message.user>.message-body{width:fit-content;max-width:min(32rem,100%)} .message.user>.message-body{width:fit-content;max-width:min(32rem,100%)}
.message-stack>.message-body{max-width:100%} .message-stack>.message-body{max-width:100%}
@ -63,7 +63,7 @@
.msg-avatar{flex:0 0 28px;width:28px;min-width:28px;height:28px;align-self:flex-end} .msg-avatar{flex:0 0 28px;width:28px;min-width:28px;height:28px;align-self:flex-end}
.msg-avatar .avatar{width:28px;height:28px} .msg-avatar .avatar{width:28px;height:28px}
.messages .remember-msg{display:none} .messages .remember-msg{display:none}
.working-label{font-size:13px;letter-spacing:.02em;line-height:1.35;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite} .working-label{font-size:13px;letter-spacing:.02em;line-height:1.35;white-space:nowrap;background:linear-gradient(90deg,var(--muted) 0%,var(--muted) 28%,var(--ink) 50%,var(--muted) 72%,var(--muted) 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
.message.assistant.spoken{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:3px;justify-content:start;align-items:end} .message.assistant.spoken{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:3px;justify-content:start;align-items:end}
.message.assistant.spoken .msg-avatar{grid-column:1;grid-row:2;align-self:start;max-width:none;margin:4px 0 0;padding:0;border-radius:0;background:transparent;line-height:normal} .message.assistant.spoken .msg-avatar{grid-column:1;grid-row:2;align-self:start;max-width:none;margin:4px 0 0;padding:0;border-radius:0;background:transparent;line-height:normal}
.message.assistant .msg-avatar>.avatar.blobatar{max-width:none;padding:0;background:transparent;color:inherit;line-height:normal;white-space:normal} .message.assistant .msg-avatar>.avatar.blobatar{max-width:none;padding:0;background:transparent;color:inherit;line-height:normal;white-space:normal}
@ -143,6 +143,6 @@
.host-menu-label{padding:2px 9px 4px;color:var(--muted);font-size:11px} .host-menu-label{padding:2px 9px 4px;color:var(--muted);font-size:11px}
.host-flag{margin-left:auto;color:var(--accent);font-size:11px;font-weight:650} .host-flag{margin-left:auto;color:var(--accent);font-size:11px;font-weight:650}
.history-pages { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 12px; position: sticky; top: 0; z-index: 1; background: var(--bg, Canvas); } /* The virtualizer owns scroll anchoring; recycled rows must not become browser anchors. */
.history-pages button { cursor: pointer; font: inherit; color: inherit; } .messages { overflow-anchor: none; }
.history-pages button:disabled { opacity: .4; cursor: default; } .message-window > div > .message-block { margin-top: 0; }

View File

@ -9,7 +9,7 @@
.empty-computer span{font-size:12px} .empty-computer span{font-size:12px}
.computer-caption{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 0;color:var(--muted);flex-wrap:wrap} .computer-caption{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 0;color:var(--muted);flex-wrap:wrap}
.control-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid var(--hairline);padding:16px 0} .control-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid var(--hairline);padding:16px 0}
.computer-overlay{position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;background:rgba(4,4,5,.98)} .computer-overlay{position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;background:var(--main)}
.computer-overlay>header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid var(--line)} .computer-overlay>header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid var(--line)}
.computer-overlay>header>div{display:flex;align-items:center;gap:10px} .computer-overlay>header>div{display:flex;align-items:center;gap:10px}
.computer-overlay .avatar{width:30px;height:30px} .computer-overlay .avatar{width:30px;height:30px}
@ -121,3 +121,21 @@
.overlay-screen{padding:0} .overlay-screen{padding:0}
.overlay-screen .overlay-desktop,.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:0;border-radius:0} .overlay-screen .overlay-desktop,.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:0;border-radius:0}
} }
/* Grok computer pane: quiet chrome, inset monitor and secondary caption. */
.meeting-stage{background:var(--panel);border-left:1px solid var(--hairline);border-right:0}
.meeting-stage-head{height:48px;flex-basis:48px;padding:0 12px;gap:10px}
.meeting-stage-head .side-card-title{display:flex;align-items:center;gap:8px;margin-right:auto;font-size:13px;font-weight:500}
.meeting-stage-head .side-card-title svg{width:16px;height:16px}
.computer-expand{display:inline-flex;align-items:center;gap:5px;min-height:32px;padding:4px 8px;border:1px solid var(--hairline);border-radius:7px;background:transparent;color:var(--ink);cursor:pointer;font:inherit;font-size:12px}
.computer-expand:hover{background:var(--surface-hover)}
.meeting-stage .computer-part{padding:12px}
.meeting-stage .computer-part .preview{border:1px solid var(--hairline);border-radius:10px;overflow:hidden;background:#111}
.computer-caption{justify-content:space-between;gap:8px;padding:10px 2px 0;font-size:12px;text-align:left}
.computer-caption span{color:var(--faint)}
@media(max-width:700px){
.computer-overlay{height:var(--visible-height,100dvh)!important;top:var(--visible-top,0px)!important;bottom:auto!important}
.computer-overlay>header .icon-button{min-width:44px;min-height:44px}
.computer-overlay>header{padding-bottom:6px}
}
.meeting-stage .computer-part .preview{flex:0 1 auto;aspect-ratio:16/11;min-height:240px}

View File

@ -41,7 +41,7 @@
/* Mobile conversation drawer and two-row chat header. */ /* Mobile conversation drawer and two-row chat header. */
.mobile-nav-backdrop,.mobile-nav-close{display:none} .mobile-nav-backdrop,.mobile-nav-close{display:none}
.brand-icon{width:30px;height:30px;flex:none;border-radius:9px;margin-right:9px} .brand-icon{width:30px;height:30px;flex:none;border-radius:0;object-fit:contain;margin-right:9px}
@media(max-width:700px){ @media(max-width:700px){
.mobile-nav-backdrop{display:block;position:fixed;inset:0;z-index:39;border:0;border-radius:0; .mobile-nav-backdrop{display:block;position:fixed;inset:0;z-index:39;border:0;border-radius:0;
padding:0;background:#0008;backdrop-filter:blur(3px);touch-action:manipulation} padding:0;background:#0008;backdrop-filter:blur(3px);touch-action:manipulation}
@ -68,3 +68,78 @@
.topbar .call-entry>.top-tool-button{width:100%} .topbar .call-entry>.top-tool-button{width:100%}
.topbar .session-menu{right:0;left:auto;max-width:calc(100vw - 24px)} .topbar .session-menu{right:0;left:auto;max-width:calc(100vw - 24px)}
} }
/* Grok-style phone navigation: inbox and conversation are separate full pages. */
.mobile-inbox-heading{display:none}
.app-shell[data-mobile="true"]{display:block;height:100dvh;grid-template-columns:none}
.app-shell[data-mobile="true"]>.sidebar{position:relative;inset:auto;z-index:auto;display:flex;visibility:visible;width:100%;max-width:none;height:100%;transform:none;transition:none;box-shadow:none;border:0;padding:env(safe-area-inset-top) 16px max(12px,env(safe-area-inset-bottom));background:var(--main)}
.app-shell[data-mobile="true"].mobile-chat>.sidebar,
.app-shell[data-mobile="true"].mobile-list>.chat-panel{display:none}
.app-shell[data-mobile="true"]>.meeting-stage{display:none!important}
.app-shell[data-mobile="true"] .brand{height:64px;flex-shrink:0;padding:0 4px;font-size:18px}
.app-shell[data-mobile="true"] .brand .create-menu-wrap{margin-left:auto}
.app-shell[data-mobile="true"] .brand .icon-button{width:44px;height:44px}
.app-shell[data-mobile="true"] .mobile-inbox-heading{display:block;padding:12px 4px 8px}
.mobile-inbox-heading h1{margin:0;font-size:30px;line-height:1.2;font-weight:700;letter-spacing:-.03em}
.mobile-inbox-heading p{margin:8px 0 0;font-size:14px;color:var(--muted)}
.app-shell[data-mobile="true"] .sidebar .search{height:46px;flex-shrink:0;margin:12px 0 18px;background:var(--inset);border:1px solid var(--line);border-radius:12px}
.app-shell[data-mobile="true"] .sidebar .bot-list{flex:1;min-height:0;overscroll-behavior:contain}
.app-shell[data-mobile="true"] .sidebar .group-label{padding:8px 10px;color:var(--muted);font-size:12px}
.app-shell[data-mobile="true"] .sidebar .bot-row{min-height:80px;padding:14px 10px;gap:14px;border-radius:14px}
.app-shell[data-mobile="true"] .sidebar .bot-row.selected{background:transparent}
.app-shell[data-mobile="true"] .sidebar .bot-row:hover,.app-shell[data-mobile="true"] .sidebar .bot-row:active{background:var(--surface-hover)}
.app-shell[data-mobile="true"] .sidebar .bot-copy{gap:6px}
.app-shell[data-mobile="true"] .sidebar .bot-copy strong{font-size:16px}
.app-shell[data-mobile="true"] .sidebar .bot-copy small{font-size:13px;line-height:1.4}
.app-shell[data-mobile="true"] .sidebar-bottom{flex-shrink:0;margin-top:12px;padding-top:10px}
.app-shell[data-mobile="true"]>.chat-panel{height:100%;width:100%;border:0}
.app-shell[data-mobile="true"] .chat-panel>.topbar{display:grid;grid-template-columns:44px minmax(0,1fr) 44px;grid-template-rows:48px;gap:8px;height:auto;flex:0 0 auto;padding:calc(6px + env(safe-area-inset-top)) 12px 6px}
.app-shell[data-mobile="true"] .topbar>.mobile-menu{display:inline-flex;grid-column:1;grid-row:1;width:44px;height:44px}
.app-shell[data-mobile="true"] .topbar>.topbar-identity{grid-column:2;grid-row:1;min-width:0}
.app-shell[data-mobile="true"] .topbar>.avatar-stack{grid-column:2;grid-row:1;justify-self:start}
.app-shell[data-mobile="true"] .topbar>.avatar-stack+strong{grid-column:2;grid-row:1;padding-left:62px}
.app-shell[data-mobile="true"] .topbar>.grow{display:none}
.app-shell[data-mobile="true"] .topbar>.top-tools{grid-column:3;grid-row:1;display:flex;width:44px;margin:0;padding:0;border:0;background:transparent;box-shadow:none}
.app-shell[data-mobile="true"] .topbar .top-tool-button{width:44px;height:44px;min-height:44px;background:transparent}
/* Phone chat spacing: one shared gutter, no inherited positional offsets. */
.app-shell[data-mobile="true"]>.chat-panel{--chat-gutter:16px;--chat-col:100%}
.app-shell[data-mobile="true"] .chat-panel>.topbar{padding:calc(14px + env(safe-area-inset-top)) 12px 14px}
.app-shell[data-mobile="true"] .messages{padding:24px var(--chat-gutter) 24px;scroll-padding-block:24px}
.app-shell[data-mobile="true"] .message-block{margin-top:14px;gap:16px}
.app-shell[data-mobile="true"] .message-block:first-child{margin-top:0}
.app-shell[data-mobile="true"] .message-block.tight{margin-top:4px;gap:0}
.app-shell[data-mobile="true"] .message-block.has-day{gap:20px}
.app-shell[data-mobile="true"] .message.assistant{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:6px;align-items:start}
.app-shell[data-mobile="true"] .message.assistant>.msg-avatar{grid-column:1;grid-row:1;align-self:start;margin-top:5px}
.app-shell[data-mobile="true"] .message.assistant>.message-stack{grid-column:2;grid-row:1;width:fit-content;max-width:100%;min-width:0}
.app-shell[data-mobile="true"] .message.assistant>.message-time{grid-column:2;grid-row:2;justify-self:start;padding:0 2px}
.app-shell[data-mobile="true"] .message.user{display:grid;grid-template-columns:minmax(0,1fr);row-gap:6px;justify-items:end}
.app-shell[data-mobile="true"] .message.user>.message-body{grid-column:1;grid-row:1;max-width:calc(100% - 38px);min-width:0}
.app-shell[data-mobile="true"] .message.user>.message-time{grid-column:1;grid-row:2;padding:0 2px}
.app-shell[data-mobile="true"] .message .message-body{padding:12px 14px;font-size:15px;line-height:1.65}
.app-shell[data-mobile="true"] .composer-dock{padding:16px var(--chat-gutter) calc(24px + env(safe-area-inset-bottom));gap:12px}
.app-shell[data-mobile="true"] .composer{position:relative;inset:auto;width:100%;max-width:760px;min-height:64px;margin:0 auto;padding:9px 10px;border-radius:24px;gap:8px;align-items:center;transform:none}
.app-shell[data-mobile="true"] .composer textarea{min-width:0;height:44px;min-height:44px;padding:10px 0;font-size:16px;line-height:24px;align-self:center}
.app-shell[data-mobile="true"] .composer-plus,
.app-shell[data-mobile="true"] .composer .send{width:40px;height:40px;flex:0 0 40px;align-self:center}
.app-shell[data-mobile="true"] .brand{height:auto;min-height:88px;padding:16px 4px;gap:12px}
.mobile-chats-title{margin:0;font-size:30px;line-height:1.3;font-weight:700;letter-spacing:-.03em}
.app-shell[data-mobile="true"] .sidebar .search{margin-top:0}
.app-shell[data-mobile="true"] .sidebar-bottom .account{min-height:52px;gap:12px}
.app-shell[data-mobile="true"] .sidebar-bottom .account>span{font-size:15px}
.app-shell[data-mobile="true"] .composer .send>svg{width:20px;height:20px;display:block}
/* Phone inbox: profile at left, circular search/add actions at right. */
.app-shell[data-mobile="true"] .brand{min-height:84px;padding:12px 6px 20px;gap:12px}
.app-shell[data-mobile="true"] .brand .mobile-profile{margin-right:auto;width:46px;height:46px;border:0;border-radius:50%;display:grid;place-items:center;padding:4px;background:var(--inset);cursor:pointer}
.mobile-profile>span{color:var(--ink);font-size:20px;font-weight:600;line-height:1}
.app-shell[data-mobile="true"] .mobile-search-toggle,.app-shell[data-mobile="true"] .brand .icon-button{width:46px;height:46px;padding:11px;border:1px solid var(--line);border-radius:50%;background:transparent;color:var(--ink);display:grid;place-items:center;cursor:pointer}
.mobile-search-toggle svg{width:23px;height:23px}
.app-shell[data-mobile="true"] .brand .create-menu-wrap{margin-left:0}
.app-shell[data-mobile="true"] .sidebar-bottom{display:none}
.app-shell[data-mobile="true"] .bot-group>.group-label:first-child{display:none}
.app-shell[data-mobile="true"] .bot-row{min-height:84px;padding:16px 6px}
.app-shell[data-mobile="true"] .bot-name{display:flex;align-items:center;gap:8px}
.app-shell[data-mobile="true"] .bot-name .bot-tag{font-size:10px;font-weight:400;flex-shrink:0}
.app-shell[data-mobile="true"] .brand .icon-button svg{width:24px;height:24px}

View File

@ -249,7 +249,7 @@
height: 64px; height: 64px;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
background: #181818; background: var(--inset);
border-radius: 16px; border-radius: 16px;
} }
@ -333,3 +333,58 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
/* Phone settings use a page stack, with full-width touch rows. */
.app-shell[data-mobile="true"] .lb-overlay{padding:0;background:var(--main)}
.mobile-settings-page,.lb-about-dialog.mobile-about-page{width:100%;height:100dvh;max-width:none;max-height:none;display:flex;flex-direction:column;padding:0;border:0;border-radius:0;box-shadow:none;background:var(--main);color:var(--ink);overflow:hidden}
.mobile-settings-header{display:grid;grid-template-columns:44px minmax(0,1fr) 44px;gap:8px;align-items:center;flex-shrink:0;padding:calc(14px + env(safe-area-inset-top)) 12px 14px;border-bottom:1px solid var(--hairline)}
.mobile-settings-header button{display:grid;place-items:center;width:44px;height:44px;padding:0;background:transparent;color:var(--ink);border:0;border-radius:12px;cursor:pointer}
.mobile-settings-header svg{width:20px;height:20px}
.mobile-settings-header h2{margin:0;text-align:center;font-size:17px;line-height:1.4;font-weight:600}
.mobile-settings-content{flex:1;min-height:0;overflow:auto;padding:28px 20px calc(28px + env(safe-area-inset-bottom));display:flex;flex-direction:column;gap:28px}
.mobile-workspace{display:flex;align-items:center;gap:14px;padding:4px 4px 8px}
.mobile-workspace>div{min-width:0}
.mobile-workspace strong{font-size:17px;font-weight:600}
.mobile-workspace p{margin:6px 0 0;font-size:13px;line-height:1.6;color:var(--muted)}
.mobile-workspace .lb-account-card__avatar{width:48px;height:48px;flex-basis:48px;background:var(--surface);box-shadow:none}
.mobile-settings-group{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:16px;background:var(--inset);overflow:hidden;flex-shrink:0}
.mobile-settings-group>button{display:flex;align-items:center;gap:12px;min-height:62px;padding:16px;border:0;border-radius:0;background:transparent;color:var(--ink);font:inherit;font-size:16px;text-align:left;cursor:pointer}
.mobile-settings-group>button+button{border-top:1px solid var(--line)}
.mobile-settings-group>button>span:first-child{flex:1;min-width:0}
.mobile-settings-group small{color:var(--muted);font-size:14px}
.mobile-settings-group>button>span:last-child{color:var(--muted)}
.mobile-settings-group>button:active{background:var(--surface-hover)}
.mobile-settings-group .mobile-setting-check{min-width:20px;font-size:19px;color:var(--accent)!important}
.mobile-settings-content .lb-settings-row{min-height:64px;padding:16px;flex-wrap:wrap;font-size:16px;border-radius:14px}
.mobile-settings-content .lb-settings-copy small{font-size:13px;line-height:1.6}
.mobile-settings-content .lb-settings-row button{min-height:44px}
.mobile-settings-content .lb-settings-group>h3{font-size:13px;font-weight:500;padding-left:4px}
.mobile-about-page .lb-about-close{display:none}
.mobile-about-page .lb-about-brand{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:28px}
.mobile-about-page footer{padding:20px 24px calc(28px + env(safe-area-inset-bottom))}
.mobile-about-page footer button{width:100%;min-height:52px;border-radius:14px}
.app-shell[data-mobile="true"] .modal-backdrop:has(.agent-settings){padding:0;background:var(--main)}
.app-shell[data-mobile="true"] .dialog.agent-settings{width:100%;height:100dvh;max-height:none;border:0;border-radius:0;box-shadow:none;padding:0 20px calc(24px + env(safe-area-inset-bottom));align-content:start;gap:24px;background:var(--main)}
.app-shell[data-mobile="true"] .agent-settings .dialog-title{position:sticky;top:0;z-index:2;min-height:76px;padding:calc(12px + env(safe-area-inset-top)) 0 12px;background:var(--main);border-bottom:1px solid var(--hairline)}
.app-shell[data-mobile="true"] .agent-settings .dialog-title h2{font-size:18px}
.app-shell[data-mobile="true"] .agent-settings .dialog-title button{min-width:44px;min-height:44px}
.app-shell[data-mobile="true"] .agent-settings input,
.app-shell[data-mobile="true"] .agent-settings textarea{font-size:16px;min-height:48px}
.app-shell[data-mobile="true"] .agent-settings .dialog-actions{position:sticky;bottom:0;padding:12px 0;background:var(--main)}
.app-shell[data-mobile="true"] .agent-settings .dialog-actions button{min-height:48px;flex:1}
/* Inset phone sheet with the app identity anchored below the preferences. */
.app-shell[data-mobile="true"] .lb-overlay{padding:calc(20px + env(safe-area-inset-top)) 8px 0;align-items:flex-end;background:rgb(0 0 0 / .48);backdrop-filter:blur(5px)}
.app-shell[data-mobile="true"] .mobile-settings-page,.app-shell[data-mobile="true"] .mobile-about-page{height:calc(100dvh - 32px - env(safe-area-inset-top));width:100%;max-width:560px;border-radius:28px 28px 0 0;box-shadow:0 -8px 40px rgb(0 0 0 / .12)}
.mobile-settings-header{padding:16px;border-bottom:0}
.mobile-settings-header button{border-radius:50%;background:var(--inset)}
.mobile-settings-content{padding:16px 20px calc(32px + env(safe-area-inset-bottom));gap:28px}
.mobile-settings-identity{margin-top:auto;padding:40px 0 4px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;flex-shrink:0}
.mobile-settings-identity img{display:block;width:64px;height:64px;margin-bottom:6px}
.mobile-settings-identity strong{font-size:18px;font-weight:600}
.mobile-settings-identity span,.mobile-settings-identity small{font-size:13px;color:var(--muted)}
.computer-settings-status{font-size:14px;line-height:1.6;overflow-wrap:anywhere;color:var(--muted)}
.app-shell[data-mobile="true"] .lb-overlay{padding-bottom:8px}
.app-shell[data-mobile="true"] .mobile-settings-page,.app-shell[data-mobile="true"] .mobile-about-page{height:calc(100dvh - 104px - env(safe-area-inset-top));border-radius:28px}
.mobile-settings-group{background:color-mix(in srgb,var(--main),var(--ink) 7%);border-color:transparent}
@media(max-height:540px){.app-shell[data-mobile="true"] .mobile-settings-page,.app-shell[data-mobile="true"] .mobile-about-page{height:calc(100dvh - 24px)}}

View File

@ -1,8 +1,8 @@
:root{font-family:"Huninn","jf open 粉圓","PingFang TC","PingFang SC","Hiragino Sans","Noto Sans CJK TC","Noto Sans JP",sans-serif;color:var(--ink);background:var(--page);font-synthesis:none;--page:#050506;--side:#0b0b0c;--main:#0d0d0e;--panel:#0a0a0b;--inset:#101012;--line:#202023;--border:#29292d;--surface:#151517;--ink:#f1f1f2;--muted:#85858a;--faint:#626267;--cream:#f1f1ef;--accent:#3ec5a8;--danger:#ef5555;--success:#4ecb71;--hairline:#171719;--elevated:#121214;--menu:#18181b;--input:#0c0c0d;--dialog:#111113;--on-cream:#1a1a1a;--on-send:#1b1b1c;--online:#22c55e;--unread:#3b82f6;--surface-hover:rgba(255,255,255,.06);--focus:#4b4b50;--error-bg:#2a1717;--error-fg:#fca5a5;--error-border:#5a2a2a;--danger-fill:#8f2828;--danger-line:#a43b3b;--danger-soft:#ff8585;--warn-bg:#221c0e;--warn-fg:#fcd68a;--warn-border:#5a4a1f} :root{font-family:"Huninn","jf open 粉圓","PingFang TC","PingFang SC","Hiragino Sans","Noto Sans CJK TC","Noto Sans JP",sans-serif;color:var(--ink);background:var(--page);font-synthesis:none;--bubble-assistant:#19191c;--page:#050506;--side:#0b0b0c;--main:#0d0d0e;--panel:#0a0a0b;--inset:#101012;--line:#202023;--border:#29292d;--surface:#151517;--ink:#f1f1f2;--muted:#85858a;--faint:#626267;--cream:#f1f1ef;--accent:#3ec5a8;--danger:#ef5555;--success:#4ecb71;--hairline:#171719;--elevated:#121214;--menu:#18181b;--input:#0c0c0d;--dialog:#111113;--on-cream:#1a1a1a;--on-send:#1b1b1c;--online:#22c55e;--unread:#3b82f6;--surface-hover:rgba(255,255,255,.06);--focus:#4b4b50;--error-bg:#2a1717;--error-fg:#fca5a5;--error-border:#5a2a2a;--danger-fill:#8f2828;--danger-line:#a43b3b;--danger-soft:#ff8585;--warn-bg:#221c0e;--warn-fg:#fcd68a;--warn-border:#5a4a1f}
html[data-theme="dark"]{color-scheme:dark} html[data-theme="dark"]{color-scheme:dark}
html[data-theme="light"]{color-scheme:light;--page:#f3f3f4;--side:#f7f7f8;--main:#fcfcfc;--panel:#f7f7f8;--inset:#f0f0f1;--line:#e4e4e7;--border:#d8d8dc;--surface:#ececee;--ink:#141414;--muted:#6b6b70;--faint:#8a8a90;--cream:#141414;--accent:#1a9d82;--hairline:#ebebec;--elevated:#ffffff;--menu:#ffffff;--input:#f4f4f5;--dialog:#ffffff;--on-cream:#f7f7f7;--on-send:#f7f7f7;--surface-hover:rgba(0,0,0,.05);--focus:#b0b0b5;--error-bg:#fde8e8;--error-fg:#b42318;--error-border:#f0b0b0;--danger-fill:#c53030;--danger-line:#e05353;--danger-soft:#c53030;--warn-bg:#fbf3d5;--warn-fg:#854d0e;--warn-border:#e8d48b} html[data-theme="light"]{color-scheme:light;--bubble-assistant:#f0f1f3;--page:#ffffff;--side:#f5f5f6;--main:#ffffff;--panel:#f7f7f8;--inset:#f0f0f1;--line:#e4e4e7;--border:#d8d8dc;--surface:#ececee;--ink:#141414;--muted:#6b6b70;--faint:#71717a;--cream:#141414;--accent:#087b66;--success:#16803d;--hairline:#ebebec;--elevated:#ffffff;--menu:#ffffff;--input:#f4f4f5;--dialog:#ffffff;--on-cream:#f7f7f7;--on-send:#f7f7f7;--surface-hover:rgba(0,0,0,.05);--focus:#b0b0b5;--error-bg:#fde8e8;--error-fg:#b42318;--error-border:#f0b0b0;--danger-fill:#c53030;--danger-line:#e05353;--danger-soft:#c53030;--warn-bg:#fbf3d5;--warn-fg:#854d0e;--warn-border:#e8d48b}
*{box-sizing:border-box} *{box-sizing:border-box}

41
web/src/lazyboy/theme.css Normal file
View File

@ -0,0 +1,41 @@
/* Neutral light surfaces; remote desktop pixels keep their own appearance. */
html[data-theme="light"] .composer{background:#fff;box-shadow:0 4px 18px #18181b08}
html[data-theme="light"] .search{background:#fff}
html[data-theme="light"] .bot-row.selected{background:#e8e9ec}
html[data-theme="light"] .bot-row:hover{background:#eceef0}
html[data-theme="light"] .dialog{box-shadow:0 24px 80px #18181b26}
html[data-theme="light"] .account-menu,
html[data-theme="light"] .context-menu,
html[data-theme="light"] .host-menu,
html[data-theme="light"] .slash-suggestions{box-shadow:0 8px 30px #18181b1a}
html[data-theme="light"] .message-body.md pre{background:#e4e6e9;color:#202124}
html[data-theme="light"] .message-body.md :not(pre)>code{background:#e0e3e7;color:#202124}
html[data-theme="light"] .message.user .message-body.md pre,
html[data-theme="light"] .message.user .message-body.md :not(pre)>code{background:#ffffff1f;color:#fff}
html[data-theme="light"] .message.user .mention-link{color:#9ce7d7}
html[data-theme="light"] .day-divider time,
html[data-theme="light"] .plugin-icon,
html[data-theme="light"] .stack-extra,
html[data-theme="light"] .reply-quote,
html[data-theme="light"] .composer-reply,
html[data-theme="light"] .control-badge.is-off{background:var(--surface)}
html[data-theme="light"] .message.user .reply-quote{background:#ffffff14}
html[data-theme="light"] .copy-msg:hover,
html[data-theme="light"] .file-card-remove:hover,
html[data-theme="light"] .slash-suggestions button:hover,
html[data-theme="light"] .host-menu button:hover,
html[data-theme="light"] .expr-grid button:hover,
html[data-theme="light"] .bg-grid button:hover{background:var(--surface-hover)}
html[data-theme="light"] .expr-grid button.selected,
html[data-theme="light"] .bg-grid button.selected{background:var(--surface);border-color:var(--ink)}
html[data-theme="light"] .computer-hud{background:#f3f5f5eb}
html[data-theme="light"] .computer-hud-label{color:#344940}
.brand-icon{object-fit:contain;border-radius:0}
.lb-about-brand img{object-fit:contain;background:transparent;border-radius:0}
html[data-theme="light"] .top-tools{background:var(--main);border-color:var(--line);box-shadow:none}
html[data-theme="light"] .top-tool-button.active{background:var(--surface);color:var(--ink)}
html[data-theme="light"] .teach-banner,
html[data-theme="light"] .teach-banner.recording{background:var(--error-bg);border-color:var(--error-border);color:var(--error-fg)}
html[data-theme="light"] .skill-draft{background:#f0f8f5;border-color:#c5ddd4}
html[data-theme="light"] .skill-intent{color:var(--muted)}
html[data-theme="light"] .login-screen{background:radial-gradient(circle at 50% 18%,#e9f4ef 0,#fff 58%)}

View File

@ -14,6 +14,7 @@ import "./lazyboy/computer.css";
import "./lazyboy/responsive.css"; import "./lazyboy/responsive.css";
import "./lazyboy/extra.css"; import "./lazyboy/extra.css";
import "./lazyboy/settings.css"; import "./lazyboy/settings.css";
import "./lazyboy/theme.css";
applyTheme(readTheme()); applyTheme(readTheme());

View File

@ -16,6 +16,7 @@ export type SettingsComputer = {
pending: "start" | "restart" | "update" | null; pending: "start" | "restart" | "update" | null;
working: boolean; working: boolean;
upToDate: boolean; upToDate: boolean;
status?: string;
onUpdate: () => void; onUpdate: () => void;
onRestart: () => void; onRestart: () => void;
}; };
@ -181,6 +182,7 @@ function UpdatesPanel({ computer }: { computer?: SettingsComputer }) {
</span> </span>
</div> </div>
</SettingsGroup> </SettingsGroup>
{computer?.status ? <p className="computer-settings-status" role="status">{computer.status}</p> : null}
{computer ? ( {computer ? (
<SettingsGroup title={t.appComputer}> <SettingsGroup title={t.appComputer}>
{computer.upToDate ? ( {computer.upToDate ? (
@ -220,12 +222,50 @@ function UpdatesPanel({ computer }: { computer?: SettingsComputer }) {
); );
} }
export function SettingsDialog({ onClose, computer }: { onClose: () => void; computer?: SettingsComputer }) { function MobilePageHeader({ title, backLabel, onBack, close = false }: { title: string; backLabel: string; onBack: () => void; close?: boolean }) {
return <header className="mobile-settings-header">
<button type="button" aria-label={backLabel} onClick={onBack}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={close ? "m6 6 12 12M18 6 6 18" : "m14 6-6 6 6 6"} /></svg></button>
<h2>{title}</h2><span aria-hidden="true" />
</header>;
}
function MobileSettings({ onClose, onAbout, computer }: { onClose: () => void; onAbout?: () => void; computer?: SettingsComputer }) {
const { t, locale, setLocale } = useI18n();
const [page, setPage] = useState<"home" | "theme" | "language" | "updates">("home");
const [theme, setTheme] = useState<ThemePref>(readTheme);
const themeLabel = { system: t.themeSystem, light: t.themeLight, dark: t.themeDark };
const title = page === "home" ? t.settings : page === "theme" ? t.appearance : page === "language" ? t.language : t.updates;
const back = () => page === "home" ? onClose() : setPage("home");
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key === "Escape") back(); }; window.addEventListener("keydown", key); return () => window.removeEventListener("keydown", key); }, [page, onClose]);
return <OverlayShell label={t.settingsLabel} className="mobile-settings-page" onClose={onClose}>
<MobilePageHeader close={page === "home"} title={title} backLabel={page === "home" ? t.backToChats : t.settings} onBack={back} />
<div className="mobile-settings-content">
{page === "home" ? <>
<section className="mobile-settings-group" aria-label={t.general}>
<button type="button" onClick={() => setPage("theme")}><span>{t.appearance}</span><small>{themeLabel[theme]}</small><span aria-hidden="true"></span></button>
<button type="button" onClick={() => setPage("language")}><span>{t.language}</span><small>{LOCALE_OPTIONS.find(option => option.id === locale)?.native}</small><span aria-hidden="true"></span></button>
</section>
<section className="mobile-settings-group" aria-label={t.updates}>
<button type="button" onClick={() => setPage("updates")}><span>{t.appComputer}</span><small>{t.updates}</small><span aria-hidden="true"></span></button>
{onAbout ? <button type="button" onClick={onAbout}><span>{t.about}</span><span aria-hidden="true"></span></button> : null}
</section>
<footer className="mobile-settings-identity"><img src="/lazyboy-round.svg" alt="" width="64" height="64"/><strong>{APP_NAME}</strong><span>{t.localWorkspace}</span><small>{APP_VERSION}</small></footer>
</> : page === "theme" ? <div className="mobile-settings-group" role="radiogroup" aria-label={t.theme}>
{THEME_PREFS.map(value => <button key={value} type="button" role="radio" aria-checked={theme === value} onClick={() => { persistTheme(value); setTheme(value); }}><span>{themeLabel[value]}</span><span className="mobile-setting-check" aria-hidden="true">{theme === value ? "✓" : ""}</span></button>)}
</div> : page === "language" ? <div className="mobile-settings-group" role="radiogroup" aria-label={t.language}>
{LOCALE_OPTIONS.map(option => <button key={option.id} type="button" role="radio" aria-checked={locale === option.id} onClick={() => setLocale(option.id)}><span>{option.native}</span><span className="mobile-setting-check" aria-hidden="true">{locale === option.id ? "✓" : ""}</span></button>)}
</div> : <UpdatesPanel computer={computer} />}
</div>
</OverlayShell>;
}
export function SettingsDialog({ onClose, computer, mobile = false, onAbout }: { onClose: () => void; computer?: SettingsComputer; mobile?: boolean; onAbout?: () => void }) {
const { t } = useI18n(); const { t } = useI18n();
const [section, setSection] = useState<SettingsSectionId>("general"); const [section, setSection] = useState<SettingsSectionId>("general");
const headingId = `lb-settings-heading-${section}`; const headingId = `lb-settings-heading-${section}`;
const panelId = `lb-settings-panel-${section}`; const panelId = `lb-settings-panel-${section}`;
const sectionLabel = section === "general" ? t.general : t.updates; const sectionLabel = section === "general" ? t.general : t.updates;
if (mobile) return <MobileSettings onClose={onClose} onAbout={onAbout} computer={computer} />;
return ( return (
<OverlayShell label={t.settingsLabel} className="lb-settings-dialog" onClose={onClose}> <OverlayShell label={t.settingsLabel} className="lb-settings-dialog" onClose={onClose}>
@ -263,7 +303,7 @@ export function SettingsDialog({ onClose, computer }: { onClose: () => void; com
); );
} }
export function AboutDialog({ onClose }: { onClose: () => void }) { export function AboutDialog({ onClose, mobile = false }: { onClose: () => void; mobile?: boolean }) {
const { t, format } = useI18n(); const { t, format } = useI18n();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [copyGeneration, setCopyGeneration] = useState(0); const [copyGeneration, setCopyGeneration] = useState(0);
@ -288,13 +328,14 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
}; };
return ( return (
<OverlayShell label={t.about} className="lb-about-dialog" onClose={onClose}> <OverlayShell label={t.about} className={`lb-about-dialog ${mobile ? "mobile-about-page" : ""}`} onClose={onClose}>
{mobile ? <MobilePageHeader title={t.about} backLabel={t.settings} onBack={onClose} /> : null}
<button type="button" className="lb-about-close" aria-label={t.close} onClick={onClose}> <button type="button" className="lb-about-close" aria-label={t.close} onClick={onClose}>
<CloseIcon /> <CloseIcon />
</button> </button>
<div className="lb-about-brand"> <div className="lb-about-brand">
<span className="lb-about-icon"> <span className="lb-about-icon">
<img src="/lazyboy-icon.png" width={64} height={64} alt="" draggable={false} decoding="async" /> <img src="/lazyboy-round.svg" width={64} height={64} alt="" draggable={false} decoding="async" />
</span> </span>
<div className="lb-about-heading"> <div className="lb-about-heading">
<h2>{APP_NAME}</h2> <h2>{APP_NAME}</h2>

View File

@ -23,6 +23,7 @@ export default defineConfig(({ mode }) => {
}; };
return { return {
plugins: [react()], plugins: [react()],
resolve: { dedupe: ["react", "react-dom"] },
define: { define: {
"import.meta.env.LAZYBOY_VERSION": JSON.stringify(version), "import.meta.env.LAZYBOY_VERSION": JSON.stringify(version),
}, },