version_0_1_0-alpha
This commit is contained in:
parent
42c159e42f
commit
8e8174e8bf
15
Makefile
15
Makefile
|
|
@ -6,6 +6,8 @@
|
|||
# make stop
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.NOTPARALLEL: build start restart
|
||||
export CARGO_BUILD_JOBS ?= 2
|
||||
|
||||
ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
|
||||
RUNDIR := $(ROOT)/.run
|
||||
|
|
@ -108,7 +110,7 @@ wait_listen() {
|
|||
name="$$3"
|
||||
log="$$4"
|
||||
i=0
|
||||
while [ $$i -lt 50 ]; do
|
||||
while [ $$i -lt 150 ]; do
|
||||
if lsof -nP -iTCP:"$$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
|
@ -172,12 +174,13 @@ build-bin:
|
|||
@cargo build -p lazyboy
|
||||
|
||||
build-ui:
|
||||
@if [ ! -d "$(WEBDIR)/node_modules" ]; then \
|
||||
echo "npm install…"; \
|
||||
(cd "$(WEBDIR)" && npm install) || exit 1; \
|
||||
@if [ ! -f "$(WEBDIR)/node_modules/.lazyboy-lock" ] || ! cmp -s "$(WEBDIR)/package-lock.json" "$(WEBDIR)/node_modules/.lazyboy-lock"; then \
|
||||
echo "installing locked web dependencies…"; \
|
||||
(cd "$(WEBDIR)" && npm ci) || exit 1; \
|
||||
cp "$(WEBDIR)/package-lock.json" "$(WEBDIR)/node_modules/.lazyboy-lock"; \
|
||||
fi
|
||||
@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:
|
||||
@echo "building computer image lazyboy-box:local…"
|
||||
|
|
@ -237,7 +240,7 @@ start-web:
|
|||
echo "npm install…"; \
|
||||
(cd "$(WEBDIR)" && npm install) || exit 1; \
|
||||
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_listen "$(PID_WEB)" "$(PORT_WEB)" web "$(LOG_WEB)" || exit 1; \
|
||||
echo "started web pid=$$(cat "$(PID_WEB)") :$(PORT_WEB) log=$(LOG_WEB)"
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -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.
|
||||
|
||||
## Remote computer controls
|
||||
|
||||
右側電腦採用 Grok 風格的螢幕卡片,按「放大」可開啟全頁操作。桌面版不顯示手機控制列,直接使用滑鼠鍵盤;Ctrl+Alt++/− 可縮放,Ctrl+Alt+0 重設,Ctrl+Alt+B 開啟文字剪貼簿面板。
|
||||
|
||||
手機沿用 LazyBoy 的獨立觸控板:滑動移動游標、輕點左鍵、雙指捲動/輕點右鍵,也可按左右鍵、捲動與「拖曳」(再按一次放開)。可切換直接點選模式。直向控制區在下方,橫向在左側;鍵盤、常用按鍵、1–4 倍縮放、移動畫面與雙向文字剪貼簿都在手機控制區。若區網 HTTP 或瀏覽器限制剪貼簿權限,可在面板長按貼上/複製。
|
||||
|
||||
## Start
|
||||
|
||||
```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).
|
||||
|
||||
### 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).
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ const BROWSER_PROFILE: &str = "/home/box/chrome-profile";
|
|||
const JOB_OUTPUT_HEAD_CHARS: usize = 16_000;
|
||||
const JOB_OUTPUT_TAIL_CHARS: usize = 32_000;
|
||||
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)]
|
||||
pub struct BoxSeat {
|
||||
|
|
@ -285,6 +288,9 @@ impl BoxHub {
|
|||
docker_info().await?;
|
||||
let _provision = ProvisionLock::acquire().await?;
|
||||
ensure_image().await?;
|
||||
let pool = BoxPool::global();
|
||||
pool.reconcile().await?;
|
||||
pool.reclaim(self.seat_id()).await?;
|
||||
let port = ensure_container(&self.seat).await?;
|
||||
wait_desktop(&self.seat.container).await?;
|
||||
self.inner.lock().await.host_port = Some(port);
|
||||
|
|
@ -313,7 +319,11 @@ impl BoxHub {
|
|||
let result = async {
|
||||
docker_info().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() {
|
||||
limit_container(self.container()).await?;
|
||||
docker(["restart", "-t", "20", self.container()]).await?;
|
||||
} else {
|
||||
ensure_image().await?;
|
||||
|
|
@ -342,6 +352,9 @@ impl BoxHub {
|
|||
let result = async {
|
||||
docker_info().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;
|
||||
build_image().await?;
|
||||
let after = local_image_id().await;
|
||||
|
|
@ -963,7 +976,17 @@ async fn local_image_id() -> Option<String> {
|
|||
.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> {
|
||||
if docker(["container", "inspect", &seat.container]).await.is_ok() {
|
||||
limit_container(&seat.container).await?;
|
||||
}
|
||||
if docker_running(&seat.container).await?
|
||||
&& container_image_id(&seat.container).await == local_image_id().await
|
||||
{
|
||||
|
|
@ -994,7 +1017,11 @@ async fn ensure_container(seat: &BoxSeat) -> Result<u16> {
|
|||
"create",
|
||||
"--name",
|
||||
&seat.container,
|
||||
"--shm-size=2g",
|
||||
"--memory", COMPUTER_MEMORY,
|
||||
"--memory-swap", COMPUTER_MEMORY,
|
||||
"--cpus", COMPUTER_CPUS,
|
||||
"--pids-limit", COMPUTER_PIDS,
|
||||
"--shm-size=256m",
|
||||
"-p",
|
||||
&publish,
|
||||
"--stop-timeout=20",
|
||||
|
|
@ -1133,12 +1160,14 @@ fn max_running() -> usize {
|
|||
}
|
||||
|
||||
pub struct BoxPool {
|
||||
provisioning: Mutex<()>,
|
||||
hubs: std::sync::Mutex<HashMap<String, Arc<BoxHub>>>,
|
||||
}
|
||||
|
||||
impl BoxPool {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
provisioning: Mutex::new(()),
|
||||
hubs: std::sync::Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
|
@ -1163,8 +1192,30 @@ impl BoxPool {
|
|||
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> {
|
||||
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?;
|
||||
if crowded {
|
||||
payload["crowded"] = json!(true);
|
||||
|
|
@ -1193,8 +1244,9 @@ impl BoxPool {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn reclaim(&self, keep: &str) -> bool {
|
||||
async fn reclaim(&self, keep: &str) -> Result<bool> {
|
||||
let max = max_running();
|
||||
let reserve_slot = !keep.is_empty();
|
||||
let keep = BoxSeat::for_id(keep).id;
|
||||
loop {
|
||||
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));
|
||||
}
|
||||
}
|
||||
if running.len() < max {
|
||||
return false;
|
||||
let already_running = running.iter().any(|(_, hub)| hub.seat_id() == keep);
|
||||
if running.len() < max || ((!reserve_slot || already_running) && running.len() <= max) {
|
||||
return Ok(false);
|
||||
}
|
||||
running.sort_by_key(|(used, _)| *used);
|
||||
let victim = running.into_iter().find(|(_, hub)| {
|
||||
|
|
@ -1214,10 +1267,10 @@ impl BoxPool {
|
|||
match victim {
|
||||
Some((_, hub)) => {
|
||||
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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,41 +34,62 @@ html, body {
|
|||
height: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
#noVNC_container,
|
||||
#noVNC_screen,
|
||||
#noVNC_container > div {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: #111 !important;
|
||||
overflow: hidden !important;
|
||||
display: flex !important;
|
||||
}
|
||||
#noVNC_container canvas,
|
||||
#noVNC_screen canvas {
|
||||
flex: 1 1 auto !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
object-fit: fill !important;
|
||||
}
|
||||
#noVNC_container, #noVNC_container > div, #noVNC_container canvas { border-radius:0!important; clip-path:none!important; }
|
||||
#noVNC_container { position:absolute!important; inset:0!important; width:100%!important; height:100%!important; overflow:auto!important; background:#111!important; }
|
||||
#noVNC_screen { width:100%!important; height:100%!important; }
|
||||
#noVNC_container canvas { flex:none!important; margin:auto!important; touch-action:none; }
|
||||
#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; }
|
||||
#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; }
|
||||
#lazyboy-tools button[aria-pressed="true"] { background:#395b50; }
|
||||
#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; }
|
||||
#lazyboy-clipboard[hidden] { display:none; }
|
||||
#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; }
|
||||
#lazyboy-clipboard p { margin:8px 0 0;line-height:1.5; }
|
||||
#lazyboy-pan { position:absolute;inset:0;z-index:99;touch-action:none;cursor:grab; }
|
||||
#lazyboy-pan[hidden] { display:none; }
|
||||
#lazyboy-tools { display:none; }
|
||||
html[data-mobile="true"] body { display:grid;grid-template:"screen" minmax(0,1fr) "controls" auto / minmax(0,1fr); }
|
||||
html[data-mobile="true"] #noVNC_container { position:relative!important;grid-area:screen;min-width:0;min-height:0; }
|
||||
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; }
|
||||
#lazyboy-tools .buttons { display:flex;gap:5px;overflow-x:auto;flex-shrink:0; }
|
||||
#lazyboy-tools button { min-height:44px;min-width:44px; }
|
||||
#lazyboy-tools .secondary button { font-size:12px; }
|
||||
#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; }
|
||||
#lazyboy-trackpad[hidden] { display:none; }
|
||||
#lazyboy-pointer-help { margin:0;color:#999;font-size:11px;line-height:1.4; }
|
||||
#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; }
|
||||
#lazyboy-cursor::after { content:"";position:absolute;inset:4px;background:white;border-radius:50%; }
|
||||
html[data-mobile="true"][data-landscape="true"] body { grid-template:"controls screen" minmax(0,1fr) / 190px minmax(0,1fr); }
|
||||
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>
|
||||
<script id="lazyboy-novnc-bridge">
|
||||
(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 messages = null;
|
||||
import("./core/rfb.js").then(function(mod) { messages = mod.default.messages; }).catch(function() {});
|
||||
var lastHost = "";
|
||||
var lastBox = "";
|
||||
var lastGesture = 0;
|
||||
var lastPasteAt = 0;
|
||||
import("./app/ui.js").then(function (mod) {
|
||||
ui = mod && (mod.default || mod.UI || mod);
|
||||
|
|
@ -98,60 +119,46 @@ html, body {
|
|||
return document.getElementById("noVNC_clipboard_text");
|
||||
}
|
||||
|
||||
var zoom = 1;
|
||||
var paintMobilePointer = function() {};
|
||||
var resetMobilePointer = function() {};
|
||||
function fillCanvas(session) {
|
||||
if (!session) return;
|
||||
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;
|
||||
if (screen && screen.style) {
|
||||
screen.style.overflow = "hidden";
|
||||
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 {
|
||||
var container = document.getElementById("noVNC_container");
|
||||
if (!canvas || !screen || !container) return;
|
||||
session.resizeSession = false;
|
||||
session.scaleViewport = false;
|
||||
session.clipViewport = false;
|
||||
session.background = "#111";
|
||||
} catch (_e2) {}
|
||||
var display = session._display;
|
||||
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) {
|
||||
if (!text || text === lastHost || text === lastBox) return;
|
||||
if (typeof text !== "string") return;
|
||||
lastBox = text;
|
||||
var output = document.getElementById("lazyboy-clipboard-text");
|
||||
if (output) output.value = text;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(function () {});
|
||||
navigator.clipboard.writeText(text).catch(function () { status("電腦文字已收到,請按「複製到本機」或手動選取文字。"); });
|
||||
}
|
||||
try {
|
||||
if (window.parent && window.parent !== window) {
|
||||
|
|
@ -160,6 +167,12 @@ html, body {
|
|||
} catch (_e) {}
|
||||
}
|
||||
|
||||
function requestBoxClipboard(session) {
|
||||
if (messages && session && (session._clipboardServerCapabilitiesActions || {})[1 << 25]) {
|
||||
messages.extendedClipboardRequest(session._sock, [1]);
|
||||
}
|
||||
}
|
||||
|
||||
function copyBox(text) {
|
||||
writeHost(text);
|
||||
}
|
||||
|
|
@ -172,8 +185,17 @@ html, body {
|
|||
lastHost = text;
|
||||
var session = rfb();
|
||||
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);
|
||||
}
|
||||
}
|
||||
var box = clipboardBox();
|
||||
if (box) box.value = text;
|
||||
if (typeKey && session && typeof session.sendKey === "function") {
|
||||
|
|
@ -194,7 +216,7 @@ html, body {
|
|||
done(text || "");
|
||||
}
|
||||
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;
|
||||
finish(typeof event.data.text === "string" ? event.data.text : "");
|
||||
};
|
||||
|
|
@ -242,11 +264,8 @@ html, body {
|
|||
}
|
||||
|
||||
function pasteHost(typeKey) {
|
||||
var now = Date.now();
|
||||
if (now - lastGesture < 200 && !typeKey) return;
|
||||
lastGesture = now;
|
||||
readHostText(function (text) {
|
||||
if (!text || text === lastBox) return;
|
||||
if (!text) return;
|
||||
applyHostPaste(text, typeKey);
|
||||
});
|
||||
}
|
||||
|
|
@ -260,7 +279,9 @@ html, body {
|
|||
var text = event && event.detail && event.detail.text;
|
||||
if (typeof text === "string") copyBox(text);
|
||||
});
|
||||
session.addEventListener("disconnect", function() { resetMobilePointer(); });
|
||||
session.addEventListener("connect", function () {
|
||||
resetMobilePointer();
|
||||
hideChrome();
|
||||
fillCanvas(session);
|
||||
});
|
||||
|
|
@ -270,23 +291,201 @@ html, body {
|
|||
hideChrome();
|
||||
bindRfb(rfb());
|
||||
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);
|
||||
window.addEventListener("load", hideChrome);
|
||||
window.addEventListener("focus", function () { pasteHost(false); });
|
||||
document.addEventListener("mousedown", function () { pasteHost(false); }, true);
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.visibilityState === "visible") pasteHost(false);
|
||||
});
|
||||
if (typeof MutationObserver === "function") {
|
||||
new MutationObserver(hideChrome).observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
subtree: true
|
||||
});
|
||||
// Same interaction as LazyBoy's viewer, adapted to this noVNC version's
|
||||
// scaled-coordinate _sendMouse(x, y, buttonMask) API.
|
||||
function mountPointerControls(tools) {
|
||||
var pad = tools.querySelector("#lazyboy-trackpad");
|
||||
var modeButton = tools.querySelector('[data-action="mode"]');
|
||||
var dragButton = tools.querySelector('[data-action="drag"]');
|
||||
var cursor = document.createElement("div"); cursor.id = "lazyboy-cursor"; document.body.appendChild(cursor);
|
||||
var trackpad = true, dragging = false, pointer = { x:.5, y:.5 };
|
||||
var points = new Map(), gesture = null, scrollX = 0, scrollY = 0;
|
||||
function usable() { var r = rfb(); return r && r._rfbConnectionState === "connected" && !r.viewOnly && !r._viewOnly; }
|
||||
function geometry() { var r = rfb(); return r && r._canvas && r._canvas.getBoundingClientRect(); }
|
||||
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);
|
||||
|
||||
function sendCtrlChord(session, keysym, code, shift) {
|
||||
|
|
@ -308,6 +507,7 @@ html, body {
|
|||
}
|
||||
|
||||
document.addEventListener("paste", function (e) {
|
||||
if (e.target.closest && e.target.closest("#lazyboy-clipboard")) return;
|
||||
var text = e.clipboardData && e.clipboardData.getData("text/plain");
|
||||
if (!text) return;
|
||||
e.preventDefault();
|
||||
|
|
@ -316,6 +516,7 @@ html, body {
|
|||
}, true);
|
||||
|
||||
document.addEventListener("copy", function (e) {
|
||||
if (e.target.closest && e.target.closest("#lazyboy-clipboard")) return;
|
||||
var box = clipboardBox();
|
||||
var text = box && box.value;
|
||||
if (!text) return;
|
||||
|
|
@ -327,6 +528,7 @@ html, body {
|
|||
}, true);
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.target.closest && e.target.closest("#lazyboy-clipboard")) return;
|
||||
var mac = /Mac/i.test((navigator && navigator.platform) || "");
|
||||
var chord = mac ? e.metaKey : e.ctrlKey;
|
||||
if (!chord) return;
|
||||
|
|
@ -343,13 +545,14 @@ html, body {
|
|||
e.preventDefault();
|
||||
sendCtrlChord(session, keysym, e.code, e.shiftKey);
|
||||
if (e.code === "KeyC" || e.code === "KeyX") {
|
||||
setTimeout(function() { requestBoxClipboard(session); }, 150);
|
||||
setTimeout(function () {
|
||||
var box = clipboardBox();
|
||||
if (box && box.value) copyBox(box.value);
|
||||
if (box && box.value && box.value !== lastBox && box.value !== lastHost) copyBox(box.value);
|
||||
}, 80);
|
||||
setTimeout(function () {
|
||||
var box = clipboardBox();
|
||||
if (box && box.value) copyBox(box.value);
|
||||
if (box && box.value && box.value !== lastBox && box.value !== lastHost) copyBox(box.value);
|
||||
}, 220);
|
||||
}
|
||||
}, true);
|
||||
|
|
@ -388,7 +591,7 @@ mod tests {
|
|||
assert!(out.contains("import(\"./app/ui.js\")"));
|
||||
assert!(out.contains("lazyboy-clipboard-write"));
|
||||
assert!(out.contains("#noVNC_connect_dlg"));
|
||||
assert!(out.contains("object-fit: fill"));
|
||||
assert!(out.contains("lazyboy-tools"));
|
||||
assert!(out.ends_with("</html>"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
|
|||
ca_pem: tls.as_ref().map(|m| m.ca_pem.clone()),
|
||||
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()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
@ -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()}
|
||||
|
|
@ -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();}
|
||||
|
|
@ -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();}
|
||||
|
|
@ -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();}
|
||||
|
|
@ -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()}
|
||||
|
|
@ -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(); }
|
||||
|
|
@ -48,13 +48,13 @@ try {
|
|||
});
|
||||
const url = process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173";
|
||||
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("這是會留下來的回覆"));
|
||||
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);
|
||||
await page.reload();
|
||||
await page.getByText("這是會留下來的回覆", { exact: true }).waitFor();
|
||||
assert.equal(await page.getByText("這是會留下來的回覆", { exact: true }).count(), 1);
|
||||
await page.locator(".messages").getByText("這是會留下來的回覆", { exact: true }).waitFor();
|
||||
assert.equal(await page.locator(".messages").getByText("這是會留下來的回覆", { exact: true }).count(), 1);
|
||||
assert.equal(await page.getByText("這段 scratchpad 不該出現在聊天裡").count(), 0);
|
||||
console.log("PASS transcript stays the public snapshot across events and reload");
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
<meta name="apple-mobile-web-app-title" content="LazyBoy" />
|
||||
<title>LazyBoy</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<link rel="icon" href="/lazyboy-round.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/lazyboy-round.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,10 +11,13 @@
|
|||
"dependencies": {
|
||||
"@blobatar/react": "^2.7.0",
|
||||
"@fontsource/huninn": "^5.3.0",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"blobatar": "^2.7.0",
|
||||
"react": "^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": {
|
||||
"@types/react": "^19.3.0",
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 687 KiB |
|
|
@ -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 |
|
|
@ -7,5 +7,5 @@
|
|||
"orientation": "portrait",
|
||||
"background_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" }]
|
||||
}
|
||||
|
|
|
|||
268
web/src/App.tsx
268
web/src/App.tsx
|
|
@ -1,6 +1,5 @@
|
|||
import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import arrowUp from "react-useanimations/lib/arrowUp";
|
||||
import menu from "react-useanimations/lib/menu";
|
||||
import { FormEvent, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import searchToX from "react-useanimations/lib/searchToX";
|
||||
import { api, type HumanQuestion, type AgentActivity, type AgentRow, type TeamEvent, type TranscriptItem } from "./api";
|
||||
import { AgentSettingsDialog } from "./agent-settings";
|
||||
|
|
@ -27,7 +26,7 @@ function TrashIcon() {
|
|||
}
|
||||
|
||||
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">) {
|
||||
|
|
@ -249,7 +248,7 @@ function visibleTranscript(items: TranscriptItem[]): TranscriptItem[] {
|
|||
}
|
||||
|
||||
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({
|
||||
|
|
@ -257,37 +256,50 @@ const MessageLog = memo(function MessageLog({
|
|||
questionPrompt,
|
||||
locale,
|
||||
agent,
|
||||
scrollRef,
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
transcript: TranscriptItem[];
|
||||
questionPrompt: string | null;
|
||||
locale: Locale;
|
||||
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 [page, setPage] = useState(0);
|
||||
const pageCount = Math.max(1, Math.ceil(transcript.length / 40));
|
||||
const currentPage = Math.min(page, pageCount - 1);
|
||||
const end = Math.max(0, transcript.length - currentPage * 40);
|
||||
const items = transcript.slice(Math.max(0, end - 40), end).filter((item) => !(questionPrompt && item.role === "assistant" && item.content.trim() === questionPrompt));
|
||||
const items = useMemo(() => transcript
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter(({ item }) => !(questionPrompt && item.role === "assistant" && item.content.trim() === questionPrompt)), [transcript, questionPrompt]);
|
||||
const rows = useVirtualizer({
|
||||
count: items.length,
|
||||
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 (
|
||||
<>
|
||||
{pageCount > 1 ? <nav className="history-pages" aria-label={locale.startsWith("zh") ? "對話記錄分頁" : "Conversation pages"}>
|
||||
<button type="button" disabled={currentPage >= pageCount - 1} onClick={() => setPage(currentPage + 1)}>← {locale.startsWith("zh") ? "較早訊息" : "Older"}</button>
|
||||
<span>{currentPage + 1} / {pageCount}</span>
|
||||
<button type="button" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>{locale.startsWith("zh") ? "較新訊息" : "Newer"} →</button>
|
||||
{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];
|
||||
<div className="message-window" style={{ height: rows.getTotalSize(), position: "relative", width: "100%" }}>
|
||||
{rows.getVirtualItems().map((row) => {
|
||||
const { item, index } = items[row.index];
|
||||
const prev = items[row.index - 1]?.item;
|
||||
const next = items[row.index + 1]?.item;
|
||||
const newDay = Boolean(item.at && !sameLocalDay(item.at, prev?.at));
|
||||
const clusterStart = newDay || !sameChatCluster(prev, item);
|
||||
const clusterEnd = !sameChatCluster(item, next);
|
||||
const showTime = Boolean(item.at) && clusterEnd;
|
||||
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" : ""}`}
|
||||
key={`${item.role}-${item.at || i}-${i}`}
|
||||
key={`${item.role}-${item.at || index}-${index}`}
|
||||
>
|
||||
{newDay && item.at ? (
|
||||
<div className="day-divider">
|
||||
|
|
@ -321,9 +333,10 @@ const MessageLog = memo(function MessageLog({
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}, (prev, next) => (
|
||||
prev.transcript === next.transcript &&
|
||||
|
|
@ -345,7 +358,17 @@ export function App() {
|
|||
return () => document.removeEventListener("visibilitychange", update);
|
||||
}, []);
|
||||
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 [workingStep, setWorkingStep] = useState<StepKey | null>(null);
|
||||
const [question, setQuestion] = useState<QuestionCard | null>(null);
|
||||
|
|
@ -355,8 +378,7 @@ export function App() {
|
|||
const handoverSeen = useRef<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [rightOpen, setRightOpen] = useState(true);
|
||||
const [rightOpen, setRightOpen] = useState(() => !isPhone());
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
const [computerUrl, setComputerUrl] = useState("");
|
||||
const [computerStatus, setComputerStatus] = useState("");
|
||||
|
|
@ -370,6 +392,9 @@ export function App() {
|
|||
const [typing, setTyping] = useState(false);
|
||||
const [activityState, setActivityState] = useState<AgentActivity["state"] | "unknown">("idle");
|
||||
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 transcriptGeneration = useRef(0);
|
||||
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 [settingsAgent, setSettingsAgent] = useState<AgentRow | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [mobileSearch, setMobileSearch] = useState(false);
|
||||
const accountRef = useRef<HTMLDivElement>(null);
|
||||
const createMenuRef = useRef<HTMLDivElement>(null);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -444,7 +470,9 @@ export function App() {
|
|||
const loadAgents = useCallback(() => {
|
||||
if (rosterRequest.current) return rosterRequest.current;
|
||||
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));
|
||||
return next;
|
||||
}).finally(() => { rosterRequest.current = null; });
|
||||
|
|
@ -453,6 +481,19 @@ export function App() {
|
|||
}, []);
|
||||
|
||||
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));
|
||||
setTyping(data.state === "running");
|
||||
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 copy = tRef.current;
|
||||
const target = id || activeIdRef.current;
|
||||
if (!target) return;
|
||||
const op = `${target}:${action}`;
|
||||
if (computerOpRef.current === op) return;
|
||||
if (action === "restart" && !window.confirm(copy.restartConfirm)) return;
|
||||
|
|
@ -598,7 +638,11 @@ export function App() {
|
|||
setActiveId(id);
|
||||
if (!keepChannel) setActiveChannelId(null);
|
||||
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("");
|
||||
setExecutionDetails([]);
|
||||
setError("");
|
||||
|
|
@ -606,9 +650,9 @@ export function App() {
|
|||
setComputerReady(false);
|
||||
setComputerUrl("");
|
||||
setImageFresh(false);
|
||||
setComputerBusy("start");
|
||||
setComputerStatus(tRef.current.computerStarting);
|
||||
void runComputer("start", id);
|
||||
setComputerBusy(isPhone() ? null : "start");
|
||||
setComputerStatus(isPhone() ? "" : tRef.current.computerStarting);
|
||||
if (!isPhone()) void runComputer("start", id);
|
||||
try {
|
||||
const data = await api.agent(id, controller.signal);
|
||||
if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return;
|
||||
|
|
@ -654,6 +698,7 @@ export function App() {
|
|||
useEffect(() => {
|
||||
loadAgents()
|
||||
.then((list) => {
|
||||
if (isPhone()) return;
|
||||
const remembered = localStorage.getItem("lazyboy.agent");
|
||||
const pick = list.find((a) => a.id === remembered || a.name === remembered) || list[0];
|
||||
if (pick) return openAgent(pick.id);
|
||||
|
|
@ -661,6 +706,52 @@ export function App() {
|
|||
.catch((err) => setError(String(err.message)));
|
||||
}, [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";
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (rightOpen || overlayOpen) void ensureComputer();
|
||||
}, [rightOpen, overlayOpen, ensureComputer]);
|
||||
|
|
@ -697,15 +806,16 @@ export function App() {
|
|||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
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;
|
||||
if (!data || typeof data !== "object") return;
|
||||
const source = event.source as Window | null;
|
||||
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) {
|
||||
void navigator.clipboard
|
||||
.readText()
|
||||
void (navigator.clipboard?.readText() ?? Promise.reject(new Error("Clipboard unavailable")))
|
||||
.then((text) => 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 */ };
|
||||
void refreshActivity(sourceAgent);
|
||||
const poll = window.setInterval(() => { void refreshActivity(sourceAgent); }, 2000);
|
||||
const rosterPoll = window.setInterval(() => { loadAgents().catch(() => undefined); }, 4000);
|
||||
return () => {
|
||||
source.close();
|
||||
transcriptRequest.current?.controller.abort();
|
||||
window.clearInterval(poll);
|
||||
window.clearInterval(rosterPoll);
|
||||
};
|
||||
}, [activeId, pageVisible, loadAgents, refreshActivity, syncTranscript]);
|
||||
|
||||
|
|
@ -824,7 +932,13 @@ export function App() {
|
|||
if (activeChannelId && !nextChannels.some((channel) => channel.id === activeChannelId)) setActiveChannelId(null);
|
||||
if (activeId === agent.id || active?.name === agent.name) {
|
||||
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);
|
||||
} else {
|
||||
setActiveId("");
|
||||
|
|
@ -854,6 +968,7 @@ export function App() {
|
|||
setCreateOpen(true);
|
||||
return;
|
||||
}
|
||||
stoppedTasks.current.delete(id);
|
||||
if (fromComposer) setDraft("");
|
||||
stickToBottom.current = true;
|
||||
setTranscript((cur) => {
|
||||
|
|
@ -884,13 +999,26 @@ export function App() {
|
|||
|
||||
async function onStop() {
|
||||
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 {
|
||||
await api.stop(id);
|
||||
activityGeneration.current += 1;
|
||||
await refreshActivity(id);
|
||||
} catch (err) {
|
||||
stoppedTasks.current.delete(id);
|
||||
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 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 computerLive = computerBusy
|
||||
? t[computerBusyKey(computerBusy)]
|
||||
|
|
@ -941,16 +1069,12 @@ export function App() {
|
|||
|
||||
return (
|
||||
<AvatarLookProvider value={looks}>
|
||||
<div className={`app-shell ${showStage ? "right-open stage-open" : "right-collapsed"}`}>
|
||||
{sidebarOpen ? <button type="button" className="mobile-nav-backdrop" aria-label={t.close} onClick={() => setSidebarOpen(false)} /> : null}
|
||||
<div className={`app-shell ${showStage ? "right-open stage-open" : "right-collapsed"} mobile-${mobilePage}`} data-mobile={phone || undefined}>
|
||||
|
||||
<aside id="conversation-sidebar" className={`sidebar ${sidebarOpen ? "open" : ""}`}>
|
||||
<aside id="conversation-sidebar" className="sidebar" aria-label={t.chats}>
|
||||
<div className="brand">
|
||||
<img className="brand-icon" src="/lazyboy-icon.png" alt="" />
|
||||
<span>LazyBoy</span>
|
||||
<button type="button" className="icon-button mobile-nav-close" aria-label={t.close} onClick={() => setSidebarOpen(false)}>
|
||||
<X />
|
||||
</button>
|
||||
{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></>}
|
||||
{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}
|
||||
<div className="create-menu-wrap" ref={createMenuRef} onClick={(event) => event.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -991,10 +1115,11 @@ export function App() {
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<label className="search">
|
||||
{!phone || mobileSearch ? <label className="search">
|
||||
<UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)" />
|
||||
<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">
|
||||
{visibleChannels.length > 0 ? (
|
||||
<section className="bot-group">
|
||||
|
|
@ -1023,7 +1148,7 @@ export function App() {
|
|||
}}
|
||||
>
|
||||
<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 className="bot-copy">
|
||||
<strong>{channel.name}</strong>
|
||||
|
|
@ -1052,15 +1177,15 @@ export function App() {
|
|||
}}
|
||||
>
|
||||
<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 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>
|
||||
</span>
|
||||
<span className="bot-trailing">
|
||||
{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>
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -1097,14 +1222,12 @@ export function App() {
|
|||
<button
|
||||
type="button"
|
||||
className={`account ${accountOpen ? "open" : ""}`}
|
||||
title={t.localWorkspace}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountOpen}
|
||||
onClick={() => setAccountOpen((open) => !open)}
|
||||
title={phone ? t.settings : t.localWorkspace}
|
||||
aria-haspopup={phone ? "dialog" : "menu"}
|
||||
aria-expanded={phone ? accountOverlay === "settings" : accountOpen}
|
||||
onClick={() => phone ? setAccountOverlay("settings") : setAccountOpen((open) => !open)}
|
||||
>
|
||||
<span className="workspace-avatar">GB</span>
|
||||
<span>{t.localWorkspace}</span>
|
||||
<ChevronDown className="chevron" />
|
||||
{phone ? <><Settings /><span>{t.settings}</span></> : <><span className="workspace-avatar">GB</span><span>{t.localWorkspace}</span><ChevronDown className="chevron" /></>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1112,8 +1235,8 @@ export function App() {
|
|||
|
||||
<main className="chat-panel">
|
||||
<header className="topbar">
|
||||
<button type="button" className="icon-button mobile-menu" aria-label={t.pickAgent} onClick={() => setSidebarOpen((open) => !open)}>
|
||||
<UseAnimations animation={menu} size={18} strokeColor="var(--ink)" />
|
||||
<button type="button" className="icon-button mobile-menu" aria-label={t.backToChats} onClick={backToChats}>
|
||||
<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>
|
||||
{activeChannel && channelMembers.length > 0 ? (
|
||||
<>
|
||||
|
|
@ -1164,6 +1287,7 @@ export function App() {
|
|||
) : null}
|
||||
<MessageLog
|
||||
key={activeId}
|
||||
scrollRef={scroller}
|
||||
transcript={transcript}
|
||||
questionPrompt={question?.prompt || null}
|
||||
locale={locale}
|
||||
|
|
@ -1245,7 +1369,9 @@ export function App() {
|
|||
|
||||
<section className={`meeting-stage ${showStage ? "" : "is-hidden"}`}>
|
||||
<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)}>
|
||||
<ChevronsRight />
|
||||
</button>
|
||||
|
|
@ -1253,6 +1379,7 @@ export function App() {
|
|||
<div className="side-card-body">
|
||||
<div className="computer-part">
|
||||
<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>
|
||||
</section>
|
||||
|
|
@ -1361,17 +1488,20 @@ export function App() {
|
|||
|
||||
{accountOverlay === "settings" ? (
|
||||
<SettingsDialog
|
||||
onClose={() => setAccountOverlay(null)}
|
||||
mobile={phone}
|
||||
onAbout={() => setAccountOverlay("about")}
|
||||
onClose={closeAccountSettings}
|
||||
computer={{
|
||||
pending: computerBusy,
|
||||
working,
|
||||
upToDate: imageFresh,
|
||||
status: computerStatus,
|
||||
onUpdate: () => void runComputer("update"),
|
||||
onRestart: () => void runComputer("restart"),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{accountOverlay === "about" ? <AboutDialog onClose={() => setAccountOverlay(null)} /> : null}
|
||||
{accountOverlay === "about" ? <AboutDialog mobile={phone} onClose={() => phone ? setAccountOverlay("settings") : setAccountOverlay(null)} /> : null}
|
||||
{createOpen ? (
|
||||
<AgentSettingsDialog
|
||||
mode="create"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type AgentActivity = {
|
|||
queued: boolean;
|
||||
observed_at_ms: number;
|
||||
active_task_ids: string[];
|
||||
queued_task_ids?: string[];
|
||||
question?: HumanQuestion | null;
|
||||
};
|
||||
|
||||
|
|
@ -124,7 +125,7 @@ export const api = {
|
|||
updated?: boolean;
|
||||
revision?: string;
|
||||
crowded?: boolean;
|
||||
}>(`/api/agents/${encodeURIComponent(id)}/computer`, {
|
||||
}>(id ? `/api/agents/${encodeURIComponent(id)}/computer` : "/api/computer", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action }),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -101,7 +101,9 @@ const zhHant = {
|
|||
agentWorking: "工作中",
|
||||
newChat: "新對話",
|
||||
localWorkspace: "本機工作區",
|
||||
pickAgent: "選擇 Agent",
|
||||
chats: "聊天",
|
||||
backToChats: "返回聊天列表",
|
||||
pickAgent: "選擇聊天對象",
|
||||
workTools: "工作工具",
|
||||
stop: "停止",
|
||||
computer: "電腦",
|
||||
|
|
@ -239,7 +241,9 @@ const zhHans: Messages = {
|
|||
agentWorking: "工作中",
|
||||
newChat: "新对话",
|
||||
localWorkspace: "本地工作区",
|
||||
pickAgent: "选择 Agent",
|
||||
chats: "聊天",
|
||||
backToChats: "返回聊天列表",
|
||||
pickAgent: "选择聊天对象",
|
||||
workTools: "工作工具",
|
||||
stop: "停止",
|
||||
computer: "电脑",
|
||||
|
|
@ -374,7 +378,9 @@ const en: Messages = {
|
|||
agentWorking: "Working",
|
||||
newChat: "New chat",
|
||||
localWorkspace: "Local workspace",
|
||||
pickAgent: "Choose an agent",
|
||||
chats: "Chats",
|
||||
backToChats: "Back to chats",
|
||||
pickAgent: "Choose who to chat with",
|
||||
workTools: "Work tools",
|
||||
stop: "Stop",
|
||||
computer: "Computer",
|
||||
|
|
@ -509,7 +515,9 @@ const ja: Messages = {
|
|||
agentWorking: "作業中",
|
||||
newChat: "新しい会話",
|
||||
localWorkspace: "ローカルワークスペース",
|
||||
pickAgent: "Agent を選ぶ",
|
||||
chats: "チャット",
|
||||
backToChats: "チャット一覧に戻る",
|
||||
pickAgent: "チャット相手を選ぶ",
|
||||
workTools: "作業ツール",
|
||||
stop: "停止",
|
||||
computer: "コンピュータ",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
.message>.message-body.md,.message-stack>.message-body.md{min-width:0;white-space:normal}
|
||||
.message.user{justify-content:flex-end}
|
||||
.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.user>.message-body{width:fit-content;max-width:min(32rem,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 .avatar{width:28px;height:28px}
|
||||
.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 .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}
|
||||
|
|
@ -143,6 +143,6 @@
|
|||
.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}
|
||||
|
||||
.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); }
|
||||
.history-pages button { cursor: pointer; font: inherit; color: inherit; }
|
||||
.history-pages button:disabled { opacity: .4; cursor: default; }
|
||||
/* The virtualizer owns scroll anchoring; recycled rows must not become browser anchors. */
|
||||
.messages { overflow-anchor: none; }
|
||||
.message-window > div > .message-block { margin-top: 0; }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
.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}
|
||||
.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>div{display:flex;align-items:center;gap:10px}
|
||||
.computer-overlay .avatar{width:30px;height:30px}
|
||||
|
|
@ -121,3 +121,21 @@
|
|||
.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}
|
||||
}
|
||||
|
||||
/* 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}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
|
||||
/* Mobile conversation drawer and two-row chat header. */
|
||||
.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){
|
||||
.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}
|
||||
|
|
@ -68,3 +68,78 @@
|
|||
.topbar .call-entry>.top-tool-button{width:100%}
|
||||
.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}
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@
|
|||
height: 64px;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #181818;
|
||||
background: var(--inset);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
|
|
@ -333,3 +333,58 @@
|
|||
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)}}
|
||||
|
|
|
|||
|
|
@ -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="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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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%)}
|
||||
|
|
@ -14,6 +14,7 @@ import "./lazyboy/computer.css";
|
|||
import "./lazyboy/responsive.css";
|
||||
import "./lazyboy/extra.css";
|
||||
import "./lazyboy/settings.css";
|
||||
import "./lazyboy/theme.css";
|
||||
|
||||
applyTheme(readTheme());
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type SettingsComputer = {
|
|||
pending: "start" | "restart" | "update" | null;
|
||||
working: boolean;
|
||||
upToDate: boolean;
|
||||
status?: string;
|
||||
onUpdate: () => void;
|
||||
onRestart: () => void;
|
||||
};
|
||||
|
|
@ -181,6 +182,7 @@ function UpdatesPanel({ computer }: { computer?: SettingsComputer }) {
|
|||
</span>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
{computer?.status ? <p className="computer-settings-status" role="status">{computer.status}</p> : null}
|
||||
{computer ? (
|
||||
<SettingsGroup title={t.appComputer}>
|
||||
{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 [section, setSection] = useState<SettingsSectionId>("general");
|
||||
const headingId = `lb-settings-heading-${section}`;
|
||||
const panelId = `lb-settings-panel-${section}`;
|
||||
const sectionLabel = section === "general" ? t.general : t.updates;
|
||||
if (mobile) return <MobileSettings onClose={onClose} onAbout={onAbout} computer={computer} />;
|
||||
|
||||
return (
|
||||
<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 [copied, setCopied] = useState(false);
|
||||
const [copyGeneration, setCopyGeneration] = useState(0);
|
||||
|
|
@ -288,13 +328,14 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
|
|||
};
|
||||
|
||||
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}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
<div className="lb-about-brand">
|
||||
<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>
|
||||
<div className="lb-about-heading">
|
||||
<h2>{APP_NAME}</h2>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default defineConfig(({ mode }) => {
|
|||
};
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: { dedupe: ["react", "react-dom"] },
|
||||
define: {
|
||||
"import.meta.env.LAZYBOY_VERSION": JSON.stringify(version),
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue