From 0041618fe0973f896e3b8997f873cb3ace5d97f7 Mon Sep 17 00:00:00 2001 From: David Khachaturov Date: Tue, 14 Apr 2026 23:52:11 +0100 Subject: [PATCH 1/3] feat(sidecar): initial working prototype --- .github/workflows/deploy-pwa.yml | 38 + .vscode/tasks.json | 23 +- README.md | 155 +- package.json | 1 + prune.js | 27 + pwa/css/style.css | 977 ++++++++ pwa/icons/copilot.png | Bin 0 -> 7435 bytes pwa/index.html | 99 + pwa/js/app.js | 844 +++++++ pwa/js/chat-renderer.js | 951 ++++++++ pwa/js/qr-scanner.js | 8 + pwa/js/ws-client.js | 140 ++ pwa/manifest.json | 18 + pwa/sw.js | 61 + script/pwaDevServer.mjs | 101 + .../authentication.contribution.ts | 8 +- .../claude/node/claudeCodeModels.ts | 15 +- .../copilotcli/node/copilotCli.ts | 15 +- .../vscode-node/languageModelAccess.ts | 14 +- .../vscode-node/mobileMirrorContribution.ts | 538 +++++ .../vscode-node/sidecarContribution.ts | 1200 +++++++++ .../node/conversationStore.spec.ts | 8 +- .../node/conversationStore.ts | 712 ++++++ .../extension/vscode-node/contributions.ts | 2 + .../node/chatParticipantRequestHandler.ts | 580 ++++- .../vscode-node/copilotTokenManager.ts | 19 +- src/platform/bridge/bridgeServer.ts | 659 +++++ src/platform/bridge/conversationBridge.ts | 2141 +++++++++++++++++ .../bridge/test/node/chatRenderer.spec.ts | 267 ++ .../node/conversationBridgePipeline.spec.ts | 1220 ++++++++++ .../github/common/octoKitServiceImpl.ts | 16 +- 31 files changed, 10794 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/deploy-pwa.yml create mode 100644 prune.js create mode 100644 pwa/css/style.css create mode 100644 pwa/icons/copilot.png create mode 100644 pwa/index.html create mode 100644 pwa/js/app.js create mode 100644 pwa/js/chat-renderer.js create mode 100644 pwa/js/qr-scanner.js create mode 100644 pwa/js/ws-client.js create mode 100644 pwa/manifest.json create mode 100644 pwa/sw.js create mode 100644 script/pwaDevServer.mjs create mode 100644 src/extension/conversation/vscode-node/mobileMirrorContribution.ts create mode 100644 src/extension/conversation/vscode-node/sidecarContribution.ts create mode 100644 src/platform/bridge/bridgeServer.ts create mode 100644 src/platform/bridge/conversationBridge.ts create mode 100644 src/platform/bridge/test/node/chatRenderer.spec.ts create mode 100644 src/platform/bridge/test/node/conversationBridgePipeline.spec.ts diff --git a/.github/workflows/deploy-pwa.yml b/.github/workflows/deploy-pwa.yml new file mode 100644 index 0000000000..fbc21cad19 --- /dev/null +++ b/.github/workflows/deploy-pwa.yml @@ -0,0 +1,38 @@ +name: Deploy PWA + +on: + push: + branches: + - main + paths: + - pwa/** + - .github/workflows/deploy-pwa.yml + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload PWA Artifact + uses: actions/upload-pages-artifact@v3 + with: + path: pwa + + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5f72d7fb4c..786aceecf8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -8,10 +8,18 @@ "inSessions": true, "runOptions": { "runOn": "worktreeCreated" } }, + { + "label": "PWA: Dev Server", + "type": "shell", + "command": "npm run pwa:dev", + "isBackground": true, + "problemMatcher": [], + "inSessions": true + }, { "label": "Compile & Launch Extension Host", "type": "shell", - "command": "npm run compile && COPILOT_LOG_TELEMETRY=true VSCODE_DEV_DEBUG=1 code-insiders --extensionDevelopmentPath=$(pwd)", + "command": "npm run compile && if command -v code-insiders >/dev/null 2>&1; then COPILOT_LOG_TELEMETRY=true VSCODE_DEV_DEBUG=1 code-insiders --extensionDevelopmentPath=$(pwd); else COPILOT_LOG_TELEMETRY=true VSCODE_DEV_DEBUG=1 code --enable-proposed-api github.copilot-chat --extensionDevelopmentPath=$(pwd); fi", "inSessions": true }, { @@ -138,6 +146,19 @@ "reveal": "never" } }, + { + "label": "Package & Install VSIX", + "type": "shell", + "command": "npm run compile && npx vsce package --out copilot-chat-sidecar.vsix --allow-package-secrets sendgrid && if command -v code-insiders >/dev/null 2>&1; then code-insiders --install-extension copilot-chat-sidecar.vsix --force; else code --install-extension copilot-chat-sidecar.vsix --force; fi", + "group": "build", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true, + "focus": true + }, + "problemMatcher": [] + }, { "label": "simulate", "type": "process", diff --git a/README.md b/README.md index 3d0f42a7f7..9ca846d002 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,151 @@ -# GitHub Copilot - Your autonomous AI peer programmer +# Sidecar for VS Code's GitHub Copilot -**[GitHub Copilot](https://code.visualstudio.com/docs/copilot/overview)** is an AI peer programming tool that transforms how you write code in Visual Studio Code. +**Dispatch into VS Code's Copilot Chat from your phone, from anywhere** -GitHub Copilot agents handle complete coding tasks end-to-end, autonomously planning work, editing files, running commands, and self-correcting when they hit errors. You can also leverage inline suggestions for quick coding assistance and inline chat for precise, focused edits directly in the editor. +> prototype; this is an early-stage experiment. It works, but expect rough edges. PRs are very welcome. -**Sign up for [GitHub Copilot Free](https://github.com/settings/copilot?utm_source=vscode-chat-readme&utm_medium=first&utm_campaign=2025mar-em-MSFT-signup)!** +*Sidecar* is a lightweight orchestration layer for mobile access to your active Copilot Chat workflow. It lets a phone sync with and interact with the same chat session that is open on desktop, without adding relay infrastructure or installing a native phone app. -![Working with GitHub Copilot agent mode to make edits to code in your workspace](https://github.com/microsoft/vscode-docs/raw/732b9599e49ee7034744a3e5b0485b7fb4bdf530/docs/copilot/images/getting-started/custom-reviewer-mode.png) +## Why this exists +Primarily because I like VS Code and don't use Claude Code - the former fits my workflow well, and I like the flexibilty Copilot provides whereby I can swap models on the fly. -## Getting access to GitHub Copilot +Actual features of sidear: +- **Resume desktop context, not just repo context** + Continue the exact repo/branch/chat thread already running in your desktop editor. +- **Work with local-first state** + Interact with unpushed changes, local files, and the real host machine state. +- **Phone-first access to your IDE** + Open from a QR pairing flow and use a touch-optimised UI. +- **Faster pick-up flow** + Useful for quickly continuing one thread, answering a follow-up, or checking progress while you're grabbing lunch and left your Copilot running. -Sign up for [GitHub Copilot Free](https://github.com/settings/copilot?utm_source=vscode-chat-readme&utm_medium=second&utm_campaign=2025mar-em-MSFT-signup), or request access from your enterprise admin. +## Quickstart: Install as a VSIX -To access GitHub Copilot, an active GitHub Copilot subscription is required. You can read more about our business and individual offerings at [github.com/features/copilot](https://github.com/features/copilot?utm_source=vscode-chat&utm_medium=readme&utm_campaign=2025mar-em-MSFT-signup). +If you just want to run Sidecar without setting up the full dev environment, you can package and install it as a `.vsix`: -## Build with autonomous agents +1. Install dependencies: -**Let AI agents implement complex features end-to-end**. Give an agent a high-level task and it breaks the work into steps, edits multiple files, runs terminal commands, and self-corrects when it hits errors or failing tests. Agents excel at [building new features](https://code.visualstudio.com/docs/copilot/agents/overview), [debugging and fixing failing tests](https://code.visualstudio.com/docs/copilot/guides/debug-with-copilot), refactoring codebases, and [collaborating via pull requests](https://code.visualstudio.com/docs/copilot/agents/cloud-agents). + ``` + npm ci + ``` -**Manage sessions from a central view.** Run multiple [agent sessions](https://code.visualstudio.com/docs/copilot/chat/chat-sessions) in parallel and track them in one place. Monitor session status, switch between active work, review file changes, and resume where you left off. +2. Disable the official `Github Copilot Chat` extension -**Run agents with your preferred harness.** Use agents locally in VS Code, in the background via Copilot CLI, or Cloud via Copilot Coding Agent. You can also work with providers like Claude and Codex, and hand tasks off between agent types with context preserved all within the VS Code. +2. Run the VS Code task **Package & Install VSIX** (Terminal β†’ Run Task), or run it manually: -![Video showing an agent session building a complete feature in VS Code.](https://github.com/microsoft/vscode-docs/raw/refs/heads/main/docs/copilot/images/overview/agents-intro.gif) + ``` + npm run compile && npx vsce package --out copilot-chat-sidecar.vsix --allow-package-secrets sendgrid + ``` -**Use agents to [plan before you build](https://code.visualstudio.com/docs/copilot/agents/planning) with the Plan agent**, which breaks tasks into structured implementation plans and asks clarifying questions. When your plan is ready, hand it off to an implementation agent to execute it. You can also [delegate tasks to cloud agents](https://code.visualstudio.com/docs/copilot/agents/cloud-agents) that create branches, implement changes, and open pull requests for your team to review. + Then install: -## More ways to code with AI + ``` + code-insiders --install-extension copilot-chat-sidecar.vsix --force + # or: code --install-extension copilot-chat-sidecar.vsix --force + ``` -**Receive intelligent inline suggestions** as you type with [ghost text suggestions](https://aka.ms/vscode-completions) and [next edit suggestions](https://aka.ms/vscode-nes), helping you write code faster. Copilot predicts your next logical change, and you can accept suggestions with the Tab key. +3. Reload VS Code and re-enable the `Github Copilot Chat` - this over-rides it. The Sidecar extension is now installed - **click the "Sidecar" panel at the bottom-right-hand-corner of your VS Code window** to launch. -![Video showing Copilot next edit suggestions.](https://github.com/microsoft/vscode-docs/raw/refs/heads/main/docs/copilot/images/inline-suggestions/nes-video.gif) +4. Accept the default URL (`https://davidobot.github.io/vscode-copilot-chat-sidecar/`) unless you're self-hosting; this is just the visual layer -**Use inline chat for targeted edits** by pressing `Ctrl+I`/`Cmd+I` to open a chat prompt directly in the editor. Describe a change and Copilot suggests edits in place for refactoring methods, adding error handling, or explaining complex algorithms without leaving your editor. +## Development -![Inline chat in VS Code](https://code.visualstudio.com/assets/docs/copilot/copilot-chat/inline-chat-question-example.png) +### Sidecar architecture +- The extension starts a local HTTP + WebSocket bridge server on localhost. +- Sidecar bootstraps Dev Tunnels directly by running `devtunnel host -p --allow-anonymous` and using the resulting `*.devtunnels.ms` endpoint. +- If direct devtunnel bootstrap is unavailable or fails, Sidecar prompts you to [install it](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started). +- A QR code in VS Code opens the PWA with a signed pairing URL (`ws` + session `token`). +- The phone PWA syncs conversation list, history, and streaming assistant chunks. +- Prompts sent from the phone are forwarded back into Copilot Chat on desktop. +- The status bar entry is `Sidecar` on the **bottom-right-hand-corner** of your window: + - Disconnected state shows a disconnect icon. + - Clicking `Sidecar` starts the bridge and then opens the pairing panel. -## Customize AI for your workflow +### PWA deployment -**Agents work best when they understand your project's conventions and have the right tools**. Tailor Copilot so it generates code that fits your codebase from the start. +The `pwa/` directory is a static web app designed for GitHub Pages deployment. This repository includes `.github/workflows/deploy-pwa.yml`, which deploys `pwa/` to Pages on pushes to `main`. -**Project context.** Use [custom instructions](https://code.visualstudio.com/docs/copilot/customization/custom-instructions) to specify project-wide or task-specific context and coding guidelines. +Use my deployed Github pages URL (`https://davidobot.github.io/vscode-copilot-chat-sidecar/`) in the Sidecar panel when generating pairing QR codes, or deploy your own. -**Add specialized capabilities**. Teach Copilot specialized capabilities with [agent skills](https://code.visualstudio.com/docs/copilot/customization/agent-skills) or define specialized personas with [custom agents](https://code.visualstudio.com/docs/copilot/customization/custom-agents). +### Local PWA dev server -**Connect to external tools and services**. Extend agents further with tools from [MCP servers](https://code.visualstudio.com/docs/copilot/customization/mcp-servers) and extensions to give Copilot a gateway to external data sources, APIs, or specialized tools. +You can test without deploying GitHub Pages. -### Supported languages and frameworks +1. Start the local PWA server: -GitHub Copilot works on any language, including Java, PHP, Python, JavaScript, Ruby, Go, C#, or C++. Because it’s been trained on languages in public repositories, it works for most popular languages, libraries and frameworks. + ``` + npm run pwa:dev + ``` -### Version compatibility + (or run the VS Code task `PWA: Dev Server`) -As Copilot Chat releases in lockstep with VS Code due to its deep UI integration, every new version of Copilot Chat is only compatible with the latest and newest release of VS Code. This means that if you are using an older version of VS Code, you will not be able to use the latest Copilot Chat. +2. Point Sidecar to your local PWA URL: -Only the latest Copilot Chat versions will use the latest models provided by the Copilot service, as even minor model upgrades require prompt changes and fixes in the extension. + - For phone testing on same Wi-Fi: `http://:4173/` + - For desktop-only testing: `http://localhost:4173/` -### Privacy and preview terms +3. Optional override: set `COPILOT_PWA_DEV_URL` before launching the extension host. -By using Copilot Chat you agree to [GitHub Copilot chat preview terms](https://docs.github.com/en/early-access/copilot/github-copilot-chat-technical-preview-license-terms). Review the [transparency note](https://aka.ms/CopilotChatTransparencyNote) to understand about usage, limitations and ways to improve Copilot Chat during the technical preview. + - Example: `COPILOT_PWA_DEV_URL=http://:4173/` + - Sidecar prefers this env value over saved settings. -Please refer to our [Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-privacy-statement) to learn about the data we collect, how we use it, and the controls available to you. +### Run and test locally -To get the latest security fixes, please use the latest version of the Copilot extension and VS Code. +1. Install dependencies in the repo root: -### Resources & next steps -* **[Sign up for GitHub Copilot Free](https://github.com/settings/copilot?utm_source=vscode-chat-readme&utm_medium=third&utm_campaign=2025mar-em-MSFT-signup)**: Explore Copilot's AI capabilities at no cost before upgrading to a paid plan. - * If you're using Copilot for your business, check out [Copilot Business](https://docs.github.com/en/copilot/copilot-business/about-github-copilot-business) and [Copilot Enterprise](https://docs.github.com/en/copilot/github-copilot-enterprise/overview/about-github-copilot-enterprise). -* **[Copilot Quickstart](https://code.visualstudio.com/docs/copilot/getting-started)**: Discover the key features of Copilot in VS Code. -* **[Agents Tutorial](https://code.visualstudio.com/docs/copilot/agents/agents-tutorial)**: Get started with autonomous agents across different environments. -* **[VS Code on YouTube](https://www.youtube.com/@code)**: Watch the latest demos and updates on the VS Code channel. -* **[Frequently Asked Questions](https://code.visualstudio.com/docs/copilot/faq)**: Get answers to commonly asked questions about Copilot in VS Code. -* **[Provide Feedback](https://github.com/microsoft/vscode-copilot-release/issues)**: Send us your feedback and feature request to help us make GitHub Copilot better! + ``` + npm ci + ``` -## Data and telemetry +2. Build and launch the extension host (VS Code task): -The GitHub Copilot Extension for Visual Studio Code collects usage data and sends it to Microsoft to help improve our products and services. Read our [privacy statement](https://privacy.microsoft.com/privacystatement) to learn more. This extension respects the `telemetry.telemetryLevel` setting which you can learn more about at https://code.visualstudio.com/docs/supporting/faq#_how-to-disable-telemetry-reporting. + ``` + Compile & Launch Extension Host + ``` -## Trademarks + The task prefers VS Code Insiders. If `code-insiders` is not installed, it falls back to `code` with `--enable-proposed-api github.copilot-chat`. -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow Microsoft's Trademark & Brand Guidelines. Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. +3. In the Extension Development Host window: + + - Disable the official GitHub Copilot Chat extension to avoid conflicts. + - Sign in to GitHub Copilot. + - Install [Azure Dev Tunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started) and sign in for direct tunnel bootstrap: + - Open a workspace and open the Chat view. + +4. In the status bar, confirm Sidecar starts in disconnected state (`$(debug-disconnect) Sidecar`). + +5. Click `Sidecar` to start the bridge and open the pairing panel. + +6. Set a PWA URL: + - Default hosted URL: https://davidobot.github.io/vscode-copilot-chat-sidecar/ + - Local dev URL: `http://0.0.0.0:4173/` + +7. Scan the QR code from your phone and open the PWA. + +8. Validate bidirectional sync: + - Send a desktop chat prompt and confirm it appears on phone. + - Send a phone prompt and confirm it appears in desktop chat. + - Confirm assistant streaming chunks render live on phone. + - Confirm status bar item updates from disconnected yellow to active Sidecar after startup. + - Confirm the Sidecar panel shows a non-loopback bridge endpoint (if it shows a loopback warning, phone pairing will fail). + +9. Validate reconnect behavior: + - Disconnect/reconnect phone network. + - Confirm status changes to reconnecting, then connected, and conversation list refreshes. + +10. If pairing fails: + - Verify `devtunnel` CLI is installed and signed in. + - Regenerate token from the Sidecar panel and rescan. + - Reopen the Sidecar panel to refresh the pairing URL. + +## Original Repo + +This is just a fork. The original repo can be found [here](https://github.com/microsoft/vscode-copilot-chat/). ## License -Copyright (c) Microsoft Corporation. All rights reserved. +Original repo licensed under the [MIT](LICENSE.txt) license. -Licensed under the [MIT](LICENSE.txt) license. +New changes in this fork are also licensed under the [MIT](LICENSE.txt) license. diff --git a/package.json b/package.json index 89a00915e9..42895234e3 100644 --- a/package.json +++ b/package.json @@ -6216,6 +6216,7 @@ "vscode-dts:main": "node node_modules/@vscode/dts/index.js main && node script/build/moveProposedDts.js", "build": "node .esbuild.ts --sourcemaps", "compile": "node .esbuild.ts --dev", + "pwa:dev": "node script/pwaDevServer.mjs", "watch": "npm-run-all -p watch:*", "watch:esbuild": "node .esbuild.ts --watch --dev", "watch:tsc-extension": "tsc --noEmit --watch --project tsconfig.json", diff --git a/prune.js b/prune.js new file mode 100644 index 0000000000..45d72fe1f4 --- /dev/null +++ b/prune.js @@ -0,0 +1,27 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/extension/conversation/vscode-node/sidecarContribution.ts', 'utf8'); + +const regexes = [ + /^[ \t]*private async tryResolveViaCodeTunnelCli[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private async tryAutoCreateDevTunnel[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private async tryActivateVsCodeTunnelResources[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private async tryRunTunnelBootstrapCommands[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private async tryRunCodeTunnelBootstrap[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private ensureWorkspacePathOnVsCodeTunnelUri[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private async tryStartCodeTunnelProcessForUri[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private getCodeTunnelWorkingDirectory[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private waitForVsCodeTunnelUri[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private getCodeCliCandidates[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private runCodeCliCommand[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private extractTunnelUriFromCommandResult[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private extractVsCodeTunnelUri[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private attachCodeTunnelProcessLifecycle[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private stopCodeTunnelProcess[\s\S]*?\n[ \t]*\}\n/gm, + /^[ \t]*private terminateCodeTunnelProcess[\s\S]*?\n[ \t]*\}\n/gm +]; + +for (const r of regexes) { + code = code.replace(r, '\n'); +} + +fs.writeFileSync('src/extension/conversation/vscode-node/sidecarContribution.ts', code); diff --git a/pwa/css/style.css b/pwa/css/style.css new file mode 100644 index 0000000000..310e9724f2 --- /dev/null +++ b/pwa/css/style.css @@ -0,0 +1,977 @@ +/* ================================================================ + Copilot Chat PWA β€” VS Code Dark Theme Replica + ================================================================ */ + +:root { + --vscode-editor-background: #1e1e1e; + --vscode-sideBar-background: #252526; + --vscode-sideBar-border: #2d2d2d; + --vscode-editor-foreground: #cccccc; + --vscode-foreground: #cccccc; + --vscode-descriptionForeground: #9d9d9d; + --vscode-focusBorder: #007fd4; + --vscode-input-background: #3c3c3c; + --vscode-input-foreground: #cccccc; + --vscode-input-border: #3c3c3c; + --vscode-button-background: #0e639c; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #1177bb; + --vscode-list-activeSelectionBackground: #04395e; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-textCodeBlock-background: #2b2b2b; + --vscode-textLink-foreground: #3794ff; + --vscode-badge-background: #4d4d4d; + --vscode-badge-foreground: #ffffff; + --vscode-chat-requestBorder: #3c3c3c; + --vscode-chat-avatarBackground: #3c3c3c; + + --font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", system-ui, "Ubuntu", "Droid Sans", sans-serif; + --font-size: 13px; + --code-font-family: "Cascadia Code", "Fira Code", Menlo, Monaco, "Courier New", monospace; + --code-font-size: 12px; + + --safe-top: env(safe-area-inset-top, 0px); + --safe-right: env(safe-area-inset-right, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-left: env(safe-area-inset-left, 0px); +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + font-family: var(--font-family); + font-size: var(--font-size); +} + +/* ---- Layout ---- */ +#app { + display: flex; + height: 100dvh; + padding-top: var(--safe-top); + padding-bottom: var(--safe-bottom); +} + +/* ---- Sidebar (conversation panel) ---- */ +.conversation-panel { + width: 260px; + min-width: 260px; + background: var(--vscode-sideBar-background); + border-right: 1px solid var(--vscode-sideBar-border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--vscode-sideBar-border); +} + +.panel-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +} + +.panel-actions { + display: flex; + gap: 2px; +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + padding: 0; +} + +.icon-button:hover { + background: var(--vscode-list-hoverBackground); +} + +.conversation-filters { + padding: 8px 10px; + border-bottom: 1px solid var(--vscode-sideBar-border); + display: flex; + flex-direction: column; + gap: 6px; + background: color-mix(in srgb, var(--vscode-sideBar-background) 94%, #1f1f1f); +} + +.filter-search { + height: 28px; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + padding: 0 8px; + font-family: var(--font-family); + font-size: 12px; + outline: none; +} + +.filter-search::placeholder { + color: var(--vscode-descriptionForeground); +} + +.filter-search:focus { + border-color: var(--vscode-focusBorder); +} + +.filter-row { + display: flex; + align-items: center; + gap: 6px; +} + +.filter-select { + height: 26px; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + padding: 0 6px; + font-family: var(--font-family); + font-size: 11px; + min-width: 0; + flex: 1; +} + +.filter-clear { + height: 26px; + border: 1px solid var(--vscode-sideBar-border); + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + font-family: var(--font-family); + font-size: 11px; + padding: 0 8px; + cursor: pointer; + white-space: nowrap; +} + +.filter-clear.active { + color: var(--vscode-foreground); + border-color: var(--vscode-focusBorder); + background: color-mix(in srgb, var(--vscode-focusBorder) 18%, transparent); +} + +.filter-clear:hover { + background: var(--vscode-list-hoverBackground); + color: var(--vscode-foreground); +} + +.filter-clear:disabled { + opacity: 0.5; + cursor: default; +} + +.filter-clear:disabled:hover { + background: transparent; + color: var(--vscode-descriptionForeground); +} + +.conversation-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1; +} + +.conversation-item { + padding: 6px 12px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 1px; + border-left: 2px solid transparent; +} + +.conversation-item:hover { + background: var(--vscode-list-hoverBackground); +} + +.conversation-item.active { + background: var(--vscode-list-activeSelectionBackground); + border-left-color: var(--vscode-focusBorder); +} + +.conversation-item.active .conversation-title { + color: var(--vscode-list-activeSelectionForeground); +} + +.conversation-title { + font-size: 13px; + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.conversation-time { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +/* ---- Chat Container ---- */ +.chat-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.chat-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--vscode-sideBar-border); + background: var(--vscode-editor-background); + min-height: 36px; +} + +.sidebar-toggle { + display: none; +} + +.chat-title { + font-size: 13px; + font-weight: 600; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---- Status Badge ---- */ +.status { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; +} + +.status-connecting { + background: #5a4a00; + color: #ffd700; +} + +.status-connected { + background: #1a3a1a; + color: #73c991; +} + +.status-reconnecting, +.status-disconnected { + background: #4a1a1a; + color: #f48771; +} + +/* ---- Connection banner ---- */ +.connection-banner { + padding: 4px 12px; + font-size: 12px; + background: #063b49; + color: #d4d4d4; + text-align: center; +} + +.hidden { + display: none !important; +} + +/* ---- Messages area ---- */ +.messages { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 0; + scroll-behavior: smooth; +} + +/* ---- Message rows ---- */ +.chat-message { + display: flex; + gap: 10px; + padding: 12px 0; +} + +.chat-message + .chat-message { + border-top: 1px solid var(--vscode-chat-requestBorder); +} + +.chat-message.user-message { + /* User messages have a subtle top border in VS Code */ +} + +.chat-avatar { + flex-shrink: 0; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--vscode-chat-avatarBackground); + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; +} + +.chat-avatar svg { + width: 16px; + height: 16px; +} + +.chat-avatar.copilot-avatar { + background: linear-gradient(135deg, #6e40c9 0%, #8957e5 100%); +} + +.chat-content { + flex: 1; + min-width: 0; + line-height: 1.5; + word-break: break-word; +} + +.chat-content .sender-name { + font-weight: 600; + font-size: 13px; + margin-bottom: 4px; + display: block; +} + +.chat-content .sender-name.copilot { + color: #c084fc; +} + +/* ---- User message text ---- */ +.user-text { + white-space: pre-wrap; + font-size: 13px; +} + +/* ---- Assistant markdown content ---- */ +.assistant-content { + font-size: 13px; +} + +.assistant-history-tool-lines:empty, +.assistant-markdown:empty, +.assistant-artifacts:empty, +.assistant-status:empty, +.assistant-tools:empty, +.assistant-confirmations:empty, +.assistant-questions:empty, +.assistant-command-buttons:empty, +.assistant-extras:empty, +.assistant-references:empty, +.assistant-citations:empty { + display: none; +} + +.assistant-artifacts { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Tool invocation lines shown above the markdown response, matching VS Code's sidebar style */ +.assistant-history-tool-lines { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 8px; +} + +.history-tool-line { + font-size: 11px; + color: var(--vscode-descriptionForeground); + padding: 1px 0 1px 10px; + border-left: 2px solid color-mix(in srgb, var(--vscode-descriptionForeground) 30%, transparent); + line-height: 1.4; + opacity: 0.85; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.assistant-status, +.assistant-tools, +.assistant-confirmations, +.assistant-questions, +.assistant-command-buttons, +.assistant-extras { + display: flex; + flex-direction: column; + gap: 6px; +} + +.assistant-status-item, +.assistant-tool-item { + border: 1px solid var(--vscode-sideBar-border); + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-input-background) 60%, transparent); + padding: 6px 10px; + font-size: 12px; + line-height: 1.35; +} + +.assistant-status-item { + display: flex; + gap: 8px; + align-items: baseline; +} + +.assistant-status-kind { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 700; + color: var(--vscode-descriptionForeground); + min-width: 56px; +} + +.assistant-status-warning { + border-color: #ad8f45; +} + +.assistant-status-thinking { + border-color: #5b84c4; +} + +.assistant-tool-item { + font-family: var(--code-font-family); + word-break: break-word; +} + +.assistant-tool-item.is-complete { + border-color: #4f8c6a; +} + +.assistant-tool-item.is-error { + border-color: #b85c56; +} + +.assistant-confirmation-item, +.assistant-questions-item { + border: 1px solid var(--vscode-sideBar-border); + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-textCodeBlock-background) 68%, transparent); + padding: 8px 10px; + font-size: 12px; + line-height: 1.35; +} + +.assistant-confirmation-title, +.assistant-questions-title, +.assistant-question-title { + font-weight: 600; + margin-bottom: 4px; +} + +.assistant-confirmation-message, +.assistant-question-message, +.assistant-question-options { + color: var(--vscode-foreground); + margin-bottom: 4px; +} + +.assistant-confirmation-buttons { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.assistant-confirmation-button { + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--vscode-sideBar-border); + background: color-mix(in srgb, var(--vscode-button-background) 22%, transparent); + font-size: 11px; +} + +.assistant-questions-list { + margin: 0; + padding-left: 16px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.assistant-question-item { + margin: 0; +} + +.assistant-command-buttons { + flex-direction: row; + flex-wrap: wrap; + gap: 8px; +} + +.assistant-command-button { + border: 1px solid var(--vscode-sideBar-border); + border-radius: 6px; + padding: 5px 10px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + font-size: 12px; + cursor: pointer; +} + +.assistant-command-button:hover { + background: var(--vscode-button-hoverBackground); +} + +.assistant-reference-item, +.assistant-citation-item, +.assistant-extra-item { + border: 1px solid var(--vscode-sideBar-border); + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-textCodeBlock-background) 74%, transparent); + padding: 8px 10px; +} + +.assistant-extra-item.is-complete { + border-color: #4f8c6a; +} + +.assistant-extra-title { + font-size: 11px; + font-weight: 600; + margin-bottom: 6px; +} + +.assistant-extra-meta, +.assistant-extra-description { + font-size: 11px; + line-height: 1.35; + color: var(--vscode-descriptionForeground); + margin-bottom: 6px; +} + +.assistant-extra-list { + margin: 0; + padding-left: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.assistant-extra-list-item { + margin: 0; + font-size: 12px; +} + +.assistant-extra-tree { + margin: 0; + padding: 8px; + border-radius: 4px; + border: 1px solid var(--vscode-sideBar-border); + background: var(--vscode-textCodeBlock-background); + overflow-x: auto; + font-family: var(--code-font-family); + font-size: var(--code-font-size); + line-height: 1.4; +} + +.assistant-reference-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; + font-size: 12px; +} + +.assistant-reference-link:hover { + text-decoration: underline; +} + +.assistant-citation-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + margin-bottom: 6px; +} + +.assistant-citation-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; + font-weight: 600; +} + +.assistant-citation-link:hover { + text-decoration: underline; +} + +.assistant-citation-license { + padding: 1px 6px; + border-radius: 10px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.assistant-citation-snippet { + margin: 0; + padding: 8px; + border-radius: 4px; + border: 1px solid var(--vscode-sideBar-border); + background: var(--vscode-textCodeBlock-background); + overflow-x: auto; +} + +.assistant-citation-snippet code { + font-family: var(--code-font-family); + font-size: var(--code-font-size); + white-space: pre; + background: transparent; + padding: 0; + border-radius: 0; +} + +.assistant-content p { + margin: 0 0 8px 0; +} + +.assistant-content p:last-child { + margin-bottom: 0; +} + +.assistant-content ul, +.assistant-content ol { + margin: 4px 0 8px 0; + padding-left: 24px; +} + +.assistant-content li { + margin-bottom: 2px; +} + +.assistant-content h1, +.assistant-content h2, +.assistant-content h3, +.assistant-content h4 { + margin: 16px 0 8px 0; + font-weight: 600; +} + +.assistant-content h1 { font-size: 20px; } +.assistant-content h2 { font-size: 17px; } +.assistant-content h3 { font-size: 15px; } +.assistant-content h4 { font-size: 13px; } + +.assistant-content a { + color: var(--vscode-textLink-foreground); + text-decoration: none; +} + +.assistant-content a:hover { + text-decoration: underline; +} + +.assistant-content blockquote { + margin: 4px 0 8px 0; + padding: 4px 12px; + border-left: 3px solid var(--vscode-sideBar-border); + color: var(--vscode-descriptionForeground); +} + +/* ---- Inline code ---- */ +.assistant-content code { + font-family: var(--code-font-family); + font-size: var(--code-font-size); + background: var(--vscode-textCodeBlock-background); + padding: 2px 4px; + border-radius: 3px; +} + +/* ---- Code blocks ---- */ +.assistant-content pre { + margin: 8px 0; + padding: 0; + border-radius: 6px; + overflow: hidden; + background: var(--vscode-textCodeBlock-background); + border: 1px solid var(--vscode-sideBar-border); + position: relative; +} + +.assistant-content pre code { + display: block; + padding: 12px 16px; + background: transparent; + border-radius: 0; + overflow-x: auto; + line-height: 1.45; +} + +.code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px 4px 12px; + background: #2d2d2d; + border-bottom: 1px solid var(--vscode-sideBar-border); + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.code-language { + text-transform: lowercase; +} + +.copy-code { + display: inline-flex; + align-items: center; + gap: 4px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 11px; + padding: 2px 6px; + cursor: pointer; +} + +.copy-code:hover { + background: var(--vscode-list-hoverBackground); + color: var(--vscode-foreground); +} + +/* ---- Streaming cursor ---- */ +.streaming .assistant-content::after { + content: "▍"; + display: inline; + color: var(--vscode-foreground); + animation: blink 0.7s step-end infinite; + font-weight: 300; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +/* ---- Composer ---- */ +.composer { + padding: 8px 16px 12px; + border-top: 1px solid var(--vscode-sideBar-border); + background: var(--vscode-editor-background); +} + +.composer-toolbar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + min-width: 0; +} + +.toolbar-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--vscode-sideBar-border); + border-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + padding: 0; + flex-shrink: 0; +} + +.toolbar-button:hover { + background: var(--vscode-list-hoverBackground); +} + +.toolbar-select { + height: 28px; + border: 1px solid var(--vscode-sideBar-border); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + padding: 0 8px; + font-family: var(--font-family); + font-size: 12px; + min-width: 96px; + max-width: 220px; +} + +.toolbar-select:disabled { + opacity: 0.75; + color: var(--vscode-descriptionForeground); +} + +.model-select { + flex: 1; + min-width: 110px; + max-width: none; +} + +.composer-inner { + display: flex; + align-items: flex-end; + gap: 6px; + border: 1px solid var(--vscode-input-border); + border-radius: 8px; + background: var(--vscode-input-background); + padding: 4px 4px 4px 12px; +} + +.composer-inner:focus-within { + border-color: var(--vscode-focusBorder); +} + +#prompt-input { + flex: 1; + resize: none; + min-height: 24px; + max-height: 200px; + padding: 4px 0; + border: none; + background: transparent; + color: var(--vscode-input-foreground); + font-family: var(--font-family); + font-size: var(--font-size); + line-height: 1.4; + outline: none; +} + +#prompt-input::placeholder { + color: var(--vscode-descriptionForeground); +} + +.send-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + cursor: pointer; + padding: 0; + flex-shrink: 0; +} + +.send-button:hover { + background: var(--vscode-button-hoverBackground); +} + +/* ---- Empty state ---- */ +.empty-state { + color: var(--vscode-descriptionForeground); + text-align: center; + margin: auto; + padding: 0 24px; + font-size: 13px; +} + +.empty-state .copilot-logo { + margin-bottom: 12px; +} + +/* ---- Mobile responsive ---- */ +@media (max-width: 640px) { + .conversation-panel { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 100; + transform: translateX(-100%); + transition: transform 0.2s ease; + box-shadow: none; + width: 280px; + } + + .conversation-panel.open { + transform: translateX(0); + box-shadow: 4px 0 16px rgba(0, 0, 0, 0.4); + } + + .sidebar-toggle { + display: inline-flex; + } + + .messages { + padding: 12px 12px; + } + + .composer { + padding: 6px 8px 8px; + padding-bottom: calc(8px + var(--safe-bottom)); + } + + .composer-toolbar { + gap: 4px; + overflow-x: auto; + padding-bottom: 2px; + } + + .filter-row { + gap: 4px; + } + + .filter-clear { + padding: 0 6px; + } + + .toolbar-select { + min-width: 90px; + } + + .chat-avatar { + width: 20px; + height: 20px; + } + + .chat-avatar svg { + width: 14px; + height: 14px; + } +} + +/* ---- Overlay for mobile sidebar ---- */ +.sidebar-overlay { + position: fixed; + inset: 0; + z-index: 99; + background: rgba(0, 0, 0, 0.5); + display: none; +} + +.sidebar-overlay.visible { + display: block; +} diff --git a/pwa/icons/copilot.png b/pwa/icons/copilot.png new file mode 100644 index 0000000000000000000000000000000000000000..e71958c94738d3837cb1fb2bc6082ecbc96a5a01 GIT binary patch literal 7435 zcmW+*2{hE-7k}R|7+V-imM~+_lC2_H#@L2m*-NsGJt14!m$467QcAKfl_DV_+l(bd zSyB;YFZ;eUG5G6$&U@$Gd*A2XbI&{X+;`u-i6%z+ER1}N000&PoQ@d)&@l-C1l{ps z<5%i>yfFCSto#9B;`lcq@Fa)lAH?5G9}6n_PAwcWa5t^XS^!j~F;N|80XS7)prd6G z1g+$LO1tv)NvHjM_78(hzvtXBGYB8y686x~8mc*Rd#du#a5N(xAD+$ zHInli)*Z-g?ahQ+v%SZw%h;yhtugf0UtScmZtYEd^!SjIV4o(;yTLaXx=67iosTp= z?=^bcm{{kDoL1u%nRt=f<%0il(dEN}!U&s0J$csL0;&OfLXY?%wa7zB6hr8t;8tqbMYln8yI zvFM_THu^C$EuQ%-6_1O>%5yW4*^WtrSZpQ4NQhU6I4g=pUlR*~Oybi<53CBOy68d> zza8a-Zau#7z=tR>668c#^QtNTG!Sabg=7!1i>B6^mWTobbx^<%o}xs6{WN|L@_W` zMMf#JmCzTwX~>L?H(tJnD;UT42on0%{l7?@Unrdk-SKReiDFNtFPNKaw`E$@7B-67 zq3?^W4svIM71$nnv>k?jW=@17kwHQ*$r(*4noN>N>{)GnI<%ItT9*ejz&lFopfBVu zsOFzfu@hTYgb!?J+}yW$bhjRea|>)K$W7(~`EiNV`^1D`=rtDn{Hi}AaptpW*SQH- z?hp|W(t3hV(>E)X`F_lJ>@zzAp>MmLlC>;S5#zRfU+%JY9}`A=$e3uHvr~wjyp1%O zAP1v6?0;WzTu)X>dIQ7ENI+BN2$j>|Yy=Wzltl3Vzi1{0p(6HA1Q{g~l>Uj>r;-sW zXb{4l_9ymU(EMrkdX(%?1DLOP@gEi!hF`_lUoW`=4TKLp?(ILJi4=LPj$p4pEjxH! z0`GcNC?&rm$(?@{_g~F4H2;@s^FHAol&Bi;wki)}EeN=`<`HQ4ZD%}3%1}L+_Y<<* zL_4d~k3ClVasyFt($rIT!Et)^b9H%ndG{;pz}XhnnQMb?l_f2jN#DQg)6E@^SBsu4 zwNCrzgXar*(Qk)t1Xn6R_|6Fp`E7pXmIu~>t0j8jOS`$-9(N!J7l(J%t1a7lV7&ZH zeuO@lV0&3s_8Sq%FSsQv$p^U1S{{YTs!xze!e@Zol!6$1KcZQg3x28unRqsX)6)?2 zF$ON5W@e^N|C08b8C#oeP_leDZs%QWlSd1^ zougUgCgjKZ9$ou*Bhq)n@JUq~0|K-5S7e*lY22Gi#T8T3aJE?P4O8wg;<5=5CdpqaQzb=H$ z6LM4zi#)%|e(1O*iMIyxdrUvW$K++M>Dx1q?W$tWL6bT{se5;{z42TgEZD1X)SCul zec`yO2qk&td(m`-`d%NoD&GnMWTZeMXFaul*REhg8LVzZwI8A8G$}DU%&H!*$^}fm z(-1FgB=82$eTl(kk_32=7RHECHXySg@Rd>1=TG`9lS_5#8oqjPa4xlEi&?nX4_kR3 zbUdOIPc^c@Cs&jwh@V7CYw9ppAY##S!m+N+r>V<;2{N6ijSxVH5t zry8%0=jLVKzKDj^bB_r^6;OQ4k~?D%-1W}vUG6OflIBj*sqjN0+_12PPy{374OhVF zl3msgCvaEfrB*{jDyes}tHkxpW)(aWff{#x-y7L)UsHHSkaf~0Nr*dG)8lhLUC_D}olpIh5-TMJJ{Z?HPG_eO1K!(n1|CrzBsPnt5Ysa7TtqPm_9TU`*39gDNk=?HOc@CE* zfgtE8{D35iSv}1e<0XATvQTlPKCJlCGUre!k<@9C0_J5-U1Cs)QxkpJqBsdGak9C@4hpj z%fDi3%)&4QqBj1BACS*Ni0a+QgRvmJNHAPa)Z#n{9wzT=T2xB0tt)zSXj7_S1wW4} z;JCr>5$I*ygWSA}DOq4~dY?&L<8>r|Ik+tm2R%?DpgfMc_r@C@+Ji|A{rb`JKa)z* z)9*_>WMdm2j*a9I3}9@|yCmWU&8pFooNjNv*BO|(l5xs@Ck`suC&j1j_$U^X2GPp^ zr*?v`AjT5*Yt^snw@?T;tbUH4udVqpe&2;|LCGP+WrJG?`0aMNw_KT;a= zf--^-z}0}1l%_?hw`8_ga~k->IU;m5Nn~2Qv-yA|Yk@RmKC6Ag#Vt#*I8u^#L-<#J ze*5lyJoRD%NyQLEe>~(*;?YLz_?Wm>gh_7rq|;SA^ywwUvphX8g;koven+&jq2N6{ z{*tRc?CfbM<7x*VdkN4$Jy`=d9mF}Idg~vL*W&NfQma{&Q3+x7pWE{Y{TG z+x^svgNVY1a+~Oc0Up2NX!eAAh~%mbY5KJ6lUC3?!YX0yRUvVzOmqu-P38@esCg=l ze&C#35fhYD6Tdgk64)Niw6^b zikELW6K8k-#G$+(nL82iY<(?zpWHTlDJaWBfEJ6eZzmL8$SJe8X9~6@JD8%pY;HQ2)xqW zBRPrRIm=80tvrtb$&JW&k}!&A-byp7cK!66>HSJ=!pjdNhez8qzwaZO{_ZxW@R3^2 zvCDUQZ*A>X!7T2{MxN8uJW4}elR!Dv9Z`lNdr0}}ZsDe^)X-8!ylvF^I+k{tw4hVN zR`KDaap@y!o92gzJDqPc7`!X(z@Btv)XnURlCq`gA*&C~H#14p*CTYpM2~j0DEq_B zOe9a8@(VK%Hd_G)%tz5lV{DD*(#frY+Y+gy$9dUpo#!08iu<`W>~eV%({CLI`;5as zm7Un*bi{AEPR{T_$zLLC_J?o`9QB0Kr2SpNaF7)Xo}8yT;Bn2z4>Q&z(~`i>_m`P} zn9^tM-8~gU|1Eq)ddFKx(^R%SZ6B{wbw)(t4>VABUFtvlz+7)4sxN zi$^y<{dz4-MhKb-{-pAEr#xH1ptI7@uRgv~|yX|IZjA5207<$>GtPVAbrfR$b^ zJX=hoBT&PL`k~k7hvtcs(gL-urCv(j1f9jP9{zQCQC!m!jr=dc?9L?m>EbQ7@T;4N zv*#k@%OcMg=nSi_^UmxAELUchX&rW$4=#Ihr^$EPTk7Y{t^U9z@sQ9K>}0o4v&fd( zOwgsYP>cu?0Y3X)$|1?ZqU@ND_FpJ0dT`Iw46pm=4jagC5)G4#{Si&<1o7kv0{t&- zE+j<_COnoFJK-7UL0+@_yUFyLb}$Gd!QVg^1kg1 zNb!BzqDT%JxHy@O60T}2g_kdw2_Tb$T*Jx15GH#L``-h_&(~jvtcMd5(xWVky^?pRrXY&IbO>hDXh9549I>fYC-z*#}h$F^?en+xLEQy#sv zk#BEJ-~XVOA;pJXw#|cHBHgn$R(~#pOG-yvW&k&eV5Ugri%_=T0m~IQp0n=KQG;ss zH@l(fCtmvkojg0`x3b=IetESxQaqmT%}~FrF8`<1=BV^~@a~?^ic(Lwk*@bx?U#q~ zQ!^1Gn*mQaL4tPF?8#|Vi(EI6osnLOp{gykk}j5Y>D=fkXU`}pjPMEQO6}Wc{OF3w z`QK8B{w`$PBPVh8>FGBt5JkurZeLd*EXsO9W8ww9kMXI(o?B;PDFi1wa(^EJ)4+1h zDH5rY>ZRh>pCyX7P7#ego1)GCvBU)O@$!0WbQGBuG_yCB`QrcXdnm#M2j9A;IHAWhDOcFQ*n2J)-V;j@P(zV1b(yJ(0tOepr#@`%=5wbhU1LbJI;(m47SZRH-(i6r~_=rP&Ix9{rj_8Tl&}RVc0DBY8)KJ?I-x{6)Fk*c6Liqh%FD#Dv zV~qqjn3D5kBh>CemB}@)Ugb0Ra;eRYq_)uRecvNF{PKx>|+_L+VY88vm1jJvGaQyDX5%Xr?;-S>6uVX0gy zCs>h6rp7e{&wWy^FH2@tD+)JWlij`;RKLhvyq=fDc>Cwthm-4YQb9nYcZWbJ&k+r@~d`sFqLWLZ`r)q5?^eWyT6>YCDSrp06H7FMU9k?TxZ%r*%X zHE_YR&UwbU5weS)#NkDT5%;3bb$Fpy>1qZ*xx4eKAD6{!Eu3>(RyuWMT zJvf&}yj;mDR9BUnpnBeP<%Qd4g&E_F6{FmdIs?D;$<~-)-dR`1L|*+igj3B^ zITUVA#zajyHTsP?MqYQ>3w=Z{#`N(t^!R$uag@Ws4)ay$0&>+O9Qzucm?PSX&a_Xw z%>H&tHc&%V_K^M^97wX)ml)B%S1^f>R`%K0wcBYQ6@h7tVS(kr^-wKmtT&FC4gaI_4R7~a% z+S&GaE4J-=$2;_{QbRWniu$hxvIAe*qbdOWm%yeqC^zz*TRImPp+aSumS=IB4hVvp zaX~Zs_LzWHw=k{namvS~y(V?u9hR;PATxU~g@u6(1hvFSz&A(LpT_E*gbMW|6i?4$ z{yaPX{R7hp!sm%%-H5_uu>EC$V*`!=P0-lGXoqzIUh6@)`;uaFwCWAV8s$kPonY@D zqB8oY&+f&v!9dssjQNk{Rp+Up{#Sokl4*+RHsRL>VmO>$LfQ#LkrwZlR!qGCS|8Q- zml@Lz3(CQEhlm_`z~b6H7m^*_&3=x?({vXMI6~~pV#lW+)(jWFFu>=%+i&L-JkUA~ zftFy%s>Q7MgZ0twtrdq>c{ZB*3^tm0h9&pjc`e{FZdR;WDh$*&2AH}(TSVYFgGShd zCr>T4@%k)yhwbOubPcxIFca4k<$D^Q>P4Dd2ZW_?DA?T`0c`q?>k8zH1fEzaL_LY; zm-o+i5Aw#YPqe|JLo-ud1O4Obm*lIqfy_sl zxwbE5qc5C#=Ssa?R1JU_>~SFVKAaIwCPsN8;5m9Vtg$KtM)i zeqAWnX|uFvb?rB02H&NB1zT)bbHp1j*`A+^uq zWDTM(BYs0!-ZNY#%*)ZS;?L}z5~jP|>@;q+?M*TD;PPxktNrg3Rke z-Ew4K`sb8nrjgU4w0TkN@H3hkyhFmD+`sZyF7EL!A_=A1Q3eI&@i|fMiMq1tuJ~Ek z=r0d)6fQ87Rgem~A;3(#>2Q8HS(hzfdE^n_UaHqvefo`v^2WpXQ8u8U#Ir?Z*l=P>ll-vg0_3R^VxRcIhmQ3)}f!?-4JUNopjA$=XZ)P zeEwQt4#Rt4^lnyZ*+ZML&?|)j5Gd^DT2FV-DnMWC+V7#*KYHSB2-sjK&f^-ZGhZUJ z&CzJ!uUk3*Y(cTJMZ5Kyy{_(va4ml+hW3(ioVsh$)3!#0iR8a*2^@DhMKG<%B{!FSBDYBaxEdOs_^VgmT! zdpez24WZPn&o@utc`DEt;ey(C2QEH_0O%6n$;bBPZ~zQD3YyN@ppl>jCU6`^#hpsF zf&eFk1buv(KG*+2gOZ1ip(g>^|&;f7gHQ8%fIO?=Q*U&#Ff2)ln zz(5(J!zxA};hUyI*BNQ9)++r^*R*6Jv+Iu7G`mp^?4;Jjr0wF3=CmhlEP3y3TX;3s zjR5#9Pr8xPP9F?bC{fcZmD^&CTlazB_sBOroeq2ALI1 zE?@eQItY1Y4aw%`s%7vJDW07e-@geApJ}?W-h>Qz@u5O~!(PvEm*QUBI5-`}yAyLh zg01QQTo%@z?(M-O%fvrkY=#BTyQzcV#Xkwz?R2K1q*zgRcjA*^^F^>>KeSOGLT zy&Kg?LOF9Wl6fTIt@jWtkuscw(2)ZN_z@I^;lCrD3{&Sjx&hQIKxJwGRbuE6ac|IE05 zi8q1~(=Mq%z8+`G8$_R0=Jfp7S7~DOxsSwCTC602Y;iLU5o0M}Y(R-7upjb=w_J+x zKFLUMy20x3Tmyv=*s$b8@^)-!c9SPBv|Xg5C3D=SxzA1?dqN<*rkQ$ySv5T>>ZrV5 z1 literal 0 HcmV?d00001 diff --git a/pwa/index.html b/pwa/index.html new file mode 100644 index 0000000000..4f7d18103b --- /dev/null +++ b/pwa/index.html @@ -0,0 +1,99 @@ + + + + + + + Copilot Chat + + + + + + +
+ + +
+
+ + Copilot +
Connecting
+
+ + + +
+ +
+
+ + + + + +
+
+ + +
+
+
+
+ + + + + + + + + diff --git a/pwa/js/app.js b/pwa/js/app.js new file mode 100644 index 0000000000..464ab6a35c --- /dev/null +++ b/pwa/js/app.js @@ -0,0 +1,844 @@ +(() => { + const statusEl = document.getElementById('connection-status'); + const bannerEl = document.getElementById('connection-banner'); + const messagesEl = document.getElementById('messages'); + const conversationListEl = document.getElementById('conversation-list'); + const conversationPanelEl = document.getElementById('conversation-panel'); + const toggleConversationsEl = document.getElementById('toggle-conversations'); + const refreshConversationsEl = document.getElementById('refresh-conversations'); + const newChatEl = document.getElementById('new-chat'); + const composerEl = document.getElementById('composer'); + const modeSelectorEl = document.getElementById('mode-selector'); + const modelSelectorEl = document.getElementById('model-selector'); + const chatTitleEl = document.getElementById('chat-title'); + const attachFileEl = document.getElementById('attach-file'); + const attachSelectionEl = document.getElementById('attach-selection'); + const openModelPickerEl = document.getElementById('open-model-picker'); + const promptInputEl = document.getElementById('prompt-input'); + const conversationSearchEl = document.getElementById('conversation-search'); + const providerFilterEl = document.getElementById('provider-filter'); + const statusFilterEl = document.getElementById('status-filter'); + const clearFiltersEl = document.getElementById('clear-filters'); + + const FILTER_REFRESH_DEBOUNCE_MS = 180; + const DEFAULT_CHAT_TITLE = 'Copilot'; + + // Create overlay for mobile sidebar + const sidebarOverlay = document.createElement('div'); + sidebarOverlay.className = 'sidebar-overlay'; + document.getElementById('app').appendChild(sidebarOverlay); + + const renderer = new window.ChatRenderer(messagesEl); + + const state = { + client: undefined, + currentConversationId: undefined, + conversations: [], + optimisticByConversation: new Map(), + pendingOptimisticContent: undefined, + isComposingNewConversation: false, + conversationFilter: { + search: '', + provider: '', + status: '', + }, + filterRefreshTimer: undefined, + uiState: { + modes: [], + selectedModeId: undefined, + models: [], + selectedModelId: undefined, + }, + }; + + renderer.setCommandRunner(command => { + if (!state.client || !command || typeof command.commandId !== 'string') { + return; + } + + state.client.send({ + type: 'ui:command', + commandId: command.commandId, + args: Array.isArray(command.args) ? command.args : undefined, + }); + }); + + function normalizeEndpoint(rawWsUrl, rawToken) { + const wsUrl = typeof rawWsUrl === 'string' ? rawWsUrl.trim() : ''; + const token = typeof rawToken === 'string' ? rawToken.trim() : ''; + if (!wsUrl || !token) { + return undefined; + } + return { wsUrl, token }; + } + + function parseQueryParams() { + const params = new URLSearchParams(window.location.search); + const queryEndpoint = normalizeEndpoint(params.get('ws'), params.get('token')); + if (queryEndpoint) { + window.localStorage.setItem('sidecar.wsUrl', queryEndpoint.wsUrl); + window.localStorage.setItem('sidecar.token', queryEndpoint.token); + return queryEndpoint; + } + + const storedEndpoint = normalizeEndpoint( + window.localStorage.getItem('sidecar.wsUrl'), + window.localStorage.getItem('sidecar.token') + ); + if (storedEndpoint) { + return storedEndpoint; + } + + const pairingInput = window.prompt('Paste the Sidecar pairing URL from VS Code'); + if (!pairingInput) { + return undefined; + } + + try { + const pairingUrl = new URL(pairingInput); + const parsedEndpoint = normalizeEndpoint(pairingUrl.searchParams.get('ws'), pairingUrl.searchParams.get('token')); + if (parsedEndpoint) { + window.localStorage.setItem('sidecar.wsUrl', parsedEndpoint.wsUrl); + window.localStorage.setItem('sidecar.token', parsedEndpoint.token); + return parsedEndpoint; + } + } catch { + // No-op: invalid URL entered. + } + + return undefined; + } + + function formatRelativeTime(timestamp) { + if (!timestamp) { + return 'Unknown'; + } + const deltaMs = Date.now() - timestamp; + const deltaSec = Math.floor(deltaMs / 1000); + if (deltaSec < 60) { + return 'Now'; + } + const deltaMin = Math.floor(deltaSec / 60); + if (deltaMin < 60) { + return `${deltaMin}m ago`; + } + const deltaHours = Math.floor(deltaMin / 60); + if (deltaHours < 24) { + return `${deltaHours}h ago`; + } + const deltaDays = Math.floor(deltaHours / 24); + return `${deltaDays}d ago`; + } + + function connectionStatusText(stateName, detail) { + switch (stateName) { + case 'connected': + return 'Connected'; + case 'reconnecting': { + if (detail && typeof detail.delayMs === 'number') { + return `Reconnecting (${Math.ceil(detail.delayMs / 1000)}s)`; + } + return 'Reconnecting'; + } + case 'disconnected': + return 'Disconnected'; + default: + return 'Connecting'; + } + } + + function setStatus(stateName, detail) { + statusEl.textContent = connectionStatusText(stateName, detail); + statusEl.classList.remove('status-connecting', 'status-connected', 'status-reconnecting', 'status-disconnected'); + switch (stateName) { + case 'connected': + statusEl.classList.add('status-connected'); + break; + case 'reconnecting': + statusEl.classList.add('status-reconnecting'); + break; + case 'disconnected': + statusEl.classList.add('status-disconnected'); + break; + default: + statusEl.classList.add('status-connecting'); + } + } + + function showBanner(text, timeoutMs = 1400) { + bannerEl.textContent = text; + bannerEl.classList.remove('hidden'); + window.setTimeout(() => { + bannerEl.classList.add('hidden'); + }, timeoutMs); + } + + function normalizeFilterValue(value) { + if (typeof value !== 'string') { + return ''; + } + + return value.trim().toLowerCase(); + } + + function hasActiveConversationFilters() { + return state.conversationFilter.search.length > 0 + || state.conversationFilter.provider.length > 0 + || state.conversationFilter.status.length > 0; + } + + function updateFilterClearButtonState() { + if (!clearFiltersEl) { + return; + } + + const hasFilters = hasActiveConversationFilters(); + clearFiltersEl.classList.toggle('active', hasFilters); + clearFiltersEl.disabled = !hasFilters; + } + + function syncConversationFiltersFromInputs() { + state.conversationFilter.search = normalizeFilterValue(conversationSearchEl?.value); + state.conversationFilter.provider = normalizeFilterValue(providerFilterEl?.value); + state.conversationFilter.status = normalizeFilterValue(statusFilterEl?.value); + updateFilterClearButtonState(); + } + + function buildConversationFilterPayload() { + const filter = {}; + if (state.conversationFilter.provider) { + filter.providers = [state.conversationFilter.provider]; + } + if (state.conversationFilter.status) { + filter.statuses = [state.conversationFilter.status]; + } + if (state.conversationFilter.search) { + filter.search = state.conversationFilter.search; + } + + return Object.keys(filter).length > 0 ? filter : undefined; + } + + function requestConversationList() { + if (!state.client) { + return; + } + + state.client.requestConversationList(buildConversationFilterPayload()); + } + + function scheduleConversationListRefresh() { + if (state.filterRefreshTimer !== undefined) { + window.clearTimeout(state.filterRefreshTimer); + } + + state.filterRefreshTimer = window.setTimeout(() => { + state.filterRefreshTimer = undefined; + requestConversationList(); + }, FILTER_REFRESH_DEBOUNCE_MS); + } + + function ensureConversation(conversationId, fallbackTitle) { + let conversation = state.conversations.find(item => item.id === conversationId); + if (conversation) { + return conversation; + } + conversation = { + id: conversationId, + title: fallbackTitle || 'Untitled conversation', + lastUpdated: Date.now(), + }; + state.conversations.unshift(conversation); + renderConversationList(); + return conversation; + } + + function updateConversation(conversationId, fields = {}) { + const conversation = ensureConversation(conversationId, fields.title || 'Untitled conversation'); + Object.assign(conversation, fields); + state.conversations.sort((a, b) => (b.lastUpdated || 0) - (a.lastUpdated || 0)); + renderConversationList(); + } + + function renderConversationList() { + conversationListEl.innerHTML = ''; + if (state.conversations.length === 0) { + const empty = document.createElement('li'); + empty.className = 'empty-state'; + empty.textContent = hasActiveConversationFilters() + ? 'No conversations match current filters.' + : 'No conversations yet.'; + conversationListEl.appendChild(empty); + return; + } + + for (const conversation of state.conversations) { + const item = document.createElement('li'); + item.className = `conversation-item${conversation.id === state.currentConversationId ? ' active' : ''}`; + item.tabIndex = 0; + item.setAttribute('role', 'button'); + item.dataset.conversationId = conversation.id; + + const title = document.createElement('div'); + title.className = 'conversation-title'; + title.textContent = conversation.title || 'Untitled conversation'; + item.appendChild(title); + + const time = document.createElement('div'); + time.className = 'conversation-time'; + time.textContent = formatRelativeTime(conversation.lastUpdated); + item.appendChild(time); + + item.addEventListener('click', () => { + selectConversation(conversation.id); + }); + item.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + selectConversation(conversation.id); + } + }); + + conversationListEl.appendChild(item); + } + } + + function renderSelector(selectEl, options, selectedId, fallbackLabel) { + if (!selectEl) { + return; + } + + selectEl.innerHTML = ''; + if (!Array.isArray(options) || options.length === 0) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = fallbackLabel; + selectEl.appendChild(option); + selectEl.disabled = true; + return; + } + + for (const item of options) { + const option = document.createElement('option'); + option.value = item.id; + option.textContent = item.label; + selectEl.appendChild(option); + } + + const resolvedValue = selectedId && options.some(item => item.id === selectedId) + ? selectedId + : options[0].id; + selectEl.value = resolvedValue; + selectEl.disabled = false; + } + + function updateChatTitle(workspaceLabel) { + if (!chatTitleEl) { + return; + } + + const label = typeof workspaceLabel === 'string' && workspaceLabel.trim().length > 0 + ? workspaceLabel.trim() + : DEFAULT_CHAT_TITLE; + chatTitleEl.textContent = label; + chatTitleEl.title = label; + } + + function handleUiState(message) { + state.uiState = { + modes: Array.isArray(message.modes) ? message.modes : [], + selectedModeId: typeof message.selectedModeId === 'string' ? message.selectedModeId : undefined, + models: Array.isArray(message.models) ? message.models : [], + selectedModelId: typeof message.selectedModelId === 'string' ? message.selectedModelId : undefined, + }; + + renderSelector(modeSelectorEl, state.uiState.modes, state.uiState.selectedModeId, 'Mode'); + renderSelector(modelSelectorEl, state.uiState.models, state.uiState.selectedModelId, 'Model'); + updateChatTitle(message.workspaceLabel); + } + + function queueOptimisticMessage(conversationId, content) { + const queue = state.optimisticByConversation.get(conversationId) || []; + queue.push(content); + state.optimisticByConversation.set(conversationId, queue); + } + + function consumeOptimisticMessage(conversationId, content) { + const queue = state.optimisticByConversation.get(conversationId); + if (!queue || queue.length === 0) { + return false; + } + if (queue[0] === content) { + queue.shift(); + if (queue.length === 0) { + state.optimisticByConversation.delete(conversationId); + } + return true; + } + return false; + } + + function selectConversation(conversationId) { + state.currentConversationId = conversationId; + state.isComposingNewConversation = false; + renderConversationList(); + renderer.showEmptyState('Loading conversation...'); + if (state.client) { + state.client.send({ type: 'conversation:select', conversationId }); + } + closeSidebar(); + } + + function handleConversationList(message) { + if (!Array.isArray(message.conversations)) { + return; + } + + state.conversations = message.conversations + .map(conversation => ({ + id: conversation.id, + title: conversation.title, + lastUpdated: conversation.lastUpdated, + })) + .sort((a, b) => (b.lastUpdated || 0) - (a.lastUpdated || 0)); + + renderConversationList(); + + if (!state.currentConversationId && state.conversations.length === 0) { + renderer.showEmptyState(hasActiveConversationFilters() + ? 'No conversations match your filters. Clear filters to view all sessions.' + : 'No conversations yet. Start a new chat below.'); + return; + } + + if (!state.currentConversationId && state.conversations.length > 0 && !state.isComposingNewConversation) { + selectConversation(state.conversations[0].id); + } + } + + function handleConversationHistory(message) { + if (!message || message.conversationId !== state.currentConversationId) { + return; + } + renderer.renderHistory(message.turns); + } + + function handleIncomingUserTurn(message) { + if (!state.currentConversationId) { + state.currentConversationId = message.conversationId; + state.isComposingNewConversation = false; + if (state.pendingOptimisticContent) { + queueOptimisticMessage(message.conversationId, state.pendingOptimisticContent); + state.pendingOptimisticContent = undefined; + } + renderConversationList(); + } + + updateConversation(message.conversationId, { + lastUpdated: Date.now(), + }); + + if (message.conversationId !== state.currentConversationId) { + return; + } + if (consumeOptimisticMessage(message.conversationId, message.content)) { + return; + } + renderer.appendUserTurn(message.content); + } + + function handleIncomingAssistantStart(message) { + if (!state.currentConversationId && state.isComposingNewConversation) { + state.currentConversationId = message.conversationId; + state.isComposingNewConversation = false; + renderConversationList(); + } + updateConversation(message.conversationId, { lastUpdated: Date.now() }); + if (message.conversationId === state.currentConversationId) { + renderer.startAssistantTurn(message.turnId); + } + } + + function handleIncomingAssistantChunk(message) { + if (message.conversationId === state.currentConversationId) { + renderer.appendAssistantChunk(message.turnId, message.content); + } + } + + function handleIncomingAssistantReference(message) { + if (message.conversationId === state.currentConversationId) { + renderer.appendAssistantReference(message.turnId, { + label: message.label, + uri: message.uri, + }); + } + } + + function handleIncomingAssistantCodeCitation(message) { + if (message.conversationId === state.currentConversationId) { + renderer.appendAssistantCodeCitation(message.turnId, { + uri: message.uri, + license: message.license, + snippet: message.snippet, + }); + } + } + + function handleIncomingAssistantStatus(message) { + if (message.conversationId === state.currentConversationId) { + renderer.appendAssistantStatus(message.turnId, { + kind: message.kind, + content: message.content, + }); + } + } + + function handleIncomingAssistantToolInvocation(message) { + if (message.conversationId === state.currentConversationId) { + renderer.appendAssistantToolInvocation(message.turnId, { + toolName: message.toolName, + toolCallId: message.toolCallId, + message: message.message, + isError: message.isError, + isComplete: message.isComplete, + }); + } + } + + function handleIncomingAssistantConfirmation(message) { + if (message.conversationId === state.currentConversationId) { + renderer.appendAssistantConfirmation(message.turnId, { + title: message.title, + message: message.message, + buttons: message.buttons, + }); + } + } + + function handleIncomingAssistantQuestions(message) { + if (message.conversationId === state.currentConversationId) { + renderer.appendAssistantQuestions(message.turnId, { + allowSkip: message.allowSkip, + questions: message.questions, + }); + } + } + + function handleIncomingAssistantCommandButton(message) { + if (message.conversationId === state.currentConversationId) { + renderer.appendAssistantCommandButton(message.turnId, { + commandId: message.commandId, + title: message.title, + args: message.args, + }); + } + } + + function handleIncomingAssistantExtra(message) { + if (message.conversationId === state.currentConversationId) { + renderer.appendAssistantExtra(message.turnId, message.extra); + } + } + + function handleIncomingAssistantComplete(message) { + updateConversation(message.conversationId, { lastUpdated: Date.now() }); + if (message.conversationId === state.currentConversationId) { + renderer.completeAssistantTurn(message.turnId); + } + } + + function handleBridgeMessage(message) { + if (!message || typeof message.type !== 'string') { + return; + } + + switch (message.type) { + case 'conversation:list': + handleConversationList(message); + break; + case 'ui:state': + handleUiState(message); + break; + case 'conversation:history': + handleConversationHistory(message); + break; + case 'turn:user': + handleIncomingUserTurn(message); + break; + case 'turn:start': + handleIncomingAssistantStart(message); + break; + case 'turn:chunk': + handleIncomingAssistantChunk(message); + break; + case 'turn:reference': + handleIncomingAssistantReference(message); + break; + case 'turn:codeCitation': + handleIncomingAssistantCodeCitation(message); + break; + case 'turn:status': + handleIncomingAssistantStatus(message); + break; + case 'turn:tool': + handleIncomingAssistantToolInvocation(message); + break; + case 'turn:confirmation': + handleIncomingAssistantConfirmation(message); + break; + case 'turn:questions': + handleIncomingAssistantQuestions(message); + break; + case 'turn:button': + handleIncomingAssistantCommandButton(message); + break; + case 'turn:extra': + handleIncomingAssistantExtra(message); + break; + case 'turn:complete': + handleIncomingAssistantComplete(message); + break; + } + } + + function submitPrompt() { + if (!state.client) { + return; + } + + const content = promptInputEl.value.trim(); + if (!content) { + return; + } + + const conversationId = state.currentConversationId; + + renderer.appendUserTurn(content); + if (conversationId) { + queueOptimisticMessage(conversationId, content); + updateConversation(conversationId, { + lastUpdated: Date.now(), + }); + } else { + state.pendingOptimisticContent = content; + state.isComposingNewConversation = true; + } + state.client.send({ type: 'prompt:submit', content, conversationId }); + promptInputEl.value = ''; + promptInputEl.style.height = 'auto'; + } + + function isMobileEnterNewlinePreferred() { + if (typeof navigator.userAgentData?.mobile === 'boolean') { + return navigator.userAgentData.mobile; + } + + const userAgent = navigator.userAgent || ''; + return /(android|iphone|ipad|ipod|iemobile|opera mini|mobile)/i.test(userAgent); + } + + function initializeComposer() { + promptInputEl.addEventListener('input', () => { + promptInputEl.style.height = 'auto'; + promptInputEl.style.height = `${Math.min(promptInputEl.scrollHeight, 200)}px`; + }); + + // Desktop: Enter sends, Shift+Enter inserts newline. Mobile: Enter inserts newline. + promptInputEl.addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !isMobileEnterNewlinePreferred()) { + event.preventDefault(); + submitPrompt(); + } + }); + + composerEl.addEventListener('submit', event => { + event.preventDefault(); + submitPrompt(); + }); + + if (modeSelectorEl) { + modeSelectorEl.addEventListener('change', () => { + if (!state.client || !modeSelectorEl.value) { + return; + } + state.client.send({ type: 'ui:mode:set', modeId: modeSelectorEl.value }); + }); + } + + if (modelSelectorEl) { + modelSelectorEl.addEventListener('change', () => { + if (!state.client || !modelSelectorEl.value) { + return; + } + state.client.send({ type: 'ui:model:set', modelId: modelSelectorEl.value }); + }); + } + + if (attachFileEl) { + attachFileEl.addEventListener('click', () => { + state.client?.send({ type: 'ui:command', commandId: 'workbench.action.chat.attachFile' }); + }); + } + + if (attachSelectionEl) { + attachSelectionEl.addEventListener('click', () => { + state.client?.send({ type: 'ui:command', commandId: 'workbench.action.chat.attachSelection' }); + }); + } + + if (openModelPickerEl) { + openModelPickerEl.addEventListener('click', () => { + state.client?.send({ type: 'ui:command', commandId: 'github.copilot.chat.openModelPicker' }); + }); + } + } + + function openSidebar() { + conversationPanelEl.classList.add('open'); + sidebarOverlay.classList.add('visible'); + } + + function closeSidebar() { + conversationPanelEl.classList.remove('open'); + sidebarOverlay.classList.remove('visible'); + } + + function initializeConversationPanel() { + toggleConversationsEl.addEventListener('click', () => { + if (conversationPanelEl.classList.contains('open')) { + closeSidebar(); + } else { + openSidebar(); + } + }); + sidebarOverlay.addEventListener('click', () => { + closeSidebar(); + }); + refreshConversationsEl.addEventListener('click', () => { + requestConversationList(); + }); + if (newChatEl) { + newChatEl.addEventListener('click', () => { + state.currentConversationId = undefined; + state.pendingOptimisticContent = undefined; + state.isComposingNewConversation = true; + renderConversationList(); + renderer.showEmptyState('Start a new conversation by typing a message.'); + promptInputEl.focus(); + closeSidebar(); + }); + } + } + + function initializeConversationFilters() { + syncConversationFiltersFromInputs(); + + if (conversationSearchEl) { + conversationSearchEl.addEventListener('input', () => { + syncConversationFiltersFromInputs(); + scheduleConversationListRefresh(); + }); + + conversationSearchEl.addEventListener('keydown', event => { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + syncConversationFiltersFromInputs(); + requestConversationList(); + }); + } + + if (providerFilterEl) { + providerFilterEl.addEventListener('change', () => { + syncConversationFiltersFromInputs(); + requestConversationList(); + }); + } + + if (statusFilterEl) { + statusFilterEl.addEventListener('change', () => { + syncConversationFiltersFromInputs(); + requestConversationList(); + }); + } + + if (clearFiltersEl) { + clearFiltersEl.addEventListener('click', () => { + if (conversationSearchEl) { + conversationSearchEl.value = ''; + } + if (providerFilterEl) { + providerFilterEl.value = ''; + } + if (statusFilterEl) { + statusFilterEl.value = ''; + } + + syncConversationFiltersFromInputs(); + requestConversationList(); + }); + } + + updateFilterClearButtonState(); + } + + function initializeServiceWorker() { + if (!('serviceWorker' in navigator)) { + return; + } + window.addEventListener('load', () => { + navigator.serviceWorker.register('./sw.js').catch(() => { + // Ignore registration failures in restricted environments. + }); + }); + } + + function initializeConnection(endpoint) { + state.client = new window.SidecarWebSocketClient(endpoint.wsUrl, endpoint.token); + + state.client.addEventListener('status', event => { + setStatus(event.detail.state, event.detail); + if (event.detail.state === 'connected' && event.detail.reconnected) { + showBanner('Reconnected'); + requestConversationList(); + } + }); + + state.client.addEventListener('open', () => { + requestConversationList(); + if (state.currentConversationId) { + state.client.send({ type: 'conversation:select', conversationId: state.currentConversationId }); + } + }); + + state.client.addEventListener('message', event => { + handleBridgeMessage(event.detail); + }); + + state.client.connect(); + } + + function bootstrap() { + initializeServiceWorker(); + initializeConversationPanel(); + initializeConversationFilters(); + initializeComposer(); + updateChatTitle(); + renderSelector(modeSelectorEl, [], undefined, 'Mode'); + renderSelector(modelSelectorEl, [], undefined, 'Model'); + + const endpoint = parseQueryParams(); + if (!endpoint) { + setStatus('disconnected'); + renderer.showEmptyState('Missing pairing details. Open from the QR code URL or paste a pairing URL.'); + return; + } + + initializeConnection(endpoint); + renderer.showEmptyState('Waiting for conversations...'); + } + + bootstrap(); +})(); diff --git a/pwa/js/chat-renderer.js b/pwa/js/chat-renderer.js new file mode 100644 index 0000000000..ec1c0bd248 --- /dev/null +++ b/pwa/js/chat-renderer.js @@ -0,0 +1,951 @@ +(() => { + function escapeHtml(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\"/g, '"') + .replace(/'/g, '''); + } + + const USER_AVATAR_SVG = ''; + const COPILOT_AVATAR_SVG = ''; + + class ChatRenderer { + constructor(container) { + this.container = container; + this.streamingTurns = new Map(); + this.historyCounter = 0; + this.commandRunner = undefined; + this.configureMarkdown(); + } + + setCommandRunner(commandRunner) { + this.commandRunner = commandRunner; + } + + configureMarkdown() { + if (window.marked) { + window.marked.setOptions({ + gfm: true, + breaks: false, + headerIds: false, + mangle: false, + highlight(code, lang) { + if (!window.hljs) { + return code; + } + if (lang && window.hljs.getLanguage(lang)) { + return window.hljs.highlight(code, { language: lang }).value; + } + return window.hljs.highlightAuto(code).value; + } + }); + } + } + + clear() { + this.container.innerHTML = ''; + this.streamingTurns.clear(); + } + + showEmptyState(text) { + this.clear(); + const state = document.createElement('div'); + state.className = 'empty-state'; + state.innerHTML = `
${escapeHtml(text)}
`; + this.container.appendChild(state); + } + + renderHistory(turns) { + this.clear(); + if (!Array.isArray(turns) || turns.length === 0) { + this.showEmptyState('No messages yet.'); + return; + } + + for (const turn of turns) { + if (turn.role === 'user') { + this.appendUserTurn(turn.content); + } else if (turn.role === 'assistant') { + this.appendAssistantMessage(turn.content, turn.artifacts, turn.toolLines); + } + } + } + + appendUserTurn(content) { + if (!content) { + return; + } + const row = this.createMessageRow('user'); + const textEl = document.createElement('div'); + textEl.className = 'user-text'; + textEl.textContent = content; + row.contentHost.appendChild(textEl); + this.container.appendChild(row.element); + this.scrollToBottom(); + } + + appendAssistantMessage(content, artifacts, toolLines) { + const hasContent = typeof content === 'string' && content.trim().length > 0; + const hasToolLines = Array.isArray(toolLines) && toolLines.length > 0; + const hasArtifacts = artifacts && typeof artifacts === 'object' + && ( + (Array.isArray(artifacts.statuses) && artifacts.statuses.length > 0) + || (Array.isArray(artifacts.tools) && artifacts.tools.length > 0) + || (Array.isArray(artifacts.confirmations) && artifacts.confirmations.length > 0) + || (Array.isArray(artifacts.questionCarousels) && artifacts.questionCarousels.length > 0) + || (Array.isArray(artifacts.commandButtons) && artifacts.commandButtons.length > 0) + || (Array.isArray(artifacts.extras) && artifacts.extras.length > 0) + || (Array.isArray(artifacts.references) && artifacts.references.length > 0) + || (Array.isArray(artifacts.codeCitations) && artifacts.codeCitations.length > 0) + ); + + if (!hasContent && !hasToolLines && !hasArtifacts) { + return; + } + + const turnId = `history-${this.historyCounter++}`; + this.startAssistantTurn(turnId); + if (hasToolLines) { + this.appendHistoryToolLines(turnId, toolLines); + } + if (hasContent) { + this.appendAssistantChunk(turnId, content); + } + if (hasArtifacts) { + this.appendAssistantArtifacts(turnId, artifacts); + } + this.completeAssistantTurn(turnId); + } + + startAssistantTurn(turnId) { + let entry = this.streamingTurns.get(turnId); + if (entry) { + return entry; + } + + const row = this.createMessageRow('assistant'); + row.element.classList.add('streaming'); + + const historyToolLinesHost = document.createElement('div'); + historyToolLinesHost.className = 'assistant-history-tool-lines'; + row.contentHost.appendChild(historyToolLinesHost); + + const markdownHost = document.createElement('div'); + markdownHost.className = 'assistant-markdown'; + row.contentHost.appendChild(markdownHost); + + const artifactsHost = document.createElement('div'); + artifactsHost.className = 'assistant-artifacts'; + row.contentHost.appendChild(artifactsHost); + + const statusHost = document.createElement('div'); + statusHost.className = 'assistant-status'; + artifactsHost.appendChild(statusHost); + + const toolHost = document.createElement('div'); + toolHost.className = 'assistant-tools'; + artifactsHost.appendChild(toolHost); + + const confirmationHost = document.createElement('div'); + confirmationHost.className = 'assistant-confirmations'; + artifactsHost.appendChild(confirmationHost); + + const questionsHost = document.createElement('div'); + questionsHost.className = 'assistant-questions'; + artifactsHost.appendChild(questionsHost); + + const commandButtonsHost = document.createElement('div'); + commandButtonsHost.className = 'assistant-command-buttons'; + artifactsHost.appendChild(commandButtonsHost); + + const extrasHost = document.createElement('div'); + extrasHost.className = 'assistant-extras'; + artifactsHost.appendChild(extrasHost); + + const referenceHost = document.createElement('div'); + referenceHost.className = 'assistant-references'; + artifactsHost.appendChild(referenceHost); + + const citationHost = document.createElement('div'); + citationHost.className = 'assistant-citations'; + artifactsHost.appendChild(citationHost); + + this.container.appendChild(row.element); + + entry = { + element: row.element, + contentHost: row.contentHost, + historyToolLinesHost, + markdownHost, + statusHost, + toolHost, + confirmationHost, + questionsHost, + commandButtonsHost, + extrasHost, + referenceHost, + citationHost, + markdown: '', + statusKeys: new Set(), + toolItems: new Map(), + confirmationKeys: new Set(), + questionKeys: new Set(), + commandButtonKeys: new Set(), + extraKeys: new Set(), + editExtraItems: new Map(), + referenceKeys: new Set(), + citationKeys: new Set() + }; + this.streamingTurns.set(turnId, entry); + this.scrollToBottom(); + return entry; + } + + appendHistoryToolLines(turnId, lines) { + if (!Array.isArray(lines) || lines.length === 0) { + return; + } + const entry = this.startAssistantTurn(turnId); + for (const line of lines) { + if (typeof line !== 'string' || !line.trim()) { + continue; + } + const item = document.createElement('div'); + item.className = 'history-tool-line'; + item.textContent = line.trim(); + entry.historyToolLinesHost.appendChild(item); + } + this.scrollToBottom(); + } + + appendAssistantChunk(turnId, chunk) { + if (!chunk) { + return; + } + + const entry = this.startAssistantTurn(turnId); + entry.markdown += chunk; + entry.markdownHost.innerHTML = this.renderMarkdown(entry.markdown); + this.decorateCodeBlocks(entry.markdownHost); + this.scrollToBottom(); + } + + appendAssistantReference(turnId, reference) { + if (!reference || (typeof reference.label !== 'string' && typeof reference.uri !== 'string')) { + return; + } + + const entry = this.startAssistantTurn(turnId); + const label = typeof reference.label === 'string' ? reference.label.trim() : ''; + const uri = typeof reference.uri === 'string' && reference.uri.trim().length > 0 ? reference.uri : ''; + if (!label && !uri) { + return; + } + + const key = `${label}::${uri}`; + if (entry.referenceKeys.has(key)) { + return; + } + entry.referenceKeys.add(key); + + const item = document.createElement('div'); + item.className = 'assistant-reference-item'; + + const content = document.createElement(uri ? 'a' : 'span'); + content.className = 'assistant-reference-link'; + content.textContent = label || uri; + if (uri) { + content.href = uri; + content.target = '_blank'; + content.rel = 'noopener noreferrer'; + } + + item.appendChild(content); + entry.referenceHost.appendChild(item); + this.scrollToBottom(); + } + + appendAssistantCodeCitation(turnId, citation) { + if (!citation || typeof citation.uri !== 'string') { + return; + } + + const entry = this.startAssistantTurn(turnId); + const license = typeof citation.license === 'string' ? citation.license : ''; + const snippet = typeof citation.snippet === 'string' ? citation.snippet : ''; + const key = `${citation.uri}::${license}::${snippet}`; + if (entry.citationKeys.has(key)) { + return; + } + entry.citationKeys.add(key); + + const item = document.createElement('div'); + item.className = 'assistant-citation-item'; + + const meta = document.createElement('div'); + meta.className = 'assistant-citation-meta'; + + const link = document.createElement('a'); + link.className = 'assistant-citation-link'; + link.href = citation.uri; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = 'Code citation'; + meta.appendChild(link); + + if (license) { + const badge = document.createElement('span'); + badge.className = 'assistant-citation-license'; + badge.textContent = license; + meta.appendChild(badge); + } + + item.appendChild(meta); + + if (snippet) { + const snippetHost = document.createElement('pre'); + snippetHost.className = 'assistant-citation-snippet'; + const code = document.createElement('code'); + code.textContent = snippet; + snippetHost.appendChild(code); + item.appendChild(snippetHost); + } + + entry.citationHost.appendChild(item); + this.scrollToBottom(); + } + + appendAssistantArtifacts(turnId, artifacts) { + if (!artifacts || typeof artifacts !== 'object') { + return; + } + + if (Array.isArray(artifacts.statuses)) { + for (const status of artifacts.statuses) { + this.appendAssistantStatus(turnId, status); + } + } + + if (Array.isArray(artifacts.tools)) { + for (const tool of artifacts.tools) { + this.appendAssistantToolInvocation(turnId, tool); + } + } + + if (Array.isArray(artifacts.confirmations)) { + for (const confirmation of artifacts.confirmations) { + this.appendAssistantConfirmation(turnId, confirmation); + } + } + + if (Array.isArray(artifacts.questionCarousels)) { + for (const questionCarousel of artifacts.questionCarousels) { + this.appendAssistantQuestions(turnId, questionCarousel); + } + } + + if (Array.isArray(artifacts.commandButtons)) { + for (const commandButton of artifacts.commandButtons) { + this.appendAssistantCommandButton(turnId, commandButton); + } + } + + if (Array.isArray(artifacts.extras)) { + for (const extra of artifacts.extras) { + this.appendAssistantExtra(turnId, extra); + } + } + + if (Array.isArray(artifacts.references)) { + for (const reference of artifacts.references) { + this.appendAssistantReference(turnId, reference); + } + } + + if (Array.isArray(artifacts.codeCitations)) { + for (const citation of artifacts.codeCitations) { + this.appendAssistantCodeCitation(turnId, citation); + } + } + } + + appendAssistantStatus(turnId, status) { + if (!status || typeof status.content !== 'string') { + return; + } + + const entry = this.startAssistantTurn(turnId); + const content = status.content.trim(); + if (!content) { + return; + } + + const kind = typeof status.kind === 'string' ? status.kind : 'progress'; + const key = `${kind}::${content}`; + if (entry.statusKeys.has(key)) { + return; + } + entry.statusKeys.add(key); + + const item = document.createElement('div'); + item.className = `assistant-status-item assistant-status-${kind}`; + + const kindLabel = document.createElement('span'); + kindLabel.className = 'assistant-status-kind'; + kindLabel.textContent = kind; + item.appendChild(kindLabel); + + const text = document.createElement('span'); + text.className = 'assistant-status-text'; + text.textContent = content; + item.appendChild(text); + + entry.statusHost.appendChild(item); + this.scrollToBottom(); + } + + appendAssistantToolInvocation(turnId, tool) { + if (!tool || typeof tool.toolCallId !== 'string' || typeof tool.toolName !== 'string') { + return; + } + + const entry = this.startAssistantTurn(turnId); + const toolCallId = tool.toolCallId.trim(); + const toolName = tool.toolName.trim(); + if (!toolCallId || !toolName) { + return; + } + + let item = entry.toolItems.get(toolCallId); + if (!item) { + item = document.createElement('div'); + item.className = 'assistant-tool-item'; + entry.toolItems.set(toolCallId, item); + entry.toolHost.appendChild(item); + } + + item.classList.toggle('is-error', Boolean(tool.isError)); + item.classList.toggle('is-complete', Boolean(tool.isComplete)); + + const statusText = tool.isError ? 'error' : tool.isComplete ? 'done' : 'running'; + const summary = typeof tool.message === 'string' && tool.message.trim().length > 0 + ? tool.message.trim() + : `${toolName} (${statusText})`; + + item.textContent = `${toolName}: ${summary}`; + this.scrollToBottom(); + } + + appendAssistantConfirmation(turnId, confirmation) { + if (!confirmation || (typeof confirmation.title !== 'string' && typeof confirmation.message !== 'string')) { + return; + } + + const entry = this.startAssistantTurn(turnId); + const title = typeof confirmation.title === 'string' ? confirmation.title.trim() : ''; + const message = typeof confirmation.message === 'string' ? confirmation.message.trim() : ''; + const buttons = Array.isArray(confirmation.buttons) ? confirmation.buttons : []; + if (!title && !message && buttons.length === 0) { + return; + } + + const key = `${title}::${message}::${buttons.join('::')}`; + if (entry.confirmationKeys.has(key)) { + return; + } + entry.confirmationKeys.add(key); + + const item = document.createElement('div'); + item.className = 'assistant-confirmation-item'; + + if (title) { + const titleEl = document.createElement('div'); + titleEl.className = 'assistant-confirmation-title'; + titleEl.textContent = title; + item.appendChild(titleEl); + } + + if (message) { + const messageEl = document.createElement('div'); + messageEl.className = 'assistant-confirmation-message'; + messageEl.textContent = message; + item.appendChild(messageEl); + } + + if (buttons.length > 0) { + const buttonRow = document.createElement('div'); + buttonRow.className = 'assistant-confirmation-buttons'; + for (const buttonText of buttons) { + const buttonEl = document.createElement('span'); + buttonEl.className = 'assistant-confirmation-button'; + buttonEl.textContent = buttonText; + buttonRow.appendChild(buttonEl); + } + item.appendChild(buttonRow); + } + + entry.confirmationHost.appendChild(item); + this.scrollToBottom(); + } + + appendAssistantQuestions(turnId, questionCarousel) { + if (!questionCarousel || !Array.isArray(questionCarousel.questions) || questionCarousel.questions.length === 0) { + return; + } + + const entry = this.startAssistantTurn(turnId); + const key = JSON.stringify({ + allowSkip: Boolean(questionCarousel.allowSkip), + questions: questionCarousel.questions, + }); + if (entry.questionKeys.has(key)) { + return; + } + entry.questionKeys.add(key); + + const item = document.createElement('div'); + item.className = 'assistant-questions-item'; + + const titleEl = document.createElement('div'); + titleEl.className = 'assistant-questions-title'; + titleEl.textContent = questionCarousel.allowSkip ? 'Questions (optional)' : 'Questions'; + item.appendChild(titleEl); + + const list = document.createElement('ul'); + list.className = 'assistant-questions-list'; + for (const question of questionCarousel.questions) { + if (!question || typeof question.title !== 'string') { + continue; + } + + const questionItem = document.createElement('li'); + questionItem.className = 'assistant-question-item'; + const questionTitle = document.createElement('div'); + questionTitle.className = 'assistant-question-title'; + questionTitle.textContent = `${question.title} (${question.type || 'unknown'})`; + questionItem.appendChild(questionTitle); + + if (typeof question.message === 'string' && question.message.trim().length > 0) { + const questionMessage = document.createElement('div'); + questionMessage.className = 'assistant-question-message'; + questionMessage.textContent = question.message; + questionItem.appendChild(questionMessage); + } + + if (Array.isArray(question.options) && question.options.length > 0) { + const options = document.createElement('div'); + options.className = 'assistant-question-options'; + options.textContent = `Options: ${question.options.join(', ')}`; + questionItem.appendChild(options); + } + + list.appendChild(questionItem); + } + + if (list.childNodes.length > 0) { + item.appendChild(list); + } + + entry.questionsHost.appendChild(item); + this.scrollToBottom(); + } + + appendAssistantCommandButton(turnId, commandButton) { + if (!commandButton || typeof commandButton.commandId !== 'string') { + return; + } + + const entry = this.startAssistantTurn(turnId); + const commandId = commandButton.commandId.trim(); + if (!commandId) { + return; + } + + const title = typeof commandButton.title === 'string' && commandButton.title.trim().length > 0 + ? commandButton.title.trim() + : commandId; + const args = Array.isArray(commandButton.args) ? [...commandButton.args] : undefined; + const key = `${commandId}::${title}::${JSON.stringify(args ?? [])}`; + if (entry.commandButtonKeys.has(key)) { + return; + } + entry.commandButtonKeys.add(key); + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'assistant-command-button'; + button.textContent = title; + button.addEventListener('click', () => { + if (typeof this.commandRunner === 'function') { + this.commandRunner({ commandId, args }); + } + }); + + entry.commandButtonsHost.appendChild(button); + this.scrollToBottom(); + } + + appendAssistantExtra(turnId, extra) { + if (!extra || typeof extra.kind !== 'string') { + return; + } + + const entry = this.startAssistantTurn(turnId); + if (extra.kind === 'textEdit' || extra.kind === 'notebookEdit') { + if (typeof extra.uri !== 'string') { + return; + } + + const key = `${extra.kind}::${extra.uri}`; + let item = entry.editExtraItems.get(key); + if (!item) { + item = document.createElement('div'); + item.className = 'assistant-extra-item'; + entry.editExtraItems.set(key, item); + entry.extrasHost.appendChild(item); + } + + const kindLabel = extra.kind === 'textEdit' ? 'Text edits' : 'Notebook edits'; + const editCount = Number.isFinite(extra.editCount) ? extra.editCount : 0; + const countLabel = `${editCount} ${editCount === 1 ? 'edit' : 'edits'}`; + const doneSuffix = extra.isDone ? ', done' : ''; + item.textContent = `${kindLabel}: ${this.uriLabel(extra.uri)} (${countLabel}${doneSuffix})`; + item.classList.toggle('is-complete', Boolean(extra.isDone)); + this.scrollToBottom(); + return; + } + + const key = JSON.stringify(extra); + if (entry.extraKeys.has(key)) { + return; + } + entry.extraKeys.add(key); + + const item = document.createElement('div'); + item.className = 'assistant-extra-item'; + + switch (extra.kind) { + case 'anchor': { + const label = typeof extra.label === 'string' && extra.label.trim().length > 0 + ? extra.label.trim() + : (typeof extra.uri === 'string' ? this.uriLabel(extra.uri) : 'Anchor'); + if (typeof extra.uri === 'string' && extra.uri.trim().length > 0) { + const link = document.createElement('a'); + link.className = 'assistant-reference-link'; + link.href = extra.uri; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = label; + item.appendChild(link); + } else { + item.textContent = label; + } + break; + } + case 'fileTree': { + const title = document.createElement('div'); + title.className = 'assistant-extra-title'; + title.textContent = `File tree (${this.uriLabel(extra.baseUri)})`; + item.appendChild(title); + + const tree = document.createElement('pre'); + tree.className = 'assistant-extra-tree'; + tree.textContent = extra.tree; + item.appendChild(tree); + break; + } + case 'codeblockUri': { + const link = document.createElement('a'); + link.className = 'assistant-reference-link'; + link.href = extra.uri; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + const prefix = extra.isEdit ? 'Edited file' : 'File'; + link.textContent = `${prefix}: ${this.uriLabel(extra.uri)}`; + item.appendChild(link); + break; + } + case 'workspaceEdit': { + const edits = Array.isArray(extra.edits) ? extra.edits : []; + const operations = edits.map(edit => { + if (typeof edit.oldUri === 'string' && typeof edit.newUri === 'string') { + return `Rename ${this.uriLabel(edit.oldUri)} -> ${this.uriLabel(edit.newUri)}`; + } + + if (typeof edit.newUri === 'string') { + return `Create ${this.uriLabel(edit.newUri)}`; + } + + if (typeof edit.oldUri === 'string') { + return `Delete ${this.uriLabel(edit.oldUri)}`; + } + + return ''; + }).filter(Boolean); + if (operations.length === 0) { + return; + } + + item.textContent = `Workspace edit: ${operations.join('; ')}`; + break; + } + case 'move': { + const range = extra.startLine === extra.endLine + ? `#L${extra.startLine}` + : `#L${extra.startLine}-${extra.endLine}`; + const link = document.createElement('a'); + link.className = 'assistant-reference-link'; + link.href = extra.uri; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = `Move target: ${this.uriLabel(extra.uri)}${range}`; + item.appendChild(link); + break; + } + case 'extensions': { + if (!Array.isArray(extra.extensions) || extra.extensions.length === 0) { + return; + } + + item.textContent = `Suggested extensions: ${extra.extensions.join(', ')}`; + break; + } + case 'pullRequest': { + const title = typeof extra.title === 'string' ? extra.title.trim() : ''; + const description = typeof extra.description === 'string' ? extra.description.trim() : ''; + const author = typeof extra.author === 'string' ? extra.author.trim() : ''; + const linkTag = typeof extra.linkTag === 'string' ? extra.linkTag.trim() : ''; + + const titleEl = document.createElement('div'); + titleEl.className = 'assistant-extra-title'; + titleEl.textContent = title || 'Pull request'; + item.appendChild(titleEl); + + const meta = []; + if (author) { + meta.push(`author: ${author}`); + } + if (linkTag) { + meta.push(linkTag); + } + if (meta.length > 0) { + const metaEl = document.createElement('div'); + metaEl.className = 'assistant-extra-meta'; + metaEl.textContent = meta.join(' β€’ '); + item.appendChild(metaEl); + } + + if (description) { + const descriptionEl = document.createElement('div'); + descriptionEl.className = 'assistant-extra-description'; + descriptionEl.textContent = description; + item.appendChild(descriptionEl); + } + + if (typeof extra.commandId === 'string' && extra.commandId.trim().length > 0 && typeof this.commandRunner === 'function') { + const openButton = document.createElement('button'); + openButton.type = 'button'; + openButton.className = 'assistant-command-button'; + openButton.textContent = 'Open pull request'; + openButton.addEventListener('click', () => { + this.commandRunner({ + commandId: extra.commandId, + args: Array.isArray(extra.commandArgs) ? extra.commandArgs : undefined, + }); + }); + item.appendChild(openButton); + } + break; + } + case 'externalEdit': { + const uris = Array.isArray(extra.uris) ? extra.uris : []; + if (uris.length === 0) { + return; + } + + item.textContent = `External edits applied: ${uris.map(uri => this.uriLabel(uri)).join(', ')}`; + break; + } + case 'multiDiff': { + const title = typeof extra.title === 'string' && extra.title.trim().length > 0 + ? extra.title.trim() + : 'Changes'; + const entries = Array.isArray(extra.entries) ? extra.entries : []; + + const titleEl = document.createElement('div'); + titleEl.className = 'assistant-extra-title'; + titleEl.textContent = `Multi diff: ${title}`; + item.appendChild(titleEl); + + if (entries.length > 0) { + const summaryEl = document.createElement('div'); + summaryEl.className = 'assistant-extra-meta'; + summaryEl.textContent = `${entries.length} ${entries.length === 1 ? 'file' : 'files'}${extra.readOnly ? ' β€’ read-only' : ''}`; + item.appendChild(summaryEl); + + const listEl = document.createElement('ul'); + listEl.className = 'assistant-extra-list'; + for (const diffEntry of entries) { + const listItem = document.createElement('li'); + listItem.className = 'assistant-extra-list-item'; + const sourceUri = typeof diffEntry.modifiedUri === 'string' + ? diffEntry.modifiedUri + : typeof diffEntry.originalUri === 'string' + ? diffEntry.originalUri + : diffEntry.goToFileUri; + if (typeof sourceUri === 'string') { + let label = this.uriLabel(sourceUri); + const changes = []; + if (typeof diffEntry.added === 'number') { + changes.push(`+${diffEntry.added}`); + } + if (typeof diffEntry.removed === 'number') { + changes.push(`-${diffEntry.removed}`); + } + if (changes.length > 0) { + label = `${label} (${changes.join(' ')})`; + } + listItem.textContent = label; + listEl.appendChild(listItem); + } + } + + if (listEl.childNodes.length > 0) { + item.appendChild(listEl); + } + } + break; + } + default: + return; + } + + entry.extrasHost.appendChild(item); + this.scrollToBottom(); + } + + completeAssistantTurn(turnId) { + const entry = this.streamingTurns.get(turnId); + if (!entry) { + return; + } + entry.element.classList.remove('streaming'); + this.decorateCodeBlocks(entry.markdownHost); + this.streamingTurns.delete(turnId); + this.scrollToBottom(); + } + + renderMarkdown(markdownText) { + if (!window.marked) { + return `
${escapeHtml(markdownText)}
`; + } + return window.marked.parse(markdownText); + } + + uriLabel(uri) { + if (typeof uri !== 'string') { + return ''; + } + + try { + const parsed = new URL(uri); + const segments = parsed.pathname.split('/').filter(Boolean); + return segments[segments.length - 1] || parsed.pathname || uri; + } catch { + const segments = uri.split('/').filter(Boolean); + return segments[segments.length - 1] || uri; + } + } + + decorateCodeBlocks(container) { + const blocks = container.querySelectorAll('pre'); + for (const block of blocks) { + if (block.querySelector('.code-header')) { + continue; + } + + const code = block.querySelector('code'); + if (!code) { + continue; + } + + // Detect language from class + let language = ''; + for (const cls of code.classList) { + if (cls.startsWith('language-')) { + language = cls.replace('language-', ''); + break; + } else if (cls.startsWith('hljs')) { + continue; + } + } + + const header = document.createElement('div'); + header.className = 'code-header'; + + const langLabel = document.createElement('span'); + langLabel.className = 'code-language'; + langLabel.textContent = language || 'text'; + header.appendChild(langLabel); + + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.className = 'copy-code'; + copyButton.textContent = 'Copy'; + copyButton.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(code.textContent || ''); + copyButton.textContent = 'Copied!'; + window.setTimeout(() => { + copyButton.textContent = 'Copy'; + }, 1200); + } catch { + copyButton.textContent = 'Error'; + window.setTimeout(() => { + copyButton.textContent = 'Copy'; + }, 1200); + } + }); + header.appendChild(copyButton); + + block.insertBefore(header, block.firstChild); + } + } + + createMessageRow(role) { + const row = document.createElement('div'); + row.className = `chat-message ${role}-message`; + + const avatar = document.createElement('div'); + avatar.className = `chat-avatar ${role === 'assistant' ? 'copilot-avatar' : ''}`; + avatar.innerHTML = role === 'assistant' ? COPILOT_AVATAR_SVG : USER_AVATAR_SVG; + row.appendChild(avatar); + + const content = document.createElement('div'); + content.className = 'chat-content'; + + const senderName = document.createElement('span'); + senderName.className = `sender-name ${role === 'assistant' ? 'copilot' : ''}`; + senderName.textContent = role === 'assistant' ? 'Copilot' : 'You'; + content.appendChild(senderName); + + const contentHost = document.createElement('div'); + contentHost.className = role === 'assistant' ? 'assistant-content' : ''; + content.appendChild(contentHost); + + row.appendChild(content); + + return { element: row, contentHost }; + } + + scrollToBottom() { + window.requestAnimationFrame(() => { + this.container.scrollTop = this.container.scrollHeight; + }); + } + } + + window.ChatRenderer = ChatRenderer; +})(); diff --git a/pwa/js/qr-scanner.js b/pwa/js/qr-scanner.js new file mode 100644 index 0000000000..9ff65b118d --- /dev/null +++ b/pwa/js/qr-scanner.js @@ -0,0 +1,8 @@ +(() => { + // Optional extension point for future camera-based QR scanning in the PWA. + window.SidecarQrScanner = { + isSupported() { + return false; + } + }; +})(); diff --git a/pwa/js/ws-client.js b/pwa/js/ws-client.js new file mode 100644 index 0000000000..d0501b2572 --- /dev/null +++ b/pwa/js/ws-client.js @@ -0,0 +1,140 @@ +(() => { + class SidecarWebSocketClient extends EventTarget { + constructor(wsBaseUrl, token) { + super(); + this.wsBaseUrl = wsBaseUrl; + this.token = token; + this.socket = undefined; + this.manualClose = false; + this.reconnectAttempt = 0; + this.reconnectTimer = undefined; + this.connectedOnce = false; + } + + setEndpoint(wsBaseUrl, token) { + this.wsBaseUrl = wsBaseUrl; + this.token = token; + } + + connect() { + if (!this.wsBaseUrl) { + this.emitStatus('disconnected'); + return; + } + + this.manualClose = false; + this.clearReconnectTimer(); + + const socketUrl = this.createSocketUrl(); + if (!socketUrl) { + this.emitStatus('disconnected'); + return; + } + + this.emitStatus(this.connectedOnce ? 'reconnecting' : 'connecting'); + + const socket = new WebSocket(socketUrl); + this.socket = socket; + + socket.addEventListener('open', () => { + const reconnected = this.connectedOnce; + this.connectedOnce = true; + this.reconnectAttempt = 0; + this.emitStatus('connected', { reconnected }); + this.dispatchEvent(new CustomEvent('open', { detail: { reconnected } })); + }); + + socket.addEventListener('message', event => { + let payload; + try { + payload = JSON.parse(event.data); + } catch { + return; + } + this.dispatchEvent(new CustomEvent('message', { detail: payload })); + }); + + socket.addEventListener('close', () => { + if (this.socket === socket) { + this.socket = undefined; + } + if (this.manualClose) { + this.emitStatus('disconnected'); + return; + } + this.scheduleReconnect(); + }); + + socket.addEventListener('error', () => { + if (!this.manualClose) { + this.emitStatus('reconnecting'); + } + }); + } + + disconnect() { + this.manualClose = true; + this.clearReconnectTimer(); + if (this.socket) { + this.socket.close(); + this.socket = undefined; + } + this.emitStatus('disconnected'); + } + + send(message) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + return false; + } + this.socket.send(JSON.stringify(message)); + return true; + } + + requestConversationList(filter) { + const message = { type: 'conversation:list:request' }; + if (filter && typeof filter === 'object') { + message.filter = filter; + } + this.send(message); + } + + scheduleReconnect() { + this.clearReconnectTimer(); + const delayMs = Math.min(1000 * (2 ** this.reconnectAttempt), 30000); + this.reconnectAttempt += 1; + this.emitStatus('reconnecting', { attempt: this.reconnectAttempt, delayMs }); + this.reconnectTimer = window.setTimeout(() => this.connect(), delayMs); + } + + clearReconnectTimer() { + if (this.reconnectTimer !== undefined) { + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + } + + emitStatus(state, extra = {}) { + this.dispatchEvent(new CustomEvent('status', { detail: { state, ...extra } })); + } + + createSocketUrl() { + try { + const url = new URL(this.wsBaseUrl); + if (url.protocol === 'https:') { + url.protocol = 'wss:'; + } else if (url.protocol === 'http:') { + url.protocol = 'ws:'; + } + + if (this.token) { + url.searchParams.set('token', this.token); + } + return url.toString(); + } catch { + return undefined; + } + } + } + + window.SidecarWebSocketClient = SidecarWebSocketClient; +})(); diff --git a/pwa/manifest.json b/pwa/manifest.json new file mode 100644 index 0000000000..659eb00218 --- /dev/null +++ b/pwa/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Copilot Sidecar", + "short_name": "Sidecar", + "description": "Sync live VS Code Copilot Chat sessions on mobile.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#020617", + "theme_color": "#0f172a", + "icons": [ + { + "src": "./icons/copilot.png", + "sizes": "256x256", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/pwa/sw.js b/pwa/sw.js new file mode 100644 index 0000000000..8f0c0211a4 --- /dev/null +++ b/pwa/sw.js @@ -0,0 +1,61 @@ +const CACHE_NAME = 'copilot-sidecar-v2'; +const STATIC_ASSETS = [ + './', + './index.html', + './manifest.json', + './css/style.css', + './js/app.js', + './js/ws-client.js', + './js/chat-renderer.js', + './js/qr-scanner.js', + './icons/copilot.png', +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => Promise.all( + keys + .filter(key => key !== CACHE_NAME) + .map(key => caches.delete(key)) + )) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') { + return; + } + + const requestUrl = new URL(event.request.url); + if (requestUrl.origin !== self.location.origin) { + return; + } + + event.respondWith( + caches.match(event.request).then(cached => { + if (cached) { + return cached; + } + + return fetch(event.request).then(response => { + if (!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + const cloned = response.clone(); + caches.open(CACHE_NAME).then(cache => { + cache.put(event.request, cloned); + }); + return response; + }); + }) + ); +}); diff --git a/script/pwaDevServer.mjs b/script/pwaDevServer.mjs new file mode 100644 index 0000000000..39f00cce9f --- /dev/null +++ b/script/pwaDevServer.mjs @@ -0,0 +1,101 @@ +import { createReadStream, existsSync, statSync } from 'fs'; +import { createServer } from 'http'; +import { dirname, extname, normalize, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const host = process.env.PWA_DEV_HOST || '0.0.0.0'; +const port = Number.parseInt(process.env.PWA_DEV_PORT || '4173', 10); + +if (!Number.isInteger(port) || port <= 0 || port > 65535) { + console.error(`Invalid PWA_DEV_PORT: ${process.env.PWA_DEV_PORT || ''}`); + process.exit(1); +} + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const pwaRoot = resolve(scriptDir, '..', 'pwa'); + +if (!existsSync(pwaRoot)) { + console.error(`PWA directory not found: ${pwaRoot}`); + process.exit(1); +} + +const mimeByExtension = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', +}; + +function isPathInsideRoot(filePath) { + return filePath === pwaRoot || filePath.startsWith(`${pwaRoot}/`); +} + +function decodePathname(urlPathname) { + try { + return decodeURIComponent(urlPathname); + } catch { + return '/'; + } +} + +function resolveStaticPath(urlPathname) { + const decodedPath = decodePathname(urlPathname); + const requestPath = decodedPath === '/' ? '/index.html' : decodedPath; + const normalizedPath = normalize(requestPath).replace(/^[/\\]+/, ''); + const absolutePath = resolve(pwaRoot, normalizedPath); + + if (!isPathInsideRoot(absolutePath)) { + return undefined; + } + + if (existsSync(absolutePath) && statSync(absolutePath).isFile()) { + return absolutePath; + } + + if (!extname(normalizedPath)) { + const fallback = resolve(pwaRoot, 'index.html'); + if (existsSync(fallback) && statSync(fallback).isFile()) { + return fallback; + } + } + + return undefined; +} + +function getContentType(filePath) { + const extension = extname(filePath).toLowerCase(); + return mimeByExtension[extension] || 'application/octet-stream'; +} + +const server = createServer((request, response) => { + if (!request.url) { + response.statusCode = 400; + response.end('Bad Request'); + return; + } + + const url = new URL(request.url, 'http://localhost'); + const targetPath = resolveStaticPath(url.pathname); + if (!targetPath) { + response.statusCode = 404; + response.setHeader('Content-Type', 'text/plain; charset=utf-8'); + response.end('Not Found'); + return; + } + + response.statusCode = 200; + response.setHeader('Content-Type', getContentType(targetPath)); + createReadStream(targetPath).pipe(response); +}); + +server.listen(port, host, () => { + console.log(`PWA dev server running at http://${host}:${port}/`); + console.log(`Serving: ${pwaRoot}`); + console.log('Set COPILOT_PWA_DEV_URL to this URL before launching the extension host.'); +}); diff --git a/src/extension/authentication/vscode-node/authentication.contribution.ts b/src/extension/authentication/vscode-node/authentication.contribution.ts index 43cfbbfebd..eac9a62f3f 100644 --- a/src/extension/authentication/vscode-node/authentication.contribution.ts +++ b/src/extension/authentication/vscode-node/authentication.contribution.ts @@ -53,8 +53,12 @@ class AuthUpgradeAsk extends Disposable { try { await this._authenticationService.getCopilotToken(); } catch (error) { - // likely due to the user canceling the auth flow - this._logService.error(error, 'Failed to get copilot token'); + if (error instanceof Error && error.message === 'GitHubLoginFailed') { + this._logService.info('Copilot token unavailable until GitHub sign-in completes.'); + } else { + // likely due to the user canceling the auth flow + this._logService.error(error instanceof Error ? error : String(error), 'Failed to get copilot token'); + } } await Event.toPromise( diff --git a/src/extension/chatSessions/claude/node/claudeCodeModels.ts b/src/extension/chatSessions/claude/node/claudeCodeModels.ts index 3ccbc0723f..3aa18f43fb 100644 --- a/src/extension/chatSessions/claude/node/claudeCodeModels.ts +++ b/src/extension/chatSessions/claude/node/claudeCodeModels.ts @@ -56,6 +56,15 @@ export interface IClaudeCodeModels { export const IClaudeCodeModels = createServiceIdentifier('IClaudeCodeModels'); +function isExpectedUnauthenticatedModelFetchError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = error.message.toLowerCase(); + return message.includes('githubloginfailed') || message.includes('failed to get token for auth info'); +} + export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels { declare _serviceBrand: undefined; private _cachedEndpoints: Promise | undefined; @@ -179,7 +188,11 @@ export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels { return claudeEndpoints.sort((a, b) => b.name.localeCompare(a.name)); } catch (ex) { - this.logService.error(`[ClaudeCodeModels] Failed to fetch models`, ex); + if (isExpectedUnauthenticatedModelFetchError(ex)) { + this.logService.debug('[ClaudeCodeModels] Skipping model fetch until GitHub sign-in is available'); + } else { + this.logService.error(ex instanceof Error ? ex : String(ex), '[ClaudeCodeModels] Failed to fetch models'); + } return []; } } diff --git a/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 9185e95b1e..5da2b9e9e9 100644 --- a/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -59,6 +59,15 @@ export const ICopilotCLISDK = createServiceIdentifier('ICopilotC export const ICopilotCLIModels = createServiceIdentifier('ICopilotCLIModels'); +function isExpectedUnauthenticatedModelFetchError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = error.message.toLowerCase(); + return message.includes('githubloginfailed') || message.includes('failed to get token for auth info'); +} + export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { declare _serviceBrand: undefined; private readonly _availableModels: Lazy>; @@ -126,7 +135,11 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { supportsVision: model.capabilities.supports.vision, } satisfies CopilotCLIModelInfo)); } catch (ex) { - this.logService.error(`[CopilotCLISession] Failed to fetch models`, ex); + if (isExpectedUnauthenticatedModelFetchError(ex)) { + this.logService.debug('[CopilotCLISession] Skipping model fetch until GitHub sign-in is available'); + } else { + this.logService.error(ex instanceof Error ? ex : String(ex), '[CopilotCLISession] Failed to fetch models'); + } return []; } } diff --git a/src/extension/conversation/vscode-node/languageModelAccess.ts b/src/extension/conversation/vscode-node/languageModelAccess.ts index c341dd609f..df2b7a7451 100644 --- a/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -170,6 +170,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib private _chatEndpoints: IChatEndpoint[] = []; private _lmWrapper: CopilotLanguageModelWrapper; private _promptBaseCountCache: LanguageModelAccessPromptBaseCountCache; + private hasLoggedMissingAuth = false; constructor( @ILogService private readonly _logService: ILogService, @@ -438,10 +439,21 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib private async _getToken(): Promise { try { const copilotToken = await this._authenticationService.getCopilotToken(); + this.hasLoggedMissingAuth = false; return copilotToken; } catch (e) { + if (e instanceof Error && e.message === 'GitHubLoginFailed') { + if (!this.hasLoggedMissingAuth) { + this._logService.warn('[LanguageModelAccess] LanguageModel/Embeddings are not available without auth token'); + this.hasLoggedMissingAuth = true; + } else { + this._logService.debug('[LanguageModelAccess] Auth token is still unavailable'); + } + return undefined; + } + this._logService.warn('[LanguageModelAccess] LanguageModel/Embeddings are not available without auth token'); - this._logService.error(e); + this._logService.error(e instanceof Error ? e : String(e)); return undefined; } } diff --git a/src/extension/conversation/vscode-node/mobileMirrorContribution.ts b/src/extension/conversation/vscode-node/mobileMirrorContribution.ts new file mode 100644 index 0000000000..06d6df640d --- /dev/null +++ b/src/extension/conversation/vscode-node/mobileMirrorContribution.ts @@ -0,0 +1,538 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) David Khachaturov. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { l10n } from 'vscode'; +import { BridgeServer } from '../../../platform/bridge/bridgeServer'; +import { ConversationBridge } from '../../../platform/bridge/conversationBridge'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IExtensionContribution } from '../../common/contributions'; +import { IConversationStore } from '../../conversationStore/node/conversationStore'; + +const showSidecarPanelCommandId = 'github.copilot.sidecar.showPanel'; +const pwaUrlStorageKey = 'sidecar.pwaUrl'; +const legacyPwaUrlStorageKey = 'mobileMirror.pwaUrl'; +const defaultPwaUrl = 'https://davidobot.github.io/vscode-copilot-chat-sidecar/'; +const pwaDevUrlEnvVar = 'COPILOT_PWA_DEV_URL'; + +type SidecarState = 'disconnected' | 'starting' | 'ready'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function createNonce(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function isLoopbackHost(host: string | undefined): boolean { + if (!host) { + return false; + } + + const normalizedHost = host.trim().toLowerCase(); + return normalizedHost === 'localhost' || normalizedHost === '127.0.0.1' || normalizedHost === '::1' || normalizedHost === '[::1]'; +} + +function getUriHostname(uri: vscode.Uri): string | undefined { + try { + return new URL(uri.toString(true)).hostname; + } catch { + return undefined; + } +} + +export class SidecarContribution extends Disposable implements IExtensionContribution { + readonly id = 'sidecarContribution'; + readonly activationBlocker: Promise; + + private readonly bridgeServer = this._register(new BridgeServer()); + private readonly conversationBridge: ConversationBridge; + private tunnelUri: vscode.Uri | undefined; + private statusBarItem: vscode.StatusBarItem | undefined; + private panel: vscode.WebviewPanel | undefined; + private sidecarState: SidecarState = 'disconnected'; + private startPromise: Promise | undefined; + private hasRegisteredBridgeListeners = false; + + constructor( + @IConversationStore conversationStore: IConversationStore, + @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, + @ILogService private readonly logService: ILogService, + @IFileSystemService fileSystemService: IFileSystemService, + ) { + super(); + this.conversationBridge = this._register(new ConversationBridge(this.bridgeServer, conversationStore, this.logService, fileSystemService, this.extensionContext)); + this.registerUi(); + this.activationBlocker = Promise.resolve(); + } + + private async startSidecar(): Promise { + if (this.tunnelUri) { + return; + } + + if (this.startPromise) { + return this.startPromise; + } + + this.sidecarState = 'starting'; + this.updateStatusBar(); + + this.startPromise = this.doStartSidecar().finally(() => { + this.startPromise = undefined; + }); + + return this.startPromise; + } + + private async doStartSidecar(): Promise { + try { + const port = await this.bridgeServer.start(); + const localBridgeUri = vscode.Uri.parse(`http://localhost:${port}`); + this.tunnelUri = await vscode.env.asExternalUri(localBridgeUri); + this.conversationBridge.activate(); + if (!this.hasRegisteredBridgeListeners) { + this._register(this.bridgeServer.onDidClientCountChange(() => { + this.updateStatusBar(); + void this.refreshPanel(); + })); + this.hasRegisteredBridgeListeners = true; + } + + this.sidecarState = 'ready'; + this.updateStatusBar(); + if (isLoopbackHost(getUriHostname(this.tunnelUri))) { + this.logService.warn(`[Sidecar] bridge resolved to loopback endpoint (${this.tunnelUri.toString(true)}). Phone pairing requires a routable endpoint.`); + } else { + this.logService.info(`[Sidecar] bridge ready at ${this.tunnelUri.toString(true)}`); + } + } catch (error) { + this.sidecarState = 'disconnected'; + this.updateStatusBar(); + this.logService.error(error, '[Sidecar] failed to initialize bridge'); + throw error; + } + } + + private registerUi(): void { + this._register(vscode.commands.registerCommand(showSidecarPanelCommandId, () => { + void this.onStatusBarClicked(); + })); + + this.statusBarItem = this._register(vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 50)); + this.statusBarItem.command = showSidecarPanelCommandId; + this.statusBarItem.show(); + this.updateStatusBar(); + } + + private async onStatusBarClicked(): Promise { + try { + await this.startSidecar(); + } catch { + vscode.window.showErrorMessage(l10n.t('Sidecar failed to start. Check extension logs and try again.')); + return; + } + + await this.showQrPanel(); + } + + private updateStatusBar(): void { + if (!this.statusBarItem) { + return; + } + + if (this.sidecarState === 'starting') { + this.statusBarItem.text = '$(sync~spin) Sidecar starting'; + this.statusBarItem.tooltip = l10n.t('Starting Sidecar bridge...'); + this.statusBarItem.color = new vscode.ThemeColor('statusBarItem.warningForeground'); + this.statusBarItem.backgroundColor = undefined; + return; + } + + if (this.sidecarState === 'disconnected') { + this.statusBarItem.text = '$(debug-disconnect) Sidecar'; + this.statusBarItem.tooltip = l10n.t('Sidecar is disconnected. Click to start pairing.'); + this.statusBarItem.color = new vscode.ThemeColor('statusBarItem.warningForeground'); + this.statusBarItem.backgroundColor = undefined; + return; + } + + const connectedCount = this.bridgeServer.connectedClients; + this.statusBarItem.text = connectedCount > 0 + ? `$(device-mobile) Sidecar ${connectedCount}` + : '$(device-mobile) Sidecar'; + this.statusBarItem.tooltip = l10n.t('Show Sidecar pairing QR code'); + this.statusBarItem.color = undefined; + this.statusBarItem.backgroundColor = undefined; + } + + private async showQrPanel(): Promise { + if (!this.tunnelUri) { + vscode.window.showWarningMessage(l10n.t('Sidecar is disconnected. Click the Sidecar status item to start it.')); + return; + } + + if (!this.panel) { + this.panel = vscode.window.createWebviewPanel( + 'copilot.sidecar', + l10n.t('Copilot Sidecar'), + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + } + ); + + this._register(this.panel.onDidDispose(() => { + this.panel = undefined; + })); + + this._register(this.panel.webview.onDidReceiveMessage(message => { + void this.handlePanelMessage(message); + })); + } else { + this.panel.reveal(vscode.ViewColumn.Active); + } + + await this.refreshPanel(); + } + + private async handlePanelMessage(message: unknown): Promise { + if (!isRecord(message) || typeof message.type !== 'string') { + return; + } + + switch (message.type) { + case 'regenerate-token': { + this.bridgeServer.regenerateToken(); + await this.refreshPanel(); + break; + } + case 'configure-pwa-url': { + await this.configurePwaUrl(); + await this.refreshPanel(); + break; + } + } + } + + private async refreshPanel(): Promise { + if (!this.panel || !this.tunnelUri) { + return; + } + + const bridgeWsUri = this.getBridgeWebSocketUri(); + if (!bridgeWsUri) { + this.panel.webview.html = this.getErrorHtml(this.panel.webview, l10n.t('Failed to resolve Sidecar bridge endpoint.')); + return; + } + + const pwaBaseUrl = await this.getPwaUrl(); + if (!pwaBaseUrl) { + this.panel.webview.html = this.getErrorHtml(this.panel.webview, l10n.t('A PWA URL is required to pair Sidecar.')); + return; + } + + const pairingUrl = this.createPairingUrl(pwaBaseUrl); + if (!pairingUrl) { + this.panel.webview.html = this.getErrorHtml(this.panel.webview, l10n.t('Failed to build the pairing URL.')); + return; + } + + this.panel.webview.html = this.getPanelHtml(this.panel.webview, { + pairingUrl, + pwaBaseUrl, + bridgeEndpoint: bridgeWsUri.toString(true), + bridgeIsLoopback: isLoopbackHost(getUriHostname(bridgeWsUri)), + connectedClients: this.bridgeServer.connectedClients, + }); + } + + private async configurePwaUrl(): Promise { + const currentValue = await this.getPwaUrl(); + const input = await vscode.window.showInputBox({ + prompt: l10n.t('Enter the hosted PWA URL for Sidecar'), + placeHolder: defaultPwaUrl, + value: currentValue ?? defaultPwaUrl, + ignoreFocusOut: true, + validateInput: value => this.normalizePwaUrl(value) ? undefined : l10n.t('Please enter a valid http(s) URL.'), + }); + + if (!input) { + return; + } + + const normalized = this.normalizePwaUrl(input); + if (!normalized) { + vscode.window.showErrorMessage(l10n.t('Invalid PWA URL.')); + return; + } + + await this.extensionContext.globalState.update(pwaUrlStorageKey, normalized); + } + + private async getPwaUrl(): Promise { + const devUrlRaw = process.env[pwaDevUrlEnvVar]; + if (typeof devUrlRaw === 'string' && devUrlRaw.trim().length > 0) { + const devUrl = this.normalizePwaUrl(devUrlRaw); + if (devUrl) { + return devUrl; + } + + this.logService.warn(`[Sidecar] Ignoring invalid ${pwaDevUrlEnvVar}: ${devUrlRaw}`); + } + + const existing = this.extensionContext.globalState.get(pwaUrlStorageKey) + ?? this.extensionContext.globalState.get(legacyPwaUrlStorageKey); + if (existing) { + if (!this.extensionContext.globalState.get(pwaUrlStorageKey)) { + await this.extensionContext.globalState.update(pwaUrlStorageKey, existing); + } + return existing; + } + + const input = await vscode.window.showInputBox({ + prompt: l10n.t('Set the hosted PWA URL for Sidecar'), + placeHolder: defaultPwaUrl, + value: defaultPwaUrl, + ignoreFocusOut: true, + validateInput: value => this.normalizePwaUrl(value) ? undefined : l10n.t('Please enter a valid http(s) URL.'), + }); + + if (!input) { + return undefined; + } + + const normalized = this.normalizePwaUrl(input); + if (!normalized) { + return undefined; + } + + await this.extensionContext.globalState.update(pwaUrlStorageKey, normalized); + return normalized; + } + + private normalizePwaUrl(value: string): string | undefined { + try { + const parsed = new URL(value.trim()); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return undefined; + } + parsed.hash = ''; + parsed.search = ''; + if (!parsed.pathname.endsWith('/')) { + parsed.pathname = `${parsed.pathname}/`; + } + return parsed.toString(); + } catch { + return undefined; + } + } + + private createPairingUrl(pwaBaseUrl: string): string | undefined { + const bridgeWsUri = this.getBridgeWebSocketUri(); + if (!bridgeWsUri) { + return undefined; + } + + try { + const url = new URL(pwaBaseUrl); + url.searchParams.set('ws', bridgeWsUri.toString(true)); + url.searchParams.set('token', this.bridgeServer.sessionToken); + return url.toString(); + } catch { + return undefined; + } + } + + private getBridgeWebSocketUri(): vscode.Uri | undefined { + if (!this.tunnelUri) { + return undefined; + } + + return this.tunnelUri.with({ scheme: this.tunnelUri.scheme === 'https' ? 'wss' : 'ws' }); + } + + private getErrorHtml(webview: vscode.Webview, message: string): string { + const nonce = createNonce(); + return ` + + + + + + + + +

${escapeHtml(message)}

+ + + +`; + } + + private getPanelHtml(webview: vscode.Webview, data: { pairingUrl: string; pwaBaseUrl: string; bridgeEndpoint: string; bridgeIsLoopback: boolean; connectedClients: number }): string { + const nonce = createNonce(); + const escapedPairingUrl = escapeHtml(data.pairingUrl); + const pairingLiteral = JSON.stringify(data.pairingUrl); + const loopbackWarning = data.bridgeIsLoopback + ? `
${escapeHtml(l10n.t('Bridge endpoint is loopback-only ({0}). This pairing URL will not work from a phone unless the bridge is routed externally.', data.bridgeEndpoint))}
` + : ''; + return ` + + + + + + + + +
+

${escapeHtml(l10n.t('Copilot Sidecar'))}

+
${escapeHtml(l10n.t('Connected phones: {0}', data.connectedClients))}
+
+
${escapedPairingUrl}
+
+ + + +
+
${escapeHtml(l10n.t('PWA: {0}', data.pwaBaseUrl))}
+
${escapeHtml(l10n.t('Bridge: {0}', data.bridgeEndpoint))}
+ ${loopbackWarning} +
+ + + +`; + } +} diff --git a/src/extension/conversation/vscode-node/sidecarContribution.ts b/src/extension/conversation/vscode-node/sidecarContribution.ts new file mode 100644 index 0000000000..ca2141e775 --- /dev/null +++ b/src/extension/conversation/vscode-node/sidecarContribution.ts @@ -0,0 +1,1200 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) David Khachaturov. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import * as http from 'http'; +import * as https from 'https'; +import * as vscode from 'vscode'; +import { l10n } from 'vscode'; +import { BridgeConversationProvider, BridgeConversationStatus, BridgeConversationSummary, BridgeServer } from '../../../platform/bridge/bridgeServer'; +import { ConversationBridge } from '../../../platform/bridge/conversationBridge'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle'; +import { IExtensionContribution } from '../../common/contributions'; +import { IConversationStore } from '../../conversationStore/node/conversationStore'; + +const showSidecarPanelCommandId = 'github.copilot.sidecar.showPanel'; +const pwaUrlStorageKey = 'sidecar.pwaUrl'; +const defaultPwaUrl = 'https://davidobot.github.io/vscode-copilot-chat-sidecar/'; +const pwaDevUrlEnvVar = 'COPILOT_PWA_DEV_URL'; +const devTunnelDomainSuffix = '.devtunnels.ms'; +const devTunnelInstallUrl = 'https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started'; +const devTunnelExecutableName = 'devtunnel'; +const devTunnelStartupTimeoutMs = 15000; +const devTunnelOutputBufferLimit = 16000; +const bridgeEndpointProbeTimeoutMs = 4000; +const bridgeEndpointProbeMaxBodyBytes = 12000; +const bridgeEndpointProbeSignature = 'Copilot Sidecar Bridge'; +const devTunnelUrlPattern = /https?:\/\/[^\s"'<>`]+/g; + +type DevTunnelHostAttemptResult = { + readonly uri: vscode.Uri | undefined; + readonly failureReason: string | undefined; +}; + +type SidecarState = 'disconnected' | 'starting' | 'ready'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function createNonce(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function isLoopbackHost(host: string | undefined): boolean { + if (!host) { + return false; + } + + const normalizedHost = host.trim().toLowerCase(); + return normalizedHost === 'localhost' || normalizedHost === '127.0.0.1' || normalizedHost === '::1' || normalizedHost === '[::1]'; +} + +function isDevTunnelsHost(host: string | undefined): boolean { + if (!host) { + return false; + } + + const normalizedHost = host.trim().toLowerCase(); + return normalizedHost === 'devtunnels.ms' || normalizedHost.endsWith(devTunnelDomainSuffix); +} + +function isPublicTunnelUri(uri: vscode.Uri): boolean { + const host = getUriHostname(uri); + if (!host || isLoopbackHost(host)) { + return false; + } + + return true; +} + +function isDevTunnelCliMissingReason(reason: string | undefined): boolean { + if (!reason) { + return false; + } + + const normalizedReason = reason.toLowerCase(); + return normalizedReason.includes('enoent') + || normalizedReason.includes('not installed') + || normalizedReason.includes('not available on path') + || normalizedReason.includes('command not found'); +} + +function getUriHostname(uri: vscode.Uri): string | undefined { + try { + return new URL(uri.toString(true)).hostname; + } catch { + return undefined; + } +} + +function getUriPort(uri: vscode.Uri): number | undefined { + try { + const parsed = new URL(uri.toString(true)); + const parsedPort = Number.parseInt(parsed.port, 10); + return Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : undefined; + } catch { + return undefined; + } +} + +class ChatSessionSourceRegistry extends Disposable { + private readonly providersByType = new Map(); + private readonly controllersByType = new Map(); + private readonly originalRegisterProvider = vscode.chat.registerChatSessionItemProvider.bind(vscode.chat); + private readonly originalCreateController = vscode.chat.createChatSessionItemController.bind(vscode.chat); + + constructor( + private readonly logService: ILogService, + ) { + super(); + this.patchRegistrationApis(); + } + + private patchRegistrationApis(): void { + const chatApi = vscode.chat as { + registerChatSessionItemProvider: typeof vscode.chat.registerChatSessionItemProvider; + createChatSessionItemController: typeof vscode.chat.createChatSessionItemController; + }; + + try { + chatApi.registerChatSessionItemProvider = (chatSessionType, provider) => { + this.providersByType.set(chatSessionType, provider); + const registration = this.originalRegisterProvider(chatSessionType, provider); + return toDisposable(() => { + if (this.providersByType.get(chatSessionType) === provider) { + this.providersByType.delete(chatSessionType); + } + + registration.dispose(); + }); + }; + + chatApi.createChatSessionItemController = (chatSessionType, refreshHandler) => { + const controller = this.originalCreateController(chatSessionType, refreshHandler); + + const proxy = new Proxy(controller, { + get: (target, prop, receiver) => { + if (prop === 'dispose') { + return () => { + if (this.controllersByType.get(chatSessionType) === proxy) { + this.controllersByType.delete(chatSessionType); + } + target.dispose(); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + } + }); + + this.controllersByType.set(chatSessionType, proxy); + return proxy; + }; + + this._register(toDisposable(() => { + chatApi.registerChatSessionItemProvider = this.originalRegisterProvider; + chatApi.createChatSessionItemController = this.originalCreateController; + })); + } catch (error) { + this.logService.warn(`[Sidecar] Failed to patch chat session provider registration APIs: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async listSummaries(): Promise { + const summaries = new Map(); + const upsert = (summary: BridgeConversationSummary): void => { + const existing = summaries.get(summary.id); + if (!existing || summary.lastUpdated > existing.lastUpdated) { + summaries.set(summary.id, summary); + } + }; + + for (const [chatSessionType, controller] of this.controllersByType) { + try { + await controller.refreshHandler(CancellationToken.None); + } catch (error) { + this.logService.trace(`[Sidecar] Failed to refresh controller items for ${chatSessionType}: ${error instanceof Error ? error.message : String(error)}`); + } + + for (const [, item] of controller.items) { + const summary = this.toSummary(chatSessionType, item); + if (summary) { + upsert(summary); + } + } + } + + for (const [chatSessionType, provider] of this.providersByType) { + let items: readonly vscode.ChatSessionItem[] = []; + try { + const provided = await provider.provideChatSessionItems(CancellationToken.None); + items = Array.isArray(provided) ? provided : []; + } catch (error) { + this.logService.trace(`[Sidecar] Failed to read provider items for ${chatSessionType}: ${error instanceof Error ? error.message : String(error)}`); + } + + for (const item of items) { + const summary = this.toSummary(chatSessionType, item); + if (summary) { + upsert(summary); + } + } + } + + return Array.from(summaries.values()); + } + + private toSummary(chatSessionType: string, item: vscode.ChatSessionItem): BridgeConversationSummary | undefined { + const title = item.label.trim(); + if (title.length === 0) { + return undefined; + } + + const resourcePath = item.resource.path.replace(/^\/+/, '').trim(); + const id = resourcePath.length > 0 ? resourcePath : item.resource.toString(true); + return { + id, + title, + lastUpdated: this.lastUpdatedFromItem(item), + provider: this.providerFromSessionType(chatSessionType, item), + status: this.statusFromSessionItem(item), + }; + } + + private lastUpdatedFromItem(item: vscode.ChatSessionItem): number { + const timing = item.timing; + if (timing?.lastRequestEnded && timing.lastRequestEnded > 0) { + return timing.lastRequestEnded; + } + + if (timing?.lastRequestStarted && timing.lastRequestStarted > 0) { + return timing.lastRequestStarted; + } + + if (timing?.endTime && timing.endTime > 0) { + return timing.endTime; + } + + if (timing?.startTime && timing.startTime > 0) { + return timing.startTime; + } + + if (timing?.created && timing.created > 0) { + return timing.created; + } + + return Date.now(); + } + + private providerFromSessionType(chatSessionType: string, item: vscode.ChatSessionItem): BridgeConversationProvider { + const normalizedType = chatSessionType.toLowerCase(); + if (normalizedType === 'copilotcli') { + return 'copilot-cli'; + } + + if (normalizedType === 'copilot-cloud-agent' || normalizedType.includes('cloud')) { + return 'cloud'; + } + + if (normalizedType === 'claude-code' || normalizedType.includes('claude')) { + return 'claude'; + } + + if (normalizedType.includes('codex')) { + return 'codex'; + } + + const metadata = isRecord(item.metadata) ? item.metadata : undefined; + if (typeof metadata?.provider === 'string') { + const provider = metadata.provider.toLowerCase(); + if (provider === 'copilot-cli' || provider === 'copilotcli') { + return 'copilot-cli'; + } + if (provider === 'cloud' || provider === 'copilot-cloud-agent') { + return 'cloud'; + } + if (provider === 'claude' || provider === 'claude-code') { + return 'claude'; + } + if (provider === 'codex') { + return 'codex'; + } + } + + return 'unknown'; + } + + private statusFromSessionItem(item: vscode.ChatSessionItem): BridgeConversationStatus { + if (item.archived) { + return 'archived'; + } + + switch (item.status) { + case vscode.ChatSessionStatus.Completed: + return 'completed'; + case vscode.ChatSessionStatus.Failed: + return 'failed'; + case vscode.ChatSessionStatus.InProgress: + return 'in-progress'; + case vscode.ChatSessionStatus.NeedsInput: + return 'input-needed'; + default: + return 'unknown'; + } + } +} + +export class SidecarContribution extends Disposable implements IExtensionContribution { + readonly id = 'sidecarContribution'; + readonly activationBlocker: Promise; + + private readonly bridgeServer = this._register(new BridgeServer()); + private readonly sessionSourceRegistry: ChatSessionSourceRegistry; + private readonly conversationBridge: ConversationBridge; + private tunnelUri: vscode.Uri | undefined; + private statusBarItem: vscode.StatusBarItem | undefined; + private panel: vscode.WebviewPanel | undefined; + private sidecarState: SidecarState = 'disconnected'; + private startPromise: Promise | undefined; + private hasRegisteredBridgeListeners = false; + private devTunnelHostProcess: ChildProcessWithoutNullStreams | undefined; + + constructor( + @IConversationStore conversationStore: IConversationStore, + @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, + @ILogService private readonly logService: ILogService, + @IFileSystemService fileSystemService: IFileSystemService, + ) { + super(); + this._register(toDisposable(() => { + this.stopDevTunnelHost(); + })); + this.sessionSourceRegistry = this._register(new ChatSessionSourceRegistry(this.logService)); + this.conversationBridge = this._register(new ConversationBridge( + this.bridgeServer, + conversationStore, + this.logService, + fileSystemService, + this.extensionContext, + () => this.sessionSourceRegistry.listSummaries(), + )); + this.registerUi(); + this.activationBlocker = Promise.resolve(); + } + + private async startSidecar(): Promise { + if (this.tunnelUri) { + return; + } + + if (this.startPromise) { + return this.startPromise; + } + + this.sidecarState = 'starting'; + this.updateStatusBar(); + + this.startPromise = this.doStartSidecar().finally(() => { + this.startPromise = undefined; + }); + + return this.startPromise; + } + + private async doStartSidecar(): Promise { + try { + this.stopDevTunnelHost(); + const port = await this.bridgeServer.start(); + const localBridgeUri = vscode.Uri.parse(`http://localhost:${port}`); + this.tunnelUri = await this.resolveDevTunnelUri(localBridgeUri); + this.conversationBridge.activate(); + if (!this.hasRegisteredBridgeListeners) { + this._register(this.bridgeServer.onDidClientCountChange(() => { + this.updateStatusBar(); + void this.refreshPanel(); + })); + this.hasRegisteredBridgeListeners = true; + } + + this.sidecarState = 'ready'; + this.updateStatusBar(); + this.logService.info(`[Sidecar] bridge ready at ${this.tunnelUri.toString(true)}`); + } catch (error) { + this.stopDevTunnelHost(); + this.sidecarState = 'disconnected'; + this.updateStatusBar(); + this.logService.error(error, '[Sidecar] failed to initialize bridge'); + throw error; + } + } + + private async resolveDevTunnelUri(localBridgeUri: vscode.Uri): Promise { + const initialResolution = await vscode.env.asExternalUri(localBridgeUri); + if (await this.isBridgeEndpointUsable(initialResolution, 'vscode.env.asExternalUri')) { + return initialResolution; + } + + const autoResolved = await this.tryBootstrapDevTunnel(localBridgeUri); + if (autoResolved) { + return autoResolved; + } + + throw new Error(`Sidecar requires a Dev Tunnel endpoint (*.devtunnels.ms). Resolved bridge URI was ${initialResolution.toString(true)}.`); + } + + private async tryBootstrapDevTunnel(localBridgeUri: vscode.Uri): Promise { + const directHostAttempt = await this.tryStartDirectDevTunnelHost(localBridgeUri); + if (directHostAttempt.uri) { + return directHostAttempt.uri; + } + + if (directHostAttempt.failureReason) { + if (isDevTunnelCliMissingReason(directHostAttempt.failureReason)) { + void this.showDevTunnelInstallPrompt(directHostAttempt.failureReason).catch(error => { + this.logService.warn(`[Sidecar] Failed to show devtunnel install prompt: ${error instanceof Error ? error.message : String(error)}`); + }); + } else { + void this.showDevTunnelStartupWarning(directHostAttempt.failureReason).catch(error => { + this.logService.warn(`[Sidecar] Failed to show devtunnel startup warning: ${error instanceof Error ? error.message : String(error)}`); + }); + } + } + + return undefined; + } + + private async tryStartDirectDevTunnelHost(localBridgeUri: vscode.Uri): Promise { + const port = getUriPort(localBridgeUri); + if (!port) { + this.logService.warn(`[Sidecar] Unable to determine local bridge port from URI for direct devtunnel bootstrap: ${localBridgeUri.toString(true)}`); + return { + uri: undefined, + failureReason: undefined, + }; + } + + let devTunnelHostProcess: ChildProcessWithoutNullStreams; + try { + devTunnelHostProcess = spawn(devTunnelExecutableName, ['host', '-p', `${port}`, '--allow-anonymous']); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.logService.warn(`[Sidecar] Failed to launch direct devtunnel host process: ${reason}`); + return { + uri: undefined, + failureReason: reason, + }; + } + + const readyResult = await this.waitForDevTunnelHostUri(devTunnelHostProcess); + if (!readyResult.uri) { + this.terminateDevTunnelProcess(devTunnelHostProcess); + const failureReason = readyResult.failureReason; + if (failureReason) { + this.logService.warn(`[Sidecar] Direct devtunnel host did not become ready: ${failureReason}`); + } + return { + uri: undefined, + failureReason, + }; + } + + this.devTunnelHostProcess = devTunnelHostProcess; + this.attachDevTunnelHostLifecycle(devTunnelHostProcess); + this.logService.info(`[Sidecar] Direct devtunnel host ready at ${readyResult.uri.toString(true)}.`); + return { + uri: readyResult.uri, + failureReason: undefined, + }; + } + + private waitForDevTunnelHostUri(devTunnelHostProcess: ChildProcessWithoutNullStreams): Promise { + return new Promise(resolve => { + let outputBuffer = ''; + let settled = false; + + const settle = (result: DevTunnelHostAttemptResult): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + devTunnelHostProcess.stdout.off('data', onStdoutData); + devTunnelHostProcess.stderr.off('data', onStderrData); + devTunnelHostProcess.off('error', onError); + devTunnelHostProcess.off('exit', onExit); + resolve(result); + }; + + const appendOutput = (chunk: Buffer | string): vscode.Uri | undefined => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + if (text.trim().length > 0) { + this.logService.trace(`[Sidecar][devtunnel] ${text.trim()}`); + } + + outputBuffer = `${outputBuffer}${text}`; + if (outputBuffer.length > devTunnelOutputBufferLimit) { + outputBuffer = outputBuffer.slice(-devTunnelOutputBufferLimit); + } + + return this.extractPublicDevTunnelUri(outputBuffer); + }; + + const onStdoutData = (chunk: Buffer | string) => { + const uri = appendOutput(chunk); + if (uri) { + settle({ + uri, + failureReason: undefined, + }); + } + }; + + const onStderrData = (chunk: Buffer | string) => { + const uri = appendOutput(chunk); + if (uri) { + settle({ + uri, + failureReason: undefined, + }); + } + }; + + const onError = (error: Error) => { + let failureReason = error.message; + if (failureReason.includes('ENOENT')) { + failureReason = l10n.t('devtunnel CLI is not installed or not available on PATH'); + } + + settle({ + uri: undefined, + failureReason, + }); + }; + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + const failureReason = code === 0 + ? l10n.t('devtunnel host exited before publishing a public endpoint') + : l10n.t('devtunnel host exited with code {0}{1}', code ?? 'unknown', signal ? ` (${signal})` : ''); + settle({ + uri: undefined, + failureReason, + }); + }; + + const timeout = setTimeout(() => { + settle({ + uri: undefined, + failureReason: l10n.t('timed out waiting for devtunnel host to publish a public endpoint'), + }); + }, devTunnelStartupTimeoutMs); + + devTunnelHostProcess.stdout.on('data', onStdoutData); + devTunnelHostProcess.stderr.on('data', onStderrData); + devTunnelHostProcess.once('error', onError); + devTunnelHostProcess.once('exit', onExit); + }); + } + + private extractPublicDevTunnelUri(output: string): vscode.Uri | undefined { + const matches = output.match(devTunnelUrlPattern); + if (!matches) { + return undefined; + } + + for (let index = matches.length - 1; index >= 0; index--) { + const candidate = matches[index].replace(/[),.;]+$/, ''); + let parsed: vscode.Uri; + try { + parsed = vscode.Uri.parse(candidate); + } catch { + continue; + } + + const host = getUriHostname(parsed); + if (!host || !isDevTunnelsHost(host) || !isPublicTunnelUri(parsed)) { + continue; + } + + return parsed; + } + + return undefined; + } + + private attachDevTunnelHostLifecycle(devTunnelHostProcess: ChildProcessWithoutNullStreams): void { + devTunnelHostProcess.on('exit', (code, signal) => { + if (this.devTunnelHostProcess !== devTunnelHostProcess) { + return; + } + + this.devTunnelHostProcess = undefined; + this.logService.warn(`[Sidecar] Direct devtunnel host exited (code=${code ?? 'unknown'}, signal=${signal ?? 'none'}).`); + }); + + devTunnelHostProcess.on('error', error => { + if (this.devTunnelHostProcess !== devTunnelHostProcess) { + return; + } + + this.logService.warn(`[Sidecar] Direct devtunnel host process error: ${error instanceof Error ? error.message : String(error)}`); + }); + } + + private terminateDevTunnelProcess(devTunnelHostProcess: ChildProcessWithoutNullStreams): void { + try { + devTunnelHostProcess.kill('SIGTERM'); + } catch { + // Ignore process termination failures. + } + } + + private stopDevTunnelHost(): void { + const devTunnelHostProcess = this.devTunnelHostProcess; + if (!devTunnelHostProcess) { + return; + } + + this.devTunnelHostProcess = undefined; + this.terminateDevTunnelProcess(devTunnelHostProcess); + } + + private async showDevTunnelInstallPrompt(reason: string): Promise { + const normalizedReason = reason.trim(); + const renderedReason = normalizedReason.length > 0 + ? normalizedReason + : l10n.t('unknown reason'); + const selected = await vscode.window.showWarningMessage( + l10n.t('Sidecar requires the devtunnel CLI, but it was not detected ({0}).', renderedReason), + l10n.t('Install Dev Tunnels') + ); + + if (selected) { + void vscode.env.openExternal(vscode.Uri.parse(devTunnelInstallUrl)); + } + } + + private async showDevTunnelStartupWarning(reason: string): Promise { + const normalizedReason = reason.trim(); + const renderedReason = normalizedReason.length > 0 + ? normalizedReason + : l10n.t('unknown reason'); + await vscode.window.showWarningMessage(l10n.t('Sidecar could not start a direct dev tunnel ({0}).', renderedReason)); + } + + private async isBridgeEndpointUsable(candidateUri: vscode.Uri, source: string): Promise { + if (!isPublicTunnelUri(candidateUri)) { + return false; + } + + if (!isDevTunnelsHost(getUriHostname(candidateUri))) { + this.logService.warn(`[Sidecar] Ignoring tunnel endpoint from ${source}; Sidecar requires a devtunnels.ms endpoint for WebSocket support (${candidateUri.toString(true)}).`); + return false; + } + + if (await this.probeBridgeEndpoint(candidateUri)) { + return true; + } + + this.logService.warn(`[Sidecar] Ignoring tunnel endpoint from ${source}; endpoint did not route to the bridge (${candidateUri.toString(true)}).`); + return false; + } + + private async probeBridgeEndpoint(candidateUri: vscode.Uri): Promise { + const probeUri = candidateUri.with({ query: '', fragment: '' }); + const probeTarget = probeUri.toString(true); + + for (let attempt = 0; attempt < 3; attempt++) { + if (await this.tryProbeBridgeEndpointOnce(probeTarget)) { + return true; + } + + if (attempt < 2) { + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + + return false; + } + + private tryProbeBridgeEndpointOnce(probeTarget: string): Promise { + return new Promise(resolve => { + let settled = false; + const finish = (value: boolean): void => { + if (settled) { + return; + } + settled = true; + resolve(value); + }; + + let parsed: URL; + try { + parsed = new URL(probeTarget); + } catch { + finish(false); + return; + } + + const transport = parsed.protocol === 'https:' + ? https + : parsed.protocol === 'http:' + ? http + : undefined; + if (!transport) { + finish(false); + return; + } + + const request = transport.request(parsed, { method: 'GET' }, response => { + const status = response.statusCode ?? 0; + // Dev Tunnels may return 3xx/401/403 if the tunnel is private, or 200 if public. + // A 404 means the tunnel doesn't exist or port isn't forwarded. + // A 5xx means the devtunnels relay cannot reach the host. + if (status >= 500 || status === 404) { + response.resume(); + finish(false); + return; + } + + if (status >= 300 && status < 500) { + response.resume(); + finish(true); + return; + } + + let body = ''; + let bodyBytes = 0; + response.setEncoding('utf8'); + response.on('data', chunk => { + if (bodyBytes >= bridgeEndpointProbeMaxBodyBytes) { + return; + } + + const textChunk = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + bodyBytes += textChunk.length; + body += textChunk; + }); + response.on('end', () => { + finish(body.includes(bridgeEndpointProbeSignature)); + }); + }); + + request.setTimeout(bridgeEndpointProbeTimeoutMs, () => { + request.destroy(); + finish(false); + }); + request.on('error', () => { + finish(false); + }); + request.end(); + }); + } + + private registerUi(): void { + this._register(vscode.commands.registerCommand(showSidecarPanelCommandId, () => { + void this.onStatusBarClicked(); + })); + + this.statusBarItem = this._register(vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 50)); + this.statusBarItem.command = showSidecarPanelCommandId; + this.statusBarItem.show(); + this.updateStatusBar(); + } + + private async onStatusBarClicked(): Promise { + try { + await this.startSidecar(); + } catch (error) { + if (error instanceof Error && (error.message.includes(devTunnelDomainSuffix) || error.message.includes('dev tunnel'))) { + const actionLabel = isDevTunnelCliMissingReason(error.message) + ? l10n.t('Install Dev Tunnels') + : l10n.t('Open Dev Tunnels Docs'); + const selected = await vscode.window.showErrorMessage( + l10n.t('Sidecar requires a dev tunnel endpoint to pair. Install or configure dev tunnels and try again.'), + actionLabel + ); + + if (selected) { + void vscode.env.openExternal(vscode.Uri.parse(devTunnelInstallUrl)); + } + return; + } + + vscode.window.showErrorMessage(l10n.t('Sidecar failed to start. Check extension logs and try again.')); + return; + } + + await this.showQrPanel(); + } + + private updateStatusBar(): void { + if (!this.statusBarItem) { + return; + } + + if (this.sidecarState === 'starting') { + this.statusBarItem.text = '$(sync~spin) Sidecar starting'; + this.statusBarItem.tooltip = l10n.t('Starting Sidecar bridge...'); + this.statusBarItem.color = new vscode.ThemeColor('statusBarItem.warningForeground'); + this.statusBarItem.backgroundColor = undefined; + return; + } + + if (this.sidecarState === 'disconnected') { + this.statusBarItem.text = '$(debug-disconnect) Sidecar'; + this.statusBarItem.tooltip = l10n.t('Sidecar is disconnected. Click to start pairing.'); + this.statusBarItem.color = new vscode.ThemeColor('statusBarItem.warningForeground'); + this.statusBarItem.backgroundColor = undefined; + return; + } + + const connectedCount = this.bridgeServer.connectedClients; + this.statusBarItem.text = connectedCount > 0 + ? `$(device-mobile) Sidecar ${connectedCount}` + : '$(device-mobile) Sidecar'; + this.statusBarItem.tooltip = l10n.t('Show Sidecar pairing QR code'); + this.statusBarItem.color = undefined; + this.statusBarItem.backgroundColor = undefined; + } + + private async showQrPanel(): Promise { + if (!this.tunnelUri) { + vscode.window.showWarningMessage(l10n.t('Sidecar is disconnected. Click the Sidecar status item to start it.')); + return; + } + + if (!this.panel) { + this.panel = vscode.window.createWebviewPanel( + 'copilot.sidecar', + l10n.t('Copilot Sidecar'), + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + } + ); + + this._register(this.panel.onDidDispose(() => { + this.panel = undefined; + })); + + this._register(this.panel.webview.onDidReceiveMessage(message => { + void this.handlePanelMessage(message); + })); + } else { + this.panel.reveal(vscode.ViewColumn.Active); + } + + await this.refreshPanel(); + } + + private async handlePanelMessage(message: unknown): Promise { + if (!isRecord(message) || typeof message.type !== 'string') { + return; + } + + switch (message.type) { + case 'regenerate-token': { + this.bridgeServer.regenerateToken(); + await this.refreshPanel(); + break; + } + case 'configure-pwa-url': { + await this.configurePwaUrl(); + await this.refreshPanel(); + break; + } + } + } + + private async refreshPanel(): Promise { + if (!this.panel || !this.tunnelUri) { + return; + } + + const bridgeWsUri = this.getBridgeWebSocketUri(); + if (!bridgeWsUri) { + this.panel.webview.html = this.getErrorHtml(this.panel.webview, l10n.t('Failed to resolve Sidecar bridge endpoint.')); + return; + } + + const pwaBaseUrl = await this.getPwaUrl(); + if (!pwaBaseUrl) { + this.panel.webview.html = this.getErrorHtml(this.panel.webview, l10n.t('A PWA URL is required to pair Sidecar.')); + return; + } + + const pairingUrl = this.createPairingUrl(pwaBaseUrl); + if (!pairingUrl) { + this.panel.webview.html = this.getErrorHtml(this.panel.webview, l10n.t('Failed to build the pairing URL.')); + return; + } + + this.panel.webview.html = this.getPanelHtml(this.panel.webview, { + pairingUrl, + pwaBaseUrl, + bridgeEndpoint: bridgeWsUri.toString(true), + bridgeIsLoopback: isLoopbackHost(getUriHostname(bridgeWsUri)), + connectedClients: this.bridgeServer.connectedClients, + }); + } + + private async configurePwaUrl(): Promise { + const currentValue = await this.getPwaUrl(); + const input = await vscode.window.showInputBox({ + prompt: l10n.t('Enter the hosted PWA URL for Sidecar'), + placeHolder: defaultPwaUrl, + value: currentValue ?? defaultPwaUrl, + ignoreFocusOut: true, + validateInput: value => this.normalizePwaUrl(value) ? undefined : l10n.t('Please enter a valid http(s) URL.'), + }); + + if (!input) { + return; + } + + const normalized = this.normalizePwaUrl(input); + if (!normalized) { + vscode.window.showErrorMessage(l10n.t('Invalid PWA URL.')); + return; + } + + await this.extensionContext.globalState.update(pwaUrlStorageKey, normalized); + } + + private async getPwaUrl(): Promise { + const devUrlRaw = process.env[pwaDevUrlEnvVar]; + if (typeof devUrlRaw === 'string' && devUrlRaw.trim().length > 0) { + const devUrl = this.normalizePwaUrl(devUrlRaw); + if (devUrl) { + return devUrl; + } + + this.logService.warn(`[Sidecar] Ignoring invalid ${pwaDevUrlEnvVar}: ${devUrlRaw}`); + } + + const existing = this.extensionContext.globalState.get(pwaUrlStorageKey); + if (existing) { + return existing; + } + + const input = await vscode.window.showInputBox({ + prompt: l10n.t('Set the hosted PWA URL for Sidecar'), + placeHolder: defaultPwaUrl, + value: defaultPwaUrl, + ignoreFocusOut: true, + validateInput: value => this.normalizePwaUrl(value) ? undefined : l10n.t('Please enter a valid http(s) URL.'), + }); + + if (!input) { + return undefined; + } + + const normalized = this.normalizePwaUrl(input); + if (!normalized) { + return undefined; + } + + await this.extensionContext.globalState.update(pwaUrlStorageKey, normalized); + return normalized; + } + + private normalizePwaUrl(value: string): string | undefined { + try { + const parsed = new URL(value.trim()); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return undefined; + } + parsed.hash = ''; + parsed.search = ''; + if (!parsed.pathname.endsWith('/')) { + parsed.pathname = `${parsed.pathname}/`; + } + return parsed.toString(); + } catch { + return undefined; + } + } + + private createPairingUrl(pwaBaseUrl: string): string | undefined { + const bridgeWsUri = this.getBridgeWebSocketUri(); + if (!bridgeWsUri) { + return undefined; + } + + try { + const url = new URL(pwaBaseUrl); + url.searchParams.set('ws', bridgeWsUri.toString(true)); + url.searchParams.set('token', this.bridgeServer.sessionToken); + return url.toString(); + } catch { + return undefined; + } + } + + private getBridgeWebSocketUri(): vscode.Uri | undefined { + if (!this.tunnelUri) { + return undefined; + } + + return this.tunnelUri.with({ scheme: this.tunnelUri.scheme === 'https' ? 'wss' : 'ws' }); + } + + private getErrorHtml(webview: vscode.Webview, message: string): string { + const nonce = createNonce(); + return ` + + + + + + + + +

${escapeHtml(message)}

+ + + +`; + } + + private getPanelHtml(webview: vscode.Webview, data: { pairingUrl: string; pwaBaseUrl: string; bridgeEndpoint: string; bridgeIsLoopback: boolean; connectedClients: number }): string { + const nonce = createNonce(); + const escapedPairingUrl = escapeHtml(data.pairingUrl); + const pairingLiteral = JSON.stringify(data.pairingUrl); + const loopbackWarning = data.bridgeIsLoopback + ? `
${escapeHtml(l10n.t('Bridge endpoint is loopback-only ({0}). This pairing URL will not work from a phone unless the bridge is routed externally.', data.bridgeEndpoint))}
` + : ''; + return ` + + + + + + + + +
+

${escapeHtml(l10n.t('Copilot Sidecar'))}

+
${escapeHtml(l10n.t('Connected phones: {0}', data.connectedClients))}
+
+
${escapedPairingUrl}
+
+ + + +
+
${escapeHtml(l10n.t('PWA: {0}', data.pwaBaseUrl))}
+
${escapeHtml(l10n.t('Bridge: {0}', data.bridgeEndpoint))}
+ ${loopbackWarning} +
+ + + +`; + } +} diff --git a/src/extension/conversationStore/node/conversationStore.spec.ts b/src/extension/conversationStore/node/conversationStore.spec.ts index 66e054b874..fed08035ae 100644 --- a/src/extension/conversationStore/node/conversationStore.spec.ts +++ b/src/extension/conversationStore/node/conversationStore.spec.ts @@ -5,6 +5,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { IChatSessionService } from '../../../platform/chat/common/chatSessionService'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { ILogService } from '../../../platform/log/common/logService'; import { Emitter } from '../../../util/vs/base/common/event'; import { Conversation, Turn } from '../../prompt/common/conversation'; import { ConversationStore } from './conversationStore'; @@ -24,7 +27,10 @@ describe('ConversationStore', () => { _serviceBrand: undefined, onDidDisposeChatSession: disposeChatSession.event, }; - store = new ConversationStore(chatSessionService); + const extensionContext = { globalStorageUri: undefined } as unknown as IVSCodeExtensionContext; + const fileSystemService = {} as unknown as IFileSystemService; + const logService = { error: vi.fn() } as unknown as ILogService; + store = new ConversationStore(chatSessionService, extensionContext, fileSystemService, logService); }); afterEach(() => { diff --git a/src/extension/conversationStore/node/conversationStore.ts b/src/extension/conversationStore/node/conversationStore.ts index 8ce9a0b534..e96d448fc0 100644 --- a/src/extension/conversationStore/node/conversationStore.ts +++ b/src/extension/conversationStore/node/conversationStore.ts @@ -4,43 +4,380 @@ *--------------------------------------------------------------------------------------------*/ import { IChatSessionService } from '../../../platform/chat/common/chatSessionService'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService, createDirectoryIfNotExists } from '../../../platform/filesystem/common/fileSystemService'; +import { ILogService } from '../../../platform/log/common/logService'; import { createServiceIdentifier } from '../../../util/common/services'; import { TimeoutTimer } from '../../../util/vs/base/common/async'; +import { Emitter, Event } from '../../../util/vs/base/common/event'; import { Disposable, DisposableMap } from '../../../util/vs/base/common/lifecycle'; import { LRUCache } from '../../../util/vs/base/common/map'; +import { URI } from '../../../util/vs/base/common/uri'; import { Conversation } from '../../prompt/common/conversation'; export const IConversationStore = createServiceIdentifier('IConversationStore'); +export interface IConversationSummary { + readonly id: string; + readonly title: string; + readonly lastUpdated: number; +} + +export interface IConversationTurnEvent { + readonly conversationId: string; + readonly turnId: string; + readonly content: string; + readonly timestamp: number; +} + +export interface IConversationTurnLifecycleEvent { + readonly conversationId: string; + readonly turnId: string; +} + +export interface IConversationTurnChunkEvent { + readonly conversationId: string; + readonly turnId: string; + readonly content: string; +} + +export interface IConversationTurnReferenceEvent { + readonly conversationId: string; + readonly turnId: string; + readonly label: string; + readonly uri: string | undefined; +} + +export interface IConversationTurnCodeCitationEvent { + readonly conversationId: string; + readonly turnId: string; + readonly uri: string; + readonly license: string; + readonly snippet: string; +} + +export type IConversationAssistantStatusKind = 'progress' | 'warning' | 'thinking'; + +export interface IConversationTurnStatusEvent { + readonly conversationId: string; + readonly turnId: string; + readonly kind: IConversationAssistantStatusKind; + readonly content: string; +} + +export interface IConversationTurnToolInvocationEvent { + readonly conversationId: string; + readonly turnId: string; + readonly toolName: string; + readonly toolCallId: string; + readonly message: string | undefined; + readonly isError: boolean; + readonly isComplete: boolean; +} + +export interface IConversationTurnConfirmationEvent { + readonly conversationId: string; + readonly turnId: string; + readonly title: string; + readonly message: string; + readonly buttons: readonly string[] | undefined; +} + +export type IConversationQuestionType = 'text' | 'singleSelect' | 'multiSelect' | 'unknown'; + +export interface IConversationQuestionItem { + readonly id: string; + readonly type: IConversationQuestionType; + readonly title: string; + readonly message: string | undefined; + readonly options: readonly string[] | undefined; + readonly allowFreeformInput: boolean; +} + +export interface IConversationTurnQuestionCarouselEvent { + readonly conversationId: string; + readonly turnId: string; + readonly allowSkip: boolean; + readonly questions: readonly IConversationQuestionItem[]; +} + +export interface IConversationTurnCommandButtonEvent { + readonly conversationId: string; + readonly turnId: string; + readonly commandId: string; + readonly title: string; + readonly args: readonly unknown[] | undefined; +} + +export interface IConversationAssistantAnchorItem { + readonly kind: 'anchor'; + readonly label: string; + readonly uri: string | undefined; +} + +export interface IConversationAssistantFileTreeItem { + readonly kind: 'fileTree'; + readonly baseUri: string; + readonly tree: string; +} + +export interface IConversationAssistantCodeblockUriItem { + readonly kind: 'codeblockUri'; + readonly uri: string; + readonly isEdit: boolean; + readonly undoStopId: string | undefined; +} + +export interface IConversationAssistantTextEditItem { + readonly kind: 'textEdit'; + readonly uri: string; + readonly editCount: number; + readonly isDone: boolean; +} + +export interface IConversationAssistantNotebookEditItem { + readonly kind: 'notebookEdit'; + readonly uri: string; + readonly editCount: number; + readonly isDone: boolean; +} + +export interface IConversationAssistantWorkspaceEditOperationItem { + readonly oldUri: string | undefined; + readonly newUri: string | undefined; +} + +export interface IConversationAssistantWorkspaceEditItem { + readonly kind: 'workspaceEdit'; + readonly edits: readonly IConversationAssistantWorkspaceEditOperationItem[]; +} + +export interface IConversationAssistantMoveItem { + readonly kind: 'move'; + readonly uri: string; + readonly startLine: number; + readonly endLine: number; +} + +export interface IConversationAssistantExtensionsItem { + readonly kind: 'extensions'; + readonly extensions: readonly string[]; +} + +export interface IConversationAssistantPullRequestItem { + readonly kind: 'pullRequest'; + readonly title: string; + readonly description: string; + readonly author: string; + readonly linkTag: string; + readonly commandId: string | undefined; + readonly commandArgs: readonly unknown[] | undefined; +} + +export interface IConversationAssistantExternalEditItem { + readonly kind: 'externalEdit'; + readonly uris: readonly string[]; +} + +export interface IConversationAssistantMultiDiffEntryItem { + readonly originalUri: string | undefined; + readonly modifiedUri: string | undefined; + readonly goToFileUri: string | undefined; + readonly added: number | undefined; + readonly removed: number | undefined; +} + +export interface IConversationAssistantMultiDiffItem { + readonly kind: 'multiDiff'; + readonly title: string; + readonly readOnly: boolean; + readonly entries: readonly IConversationAssistantMultiDiffEntryItem[]; +} + +export type IConversationAssistantExtraItem = + | IConversationAssistantAnchorItem + | IConversationAssistantFileTreeItem + | IConversationAssistantCodeblockUriItem + | IConversationAssistantTextEditItem + | IConversationAssistantNotebookEditItem + | IConversationAssistantWorkspaceEditItem + | IConversationAssistantMoveItem + | IConversationAssistantExtensionsItem + | IConversationAssistantPullRequestItem + | IConversationAssistantExternalEditItem + | IConversationAssistantMultiDiffItem; + +export interface IConversationTurnExtraEvent { + readonly conversationId: string; + readonly turnId: string; + readonly extra: IConversationAssistantExtraItem; +} + +export interface IConversationAssistantStatusItem { + readonly kind: IConversationAssistantStatusKind; + readonly content: string; +} + +export interface IConversationAssistantToolInvocationItem { + readonly toolName: string; + readonly toolCallId: string; + readonly message: string | undefined; + readonly isError: boolean; + readonly isComplete: boolean; +} + +export interface IConversationAssistantReferenceItem { + readonly label: string; + readonly uri: string | undefined; +} + +export interface IConversationAssistantCodeCitationItem { + readonly uri: string; + readonly license: string; + readonly snippet: string; +} + +export interface IConversationAssistantConfirmationItem { + readonly title: string; + readonly message: string; + readonly buttons: readonly string[] | undefined; +} + +export interface IConversationAssistantQuestionCarouselItem { + readonly allowSkip: boolean; + readonly questions: readonly IConversationQuestionItem[]; +} + +export interface IConversationAssistantCommandButtonItem { + readonly commandId: string; + readonly title: string; + readonly args: readonly unknown[] | undefined; +} + +export interface IConversationTurnArtifacts { + readonly statuses: readonly IConversationAssistantStatusItem[]; + readonly tools: readonly IConversationAssistantToolInvocationItem[]; + readonly references: readonly IConversationAssistantReferenceItem[]; + readonly codeCitations: readonly IConversationAssistantCodeCitationItem[]; + readonly confirmations: readonly IConversationAssistantConfirmationItem[]; + readonly questionCarousels: readonly IConversationAssistantQuestionCarouselItem[]; + readonly commandButtons: readonly IConversationAssistantCommandButtonItem[]; + readonly extras: readonly IConversationAssistantExtraItem[]; +} + export interface IConversationStore { readonly _serviceBrand: undefined; + readonly onDidConversationListChanged: Event; + readonly onDidUserTurn: Event; + readonly onDidAssistantTurnStart: Event; + readonly onDidAssistantTurnChunk: Event; + readonly onDidAssistantTurnReference: Event; + readonly onDidAssistantTurnCodeCitation: Event; + readonly onDidAssistantTurnStatus: Event; + readonly onDidAssistantTurnToolInvocation: Event; + readonly onDidAssistantTurnConfirmation: Event; + readonly onDidAssistantTurnQuestionCarousel: Event; + readonly onDidAssistantTurnCommandButton: Event; + readonly onDidAssistantTurnExtra: Event; + readonly onDidAssistantTurnComplete: Event; addConversation(responseId: string, conversation: Conversation): void; getConversation(responseId: string): Conversation | undefined; + getConversationBySessionId(sessionId: string): Conversation | undefined; + getAssistantTurnArtifacts(conversationId: string, turnId: string): IConversationTurnArtifacts | undefined; + listConversations(): readonly IConversationSummary[]; + reportUserTurn(event: IConversationTurnEvent): void; + reportAssistantTurnStart(event: IConversationTurnLifecycleEvent): void; + reportAssistantTurnChunk(event: IConversationTurnChunkEvent): void; + reportAssistantTurnReference(event: IConversationTurnReferenceEvent): void; + reportAssistantTurnCodeCitation(event: IConversationTurnCodeCitationEvent): void; + reportAssistantTurnStatus(event: IConversationTurnStatusEvent): void; + reportAssistantTurnToolInvocation(event: IConversationTurnToolInvocationEvent): void; + reportAssistantTurnConfirmation(event: IConversationTurnConfirmationEvent): void; + reportAssistantTurnQuestionCarousel(event: IConversationTurnQuestionCarouselEvent): void; + reportAssistantTurnCommandButton(event: IConversationTurnCommandButtonEvent): void; + reportAssistantTurnExtra(event: IConversationTurnExtraEvent): void; + reportAssistantTurnComplete(event: IConversationTurnLifecycleEvent): void; lastConversation: Conversation | undefined; } const CLEANUP_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const SUMMARIES_FILE = 'conversations.json'; +const PERSIST_DEBOUNCE_MS = 2000; +const TURN_ARTIFACTS_KEY_SEPARATOR = '\u0000'; + +type MutableConversationTurnArtifacts = { + statuses: IConversationAssistantStatusItem[]; + tools: IConversationAssistantToolInvocationItem[]; + references: IConversationAssistantReferenceItem[]; + codeCitations: IConversationAssistantCodeCitationItem[]; + confirmations: IConversationAssistantConfirmationItem[]; + questionCarousels: IConversationAssistantQuestionCarouselItem[]; + commandButtons: IConversationAssistantCommandButtonItem[]; + extras: IConversationAssistantExtraItem[]; +}; export class ConversationStore extends Disposable implements IConversationStore { readonly _serviceBrand: undefined; private readonly conversationMap: LRUCache; private readonly pendingCleanups: DisposableMap = this._register(new DisposableMap()); + private readonly _onDidConversationListChanged = this._register(new Emitter()); + readonly onDidConversationListChanged: Event = this._onDidConversationListChanged.event; + private readonly _onDidUserTurn = this._register(new Emitter()); + readonly onDidUserTurn: Event = this._onDidUserTurn.event; + private readonly _onDidAssistantTurnStart = this._register(new Emitter()); + readonly onDidAssistantTurnStart: Event = this._onDidAssistantTurnStart.event; + private readonly _onDidAssistantTurnChunk = this._register(new Emitter()); + readonly onDidAssistantTurnChunk: Event = this._onDidAssistantTurnChunk.event; + private readonly _onDidAssistantTurnReference = this._register(new Emitter()); + readonly onDidAssistantTurnReference: Event = this._onDidAssistantTurnReference.event; + private readonly _onDidAssistantTurnCodeCitation = this._register(new Emitter()); + readonly onDidAssistantTurnCodeCitation: Event = this._onDidAssistantTurnCodeCitation.event; + private readonly _onDidAssistantTurnStatus = this._register(new Emitter()); + readonly onDidAssistantTurnStatus: Event = this._onDidAssistantTurnStatus.event; + private readonly _onDidAssistantTurnToolInvocation = this._register(new Emitter()); + readonly onDidAssistantTurnToolInvocation: Event = this._onDidAssistantTurnToolInvocation.event; + private readonly _onDidAssistantTurnConfirmation = this._register(new Emitter()); + readonly onDidAssistantTurnConfirmation: Event = this._onDidAssistantTurnConfirmation.event; + private readonly _onDidAssistantTurnQuestionCarousel = this._register(new Emitter()); + readonly onDidAssistantTurnQuestionCarousel: Event = this._onDidAssistantTurnQuestionCarousel.event; + private readonly _onDidAssistantTurnCommandButton = this._register(new Emitter()); + readonly onDidAssistantTurnCommandButton: Event = this._onDidAssistantTurnCommandButton.event; + private readonly _onDidAssistantTurnExtra = this._register(new Emitter()); + readonly onDidAssistantTurnExtra: Event = this._onDidAssistantTurnExtra.event; + private readonly _onDidAssistantTurnComplete = this._register(new Emitter()); + readonly onDidAssistantTurnComplete: Event = this._onDidAssistantTurnComplete.event; + + private readonly persistedSummaries = new Map(); + private readonly assistantArtifactsByTurn = new Map(); + private readonly summariesUri: URI | undefined; + private persistTimer: ReturnType | undefined; constructor( @IChatSessionService chatSessionService: IChatSessionService, + @IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext, + @IFileSystemService private readonly fileSystemService: IFileSystemService, + @ILogService private readonly logService: ILogService, ) { super(); this.conversationMap = new LRUCache(1000); this._register(chatSessionService.onDidDisposeChatSession(sessionId => { this._scheduleSessionCleanup(sessionId); })); + + const globalStorageUri = extensionContext.globalStorageUri; + if (globalStorageUri) { + this.summariesUri = URI.joinPath(globalStorageUri, SUMMARIES_FILE); + void this.loadPersistedSummaries(); + } } addConversation(responseId: string, conversation: Conversation): void { this.conversationMap.set(responseId, conversation); this.pendingCleanups.deleteAndDispose(conversation.sessionId); + this._onDidConversationListChanged.fire(); + this.schedulePersist(); } getConversation(responseId: string): Conversation | undefined { @@ -51,6 +388,126 @@ export class ConversationStore extends Disposable implements IConversationStore return conversation; } + getConversationBySessionId(sessionId: string): Conversation | undefined { + let foundConversation: Conversation | undefined; + this.conversationMap.forEach(conversation => { + if (conversation.sessionId !== sessionId) { + return; + } + + if (!foundConversation || this.getConversationLastUpdated(conversation) > this.getConversationLastUpdated(foundConversation)) { + foundConversation = conversation; + } + }); + + if (foundConversation) { + this.pendingCleanups.deleteAndDispose(foundConversation.sessionId); + } + + return foundConversation; + } + + getAssistantTurnArtifacts(conversationId: string, turnId: string): IConversationTurnArtifacts | undefined { + const key = this.getAssistantTurnArtifactsKey(conversationId, turnId); + const artifacts = this.assistantArtifactsByTurn.get(key); + if (!artifacts) { + return undefined; + } + + return { + statuses: [...artifacts.statuses], + tools: [...artifacts.tools], + references: [...artifacts.references], + codeCitations: [...artifacts.codeCitations], + confirmations: [...artifacts.confirmations], + questionCarousels: [...artifacts.questionCarousels], + commandButtons: [...artifacts.commandButtons], + extras: [...artifacts.extras], + }; + } + + listConversations(): readonly IConversationSummary[] { + const conversationsBySession = new Map(); + + // Start with persisted summaries (historical) + for (const [id, summary] of this.persistedSummaries) { + conversationsBySession.set(id, summary); + } + + // In-memory conversations override persisted summaries + this.conversationMap.forEach(conversation => { + const id = conversation.sessionId; + const lastUpdated = this.getConversationLastUpdated(conversation); + const existing = conversationsBySession.get(id); + if (!existing || lastUpdated > existing.lastUpdated) { + conversationsBySession.set(id, { + id, + title: this.getConversationTitle(conversation), + lastUpdated, + }); + } + }); + + return Array.from(conversationsBySession.values()) + .sort((a, b) => b.lastUpdated - a.lastUpdated); + } + + reportUserTurn(event: IConversationTurnEvent): void { + this._onDidUserTurn.fire(event); + } + + reportAssistantTurnStart(event: IConversationTurnLifecycleEvent): void { + this._onDidAssistantTurnStart.fire(event); + } + + reportAssistantTurnChunk(event: IConversationTurnChunkEvent): void { + this._onDidAssistantTurnChunk.fire(event); + } + + reportAssistantTurnReference(event: IConversationTurnReferenceEvent): void { + this.recordAssistantReference(event); + this._onDidAssistantTurnReference.fire(event); + } + + reportAssistantTurnCodeCitation(event: IConversationTurnCodeCitationEvent): void { + this.recordAssistantCodeCitation(event); + this._onDidAssistantTurnCodeCitation.fire(event); + } + + reportAssistantTurnStatus(event: IConversationTurnStatusEvent): void { + this.recordAssistantStatus(event); + this._onDidAssistantTurnStatus.fire(event); + } + + reportAssistantTurnToolInvocation(event: IConversationTurnToolInvocationEvent): void { + this.recordAssistantToolInvocation(event); + this._onDidAssistantTurnToolInvocation.fire(event); + } + + reportAssistantTurnConfirmation(event: IConversationTurnConfirmationEvent): void { + this.recordAssistantConfirmation(event); + this._onDidAssistantTurnConfirmation.fire(event); + } + + reportAssistantTurnQuestionCarousel(event: IConversationTurnQuestionCarouselEvent): void { + this.recordAssistantQuestionCarousel(event); + this._onDidAssistantTurnQuestionCarousel.fire(event); + } + + reportAssistantTurnCommandButton(event: IConversationTurnCommandButtonEvent): void { + this.recordAssistantCommandButton(event); + this._onDidAssistantTurnCommandButton.fire(event); + } + + reportAssistantTurnExtra(event: IConversationTurnExtraEvent): void { + this.recordAssistantExtra(event); + this._onDidAssistantTurnExtra.fire(event); + } + + reportAssistantTurnComplete(event: IConversationTurnLifecycleEvent): void { + this._onDidAssistantTurnComplete.fire(event); + } + get lastConversation(): Conversation | undefined { const conversation = this.conversationMap.last; if (conversation) { @@ -81,5 +538,260 @@ export class ConversationStore extends Disposable implements IConversationStore for (const key of keysToDelete) { this.conversationMap.delete(key); } + + for (const key of this.assistantArtifactsByTurn.keys()) { + if (key.startsWith(`${sessionId}${TURN_ARTIFACTS_KEY_SEPARATOR}`)) { + this.assistantArtifactsByTurn.delete(key); + } + } + + if (keysToDelete.length > 0) { + this._onDidConversationListChanged.fire(); + this.schedulePersist(); + } + } + + private getConversationTitle(conversation: Conversation): string { + const firstUserTurn = conversation.turns.find(turn => turn.request.type === 'user' && turn.request.message.trim().length > 0); + const title = firstUserTurn?.request.message ?? conversation.getLatestTurn().request.message; + if (title.length <= 80) { + return title; + } + + return `${title.slice(0, 77)}...`; + } + + private getConversationLastUpdated(conversation: Conversation): number { + return conversation.getLatestTurn().startTime; + } + + private recordAssistantReference(event: IConversationTurnReferenceEvent): void { + const artifacts = this.getOrCreateAssistantTurnArtifacts(event.conversationId, event.turnId); + if (artifacts.references.some(item => item.label === event.label && item.uri === event.uri)) { + return; + } + + artifacts.references.push({ + label: event.label, + uri: event.uri, + }); + } + + private recordAssistantCodeCitation(event: IConversationTurnCodeCitationEvent): void { + const artifacts = this.getOrCreateAssistantTurnArtifacts(event.conversationId, event.turnId); + if (artifacts.codeCitations.some(item => item.uri === event.uri && item.license === event.license && item.snippet === event.snippet)) { + return; + } + + artifacts.codeCitations.push({ + uri: event.uri, + license: event.license, + snippet: event.snippet, + }); + } + + private recordAssistantStatus(event: IConversationTurnStatusEvent): void { + const artifacts = this.getOrCreateAssistantTurnArtifacts(event.conversationId, event.turnId); + if (artifacts.statuses.some(item => item.kind === event.kind && item.content === event.content)) { + return; + } + + artifacts.statuses.push({ + kind: event.kind, + content: event.content, + }); + } + + private recordAssistantToolInvocation(event: IConversationTurnToolInvocationEvent): void { + const artifacts = this.getOrCreateAssistantTurnArtifacts(event.conversationId, event.turnId); + const existingIndex = artifacts.tools.findIndex(item => item.toolCallId === event.toolCallId); + const updatedItem: IConversationAssistantToolInvocationItem = { + toolName: event.toolName, + toolCallId: event.toolCallId, + message: event.message, + isError: event.isError, + isComplete: event.isComplete, + }; + + if (existingIndex === -1) { + artifacts.tools.push(updatedItem); + return; + } + + const previous = artifacts.tools[existingIndex]; + artifacts.tools[existingIndex] = { + toolName: updatedItem.toolName, + toolCallId: updatedItem.toolCallId, + message: updatedItem.message ?? previous.message, + isError: updatedItem.isError, + isComplete: updatedItem.isComplete, + }; + } + + private recordAssistantConfirmation(event: IConversationTurnConfirmationEvent): void { + const artifacts = this.getOrCreateAssistantTurnArtifacts(event.conversationId, event.turnId); + const signature = `${event.title}\u0000${event.message}\u0000${event.buttons?.join('\u0000') ?? ''}`; + if (artifacts.confirmations.some(item => `${item.title}\u0000${item.message}\u0000${item.buttons?.join('\u0000') ?? ''}` === signature)) { + return; + } + + artifacts.confirmations.push({ + title: event.title, + message: event.message, + buttons: event.buttons, + }); + } + + private recordAssistantQuestionCarousel(event: IConversationTurnQuestionCarouselEvent): void { + const artifacts = this.getOrCreateAssistantTurnArtifacts(event.conversationId, event.turnId); + const signature = JSON.stringify({ + allowSkip: event.allowSkip, + questions: event.questions, + }); + if (artifacts.questionCarousels.some(item => JSON.stringify(item) === signature)) { + return; + } + + artifacts.questionCarousels.push({ + allowSkip: event.allowSkip, + questions: [...event.questions], + }); + } + + private recordAssistantCommandButton(event: IConversationTurnCommandButtonEvent): void { + const artifacts = this.getOrCreateAssistantTurnArtifacts(event.conversationId, event.turnId); + const signature = `${event.commandId}\u0000${event.title}\u0000${JSON.stringify(event.args ?? [])}`; + if (artifacts.commandButtons.some(item => `${item.commandId}\u0000${item.title}\u0000${JSON.stringify(item.args ?? [])}` === signature)) { + return; + } + + artifacts.commandButtons.push({ + commandId: event.commandId, + title: event.title, + args: event.args, + }); + } + + private recordAssistantExtra(event: IConversationTurnExtraEvent): void { + const artifacts = this.getOrCreateAssistantTurnArtifacts(event.conversationId, event.turnId); + const extra = event.extra; + + if (extra.kind === 'textEdit' || extra.kind === 'notebookEdit') { + const existingIndex = artifacts.extras.findIndex(item => item.kind === extra.kind && item.uri === extra.uri); + if (existingIndex === -1) { + artifacts.extras.push(extra); + return; + } + + const existing = artifacts.extras[existingIndex]; + if (existing.kind === extra.kind) { + artifacts.extras[existingIndex] = { + kind: extra.kind, + uri: extra.uri, + editCount: Math.max(existing.editCount, extra.editCount), + isDone: existing.isDone || extra.isDone, + }; + } + return; + } + + const signature = JSON.stringify(extra); + if (artifacts.extras.some(item => JSON.stringify(item) === signature)) { + return; + } + + artifacts.extras.push(extra); + } + + private getOrCreateAssistantTurnArtifacts(conversationId: string, turnId: string): MutableConversationTurnArtifacts { + const key = this.getAssistantTurnArtifactsKey(conversationId, turnId); + let artifacts = this.assistantArtifactsByTurn.get(key); + if (!artifacts) { + artifacts = { + statuses: [], + tools: [], + references: [], + codeCitations: [], + confirmations: [], + questionCarousels: [], + commandButtons: [], + extras: [], + }; + this.assistantArtifactsByTurn.set(key, artifacts); + } + + return artifacts; + } + + private getAssistantTurnArtifactsKey(conversationId: string, turnId: string): string { + return `${conversationId}${TURN_ARTIFACTS_KEY_SEPARATOR}${turnId}`; + } + + private schedulePersist(): void { + if (!this.summariesUri) { + return; + } + if (this.persistTimer !== undefined) { + clearTimeout(this.persistTimer); + } + this.persistTimer = setTimeout(() => { + this.persistTimer = undefined; + void this.persistSummaries(); + }, PERSIST_DEBOUNCE_MS); + } + + private async persistSummaries(): Promise { + if (!this.summariesUri) { + return; + } + + const summaries = this.listConversations(); + const data = JSON.stringify(summaries, undefined, '\t'); + const encoded = new TextEncoder().encode(data); + + try { + const dirUri = URI.joinPath(this.summariesUri, '..'); + await createDirectoryIfNotExists(this.fileSystemService, dirUri); + await this.fileSystemService.writeFile(this.summariesUri, encoded); + } catch (err) { + this.logService.warn(`[ConversationStore] Failed to persist conversation summaries: ${err instanceof Error ? err.message : String(err)}`); + } + } + + private async loadPersistedSummaries(): Promise { + if (!this.summariesUri) { + return; + } + + const parseAndStoreSummaries = (decoded: string): boolean => { + const parsed: unknown = JSON.parse(decoded); + if (!Array.isArray(parsed)) { + return false; + } + + for (const entry of parsed) { + if (typeof entry === 'object' && entry !== null && typeof entry.id === 'string' && typeof entry.title === 'string' && typeof entry.lastUpdated === 'number') { + this.persistedSummaries.set(entry.id, { + id: entry.id, + title: entry.title, + lastUpdated: entry.lastUpdated, + }); + } + } + + return true; + }; + + try { + const content = await this.fileSystemService.readFile(this.summariesUri); + const decoded = new TextDecoder().decode(content); + parseAndStoreSummaries(decoded); + } catch { + // File doesn't exist yet or is malformed. + } + + if (this.persistedSummaries.size > 0) { + this._onDidConversationListChanged.fire(); + } } } diff --git a/src/extension/extension/vscode-node/contributions.ts b/src/extension/extension/vscode-node/contributions.ts index c02806ac96..87a084776e 100644 --- a/src/extension/extension/vscode-node/contributions.ts +++ b/src/extension/extension/vscode-node/contributions.ts @@ -21,6 +21,7 @@ import { FeedbackCommandContribution } from '../../conversation/vscode-node/feed import { LanguageModelAccess } from '../../conversation/vscode-node/languageModelAccess'; import { LogWorkspaceStateContribution } from '../../conversation/vscode-node/logWorkspaceState'; import { RemoteAgentContribution } from '../../conversation/vscode-node/remoteAgents'; +import { SidecarContribution } from '../../conversation/vscode-node/sidecarContribution'; import { DiagnosticsContextContribution } from '../../diagnosticsContext/vscode/diagnosticsContextProvider'; import { LanguageModelProxyContrib } from '../../externalAgents/vscode-node/lmProxyContrib'; import { WalkthroughCommandContribution } from '../../getting-started/vscode-node/commands'; @@ -66,6 +67,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ ...vscodeContributions, asContributionFactory(ExtensionStateCommandContribution), asContributionFactory(ConversationFeature), + asContributionFactory(SidecarContribution), asContributionFactory(AuthenticationContrib), chatBlockLanguageContribution, asContributionFactory(LoggingActionsContrib), diff --git a/src/extension/prompt/node/chatParticipantRequestHandler.ts b/src/extension/prompt/node/chatParticipantRequestHandler.ts index 4a4a4a6566..37b17162d0 100644 --- a/src/extension/prompt/node/chatParticipantRequestHandler.ts +++ b/src/extension/prompt/node/chatParticipantRequestHandler.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as l10n from '@vscode/l10n'; -import type { ChatRequest, ChatRequestTurn2, ChatResponseStream, ChatResult, Location } from 'vscode'; +import type { ChatRequest, ChatRequestTurn2, ChatResponseStream, ChatResult, ExtendedChatResponsePart, Location } from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade'; import { getChatParticipantNameFromId } from '../../../platform/chat/common/chatAgents'; @@ -26,10 +26,10 @@ import { isEqual } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatResponseAnchorPart, ChatResponseFileTreePart, ChatResponseMarkdownPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseTurn, ChatLocation as VSChatLocation } from '../../../vscodeTypes'; +import { ChatQuestionType, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatResponseAnchorPart, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatToolInvocationPart, ChatLocation as VSChatLocation } from '../../../vscodeTypes'; import { ICommandService } from '../../commands/node/commandService'; import { getAgentForIntent, Intent } from '../../common/constants'; -import { IConversationStore } from '../../conversationStore/node/conversationStore'; +import { IConversationAssistantExtraItem, IConversationAssistantStatusKind, IConversationQuestionItem, IConversationQuestionType, IConversationStore, IConversationTurnCodeCitationEvent, IConversationTurnCommandButtonEvent, IConversationTurnConfirmationEvent, IConversationTurnExtraEvent, IConversationTurnQuestionCarouselEvent, IConversationTurnReferenceEvent, IConversationTurnStatusEvent, IConversationTurnToolInvocationEvent } from '../../conversationStore/node/conversationStore'; import { IIntentService } from '../../intents/node/intentService'; import { UnknownIntent } from '../../intents/node/unknownIntent'; import { ContributedToolName } from '../../tools/common/toolNames'; @@ -49,6 +49,20 @@ export interface IChatAgentArgs { intentId?: string; } +type ChatResponseMultiDiffEntryLike = { + readonly originalUri?: { toString(): string }; + readonly modifiedUri?: { toString(): string }; + readonly goToFileUri?: { toString(): string }; + readonly added?: number; + readonly removed?: number; +}; + +type ChatResponseMultiDiffPartLike = { + readonly value: readonly ChatResponseMultiDiffEntryLike[]; + readonly title: string; + readonly readOnly?: boolean; +}; + /** * Handles a single chat request: * 1) selects intent @@ -130,6 +144,63 @@ export class ChatParticipantRequestHandler { this.conversation = new Conversation(actualSessionId, turns.concat(latestTurn)); this.turn = latestTurn; + this._conversationStore.reportUserTurn({ + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + content: this.turn.request.message, + timestamp: this.turn.startTime, + }); + + this.stream = ChatResponseStreamImpl.spy(this.stream, part => { + const chunk = this.getStreamingChunk(part); + if (chunk) { + this._conversationStore.reportAssistantTurnChunk({ + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + content: chunk, + }); + } + + const statusEvent = this.getStreamingStatus(part); + if (statusEvent) { + this._conversationStore.reportAssistantTurnStatus(statusEvent); + } + + const reference = this.getStreamingReference(part); + if (reference) { + this._conversationStore.reportAssistantTurnReference(reference); + } + + const codeCitation = this.getStreamingCodeCitation(part); + if (codeCitation) { + this._conversationStore.reportAssistantTurnCodeCitation(codeCitation); + } + + const toolInvocation = this.getStreamingToolInvocation(part); + if (toolInvocation) { + this._conversationStore.reportAssistantTurnToolInvocation(toolInvocation); + } + + const confirmation = this.getStreamingConfirmation(part); + if (confirmation) { + this._conversationStore.reportAssistantTurnConfirmation(confirmation); + } + + const questionCarousel = this.getStreamingQuestionCarousel(part); + if (questionCarousel) { + this._conversationStore.reportAssistantTurnQuestionCarousel(questionCarousel); + } + + const commandButton = this.getStreamingCommandButton(part); + if (commandButton) { + this._conversationStore.reportAssistantTurnCommandButton(commandButton); + } + + const extra = this.getStreamingExtra(part); + if (extra) { + this._conversationStore.reportAssistantTurnExtra(extra); + } + }); } private getLocation(request: ChatRequest) { @@ -150,6 +221,495 @@ export class ChatParticipantRequestHandler { } } + private getStreamingChunk(part: ExtendedChatResponsePart): string | undefined { + if (part instanceof ChatResponseMarkdownPart || part instanceof ChatResponseMarkdownWithVulnerabilitiesPart) { + const value = typeof part.value === 'string' ? part.value : part.value.value; + if (value.length > 0) { + return value; + } + } + + return undefined; + } + + private getStreamingStatus(part: ExtendedChatResponsePart): IConversationTurnStatusEvent | undefined { + if (part instanceof ChatResponseProgressPart || part instanceof ChatResponseProgressPart2) { + return this.createStatusEvent('progress', this.extractTextValue(part.value)); + } + + if (part instanceof ChatResponseWarningPart) { + return this.createStatusEvent('warning', this.extractTextValue(part.value)); + } + + if (part instanceof ChatResponseThinkingProgressPart) { + const value = Array.isArray(part.value) ? part.value.join('\n') : part.value; + return this.createStatusEvent('thinking', value); + } + + return undefined; + } + + private createStatusEvent(kind: IConversationAssistantStatusKind, content: string | undefined): IConversationTurnStatusEvent | undefined { + if (!content) { + return undefined; + } + + return { + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + kind, + content, + }; + } + + private getStreamingReference(part: ExtendedChatResponsePart): IConversationTurnReferenceEvent | undefined { + if (!(part instanceof ChatResponseReferencePart || part instanceof ChatResponseReferencePart2)) { + return undefined; + } + + const serialized = this.serializeReferenceValue(part.value); + if (!serialized) { + return undefined; + } + + return { + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + label: serialized.label, + uri: serialized.uri, + }; + } + + private getStreamingCodeCitation(part: ExtendedChatResponsePart): IConversationTurnCodeCitationEvent | undefined { + if (!(part instanceof ChatResponseCodeCitationPart)) { + return undefined; + } + + return { + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + uri: part.value.toString(), + license: part.license, + snippet: part.snippet, + }; + } + + private getStreamingToolInvocation(part: ExtendedChatResponsePart): IConversationTurnToolInvocationEvent | undefined { + if (!(part instanceof ChatToolInvocationPart)) { + return undefined; + } + + const toolName = part.toolName.trim(); + const toolCallId = part.toolCallId.trim(); + if (!toolName || !toolCallId) { + return undefined; + } + + const message = + this.extractTextValue(part.invocationMessage) + ?? this.extractTextValue(part.pastTenseMessage) + ?? this.extractTextValue(part.originMessage); + + return { + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + toolName, + toolCallId, + message, + isError: Boolean(part.isError), + isComplete: Boolean(part.isComplete), + }; + } + + private getStreamingConfirmation(part: ExtendedChatResponsePart): IConversationTurnConfirmationEvent | undefined { + if (!(part instanceof ChatResponseConfirmationPart)) { + return undefined; + } + + const title = part.title.trim(); + const message = this.extractTextValue(part.message) ?? ''; + if (!title && !message) { + return undefined; + } + + return { + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + title, + message, + buttons: Array.isArray(part.buttons) && part.buttons.length > 0 ? [...part.buttons] : undefined, + }; + } + + private getStreamingQuestionCarousel(part: ExtendedChatResponsePart): IConversationTurnQuestionCarouselEvent | undefined { + if (!(part instanceof ChatResponseQuestionCarouselPart)) { + return undefined; + } + + const questions: IConversationQuestionItem[] = []; + for (const question of part.questions) { + const title = question.title.trim(); + if (!title) { + continue; + } + + questions.push({ + id: question.id, + type: this.toConversationQuestionType(question.type), + title, + message: this.extractTextValue(question.message), + options: Array.isArray(question.options) && question.options.length > 0 + ? question.options.map(option => option.label) + : undefined, + allowFreeformInput: Boolean(question.allowFreeformInput), + }); + } + + if (questions.length === 0) { + return undefined; + } + + return { + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + allowSkip: Boolean(part.allowSkip), + questions, + }; + } + + private toConversationQuestionType(type: ChatQuestionType): IConversationQuestionType { + switch (type) { + case ChatQuestionType.Text: + return 'text'; + case ChatQuestionType.SingleSelect: + return 'singleSelect'; + case ChatQuestionType.MultiSelect: + return 'multiSelect'; + default: + return 'unknown'; + } + } + + private getStreamingCommandButton(part: ExtendedChatResponsePart): IConversationTurnCommandButtonEvent | undefined { + if (!(part instanceof ChatResponseCommandButtonPart)) { + return undefined; + } + + const commandId = typeof part.value.command === 'string' ? part.value.command.trim() : ''; + if (!commandId) { + return undefined; + } + + const title = typeof part.value.title === 'string' && part.value.title.trim().length > 0 + ? part.value.title.trim() + : commandId; + + return { + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + commandId, + title, + args: Array.isArray(part.value.arguments) ? [...part.value.arguments] : undefined, + }; + } + + private getStreamingExtra(part: ExtendedChatResponsePart): IConversationTurnExtraEvent | undefined { + const extra = this.toStreamingExtra(part); + if (!extra) { + return undefined; + } + + return { + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + extra, + }; + } + + private toStreamingExtra(part: ExtendedChatResponsePart): IConversationAssistantExtraItem | undefined { + if (part instanceof ChatResponseFileTreePart) { + const tree = fileTreePartToMarkdown(part).trim(); + if (!tree) { + return undefined; + } + + return { + kind: 'fileTree', + baseUri: part.baseUri.toString(), + tree, + }; + } + + if (part instanceof ChatResponseAnchorPart) { + const serialized = this.serializeAnchorPart(part); + if (!serialized) { + return undefined; + } + + return { + kind: 'anchor', + label: serialized.label, + uri: serialized.uri, + }; + } + + if (part instanceof ChatResponseCodeblockUriPart) { + return { + kind: 'codeblockUri', + uri: part.value.toString(), + isEdit: Boolean(part.isEdit), + undoStopId: part.undoStopId, + }; + } + + if (part instanceof ChatResponseTextEditPart) { + return { + kind: 'textEdit', + uri: part.uri.toString(), + editCount: Array.isArray(part.edits) ? part.edits.length : 0, + isDone: Boolean(part.isDone), + }; + } + + if (part instanceof ChatResponseNotebookEditPart) { + return { + kind: 'notebookEdit', + uri: part.uri.toString(), + editCount: Array.isArray(part.edits) ? part.edits.length : 0, + isDone: Boolean(part.isDone), + }; + } + + if (part instanceof ChatResponseWorkspaceEditPart) { + const edits = part.edits + .map(edit => ({ + oldUri: edit.oldResource?.toString(), + newUri: edit.newResource?.toString(), + })) + .filter(edit => edit.oldUri || edit.newUri); + if (edits.length === 0) { + return undefined; + } + + return { + kind: 'workspaceEdit', + edits, + }; + } + + if (part instanceof ChatResponseMovePart) { + return { + kind: 'move', + uri: part.uri.toString(), + startLine: part.range.start.line + 1, + endLine: part.range.end.line + 1, + }; + } + + if (part instanceof ChatResponseExtensionsPart) { + const extensions = part.extensions + .map(extensionId => extensionId.trim()) + .filter(extensionId => extensionId.length > 0); + if (extensions.length === 0) { + return undefined; + } + + return { + kind: 'extensions', + extensions, + }; + } + + if (part instanceof ChatResponsePullRequestPart) { + const title = part.title.trim(); + const description = part.description.trim(); + const author = part.author.trim(); + const linkTag = part.linkTag.trim(); + if (!title && !description && !author) { + return undefined; + } + + const commandId = typeof part.command?.command === 'string' && part.command.command.trim().length > 0 + ? part.command.command.trim() + : undefined; + + return { + kind: 'pullRequest', + title: title || 'Pull request', + description, + author, + linkTag, + commandId, + commandArgs: Array.isArray(part.command?.arguments) ? [...part.command.arguments] : undefined, + }; + } + + if (part instanceof ChatResponseExternalEditPart) { + const uris = part.uris + .map(uri => uri.toString()) + .filter(uri => uri.trim().length > 0); + if (uris.length === 0) { + return undefined; + } + + return { + kind: 'externalEdit', + uris, + }; + } + + if (this.isChatResponseMultiDiffPart(part)) { + const entries = part.value + .map(entry => ({ + originalUri: entry.originalUri?.toString(), + modifiedUri: entry.modifiedUri?.toString(), + goToFileUri: entry.goToFileUri?.toString(), + added: typeof entry.added === 'number' ? entry.added : undefined, + removed: typeof entry.removed === 'number' ? entry.removed : undefined, + })) + .filter(entry => entry.originalUri || entry.modifiedUri || entry.goToFileUri || entry.added !== undefined || entry.removed !== undefined); + if (entries.length === 0 && part.title.trim().length === 0) { + return undefined; + } + + return { + kind: 'multiDiff', + title: part.title.trim() || 'Changes', + readOnly: Boolean(part.readOnly), + entries, + }; + } + + return undefined; + } + + private isChatResponseMultiDiffPart(part: ExtendedChatResponsePart): part is ExtendedChatResponsePart & ChatResponseMultiDiffPartLike { + const candidate = part as Partial; + return Array.isArray(candidate.value) + && typeof candidate.title === 'string' + && (candidate.readOnly === undefined || typeof candidate.readOnly === 'boolean'); + } + + private extractTextValue(value: unknown): string | undefined { + if (typeof value === 'string') { + const normalized = value.trim(); + return normalized || undefined; + } + + if (typeof value === 'object' && value !== null && 'value' in value && typeof value.value === 'string') { + const normalized = value.value.trim(); + return normalized || undefined; + } + + return undefined; + } + + private serializeReferenceValue(value: unknown): { label: string; uri: string | undefined } | undefined { + if (typeof value === 'string') { + const label = value.trim(); + if (!label) { + return undefined; + } + + return { + label, + uri: undefined, + }; + } + + if (URI.isUri(value)) { + return { + label: this.referenceLabelFromUri(value), + uri: value.toString(), + }; + } + + if (isLocation(value)) { + return { + label: this.referenceLabelFromLocation(value), + uri: value.uri.toString(), + }; + } + + if (typeof value === 'object' && value !== null) { + const variableReference = value as { variableName?: unknown; value?: unknown }; + if (typeof variableReference.variableName !== 'string') { + return undefined; + } + + const label = variableReference.variableName.trim(); + const nestedValue = variableReference.value; + const nestedUri = this.referenceUriFromUnknown(nestedValue); + if (!label && !nestedUri) { + return undefined; + } + + return { + label: label || this.referenceLabelFromUri(nestedUri!), + uri: nestedUri?.toString(), + }; + } + + return undefined; + } + + private serializeAnchorPart(part: ChatResponseAnchorPart): { label: string; uri: string | undefined } | undefined { + const anchorWithValue2 = part as ChatResponseAnchorPart & { value2?: unknown }; + const value = anchorWithValue2.value2 ?? part.value; + const title = typeof part.title === 'string' ? part.title.trim() : ''; + + if (isSymbolInformation(value)) { + const symbolName = value.name.trim(); + if (!title && !symbolName) { + return undefined; + } + + return { + label: title || symbolName, + uri: value.location?.uri?.toString(), + }; + } + + const serialized = this.serializeReferenceValue(value); + if (!serialized) { + return undefined; + } + + return { + label: title || serialized.label, + uri: serialized.uri, + }; + } + + private referenceUriFromUnknown(value: unknown): URI | undefined { + if (!value) { + return undefined; + } + + if (URI.isUri(value)) { + return value; + } + + if (isLocation(value)) { + return value.uri; + } + + return undefined; + } + + private referenceLabelFromUri(uri: URI): string { + const pathSegments = uri.path.split('/'); + const basename = pathSegments[pathSegments.length - 1]; + return basename || uri.toString(); + } + + private referenceLabelFromLocation(location: Location): string { + const startLine = location.range.start.line + 1; + const endLine = location.range.end.line + 1; + const lineSuffix = startLine === endLine ? `#L${startLine}` : `#L${startLine}-${endLine}`; + return `${this.referenceLabelFromUri(location.uri)}${lineSuffix}`; + } + private async sanitizeVariables(): Promise { const variablePromises = this.request.references.map(async (ref) => { const uri = isLocation(ref.value) ? ref.value.uri : URI.isUri(ref.value) ? ref.value : undefined; @@ -215,6 +775,10 @@ export class ChatParticipantRequestHandler { }; } this._logService.trace(`[${ChatLocation.toStringShorter(this.location)}] chat request received from extension host`); + this._conversationStore.reportAssistantTurnStart({ + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + }); try { // sanitize the variables of all requests @@ -275,9 +839,19 @@ export class ChatParticipantRequestHandler { } } satisfies ICopilotChatResult, true); + this._conversationStore.reportAssistantTurnComplete({ + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + }); + return result; } catch (err) { + this._conversationStore.reportAssistantTurnComplete({ + conversationId: this.conversation.sessionId, + turnId: this.turn.id, + }); + // TODO This method should not throw at all, but return a result with errorDetails, and call the IConversationStore throw err; } diff --git a/src/platform/authentication/vscode-node/copilotTokenManager.ts b/src/platform/authentication/vscode-node/copilotTokenManager.ts index ba3ee607f2..0a4246da8f 100644 --- a/src/platform/authentication/vscode-node/copilotTokenManager.ts +++ b/src/platform/authentication/vscode-node/copilotTokenManager.ts @@ -31,6 +31,7 @@ export class GitHubLoginFailedError extends Error { } export class VSCodeCopilotTokenManager extends BaseCopilotTokenManager { private _taskSingler = new TaskSingler(); + private hasLoggedGitHubLoginFailed = false; constructor( @ILogService logService: ILogService, @@ -73,11 +74,11 @@ export class VSCodeCopilotTokenManager extends BaseCopilotTokenManager { const allowNoAuthAccess = this.configurationService.getNonExtensionConfig('chat.allowAnonymousAccess'); const session = await getAnyAuthSession(this.configurationService, { silent: true }); if (!session && !allowNoAuthAccess) { - this._logService.warn('GitHub login failed'); - this._telemetryService.sendGHTelemetryErrorEvent('auth.github_login_failed'); + this.logGitHubLoginFailedOnce(); return { kind: 'failure', reason: 'GitHubLoginFailed' }; } if (session) { + this.hasLoggedGitHubLoginFailed = false; // Log the steps by default, but only log actual token values when the log level is set to debug. this._logService.info(`Logged in as ${session.account.label}`); const tokenResult = await this.authFromGitHubToken(session.accessToken, session.account.label); @@ -90,16 +91,28 @@ export class VSCodeCopilotTokenManager extends BaseCopilotTokenManager { this._logService.info(`Allowing anonymous access with devDeviceId`); const tokenResult = await this.authFromDevDeviceId(env.devDeviceId); if (tokenResult.kind === 'success') { + this.hasLoggedGitHubLoginFailed = false; this._logService.info(`Got Copilot token for devDeviceId`); this._logService.info(`Copilot Chat: ${this._envService.getVersion()}, VS Code: ${this._envService.vscodeVersion}`); } else { - this._logService.warn('GitHub login failed'); + this.logGitHubLoginFailedOnce(); return { kind: 'failure', reason: 'GitHubLoginFailed' }; } return tokenResult; } } + private logGitHubLoginFailedOnce(): void { + if (!this.hasLoggedGitHubLoginFailed) { + this._logService.warn('GitHub login failed'); + this._telemetryService.sendGHTelemetryErrorEvent('auth.github_login_failed'); + this.hasLoggedGitHubLoginFailed = true; + return; + } + + this._logService.debug('GitHub login still unavailable'); + } + private async _authShowWarnings(): Promise { const tokenResult = await this._taskSingler.getOrCreate('auth', () => this._auth()); this.sendTokenResultErrorTelemetry(tokenResult); diff --git a/src/platform/bridge/bridgeServer.ts b/src/platform/bridge/bridgeServer.ts new file mode 100644 index 0000000000..31824587a7 --- /dev/null +++ b/src/platform/bridge/bridgeServer.ts @@ -0,0 +1,659 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { randomBytes } from 'crypto'; +import { createServer, Server as HttpServer, IncomingMessage, ServerResponse } from 'http'; +import { AddressInfo } from 'net'; +import { RawData, WebSocket, WebSocketServer } from 'ws'; +import { Emitter, Event } from '../../util/vs/base/common/event'; +import { Disposable } from '../../util/vs/base/common/lifecycle'; + +export interface BridgeConversationSummary { + readonly id: string; + readonly title: string; + readonly lastUpdated: number; + readonly provider?: BridgeConversationProvider; + readonly status?: BridgeConversationStatus; +} + +export type BridgeConversationProvider = 'local' | 'claude' | 'copilot-cli' | 'cloud' | 'codex' | 'unknown'; + +export type BridgeConversationStatus = 'completed' | 'in-progress' | 'input-needed' | 'failed' | 'read' | 'archived' | 'unknown'; + +export interface BridgeConversationFilter { + readonly providers?: readonly string[]; + readonly statuses?: readonly string[]; + readonly search?: string; +} + +export type BridgeAssistantStatusKind = 'progress' | 'warning' | 'thinking'; + +export interface BridgeAssistantStatusItem { + readonly kind: BridgeAssistantStatusKind; + readonly content: string; +} + +export interface BridgeAssistantToolInvocationItem { + readonly toolName: string; + readonly toolCallId: string; + readonly message: string | undefined; + readonly isError: boolean; + readonly isComplete: boolean; +} + +export interface BridgeAssistantReferenceItem { + readonly label: string; + readonly uri: string | undefined; +} + +export interface BridgeAssistantCodeCitationItem { + readonly uri: string; + readonly license: string; + readonly snippet: string; +} + +export interface BridgeAssistantConfirmationItem { + readonly title: string; + readonly message: string; + readonly buttons: readonly string[] | undefined; +} + +export type BridgeQuestionType = 'text' | 'singleSelect' | 'multiSelect' | 'unknown'; + +export interface BridgeQuestionItem { + readonly id: string; + readonly type: BridgeQuestionType; + readonly title: string; + readonly message: string | undefined; + readonly options: readonly string[] | undefined; + readonly allowFreeformInput: boolean; +} + +export interface BridgeAssistantQuestionCarouselItem { + readonly allowSkip: boolean; + readonly questions: readonly BridgeQuestionItem[]; +} + +export interface BridgeAssistantCommandButtonItem { + readonly commandId: string; + readonly title: string; + readonly args: readonly unknown[] | undefined; +} + +export interface BridgeAssistantAnchorItem { + readonly kind: 'anchor'; + readonly label: string; + readonly uri: string | undefined; +} + +export interface BridgeAssistantFileTreeItem { + readonly kind: 'fileTree'; + readonly baseUri: string; + readonly tree: string; +} + +export interface BridgeAssistantCodeblockUriItem { + readonly kind: 'codeblockUri'; + readonly uri: string; + readonly isEdit: boolean; + readonly undoStopId: string | undefined; +} + +export interface BridgeAssistantTextEditItem { + readonly kind: 'textEdit'; + readonly uri: string; + readonly editCount: number; + readonly isDone: boolean; +} + +export interface BridgeAssistantNotebookEditItem { + readonly kind: 'notebookEdit'; + readonly uri: string; + readonly editCount: number; + readonly isDone: boolean; +} + +export interface BridgeAssistantWorkspaceEditOperationItem { + readonly oldUri: string | undefined; + readonly newUri: string | undefined; +} + +export interface BridgeAssistantWorkspaceEditItem { + readonly kind: 'workspaceEdit'; + readonly edits: readonly BridgeAssistantWorkspaceEditOperationItem[]; +} + +export interface BridgeAssistantMoveItem { + readonly kind: 'move'; + readonly uri: string; + readonly startLine: number; + readonly endLine: number; +} + +export interface BridgeAssistantExtensionsItem { + readonly kind: 'extensions'; + readonly extensions: readonly string[]; +} + +export interface BridgeAssistantPullRequestItem { + readonly kind: 'pullRequest'; + readonly title: string; + readonly description: string; + readonly author: string; + readonly linkTag: string; + readonly commandId: string | undefined; + readonly commandArgs: readonly unknown[] | undefined; +} + +export interface BridgeAssistantExternalEditItem { + readonly kind: 'externalEdit'; + readonly uris: readonly string[]; +} + +export interface BridgeAssistantMultiDiffEntryItem { + readonly originalUri: string | undefined; + readonly modifiedUri: string | undefined; + readonly goToFileUri: string | undefined; + readonly added: number | undefined; + readonly removed: number | undefined; +} + +export interface BridgeAssistantMultiDiffItem { + readonly kind: 'multiDiff'; + readonly title: string; + readonly readOnly: boolean; + readonly entries: readonly BridgeAssistantMultiDiffEntryItem[]; +} + +export type BridgeAssistantExtraItem = + | BridgeAssistantAnchorItem + | BridgeAssistantFileTreeItem + | BridgeAssistantCodeblockUriItem + | BridgeAssistantTextEditItem + | BridgeAssistantNotebookEditItem + | BridgeAssistantWorkspaceEditItem + | BridgeAssistantMoveItem + | BridgeAssistantExtensionsItem + | BridgeAssistantPullRequestItem + | BridgeAssistantExternalEditItem + | BridgeAssistantMultiDiffItem; + +export interface BridgeAssistantTurnArtifacts { + readonly statuses?: readonly BridgeAssistantStatusItem[]; + readonly tools?: readonly BridgeAssistantToolInvocationItem[]; + readonly references?: readonly BridgeAssistantReferenceItem[]; + readonly codeCitations?: readonly BridgeAssistantCodeCitationItem[]; + readonly confirmations?: readonly BridgeAssistantConfirmationItem[]; + readonly questionCarousels?: readonly BridgeAssistantQuestionCarouselItem[]; + readonly commandButtons?: readonly BridgeAssistantCommandButtonItem[]; + readonly extras?: readonly BridgeAssistantExtraItem[]; +} + +export interface BridgeTurnHistoryItem { + readonly role: 'user' | 'assistant'; + readonly content: string; + readonly timestamp: number; + readonly toolLines?: readonly string[]; + readonly artifacts?: BridgeAssistantTurnArtifacts; +} + +export interface BridgeModeOption { + readonly id: string; + readonly label: string; +} + +export interface BridgeModelOption { + readonly id: string; + readonly label: string; + readonly vendor: string; + readonly family: string | undefined; +} + +export type BridgeUiCommandId = string; + +export type BridgeMessage = + | { + type: 'conversation:list'; + conversations: readonly BridgeConversationSummary[]; + } + | { + type: 'ui:state'; + modes: readonly BridgeModeOption[]; + selectedModeId: string; + models: readonly BridgeModelOption[]; + selectedModelId: string | undefined; + workspaceLabel?: string; + } + | { + type: 'conversation:history'; + conversationId: string; + turns: readonly BridgeTurnHistoryItem[]; + } + | { + type: 'turn:start'; + conversationId: string; + turnId: string; + role: 'assistant'; + } + | { + type: 'turn:chunk'; + conversationId: string; + turnId: string; + content: string; + } + | { + type: 'turn:reference'; + conversationId: string; + turnId: string; + label: string; + uri?: string; + } + | { + type: 'turn:codeCitation'; + conversationId: string; + turnId: string; + uri: string; + license: string; + snippet: string; + } + | { + type: 'turn:status'; + conversationId: string; + turnId: string; + kind: BridgeAssistantStatusKind; + content: string; + } + | { + type: 'turn:tool'; + conversationId: string; + turnId: string; + toolName: string; + toolCallId: string; + message?: string; + isError: boolean; + isComplete: boolean; + } + | { + type: 'turn:confirmation'; + conversationId: string; + turnId: string; + title: string; + message: string; + buttons?: readonly string[]; + } + | { + type: 'turn:questions'; + conversationId: string; + turnId: string; + allowSkip: boolean; + questions: readonly BridgeQuestionItem[]; + } + | { + type: 'turn:button'; + conversationId: string; + turnId: string; + commandId: string; + title: string; + args?: readonly unknown[]; + } + | { + type: 'turn:extra'; + conversationId: string; + turnId: string; + extra: BridgeAssistantExtraItem; + } + | { + type: 'turn:complete'; + conversationId: string; + turnId: string; + } + | { + type: 'turn:user'; + conversationId: string; + turnId: string; + content: string; + }; + +type PromptSubmitMessage = { + type: 'prompt:submit'; + content: string; + conversationId?: string; +}; + +type ConversationSelectMessage = { + type: 'conversation:select'; + conversationId: string; +}; + +type ConversationListRequestMessage = { + type: 'conversation:list:request'; + filter?: BridgeConversationFilter; +}; + +type UiModelSetMessage = { + type: 'ui:model:set'; + modelId: string; +}; + +type UiModeSetMessage = { + type: 'ui:mode:set'; + modeId: string; +}; + +type UiCommandMessage = { + type: 'ui:command'; + commandId: BridgeUiCommandId; + args?: readonly unknown[]; +}; + +type ClientMessage = PromptSubmitMessage | ConversationSelectMessage | ConversationListRequestMessage | UiModelSetMessage | UiModeSetMessage | UiCommandMessage; + +const SESSION_TOKEN_BYTES = 16; + +function createSessionToken(): string { + return randomBytes(SESSION_TOKEN_BYTES).toString('hex'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every(item => typeof item === 'string'); +} + +function isConversationFilter(value: unknown): value is BridgeConversationFilter { + if (!isRecord(value)) { + return false; + } + + const providers = value.providers; + const statuses = value.statuses; + const search = value.search; + + if (providers !== undefined && !isStringArray(providers)) { + return false; + } + + if (statuses !== undefined && !isStringArray(statuses)) { + return false; + } + + if (search !== undefined && typeof search !== 'string') { + return false; + } + + return true; +} + +function isClientMessage(value: unknown): value is ClientMessage { + if (!isRecord(value) || typeof value.type !== 'string') { + return false; + } + + switch (value.type) { + case 'prompt:submit': { + return typeof value.content === 'string' && (value.conversationId === undefined || typeof value.conversationId === 'string'); + } + case 'conversation:select': { + return typeof value.conversationId === 'string'; + } + case 'conversation:list:request': { + return value.filter === undefined || isConversationFilter(value.filter); + } + case 'ui:model:set': { + return typeof value.modelId === 'string'; + } + case 'ui:mode:set': { + return typeof value.modeId === 'string'; + } + case 'ui:command': { + return typeof value.commandId === 'string' + && (value.args === undefined || Array.isArray(value.args)); + } + default: { + return false; + } + } +} + +function toRawText(data: RawData): string { + if (typeof data === 'string') { + return data; + } + + if (Buffer.isBuffer(data)) { + return data.toString('utf8'); + } + + if (Array.isArray(data)) { + return Buffer.concat(data).toString('utf8'); + } + + return data.toString(); +} + +export class BridgeServer extends Disposable { + private server: HttpServer | undefined; + private wss: WebSocketServer | undefined; + private readonly clients = new Set(); + private _sessionToken = createSessionToken(); + + private readonly _onDidClientCountChange = this._register(new Emitter()); + readonly onDidClientCountChange: Event = this._onDidClientCountChange.event; + + onPromptReceived: ((prompt: string, conversationId?: string) => void) | undefined; + onConversationSelected: ((conversationId: string, respond: (message: BridgeMessage) => void) => void) | undefined; + onConversationListRequested: ((respond: (message: BridgeMessage) => void, filter?: BridgeConversationFilter) => void) | undefined; + onUiModelSelected: ((modelId: string) => void) | undefined; + onUiModeSelected: ((modeId: string) => void) | undefined; + onUiCommandRequested: ((commandId: BridgeUiCommandId, args?: readonly unknown[]) => void) | undefined; + + get sessionToken(): string { + return this._sessionToken; + } + + get connectedClients(): number { + return this.clients.size; + } + + async start(port = 0): Promise { + if (this.server) { + const currentAddress = this.server.address(); + if (currentAddress && typeof currentAddress !== 'string') { + return currentAddress.port; + } + } + + this.server = createServer((req, res) => this.handleHttpRequest(req, res)); + this.wss = new WebSocketServer({ noServer: true }); + + this.server.on('upgrade', (request, socket, head) => { + if (!this.wss) { + socket.destroy(); + return; + } + + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + const token = requestUrl.searchParams.get('token'); + if (token !== this._sessionToken) { + socket.write('HTTP/1.1 401 Unauthorized\\r\\nConnection: close\\r\\n\\r\\n'); + socket.destroy(); + return; + } + + this.wss.handleUpgrade(request, socket, head, websocket => { + this.wss?.emit('connection', websocket, request); + }); + }); + + this.wss.on('connection', websocket => { + this.clients.add(websocket); + this._onDidClientCountChange.fire(this.clients.size); + + const respond = (message: BridgeMessage) => { + this.sendToClient(websocket, message); + }; + + this.onConversationListRequested?.(respond); + + websocket.on('message', data => { + const text = toRawText(data); + let parsedMessage: unknown; + try { + parsedMessage = JSON.parse(text); + } catch { + return; + } + + if (!isClientMessage(parsedMessage)) { + return; + } + + switch (parsedMessage.type) { + case 'prompt:submit': { + const prompt = parsedMessage.content.trim(); + if (prompt.length > 0) { + this.onPromptReceived?.(prompt, parsedMessage.conversationId); + } + break; + } + case 'conversation:select': { + this.onConversationSelected?.(parsedMessage.conversationId, respond); + break; + } + case 'conversation:list:request': { + this.onConversationListRequested?.(respond, parsedMessage.filter); + break; + } + case 'ui:model:set': { + this.onUiModelSelected?.(parsedMessage.modelId); + break; + } + case 'ui:mode:set': { + this.onUiModeSelected?.(parsedMessage.modeId); + break; + } + case 'ui:command': { + this.onUiCommandRequested?.(parsedMessage.commandId, parsedMessage.args); + break; + } + } + }); + + websocket.on('close', () => { + this.clients.delete(websocket); + this._onDidClientCountChange.fire(this.clients.size); + }); + + websocket.on('error', () => { + this.clients.delete(websocket); + this._onDidClientCountChange.fire(this.clients.size); + }); + }); + + await new Promise((resolve, reject) => { + if (!this.server) { + reject(new Error('Bridge server failed to initialize.')); + return; + } + + const errorListener = (error: Error) => { + this.server?.off('listening', listeningListener); + reject(error); + }; + const listeningListener = () => { + this.server?.off('error', errorListener); + resolve(); + }; + + this.server.once('error', errorListener); + this.server.once('listening', listeningListener); + this.server.listen(port, '127.0.0.1'); + }); + + const address = this.server.address(); + if (!address || typeof address === 'string') { + throw new Error('Bridge server failed to bind to a TCP port.'); + } + + return (address as AddressInfo).port; + } + + async stop(): Promise { + for (const client of this.clients) { + client.close(); + } + this.clients.clear(); + this._onDidClientCountChange.fire(this.clients.size); + + await Promise.all([ + new Promise(resolve => this.wss?.close(() => resolve()) ?? resolve()), + new Promise(resolve => this.server?.close(() => resolve()) ?? resolve()), + ]); + + this.wss = undefined; + this.server = undefined; + } + + override dispose(): void { + void this.stop(); + super.dispose(); + } + + regenerateToken(): void { + this._sessionToken = createSessionToken(); + for (const client of this.clients) { + client.close(4001, 'Token regenerated'); + } + this.clients.clear(); + this._onDidClientCountChange.fire(this.clients.size); + } + + broadcast(message: BridgeMessage): void { + const payload = JSON.stringify(message); + for (const client of this.clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(payload); + } + } + } + + private sendToClient(client: WebSocket, message: BridgeMessage): void { + if (client.readyState !== WebSocket.OPEN) { + return; + } + client.send(JSON.stringify(message)); + } + + private handleHttpRequest(_request: IncomingMessage, response: ServerResponse): void { + response.statusCode = 200; + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.end(` + + + + + Copilot Sidecar Bridge + + + +
+

Copilot Sidecar Bridge

+
+

Connected clients: ${this.clients.size}

+

Session token: ${this._sessionToken}

+
+
+ +`); + } +} diff --git a/src/platform/bridge/conversationBridge.ts b/src/platform/bridge/conversationBridge.ts new file mode 100644 index 0000000000..62a7df0823 --- /dev/null +++ b/src/platform/bridge/conversationBridge.ts @@ -0,0 +1,2141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IConversationStore, IConversationTurnArtifacts } from '../../extension/conversationStore/node/conversationStore'; +import { Disposable } from '../../util/vs/base/common/lifecycle'; +import { URI } from '../../util/vs/base/common/uri'; +import { IVSCodeExtensionContext } from '../extContext/common/extensionContext'; +import { IFileSystemService } from '../filesystem/common/fileSystemService'; +import { ILogService } from '../log/common/logService'; +import { BridgeAssistantTurnArtifacts, BridgeConversationFilter, BridgeConversationProvider, BridgeConversationStatus, BridgeConversationSummary, BridgeMessage, BridgeModelOption, BridgeModeOption, BridgeServer, BridgeTurnHistoryItem, BridgeUiCommandId } from './bridgeServer'; + +type GitRepositoryLike = { + readonly rootUri: vscode.Uri; + readonly state: { + readonly HEAD: { + readonly name?: string; + } | undefined; + }; +}; + +type GitApiLike = { + readonly repositories: readonly GitRepositoryLike[]; + getRepository(uri: vscode.Uri): GitRepositoryLike | null; +}; + +type GitExtensionLike = { + readonly enabled: boolean; + getAPI(version: 1): GitApiLike; +}; + +const TITLE_MAX_LENGTH = 80; +const DEFAULT_MODE_ID = 'agent'; +const UI_MODES: readonly BridgeModeOption[] = [ + { id: 'agent', label: 'Agent' }, + { id: 'ask', label: 'Ask' }, + { id: 'plan', label: 'Plan' }, +]; +const FILE_TYPE_FILE = 1; +const FILE_TYPE_DIRECTORY = 2; +const ENABLE_LEGACY_GLOBAL_JSON_FALLBACK = false; +const ENABLE_LEGACY_CLI_METADATA_FALLBACK = false; +const GLOBAL_STORAGE_SCAN_MAX_DEPTH = 6; +const KNOWN_NON_SESSION_MAP_KEYS = new Set([ + 'cache', + 'chat', + 'chats', + 'config', + 'configuration', + 'conversation', + 'conversations', + 'history', + 'index', + 'message', + 'messages', + 'metadata', + 'mode', + 'modes', + 'model', + 'models', + 'pendingrequests', + 'request', + 'requests', + 'session', + 'sessions', + 'state', + 'settings', + 'transcript', + 'transcripts', + 'turn', + 'turns', + 'workspace', + 'workspacefolder', +]); + +type BridgeUiStateMessage = Extract; +type ParsedTranscriptEntry = { + readonly type: string | undefined; + readonly role: 'user' | 'assistant' | undefined; + readonly timestamp: number; + readonly content: string | undefined; +}; +type JsonConversationScanOptions = { + readonly sourceFolder: string; + readonly fileStem: string; + readonly filePathLabel: string; + readonly providerHint: BridgeConversationProvider; + readonly fallbackLastUpdated: number; +}; + +export class ConversationBridge extends Disposable { + private isActive = false; + private readonly transcriptsDirUri: URI | undefined; + private readonly workspaceChatSessionsDirUri: URI | undefined; + private readonly globalStorageRootUri: URI | undefined; + private readonly copilotCliMetadataUri: URI | undefined; + private readonly readProviderSessionSummaries: (() => Promise) | undefined; + private readonly modelOptionsById = new Map(); + private readonly textDecoder = new TextDecoder(); + private selectedModelId: string | undefined; + private selectedModeId = DEFAULT_MODE_ID; + + constructor( + private readonly bridgeServer: BridgeServer, + private readonly conversationStore: IConversationStore, + private readonly logService: ILogService, + private readonly fileSystemService: IFileSystemService, + extensionContext: IVSCodeExtensionContext, + readProviderSessionSummaries?: () => Promise, + ) { + super(); + this.readProviderSessionSummaries = readProviderSessionSummaries; + const storageUri = extensionContext.storageUri; + if (storageUri) { + this.transcriptsDirUri = URI.joinPath(storageUri, 'transcripts'); + const workspaceBucketUri = URI.joinPath(storageUri, '..'); + this.workspaceChatSessionsDirUri = URI.joinPath(workspaceBucketUri, 'chatSessions'); + } + + const globalStorageUri = extensionContext.globalStorageUri; + if (globalStorageUri) { + this.globalStorageRootUri = globalStorageUri; + this.copilotCliMetadataUri = URI.joinPath(globalStorageUri, 'copilotCli', 'copilotcli.session.metadata.json'); + } + } + + activate(): void { + if (this.isActive) { + return; + } + this.isActive = true; + + this.bridgeServer.onPromptReceived = (prompt, conversationId) => { + void this.submitPrompt(prompt, conversationId); + }; + this.bridgeServer.onConversationListRequested = (respond, filter) => { + void this.sendSnapshot(respond, filter); + }; + this.bridgeServer.onConversationSelected = (conversationId, respond) => { + void this.getConversationHistory(conversationId).then(turns => { + respond({ + type: 'conversation:history', + conversationId, + turns, + }); + }); + }; + this.bridgeServer.onUiModelSelected = modelId => { + void this.changeModel(modelId); + }; + this.bridgeServer.onUiModeSelected = modeId => { + void this.changeMode(modeId); + }; + this.bridgeServer.onUiCommandRequested = (commandId, args) => { + void this.executeUiCommand(commandId, args); + }; + + this._register(this.conversationStore.onDidConversationListChanged(() => { + void this.broadcastConversationList(); + })); + this._register(this.conversationStore.onDidUserTurn(event => { + this.bridgeServer.broadcast({ + type: 'turn:user', + conversationId: event.conversationId, + turnId: event.turnId, + content: event.content, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnStart(event => { + this.bridgeServer.broadcast({ + type: 'turn:start', + conversationId: event.conversationId, + turnId: event.turnId, + role: 'assistant', + }); + })); + this._register(this.conversationStore.onDidAssistantTurnChunk(event => { + this.bridgeServer.broadcast({ + type: 'turn:chunk', + conversationId: event.conversationId, + turnId: event.turnId, + content: event.content, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnReference(event => { + this.bridgeServer.broadcast({ + type: 'turn:reference', + conversationId: event.conversationId, + turnId: event.turnId, + label: event.label, + uri: event.uri, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnCodeCitation(event => { + this.bridgeServer.broadcast({ + type: 'turn:codeCitation', + conversationId: event.conversationId, + turnId: event.turnId, + uri: event.uri, + license: event.license, + snippet: event.snippet, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnStatus(event => { + this.bridgeServer.broadcast({ + type: 'turn:status', + conversationId: event.conversationId, + turnId: event.turnId, + kind: event.kind, + content: event.content, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnToolInvocation(event => { + this.bridgeServer.broadcast({ + type: 'turn:tool', + conversationId: event.conversationId, + turnId: event.turnId, + toolName: event.toolName, + toolCallId: event.toolCallId, + message: event.message, + isError: event.isError, + isComplete: event.isComplete, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnConfirmation(event => { + this.bridgeServer.broadcast({ + type: 'turn:confirmation', + conversationId: event.conversationId, + turnId: event.turnId, + title: event.title, + message: event.message, + buttons: event.buttons, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnQuestionCarousel(event => { + this.bridgeServer.broadcast({ + type: 'turn:questions', + conversationId: event.conversationId, + turnId: event.turnId, + allowSkip: event.allowSkip, + questions: event.questions, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnCommandButton(event => { + this.bridgeServer.broadcast({ + type: 'turn:button', + conversationId: event.conversationId, + turnId: event.turnId, + commandId: event.commandId, + title: event.title, + args: event.args, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnExtra(event => { + this.bridgeServer.broadcast({ + type: 'turn:extra', + conversationId: event.conversationId, + turnId: event.turnId, + extra: event.extra, + }); + })); + this._register(this.conversationStore.onDidAssistantTurnComplete(event => { + this.bridgeServer.broadcast({ + type: 'turn:complete', + conversationId: event.conversationId, + turnId: event.turnId, + }); + void this.broadcastConversationList(); + })); + } + + private async sendSnapshot(respond: (message: BridgeMessage) => void, filter?: BridgeConversationFilter): Promise { + respond({ + type: 'conversation:list', + conversations: await this.getConversationSummaries(filter), + }); + respond(await this.getUiStateMessage()); + } + + private async broadcastConversationList(): Promise { + this.bridgeServer.broadcast({ + type: 'conversation:list', + conversations: await this.getConversationSummaries(), + }); + } + + private async getConversationSummaries(filter?: BridgeConversationFilter): Promise { + const providerSummaries = await this.readProviderBackedSessionSummaries(); + const workspaceSummaries = await this.readWorkspaceChatSessionSummaries(); + + if (providerSummaries.length > 0) { + const canonical = new Map(); + for (const summary of providerSummaries) { + canonical.set(summary.id, summary); + } + + for (const summary of workspaceSummaries) { + const existing = canonical.get(summary.id); + if (!existing) { + continue; + } + + canonical.set(summary.id, this.enrichCanonicalSummary(existing, summary)); + } + + for (const summary of await this.readGlobalStorageJsonSummaries()) { + const existing = canonical.get(summary.id); + if (!existing) { + continue; + } + + canonical.set(summary.id, this.enrichCanonicalSummary(existing, summary)); + } + + for (const summary of await this.readCopilotCliSessionSummaries()) { + const existing = canonical.get(summary.id); + if (!existing) { + continue; + } + + canonical.set(summary.id, this.enrichCanonicalSummary(existing, summary)); + } + + const summaries = Array.from(canonical.values()).sort((a, b) => b.lastUpdated - a.lastUpdated); + return this.applyConversationFilters(summaries, filter); + } + + const merged = new Map(); + const upsertSummary = (summary: BridgeConversationSummary) => { + const existing = merged.get(summary.id); + if (!existing || summary.lastUpdated > existing.lastUpdated) { + merged.set(summary.id, summary); + return; + } + + const canImproveProvider = (existing.provider === undefined || existing.provider === 'unknown') + && summary.provider !== undefined + && summary.provider !== 'unknown'; + const canImproveStatus = (existing.status === undefined || existing.status === 'unknown') + && summary.status !== undefined + && summary.status !== 'unknown'; + + if (canImproveProvider || canImproveStatus) { + merged.set(summary.id, { + ...existing, + provider: canImproveProvider ? summary.provider : existing.provider, + status: canImproveStatus ? summary.status : existing.status, + }); + } + }; + + for (const summary of workspaceSummaries) { + upsertSummary(summary); + } + + for (const summary of await this.readGlobalStorageJsonSummaries()) { + upsertSummary(summary); + } + + for (const summary of await this.readCopilotCliSessionSummaries()) { + upsertSummary(summary); + } + + const summaries = Array.from(merged.values()).sort((a, b) => b.lastUpdated - a.lastUpdated); + return this.applyConversationFilters(summaries, filter); + } + + private enrichCanonicalSummary(base: BridgeConversationSummary, enrichment: BridgeConversationSummary): BridgeConversationSummary { + const hasKnownBaseProvider = base.provider !== undefined && base.provider !== 'unknown'; + const hasKnownBaseStatus = base.status !== undefined && base.status !== 'unknown'; + const hasSpecificBaseTitle = base.title.trim().length > 0 && !this.isGenericConversationTitle(base.title); + const hasSpecificEnrichmentTitle = enrichment.title.trim().length > 0 && !this.isGenericConversationTitle(enrichment.title); + + return { + id: base.id, + title: hasSpecificBaseTitle + ? base.title + : hasSpecificEnrichmentTitle + ? enrichment.title + : base.title.trim().length > 0 + ? base.title + : enrichment.title, + lastUpdated: Math.max(base.lastUpdated, enrichment.lastUpdated), + provider: hasKnownBaseProvider ? base.provider : enrichment.provider, + status: hasKnownBaseStatus ? base.status : enrichment.status, + }; + } + + private isGenericConversationTitle(title: string): boolean { + const normalized = title.trim().toLowerCase(); + if (normalized.length === 0) { + return true; + } + + return normalized === 'conversation' + || normalized.startsWith('conversation ') + || normalized === 'chat' + || normalized === 'new chat'; + } + + private async readProviderBackedSessionSummaries(): Promise { + if (!this.readProviderSessionSummaries) { + return []; + } + + try { + return await this.readProviderSessionSummaries(); + } catch (error) { + this.logService.warn(`[ConversationBridge] Failed to read provider-backed session summaries: ${error instanceof Error ? error.message : String(error)}`); + return []; + } + } + + private async readWorkspaceChatSessionSummaries(): Promise { + const directories = await this.getWorkspaceChatSessionDirectories(); + if (directories.length === 0) { + return []; + } + + const summaries: BridgeConversationSummary[] = []; + for (const directoryUri of directories) { + let entries: readonly [string, vscode.FileType][]; + try { + entries = await this.fileSystemService.readDirectory(directoryUri); + } catch { + continue; + } + + const currentSummaries = await Promise.all(entries + .filter(([name, type]) => name.endsWith('.jsonl') && type === FILE_TYPE_FILE) + .map(async ([name]) => { + const sessionId = name.slice(0, -'.jsonl'.length); + const fileUri = URI.joinPath(directoryUri, name); + return this.readWorkspaceChatSessionSummaryFromFile(fileUri, sessionId); + })); + summaries.push(...currentSummaries.filter((value): value is BridgeConversationSummary => value !== undefined)); + } + + return summaries; + } + + private async getWorkspaceChatSessionDirectories(): Promise { + if (!this.workspaceChatSessionsDirUri) { + return []; + } + + return [this.workspaceChatSessionsDirUri]; + } + + private async readWorkspaceChatSessionSummaryFromFile(fileUri: URI, sessionId: string): Promise { + try { + const raw = await this.fileSystemService.readFile(fileUri); + const text = this.textDecoder.decode(raw); + + let title: string | undefined; + let provider: BridgeConversationProvider = 'local'; + let status: BridgeConversationStatus = 'read'; + + for (const line of text.split('\n')) { + if (!line.trim()) { + continue; + } + + status = this.detectStatusFromLine(line, status); + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + if (!this.isRecord(parsed)) { + continue; + } + + const kind = typeof parsed.kind === 'number' ? parsed.kind : undefined; + const keyPath = Array.isArray(parsed.k) ? parsed.k : undefined; + + if (kind === 0 && this.isRecord(parsed.v)) { + const sessionState = parsed.v; + if (!title) { + title = this.extractTitleFromSessionState(sessionState); + } + + const inputState = this.isRecord(sessionState.inputState) ? sessionState.inputState : undefined; + provider = this.providerFromModelIdentifier(this.extractModelIdentifier(inputState?.selectedModel), provider); + + if ((Array.isArray(sessionState.pendingRequests) && sessionState.pendingRequests.length > 0) || sessionState.hasPendingEdits === true) { + status = 'in-progress'; + } + } + + if (kind === 1 && keyPath) { + if (this.keyPathEquals(keyPath, 'customTitle') && typeof parsed.v === 'string' && parsed.v.trim().length > 0) { + // customTitle is the most authoritative source β€” always use it. + title = this.truncateConversationTitle(parsed.v.trim()); + } + + if (!title && this.keyPathEquals(keyPath, 'inputState', 'inputText') && typeof parsed.v === 'string' && parsed.v.trim().length > 0) { + title = this.truncateConversationTitle(parsed.v.trim()); + } + + if (!title && this.keyPathEquals(keyPath, 'pendingRequests') && Array.isArray(parsed.v)) { + title = this.extractTitleFromPendingRequests(parsed.v); + } + + if (this.keyPathEquals(keyPath, 'inputState', 'selectedModel')) { + provider = this.providerFromModelIdentifier(this.extractModelIdentifier(parsed.v), provider); + } + + if ((this.keyPathEquals(keyPath, 'pendingRequests') && Array.isArray(parsed.v) && parsed.v.length > 0) || (this.keyPathEquals(keyPath, 'hasPendingEdits') && parsed.v === true)) { + status = 'in-progress'; + } + } + + if (kind === 2 && keyPath && Array.isArray(parsed.v)) { + if (this.keyPathEquals(keyPath, 'requests')) { + const requestTitle = this.extractTitleFromRequests(parsed.v); + if (!title && requestTitle) { + title = requestTitle; + } + } else if (this.keyPathStartsWith(keyPath, 'pendingRequests')) { + const pendingTitle = this.extractTitleFromPendingRequests(parsed.v); + if (!title && pendingTitle) { + title = pendingTitle; + } + } + } + } + + let lastUpdated = 0; + try { + lastUpdated = (await this.fileSystemService.stat(fileUri)).mtime; + } catch { + lastUpdated = Date.now(); + } + + if (title === undefined) { + // No extractable title β€” session has no real content; skip it. + return undefined; + } + + return { + id: sessionId, + title, + lastUpdated, + provider, + status, + }; + } catch { + return undefined; + } + } + + private async readGlobalStorageJsonSummaries(): Promise { + if (!ENABLE_LEGACY_GLOBAL_JSON_FALLBACK) { + return []; + } + + if (!this.globalStorageRootUri) { + return []; + } + + const summaries: BridgeConversationSummary[] = []; + try { + const entries = await this.fileSystemService.readDirectory(this.globalStorageRootUri); + for (const [folderName, folderType] of entries) { + if (folderType !== FILE_TYPE_DIRECTORY) { + continue; + } + + // Avoid stale sidecar artifacts and keep this scan aligned with real session metadata files. + if (folderName.toLowerCase() === 'sidecar') { + continue; + } + + const folderUri = URI.joinPath(this.globalStorageRootUri, folderName); + let folderEntries: readonly [string, vscode.FileType][]; + try { + folderEntries = await this.fileSystemService.readDirectory(folderUri); + } catch { + continue; + } + + for (const [fileName, fileType] of folderEntries) { + if (fileType !== FILE_TYPE_FILE || !fileName.endsWith('.json')) { + continue; + } + + if (!this.isLikelySessionMetadataJsonFile(fileName)) { + continue; + } + + const fileUri = URI.joinPath(folderUri, fileName); + const scanned = await this.readGlobalStorageJsonFileSummaries(fileUri, folderName); + summaries.push(...scanned); + } + } + } catch { + return []; + } + + return summaries; + } + + private isLikelySessionMetadataJsonFile(fileName: string): boolean { + const normalized = fileName.trim().toLowerCase(); + if (!normalized.endsWith('.json')) { + return false; + } + + if (normalized === 'conversations.json') { + return false; + } + + if (normalized.includes('embedding')) { + return false; + } + + return normalized.includes('session') || normalized.includes('conversation') || normalized.includes('chat'); + } + + private async readGlobalStorageJsonFileSummaries(fileUri: URI, sourceFolder: string): Promise { + let fallbackLastUpdated = Date.now(); + try { + fallbackLastUpdated = (await this.fileSystemService.stat(fileUri)).mtime; + } catch { + // Ignore stat errors; keep fallback timestamp. + } + + try { + const raw = await this.fileSystemService.readFile(fileUri); + const parsed: unknown = JSON.parse(this.textDecoder.decode(raw)); + const fileName = this.basenameFromUri(fileUri); + const fileStem = fileName.endsWith('.json') ? fileName.slice(0, -'.json'.length) : fileName; + return this.extractConversationSummariesFromJson(parsed, { + sourceFolder, + fileStem, + filePathLabel: `${sourceFolder}/${fileName}`, + providerHint: this.providerFromSourceFolder(sourceFolder), + fallbackLastUpdated, + }); + } catch { + return []; + } + } + + private extractConversationSummariesFromJson(value: unknown, options: JsonConversationScanOptions): readonly BridgeConversationSummary[] { + const summariesById = new Map(); + + const visit = (node: unknown, mapKey: string | undefined, depth: number): void => { + if (depth > GLOBAL_STORAGE_SCAN_MAX_DEPTH) { + return; + } + + if (Array.isArray(node)) { + for (const item of node) { + visit(item, undefined, depth + 1); + } + return; + } + + if (!this.isRecord(node)) { + return; + } + + const summary = this.tryBuildSummaryFromJsonRecord(node, mapKey, options); + if (summary) { + const existing = summariesById.get(summary.id); + if (!existing || summary.lastUpdated > existing.lastUpdated) { + summariesById.set(summary.id, summary); + } + } + + for (const [childKey, childValue] of Object.entries(node)) { + visit(childValue, this.isRecord(childValue) ? childKey : undefined, depth + 1); + } + }; + + visit(value, undefined, 0); + return Array.from(summariesById.values()); + } + + private tryBuildSummaryFromJsonRecord(record: Record, mapKey: string | undefined, options: JsonConversationScanOptions): BridgeConversationSummary | undefined { + const title = this.extractJsonConversationTitle(record); + const status = this.extractJsonConversationStatus(record); + if (!this.looksLikeConversationRecord(record, title, status)) { + return undefined; + } + + const id = this.resolveJsonConversationId(record, mapKey, options.fileStem); + if (!id) { + return undefined; + } + + const lastUpdated = this.extractJsonConversationTimestamp(record) ?? options.fallbackLastUpdated; + const provider = this.extractJsonConversationProvider(record, options.providerHint); + return { + id, + title: title ?? 'Conversation', + lastUpdated, + provider, + status: status ?? 'unknown', + }; + } + + private resolveJsonConversationId(record: Record, mapKey: string | undefined, fileStem: string): string | undefined { + const directCandidates = [record.sessionId, record.conversationId, record.chatSessionId, record.id]; + for (const candidate of directCandidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + } + + if (mapKey && this.looksLikeSessionMapKey(mapKey)) { + return mapKey.trim(); + } + + if (this.looksLikeSessionMapKey(fileStem)) { + return fileStem.trim(); + } + + return undefined; + } + + private looksLikeSessionMapKey(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (normalized.length < 6) { + return false; + } + + if (KNOWN_NON_SESSION_MAP_KEYS.has(normalized)) { + return false; + } + + return true; + } + + private looksLikeConversationRecord(record: Record, title: string | undefined, status: BridgeConversationStatus | undefined): boolean { + if (title || status) { + return true; + } + + if (typeof record.sessionId === 'string' || typeof record.conversationId === 'string' || typeof record.chatSessionId === 'string') { + return true; + } + + if (Array.isArray(record.requests) || Array.isArray(record.turns) || Array.isArray(record.messages)) { + return true; + } + + if ((Array.isArray(record.pendingRequests) && record.pendingRequests.length > 0) || record.hasPendingEdits === true) { + return true; + } + + const inputState = this.isRecord(record.inputState) ? record.inputState : undefined; + const selectedModel = inputState?.selectedModel ?? record.selectedModel ?? record.model; + if (this.extractModelIdentifier(selectedModel) !== undefined) { + return true; + } + + return false; + } + + private extractJsonConversationTitle(record: Record): string | undefined { + const directTitleCandidates = [ + record.customTitle, + record.title, + record.name, + record.firstUserMessage, + record.summary, + ]; + + for (const candidate of directTitleCandidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return this.truncateConversationTitle(candidate.trim()); + } + } + + const inputState = this.isRecord(record.inputState) ? record.inputState : undefined; + if (inputState && typeof inputState.inputText === 'string' && inputState.inputText.trim().length > 0) { + return this.truncateConversationTitle(inputState.inputText.trim()); + } + + if (Array.isArray(record.requests)) { + const requestTitle = this.extractTitleFromRequests(record.requests); + if (requestTitle) { + return requestTitle; + } + } + + if (Array.isArray(record.turns)) { + const turnTitle = this.extractTitleFromRequests(record.turns); + if (turnTitle) { + return turnTitle; + } + } + + if (Array.isArray(record.messages)) { + for (const message of record.messages) { + if (!this.isRecord(message)) { + continue; + } + + const role = typeof message.role === 'string' ? message.role.toLowerCase() : undefined; + if (role && role !== 'user') { + continue; + } + + const messageText = this.extractTextContent(message.content ?? message.message ?? message.text); + if (messageText) { + return this.truncateConversationTitle(messageText); + } + } + } + + return undefined; + } + + private extractJsonConversationTimestamp(record: Record): number | undefined { + const timestampCandidates = [ + record.lastUpdated, + record.updatedAt, + record.updateTime, + record.timestamp, + record.time, + record.startTime, + record.modifiedAt, + record.createdAt, + record.mtime, + ]; + + for (const candidate of timestampCandidates) { + const parsed = this.parsePossibleTimestamp(candidate); + if (parsed !== undefined) { + return parsed; + } + } + + const workspaceFolder = this.isRecord(record.workspaceFolder) ? record.workspaceFolder : undefined; + const workspaceTimestamp = this.parsePossibleTimestamp(workspaceFolder?.timestamp); + if (workspaceTimestamp !== undefined) { + return workspaceTimestamp; + } + + const metadata = this.isRecord(record.metadata) ? record.metadata : undefined; + if (metadata) { + const metadataCandidates = [metadata.lastUpdated, metadata.updatedAt, metadata.timestamp, metadata.time]; + for (const candidate of metadataCandidates) { + const parsed = this.parsePossibleTimestamp(candidate); + if (parsed !== undefined) { + return parsed; + } + } + } + + return undefined; + } + + private parsePossibleTimestamp(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value < 100000000000 ? Math.floor(value * 1000) : Math.floor(value); + } + + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return undefined; + } + + const numericValue = Number(trimmed); + if (Number.isFinite(numericValue) && numericValue > 0) { + return numericValue < 100000000000 ? Math.floor(numericValue * 1000) : Math.floor(numericValue); + } + + const parsedDate = new Date(trimmed).getTime(); + return Number.isFinite(parsedDate) ? parsedDate : undefined; + } + + private extractJsonConversationProvider(record: Record, providerHint: BridgeConversationProvider): BridgeConversationProvider { + let provider = providerHint; + + if (typeof record.provider === 'string') { + provider = this.providerFromModelIdentifier(record.provider, provider); + } + + if (typeof record.vendor === 'string' && typeof record.id === 'string') { + provider = this.providerFromModelIdentifier(`${record.vendor}/${record.id}`, provider); + } + + const inputState = this.isRecord(record.inputState) ? record.inputState : undefined; + const metadata = this.isRecord(record.metadata) ? record.metadata : undefined; + const modelIdentifier = this.extractModelIdentifier( + inputState?.selectedModel + ?? record.selectedModel + ?? record.model + ?? metadata?.selectedModel + ); + + return this.providerFromModelIdentifier(modelIdentifier, provider); + } + + private extractJsonConversationStatus(record: Record): BridgeConversationStatus | undefined { + if ((Array.isArray(record.pendingRequests) && record.pendingRequests.length > 0) || record.hasPendingEdits === true) { + return 'in-progress'; + } + + const metadata = this.isRecord(record.metadata) ? record.metadata : undefined; + const statusCandidates = [record.status, record.state, record.lifecycle, metadata?.status]; + for (const candidate of statusCandidates) { + if (typeof candidate !== 'string') { + continue; + } + + const normalizedStatus = this.normalizeConversationStatus(candidate); + if (normalizedStatus) { + return normalizedStatus; + } + } + + return undefined; + } + + private normalizeConversationStatus(value: string): BridgeConversationStatus | undefined { + const normalized = value.trim().toLowerCase().replace(/[_\s]+/g, '-'); + if (normalized.length === 0) { + return undefined; + } + + if (normalized.includes('input-needed') || normalized.includes('needsinput') || normalized.includes('awaitinginput') || normalized.includes('awaiting-input') || normalized.includes('inputrequired')) { + return 'input-needed'; + } + + if (normalized.includes('in-progress') || normalized.includes('inprogress') || normalized.includes('running') || normalized.includes('pending')) { + return 'in-progress'; + } + + if (normalized.includes('fail') || normalized.includes('error')) { + return 'failed'; + } + + if (normalized.includes('archive')) { + return 'archived'; + } + + if (normalized.includes('complete') || normalized.includes('done') || normalized.includes('success')) { + return 'completed'; + } + + if (normalized.includes('read') || normalized.includes('seen')) { + return 'read'; + } + + return undefined; + } + + private providerFromSourceFolder(sourceFolder: string): BridgeConversationProvider { + const normalized = sourceFolder.trim().toLowerCase(); + if (normalized.includes('chatsession') || normalized.includes('conversation') || normalized.includes('transcript')) { + return 'local'; + } + + return this.providerFromModelIdentifier(sourceFolder, 'unknown'); + } + + private basenameFromUri(uri: URI): string { + const lastSlash = uri.path.lastIndexOf('/'); + return lastSlash === -1 ? uri.path : uri.path.slice(lastSlash + 1); + } + + private async readCopilotCliSessionSummaries(): Promise { + if (!ENABLE_LEGACY_CLI_METADATA_FALLBACK) { + return []; + } + + if (!this.copilotCliMetadataUri) { + return []; + } + + try { + const raw = await this.fileSystemService.readFile(this.copilotCliMetadataUri); + const parsed: unknown = JSON.parse(this.textDecoder.decode(raw)); + if (!this.isRecord(parsed)) { + return []; + } + + const summaries: BridgeConversationSummary[] = []; + for (const [sessionId, metadata] of Object.entries(parsed)) { + if (!this.isRecord(metadata)) { + continue; + } + + let title = typeof metadata.customTitle === 'string' ? metadata.customTitle.trim() : ''; + if (!title && typeof metadata.firstUserMessage === 'string') { + title = metadata.firstUserMessage.trim(); + } + + let lastUpdated = 0; + const workspaceFolder = this.isRecord(metadata.workspaceFolder) ? metadata.workspaceFolder : undefined; + if (workspaceFolder && typeof workspaceFolder.timestamp === 'number') { + lastUpdated = workspaceFolder.timestamp; + } + + summaries.push({ + id: sessionId, + title: title ? this.truncateConversationTitle(title) : 'Copilot CLI Session', + lastUpdated, + provider: 'copilot-cli', + status: 'completed', + }); + } + + return summaries; + } catch { + return []; + } + } + + private applyConversationFilters(summaries: readonly BridgeConversationSummary[], filter?: BridgeConversationFilter): readonly BridgeConversationSummary[] { + if (!filter) { + return summaries; + } + + const providerFilter = this.normalizeFilterSet(filter.providers); + const statusFilter = this.normalizeFilterSet(filter.statuses); + const search = typeof filter.search === 'string' ? filter.search.trim().toLowerCase() : ''; + + return summaries.filter(summary => { + const provider = (summary.provider ?? 'unknown').toLowerCase(); + if (providerFilter && !providerFilter.has(provider)) { + return false; + } + + const status = (summary.status ?? 'unknown').toLowerCase(); + if (statusFilter && !statusFilter.has(status)) { + return false; + } + + if (search) { + const haystack = `${summary.title} ${summary.id}`.toLowerCase(); + if (!haystack.includes(search)) { + return false; + } + } + + return true; + }); + } + + private normalizeFilterSet(values: readonly string[] | undefined): ReadonlySet | undefined { + if (!values || values.length === 0) { + return undefined; + } + + const normalized = values + .map(value => value.trim().toLowerCase()) + .filter(value => value.length > 0); + + return normalized.length > 0 ? new Set(normalized) : undefined; + } + + private async getConversationHistory(conversationId: string): Promise { + const conversation = this.conversationStore.getConversationBySessionId(conversationId); + if (conversation) { + return this.extractTurnsFromConversation(conversation); + } + + const transcriptHistory = await this.readTranscriptHistory(conversationId); + if (transcriptHistory.length > 0) { + return transcriptHistory; + } + + return this.readWorkspaceChatSessionHistory(conversationId); + } + + private extractTurnsFromConversation(conversation: { sessionId: string; turns: readonly { id: string; request: { message: string }; startTime: number; responseMessage?: { message?: string }; rounds?: readonly { response?: string }[] }[] }): readonly BridgeTurnHistoryItem[] { + const history: BridgeTurnHistoryItem[] = []; + for (const turn of conversation.turns) { + const userMessage = turn.request.message.trim(); + if (userMessage.length > 0) { + history.push({ + role: 'user', + content: userMessage, + timestamp: turn.startTime, + }); + } + + const assistantMessage = this.extractAssistantMessageFromTurn(turn); + const artifacts = this.toBridgeTurnArtifacts(this.conversationStore.getAssistantTurnArtifacts(conversation.sessionId, turn.id)); + if (assistantMessage.length > 0 || this.hasBridgeArtifacts(artifacts)) { + history.push({ + role: 'assistant', + content: assistantMessage, + timestamp: turn.startTime, + artifacts, + }); + } + } + return history; + } + + private extractAssistantMessageFromTurn(turn: { responseMessage?: { message?: string }; rounds?: readonly { response?: string }[] }): string { + const directMessage = turn.responseMessage?.message?.trim() ?? ''; + if (directMessage.length > 0) { + return directMessage; + } + + if (!Array.isArray(turn.rounds)) { + return ''; + } + + const roundResponses = turn.rounds + .map(round => typeof round.response === 'string' ? round.response.trim() : '') + .filter(response => response.length > 0); + if (roundResponses.length === 0) { + return ''; + } + + return roundResponses.join('\n\n'); + } + + private toBridgeTurnArtifacts(artifacts: IConversationTurnArtifacts | undefined): BridgeAssistantTurnArtifacts | undefined { + if (!artifacts) { + return undefined; + } + + const bridgeArtifacts: BridgeAssistantTurnArtifacts = { + statuses: artifacts.statuses.length > 0 ? [...artifacts.statuses] : undefined, + tools: artifacts.tools.length > 0 ? [...artifacts.tools] : undefined, + references: artifacts.references.length > 0 ? [...artifacts.references] : undefined, + codeCitations: artifacts.codeCitations.length > 0 ? [...artifacts.codeCitations] : undefined, + confirmations: artifacts.confirmations.length > 0 ? [...artifacts.confirmations] : undefined, + questionCarousels: artifacts.questionCarousels.length > 0 ? [...artifacts.questionCarousels] : undefined, + commandButtons: artifacts.commandButtons.length > 0 ? [...artifacts.commandButtons] : undefined, + extras: artifacts.extras.length > 0 ? [...artifacts.extras] : undefined, + }; + + return this.hasBridgeArtifacts(bridgeArtifacts) ? bridgeArtifacts : undefined; + } + + private hasBridgeArtifacts(artifacts: BridgeAssistantTurnArtifacts | undefined): boolean { + if (!artifacts) { + return false; + } + + return (artifacts.statuses?.length ?? 0) > 0 + || (artifacts.tools?.length ?? 0) > 0 + || (artifacts.references?.length ?? 0) > 0 + || (artifacts.codeCitations?.length ?? 0) > 0 + || (artifacts.confirmations?.length ?? 0) > 0 + || (artifacts.questionCarousels?.length ?? 0) > 0 + || (artifacts.commandButtons?.length ?? 0) > 0 + || (artifacts.extras?.length ?? 0) > 0; + } + + private async readTranscriptHistory(sessionId: string): Promise { + if (!this.transcriptsDirUri) { + return []; + } + + const fileUri = URI.joinPath(this.transcriptsDirUri, `${sessionId}.jsonl`); + try { + const raw = await this.fileSystemService.readFile(fileUri); + const text = this.textDecoder.decode(raw); + const entries = this.parseTranscriptEntries(text); + const history: BridgeTurnHistoryItem[] = []; + + for (const entry of entries) { + if (entry.role === 'user' && typeof entry.content === 'string') { + const content = entry.content.trim(); + if (content.length > 0) { + history.push({ role: 'user', content, timestamp: entry.timestamp }); + } + } else if (entry.role === 'assistant' && typeof entry.content === 'string') { + const content = entry.content.trim(); + if (content.length > 0) { + history.push({ role: 'assistant', content, timestamp: entry.timestamp }); + } + } + } + + return history; + } catch { + // Transcript file not found or unreadable β€” expected for older sessions. + return []; + } + } + + private async readWorkspaceChatSessionHistory(sessionId: string): Promise { + const fileUri = await this.findWorkspaceChatSessionFile(sessionId); + if (!fileUri) { + return []; + } + + try { + const raw = await this.fileSystemService.readFile(fileUri); + const text = this.textDecoder.decode(raw); + + // Reconstruct the final session state by applying all patches in order. + // kind=2 patches with k=["requests", N, "response"] accumulate response items + // for request N and must not be treated as arrays of request objects. + const requestsByIndex = new Map>(); + const responseAccumulator = new Map(); + let nextRequestIdx = 0; + + for (const line of text.split('\n')) { + if (!line.trim()) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + if (!this.isRecord(parsed) || typeof parsed.kind !== 'number') { + continue; + } + + if (parsed.kind === 0 && this.isRecord(parsed.v) && Array.isArray(parsed.v.requests)) { + for (let i = 0; i < parsed.v.requests.length; i++) { + if (this.isRecord(parsed.v.requests[i])) { + requestsByIndex.set(i, { ...(parsed.v.requests[i] as Record) }); + } + } + nextRequestIdx = parsed.v.requests.length; + continue; + } + + if (parsed.kind === 2 && Array.isArray(parsed.k) && Array.isArray(parsed.v)) { + if (this.keyPathEquals(parsed.k, 'requests')) { + // Each such patch appends one (or more) new request objects. + for (const item of parsed.v) { + if (this.isRecord(item)) { + requestsByIndex.set(nextRequestIdx++, { ...(item as Record) }); + } + } + continue; + } + + if (parsed.k.length === 3 && parsed.k[0] === 'requests' && typeof parsed.k[1] === 'number' && parsed.k[2] === 'response') { + // Accumulate response parts for a specific request index. + const reqIdx = parsed.k[1] as number; + const existing = responseAccumulator.get(reqIdx) ?? []; + responseAccumulator.set(reqIdx, existing.concat(parsed.v)); + continue; + } + } + } + + // Merge accumulated response patches into each request's response array. + for (const [idx, req] of requestsByIndex) { + const accumulated = responseAccumulator.get(idx); + if (accumulated && accumulated.length > 0) { + const existing = Array.isArray(req.response) ? (req.response as unknown[]) : []; + req.response = [...existing, ...accumulated]; + } + } + + // Extract conversation turns from the fully-reconstructed requests. + const history: BridgeTurnHistoryItem[] = []; + const seenKeys = new Set(); + const sortedRequests = Array.from(requestsByIndex.entries()) + .sort(([a], [b]) => a - b) + .map(([, req]) => req); + + this.appendTurnsFromRequestArray(sortedRequests, history, seenKeys); + history.sort((a, b) => a.timestamp - b.timestamp); + return history; + } catch { + return []; + } + } + + private async findWorkspaceChatSessionFile(sessionId: string): Promise { + const directories = await this.getWorkspaceChatSessionDirectories(); + for (const directoryUri of directories) { + const candidate = URI.joinPath(directoryUri, `${sessionId}.jsonl`); + try { + const stat = await this.fileSystemService.stat(candidate); + if (stat.type === FILE_TYPE_FILE) { + return candidate; + } + } catch { + continue; + } + } + + return undefined; + } + + private appendTurnsFromRequestArray(requests: readonly unknown[], history: BridgeTurnHistoryItem[], seenKeys: Set): void { + for (const request of requests) { + if (!this.isRecord(request)) { + continue; + } + + const timestamp = this.extractRequestTimestamp(request); + const userMessage = this.extractRequestMessage(request); + if (userMessage) { + const key = `user:${timestamp}:${userMessage}`; + if (!seenKeys.has(key)) { + seenKeys.add(key); + history.push({ role: 'user', content: userMessage, timestamp }); + } + } + + const { text: assistantMessage, toolLines } = this.extractRequestAssistantContent(request); + if (assistantMessage || toolLines.length > 0) { + const key = `assistant:${timestamp}:${assistantMessage}`; + if (!seenKeys.has(key)) { + seenKeys.add(key); + history.push({ + role: 'assistant', + content: assistantMessage ?? '', + timestamp, + toolLines: toolLines.length > 0 ? toolLines : undefined, + }); + } + } + } + } + + private parseTranscriptEntries(text: string): readonly ParsedTranscriptEntry[] { + const entries: ParsedTranscriptEntry[] = []; + const now = Date.now(); + + for (const line of text.split('\n')) { + if (!line.trim()) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + if (!this.isRecord(parsed)) { + continue; + } + + const type = typeof parsed.type === 'string' ? parsed.type : undefined; + const role = this.extractTranscriptRole(parsed, type); + const content = this.extractTranscriptContent(parsed, role); + const rawTimestamp = parsed.timestamp; + const parsedTimestamp = typeof rawTimestamp === 'string' || typeof rawTimestamp === 'number' + ? new Date(rawTimestamp).getTime() + : now; + + entries.push({ + type, + role, + timestamp: Number.isFinite(parsedTimestamp) ? parsedTimestamp : now, + content, + }); + } + + return entries; + } + + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } + + private keyPathEquals(path: readonly unknown[] | undefined, ...segments: readonly string[]): boolean { + if (!path || path.length !== segments.length) { + return false; + } + + return segments.every((segment, index) => path[index] === segment); + } + + private keyPathStartsWith(path: readonly unknown[] | undefined, ...segments: readonly string[]): boolean { + if (!path || path.length < segments.length) { + return false; + } + + return segments.every((segment, index) => path[index] === segment); + } + + private detectStatusFromLine(line: string, currentStatus: BridgeConversationStatus): BridgeConversationStatus { + if (currentStatus === 'in-progress') { + return currentStatus; + } + + const normalized = line.toLowerCase(); + if (normalized.includes('input needed') || normalized.includes('needsinput') || normalized.includes('status":"inputneeded')) { + return 'input-needed'; + } + + if (normalized.includes('status":"failed') || normalized.includes('chatsessionstatus.failed')) { + return 'failed'; + } + + if (normalized.includes('status":"inprogress') || normalized.includes('chatsessionstatus.inprogress')) { + return 'in-progress'; + } + + if (normalized.includes('archived')) { + return 'archived'; + } + + return currentStatus; + } + + private extractTitleFromSessionState(state: Record): string | undefined { + if (typeof state.customTitle === 'string' && state.customTitle.trim().length > 0) { + return this.truncateConversationTitle(state.customTitle.trim()); + } + + const titleFromRequests = this.extractTitleFromRequests(state.requests); + if (titleFromRequests) { + return titleFromRequests; + } + + const titleFromPendingRequests = this.extractTitleFromPendingRequests(state.pendingRequests); + if (titleFromPendingRequests) { + return titleFromPendingRequests; + } + + const inputState = this.isRecord(state.inputState) ? state.inputState : undefined; + if (inputState && typeof inputState.inputText === 'string' && inputState.inputText.trim().length > 0) { + return this.truncateConversationTitle(inputState.inputText.trim()); + } + + return undefined; + } + + private extractTitleFromRequests(value: unknown): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + for (const request of value) { + if (!this.isRecord(request)) { + continue; + } + + const message = this.extractRequestMessage(request); + if (message) { + return this.truncateConversationTitle(message); + } + } + + return undefined; + } + + private extractTitleFromPendingRequests(value: unknown): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + for (const entry of value) { + if (!this.isRecord(entry)) { + continue; + } + + const nestedRequest = this.isRecord(entry.request) ? entry.request : undefined; + if (!nestedRequest) { + continue; + } + + const message = this.extractRequestMessage(nestedRequest); + if (message) { + return this.truncateConversationTitle(message); + } + } + + return undefined; + } + + private extractRequestMessage(request: Record): string | undefined { + const asTrimmed = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + }; + + const message = request.message; + const messageText = asTrimmed(message); + if (messageText) { + return messageText; + } + + if (this.isRecord(message)) { + const fromText = asTrimmed(message.text); + if (fromText) { + return fromText; + } + + const fromMessage = asTrimmed(message.message); + if (fromMessage) { + return fromMessage; + } + } + + const nestedRequest = this.isRecord(request.request) ? request.request : undefined; + if (nestedRequest) { + const nestedMessage = asTrimmed(nestedRequest.message); + if (nestedMessage) { + return nestedMessage; + } + + if (this.isRecord(nestedRequest.message)) { + const nestedText = asTrimmed(nestedRequest.message.text); + if (nestedText) { + return nestedText; + } + } + } + + const prompt = asTrimmed(request.prompt); + if (prompt) { + return prompt; + } + + return undefined; + } + + /** + * Extracts assistant content from a request, separating displayable tool invocation + * labels (e.g. "Read file foo.ts") from the main markdown text. + */ + private extractRequestAssistantContent(request: Record): { text: string | undefined; toolLines: string[] } { + const responseCandidates = [ + request.response, + request.responseMessage, + request.result, + request.responses, + request.rounds, + ]; + + for (const candidate of responseCandidates) { + const result = this.extractAssistantContentParts(candidate); + if (result.text || result.toolLines.length > 0) { + return result; + } + + if (this.isRecord(candidate)) { + const nestedCandidates = [ + candidate.message, + candidate.value, + candidate.response, + candidate.responses, + candidate.rounds, + ]; + for (const nestedCandidate of nestedCandidates) { + const nested = this.extractAssistantContentParts(nestedCandidate); + if (nested.text || nested.toolLines.length > 0) { + return nested; + } + } + } + } + + return { text: undefined, toolLines: [] }; + } + + /** + * Processes an array of response parts (or a single part), returning the main text + * and tool invocation labels separately. + */ + private extractAssistantContentParts(value: unknown): { text: string | undefined; toolLines: string[] } { + if (!Array.isArray(value)) { + const text = this.extractTextContent(value); + return { text, toolLines: [] }; + } + + const textParts: string[] = []; + const toolLines: string[] = []; + + for (const item of value) { + if (!this.isRecord(item)) { + continue; + } + + const normalizedKind = typeof item.kind === 'string' ? item.kind.toLowerCase() : ''; + if (normalizedKind === 'toolinvocationserialized') { + // Extract completed past-tense message (e.g. "Read file foo.ts"). + if (item.isComplete !== false) { + const msgObj = (this.isRecord(item.pastTenseMessage) ? item.pastTenseMessage : undefined) + ?? (this.isRecord(item.invocationMessage) ? item.invocationMessage : undefined); + if (msgObj && typeof msgObj.value === 'string') { + const label = this.stripMarkdownLinks(msgObj.value.trim()); + if (label.length > 0) { + toolLines.push(label); + } + } + } + continue; + } + + // All other non-display parts (thinking, progress, etc.) are skipped. + if (this.isNonDisplayResponsePart(item)) { + continue; + } + + // NOKIND items: {value: string, supportThemeIcons, ...} β€” the actual markdown text. + if ('value' in item && typeof item.value === 'string') { + const text = item.value.trim(); + if (text.length > 0) { + textParts.push(text); + } + continue; + } + + // Fallback: try generic text extraction. + const text = this.extractTextContent(item); + if (text) { + textParts.push(text); + } + } + + const joined = textParts.join('').trim(); + return { + text: joined.length > 0 ? joined : undefined, + toolLines, + }; + } + + /** + * Strips markdown link syntax, keeping only the label text. + * e.g. "Read [foo.ts](file:///path/foo.ts)" β†’ "Read foo.ts" + */ + private stripMarkdownLinks(value: string): string { + return value.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').trim(); + } + + private extractRequestTimestamp(request: Record): number { + const candidates = [ + request.timestamp, + request.startTime, + request.requestTimestamp, + request.time, + ]; + + for (const candidate of candidates) { + if (typeof candidate !== 'string' && typeof candidate !== 'number') { + continue; + } + + const parsedTimestamp = new Date(candidate).getTime(); + if (Number.isFinite(parsedTimestamp)) { + return parsedTimestamp; + } + } + + return Date.now(); + } + + private extractModelIdentifier(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + + if (!this.isRecord(value)) { + return undefined; + } + + if (typeof value.identifier === 'string' && value.identifier.trim().length > 0) { + return value.identifier; + } + + if (typeof value.id === 'string' && typeof value.vendor === 'string') { + return `${value.vendor}/${value.id}`; + } + + const metadata = this.isRecord(value.metadata) ? value.metadata : undefined; + if (metadata) { + if (typeof metadata.identifier === 'string' && metadata.identifier.trim().length > 0) { + return metadata.identifier; + } + + if (typeof metadata.id === 'string' && typeof metadata.vendor === 'string') { + return `${metadata.vendor}/${metadata.id}`; + } + } + + return undefined; + } + + private providerFromModelIdentifier(modelIdentifier: string | undefined, fallback: BridgeConversationProvider): BridgeConversationProvider { + if (!modelIdentifier) { + return fallback; + } + + const normalized = modelIdentifier.toLowerCase(); + if (normalized.includes('codex')) { + return 'codex'; + } + + if (normalized.includes('claude')) { + return 'claude'; + } + + if (normalized.includes('copilot-cli') || normalized.includes('copilot_cli') || normalized.includes('copilotcli')) { + return 'copilot-cli'; + } + + if (normalized.includes('cloud')) { + return 'cloud'; + } + + return fallback; + } + + private extractTranscriptRole(entry: Record, type: string | undefined): 'user' | 'assistant' | undefined { + if (type === 'user.message' || type === 'user') { + return 'user'; + } + if (type === 'assistant.message' || type === 'assistant') { + return 'assistant'; + } + + const message = this.isRecord(entry.message) ? entry.message : undefined; + const role = message?.role; + if (role === 'user' || role === 'assistant') { + return role; + } + + return undefined; + } + + private extractTranscriptContent(entry: Record, role: 'user' | 'assistant' | undefined): string | undefined { + const data = this.isRecord(entry.data) ? entry.data : undefined; + const dataContent = this.extractTextContent(data?.content); + if (dataContent) { + return this.sanitizeTranscriptContent(dataContent); + } + + if (this.isRecord(entry.message)) { + const message = entry.message; + if (role === 'user' && this.isToolResultOnlyMessage(message.content)) { + return undefined; + } + + const messageContent = this.extractTextContent(message.content); + if (messageContent) { + return this.sanitizeTranscriptContent(messageContent); + } + } + + const topLevelContent = this.extractTextContent(entry.content); + if (topLevelContent) { + return this.sanitizeTranscriptContent(topLevelContent); + } + + return undefined; + } + + private isToolResultOnlyMessage(content: unknown): boolean { + if (!Array.isArray(content) || content.length === 0) { + return false; + } + + return content.every(item => this.isRecord(item) && item.type === 'tool_result'); + } + + private extractTextContent(value: unknown): string | undefined { + if (typeof value === 'string') { + const text = value.trim(); + return text.length > 0 ? text : undefined; + } + + if (Array.isArray(value)) { + const parts: string[] = []; + for (const item of value) { + const text = this.extractTextContent(item); + if (text) { + parts.push(text); + } + } + + const joined = parts.join('\n').trim(); + return joined.length > 0 ? joined : undefined; + } + + if (this.isRecord(value)) { + const normalizedKind = typeof value.kind === 'string' ? value.kind.toLowerCase() : ''; + + if (normalizedKind === 'toolinvocationserialized') { + // Tool invocations are handled separately via extractAssistantContentParts. + return undefined; + } + + if (this.isNonDisplayResponsePart(value)) { + return undefined; + } + + if (typeof value.text === 'string') { + const text = value.text.trim(); + if (text.length > 0) { + return text; + } + } + + if (value.type === 'thinking') { + return undefined; + } + + if (typeof value.content === 'string') { + const text = value.content.trim(); + if (text.length > 0) { + return text; + } + } else if (this.isRecord(value.content) || Array.isArray(value.content)) { + const nestedContent = this.extractTextContent(value.content); + if (nestedContent) { + return nestedContent; + } + } + + if ('value' in value) { + const nestedValue = this.extractTextContent(value.value); + if (nestedValue) { + return nestedValue; + } + } + + if ('message' in value) { + const nestedMessage = this.extractTextContent(value.message); + if (nestedMessage) { + return nestedMessage; + } + } + + if (Array.isArray(value.responses)) { + const nestedResponses = this.extractTextContent(value.responses); + if (nestedResponses) { + return nestedResponses; + } + } + + if (Array.isArray(value.rounds)) { + const nestedRounds = this.extractTextContent(value.rounds); + if (nestedRounds) { + return nestedRounds; + } + } + } + + return undefined; + } + + private isNonDisplayResponsePart(value: Record): boolean { + const normalizedType = typeof value.type === 'string' ? value.type.toLowerCase() : ''; + if (normalizedType.length > 0) { + if (normalizedType === 'thinking' || normalizedType === 'tool_result' || normalizedType === 'tool_use' || normalizedType === 'tool_invocation') { + return true; + } + } + + const normalizedKind = typeof value.kind === 'string' ? value.kind.toLowerCase() : ''; + if (normalizedKind.length > 0) { + if (normalizedKind === 'thinking' + || normalizedKind === 'progress' + || normalizedKind === 'warning' + || normalizedKind === 'reference' + || normalizedKind === 'inlinereference' + || normalizedKind === 'codecitation' + || normalizedKind === 'tool' + || normalizedKind === 'toolinvocation' + || normalizedKind === 'toolinvocationserialized' + || normalizedKind === 'progresstaskserialized' + || normalizedKind === 'elicitationserialized' + || normalizedKind === 'mcpserversstarting' + || normalizedKind === 'undostop' + || normalizedKind === 'confirmation' + || normalizedKind === 'questioncarousel' + || normalizedKind === 'commandbutton' + || normalizedKind === 'anchor' + || normalizedKind === 'filetree' + || normalizedKind === 'codeblockuri' + || normalizedKind === 'textedit' + || normalizedKind === 'textedigroup' + || normalizedKind === 'texteditgroup' + || normalizedKind === 'notebookedit' + || normalizedKind === 'workspaceedit' + || normalizedKind === 'move' + || normalizedKind === 'extensions' + || normalizedKind === 'pullrequest' + || normalizedKind === 'externaledit' + || normalizedKind === 'multidiff') { + return true; + } + } + + if (typeof value.toolCallId === 'string' || typeof value.toolName === 'string') { + return true; + } + + if (Array.isArray(value.questions) + || Array.isArray(value.buttons) + || Array.isArray(value.edits) + || Array.isArray(value.uris)) { + return true; + } + + return false; + } + + private sanitizeTranscriptContent(value: string): string | undefined { + const sanitized = value + .replace(/[\s\S]*?<\/system-reminder>/gi, '') + .trim(); + + return sanitized.length > 0 ? sanitized : undefined; + } + + private truncateConversationTitle(value: string): string { + if (value.length <= TITLE_MAX_LENGTH) { + return value; + } + + return `${value.slice(0, TITLE_MAX_LENGTH - 3)}...`; + } + + private async getUiStateMessage(): Promise { + if (!UI_MODES.some(mode => mode.id === this.selectedModeId)) { + this.selectedModeId = DEFAULT_MODE_ID; + } + + const modelOptions = await this.getModelOptions(); + if (!this.selectedModelId || !this.modelOptionsById.has(this.selectedModelId)) { + this.selectedModelId = modelOptions[0]?.id; + } + const workspaceLabel = await this.getWorkspaceLabel(); + + return { + type: 'ui:state', + modes: UI_MODES, + selectedModeId: this.selectedModeId, + models: modelOptions, + selectedModelId: this.selectedModelId, + workspaceLabel, + }; + } + + private async getWorkspaceLabel(): Promise { + const workspaceFolder = vscode.workspace?.workspaceFolders?.[0]; + if (!workspaceFolder) { + return undefined; + } + + const repoName = workspaceFolder.name.trim(); + const branchName = await this.getWorkspaceBranchName(workspaceFolder.uri); + if (!repoName) { + return branchName; + } + + return branchName ? `${repoName} (${branchName})` : repoName; + } + + private async getWorkspaceBranchName(workspaceUri: vscode.Uri): Promise { + const gitExtension = vscode.extensions.getExtension('vscode.git'); + if (!gitExtension) { + return undefined; + } + + try { + const extension = gitExtension.isActive ? gitExtension.exports : await gitExtension.activate(); + if (!extension.enabled) { + return undefined; + } + + const gitApi = extension.getAPI(1); + const repository = this.resolveWorkspaceRepository(gitApi, workspaceUri); + const branchName = repository?.state.HEAD?.name?.trim(); + return branchName && branchName.length > 0 ? branchName : undefined; + } catch (error) { + this.logService.trace(`[ConversationBridge] Failed to resolve workspace branch label: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + private resolveWorkspaceRepository(gitApi: GitApiLike, workspaceUri: vscode.Uri): GitRepositoryLike | undefined { + const direct = gitApi.getRepository(workspaceUri); + if (direct) { + return direct; + } + + const exact = gitApi.repositories.find(repository => repository.rootUri.toString() === workspaceUri.toString()); + if (exact) { + return exact; + } + + return gitApi.repositories.find(repository => this.isParentUriPath(repository.rootUri.path, workspaceUri.path)); + } + + private isParentUriPath(parentPath: string, childPath: string): boolean { + const normalizedParent = parentPath.endsWith('/') ? parentPath : `${parentPath}/`; + const normalizedChild = childPath.endsWith('/') ? childPath : `${childPath}/`; + return normalizedChild.startsWith(normalizedParent); + } + + private async getModelOptions(): Promise { + try { + const models = await vscode.lm.selectChatModels({ vendor: 'copilot' }); + this.modelOptionsById.clear(); + + for (const model of models) { + if (this.modelOptionsById.has(model.id)) { + continue; + } + + this.modelOptionsById.set(model.id, { + id: model.id, + label: model.name || model.id, + vendor: model.vendor || 'copilot', + family: model.family, + }); + } + + return Array.from(this.modelOptionsById.values()) + .sort((a, b) => a.label.localeCompare(b.label)); + } catch (error) { + this.logService.warn(`[ConversationBridge] Failed to read chat models for sidecar UI: ${error instanceof Error ? error.message : String(error)}`); + return []; + } + } + + private async changeModel(modelId: string): Promise { + if (this.modelOptionsById.size === 0) { + await this.getModelOptions(); + } + + const model = this.modelOptionsById.get(modelId); + if (!model) { + return; + } + + try { + await vscode.commands.executeCommand('workbench.action.chat.changeModel', { + vendor: model.vendor, + id: model.id, + family: model.family, + }); + this.selectedModelId = model.id; + this.bridgeServer.broadcast(await this.getUiStateMessage()); + } catch (error) { + this.logService.warn(`[ConversationBridge] Failed to change chat model from sidecar: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async changeMode(modeId: string): Promise { + const normalizedModeId = this.normalizeModeId(modeId); + if (normalizedModeId === this.selectedModeId) { + return; + } + + const commandModeId = this.modeIdForCommand(normalizedModeId); + + try { + try { + await vscode.commands.executeCommand('workbench.action.chat.toggleAgentMode', { modeId: commandModeId }); + } catch { + await vscode.commands.executeCommand('workbench.action.chat.toggleAgentMode'); + } + this.selectedModeId = normalizedModeId; + this.bridgeServer.broadcast(await this.getUiStateMessage()); + } catch (error) { + this.logService.warn(`[ConversationBridge] Failed to change chat mode from sidecar: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private normalizeModeId(modeId: string): string { + const normalized = modeId.trim().toLowerCase(); + if (normalized === 'ask') { + return 'ask'; + } + if (normalized === 'plan') { + return 'plan'; + } + + return 'agent'; + } + + private modeIdForCommand(modeId: string): string { + if (modeId === 'ask') { + return 'Ask'; + } + if (modeId === 'plan') { + return 'Plan'; + } + + return 'Agent'; + } + + private async executeUiCommand(commandId: BridgeUiCommandId, args?: readonly unknown[]): Promise { + try { + if (args && args.length > 0) { + await vscode.commands.executeCommand(commandId, ...args); + } else { + await vscode.commands.executeCommand(commandId); + } + } catch (error) { + this.logService.warn(`[ConversationBridge] Failed to execute sidecar UI command ${commandId}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async submitPrompt(prompt: string, conversationId: string | undefined): Promise { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) { + return; + } + + try { + await vscode.commands.executeCommand('workbench.action.chat.open', { + query: trimmedPrompt, + isPartialQuery: false, + sessionId: conversationId, + }); + return; + } catch (error) { + this.logService.warn(`[ConversationBridge] Failed to submit prompt with direct chat.open submission: ${error instanceof Error ? error.message : String(error)}`); + } + + await vscode.commands.executeCommand('workbench.action.chat.open', { + query: trimmedPrompt, + sessionId: conversationId, + }); + + try { + await vscode.commands.executeCommand('workbench.action.chat.submit'); + } catch { + // Some VS Code versions may not expose workbench.action.chat.submit. + } + } +} diff --git a/src/platform/bridge/test/node/chatRenderer.spec.ts b/src/platform/bridge/test/node/chatRenderer.spec.ts new file mode 100644 index 0000000000..4d6978fdb6 --- /dev/null +++ b/src/platform/bridge/test/node/chatRenderer.spec.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { readFileSync } from 'fs'; +import * as path from 'path'; +import { describe, expect, test, vi } from 'vitest'; +import { Script } from 'vm'; + +type AssistantExtraPayload = { + readonly kind: string; + readonly [key: string]: unknown; +}; + +type AssistantCommandPayload = { + readonly commandId: string; + readonly args: readonly unknown[] | undefined; +}; + +type ChatRendererPrototype = { + appendAssistantExtra(this: TestRendererContext, turnId: string, extra: AssistantExtraPayload): void; + uriLabel(this: unknown, uri: string): string; +}; + +type ChatRendererConstructor = { + readonly prototype: ChatRendererPrototype; +}; + +type TestRendererEntry = { + readonly extrasHost: TestElement; + readonly extraKeys: Set; + readonly editExtraItems: Map; +}; + +type TestRendererContext = { + startAssistantTurn(turnId: string): TestRendererEntry; + scrollToBottom(): void; + uriLabel(uri: string): string; + commandRunner: (command: AssistantCommandPayload) => void; +}; + +class TestElement { + readonly childNodes: TestElement[] = []; + textContent = ''; + href = ''; + target = ''; + rel = ''; + type = ''; + + private readonly listeners = new Map void>>(); + private classTokens = new Set(); + private _className = ''; + + readonly classList = { + add: (...tokens: string[]) => { + for (const token of tokens) { + if (token.trim().length > 0) { + this.classTokens.add(token); + } + } + this.syncClassName(); + }, + remove: (...tokens: string[]) => { + for (const token of tokens) { + this.classTokens.delete(token); + } + this.syncClassName(); + }, + toggle: (token: string, force?: boolean) => { + const shouldAdd = force === undefined ? !this.classTokens.has(token) : force; + if (shouldAdd) { + this.classTokens.add(token); + } else { + this.classTokens.delete(token); + } + this.syncClassName(); + return this.classTokens.has(token); + }, + contains: (token: string) => this.classTokens.has(token), + [Symbol.iterator]: () => this.classTokens.values(), + }; + + get className(): string { + return this._className; + } + + set className(value: string) { + this._className = value; + this.classTokens = new Set(value.split(/\s+/).map(token => token.trim()).filter(Boolean)); + } + + appendChild(child: TestElement): TestElement { + this.childNodes.push(child); + return child; + } + + addEventListener(type: string, listener: () => void): void { + const listeners = this.listeners.get(type); + if (listeners) { + listeners.push(listener); + return; + } + + this.listeners.set(type, [listener]); + } + + click(): void { + for (const listener of this.listeners.get('click') ?? []) { + listener(); + } + } + + querySelector(selector: string): TestElement | null { + if (!selector.startsWith('.')) { + return null; + } + + const className = selector.slice(1); + for (const child of this.childNodes) { + if (child.classList.contains(className)) { + return child; + } + + const nested = child.querySelector(selector); + if (nested) { + return nested; + } + } + + return null; + } + + private syncClassName(): void { + this._className = Array.from(this.classTokens.values()).join(' '); + } +} + +class TestDocument { + createElement(_tagName: string): TestElement { + return new TestElement(); + } +} + +function loadChatRenderer(document: TestDocument): ChatRendererConstructor { + const rendererScriptPath = path.resolve(process.cwd(), 'pwa/js/chat-renderer.js'); + const rendererScript = readFileSync(rendererScriptPath, 'utf8'); + const windowObject: { ChatRenderer?: unknown } = {}; + + const sandbox = { + window: windowObject, + document, + navigator: { clipboard: { writeText: async () => undefined } }, + URL, + console, + }; + + new Script(rendererScript, { filename: rendererScriptPath }).runInNewContext(sandbox); + if (typeof windowObject.ChatRenderer !== 'function') { + throw new Error('ChatRenderer was not attached to window by the PWA renderer script.'); + } + + return windowObject.ChatRenderer as ChatRendererConstructor; +} + +function createRendererContext(rendererPrototype: ChatRendererPrototype): { context: TestRendererContext; entry: TestRendererEntry; commandCalls: AssistantCommandPayload[] } { + const entry: TestRendererEntry = { + extrasHost: new TestElement(), + extraKeys: new Set(), + editExtraItems: new Map(), + }; + + const commandCalls: AssistantCommandPayload[] = []; + const context: TestRendererContext = { + startAssistantTurn: () => entry, + scrollToBottom: vi.fn(), + uriLabel: uri => rendererPrototype.uriLabel.call(undefined, uri), + commandRunner: command => { + commandCalls.push(command); + }, + }; + + return { context, entry, commandCalls }; +} + +describe('ChatRenderer extras rendering', () => { + test('updates text edit extras in place', () => { + const document = new TestDocument(); + const chatRenderer = loadChatRenderer(document); + const { context, entry } = createRendererContext(chatRenderer.prototype); + + chatRenderer.prototype.appendAssistantExtra.call(context, 'turn-1', { + kind: 'textEdit', + uri: 'file:///workspace/src/example.ts', + editCount: 1, + isDone: false, + }); + chatRenderer.prototype.appendAssistantExtra.call(context, 'turn-1', { + kind: 'textEdit', + uri: 'file:///workspace/src/example.ts', + editCount: 3, + isDone: true, + }); + + expect(entry.extrasHost.childNodes.length).toBe(1); + const renderedItem = entry.extrasHost.childNodes[0]; + expect(renderedItem.textContent).toContain('Text edits: example.ts (3 edits, done)'); + expect(renderedItem.classList.contains('is-complete')).toBe(true); + }); + + test('renders remaining extra artifact kinds and supports pull request action button', () => { + const document = new TestDocument(); + const chatRenderer = loadChatRenderer(document); + const { context, entry, commandCalls } = createRendererContext(chatRenderer.prototype); + + chatRenderer.prototype.appendAssistantExtra.call(context, 'turn-2', { + kind: 'extensions', + extensions: ['ms-python.python', 'github.copilot-chat'], + }); + chatRenderer.prototype.appendAssistantExtra.call(context, 'turn-2', { + kind: 'pullRequest', + title: 'Ship parity updates', + description: 'Adds support for remaining response parts', + author: 'octocat', + linkTag: 'PR', + commandId: 'vscode.open', + commandArgs: ['https://github.com/org/repo/pull/10'], + }); + chatRenderer.prototype.appendAssistantExtra.call(context, 'turn-2', { + kind: 'externalEdit', + uris: ['file:///workspace/src/edited.ts'], + }); + chatRenderer.prototype.appendAssistantExtra.call(context, 'turn-2', { + kind: 'multiDiff', + title: 'Workspace changes', + readOnly: true, + entries: [ + { + originalUri: 'file:///workspace/src/before.ts', + modifiedUri: 'file:///workspace/src/after.ts', + goToFileUri: 'file:///workspace/src/after.ts', + added: 4, + removed: 1, + }, + ], + }); + + expect(entry.extrasHost.childNodes.length).toBe(4); + expect(entry.extrasHost.childNodes[0].textContent).toContain('Suggested extensions: ms-python.python, github.copilot-chat'); + expect(entry.extrasHost.childNodes[2].textContent).toContain('External edits applied: edited.ts'); + + const pullRequestButton = entry.extrasHost.childNodes[1].querySelector('.assistant-command-button'); + expect(pullRequestButton).not.toBeNull(); + pullRequestButton?.click(); + expect(commandCalls).toEqual([ + { + commandId: 'vscode.open', + args: ['https://github.com/org/repo/pull/10'], + }, + ]); + + const multiDiffList = entry.extrasHost.childNodes[3].querySelector('.assistant-extra-list'); + expect(multiDiffList).not.toBeNull(); + expect(multiDiffList?.childNodes.length).toBe(1); + expect(multiDiffList?.childNodes[0].textContent).toContain('after.ts (+4 -1)'); + }); +}); diff --git a/src/platform/bridge/test/node/conversationBridgePipeline.spec.ts b/src/platform/bridge/test/node/conversationBridgePipeline.spec.ts new file mode 100644 index 0000000000..599b3ac1d1 --- /dev/null +++ b/src/platform/bridge/test/node/conversationBridgePipeline.spec.ts @@ -0,0 +1,1220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { ChatResult } from 'vscode'; +import { RawData, WebSocket } from 'ws'; +import { + IConversationAssistantCodeCitationItem, + IConversationAssistantCommandButtonItem, + IConversationAssistantConfirmationItem, + IConversationAssistantExtraItem, + IConversationAssistantQuestionCarouselItem, + IConversationAssistantReferenceItem, + IConversationAssistantStatusItem, + IConversationAssistantToolInvocationItem, + IConversationStore, + IConversationSummary, + IConversationTurnArtifacts, + IConversationTurnChunkEvent, + IConversationTurnCodeCitationEvent, + IConversationTurnCommandButtonEvent, + IConversationTurnConfirmationEvent, + IConversationTurnEvent, + IConversationTurnExtraEvent, + IConversationTurnLifecycleEvent, + IConversationTurnQuestionCarouselEvent, + IConversationTurnReferenceEvent, + IConversationTurnStatusEvent, + IConversationTurnToolInvocationEvent, +} from '../../../../extension/conversationStore/node/conversationStore'; +import { Conversation, Turn, TurnStatus } from '../../../../extension/prompt/common/conversation'; +import { Emitter, Event } from '../../../../util/vs/base/common/event'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { IVSCodeExtensionContext } from '../../../extContext/common/extensionContext'; +import { FileType } from '../../../filesystem/common/fileTypes'; +import { MockFileSystemService } from '../../../filesystem/node/test/mockFileSystemService'; +import { TestLogService } from '../../../testing/common/testLogService'; +import { BridgeConversationSummary, BridgeMessage, BridgeServer } from '../../bridgeServer'; +import { ConversationBridge } from '../../conversationBridge'; + +class TestConversationStore implements IConversationStore { + readonly _serviceBrand: undefined; + + private readonly onDidConversationListChangedEmitter = new Emitter(); + readonly onDidConversationListChanged: Event = this.onDidConversationListChangedEmitter.event; + + private readonly onDidUserTurnEmitter = new Emitter(); + readonly onDidUserTurn: Event = this.onDidUserTurnEmitter.event; + + private readonly onDidAssistantTurnStartEmitter = new Emitter(); + readonly onDidAssistantTurnStart: Event = this.onDidAssistantTurnStartEmitter.event; + + private readonly onDidAssistantTurnChunkEmitter = new Emitter(); + readonly onDidAssistantTurnChunk: Event = this.onDidAssistantTurnChunkEmitter.event; + + private readonly onDidAssistantTurnReferenceEmitter = new Emitter(); + readonly onDidAssistantTurnReference: Event = this.onDidAssistantTurnReferenceEmitter.event; + + private readonly onDidAssistantTurnCodeCitationEmitter = new Emitter(); + readonly onDidAssistantTurnCodeCitation: Event = this.onDidAssistantTurnCodeCitationEmitter.event; + + private readonly onDidAssistantTurnStatusEmitter = new Emitter(); + readonly onDidAssistantTurnStatus: Event = this.onDidAssistantTurnStatusEmitter.event; + + private readonly onDidAssistantTurnToolInvocationEmitter = new Emitter(); + readonly onDidAssistantTurnToolInvocation: Event = this.onDidAssistantTurnToolInvocationEmitter.event; + + private readonly onDidAssistantTurnConfirmationEmitter = new Emitter(); + readonly onDidAssistantTurnConfirmation: Event = this.onDidAssistantTurnConfirmationEmitter.event; + + private readonly onDidAssistantTurnQuestionCarouselEmitter = new Emitter(); + readonly onDidAssistantTurnQuestionCarousel: Event = this.onDidAssistantTurnQuestionCarouselEmitter.event; + + private readonly onDidAssistantTurnCommandButtonEmitter = new Emitter(); + readonly onDidAssistantTurnCommandButton: Event = this.onDidAssistantTurnCommandButtonEmitter.event; + + private readonly onDidAssistantTurnExtraEmitter = new Emitter(); + readonly onDidAssistantTurnExtra: Event = this.onDidAssistantTurnExtraEmitter.event; + + private readonly onDidAssistantTurnCompleteEmitter = new Emitter(); + readonly onDidAssistantTurnComplete: Event = this.onDidAssistantTurnCompleteEmitter.event; + + private readonly conversationsByResponseId = new Map(); + private readonly conversationsBySessionId = new Map(); + private readonly assistantArtifactsByTurn = new Map(); + private summaries: readonly IConversationSummary[] = []; + private _lastConversation: Conversation | undefined; + + addConversation(responseId: string, conversation: Conversation): void { + this.conversationsByResponseId.set(responseId, conversation); + this.conversationsBySessionId.set(conversation.sessionId, conversation); + this._lastConversation = conversation; + this.onDidConversationListChangedEmitter.fire(); + } + + getConversation(responseId: string): Conversation | undefined { + return this.conversationsByResponseId.get(responseId); + } + + getConversationBySessionId(sessionId: string): Conversation | undefined { + return this.conversationsBySessionId.get(sessionId); + } + + getAssistantTurnArtifacts(conversationId: string, turnId: string): IConversationTurnArtifacts | undefined { + const artifacts = this.assistantArtifactsByTurn.get(this.getArtifactsKey(conversationId, turnId)); + if (!artifacts) { + return undefined; + } + + return { + statuses: [...artifacts.statuses], + tools: [...artifacts.tools], + references: [...artifacts.references], + codeCitations: [...artifacts.codeCitations], + confirmations: [...artifacts.confirmations], + questionCarousels: [...artifacts.questionCarousels], + commandButtons: [...artifacts.commandButtons], + extras: [...artifacts.extras], + }; + } + + listConversations(): readonly IConversationSummary[] { + return this.summaries; + } + + reportUserTurn(event: IConversationTurnEvent): void { + this.onDidUserTurnEmitter.fire(event); + } + + reportAssistantTurnStart(event: IConversationTurnLifecycleEvent): void { + this.onDidAssistantTurnStartEmitter.fire(event); + } + + reportAssistantTurnChunk(event: IConversationTurnChunkEvent): void { + this.onDidAssistantTurnChunkEmitter.fire(event); + } + + reportAssistantTurnReference(event: IConversationTurnReferenceEvent): void { + const artifacts = this.getOrCreateArtifacts(event.conversationId, event.turnId); + artifacts.references.push({ + label: event.label, + uri: event.uri, + }); + this.onDidAssistantTurnReferenceEmitter.fire(event); + } + + reportAssistantTurnCodeCitation(event: IConversationTurnCodeCitationEvent): void { + const artifacts = this.getOrCreateArtifacts(event.conversationId, event.turnId); + artifacts.codeCitations.push({ + uri: event.uri, + license: event.license, + snippet: event.snippet, + }); + this.onDidAssistantTurnCodeCitationEmitter.fire(event); + } + + reportAssistantTurnStatus(event: IConversationTurnStatusEvent): void { + const artifacts = this.getOrCreateArtifacts(event.conversationId, event.turnId); + artifacts.statuses.push({ + kind: event.kind, + content: event.content, + }); + this.onDidAssistantTurnStatusEmitter.fire(event); + } + + reportAssistantTurnToolInvocation(event: IConversationTurnToolInvocationEvent): void { + const artifacts = this.getOrCreateArtifacts(event.conversationId, event.turnId); + artifacts.tools.push({ + toolName: event.toolName, + toolCallId: event.toolCallId, + message: event.message, + isError: event.isError, + isComplete: event.isComplete, + }); + this.onDidAssistantTurnToolInvocationEmitter.fire(event); + } + + reportAssistantTurnConfirmation(event: IConversationTurnConfirmationEvent): void { + const artifacts = this.getOrCreateArtifacts(event.conversationId, event.turnId); + artifacts.confirmations.push({ + title: event.title, + message: event.message, + buttons: event.buttons, + }); + this.onDidAssistantTurnConfirmationEmitter.fire(event); + } + + reportAssistantTurnQuestionCarousel(event: IConversationTurnQuestionCarouselEvent): void { + const artifacts = this.getOrCreateArtifacts(event.conversationId, event.turnId); + artifacts.questionCarousels.push({ + allowSkip: event.allowSkip, + questions: [...event.questions], + }); + this.onDidAssistantTurnQuestionCarouselEmitter.fire(event); + } + + reportAssistantTurnCommandButton(event: IConversationTurnCommandButtonEvent): void { + const artifacts = this.getOrCreateArtifacts(event.conversationId, event.turnId); + artifacts.commandButtons.push({ + commandId: event.commandId, + title: event.title, + args: event.args, + }); + this.onDidAssistantTurnCommandButtonEmitter.fire(event); + } + + reportAssistantTurnExtra(event: IConversationTurnExtraEvent): void { + const artifacts = this.getOrCreateArtifacts(event.conversationId, event.turnId); + artifacts.extras.push(event.extra); + this.onDidAssistantTurnExtraEmitter.fire(event); + } + + reportAssistantTurnComplete(event: IConversationTurnLifecycleEvent): void { + this.onDidAssistantTurnCompleteEmitter.fire(event); + } + + get lastConversation(): Conversation | undefined { + return this._lastConversation; + } + + setConversation(conversation: Conversation): void { + this.conversationsBySessionId.set(conversation.sessionId, conversation); + this._lastConversation = conversation; + } + + setSummaries(summaries: readonly IConversationSummary[]): void { + this.summaries = summaries; + } + + private getOrCreateArtifacts(conversationId: string, turnId: string): { + statuses: IConversationAssistantStatusItem[]; + tools: IConversationAssistantToolInvocationItem[]; + references: IConversationAssistantReferenceItem[]; + codeCitations: IConversationAssistantCodeCitationItem[]; + confirmations: IConversationAssistantConfirmationItem[]; + questionCarousels: IConversationAssistantQuestionCarouselItem[]; + commandButtons: IConversationAssistantCommandButtonItem[]; + extras: IConversationAssistantExtraItem[]; + } { + const key = this.getArtifactsKey(conversationId, turnId); + let artifacts = this.assistantArtifactsByTurn.get(key); + if (!artifacts) { + artifacts = { + statuses: [], + tools: [], + references: [], + codeCitations: [], + confirmations: [], + questionCarousels: [], + commandButtons: [], + extras: [], + }; + this.assistantArtifactsByTurn.set(key, artifacts); + } + + return artifacts; + } + + private getArtifactsKey(conversationId: string, turnId: string): string { + return `${conversationId}:${turnId}`; + } + + dispose(): void { + this.onDidConversationListChangedEmitter.dispose(); + this.onDidUserTurnEmitter.dispose(); + this.onDidAssistantTurnStartEmitter.dispose(); + this.onDidAssistantTurnChunkEmitter.dispose(); + this.onDidAssistantTurnReferenceEmitter.dispose(); + this.onDidAssistantTurnCodeCitationEmitter.dispose(); + this.onDidAssistantTurnStatusEmitter.dispose(); + this.onDidAssistantTurnToolInvocationEmitter.dispose(); + this.onDidAssistantTurnConfirmationEmitter.dispose(); + this.onDidAssistantTurnQuestionCarouselEmitter.dispose(); + this.onDidAssistantTurnCommandButtonEmitter.dispose(); + this.onDidAssistantTurnExtraEmitter.dispose(); + this.onDidAssistantTurnCompleteEmitter.dispose(); + } +} + +class BridgeClient { + private readonly _messages: BridgeMessage[] = []; + + constructor(readonly socket: WebSocket) { + socket.on('message', data => { + this._messages.push(parseBridgeMessage(data)); + }); + } + + get messages(): readonly BridgeMessage[] { + return this._messages; + } + + send(message: unknown): void { + this.socket.send(JSON.stringify(message)); + } + + countType(type: BridgeMessage['type']): number { + return this._messages.filter(message => message.type === type).length; + } + + async waitForType(type: TType, occurrence = 1): Promise> { + await vi.waitFor(() => { + expect(this.countType(type)).toBeGreaterThanOrEqual(occurrence); + }, { timeout: 5000 }); + + const typedMessages = this._messages.filter((message): message is Extract => message.type === type); + return typedMessages[occurrence - 1]; + } + + async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) { + return; + } + + await new Promise(resolve => { + this.socket.once('close', () => { + resolve(); + }); + this.socket.close(); + }); + } +} + +type BridgeHarness = { + readonly bridgeServer: BridgeServer; + readonly bridge: ConversationBridge; + readonly store: TestConversationStore; + readonly client: BridgeClient; + setProviderSummaries(summaries: readonly BridgeConversationSummary[]): void; +}; + +type HarnessWorkspaceSessionFile = { + readonly id: string; + readonly contents: string; + readonly mtime: number; +}; + +type BridgeHarnessOptions = { + readonly storageUri?: URI; + readonly workspaceSessionFiles?: readonly HarnessWorkspaceSessionFile[]; +}; + +function parseBridgeMessage(data: RawData): BridgeMessage { + if (typeof data === 'string') { + return JSON.parse(data) as BridgeMessage; + } + if (Buffer.isBuffer(data)) { + return JSON.parse(data.toString('utf8')) as BridgeMessage; + } + if (Array.isArray(data)) { + return JSON.parse(Buffer.concat(data).toString('utf8')) as BridgeMessage; + } + return JSON.parse(data.toString()) as BridgeMessage; +} + +async function connectClient(port: number, token: string): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${port}/?token=${token}`); + const client = new BridgeClient(socket); + await new Promise((resolve, reject) => { + const onClose = () => { + socket.off('open', onOpen); + socket.off('error', onError); + reject(new Error('WebSocket closed before opening')); + }; + const onOpen = () => { + socket.off('error', onError); + socket.off('close', onClose); + resolve(); + }; + const onError = (error: Error) => { + socket.off('open', onOpen); + socket.off('close', onClose); + reject(error); + }; + socket.once('open', onOpen); + socket.once('error', onError); + socket.once('close', onClose); + }); + return client; +} + +async function createHarness(initialProviderSummaries: readonly BridgeConversationSummary[], options: BridgeHarnessOptions = {}): Promise { + let providerSummaries = [...initialProviderSummaries]; + const bridgeServer = new BridgeServer(); + const store = new TestConversationStore(); + const fileSystemService = new MockFileSystemService(); + const logService = new TestLogService(); + if (options.storageUri && options.workspaceSessionFiles && options.workspaceSessionFiles.length > 0) { + const workspaceChatSessionsDirUri = URI.joinPath(options.storageUri, '..', 'chatSessions'); + fileSystemService.mockDirectory(workspaceChatSessionsDirUri, options.workspaceSessionFiles.map(file => [`${file.id}.jsonl`, FileType.File])); + for (const file of options.workspaceSessionFiles) { + const sessionFileUri = URI.joinPath(workspaceChatSessionsDirUri, `${file.id}.jsonl`); + fileSystemService.mockFile(sessionFileUri, file.contents, file.mtime); + } + } + const extensionContext = { + storageUri: options.storageUri, + globalStorageUri: undefined, + } as unknown as IVSCodeExtensionContext; + const bridge = new ConversationBridge( + bridgeServer, + store, + logService, + fileSystemService, + extensionContext, + async () => providerSummaries, + ); + bridge.activate(); + + const port = await bridgeServer.start(); + const client = await connectClient(port, bridgeServer.sessionToken); + + return { + bridgeServer, + bridge, + store, + client, + setProviderSummaries(summaries: readonly BridgeConversationSummary[]) { + providerSummaries = [...summaries]; + }, + }; +} + +function createConversationWithAssistantResponse(sessionId: string, prompt: string, response: string): Conversation { + const turn = new Turn('turn-1', { message: prompt, type: 'user' }); + turn.setResponse(TurnStatus.Success, { message: response, type: 'model' }, 'response-1', undefined); + return new Conversation(sessionId, [turn]); +} + +function createConversationWithRoundFallbackResponse(sessionId: string, prompt: string, roundResponse: string): Conversation { + const turn = new Turn('turn-1', { message: prompt, type: 'user' }); + turn.setResponse(TurnStatus.Success, undefined, 'response-1', { + metadata: { + modelMessageId: 'model-message-1', + responseId: 'response-1', + sessionId, + agentId: 'agent', + toolCallRounds: [ + { + id: 'round-1', + response: roundResponse, + toolInputRetry: 0, + toolCalls: [], + }, + ], + }, + } as unknown as ChatResult); + return new Conversation(sessionId, [turn]); +} + +function createWorkspaceChatSessionFileContents(requests: readonly unknown[], customTitle?: string): string { + const state: Record = { requests }; + if (customTitle) { + state.customTitle = customTitle; + } + + return `${JSON.stringify({ kind: 0, v: state })}\n`; +} + +describe('ConversationBridge pipeline', () => { + let harness: BridgeHarness | undefined; + + afterEach(async () => { + if (harness) { + await harness.client.close(); + harness.bridge.dispose(); + await harness.bridgeServer.stop(); + harness.bridgeServer.dispose(); + harness.store.dispose(); + harness = undefined; + } + }); + + test('sends snapshot on connect and returns conversation history over the socket', async () => { + const summary: BridgeConversationSummary = { + id: 'session-1', + title: 'Ship release', + lastUpdated: 1712345678901, + provider: 'cloud', + status: 'completed', + }; + harness = await createHarness([summary]); + harness.store.setConversation(createConversationWithAssistantResponse('session-1', 'How is release?', 'Release is healthy')); + + const conversationListMessage = await harness.client.waitForType('conversation:list'); + expect(conversationListMessage.conversations).toEqual([summary]); + + const uiStateMessage = await harness.client.waitForType('ui:state'); + expect(uiStateMessage.selectedModeId).toBe('agent'); + expect(uiStateMessage.modes.map(mode => mode.id)).toEqual(['agent', 'ask', 'plan']); + + harness.client.send({ type: 'conversation:select', conversationId: 'session-1' }); + const conversationHistoryMessage = await harness.client.waitForType('conversation:history'); + expect(conversationHistoryMessage.conversationId).toBe('session-1'); + expect(conversationHistoryMessage.turns).toEqual([ + { role: 'user', content: 'How is release?', timestamp: expect.any(Number) }, + { role: 'assistant', content: 'Release is healthy', timestamp: expect.any(Number) }, + ]); + }); + + test('normalizes plan mode ids and command mode casing', async () => { + harness = await createHarness([]); + const privateBridge = harness.bridge as unknown as { + normalizeModeId(modeId: string): string; + modeIdForCommand(modeId: string): string; + }; + + await harness.client.waitForType('conversation:list'); + const uiStateMessage = await harness.client.waitForType('ui:state'); + expect(uiStateMessage.modes.map(mode => mode.id)).toContain('plan'); + expect(privateBridge.normalizeModeId('plan')).toBe('plan'); + expect(privateBridge.normalizeModeId('Plan')).toBe('plan'); + expect(privateBridge.normalizeModeId('ask')).toBe('ask'); + expect(privateBridge.normalizeModeId('Agent')).toBe('agent'); + expect(privateBridge.modeIdForCommand('plan')).toBe('Plan'); + expect(privateBridge.modeIdForCommand('ask')).toBe('Ask'); + expect(privateBridge.modeIdForCommand('agent')).toBe('Agent'); + }); + + test('uses provider summaries as canonical list when provider sessions are available', async () => { + const storageUri = URI.parse('file:///workspace/.storage/provider-canonical'); + harness = await createHarness( + [ + { + id: 'provider-session', + title: 'Provider canonical title', + lastUpdated: 1000, + provider: 'cloud', + status: 'completed', + }, + ], + { + storageUri, + workspaceSessionFiles: [ + { + id: 'provider-session', + contents: createWorkspaceChatSessionFileContents([{ request: { message: 'Workspace title should not win' } }], 'Workspace title should not win'), + mtime: 2000, + }, + { + id: 'stale-session', + contents: createWorkspaceChatSessionFileContents([{ request: { message: 'Stale workspace session' } }], 'Stale workspace session'), + mtime: 3000, + }, + ], + } + ); + + const conversationListMessage = await harness.client.waitForType('conversation:list'); + expect(conversationListMessage.conversations).toEqual([ + { + id: 'provider-session', + title: 'Provider canonical title', + lastUpdated: 2000, + provider: 'cloud', + status: 'completed', + }, + ]); + }); + + test('replaces generic provider titles with descriptive workspace titles', async () => { + const storageUri = URI.parse('file:///workspace/.storage/provider-generic-title'); + harness = await createHarness( + [ + { + id: 'provider-session', + title: 'Conversation', + lastUpdated: 1000, + provider: 'cloud', + status: 'completed', + }, + ], + { + storageUri, + workspaceSessionFiles: [ + { + id: 'provider-session', + contents: createWorkspaceChatSessionFileContents([{ request: { message: 'Workspace descriptive title' } }], 'Workspace descriptive title'), + mtime: 2000, + }, + ], + } + ); + + const conversationListMessage = await harness.client.waitForType('conversation:list'); + expect(conversationListMessage.conversations).toEqual([ + { + id: 'provider-session', + title: 'Workspace descriptive title', + lastUpdated: 2000, + provider: 'cloud', + status: 'completed', + }, + ]); + }); + + test('extracts assistant history from response value parts and skips thinking content', async () => { + const storageUri = URI.parse('file:///workspace/.storage/response-values'); + harness = await createHarness([], { + storageUri, + workspaceSessionFiles: [ + { + id: 'session-value', + contents: createWorkspaceChatSessionFileContents([ + { + request: { message: 'What changed?' }, + response: [ + { kind: 'thinking', value: 'internal reasoning that should be hidden' }, + { kind: 'markdown', value: 'Applied fix in response value' }, + ], + }, + ]), + mtime: 1712345678000, + }, + ], + }); + + await harness.client.waitForType('conversation:list'); + await harness.client.waitForType('ui:state'); + + harness.client.send({ type: 'conversation:select', conversationId: 'session-value' }); + const historyMessage = await harness.client.waitForType('conversation:history'); + expect(historyMessage.turns).toEqual([ + { role: 'user', content: 'What changed?', timestamp: expect.any(Number) }, + { role: 'assistant', content: 'Applied fix in response value', timestamp: expect.any(Number) }, + ]); + }); + + test('falls back to round responses when responseMessage is missing', async () => { + harness = await createHarness([]); + await harness.client.waitForType('conversation:list'); + await harness.client.waitForType('ui:state'); + + harness.store.setConversation(createConversationWithRoundFallbackResponse('session-rounds', 'Need a status update', 'Round response fallback works')); + harness.client.send({ type: 'conversation:select', conversationId: 'session-rounds' }); + + const historyMessage = await harness.client.waitForType('conversation:history'); + expect(historyMessage.turns).toEqual([ + { role: 'user', content: 'Need a status update', timestamp: expect.any(Number) }, + { role: 'assistant', content: 'Round response fallback works', timestamp: expect.any(Number) }, + ]); + }); + + test('broadcasts turn lifecycle events and refreshes conversation list after completion', async () => { + const summary: BridgeConversationSummary = { + id: 'session-2', + title: 'Bridge stream', + lastUpdated: 1712345678999, + provider: 'claude', + status: 'in-progress', + }; + harness = await createHarness([summary]); + + await harness.client.waitForType('conversation:list'); + await harness.client.waitForType('ui:state'); + const initialListCount = harness.client.countType('conversation:list'); + + harness.store.reportUserTurn({ + conversationId: 'session-2', + turnId: 'turn-2', + content: 'Start streaming', + timestamp: Date.now(), + }); + harness.store.reportAssistantTurnStart({ + conversationId: 'session-2', + turnId: 'turn-2', + }); + harness.store.reportAssistantTurnChunk({ + conversationId: 'session-2', + turnId: 'turn-2', + content: 'Chunk A', + }); + harness.store.reportAssistantTurnReference({ + conversationId: 'session-2', + turnId: 'turn-2', + label: 'README.md', + uri: 'file:///workspace/README.md', + }); + harness.store.reportAssistantTurnCodeCitation({ + conversationId: 'session-2', + turnId: 'turn-2', + uri: 'file:///workspace/LICENSE.txt', + license: 'MIT', + snippet: 'Copyright (c) Microsoft Corporation.', + }); + harness.store.reportAssistantTurnStatus({ + conversationId: 'session-2', + turnId: 'turn-2', + kind: 'thinking', + content: 'Planning next steps', + }); + harness.store.reportAssistantTurnToolInvocation({ + conversationId: 'session-2', + turnId: 'turn-2', + toolName: 'grep_search', + toolCallId: 'tool-1', + message: 'Searching workspace', + isError: false, + isComplete: false, + }); + harness.store.reportAssistantTurnConfirmation({ + conversationId: 'session-2', + turnId: 'turn-2', + title: 'Apply changes?', + message: 'Review and approve edits', + buttons: ['Accept', 'Reject'], + }); + harness.store.reportAssistantTurnQuestionCarousel({ + conversationId: 'session-2', + turnId: 'turn-2', + allowSkip: true, + questions: [ + { + id: 'q1', + type: 'singleSelect', + title: 'Pick a target', + message: 'Choose one', + options: ['web', 'desktop'], + allowFreeformInput: false, + }, + ], + }); + harness.store.reportAssistantTurnCommandButton({ + conversationId: 'session-2', + turnId: 'turn-2', + commandId: 'workbench.action.chat.open', + title: 'Open Chat', + args: [{ query: 'Hello' }], + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'textEdit', + uri: 'file:///workspace/src/index.ts', + editCount: 2, + isDone: false, + }, + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'extensions', + extensions: ['ms-python.python', 'github.copilot-chat'], + }, + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'pullRequest', + title: 'Fix chat parity', + description: 'Adds missing streaming part support', + author: 'octocat', + linkTag: 'PR', + commandId: 'vscode.open', + commandArgs: ['https://github.com/org/repo/pull/42'], + }, + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'externalEdit', + uris: ['file:///workspace/src/one.ts', 'file:///workspace/src/two.ts'], + }, + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'multiDiff', + title: 'Proposed changes', + readOnly: true, + entries: [ + { + originalUri: 'file:///workspace/src/old.ts', + modifiedUri: 'file:///workspace/src/new.ts', + goToFileUri: 'file:///workspace/src/new.ts', + added: 10, + removed: 3, + }, + ], + }, + }); + harness.store.reportAssistantTurnComplete({ + conversationId: 'session-2', + turnId: 'turn-2', + }); + + const userTurnMessage = await harness.client.waitForType('turn:user'); + expect(userTurnMessage.content).toBe('Start streaming'); + + const assistantStartMessage = await harness.client.waitForType('turn:start'); + expect(assistantStartMessage.turnId).toBe('turn-2'); + + const assistantChunkMessage = await harness.client.waitForType('turn:chunk'); + expect(assistantChunkMessage.content).toBe('Chunk A'); + + const assistantReferenceMessage = await harness.client.waitForType('turn:reference'); + expect(assistantReferenceMessage).toEqual({ + type: 'turn:reference', + conversationId: 'session-2', + turnId: 'turn-2', + label: 'README.md', + uri: 'file:///workspace/README.md', + }); + + const assistantCodeCitationMessage = await harness.client.waitForType('turn:codeCitation'); + expect(assistantCodeCitationMessage).toEqual({ + type: 'turn:codeCitation', + conversationId: 'session-2', + turnId: 'turn-2', + uri: 'file:///workspace/LICENSE.txt', + license: 'MIT', + snippet: 'Copyright (c) Microsoft Corporation.', + }); + + const assistantStatusMessage = await harness.client.waitForType('turn:status'); + expect(assistantStatusMessage).toEqual({ + type: 'turn:status', + conversationId: 'session-2', + turnId: 'turn-2', + kind: 'thinking', + content: 'Planning next steps', + }); + + const assistantToolMessage = await harness.client.waitForType('turn:tool'); + expect(assistantToolMessage).toEqual({ + type: 'turn:tool', + conversationId: 'session-2', + turnId: 'turn-2', + toolName: 'grep_search', + toolCallId: 'tool-1', + message: 'Searching workspace', + isError: false, + isComplete: false, + }); + + const assistantConfirmationMessage = await harness.client.waitForType('turn:confirmation'); + expect(assistantConfirmationMessage).toEqual({ + type: 'turn:confirmation', + conversationId: 'session-2', + turnId: 'turn-2', + title: 'Apply changes?', + message: 'Review and approve edits', + buttons: ['Accept', 'Reject'], + }); + + const assistantQuestionsMessage = await harness.client.waitForType('turn:questions'); + expect(assistantQuestionsMessage).toEqual({ + type: 'turn:questions', + conversationId: 'session-2', + turnId: 'turn-2', + allowSkip: true, + questions: [ + { + id: 'q1', + type: 'singleSelect', + title: 'Pick a target', + message: 'Choose one', + options: ['web', 'desktop'], + allowFreeformInput: false, + }, + ], + }); + + const assistantButtonMessage = await harness.client.waitForType('turn:button'); + expect(assistantButtonMessage).toEqual({ + type: 'turn:button', + conversationId: 'session-2', + turnId: 'turn-2', + commandId: 'workbench.action.chat.open', + title: 'Open Chat', + args: [{ query: 'Hello' }], + }); + + const assistantExtraMessage = await harness.client.waitForType('turn:extra'); + expect(assistantExtraMessage).toEqual({ + type: 'turn:extra', + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'textEdit', + uri: 'file:///workspace/src/index.ts', + editCount: 2, + isDone: false, + }, + }); + + const assistantExtensionsExtraMessage = await harness.client.waitForType('turn:extra', 2); + expect(assistantExtensionsExtraMessage).toEqual({ + type: 'turn:extra', + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'extensions', + extensions: ['ms-python.python', 'github.copilot-chat'], + }, + }); + + const assistantPullRequestExtraMessage = await harness.client.waitForType('turn:extra', 3); + expect(assistantPullRequestExtraMessage).toEqual({ + type: 'turn:extra', + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'pullRequest', + title: 'Fix chat parity', + description: 'Adds missing streaming part support', + author: 'octocat', + linkTag: 'PR', + commandId: 'vscode.open', + commandArgs: ['https://github.com/org/repo/pull/42'], + }, + }); + + const assistantExternalEditExtraMessage = await harness.client.waitForType('turn:extra', 4); + expect(assistantExternalEditExtraMessage).toEqual({ + type: 'turn:extra', + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'externalEdit', + uris: ['file:///workspace/src/one.ts', 'file:///workspace/src/two.ts'], + }, + }); + + const assistantMultiDiffExtraMessage = await harness.client.waitForType('turn:extra', 5); + expect(assistantMultiDiffExtraMessage).toEqual({ + type: 'turn:extra', + conversationId: 'session-2', + turnId: 'turn-2', + extra: { + kind: 'multiDiff', + title: 'Proposed changes', + readOnly: true, + entries: [ + { + originalUri: 'file:///workspace/src/old.ts', + modifiedUri: 'file:///workspace/src/new.ts', + goToFileUri: 'file:///workspace/src/new.ts', + added: 10, + removed: 3, + }, + ], + }, + }); + + const assistantCompleteMessage = await harness.client.waitForType('turn:complete'); + expect(assistantCompleteMessage.turnId).toBe('turn-2'); + + const refreshedConversationList = await harness.client.waitForType('conversation:list', initialListCount + 1); + expect(refreshedConversationList.conversations[0]?.id).toBe('session-2'); + }); + + test('rehydrates assistant artifacts in conversation history', async () => { + harness = await createHarness([]); + await harness.client.waitForType('conversation:list'); + await harness.client.waitForType('ui:state'); + + harness.store.setConversation(createConversationWithAssistantResponse('session-3', 'What happened?', 'Summary response')); + harness.store.reportAssistantTurnStatus({ + conversationId: 'session-3', + turnId: 'turn-1', + kind: 'progress', + content: 'Gathering context', + }); + harness.store.reportAssistantTurnToolInvocation({ + conversationId: 'session-3', + turnId: 'turn-1', + toolName: 'semantic_search', + toolCallId: 'tool-3', + message: 'Searching workspace', + isError: false, + isComplete: true, + }); + harness.store.reportAssistantTurnReference({ + conversationId: 'session-3', + turnId: 'turn-1', + label: 'conversationBridge.ts', + uri: 'file:///workspace/src/platform/bridge/conversationBridge.ts', + }); + harness.store.reportAssistantTurnCodeCitation({ + conversationId: 'session-3', + turnId: 'turn-1', + uri: 'file:///workspace/LICENSE.txt', + license: 'MIT', + snippet: 'Licensed under the MIT License.', + }); + harness.store.reportAssistantTurnConfirmation({ + conversationId: 'session-3', + turnId: 'turn-1', + title: 'Commit these edits?', + message: 'You can accept or reject', + buttons: ['Accept', 'Reject'], + }); + harness.store.reportAssistantTurnQuestionCarousel({ + conversationId: 'session-3', + turnId: 'turn-1', + allowSkip: false, + questions: [ + { + id: 'q2', + type: 'text', + title: 'Describe scope', + message: 'Short answer', + options: undefined, + allowFreeformInput: true, + }, + ], + }); + harness.store.reportAssistantTurnCommandButton({ + conversationId: 'session-3', + turnId: 'turn-1', + commandId: 'workbench.action.chat.open', + title: 'Re-open Chat', + args: [{ query: 'resume' }], + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-3', + turnId: 'turn-1', + extra: { + kind: 'anchor', + label: 'README.md', + uri: 'file:///workspace/README.md', + }, + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-3', + turnId: 'turn-1', + extra: { + kind: 'extensions', + extensions: ['ms-python.python'], + }, + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-3', + turnId: 'turn-1', + extra: { + kind: 'pullRequest', + title: 'Ship sidecar parity', + description: 'Includes remaining response part support', + author: 'octocat', + linkTag: 'PR', + commandId: 'vscode.open', + commandArgs: ['https://github.com/org/repo/pull/99'], + }, + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-3', + turnId: 'turn-1', + extra: { + kind: 'externalEdit', + uris: ['file:///workspace/src/edited.ts'], + }, + }); + harness.store.reportAssistantTurnExtra({ + conversationId: 'session-3', + turnId: 'turn-1', + extra: { + kind: 'multiDiff', + title: 'Workspace changes', + readOnly: false, + entries: [ + { + originalUri: 'file:///workspace/src/before.ts', + modifiedUri: 'file:///workspace/src/after.ts', + goToFileUri: 'file:///workspace/src/after.ts', + added: 5, + removed: 1, + }, + ], + }, + }); + + harness.client.send({ type: 'conversation:select', conversationId: 'session-3' }); + const historyMessage = await harness.client.waitForType('conversation:history'); + expect(historyMessage.turns).toEqual([ + { role: 'user', content: 'What happened?', timestamp: expect.any(Number) }, + { + role: 'assistant', + content: 'Summary response', + timestamp: expect.any(Number), + artifacts: { + statuses: [ + { kind: 'progress', content: 'Gathering context' }, + ], + tools: [ + { + toolName: 'semantic_search', + toolCallId: 'tool-3', + message: 'Searching workspace', + isError: false, + isComplete: true, + }, + ], + references: [ + { + label: 'conversationBridge.ts', + uri: 'file:///workspace/src/platform/bridge/conversationBridge.ts', + }, + ], + codeCitations: [ + { + uri: 'file:///workspace/LICENSE.txt', + license: 'MIT', + snippet: 'Licensed under the MIT License.', + }, + ], + confirmations: [ + { + title: 'Commit these edits?', + message: 'You can accept or reject', + buttons: ['Accept', 'Reject'], + }, + ], + questionCarousels: [ + { + allowSkip: false, + questions: [ + { + id: 'q2', + type: 'text', + title: 'Describe scope', + message: 'Short answer', + options: undefined, + allowFreeformInput: true, + }, + ], + }, + ], + commandButtons: [ + { + commandId: 'workbench.action.chat.open', + title: 'Re-open Chat', + args: [{ query: 'resume' }], + }, + ], + extras: [ + { + kind: 'anchor', + label: 'README.md', + uri: 'file:///workspace/README.md', + }, + { + kind: 'extensions', + extensions: ['ms-python.python'], + }, + { + kind: 'pullRequest', + title: 'Ship sidecar parity', + description: 'Includes remaining response part support', + author: 'octocat', + linkTag: 'PR', + commandId: 'vscode.open', + commandArgs: ['https://github.com/org/repo/pull/99'], + }, + { + kind: 'externalEdit', + uris: ['file:///workspace/src/edited.ts'], + }, + { + kind: 'multiDiff', + title: 'Workspace changes', + readOnly: false, + entries: [ + { + originalUri: 'file:///workspace/src/before.ts', + modifiedUri: 'file:///workspace/src/after.ts', + goToFileUri: 'file:///workspace/src/after.ts', + added: 5, + removed: 1, + }, + ], + }, + ], + }, + }, + ]); + }); + + test('applies conversation filters from conversation:list:request messages', async () => { + harness = await createHarness([ + { id: 'session-1', title: 'Ship release', lastUpdated: 3, provider: 'claude', status: 'completed' }, + { id: 'session-2', title: 'Fix flaky tests', lastUpdated: 2, provider: 'cloud', status: 'in-progress' }, + { id: 'session-3', title: 'Ship docs', lastUpdated: 1, provider: 'claude', status: 'failed' }, + ]); + + await harness.client.waitForType('conversation:list'); + await harness.client.waitForType('ui:state'); + const initialListCount = harness.client.countType('conversation:list'); + + harness.client.send({ + type: 'conversation:list:request', + filter: { + providers: ['claude'], + statuses: ['completed'], + search: 'ship', + }, + }); + + const filteredConversationList = await harness.client.waitForType('conversation:list', initialListCount + 1); + expect(filteredConversationList.conversations).toEqual([ + { id: 'session-1', title: 'Ship release', lastUpdated: 3, provider: 'claude', status: 'completed' }, + ]); + }); +}); diff --git a/src/platform/github/common/octoKitServiceImpl.ts b/src/platform/github/common/octoKitServiceImpl.ts index 4cb228cbd4..6ae889c89c 100644 --- a/src/platform/github/common/octoKitServiceImpl.ts +++ b/src/platform/github/common/octoKitServiceImpl.ts @@ -219,7 +219,7 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic const authToken = (await this._getPermissiveSession(authOptions))?.accessToken; if (!authToken) { this._logService.debug(`[getAllSessions] No authentication token available (nwo=${nwo})`); - throw new PermissiveAuthRequiredError(); + return []; } this._logService.debug(`[getAllSessions] Fetching sessions for nwo=${nwo}, open=${open}`); const result = await this._capiClientService.makeRequest({ @@ -231,6 +231,11 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic this._logService.debug(`[getAllSessions] Got ${Array.isArray(result) ? result.length : 'non-array'} sessions for nwo=${nwo}`); return result; } catch (e) { + if (e instanceof PermissiveAuthRequiredError) { + this._logService.debug(`[getAllSessions] Permissive authentication required (nwo=${nwo}).`); + return []; + } + if (e instanceof Error) { this._logService.error(e, 'Error in getAllSessions'); this._logService.debug(`[getAllSessions] Error for nwo=${nwo}: ${e.message}`); @@ -435,8 +440,8 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic try { const authToken = (await this._getPermissiveSession(authOptions))?.accessToken; if (!authToken) { - this._logService.trace('No authentication token available for getCopilotAgentModels'); - throw new PermissiveAuthRequiredError(); + this._logService.debug('[getCopilotAgentModels] No authentication token available'); + return []; } const response = await this._capiClientService.makeRequest({ method: 'GET', @@ -454,6 +459,11 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic } return []; } catch (e) { + if (e instanceof PermissiveAuthRequiredError) { + this._logService.debug('[getCopilotAgentModels] Permissive authentication required.'); + return []; + } + this._logService.error(e); return []; } From e070995e39a574f51cd24f3c4dd0298c9aae7fdc Mon Sep 17 00:00:00 2001 From: David Khachaturov Date: Tue, 14 Apr 2026 23:55:43 +0100 Subject: [PATCH 2/3] Fix Github actions for deploy-pwa --- .github/workflows/deploy-pwa.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-pwa.yml b/.github/workflows/deploy-pwa.yml index fbc21cad19..14bfd16e53 100644 --- a/.github/workflows/deploy-pwa.yml +++ b/.github/workflows/deploy-pwa.yml @@ -3,7 +3,7 @@ name: Deploy PWA on: push: branches: - - main + - sidecar paths: - pwa/** - .github/workflows/deploy-pwa.yml From 2ff42551e8afcce45117119862efc66d8d40020e Mon Sep 17 00:00:00 2001 From: David Khachaturov Date: Wed, 15 Apr 2026 00:10:44 +0100 Subject: [PATCH 3/3] Fix chat tool streaming rendering; update default URL --- README.md | 6 +-- TODOs.md | 1 + pwa/css/style.css | 48 +++++++++++++++++++ pwa/js/chat-renderer.js | 26 ++++++++-- .../vscode-node/mobileMirrorContribution.ts | 2 +- .../vscode-node/sidecarContribution.ts | 2 +- src/platform/bridge/conversationBridge.ts | 9 ++++ 7 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 TODOs.md diff --git a/README.md b/README.md index 9ca846d002..f708148de0 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ If you just want to run Sidecar without setting up the full dev environment, you 3. Reload VS Code and re-enable the `Github Copilot Chat` - this over-rides it. The Sidecar extension is now installed - **click the "Sidecar" panel at the bottom-right-hand-corner of your VS Code window** to launch. -4. Accept the default URL (`https://davidobot.github.io/vscode-copilot-chat-sidecar/`) unless you're self-hosting; this is just the visual layer +4. Accept the default URL (`https://davidobot.net/vscode-copilot-chat-sidecar/`) unless you're self-hosting; this is just the visual layer ## Development @@ -67,7 +67,7 @@ If you just want to run Sidecar without setting up the full dev environment, you The `pwa/` directory is a static web app designed for GitHub Pages deployment. This repository includes `.github/workflows/deploy-pwa.yml`, which deploys `pwa/` to Pages on pushes to `main`. -Use my deployed Github pages URL (`https://davidobot.github.io/vscode-copilot-chat-sidecar/`) in the Sidecar panel when generating pairing QR codes, or deploy your own. +Use my deployed Github pages URL (`https://davidobot.net/vscode-copilot-chat-sidecar/`) in the Sidecar panel when generating pairing QR codes, or deploy your own. ### Local PWA dev server @@ -119,7 +119,7 @@ You can test without deploying GitHub Pages. 5. Click `Sidecar` to start the bridge and open the pairing panel. 6. Set a PWA URL: - - Default hosted URL: https://davidobot.github.io/vscode-copilot-chat-sidecar/ + - Default hosted URL: https://davidobot.net/vscode-copilot-chat-sidecar/ - Local dev URL: `http://0.0.0.0:4173/` 7. Scan the QR code from your phone and open the PWA. diff --git a/TODOs.md b/TODOs.md new file mode 100644 index 0000000000..42cf634398 --- /dev/null +++ b/TODOs.md @@ -0,0 +1 @@ +1. Some chat histories are still not appearing correctly \ No newline at end of file diff --git a/pwa/css/style.css b/pwa/css/style.css index 310e9724f2..e47441077b 100644 --- a/pwa/css/style.css +++ b/pwa/css/style.css @@ -466,6 +466,54 @@ html, body { border-color: #5b84c4; } +.assistant-thinking-block { + border: 1px solid #5b84c4; + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-input-background) 60%, transparent); + font-size: 12px; + overflow: hidden; +} + +.assistant-thinking-summary { + cursor: pointer; + padding: 5px 10px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 700; + color: var(--vscode-descriptionForeground); + user-select: none; + list-style: none; +} + +.assistant-thinking-summary::-webkit-details-marker { + display: none; +} + +.assistant-thinking-summary::before { + content: '\25B6\00A0'; + font-size: 8px; + opacity: 0.7; +} + +details[open] > .assistant-thinking-summary::before { + content: '\25BC\00A0'; +} + +.assistant-thinking-content { + margin: 0; + padding: 6px 10px 8px; + font-family: var(--code-font-family); + font-size: 11px; + line-height: 1.5; + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; + word-break: break-word; + border-top: 1px solid color-mix(in srgb, #5b84c4 30%, transparent); + max-height: 260px; + overflow-y: auto; +} + .assistant-tool-item { font-family: var(--code-font-family); word-break: break-word; diff --git a/pwa/js/chat-renderer.js b/pwa/js/chat-renderer.js index ec1c0bd248..80ac0648b7 100644 --- a/pwa/js/chat-renderer.js +++ b/pwa/js/chat-renderer.js @@ -196,7 +196,8 @@ extraKeys: new Set(), editExtraItems: new Map(), referenceKeys: new Set(), - citationKeys: new Set() + citationKeys: new Set(), + thinkingItem: null, }; this.streamingTurns.set(turnId, entry); this.scrollToBottom(); @@ -377,12 +378,31 @@ } const entry = this.startAssistantTurn(turnId); + const kind = typeof status.kind === 'string' ? status.kind : 'progress'; + + if (kind === 'thinking') { + if (!entry.thinkingItem) { + const details = document.createElement('details'); + details.className = 'assistant-thinking-block'; + const summary = document.createElement('summary'); + summary.className = 'assistant-thinking-summary'; + summary.textContent = 'Thinking\u2026'; + details.appendChild(summary); + const pre = document.createElement('pre'); + pre.className = 'assistant-thinking-content'; + details.appendChild(pre); + entry.statusHost.appendChild(details); + entry.thinkingItem = pre; + } + entry.thinkingItem.textContent += status.content; + this.scrollToBottom(); + return; + } + const content = status.content.trim(); if (!content) { return; } - - const kind = typeof status.kind === 'string' ? status.kind : 'progress'; const key = `${kind}::${content}`; if (entry.statusKeys.has(key)) { return; diff --git a/src/extension/conversation/vscode-node/mobileMirrorContribution.ts b/src/extension/conversation/vscode-node/mobileMirrorContribution.ts index 06d6df640d..7d53b85020 100644 --- a/src/extension/conversation/vscode-node/mobileMirrorContribution.ts +++ b/src/extension/conversation/vscode-node/mobileMirrorContribution.ts @@ -17,7 +17,7 @@ import { IConversationStore } from '../../conversationStore/node/conversationSto const showSidecarPanelCommandId = 'github.copilot.sidecar.showPanel'; const pwaUrlStorageKey = 'sidecar.pwaUrl'; const legacyPwaUrlStorageKey = 'mobileMirror.pwaUrl'; -const defaultPwaUrl = 'https://davidobot.github.io/vscode-copilot-chat-sidecar/'; +const defaultPwaUrl = 'https://davidobot.net/vscode-copilot-chat-sidecar/'; const pwaDevUrlEnvVar = 'COPILOT_PWA_DEV_URL'; type SidecarState = 'disconnected' | 'starting' | 'ready'; diff --git a/src/extension/conversation/vscode-node/sidecarContribution.ts b/src/extension/conversation/vscode-node/sidecarContribution.ts index ca2141e775..c73dc884d9 100644 --- a/src/extension/conversation/vscode-node/sidecarContribution.ts +++ b/src/extension/conversation/vscode-node/sidecarContribution.ts @@ -20,7 +20,7 @@ import { IConversationStore } from '../../conversationStore/node/conversationSto const showSidecarPanelCommandId = 'github.copilot.sidecar.showPanel'; const pwaUrlStorageKey = 'sidecar.pwaUrl'; -const defaultPwaUrl = 'https://davidobot.github.io/vscode-copilot-chat-sidecar/'; +const defaultPwaUrl = 'https://davidobot.net/vscode-copilot-chat-sidecar/'; const pwaDevUrlEnvVar = 'COPILOT_PWA_DEV_URL'; const devTunnelDomainSuffix = '.devtunnels.ms'; const devTunnelInstallUrl = 'https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started'; diff --git a/src/platform/bridge/conversationBridge.ts b/src/platform/bridge/conversationBridge.ts index 62a7df0823..eb32078a09 100644 --- a/src/platform/bridge/conversationBridge.ts +++ b/src/platform/bridge/conversationBridge.ts @@ -1235,6 +1235,15 @@ export class ConversationBridge extends Disposable { continue; } + if (parsed.kind === 1 && Array.isArray(parsed.k) && parsed.k.length === 3 + && parsed.k[0] === 'requests' && typeof parsed.k[1] === 'number' && parsed.k[2] === 'response' + && Array.isArray(parsed.v)) { + // kind=1 sets the full response array for a specific request index. + const reqIdx = parsed.k[1] as number; + responseAccumulator.set(reqIdx, parsed.v as unknown[]); + continue; + } + if (parsed.kind === 2 && Array.isArray(parsed.k) && Array.isArray(parsed.v)) { if (this.keyPathEquals(parsed.k, 'requests')) { // Each such patch appends one (or more) new request objects.