diff --git a/CHANGELOG.md b/CHANGELOG.md index ff4095aed..aa1eee46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman - Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete +- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro diff --git a/docs/images/mobile-ui-screenshot.png b/docs/images/mobile-ui-screenshot.png new file mode 100644 index 000000000..68af07b8a Binary files /dev/null and b/docs/images/mobile-ui-screenshot.png differ diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index c4c67945a..c12b1e543 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -56,7 +56,11 @@ async function withTempHome(fn: (home: string) => Promise): Promise { const previousUserProfile = process.env.USERPROFILE; const previousHomeDrive = process.env.HOMEDRIVE; const previousHomePath = process.env.HOMEPATH; + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousClawdisStateDir = process.env.CLAWDIS_STATE_DIR; process.env.HOME = base; + process.env.CLAWDBOT_STATE_DIR = join(base, ".clawdbot"); + process.env.CLAWDIS_STATE_DIR = join(base, ".clawdbot"); if (process.platform === "win32") { process.env.USERPROFILE = base; const driveMatch = base.match(/^([A-Za-z]:)(.*)$/); @@ -74,6 +78,8 @@ async function withTempHome(fn: (home: string) => Promise): Promise { process.env.USERPROFILE = previousUserProfile; process.env.HOMEDRIVE = previousHomeDrive; process.env.HOMEPATH = previousHomePath; + process.env.CLAWDBOT_STATE_DIR = previousStateDir; + process.env.CLAWDIS_STATE_DIR = previousClawdisStateDir; await fs.rm(base, { recursive: true, force: true }); } } diff --git a/src/infra/bridge/server.test.ts b/src/infra/bridge/server.test.ts index c60de4ca5..fe320d8f1 100644 --- a/src/infra/bridge/server.test.ts +++ b/src/infra/bridge/server.test.ts @@ -46,6 +46,14 @@ function sendLine(socket: net.Socket, obj: unknown) { socket.write(`${JSON.stringify(obj)}\n`); } +async function waitForSocketConnect(socket: net.Socket) { + if (!socket.connecting) return; + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); +} + describe("node bridge server", () => { let baseDir = ""; @@ -156,6 +164,7 @@ describe("node bridge server", () => { }); const socket = net.connect({ host: "127.0.0.1", port: server.port }); + await waitForSocketConnect(socket); const readLine = createLineReader(socket); sendLine(socket, { type: "pair-request", nodeId: "n2", platform: "ios" }); @@ -189,6 +198,7 @@ describe("node bridge server", () => { socket.destroy(); const socket2 = net.connect({ host: "127.0.0.1", port: server.port }); + await waitForSocketConnect(socket2); const readLine2 = createLineReader(socket2); sendLine(socket2, { type: "hello", nodeId: "n2", token }); const line3 = JSON.parse(await readLine2()) as { type: string }; @@ -239,6 +249,7 @@ describe("node bridge server", () => { }); const socket = net.connect({ host: "127.0.0.1", port: server.port }); + await waitForSocketConnect(socket); const readLine = createLineReader(socket); sendLine(socket, { type: "pair-request", @@ -248,7 +259,7 @@ describe("node bridge server", () => { // Approve the pending request from the gateway side. let reqId: string | undefined; - for (let i = 0; i < 40; i += 1) { + for (let i = 0; i < 120; i += 1) { const list = await listNodePairing(baseDir); const req = list.pending.find((p) => p.nodeId === "n3-rpc"); if (req) { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index df6f39c90..1e42dbbfb 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -363,3 +363,277 @@ grid-template-columns: 1fr; } } + +/* Mobile-specific improvements */ +@media (max-width: 600px) { + .shell { + --shell-pad: 8px; + --shell-gap: 8px; + } + + /* Compact topbar for mobile */ + .topbar { + padding: 10px 12px; + border-radius: 12px; + gap: 8px; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + } + + .brand { + flex: 1; + min-width: 0; + } + + .brand-title { + font-size: 15px; + letter-spacing: 0.3px; + } + + .brand-sub { + display: none; + } + + .topbar-status { + gap: 6px; + width: auto; + flex-wrap: nowrap; + } + + .topbar-status .pill { + padding: 4px 8px; + font-size: 11px; + gap: 4px; + } + + .topbar-status .pill .mono { + display: none; + } + + .topbar-status .pill span:nth-child(2) { + display: none; + } + + /* Horizontal scrollable nav for mobile */ + .nav { + padding: 8px; + border-radius: 12px; + gap: 8px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .nav::-webkit-scrollbar { + display: none; + } + + .nav-group { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 6px; + margin-bottom: 0; + flex-shrink: 0; + } + + .nav-label { + display: none; + } + + .nav-item { + padding: 7px 10px; + font-size: 12px; + border-radius: 8px; + white-space: nowrap; + flex-shrink: 0; + } + + .nav-item::before { + display: none; + } + + /* Hide page title on mobile - nav already shows where you are */ + .content-header { + display: none; + } + + .content { + padding: 4px 4px 16px; + gap: 12px; + } + + /* Smaller cards on mobile */ + .card { + padding: 12px; + border-radius: 12px; + } + + .card-title { + font-size: 14px; + } + + /* Stat grid adjustments */ + .stat-grid { + gap: 8px; + grid-template-columns: repeat(2, 1fr); + } + + .stat { + padding: 10px; + border-radius: 10px; + } + + .stat-label { + font-size: 10px; + } + + .stat-value { + font-size: 16px; + } + + /* Notes grid */ + .note-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + /* Form fields */ + .form-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .field input, + .field textarea, + .field select { + padding: 8px 10px; + border-radius: 10px; + font-size: 14px; + } + + /* Buttons */ + .btn { + padding: 8px 12px; + font-size: 13px; + } + + /* Pills */ + .pill { + padding: 4px 10px; + font-size: 12px; + } + + /* Chat-specific mobile improvements */ + .chat-header { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .chat-header__left { + flex-direction: column; + align-items: stretch; + } + + .chat-header__right { + justify-content: space-between; + } + + .chat-session { + min-width: unset; + width: 100%; + } + + .chat-thread { + margin-top: 8px; + padding: 10px 8px; + border-radius: 12px; + } + + .chat-msg { + max-width: 92%; + } + + .chat-bubble { + padding: 8px 10px; + border-radius: 12px; + } + + .chat-compose { + gap: 8px; + } + + .chat-compose__field textarea { + min-height: 60px; + padding: 8px 10px; + border-radius: 12px; + font-size: 14px; + } + + /* Log stream mobile */ + .log-stream { + border-radius: 10px; + max-height: 400px; + } + + .log-row { + grid-template-columns: 1fr; + gap: 4px; + padding: 8px; + } + + .log-time { + font-size: 10px; + } + + .log-level { + font-size: 9px; + } + + .log-subsystem { + font-size: 11px; + } + + .log-message { + font-size: 12px; + } + + /* Hide docs link on mobile - saves space */ + .docs-link { + display: none; + } + + /* List items */ + .list-item { + padding: 10px; + border-radius: 10px; + } + + .list-title { + font-size: 14px; + } + + .list-sub { + font-size: 11px; + } + + /* Code blocks */ + .code-block { + padding: 8px; + border-radius: 10px; + font-size: 11px; + } + + /* Theme toggle smaller */ + .theme-toggle { + --theme-item: 24px; + --theme-gap: 4px; + --theme-pad: 4px; + } + + .theme-icon { + width: 14px; + height: 14px; + } +}