diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fb0da..da7f446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.23] - 2026-07-28 + +### Added + +- Admins now receive a one-time **Later** / **Restart Now** prompt when a + desktop update has finished downloading and verification, without + needing to open Profile or manually check first. +- Fresh macOS and Windows installations can connect to an existing 1Helm + workspace with the clean `[workspace].1helm.com` gateway or its alternate + HTTPS URL flow. **New User?** reveals the explicit option to set the current + PC up as a new 1Helm server, while configured desktop servers continue to + open their existing local workspace normally and Linux stays headless. A + client-only desktop does not create a second local server or server login + item behind the connection screen. +- The live Routes graphic includes one collapsed **Custom** provider node and + illuminates its line when any custom OpenAI-compatible endpoint handles a + request, while the request details retain the actual endpoint name. + +### Fixed + +- Presentations fit the entire dotted printable boundary into view whenever a + deck opens or a slide is selected, created, or duplicated, without changing + the slide's printable dimensions, content, persistence, or PDF export. +- The Cowork agent is available from a section or nested folder before a file + is opened, and a new chat receives that exact `/workspace` folder path just + as file-scoped chats receive their exact file path. +- Long Cowork Code files scroll inside their finite editor viewport instead of + extending below the visible canvas. +- The Android and iOS connection shell releases its native splash after its + first paint, uses the real 1Helm artwork, and defaults to the same clean + workspace-name connection flow. + ## [0.0.22] - 2026-07-28 ### Added @@ -700,7 +732,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.22...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.23...HEAD +[0.0.23]: https://github.com/gitcommit90/1Helm/compare/v0.0.22...v0.0.23 [0.0.22]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.22 [0.0.21]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.21 [0.0.20]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.20 diff --git a/README.md b/README.md index 53f1ee7..4ae9369 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.22` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.23` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/desktop/gateway.html b/desktop/gateway.html new file mode 100644 index 0000000..e7d04f8 --- /dev/null +++ b/desktop/gateway.html @@ -0,0 +1,145 @@ + + + + + + + + Connect to 1Helm + + + +
+ 1Helm +

Connect to 1Helm

+

Enter your workspace name.

