From a6a42093f76accbac504c8c3aa36bd098445e570 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 28 Jul 2026 20:03:24 +0000 Subject: [PATCH 1/4] Add desktop connection mode and polish Cowork --- CHANGELOG.md | 32 ++++++- README.md | 2 +- desktop/gateway.html | 145 +++++++++++++++++++++++++++++ desktop/main.cjs | 114 +++++++++++++++++++---- desktop/workspace-target.cjs | 58 ++++++++++++ mobile-gateway/1helm-logo.png | Bin 0 -> 66756 bytes mobile-gateway/error.html | 2 +- mobile-gateway/index.html | 71 ++++++++++++-- package-lock.json | 4 +- package.json | 2 +- public/index.html | 4 +- src/client/cowork-editors.ts | 26 +++++- src/client/cowork.ts | 42 ++++++--- src/client/routing.ts | 5 +- src/client/styles.css | 1 + src/server/channel-computers.ts | 2 +- src/server/cowork-collaboration.ts | 10 +- src/server/db.ts | 2 +- src/server/index.ts | 10 +- test/brief-regressions-browser.mjs | 4 +- test/channel-computers.mjs | 2 +- test/channel-surfaces.mjs | 12 ++- test/cowork-browser.mjs | 25 ++++- test/desktop.mjs | 31 +++++- test/mobile.mjs | 8 ++ test/routing-ui-contract.mjs | 6 +- 26 files changed, 550 insertions(+), 70 deletions(-) create mode 100644 desktop/gateway.html create mode 100644 desktop/workspace-target.cjs create mode 100644 mobile-gateway/1helm-logo.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fb0da..fe53cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.23] - 2026-07-28 + +### Added + +- 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 +729,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/mobile-gateway/1helm-logo.png b/mobile-gateway/1helm-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8323e6ef72e896fd14a5c9a24fc0a7f3d9b2a347 GIT binary patch literal 66756 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 Z{{c^RI@g^^Z{Yv{002ovPDHLkV1jyKlfM7} literal 0 HcmV?d00001 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..93849a9 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/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..badf9b3 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -944,6 +944,7 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n .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 .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..1daf6f8 100644 --- a/test/brief-regressions-browser.mjs +++ b/test/brief-regressions-browser.mjs @@ -481,12 +481,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..cba0f0b 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(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 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..901d5a4 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 }); @@ -132,6 +132,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 +373,16 @@ 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 codeScroll = await page.$eval('[aria-label="Code editor"] .cm-scroller', (scroller) => { + scroller.scrollTop = scroller.scrollHeight; + return { 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 +437,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 +478,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..09b4ae3 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/); diff --git a/test/mobile.mjs b/test/mobile.mjs index 15e2413..506b610 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}`); 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/, From 7e99024a45a939f4432178eafbfcfd7eee8015ef Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 28 Jul 2026 20:21:35 +0000 Subject: [PATCH 2/4] Fix Cowork code wheel scrolling --- public/index.html | 2 +- src/client/styles.css | 2 +- test/cowork-browser.mjs | 15 +++++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/public/index.html b/public/index.html index 93849a9..b55210c 100644 --- a/public/index.html +++ b/public/index.html @@ -30,7 +30,7 @@ document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - + diff --git a/src/client/styles.css b/src/client/styles.css index badf9b3..b6bd1bd 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -943,7 +943,7 @@ 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; } diff --git a/test/cowork-browser.mjs b/test/cowork-browser.mjs index 901d5a4..7d7104a 100644 --- a/test/cowork-browser.mjs +++ b/test/cowork-browser.mjs @@ -376,10 +376,17 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil 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 codeScroll = await page.$eval('[aria-label="Code editor"] .cm-scroller', (scroller) => { - scroller.scrollTop = scroller.scrollHeight; - return { clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight, scrollTop: scroller.scrollTop, viewportHeight: document.querySelector('[data-cowork-viewport]').clientHeight }; - }); + 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"); From 47343d32e1e27768a664c0807389121b3e08cad7 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 28 Jul 2026 20:57:46 +0000 Subject: [PATCH 3/4] Notify admins when updates are ready --- CHANGELOG.md | 3 +++ docs/USER_GUIDE.md | 4 ++- public/index.html | 2 +- src/client/app.ts | 39 +++++++++++++++++++++++++++--- test/brief-regressions-browser.mjs | 26 ++++++++++++++++++++ test/channel-surfaces.mjs | 2 +- test/cowork-browser.mjs | 20 ++++++++++----- test/desktop.mjs | 4 +++ 8 files changed, 88 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe53cbf..da7f446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 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/public/index.html b/public/index.html index b55210c..d23ba90 100644 --- a/public/index.html +++ b/public/index.html @@ -36,6 +36,6 @@
- + 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/test/brief-regressions-browser.mjs b/test/brief-regressions-browser.mjs index 1daf6f8..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" }); diff --git a/test/channel-surfaces.mjs b/test/channel-surfaces.mjs index cba0f0b..f6afd56 100644 --- a/test/channel-surfaces.mjs +++ b/test/channel-surfaces.mjs @@ -206,7 +206,7 @@ test("channel UI source exposes file-backed Cowork, traditional Files, audio pre 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(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 legible light surface without changing its dark treatment"); + 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"); diff --git a/test/cowork-browser.mjs b/test/cowork-browser.mjs index 7d7104a..54be6c9 100644 --- a/test/cowork-browser.mjs +++ b/test/cowork-browser.mjs @@ -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; diff --git a/test/desktop.mjs b/test/desktop.mjs index 09b4ae3..c752e9d 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -100,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\(\)/); From e7c6c4a6f0ab36b576ccc0bdda0454c7c340bbdb Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 28 Jul 2026 21:03:38 +0000 Subject: [PATCH 4/4] Update mobile gateway browser contract --- test/mobile.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/mobile.mjs b/test/mobile.mjs index 506b610..a229bc1 100644 --- a/test/mobile.mjs +++ b/test/mobile.mjs @@ -247,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);