From e9f35f47ce9cb36dd54c0ed78efb3a475b7e6f45 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 28 Jul 2026 06:25:40 +0000 Subject: [PATCH 1/2] Release 0.0.22: mobile gateways and product fixes --- CHANGELOG.md | 61 ++++++++- README.md | 22 ++-- .../onehelm/mobile/InstanceGatewayPlugin.java | 71 +++++++++++ .../onehelm/mobile/MainActivity.java | 25 +++- capacitor.config.json | 5 +- ios/App/App.xcodeproj/project.pbxproj | 12 +- ios/App/App/Base.lproj/Main.storyboard | 2 +- ios/App/App/GatewayViewController.swift | 86 +++++++++++++ mobile-gateway/error.html | 8 ++ mobile-gateway/index.html | 63 ++++++++++ package-lock.json | 59 ++++++++- package.json | 3 +- public/index.html | 6 +- src/client/api.ts | 7 +- src/client/app.ts | 77 +++++++----- src/client/cowork-editors.ts | 12 +- src/client/cowork.ts | 118 ++++++++++++++++-- src/client/mobile.ts | 35 +++--- src/client/routing.ts | 61 +++++---- src/client/styles.css | 36 +++++- src/server/bots.ts | 34 +++-- src/server/channel-computers.ts | 2 +- src/server/db.ts | 6 +- src/server/index.ts | 44 +++++-- src/server/routing.ts | 75 ++++++++++- src/server/store.ts | 33 +++-- src/server/thread-audit.ts | 42 ++++--- test/brief-regressions-browser.mjs | 67 +++++++++- test/channel-computers.mjs | 2 +- test/channel-surfaces.mjs | 4 + test/cowork-browser.mjs | 37 +++++- test/mobile.mjs | 25 +++- test/native-world.mjs | 46 ++++++- test/routing-ui-contract.mjs | 20 +++ test/thread-audit.mjs | 36 +++--- 35 files changed, 1041 insertions(+), 201 deletions(-) create mode 100644 android/app/src/main/java/com/gitcommit90/onehelm/mobile/InstanceGatewayPlugin.java create mode 100644 ios/App/App/GatewayViewController.swift create mode 100644 mobile-gateway/error.html create mode 100644 mobile-gateway/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index ac11693..27fb0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.22] - 2026-07-28 + +### Added + +- Presentations now define a visible, locked printable boundary on every + slide, defaulting to 1500 × 1000 with configurable dimensions. The + Excalidraw menu exports the whole bounded deck as one real multi-page PDF and + excludes material outside each printable page. +- Android and iOS now package only a minimal HTTPS instance connection and + recovery shell. After selection they load the chosen instance's live + frontend directly, preserve native secure sessions, notifications, links, + attachments, microphone, keyboard, and safe-area behavior, and confine the + native bridge to an exact scheme/host/effective-port match. + +### Changed + +- Fresh browser profiles start in light mode, while retaining the existing + user-controlled dark-mode switch. +- The Cowork section tabs are centered at normal widths. +- Routes now presents a spacious bottom-to-top request flow through **1Helm + Router** into a stable arc of ChatGPT, Claude, Antigravity, xAI, OpenRouter, + NVIDIA, Cloudflare, and GLM. Dotted live paths and the request ledger keep + requested model policy separate from the routed provider or fallback. +- Structured question choices now show an accessible pressed state with a + strong ring, fill, inset accent, and checked indicator in both themes. + +### Fixed + +- **Log Out** is now available in the profile menu. Native profiles also retain + their separate **Disconnect** action for erasing the selected instance and + secure session. +- Every composer now displays and submits the authoritative effective model + policy using thread → channel → personal → workspace → agent precedence. + Personal overrides are labeled and can be cleared with **Use workspace + default**; stale submissions are rejected before admission, and queued turns + retain the model and provider snapshot they were admitted with. +- Silent thread audits use their own `system-*` routing identity and the + Captain-scoped provider fabric, so concurrent user requests cannot be + mislabeled as maintenance. Router fallback remains a provider outcome rather + than appearing to change the requested model mid-turn. +- HTTP browser instances now use `ws:` while HTTPS and native instances use + `wss:`. Fast Cowork collaboration sync, detached Files test rows, and legacy + presentation element ordering are also handled deterministically. + +### Tests + +- Native integration coverage proves visible personal-policy execution, + immediate restoration of the workspace default, stale-policy rejection, + durable admission metadata, and queued-model immutability across a later + preference change. +- Browser coverage verifies profile Log Out, first-profile light mode, centered + Cowork tabs, selected answers in both themes, the literal eight-provider + Routes geometry, the locked presentation boundary, configurable page size, + and a parsed two-page 1600 × 900 PDF export. +- Mobile contracts prove only gateway assets are packaged, HTTPS origins are + normalized, native sessions stay secure, and Android/iOS navigation cannot + broaden bridge access through an origin-prefix attack. + ## [0.0.21] - 2026-07-27 ### Fixed @@ -642,7 +700,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.21...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.22...HEAD +[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 [0.0.19]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.19 diff --git a/README.md b/README.md index e1af4ad..53f1ee7 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,11 @@ acceptance from the same source commit. The mobile apps are thin, native gateways to a 1Helm you already run. Install and finish setup on a supported Mac, Windows, or Linux host first, give that -host an HTTPS address, then enter the address, username, and password in the -mobile app. The password is used only for sign-in and is never retained; the -resulting session is stored in the iOS Keychain or encrypted with a key held by -Android Keystore. +host an HTTPS address, then select that address in the app. The app loads the +selected instance's live frontend directly, so browsers, iPhone, iPad, and +Android always use the same current interface. Sign-in happens on that live +frontend; the password is never retained and the resulting session is stored +in the iOS Keychain or encrypted with a key held by Android Keystore. - iPhone and iPad use the App Store build under the stable bundle identifier `com.gitcommit90.onehelm.mobile`. @@ -155,9 +156,11 @@ Android Keystore. you used to open the file. Verify the release SHA-256 and install it; future releases signed by the same permanent 1Helm certificate install in place. - The native clients require HTTPS, do not contain or initialize the 1Helm - server, and do not retain host data or provider credentials beyond the - selected server address and secure session token. Use **Disconnect** in the - profile menu to erase both from the device. + server or a frozen copy of its product frontend, and do not retain host data + or provider credentials beyond the selected server address and secure + session token. Native bridge access is restricted to that exact scheme, + host, and port. Use **Disconnect** in the profile menu to erase both from the + device. - iPhone notifications are opt-in under **Settings → Notifications**. Device registration belongs to the signed-in account, per-channel mute still wins, and tapping an update opens its channel or thread. iOS camera and photo @@ -252,6 +255,9 @@ and an audit trail. A prompt saying “use this service” is not a connector. `/workspace/presentations`, with one consistent file rail, live collaborative text editors, embedded whiteboard and slide canvases, and an optional channel-agent panel that starts an ordinary thread with the open file path. +- Presentations define a visible, locked printable area (1500 × 1000 by + default), allow custom page dimensions, and export the complete bounded slide + deck as one multi-page PDF while excluding work outside each page. - Quick Note in the top bar for capturing a Markdown note into `/workspace/notes` without leaving or repositioning the current channel view. - Durable files, threads, curated memory, Mnemosyne long-term recall, @@ -301,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.21` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.22` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/android/app/src/main/java/com/gitcommit90/onehelm/mobile/InstanceGatewayPlugin.java b/android/app/src/main/java/com/gitcommit90/onehelm/mobile/InstanceGatewayPlugin.java new file mode 100644 index 0000000..e28c4b7 --- /dev/null +++ b/android/app/src/main/java/com/gitcommit90/onehelm/mobile/InstanceGatewayPlugin.java @@ -0,0 +1,71 @@ +package com.gitcommit90.onehelm.mobile; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +@CapacitorPlugin(name = "InstanceGateway") +public class InstanceGatewayPlugin extends Plugin { + static final String PREFS = "onehelm_instance_gateway"; + static final String SERVER = "selected_https_origin"; + + static String validOrigin(String raw) { + if (raw == null) return null; + Uri uri = Uri.parse(raw.trim()); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null) return null; + String path = uri.getPath(); + if (path != null && !path.isEmpty() && !"/".equals(path)) return null; + return new Uri.Builder().scheme("https").encodedAuthority(uri.getEncodedAuthority()).build().toString(); + } + + private static int effectivePort(Uri uri) { + if (uri.getPort() >= 0) return uri.getPort(); + return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : "http".equalsIgnoreCase(uri.getScheme()) ? 80 : -1; + } + + private static boolean sameOrigin(Uri left, Uri right) { + return left != null && right != null && left.getScheme() != null && right.getScheme() != null + && left.getHost() != null && right.getHost() != null + && left.getScheme().equalsIgnoreCase(right.getScheme()) + && left.getHost().equalsIgnoreCase(right.getHost()) + && effectivePort(left) == effectivePort(right); + } + + @Override + public Boolean shouldOverrideLoad(Uri url) { + String scheme = url == null ? "" : String.valueOf(url.getScheme()).toLowerCase(); + if ("data".equals(scheme) || "blob".equals(scheme)) return false; + if (!"http".equals(scheme) && !"https".equals(scheme)) return null; + String selected = validOrigin(getContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(SERVER, null)); + // The bundled connection/error shell and the exact selected origin are + // the only HTTP(S) documents allowed inside this bridge. External + // links are opened by the shared frontend before navigation begins. + return !(sameOrigin(url, Uri.parse("https://localhost")) || (selected != null && sameOrigin(url, Uri.parse(selected)))); + } + + @PluginMethod + public void getServer(PluginCall call) { + String origin = validOrigin(getContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(SERVER, null)); + JSObject result = new JSObject(); result.put("origin", origin == null ? "" : origin); call.resolve(result); + } + + @PluginMethod + public void selectServer(PluginCall call) { + String origin = validOrigin(call.getString("origin")); + if (origin == null) { call.reject("Choose a valid HTTPS root address."); return; } + getContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(SERVER, origin).apply(); + JSObject result = new JSObject(); result.put("origin", origin); call.resolve(result); + getActivity().runOnUiThread(() -> { Intent intent = getActivity().getIntent(); getActivity().finish(); getActivity().startActivity(intent); }); + } + + @PluginMethod + public void clearServer(PluginCall call) { + getContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(SERVER).apply(); call.resolve(); + getActivity().runOnUiThread(() -> { Intent intent = getActivity().getIntent(); getActivity().finish(); getActivity().startActivity(intent); }); + } +} diff --git a/android/app/src/main/java/com/gitcommit90/onehelm/mobile/MainActivity.java b/android/app/src/main/java/com/gitcommit90/onehelm/mobile/MainActivity.java index b5099ce..31ffa19 100644 --- a/android/app/src/main/java/com/gitcommit90/onehelm/mobile/MainActivity.java +++ b/android/app/src/main/java/com/gitcommit90/onehelm/mobile/MainActivity.java @@ -1,5 +1,28 @@ package com.gitcommit90.onehelm.mobile; +import android.content.Context; +import android.os.Bundle; import com.getcapacitor.BridgeActivity; +import com.getcapacitor.CapConfig; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + registerPlugin(InstanceGatewayPlugin.class); + String selected = InstanceGatewayPlugin.validOrigin(getSharedPreferences(InstanceGatewayPlugin.PREFS, Context.MODE_PRIVATE).getString(InstanceGatewayPlugin.SERVER, null)); + if (selected != null) { + config = new CapConfig.Builder(this) + .setServerUrl(selected) + .setErrorPath("error.html") + .setAndroidScheme("https") + .setAppendedUserAgentString("1HelmMobile") + .setBackgroundColor("#111318") + .setAllowMixedContent(false) + .setWebContentsDebuggingEnabled(false) + .setUseLegacyBridge(false) + .setZoomableWebView(false) + .create(); + } + super.onCreate(savedInstanceState); + } +} diff --git a/capacitor.config.json b/capacitor.config.json index 2d662b9..9093ce9 100644 --- a/capacitor.config.json +++ b/capacitor.config.json @@ -1,7 +1,7 @@ { "appId": "com.gitcommit90.onehelm.mobile", "appName": "1Helm", - "webDir": "public", + "webDir": "mobile-gateway", "loggingBehavior": "none", "backgroundColor": "#111318", "zoomEnabled": false, @@ -10,7 +10,8 @@ "hostname": "localhost", "iosScheme": "capacitor", "androidScheme": "https", - "cleartext": false + "cleartext": false, + "errorPath": "error.html" }, "ios": { "contentInset": "never", diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 5fc83d5..437f757 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + A1E100022026072800000001 /* GatewayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E100022026072800000002 /* GatewayViewController.swift */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; @@ -23,6 +24,7 @@ 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + A1E100022026072800000002 /* GatewayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayViewController.swift; sourceTree = ""; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -66,6 +68,7 @@ children = ( 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + A1E100022026072800000002 /* GatewayViewController.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -160,6 +163,7 @@ buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + A1E100022026072800000001 /* GatewayViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -302,14 +306,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 22; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.19; + MARKETING_VERSION = 0.0.22; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile; PRODUCT_NAME = 1Helm; @@ -326,14 +330,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 22; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.19; + MARKETING_VERSION = 0.0.22; PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile; PRODUCT_NAME = 1Helm; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard index b44df7b..4640a85 100644 --- a/ios/App/App/Base.lproj/Main.storyboard +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -11,7 +11,7 @@ - + diff --git a/ios/App/App/GatewayViewController.swift b/ios/App/App/GatewayViewController.swift new file mode 100644 index 0000000..cf76cd4 --- /dev/null +++ b/ios/App/App/GatewayViewController.swift @@ -0,0 +1,86 @@ +import UIKit +import Capacitor +import WebKit + +private let gatewayServerKey = "onehelm.selected.https.origin" + +private func validatedGatewayOrigin(_ raw: String?) -> String? { + guard let raw, let components = URLComponents(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)), + components.scheme?.lowercased() == "https", components.host != nil, + components.user == nil, components.password == nil, components.query == nil, components.fragment == nil, + components.path.isEmpty || components.path == "/" else { return nil } + var root = components; root.path = ""; return root.url?.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) +} + +@objc(InstanceGatewayPlugin) +class InstanceGatewayPlugin: CAPPlugin, CAPBridgedPlugin { + let identifier = "InstanceGatewayPlugin" + let jsName = "InstanceGateway" + let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "getServer", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "selectServer", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearServer", returnType: CAPPluginReturnPromise) + ] + + @objc func getServer(_ call: CAPPluginCall) { + call.resolve(["origin": validatedGatewayOrigin(UserDefaults.standard.string(forKey: gatewayServerKey)) ?? ""]) + } + + @objc func selectServer(_ call: CAPPluginCall) { + guard let origin = validatedGatewayOrigin(call.getString("origin")) else { call.reject("Choose a valid HTTPS root address."); return } + UserDefaults.standard.set(origin, forKey: gatewayServerKey); call.resolve(["origin": origin]); replaceGatewayRoot() + } + + @objc func clearServer(_ call: CAPPluginCall) { + UserDefaults.standard.removeObject(forKey: gatewayServerKey); call.resolve(); replaceGatewayRoot() + } + + override func shouldOverrideLoad(_ navigationAction: WKNavigationAction) -> NSNumber? { + guard let url = navigationAction.request.url, let scheme = url.scheme?.lowercased() else { return NSNumber(value: true) } + if scheme == "data" || scheme == "blob" { return NSNumber(value: false) } + guard scheme == "http" || scheme == "https" else { return nil } + let selected = validatedGatewayOrigin(UserDefaults.standard.string(forKey: gatewayServerKey)) + let local = bridge?.config.localURL + let exactSelected = selected.flatMap(URL.init(string:)).map { sameOrigin(url, $0) } ?? false + let exactLocal = local.map { sameOrigin(url, $0) } ?? false + // Do not rely on Capacitor's URL-prefix navigation check here: exact + // origin comparison prevents `selected.example.evil` from inheriting + // the selected instance's native bridge. + return NSNumber(value: !(exactSelected || exactLocal)) + } + + private func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool { + let left = URLComponents(url: lhs, resolvingAgainstBaseURL: false) + let right = URLComponents(url: rhs, resolvingAgainstBaseURL: false) + let port: (URLComponents?) -> Int? = { components in + if let explicit = components?.port { return explicit } + if components?.scheme?.lowercased() == "https" { return 443 } + if components?.scheme?.lowercased() == "http" { return 80 } + return nil + } + return left?.scheme?.lowercased() == right?.scheme?.lowercased() + && left?.host?.lowercased() == right?.host?.lowercased() + && port(left) == port(right) + } + + private func replaceGatewayRoot() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + let window = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first + window?.rootViewController = GatewayViewController(); window?.makeKeyAndVisible() + } + } +} + +@objc(GatewayViewController) +class GatewayViewController: CAPBridgeViewController { + override func instanceDescriptor() -> InstanceDescriptor { + let descriptor = super.instanceDescriptor() + descriptor.errorPath = "error.html" + if let origin = validatedGatewayOrigin(UserDefaults.standard.string(forKey: gatewayServerKey)) { descriptor.serverURL = origin } + return descriptor + } + + override func capacitorDidLoad() { + bridge?.registerPluginInstance(InstanceGatewayPlugin()) + } +} diff --git a/mobile-gateway/error.html b/mobile-gateway/error.html new file mode 100644 index 0000000..c49b130 --- /dev/null +++ b/mobile-gateway/error.html @@ -0,0 +1,8 @@ + + + + 1Helm unavailable + + +

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 new file mode 100644 index 0000000..3211c0e --- /dev/null +++ b/mobile-gateway/index.html @@ -0,0 +1,63 @@ + + + + + + + Connect 1Helm + + + +
+ +

Connect to 1Helm

+

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