+
+ +
+ + .1helm.com +
+ + + +
+
+ + +
+ Your saved workspace opens here next time. Other links stay outside the app. +
+ + + diff --git a/desktop/main.cjs b/desktop/main.cjs index 073c615..5b38adf 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -8,6 +8,7 @@ const crypto = require("node:crypto"); const fs = require("node:fs"); const { spawnSync } = require("node:child_process"); const { createNativeUpdateService } = require("./updater.cjs"); +const { allowedRemoteUrl, desktopGatewayAction, isHostedWorkspaceOrigin, normalizeRemoteOrigin } = require("./workspace-target.cjs"); const LOOPBACK = "127.0.0.1"; let mainWindow = null; @@ -16,6 +17,23 @@ let localOrigin = ""; let quitting = false; let hostUpdateService = null; const remoteWorkspacePath = () => path.join(app.getPath("userData"), "remote-workspace"); +const desktopModePath = () => path.join(app.getPath("userData"), "desktop-mode"); +const localDatabasePath = () => path.join(app.getPath("userData"), "ctrl-pane.db"); + +function desktopMode() { + try { + const mode = fs.readFileSync(desktopModePath(), "utf8").trim(); + if (["client", "server"].includes(mode)) return mode; + } catch { /* older installations have no explicit mode */ } + if (fs.existsSync(remoteWorkspacePath())) return "client"; + if (fs.existsSync(localDatabasePath())) return "server"; + return "choose"; +} + +function rememberDesktopMode(mode) { + if (!["client", "server"].includes(mode)) return; + fs.writeFileSync(desktopModePath(), `${mode}\n`, { mode: 0o600 }); +} function handleSquirrelEvent() { if (process.platform !== "win32") return false; @@ -38,17 +56,20 @@ function handleSquirrelEvent() { } function preferredWorkspaceOrigin() { + if (desktopMode() !== "client") return localOrigin; try { const value = fs.readFileSync(remoteWorkspacePath(), "utf8").trim(); - return allowedTeamUrl(value) ? new URL(value).origin : localOrigin; + return normalizeRemoteOrigin(value); } catch { - return localOrigin; + return ""; } } function rememberTeamUrl(raw) { - if (!allowedTeamUrl(raw)) return; - fs.writeFileSync(remoteWorkspacePath(), new URL(raw).origin + "\n", { mode: 0o600 }); + const origin = normalizeRemoteOrigin(raw); + if (!origin) return; + fs.writeFileSync(remoteWorkspacePath(), origin + "\n", { mode: 0o600 }); + rememberDesktopMode("client"); } process.on("1helm-removal-prepared", () => { @@ -117,6 +138,10 @@ function keepSkipperAvailable() { app.setLoginItemSettings({ openAtLogin: true, type: "mainAppService" }); } +function stopAutomaticServerStartup() { + app.setLoginItemSettings({ openAtLogin: false, type: "mainAppService" }); +} + function prepareWindowsWslDataRoot() { if (process.platform !== "win32") return; // Per-channel virtual disks stay outside the replaceable application @@ -134,15 +159,60 @@ function allowedLocalUrl(raw) { } function allowedTeamUrl(raw) { + return allowedRemoteUrl(raw, preferredWorkspaceOrigin() === localOrigin ? "" : preferredWorkspaceOrigin()); +} + +const allowedAppUrl = (raw) => allowedLocalUrl(raw) || allowedTeamUrl(raw); + +async function connectRemoteWorkspace(window, origin) { try { - const url = new URL(raw); - return url.protocol === "https:" && /^[a-z0-9](?:[a-z0-9-]{1,46}[a-z0-9])?\.1helm\.com$/i.test(url.hostname) && !["demo.1helm.com", "provision.1helm.com"].includes(url.hostname.toLowerCase()); - } catch { - return false; + const response = await fetch(`${origin}/api/mobile/compatibility`, { signal: AbortSignal.timeout(15_000) }); + const result = await response.json().catch(() => ({})); + if (!response.ok || result.product !== "1Helm" || result.mobile_api !== 1) throw new Error("That address is not a compatible 1Helm instance."); + if (!result.has_users || !result.setup_complete) throw new Error("Finish setting up this 1Helm instance before connecting the app."); + rememberTeamUrl(origin); + await window.loadURL(origin); + } catch (error) { + await loadDesktopGateway(window, { origin, error: error instanceof Error ? error.message : "Could not connect to this instance." }); } } -const allowedAppUrl = (raw) => allowedLocalUrl(raw) || allowedTeamUrl(raw); +function loadDesktopGateway(window, state = {}) { + return window.loadFile(path.join(__dirname, "gateway.html"), { query: { + ...(state.origin ? { origin: state.origin, custom: isHostedWorkspaceOrigin(state.origin) ? "0" : "1" } : {}), + ...(state.error ? { error: state.error } : {}), + } }); +} + +async function loadInitialWorkspace(window) { + const mode = desktopMode(); + if (mode === "server" || process.platform === "linux") { await window.loadURL(localOrigin); return; } + if (mode === "client") { + const preferred = preferredWorkspaceOrigin(); + if (preferred) { await window.loadURL(preferred); return; } + } + await loadDesktopGateway(window); +} + +async function startServerMode(window) { + try { + keepSkipperAvailable(); + prepareWindowsWslDataRoot(); + if (!localOrigin) await startLocalRuntime(); + rememberDesktopMode("server"); + hostUpdateService?.schedule(); + await window.loadURL(localOrigin); + } catch (error) { + stopAutomaticServerStartup(); + await dialog.showMessageBox({ + type: "error", + title: "1Helm could not start", + message: `The local 1Helm runtime could not start on this ${process.platform === "win32" ? "Windows PC" : "Mac"}.`, + detail: error instanceof Error ? error.stack || error.message : String(error), + }); + await loadDesktopGateway(window, { error: "This PC could not start its local 1Helm server." }); + } +} function microphonePermissionAllowed(webContents, permission, details = {}) { const pageUrl = webContents?.getURL?.() || ""; @@ -202,6 +272,13 @@ function createWindow(showWhenReady = true) { return { action: "deny" }; }); window.webContents.on("will-navigate", (event, url) => { + const gatewayAction = desktopGatewayAction(url); + if (gatewayAction) { + event.preventDefault(); + if (gatewayAction.type === "setup") void startServerMode(window); + else void connectRemoteWorkspace(window, gatewayAction.origin); + return; + } if (allowedTeamUrl(url)) { rememberTeamUrl(url); return; } if (allowedLocalUrl(url)) return; event.preventDefault(); @@ -211,7 +288,7 @@ function createWindow(showWhenReady = true) { }); if (showWhenReady) window.once("ready-to-show", () => window.show()); window.on("closed", () => { if (mainWindow === window) mainWindow = null; }); - void window.loadURL(preferredWorkspaceOrigin()); + void loadInitialWorkspace(window); mainWindow = window; } @@ -254,8 +331,6 @@ if (handleSquirrelEvent()) { }); try { removeLegacyWakeLaunchAgent(); - keepSkipperAvailable(); - prepareWindowsWslDataRoot(); hostUpdateService = createNativeUpdateService({ app, autoUpdater }); hostUpdateService.initialize(); globalThis[Symbol.for("1helm.nativeUpdater")] = { @@ -264,8 +339,15 @@ if (handleSquirrelEvent()) { install: hostUpdateService.install, }; process.on("1helm-native-update-ready", () => { hostUpdateService?.commitInstall(); }); - await startLocalRuntime(); - hostUpdateService.schedule(); + const mode = desktopMode(); + if (mode === "server" || process.platform === "linux") { + keepSkipperAvailable(); + prepareWindowsWslDataRoot(); + await startLocalRuntime(); + hostUpdateService.schedule(); + } else { + stopAutomaticServerStartup(); + } const login = app.getLoginItemSettings({ type: "mainAppService" }); createWindow(!login.wasOpenedAtLogin && !process.argv.includes("--1helm-background")); } catch (error) { @@ -279,7 +361,7 @@ if (handleSquirrelEvent()) { } }); - app.on("activate", () => { if (!mainWindow && localOrigin) createWindow(); }); + app.on("activate", () => { if (!mainWindow) createWindow(); }); app.on("window-all-closed", () => { // On macOS 1Helm remains the native scheduler/fleet manager until Cmd-Q. if (process.platform !== "darwin") app.quit(); @@ -290,6 +372,6 @@ if (handleSquirrelEvent()) { quitting = true; // Explicit Quit is respected; the signed main-app login service starts the // local control plane hidden again at the next user login. - process.emit("SIGTERM", "SIGTERM"); + if (localOrigin) process.emit("SIGTERM", "SIGTERM"); }); } diff --git a/desktop/workspace-target.cjs b/desktop/workspace-target.cjs new file mode 100644 index 0000000..e71a842 --- /dev/null +++ b/desktop/workspace-target.cjs @@ -0,0 +1,58 @@ +"use strict"; + +const WORKSPACE_HOST = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.1helm\.com$/i; +const RESERVED_WORKSPACES = new Set(["demo.1helm.com", "provision.1helm.com"]); +const DESKTOP_ACTION_ORIGIN = "https://desktop-action.1helm.invalid"; +const CONNECT_PATH = "/connect"; +const LOCAL_SETUP_PATH = "/setup"; + +function normalizeRemoteOrigin(raw) { + try { + const url = new URL(String(raw || "").trim()); + if (url.protocol !== "https:" || url.username || url.password) return ""; + return url.origin; + } catch { + return ""; + } +} + +function isHostedWorkspaceOrigin(raw) { + const origin = normalizeRemoteOrigin(raw); + if (!origin) return false; + const url = new URL(origin); + return !url.port && WORKSPACE_HOST.test(url.hostname) && !RESERVED_WORKSPACES.has(url.hostname.toLowerCase()); +} + +function allowedRemoteUrl(raw, selectedOrigin = "") { + try { + const url = new URL(raw); + if (url.protocol !== "https:") return false; + if (isHostedWorkspaceOrigin(url.origin)) return true; + return Boolean(selectedOrigin) && url.origin === normalizeRemoteOrigin(selectedOrigin); + } catch { + return false; + } +} + +function desktopGatewayAction(raw) { + try { + const url = new URL(raw); + if (url.origin !== DESKTOP_ACTION_ORIGIN || url.hash || url.username || url.password) return null; + if (url.pathname === LOCAL_SETUP_PATH && !url.search) return { type: "setup" }; + if (url.pathname !== CONNECT_PATH) return null; + const origin = normalizeRemoteOrigin(url.searchParams.get("origin")); + return origin ? { type: "connect", origin } : null; + } catch { + return null; + } +} + +module.exports = { + CONNECT_PATH, + DESKTOP_ACTION_ORIGIN, + LOCAL_SETUP_PATH, + allowedRemoteUrl, + desktopGatewayAction, + isHostedWorkspaceOrigin, + normalizeRemoteOrigin, +}; diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a0f3f69..7799aa0 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -357,7 +357,9 @@ Signed Mac releases are unique patch versions. Profile → Check for updates always operates on the machine hosting the active 1Helm instance. In the native Mac app, Electron downloads and verifies a notarized update ZIP on that Mac and offers **Restart & install** only when it is ready. It does not navigate the -browsing device to a DMG. +browsing device to a DMG. An admin who is signed in when that verified download +finishes receives a one-time **Later** / **Restart Now** prompt; choosing +**Later** leaves the same update ready in Profile. The standard Linux systemd install uses a root-owned updater. 1Helm can request that one fixed operation, but cannot choose an arbitrary URL, command, or target diff --git a/mobile-gateway/1helm-logo.png b/mobile-gateway/1helm-logo.png new file mode 100644 index 0000000..8323e6e Binary files /dev/null and b/mobile-gateway/1helm-logo.png differ diff --git a/mobile-gateway/error.html b/mobile-gateway/error.html index c49b130..9063c66 100644 --- a/mobile-gateway/error.html +++ b/mobile-gateway/error.html @@ -4,5 +4,5 @@ 1Helm unavailable -

Instance unavailable

1Helm could not reach the selected instance. Check its connection, retry, or choose a different instance.

+

Instance unavailable

1Helm could not reach the selected instance. Check its connection, retry, or choose a different instance.

diff --git a/mobile-gateway/index.html b/mobile-gateway/index.html index 3211c0e..8acfc18 100644 --- a/mobile-gateway/index.html +++ b/mobile-gateway/index.html @@ -9,24 +9,33 @@ :root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17191d; background: #f5f3ec; } * { box-sizing: border-box; } body { min-height: 100dvh; margin: 0; padding: max(1.5rem, env(safe-area-inset-top)) 1.25rem max(1.5rem, env(safe-area-inset-bottom)); display: grid; place-items: center; } main { width: min(100%, 25rem); padding: 1.5rem; border: 1px solid #d9d5ca; border-radius: 1rem; background: #fff; box-shadow: 0 24px 60px -42px #000; } - .mark { display: grid; width: 3.5rem; height: 3.5rem; place-items: center; margin: 0 auto 1rem; border-radius: .9rem; background: #17191d; color: #fff; font-size: 1.55rem; } + .mark { display: block; width: 4rem; height: 4rem; margin: 0 auto 1rem; border-radius: 1rem; object-fit: cover; } h1 { margin: 0; font-size: 1.6rem; text-align: center; } p { margin: .6rem 0 1.25rem; color: #68655e; font-size: .9rem; line-height: 1.55; text-align: center; } label { display: block; margin-bottom: .4rem; font-size: .75rem; font-weight: 700; } input, button { width: 100%; min-height: 3rem; border-radius: .65rem; font: inherit; } input { border: 1px solid #c9c5ba; padding: .75rem; background: #fff; color: #17191d; } button { margin-top: .75rem; border: 0; padding: .7rem 1rem; background: #ef5b2a; color: #fff; font-weight: 750; } + .workspace-field { display: flex; min-height: 3rem; overflow: hidden; border: 1px solid #c9c5ba; border-radius: .65rem; background: #fff; } + .workspace-field input { min-width: 0; border: 0; border-radius: 0; text-align: right; outline: none; } + .workspace-field:focus-within { border-color: #ef5b2a; box-shadow: 0 0 0 3px color-mix(in srgb, #ef5b2a 18%, transparent); } + .suffix { display: flex; align-items: center; padding: 0 .8rem 0 0; color: #77736b; font-weight: 650; white-space: nowrap; } + .alternate { min-height: auto; margin-top: .7rem; padding: .25rem; background: transparent; color: #68655e; font-size: .78rem; font-weight: 650; } button:disabled { opacity: .6; } #status { min-height: 1.25rem; margin: .65rem 0 0; color: #a23024; font-size: .78rem; text-align: left; } small { display: block; margin-top: .85rem; color: #77736b; font-size: .7rem; line-height: 1.5; text-align: center; } - @media (prefers-color-scheme: dark) { :root { color: #f4f2eb; background: #111318; } main { border-color: #34373e; background: #191c22; } .mark { background: #f4f2eb; color: #17191d; } p, small { color: #a8a59d; } input { border-color: #3d4149; background: #121419; color: #f4f2eb; } } + @media (prefers-color-scheme: dark) { :root { color: #f4f2eb; background: #111318; } main { border-color: #34373e; background: #191c22; } p, small, .alternate { color: #a8a59d; } input, .workspace-field { border-color: #3d4149; background: #121419; color: #f4f2eb; } .suffix { color: #a8a59d; } }
- + 1Helm

Connect to 1Helm

-

Choose the HTTPS address of an existing 1Helm instance. The app will load that instance’s live interface.

+

Enter your workspace name.

- - + +
+ + .1helm.com +
+
Only the selected HTTPS origin receives native bridge access. Other links open outside the app. @@ -36,20 +45,66 @@

Connect to 1Helm

const form = document.getElementById("connect"); const input = document.getElementById("server"); const submit = document.getElementById("submit"); + const alternate = document.getElementById("alternate"); + const field = document.getElementById("workspace-field"); + const label = document.getElementById("server-label"); + const prompt = document.getElementById("prompt"); const status = document.getElementById("status"); const gateway = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.InstanceGateway; + let customUrl = false; + requestAnimationFrame(() => requestAnimationFrame(() => { + const splash = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.SplashScreen; + if (splash) splash.hide({ fadeOutDuration: 180 }).catch(() => {}); + })); const normalize = (value) => { const raw = value.trim(); const parsed = new URL(raw.includes("://") ? raw : `https://${raw}`); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname !== "/" && parsed.pathname !== "")) throw new Error("Enter an HTTPS root address without a path, credentials, query, or fragment."); return parsed.origin; }; - gateway && gateway.getServer().then(({ origin }) => { if (origin) input.value = origin; }).catch(() => {}); + const workspaceOrigin = (value) => { + const workspace = value.trim().toLowerCase(); + if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(workspace)) throw new Error("Enter your workspace name without .1helm.com."); + return `https://${workspace}.1helm.com`; + }; + const workspaceFromOrigin = (value) => { + try { + const parsed = new URL(value); + const match = parsed.hostname.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.1helm\.com$/i); + return parsed.protocol === "https:" && !parsed.port && parsed.pathname === "/" && !parsed.search && !parsed.hash ? match?.[1] || "" : ""; + } catch { return ""; } + }; + const showCustomUrl = (custom, value = "") => { + customUrl = custom; + field.classList.toggle("workspace-field", !custom); + field.querySelector(".suffix").hidden = custom; + label.textContent = custom ? "Instance address" : "Workspace"; + prompt.textContent = custom ? "Choose the HTTPS address of an existing 1Helm instance." : "Enter your workspace name."; + input.type = custom ? "url" : "text"; + input.placeholder = custom ? "https://your-1helm-server.com" : "your-workspace"; + input.value = value; + alternate.textContent = custom ? "Connect to 1Helm URL?" : "Connect to a different url?"; + status.textContent = ""; + input.focus(); + }; + alternate.addEventListener("click", () => { + if (customUrl) showCustomUrl(false, workspaceFromOrigin(input.value)); + else { + let value = ""; + if (input.value) try { value = workspaceOrigin(input.value); } catch { value = input.value; } + showCustomUrl(true, value); + } + }); + gateway && gateway.getServer().then(({ origin }) => { + if (!origin) return; + const workspace = workspaceFromOrigin(origin); + showCustomUrl(!workspace, workspace || origin); + }).catch(() => {}); form.addEventListener("submit", async (event) => { event.preventDefault(); status.textContent = ""; submit.disabled = true; submit.textContent = "Checking…"; try { if (!gateway) throw new Error("The native connection service is unavailable. Reinstall this 1Helm app."); - const origin = normalize(input.value); + const origin = customUrl ? normalize(input.value) : workspaceOrigin(input.value); const response = await fetch(`${origin}/api/mobile/compatibility`, { cache: "no-store" }); const result = await response.json().catch(() => ({})); if (!response.ok || result.product !== "1Helm" || result.mobile_api !== 1) throw new Error("That address is not a compatible 1Helm instance."); diff --git a/package-lock.json b/package-lock.json index 94c4dd3..a02a669 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.22", + "version": "0.0.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.22", + "version": "0.0.23", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index d4fed57..ec4db8e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.22", + "version": "0.0.23", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/public/index.html b/public/index.html index c8019d9..d23ba90 100644 --- a/public/index.html +++ b/public/index.html @@ -30,12 +30,12 @@ document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - +
- + diff --git a/src/client/app.ts b/src/client/app.ts index fc3fe45..3c7fb11 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -151,6 +151,36 @@ async function loadUiState(): Promise { } const root = document.getElementById("app")!; const pending: { token: string; name: string; mime: string; size: number }[] = []; +type HostUpdate = { mode: "native-macos" | "native-windows" | "linux-systemd" | "source"; status: string; current_version: string; version: string | null; message: string; error: string | null }; +const UPDATE_READY_PROMPT_KEY = "1helm.updateReadyPrompt"; +let updateReadyPoll = 0; + +function maybeShowUpdateReadyPrompt(update: HostUpdate): void { + if (!S.me?.is_admin || update.status !== "ready") return; + const identity = `${update.mode}:${update.current_version}->${update.version || "downloaded"}`; + if (localStorage.getItem(UPDATE_READY_PROMPT_KEY) === identity || document.querySelector("[data-update-ready-prompt]")) return; + localStorage.setItem(UPDATE_READY_PROMPT_KEY, identity); + const version = update.version ? ` v${update.version}` : ""; + const prompt = appModal(`1Helm${version} is ready`, "A new version has been downloaded and verified. Restart 1Helm now to apply the update?", [ + { label: "Later", onClick: () => undefined }, + { label: "Restart Now", primary: true, onClick: () => { + void api("/api/app/update", { method: "POST", body: { action: "install" } }) + .catch((error) => appAlert((error as Error).message || "The update could not be installed.")); + } }, + ]); + prompt.dataset.updateReadyPrompt = identity; +} + +function scheduleHostUpdatePromptChecks(): void { + window.clearTimeout(updateReadyPoll); + if (!S.me?.is_admin) return; + const poll = async (): Promise => { + try { maybeShowUpdateReadyPrompt(await api("/api/app/update")); } + catch { /* Profile retains the visible manual retry path. */ } + updateReadyPoll = window.setTimeout(() => { void poll(); }, 30_000); + }; + updateReadyPoll = window.setTimeout(() => { void poll(); }, 25_000); +} type UiContinuity = { active: { key: string; start: number | null; end: number | null; value: string | null; checked: boolean | null; node: HTMLElement | null } | null; @@ -250,6 +280,7 @@ function writeRoute(channel: Channel | undefined, view: ChannelView, threadRootI // ---------------- boot ---------------- export async function boot(): Promise { + window.clearTimeout(updateReadyPoll); // The native clients are gateways to an existing 1Helm. They never expose // the host setup wizard, even if a remembered address points at a fresh host. if (isNativeMobile() && !getToken()) return renderAuth(); @@ -295,6 +326,7 @@ async function enterWorkspace(preferredChannelId?: number): Promise { else renderApp(); setNativeNotificationNavigation((channelId, rootMessageId) => { void openChannel(channelId, "chat", rootMessageId, true); }); void restoreNativeNotifications(); + scheduleHostUpdatePromptChecks(); if (!S.me.tour_complete && sessionStorage.getItem("1helm.justOnboarded") === "1") { sessionStorage.removeItem("1helm.justOnboarded"); void openWelcomeTour(); @@ -1137,7 +1169,6 @@ function openProfile(anchor: HTMLElement): void { class: "btn-subtle min-h-9 shrink-0 px-3 text-xs", dataset: { profileUpdateAction: "" }, }, "Check for updates") as HTMLButtonElement; - type HostUpdate = { mode: "native-macos" | "native-windows" | "linux-systemd" | "source"; status: string; current_version: string; version: string | null; message: string; error: string | null }; let updatePoll = 0; const applyUpdate = (update: HostUpdate): void => { const version = update.version ? ` · v${update.version}` : ""; @@ -1166,6 +1197,7 @@ function openProfile(anchor: HTMLElement): void { updateButton.textContent = update.status === "current" ? "Check again" : "Check host"; updateButton.onclick = () => { void runUpdateAction("download"); }; } + maybeShowUpdateReadyPrompt(update); }; const checkForUpdates = async (): Promise => { updateButton.disabled = true; @@ -3181,8 +3213,8 @@ function memberAddConfirmation(event: { channelId: number; messageId: number; us } // ---------------- shared atoms ---------------- -function appModal(title: string, body: string | HTMLElement, buttons: { label: string; primary?: boolean; danger?: boolean; onClick: () => void }[]): void { - const overlay = h("div", { class: "modal-overlay fixed inset-0 z-50 grid place-items-end bg-black/55 p-0 sm:place-items-center sm:p-6", onclick: (e: MouseEvent) => { if (e.target === overlay) overlay.remove(); } }, +function appModal(title: string, body: string | HTMLElement, buttons: { label: string; primary?: boolean; danger?: boolean; onClick: () => void }[]): HTMLElement { + const overlay = h("div", { class: "modal-overlay fixed inset-0 z-50 grid place-items-end bg-black/55 p-0 sm:place-items-center sm:p-6", role: "dialog", "aria-modal": "true", "aria-label": title, onclick: (e: MouseEvent) => { if (e.target === overlay) overlay.remove(); } }, h("section", { class: "card mobile-sheet w-full max-w-md overflow-hidden rounded-b-none shadow-2xl sm:rounded-xl" }, h("div", { class: "flex items-start justify-between gap-3 border-b border-line px-4 py-4 sm:px-6" }, h("h2", { class: "font-display text-[1.4rem] leading-tight text-fg" }, title), @@ -3194,6 +3226,7 @@ function appModal(title: string, body: string | HTMLElement, buttons: { label: s onclick: () => { btn.onClick(); overlay.remove(); }, }, btn.label))))); document.body.append(overlay); + return overlay; } export function appAlert(message: string): Promise { diff --git a/src/client/cowork-editors.ts b/src/client/cowork-editors.ts index 747c700..0b5f30d 100644 --- a/src/client/cowork-editors.ts +++ b/src/client/cowork-editors.ts @@ -27,6 +27,7 @@ type ExcalidrawApi = { updateScene: (scene: Record) => void; getAppState: () => Record; getFiles: () => Record; + scrollToContent: (target: readonly unknown[], options: { fitToContent: boolean; animate: boolean; viewportZoomFactor: number }) => void; }; export type MountedEditor = { @@ -140,6 +141,7 @@ export function mountExcalidraw( write: (scene: Scene, content: string) => string; }; exportPdf?: () => void | Promise; + fitToContentElementId?: string; } = {}, ): MountedEditor { const node = h("div", { class: `cowork-excalidraw ${className}`, "aria-label": label, dataset: { excalidrawCanvas: "" } }); @@ -147,6 +149,7 @@ export function mountExcalidraw( let api: ExcalidrawApi | null = null; let applyingRemote = false; let destroyed = false; + let fitFrame = 0; let last = collaboration.scene.get("json") || JSON.stringify({ type: "excalidraw", version: 2, elements: [], appState: {}, files: {} }); const read = options.adapter?.read || parseScene; const write = options.adapter?.write || ((next: Scene) => JSON.stringify({ type: "excalidraw", version: 2, ...next }, null, 2)); @@ -179,7 +182,26 @@ export function mountExcalidraw( collaboration.provider.awareness.on("change", updatePresence); window.addEventListener("themechange", updateTheme); const props: React.ComponentProps = { - excalidrawAPI: (value) => { api = value as unknown as ExcalidrawApi; updatePresence(); }, + excalidrawAPI: (value) => { + api = value as unknown as ExcalidrawApi; + updatePresence(); + if (options.fitToContentElementId) { + node.dataset.initialFit = "pending"; + const fit = (): void => { + if (!api || destroyed) return; + if (!node.isConnected || node.clientWidth < 1 || node.clientHeight < 1) { fitFrame = requestAnimationFrame(fit); return; } + const target = scene.elements.filter((element: any) => element?.id === options.fitToContentElementId); + api.scrollToContent(target.length ? target : scene.elements, { fitToContent: true, animate: false, viewportZoomFactor: 0.88 }); + fitFrame = requestAnimationFrame(() => { + if (!api || destroyed) return; + const zoom = api.getAppState().zoom as { value?: number } | undefined; + node.dataset.initialZoom = String(zoom?.value || ""); + node.dataset.initialFit = "complete"; + }); + }; + fitFrame = requestAnimationFrame(() => { fitFrame = requestAnimationFrame(fit); }); + } + }, initialData: { elements: scene.elements as never, appState: { ...scene.appState, collaborators: canvasCollaborators(collaboration, me) } as never, files: scene.files as never }, theme: document.documentElement.classList.contains("light") ? "light" : "dark", name: label, @@ -213,6 +235,6 @@ export function mountExcalidraw( return { node, focus: () => node.querySelector("canvas")?.focus({ preventScroll: true }), - destroy: () => { destroyed = true; collaboration.scene.unobserve(updateRemote); collaboration.provider.awareness.off("change", updatePresence); window.removeEventListener("themechange", updateTheme); root.unmount(); }, + destroy: () => { destroyed = true; if (fitFrame) cancelAnimationFrame(fitFrame); collaboration.scene.unobserve(updateRemote); collaboration.provider.awareness.off("change", updatePresence); window.removeEventListener("themechange", updateTheme); root.unmount(); }, }; } diff --git a/src/client/cowork.ts b/src/client/cowork.ts index 2261c9f..fb41551 100644 --- a/src/client/cowork.ts +++ b/src/client/cowork.ts @@ -206,6 +206,7 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const activeSession = (): SectionSession => sessions.get(section)!; const activeSection = () => SECTIONS.find((candidate) => candidate.id === section)!; const threadKey = (path: string): string => `1helm.cowork.thread.${channelId}.${path || section}`; + const contextPath = (session: SectionSession): string => session.path || session.folder; const syncCollaborationActivity = (): void => { const current = activeSession(); @@ -232,6 +233,17 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: session.saved = ""; }; + const openFolder = (path: string): void => { + const session = activeSession(); + if (session.path) resetEditor(session); + session.path = ""; + session.folder = path; + selectedEntry = null; + coworkContextPending = true; + syncCollaborationActivity(); + void draw(); + }; + const disconnectEditors = (): void => { for (const candidate of sessions.values()) if (candidate.path) resetEditor(candidate); }; @@ -254,7 +266,7 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const segments = session.folder.split("/").filter(Boolean); const add = (label: string, path: string): void => { if (breadcrumb.childNodes.length) breadcrumb.append(h("span", { class: "text-faint" }, "/")); - breadcrumb.append(h("button", { class: path === session.folder ? "shrink-0 text-fg" : "shrink-0 text-accent hover:underline", type: "button", onclick: () => { session.folder = path; selectedEntry = null; void draw(); } }, label)); + breadcrumb.append(h("button", { class: path === session.folder ? "shrink-0 text-fg" : "shrink-0 text-accent hover:underline", type: "button", onclick: () => openFolder(path) }, label)); }; add("workspace", activeSection().folder); segments.slice(1).forEach((segment, index) => add(segment, segments.slice(0, index + 2).join("/"))); @@ -322,7 +334,7 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: dictation, previewButton); const stage = mode === "notes" ? h("div", { class: "cowork-notes-edit-stage flex min-h-0 flex-1 flex-col overflow-hidden" }, editStage, preview) - : h("div", { class: "cowork-text-stage flex min-h-0 flex-1 flex-col overflow-auto" }, editStage, preview); + : h("div", { class: `cowork-text-stage flex min-h-0 flex-1 flex-col ${mode === "code" ? "overflow-hidden" : "overflow-auto"}` }, editStage, preview); return h("div", { class: `flex min-h-0 flex-1 flex-col ${mode === "docs" ? "cowork-doc-canvas" : ""}` }, toolbar, stage); }; @@ -356,7 +368,7 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: catch (error) { status.textContent = (error as Error).message; } finally { exportPending = false; } }; - const mounted = mountExcalidraw(session.collaboration!, me, "cowork-slide-canvas", `Slide ${session.activeSlide + 1} canvas`, (content) => markChanged(session, content), { adapter, exportPdf }); + const mounted = mountExcalidraw(session.collaboration!, me, "cowork-slide-canvas", `Slide ${session.activeSlide + 1} canvas`, (content) => markChanged(session, content), { adapter, exportPdf, fitToContentElementId: PRINTABLE_BOUNDARY_ID }); mounted.node.style.aspectRatio = `${deck.printableArea.width} / ${deck.printableArea.height}`; mounted.node.dataset.printableWidth = String(deck.printableArea.width); mounted.node.dataset.printableHeight = String(deck.printableArea.height); @@ -421,13 +433,13 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const drawWorkspace = async (force = false): Promise => { const session = activeSession(); clear(workspace); if (!session.path) { - workspace.append(h("div", { class: "grid h-full place-items-center p-8 text-center" }, h("div", {}, h("span", { class: "mx-auto grid h-14 w-14 place-items-center rounded-xl bg-accent-soft text-accent" }, icon(activeSection().icon, 27)), h("h2", { class: "mt-4 font-display text-2xl text-fg" }, activeSection().label), h("p", { class: "mt-2 max-w-sm text-sm leading-6 text-muted" }, "Choose a file on the left or create one. Cowork edits the same files your channel agent sees in /workspace.")))); + workspace.append(h("div", { class: "grid h-full place-items-center p-8 text-center" }, h("div", {}, h("span", { class: "mx-auto grid h-14 w-14 place-items-center rounded-xl bg-accent-soft text-accent" }, icon(activeSection().icon, 27)), h("h2", { class: "mt-4 font-display text-2xl text-fg" }, activeSection().label), h("p", { class: "mt-2 max-w-sm text-sm leading-6 text-muted" }, "Choose a file on the left or create one. Cowork edits the same files your channel agent sees in /workspace."))), agentToggle); return; } if (session.view && !force) { workspace.append(session.view, agentToggle); return; } if (section === "code" && /\.(?:db|sqlite)$/i.test(session.path)) { disposeEditor(session); - workspace.append(h("div", { class: "grid h-full place-items-center p-8 text-center" }, h("div", {}, h("span", { class: "text-accent" }, fileIcon({ path: session.path, name: visibleName(session.path), size: 0, modified: 0, kind: "file" }, 32)), h("h3", { class: "mt-3 font-semibold text-fg" }, visibleName(session.path)), h("p", { class: "mt-2 text-sm text-muted" }, "File type not supported to view.")))); + workspace.append(h("div", { class: "grid h-full place-items-center p-8 text-center" }, h("div", {}, h("span", { class: "text-accent" }, fileIcon({ path: session.path, name: visibleName(session.path), size: 0, modified: 0, kind: "file" }, 32)), h("h3", { class: "mt-3 font-semibold text-fg" }, visibleName(session.path)), h("p", { class: "mt-2 text-sm text-muted" }, "File type not supported to view."))), agentToggle); return; } if (force) { session.presenceCleanup?.(); session.presenceCleanup = null; session.mounted?.destroy(); session.mounted = null; session.view = null; } @@ -503,7 +515,7 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: class: `group mb-0.5 flex min-h-10 w-full items-center gap-2 rounded-md px-2 text-left ${selectedEntry?.path === file.path ? "bg-accent-soft ring-1 ring-accent/40" : "hover:bg-hover"}`, type: "button", dataset: { coworkPath: file.path, coworkKind: file.kind }, - ondblclick: () => { if (file.kind === "directory") { session.folder = file.path; selectedEntry = null; void draw(); } else void openPath(file.path); }, + ondblclick: () => { if (file.kind === "directory") openFolder(file.path); else void openPath(file.path); }, onclick: () => { selectedEntry = file; drawFileActions(); // Opening a file already redraws the rail and workspace together. @@ -537,27 +549,27 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const drawAgent = (): void => { agentPanel.classList.toggle("hidden", !agentOpen); agentPanel.classList.toggle("flex", agentOpen); agentToggle.setAttribute("aria-expanded", String(agentOpen)); if (!agentOpen) { if (chatTimer != null) window.clearInterval(chatTimer); chatTimer = null; return; } - const session = activeSession(); chatRootId = Number(localStorage.getItem(threadKey(session.path)) || 0); + const session = activeSession(); const context = contextPath(session); chatRootId = Number(localStorage.getItem(threadKey(context)) || 0); const stream = h("div", { class: "min-h-0 flex-1 space-y-3 overflow-y-auto p-3", dataset: { coworkChatStream: "" } }); - const input = h("textarea", { class: "min-h-20 w-full resize-none bg-transparent p-2 text-sm text-fg outline-none placeholder:text-faint", rows: 3, placeholder: session.path ? `Ask @${channel.agent?.name || "agent"} about this file…` : "Open a file to give the agent its path…", disabled: !session.path, value: agentDrafts.get(session.path) || "" }) as HTMLTextAreaElement; + const input = h("textarea", { class: "min-h-20 w-full resize-none bg-transparent p-2 text-sm text-fg outline-none placeholder:text-faint", rows: 3, placeholder: `Ask @${channel.agent?.name || "agent"} about this ${session.path ? "file" : "folder"}…`, value: agentDrafts.get(context) || "" }) as HTMLTextAreaElement; const dictate = mountSpeechToTextControl(input, "Dictate Cowork agent request"); - input.oninput = () => agentDrafts.set(session.path, input.value); + input.oninput = () => agentDrafts.set(context, input.value); input.onfocus = () => setFocusedSpeechTarget(input, dictate); const send = async (): Promise => { - const message = input.value.trim(); if (!message || !session.path) return; + const message = input.value.trim(); if (!message) return; input.disabled = true; try { const body = chatRootId ? message : `@${channel.agent?.name || "agent"} ${message}`; - const result = await api<{ message: Message }>(`/api/channels/${channelId}/messages`, { body: { body, parentId: chatRootId || null, ...(coworkContextPending ? { coworkPath: session.path } : {}) } }); - if (!chatRootId) { chatRootId = result.message.id; localStorage.setItem(threadKey(session.path), String(chatRootId)); } - coworkContextPending = false; input.value = ""; agentDrafts.delete(session.path); await renderChatMessages(); + const result = await api<{ message: Message }>(`/api/channels/${channelId}/messages`, { body: { body, parentId: chatRootId || null, ...(coworkContextPending ? { coworkPath: context, coworkKind: session.path ? "file" : "folder" } : {}) } }); + if (!chatRootId) { chatRootId = result.message.id; localStorage.setItem(threadKey(context), String(chatRootId)); } + coworkContextPending = false; input.value = ""; agentDrafts.delete(context); await renderChatMessages(); } catch (error) { void appAlert((error as Error).message); } finally { input.disabled = false; input.focus(); } }; input.onkeydown = (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void send(); } }; clear(agentPanel); - agentPanel.append(h("header", { class: "flex min-h-14 items-center gap-2 border-b border-line px-3" }, agentAvatar(), h("div", { class: "min-w-0 flex-1" }, h("div", { class: "truncate text-sm font-semibold text-fg" }, channel.agent?.display_name || channel.agent?.name || "Channel agent"), h("div", { class: "truncate text-[10px] text-muted" }, session.path ? `/workspace/${session.path}` : "Open a file")), chatRootId ? h("button", { class: "btn-ghost text-xs", onclick: async () => { try { const result = await api<{ root: Message }>(`/api/messages/${chatRootId}/thread`); openThreadCallback(result.root); } catch (error) { void appAlert((error as Error).message); } } }, "Open in Chat") : null, h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close agent panel", onclick: () => { agentOpen = false; drawAgent(); } }, icon("x", 15))), stream, - h("div", { class: "border-t border-line p-2" }, chatRootId ? h("button", { class: "btn-ghost mb-1 text-[11px]", onclick: () => { chatRootId = 0; coworkContextPending = true; localStorage.removeItem(threadKey(session.path)); drawAgent(); } }, "New session") : h("p", { class: "px-2 pb-1 text-[11px] leading-4 text-muted" }, "Your first message starts a normal channel session with this file and its current collaborators."), h("div", { class: "rounded-lg border border-line bg-raised/40 focus-within:border-accent" }, input, h("div", { class: "flex justify-end gap-1 p-1.5" }, dictate, h("button", { class: "btn-primary text-xs", disabled: !session.path, onclick: () => { void send(); } }, icon("send", 13), "Send"))))); + agentPanel.append(h("header", { class: "flex min-h-14 items-center gap-2 border-b border-line px-3" }, agentAvatar(), h("div", { class: "min-w-0 flex-1" }, h("div", { class: "truncate text-sm font-semibold text-fg" }, channel.agent?.display_name || channel.agent?.name || "Channel agent"), h("div", { class: "truncate text-[10px] text-muted" }, `/workspace/${context}`)), chatRootId ? h("button", { class: "btn-ghost text-xs", onclick: async () => { try { const result = await api<{ root: Message }>(`/api/messages/${chatRootId}/thread`); openThreadCallback(result.root); } catch (error) { void appAlert((error as Error).message); } } }, "Open in Chat") : null, h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close agent panel", onclick: () => { agentOpen = false; drawAgent(); } }, icon("x", 15))), stream, + h("div", { class: "border-t border-line p-2" }, chatRootId ? h("button", { class: "btn-ghost mb-1 text-[11px]", onclick: () => { chatRootId = 0; coworkContextPending = true; localStorage.removeItem(threadKey(context)); drawAgent(); } }, "New session") : h("p", { class: "px-2 pb-1 text-[11px] leading-4 text-muted" }, `Your first message starts a normal channel session with this ${session.path ? "file and its current collaborators" : "folder"}.`), h("div", { class: "rounded-lg border border-line bg-raised/40 focus-within:border-accent" }, input, h("div", { class: "flex justify-end gap-1 p-1.5" }, dictate, h("button", { class: "btn-primary text-xs", onclick: () => { void send(); } }, icon("send", 13), "Send"))))); void renderChatMessages(); if (chatTimer != null) window.clearInterval(chatTimer); chatTimer = window.setInterval(() => { if (!shell.isConnected || !agentOpen) { if (chatTimer != null) window.clearInterval(chatTimer); chatTimer = null; return; } void renderChatMessages(); }, 1600); if (focusAgentOnDraw) requestAnimationFrame(() => input.focus({ preventScroll: true })); focusAgentOnDraw = false; diff --git a/src/client/routing.ts b/src/client/routing.ts index 8be996e..b972e5d 100644 --- a/src/client/routing.ts +++ b/src/client/routing.ts @@ -35,6 +35,7 @@ const routeProviderFamilies = [ { id: "nvidia", name: "NVIDIA" }, { id: "cloudflare", name: "Cloudflare" }, { id: "glm", name: "GLM" }, + { id: "custom", name: "Custom" }, ] as const; const fmt = (value: unknown): string => new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(Number(value || 0)); @@ -327,13 +328,13 @@ function familyRouteMember(familyId: string, model: string, state: RoutingState) } function routingFabric(state: RoutingState): HTMLElement { - const connectedFamilies = new Set((state.providers || []).filter((provider) => provider.enabled !== false && !isCustom(provider)).map(providerFamily)); + const connectedFamilies = new Set((state.providers || []).filter((provider) => provider.enabled !== false).map((provider) => isCustom(provider) ? "custom" : providerFamily(provider))); const nodes = routeProviderFamilies; if (liveRoutingActivity) applyRoutingActivity(state, liveRoutingActivity); const rawEvents = Array.isArray(state.recentActivity) ? state.recentActivity as Array> : []; const latest = rawEvents[0] || ((state.activeRequests || [])[0] ? { type: "routed", request: (state.activeRequests || [])[0] } : null); const request = latest?.request && typeof latest.request === "object" ? latest.request as Record : null; - const activeFamily = String(request?.providerType || "").replace(/^codex$/, "chatgpt"); + const activeFamily = String(request?.providerType || "").replace(/^codex$/, "chatgpt").replace(/^(?:openai-compat|custom)$/, "custom"); const inFlight = Array.isArray(state.activeRequests) ? state.activeRequests.length : 0; const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 720 280"); svg.setAttribute("class", "routing-fabric-svg"); diff --git a/src/client/styles.css b/src/client/styles.css index 72980fb..b6bd1bd 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -943,7 +943,8 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n .cowork-notes-edit-stage .cowork-codemirror { min-height: 0; height: 100%; } .cowork-notes-edit-stage .cowork-codemirror > .cm-editor { height: 100%; } .cowork-notes-edit-stage .cm-scroller { overflow-y: auto; } -.cowork-codemirror-code { background: var(--c-surface); } +.cowork-codemirror-code { height: 100%; background: var(--c-surface); } +.cowork-codemirror-code .cm-scroller { overflow-y: auto; } .dark .cowork-codemirror-code { background: color-mix(in srgb, #071019 72%, var(--c-surface)); } .cowork-markdown-preview { min-height: 100%; overflow-y: auto; background: var(--c-surface); padding: 1.5rem 2rem; } .cowork-doc-canvas { background: color-mix(in srgb, var(--c-raised) 75%, var(--c-bg)); } diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 64404b1..d568158 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.22"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.23"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1"; const LXC_HELPER_CANDIDATES = [ diff --git a/src/server/cowork-collaboration.ts b/src/server/cowork-collaboration.ts index 8210be0..105a118 100644 --- a/src/server/cowork-collaboration.ts +++ b/src/server/cowork-collaboration.ts @@ -118,10 +118,16 @@ yWebsocket.setPersistence({ }, }); -export function normalizeCoworkPath(input: string): string { +export function normalizeCoworkFolderPath(input: string): string { const path = normalizeWorkspaceDirectoryPath(input); const root = path.split("/")[0]; - if (!path.includes("/") || !COWORK_ROOTS.has(root)) throw new Error("Cowork files must stay in a supported /workspace section."); + if (!path || !COWORK_ROOTS.has(root)) throw new Error("Cowork paths must stay in a supported /workspace section."); + return path; +} + +export function normalizeCoworkPath(input: string): string { + const path = normalizeCoworkFolderPath(input); + if (!path.includes("/")) throw new Error("Choose a Cowork file."); return path; } diff --git a/src/server/db.ts b/src/server/db.ts index f41f720..d7828fb 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -1009,7 +1009,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.22"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.23"); // Earlier Linux/Windows releases persisted the compatibility `native` // seam into every channel row. A production host update must actually // move those rows onto the platform isolation backend; changing the unit's diff --git a/src/server/index.ts b/src/server/index.ts index 4290468..917278b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -78,7 +78,7 @@ import { configurePhoton, continuePhotonConversation, deliverPhotonEvent, photon import { photonSetupStatus, startPhotonSetup } from "./photon-auth.ts"; import { completeGmailConnection, gmailConnectionStatus, saveGmailOAuthClient, startGmailConnection } from "./gmail.ts"; import { cancelMnemosyneRuntimePreparation, ensureAgentMemory, mnemosyneAvailable, prepareMnemosyneRuntime } from "./memory.ts"; -import { attachCoworkClient, coworkPresence, coworkViewerUsernames, flushCoworkDocuments, normalizeCoworkPath } from "./cowork-collaboration.ts"; +import { attachCoworkClient, coworkPresence, coworkViewerUsernames, flushCoworkDocuments, normalizeCoworkFolderPath, normalizeCoworkPath } from "./cowork-collaboration.ts"; import { runImprovementPass, scheduleAgentReview, startImprovementLoop } from "./improvements.ts"; import { runThreadAuditPass, startThreadAuditLoop } from "./thread-audit.ts"; import { startFollowupLoop, threadFollowupView, bumpThreadFollowup } from "./followups.ts"; @@ -1678,9 +1678,11 @@ const server = createServer(async (req, res) => { try { let body = String(b.body || ""); if (b.coworkPath) { - const path = normalizeCoworkPath(String(b.coworkPath)); - const viewers = coworkViewerUsernames(cid, path, Number(user.id)); - const suffix = b.parentId ? [] : [`Working file: /workspace/${path}`]; + const folderContext = b.coworkKind === "folder"; + const path = folderContext ? normalizeCoworkFolderPath(String(b.coworkPath)) : normalizeCoworkPath(String(b.coworkPath)); + if (folderContext) listWorkspaceDirectory(cid, path); + const viewers = folderContext ? [] : coworkViewerUsernames(cid, path, Number(user.id)); + const suffix = b.parentId ? [] : [`Working ${folderContext ? "folder" : "file"}: /workspace/${path}`]; if (viewers.length) suffix.push(`Working with: ${viewers.map((username) => `@${username}`).join(" ")}`); if (suffix.length) body = `${body.trim()}\n\n${suffix.join("\n")}`; } diff --git a/test/brief-regressions-browser.mjs b/test/brief-regressions-browser.mjs index c454359..776fa85 100644 --- a/test/brief-regressions-browser.mjs +++ b/test/brief-regressions-browser.mjs @@ -127,10 +127,36 @@ try { ok(brand.title.includes("1Helm") && brand.appName === "1Helm" && !brand.body.includes("1Herd"), "the browser presents the product as 1Helm throughout"); ok(brand.favicon === "/brand/1helm-sailboat.png" && brand.workspaceLogo === "/brand/1helm-sailboat.png", "the sailboat is the web favicon and default customizable workspace image"); + await page.setRequestInterception(true); + const interceptReadyUpdate = (request) => { + if (request.url() === `${base}/api/app/update` && request.method() === "GET") { + void request.respond({ status: 200, contentType: "application/json", body: JSON.stringify({ mode: "native-macos", status: "ready", current_version: "0.0.22", version: "0.0.23", message: "Verified update ready.", error: null }) }); + return; + } + void request.continue(); + }; + page.on("request", interceptReadyUpdate); await page.click('[title="Open profile"]'); await page.waitForSelector('[data-profile-logout]'); ok(await page.$eval('[data-profile-logout]', (button) => button.textContent?.trim() === "Log Out"), "the profile menu exposes the account Log Out action"); + await page.waitForSelector('[data-update-ready-prompt]'); + const updatePrompt = await page.$eval('[data-update-ready-prompt]', (dialog) => ({ + label: dialog.getAttribute("aria-label"), + text: dialog.textContent || "", + remembered: localStorage.getItem("1helm.updateReadyPrompt"), + })); + ok(updatePrompt.label === "1Helm v0.0.23 is ready" && /downloaded and verified/.test(updatePrompt.text) && /Later/.test(updatePrompt.text) && /Restart Now/.test(updatePrompt.text) && updatePrompt.remembered === "native-macos:0.0.22->0.0.23", + "admins receive one clear Later / Restart Now prompt for a verified downloaded update"); + await page.evaluate(() => [...document.querySelectorAll('[data-update-ready-prompt] button')].find((button) => button.textContent?.trim() === "Later")?.click()); + await page.waitForFunction(() => !document.querySelector('[data-update-ready-prompt]')); + await page.click('[aria-label="Close profile"]'); + await page.click('[title="Open profile"]'); + await page.waitForSelector('[data-profile-logout]'); + await new Promise((resolve) => setTimeout(resolve, 250)); + ok(await page.$('[data-update-ready-prompt]') === null, "deferring a downloaded version does not nag the same admin again"); await page.click('[aria-label="Close profile"]'); + page.off("request", interceptReadyUpdate); + await page.setRequestInterception(false); await api("/api/workspace/model-policy", { method: "PATCH", body: { model: smallModel, personal: true } }, token); await page.goto(`${base}/c/${mainChannel.slug}/chat`, { waitUntil: "networkidle0" }); @@ -481,12 +507,12 @@ try { const providerNames = [...stage.querySelectorAll('.routing-live-provider span')].map((node) => node.textContent?.trim()); const router = stage.querySelector('.routing-live-router').getBoundingClientRect(); const requests = stage.querySelector('.routing-live-source').getBoundingClientRect(); - return { width: stage.getBoundingClientRect().width, providerNames, providersAboveRouter: providers.length === 8 && providers.every((box) => box.bottom < router.top), routerAboveRequests: router.bottom < requests.top }; + return { width: stage.getBoundingClientRect().width, providerNames, providersAboveRouter: providers.length === 9 && providers.every((box) => box.bottom < router.top), routerAboveRequests: router.bottom < requests.top }; }); await page.screenshot({ path: "/tmp/1helm-routes-bottom-to-top.png" }); ok(/Requests/.test(fabricText) && /1Helm Router/.test(fabricText) && /Bottom-to-top live route map/.test(fabricAria) && fabricGeometry.width >= 500 && fabricGeometry.providersAboveRouter && fabricGeometry.routerAboveRequests - && ["ChatGPT", "Claude", "Antigravity", "xAI", "OpenRouter", "NVIDIA", "Cloudflare", "GLM"].every((name) => fabricGeometry.providerNames.includes(name)), + && ["ChatGPT", "Claude", "Antigravity", "xAI", "OpenRouter", "NVIDIA", "Cloudflare", "GLM", "Custom"].every((name) => fabricGeometry.providerNames.includes(name)), "Providers visualizes a spacious, literal bottom-to-top Requests → 1Helm Router → provider arc"); await page.evaluate(() => [...document.querySelectorAll("button")].find((button) => button.getAttribute("aria-label") === "Close settings" || button.textContent?.trim() === "Close")?.click()); await page.goto(`${base}/c/${channel.slug}`, { waitUntil: "networkidle0" }); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index c6fec75..cc8a3e3 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.22"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.23"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/channel-surfaces.mjs b/test/channel-surfaces.mjs index cbea1d1..f6afd56 100644 --- a/test/channel-surfaces.mjs +++ b/test/channel-surfaces.mjs @@ -7,6 +7,7 @@ import test, { after } from "node:test"; const root = resolve(import.meta.dirname, ".."); const agentsSource = readFileSync(join(root, "src", "server", "agents.ts"), "utf8"); const stylesSource = readFileSync(join(root, "src", "client", "styles.css"), "utf8"); +const coworkEditorsSource = readFileSync(join(root, "src", "client", "cowork-editors.ts"), "utf8"); const dataDir = mkdtempSync(join(tmpdir(), "1helm-channel-surfaces-")); process.env.CTRL_DATA_DIR = dataDir; process.env.NODE_ENV = "test"; @@ -203,13 +204,20 @@ test("channel UI source exposes file-backed Cowork, traditional Files, audio pre assert.match(channelSource, /fileOtherToggle/, "Files groups non-core root entries behind a visual Other disclosure"); assert.match(channelSource, /files\/docx[\s\S]*catch\(\(error\) => appAlert\(`DOCX download failed:[\s\S]*Download - DOCX/, "Markdown files expose a real DOCX export action with visible failure handling"); assert.match(stylesSource, /\.cowork-notes-edit-stage \.cm-scroller \{ overflow-y: auto; \}/, "long Cowork Notes edit sessions scroll in CodeMirror's real viewport"); - assert.match(stylesSource, /\.cowork-codemirror-code \{ background: var\(--c-surface\); \}[\s\S]*\.dark \.cowork-codemirror-code/, "Code uses a legible light surface without changing its dark treatment"); + assert.match(coworkSource, /mode === "code" \? "overflow-hidden" : "overflow-auto"/, "Cowork Code gives its finite editor viewport control of scrolling"); + assert.match(stylesSource, /\.cowork-codemirror-code \.cm-scroller \{ overflow-y: auto; \}/, "long Cowork Code files scroll inside CodeMirror"); + assert.match(stylesSource, /\.cowork-codemirror-code \{[^}]*background: var\(--c-surface\);[^}]*\}[\s\S]*\.dark \.cowork-codemirror-code/, "Code uses a bounded, legible light surface without changing its dark treatment"); assert.match(stylesSource, /\.cowork-slide-stage[^}]*place-items: start center/, "oversized presentation canvases remain reachable from their top edge"); + assert.match(coworkSource, /fitToContentElementId: PRINTABLE_BOUNDARY_ID/, "every opened, selected, or newly created presentation slide fits exactly its printable boundary"); + assert.match(coworkEditorsSource, /scrollToContent\(target\.length \? target : scene\.elements, \{ fitToContent: true, animate: false, viewportZoomFactor: 0\.88 \}\)/, "presentation fitting leaves a clean margin around the complete dotted boundary"); assert.match(stylesSource, /\.cowork-slide-stage \{ container-type: size[\s\S]*\.cowork-slide-canvas[^}]*overflow: visible[\s\S]*\.cowork-slide-canvas > \.excalidraw \{ overflow: visible[\s\S]*\.cowork-slide-canvas \.dropdown-menu[^}]*top: 3\.25rem !important[\s\S]*max-height: min\(25rem, max\(8rem, calc\(100cqh - 11rem\)\)\) !important/, "presentation menus escape both Excalidraw clips and stay bounded to the visible stage"); assert.match(channelSource, /files\/refresh[\s\S]*void refreshMirror\(\)/, "Files paints the host cache first and refreshes the VM mirror independently"); assert.match(serverSource, /channelFilesRefresh[\s\S]*refreshChannelWorkspaceMirror\(channelId\)/, "Files has one explicit coalesced VM refresh route instead of syncing every click"); - assert.match(coworkSource, /\.\.\.\(coworkContextPending \? \{ coworkPath: session\.path \} : \{\}\)/, "a Cowork panel's first message submits its open path for authenticated enrichment"); - assert.match(serverSource, /if \(b\.coworkPath\)[\s\S]*b\.parentId \? \[\] : \[`Working file: \/workspace\/\$\{path\}`\]/, "the server adds the validated open file path only to a new Cowork thread"); + assert.match(coworkSource, /const contextPath = \(session: SectionSession\): string => session\.path \|\| session\.folder/, "Cowork agent context follows an open file or the current folder"); + assert.match(coworkSource, /const openFolder[\s\S]*session\.path = ""[\s\S]*session\.folder = path/, "entering a nested Cowork folder replaces any prior file context"); + assert.match(coworkSource, /coworkPath: context, coworkKind: session\.path \? "file" : "folder"/, "a Cowork panel's first message identifies its validated file-or-folder context"); + assert.match(coworkSource, /if \(!session\.path\)[\s\S]*agentToggle/, "the Cowork folder empty state keeps the agent launcher available"); + assert.match(serverSource, /folderContext \? normalizeCoworkFolderPath[\s\S]*Working \$\{folderContext \? "folder" : "file"\}: \/workspace\/\$\{path\}/, "the server adds the validated file or folder path only to a new Cowork thread"); assert.match(serverSource, /coworkViewerUsernames\(cid, path, Number\(user\.id\)\)[\s\S]*Working with:/, "the server adds active co-viewers to the first Cowork agent message"); assert.doesNotMatch(appSource, /controllerchange[\s\S]{0,300}location\.reload\(/, "service-worker updates never reload an active note draft"); }); diff --git a/test/cowork-browser.mjs b/test/cowork-browser.mjs index 4bff419..54be6c9 100644 --- a/test/cowork-browser.mjs +++ b/test/cowork-browser.mjs @@ -36,7 +36,7 @@ const waitFor = async (fn, label, timeout = 20_000) => { }; test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one file-backed product", { - timeout: 120_000, + timeout: 180_000, skip: executablePath ? false : "No local Chrome executable; server and source contracts cover the nonvisual fallback.", }, async (t) => { rmSync(dataDir, { recursive: true, force: true }); @@ -47,8 +47,11 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil const app = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", "src/server/index.ts"], { cwd: root, env: { ...process.env, CTRL_DATA_DIR: dataDir, PORT: String(appPort), NODE_ENV: "test", HELM_CHANNEL_COMPUTER_BACKEND: "native", IMPROVEMENT_INTERVAL_MS: "600000" }, - stdio: "ignore", + stdio: ["ignore", "pipe", "pipe"], }); + let appLogs = ""; + app.stdout.on("data", (chunk) => { appLogs = `${appLogs}${chunk}`.slice(-12_000); }); + app.stderr.on("data", (chunk) => { appLogs = `${appLogs}${chunk}`.slice(-12_000); }); let browser; t.after(async () => { await browser?.close().catch(() => undefined); @@ -60,11 +63,16 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await waitFor(async () => (await fetch(`http://127.0.0.1:${providerPort}/v1/models`).catch(() => null))?.ok, "mock provider"); await waitFor(async () => (await fetch(`${base}/api/setup/status`).catch(() => null))?.ok, "1Helm server"); const api = async (path, options = {}, token = "") => { - const response = await fetch(base + path, { - method: options.method || (options.body !== undefined ? "POST" : "GET"), - headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(options.body !== undefined ? { "content-type": "application/json" } : {}) }, - body: options.body === undefined ? undefined : JSON.stringify(options.body), - }); + let response; + try { + response = await fetch(base + path, { + method: options.method || (options.body !== undefined ? "POST" : "GET"), + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(options.body !== undefined ? { "content-type": "application/json" } : {}) }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (error) { + throw new Error(`${path}: ${error.message}; server exit=${app.exitCode ?? "running"}\n${appLogs}`); + } const payload = await response.json().catch(() => ({})); assert.equal(response.ok, true, `${path}: ${payload.error || response.status}`); return payload; @@ -132,6 +140,16 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.waitForSelector('[data-cowork-surface]'); assert.deepEqual(await page.$$eval('[aria-label="Cowork sections"] button', (buttons) => buttons.map((button) => button.textContent.trim())), ["Notes", "Whiteboard", "Code", "Docs", "Presentations"]); await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent?.includes("field-notes.md")); + await page.waitForSelector('.cowork-workspace .cowork-agent-toggle'); + await page.click('.cowork-workspace .cowork-agent-toggle'); + await page.waitForSelector('[data-cowork-agent]:not(.hidden) textarea'); + assert.equal(await page.$eval('[data-cowork-agent] header', (header) => header.textContent.includes('/workspace/notes')), true, "an unopened Cowork section scopes its agent to the current folder"); + await page.type('[data-cowork-agent] textarea', "Summarize this folder"); + await page.evaluate(() => [...document.querySelectorAll('[data-cowork-agent] button')].find((button) => button.textContent.includes("Send"))?.click()); + const folderRootId = await waitFor(async () => page.evaluate((id) => Number(localStorage.getItem(`1helm.cowork.thread.${id}.notes`) || 0), channel.id), "folder-scoped Cowork thread id"); + const folderThread = await api(`/api/messages/${folderRootId}/thread`, {}, token); + assert.match(folderThread.root.body, /^@\S+ Summarize this folder\n\nWorking folder: \/workspace\/notes$/); + await page.click('.cowork-workspace .cowork-agent-toggle'); await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("field-notes.md"))?.click()); await page.waitForFunction(() => document.querySelector('[aria-label="Notes editor"] .cm-content')?.textContent.includes("Field notes")); const editorContent = '[aria-label="Notes editor"] .cm-content'; @@ -363,6 +381,23 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.keyboard.press("Escape"); await page.keyboard.down(primaryModifier); await page.keyboard.press("s"); await page.keyboard.up(primaryModifier); await waitFor(async () => (await api(`/api/channels/${channel.id}/files/text?path=code%2Ftool.ts`, {}, token)).file.content.includes("searchableValue"), "saved TypeScript editor content"); + await page.click('[aria-label="Code editor"] .cm-content'); + await page.keyboard.down(primaryModifier); await page.keyboard.press("a"); await page.keyboard.up(primaryModifier); await page.keyboard.press("ArrowRight"); + await page.keyboard.type(`\n${Array.from({ length: 140 }, (_, index) => `const scrollRow${index + 1} = ${index + 1};`).join("\n")}`); + const codeScroller = await page.$('[aria-label="Code editor"] .cm-scroller'); + const codeScrollerBox = await codeScroller.boundingBox(); + assert.ok(codeScrollerBox?.height > 0, JSON.stringify(codeScrollerBox)); + await page.$eval('[aria-label="Code editor"] .cm-scroller', (scroller) => { scroller.scrollTop = 0; }); + await page.mouse.move(codeScrollerBox.x + codeScrollerBox.width / 2, codeScrollerBox.y + Math.min(codeScrollerBox.height / 2, 120)); + await page.mouse.wheel({ deltaY: 700 }); + await page.waitForFunction(() => document.querySelector('[aria-label="Code editor"] .cm-scroller')?.scrollTop > 0); + const codeScroll = await page.$eval('[aria-label="Code editor"] .cm-scroller', (scroller) => ({ + clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight, scrollTop: scroller.scrollTop, + viewportHeight: document.querySelector('[data-cowork-viewport]').clientHeight, + })); + assert.ok(codeScroll.clientHeight < codeScroll.scrollHeight && codeScroll.scrollTop > 0 && codeScroll.clientHeight <= codeScroll.viewportHeight, JSON.stringify(codeScroll)); + await page.keyboard.down(primaryModifier); await page.keyboard.press("s"); await page.keyboard.up(primaryModifier); + await waitFor(async () => (await api(`/api/channels/${channel.id}/files/text?path=code%2Ftool.ts`, {}, token)).file.content.includes("scrollRow140"), "saved long TypeScript editor content"); // Docs keeps a page-oriented editor and its formatting controls write // ordinary Markdown that survives save/reopen. @@ -417,6 +452,8 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("review.slides.json")); await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("review.slides.json"))?.click()); await page.waitForSelector('[data-cowork-presentation][data-slide-count="1"] .cowork-slide-canvas .excalidraw'); + await page.waitForSelector('.cowork-slide-canvas[data-initial-fit="complete"]'); + assert.ok(await page.$eval('.cowork-slide-canvas', (canvas) => Number(canvas.dataset.initialZoom) < 1), "the initial presentation viewport zooms out to fit the complete printable boundary"); assert.deepEqual(await page.$eval('.cowork-slide-canvas', (canvas) => ({ width: Number(canvas.dataset.printableWidth), height: Number(canvas.dataset.printableHeight), @@ -456,6 +493,7 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil assert.ok(await collaboratorPage.$eval('.cowork-slide-canvas', (canvas) => Number(canvas.dataset.sceneElements || 0)) > 0); await page.evaluate(() => [...document.querySelectorAll('.cowork-editor-toolbar button')].find((button) => button.textContent.includes("Slide"))?.click()); await page.waitForSelector('[data-cowork-presentation][data-slide-count="2"]'); + await page.waitForSelector('.cowork-slide-canvas[data-initial-fit="complete"]'); await collaboratorPage.waitForSelector('[data-cowork-presentation][data-slide-count="2"]'); await page.evaluate(() => [...document.querySelectorAll('.cowork-editor-toolbar button')].find((button) => button.textContent.trim() === "Duplicate")?.click()); await page.waitForSelector('[data-cowork-presentation][data-slide-count="3"]'); diff --git a/test/desktop.mjs b/test/desktop.mjs index 42e1317..c752e9d 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -5,9 +5,35 @@ import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; +import workspaceTarget from "../desktop/workspace-target.cjs"; const root = resolve(import.meta.dirname, ".."); const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version; +const { allowedRemoteUrl, desktopGatewayAction, isHostedWorkspaceOrigin, normalizeRemoteOrigin } = workspaceTarget; + +test("desktop first launch can connect to an existing workspace or start this PC as the server", async () => { + const source = await readFile(join(root, "desktop", "main.cjs"), "utf8"); + const gateway = await readFile(join(root, "desktop", "gateway.html"), "utf8"); + assert.equal(normalizeRemoteOrigin("https://acme.1helm.com/path"), "https://acme.1helm.com"); + assert.equal(normalizeRemoteOrigin("http://acme.1helm.com"), ""); + assert.equal(isHostedWorkspaceOrigin("https://acme.1helm.com"), true); + assert.equal(isHostedWorkspaceOrigin("https://demo.1helm.com"), false); + assert.equal(allowedRemoteUrl("https://private.example/app", "https://private.example"), true); + assert.equal(allowedRemoteUrl("https://other.example/app", "https://private.example"), false); + assert.deepEqual(desktopGatewayAction("https://desktop-action.1helm.invalid/setup"), { type: "setup" }); + assert.deepEqual(desktopGatewayAction("https://desktop-action.1helm.invalid/connect?origin=https%3A%2F%2Facme.1helm.com"), { type: "connect", origin: "https://acme.1helm.com" }); + assert.equal(desktopGatewayAction("https://other.invalid/setup"), null); + assert.match(source, /if \(fs\.existsSync\(localDatabasePath\(\)\)\) return "server"/, "configured desktop installations continue opening their local server"); + assert.match(source, /if \(fs\.existsSync\(remoteWorkspacePath\(\)\)\) return "client"/, "older remote desktop installations remain clients"); + assert.match(source, /if \(mode === "server" \|\| process\.platform === "linux"\)[\s\S]*await startLocalRuntime\(\)/, "only server-mode desktops and headless Linux start the local runtime at launch"); + assert.match(source, /gatewayAction\.type === "setup"\) void startServerMode/, "the explicit new-user server action creates the local runtime"); + assert.match(source, /mode === "server" \|\| process\.platform === "linux"/, "the visual gateway is scoped to Mac and Windows, leaving Linux server-oriented"); + assert.match(source, /api\/mobile\/compatibility/, "the desktop validates the selected server before remembering it"); + assert.match(gateway, /\.1helm\.com[\s\S]*Connect to a Different URL\?[\s\S]*Connect to 1Helm URL\?/, "desktop reuses the clean workspace-name and custom-URL flow"); + assert.match(gateway, />New User\?Set this PC up as a 1Helm Server to Get Started { @@ -45,10 +71,9 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(source, /media-src 'self' blob:/, "the Electron renderer permits only same-origin and blob media for safe audio/video preview"); assert.match(source, /HELM_RESOURCES_PATH = process\.resourcesPath/, "the local runtime resolves the connector bundled in the signed app"); assert.match(source, /allowedTeamUrl/); - assert.match(source, /url\.protocol === "https:"/); + assert.match(await readFile(join(root, "desktop", "workspace-target.cjs"), "utf8"), /url\.protocol !== "https:"/); assert.match(source, /\^mailto:build@1helm\\\.com\$/i, "the company email opens only through the exact external mail link allowlist"); - assert.match(source, /\.1helm\\\.com/); - assert.match(source, /!\["demo\.1helm\.com", "provision\.1helm\.com"\]\.includes/, "only exact customer workspace subdomains may load inside the sandboxed renderer"); + assert.match(await readFile(join(root, "desktop", "workspace-target.cjs"), "utf8"), /\.1helm\\\.com/); assert.match(source, /remoteWorkspacePath/); assert.match(source, /preferredWorkspaceOrigin\(\)/, "the app remembers a selected team workspace while its own local headless runtime keeps running"); assert.match(source, /requestSingleInstanceLock/); @@ -75,6 +100,10 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(appClient, /1Helm v\$\{update\.current_version\}/, "Profile displays the installed version beside the update control"); assert.match(appClient, /Download on host/, "Profile makes native download ownership explicit"); assert.match(appClient, /Restart & install/, "Profile installs only after the host reports a verified update ready"); + assert.match(appClient, /scheduleHostUpdatePromptChecks\(\)/, "admins learn about a downloaded update without opening Profile"); + assert.match(appClient, /1helm\.updateReadyPrompt/, "the automatic update prompt is shown only once per downloaded version"); + assert.match(appClient, /A new version has been downloaded and verified\.[\s\S]*Later[\s\S]*Restart Now/, "the verified update prompt offers an explicit defer-or-restart choice"); + assert.match(appClient, /Restart Now[\s\S]*action: "install"/, "Restart Now uses the existing host-owned safe install action"); assert.doesNotMatch(appClient, /window\.open\(target/, "updating never navigates the browsing device to a host installer"); const nativeUpdater = await readFile(join(root, "desktop", "updater.cjs"), "utf8"); assert.match(nativeUpdater, /autoUpdater\.checkForUpdates\(\)/); diff --git a/test/mobile.mjs b/test/mobile.mjs index 15e2413..a229bc1 100644 --- a/test/mobile.mjs +++ b/test/mobile.mjs @@ -117,6 +117,10 @@ test("Capacitor shells keep sessions native, connections HTTPS-only, and release assert.equal(parsed.plugins.SplashScreen.launchFadeOutDuration, 180); assert.equal(parsed.plugins.SplashScreen.androidScaleType, "CENTER_INSIDE"); assert.equal(parsed.plugins.SplashScreen.layoutName, "launch_screen"); + for (const gatewayPage of [gatewayHtml, gatewayError]) { + assert.match(gatewayPage, /requestAnimationFrame\(\(\)\s*=>\s*requestAnimationFrame/, "standalone gateway screens wait for their first paint before releasing the native splash"); + assert.match(gatewayPage, /SplashScreen[\s\S]*hide\(\{\s*fadeOutDuration:\s*180\s*\}\)/, "standalone gateway screens release the native splash"); + } assert.match(mobile, /SecureStorage/); assert.match(mobile, /KeychainAccess\.whenUnlockedThisDeviceOnly/); @@ -186,6 +190,10 @@ test("Capacitor shells keep sessions native, connections HTTPS-only, and release assert.ok(pkg.scripts["mobile:sync"] && pkg.scripts["package:android:release"] && pkg.scripts["package:ios:release"]); assert.match(gatewayHtml, /Connect to 1Helm[\s\S]*api\/mobile\/compatibility[\s\S]*selectServer/); + assert.match(gatewayHtml, /]+src="1helm-logo\.png"[^>]+alt="1Helm">/, "the native connection screen uses the real product logo"); + assert.doesNotMatch(gatewayHtml, /⛵/, "the native connection screen has no emoji placeholder logo"); + assert.match(gatewayHtml, /\.1helm\.com[\s\S]*Connect to a different url\?[\s\S]*Connect to 1Helm URL\?/, "the primary workspace-name field can switch to the existing custom-URL flow and back"); + assert.match(gatewayHtml, /customUrl \? normalize\(input\.value\) : workspaceOrigin\(input\.value\)/, "workspace names resolve only beneath 1helm.com while custom URLs retain strict validation"); assert.match(gatewayError, /Instance unavailable[\s\S]*Retry[\s\S]*Change instance/); for (const frozenAsset of ["bundle.js", "app.css", "excalidraw"]) { assert.doesNotMatch(gatewayHtml + gatewayError, new RegExp(frozenAsset.replace(".", "\\."), "i"), `gateway contains no frozen ${frozenAsset}`); @@ -239,26 +247,31 @@ test("the packaged phone gateway opens a fitting connection screen instead of ho const errors = []; page.on("pageerror", (error) => errors.push(error.message)); await page.goto(base, { waitUntil: "networkidle0" }); - await page.waitForSelector('input[placeholder="https://your-1helm-server.com"]'); + await page.waitForSelector('input[placeholder="your-workspace"]'); const screen = await page.evaluate(() => { const card = document.querySelector("main"); const inputs = [...document.querySelectorAll("input")]; return { body: card?.textContent || "", serverType: inputs[0]?.getAttribute("type"), + suffix: document.querySelector(".suffix")?.textContent || "", inputCount: inputs.length, overflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth, cardFits: card ? card.getBoundingClientRect().top >= 0 && card.getBoundingClientRect().bottom <= innerHeight : false, }; }); assert.match(screen.body, /Connect to 1Helm/); - assert.match(screen.body, /live interface/); + assert.match(screen.body, /Enter your workspace name/); assert.doesNotMatch(screen.body, /Create the Captain account/); assert.doesNotMatch(screen.body, /password|Sign in/i); - assert.equal(screen.serverType, "url"); + assert.equal(screen.serverType, "text"); + assert.equal(screen.suffix, ".1helm.com"); assert.equal(screen.inputCount, 1, "the packaged gateway asks only for the selected instance"); assert.ok(screen.overflowX <= 0, `phone gateway has ${screen.overflowX}px horizontal overflow`); assert.equal(screen.cardFits, true, "the connection card fits the phone viewport"); + await page.click("#alternate"); + await page.waitForSelector('input[placeholder="https://your-1helm-server.com"]'); + assert.equal(await page.$eval("#alternate", (button) => button.textContent?.trim()), "Connect to 1Helm URL?"); assert.deepEqual(errors, []); } finally { if (browser) await browser.close().catch(() => undefined); diff --git a/test/routing-ui-contract.mjs b/test/routing-ui-contract.mjs index 416c64b..ee98972 100644 --- a/test/routing-ui-contract.mjs +++ b/test/routing-ui-contract.mjs @@ -15,11 +15,13 @@ test("provider controls expose the live dotted router flow and credential-free h assert.match(client, /routing-fabric-path/); assert.match(client, /const y = 46 \+ Math\.abs\(index - center\) \* 5/, "provider nodes form the requested downward arc"); - for (const provider of ["ChatGPT", "Claude", "Antigravity", "xAI", "OpenRouter", "NVIDIA", "Cloudflare", "GLM"]) { + for (const provider of ["ChatGPT", "Claude", "Antigravity", "xAI", "OpenRouter", "NVIDIA", "Cloudflare", "GLM", "Custom"]) { assert.match(client, new RegExp(`name: "${provider}"`), `the fixed route arc includes ${provider}`); } assert.match(client, /const nodes = routeProviderFamilies/, - "the route map always renders the complete eight-provider network"); + "the route map always renders the complete nine-provider network"); + assert.match(client, /replace\(\/\^\(\?:openai-compat\|custom\)\$\/, "custom"\)/, + "live OpenAI-compatible requests illuminate the collapsed Custom route"); assert.match(client, /M 360 248[\s\S]*M 360 135/, "request paths originate below the router and continue toward providers"); assert.match(client, /Requested ·[\s\S]*Routed via/,