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}
+
{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 @@
+
+
+
+
+
{steerable ? 'Remote session active' : 'Session exported'}
+
+ Open on GitHub
+
+ {#if onDismiss}
+
+ {/if}
+
+
+
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}
+
+ {#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();