+
+ + + + +
+ Only the selected HTTPS origin receives native bridge access. Other links open outside the app. +
+ + + + diff --git a/package-lock.json b/package-lock.json index eb2846e..94c4dd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.21", + "version": "0.0.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.21", + "version": "0.0.22", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { @@ -34,6 +34,7 @@ "codemirror": "6.0.2", "docx": "9.7.1", "node-pty": "^1.1.0", + "pdf-lib": "^1.17.1", "react": "18.3.1", "react-dom": "18.3.1", "spectrum-ts": "8.0.0", @@ -3587,6 +3588,36 @@ "@parseaple/bplist": "1.0.1" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/standard-fonts/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@pdf-lib/upng/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/@photon-ai/advanced-imessage": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@photon-ai/advanced-imessage/-/advanced-imessage-0.12.0.tgz", @@ -8901,6 +8932,30 @@ "dev": true, "license": "MIT" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/pe-library": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", diff --git a/package.json b/package.json index 2a16051..d4fed57 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.21", + "version": "0.0.22", "private": true, "type": "module", "license": "AGPL-3.0-only", @@ -80,6 +80,7 @@ "codemirror": "6.0.2", "docx": "9.7.1", "node-pty": "^1.1.0", + "pdf-lib": "^1.17.1", "react": "18.3.1", "react-dom": "18.3.1", "spectrum-ts": "8.0.0", diff --git a/public/index.html b/public/index.html index c1cfb89..c8019d9 100644 --- a/public/index.html +++ b/public/index.html @@ -23,19 +23,19 @@ (function () { window.EXCALIDRAW_ASSET_PATH = "/excalidraw/"; var t = localStorage.getItem("ctrl.theme"); - if (!t) t = matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + if (!t) t = "light"; document.documentElement.classList.add(t); document.documentElement.dataset.workspaceTheme = localStorage.getItem("ctrl.workspaceTheme") || "graphite"; var color = t === "light" ? "#15171c" : "#08090c"; document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - +
- + diff --git a/src/client/api.ts b/src/client/api.ts index 0c64ef2..79e1188 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -14,7 +14,12 @@ export type AgentQuestions = { answered?: number | null; }; export type Message = { id: number; channel_id: number; parent_id: number | null; body: string; created: number; reply_count: number; last_reply: number | null; author: Author; attachments: Attachment[]; progress?: AgentProgress[]; questions?: AgentQuestions | null; photon_conversation_id?: number | null; transport?: "inbound" | "outbound" | "app" }; -export type ModelPolicy = { provider_id: number | null; provider_name: string | null; provider_kind: string | null; model: string; overridden: boolean; editable: boolean }; +export type ModelPolicy = { + provider_id: number | null; provider_name: string | null; provider_kind: string | null; + model: string; requested_model?: string; source?: "thread" | "channel" | "personal" | "workspace" | "agent"; + source_label?: string; personal_model?: string | null; workspace_model?: string; + overridden: boolean; editable: boolean; +}; export type ResidentAgent = { id: number; bot_id: number; kind: "channel" | "skipper"; name: string; display_name: string; status: "ready" | "working" | "waiting" | "paused" | "archived"; diff --git a/src/client/app.ts b/src/client/app.ts index 095519b..fc3fe45 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -7,7 +7,7 @@ import { openOnboarding } from "./onboarding.ts"; import { defaultTerminalComputer, openTerminals, refitChannelTerminals, getTerminalChrome } from "./term.ts"; import { openCreateChannel, renderActivity, renderBoard, renderChannelSettings, renderFiles, renderGlobalThreads, renderMemory, renderNotes, renderTexts, renderThreads, type ChannelView } from "./channel.ts"; import { renderCowork, setActiveCoworkChannel, stageCoworkPath } from "./cowork.ts"; -import { apiUrl, finishNativeLaunch, forgetMobileServer, getServerOrigin, isNativeMobile, serverAssetUrl, setMobileServer } from "./mobile.ts"; +import { apiUrl, finishNativeLaunch, forgetMobileServer, getServerOrigin, isNativeMobile, serverAssetUrl } from "./mobile.ts"; /** Per-channel layout bound to the user profile (server user_ui_state). */ type ChannelUiView = { @@ -726,16 +726,6 @@ function applyMessageDeleted(e: { function renderAuth(): void { clear(root); const err = h("p", { class: "min-h-5 text-sm text-danger" }); - const server = h("input", { - class: "field", - type: "url", - inputmode: "url", - placeholder: "https://your-1helm-server.com", - autocomplete: "url", - autocapitalize: "none", - spellcheck: false, - value: getServerOrigin(), - }) as HTMLInputElement; const u = h("input", { class: "field", placeholder: "username", autocomplete: "username" }); const pw = h("input", { class: "field", type: "password", placeholder: "password", autocomplete: "current-password" }); const submitButton = h("button", { class: "btn-primary w-full py-2.5" }, "Sign in") as HTMLButtonElement; @@ -746,12 +736,6 @@ function renderAuth(): void { try { const password = pw.value; pw.value = ""; - if (isNativeMobile()) { - setMobileServer(server.value); - const compatibility = await api<{ product?: string; mobile_api?: number; has_users?: boolean; setup_complete?: boolean }>("/api/mobile/compatibility"); - if (compatibility.product !== "1Helm" || compatibility.mobile_api !== 1) throw new Error("That address is not a compatible 1Helm server."); - if (!compatibility.has_users || !compatibility.setup_complete) throw new Error("Finish setting up 1Helm on the host before connecting the mobile app."); - } const r = await api<{ token: string; user: User }>("/api/auth/login", { body: { username: u.value, password } }); await setToken(r.token); await boot(); @@ -769,12 +753,11 @@ function renderAuth(): void { h("h1", { class: "mt-5 text-[2rem] font-bold leading-none tracking-[-0.03em] text-fg" }, "1Helm"), h("p", { class: "eyebrow mt-3 text-muted" }, "Native agent workspace")), h("div", { class: "card space-y-3 p-7" }, - h("div", { class: "mb-3" }, h("h2", { class: "font-display text-[1.55rem] leading-tight text-fg" }, isNativeMobile() ? "Connect to your 1Helm" : "Enter the bridge"), h("p", { class: "mt-1.5 text-sm text-muted" }, isNativeMobile() ? "Your 1Helm host must already be set up and reachable over HTTPS." : "Sign in to your workspace.")), - isNativeMobile() ? h("label", { class: "space-y-1 text-xs font-semibold text-fg" }, "Server address", server) : null, + h("div", { class: "mb-3" }, h("h2", { class: "font-display text-[1.55rem] leading-tight text-fg" }, "Enter the bridge"), h("p", { class: "mt-1.5 text-sm text-muted" }, isNativeMobile() ? `Sign in to ${getServerOrigin()}.` : "Sign in to your workspace.")), u, pw, err, submitButton, isNativeMobile() ? h("p", { class: "text-center text-[11px] leading-5 text-faint" }, "Your password is sent only to this server for sign-in and is never saved. The session is stored in your device’s secure key store.") : access)))); submitButton.onclick = () => { void submit(); }; pw.addEventListener("keydown", (ev) => { if ((ev as KeyboardEvent).key === "Enter") submit(); }); - (isNativeMobile() && !server.value ? server : u).focus(); + u.focus(); if (!isNativeMobile()) void renderAccessRequest(access); finishNativeLaunch(); } @@ -1255,6 +1238,15 @@ function openProfile(anchor: HTMLElement): void { h("p", { class: "border-t border-line pt-3 text-xs leading-5 text-muted" }, "1Helm is AGPL-3.0-only. ", h("a", { class: "text-accent hover:underline", href: "https://github.com/gitcommit90/1Helm", target: "_blank", rel: "noopener noreferrer" }, "View source code ↗"))); + pop.append(h("section", { class: "flex items-center justify-between gap-3 border-t border-line pt-3" }, + h("div", {}, h("p", { class: "text-xs font-semibold text-fg" }, "Session"), h("p", { class: "text-[11px] text-muted" }, "Sign out of this 1Helm account.")), + h("button", { class: "btn-subtle min-h-9 shrink-0 px-3 text-xs", dataset: { profileLogout: "" }, onclick: async () => { + await disableNativeNotifications().catch(() => undefined); + await api("/api/auth/logout", { method: "POST" }).catch(() => undefined); + await clearToken(); + close(); + await boot(); + } }, "Log Out"))); if (isNativeMobile()) pop.append(h("section", { class: "flex items-center justify-between gap-3 border-t border-line pt-3" }, h("div", { class: "min-w-0" }, h("p", { class: "text-xs font-semibold text-fg" }, "Connected server"), h("p", { class: "truncate text-[11px] text-muted" }, getServerOrigin())), h("button", { class: "btn-subtle min-h-9 shrink-0 px-3 text-xs", onclick: async () => { @@ -2194,15 +2186,11 @@ function structuredQuestions(message: Message): HTMLElement | null { const buttons: HTMLButtonElement[] = []; const repaint = (): void => buttons.forEach((button) => { const selected = current.values.has(button.dataset.value || ""); - button.classList.toggle("border-accent", selected); - button.classList.toggle("bg-accent", selected); - button.classList.toggle("text-white", selected); - button.classList.toggle("shadow-sm", selected); button.setAttribute("aria-pressed", String(selected)); }); for (const option of question.options) { const button = h("button", { - class: "rounded-lg border border-line bg-surface px-3 py-2 text-left text-sm text-fg transition hover:border-accent/60 hover:bg-hover", + class: "structured-answer-option relative rounded-lg border border-line bg-surface px-3 py-2 pl-10 text-left text-sm text-fg transition hover:border-accent/60 hover:bg-hover", type: "button", "aria-pressed": "false", dataset: { value: option.label }, title: option.description || option.label, onclick: (event: MouseEvent) => { @@ -2211,7 +2199,7 @@ function structuredQuestions(message: Message): HTMLElement | null { else { current.values.clear(); current.values.add(option.label); } repaint(); }, - }, h("span", { class: "font-semibold" }, option.label), option.description ? h("span", { class: "mt-0.5 block text-xs text-muted" }, option.description) : null) as HTMLButtonElement; + }, h("span", { class: "structured-answer-check absolute left-3 top-2.5 grid h-5 w-5 place-items-center rounded-full border border-line text-transparent", "aria-hidden": "true" }, icon("check", 12)), h("span", { class: "font-semibold" }, option.label), option.description ? h("span", { class: "mt-0.5 block text-xs text-muted" }, option.description) : null) as HTMLButtonElement; buttons.push(button); options.append(button); } repaint(); @@ -2909,25 +2897,36 @@ function composer(parentId: number | null): HTMLElement { }) as HTMLButtonElement; const drawModelButton = (): void => { const policy = selectedPolicy; - modelButton.replaceChildren(icon("sliders", 13), h("span", { class: "truncate font-mono text-[11px] font-normal" }, policy?.model || agent?.model || "Choose model")); - modelButton.title = policy ? `${policy.provider_name || "Provider"} · ${policy.model}` : `Inherited model · ${agent?.model || "not configured"}`; + const source = policy?.source_label || (policy ? "Effective policy" : "Loading policy"); + modelButton.replaceChildren(icon("sliders", 13), h("span", { class: "truncate font-mono text-[11px] font-normal" }, policy ? `${source}: ${policy.model}` : agent?.model || "Loading model…")); + modelButton.title = policy ? `${source} · Requested model ${policy.requested_model || policy.model}. Router provider selection and fallback are shown separately in Routes.` : "Loading the effective model policy"; modelButton.disabled = !agent; }; drawModelButton(); - if (parentId) void api<{ policy: ModelPolicy }>(`/api/messages/${parentId}/model-policy`).then((result) => { selectedPolicy = result.policy; drawModelButton(); }).catch(() => undefined); + const refreshPolicy = async (): Promise => { + if (!agent) return; + const result = await api<{ policy: ModelPolicy }>(parentId ? `/api/messages/${parentId}/model-policy` : `/api/channels/${S.channelId}/model-policy`); + selectedPolicy = result.policy; drawModelButton(); + }; + void refreshPolicy().catch(() => undefined); const drawAttach = (): void => { clear(attachBar); pending.forEach((p, i) => attachBar.append(h("span", { class: "file-pill" }, icon("file", 13), p.name, h("button", { class: "text-faint hover:text-danger", onclick: () => { pending.splice(i, 1); drawAttach(); } }, "✕")))); }; const send = async (): Promise => { const body = input.value.trim(); if (!body && !pending.length) return; + if (agent && !selectedPolicy) { + try { await refreshPolicy(); } + catch (error) { void appAlert((error as Error).message || "Could not load the effective model policy."); return; } + } const uploads = pending.slice(); const originalValue = input.value; input.value = ""; input.style.height = "auto"; localStorage.removeItem(draftKey); pending.splice(0, uploads.length); drawAttach(); try { - await api(`/api/channels/${S.channelId}/messages`, { body: { body, parentId, uploads, modelPolicy: selectedPolicy && !parentId ? { provider_id: selectedPolicy.provider_id, model: selectedPolicy.model } : undefined } }); + await api(`/api/channels/${S.channelId}/messages`, { body: { body, parentId, uploads, modelPolicy: selectedPolicy && !parentId && selectedPolicy.source === "thread" ? { provider_id: selectedPolicy.provider_id, model: selectedPolicy.model } : undefined, effectiveModelPolicy: selectedPolicy ? { provider_id: selectedPolicy.provider_id, model: selectedPolicy.model, source: selectedPolicy.source } : undefined } }); } catch (error) { if (!input.value) { input.value = originalValue; input.dispatchEvent(new Event("input")); } pending.unshift(...uploads); drawAttach(); + if (/effective model policy changed/i.test((error as Error).message)) await refreshPolicy().catch(() => undefined); void appAlert((error as Error).message || "Could not send message"); } }; @@ -3013,7 +3012,7 @@ async function composerModelPopover(event: MouseEvent, threadRootId: number | nu provider.addEventListener("change", () => { void load(); }); const save = async (): Promise => { if (!provider.value || !model.value) { status.textContent = "Choose both a provider and a model, or use Inherit."; return; } - let policy: ModelPolicy = { provider_id: agent.provider_id, provider_name: provider.selectedOptions[0]?.textContent || null, provider_kind: "routing", model: model.value, overridden: true, editable: true }; + let policy: ModelPolicy = { provider_id: agent.provider_id, provider_name: provider.selectedOptions[0]?.textContent || null, provider_kind: "routing", model: model.value, requested_model: model.value, source: personalMode ? "personal" : "thread", source_label: personalMode ? "Personal override" : "Thread policy", personal_model: personalMode ? model.value : current?.personal_model, workspace_model: current?.workspace_model, overridden: true, editable: true }; try { if (personalMode) await api("/api/workspace/model-policy", { method: "PATCH", body: { model: policy.model, personal: true } }); else if (threadRootId) policy = (await api<{ policy: ModelPolicy }>(`/api/messages/${threadRootId}/model-policy`, { body: { provider_id: policy.provider_id, model: policy.model } })).policy; @@ -3023,8 +3022,8 @@ async function composerModelPopover(event: MouseEvent, threadRootId: number | nu const inherit = async (): Promise => { try { if (personalMode) { - const result = await api<{ model: string }>("/api/workspace/model-policy", { method: "PATCH", body: { model: "", personal: true } }); - onChange({ provider_id: agent.provider_id, provider_name: agent.provider_name, provider_kind: "routing", model: result.model, overridden: false, editable: true }); + const result = await api<{ model: string; source?: ModelPolicy["source"]; source_label?: string; workspace_model?: string }>("/api/workspace/model-policy", { method: "PATCH", body: { model: "", personal: true } }); + onChange({ provider_id: agent.provider_id, provider_name: agent.provider_name, provider_kind: "routing", model: result.model, requested_model: result.model, source: result.source || "workspace", source_label: result.source_label || "Workspace default", personal_model: null, workspace_model: result.workspace_model || result.model, overridden: false, editable: true }); } else if (threadRootId) { const result = await api<{ policy: ModelPolicy }>(`/api/messages/${threadRootId}/model-policy`, { body: { provider_id: null, model: null } }); onChange(result.policy); @@ -3032,12 +3031,22 @@ async function composerModelPopover(event: MouseEvent, threadRootId: number | nu close(); } catch (error) { status.textContent = (error as Error).message; } }; + const clearPersonal = async (): Promise => { + try { + await api("/api/workspace/model-policy", { method: "PATCH", body: { model: "", personal: true } }); + const result = await api<{ policy: ModelPolicy }>(threadRootId ? `/api/messages/${threadRootId}/model-policy` : `/api/channels/${S.channelId}/model-policy`); + onChange(result.policy); close(); + } catch (error) { status.textContent = (error as Error).message; } + }; pop.append( h("div", { class: "flex items-start justify-between gap-3" }, h("div", {}, h("div", { class: "font-semibold text-fg" }, personalMode ? "My model" : "Thread model"), h("div", { class: "mt-0.5 text-xs text-muted" }, personalMode ? "Uses your personal providers plus accounts explicitly shared with the workspace." : `Future replies from @${agent.name} use this choice.`)), h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close", onclick: close }, icon("x", 16))), h("label", { class: "block space-y-1 text-xs font-semibold text-fg" }, "Provider", provider), h("label", { class: "block space-y-1 text-xs font-semibold text-fg" }, "Model", model), status, - h("div", { class: "flex justify-end gap-2" }, h("button", { class: "btn-subtle min-h-9 px-3 text-xs", onclick: () => { void inherit(); } }, "Use workspace default"), h("button", { class: "btn-primary min-h-9 px-3 text-xs", onclick: () => { void save(); } }, personalMode ? "Use for me" : "Use for thread"))); + h("div", { class: "flex flex-wrap justify-end gap-2" }, + current?.personal_model || personalMode ? h("button", { class: "btn-subtle min-h-9 px-3 text-xs", dataset: { clearPersonalModel: "" }, onclick: () => { void clearPersonal(); } }, "Use workspace default") : null, + !personalMode ? h("button", { class: "btn-subtle min-h-9 px-3 text-xs", onclick: () => { void inherit(); } }, threadRootId ? "Use inherited policy" : "Cancel thread choice") : null, + h("button", { class: "btn-primary min-h-9 px-3 text-xs", onclick: () => { void save(); } }, personalMode ? "Use for me" : "Use for thread"))); document.body.append(pop); setTimeout(() => { document.addEventListener("mousedown", outside); document.addEventListener("keydown", keydown); }, 0); void api<{ models: RoutingModel[] }>("/api/workspace/model-policy").then(({ models }) => { diff --git a/src/client/cowork-editors.ts b/src/client/cowork-editors.ts index bd7bd4a..747c700 100644 --- a/src/client/cowork-editors.ts +++ b/src/client/cowork-editors.ts @@ -12,7 +12,7 @@ import { yaml } from "@codemirror/lang-yaml"; import { yCollab } from "y-codemirror.next"; import React from "react"; import { createRoot, type Root } from "react-dom/client"; -import { Excalidraw } from "@excalidraw/excalidraw"; +import { Excalidraw, MainMenu } from "@excalidraw/excalidraw"; import type { User } from "./api.ts"; import { h } from "./dom.ts"; import type { CoworkDocument } from "./cowork-collaboration.ts"; @@ -139,6 +139,7 @@ export function mountExcalidraw( read: (content: string) => Scene; write: (scene: Scene, content: string) => string; }; + exportPdf?: () => void | Promise; } = {}, ): MountedEditor { const node = h("div", { class: `cowork-excalidraw ${className}`, "aria-label": label, dataset: { excalidrawCanvas: "" } }); @@ -185,7 +186,7 @@ export function mountExcalidraw( isCollaborating: true, zenModeEnabled: Boolean(options.presentation), viewModeEnabled: Boolean(options.viewMode), - UIOptions: { canvasActions: { loadScene: false, saveToActiveFile: false } }, + UIOptions: { canvasActions: { loadScene: false, saveToActiveFile: false, export: false, saveAsImage: false } }, onPointerUpdate: (payload: { pointer: { x: number; y: number; tool: "pointer" | "laser" }; button: "up" | "down" }) => { collaboration.provider.awareness.setLocalStateField("canvasPointer", { ...payload.pointer, button: payload.button }); }, @@ -203,7 +204,12 @@ export function mountExcalidraw( last = content; collaboration.scene.set("json", content); onChange(content); }, }; - root.render(React.createElement(Excalidraw, props)); + const menu = options.exportPdf ? React.createElement(MainMenu, null, + React.createElement(MainMenu.Item, { onSelect: () => { void options.exportPdf?.(); }, value: "export-pdf", children: "Export PDF" }), + React.createElement(MainMenu.Separator), + React.createElement(MainMenu.DefaultItems.ToggleTheme), + React.createElement(MainMenu.DefaultItems.ChangeCanvasBackground)) : null; + root.render(React.createElement(Excalidraw, props, menu)); return { node, focus: () => node.querySelector("canvas")?.focus({ preventScroll: true }), diff --git a/src/client/cowork.ts b/src/client/cowork.ts index 698ff71..2261c9f 100644 --- a/src/client/cowork.ts +++ b/src/client/cowork.ts @@ -3,13 +3,16 @@ import { clear, color, h, icon, initials, md } from "./dom.ts"; import { appAlert, appConfirm, appPrompt, mountSpeechToTextControl, setFocusedSpeechTarget } from "./app.ts"; import { connectCoworkDocument, type CoworkDocument } from "./cowork-collaboration.ts"; import { mountCodeMirror, mountExcalidraw, type MountedEditor } from "./cowork-editors.ts"; +import { exportToCanvas, getCommonBounds } from "@excalidraw/excalidraw"; +import { PDFDocument } from "pdf-lib"; export type CoworkSection = "notes" | "whiteboards" | "code" | "docs" | "presentations"; type EditableFile = ChannelFile & { content: string }; type SlideScene = { elements: readonly unknown[]; appState?: Record; files?: Record }; type DeckSlide = { id: string; name: string; scene: SlideScene }; -type Deck = { type: "1helm-slides"; version: 2; slides: DeckSlide[] }; +type PrintableArea = { width: number; height: number }; +type Deck = { type: "1helm-slides"; version: 3; printableArea: PrintableArea; slides: DeckSlide[] }; type SectionSession = { folder: string; path: string; @@ -62,7 +65,36 @@ function fileIcon(file: ChannelFile, size = 17): SVGElement { } function blankScene(): SlideScene { return { elements: [], appState: { viewBackgroundColor: "#ffffff" }, files: {} }; } -function blankDeck(): Deck { return { type: "1helm-slides", version: 2, slides: [{ id: crypto.randomUUID(), name: "Slide 1", scene: blankScene() }] }; } +const DEFAULT_PRINTABLE_AREA: PrintableArea = { width: 1500, height: 1000 }; +const PRINTABLE_BOUNDARY_ID = "1helm-printable-area-boundary"; +function blankDeck(): Deck { return { type: "1helm-slides", version: 3, printableArea: { ...DEFAULT_PRINTABLE_AREA }, slides: [{ id: crypto.randomUUID(), name: "Slide 1", scene: blankScene() }] }; } + +function printableArea(value: unknown): PrintableArea { + const candidate = value && typeof value === "object" ? value as Partial : {}; + const width = Math.round(Number(candidate.width)); + const height = Math.round(Number(candidate.height)); + return { + width: Number.isFinite(width) ? Math.max(320, Math.min(5000, width)) : DEFAULT_PRINTABLE_AREA.width, + height: Number.isFinite(height) ? Math.max(240, Math.min(5000, height)) : DEFAULT_PRINTABLE_AREA.height, + }; +} + +function printableBoundary(area: PrintableArea): Record { + return { + id: PRINTABLE_BOUNDARY_ID, type: "rectangle", x: 0, y: 0, width: area.width, height: area.height, angle: 0, + strokeColor: "#4c6ef5", backgroundColor: "transparent", fillStyle: "solid", strokeWidth: 2, strokeStyle: "dashed", + roughness: 0, opacity: 100, groupIds: [], frameId: null, roundness: null, seed: 15001000, version: 1, + versionNonce: 15001000, index: "Zz", isDeleted: false, boundElements: null, updated: 1, link: null, locked: true, + }; +} + +function sceneWithPrintableBoundary(scene: SlideScene, area: PrintableArea): SlideScene { + return { ...scene, elements: [printableBoundary(area), ...scene.elements.filter((element: any) => element?.id !== PRINTABLE_BOUNDARY_ID)] }; +} + +function sceneWithoutPrintableBoundary(scene: SlideScene): SlideScene { + return { ...scene, elements: scene.elements.filter((element: any) => element?.id !== PRINTABLE_BOUNDARY_ID) }; +} function starterContent(section: CoworkSection): string { if (section === "whiteboards") return JSON.stringify({ type: "excalidraw", version: 2, elements: [], appState: {}, files: {} }, null, 2); @@ -73,16 +105,16 @@ function starterContent(section: CoworkSection): string { function normalizeDeck(content: string): Deck { const parsed = JSON.parse(content || "{}"); if (parsed?.type === "1helm-slides" && Array.isArray(parsed.slides)) { - return { type: "1helm-slides", version: 2, slides: parsed.slides.length ? parsed.slides.map((slide: Partial, index: number) => ({ id: slide.id || crypto.randomUUID(), name: slide.name || `Slide ${index + 1}`, scene: slide.scene?.elements ? slide.scene : blankScene() })) : blankDeck().slides }; + return { type: "1helm-slides", version: 3, printableArea: printableArea(parsed.printableArea), slides: parsed.slides.length ? parsed.slides.map((slide: Partial, index: number) => ({ id: slide.id || crypto.randomUUID(), name: slide.name || `Slide ${index + 1}`, scene: slide.scene?.elements ? sceneWithoutPrintableBoundary(slide.scene) : blankScene() })) : blankDeck().slides }; } if (Array.isArray(parsed?.slides)) { return { - type: "1helm-slides", version: 2, + type: "1helm-slides", version: 3, printableArea: { ...DEFAULT_PRINTABLE_AREA }, slides: parsed.slides.length ? parsed.slides.map((slide: { title?: string; body?: string }, index: number) => ({ id: crypto.randomUUID(), name: slide.title || `Slide ${index + 1}`, scene: { ...blankScene(), elements: [ - ...(slide.title ? [{ type: "text", id: crypto.randomUUID(), x: 120, y: 120, width: 720, height: 70, text: slide.title, originalText: slide.title, fontSize: 42, fontFamily: 1, textAlign: "center", verticalAlign: "top", strokeColor: "#1b1b1f", backgroundColor: "transparent", fillStyle: "solid", strokeWidth: 1, roughness: 1, opacity: 100, angle: 0, seed: 1, version: 1, versionNonce: 1, index: `a${index}`, isDeleted: false, groupIds: [], frameId: null, roundness: null, boundElements: null, link: null, locked: false, containerId: null, autoResize: true, lineHeight: 1.25 }] : []), - ...(slide.body ? [{ type: "text", id: crypto.randomUUID(), x: 160, y: 240, width: 640, height: 160, text: slide.body, originalText: slide.body, fontSize: 24, fontFamily: 1, textAlign: "left", verticalAlign: "top", strokeColor: "#495057", backgroundColor: "transparent", fillStyle: "solid", strokeWidth: 1, roughness: 1, opacity: 100, angle: 0, seed: 2, version: 1, versionNonce: 2, index: `b${index}`, isDeleted: false, groupIds: [], frameId: null, roundness: null, boundElements: null, link: null, locked: false, containerId: null, autoResize: true, lineHeight: 1.25 }] : []), + ...(slide.title ? [{ type: "text", id: crypto.randomUUID(), x: 120, y: 120, width: 720, height: 70, text: slide.title, originalText: slide.title, fontSize: 42, fontFamily: 1, textAlign: "center", verticalAlign: "top", strokeColor: "#1b1b1f", backgroundColor: "transparent", fillStyle: "solid", strokeWidth: 1, roughness: 1, opacity: 100, angle: 0, seed: 1, version: 1, versionNonce: 1, index: "a0", isDeleted: false, groupIds: [], frameId: null, roundness: null, boundElements: null, link: null, locked: false, containerId: null, autoResize: true, lineHeight: 1.25 }] : []), + ...(slide.body ? [{ type: "text", id: crypto.randomUUID(), x: 160, y: 240, width: 640, height: 160, text: slide.body, originalText: slide.body, fontSize: 24, fontFamily: 1, textAlign: "left", verticalAlign: "top", strokeColor: "#495057", backgroundColor: "transparent", fillStyle: "solid", strokeWidth: 1, roughness: 1, opacity: 100, angle: 0, seed: 2, version: 1, versionNonce: 2, index: "a1", isDeleted: false, groupIds: [], frameId: null, roundness: null, boundElements: null, link: null, locked: false, containerId: null, autoResize: true, lineHeight: 1.25 }] : []), ] }, })) : blankDeck().slides, }; @@ -90,6 +122,40 @@ function normalizeDeck(content: string): Deck { throw new Error("Unsupported presentation file"); } +async function presentationPdf(deck: Deck, filename: string): Promise { + const pdf = await PDFDocument.create(); + const { width, height } = deck.printableArea; + for (const slide of deck.slides) { + const elements = slide.scene.elements.filter((element: any) => { + if (!element || element.id === PRINTABLE_BOUNDARY_ID || element.isDeleted) return false; + const [minX, minY, maxX, maxY] = getCommonBounds([element] as never); + return maxX > 0 && maxY > 0 && minX < width && minY < height; + }); + const output = document.createElement("canvas"); output.width = width; output.height = height; + const context = output.getContext("2d"); + if (!context) throw new Error("This browser could not prepare the PDF canvas."); + context.fillStyle = String(slide.scene.appState?.viewBackgroundColor || "#ffffff"); + context.fillRect(0, 0, width, height); + if (elements.length) { + const [minX, minY] = getCommonBounds(elements as never); + const rendered = await exportToCanvas({ elements: elements as never, appState: { ...slide.scene.appState, exportBackground: false, exportScale: 1 } as never, files: (slide.scene.files || {}) as never, exportPadding: 0 }); + // The printable canvas is the clipping mask. Partially overlapping + // elements retain their in-bounds pixels while content wholly outside + // 0,0 → width,height never reaches the exported page. + context.save(); context.beginPath(); context.rect(0, 0, width, height); context.clip(); + context.drawImage(rendered, minX, minY); context.restore(); + } + const image = await pdf.embedPng(output.toDataURL("image/png")); + const page = pdf.addPage([width, height]); + page.drawImage(image, { x: 0, y: 0, width, height }); + } + const bytes = await pdf.save(); + const blob = new Blob([bytes as BlobPart], { type: "application/pdf" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); link.download = `${filename.replace(/\.slides\.json$/i, "") || "presentation"}.pdf`; link.click(); + window.setTimeout(() => URL.revokeObjectURL(link.href), 1000); +} + function visibleName(path: string): string { return path.split("/").pop() || path; } export function renderCowork(container: HTMLElement, channelId: number, channel: Channel, me: User, onOpenThread: (root: Message) => void, preserveExisting = false): void { @@ -117,7 +183,7 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: let openThreadCallback = onOpenThread; const agentDrafts = new Map(); const shell = h("section", { class: "cowork-shell flex h-full min-h-[34rem] flex-col bg-surface", dataset: { coworkSurface: String(channelId) } }); - const sectionNav = h("nav", { class: "cowork-sections flex shrink-0 gap-1 overflow-x-auto border-b border-line bg-raised/25 px-3", "aria-label": "Cowork sections" }); + const sectionNav = h("nav", { class: "cowork-sections flex w-full shrink-0 justify-start gap-1 overflow-x-auto border-b border-line bg-raised/25 px-3 sm:justify-center", "aria-label": "Cowork sections" }); const breadcrumb = h("nav", { class: "flex min-w-0 flex-1 items-center gap-1 overflow-x-auto font-mono text-[11px]", "aria-label": "Cowork folder path" }); const fileList = h("div", { class: "min-h-0 flex-1 overflow-y-auto p-2", dataset: { coworkFiles: "" } }); const fileActions = h("div", { class: "cowork-file-actions hidden flex-wrap gap-1 border-t border-line p-2", dataset: { coworkFileActions: "" } }); @@ -274,15 +340,26 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: catch { return h("div", { class: "grid flex-1 place-items-center p-8 text-center text-sm text-muted" }, "This file is not a supported 1Helm presentation."); } session.activeSlide = Math.min(session.activeSlide, Math.max(0, deck.slides.length - 1)); const root = h("div", { class: "flex min-h-0 flex-1 flex-col", dataset: { coworkPresentation: "", slideCount: String(deck.slides.length), activeSlide: String(session.activeSlide) } }); - const structureSignature = (next: Deck): string => next.slides.map((slide) => `${slide.id}:${slide.name}`).join("|"); + const structureSignature = (next: Deck): string => `${next.printableArea.width}x${next.printableArea.height}|${next.slides.map((slide) => `${slide.id}:${slide.name}`).join("|")}`; let structure = structureSignature(deck); const writeDeck = (next: Deck): void => { structure = structureSignature(next); const content = JSON.stringify(next, null, 2); session.collaboration!.scene.set("json", content); markChanged(session, content); }; const adapter = { - read: (content: string): SlideScene => normalizeDeck(content).slides[session.activeSlide]?.scene || blankScene(), - write: (scene: SlideScene, content: string): string => { const next = normalizeDeck(content); if (next.slides[session.activeSlide]) next.slides[session.activeSlide].scene = scene; return JSON.stringify(next, null, 2); }, + read: (content: string): SlideScene => { const next = normalizeDeck(content); return sceneWithPrintableBoundary(next.slides[session.activeSlide]?.scene || blankScene(), next.printableArea); }, + write: (scene: SlideScene, content: string): string => { const next = normalizeDeck(content); if (next.slides[session.activeSlide]) next.slides[session.activeSlide].scene = sceneWithoutPrintableBoundary(scene); return JSON.stringify(next, null, 2); }, }; const reopen = (): void => { session.mounted?.destroy(); session.mounted = null; session.view = null; void drawWorkspace(true); }; - const mounted = mountExcalidraw(session.collaboration!, me, "cowork-slide-canvas", `Slide ${session.activeSlide + 1} canvas`, (content) => markChanged(session, content), { adapter }); + let exportPending = false; + const exportPdf = async (): Promise => { + if (exportPending) return; + exportPending = true; status.textContent = `Exporting ${deck.slides.length} PDF page${deck.slides.length === 1 ? "" : "s"}…`; + try { await presentationPdf(normalizeDeck(session.collaboration!.scene.get("json") || session.content), visibleName(session.path)); status.textContent = "PDF exported"; } + 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 }); + 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); const remoteStructure = (): void => { try { const next = normalizeDeck(session.collaboration!.scene.get("json") || ""); @@ -328,7 +405,11 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: overlay.append(h("header", { class: "flex h-12 items-center gap-2 bg-black/80 px-3" }, h("span", { class: "flex-1 truncate text-sm text-white" }, visibleName(session.path)), h("button", { class: "btn-ghost text-white", disabled: at === 0, onclick: () => { at -= 1; paint(); } }, "Previous"), counter, h("button", { class: "btn-ghost text-white", disabled: at === deck.slides.length - 1, onclick: () => { at += 1; paint(); } }, "Next"), h("button", { class: "btn-subtle", onclick: close }, "Close")), stage); document.body.append(overlay); paint(); overlay.onkeydown = (event) => { if (event.key === "Escape") close(); else if (event.key === "ArrowRight" && at < deck.slides.length - 1) { at += 1; paint(); } else if (event.key === "ArrowLeft" && at > 0) { at -= 1; paint(); } }; overlay.tabIndex = -1; overlay.focus(); }; - const toolbar = commonToolbar(session, h("span", { class: "flex-1 text-xs text-muted" }, `${deck.slides.length} slide${deck.slides.length === 1 ? "" : "s"}`), + const width = h("input", { class: "field h-8 w-20 px-2 text-xs", type: "number", min: "320", max: "5000", step: "10", value: String(deck.printableArea.width), "aria-label": "Printable width" }) as HTMLInputElement; + const height = h("input", { class: "field h-8 w-20 px-2 text-xs", type: "number", min: "240", max: "5000", step: "10", value: String(deck.printableArea.height), "aria-label": "Printable height" }) as HTMLInputElement; + const resize = (): void => { const area = printableArea({ width: width.value, height: height.value }); if (area.width === deck.printableArea.width && area.height === deck.printableArea.height) return; deck.printableArea = area; writeDeck(deck); reopen(); }; + width.onchange = resize; height.onchange = resize; + const toolbar = commonToolbar(session, h("div", { class: "flex min-w-0 flex-1 items-center gap-2" }, h("span", { class: "truncate text-xs text-muted" }, `${deck.slides.length} slide${deck.slides.length === 1 ? "" : "s"}`), h("span", { class: "hidden text-[10px] font-semibold uppercase tracking-wide text-faint lg:inline" }, "Printable area"), width, h("span", { class: "text-xs text-muted" }, "×"), height), h("button", { class: "btn-subtle text-xs", onclick: () => { deck.slides.push({ id: crypto.randomUUID(), name: `Slide ${deck.slides.length + 1}`, scene: blankScene() }); session.activeSlide = deck.slides.length - 1; writeDeck(deck); reopen(); } }, icon("plus", 13), "Slide"), h("button", { class: "btn-ghost text-xs", onclick: () => { const copy = structuredClone(deck.slides[session.activeSlide]); copy.id = crypto.randomUUID(); copy.name = `${copy.name} copy`; deck.slides.splice(session.activeSlide + 1, 0, copy); session.activeSlide += 1; writeDeck(deck); reopen(); } }, "Duplicate"), h("button", { class: "btn-ghost text-xs text-danger", disabled: deck.slides.length <= 1, onclick: () => { deck.slides.splice(session.activeSlide, 1); session.activeSlide = Math.min(session.activeSlide, deck.slides.length - 1); writeDeck(deck); reopen(); } }, "Delete"), @@ -366,6 +447,11 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const timeout = window.setTimeout(() => reject(new Error("The collaborative editor could not connect.")), 12_000); const synced = (ready: boolean) => { if (!ready) return; window.clearTimeout(timeout); openingCollaboration?.provider.off("sync", synced); resolve(); }; openingCollaboration!.provider.on("sync", synced); + // A fast local socket can finish its first sync between provider + // construction and listener registration. Treat that durable state + // as completion instead of leaving Cowork on “Opening file…” until + // the timeout despite a healthy connected document. + if (openingCollaboration!.provider.synced) synced(true); }); if (session.loadVersion !== loadVersion || session.path !== openingPath || session.collaboration !== openingCollaboration) return; const shared = openingPath.endsWith(".whiteboard.json") || openingPath.endsWith(".slides.json") ? openingCollaboration.scene.get("json") || "" : openingCollaboration.text.toString(); @@ -418,7 +504,13 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: 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); }, - onclick: () => { selectedEntry = file; drawFileActions(); void loadFiles(); if (file.kind === "file") void openPath(file.path); }, + onclick: () => { + selectedEntry = file; drawFileActions(); + // Opening a file already redraws the rail and workspace together. + // A second concurrent loadFiles() raced that draw, repeatedly + // replacing the clicked node and sometimes leaving the editor unopened. + if (file.kind === "file") void openPath(file.path); else void loadFiles(); + }, }, h("span", { class: file.kind === "directory" ? "text-muted" : "text-accent" }, fileIcon(file)), h("span", { class: "min-w-0 flex-1 truncate text-xs text-fg" }, file.name), diff --git a/src/client/mobile.ts b/src/client/mobile.ts index 8b12c4d..797b6e4 100644 --- a/src/client/mobile.ts +++ b/src/client/mobile.ts @@ -1,17 +1,22 @@ import { App } from "@capacitor/app"; import { Browser } from "@capacitor/browser"; -import { Capacitor } from "@capacitor/core"; +import { Capacitor, registerPlugin } from "@capacitor/core"; import { Keyboard, KeyboardResize } from "@capacitor/keyboard"; import { SecureStorage, KeychainAccess } from "@aparajita/capacitor-secure-storage"; import { SplashScreen } from "@capacitor/splash-screen"; import { StatusBar, Style } from "@capacitor/status-bar"; const SESSION_KEY = "session"; -const SERVER_KEY = "1helm.mobile.server"; const SERVER_PREFIX = "1helm_mobile_"; const OPENROUTER_CALLBACK_PREFIX = "onehelm://openrouter"; type StoredSession = { server: string; token: string }; +type InstanceGateway = { + getServer: () => Promise<{ origin: string }>; + selectServer: (options: { origin: string }) => Promise<{ origin: string }>; + clearServer: () => Promise; +}; +const instanceGateway = registerPlugin("InstanceGateway"); const native = Capacitor.isNativePlatform(); let serverOrigin = ""; @@ -91,36 +96,30 @@ export function normalizeServerOrigin(value: string): string { export function setMobileServer(value: string): string { if (!native) return location.origin; serverOrigin = normalizeServerOrigin(value); - localStorage.setItem(SERVER_KEY, serverOrigin); return serverOrigin; } export async function forgetMobileServer(): Promise { if (!native) return; await removeSecureSession(); + await instanceGateway.clearServer(); serverOrigin = ""; - localStorage.removeItem(SERVER_KEY); } export function apiUrl(path: string): string { if (!native || !path.startsWith("/")) return path; - if (!serverOrigin) throw new Error("Choose a 1Helm server before connecting."); - return `${serverOrigin}${path}`; + if (!serverOrigin || location.origin !== serverOrigin) throw new Error("The selected 1Helm instance is unavailable."); + return path; } export function serverAssetUrl(path: string): string { - if (!native || !path.startsWith("/")) return path; - return serverOrigin ? `${serverOrigin}${path}` : path; + return path; } export function serverWebSocketUrl(path: string): string { - if (!native) { - const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${location.host}${path}`; - } - if (!serverOrigin) throw new Error("Choose a 1Helm server before connecting."); - const target = new URL(serverOrigin); - target.protocol = "wss:"; + const target = new URL(location.origin); + if (native && (!serverOrigin || location.origin !== serverOrigin)) throw new Error("The selected 1Helm instance is unavailable."); + target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; target.pathname = path.split("?")[0] || "/"; target.search = path.includes("?") ? path.slice(path.indexOf("?")) : ""; return target.toString(); @@ -131,9 +130,9 @@ export async function initializeMobileRuntime(): Promise { if (!native) return localStorage.getItem("ctrl.token") || ""; const platform = mobilePlatform(); document.documentElement.dataset.nativeMobile = platform; - serverOrigin = localStorage.getItem(SERVER_KEY) || ""; - try { if (serverOrigin) serverOrigin = normalizeServerOrigin(serverOrigin); } - catch { serverOrigin = ""; localStorage.removeItem(SERVER_KEY); } + const selected = await instanceGateway.getServer().catch(() => ({ origin: "" })); + serverOrigin = normalizeServerOrigin(selected.origin || location.origin); + if (serverOrigin !== location.origin) throw new Error("The native bridge is restricted to the selected 1Helm origin."); await SecureStorage.setKeyPrefix(SERVER_PREFIX); if (mobilePlatform() === "ios") { diff --git a/src/client/routing.ts b/src/client/routing.ts index 4ffc7b6..8be996e 100644 --- a/src/client/routing.ts +++ b/src/client/routing.ts @@ -26,6 +26,17 @@ const providerCopy: Record = { custom: "Any compatible OpenAI-style endpoint", }; +const routeProviderFamilies = [ + { id: "chatgpt", name: "ChatGPT" }, + { id: "claude", name: "Claude" }, + { id: "antigravity", name: "Antigravity" }, + { id: "xai", name: "xAI" }, + { id: "openrouter", name: "OpenRouter" }, + { id: "nvidia", name: "NVIDIA" }, + { id: "cloudflare", name: "Cloudflare" }, + { id: "glm", name: "GLM" }, +] as const; + const fmt = (value: unknown): string => new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(Number(value || 0)); const publicEndpoint = (): string => `${getServerOrigin() || location.origin}/v1`; const providerFamily = (provider: RoutingProvider): string => provider.type === "codex" ? "chatgpt" : provider.type; @@ -316,8 +327,8 @@ function familyRouteMember(familyId: string, model: string, state: RoutingState) } function routingFabric(state: RoutingState): HTMLElement { - const families = [...new Set((state.providers || []).filter((p) => p.enabled !== false).map((p) => isCustom(p) ? "custom" : providerFamily(p)))].slice(0, 8); - const nodes = families.length ? families : ["chatgpt", "claude", "openrouter"]; + const connectedFamilies = new Set((state.providers || []).filter((provider) => provider.enabled !== false && !isCustom(provider)).map(providerFamily)); + 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); @@ -325,35 +336,35 @@ function routingFabric(state: RoutingState): HTMLElement { const activeFamily = String(request?.providerType || "").replace(/^codex$/, "chatgpt"); 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 124"); svg.setAttribute("class", "routing-fabric-svg"); - svg.setAttribute("role", "img"); svg.setAttribute("aria-label", "Live dotted flow from requests through the 1Helm Router to the routed provider"); + svg.setAttribute("viewBox", "0 0 720 280"); svg.setAttribute("class", "routing-fabric-svg"); + svg.setAttribute("aria-hidden", "true"); const svgEl = (tag: K, attrs: Record): SVGElementTagNameMap[K] => { const element = document.createElementNS("http://www.w3.org/2000/svg", tag); for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, value); return element; }; const path = (d: string, live: boolean): SVGPathElement => svgEl("path", { d, class: `routing-fabric-path ${live ? "is-live" : "is-idle"}` }); - svg.append(path("M 112 62 C 205 62, 226 62, 302 62", inFlight > 0)); - const providerPoints = nodes.map((id, index) => ({ id, y: nodes.length === 1 ? 62 : 14 + index * (96 / (nodes.length - 1)) })); - for (const point of providerPoints) svg.append(path(`M 418 62 C 486 62, 500 ${point.y}, 570 ${point.y}`, inFlight > 0 && (!activeFamily || point.id === activeFamily))); - svg.append(svgEl("rect", { x: "12", y: "38", width: "100", height: "48", rx: "14", class: "routing-fabric-node" })); - const requestTitle = svgEl("text", { x: "62", y: "57", "text-anchor": "middle", class: "routing-fabric-hub-label" }); requestTitle.textContent = "REQUESTS"; svg.append(requestTitle); - const requestCount = svgEl("text", { x: "62", y: "73", "text-anchor": "middle", class: "routing-fabric-hub-label" }); requestCount.textContent = inFlight ? `${inFlight} IN FLIGHT` : "READY"; svg.append(requestCount); - svg.append(svgEl("rect", { x: "302", y: "33", width: "116", height: "58", rx: "18", class: "routing-fabric-hub" })); - const routerTitle = svgEl("text", { x: "360", y: "57", "text-anchor": "middle", class: "routing-fabric-hub-label" }); routerTitle.textContent = "1HELM ROUTER"; svg.append(routerTitle); - const routerStatus = svgEl("text", { x: "360", y: "74", "text-anchor": "middle", class: "routing-fabric-hub-label" }); routerStatus.textContent = inFlight ? "ROUTING LIVE" : "POOL · FAIL OVER"; svg.append(routerStatus); - for (const point of providerPoints) { - svg.append(svgEl("circle", { cx: "586", cy: String(point.y), r: "11", class: "routing-fabric-node" })); - const label = svgEl("text", { x: "605", y: String(point.y + 3), class: "routing-fabric-hub-label" }); label.textContent = familyLabel(point.id, state).slice(0, 16).toUpperCase(); svg.append(label); - } - const flow = h("div", { class: "min-w-0" }, svg, - h("div", { class: "routing-fabric-legend" }, - h("span", { class: `routing-fabric-chip ${inFlight ? "is-live" : ""}` }, `${inFlight || 0} active`), - ...nodes.map((id) => h("span", { class: `routing-fabric-chip ${activeFamily === id ? "border-accent text-accent is-live" : ""}` }, providerMark(id, 14), familyLabel(id, state))))); + const providerPoints = nodes.map((provider, index) => { + const x = 65 + index * (590 / (nodes.length - 1)); + const center = (nodes.length - 1) / 2; + const y = 46 + Math.abs(index - center) * 5; + return { ...provider, x, y }; + }); + svg.append(path("M 360 248 C 360 225, 360 205, 360 185", inFlight > 0)); + for (const point of providerPoints) svg.append(path(`M 360 135 C 360 96, ${point.x} 108, ${point.x} ${point.y + 25}`, inFlight > 0 && (!activeFamily || point.id === activeFamily))); + const providers = h("div", { class: "routing-live-providers", "aria-label": "Provider network" }, ...providerPoints.map((point) => h("div", { + class: `routing-live-provider ${connectedFamilies.has(point.id) ? "is-connected" : ""} ${activeFamily === point.id ? "is-active" : ""}`, + title: `${point.name} · ${connectedFamilies.has(point.id) ? "Connected" : "Not connected"}`, + style: `left:${(point.x / 720) * 100}%;top:${point.y - 22}px`, + }, providerMark(point.id, 24), h("span", {}, point.name)))); + const router = h("div", { class: `routing-live-router ${inFlight ? "is-active" : ""}` }, icon("shuffle", 21), h("span", {}, "1Helm Router"), h("small", {}, inFlight ? "Routing live" : "Pool · fail over")); + const source = h("div", { class: `routing-live-source ${inFlight ? "is-active" : ""}` }, h("span", { class: "routing-live-pulse", "aria-hidden": "true" }), h("strong", {}, "Requests"), h("small", {}, `${inFlight} in flight`)); + const flow = h("div", { class: "routing-live-stage", role: "img", "aria-label": `Bottom-to-top live route map: ${inFlight} requests through 1Helm Router to ${nodes.length} providers` }, + h("div", { class: "routing-live-label" }, "Provider network"), svg, providers, router, source); const status = !request ? h("p", { class: "mt-2 text-center text-xs text-muted" }, "Waiting for the next routed request…") : h("div", { class: "mt-2 grid gap-1 rounded-lg border border-line bg-surface px-3 py-2 text-xs sm:grid-cols-[1fr_auto]" }, - h("div", { class: "min-w-0" }, h("span", { class: "font-semibold text-fg" }, String(request.model || "Request")), h("span", { class: "ml-2 text-muted" }, request.providerName ? `→ ${String(request.providerName)}` : latest?.type === "started" ? "choosing a destination…" : "routing…")), + h("div", { class: "min-w-0" }, h("span", { class: "font-semibold text-fg" }, request.initiator === "system" ? `System work · ${String(request.work_kind || "maintenance").replace(/-/g, " ")}` : `Requested · ${String(request.model || "model")}`), h("span", { class: "ml-2 text-muted" }, request.providerName ? `Routed via ${String(request.providerName)}` : latest?.type === "started" ? "choosing a provider…" : "routing…")), h("span", { class: `font-mono ${latest?.type === "finished" && Number(request.status || 200) >= 400 ? "text-danger" : "text-accent"}` }, latest?.type === "finished" ? `${String(request.outcome || "finished")} · ${Math.max(0, Number(request.finishedAt || Date.now()) - Number(request.startedAt || Date.now()))}ms` : String(latest?.type || "active"))); return h("div", { class: "routing-fabric", title: "Live requests routed by this workspace" }, flow, status); } @@ -368,7 +379,7 @@ export async function openRoutingPopover(eventOrAnchor: Event | Element): Promis closeRoutingPopover?.(); const state = await api("/api/routing/state"); const rect = anchor?.getBoundingClientRect(); - const width = Math.min(420, Math.max(300, window.innerWidth - 24)); + const width = Math.min(760, Math.max(300, window.innerWidth - 24)); const left = rect ? Math.min(window.innerWidth - width - 12, Math.max(12, rect.right - width)) : window.innerWidth - width - 12; const top = rect ? Math.min(window.innerHeight - 180, rect.bottom + 8) : 48; const popover = h("aside", { class: "card fixed z-[80] max-h-[min(80vh,44rem)] overflow-auto p-3 shadow-xl", style: `width:${width}px;left:${left}px;top:${Math.max(8, top)}px`, dataset: { routingPopover: "" }, role: "dialog", "aria-label": "Live 1Helm Router activity" }); @@ -393,7 +404,7 @@ export async function openRoutingPopover(eventOrAnchor: Event | Element): Promis const at = finished || Number(request.routedAt || request.startedAt || 0); list.append(h("div", { class: "routing-event" }, h("span", { class: `routing-event-dot ${status >= 400 ? "is-error" : ""}` }), - h("span", { class: "min-w-0 flex-1" }, h("span", { class: "block truncate text-xs font-semibold text-fg" }, String(request.model || "Request")), h("span", { class: "block truncate text-[10px] text-muted" }, String(request.providerName || request.providerType || (finished ? request.outcome || "Completed" : "Choosing provider…")))), + h("span", { class: "min-w-0 flex-1" }, h("span", { class: "block truncate text-xs font-semibold text-fg" }, request.initiator === "system" ? `System · ${String(request.work_kind || "maintenance").replace(/-/g, " ")}` : `Requested · ${String(request.model || "model")}`), h("span", { class: "block truncate text-[10px] text-muted" }, request.providerName || request.providerType ? `Routed via ${String(request.providerName || request.providerType)}` : String(finished ? request.outcome || "Completed" : "Choosing provider…"))), h("time", { class: "font-mono text-[10px] text-faint" }, at ? timeLabel(at) : "live"))); } const endpoint = publicEndpoint(); @@ -449,7 +460,7 @@ function sourceCatalog(state: RoutingState, refresh: () => Promise, confir const imageRow = h("div", { class: "mb-4 flex items-start gap-3 rounded-lg border border-line bg-raised/40 px-4 py-3" }, h("span", { class: `mt-1 h-2.5 w-2.5 shrink-0 rounded-full ${chatgptConnected ? "bg-ok" : "bg-faint"}` }), h("span", { class: "min-w-0 flex-1" }, h("span", { class: "block text-sm font-semibold text-fg" }, "Image Generation · ChatGPT family"), h("span", { class: "mt-0.5 block text-xs leading-5 text-muted" }, chatgptConnected ? "Available automatically through the connected ChatGPT account." : "Connect a ChatGPT subscription account to make this workspace capability available."))); - return h("div", {}, heading("Provider fabric", "Requests → 1Helm → Providers", "Connect subscriptions and API keys once. Watch active work flow through 1Helm's account pool and into enabled providers."), routingFabric(state), imageRow, list); + return h("div", {}, heading("Provider fabric", "Requests rise through 1Helm Router", "Connect subscriptions and API keys once. Watch work flow bottom-to-top from each request, through the router, into an enabled provider."), routingFabric(state), imageRow, list); } function keyedForm(id: string, label: string, preset: RoutingState["keyedPresets"][number] | undefined, refresh: () => Promise): HTMLElement { diff --git a/src/client/styles.css b/src/client/styles.css index eed68f3..72980fb 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -450,7 +450,7 @@ select.field { border-radius: 9px; background: color-mix(in srgb, var(--c-raised) 45%, var(--c-surface)); } -.routing-fabric-svg { display: block; width: 100%; max-height: 7.5rem; } +.routing-fabric-svg { position: absolute; z-index: 1; inset: 0; display: block; width: 100%; height: 100%; overflow: visible; pointer-events: none; } .routing-fabric-path { fill: none; stroke: color-mix(in srgb, var(--c-accent) 55%, var(--c-line)); @@ -495,6 +495,26 @@ select.field { color: var(--c-muted); font-size: 11px; } +.routing-live-stage { position: relative; height: 17.5rem; min-width: 0; overflow: hidden; border-radius: .75rem; background: radial-gradient(circle at 50% 38%, color-mix(in srgb, var(--c-accent) 8%, transparent), transparent 45%); } +.routing-live-label { position: absolute; z-index: 4; top: .2rem; left: .25rem; color: var(--c-faint); font-family: var(--font-mono); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; } +.routing-live-providers { position: absolute; z-index: 3; inset: 0; pointer-events: none; } +.routing-live-provider { position: absolute; display: grid; width: 5.3rem; justify-items: center; gap: .2rem; transform: translateX(-50%); color: var(--c-muted); transition: color .18s ease, transform .18s ease; } +.routing-live-provider > :first-child { display: grid; width: 2.7rem; height: 2.7rem; place-items: center; border: 1px solid var(--c-line); border-radius: .85rem; background: var(--c-surface); box-shadow: 0 8px 22px -14px #000; filter: grayscale(.8); opacity: .5; } +.routing-live-provider span { width: 100%; overflow: hidden; font-size: 9px; font-weight: 650; text-align: center; text-overflow: ellipsis; white-space: nowrap; } +.routing-live-provider.is-connected > :first-child { filter: none; opacity: 1; } +.routing-live-provider.is-active { color: var(--c-accent); transform: translate(-50%, -2px); } +.routing-live-provider.is-active > :first-child { border-color: var(--c-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 17%, transparent), 0 8px 22px -14px #000; filter: none; opacity: 1; } +.routing-live-router, .routing-live-source { position: absolute; z-index: 3; left: 50%; display: grid; place-items: center; transform: translateX(-50%); } +.routing-live-router { top: 7.2rem; grid-template-columns: 1.4rem auto; min-width: 9.5rem; column-gap: .5rem; padding: .65rem .85rem; border: 1px solid var(--c-accent); border-radius: 1rem; background: color-mix(in srgb, var(--c-accent) 20%, var(--c-surface)); color: var(--c-fg); box-shadow: 0 12px 30px -20px #000; } +.routing-live-router > svg { grid-row: 1 / span 2; color: var(--c-accent); } +.routing-live-router span, .routing-live-source strong { justify-self: start; font-size: 12px; } +.routing-live-router small, .routing-live-source small { justify-self: start; color: var(--c-muted); font-family: var(--font-mono); font-size: 8px; letter-spacing: .06em; text-transform: uppercase; } +.routing-live-router.is-active { box-shadow: 0 0 0 4px color-mix(in srgb, var(--c-accent) 12%, transparent), 0 14px 32px -20px #000; } +.routing-live-source { top: 13.8rem; grid-template-columns: .75rem auto; min-width: 9rem; column-gap: .5rem; padding: .55rem .8rem; border: 1px solid var(--c-line); border-radius: .8rem; background: var(--c-surface); } +.routing-live-pulse { grid-row: 1 / span 2; width: .5rem; height: .5rem; border-radius: 50%; background: var(--c-faint); } +.routing-live-source.is-active .routing-live-pulse { background: var(--c-accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--c-accent) 16%, transparent); animation: live-request-pulse 1.25s ease-out infinite; } +@keyframes live-request-pulse { 70%, 100% { box-shadow: 0 0 0 9px transparent; } } +@media (prefers-reduced-motion: reduce) { .routing-live-source.is-active .routing-live-pulse { animation: none; } } .routing-pyramid { display: grid; justify-items: center; @@ -900,6 +920,20 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n .cowork-section { display: inline-flex; min-height: 2.65rem; flex-shrink: 0; align-items: center; gap: .4rem; border-bottom: 2px solid transparent; padding: 0 .7rem; font-family: var(--font-mono); font-size: 10px; letter-spacing: .09em; text-transform: uppercase; color: var(--c-faint); } .cowork-section:hover { color: var(--c-fg); } .cowork-section.is-active { border-bottom-color: var(--c-accent); color: var(--c-fg); } +.structured-answer-option[aria-pressed="true"] { + border-color: var(--c-accent); + background: color-mix(in srgb, var(--c-accent) 16%, var(--c-surface)); + color: var(--c-fg); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 24%, transparent), inset 4px 0 0 var(--c-accent); + font-weight: 650; +} +.structured-answer-option[aria-pressed="true"] .structured-answer-check { + border-color: var(--c-accent); + background: var(--c-accent); + color: #fff; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-accent) 18%, transparent); +} +.structured-answer-option:focus-visible { outline: 3px solid color-mix(in srgb, var(--c-accent) 48%, transparent); outline-offset: 2px; } .cowork-workspace { background-image: linear-gradient(var(--c-grid) 1px, transparent 1px), linear-gradient(90deg, var(--c-grid) 1px, transparent 1px); background-size: 24px 24px; } .cowork-editor-toolbar { display: flex; min-height: 3rem; flex-shrink: 0; align-items: center; gap: .5rem; border-bottom: 1px solid var(--c-line); background: color-mix(in srgb, var(--c-surface) 92%, transparent); padding: .45rem .7rem; } .cowork-codemirror { min-height: 0; flex: 1 1 auto; overflow: hidden; background: color-mix(in srgb, var(--c-surface) 95%, transparent); font-size: 13px; } diff --git a/src/server/bots.ts b/src/server/bots.ts index dbdb749..2d576d6 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -1,5 +1,5 @@ import { isMainChannel, q, q1, run, now, tx, type Row } from "./db.ts"; -import { createMessage, serializeMessage, resolveModelForUser, resolveProviderId, botEndpoint, isInternalMessageBody } from "./store.ts"; +import { createMessage, serializeMessage, resolvedModelPolicy, resolveModelForUser, resolveProviderId, botEndpoint, isInternalMessageBody } from "./store.ts"; import { getComputer, execOnComputer } from "./computer.ts"; import { broadcastToChannel, sendToUsers } from "./events.ts"; import { isChatGPTProvider, streamChatGPTCompletion } from "./chatgpt.ts"; @@ -1245,6 +1245,13 @@ function repaintAgentQueue(botId: number, channelId: number, threadRootId: numbe }); } +function requestUserForTurn(triggerId: number, threadRootId: number): number { + return Number(q1( + "SELECT user_id FROM messages WHERE id IN (?,?) AND user_id IS NOT NULL ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1", + triggerId, threadRootId, triggerId, + )?.user_id || 0); +} + export function runBot(bot: Row, channelId: number, triggerId: number, threadRootId: number, fresh: boolean, escalationId?: number, hostAuthorized = false): Promise { const botId = Number(bot.id); const key = turnLane(botId, channelId, threadRootId); @@ -1255,6 +1262,8 @@ export function runBot(bot: Row, channelId: number, triggerId: number, threadRoo const ahead = Number(q1("SELECT COUNT(*) n FROM agent_turns WHERE bot_id=? AND channel_id=? AND thread_root_id=? AND state IN ('queued','running')", botId, channelId, threadRootId)?.n || 0); const queuedTurn: QueuedTurn = { channelId, threadRootId, messageId: 0, progressId: 0 }; const runtimeAgent = agentForBot(botId); + const requestUserId = requestUserForTurn(triggerId, threadRootId); + const admittedPolicy = resolvedModelPolicy(botId, channelId, threadRootId, requestUserId); const admittedAt = now(); const turnId = tx(() => { queuedTurn.messageId = createMessage({ channelId, parentId: threadRootId, botId: Number(bot.id), body: "_Working…_" }); @@ -1266,9 +1275,10 @@ export function runBot(bot: Row, channelId: number, triggerId: number, threadRoo admittedAt, ).lastInsertRowid; return run(`INSERT INTO agent_turns - (bot_id,agent_id,channel_id,trigger_id,thread_root_id,message_id,state,fresh,escalation_id,host_authorized,queued_at) - VALUES (?,?,?,?,?,?,'queued',?,?,?,?)`, - botId, runtimeAgent?.id ?? null, channelId, triggerId, threadRootId, queuedTurn.messageId, fresh ? 1 : 0, escalationId ?? null, hostAuthorized ? 1 : 0, admittedAt).lastInsertRowid; + (bot_id,agent_id,channel_id,trigger_id,thread_root_id,message_id,state,fresh,escalation_id,host_authorized,queued_at,requested_model,requested_provider_id,model_source,request_user_id) + VALUES (?,?,?,?,?,?,'queued',?,?,?,?,?,?,?,?)`, + botId, runtimeAgent?.id ?? null, channelId, triggerId, threadRootId, queuedTurn.messageId, fresh ? 1 : 0, escalationId ?? null, hostAuthorized ? 1 : 0, admittedAt, + String(admittedPolicy.model || ""), admittedPolicy.provider_id ? Number(admittedPolicy.provider_id) : null, String(admittedPolicy.source || ""), requestUserId || null).lastInsertRowid; }); broadcastToChannel(channelId, { type: "message", message: serializeMessage(queuedTurn.messageId), parent: serializeMessage(threadRootId) }); queue.push(queuedTurn); @@ -1367,14 +1377,14 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread // or process shutdown — never an arbitrary wall-clock deadline. const turnSignal = controller.signal; const threadId = threadIdForRoot(threadRootId, channelId) ?? ensureThread(threadRootId, channelId); - const requestUserId = Number(q1( - "SELECT user_id FROM messages WHERE id IN (?,?) AND user_id IS NOT NULL ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1", - triggerId, threadRootId, triggerId, - )?.user_id || 0); - const model = resolveModelForUser(Number(bot.id), channelId, threadRootId, requestUserId); - let endpoint = botEndpoint(Number(bot.id), channelId, threadRootId); - const providerId = resolveProviderId(Number(bot.id), channelId, threadRootId); - const provider = providerId ? q1("SELECT kind, base_url FROM providers WHERE id=?", providerId) : undefined; + const admittedTurn = turnId ? q1("SELECT requested_model,requested_provider_id,request_user_id FROM agent_turns WHERE id=?", turnId) : undefined; + const requestUserId = Number(admittedTurn?.request_user_id || requestUserForTurn(triggerId, threadRootId)); + const model = String(admittedTurn?.requested_model || "") || resolveModelForUser(Number(bot.id), channelId, threadRootId, requestUserId); + const providerId = admittedTurn?.requested_provider_id != null ? Number(admittedTurn.requested_provider_id) : resolveProviderId(Number(bot.id), channelId, threadRootId); + const provider = providerId ? q1("SELECT kind,base_url,api_key FROM providers WHERE id=?", providerId) : undefined; + let endpoint = admittedTurn?.requested_provider_id != null + ? (provider ? { base_url: String(provider.base_url), api_key: String(provider.api_key) } : null) + : botEndpoint(Number(bot.id), channelId, threadRootId); const isChatGPT = isChatGPTProvider(provider); if (providerId && isInternalRoutingProvider(providerId) && requestUserId) endpoint = await routingEndpointForUser(requestUserId); const msgId = preparedMessageId || createMessage({ channelId, parentId: threadRootId, botId: Number(bot.id), body: "_Working…_" }); diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 2d8a129..64404b1 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.21"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.22"; 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/db.ts b/src/server/db.ts index 848ce71..f41f720 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -194,6 +194,10 @@ export function migrate(): void { CREATE INDEX IF NOT EXISTS idx_routing_usage_user_created ON routing_usage_events(user_id,created DESC); `); addColumn("agent_turns", "final_body_hash", "final_body_hash TEXT NOT NULL DEFAULT ''"); + addColumn("agent_turns", "requested_model", "requested_model TEXT NOT NULL DEFAULT ''"); + addColumn("agent_turns", "requested_provider_id", "requested_provider_id INTEGER"); + addColumn("agent_turns", "model_source", "model_source TEXT NOT NULL DEFAULT ''"); + addColumn("agent_turns", "request_user_id", "request_user_id INTEGER"); addColumn("workspace", "installation_id", "installation_id TEXT NOT NULL DEFAULT ''"); addColumn("workspace", "collaboration_enabled", "collaboration_enabled INTEGER NOT NULL DEFAULT 0"); addColumn("workspace", "collaboration_slug", "collaboration_slug TEXT NOT NULL DEFAULT ''"); @@ -1005,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.21"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.22"); // 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 85fedad..4290468 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -485,7 +485,15 @@ function setBotComputers(botId: number, computerIds: unknown[]): void { } } -function postMessage(channelId: number, user: Row, text: string, parentId: number | null, uploads: { token: string; name: string; mime: string; size: number }[], modelPolicy?: { provider_id?: number | null; model?: string | null }): Row { +function postMessage( + channelId: number, + user: Row, + text: string, + parentId: number | null, + uploads: { token: string; name: string; mime: string; size: number }[], + modelPolicy?: { provider_id?: number | null; model?: string | null }, + submittedPolicy?: { provider_id?: number | null; model?: string; source?: string }, +): Row { const channel = q1("SELECT status,kind FROM channels WHERE id=?", channelId); if (!channel) throw new Error("Channel not found."); if (channel.status !== "active") throw new Error("Restore this channel before starting new work."); @@ -497,6 +505,17 @@ function postMessage(channelId: number, user: Row, text: string, parentId: numbe if (!message && !(uploads || []).length) throw new Error("Write a message or attach a file."); if (message.length > 50_000) throw new Error("Messages are limited to 50,000 characters."); if (modelPolicy?.provider_id && !q1("SELECT 1 FROM providers WHERE id=?", Number(modelPolicy.provider_id))) throw new Error("Provider not found."); + const resident = agentForChannel(channelId); + if (submittedPolicy?.model && resident?.bot_id) { + const effective = modelPolicy?.model && !parentId && resident.kind !== "skipper" + ? { provider_id: modelPolicy.provider_id ? Number(modelPolicy.provider_id) : null, model: String(modelPolicy.model), source: "thread" } + : resolvedModelPolicy(Number(resident.bot_id), channelId, parentId, Number(user.id)); + const submittedProviderId = submittedPolicy.provider_id ? Number(submittedPolicy.provider_id) : null; + const effectiveProviderId = effective.provider_id ? Number(effective.provider_id) : null; + if (submittedProviderId !== effectiveProviderId || String(submittedPolicy.model) !== String(effective.model || "") || String(submittedPolicy.source || "") !== String(effective.source || "")) { + throw new Error("The effective model policy changed before this message was submitted. Review the refreshed model label and send again."); + } + } const rootId = parentId || 0; const existingThread = rootId ? q1("SELECT id,stopped_followup_pending FROM threads WHERE root_message_id=? AND channel_id=?", rootId, channelId) : undefined; const stoppedFollowup = Boolean(existingThread?.stopped_followup_pending); @@ -508,11 +527,10 @@ function postMessage(channelId: number, user: Row, text: string, parentId: numbe const actualRootId = parentId || id; const threadId = ensureThread(actualRootId, channelId); if (modelPolicy?.model) { - const agent = agentForChannel(channelId); // Skipper always keeps its workspace-wide policy, including in #main. - if (agent?.bot_id && agent.kind !== "skipper") { + if (resident?.bot_id && resident.kind !== "skipper") { const providerId = modelPolicy.provider_id ? Number(modelPolicy.provider_id) : null; - setModelPolicy(Number(agent.bot_id), "thread", String(actualRootId), providerId, String(modelPolicy.model)); + setModelPolicy(Number(resident.bot_id), "thread", String(actualRootId), providerId, String(modelPolicy.model)); } } run("UPDATE threads SET status='open', updated_at=? WHERE id=? AND status IN ('waiting','resolved','failed')", now(), threadId); @@ -888,7 +906,7 @@ const server = createServer(async (req, res) => { if (!canUseAgentSurfaces(user)) return json(res, 403, { error: "Join an agent channel to use model controls." }); const workspaceModel = String(q1("SELECT default_model FROM workspace WHERE id=1")?.default_model || ""); const personalModel = String(q1("SELECT model FROM user_model_prefs WHERE user_id=?", user.id)?.model || ""); - return json(res, 200, { model: personalModel || workspaceModel, inherited: !personalModel, workspace_model: workspaceModel, models: await routingModels(Number(user.id)) }); + return json(res, 200, { model: personalModel || workspaceModel, requested_model: personalModel || workspaceModel, source: personalModel ? "personal" : "workspace", source_label: personalModel ? "Personal override" : "Workspace default", personal_model: personalModel || null, inherited: !personalModel, workspace_model: workspaceModel, models: await routingModels(Number(user.id)) }); } if (p === "/api/workspace/model-policy" && m === "PATCH") { const b = await jbody(req); @@ -899,7 +917,8 @@ const server = createServer(async (req, res) => { if (model) run(`INSERT INTO user_model_prefs (user_id,model,updated) VALUES (?,?,?) ON CONFLICT(user_id) DO UPDATE SET model=excluded.model,updated=excluded.updated`, user.id, model, now()); else run("DELETE FROM user_model_prefs WHERE user_id=?", user.id); - return json(res, 200, { ok: true, model: model || String(q1("SELECT default_model FROM workspace WHERE id=1")?.default_model || ""), inherited: !model, models: available }); + const workspaceModel = String(q1("SELECT default_model FROM workspace WHERE id=1")?.default_model || ""); + return json(res, 200, { ok: true, model: model || workspaceModel, requested_model: model || workspaceModel, source: model ? "personal" : "workspace", source_label: model ? "Personal override" : "Workspace default", personal_model: model || null, workspace_model: workspaceModel, inherited: !model, models: available }); } if (!model) return json(res, 400, { error: "Choose an enabled model or named route." }); const internalId = await internalRoutingProviderId(); @@ -1665,11 +1684,18 @@ const server = createServer(async (req, res) => { if (viewers.length) suffix.push(`Working with: ${viewers.map((username) => `@${username}`).join(" ")}`); if (suffix.length) body = `${body.trim()}\n\n${suffix.join("\n")}`; } - return json(res, 200, { message: postMessage(cid, user, body, b.parentId ? Number(b.parentId) : null, (b.uploads as never[]) || [], b.modelPolicy as never) }); + return json(res, 200, { message: postMessage(cid, user, body, b.parentId ? Number(b.parentId) : null, (b.uploads as never[]) || [], b.modelPolicy as never, b.effectiveModelPolicy as never) }); } catch (error) { return json(res, 409, { error: (error as Error).message }); } } } + if ((mm = p.match(/^\/api\/channels\/(\d+)\/model-policy$/)) && m === "GET") { + const cid = Number(mm[1]); + if (!canSee(user, cid)) return json(res, 403, { error: "No access" }); + const agent = agentForChannel(cid); + if (!agent?.bot_id) return json(res, 404, { error: "Resident agent not found" }); + return json(res, 200, { policy: resolvedModelPolicy(Number(agent.bot_id), cid, null, Number(user.id)), models: await routingModels(Number(user.id)) }); + } if ((mm = p.match(/^\/api\/messages\/(\d+)\/thread$/)) && m === "GET") { const root = q1("SELECT * FROM messages WHERE id=?", Number(mm[1])); if (!root || !canSee(user, Number(root.channel_id))) return json(res, 404, { error: "Not found" }); @@ -1692,7 +1718,7 @@ const server = createServer(async (req, res) => { if (!root || !canSee(user, Number(root.channel_id))) return json(res, 404, { error: "Not found" }); const agent = agentForChannel(Number(root.channel_id)); if (!agent?.bot_id) return json(res, 404, { error: "Resident agent not found" }); - if (m === "GET") return json(res, 200, { policy: resolvedModelPolicy(Number(agent.bot_id), Number(root.channel_id), Number(root.id)) }); + if (m === "GET") return json(res, 200, { policy: resolvedModelPolicy(Number(agent.bot_id), Number(root.channel_id), Number(root.id), Number(user.id)) }); if (m === "POST") { if (!user.is_admin) return json(res, 403, { error: "Only the Captain can change a thread model." }); if (agent.kind === "skipper") return json(res, 409, { error: "Skipper always uses the workspace-wide model policy." }); @@ -1701,7 +1727,7 @@ const server = createServer(async (req, res) => { if (providerId && !q1("SELECT 1 FROM providers WHERE id=?", providerId)) return json(res, 400, { error: "Provider not found" }); if (b.model && !(await routingModels()).some((candidate) => candidate.id === String(b.model))) return json(res, 400, { error: "That model is disabled or no longer exists." }); setModelPolicy(Number(agent.bot_id), "thread", String(root.id), providerId, b.model ? String(b.model) : null); - return json(res, 200, { policy: resolvedModelPolicy(Number(agent.bot_id), Number(root.channel_id), Number(root.id)) }); + return json(res, 200, { policy: resolvedModelPolicy(Number(agent.bot_id), Number(root.channel_id), Number(root.id), Number(user.id)) }); } } if ((mm = p.match(/^\/api\/messages\/(\d+)\/stop$/)) && m === "POST") { diff --git a/src/server/routing.ts b/src/server/routing.ts index 6a8fda7..fc74fa8 100644 --- a/src/server/routing.ts +++ b/src/server/routing.ts @@ -49,6 +49,7 @@ type RoutingRuntime = { usageAggregate: (period: string) => unknown; stats: () => unknown; listModels: () => { data?: Array<{ id?: string; name?: string; combo?: boolean; providerId?: string | null; owned_by?: string; accountAliases?: string[] }> }; + chatCompletions: (options: Record) => Promise>; }; gateway: { getAddress: () => string | null; @@ -156,6 +157,7 @@ let activityUnsubscribe: (() => void) | null = null; let onActivity: ((activity?: unknown, userId?: number) => void) | null = null; const recentActivity: unknown[] = []; const recentUserActivity = new Map(); +const activeSystemRequests = new Map>>(); type ModelDiscovery = { id: string; name: string; free?: boolean }; type ModelRefreshPreview = { userId: number; providerId: string; models: ModelDiscovery[]; expiresAt: number }; const modelRefreshPreviews = new Map(); @@ -168,12 +170,21 @@ const activeOauthByFamily = new Map(); const userGateways = new Map(); function publishRoutingActivity(activity: unknown, userId = 0): void { + let published = activity; + if (activity && typeof activity === "object") { + const event = activity as { type?: unknown; request?: Record; active?: Array> }; + const systemActive = [...(activeSystemRequests.get(userId)?.values() || [])]; + published = { + ...event, + active: Array.isArray(event.active) ? [...event.active, ...systemActive] : systemActive, + }; + } const history = userId > 0 ? (recentUserActivity.get(userId) || (recentUserActivity.set(userId, []), recentUserActivity.get(userId)!)) : recentActivity; - history.unshift(activity); + history.unshift(published); if (history.length > 30) history.length = 30; - onActivity?.(activity, userId); + onActivity?.(published, userId); } const oauthKey = (userId: number, type: string): string => `${userId}:${type}`; @@ -874,6 +885,7 @@ export async function stopRoutingEngine(): Promise { await Promise.all(gateways.map((entry) => entry.gateway.stop().catch(() => undefined))); recentActivity.length = 0; recentUserActivity.clear(); + activeSystemRequests.clear(); modelRefreshPreviews.clear(); if (target) await target.close({ drainMs: 10_000 }); } @@ -1119,7 +1131,8 @@ export async function routingState(userId = 0, isAdmin = true): Promise, +): Promise { + const gateway = await ensureUserGateway(userId); + const id = `system-${randomBytes(16).toString("hex")}`; + const requests = activeSystemRequests.get(userId) || new Map>(); + activeSystemRequests.set(userId, requests); + let request: Record = { + id, + model: String(body.model || ""), + stream: Boolean(body.stream), + startedAt: Date.now(), + providerId: null, + providerType: null, + providerName: null, + initiator: "system", + work_kind: String(workKind || "maintenance"), + }; + requests.set(id, request); + publishRoutingActivity({ type: "started", request, active: [] }, userId); + let status = 500; + let outcome = "error"; + try { + const result = await gateway.router.chatCompletions({ + body, + onProviderSelected: (provider: Record) => { + request = { ...request, ...provider, routedAt: Date.now() }; + requests.set(id, request); + publishRoutingActivity({ type: "routed", request, active: [] }, userId); + }, + } as never) as unknown as Record; + status = result.ok === false ? Number(result.status || 502) : 200; + outcome = result.ok === false ? "error" : "success"; + const payload = result.ok === false ? result.error : result.openAiJson; + return new Response(JSON.stringify(payload || {}), { + status, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: { message: (error as Error).message || "Routing failed" } }), { + status, + headers: { "content-type": "application/json" }, + }); + } finally { + requests.delete(id); + if (!requests.size) activeSystemRequests.delete(userId); + publishRoutingActivity({ type: "finished", request: { ...request, status, outcome, finishedAt: Date.now() }, active: [] }, userId); + } +} + export function isInternalRoutingProvider(providerId: number | null): boolean { if (!providerId) return false; return String(q1("SELECT kind FROM providers WHERE id=?", providerId)?.kind || "") === INTERNAL_PROVIDER_KIND; diff --git a/src/server/store.ts b/src/server/store.ts index 5d3486f..6c7af01 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -138,8 +138,7 @@ export function resolveModel(botId: number, channelId: number | null, threadRoot } export function resolveModelForUser(botId: number, channelId: number | null, threadRootId: number | null, userId: number): string { - const personal = userId ? String(q1("SELECT model FROM user_model_prefs WHERE user_id=?", userId)?.model || "") : ""; - return personal || resolveModel(botId, channelId, threadRootId); + return String(resolvedModelPolicy(botId, channelId, threadRootId, userId).model || ""); } export function setModelPref(botId: number, scope: string, scopeId: string, model: string | null): void { @@ -162,18 +161,36 @@ export function resolveProviderId(botId: number, channelId: number | null, threa return bot?.provider_id ? Number(bot.provider_id) : null; } -export function resolvedModelPolicy(botId: number, channelId: number, threadRootId: number | null): Record { +export function resolvedModelPolicy(botId: number, channelId: number | null, threadRootId: number | null, userId = 0): Record { + const scoped = (scope: string, scopeId: string): Row | undefined => + q1("SELECT model,provider_id FROM model_prefs WHERE bot_id=? AND scope=? AND scope_id=?", botId, scope, scopeId); + const thread = threadRootId != null ? scoped("thread", String(threadRootId)) : undefined; + const channel = channelId != null ? scoped("channel", String(channelId)) : undefined; + const personalModel = userId ? String(q1("SELECT model FROM user_model_prefs WHERE user_id=?", userId)?.model || "") : ""; + const workspaceModel = String(q1("SELECT default_model FROM workspace WHERE id=1")?.default_model || ""); + const global = scoped("global", ""); + const agentModel = String(q1("SELECT model FROM bots WHERE id=?", botId)?.model || ""); + let source: "thread" | "channel" | "personal" | "workspace" | "agent" = "agent"; + let model = agentModel; + if (thread?.model) { source = "thread"; model = String(thread.model); } + else if (channel?.model) { source = "channel"; model = String(channel.model); } + else if (personalModel) { source = "personal"; model = personalModel; } + else if (workspaceModel) { source = "workspace"; model = workspaceModel; } + else if (global?.model) { source = "agent"; model = String(global.model); } const providerId = resolveProviderId(botId, channelId, threadRootId); const provider = providerId ? q1("SELECT id,name,kind FROM providers WHERE id=?", providerId) : undefined; - const overridden = threadRootId != null && !!q1("SELECT 1 FROM model_prefs WHERE bot_id=? AND scope='thread' AND scope_id=?", botId, String(threadRootId)); - const skipper = q1("SELECT kind FROM agents WHERE bot_id=? AND status<>'deleted'", botId)?.kind === "skipper"; return { provider_id: provider?.id ? Number(provider.id) : null, provider_name: provider?.name ? String(provider.name) : null, provider_kind: provider?.kind ? String(provider.kind) : null, - model: resolveModel(botId, channelId, threadRootId), - overridden, - editable: !skipper, + model, + requested_model: model, + source, + source_label: source === "personal" ? "Personal override" : source === "workspace" ? "Workspace default" : source === "thread" ? "Thread policy" : source === "channel" ? "Channel policy" : "Agent default", + personal_model: personalModel || null, + workspace_model: workspaceModel, + overridden: source === "thread" || source === "channel" || source === "personal", + editable: true, }; } diff --git a/src/server/thread-audit.ts b/src/server/thread-audit.ts index 4279e73..95871ca 100644 --- a/src/server/thread-audit.ts +++ b/src/server/thread-audit.ts @@ -3,6 +3,7 @@ import { botEndpoint, resolveModel } from "./store.ts"; import { broadcastToChannel } from "./events.ts"; import { refreshThreadSummary, agentForChannel } from "./agents.ts"; import { scheduleAgentReview } from "./improvements.ts"; +import { isInternalRoutingProvider, routeSystemRequestForUser } from "./routing.ts"; /** Default: every ~10 minutes Skipper audits thread statuses workspace-wide. */ const CHECK_EVERY_MS = Number(process.env.THREAD_AUDIT_INTERVAL_MS || 10 * 60_000); @@ -229,6 +230,9 @@ async function skipperModelDecisions(dossiers: ThreadDossier[]): Promise model.name === "mock-large").id; + const smallModel = provider.models.find((model) => model.name === "mock-small").id; + await api("/api/setup/complete", { body: { name: "Browser Brief", terminals_enabled: true, provider_id: provider.provider.id, model: largeModel } }, token); + const mainChannel = (await api("/api/channels", {}, token)).channels.find((candidate) => candidate.name === "main"); const channel = (await api("/api/channels", { body: { name: "Visual", purpose: "Exercise the brief regressions." } }, token)).channel; const extensionlessNote = await api(`/api/channels/${channel.id}/notes`, { body: { name: "Quiet refresh proof", content: "# Durable note\n\nStart here." } }, token); ok(extensionlessNote.note.name === "Quiet refresh proof.md", "extensionless note titles are normalized to .md by the API"); @@ -102,6 +105,7 @@ try { // Retry the read across that navigation instead of making fresh CI profiles flaky. const authBrand = await waitFor(() => page.evaluate(() => ({ heading: document.querySelector("h2")?.textContent || "", + light: document.documentElement.classList.contains("light"), src: document.querySelector(".logo-asset")?.getAttribute("src") || "", loaded: document.querySelector(".logo-asset") instanceof HTMLImageElement && document.querySelector(".logo-asset").complete @@ -109,6 +113,7 @@ try { })).catch(() => null), "stable bridge login after service-worker claim", 5_000); ok(authBrand.heading === "Enter the bridge" && authBrand.src === "/brand/1helm-sailboat.png" && authBrand.loaded, "the bridge login displays the current sailboat artwork"); + ok(authBrand.light, "a first-ever browser profile starts in light mode"); await page.evaluate((value) => localStorage.setItem("ctrl.token", value), token); await page.goto(`${base}/c/${channel.slug}/thread/${rootMessage.id}`, { waitUntil: "networkidle0" }); await page.waitForSelector("#thread:not(.hidden)"); @@ -122,8 +127,32 @@ 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.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.click('[aria-label="Close profile"]'); + + await api("/api/workspace/model-policy", { method: "PATCH", body: { model: smallModel, personal: true } }, token); + await page.goto(`${base}/c/${mainChannel.slug}/chat`, { waitUntil: "networkidle0" }); + await page.waitForFunction((expected) => document.querySelector('textarea[data-composer-parent="root"]')?.closest('.composer-wrap')?.querySelector('.model-picker-button')?.textContent?.includes(`Personal override: ${expected}`), {}, smallModel); + await page.click('textarea[data-composer-parent="root"] + *').catch(() => undefined); + await page.click('.composer-wrap .model-picker-button'); + await page.waitForSelector('[data-clear-personal-model]'); + await page.click('[data-clear-personal-model]'); + await page.waitForFunction((expected) => document.querySelector('.composer-wrap .model-picker-button')?.textContent?.includes(`Workspace default: ${expected}`), {}, largeModel); + ok(true, "#main visibly discloses a personal override and immediately repaints the workspace model when it is cleared"); + await page.goto(`${base}/c/${channel.slug}/cowork`, { waitUntil: "networkidle0" }); - await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent?.includes("Quiet refresh proof.md"))?.click()); + const coworkTabs = await page.$eval('[aria-label="Cowork sections"]', (nav) => { + const buttons = [...nav.querySelectorAll("button")]; + const navBox = nav.getBoundingClientRect(); + const first = buttons[0].getBoundingClientRect(); + const last = buttons.at(-1).getBoundingClientRect(); + return { navCenter: navBox.left + navBox.width / 2, tabsCenter: (first.left + last.right) / 2, scrollable: nav.scrollWidth >= nav.clientWidth }; + }); + ok(Math.abs(coworkTabs.navCenter - coworkTabs.tabsCenter) <= 2 && coworkTabs.scrollable, "Cowork section tabs are centered in their normal-width container"); + await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent?.includes("Quiet refresh proof.md")); + await page.click('[data-cowork-path="notes/Quiet refresh proof.md"]'); await page.waitForFunction(() => document.querySelector('[aria-label="Notes editor"] .cm-content')?.textContent.includes("Start here")); await page.click('[aria-label="Notes editor"] .cm-content'); await page.keyboard.down(primaryModifier); await page.keyboard.press("a"); await page.keyboard.up(primaryModifier); await page.keyboard.press("ArrowRight"); @@ -378,8 +407,23 @@ try { }, "structured interview"); await page.goto(`${base}/c/${channel.slug}/thread/${questionRequest.message.id}`, { waitUntil: "networkidle0" }); await page.waitForSelector('#thread [aria-pressed="false"]'); - const selectedLabel = await page.$eval('#thread [aria-pressed="false"]', (button) => { button.click(); return button.querySelector("span")?.textContent || button.textContent || ""; }); + const selectedLabel = await page.$eval('#thread [aria-pressed="false"]', (button) => { button.click(); return button.querySelector(".font-semibold")?.textContent || button.textContent || ""; }); await page.waitForFunction(() => Boolean(document.querySelector('#thread [aria-pressed="true"]'))); + const selectedVisual = async () => page.$eval('#thread [aria-pressed="true"]', (button) => { + const style = getComputedStyle(button); const check = getComputedStyle(button.querySelector('.structured-answer-check')); + return { aria: button.getAttribute("aria-pressed"), shadow: style.boxShadow, border: style.borderColor, background: style.backgroundColor, checkBackground: check.backgroundColor, checkColor: check.color }; + }); + const firstSelectedVisual = await selectedVisual(); + await page.screenshot({ path: "/tmp/1helm-selected-answer-light.png" }); + ok(firstSelectedVisual.aria === "true" && firstSelectedVisual.shadow !== "none" && firstSelectedVisual.background !== "rgba(0, 0, 0, 0)" && firstSelectedVisual.checkBackground !== "rgba(0, 0, 0, 0)", + "a selected structured answer has an unmistakable ring, fill, checked circle, and pressed accessibility state"); + const themeBeforeAnswer = await page.evaluate(() => document.documentElement.className); + await page.evaluate(() => [...document.querySelectorAll('button[title^="Switch to"]')][0]?.click()); + await page.waitForFunction((prior) => document.documentElement.className !== prior, {}, themeBeforeAnswer); + const oppositeThemeVisual = await selectedVisual(); + await page.screenshot({ path: "/tmp/1helm-selected-answer-dark.png" }); + ok(oppositeThemeVisual.aria === "true" && oppositeThemeVisual.shadow !== "none" && oppositeThemeVisual.background !== "rgba(0, 0, 0, 0)" && oppositeThemeVisual.checkBackground !== "rgba(0, 0, 0, 0)", + "the unmistakable selected-answer state remains intact in the opposite theme"); await page.evaluate((messageId) => { const row = document.querySelector(`[data-message-surface="thread"][data-message-id="${messageId}"]`); window.__briefQuestionRow = row; @@ -401,7 +445,7 @@ try { await page.evaluate(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.includes("Files"))?.click()); await page.waitForSelector('#channelview [data-file-path="README.md"]'); - await page.click('#channelview [data-file-path="README.md"]'); + await page.evaluate(() => document.querySelector('#channelview [data-file-path="README.md"]')?.click()); await page.waitForFunction(() => document.querySelector('[data-file-metadata]')?.textContent?.includes("README.md")); const fileActions = await page.$$eval("[data-file-metadata] button", (buttons) => buttons.map((button) => button.textContent?.trim()).filter((text) => text === "Open" || text === "Download")); ok(fileActions.includes("Open") && fileActions.includes("Download"), "Files exposes explicit authenticated Open and Download actions"); @@ -431,8 +475,19 @@ try { await page.evaluate(() => [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Providers")?.click()); await page.waitForSelector(".routing-fabric"); const fabricText = await page.$eval(".routing-fabric", (element) => element.textContent || ""); - const fabricAria = await page.$eval(".routing-fabric-svg", (element) => element.getAttribute("aria-label") || ""); - ok(/REQUESTS/.test(fabricText) && /1HELM ROUTER/.test(fabricText) && /routed provider/.test(fabricAria), "Providers visualizes the live dotted Requests → 1Helm Router → provider flow"); + const fabricAria = await page.$eval(".routing-live-stage", (element) => element.getAttribute("aria-label") || ""); + const fabricGeometry = await page.$eval(".routing-live-stage", (stage) => { + const providers = [...stage.querySelectorAll('.routing-live-provider')].map((node) => node.getBoundingClientRect()); + 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 }; + }); + 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)), + "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 c3c31ab..c6fec75 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.21"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.22"); 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 344137f..cbea1d1 100644 --- a/test/channel-surfaces.mjs +++ b/test/channel-surfaces.mjs @@ -189,6 +189,10 @@ test("channel UI source exposes file-backed Cowork, traditional Files, audio pre assert.match(channelSource, /data(?:set)?: \{ fileFolderTree: "" \}/, "Files has a folder navigation rail"); assert.match(channelSource, /icon\("folder"/, "Files uses a recognizable folder icon"); assert.match(coworkSource, /const SECTIONS[\s\S]*"notes"[\s\S]*"whiteboards"[\s\S]*"code"[\s\S]*"docs"[\s\S]*"presentations"/, "Cowork exposes five file-backed work modes"); + assert.match(coworkSource, /version: 3[\s\S]*DEFAULT_PRINTABLE_AREA[\s\S]*width: 1500, height: 1000[\s\S]*PRINTABLE_BOUNDARY_ID/, "Presentations migrate to a visible 1500×1000 printable-area contract"); + assert.match(coworkSource, /presentationPdf[\s\S]*PDFDocument\.create\(\)[\s\S]*for \(const slide of deck\.slides\)[\s\S]*pdf\.addPage/, "Presentations export every slide into one multipage PDF"); + assert.match(coworkSource, /sceneWithoutPrintableBoundary[\s\S]*element\?\.id !== PRINTABLE_BOUNDARY_ID/, "the visible printable boundary is not persisted as user content"); + assert.match(coworkSource, /aria-label": "Printable width"[\s\S]*aria-label": "Printable height"/, "printable dimensions are user configurable"); assert.match(coworkSource, /mountSpeechToTextControl\(input, "Dictate Cowork agent request"\)/, "Cowork agent requests expose the shared explicit dictation control"); assert.match(coworkSource, /mountSpeechToTextControl\(speechTarget, `Dictate \$\{mode === "docs" \? "document" : "note"\}`\)/, "Cowork Notes and Docs expose the shared explicit dictation control"); assert.match(coworkSource, /if \(!value && channel\.agent\?\.kind === "skipper"\) return h\("img"/, "Cowork renders Skipper with a real product avatar instead of an initial"); diff --git a/test/cowork-browser.mjs b/test/cowork-browser.mjs index e9ef609..4bff419 100644 --- a/test/cowork-browser.mjs +++ b/test/cowork-browser.mjs @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { accessSync, constants as fsConstants, rmSync, writeFileSync } from "node:fs"; +import { accessSync, constants as fsConstants, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { join } from "node:path"; import test from "node:test"; import puppeteer from "puppeteer"; +import { PDFDocument } from "pdf-lib"; const root = process.cwd(); const dataDir = join(root, ".native-test-data", `cowork-browser-${process.pid}`); @@ -90,6 +91,9 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox", "--use-fake-ui-for-media-stream"] }); const page = await browser.newPage(); + const downloadDir = join(dataDir, "downloads"); + mkdirSync(downloadDir, { recursive: true }); + await page.createCDPSession().then((session) => session.send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: downloadDir })); const errors = []; page.on("pageerror", (error) => errors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); @@ -127,6 +131,7 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.goto(`${base}/c/${channel.slug}/cowork`, { waitUntil: "networkidle0" }); 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.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'; @@ -412,6 +417,11 @@ 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'); + assert.deepEqual(await page.$eval('.cowork-slide-canvas', (canvas) => ({ + width: Number(canvas.dataset.printableWidth), + height: Number(canvas.dataset.printableHeight), + ratio: getComputedStyle(canvas).aspectRatio, + })), { width: 1500, height: 1000, ratio: "1500 / 1000" }, "legacy decks open inside the default 1500 × 1000 printable area"); await page.click('.cowork-slide-canvas .main-menu-trigger'); await page.waitForSelector('.cowork-slide-canvas .dropdown-menu'); const menuGeometry = await page.$eval('.cowork-slide-canvas .dropdown-menu', (menu) => { @@ -433,6 +443,9 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil }); assert.ok(menuGeometry.menuTop >= menuGeometry.stageTop - 1 && menuGeometry.menuBottom <= menuGeometry.stageBottom + 1, JSON.stringify(menuGeometry)); assert.ok(menuGeometry.menuHeight > 100 && menuGeometry.clientHeight > 100 && menuGeometry.scrollHeight >= menuGeometry.clientHeight && menuGeometry.firstActionVisible, JSON.stringify(menuGeometry)); + const presentationMenuText = await page.$eval('.cowork-slide-canvas .dropdown-menu', (menu) => menu.textContent || ""); + assert.match(presentationMenuText, /Export PDF/); + assert.doesNotMatch(presentationMenuText, /Export Image/); await page.screenshot({ path: "/tmp/1helm-presentation-menu.png" }); await page.click('.cowork-slide-canvas .main-menu-trigger'); await collaboratorPage.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Presentations")?.click()); @@ -459,6 +472,28 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.evaluate(() => [...document.querySelectorAll('.cowork-editor-toolbar button')].find((button) => button.textContent.trim() === "Delete")?.click()); await waitFor(async () => JSON.parse((await api(`/api/channels/${channel.id}/files/text?path=presentations%2Freview.slides.json`, {}, token)).file.content).slides.length === 2, "deleted presentation slide"); await page.waitForFunction(() => document.querySelector('[data-cowork-presentation]')?.dataset.slideCount === "2"); + await page.$eval('[aria-label="Printable width"]', (input) => { input.value = "1600"; input.dispatchEvent(new Event("change", { bubbles: true })); }); + await page.waitForSelector('.cowork-slide-canvas[data-printable-width="1600"]'); + await page.$eval('[aria-label="Printable height"]', (input) => { input.value = "900"; input.dispatchEvent(new Event("change", { bubbles: true })); }); + await page.waitForSelector('.cowork-slide-canvas[data-printable-width="1600"][data-printable-height="900"]'); + const persistedDeck = await waitFor(async () => { + const parsed = JSON.parse((await api(`/api/channels/${channel.id}/files/text?path=presentations%2Freview.slides.json`, {}, token)).file.content); + return parsed.version === 3 && parsed.printableArea?.width === 1600 && parsed.printableArea?.height === 900 ? parsed : null; + }, "custom printable area persistence"); + assert.equal(persistedDeck.slides.flatMap((slide) => slide.scene?.elements || []).some((element) => element.id === "1helm-printable-area-boundary"), false, + "the locked editing boundary never becomes persisted slide content"); + + await page.click('.cowork-slide-canvas .main-menu-trigger'); + await page.waitForSelector('.cowork-slide-canvas .dropdown-menu'); + await page.evaluate(() => [...document.querySelectorAll('.cowork-slide-canvas .dropdown-menu-item')].find((item) => item.textContent?.trim() === "Export PDF")?.click()); + const pdfPath = await waitFor(() => { + const file = readdirSync(downloadDir).find((name) => name.endsWith(".pdf") && !name.endsWith(".crdownload")); + return file ? join(downloadDir, file) : null; + }, "complete presentation PDF download", 30_000); + const exportedPdf = await PDFDocument.load(readFileSync(pdfPath)); + assert.equal(exportedPdf.getPageCount(), 2, "Export PDF emits the entire deck as one two-page document"); + assert.deepEqual(exportedPdf.getPages().map((pdfPage) => ({ width: pdfPage.getWidth(), height: pdfPage.getHeight() })), + [{ width: 1600, height: 900 }, { width: 1600, height: 900 }], "every PDF page uses the exact custom printable dimensions"); await page.evaluate(() => [...document.querySelectorAll('.cowork-editor-toolbar button')].find((button) => button.textContent.trim() === "Present")?.click()); await page.waitForSelector('[role="dialog"][aria-label="Presentation mode"]'); await page.keyboard.press("Escape"); diff --git a/test/mobile.mjs b/test/mobile.mjs index 0757f26..7ee876d 100644 --- a/test/mobile.mjs +++ b/test/mobile.mjs @@ -87,18 +87,22 @@ test("mobile compatibility is explicit and CORS is confined to packaged Capacito }); test("Capacitor shells keep sessions native, connections HTTPS-only, and release identities stable", async () => { - const [config, mobile, api, app, notifications, androidManifest, androidBuild, androidRules, androidPackage, androidStyles, androidLaunch, iosInfo, iosProject, iosLaunch, privacy, packageJson, iosPackage] = await Promise.all([ + const [config, mobile, api, app, notifications, androidManifest, androidBuild, androidRules, androidPackage, androidStyles, androidLaunch, iosInfo, iosProject, iosLaunch, privacy, packageJson, iosPackage, gatewayHtml, gatewayError, androidGateway, androidActivity, iosGateway] = await Promise.all([ read("capacitor.config.json"), read("src/client/mobile.ts"), read("src/client/api.ts"), read("src/client/app.ts"), read("src/client/notifications.ts"), read("android/app/src/main/AndroidManifest.xml"), read("android/app/build.gradle"), read("android/app/src/main/res/xml/data_extraction_rules.xml"), read("scripts/package-android-apk.mjs"), read("android/app/src/main/res/values/styles.xml"), read("android/app/src/main/res/layout/launch_screen.xml"), read("ios/App/App/Info.plist"), read("ios/App/App.xcodeproj/project.pbxproj"), read("ios/App/App/Base.lproj/LaunchScreen.storyboard"), read("ios/App/App/PrivacyInfo.xcprivacy"), read("package.json"), read("scripts/package-ios-ipa.mjs"), + read("mobile-gateway/index.html"), read("mobile-gateway/error.html"), read("android/app/src/main/java/com/gitcommit90/onehelm/mobile/InstanceGatewayPlugin.java"), read("android/app/src/main/java/com/gitcommit90/onehelm/mobile/MainActivity.java"), read("ios/App/App/GatewayViewController.swift"), ]); const parsed = JSON.parse(config); assert.equal(parsed.appId, "com.gitcommit90.onehelm.mobile"); assert.equal(parsed.server.androidScheme, "https"); assert.equal(parsed.server.cleartext, false); - assert.equal(parsed.server.url, undefined, "release app packages the audited local UI instead of navigating a wildcard remote WebView"); + assert.equal(parsed.webDir, "mobile-gateway", "the app packages only the connection and recovery shell"); + assert.equal(parsed.server.url, undefined, "the exact selected server is injected natively at launch, never as a wildcard build-time origin"); + assert.equal(parsed.server.allowNavigation, undefined, "no wildcard navigation grant can broaden native bridge access"); + assert.equal(parsed.server.errorPath, "error.html"); assert.equal(parsed.loggingBehavior, "none"); assert.equal(parsed.android.buildOptions.releaseType, "APK"); assert.equal(parsed.android.webContentsDebuggingEnabled, false); @@ -124,7 +128,10 @@ test("Capacitor shells keep sessions native, connections HTTPS-only, and release assert.match(mobile, /StatusBar\.setBackgroundColor\(\{ color: surface \}\)/, "native status chrome follows the computed header surface"); assert.match(mobile, /contains\("light"\) \? Style\.Light : Style\.Dark/, "status indicators stay legible when the user switches theme"); assert.match(mobile, /App\.getLaunchUrl/, "a cold-start OAuth callback is retained"); - assert.doesNotMatch(mobile, /destination\.origin === serverOrigin/, "server links cannot replace the audited packaged WebView"); + assert.match(mobile, /registerPlugin\("InstanceGateway"\)/); + assert.match(mobile, /location\.origin !== serverOrigin/, "the live frontend rejects a bridge injected onto any origin other than the selected instance"); + assert.match(mobile, /target\.protocol = target\.protocol === "https:" \? "wss:" : "ws:"/, + "same-origin WebSockets preserve ws:// for HTTP browser instances and use wss:// for HTTPS/native gateways"); assert.doesNotMatch(api, /let token = localStorage\.getItem/, "the session is not eagerly copied out of native secure storage"); assert.match(app, /if \(isNativeMobile\(\) && !getToken\(\)\) return renderAuth\(\)/, "the gateway never opens host onboarding"); assert.match(app, /src: serverAssetUrl\(avatarValue\)/, "server-hosted custom avatars resolve against the selected host"); @@ -134,6 +141,9 @@ test("Capacitor shells keep sessions native, connections HTTPS-only, and release assert.match(androidManifest, /android\.permission\.INTERNET/); assert.match(androidManifest, /android\.permission\.RECORD_AUDIO/); assert.match(androidManifest, /android:scheme="onehelm" android:host="openrouter"/); + assert.match(androidGateway, /parsed\.protocol|"https"\.equalsIgnoreCase|validOrigin/); + assert.match(androidGateway, /shouldOverrideLoad[\s\S]*sameOrigin[\s\S]*https:\/\/localhost/, "Android rejects in-WebView HTTP(S) navigation outside the exact local or selected origin"); + assert.match(androidActivity, /registerPlugin\(InstanceGatewayPlugin\.class\)[\s\S]*setServerUrl\(selected\)[\s\S]*setErrorPath\("error\.html"\)/, "Android builds a bridge for exactly the selected HTTPS origin"); assert.match(androidBuild, /versionName oneHelmVersion/); assert.match(androidBuild, /HELM_ANDROID_SIGNING_PROPERTIES/); assert.match(androidBuild, /signingConfig signingConfigs\.release/); @@ -157,6 +167,9 @@ test("Capacitor shells keep sessions native, connections HTTPS-only, and release assert.match(iosInfo, /NSSpeechRecognitionUsageDescription/); assert.match(iosInfo, /ITSAppUsesNonExemptEncryption/); assert.match(iosProject, /PRODUCT_BUNDLE_IDENTIFIER = com\.gitcommit90\.onehelm\.mobile/); + assert.match(iosProject, /GatewayViewController\.swift in Sources/); + assert.match(iosGateway, /descriptor\.errorPath = "error\.html"[\s\S]*descriptor\.serverURL = origin[\s\S]*registerPluginInstance\(InstanceGatewayPlugin\(\)\)/, "iOS builds a bridge for exactly the selected HTTPS origin"); + assert.match(iosGateway, /shouldOverrideLoad[\s\S]*sameOrigin[\s\S]*scheme\?\.lowercased\(\)[\s\S]*host\?\.lowercased\(\)/, "iOS rejects in-WebView HTTP(S) navigation outside an exact scheme, host, and port match"); assert.match(iosProject, /PrivacyInfo\.xcprivacy in Resources/); assert.match(iosProject, /CODE_SIGN_ENTITLEMENTS = App\/App\.entitlements/); assert.match(notifications, /mobilePlatform\(\) !== "ios"/, "the current release offers push only on the platform with a complete APNs delivery path"); @@ -171,6 +184,12 @@ test("Capacitor shells keep sessions native, connections HTTPS-only, and release for (const dependency of ["@capacitor/core", "@capacitor/android", "@capacitor/ios", "@aparajita/capacitor-secure-storage"]) assert.ok(pkg.dependencies[dependency]); 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(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}`); + } + const iosIcon = await sharp(join(root, "ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png")).metadata(); assert.equal(iosIcon.width, 1024); assert.equal(iosIcon.height, 1024); const iosLaunchMark = await sharp(join(root, "ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png")).metadata(); diff --git a/test/native-world.mjs b/test/native-world.mjs index 2cb5758..c8631ac 100644 --- a/test/native-world.mjs +++ b/test/native-world.mjs @@ -141,6 +141,31 @@ try { let channels = (await api("/api/channels", {}, captain)).body.channels; const main = channels.find((channel) => channel.name === "main"); ok(main?.agent?.kind === "skipper" && main.agent.name === "skipper", "#main exposes the one workspace-wide Skipper"); + const initialMainPolicy = await api(`/api/channels/${main.id}/model-policy`, {}, captain); + ok(initialMainPolicy.body.policy.model === primaryLargeModel && initialMainPolicy.body.policy.source === "workspace" && initialMainPolicy.body.policy.requested_model === primaryLargeModel, + "#main composer receives the same authoritative workspace model policy Skipper will execute"); + const stalePolicyTurn = await api(`/api/channels/${main.id}/messages`, { body: { + body: "@skipper this stale model label must not start a turn", + effectiveModelPolicy: { model: primarySmallModel, source: "personal" }, + } }, captain); + ok(stalePolicyTurn.status === 409 && /effective model policy changed/i.test(stalePolicyTurn.body.error || ""), + "a stale composer policy is rejected before its message or agent turn is admitted"); + await api("/api/workspace/model-policy", { method: "PATCH", body: { model: primarySmallModel, personal: true } }, captain); + const personalMainPolicy = await api(`/api/channels/${main.id}/model-policy`, {}, captain); + ok(personalMainPolicy.body.policy.model === primarySmallModel && personalMainPolicy.body.policy.source === "personal" && personalMainPolicy.body.policy.source_label === "Personal override" && personalMainPolicy.body.policy.personal_model === primarySmallModel, + "a Captain personal override is explicitly disclosed by every effective-policy response"); + const personalTurn = await api(`/api/channels/${main.id}/messages`, { body: { body: "@skipper prove the effective personal model" } }, captain); + const personalTurnReply = await waitForAgentReply(personalTurn.body.message.id, captain, "skipper"); + ok(/mock-small/.test(personalTurnReply.body), "the visibly disclosed personal override is the model actually used by Skipper"); + const personalTurnDb = new DatabaseSync(join(dataDir, "ctrl-pane.db")); + const admittedPersonalTurn = personalTurnDb.prepare("SELECT requested_model,requested_provider_id,model_source,request_user_id FROM agent_turns WHERE trigger_id=?").get(personalTurn.body.message.id); + personalTurnDb.close(); + ok(admittedPersonalTurn?.requested_model === primarySmallModel && admittedPersonalTurn?.requested_provider_id === main.agent.provider_id && admittedPersonalTurn?.model_source === "personal" && admittedPersonalTurn?.request_user_id === registration.body.user.id, + "an admitted personal-policy turn durably records its requested model, provider, source, and requesting user"); + await api("/api/workspace/model-policy", { method: "PATCH", body: { model: "", personal: true } }, captain); + const restoredMainPolicy = await api(`/api/channels/${main.id}/model-policy`, {}, captain); + ok(restoredMainPolicy.body.policy.model === primaryLargeModel && restoredMainPolicy.body.policy.source === "workspace" && restoredMainPolicy.body.policy.personal_model === null, + "clearing a personal override immediately restores the visible workspace model"); ok(main.agent.runtime.avatar === "color:#4F6D7A", "Skipper starts with the customizable flat-color avatar"); const skipperTerm = await api("/api/term/open", { body: { channelId: main.id, cols: 240, rows: 28 } }, captain); const skipperTermState = await new Promise((resolve, reject) => { @@ -275,16 +300,31 @@ try { const queueRoot = (await api(`/api/channels/${main.id}/messages`, { body: { body: "@skipper slow-turn run whoami for ordered session one" } }, captain)).body.message.id; await waitFor(async () => (await api(`/api/messages/${queueRoot}/thread`, {}, captain)).body.replies?.some((reply) => reply.progress?.some((item) => item.status === "running")), "ordered Skipper turn start"); - await api(`/api/channels/${main.id}/messages`, { body: { body: "@skipper run whoami for ordered session two", parentId: queueRoot } }, captain); + const queuedPolicy = await api(`/api/messages/${queueRoot}/model-policy`, {}, captain); + const queuedMessage = await api(`/api/channels/${main.id}/messages`, { body: { + body: "@skipper report the admitted model for ordered session two", + parentId: queueRoot, + effectiveModelPolicy: { provider_id: queuedPolicy.body.policy.provider_id, model: queuedPolicy.body.policy.model, source: queuedPolicy.body.policy.source }, + } }, captain); await waitFor(async () => { const replies = (await api(`/api/messages/${queueRoot}/thread`, {}, captain)).body.replies || []; return replies.find((reply) => reply.progress?.some((item) => /Queued · 1 ahead/.test(item.body || ""))); }, "visible same-thread queue"); ok(true, "a same-thread follow-up is durably admitted and immediately shows Queued · 1 ahead"); + await api("/api/workspace/model-policy", { method: "PATCH", body: { model: primarySmallModel, personal: true } }, captain); + const admittedQueueDb = new DatabaseSync(join(dataDir, "ctrl-pane.db")); + const admittedQueue = admittedQueueDb.prepare("SELECT requested_model,requested_provider_id,model_source,state FROM agent_turns WHERE trigger_id=?").get(queuedMessage.body.message.id); + admittedQueueDb.close(); + ok(admittedQueue?.requested_model === primaryLargeModel && admittedQueue?.requested_provider_id === main.agent.provider_id && admittedQueue?.model_source === "workspace" && ["queued", "running", "completed"].includes(admittedQueue?.state), + "a queued turn snapshots the effective workspace model and provider at admission"); await waitFor(async () => { - const replies = (await api(`/api/messages/${queueRoot}/thread`, {}, captain)).body.replies || []; - return replies.filter((reply) => reply.author?.name === "skipper" && /Answer complete/.test(reply.body || "")).length >= 2; + const queuedDb = new DatabaseSync(join(dataDir, "ctrl-pane.db")); + const completed = queuedDb.prepare("SELECT t.state,m.body FROM agent_turns t JOIN messages m ON m.id=t.message_id WHERE t.trigger_id=?").get(queuedMessage.body.message.id); + queuedDb.close(); + return completed?.state === "completed" && /mock-large/.test(completed.body || "") && completed; }, "same-thread queue drain", 15_000); + ok(true, "a queued turn executes its admitted model even when the personal policy changes before execution"); + await api("/api/workspace/model-policy", { method: "PATCH", body: { model: "", personal: true } }, captain); const deleteRequest = await api(`/api/channels/${main.id}/messages`, { body: { body: "@skipper sunset and delete #ideas" } }, captain); await waitForAgentReply(deleteRequest.body.message.id, captain, "skipper"); diff --git a/test/routing-ui-contract.mjs b/test/routing-ui-contract.mjs index 7d36648..416c64b 100644 --- a/test/routing-ui-contract.mjs +++ b/test/routing-ui-contract.mjs @@ -5,6 +5,7 @@ import test from "node:test"; const ROOT = new URL("..", import.meta.url); const client = readFileSync(new URL("src/client/routing.ts", ROOT), "utf8"); const server = readFileSync(new URL("src/server/routing.ts", ROOT), "utf8"); +const styles = readFileSync(new URL("src/client/styles.css", ROOT), "utf8"); test("provider controls expose the live dotted router flow and credential-free header popover", () => { assert.match(client, /export async function openRoutingPopover\(eventOrAnchor: Event \| Element\)/); @@ -12,6 +13,25 @@ test("provider controls expose the live dotted router flow and credential-free h assert.match(client, /latestRequests\(state, 10\)/); assert.match(client, /API keys for the router are stored in Settings → Providers → Endpoints\./); 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"]) { + 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"); + 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/, + "the requested model stays distinct from the provider/fallback outcome"); + assert.match(styles, /\.routing-live-router \{ top: 7\.2rem/); + assert.match(styles, /\.routing-live-source \{ top: 13\.8rem/); + assert.match(styles, /\.routing-fabric-path[\s\S]*stroke-dasharray[\s\S]*animation: routing-flow/, + "live request paths are dotted and animated"); + assert.match(server, /routeSystemRequestForUser[\s\S]*id = `system-[\s\S]*initiator: "system"[\s\S]*work_kind/, + "silent internal work is classified separately from user requests"); + assert.doesNotMatch(server, /pendingSystemRequests|candidate\.model === String\(event\.request\?\.model/, + "system classification never guesses from an imminent same-model user request"); assert.doesNotMatch(client, /routing-pyramid-tier/); assert.doesNotMatch(client.slice(client.indexOf("export async function openRoutingPopover"), client.indexOf("function sourceCatalog")), /routing\/credentials|apiKey/); }); diff --git a/test/thread-audit.mjs b/test/thread-audit.mjs index 98a9aee..9b98ca5 100644 --- a/test/thread-audit.mjs +++ b/test/thread-audit.mjs @@ -74,13 +74,17 @@ try { const captain = registration.body.token; ok(registration.status === 200 && captain, "captain registers"); - const provider = await api("/api/providers", { - body: { name: "Deterministic provider", base_url: `http://127.0.0.1:${mockPort}/v1`, api_key: "test" }, + const provider = await api("/api/routing/action", { + body: { action: "app:add-keyed-provider", payload: { + name: "Deterministic provider", + baseUrl: `http://127.0.0.1:${mockPort}/v1`, + apiKey: "test", + models: [{ id: "mock-small", name: "mock-small", enabled: true }], + } }, }, captain); - const providerId = provider.body.provider.id; + ok(provider.status === 200 && provider.body.id, "routing provider is connected"); const setup = await api("/api/setup/complete", { body: { - provider_id: providerId, model: "mock-small", terminals_enabled: false, name: "Audit Workspace", @@ -101,7 +105,7 @@ try { await waitFor(async () => { const thread = await api(`/api/messages/${doneRoot.body.message.id}/thread`, {}, captain); return thread.body.replies?.some((message) => /Answer complete\./.test(message.body || "")); - }, "agent finished done thread"); + }, "agent finished done thread", 30_000); // Thread B: clearly waiting on human. const waitRoot = await api(`/api/channels/${channelId}/messages`, { @@ -113,24 +117,17 @@ try { body: { body: "still waiting for your decision on staging vs prod", parentId: waitRoot.body.message.id }, }, captain); - // Thread C: ambiguous / still open work — should keep. + // Thread C: ambiguous / still open work — should keep. Keep this fixture + // human-only: resident-turn scheduling is covered elsewhere, and making the + // thread-audit contract depend on a second unrelated agent turn caused this + // focused test to race a loaded host. const openRoot = await api(`/api/channels/${channelId}/messages`, { - body: { body: `@${agentName} explore options for the next sprint` }, + body: { body: "explore options for the next sprint" }, }, captain); ok(openRoot.status === 200, "open root posted"); - await waitFor(async () => { - const thread = await api(`/api/messages/${openRoot.body.message.id}/thread`, {}, captain); - return thread.body.replies?.some((message) => /Answer complete\./.test(message.body || "")); - }, "agent finished open thread"); - // Human follow-up without @mention keeps the conversational agent engaged; wait for that too. await api(`/api/channels/${channelId}/messages`, { body: { body: "keep this open — still gathering options, more work remains", parentId: openRoot.body.message.id }, }, captain); - await waitFor(async () => { - const thread = await api(`/api/messages/${openRoot.body.message.id}/thread`, {}, captain); - const replies = thread.body.replies || []; - return replies.filter((message) => message.author?.kind === "bot").length >= 2; - }, "agent second reply on open thread", 15_000).catch(() => null); const list = (await api(`/api/channels/${channelId}/threads`, {}, captain)).body.threads; const byRoot = Object.fromEntries(list.map((thread) => [thread.root_message_id, thread])); @@ -184,6 +181,11 @@ try { const hasAuditLog = rawActivity.some((item) => item.kind === "thread_audit" || /Skipper marked thread/i.test(item.summary || "")); ok(hasAuditLog, "thread_audit activity is recorded"); + const routingState = await api("/api/routing/state", {}, captain); + const systemRequest = (routingState.body.recentActivity || []).find((event) => event.request?.work_kind === "thread-audit"); + ok(systemRequest?.request?.initiator === "system" && String(systemRequest.request.id || "").startsWith("system-"), + "silent audit routing is explicitly identified by its own system request identity"); + const forbidden = await api("/api/thread-audit/run", { body: {} }, ""); ok(forbidden.status === 401 || forbidden.status === 403, "anonymous cannot run audit"); } catch (error) { From 5d4d9235cc43007ddebd953cd5d2c73f44189d26 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 28 Jul 2026 06:31:45 +0000 Subject: [PATCH 2/2] Test the packaged mobile gateway shell --- test/mobile.mjs | 66 +++++++++++++++++++------------------------------ 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/test/mobile.mjs b/test/mobile.mjs index 7ee876d..15e2413 100644 --- a/test/mobile.mjs +++ b/test/mobile.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { access, mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; +import { createServer as createHttpServer } from "node:http"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -213,71 +214,54 @@ test("mobile server addresses normalize to an HTTPS origin and reject ambiguous test("the packaged phone gateway opens a fitting connection screen instead of host setup", { skip: executablePath ? false : "No local Chrome executable; native-shell and transport contracts still run independently.", }, async () => { - const dataDir = await mkdtemp(join(tmpdir(), "1helm-mobile-ui-test-")); + const gatewayHtml = await read("mobile-gateway/index.html"); const port = await freePort(); - const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", "src/server/index.ts"], { - cwd: root, - env: { ...process.env, CTRL_DATA_DIR: dataDir, PORT: String(port), HELM_CHANNEL_COMPUTER_BACKEND: "native" }, - stdio: ["ignore", "pipe", "pipe"], + const gatewayServer = createHttpServer((request, response) => { + if (request.url === "/" || request.url === "/index.html") { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end(gatewayHtml); return; + } + if (request.url === "/capacitor.js") { + response.writeHead(200, { "content-type": "text/javascript; charset=utf-8" }); + response.end(`window.Capacitor={Plugins:{InstanceGateway:{getServer:async()=>({origin:""}),selectServer:async({origin})=>({origin})}}};`); return; + } + response.writeHead(404); response.end("not found"); + }); + await new Promise((resolveListen, reject) => { + gatewayServer.once("error", reject); + gatewayServer.listen(port, "127.0.0.1", resolveListen); }); let browser; try { const base = `http://127.0.0.1:${port}`; - await waitFor(`${base}/api/setup/status`); browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] }); const page = await browser.newPage(); await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); - await page.evaluateOnNewDocument(() => { - // Minimal Capacitor Android bridge contract. Native methods resolve as - // they do on-device, while secure storage begins empty for this test. - Object.defineProperty(window, "androidBridge", { value: {}, configurable: true }); - const promiseMethods = (names) => names.map((name) => ({ name, rtype: "promise" })); - window.Capacitor = { - PluginHeaders: [ - { name: "SecureStorage", methods: promiseMethods(["setSynchronizeKeychain", "internalGetItem", "internalSetItem", "internalRemoveItem", "clearItemsWithPrefix", "getPrefixedKeys"]) }, - { name: "StatusBar", methods: promiseMethods(["setStyle", "setOverlaysWebView"]) }, - { name: "Keyboard", methods: promiseMethods(["setResizeMode"]) }, - { name: "App", methods: [{ name: "addListener", rtype: "callback" }, ...promiseMethods(["removeListener", "getLaunchUrl"]) ] }, - { name: "Browser", methods: promiseMethods(["open", "close"]) }, - ], - nativePromise(plugin, method) { - if (plugin === "SecureStorage" && method === "internalGetItem") return Promise.resolve({ data: null }); - if (plugin === "SecureStorage" && method === "internalRemoveItem") return Promise.resolve({ success: false }); - if (plugin === "SecureStorage" && method === "getPrefixedKeys") return Promise.resolve({ keys: [] }); - return Promise.resolve({}); - }, - nativeCallback() { return Promise.resolve("mobile-test-listener"); }, - }; - }); 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"]'); const screen = await page.evaluate(() => { - const stage = document.querySelector(".auth-stage"); + const card = document.querySelector("main"); const inputs = [...document.querySelectorAll("input")]; return { - body: document.body.textContent || "", + body: card?.textContent || "", serverType: inputs[0]?.getAttribute("type"), - passwordType: inputs.at(-1)?.getAttribute("type"), + inputCount: inputs.length, overflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth, - stageOverflowY: stage ? stage.scrollHeight - stage.clientHeight : -1, - nativePlatform: document.documentElement.dataset.nativeMobile, + cardFits: card ? card.getBoundingClientRect().top >= 0 && card.getBoundingClientRect().bottom <= innerHeight : false, }; }); - assert.match(screen.body, /Connect to your 1Helm/); - assert.match(screen.body, /must already be set up and reachable over HTTPS/); + assert.match(screen.body, /Connect to 1Helm/); + assert.match(screen.body, /live interface/); assert.doesNotMatch(screen.body, /Create the Captain account/); + assert.doesNotMatch(screen.body, /password|Sign in/i); assert.equal(screen.serverType, "url"); - assert.equal(screen.passwordType, "password"); - assert.equal(screen.nativePlatform, "android"); + 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.ok(screen.stageOverflowY <= 0, `phone connection card has ${screen.stageOverflowY}px vertical overflow`); + assert.equal(screen.cardFits, true, "the connection card fits the phone viewport"); assert.deepEqual(errors, []); } finally { if (browser) await browser.close().catch(() => undefined); - child.kill("SIGTERM"); - await new Promise((resolveWait) => child.once("exit", resolveWait)); - await rm(dataDir, { recursive: true, force: true }); + await new Promise((resolveClose) => gatewayServer.close(resolveClose)); } });