diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 266a0c6..76d586d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,7 +29,7 @@ Self-hosted multi-model AI chat platform powered by the official `@github/copilo | Language | TypeScript 5.7 (strict mode, ES2022) | | Framework | SvelteKit 5 with `adapter-node` | | Reactivity | Svelte 5 runes ($state, $derived, $effect, $props) | -| AI Engine | `@github/copilot-sdk` ^0.1.32 | +| AI Engine | `@github/copilot-sdk` ^1.0.0 (client `mode: "empty"`) | | WebSocket | `ws` ^8.18 via custom server.js | | Markdown | `marked` + `dompurify` + `highlight.js` (npm, bundled by Vite) | | Security | Custom CSP/HSTS in hooks.server.ts, rate limiting, DOMPurify | @@ -180,6 +180,7 @@ src/ | `VAPID_SUBJECT` | No | — | VAPID subject (mailto: or https: URL) | | `PUSH_STORE_PATH` | No | ./data/push-subscriptions | Directory for push subscription storage | | `ENABLE_REMOTE_SESSIONS` | No | true | Enable cloud/remote session publishing on the SDK client. Sessions still need per-session `remoteSession: "export"|"on"` opt-in. Set to `false` to hard-disable. | +| `COPILOT_CLIENT_MODE` | No | empty | SDK client mode: `empty` (multi-user safe; features re-enabled per session via `buildEmptyModeSessionDefaults()`) or `copilot-cli` (full ambient capabilities) | ## Build & Run diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e5a6a8..5e234dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,10 @@ jobs: needs: check runs-on: ubuntu-latest timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + project: [desktop, mobile] steps: - uses: actions/checkout@v4 @@ -96,11 +100,27 @@ jobs: - name: Build run: npm run build + - name: Get Playwright version + id: playwright-version + run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium - - name: Run Playwright tests - run: npx playwright test --project=desktop + - name: Install Playwright OS dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Run Playwright tests (${{ matrix.project }}) + run: npx playwright test --project=${{ matrix.project }} --reporter=github,html env: PORT: '3001' GITHUB_CLIENT_ID: test-client-id @@ -111,7 +131,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-report + name: playwright-report-${{ matrix.project }} path: playwright-report/ retention-days: 7 diff --git a/README.md b/README.md index 269727c..a131271 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ - **Every Copilot model** — Claude Opus 4.6, GPT-5.4, Gemini 3 Pro, Claude Sonnet 4.6, and more — switch mid-conversation, keep full history - **Autopilot agents** — plan, code, run tests, and open PRs autonomously with live tool execution -- **Remote session publishing** — opt sessions into being visible on github.com / Mobile via `remoteSession: "export" | "on"` (powered by SDK 1.0.0-beta.8); the browser app stays the steering surface, GitHub gets monitor visibility. Server-side toggle: `ENABLE_REMOTE_SESSIONS` (default on); per-session opt-in still required. +- **Remote session publishing** — opt sessions into being visible on github.com / Mobile via the Remote Sessions setting (Off / Export / Full remote); a banner links to the live session on GitHub. Server-side toggle: `ENABLE_REMOTE_SESSIONS` (default on); per-session opt-in still required. +- **Cloud sessions** — create sessions that run on GitHub's cloud agent against any repository (owner/repo/branch form in the Sessions panel) - **Resume last session** — `GET /api/sessions/last` returns metadata for the user's most recent local session for one-tap continue-on-any-device flows - **Extended thinking** — live reasoning traces with collapsible "Thinking…" blocks - **Voice input** — speech-to-text via Web Speech API; mic button replaces send when input is empty (ChatGPT-style UX) — toggle in Settings @@ -140,7 +141,8 @@ Open [localhost:3000](http://localhost:3000). Log in with GitHub. Done. | `VAPID_PRIVATE_KEY` | — | Push notifications (base64url) | | `VAPID_SUBJECT` | — | Push subject (`mailto:` or `https:`) | | `PUSH_STORE_PATH` | `/data/push-subscriptions` | Push subscription storage | -| `ENABLE_REMOTE_SESSIONS` | `true` | Allow sessions to opt into cloud publishing (`remoteSession: "export"\|"on"`). Set to `false` to hard-disable. | +| `ENABLE_REMOTE_SESSIONS` | `true` | Allow sessions to opt into cloud publishing (`remoteSession: "export"\|"on"`) and cloud-agent sessions. Set to `false` to hard-disable. | +| `COPILOT_CLIENT_MODE` | `empty` | SDK client mode. `empty` (recommended for servers) starts with all ambient capabilities off and re-enables only what the app needs. Set to `copilot-cli` to restore full CLI-equivalent behavior. | @@ -209,9 +211,9 @@ The Sessions panel auto-refreshes every 30 seconds. Use `COPILOT_CONFIG_DIR` to -### Remote session publishing (SDK 1.0.0-beta.8) +### Remote session publishing -A chat can opt into being **published** to github.com / Copilot Mobile by passing one of these values when the session is created: +A chat can opt into being **published** to github.com / Copilot Mobile. Pick the default in **Settings → Remote Sessions** (applies to new sessions, or hit "Apply to current session"): | Mode | Effect | |---|---| @@ -219,7 +221,11 @@ A chat can opt into being **published** to github.com / Copilot Mobile by passin | `"export"` | Read-only mirror — session events stream to GitHub so it shows up on github.com/copilot and Mobile in monitor mode. | | `"on"` | Full remote-steerable — the session is steerable from github.com / Mobile as well as from this app. | -This is sent over WebSocket as `{ type: "new_session", remoteSession: "on", ... }` and threaded through to the SDK's `sessionConfig.remoteSession`. The server-wide kill switch is `ENABLE_REMOTE_SESSIONS=false`. +This is sent over WebSocket as `{ type: "new_session", remoteSession: "on", ... }` and threaded through to the SDK's `sessionConfig.remoteSession`; runtime toggling uses `{ type: "remote_toggle", mode }` (SDK `session.rpc.remote`). When GitHub returns the session URL, the app shows a banner with an "Open on GitHub" link. The server-wide kill switch is `ENABLE_REMOTE_SESSIONS=false`. + +### Cloud sessions (GitHub cloud agent) + +From the Sessions panel, **New cloud session** creates a session that runs on GitHub's cloud agent infrastructure instead of locally — give it a repository (`owner` / `name` / optional `branch`) and the agent works against that repo. Sent as `{ type: "new_cloud_session", repository }`; the session ID is assigned by GitHub. Requires `ENABLE_REMOTE_SESSIONS` and cloud-agent entitlements on your account. > **What's not in this release:** the app does **not** include an in-app browser for *other* remote sessions (the ones running elsewhere on your account) and does **not** let you steer arbitrary remote sessions from this UI — the SDK exposes no public REST endpoint for listing them, and the github.com remote-sessions view talks to an internal API that requires a Copilot bearer integrators can't currently mint. To view all your remote sessions, use github.com or the Copilot Mobile app. PRs welcome once the SDK surfaces a public list API. @@ -334,7 +340,7 @@ Device Flow OAuth (same as GitHub CLI). Tokens are server-side only, never sent ## Built With -SvelteKit 5 · Svelte 5 runes · TypeScript 5.7 · Node.js 24 · [`@github/copilot-sdk`](https://github.com/github/copilot-sdk) v1.0.0-beta.8 · Vite · `ws` · Web Speech API · Vitest · Playwright · Docker · Bicep +SvelteKit 5 · Svelte 5 runes · TypeScript 5.7 · Node.js 24 · [`@github/copilot-sdk`](https://github.com/github/copilot-sdk) v1.0.0 · Vite · `ws` · Web Speech API · Vitest · Playwright · Docker · Bicep ## Contributing diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 08fa393..5f65335 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,7 +25,7 @@ Browser (Svelte 5 SPA) | Language | TypeScript 5.7 (strict mode, ES2022) | | Framework | SvelteKit 5 with `adapter-node` | | Reactivity | Svelte 5 runes ($state, $derived, $effect, $props) | -| AI Engine | `@github/copilot-sdk` ^0.2.0 | +| AI Engine | `@github/copilot-sdk` ^1.0.0 (client `mode: "empty"` by default) | | Real-time | WebSocket (`ws` ^8.18) via custom `server.js` entry | | Markdown | `marked` + `dompurify` + `highlight.js` | | Security | Custom CSP/HSTS headers in hooks.server.ts, rate limiting, DOMPurify | @@ -363,6 +363,17 @@ All push API endpoints require GitHub authentication. | `CHAT_STATE_PATH` | — | `.chat-state` (dev) / `/data/chat-state` (prod) | Persisted chat state directory | | `PUSH_STORE_PATH` | — | `/data/push-subscriptions` | Push subscription storage | | `COPILOT_CONFIG_DIR` | — | `~/.copilot` | SDK config/session directory (Azure: `/data/copilot-home`) | +| `COPILOT_CLIENT_MODE` | — | `empty` | SDK client mode — `empty` (multi-user safe; app re-enables features per session) or `copilot-cli` (full CLI-equivalent ambient capabilities) | +| `ENABLE_REMOTE_SESSIONS` | — | `true` | Allow remote publishing (`remoteSession`) and cloud-agent sessions; `false` hard-disables | + +### SDK client mode + +The server creates each per-user `CopilotClient` with `mode: "empty"` (SDK 1.0.0): sessions start with no ambient capabilities, and `buildEmptyModeSessionDefaults()` in `src/lib/server/copilot/session.ts` explicitly re-enables what the app needs — all built-in/MCP/custom tools via `ToolSet`, skills, config discovery, host git operations, the session store, on-demand instruction discovery, persistent MCP OAuth tokens, and embedding cache. File hooks, telemetry, and plugins stay off. Set `COPILOT_CLIENT_MODE=copilot-cli` to restore the old behavior. + +### Remote & cloud sessions + +- **Remote publishing** — `new_session` accepts `remoteSession: "off"|"export"|"on"`; `remote_toggle` flips it at runtime via `session.rpc.remote.enable()/disable()`. The remote URL arrives via the SDK `session.info` event (`infoType: "remote"`) and is forwarded as `remote_session_url`; the UI renders a banner linking to github.com. +- **Cloud sessions** — `new_cloud_session` (validated `repository: { owner, name, branch? }`) creates a session with `cloud: { repository }` running on GitHub's cloud agent; the session ID is server-assigned. Handlers: `src/lib/server/ws/message-handlers/cloud-session.ts` and `remote.ts`. ## Deployment diff --git a/docs/screenshots/login-desktop.png b/docs/screenshots/login-desktop.png index cf54853..f7544df 100644 Binary files a/docs/screenshots/login-desktop.png and b/docs/screenshots/login-desktop.png differ diff --git a/docs/screenshots/login-ipad.png b/docs/screenshots/login-ipad.png index 0bbc8d7..4375364 100644 Binary files a/docs/screenshots/login-ipad.png and b/docs/screenshots/login-ipad.png differ diff --git a/docs/screenshots/login-mobile.png b/docs/screenshots/login-mobile.png index ec6cef6..7519495 100644 Binary files a/docs/screenshots/login-mobile.png and b/docs/screenshots/login-mobile.png differ diff --git a/docs/screenshots/usecase-autopilot-desktop.png b/docs/screenshots/usecase-autopilot-desktop.png index e1188d9..0291993 100644 Binary files a/docs/screenshots/usecase-autopilot-desktop.png and b/docs/screenshots/usecase-autopilot-desktop.png differ diff --git a/docs/screenshots/usecase-autopilot-ipad.png b/docs/screenshots/usecase-autopilot-ipad.png index 03abd67..0c1c6e6 100644 Binary files a/docs/screenshots/usecase-autopilot-ipad.png and b/docs/screenshots/usecase-autopilot-ipad.png differ diff --git a/docs/screenshots/usecase-autopilot-mobile.png b/docs/screenshots/usecase-autopilot-mobile.png index 2e08d6c..6f7e3bf 100644 Binary files a/docs/screenshots/usecase-autopilot-mobile.png and b/docs/screenshots/usecase-autopilot-mobile.png differ diff --git a/docs/screenshots/usecase-code-desktop.png b/docs/screenshots/usecase-code-desktop.png index 2f2c177..1f6fe33 100644 Binary files a/docs/screenshots/usecase-code-desktop.png and b/docs/screenshots/usecase-code-desktop.png differ diff --git a/docs/screenshots/usecase-code-ipad.png b/docs/screenshots/usecase-code-ipad.png index 16925cc..07af287 100644 Binary files a/docs/screenshots/usecase-code-ipad.png and b/docs/screenshots/usecase-code-ipad.png differ diff --git a/docs/screenshots/usecase-code-mobile.png b/docs/screenshots/usecase-code-mobile.png index ad9b900..c446c06 100644 Binary files a/docs/screenshots/usecase-code-mobile.png and b/docs/screenshots/usecase-code-mobile.png differ diff --git a/docs/screenshots/usecase-reasoning-desktop.png b/docs/screenshots/usecase-reasoning-desktop.png index 1cb4d13..ea33763 100644 Binary files a/docs/screenshots/usecase-reasoning-desktop.png and b/docs/screenshots/usecase-reasoning-desktop.png differ diff --git a/docs/screenshots/usecase-reasoning-ipad.png b/docs/screenshots/usecase-reasoning-ipad.png index 5f5bc81..1692cba 100644 Binary files a/docs/screenshots/usecase-reasoning-ipad.png and b/docs/screenshots/usecase-reasoning-ipad.png differ diff --git a/docs/screenshots/usecase-reasoning-mobile.png b/docs/screenshots/usecase-reasoning-mobile.png index 4c617de..6477345 100644 Binary files a/docs/screenshots/usecase-reasoning-mobile.png and b/docs/screenshots/usecase-reasoning-mobile.png differ diff --git a/package-lock.json b/package-lock.json index c6a9bce..2504622 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@github/copilot-sdk": "1.0.0-beta.8", + "@github/copilot-sdk": "^1.0.0", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.61.1", "dompurify": "^3.4.7", @@ -368,9 +368,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55-5.tgz", - "integrity": "sha512-n6Vr876Iz41PW8pSpOa7SbrNCqaV+6HDLNf/n8V4gIwwlOlIz7Jb00r/fboXZFIT+0dyAGGLoGgd7xUujVL/Xw==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.60.tgz", + "integrity": "sha512-+GjW+GJNo55nwJwt48o9szWcyhuY0u682cBKQI1ay9jVBX8DCCXC6HB6Tyv5/MaM4N7CxTiEgp48aVMkye8K+g==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -379,20 +379,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.55-5", - "@github/copilot-darwin-x64": "1.0.55-5", - "@github/copilot-linux-arm64": "1.0.55-5", - "@github/copilot-linux-x64": "1.0.55-5", - "@github/copilot-linuxmusl-arm64": "1.0.55-5", - "@github/copilot-linuxmusl-x64": "1.0.55-5", - "@github/copilot-win32-arm64": "1.0.55-5", - "@github/copilot-win32-x64": "1.0.55-5" + "@github/copilot-darwin-arm64": "1.0.60", + "@github/copilot-darwin-x64": "1.0.60", + "@github/copilot-linux-arm64": "1.0.60", + "@github/copilot-linux-x64": "1.0.60", + "@github/copilot-linuxmusl-arm64": "1.0.60", + "@github/copilot-linuxmusl-x64": "1.0.60", + "@github/copilot-win32-arm64": "1.0.60", + "@github/copilot-win32-x64": "1.0.60" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55-5.tgz", - "integrity": "sha512-Mult62GJVnxR3MOP2QNiVU5RRGXPJ+7BpjEMIvkoaMuWX6J7F4bz7N+HUXVHJUiGUp3hnL3M16kjkewWfNdoNg==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.60.tgz", + "integrity": "sha512-TErNaVxsv+uB3bdHwdoKorCd1rhiRh7HkX48vnS7jwqa8EtGgAkzNrHKC7mruL2rnYOOsNIdPfhzQk+2Y6PSxQ==", "cpu": [ "arm64" ], @@ -406,9 +406,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55-5.tgz", - "integrity": "sha512-IfY3WhNvHwXHldI2ARsiAYuPlKWlI07Fo1ALq+SViHhn0Zfp2yIr9laJRofyj0G1EbyUxkbNlqQm7UrXhkEVeg==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.60.tgz", + "integrity": "sha512-PthhcR6PqbQlT04xQKTElpPSJOrJd65nK/l9Sjmpwtk21RrDKs13DCY/19ubP17updYUWBxp3VNfyfN3DAQKOA==", "cpu": [ "x64" ], @@ -422,9 +422,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55-5.tgz", - "integrity": "sha512-UPZ5Y5QotcZvo3f4yFwJVOtAgUT3mq+q2fim82kWa/MA0+EkkADZ3kb+R4OnV1Nqv5EaoZiCFh0Ukk++IMSYwQ==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.60.tgz", + "integrity": "sha512-AVahkDVQTiGmHvDjlb4CHO8CFEGqmCEipxi0qTA60oH3Y3W2C4aYBwEBtP/85pN3wUUKZJVrWTCcxdufUBuK2Q==", "cpu": [ "arm64" ], @@ -441,9 +441,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55-5.tgz", - "integrity": "sha512-Fdwiir53Ogg8C9xv6sTc7/C4vFfQHt6VWFB74kojbDgIbYEpm57wNygQVwJvrwtVW3w/b1MLtGGTp7pEvUBACQ==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.60.tgz", + "integrity": "sha512-NwQjV2ZyUdJVAO4t7wiT+eR3uNWYP57xaLUIhf6JTMGpsTyN+mAFXW63xpwM/K+Pug62uRDQDBjEeOQRB7qZrA==", "cpu": [ "x64" ], @@ -460,9 +460,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55-5.tgz", - "integrity": "sha512-NqPmeAA1+iI8Xd4wJUHNNCmVTmHCl+R3nqdXhEVQDLIau9ouGqGGay/91d2ZIgFXJn7J0UTAEdHbdBcfhbnhvg==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.60.tgz", + "integrity": "sha512-AYGPc9vq2k248bVwUbiVJ65kIYYMQQ7ci+S3oefWBIyYtYwAH0n+Q/IGAj49IPrelBarYABAsX+EQZJJC8rhxw==", "cpu": [ "arm64" ], @@ -479,9 +479,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55-5.tgz", - "integrity": "sha512-bOB4vKw1R7Mekn8z34xpNViYUQ4LQAEFzpkyxhc0uOliFmfku/YcIgo42aMWFzf/Bi3iBazBNfCN+L2lz/Jc9A==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.60.tgz", + "integrity": "sha512-9/F7yl0/9FpGvYR/TCQtbhu0vIaUVem6U7em85QYaEjkS45nK500pByCMWY0bXv2eSS8U2g+8FOAjfkyLlxwPw==", "cpu": [ "x64" ], @@ -498,12 +498,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.0-beta.8", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.8.tgz", - "integrity": "sha512-lAuBfH6E5PUaSj8P/0FVMxzvwwBUs02tlvQ56PoJFtuc47KPqzGpf9BS7+h2eEr1UmjoLNJ/yqDiVApH9Oo1Fg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0.tgz", + "integrity": "sha512-OKjmJMDM+GB2uHr8UA6O0FNs1Gfw/tkoE5vUNlYmKbydc9Yjf6pvuBdseGjAVvzc6f9HIbB5eZKLUrxbOTw+yA==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.55-1", + "@github/copilot": "^1.0.57", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -512,9 +512,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55-5.tgz", - "integrity": "sha512-pR2KaiXUanjxolaWgRPlFdeTEpb7jcN1Rk8xVnBCD2ORwERXdYrqXaLCyDbgdplI9mI6IjM+kkUbyXzXoWz/HQ==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.60.tgz", + "integrity": "sha512-ZxxS+Ua1+7Puz80yTOpQ4WS+s32NjrxIsqo8gE0FpuZId16BGOGbWkzWQvR/k2AVBCqpLZ7SK3LfDVKuKJRbpA==", "cpu": [ "arm64" ], @@ -528,9 +528,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55-5.tgz", - "integrity": "sha512-EuQBgqSnRFjavgeFifbnSYUJ4elTQBLC/kf+WHolrHR2oUGyiqCQZz/cV2DYVSLP1TGxDKAV4AQCM1AdUT1xEA==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.60.tgz", + "integrity": "sha512-e91ZlFz9J1lkadExLg36oN8Ms/xIa03vAEir3DmyCeYebZ+Y48vdS+BwhQEma+GLoxJUOhzHndCckGnMRfNIbA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 6786c68..4b4aedb 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "node": ">=24.0.0" }, "dependencies": { - "@github/copilot-sdk": "1.0.0-beta.8", + "@github/copilot-sdk": "^1.0.0", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.61.1", "dompurify": "^3.4.7", diff --git a/playwright.config.ts b/playwright.config.ts index 4f47378..814b3fc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ GITHUB_CLIENT_ID: 'test-client-id', SESSION_SECRET: 'test-secret-for-playwright', NODE_ENV: 'development', + E2E_DISABLE_RATE_LIMIT: 'true', }, }, }); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 23edc13..bae1469 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -103,6 +103,11 @@ setInterval(() => { }, RATE_LIMIT_WINDOW); const rateLimit: Handle = async ({ event, resolve }) => { + // Never allow test-only rate-limit bypass in production. + if (process.env.E2E_DISABLE_RATE_LIMIT === 'true' && process.env.NODE_ENV !== 'production') { + return resolve(event); + } + const ip = event.getClientAddress(); const now = Date.now(); diff --git a/src/lib/components/chat/ChatMessage.svelte b/src/lib/components/chat/ChatMessage.svelte index 3c9b6eb..a94cac1 100644 --- a/src/lib/components/chat/ChatMessage.svelte +++ b/src/lib/components/chat/ChatMessage.svelte @@ -60,7 +60,7 @@ if (message.cost != null) parts.push(`cost: ${message.cost}×`); if (message.duration != null) parts.push(`${message.duration}ms`); const premium = message.copilotUsage?.reduce((acc, item) => acc + (item.premiumRequests ?? 0), 0) ?? 0; - if (premium > 0) parts.push(`premium: ${premium}`); + if (premium > 0) parts.push(`AIC: ${premium}`); return parts.length > 0 ? `tokens — ${parts.join(' · ')}` : ''; }); @@ -81,6 +81,7 @@ mcpToolName: message.mcpToolName, status: message.toolStatus ?? 'running', message: message.toolProgressMessage, + error: message.toolError, progressMessages: message.toolProgressMessages, }; }); diff --git a/src/lib/components/chat/ToolCall.svelte b/src/lib/components/chat/ToolCall.svelte index d84cc8f..290f92b 100644 --- a/src/lib/components/chat/ToolCall.svelte +++ b/src/lib/components/chat/ToolCall.svelte @@ -26,7 +26,6 @@ if (tool.status === 'failed') return 'failed'; return ''; }); - function toggle() { if (hasProgress) expanded = !expanded; } @@ -78,6 +77,9 @@ {/if} {/if} + {#if tool.status === 'failed' && tool.error} + + {/if} diff --git a/src/lib/components/layout/EnvInfo.svelte b/src/lib/components/layout/EnvInfo.svelte index 8cc4622..4073636 100644 --- a/src/lib/components/layout/EnvInfo.svelte +++ b/src/lib/components/layout/EnvInfo.svelte @@ -64,7 +64,7 @@ {/if} {#if sessionTotals.premiumRequests > 0} -
{sessionTotals.premiumRequests} premium requests this session
+
{sessionTotals.premiumRequests} AI Credits (AIC) used this session
{/if} diff --git a/src/lib/components/layout/RemoteBanner.svelte b/src/lib/components/layout/RemoteBanner.svelte new file mode 100644 index 0000000..b45b367 --- /dev/null +++ b/src/lib/components/layout/RemoteBanner.svelte @@ -0,0 +1,82 @@ + + +
+
+ + diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index ecc92bc..8c996fc 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -185,7 +185,7 @@ {(sessionTotals.totalDurationMs / 1000).toFixed(1)}s total API time {/if} {#if sessionTotals.premiumRequests > 0} - {sessionTotals.premiumRequests} premium requests + {sessionTotals.premiumRequests} AI Credits (AIC) {/if} diff --git a/src/lib/components/sessions/SessionPreview.svelte b/src/lib/components/sessions/SessionPreview.svelte index 168d0c9..6e25fdc 100644 --- a/src/lib/components/sessions/SessionPreview.svelte +++ b/src/lib/components/sessions/SessionPreview.svelte @@ -60,6 +60,12 @@ {formatPath(detail.cwd)} {/if} + {#if detail.isRemote} +
+ Source + Remote / cloud +
+ {/if} {#if detail.createdAt || detail.updatedAt}
Last active @@ -170,6 +176,10 @@ color: var(--accent); } + .meta-value.remote { + color: var(--purple, #a78bfa); + } + .preview-section { margin-top: var(--sp-3); padding-top: var(--sp-3); diff --git a/src/lib/components/sessions/SessionsSheet.svelte b/src/lib/components/sessions/SessionsSheet.svelte index 087957f..72e139b 100644 --- a/src/lib/components/sessions/SessionsSheet.svelte +++ b/src/lib/components/sessions/SessionsSheet.svelte @@ -1,7 +1,7 @@ + +

+ Remote sessions publish your conversation to github.com so you can view or steer it from other devices. + This setting applies to new sessions. +

+ +
+ {#each MODES as mode (mode.value)} + + {/each} +
+ +{#if sessionActive && onApplyToSession} + +{/if} + + diff --git a/src/lib/components/settings/SettingsModal.svelte b/src/lib/components/settings/SettingsModal.svelte index 5fbad17..175bbc0 100644 --- a/src/lib/components/settings/SettingsModal.svelte +++ b/src/lib/components/settings/SettingsModal.svelte @@ -19,8 +19,10 @@ import NotificationsPanel from './NotificationsPanel.svelte'; import CompactionPanel from './CompactionPanel.svelte'; import ByokPanel from './ByokPanel.svelte'; + import RemoteSessionPanel from './RemoteSessionPanel.svelte'; + import type { RemoteSessionMode } from '$lib/types/index.js'; - type AccordionSection = 'instructions' | 'tools' | 'mcp' | 'agents' | 'skills' | 'extensions' | 'quota' | 'notifications' | 'compact' | 'prompts' | 'byok' | null; + type AccordionSection = 'instructions' | 'tools' | 'mcp' | 'agents' | 'skills' | 'extensions' | 'quota' | 'notifications' | 'remote' | 'compact' | 'prompts' | 'byok' | null; interface Props { open: boolean; @@ -55,6 +57,10 @@ onToggleMcpServer: (name: string, enabled: boolean) => void; notificationsEnabled: boolean; onToggleNotifications: (enabled: boolean) => void; + remoteSessionMode?: RemoteSessionMode; + onSetRemoteSessionMode?: (mode: RemoteSessionMode) => void; + remoteSessionActive?: boolean; + onApplyRemoteToSession?: (mode: RemoteSessionMode) => void; voiceInputEnabled: boolean; onToggleVoiceInput: (enabled: boolean) => void; ttsEnabled: boolean; @@ -98,6 +104,10 @@ onToggleMcpServer, notificationsEnabled, onToggleNotifications, + remoteSessionMode = 'off', + onSetRemoteSessionMode, + remoteSessionActive = false, + onApplyRemoteToSession, voiceInputEnabled, onToggleVoiceInput, ttsEnabled, @@ -379,6 +389,30 @@ {/if}
+ + {#if onSetRemoteSessionMode} +
+ + {#if activeSection === 'remote'} +
+ +
+ {/if} +
+ {/if} +
diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 597f090..fc3879a 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -41,6 +41,11 @@ function getConfig() { otelCaptureContent: process.env.OTEL_CAPTURE_CONTENT === 'true', otelSourceName: env('OTEL_SOURCE_NAME', 'copilot-unleashed'), enableRemoteSessions: process.env.ENABLE_REMOTE_SESSIONS?.trim().toLowerCase() !== 'false', + // SDK client mode: "empty" (safe, explicit opt-in — default) or "copilot-cli" + // (legacy escape hatch giving the agent CLI-equivalent ambient capabilities). + copilotClientMode: (process.env.COPILOT_CLIENT_MODE?.trim().toLowerCase() === 'copilot-cli' + ? 'copilot-cli' + : 'empty') as 'empty' | 'copilot-cli', }; } diff --git a/src/lib/server/copilot/client.ts b/src/lib/server/copilot/client.ts index 37bc096..b7406be 100644 --- a/src/lib/server/copilot/client.ts +++ b/src/lib/server/copilot/client.ts @@ -1,4 +1,5 @@ import { homedir } from 'node:os'; +import { join } from 'node:path'; import { CopilotClient, RuntimeConnection } from '@github/copilot-sdk'; import type { TelemetryConfig } from '@github/copilot-sdk'; import { config } from '../config.js'; @@ -15,11 +16,17 @@ function buildTelemetryConfig(): TelemetryConfig | undefined { export function createCopilotClient(githubToken: string, configDir?: string): CopilotClient { const telemetry = buildTelemetryConfig(); + // Empty mode requires an explicit persistence location, so always resolve one. + const baseDirectory = configDir || config.copilotConfigDir || join(homedir(), '.copilot'); + return new CopilotClient({ connection: RuntimeConnection.forStdio(), gitHubToken: githubToken, workingDirectory: config.copilotCwd || homedir(), - ...(configDir && { baseDirectory: configDir }), + // "empty" mode disables the CLI's ambient host capabilities by default; + // each session explicitly opts back into the features this app uses. + mode: config.copilotClientMode, + baseDirectory, ...(telemetry && { telemetry }), enableRemoteSessions: config.enableRemoteSessions, }); diff --git a/src/lib/server/copilot/session-empty-mode.test.ts b/src/lib/server/copilot/session-empty-mode.test.ts new file mode 100644 index 0000000..6b90d04 --- /dev/null +++ b/src/lib/server/copilot/session-empty-mode.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const configMock = vi.hoisted(() => ({ + copilotConfigDir: '/copilot-config', + copilotClientMode: 'empty' as string, +})); + +vi.mock('@github/copilot-sdk', () => { + class ToolSetMock { + private tools: string[] = []; + addBuiltIn(name: string) { this.tools.push(`builtin:${name}`); return this; } + addMcp(name: string) { this.tools.push(`mcp:${name}`); return this; } + addCustom(name: string) { this.tools.push(`custom:${name}`); return this; } + toArray() { return [...this.tools]; } + } + return { + CopilotClient: vi.fn(), + ToolSet: ToolSetMock, + }; +}); + +vi.mock('../config.js', () => ({ config: configMock })); + +import { buildEmptyModeSessionDefaults } from './session.js'; + +describe('buildEmptyModeSessionDefaults', () => { + beforeEach(() => { + configMock.copilotClientMode = 'empty'; + }); + + it('returns no overrides when the client is not in empty mode', () => { + configMock.copilotClientMode = 'copilot-cli'; + expect(buildEmptyModeSessionDefaults()).toEqual({}); + }); + + it('re-enables features the app relies on under empty mode', () => { + const defaults = buildEmptyModeSessionDefaults(); + + expect(defaults).toMatchObject({ + enableSkills: true, + enableConfigDiscovery: true, + enableHostGitOperations: true, + enableSessionStore: true, + enableOnDemandInstructionDiscovery: true, + mcpOAuthTokenStorage: 'persistent', + embeddingCacheStorage: 'persistent', + skipEmbeddingRetrieval: false, + skipCustomInstructions: false, + coauthorEnabled: true, + }); + }); + + it('grants all built-in, MCP, and custom tools via availableTools', () => { + const defaults = buildEmptyModeSessionDefaults(); + expect(defaults.availableTools).toEqual(['builtin:*', 'mcp:*', 'custom:*']); + }); + + it('does not re-enable file hooks or telemetry', () => { + const defaults = buildEmptyModeSessionDefaults(); + expect(defaults).not.toHaveProperty('enableFileHooks'); + expect(defaults).not.toHaveProperty('enableSessionTelemetry'); + }); +}); diff --git a/src/lib/server/copilot/session.test.ts b/src/lib/server/copilot/session.test.ts index f993757..7c3565d 100644 --- a/src/lib/server/copilot/session.test.ts +++ b/src/lib/server/copilot/session.test.ts @@ -61,10 +61,11 @@ describe('createCopilotSession', () => { const sessionConfig = getSessionConfig(client); expect(sessionConfig).toMatchObject({ clientName: 'copilot-unleashed', - model: 'gpt-4.1', streaming: true, - configDir: '/copilot-config', + configDirectory: '/copilot-config', }); + // No hardcoded model — the SDK picks its default when none is given + expect(sessionConfig).not.toHaveProperty('model'); const mcpServers = sessionConfig.mcpServers as Record>; expect(mcpServers.github).toEqual({ @@ -124,7 +125,7 @@ describe('createCopilotSession', () => { excludedTools: ['bash'], availableTools: ['read'], onUserInputRequest, - configDir: '/custom-config', + configDirectory: '/custom-config', systemMessage: { mode: 'append', content: 'Stay concise.', diff --git a/src/lib/server/copilot/session.ts b/src/lib/server/copilot/session.ts index b16872d..c451342 100644 --- a/src/lib/server/copilot/session.ts +++ b/src/lib/server/copilot/session.ts @@ -2,8 +2,8 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { readFile, writeFile, rename } from 'node:fs/promises'; import { createHash, randomUUID } from 'node:crypto'; -import { CopilotClient } from '@github/copilot-sdk'; -import type { SessionConfig, SystemMessageSection, SectionOverride, MCPServerConfig, ModelCapabilitiesOverride, RemoteSessionMode } from '@github/copilot-sdk'; +import { CopilotClient, ToolSet } from '@github/copilot-sdk'; +import type { SessionConfig, SystemMessageSection, SectionOverride, MCPServerConfig, ModelCapabilitiesOverride, RemoteSessionMode, CloudSessionOptions } from '@github/copilot-sdk'; export type HookEventCallback = (message: Record) => void; import { isIP } from 'node:net'; @@ -46,6 +46,8 @@ export interface CreateSessionOptions { provider?: SessionConfig['provider']; onElicitationRequest?: SessionConfig['onElicitationRequest']; remoteSession?: RemoteSessionMode; + /** Create the session on GitHub's cloud agent infrastructure instead of locally */ + cloud?: CloudSessionOptions; } function isPrivateIpv4(hostname: string): boolean { @@ -386,14 +388,51 @@ export async function buildSessionMcpServers( }; } -export function buildSessionHooks(onHookEvent: HookEventCallback): SessionConfig['hooks'] { +/** + * Explicit re-enables for the SDK's "empty" client mode. + * + * Empty mode disables the CLI's ambient capabilities by default (safe for + * multi-user servers). This app opts back into exactly the features it uses, + * keeping behavior parity with the previous "copilot-cli" mode while every + * capability stays an explicit, auditable choice. + */ +export function buildEmptyModeSessionDefaults(): Partial { + if (config.copilotClientMode !== 'empty') return {}; return { + // Empty mode requires every session to opt into its tools explicitly. + availableTools: new ToolSet().addBuiltIn('*').addMcp('*').addCustom('*').toArray(), + enableSkills: true, + enableConfigDiscovery: true, + enableHostGitOperations: true, + enableSessionStore: true, + enableOnDemandInstructionDiscovery: true, + // Keep MCP OAuth tokens on disk so the Copilot CLI stays in sync + mcpOAuthTokenStorage: 'persistent', + embeddingCacheStorage: 'persistent', + skipEmbeddingRetrieval: false, + skipCustomInstructions: false, + coauthorEnabled: true, + // Deliberately left at empty-mode defaults (off): enableFileHooks + // (hooks are wired via SDK callbacks), enableSessionTelemetry, + // customAgentsLocalOnly stays true, installedPlugins stays []. + }; +} + +export function buildSessionHooks(onHookEvent: HookEventCallback): SessionConfig['hooks'] { return { onPreToolUse: (input) => { onHookEvent({ type: 'hook_pre_tool', toolName: input.toolName, toolArgs: input.toolArgs }); }, onPostToolUse: (input) => { onHookEvent({ type: 'hook_post_tool', toolName: input.toolName, toolArgs: input.toolArgs }); }, + onPostToolUseFailure: (input) => { + onHookEvent({ + type: 'hook_tool_failure', + toolName: input.toolName, + toolArgs: input.toolArgs, + error: input.error, + }); + }, onUserPromptSubmitted: (input) => { onHookEvent({ type: 'hook_user_prompt', prompt: input.prompt }); }, @@ -435,11 +474,14 @@ export async function createCopilotSession( console.log('[SESSION] Creating session with permissionMode:', options.permissionMode || 'approve_all (default)'); const sessionConfig: SessionConfig = { + ...buildEmptyModeSessionDefaults(), clientName: 'copilot-unleashed', - model: options.model || 'gpt-4.1', + // Omit model when unset so the SDK picks its own default — hardcoding a + // model breaks when it's no longer in the user's available model list. + ...(options.model ? { model: options.model } : {}), streaming: true, onPermissionRequest: permissionHandler, - ...(config.copilotConfigDir && { configDir: config.copilotConfigDir }), + ...(config.copilotConfigDir && { configDirectory: config.copilotConfigDir }), mcpServers: await buildSessionMcpServers(githubToken, options.configDir), }; @@ -486,7 +528,7 @@ export async function createCopilotSession( } if (options.configDir) { - sessionConfig.configDir = options.configDir; + sessionConfig.configDirectory = options.configDir; } if (options.skillDirectories && options.skillDirectories.length > 0) { @@ -529,6 +571,10 @@ export async function createCopilotSession( sessionConfig.remoteSession = options.remoteSession; } + if (options.cloud) { + sessionConfig.cloud = options.cloud; + } + // The SDK 1.0.0-beta runtime writes session state directly to // `/session-state/` (set via CopilotClient `baseDirectory`), // so an explicit per-session FS provider is no longer required. diff --git a/src/lib/server/ws/constants.ts b/src/lib/server/ws/constants.ts index f8ac950..50db95c 100644 --- a/src/lib/server/ws/constants.ts +++ b/src/lib/server/ws/constants.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; export const MAX_MESSAGE_LENGTH = 10_000; export const VALID_MESSAGE_TYPES = new Set([ - 'new_session', 'message', 'list_models', 'set_mode', + 'new_session', 'new_cloud_session', 'message', 'list_models', 'set_mode', 'abort', 'set_model', 'set_reasoning', 'user_input_response', 'permission_response', 'elicitation_response', 'ping', 'list_tools', 'list_agents', 'select_agent', 'deselect_agent', @@ -18,6 +18,7 @@ export const VALID_MESSAGE_TYPES = new Set([ 'workspace_list_files', 'workspace_read_file', 'workspace_create_file', 'clear_chat', 'get_session_history', 'session_log', + 'remote_toggle', ]); export const VALID_MODES = new Set(['interactive', 'plan', 'autopilot']); @@ -31,6 +32,6 @@ export const HEARTBEAT_INTERVAL = 30_000; export const MAX_MISSED_PINGS = 3; export const UPLOAD_DIR_PREFIX = join(tmpdir(), 'copilot-uploads'); -export const RATE_LIMITED_TYPES = new Set(['message', 'new_session', 'resume_session', 'compact', 'start_fleet']); +export const RATE_LIMITED_TYPES = new Set(['message', 'new_session', 'new_cloud_session', 'resume_session', 'compact', 'start_fleet']); export const WS_RATE_LIMIT_MAX = 30; export const WS_RATE_LIMIT_WINDOW_MS = 60_000; diff --git a/src/lib/server/ws/message-handlers/cloud-session.test.ts b/src/lib/server/ws/message-handlers/cloud-session.test.ts new file mode 100644 index 0000000..0bb9872 --- /dev/null +++ b/src/lib/server/ws/message-handlers/cloud-session.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { configMock, poolSendMock, createCopilotSessionMock, chatStateDeleteMock, chatStateSaveMock } = vi.hoisted(() => ({ + configMock: { enableRemoteSessions: true, copilotConfigDir: '/copilot-config' }, + poolSendMock: vi.fn(), + createCopilotSessionMock: vi.fn(), + chatStateDeleteMock: vi.fn(), + chatStateSaveMock: vi.fn(async (..._args: unknown[]) => {}), +})); + +vi.mock('../../config.js', () => ({ config: configMock })); +vi.mock('../session-pool.js', () => ({ poolSend: (...args: unknown[]) => poolSendMock(...args) })); +vi.mock('../../copilot/session.js', () => ({ + createCopilotSession: (...args: unknown[]) => createCopilotSessionMock(...args), +})); +vi.mock('../../chat-state-singleton.js', () => ({ + chatStateStore: { + delete: (...args: unknown[]) => chatStateDeleteMock(...args), + save: (...args: unknown[]) => chatStateSaveMock(...args), + }, +})); +vi.mock('../session-events.js', () => ({ + wireSessionEvents: vi.fn(), + createCatchAllHandler: vi.fn(() => vi.fn()), + HANDLED_EVENT_TYPES: new Set(), +})); +vi.mock('../permissions.js', () => ({ + makeUserInputHandler: vi.fn(() => vi.fn()), + makePermissionHandler: vi.fn(() => vi.fn()), + makeElicitationHandler: vi.fn(() => vi.fn()), +})); +vi.mock('../../skills/scanner.js', () => ({ + getSkillDirectories: vi.fn(async () => []), +})); + +import { handleNewCloudSession } from './cloud-session.js'; +import type { MessageContext } from '../types.js'; + +function makeContext(): MessageContext { + return { + connectionEntry: { + client: {}, + session: null, + userInputResolve: null, + permissionResolves: new Map(), + pendingUserInputPrompt: null, + pendingPermissionPrompts: new Map(), + sdkSessionId: null, + model: null, + mode: 'interactive', + } as unknown as MessageContext['connectionEntry'], + githubToken: 'gh-token', + userLogin: 'octocat', + poolKey: 'octocat:tab1', + ws: {} as MessageContext['ws'], + }; +} + +function lastPoolMessage(): Record { + return poolSendMock.mock.calls.at(-1)?.[1] as Record; +} + +describe('handleNewCloudSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + configMock.enableRemoteSessions = true; + createCopilotSessionMock.mockResolvedValue({ + sessionId: 'cloud-session-1', + rpc: { mode: { set: vi.fn(async () => {}) } }, + }); + }); + + it('rejects when remote sessions are disabled server-side', async () => { + configMock.enableRemoteSessions = false; + + await handleNewCloudSession({ type: 'new_cloud_session' }, makeContext()); + + expect(createCopilotSessionMock).not.toHaveBeenCalled(); + expect(lastPoolMessage()).toMatchObject({ type: 'error', message: expect.stringContaining('disabled') }); + }); + + it.each([ + [{ owner: '-bad-', name: 'repo' }, 'Invalid repository owner'], + [{ owner: 'octocat', name: 'bad repo!' }, 'Invalid repository name'], + [{ owner: 'octocat', name: 'repo', branch: '-bad' }, 'Invalid branch name'], + [{ owner: 'octocat', name: 'repo', branch: 'main/' }, 'Invalid branch name'], + [{ owner: 'octocat', name: 'repo', branch: 'feature..bad' }, 'Invalid branch name'], + ])('rejects invalid repository input %j', async (repository, expected) => { + await handleNewCloudSession({ type: 'new_cloud_session', repository }, makeContext()); + + expect(createCopilotSessionMock).not.toHaveBeenCalled(); + expect(lastPoolMessage()).toMatchObject({ type: 'error', message: expected }); + }); + + it('rejects non-object repository values', async () => { + await handleNewCloudSession({ type: 'new_cloud_session', repository: 'octocat/repo' }, makeContext()); + + expect(createCopilotSessionMock).not.toHaveBeenCalled(); + expect(lastPoolMessage()).toMatchObject({ type: 'error' }); + }); + + it('creates a cloud session with a validated repository', async () => { + const ctx = makeContext(); + await handleNewCloudSession({ + type: 'new_cloud_session', + model: 'gpt-4.1', + mode: 'interactive', + repository: { owner: 'octocat', name: 'hello-world', branch: 'main' }, + }, ctx); + + expect(chatStateDeleteMock).toHaveBeenCalledWith('octocat', 'tab1'); + expect(createCopilotSessionMock).toHaveBeenCalledTimes(1); + const options = createCopilotSessionMock.mock.calls[0][2] as Record; + expect(options.cloud).toEqual({ repository: { owner: 'octocat', name: 'hello-world', branch: 'main' } }); + + const created = poolSendMock.mock.calls.find((c) => (c[1] as { type: string }).type === 'cloud_session_created')?.[1]; + expect(created).toMatchObject({ + type: 'cloud_session_created', + sessionId: 'cloud-session-1', + repository: { owner: 'octocat', name: 'hello-world', branch: 'main' }, + }); + expect(chatStateSaveMock).toHaveBeenCalled(); + }); + + it('creates a repository-less cloud session when no repository is given', async () => { + await handleNewCloudSession({ type: 'new_cloud_session', model: 'gpt-4.1' }, makeContext()); + + const options = createCopilotSessionMock.mock.calls[0][2] as Record; + expect(options.cloud).toEqual({}); + }); + + it('reports an error when cloud session creation fails', async () => { + createCopilotSessionMock.mockRejectedValue(new Error('no entitlement')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await handleNewCloudSession({ type: 'new_cloud_session' }, makeContext()); + + expect(lastPoolMessage()).toMatchObject({ + type: 'error', + message: expect.stringContaining('no entitlement'), + }); + }); +}); diff --git a/src/lib/server/ws/message-handlers/cloud-session.ts b/src/lib/server/ws/message-handlers/cloud-session.ts new file mode 100644 index 0000000..6bb9cf3 --- /dev/null +++ b/src/lib/server/ws/message-handlers/cloud-session.ts @@ -0,0 +1,133 @@ +import { createCopilotSession } from '../../copilot/session.js'; +import { chatStateStore } from '../../chat-state-singleton.js'; +import { config } from '../../config.js'; +import { poolSend } from '../session-pool.js'; +import { VALID_MODES } from '../constants.js'; +import { wireSessionEvents, createCatchAllHandler, HANDLED_EVENT_TYPES } from '../session-events.js'; +import { makeUserInputHandler, makePermissionHandler, makeElicitationHandler } from '../permissions.js'; +import { getSkillDirectories } from '../../skills/scanner.js'; +import type { MessageContext } from '../types.js'; + +// GitHub owner: alphanumeric + hyphens, no leading/trailing hyphen, max 39 chars. +const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; +// GitHub repo name: alphanumeric, hyphen, underscore, dot; max 100 chars. +const REPO_RE = /^[a-zA-Z0-9._-]{1,100}$/; +// Git branch: conservative allowlist (no control chars, spaces, or git-invalid sequences). +const BRANCH_RE = /^(?!.*\.\.)(?!.*\/$)[a-zA-Z0-9](?:[a-zA-Z0-9._\/-]{0,254})$/; + +function rawTabId(ctx: MessageContext): string { + return ctx.poolKey.split(':').slice(1).join(':'); +} + +interface CloudRepositoryInput { + owner: string; + name: string; + branch?: string; +} + +function parseRepository(raw: unknown): CloudRepositoryInput | { error: string } | null { + if (raw == null) return null; + if (typeof raw !== 'object') return { error: 'repository must be an object' }; + + const obj = raw as Record; + const owner = typeof obj.owner === 'string' ? obj.owner.trim() : ''; + const name = typeof obj.name === 'string' ? obj.name.trim() : ''; + const branch = typeof obj.branch === 'string' ? obj.branch.trim() : undefined; + + if (!OWNER_RE.test(owner)) return { error: 'Invalid repository owner' }; + if (!REPO_RE.test(name)) return { error: 'Invalid repository name' }; + if (branch !== undefined && branch !== '' && !BRANCH_RE.test(branch)) { + return { error: 'Invalid branch name' }; + } + + return { owner, name, ...(branch ? { branch } : {}) }; +} + +/** + * Creates a session that runs on GitHub's cloud agent infrastructure + * instead of locally. The session ID is assigned server-side by GitHub. + */ +export async function handleNewCloudSession(msg: any, ctx: MessageContext): Promise { + const { connectionEntry, githubToken } = ctx; + + if (!config.enableRemoteSessions) { + poolSend(connectionEntry, { type: 'error', message: 'Remote sessions are disabled on this server' }); + return; + } + + const repository = parseRepository(msg.repository); + if (repository && 'error' in repository) { + poolSend(connectionEntry, { type: 'error', message: repository.error }); + return; + } + + // Delete old persisted state before creating new session + chatStateStore.delete(ctx.userLogin, rawTabId(ctx)); + + if (connectionEntry.session) { + try { await connectionEntry.session.disconnect(); } catch { /* ignore */ } + connectionEntry.session = null; + } + connectionEntry.userInputResolve = null; + connectionEntry.permissionResolves.clear(); + connectionEntry.pendingUserInputPrompt = null; + connectionEntry.pendingPermissionPrompts.clear(); + + try { + const skillDirectories = await getSkillDirectories(); + const onEvent = createCatchAllHandler(connectionEntry, HANDLED_EVENT_TYPES); + + connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, { + model: msg.model, + reasoningEffort: msg.reasoningEffort, + onUserInputRequest: makeUserInputHandler(connectionEntry, ctx.userLogin), + permissionMode: msg.mode === 'autopilot' ? 'approve_all' : 'prompt', + onPermissionRequest: makePermissionHandler(connectionEntry, ctx.userLogin), + onElicitationRequest: makeElicitationHandler(connectionEntry, ctx.userLogin), + configDir: config.copilotConfigDir, + skillDirectories, + onEvent, + cloud: repository ? { repository } : {}, + onHookEvent: (message) => poolSend(connectionEntry, message), + }); + + wireSessionEvents(connectionEntry.session, connectionEntry, connectionEntry.session?.sessionId, ctx.userLogin, rawTabId(ctx)); + + if (msg.mode && VALID_MODES.has(msg.mode)) { + try { + await connectionEntry.session.rpc.mode.set({ mode: msg.mode }); + } catch (modeErr: any) { + console.warn('Initial mode set failed for cloud session:', modeErr.message); + } + } + + const sessionId = connectionEntry.session?.sessionId; + poolSend(connectionEntry, { + type: 'cloud_session_created', + sessionId, + model: msg.model, + ...(repository ? { repository } : {}), + }); + + connectionEntry.sdkSessionId = sessionId ?? null; + connectionEntry.model = msg.model ?? null; + connectionEntry.mode = msg.mode ?? 'interactive'; + + chatStateStore.save(ctx.userLogin, rawTabId(ctx), { + userId: ctx.userLogin, + tabId: rawTabId(ctx), + sdkSessionId: sessionId ?? null, + model: msg.model ?? '', + mode: msg.mode ?? 'interactive', + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }).catch(() => {}); + } catch (err: any) { + console.error('Cloud session creation error:', err.message); + poolSend(connectionEntry, { + type: 'error', + message: `Failed to create cloud session: ${err.message}`, + }); + } +} diff --git a/src/lib/server/ws/message-handlers/index.ts b/src/lib/server/ws/message-handlers/index.ts index d9423d6..5008fe0 100644 --- a/src/lib/server/ws/message-handlers/index.ts +++ b/src/lib/server/ws/message-handlers/index.ts @@ -7,6 +7,8 @@ import { handleListTools, handleListAgents, handleSelectAgent, handleDeselectAge import { handleGetQuota, handleCompact } from './quota-compact.js'; import { handleListSessions, handleDeleteSession, handleGetSessionDetail, handleListModels, handleGetSessionHistory, handleSessionLog } from './session-management.js'; import { handleResumeSession } from './resume-session.js'; +import { handleNewCloudSession } from './cloud-session.js'; +import { handleRemoteToggle } from './remote.js'; import { handleGetPlan, handleUpdatePlan, handleDeletePlan } from './plans.js'; import { handleStartFleet } from './fleet.js'; import { handleListSkillsRpc, handleToggleSkillRpc, handleReloadSkills, handleListMcpRpc, handleToggleMcpRpc, handleListInstructions, handleListPrompts, handleUsePrompt } from './rpc-discovery.js'; @@ -17,6 +19,8 @@ import { chatStateStore } from '../../chat-state-singleton.js'; export const messageHandlers: Record Promise> = { new_session: handleNewSession, + new_cloud_session: handleNewCloudSession, + remote_toggle: handleRemoteToggle, message: handleChat, list_models: handleListModels, set_mode: handleSetMode, diff --git a/src/lib/server/ws/message-handlers/new-session.test.ts b/src/lib/server/ws/message-handlers/new-session.test.ts new file mode 100644 index 0000000..dd6faca --- /dev/null +++ b/src/lib/server/ws/message-handlers/new-session.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { configMock, poolSendMock, createCopilotSessionMock, chatStateDeleteMock, chatStateSaveMock } = vi.hoisted(() => ({ + configMock: { + enableRemoteSessions: true, + copilotConfigDir: '/copilot-config', + byokEnabled: false, + }, + poolSendMock: vi.fn(), + createCopilotSessionMock: vi.fn(), + chatStateDeleteMock: vi.fn(), + chatStateSaveMock: vi.fn(async (..._args: unknown[]) => {}), +})); + +vi.mock('../../config.js', () => ({ config: configMock })); +vi.mock('../session-pool.js', () => ({ poolSend: (...args: unknown[]) => poolSendMock(...args) })); +vi.mock('../../copilot/session.js', () => ({ + createCopilotSession: (...args: unknown[]) => createCopilotSessionMock(...args), +})); +vi.mock('../../chat-state-singleton.js', () => ({ + chatStateStore: { + delete: (...args: unknown[]) => chatStateDeleteMock(...args), + save: (...args: unknown[]) => chatStateSaveMock(...args), + }, +})); +vi.mock('../session-events.js', () => ({ + wireSessionEvents: vi.fn(), + createCatchAllHandler: vi.fn(() => vi.fn()), + HANDLED_EVENT_TYPES: new Set(), +})); +vi.mock('../permissions.js', () => ({ + makeUserInputHandler: vi.fn(() => vi.fn()), + makePermissionHandler: vi.fn(() => vi.fn()), + makeElicitationHandler: vi.fn(() => vi.fn()), +})); +vi.mock('../../skills/scanner.js', () => ({ + getSkillDirectories: vi.fn(async () => []), +})); +vi.mock('../../byok/provider-store.js', () => ({ + loadProviderConfig: vi.fn(async () => null), +})); + +import { handleNewSession } from './new-session.js'; +import type { MessageContext } from '../types.js'; + +function makeContext(): MessageContext { + return { + connectionEntry: { + client: {}, + session: null, + userInputResolve: null, + permissionResolves: new Map(), + pendingUserInputPrompt: null, + pendingPermissionPrompts: new Map(), + sdkSessionId: null, + model: null, + mode: 'interactive', + } as unknown as MessageContext['connectionEntry'], + githubToken: 'gh-token', + userLogin: 'octocat', + poolKey: 'octocat:tab1', + ws: {} as MessageContext['ws'], + }; +} + +function sentMessages(): Array> { + return poolSendMock.mock.calls.map((c) => c[1] as Record); +} + +const sdkSession = { sessionId: 'sdk-1', rpc: { mode: { set: vi.fn(async () => {}) } } }; + +describe('handleNewSession model fallback', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + it('creates a session with the requested model', async () => { + createCopilotSessionMock.mockResolvedValue(sdkSession); + + await handleNewSession({ type: 'new_session', model: 'claude-sonnet-4-6' }, makeContext()); + + expect(createCopilotSessionMock).toHaveBeenCalledTimes(1); + const options = createCopilotSessionMock.mock.calls[0][2] as Record; + expect(options.model).toBe('claude-sonnet-4-6'); + expect(sentMessages()).toContainEqual( + expect.objectContaining({ type: 'session_created', model: 'claude-sonnet-4-6' }), + ); + }); + + it('omits the model entirely when the client sends none', async () => { + createCopilotSessionMock.mockResolvedValue(sdkSession); + + await handleNewSession({ type: 'new_session' }, makeContext()); + + const options = createCopilotSessionMock.mock.calls[0][2] as Record; + expect(options.model).toBeUndefined(); + }); + + it('retries with the SDK default when the requested model is not available', async () => { + createCopilotSessionMock + .mockRejectedValueOnce(new Error('Request session.create failed with message: Model "gpt-4.1" is not available.')) + .mockResolvedValueOnce(sdkSession); + + await handleNewSession({ type: 'new_session', model: 'gpt-4.1', reasoningEffort: 'high' }, makeContext()); + + expect(createCopilotSessionMock).toHaveBeenCalledTimes(2); + const retryOptions = createCopilotSessionMock.mock.calls[1][2] as Record; + expect(retryOptions.model).toBeUndefined(); + expect(retryOptions.reasoningEffort).toBeUndefined(); + + // Client is informed and session_created carries no stale model + expect(sentMessages()).toContainEqual( + expect.objectContaining({ type: 'info', message: expect.stringContaining('no longer available') }), + ); + expect(sentMessages()).toContainEqual( + expect.objectContaining({ type: 'session_created', sessionId: 'sdk-1', model: undefined }), + ); + }); + + it('still fails for non-model errors without retrying', async () => { + createCopilotSessionMock.mockRejectedValue(new Error('network down')); + + await handleNewSession({ type: 'new_session', model: 'gpt-5' }, makeContext()); + + expect(createCopilotSessionMock).toHaveBeenCalledTimes(1); + expect(sentMessages()).toContainEqual( + expect.objectContaining({ type: 'error', message: expect.stringContaining('network down') }), + ); + }); +}); diff --git a/src/lib/server/ws/message-handlers/new-session.ts b/src/lib/server/ws/message-handlers/new-session.ts index f91c60b..e33ce86 100644 --- a/src/lib/server/ws/message-handlers/new-session.ts +++ b/src/lib/server/ws/message-handlers/new-session.ts @@ -118,7 +118,7 @@ export async function handleNewSession(msg: any, ctx: MessageContext): Promise poolSend(connectionEntry, message), - }); + onHookEvent: (message: Record) => poolSend(connectionEntry, message), + }; + + let resolvedModel: string | undefined = msg.model; + try { + connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, sessionOptions); + } catch (createErr: any) { + // Stale/unavailable model (e.g. persisted default no longer offered) — + // retry once letting the SDK pick its own default model. + if (msg.model && /not available/i.test(createErr?.message ?? '')) { + console.warn(`[SESSION] Model "${msg.model}" unavailable — retrying with SDK default`); + resolvedModel = undefined; + connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, { + ...sessionOptions, + model: undefined, + reasoningEffort: undefined, + }); + poolSend(connectionEntry, { + type: 'info', + message: `Model "${msg.model}" is no longer available — switched to the default model.`, + }); + } else { + throw createErr; + } + } wireSessionEvents(connectionEntry.session, connectionEntry, connectionEntry.session?.sessionId, ctx.userLogin, rawTabId(ctx)); @@ -155,13 +178,13 @@ export async function handleNewSession(msg: any, ctx: MessageContext): Promise ({ + configMock: { enableRemoteSessions: true }, + poolSendMock: vi.fn(), +})); + +vi.mock('../../config.js', () => ({ config: configMock })); +vi.mock('../session-pool.js', () => ({ poolSend: (...args: unknown[]) => poolSendMock(...args) })); +vi.mock('../../logger.js', () => ({ debug: vi.fn() })); + +import { handleRemoteToggle } from './remote.js'; +import type { MessageContext } from '../types.js'; + +function makeContext(session: unknown): MessageContext { + return { + connectionEntry: { session } as unknown as MessageContext['connectionEntry'], + githubToken: 'gh-token', + userLogin: 'octocat', + poolKey: 'octocat:tab1', + ws: {} as MessageContext['ws'], + }; +} + +function sentMessages(): Array> { + return poolSendMock.mock.calls.map((c) => c[1] as Record); +} + +describe('handleRemoteToggle', () => { + beforeEach(() => { + vi.clearAllMocks(); + configMock.enableRemoteSessions = true; + }); + + it('rejects when remote sessions are disabled server-side', async () => { + configMock.enableRemoteSessions = false; + + await handleRemoteToggle({ type: 'remote_toggle', mode: 'on' }, makeContext({})); + + expect(sentMessages()[0]).toMatchObject({ type: 'error', message: expect.stringContaining('disabled') }); + }); + + it('errors when there is no active session', async () => { + await handleRemoteToggle({ type: 'remote_toggle', mode: 'on' }, makeContext(null)); + + expect(sentMessages()[0]).toMatchObject({ type: 'error', message: 'No active session' }); + }); + + it('disables remote and replies remote_toggled enabled:false', async () => { + const disable = vi.fn(async () => {}); + const session = { rpc: { remote: { disable, enable: vi.fn() } } }; + + await handleRemoteToggle({ type: 'remote_toggle', mode: 'off' }, makeContext(session)); + + expect(disable).toHaveBeenCalledTimes(1); + expect(sentMessages()).toEqual([{ type: 'remote_toggled', enabled: false }]); + }); + + it('enables remote and forwards the github.com URL', async () => { + const enable = vi.fn(async () => ({ url: 'https://github.com/copilot/c/abc', remoteSteerable: true })); + const session = { rpc: { remote: { enable, disable: vi.fn() } } }; + + await handleRemoteToggle({ type: 'remote_toggle', mode: 'on' }, makeContext(session)); + + expect(enable).toHaveBeenCalledWith({ mode: 'on' }); + expect(sentMessages()).toEqual([ + { type: 'remote_toggled', enabled: true }, + { type: 'remote_session_url', url: 'https://github.com/copilot/c/abc' }, + ]); + }); + + it('defaults invalid modes to "export"', async () => { + const enable = vi.fn(async () => ({})); + const session = { rpc: { remote: { enable, disable: vi.fn() } } }; + + await handleRemoteToggle({ type: 'remote_toggle', mode: 'bogus' }, makeContext(session)); + + expect(enable).toHaveBeenCalledWith({ mode: 'export' }); + expect(sentMessages()).toEqual([{ type: 'remote_toggled', enabled: true }]); + }); + + it('reports an error when the RPC fails', async () => { + const enable = vi.fn(async () => { throw new Error('not supported'); }); + const session = { rpc: { remote: { enable, disable: vi.fn() } } }; + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await handleRemoteToggle({ type: 'remote_toggle', mode: 'export' }, makeContext(session)); + + expect(sentMessages()[0]).toMatchObject({ + type: 'error', + message: expect.stringContaining('not supported'), + }); + }); +}); diff --git a/src/lib/server/ws/message-handlers/remote.ts b/src/lib/server/ws/message-handlers/remote.ts new file mode 100644 index 0000000..3372433 --- /dev/null +++ b/src/lib/server/ws/message-handlers/remote.ts @@ -0,0 +1,49 @@ +import { config } from '../../config.js'; +import { poolSend } from '../session-pool.js'; +import { debug } from '../../logger.js'; +import type { MessageContext } from '../types.js'; + +const VALID_REMOTE_MODES = new Set(['off', 'export', 'on']); + +/** + * Toggles remote session export/steering on the active session at runtime + * via the SDK's experimental `session.rpc.remote` surface. + * + * msg.mode: "off" disables; "export" publishes events to GitHub; + * "on" enables export + remote steering (github.com / Mobile). + */ +export async function handleRemoteToggle(msg: any, ctx: MessageContext): Promise { + const { connectionEntry } = ctx; + + if (!config.enableRemoteSessions) { + poolSend(connectionEntry, { type: 'error', message: 'Remote sessions are disabled on this server' }); + return; + } + + const session = connectionEntry.session; + if (!session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session' }); + return; + } + + // Fail-safe default for malformed input: export-only (non-steerable). + const mode = typeof msg.mode === 'string' && VALID_REMOTE_MODES.has(msg.mode) ? msg.mode : 'export'; + + try { + if (mode === 'off') { + await session.rpc.remote.disable(); + poolSend(connectionEntry, { type: 'remote_toggled', enabled: false }); + return; + } + + const result = await session.rpc.remote.enable({ mode }); + poolSend(connectionEntry, { type: 'remote_toggled', enabled: true }); + if (result?.url) { + poolSend(connectionEntry, { type: 'remote_session_url', url: result.url }); + } + debug('[REMOTE] Enabled mode:', mode, 'steerable:', result?.remoteSteerable, 'url:', result?.url); + } catch (err: any) { + console.error('[REMOTE] Toggle error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to toggle remote session: ${err.message}` }); + } +} diff --git a/src/lib/server/ws/message-handlers/resume-session.ts b/src/lib/server/ws/message-handlers/resume-session.ts index c725886..8efb48b 100644 --- a/src/lib/server/ws/message-handlers/resume-session.ts +++ b/src/lib/server/ws/message-handlers/resume-session.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; import { approveAll } from '@github/copilot-sdk'; -import { createCopilotSession, buildSessionHooks, buildSessionMcpServers } from '../../copilot/session.js'; +import { createCopilotSession, buildSessionHooks, buildSessionMcpServers, buildEmptyModeSessionDefaults } from '../../copilot/session.js'; import { getSessionDetail, buildSessionContext, isValidSessionId } from '../../copilot/session-metadata.js'; import { loadSessionTurns } from '../../copilot/session-store-db.js'; import { chatStateStore } from '../../chat-state-singleton.js'; @@ -57,11 +57,17 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis // Try native SDK resume first try { connectionEntry.session = await connectionEntry.client.resumeSession(sessionId, { + ...buildEmptyModeSessionDefaults(), onPermissionRequest: (await import('@github/copilot-sdk')).approveAll, streaming: true, + // Re-prompt any permission requests that were pending when the + // session was last suspended instead of dropping them. + continuePendingWork: true, + // Don't emit a visible resume event when silently re-attaching after reconnect + ...(msg.silent === true ? { suppressResumeEvent: true } : {}), onUserInputRequest: makeUserInputHandler(connectionEntry, ctx.userLogin), hooks: buildSessionHooks((message) => poolSend(connectionEntry, message)), - configDir: resolvedConfigDir, + configDirectory: resolvedConfigDir, mcpServers: mcpServersConfig as any, onEvent, ...(msg.modelCapabilities ? { modelCapabilities: msg.modelCapabilities } : {}), @@ -142,7 +148,7 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis const turns = loadSessionTurns(sessionId); if (turns.length > 0) { debug(`[RESUME] Loaded ${turns.length} messages from session-store.db for ${sessionId}`); - const resolvedModel = msg.model || 'gpt-4.1'; + const resolvedModel = msg.model || ''; poolSend(connectionEntry, { type: 'cold_resume', messages: turns, diff --git a/src/lib/server/ws/message-handlers/session-management.ts b/src/lib/server/ws/message-handlers/session-management.ts index 831cdac..bbde5a2 100644 --- a/src/lib/server/ws/message-handlers/session-management.ts +++ b/src/lib/server/ws/message-handlers/session-management.ts @@ -140,7 +140,7 @@ export async function handleGetSessionHistory(msg: any, ctx: MessageContext): Pr } try { - const events = await session.getMessages(); + const events = await session.getEvents(); const eventList = Array.isArray(events) ? events : []; debug('[SESSION_HISTORY] Got', eventList.length, 'events'); diff --git a/src/lib/server/ws/session-events.ts b/src/lib/server/ws/session-events.ts index c04c855..e5169e0 100644 --- a/src/lib/server/ws/session-events.ts +++ b/src/lib/server/ws/session-events.ts @@ -108,8 +108,13 @@ export function wireSessionEvents( poolSend(entry, { type: 'tool_start', toolCallId: event.data.toolCallId, toolName: event.data.toolName, mcpServerName: event.data.mcpServerName, mcpToolName: event.data.mcpToolName }); }); session.on('tool.execution_complete', (event: any) => { - debug('[TOOL] execution_complete:', event.data.toolCallId); - poolSend(entry, { type: 'tool_end', toolCallId: event.data.toolCallId }); + debug('[TOOL] execution_complete:', event.data.toolCallId, 'success:', event.data.success); + poolSend(entry, { + type: 'tool_end', + toolCallId: event.data.toolCallId, + success: event.data.success !== false, + ...(event.data.error?.message ? { error: event.data.error.message } : {}), + }); }); session.on('tool.execution_progress', (event: any) => { debug('[TOOL] execution_progress:', event.data.toolCallId, event.data.message); @@ -177,7 +182,14 @@ export function wireSessionEvents( poolSend(entry, { type: 'subagent_end', agentName: event.data.agentName }); }); session.on('session.info', (event: any) => { - poolSend(entry, { type: 'info', message: event.data?.message || event.data }); + const infoType = event.data?.infoType; + const url = event.data?.url; + // Remote session export publishes a github.com URL for monitoring/steering + if (infoType === 'remote' && typeof url === 'string') { + poolSend(entry, { type: 'remote_session_url', url, message: event.data?.message }); + return; + } + poolSend(entry, { type: 'info', message: event.data?.message || event.data, ...(infoType ? { infoType } : {}), ...(url ? { url } : {}) }); }); session.on('session.plan_changed', (event: any) => { poolSend(entry, { type: 'plan_changed', content: event.data?.content, path: event.data?.path }); diff --git a/src/lib/stores/chat.svelte.ts b/src/lib/stores/chat.svelte.ts index 258bf37..27698f1 100644 --- a/src/lib/stores/chat.svelte.ts +++ b/src/lib/stores/chat.svelte.ts @@ -4,6 +4,7 @@ import type { ChatMessageRole, CopilotUsageItem, ToolCallState, + ToolCallStatus, ServerMessage, SessionMode, ReasoningEffort, @@ -44,6 +45,10 @@ export interface ChatStore { readonly fleetActive: boolean; readonly fleetAgents: Array<{ agentId: string; agentType: string; status: 'running' | 'completed' | 'failed'; error?: string }>; readonly sessionTitle: string | null; + /** github.com URL when the session is exported/steerable remotely */ + readonly remoteUrl: string | null; + /** True when the active session runs on GitHub's cloud agent */ + readonly isCloudSession: boolean; readonly pendingUserInput: UserInputState | null; readonly pendingElicitation: ElicitationState | null; readonly pendingPermissions: PermissionRequestState[]; @@ -110,6 +115,9 @@ export function createChatStore(wsStore: WsStore): ChatStore { let fleetAgents = $state>([]); let sessionTitle = $state(null); let currentSessionId = $state(null); + // Remote/cloud session state + let remoteUrl = $state(null); + let isCloudSession = $state(false); let pendingUserInput = $state(null); let pendingElicitation = $state(null); let pendingPermissions = $state([]); @@ -249,10 +257,37 @@ export function createChatStore(wsStore: WsStore): ChatStore { currentModel = msg.model; if (msg.sessionId) currentSessionId = msg.sessionId; plan = { exists: false, content: '' }; + isCloudSession = false; + remoteUrl = null; wsStore.getQuota(); wsStore.listSessions(); break; + case 'cloud_session_created': { + if (msg.model) currentModel = msg.model; + if (msg.sessionId) currentSessionId = msg.sessionId; + plan = { exists: false, content: '' }; + isCloudSession = true; + remoteUrl = null; + const repo = msg.repository ? ` for ${msg.repository.owner}/${msg.repository.name}${msg.repository.branch ? `@${msg.repository.branch}` : ''}` : ''; + addInfoMessage(`Cloud session created${repo} — running on GitHub's cloud agent`); + wsStore.getQuota(); + wsStore.listSessions(); + break; + } + + case 'remote_session_url': + remoteUrl = msg.url; + addInfoMessage(msg.message || `Session available on GitHub: ${msg.url}`); + break; + + case 'remote_toggled': + if (!msg.enabled) { + remoteUrl = null; + addInfoMessage('Remote session disabled'); + } + break; + case 'session_reconnected': if (msg.hasSession) { addInfoMessage('Session reconnected'); @@ -319,7 +354,13 @@ export function createChatStore(wsStore: WsStore): ChatStore { case 'tool_end': messages = messages.map(m => - m.toolCallId === msg.toolCallId ? { ...m, toolStatus: 'complete' as const } : m, + m.toolCallId === msg.toolCallId + ? { + ...m, + toolStatus: (msg.success === false ? 'failed' : 'complete') as ToolCallStatus, + ...(msg.success === false && msg.error ? { toolError: msg.error } : {}), + } + : m, ); break; @@ -760,7 +801,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { } addInfoMessage( 'Session ended' + - (msg.totalPremiumRequests != null ? ` · ${msg.totalPremiumRequests} premium requests` : '') + + (msg.totalPremiumRequests != null ? ` · ${msg.totalPremiumRequests} AIC` : '') + (msg.totalApiDurationMs != null ? ` · ${(msg.totalApiDurationMs / 1000).toFixed(1)}s total API time` : ''), ); break; @@ -918,6 +959,8 @@ export function createChatStore(wsStore: WsStore): ChatStore { get fleetActive() { return fleetActive; }, get fleetAgents() { return fleetAgents; }, get sessionTitle() { return sessionTitle; }, + get remoteUrl() { return remoteUrl; }, + get isCloudSession() { return isCloudSession; }, get pendingUserInput() { return pendingUserInput; }, get pendingElicitation() { return pendingElicitation; }, get pendingPermissions() { return pendingPermissions; }, diff --git a/src/lib/stores/chat.test.ts b/src/lib/stores/chat.test.ts index 932265d..6d2ee1e 100644 --- a/src/lib/stores/chat.test.ts +++ b/src/lib/stores/chat.test.ts @@ -36,6 +36,8 @@ function createWsStoreMock(options: { send: vi.fn(), sendMessage: vi.fn(), newSession: vi.fn(), + newCloudSession: vi.fn(), + remoteToggle: vi.fn(), resumeSession: vi.fn(), setMode: vi.fn(), setModel: vi.fn(), @@ -577,7 +579,7 @@ describe('createChatStore', () => { expect(store.messages).toEqual([ expect.objectContaining({ role: 'assistant', content: 'Response' }), expect.objectContaining({ role: 'usage' }), - expect.objectContaining({ role: 'info', content: 'Session ended · 5 premium requests · 3.2s total API time' }), + expect.objectContaining({ role: 'info', content: 'Session ended · 5 AIC · 3.2s total API time' }), ]); }); diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index 170b6b4..cf47ba3 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -28,8 +28,11 @@ const DEFAULT_SETTINGS: PersistedSettings = { voiceInputEnabled: true, ttsEnabled: true, ttsRate: 1.0, + remoteSession: 'off', }; +const VALID_REMOTE_SESSION = new Set>(['off', 'export', 'on']); + const VALID_MODES = new Set(['interactive', 'plan', 'autopilot']); const VALID_REASONING = new Set(['low', 'medium', 'high', 'xhigh']); @@ -49,6 +52,7 @@ export interface SettingsStore { voiceInputEnabled: boolean; ttsEnabled: boolean; ttsRate: number; + remoteSession: NonNullable; load(): void; save(): void; syncFromServer(): Promise; @@ -72,6 +76,7 @@ export function createSettingsStore(): SettingsStore { let voiceInputEnabled = $state(DEFAULT_SETTINGS.voiceInputEnabled ?? true); let ttsEnabled = $state(DEFAULT_SETTINGS.ttsEnabled ?? true); let ttsRate = $state(DEFAULT_SETTINGS.ttsRate ?? 1.0); + let remoteSession = $state>(DEFAULT_SETTINGS.remoteSession ?? 'off'); // Detect a usable browser localStorage. Node 25+ exposes a built-in // `localStorage` global as a stub when `--localstorage-file` is not set; @@ -104,6 +109,7 @@ export function createSettingsStore(): SettingsStore { voiceInputEnabled, ttsEnabled, ttsRate, + remoteSession, }; } @@ -147,6 +153,9 @@ export function createSettingsStore(): SettingsStore { if (typeof parsed.ttsRate === 'number') { ttsRate = Math.max(0.5, Math.min(2, parsed.ttsRate)); } + if (parsed.remoteSession && VALID_REMOTE_SESSION.has(parsed.remoteSession)) { + remoteSession = parsed.remoteSession; + } } function save(): void { @@ -288,6 +297,12 @@ export function createSettingsStore(): SettingsStore { get ttsRate() { return ttsRate; }, set ttsRate(v: number) { ttsRate = Math.max(0.5, Math.min(2, v)); save(); }, + get remoteSession() { return remoteSession; }, + set remoteSession(v: NonNullable) { + remoteSession = VALID_REMOTE_SESSION.has(v) ? v : 'off'; + save(); + }, + load, save, syncFromServer, diff --git a/src/lib/stores/settings.test.ts b/src/lib/stores/settings.test.ts index 4c8a807..e0f3143 100644 --- a/src/lib/stores/settings.test.ts +++ b/src/lib/stores/settings.test.ts @@ -155,6 +155,7 @@ describe('createSettingsStore', () => { voiceInputEnabled: true, ttsEnabled: true, ttsRate: 1, + remoteSession: 'off', }, }), }); diff --git a/src/lib/stores/ws.svelte.ts b/src/lib/stores/ws.svelte.ts index e9c3be0..3892492 100644 --- a/src/lib/stores/ws.svelte.ts +++ b/src/lib/stores/ws.svelte.ts @@ -6,6 +6,7 @@ import type { ClientMessage, ServerMessage, NewSessionConfig, + CloudSessionConfig, MessageDeliveryMode, } from '$lib/types/index.js'; import { notify } from '$lib/utils/notifications.js'; @@ -57,7 +58,9 @@ export interface WsStore { mode?: MessageDeliveryMode, ): void; newSession(config: NewSessionConfig): void; - resumeSession(sessionId: string): void; + newCloudSession(config: CloudSessionConfig): void; + remoteToggle(mode?: 'off' | 'export' | 'on'): void; + resumeSession(sessionId: string, options?: { silent?: boolean }): void; setMode(mode: SessionMode): void; setModel(model: string): void; setReasoning(effort: ReasoningEffort): void; @@ -403,13 +406,29 @@ export function createWsStore(): WsStore { ...(config.customInstructions?.trim() && { customInstructions: config.customInstructions.trim() }), ...(config.excludedTools?.length && { excludedTools: config.excludedTools }), ...(config.infiniteSessions && { infiniteSessions: config.infiniteSessions }), + ...(config.remoteSession && config.remoteSession !== 'off' && { remoteSession: config.remoteSession }), }; send(msg); } - function resumeSession(sessionId: string): void { + function newCloudSession(config: CloudSessionConfig): void { sessionReady = false; - send({ type: 'resume_session', sessionId }); + send({ + type: 'new_cloud_session', + ...(config.model && { model: config.model }), + ...(config.mode && { mode: config.mode }), + ...(config.reasoningEffort && { reasoningEffort: config.reasoningEffort }), + ...(config.repository && { repository: config.repository }), + }); + } + + function remoteToggle(mode?: 'off' | 'export' | 'on'): void { + send({ type: 'remote_toggle', ...(mode ? { mode } : {}) }); + } + + function resumeSession(sessionId: string, options?: { silent?: boolean }): void { + sessionReady = false; + send({ type: 'resume_session', sessionId, ...(options?.silent ? { silent: true } : {}) }); } function setMode(mode: SessionMode): void { @@ -508,6 +527,8 @@ export function createWsStore(): WsStore { send, sendMessage, newSession, + newCloudSession, + remoteToggle, resumeSession, setMode, setModel, diff --git a/src/lib/types/chat.ts b/src/lib/types/chat.ts index a50e604..a56d3aa 100644 --- a/src/lib/types/chat.ts +++ b/src/lib/types/chat.ts @@ -31,6 +31,7 @@ export interface ChatMessage { toolCallId?: string; toolName?: string; toolStatus?: ToolCallStatus; + toolError?: string; toolProgressMessage?: string; toolProgressMessages?: string[]; mcpServerName?: string; @@ -59,5 +60,7 @@ export interface ToolCallState { mcpToolName?: string; status: ToolCallStatus; message?: string; + /** Error message when status is "failed" */ + error?: string; progressMessages?: string[]; } diff --git a/src/lib/types/client-messages.ts b/src/lib/types/client-messages.ts index 0002c34..5540539 100644 --- a/src/lib/types/client-messages.ts +++ b/src/lib/types/client-messages.ts @@ -6,7 +6,8 @@ export type MessageDeliveryMode = 'immediate' | 'enqueue'; export interface NewSessionMessage { type: 'new_session'; - model: string; + /** Omitted → the SDK picks its default model */ + model?: string; mode?: SessionMode; reasoningEffort?: ReasoningEffort; customInstructions?: string; @@ -15,6 +16,23 @@ export interface NewSessionMessage { systemPromptSections?: Record; modelCapabilities?: ModelCapabilitiesOverride; enableConfigDiscovery?: boolean; + /** "off" local only, "export" publish events to GitHub, "on" export + remote steering */ + remoteSession?: 'off' | 'export' | 'on'; +} + +/** Creates a session running on GitHub's cloud agent infrastructure */ +export interface NewCloudSessionMessage { + type: 'new_cloud_session'; + model?: string; + mode?: SessionMode; + reasoningEffort?: ReasoningEffort; + repository?: { owner: string; name: string; branch?: string }; +} + +/** Toggles remote export/steering on the active session */ +export interface RemoteToggleMessage { + type: 'remote_toggle'; + mode?: 'off' | 'export' | 'on'; } export interface SendMessage { @@ -102,6 +120,8 @@ export interface ListSessionsMessage { export interface ResumeSessionMessage { type: 'resume_session'; sessionId: string; + /** Suppress the SDK resume event for silent re-attach (auto cold-resume) */ + silent?: boolean; } export interface DeleteSessionMessage { @@ -227,6 +247,8 @@ export interface WorkspaceCreateFileMessage { export type ClientMessage = | NewSessionMessage + | NewCloudSessionMessage + | RemoteToggleMessage | SendMessage | ListModelsMessage | SetModeMessage diff --git a/src/lib/types/config.ts b/src/lib/types/config.ts index cbe3b05..d5cc4a3 100644 --- a/src/lib/types/config.ts +++ b/src/lib/types/config.ts @@ -15,8 +15,12 @@ export interface SystemPromptSectionInput { content?: string; } +/** "off" local only, "export" publish events to GitHub, "on" export + remote steering */ +export type RemoteSessionMode = 'off' | 'export' | 'on'; + export interface NewSessionConfig { - model: string; + /** Omitted → the SDK picks its default model */ + model?: string; mode?: SessionMode; reasoningEffort?: ReasoningEffort; customInstructions?: string; @@ -25,6 +29,15 @@ export interface NewSessionConfig { systemPromptSections?: Record; modelCapabilities?: ModelCapabilitiesOverride; enableConfigDiscovery?: boolean; + /** "off" local only, "export" publish events to GitHub, "on" export + remote steering */ + remoteSession?: RemoteSessionMode; +} + +export interface CloudSessionConfig { + model?: string; + mode?: SessionMode; + reasoningEffort?: ReasoningEffort; + repository?: { owner: string; name: string; branch?: string }; } export interface PersistedSettings { @@ -49,7 +62,7 @@ export interface PersistedSettings { * - "on": full remote monitor + steer via github.com/Mobile. * The active client only honors this when ENABLE_REMOTE_SESSIONS is enabled server-side. */ - remoteSession?: 'off' | 'export' | 'on'; + remoteSession?: RemoteSessionMode; } export interface CustomAgentDefinition { diff --git a/src/lib/types/quota.ts b/src/lib/types/quota.ts index fa6446e..ae41d03 100644 --- a/src/lib/types/quota.ts +++ b/src/lib/types/quota.ts @@ -10,10 +10,10 @@ export interface QuotaSnapshot { export type QuotaSnapshots = Record; -/** Priority order for picking the most relevant quota snapshot */ -const QUOTA_PRIORITY = ['copilot_premium', 'premium_requests', 'premium_interactions'] as const; +/** Priority order for picking the most relevant quota snapshot (UBB AI Credits first, legacy premium keys for older accounts) */ +const QUOTA_PRIORITY = ['ai_credits', 'aic', 'copilot_premium', 'premium_requests', 'premium_interactions'] as const; -/** Pick the most relevant quota snapshot: premium types first, then any other key */ +/** Pick the most relevant quota snapshot: AI Credit / premium types first, then any other key */ export function pickPrimaryQuota(snapshots: QuotaSnapshots | null): { key: string; label: string; snapshot: QuotaSnapshot } | null { if (!snapshots) return null; const keys = Object.keys(snapshots); @@ -26,6 +26,18 @@ export function pickPrimaryQuota(snapshots: QuotaSnapshots | null): { key: strin return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; } +/** Friendly display names under usage-based billing (UBB) */ +const QUOTA_LABELS: Record = { + ai_credits: 'AI Credits (AIC)', + aic: 'AI Credits (AIC)', + // Legacy premium-request keys are surfaced as AI Credits in the UI + copilot_premium: 'AI Credits (AIC)', + premium_requests: 'AI Credits (AIC)', + premium_interactions: 'AI Credits (AIC)', + chat: 'Chat', + completions: 'Completions', +}; + function formatQuotaLabel(key: string): string { - return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + return QUOTA_LABELS[key] ?? key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } diff --git a/src/lib/types/server-messages.ts b/src/lib/types/server-messages.ts index d18d52b..19a038c 100644 --- a/src/lib/types/server-messages.ts +++ b/src/lib/types/server-messages.ts @@ -89,6 +89,10 @@ export interface ToolProgressMessage { export interface ToolEndMessage { type: 'tool_end'; toolCallId: string; + /** False when the tool execution failed */ + success?: boolean; + /** Human-readable error message when success is false */ + error?: string; } export interface ModelsMessage { @@ -277,6 +281,29 @@ export interface SubagentDeselectedMessage { export interface InfoMessage { type: 'info'; message: string; + infoType?: string; + url?: string; +} + +/** Published when a remote-enabled session receives its github.com monitoring URL */ +export interface RemoteSessionUrlMessage { + type: 'remote_session_url'; + url: string; + message?: string; +} + +/** Result of toggling remote steering on the active session */ +export interface RemoteToggledMessage { + type: 'remote_toggled'; + enabled: boolean; +} + +/** Confirmation that a cloud session was created on GitHub's infrastructure */ +export interface CloudSessionCreatedMessage { + type: 'cloud_session_created'; + sessionId?: string; + model?: string; + repository?: { owner: string; name: string; branch?: string }; } export interface ElicitationRequestedMessage { @@ -393,6 +420,13 @@ export interface HookPostToolMessage { toolArgs?: unknown; } +export interface HookToolFailureMessage { + type: 'hook_tool_failure'; + toolName: string; + toolArgs?: unknown; + error: string; +} + export interface HookSessionStartMessage { type: 'hook_session_start'; source: string; @@ -418,6 +452,7 @@ export interface HookErrorMessage { export type HookMessage = | HookPreToolMessage | HookPostToolMessage + | HookToolFailureMessage | HookUserPromptMessage | HookSessionStartMessage | HookSessionEndMessage @@ -615,6 +650,9 @@ export type ServerMessage = | SubagentSelectedMessage | SubagentDeselectedMessage | InfoMessage + | RemoteSessionUrlMessage + | RemoteToggledMessage + | CloudSessionCreatedMessage | ElicitationRequestedMessage | ElicitationCompletedMessage | ExitPlanModeRequestedMessage @@ -633,6 +671,7 @@ export type ServerMessage = | SystemNotificationMessage | HookPreToolMessage | HookPostToolMessage + | HookToolFailureMessage | HookUserPromptMessage | HookSessionStartMessage | HookSessionEndMessage diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5687868..db24c77 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -11,6 +11,7 @@ import SettingsModal from '$lib/components/settings/SettingsModal.svelte'; import SessionsSheet from '$lib/components/sessions/SessionsSheet.svelte'; import TopBar from '$lib/components/layout/TopBar.svelte'; + import RemoteBanner from '$lib/components/layout/RemoteBanner.svelte'; import ModelSheet from '$lib/components/model/ModelSheet.svelte'; import { createWsStore } from '$lib/stores/ws.svelte.js'; import { createChatStore } from '$lib/stores/chat.svelte.js'; @@ -35,10 +36,12 @@ let modelSheetOpen = $state(false); let sessionsLoading = $state(false); let sessionLoading = $state(true); + let dismissedRemoteUrl = $state(null); + const showRemoteBanner = $derived(!!chatStore.remoteUrl && chatStore.remoteUrl !== dismissedRemoteUrl); - // Use the confirmed model from the active session; fall back to the user's saved preference - // so the TopBar/ModelSheet show the correct model immediately before session_created arrives. - const effectiveModel = $derived(chatStore.currentModel || settings.selectedModel || 'gpt-4.1'); + // Use the confirmed model from the active session; fall back to the user's saved preference. + // No hardcoded model — when empty the TopBar shows "Select model" and the SDK default is used. + const effectiveModel = $derived(chatStore.currentModel || settings.selectedModel || ''); const modelCount = $derived(chatStore.models.size); const toolCount = $derived(chatStore.tools.length); @@ -47,7 +50,8 @@ ); const supportsVision = $derived.by(() => { - const model = settings.selectedModel || 'gpt-4.1'; + const model = effectiveModel; + if (!model) return false; const info = chatStore.models.get(model); return info?.capabilities?.supports?.vision === true; }); @@ -90,7 +94,7 @@ if (msg.sdkSessionId) { // Session will be restored — keep sessionLoading true until cold_resume/session_resumed console.log('[PAGE] connected with sdkSessionId, resuming', msg.sdkSessionId); - wsStore.resumeSession(msg.sdkSessionId); + wsStore.resumeSession(msg.sdkSessionId, { silent: true }); } else { // No previous session — show new chat immediately sessionLoading = false; @@ -135,6 +139,14 @@ settings.selectedMode = msg.mode; } + // Drop a stale saved model preference when it's no longer offered + // (e.g. deprecated) — the SDK default takes over on the next session. + if (msg.type === 'models' && settings.selectedModel && chatStore.models.size > 0 + && !chatStore.models.has(settings.selectedModel)) { + console.warn(`[PAGE] Saved model "${settings.selectedModel}" no longer available — resetting to default`); + settings.selectedModel = ''; + } + // Route customization list messages to settings store if (msg.type === 'skills_list') { settings.availableSkills = msg.skills; @@ -189,16 +201,20 @@ // ── Helpers ──────────────────────────────────────────────────────────── function requestNewSession(): void { - const model = settings.selectedModel || 'gpt-4.1'; - const modelInfo = chatStore.models.get(model); + // Only send a model if the user actually picked one — otherwise let the + // SDK choose its default (a hardcoded fallback breaks when that model + // disappears from the user's available model list). + const model = settings.selectedModel || undefined; + const modelInfo = model ? chatStore.models.get(model) : undefined; const isReasoning = modelInfo?.capabilities?.supports?.reasoningEffort; wsStore.newSession({ - model, + ...(model && { model }), mode: settings.selectedMode, ...(isReasoning && { reasoningEffort: settings.reasoningEffort }), ...(settings.additionalInstructions.trim() && { customInstructions: settings.additionalInstructions.trim() }), ...(settings.excludedTools.length > 0 && { excludedTools: settings.excludedTools }), + ...(settings.remoteSession !== 'off' && { remoteSession: settings.remoteSession }), infiniteSessions: settings.infiniteSessions, }); } @@ -370,6 +386,14 @@ onOpenModelSheet={() => modelSheetOpen = true} /> + {#if showRemoteBanner && chatStore.remoteUrl} + { dismissedRemoteUrl = chatStore.remoteUrl; }} + /> + {/if} +
{#if sessionLoading}
@@ -533,6 +557,10 @@ onToggleMcpServer={(name, enabled) => wsStore.send({ type: 'toggle_mcp_rpc', name, enabled })} notificationsEnabled={settings.notificationsEnabled} onToggleNotifications={(v) => { settings.notificationsEnabled = v; }} + remoteSessionMode={settings.remoteSession} + onSetRemoteSessionMode={(mode) => { settings.remoteSession = mode; }} + remoteSessionActive={wsStore.sessionReady} + onApplyRemoteToSession={(mode) => wsStore.remoteToggle(mode)} voiceInputEnabled={settings.voiceInputEnabled} onToggleVoiceInput={(v) => { settings.voiceInputEnabled = v; }} ttsEnabled={settings.ttsEnabled} @@ -551,6 +579,14 @@ onResume={handleResumeSession} onDelete={(id) => wsStore.deleteSession(id)} onRequestDetail={(id) => wsStore.getSessionDetail(id)} + onNewCloudSession={(repository) => { + chatStore.clearMessages(); + wsStore.newCloudSession({ + ...(settings.selectedModel && { model: settings.selectedModel }), + mode: settings.selectedMode, + repository, + }); + }} />
{:else} diff --git a/tests/auth-flow.spec.ts b/tests/auth-flow.spec.ts index 98d4cea..8aeeaa1 100644 --- a/tests/auth-flow.spec.ts +++ b/tests/auth-flow.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import type { Page } from '@playwright/test'; -import { MOCK_USER } from './helpers'; +import { MOCK_USER, openSidebar } from './helpers'; const DEFAULT_DEVICE_FLOW = { user_code: 'ABCD-1234', @@ -16,35 +16,24 @@ const AUTHORIZED_USER = { }; function buildLayoutData(authenticated: boolean) { - if (authenticated) { - return { - type: 'data', - nodes: [ - { - type: 'data', - data: [ - { authenticated: 1, user: 2 }, - true, - { login: 3, name: 4 }, - MOCK_USER.login, - MOCK_USER.name, - ], - uses: {}, - }, - { type: 'skip' }, - ], - }; - } - return { type: 'data', nodes: [ { type: 'data', - data: [{ authenticated: 1, user: 2 }, false, null], + data: authenticated + ? [ + { authenticated: 1, user: 2, byokEnabled: 5 }, + true, + { login: 3, name: 4 }, + MOCK_USER.login, + MOCK_USER.name, + false, + ] + : [{ authenticated: 1, user: 2, byokEnabled: 1 }, false, null], uses: {}, }, - { type: 'skip' }, + null, ], }; } @@ -107,14 +96,14 @@ async function mockAuthReloadState( page: Page, authState: { authenticated: boolean }, ): Promise { - await page.route('/', async (route) => { + await page.route((url) => url.pathname === '/', async (route) => { const response = await route.fetch(); let html = await response.text(); if (authState.authenticated) { html = html.replace( - /data:\{authenticated:false,user:null\}/g, - `data:{authenticated:true,user:{login:"${MOCK_USER.login}",name:"${MOCK_USER.name}"}}`, + /data:\{authenticated:false,user:null,byokEnabled:false\}/g, + `data:{authenticated:true,user:{login:"${MOCK_USER.login}",name:"${MOCK_USER.name}"},byokEnabled:false}`, ); } @@ -142,7 +131,12 @@ async function mockAuthReloadState( } test.describe('Device flow authentication', () => { - test('completes the full device flow journey and opens the chat screen', async ({ page }) => { + test('completes the full device flow journey and opens the chat screen', async ({ browser }) => { + const context = await browser.newContext({ + baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'http://localhost:3001', + serviceWorkers: 'block', + }); + const page = await context.newPage(); const authState = { authenticated: false }; let pollCount = 0; @@ -174,8 +168,13 @@ test.describe('Device flow authentication', () => { await expect(page.locator('.login-status')).toContainText('Waiting for authorization'); await expect(page.locator('.terminal')).toBeVisible({ timeout: 15000 }); - await expect(page.locator('.hamburger-btn')).toBeVisible({ timeout: 15000 }); + if (await page.getByRole('button', { name: 'Open menu' }).isVisible().catch(() => false)) { + await expect(page.getByRole('button', { name: 'Open menu' })).toBeVisible(); + } else { + await expect(page.getByLabel('Sidebar navigation')).toBeVisible({ timeout: 15000 }); + } await expect(page.locator('.login-screen')).toBeHidden(); + await context.close(); }); test('shows an error state when the device flow cannot start', async ({ page }) => { @@ -284,7 +283,12 @@ test.describe('Device flow authentication', () => { }); test.describe('Authenticated session actions', () => { - test('signing out returns the user to the login screen', async ({ page }) => { + test('signing out returns the user to the login screen', async ({ browser }) => { + const context = await browser.newContext({ + baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'http://localhost:3001', + serviceWorkers: 'block', + }); + const page = await context.newPage(); const authState = { authenticated: true }; await mockAuthReloadState(page, authState); @@ -298,13 +302,14 @@ test.describe('Authenticated session actions', () => { await page.goto('/'); await expect(page.locator('.terminal')).toBeVisible({ timeout: 10000 }); - await page.locator('button.hamburger-btn').click(); - const signOutButton = page.locator('button.sidebar-action.sidebar-action-danger'); + await openSidebar(page); + const signOutButton = page.getByRole('button', { name: 'Sign Out' }); await expect(signOutButton).toBeVisible(); await signOutButton.click(); await expect(page.locator('.login-screen')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.terminal')).toBeHidden(); + await context.close(); }); }); diff --git a/tests/chat-messaging.spec.ts b/tests/chat-messaging.spec.ts index 93970f7..710045b 100644 --- a/tests/chat-messaging.spec.ts +++ b/tests/chat-messaging.spec.ts @@ -35,7 +35,7 @@ test.describe('Chat messaging', () => { test('shows the banner before the first message', async ({ browser }) => { await withAuthenticatedChat(browser, {}, async (page) => { await expect(page.locator('.banner-box')).toBeVisible(); - await expect(page.locator('button.send-btn')).toBeVisible(); + await expect(page.locator('.toolbar-right button')).toBeVisible(); }); }); @@ -166,9 +166,9 @@ test.describe('Chat messaging', () => { seq .send({ type: 'turn_start' }, 20) .send({ type: 'delta', content: 'Usage details coming up.' }, 100) + .send({ type: 'usage', inputTokens: 12, outputTokens: 8 }, 50) .send({ type: 'turn_end' }, 120) - .send({ type: 'done' }, 50) - .send({ type: 'usage', inputTokens: 12, outputTokens: 8 }, 50); + .send({ type: 'done' }, 50); } }, }, diff --git a/tests/chat.spec.ts b/tests/chat.spec.ts index b7c8d0c..ca2414e 100644 --- a/tests/chat.spec.ts +++ b/tests/chat.spec.ts @@ -54,7 +54,7 @@ test.describe('Chat screen structure', () => { expect(response.headers()['content-type']).toContain('application/json'); const data = await response.json(); - expect(data).toEqual({ status: 'ok' }); + expect(data).toMatchObject({ status: 'ok', telemetry: { enabled: expect.any(Boolean) } }); }); test('auth status endpoint returns unauthenticated JSON shape', async ({ page }) => { diff --git a/tests/error-handling.spec.ts b/tests/error-handling.spec.ts index 271f000..4e19a56 100644 --- a/tests/error-handling.spec.ts +++ b/tests/error-handling.spec.ts @@ -4,6 +4,7 @@ import { mockWebSocket, goToChat, sendMessage, + openSidebar, MOCK_USER, } from './helpers'; @@ -18,7 +19,8 @@ async function setupAuthenticatedChat( await goToChat(session.page); } catch { await session.page.waitForSelector('.terminal', { state: 'visible', timeout: 10000 }); - await session.page.click('.newchat-btn'); + await openSidebar(session.page); + await session.page.getByRole('button', { name: 'New Chat' }).click(); await session.page.waitForSelector('textarea:not([disabled])', { state: 'visible', timeout: 10000, @@ -87,9 +89,7 @@ test.describe('Error handling', () => { const { page, context } = await setupAuthenticatedChat(browser); try { - await expect(page.locator('.conn-dot.dot-connected')).toBeVisible(); - await expect(page.locator('.conn-dot.dot-disconnected')).toHaveCount(0); - await expect(page.locator('.conn-dot.dot-connecting')).toHaveCount(0); + await expect(page.locator('textarea:not([disabled])')).toBeVisible(); } finally { await context.close(); } @@ -99,7 +99,7 @@ test.describe('Error handling', () => { const response = await request.get('/health'); expect(response.status()).toBe(200); - expect(await response.json()).toEqual({ status: 'ok' }); + expect(await response.json()).toMatchObject({ status: 'ok', telemetry: { enabled: expect.any(Boolean) } }); }); test('auth status shows unauthenticated', async ({ request }) => { @@ -203,7 +203,7 @@ test.describe('Error handling', () => { try { await goToChat(page); - await expect(page.locator('.conn-dot.dot-connected')).toBeVisible(); + await expect(page.locator('textarea:not([disabled])')).toBeVisible(); await sendMessage(page, 'sanity check'); await expect(page.locator('.message.assistant')).toContainText('All good here'); diff --git a/tests/helpers.ts b/tests/helpers.ts index 8cd5428..c6406fd 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -7,7 +7,7 @@ * - MOCK_MODELS, MOCK_USER — shared test data */ -import type { Browser, Page, BrowserContext } from '@playwright/test'; +import { expect, type Browser, type Page, type BrowserContext } from '@playwright/test'; // ── Shared test data ────────────────────────────────────────────────────────── @@ -28,8 +28,8 @@ export const MOCK_TOOLS = [ ]; export const MOCK_AGENTS = [ - { slug: 'copilot', name: 'Copilot', description: 'Default assistant', current: true }, - { slug: 'reviewer', name: 'Code Reviewer', description: 'Reviews code changes', current: false }, + { slug: 'copilot', name: 'Copilot', description: 'Default assistant', source: 'user', isSelected: true }, + { slug: 'reviewer', name: 'Code Reviewer', description: 'Reviews code changes', source: 'user', isSelected: false }, ]; export const MOCK_SESSIONS = [ @@ -208,6 +208,25 @@ export async function goToChat(page: Page) { await page.waitForSelector('textarea:not([disabled])', { state: 'visible', timeout: 30000 }); } +/** + * Opens the sidebar when needed and waits for its primary actions to be usable. + * The desktop layout keeps the sidebar visible; mobile uses the hamburger. + */ +export async function openSidebar(page: Page) { + const sidebar = page.getByLabel('Sidebar navigation'); + const hamburger = page.getByRole('button', { name: 'Open menu' }); + + if (await hamburger.isVisible().catch(() => false)) { + await hamburger.click(); + await expect(sidebar).toHaveClass(/open/); + } + + await expect(sidebar).toBeVisible(); + await expect(page.getByRole('button', { name: 'New Chat' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sessions' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible(); +} + /** * Sends a chat message by filling textarea and pressing Enter. */ diff --git a/tests/model-selection.spec.ts b/tests/model-selection.spec.ts index 26246c3..728dc45 100644 --- a/tests/model-selection.spec.ts +++ b/tests/model-selection.spec.ts @@ -56,7 +56,7 @@ test.describe('Model selection', () => { const { page, context } = await openAuthenticatedChat(browser); try { - await expect(page.locator('.conn-dot')).toHaveClass(/dot-connected/); + await expect(page.locator('textarea:not([disabled])')).toBeVisible(); await expect(page.locator('.model-name')).toHaveText('gpt-4.1'); } finally { await context.close(); @@ -178,13 +178,6 @@ test.describe('Model selection', () => { await highButton.click(); - await expectSentMessage( - sentMessages, - (msg) => - (msg.type === 'new_session' && msg.model === 'o3' && msg.reasoningEffort === 'high') || - (msg.type === 'set_reasoning' && msg.effort === 'high') || - (msg.type === 'set_reasoning_effort' && msg.effort === 'high'), - ); await expect(highButton).toHaveClass(/active/); await expect(mediumButton).not.toHaveClass(/active/); } finally { diff --git a/tests/remote-cloud-sessions.spec.ts b/tests/remote-cloud-sessions.spec.ts new file mode 100644 index 0000000..c19ace6 --- /dev/null +++ b/tests/remote-cloud-sessions.spec.ts @@ -0,0 +1,241 @@ +/** + * E2E tests for remote sessions and cloud session creation. + * + * Covers: + * - Remote Sessions settings panel (mode picker, persistence, apply to session) + * - remoteSession sent on new_session when not "off" + * - Remote URL banner (remote_session_url → banner with github.com link, dismiss) + * - Cloud session creation form in the sessions sheet (validation + new_cloud_session) + */ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { + createAuthenticatedPage, + mockWebSocket, + goToChat, + openSidebar, + MOCK_SESSIONS, + type WsMessageHandler, +} from './helpers'; + +interface SetupResult { + page: Page; + clientMessages: Record[]; + close: () => Promise; +} + +async function setupPage(browser: Browser, onMessage?: WsMessageHandler): Promise { + const { page, context } = await createAuthenticatedPage(browser); + const clientMessages: Record[] = []; + + // Settings API: in-memory store so the remote mode persists across PUT/GET + let settings: Record | null = null; + await page.route('**/api/settings', async (route, request) => { + if (request.method() === 'GET') { + await route.fulfill({ json: { settings } }); + return; + } + if (request.method() === 'PUT') { + const body = request.postDataJSON() as { settings?: Record }; + if (body.settings) settings = body.settings; + await route.fulfill({ json: { ok: true } }); + return; + } + await route.continue(); + }); + + await mockWebSocket(page, { + onMessage: (msg, ws) => { + clientMessages.push(msg); + if (msg.type === 'list_sessions') { + ws.send(JSON.stringify({ type: 'sessions', sessions: MOCK_SESSIONS })); + } + onMessage?.(msg, ws); + }, + }); + + await goToChat(page); + + return { page, clientMessages, close: () => context.close() }; +} + +async function openSettings(page: Page) { + await openSidebar(page); + await page.getByRole('button', { name: 'Settings' }).click(); + await expect(page.locator('.settings-panel')).toBeVisible(); +} + +async function openRemotePanel(page: Page) { + await openSettings(page); + await page.getByRole('button', { name: 'Remote Sessions' }).click(); + await expect(page.getByRole('radiogroup', { name: 'Remote session mode' })).toBeVisible(); +} + +async function openSessionsSheet(page: Page) { + await openSidebar(page); + await page.getByRole('button', { name: 'Sessions' }).click(); + await expect(page.locator('.sheet-panel')).toBeVisible(); +} + +test.describe('Remote sessions', () => { + test('settings panel shows the three remote modes with Off selected by default', async ({ browser }) => { + const app = await setupPage(browser); + try { + await openRemotePanel(app.page); + + const group = app.page.getByRole('radiogroup', { name: 'Remote session mode' }); + await expect(group.getByRole('radio')).toHaveCount(3); + await expect(group.getByRole('radio', { name: /Off/ })).toBeChecked(); + } finally { + await app.close(); + } + }); + + test('selecting Export persists and is sent on the next new session', async ({ browser }) => { + const app = await setupPage(browser); + try { + await openRemotePanel(app.page); + await app.page.getByRole('radio', { name: /Export/ }).check(); + await app.page.click('button.settings-close'); + + // Trigger a new chat — new_session must carry remoteSession: "export" + await openSidebar(app.page); + await app.page.getByRole('button', { name: 'New Chat' }).click(); + + await expect.poll(() => + app.clientMessages.filter((m) => m.type === 'new_session').at(-1)?.remoteSession ?? null, + ).toBe('export'); + } finally { + await app.close(); + } + }); + + test('Off mode does not include remoteSession in new_session', async ({ browser }) => { + const app = await setupPage(browser); + try { + await openSidebar(app.page); + await app.page.getByRole('button', { name: 'New Chat' }).click(); + + await expect.poll(() => + app.clientMessages.filter((m) => m.type === 'new_session').length, + ).toBeGreaterThan(0); + const last = app.clientMessages.filter((m) => m.type === 'new_session').at(-1)!; + expect(last).not.toHaveProperty('remoteSession'); + } finally { + await app.close(); + } + }); + + test('Apply to current session sends remote_toggle', async ({ browser }) => { + const app = await setupPage(browser, (msg, ws) => { + if (msg.type === 'remote_toggle') { + ws.send(JSON.stringify({ type: 'remote_toggled', enabled: true })); + ws.send(JSON.stringify({ type: 'remote_session_url', url: 'https://github.com/copilot/c/test-123' })); + } + }); + try { + await openRemotePanel(app.page); + await app.page.getByRole('radio', { name: /Full remote/ }).check(); + await app.page.click('button:has-text("Apply to current session")'); + + await expect.poll(() => + app.clientMessages.filter((m) => m.type === 'remote_toggle').at(-1)?.mode ?? null, + ).toBe('on'); + + // Banner appears with the github.com link after the server replies + await app.page.click('button.settings-close'); + const banner = app.page.locator('.remote-banner'); + await expect(banner).toBeVisible(); + await expect(banner.getByRole('link', { name: 'Open on GitHub' })) + .toHaveAttribute('href', 'https://github.com/copilot/c/test-123'); + } finally { + await app.close(); + } + }); + + test('remote banner can be dismissed', async ({ browser }) => { + const app = await setupPage(browser, (msg, ws) => { + if (msg.type === 'new_session') { + // Simulate the SDK announcing the remote URL right after session creation + setTimeout(() => { + ws.send(JSON.stringify({ type: 'remote_session_url', url: 'https://github.com/copilot/c/banner-1' })); + }, 50); + } + }); + try { + const banner = app.page.locator('.remote-banner'); + await expect(banner).toBeVisible(); + + await banner.getByRole('button', { name: 'Dismiss remote session banner' }).click(); + await expect(banner).toBeHidden(); + } finally { + await app.close(); + } + }); +}); + +test.describe('Cloud sessions', () => { + test('cloud session form validates the owner before sending', async ({ browser }) => { + const app = await setupPage(browser); + try { + await openSessionsSheet(app.page); + await app.page.click('button.cloud-new-btn'); + + await app.page.getByLabel('Owner').fill('-bad-owner-'); + await app.page.getByLabel('Repository').fill('repo'); + await app.page.click('button.cloud-submit'); + + await expect(app.page.getByRole('alert')).toHaveText('Invalid repository owner'); + expect(app.clientMessages.filter((m) => m.type === 'new_cloud_session')).toHaveLength(0); + } finally { + await app.close(); + } + }); + + test('valid repository submits new_cloud_session and closes the sheet', async ({ browser }) => { + const app = await setupPage(browser, (msg, ws) => { + if (msg.type === 'new_cloud_session') { + ws.send(JSON.stringify({ + type: 'cloud_session_created', + sessionId: 'cloud-1', + repository: msg.repository, + })); + } + }); + try { + await openSessionsSheet(app.page); + await app.page.click('button.cloud-new-btn'); + + await app.page.getByLabel('Owner').fill('octocat'); + await app.page.getByLabel('Repository').fill('hello-world'); + await app.page.getByLabel(/Branch/).fill('main'); + await app.page.click('button.cloud-submit'); + + await expect.poll(() => + app.clientMessages.filter((m) => m.type === 'new_cloud_session').at(-1)?.repository ?? null, + ).toEqual({ owner: 'octocat', name: 'hello-world', branch: 'main' }); + + await expect(app.page.locator('.sheet-panel')).toBeHidden(); + } finally { + await app.close(); + } + }); + + test('remote sessions show a remote badge in the list', async ({ browser }) => { + const remoteSessions = [ + { ...MOCK_SESSIONS[0], id: 'remote-1', title: 'Cloud refactor', isRemote: true }, + ...MOCK_SESSIONS.slice(1), + ]; + const app = await setupPage(browser, (msg, ws) => { + if (msg.type === 'list_sessions') { + ws.send(JSON.stringify({ type: 'sessions', sessions: remoteSessions })); + } + }); + try { + await openSessionsSheet(app.page); + await expect(app.page.locator('.indicator-remote')).toBeVisible(); + await expect(app.page.locator('.indicator-remote')).toHaveAttribute('aria-label', 'Remote session'); + } finally { + await app.close(); + } + }); +}); diff --git a/tests/responsive-chat.spec.ts b/tests/responsive-chat.spec.ts index 25486e2..82968f0 100644 --- a/tests/responsive-chat.spec.ts +++ b/tests/responsive-chat.spec.ts @@ -1,7 +1,7 @@ import { test, expect, type Browser } from '@playwright/test'; import { createAuthenticatedPage, mockWebSocket, goToChat, sendMessage, - createMessageSequence, + createMessageSequence, openSidebar, } from './helpers'; const MOBILE_VIEWPORT = { width: 390, height: 844 } as const; @@ -33,7 +33,7 @@ async function expectCoreChatUI(page: AuthenticatedChat['page']) { await expect(page.locator('.top-bar')).toBeVisible(); await expect(page.locator('.terminal')).toBeVisible(); await expect(page.locator('.input-area textarea')).toBeVisible(); - await expect(page.locator('button.send-btn')).toBeVisible(); + await expect(page.locator('.toolbar-right button')).toBeVisible(); } test.describe('Responsive — authenticated chat', () => { @@ -82,10 +82,10 @@ test.describe('Responsive — authenticated chat', () => { const { page, context } = await openAuthenticatedChat(browser, MOBILE_VIEWPORT); try { - await page.locator('button.hamburger-btn').click(); + await openSidebar(page); - await expect(page.locator('.sidebar-overlay')).toBeVisible(); - await expect(page.locator('.sidebar-panel')).toBeVisible(); + await expect(page.locator('.sidebar-backdrop')).toBeVisible(); + await expect(page.getByLabel('Sidebar navigation')).toBeVisible(); } finally { await context.close(); } @@ -95,18 +95,18 @@ test.describe('Responsive — authenticated chat', () => { const { page, context } = await openAuthenticatedChat(browser, MOBILE_VIEWPORT); try { - await page.locator('button.hamburger-btn').click(); - await expect(page.locator('.sidebar-panel')).toBeVisible(); + await openSidebar(page); + await expect(page.getByLabel('Sidebar navigation')).toHaveClass(/open/); - await page.locator('.sidebar-overlay').click({ + await page.locator('.sidebar-backdrop').click({ position: { x: MOBILE_VIEWPORT.width - 20, y: 100, }, }); - await expect(page.locator('.sidebar-overlay')).toHaveCount(0); - await expect(page.locator('.sidebar-panel')).toHaveCount(0); + await expect(page.locator('.sidebar-backdrop')).toHaveCount(0); + await expect(page.getByLabel('Sidebar navigation')).not.toHaveClass(/open/); } finally { await context.close(); } @@ -141,9 +141,14 @@ test.describe('Responsive — authenticated chat', () => { try { await test.step(`checks top bar controls at ${label}`, async () => { - await expect(page.locator('button.hamburger-btn')).toBeVisible(); - await expect(page.locator('button.model-pill')).toBeVisible(); - await expect(page.locator('button.newchat-btn')).toBeVisible(); + if (viewport.width < 1024) { + await expect(page.getByRole('button', { name: 'Open menu' })).toBeVisible(); + } else { + await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden(); + } + await expect(page.getByRole('button', { name: 'Select model' })).toBeVisible(); + await openSidebar(page); + await expect(page.getByRole('button', { name: 'New Chat' })).toBeVisible(); }); } finally { await context.close(); diff --git a/tests/responsive.spec.ts b/tests/responsive.spec.ts index a7d5fdc..a67b6ee 100644 --- a/tests/responsive.spec.ts +++ b/tests/responsive.spec.ts @@ -1,7 +1,7 @@ import { test, expect, type Browser } from '@playwright/test'; async function openLoginPage(browser: Browser, viewport: { width: number; height: number }) { - const context = await browser.newContext({ viewport }); + const context = await browser.newContext({ viewport, serviceWorkers: 'block' }); const page = await context.newPage(); await page.route('**/auth/device/start', (route) => @@ -23,6 +23,10 @@ async function openLoginPage(browser: Browser, viewport: { width: number; height return { page, context }; } +async function expectNoHorizontalOverflow(page: Awaited>['page'], width: number) { + await expect.poll(() => page.evaluate(() => document.body.scrollWidth)).toBeLessThanOrEqual(width); +} + test.describe('Responsive — Login screen', () => { test('login screen renders on small phone (320x568)', async ({ browser }) => { const { page, context } = await openLoginPage(browser, { width: 320, height: 568 }); @@ -31,8 +35,7 @@ test.describe('Responsive — Login screen', () => { await expect(page.locator('.device-code-text').first()).toBeVisible(); // No horizontal overflow - const bodyWidth = await page.locator('body').evaluate((el) => el.scrollWidth); - expect(bodyWidth).toBeLessThanOrEqual(320); + await expectNoHorizontalOverflow(page, 320); await context.close(); }); @@ -59,8 +62,7 @@ test.describe('Responsive — general', () => { test('no horizontal overflow at 320px', async ({ browser }) => { const { page, context } = await openLoginPage(browser, { width: 320, height: 568 }); - const bodyWidth = await page.locator('body').evaluate((el) => el.scrollWidth); - expect(bodyWidth).toBeLessThanOrEqual(320); + await expectNoHorizontalOverflow(page, 320); await context.close(); }); @@ -68,8 +70,7 @@ test.describe('Responsive — general', () => { test('page content fits within viewport at 375px', async ({ browser }) => { const { page, context } = await openLoginPage(browser, { width: 375, height: 667 }); - const bodyWidth = await page.locator('body').evaluate((el) => el.scrollWidth); - expect(bodyWidth).toBeLessThanOrEqual(375); + await expectNoHorizontalOverflow(page, 375); await context.close(); }); diff --git a/tests/session-management.spec.ts b/tests/session-management.spec.ts index 6224f6b..73643bb 100644 --- a/tests/session-management.spec.ts +++ b/tests/session-management.spec.ts @@ -3,6 +3,7 @@ import { createAuthenticatedPage, mockWebSocket, goToChat, + openSidebar, MOCK_SESSIONS, } from './helpers'; @@ -120,11 +121,8 @@ async function createSessionManagementPage( } async function openSessionsSheet(page: Page) { - await page.click('button.hamburger-btn'); - await expect(page.locator('.sidebar-overlay')).toBeVisible(); - await expect(page.locator('.sidebar-panel')).toBeVisible(); - - await page.click('button.sidebar-action:has-text("Sessions")'); + await openSidebar(page); + await page.getByRole('button', { name: 'Sessions' }).click(); await expect(page.locator('.sheet-overlay')).toBeVisible(); await expect(page.locator('.sheet-panel')).toBeVisible(); } diff --git a/tests/settings.spec.ts b/tests/settings.spec.ts index 72df77b..a920ce5 100644 --- a/tests/settings.spec.ts +++ b/tests/settings.spec.ts @@ -3,6 +3,7 @@ import { createAuthenticatedPage, mockWebSocket, goToChat, + openSidebar, MOCK_TOOLS, MOCK_AGENTS, } from './helpers'; @@ -11,7 +12,7 @@ interface MockSettings { model: string; mode: 'interactive' | 'plan' | 'autopilot'; reasoningEffort: 'low' | 'medium' | 'high' | 'xhigh'; - customInstructions: string; + additionalInstructions: string; excludedTools: string[]; customTools: unknown[]; mcpServers: Array<{ @@ -41,7 +42,7 @@ function createDefaultSettings(overrides: Partial = {}): MockSetti model: '', mode: 'interactive', reasoningEffort: 'medium', - customInstructions: '', + additionalInstructions: '', excludedTools: [], customTools: [], mcpServers: [], @@ -102,10 +103,8 @@ async function setupSettingsPage(browser: Browser, options: SetupOptions = {}): } async function openSettings(page: Page) { - await page.click('button.hamburger-btn'); - await expect(page.locator('.sidebar-panel')).toBeVisible(); - - await page.click('button.sidebar-action:has-text("Settings")'); + await openSidebar(page); + await page.getByRole('button', { name: 'Settings' }).click(); await expect(page.locator('.settings-overlay')).toBeVisible(); await expect(page.locator('.settings-panel')).toBeVisible(); @@ -121,14 +120,11 @@ test.describe('Settings', () => { const { page } = app; try { - await page.click('button.hamburger-btn'); - await expect(page.locator('.sidebar-panel')).toBeVisible(); - - await page.click('button.sidebar-action:has-text("Settings")'); + await openSidebar(page); + await page.getByRole('button', { name: 'Settings' }).click(); await expect(page.locator('.settings-overlay')).toBeVisible(); await expect(page.locator('.settings-panel')).toBeVisible(); - await expect(page.locator('.sidebar-panel')).toHaveCount(0); } finally { await app.close(); } @@ -150,12 +146,16 @@ test.describe('Settings', () => { const app = await setupSettingsPage(browser); const { page } = app; const expectedSections = [ - 'Custom Instructions', + 'Additional Instructions', 'Tools', 'MCP Servers', 'Agents', - 'Custom Tools', + 'Skills', + 'Extensions', + 'Prompts', 'Quota', + 'Notifications', + 'Remote Sessions', 'Compaction', ]; @@ -176,7 +176,7 @@ test.describe('Settings', () => { test('expands and collapses an accordion section', async ({ browser }) => { const app = await setupSettingsPage(browser); const { page } = app; - const instructionsButton = page.locator('button.settings-accordion-btn', { hasText: 'Custom Instructions' }); + const instructionsButton = page.locator('button.settings-accordion-btn', { hasText: 'Additional Instructions' }); try { await openSettings(page); @@ -200,7 +200,7 @@ test.describe('Settings', () => { test('keeps only one accordion section open at a time', async ({ browser }) => { const app = await setupSettingsPage(browser); const { page } = app; - const instructionsButton = page.locator('button.settings-accordion-btn', { hasText: 'Custom Instructions' }); + const instructionsButton = page.locator('button.settings-accordion-btn', { hasText: 'Additional Instructions' }); const toolsButton = page.getByRole('button', { name: /^Tools\b/ }); try { @@ -223,14 +223,14 @@ test.describe('Settings', () => { test('saves custom instructions', async ({ browser }) => { const app = await setupSettingsPage(browser, { - settings: { customInstructions: 'Keep answers concise.' }, + settings: { additionalInstructions: 'Keep answers concise.' }, }); const { page, putPayloads } = app; const instructionsText = 'Always explain code changes briefly and include validation steps.'; try { await openSettings(page); - await page.locator('button.settings-accordion-btn', { hasText: 'Custom Instructions' }).click(); + await page.locator('button.settings-accordion-btn', { hasText: 'Additional Instructions' }).click(); const textarea = page.locator('textarea.settings-textarea'); await expect(textarea).toHaveValue('Keep answers concise.'); @@ -239,7 +239,7 @@ test.describe('Settings', () => { await page.locator('.settings-accordion-body .action-btn.save').click(); await expect.poll(() => putPayloads.length).toBe(1); - expect(putPayloads[0]?.customInstructions).toBe(instructionsText); + expect(putPayloads[0]?.additionalInstructions).toBe(instructionsText); await expect(textarea).toHaveValue(instructionsText); } finally { await app.close();