diff --git a/.github/workflows/chat-lib-package.yml b/.github/workflows/chat-lib-package.yml index 007fc962c89226..ff4987a8c7e3b5 100644 --- a/.github/workflows/chat-lib-package.yml +++ b/.github/workflows/chat-lib-package.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: extensions/copilot/.nvmrc cache: npm diff --git a/.github/workflows/chat-perf.yml b/.github/workflows/chat-perf.yml index 7f91137b7a5aa5..ba94b89cf1965c 100644 --- a/.github/workflows/chat-perf.yml +++ b/.github/workflows/chat-perf.yml @@ -93,12 +93,12 @@ jobs: fi - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ steps.resolve.outputs.is_version != 'true' && inputs.test_build || github.ref }} - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc cache: npm @@ -212,12 +212,12 @@ jobs: matrix: ${{ fromJSON(needs.setup.outputs.perf_matrix) }} steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.setup.outputs.test_is_version != 'true' && inputs.test_build || github.ref }} - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc cache: npm @@ -394,12 +394,12 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.setup.outputs.test_is_version != 'true' && inputs.test_build || github.ref }} - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc cache: npm @@ -502,12 +502,12 @@ jobs: timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.setup.outputs.test_is_version != 'true' && inputs.test_build || github.ref }} - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/component-fixtures.yml b/.github/workflows/component-fixtures.yml index 179b673ab84c27..7c6fe21639108b 100644 --- a/.github/workflows/component-fixtures.yml +++ b/.github/workflows/component-fixtures.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need enough history for the merge-base lookup below to succeed even # when the target branch has advanced since the PR was opened. Full @@ -34,7 +34,7 @@ jobs: fetch-depth: 50 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index d7e4a00891419b..3daeb39c938ee0 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -26,10 +26,10 @@ jobs: # If you do not check out your code, Copilot will do this for you. steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/css-order-scan.yml b/.github/workflows/css-order-scan.yml index aab1b9c70e9014..0d7194143abb78 100644 --- a/.github/workflows/css-order-scan.yml +++ b/.github/workflows/css-order-scan.yml @@ -20,12 +20,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/monaco-editor.yml b/.github/workflows/monaco-editor.yml index fe6d8ab08f113e..a3653ee3621663 100644 --- a/.github/workflows/monaco-editor.yml +++ b/.github/workflows/monaco-editor.yml @@ -19,12 +19,12 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true persist-credentials: false - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index 09a414c60f44a6..cd0712c52d7b64 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -30,7 +30,7 @@ jobs: VSCODE_ARCH: arm64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true @@ -42,7 +42,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/pr-linux-cli-test.yml b/.github/workflows/pr-linux-cli-test.yml index 50d1aadb7bd7d2..21f3ba72420dff 100644 --- a/.github/workflows/pr-linux-cli-test.yml +++ b/.github/workflows/pr-linux-cli-test.yml @@ -16,7 +16,7 @@ jobs: RUSTUP_TOOLCHAIN: ${{ inputs.rustup_toolchain }} steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 38627693e39f4a..7e2cf2b1c3b00e 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -30,7 +30,7 @@ jobs: VSCODE_ARCH: x64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true @@ -42,7 +42,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index d6cb25add835e0..8b83a03492fe79 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -13,10 +13,10 @@ jobs: runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=compile-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc @@ -81,10 +81,10 @@ jobs: VSCODE_ARCH: x64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc @@ -151,10 +151,10 @@ jobs: VSCODE_ARCH: arm64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc @@ -210,10 +210,10 @@ jobs: VSCODE_ARCH: x64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc @@ -266,10 +266,10 @@ jobs: runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-linux-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: extensions/copilot/.nvmrc @@ -299,10 +299,10 @@ jobs: runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=copilot-windows-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: extensions/copilot/.nvmrc diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index 844cb3814d15bd..981bf097e7d679 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -30,7 +30,7 @@ jobs: VSCODE_ARCH: x64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true @@ -42,7 +42,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a9051eaebf9b8b..b04ecfe533cbf4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,12 +23,12 @@ jobs: runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=compile-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] steps: - name: Checkout microsoft/vscode - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc @@ -211,11 +211,11 @@ jobs: pull-requests: read steps: - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: extensions/copilot/.nvmrc @@ -255,11 +255,11 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: extensions/copilot/.nvmrc @@ -274,12 +274,12 @@ jobs: contents: read steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: extensions/copilot/.nvmrc @@ -368,12 +368,12 @@ jobs: contents: read steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: lfs: true - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: extensions/copilot/.nvmrc diff --git a/.github/workflows/sessions-e2e.yml b/.github/workflows/sessions-e2e.yml index f1b2eec70d16ae..16371d093eb4a2 100644 --- a/.github/workflows/sessions-e2e.yml +++ b/.github/workflows/sessions-e2e.yml @@ -25,10 +25,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/telemetry.yml b/.github/workflows/telemetry.yml index 78a6fbd1400c81..b0017798e3ba50 100644 --- a/.github/workflows/telemetry.yml +++ b/.github/workflows/telemetry.yml @@ -7,11 +7,11 @@ jobs: runs-on: 'ubuntu-latest' steps: - - uses: 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' # v6.1.0 + - uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' # v7.0.1 with: persist-credentials: false - - uses: 'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' # v6.5.0 + - uses: 'actions/setup-node@820762786026740c76f36085b0efc47a31fe5020' # v7.0.0 with: node-version: 'lts/*' diff --git a/.npmrc b/.npmrc index c28bfd82d97fe4..da649877931abe 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="42.8.1" -ms_build_id="14906494" +target="42.10.0" +ms_build_id="15109253" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index bfb554e136b1aa..c02fd323cbbb3f 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -1b35c5d29a11b097ff2f61b45d1cacfb79ccdff2ecd22922776a4e6459c7ecfb *chromedriver-v42.8.1-darwin-arm64.zip -a27788a398a7135b9cb2b0b56f5ba0b50e8aab1b54d83b1b413b3e1e3c770709 *chromedriver-v42.8.1-darwin-x64.zip -e7aad0ffb9a206152362d4209e2078c165814c5c91de44ad410c52385f0a8fb7 *chromedriver-v42.8.1-linux-arm64.zip -e1c4a6c39e8e9380da7eabc029447fa0a9ac8834f9b3ff6a75a1b3f8b31c1fb1 *chromedriver-v42.8.1-linux-armv7l.zip -f8839cedadeae394dda47306a35ac156d15f6d0612af01c9d04ddfd51a5537c5 *chromedriver-v42.8.1-linux-x64.zip -87b9e946001c0950589d5848b433e2052f00f4e2a0be8682fa4980bce3a480fb *chromedriver-v42.8.1-mas-arm64.zip -41de1fe6819e429c4afa696f3679d26b432bbd59aca4535955190c3bbf10104c *chromedriver-v42.8.1-mas-x64.zip -7f0e84e0f567d098f1467c18455d6c00f9698bfa8007cc845494e52da60a7054 *chromedriver-v42.8.1-win32-arm64.zip -fa25f5586d98188cc9022864babd50ca4110f1cef8ed616d56c7a6a4f39d1ec3 *chromedriver-v42.8.1-win32-ia32.zip -48a5e475df33be4e7c79f08ea9fe887a68ccaae6fd3187d09b3e0fae78c86aa4 *chromedriver-v42.8.1-win32-x64.zip -2052a8e72ff894b62851f3d23bcc4ce8683f21194b0dc973711237ee33175bed *electron-api.json -fe2be77c97b1d9adca681ddaa3f41ea895bc7fc5c3f904a0c8bc835925c430e7 *electron-v42.8.1-darwin-arm64-dsym-snapshot.zip -196bb6cc8dfeb27ddf4cece18d5c8b9aa951488135db81e0c70e179655b60d89 *electron-v42.8.1-darwin-arm64-dsym.tar.xz -b1b08c35fa7f7f4f1422f35ef310dd3dbc5cea16dcc50499b6ee3299179a18f8 *electron-v42.8.1-darwin-arm64-symbols.zip -f03df963463d120a35a194e0c172f15c611ea81dceda09a6ea275843611231fd *electron-v42.8.1-darwin-arm64.zip -4d288d7706f6f377b318498a1c2f3175926a452c2885330b6cf1aee19f95ae04 *electron-v42.8.1-darwin-x64-dsym-snapshot.zip -b23dbbca88a280ae1159759d2e00b45a4d0acac80b27a98e77dfcf4bd3176f44 *electron-v42.8.1-darwin-x64-dsym.tar.xz -a0003d15b543fa75f0a216daa3b79316211bfa6e910b9733e8a3978a2c6e2b9a *electron-v42.8.1-darwin-x64-symbols.zip -a9cb0bc4e7e41e047798c5366a2136ece4949fb9092c59d54597d6ce09bb2e09 *electron-v42.8.1-darwin-x64.zip -c0548d7fa5f182d9f41c3134a7c1860b0a133e6d410d9ce0eaa5dc9698a0cf80 *electron-v42.8.1-linux-arm64-debug.zip -5e359d0b7b1be96a6b92e93b53267fb26732a93256de8fa6ea2fbee2b8d8ba10 *electron-v42.8.1-linux-arm64-symbols.zip -072e2441ee95b9ecf0d5ae56ef4715ee949258d10ca11ae5e856d7f93a7ebb36 *electron-v42.8.1-linux-arm64.zip -d54bb6956812451ac4d6156462085e66e81a67616b64b6aa7743de355fd3eae3 *electron-v42.8.1-linux-armv7l-debug.zip -32b6a377322b5334dd5fffbd9f8cda90794648b15c7eb3c4a83be81b3f3c7857 *electron-v42.8.1-linux-armv7l-symbols.zip -8f14bbbd9717a00749928ca009a3b9ee2b4b05e6f4dbf49d1d48365dad346053 *electron-v42.8.1-linux-armv7l.zip -bffc45ef137fc592ac75b107b00ec78b6a8ca8fe4271092c2d0b37b2def43d87 *electron-v42.8.1-linux-x64-debug.zip -48f6b04c50073c6faf552966e6f2f18d02cfe58eb704e1ec352ed6c5c1f35cc5 *electron-v42.8.1-linux-x64-symbols.zip -2b47299ee6927b1e6cd6c12ee655794118349a3de48a0add97e438ebeebad809 *electron-v42.8.1-linux-x64.zip -d37e2eaa1cec6e507950124ad8a08158f2cec1b133f65a0ee170b8c44d8b0ab7 *electron-v42.8.1-mas-arm64-dsym-snapshot.zip -f17d95755dd116aad40d2703e894822607519a2658222ebd2e9442f5bb006028 *electron-v42.8.1-mas-arm64-dsym.tar.xz -ef4de6549587f7c74224c56f819772a4a2653c89e42e0af5bc759575065da532 *electron-v42.8.1-mas-arm64-symbols.zip -f3a2c8920b3923fb61eea8a2493e183f9afd1750062ebdc8f7cc92b3afe99223 *electron-v42.8.1-mas-arm64.zip -d542f6206a2382eed67ec04000b74f8123006d737a2c394b14ccee2758335500 *electron-v42.8.1-mas-x64-dsym-snapshot.zip -82879f4732de2334897892825ef73ceceb2590e45ec42fa0ad66f30d68f35042 *electron-v42.8.1-mas-x64-dsym.tar.xz -8843bf7548deb599aab32c225f9c6351af0a8379865d56f15019931295c9bb86 *electron-v42.8.1-mas-x64-symbols.zip -9728d2f1bb6688f1e74041d10e8f8a506bc10b290a6d00c45b555adea6ce46c0 *electron-v42.8.1-mas-x64.zip -a2a9bbbf06d1d858556e66102330f520ea9a3129995bdfc30828540fba9647b1 *electron-v42.8.1-win32-arm64-pdb.zip -08415bee58fd0c16c54854ef8ffe11450bdb1d7c79f7c45658e898ccf0bb8977 *electron-v42.8.1-win32-arm64-symbols.zip -93e0253324919c9cd187a9864291f4452e4632747b8ecc1d9ec7e6d6afbf86e9 *electron-v42.8.1-win32-arm64-toolchain-profile.zip -03589ff4df68a5a1a7c11b6275fb77b6b35d76e296cbeb5c27eca5fd7d165bbb *electron-v42.8.1-win32-arm64.zip -fd8cd58035df78a74f3e4ba226162bca99847317e8f068b45500ce12a0b95f0d *electron-v42.8.1-win32-ia32-pdb.zip -c5e0d4058b4cef46efb0913255ecf5932f7318317a9f2ca536db3c169cc34850 *electron-v42.8.1-win32-ia32-symbols.zip -93e0253324919c9cd187a9864291f4452e4632747b8ecc1d9ec7e6d6afbf86e9 *electron-v42.8.1-win32-ia32-toolchain-profile.zip -cb3c62378215ddd2d57e1bfe5cad9bd45d4188e2ef8b732a88972e25a0d01b78 *electron-v42.8.1-win32-ia32.zip -a20b32e2d2c8c7cbd169c4bd4f904eb316f05a52d6d2438fdc630191f92b8a14 *electron-v42.8.1-win32-x64-pdb.zip -0496a2ea91e9a4b98bb4557dcbdb3f23d7a476d463846194a3fcba83b0c71d03 *electron-v42.8.1-win32-x64-symbols.zip -93e0253324919c9cd187a9864291f4452e4632747b8ecc1d9ec7e6d6afbf86e9 *electron-v42.8.1-win32-x64-toolchain-profile.zip -7a1aff619f94ead8a377d82e1f59bfd9a31a17db5b948f82fc5e60d576fe9304 *electron-v42.8.1-win32-x64.zip -382aaaf4ffae549fb2a105f274c12900f64b9611ce5b795adb4b130334a79456 *electron.d.ts -7373ae2f14806951b289c0bafd28ce6517a6ea59f978d930e8d2cb578c564ff8 *ffmpeg-v42.8.1-darwin-arm64.zip -377b073d86cf2b0dba87e4822619c681ff31cd65b145e1aa677170abf9ffcb7a *ffmpeg-v42.8.1-darwin-x64.zip -80b5eacd0a7518ec6e577adee584a89cbd51b80eeeab42afbe539f614f807f9e *ffmpeg-v42.8.1-linux-arm64.zip -2e24581796500f7d6ed0c16076d74b7fe0b7cf625844623535077df03886bc26 *ffmpeg-v42.8.1-linux-armv7l.zip -1a693053890f4f1198f8b9b6b56a3ae28608da0a5d129ee2cb15c1a119315b23 *ffmpeg-v42.8.1-linux-x64.zip -2f4b9d486fe07df24803e5f7c280bcb618ce00b04d858ec7e7346a66ca6e55ca *ffmpeg-v42.8.1-mas-arm64.zip -19f58a28cfe9bd25e058071a4c06fce84cb5ddfdf62861a566b427f7a26f1193 *ffmpeg-v42.8.1-mas-x64.zip -d4fa9af810d569b747457d05d6c58a5ff1a366ea36283d35b127d42bac78ce94 *ffmpeg-v42.8.1-win32-arm64.zip -47a55f20bce68efe447e525ff15aa5bb370bd6bdf592e272c0a51f1d00f6b783 *ffmpeg-v42.8.1-win32-ia32.zip -31d471e7992e1cd86e0f96c80d0b4d57b7bb653b9417fe4858baef0c8959c689 *ffmpeg-v42.8.1-win32-x64.zip -0b43113d2e84dfa7532133d95c9d63b1e2351f3a34c805c00c0b95bd83b3e7f5 *hunspell_dictionaries.zip -a64a62d2064056c9877544e09f378b14c1e05321aa1da1ac4786040aa2016cec *libcxx-objects-v42.8.1-linux-arm64.zip -97a1ca26bd5363bf0ab90c2895fca4271818a75e5c9870e486736ffac60ce152 *libcxx-objects-v42.8.1-linux-armv7l.zip -c954df6f47c48686fae79aee1645e591c822997c44e0801ee23ec2466d34ab04 *libcxx-objects-v42.8.1-linux-x64.zip -1922727da0c69a22f3be97dce1f024b0412c817eaeace9ea2fbffcae1575cf71 *libcxx_headers.zip -4c150f4569cb6c661f2c6def9aac93bc396520fe0dccb9544a95958e74ccfa7b *libcxxabi_headers.zip -319d46eb1d877463f4d53289800ee1cf111dff67034a344837550753a131ef4f *mksnapshot-v42.8.1-darwin-arm64.zip -ac7d64f9b38b2d4d1fe3c654663e90282b34893f7b853df5a749ca6f0d952bfd *mksnapshot-v42.8.1-darwin-x64.zip -b4329a9eceb1b7029e1396e4eefc197001dbffa4921e560d915d27c7f33340c7 *mksnapshot-v42.8.1-linux-arm64-x64.zip -71702aaed0fd48738f727d4c35bcd94798c46cac0d1cf8f87130861245b009b0 *mksnapshot-v42.8.1-linux-armv7l-x64.zip -25aecb76b33f8163b7455c6b942e48857500dac64aca69125c8f7e9d0deb8a81 *mksnapshot-v42.8.1-linux-x64.zip -4b39b9b51151f5674b116f531964623ecf3141e9f92c4839126463d2f3834d6e *mksnapshot-v42.8.1-mas-arm64.zip -4adcea8e1766b8c2228b1094692de0cd56de384449026cc0a9b26158789f2a69 *mksnapshot-v42.8.1-mas-x64.zip -471cd2a61e31f092ee71cff18a936cefe7903daa828b1c00e95463b066295059 *mksnapshot-v42.8.1-win32-arm64-x64.zip -e0811a7d72e763ae5f89dd6d91e1e17e1cc3ac9d3e3c8e8fc993c43fa304c0d7 *mksnapshot-v42.8.1-win32-ia32.zip -1c1f3909f03e589924c9aa2f311c90333f5d66d26b43ae21b42efe7d9907a47a *mksnapshot-v42.8.1-win32-x64.zip +d363913bed5b850303e7e96b4641dceb939877bd0951c0cbe76ba042fe3148e4 *chromedriver-v42.10.0-darwin-arm64.zip +84594714534214250acab106af9ec45838f8323cc20b9c171f46464bd8df5352 *chromedriver-v42.10.0-darwin-x64.zip +41b66fbead2d1bc206ffe07e10a27347d455bbca9ae2e504c6204ece37e79fcd *chromedriver-v42.10.0-linux-arm64.zip +92191344964d0cf94fd0dd02de72f20b86c3dae776ca22cd3eb69b904342fa6b *chromedriver-v42.10.0-linux-armv7l.zip +5706516de71ba63e0c4aca75551a8b87d1c7d87b17a9aea5415688718a55ef16 *chromedriver-v42.10.0-linux-x64.zip +e067b991f36dbae1e142397d70cb06af1e321c4b627bce274f873235dfcb326f *chromedriver-v42.10.0-mas-arm64.zip +ed899591c5bba38156626d2f7e22d1ad1f55307c36c77d607c159ff61329af75 *chromedriver-v42.10.0-mas-x64.zip +5649eb547d51912843d47cc9fe77e354fd8d3e4710a52fefe42b6c547152bb54 *chromedriver-v42.10.0-win32-arm64.zip +58c4132be55b61c71189169195be3bf66a8775c96dec34de521482df05cf3270 *chromedriver-v42.10.0-win32-ia32.zip +4de9e69a783f64376c4cf1e24bc7ae0ec9788f2d1f0f4e75bae59e21df2c4c98 *chromedriver-v42.10.0-win32-x64.zip +24cf80104ef9841ff6a63c0a2432691460fe26a79d72a6da9f39e9b40d006f2d *electron-api.json +b883762cf24c04e6ae04fc101f2ee83cb76ba712dbccbd209c03cf038e6386a6 *electron-v42.10.0-darwin-arm64-dsym-snapshot.zip +41ca8c82868a951714ad991cc253930c74001b36173077601075df4a99ee0e34 *electron-v42.10.0-darwin-arm64-dsym.tar.xz +d59521f32d454d010d502197f29bf5a7582569e1e600acb7f4e6996dbf52bc2c *electron-v42.10.0-darwin-arm64-symbols.zip +89713813621bfcc60fdb8eaba13b6ca42ea859721f65273370228174146f546c *electron-v42.10.0-darwin-arm64.zip +60353ab62a86bf200acc0a3d1307f8c7554111b0c70987d31c95fbc1350b31bb *electron-v42.10.0-darwin-x64-dsym-snapshot.zip +642f9d79319b70c31747590e718f9420e9ee9a76e1487013af15058f42113a42 *electron-v42.10.0-darwin-x64-dsym.tar.xz +f5f84f441f0f3f5b9e48e31e7f672c323e5bc62cd7867ac6b730e1cd55a4f445 *electron-v42.10.0-darwin-x64-symbols.zip +248bdf2559e13e570e23bfc1edd639a4ee494a4d592d7fe0843314ead1215c92 *electron-v42.10.0-darwin-x64.zip +e2070c264e31e3d5948a726c12f82033b34311a7664202bb249c846423ea0caf *electron-v42.10.0-linux-arm64-debug.zip +6813bbf478c44da55f8221ac3b4432d8673158b89329bd641fd0a7f129b0ac67 *electron-v42.10.0-linux-arm64-symbols.zip +afc9047efbb23e68f057664f9a9ac5a9c469726d64bc5d4af1bddbe2fd21be02 *electron-v42.10.0-linux-arm64.zip +644fcccecd6cbb885351f26aff3c5ce874b053d6f2e350cb4913d8d2a761f433 *electron-v42.10.0-linux-armv7l-debug.zip +8a962829287b064a27f7e6abcb9ef2ecd8855d219d25943274768fca90741664 *electron-v42.10.0-linux-armv7l-symbols.zip +b22f2c5a30b01286975fcf1ffa7558e2ed42045739bcbb9d6bad97ed0c86e237 *electron-v42.10.0-linux-armv7l.zip +6b4f5bb881893fc8ee392b2d73f977760128ae0b99f2dd6112ce363eb5d3791c *electron-v42.10.0-linux-x64-debug.zip +acf02b86a73fd7f1b65ad90ff0173451120d303dffd7a912a656a40903e5681e *electron-v42.10.0-linux-x64-symbols.zip +edcfae0a8cf2a75ceee8a9842a0117521283164708b193495f4cb60ab2d6aa0d *electron-v42.10.0-linux-x64.zip +d89804d436f76777ce9058ac08ae11e072bded850e4a283e239c4fe89b25da59 *electron-v42.10.0-mas-arm64-dsym-snapshot.zip +7aadecc79b8aab6342b2037e3c7b70a45aec821c5c0fce205f9c60af0cffe664 *electron-v42.10.0-mas-arm64-dsym.tar.xz +6e916f81a04d6982a025b5c05a392ceefa1b6af6a9e5c068b22800d27ab2c234 *electron-v42.10.0-mas-arm64-symbols.zip +36cf288c0feba0513e7d059c66fae5e7308e5bdab642f5aa75a660888f70470b *electron-v42.10.0-mas-arm64.zip +9f4b29a386310acdcb9f61ed10471fd2c49a4aa7c10c1510710acd6074c441e8 *electron-v42.10.0-mas-x64-dsym-snapshot.zip +9c6c1b6b3238b65b99a2da63378223e52a027d42887ef3d3a63fde972b73a781 *electron-v42.10.0-mas-x64-dsym.tar.xz +34e782cc97ed01132acf63c79ae430b8c28c8bb860acbb7013aba967a98b4ebf *electron-v42.10.0-mas-x64-symbols.zip +941a89201b9e813a3e7b88979c3e4f3167518b0f6ad6ef64cad550c4e020020e *electron-v42.10.0-mas-x64.zip +aa3df04accf0499515717651e743c7e2cc893b4a3db4f0baa28c03c21a00ce09 *electron-v42.10.0-win32-arm64-pdb.zip +1de01053e98cfea5eddb9250753bb1bb305a1068fb5bc85209f029265adee94b *electron-v42.10.0-win32-arm64-symbols.zip +90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v42.10.0-win32-arm64-toolchain-profile.zip +b6204381cfc035fec831678a24edde7803622468c814cc1487e5910ee688a739 *electron-v42.10.0-win32-arm64.zip +e986ed39fbe20c82c06ead60e8184a079a47da4752a2d95308794149f6bad883 *electron-v42.10.0-win32-ia32-pdb.zip +ed037c22177b89021e18ce719ba50f744dacef553a6f82879db5a58bdab3acd0 *electron-v42.10.0-win32-ia32-symbols.zip +90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v42.10.0-win32-ia32-toolchain-profile.zip +f22690f5209b22cabb043b96d8e4da5d518571a0d70738f88903be0e768afd90 *electron-v42.10.0-win32-ia32.zip +27c3a1b6c6cf4b3f4e3184216438ba685fbdb889439f4a21065fe997a965b882 *electron-v42.10.0-win32-x64-pdb.zip +8237869e2060e1b570d31e3ee28bf2067e3f31a6868748ef78b25a31f2121b92 *electron-v42.10.0-win32-x64-symbols.zip +90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v42.10.0-win32-x64-toolchain-profile.zip +6988553dc944800c127f6600133b9dd7810a83a82b9b68d2faa07dbb10ef5071 *electron-v42.10.0-win32-x64.zip +24bd380cc4aec824e8c7e8b18bdfcb8a4078d522fc607645b38774dac094da41 *electron.d.ts +fc01c280e2779dacc206df5cc075fa6f60a190f30d06910faea2e5a078cabd1c *ffmpeg-v42.10.0-darwin-arm64.zip +b85b3208f73184a6193fec5f91e7d25195b57049016fd93bd639675203fdcb46 *ffmpeg-v42.10.0-darwin-x64.zip +9289325530c6594925d6125c384c80fa29996f5fe78e90ea89813fd053f1d2ae *ffmpeg-v42.10.0-linux-arm64.zip +2e24581796500f7d6ed0c16076d74b7fe0b7cf625844623535077df03886bc26 *ffmpeg-v42.10.0-linux-armv7l.zip +9217d7147f5ed7eb6f0c14a280743dbca3a2e67b5ad192ce38f8b94fad0a245b *ffmpeg-v42.10.0-linux-x64.zip +9c9a17d5ed1056b5024a0dc6554b954367628d90b1f4f708e8fa5131aa34edaf *ffmpeg-v42.10.0-mas-arm64.zip +270a94d0d382c2e384ee92d1fba7c222a3486812f73355a09266ceeeef7f0237 *ffmpeg-v42.10.0-mas-x64.zip +58f2d3038d2e1aefba931237fceaf86678e0628cdb25c4928c1a1cf6291c852a *ffmpeg-v42.10.0-win32-arm64.zip +a54e4e1f1cab6d3e706d5faf2caf897ec8175e172e533ad937b1b23bb49a9035 *ffmpeg-v42.10.0-win32-ia32.zip +63a94e025397c8d72163c3923da63799587cbc265e723a9d4a8cf14ad0ee1e06 *ffmpeg-v42.10.0-win32-x64.zip +740a0b86826316f1c11ba03c7f041feadf758938a6fbdef76f54e74ab2d007a3 *hunspell_dictionaries.zip +7bcec0de6b6f630d8e2dd602489608c9573a1961364cfa932d60ca2147e61f3e *libcxx-objects-v42.10.0-linux-arm64.zip +ad52a931433f819914764f051152b1b86b083afc7d9d1ea6eb8f57946d690e7a *libcxx-objects-v42.10.0-linux-armv7l.zip +7d407127404196c40275438113ddbf857a2963729c7e566f7e188900904e1102 *libcxx-objects-v42.10.0-linux-x64.zip +3bd2212473ffda8e13d2d5d1d6b92f2c8dc7437ee62ffe8fb8564ed95ecfe48b *libcxx_headers.zip +176f64da935e6c6c9bfb68a65317ea34c521ddc77572ddad83f2e1fca73ffa0c *libcxxabi_headers.zip +cf4314f76cc9689020dbc7740e4cbbdc9d3e08eff26ae6ed19e6284d29501989 *mksnapshot-v42.10.0-darwin-arm64.zip +403a7825f725e41003e78f0bcc5c258d43f1c05dad119c65ac9496518ca3528b *mksnapshot-v42.10.0-darwin-x64.zip +4f898843e363df5f4ad59cfd851c8e0575115d288b439d5f81420c683535a884 *mksnapshot-v42.10.0-linux-arm64-x64.zip +74447fd7861c396a99fcc81a2745f7df1845671bde7e3267d3e7f16216ff5038 *mksnapshot-v42.10.0-linux-armv7l-x64.zip +a070a7e54f1080cd1f9c65ce6670968c1630e08bca1b42e67e8c64ee398a4077 *mksnapshot-v42.10.0-linux-x64.zip +1f39d17281a961a380991c898bb79b0a4e836499652eb525e03771472c05fe60 *mksnapshot-v42.10.0-mas-arm64.zip +37364906af89fb1815d314dd972edd99d7e0e27c2a6cbf5e6034342fe445d619 *mksnapshot-v42.10.0-mas-x64.zip +949a1a05c1d519430aa0dc380a01307458e38b7e66c1bd6b19e02ad625e4400e *mksnapshot-v42.10.0-win32-arm64-x64.zip +518674b18c31053607bf02f58c3584ec1bb26142a3a485b12272760c90f4c752 *mksnapshot-v42.10.0-win32-ia32.zip +8c2a3fe8765d827c96d670ec6a92d6813b3bfc22f31faa633a46be3523bd4ed4 *mksnapshot-v42.10.0-win32-x64.zip diff --git a/build/dictation-runtime/nuget.ts b/build/dictation-runtime/nuget.ts index 91c2025c9f811d..06693879567d30 100644 --- a/build/dictation-runtime/nuget.ts +++ b/build/dictation-runtime/nuget.ts @@ -33,6 +33,9 @@ import { SDK_PACKAGE_NAME } from './common.ts'; const SCRIPT = 'nuget.ts'; const VSCODE_FEED_PREFIX = 'https://pkgs.dev.azure.com/monacotools/'; const VSS_NUGET_ACCESSTOKEN = 'VSS_NUGET_ACCESSTOKEN'; +const MAX_DOWNLOAD_ATTEMPTS = 5; +const INITIAL_RETRY_DELAY_MS = 1_000; +const MAX_RETRY_DELAY_MS = 30_000; export const VSCODE_NUGET_FEED = 'https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json'; /** @@ -274,10 +277,32 @@ function downloadToFile(url: string, dest: string): Promise { })); } -/** Issue a GET, following up to 5 redirects, then hand the 200 response to `onOk`. */ +function isRetryableStatus(status: number): boolean { + return status === 408 || status === 429 || status >= 500; +} + +function getRetryDelay(response: import('http').IncomingMessage, attempt: number): number { + const retryAfterHeader = response.headers['retry-after']; + const retryAfter = Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader; + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) { + return Math.min(Math.max(seconds * 1_000, 0), MAX_RETRY_DELAY_MS); + } + + const date = Date.parse(retryAfter); + if (!Number.isNaN(date)) { + return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_DELAY_MS); + } + } + + return Math.min(INITIAL_RETRY_DELAY_MS * 2 ** (attempt - 1), MAX_RETRY_DELAY_MS); +} + +/** Issue a GET with bounded retries, following up to 5 redirects, then hand the 200 response to `onOk`. */ function followRedirects(url: string, onOk: (res: import('http').IncomingMessage) => Promise): Promise { return new Promise((resolve, reject) => { - const request = (currentUrl: string, redirectsLeft: number): void => { + const request = (currentUrl: string, redirectsLeft: number, attempt: number): void => { https.get(currentUrl, getRequestOptions(currentUrl), res => { const status = res.statusCode ?? 0; if (status >= 300 && status < 400 && res.headers.location) { @@ -286,17 +311,23 @@ function followRedirects(url: string, onOk: (res: import('http').IncomingMess reject(new Error(`Too many redirects downloading ${url}.`)); return; } - request(new URL(res.headers.location, currentUrl).toString(), redirectsLeft - 1); + request(new URL(res.headers.location, currentUrl).toString(), redirectsLeft - 1, attempt); return; } if (status !== 200) { res.resume(); + if (isRetryableStatus(status) && attempt < MAX_DOWNLOAD_ATTEMPTS) { + const delay = getRetryDelay(res, attempt); + console.warn(`[${SCRIPT}] Download failed with status ${status}; retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_DOWNLOAD_ATTEMPTS})...`); + setTimeout(() => request(currentUrl, redirectsLeft, attempt + 1), delay); + return; + } reject(new Error(`Download failed with status ${status}: ${currentUrl}`)); return; } onOk(res).then(resolve, reject); }).on('error', reject); }; - request(url, 5); + request(url, 5, 1); }); } diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index edd0c10c4f2272..4b0668ca3b6777 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1116,6 +1116,8 @@ "--scroll-shadow-surface", "--vscode-chat-list-background", "--vscode-chat-persistent-content-height", + "--vscode-colorPicker-colorDecoratorMargin", + "--vscode-colorPicker-colorDecoratorWidth", "--vscode-editorCodeLens-fontFamily", "--vscode-editorCodeLens-fontFamilyDefault", "--vscode-editorCodeLens-fontFeatureSettings", diff --git a/cgmanifest.json b/cgmanifest.json index 5390edeaa3db67..50d7db088bac27 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "fc77625f01310b17d7e407b7c5600f9c15628ecb", - "tag": "42.8.1" + "commitHash": "9f0109679540320c5dc5b7bef0aa94733058e8c2", + "tag": "42.10.0" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "42.8.1" + "version": "42.10.0" }, { "component": { diff --git a/extensions/copilot/chat-lib/package-lock.json b/extensions/copilot/chat-lib/package-lock.json index 833b5a07ba69c2..ba18b671fa3618 100644 --- a/extensions/copilot/chat-lib/package-lock.json +++ b/extensions/copilot/chat-lib/package-lock.json @@ -5058,9 +5058,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 2836a196044a6e..89d405cffc358b 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -471,10 +471,23 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS if (!await this.shouldShowSession(metadata.sessionId, metadata.context)) { return; } + // A legacy session stays in this list until it is opened: opening adopts it + // into the agent host, which then owns it (and this provider drops it via + // `agentHostOwnedSessionIds` above). Until then the agent host does not + // surface un-adopted legacy rows, so there is nothing to stand down for here. const id = metadata.sessionId; const startTime = metadata.startTime.getTime(); const endTime = metadata.modifiedTime.getTime(); - const label = await this.getSessionTitleImpl(metadata.sessionId, metadata, token); + // Never drop an on-disk session that passed `shouldShowSession` just because its + // title could not be resolved; fall back to its folder name / a generic label. + // A freshly-created, still-empty session is a live wrapper with no derivable + // title — leave it to the in-progress path below rather than surfacing it here + // with a synthetic label. + const resolvedTitle = await this.getSessionTitleImpl(metadata.sessionId, metadata, token); + const label = resolvedTitle + || (this._sessionWrappers.has(metadata.sessionId) + ? undefined + : metadata.context?.cwd?.split(/[\\/]/).filter(Boolean).pop() || l10n.t("Copilot CLI session")); if (!label) { return; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index 1c56c6a5754ef6..142616edb032dd 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -13,6 +13,7 @@ import { CancellationToken } from 'vscode-languageserver-protocol'; import { IAuthenticationService } from '../../../../../platform/authentication/common/authentication'; import { NullChatDebugFileLoggerService } from '../../../../../platform/chat/common/chatDebugFileLoggerService'; import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; +import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService'; import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext'; import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService'; import { FileType } from '../../../../../platform/filesystem/common/fileTypes'; @@ -727,6 +728,26 @@ describe('CopilotCLISessionService', () => { }); }); + it('lists all legacy sessions when migration is enabled (extension owns them until opened)', async () => { + await (configurationService as InMemoryConfigurationService).setNonExtensionConfig('chat.agentSessions.migrateLegacyCopilotCli', true); + const migrateService = disposables.add(createSessionService()); + const migrateManager = await migrateService.getSessionManager() as unknown as MockCliSdkSessionManager; + + for (const id of ['legacy-active', 'legacy-archived']) { + const sdkSession = new MockCliSdkSession(id, new Date(0)); + sdkSession.clientName = 'vscode'; + sdkSession.summary = id; + migrateManager.sessions.set(id, sdkSession); + } + await metadataStore.setSessionArchived('legacy-archived', true); + + const result = await migrateService.getAllSessions(CancellationToken.None); + + // The agent host no longer surfaces un-adopted legacy rows, so this list keeps + // every legacy session (archived or not) until it is opened and thereby adopted. + expect(result.map(item => item.id).sort()).toEqual(['legacy-active', 'legacy-archived']); + }); + it('does not list sessions created outside VS Code, even once loaded into memory', async () => { const external = new MockCliSdkSession('external-cli', new Date(0)); external.summary = 'external-cli'; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts index 27db19de13a856..79a07a972b92e3 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts @@ -34,6 +34,7 @@ export class MockCliSdkSession { this.summary = summary; }; public summary?: string; + public context?: { cwd?: string; gitRoot?: string; repository?: string }; constructor(public readonly sessionId: string, public readonly startTime: Date) { } getChatContextMessages(): Promise<{}[]> { return Promise.resolve(this.messages); } getEvents(): {}[] { return this.events; } @@ -52,7 +53,7 @@ export class MockCliSdkSession { get permissions() { return { setRequired: (_required: boolean) => { - // no-op in tests + // no-op in tests } }; } @@ -91,11 +92,11 @@ export class MockCliSdkSessionManager { return Promise.resolve(undefined); } listSessions() { - return Promise.resolve(Array.from(this.sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime, modifiedTime: s.startTime, summary: s.summary, name: s.name, clientName: s.clientName }))); + return Promise.resolve(Array.from(this.sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime, modifiedTime: s.startTime, summary: s.summary, name: s.name, clientName: s.clientName, context: s.context }))); } getSessionMetadata({ sessionId }: { sessionId: string }) { const session = this.sessions.get(sessionId); - return Promise.resolve(session ? { sessionId: session.sessionId, startTime: session.startTime, modifiedTime: session.startTime, summary: session.summary, name: session.name, clientName: session.clientName, isRemote: false } : undefined); + return Promise.resolve(session ? { sessionId: session.sessionId, startTime: session.startTime, modifiedTime: session.startTime, summary: session.summary, name: session.name, clientName: session.clientName, context: session.context, isRemote: false } : undefined); } deleteSession(id: string) { this.sessions.delete(id); return Promise.resolve(); } closeSession(_id: string) { return Promise.resolve(); } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 023cdcf7eaf742..84038c0cfa53df 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -380,6 +380,9 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } item.timing = session.timing; item.status = session.status ?? vscode.ChatSessionStatus.Completed; + // Rehydrate persisted archived state: a re-emit (e.g. onDidChangeSession) must not + // resurrect an archived row by rebuilding the item without it. + item.archived = await this._metadataStore.getSessionArchived(session.id); // Building changes is expensive, so defer it to explicit resolve and refresh paths // when lazy loading is enabled. Preserve eager loading when it is disabled. diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts index f6a814c8e6299b..c8a8eaba8d4e0b 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts @@ -171,6 +171,7 @@ function createProvider() { override getRequestDetails = vi.fn(async () => []); override getRepositoryProperties = vi.fn(async () => undefined); override getSessionParentId = vi.fn(async () => undefined); + override getSessionArchived = vi.fn(async () => false); }; const gitService = new TestGitService(); const folderRepositoryManager = new TestFolderRepositoryManager(); @@ -227,6 +228,7 @@ function createProvider() { worktreeService, gitService, octoKitService, + metadataStore, }; } @@ -509,6 +511,20 @@ describe('CopilotCLIChatSessionContentProvider (additional)', () => { expect(item.label).toBe('Test Session'); }); + it('toChatSessionItem rehydrates persisted archived state', async () => { + const { provider, metadataStore } = createProvider(); + metadataStore.getSessionArchived.mockResolvedValue(true); + const sessionItem: ICopilotCLISessionItem = { + id: 'session-1', + label: 'Test Session', + status: undefined, + workingDirectory: undefined, + } as unknown as ICopilotCLISessionItem; + + const item = await provider.toChatSessionItem(sessionItem); + expect(item.archived).toBe(true); + }); + it('only includes cached changes when explicitly requested', async () => { const { provider, worktreeService } = createProvider(); const sessionItem: ICopilotCLISessionItem = { diff --git a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts index 5d09865904ff2d..f203c379727981 100644 --- a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts @@ -113,6 +113,7 @@ export interface IModelPromo { /** ISO 8601 end date; absent for open-ended promotions. */ ends_at?: string; message: string; + show_banner?: boolean; } export interface IModelBilling { diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 64111f1a16bf26..91c51279188da2 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -200,7 +200,7 @@ export class ChatEndpoint implements IChatEndpoint { public readonly maxPromptImages?: number | undefined; public readonly warningText?: Record | undefined; public readonly infoText?: Record | undefined; - public readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string } | undefined; + public readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string; showBanner?: boolean } | undefined; private readonly _supportsStreaming: boolean; @@ -259,6 +259,7 @@ export class ChatEndpoint implements IChatEndpoint { discountPercent: modelMetadata.billing.promo.discount_percent, endsAt: modelMetadata.billing.promo.ends_at, message: modelMetadata.billing.promo.message, + showBanner: modelMetadata.billing.promo.show_banner, } : undefined; } diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index f3384ef4e72fa1..e48ad42bc3217b 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -348,7 +348,7 @@ export interface IChatEndpoint extends IEndpoint { readonly warningText?: Record; /** Category-keyed info banners for the model picker. Unlike {@link warningText} these never signal a problem. */ readonly infoText?: Record; - readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string }; + readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string; showBanner?: boolean }; readonly multiplier?: number; readonly restrictedToSkus?: string[]; /** diff --git a/package.json b/package.json index c4d4fc6eabe813..4b687cd212329e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.136.0", - "distro": "ebabac9aa9cfb4c9351977aa0d1cab83b357ffaa", + "distro": "1c2dba21c7247ff2c7ebecc69aa6015474107e92", "author": { "name": "Microsoft Corporation" }, diff --git a/scripts/sync-agent-host-protocol.ts b/scripts/sync-agent-host-protocol.ts index f5b3e879b28b5a..1b0a2d6c7901d1 100644 --- a/scripts/sync-agent-host-protocol.ts +++ b/scripts/sync-agent-host-protocol.ts @@ -219,30 +219,12 @@ function mergeDuplicateImports(content: string): string { }).join('\n'); } -function applyGeneratedSourceFixes(content: string, dest: string): string { - const replaceRequired = (search: string | RegExp, replacement: string): void => { - const next = content.replace(search, replacement); - if (next === content) { - throw new Error(`Required generated-source compatibility fix no longer matches ${dest}`); - } - content = next; - }; - if (dest === 'channels-automation/state.ts') { - replaceRequired( - 'import type { AutomationCreateRequestedAction, AutomationRemovedAction, AutomationSetAction, AutomationUpdateRequestedAction } from \'./actions.js\';', - 'import type { AutomationRemovedAction, AutomationSetAction, AutomationUpdateRequestedAction } from \'./actions.js\';', - ); - } - return content; -} - function processFile(src: string, dest: string): void { let content = fs.readFileSync(src, 'utf-8'); content = stripExistingHeader(content); // Merge duplicate imports from the same module content = mergeDuplicateImports(content); - content = applyGeneratedSourceFixes(content, dest); content = convertIndentation(content); content = content.split('\n').map(line => line.trimEnd()).join('\n'); diff --git a/src/vs/editor/browser/view/domLineBreaksComputer.ts b/src/vs/editor/browser/view/domLineBreaksComputer.ts index 1c88653206fac9..abac9d8a123d1f 100644 --- a/src/vs/editor/browser/view/domLineBreaksComputer.ts +++ b/src/vs/editor/browser/view/domLineBreaksComputer.ts @@ -12,7 +12,7 @@ import { WrappingIndent } from '../../common/config/editorOptions.js'; import { StringBuilder } from '../../common/core/stringBuilder.js'; import { InjectedTextOptions } from '../../common/model.js'; import { ILineBreaksComputer, ILineBreaksComputerContext, ILineBreaksComputerFactory, ModelLineProjectionData } from '../../common/modelLineProjectionData.js'; -import { LineInjectedText } from '../../common/textModelEvents.js'; +import { FixedWidthInjectedTextRange, LineInjectedText } from '../../common/textModelEvents.js'; import { FontInfo } from '../../common/config/fontInfo.js'; const ttPolicy = createTrustedTypesPolicy('domLineBreaksComputer', { createHTML: value => value }); @@ -78,10 +78,13 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont const wrappedTextIndentLengths: number[] = []; const renderLineContents: string[] = []; const allCharOffsets: number[][] = []; + const allSpanStartOffsets: number[][] = []; const allVisibleColumns: number[][] = []; for (let i = 0; i < lineNumbers.length; i++) { const lineNumber = lineNumbers[i]; - const lineContent = LineInjectedText.applyInjectedText(context.getLineContent(lineNumber), context.getLineInjectedText(lineNumber)); + const injectedTexts = context.getLineInjectedText(lineNumber); + const lineContent = LineInjectedText.applyInjectedText(context.getLineContent(lineNumber), injectedTexts); + const fixedWidthRanges = LineInjectedText.getFixedWidthInjectedTextRanges(injectedTexts); let firstNonWhitespaceIndex = 0; let wrappedTextIndentLength = 0; @@ -97,14 +100,20 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont // Track existing indent for (let i = 0; i < firstNonWhitespaceIndex; i++) { - const charWidth = ( - lineContent.charCodeAt(i) === CharCode.Tab - ? (tabSize - (wrappedTextIndentLength % tabSize)) - : 1 - ); - wrappedTextIndentLength += charWidth; + const fixedWidthRange = fixedWidthRanges[0]; + const isFixedWidthStart = fixedWidthRange && fixedWidthRange.startOffset === i; + if (isFixedWidthStart) { + firstNonWhitespaceIndex = i; + break; + } else { + const charWidth = ( + lineContent.charCodeAt(i) === CharCode.Tab + ? (tabSize - (wrappedTextIndentLength % tabSize)) + : 1 + ); + wrappedTextIndentLength += charWidth; + } } - const indentWidth = Math.ceil(fontInfo.spaceWidth * wrappedTextIndentLength); // Force sticking to beginning of line if no character would fit except for the indentation @@ -118,11 +127,19 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont } const renderLineContent = lineContent.substr(firstNonWhitespaceIndex); - const tmp = renderLine(renderLineContent, wrappedTextIndentLength, tabSize, width, sb, additionalIndentLength); + const shiftedFixedWidthRanges = firstNonWhitespaceIndex === 0 + ? fixedWidthRanges + : fixedWidthRanges.map(range => ({ + startOffset: Math.max(0, range.startOffset - firstNonWhitespaceIndex), + endOffset: range.endOffset - firstNonWhitespaceIndex, + widthInEm: range.widthInEm + })); + const tmp = renderLine(renderLineContent, wrappedTextIndentLength, tabSize, width, sb, additionalIndentLength, shiftedFixedWidthRanges); firstNonWhitespaceIndices[i] = firstNonWhitespaceIndex; wrappedTextIndentLengths[i] = wrappedTextIndentLength; renderLineContents[i] = renderLineContent; allCharOffsets[i] = tmp[0]; + allSpanStartOffsets[i] = tmp[2]; allVisibleColumns[i] = tmp[1]; } const html = sb.build(); @@ -149,7 +166,7 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont for (let i = 0; i < lineNumbers.length; i++) { const lineNumber = lineNumbers[i]; const lineDomNode = lineDomNodes[i]; - const breakOffsets: number[] | null = readLineBreaks(range, lineDomNode, renderLineContents[i], allCharOffsets[i]); + const breakOffsets: number[] | null = readLineBreaks(range, lineDomNode, renderLineContents[i], allCharOffsets[i], allSpanStartOffsets[i]); if (breakOffsets === null) { result[i] = createEmptyLineBreakWithPossiblyInjectedText(lineNumber); continue; @@ -193,7 +210,7 @@ const enum Constants { SPAN_MODULO_LIMIT = 16384 } -function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: number, width: number, sb: StringBuilder, wrappingIndentLength: number): [number[], number[]] { +function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: number, width: number, sb: StringBuilder, wrappingIndentLength: number, fixedWidthRanges: readonly FixedWidthInjectedTextRange[]): [number[], number[], number[]] { if (wrappingIndentLength !== 0) { const hangingOffset = String(wrappingIndentLength); @@ -214,14 +231,51 @@ function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: const len = lineContent.length; let visibleColumn = initialVisibleColumn; let charOffset = 0; + let fixedWidthRangeIndex = 0; const charOffsets: number[] = []; + const spanStartOffsets: number[] = [0]; const visibleColumns: number[] = []; let nextCharCode = (0 < len ? lineContent.charCodeAt(0) : CharCode.Null); + let spanOpen = true; sb.appendString(''); for (let charIndex = 0; charIndex < len; charIndex++) { - if (charIndex !== 0 && charIndex % Constants.SPAN_MODULO_LIMIT === 0) { + let fixedWidthRange = fixedWidthRanges[fixedWidthRangeIndex]; + const startsFixedWidth = fixedWidthRange && fixedWidthRange.startOffset === charIndex; + if (startsFixedWidth) { + if (spanOpen) { + sb.appendString(''); + } + // Injected text that only reserves horizontal space covers no character, so it gets a span of + // its own. Rendering it inside the span of the character below would make that character fixed + // width as well. Several such injections can sit at the same offset. + while (fixedWidthRange && fixedWidthRange.startOffset === charIndex && fixedWidthRange.endOffset === charIndex) { + sb.appendString(''); + sb.appendString(''); + spanStartOffsets.push(charOffset); + fixedWidthRange = fixedWidthRanges[++fixedWidthRangeIndex]; + } + // The character below goes into a fixed width span if one still covers it, a normal one + // otherwise. At most one such range can start here: injections at the same column are laid + // out one after the other, so only an empty one leaves the next starting at the same offset. + if (fixedWidthRange && fixedWidthRange.startOffset === charIndex) { + sb.appendString(''); + } else { + sb.appendString(''); + } + spanStartOffsets.push(charOffset); + spanOpen = true; + } else if (!spanOpen) { + sb.appendString(''); + spanStartOffsets.push(charOffset); + spanOpen = true; + } else if ((!fixedWidthRange || charIndex < fixedWidthRange.startOffset) && charIndex !== 0 && charIndex % Constants.SPAN_MODULO_LIMIT === 0) { sb.appendString(''); + spanStartOffsets.push(charOffset); } charOffsets[charIndex] = charOffset; visibleColumns[charIndex] = visibleColumn; @@ -286,18 +340,30 @@ function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: charOffset += producedCharacters; visibleColumn += charWidth; + + // A range that covers no character has already been closed above, and must not be consumed here: + // its `endOffset` equals its `startOffset`, so this condition would hold one character too early. + if (fixedWidthRange && fixedWidthRange.startOffset < fixedWidthRange.endOffset && charIndex + 1 === fixedWidthRange.endOffset) { + sb.appendString(''); + spanOpen = false; + fixedWidthRangeIndex++; + } + } + if (spanOpen) { + sb.appendString(''); } - sb.appendString(''); + // A spacing-only injection at the very end of the line is left out on purpose: nothing follows it, + // so it cannot move a break point. `MonospaceLineBreaksComputer` ignores it for the same reason. charOffsets[lineContent.length] = charOffset; visibleColumns[lineContent.length] = visibleColumn; sb.appendString(''); - return [charOffsets, visibleColumns]; + return [charOffsets, visibleColumns, spanStartOffsets]; } -function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: string, charOffsets: number[]): number[] | null { +function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: string, charOffsets: number[], spanStartOffsets: number[]): number[] | null { if (lineContent.length <= 1) { return null; } @@ -305,7 +371,7 @@ function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: const breakOffsets: number[] = []; try { - discoverBreaks(range, spans, charOffsets, 0, null, lineContent.length - 1, null, breakOffsets); + discoverBreaks(range, spans, charOffsets, spanStartOffsets, 0, null, lineContent.length - 1, null, breakOffsets); } catch (err) { console.error(err); return null; @@ -319,13 +385,13 @@ function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: return breakOffsets; } -function discoverBreaks(range: Range, spans: HTMLSpanElement[], charOffsets: number[], low: number, lowRects: DOMRectList | null, high: number, highRects: DOMRectList | null, result: number[]): void { +function discoverBreaks(range: Range, spans: HTMLSpanElement[], charOffsets: number[], spanStartOffsets: number[], low: number, lowRects: DOMRectList | null, high: number, highRects: DOMRectList | null, result: number[]): void { if (low === high) { return; } - lowRects = lowRects || readClientRect(range, spans, charOffsets[low], charOffsets[low + 1]); - highRects = highRects || readClientRect(range, spans, charOffsets[high], charOffsets[high + 1]); + lowRects = lowRects || readClientRect(range, spans, charOffsets[low], charOffsets[low + 1], spanStartOffsets); + highRects = highRects || readClientRect(range, spans, charOffsets[high], charOffsets[high + 1], spanStartOffsets); if (Math.abs(lowRects[0].top - highRects[0].top) <= 0.1) { // same line @@ -340,13 +406,34 @@ function discoverBreaks(range: Range, spans: HTMLSpanElement[], charOffsets: num } const mid = low + ((high - low) / 2) | 0; - const midRects = readClientRect(range, spans, charOffsets[mid], charOffsets[mid + 1]); - discoverBreaks(range, spans, charOffsets, low, lowRects, mid, midRects, result); - discoverBreaks(range, spans, charOffsets, mid, midRects, high, highRects, result); + const midRects = readClientRect(range, spans, charOffsets[mid], charOffsets[mid + 1], spanStartOffsets); + discoverBreaks(range, spans, charOffsets, spanStartOffsets, low, lowRects, mid, midRects, result); + discoverBreaks(range, spans, charOffsets, spanStartOffsets, mid, midRects, high, highRects, result); } -function readClientRect(range: Range, spans: HTMLSpanElement[], startOffset: number, endOffset: number): DOMRectList { - range.setStart(spans[(startOffset / Constants.SPAN_MODULO_LIMIT) | 0].firstChild!, startOffset % Constants.SPAN_MODULO_LIMIT); - range.setEnd(spans[(endOffset / Constants.SPAN_MODULO_LIMIT) | 0].firstChild!, endOffset % Constants.SPAN_MODULO_LIMIT); +function readClientRect(range: Range, spans: HTMLSpanElement[], startOffset: number, endOffset: number, spanStartOffsets: number[]): DOMRectList { + if (!spanStartOffsets) { + range.setStart(spans[(startOffset / Constants.SPAN_MODULO_LIMIT) | 0].firstChild!, startOffset % Constants.SPAN_MODULO_LIMIT); + range.setEnd(spans[(endOffset / Constants.SPAN_MODULO_LIMIT) | 0].firstChild!, endOffset % Constants.SPAN_MODULO_LIMIT); + return range.getClientRects(); + } + const startSpanIndex = findSpanIndex(spanStartOffsets, startOffset); + const endSpanIndex = findSpanIndex(spanStartOffsets, endOffset); + range.setStart(spans[startSpanIndex].firstChild!, startOffset - spanStartOffsets[startSpanIndex]); + range.setEnd(spans[endSpanIndex].firstChild!, endOffset - spanStartOffsets[endSpanIndex]); return range.getClientRects(); } + +function findSpanIndex(spanStartOffsets: readonly number[], offset: number): number { + let low = 0; + let high = spanStartOffsets.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (spanStartOffsets[mid] <= offset) { + low = mid + 1; + } else { + high = mid; + } + } + return low - 1; +} diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index 2fc027b34a231c..34c57f255760cf 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -347,6 +347,12 @@ export interface InjectedTextOptions { */ readonly inlineClassNameAffectsLetterSpacing?: boolean; + /** + * Sets the width used to wrap this injected text in editor-font em units. + * @internal + */ + readonly widthInEm?: number; + /** * This field allows to attach data to this injected text. * The data can be read when injected texts at a given position are queried. diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index 6d7e5a6be55037..ee6ad23b353b83 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -2463,6 +2463,7 @@ export class ModelDecorationInjectedTextOptions implements model.InjectedTextOpt public readonly tokens: TokenArray | null; readonly inlineClassName: string | null; readonly inlineClassNameAffectsLetterSpacing: boolean; + readonly widthInEm: number | undefined; readonly attachedData: unknown | null; readonly cursorStops: model.InjectedTextCursorStops | null; @@ -2471,6 +2472,7 @@ export class ModelDecorationInjectedTextOptions implements model.InjectedTextOpt this.tokens = options.tokens ?? null; this.inlineClassName = options.inlineClassName || null; this.inlineClassNameAffectsLetterSpacing = options.inlineClassNameAffectsLetterSpacing || false; + this.widthInEm = options.widthInEm !== undefined && Number.isFinite(options.widthInEm) && options.widthInEm >= 0 ? options.widthInEm : undefined; this.attachedData = options.attachedData || null; this.cursorStops = options.cursorStops || null; } diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index 1f504db5852e61..561feefda77050 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -234,6 +234,24 @@ export class ModelRawFlush { public readonly changeType = RawContentChangedType.Flush; } +/** + * Represents a fixed-width injected text range within a line. + * @internal + */ +export interface FixedWidthInjectedTextRange { + readonly startOffset: number; + readonly endOffset: number; + readonly widthInEm: number; +} + +/** + * Whether injected text takes up space on a line, either through its content or, when it is + * width-only (e.g. `{ content: '', widthInEm: 1 }`), through the horizontal space it reserves. + */ +function occupiesHorizontalSpace(options: InjectedTextOptions): boolean { + return options.content.length > 0 || (options.widthInEm !== undefined && options.widthInEm > 0); +} + /** * Represents text injected on a line * @internal @@ -257,7 +275,7 @@ export class LineInjectedText { public static fromDecorations(decorations: IModelDecoration[]): LineInjectedText[] { const result: LineInjectedText[] = []; for (const decoration of decorations) { - if (decoration.options.before && decoration.options.before.content.length > 0) { + if (decoration.options.before && occupiesHorizontalSpace(decoration.options.before)) { result.push(new LineInjectedText( decoration.ownerId, decoration.range.startLineNumber, @@ -266,7 +284,7 @@ export class LineInjectedText { 0, )); } - if (decoration.options.after && decoration.options.after.content.length > 0) { + if (decoration.options.after && occupiesHorizontalSpace(decoration.options.after)) { result.push(new LineInjectedText( decoration.ownerId, decoration.range.endLineNumber, @@ -288,6 +306,32 @@ export class LineInjectedText { return result; } + /** + * The ranges of `applyInjectedText(...)` that are rendered at a fixed width. Width-only injected + * text produces an empty range (`startOffset === endOffset`) which reserves horizontal space + * without covering any character. + * + * `injectedTexts` must be sorted by column, which is what `fromDecorations` produces and what + * `applyInjectedText` already requires. The result is then sorted by `startOffset` and never + * overlaps: injections at the same column are laid out one after the other, so only an injection + * with empty content leaves the next one starting at the same offset. + */ + public static getFixedWidthInjectedTextRanges(injectedTexts: readonly LineInjectedText[] | null): FixedWidthInjectedTextRange[] { + const result: FixedWidthInjectedTextRange[] = []; + let injectedTextLength = 0; + for (const injectedText of injectedTexts ?? []) { + const length = injectedText.options.content.length; + const startOffset = injectedText.column - 1 + injectedTextLength; + const endOffset = startOffset + length; + const widthInEm = injectedText.options.widthInEm; + if (widthInEm !== undefined) { + result.push({ startOffset, endOffset, widthInEm }); + } + injectedTextLength += length; + } + return result; + } + constructor( public readonly ownerId: number, public readonly lineNumber: number, diff --git a/src/vs/editor/common/viewLayout/lineDecorations.ts b/src/vs/editor/common/viewLayout/lineDecorations.ts index 3439b945aacc27..521d2668face0e 100644 --- a/src/vs/editor/common/viewLayout/lineDecorations.ts +++ b/src/vs/editor/common/viewLayout/lineDecorations.ts @@ -92,7 +92,9 @@ export class LineDecoration { } private static _typeCompare(a: InlineDecorationType, b: InlineDecorationType): number { - const ORDER = [2, 0, 1, 3]; + // WidthOnly, Before, After, Regular, RegularAffectingLetterSpacing. + // Width only decorations come from injected text, which renders before any other decoration. + const ORDER = [3, 1, 2, 4, 0]; return ORDER[a] - ORDER[b]; } diff --git a/src/vs/editor/common/viewLayout/lineHeights.ts b/src/vs/editor/common/viewLayout/lineHeights.ts index 3c5ab572d66a61..7c54052046e1d3 100644 --- a/src/vs/editor/common/viewLayout/lineHeights.ts +++ b/src/vs/editor/common/viewLayout/lineHeights.ts @@ -37,9 +37,9 @@ export class CustomLine { this.decorationId = decorationId; this.index = index; this.lineNumber = lineNumber; - this.specialHeight = specialHeight; + this.specialHeight = Math.round(specialHeight); this.prefixSum = prefixSum; - this.maximumSpecialHeight = specialHeight; + this.maximumSpecialHeight = Math.round(specialHeight); this.deleted = false; } } diff --git a/src/vs/editor/common/viewLayout/viewLineRenderer.ts b/src/vs/editor/common/viewLayout/viewLineRenderer.ts index 11aca36bbb9a7c..3616af6c04d186 100644 --- a/src/vs/editor/common/viewLayout/viewLineRenderer.ts +++ b/src/vs/editor/common/viewLayout/viewLineRenderer.ts @@ -382,18 +382,19 @@ export function renderViewLine(input: RenderLineInput, sb: StringBuilder): Rende let afterCount = 0; let containsForeignElements = ForeignElementType.None; for (const lineDecoration of input.lineDecorations) { - if (lineDecoration.type === InlineDecorationType.Before || lineDecoration.type === InlineDecorationType.After) { + if (lineDecoration.type === InlineDecorationType.Before || lineDecoration.type === InlineDecorationType.After || lineDecoration.type === InlineDecorationType.WidthOnly) { sb.appendString(``); - if (lineDecoration.type === InlineDecorationType.Before) { - containsForeignElements |= ForeignElementType.Before; - beforeCount++; - } if (lineDecoration.type === InlineDecorationType.After) { containsForeignElements |= ForeignElementType.After; afterCount++; + } else { + // Width only decorations sit before the character at their column, just like + // before content decorations. + containsForeignElements |= ForeignElementType.Before; + beforeCount++; } } } @@ -502,6 +503,9 @@ function resolveRenderLineInput(input: RenderLineInput): ResolvedRenderLineInput containsForeignElements |= ForeignElementType.Before; } else if (lineDecoration.type === InlineDecorationType.After) { containsForeignElements |= ForeignElementType.After; + } else if (lineDecoration.type === InlineDecorationType.WidthOnly) { + // Pretend there are foreign elements... although not 100% accurate. See above. + containsForeignElements |= ForeignElementType.Before; } } tokens = _applyInlineDecorations(lineContent, len, tokens, input.lineDecorations); diff --git a/src/vs/editor/common/viewModel/inlineDecorations.ts b/src/vs/editor/common/viewModel/inlineDecorations.ts index f6869c57dd334e..5e8a25204e6444 100644 --- a/src/vs/editor/common/viewModel/inlineDecorations.ts +++ b/src/vs/editor/common/viewModel/inlineDecorations.ts @@ -13,7 +13,15 @@ export const enum InlineDecorationType { Regular = 0, Before = 1, After = 2, - RegularAffectingLetterSpacing = 3 + RegularAffectingLetterSpacing = 3, + /** + * A decoration that covers no character, yet is rendered as an element of its own. + * Used for injected text that only reserves horizontal space (e.g. `{ content: '', widthInEm: 1 }`): + * there is no character to decorate, so the width comes from the decoration's class instead. + * Unlike {@link InlineDecorationType.Before}/{@link InlineDecorationType.After}, this is not a + * pseudo element attached to a surrounding decoration, but the injected content itself. + */ + WidthOnly = 4 } export class InlineDecoration { @@ -230,27 +238,40 @@ export class InjectedTextInlineDecorationsComputer implements IInlineDecorations const lineEndOffsetInInputWithInjections = breakOffsets[outputLineIndex]; while (currentInjectedOffset < injectionOffsets.length) { - const length = injectionOptions![currentInjectedOffset].content.length; + const options = injectionOptions![currentInjectedOffset]; + const length = options.content.length; const injectedTextStartOffsetInInputWithInjections = injectionOffsets[currentInjectedOffset] + totalInjectedTextLengthBefore; const injectedTextEndOffsetInInputWithInjections = injectedTextStartOffsetInInputWithInjections + length; + const isWidthOnly = (length === 0 && options.widthInEm !== undefined); + const isLastOutputLine = outputLineIndex === breakOffsets.length - 1; + const isAtInternalWrapBoundary = injectedTextStartOffsetInInputWithInjections === lineEndOffsetInInputWithInjections && !isLastOutputLine; - if (injectedTextStartOffsetInInputWithInjections > lineEndOffsetInInputWithInjections) { + if (injectedTextStartOffsetInInputWithInjections > lineEndOffsetInInputWithInjections || (isWidthOnly && isAtInternalWrapBoundary)) { // Injected text only starts in later wrapped lines. break; } - if (lineStartOffsetInInputWithInjections < injectedTextEndOffsetInInputWithInjections) { + const isInLine = isWidthOnly + ? lineStartOffsetInInputWithInjections <= injectedTextStartOffsetInInputWithInjections + && (injectedTextStartOffsetInInputWithInjections < lineEndOffsetInInputWithInjections || isLastOutputLine) + : lineStartOffsetInInputWithInjections < injectedTextEndOffsetInInputWithInjections; + if (isInLine) { // Injected text ends after or in this line (but also starts in or before this line). - const options = injectionOptions![currentInjectedOffset]; if (options.inlineClassName) { const wrappedTextIndentLength = this.context.getWrappedTextIndentLength(modelLineNumber); const offset = (outputLineIndex > 0 ? wrappedTextIndentLength : 0); const start = offset + Math.max(injectedTextStartOffsetInInputWithInjections - lineStartOffsetInInputWithInjections, 0); const end = offset + Math.min(injectedTextEndOffsetInInputWithInjections - lineStartOffsetInInputWithInjections, lineEndOffsetInInputWithInjections - lineStartOffsetInInputWithInjections); - if (start !== end) { + if (start !== end || isWidthOnly) { const viewLineNumber = this.context.getBaseViewLineNumber(modelLineNumber) + outputLineIndex; const range = new Range(viewLineNumber, start + 1, viewLineNumber, end + 1); - const type: InlineDecorationType = options.inlineClassNameAffectsLetterSpacing ? InlineDecorationType.RegularAffectingLetterSpacing : InlineDecorationType.Regular; + const type: InlineDecorationType = ( + isWidthOnly + ? InlineDecorationType.WidthOnly + : options.inlineClassNameAffectsLetterSpacing + ? InlineDecorationType.RegularAffectingLetterSpacing + : InlineDecorationType.Regular + ); inlineDecorations.push(new InlineDecoration(range, options.inlineClassName, type)); } } diff --git a/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts b/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts index 678c350916de2a..08826554820276 100644 --- a/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts +++ b/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts @@ -8,7 +8,7 @@ import * as strings from '../../../base/common/strings.js'; import { WrappingIndent, IComputedEditorOptions, EditorOption } from '../config/editorOptions.js'; import { CharacterClassifier } from '../core/characterClassifier.js'; import { FontInfo } from '../config/fontInfo.js'; -import { LineInjectedText } from '../textModelEvents.js'; +import { FixedWidthInjectedTextRange, LineInjectedText } from '../textModelEvents.js'; import { InjectedTextOptions } from '../model.js'; import { ILineBreaksComputerFactory, ILineBreaksComputer, ModelLineProjectionData, ILineBreaksComputerContext } from '../modelLineProjectionData.js'; @@ -46,7 +46,7 @@ export class MonospaceLineBreaksComputerFactory implements ILineBreaksComputerFa if (previousLineBreakData && !previousLineBreakData.injectionOptions && !injectedText && !isLineFeedWrappingEnabled) { result[i] = createLineBreaksFromPreviousLineBreaks(this.classifier, previousLineBreakData, lineText, tabSize, wrappingColumn, columnsForFullWidthChar, wrappingIndent, wordBreak); } else { - result[i] = createLineBreaks(this.classifier, lineText, injectedText, tabSize, wrappingColumn, columnsForFullWidthChar, wrappingIndent, wordBreak, isLineFeedWrappingEnabled); + result[i] = createLineBreaks(this.classifier, lineText, injectedText, tabSize, wrappingColumn, columnsForFullWidthChar, fontInfo, wrappingIndent, wordBreak, isLineFeedWrappingEnabled); } } arrPool1.length = 0; @@ -356,8 +356,9 @@ function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterCla return previousBreakingData; } -function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: string, injectedTexts: LineInjectedText[] | null, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ModelLineProjectionData | null { +function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: string, injectedTexts: LineInjectedText[] | null, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, fontInfo: FontInfo, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ModelLineProjectionData | null { const lineText = LineInjectedText.applyInjectedText(_lineText, injectedTexts); + const fixedWidthRanges = LineInjectedText.getFixedWidthInjectedTextRanges(injectedTexts); let injectionOptions: InjectedTextOptions[] | null; let injectionOffsets: number[] | null; @@ -389,73 +390,117 @@ function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: st } const isKeepAll = (wordBreak === 'keepAll'); - const wrappedTextIndentLength = computeWrappedTextIndentLength(lineText, tabSize, firstLineBreakColumn, columnsForFullWidthChar, wrappingIndent); - const wrappedLineBreakColumn = firstLineBreakColumn - wrappedTextIndentLength; + const wrappedTextIndentLength = computeWrappedTextIndentLength(lineText, tabSize, firstLineBreakColumn, columnsForFullWidthChar, wrappingIndent, fixedWidthRanges); + + // The wrapping decision is taken in pixels, because injected text can request an arbitrary + // width via `widthInEm` which does not map to a whole number of columns. + const typicalHalfwidthCharacterWidth = fontInfo.typicalHalfwidthCharacterWidth; + const wrappedLineBreakPixelWidth = (firstLineBreakColumn - wrappedTextIndentLength) * typicalHalfwidthCharacterWidth; const breakingOffsets: number[] = []; const breakingOffsetsVisibleColumn: number[] = []; let breakingOffsetsCount: number = 0; let breakOffset = 0; let breakOffsetVisibleColumn = 0; - - let breakingColumn = firstLineBreakColumn; - let prevCharCode = lineText.charCodeAt(0); - let prevCharCodeClass = classifier.get(prevCharCode); - let visibleColumn = computeCharWidth(prevCharCode, 0, tabSize, columnsForFullWidthChar); - - let startOffset = 1; - if (strings.isHighSurrogate(prevCharCode)) { - // A surrogate pair must always be considered as a single unit, so it is never to be broken - visibleColumn += 1; - prevCharCode = lineText.charCodeAt(1); + let breakOffsetPixelWidth = 0; + + let breakingPixelWidth = firstLineBreakColumn * typicalHalfwidthCharacterWidth; + let fixedWidthRangeIndex = 0; + const firstFixedWidthRange = fixedWidthRanges.length > 0 ? fixedWidthRanges[0] : null; + const startsWithFixedWidth = firstFixedWidthRange && firstFixedWidthRange.startOffset === 0; + + let prevCharCode: number; + let prevCharCodeClass: CharacterClass; + let visibleColumn: number; + let currentLinePixelWidth: number; + let startOffset: number; + if (startsWithFixedWidth) { + prevCharCode = CharCode.Null; + prevCharCodeClass = CharacterClass.NONE; + visibleColumn = computeFixedWidthRangeColumnWidth(lineText, firstFixedWidthRange, 0, tabSize, columnsForFullWidthChar); + currentLinePixelWidth = firstFixedWidthRange.widthInEm * fontInfo.fontSize; + startOffset = firstFixedWidthRange.endOffset; + fixedWidthRangeIndex++; + } else { + prevCharCode = lineText.charCodeAt(0); + visibleColumn = computeCharWidth(prevCharCode, 0, tabSize, columnsForFullWidthChar); + currentLinePixelWidth = computeCharPixelWidth(prevCharCode, 0, tabSize, fontInfo); + startOffset = 1; + if (strings.isHighSurrogate(prevCharCode)) { + // A surrogate pair must always be considered as a single unit, so it is never to be broken + visibleColumn += 1; + currentLinePixelWidth += typicalHalfwidthCharacterWidth; + prevCharCode = lineText.charCodeAt(1); + startOffset++; + } prevCharCodeClass = classifier.get(prevCharCode); - startOffset++; } for (let i = startOffset; i < len; i++) { + const fixedWidthRange = fixedWidthRanges.length > 0 && fixedWidthRangeIndex < fixedWidthRanges.length ? fixedWidthRanges[fixedWidthRangeIndex] : null; const charStartOffset = i; - const charCode = lineText.charCodeAt(i); + let charCode = lineText.charCodeAt(i); let charCodeClass: CharacterClass; let charWidth: number; + let charPixelWidth: number; let wrapEscapedLineFeed = false; - if (strings.isHighSurrogate(charCode)) { + if (fixedWidthRange && fixedWidthRange.startOffset === i) { + charCode = CharCode.Null; + charCodeClass = CharacterClass.NONE; + charWidth = computeFixedWidthRangeColumnWidth(lineText, fixedWidthRange, visibleColumn, tabSize, columnsForFullWidthChar); + charPixelWidth = fixedWidthRange.widthInEm * fontInfo.fontSize; + i = fixedWidthRange.endOffset - 1; + fixedWidthRangeIndex++; + } else if (strings.isHighSurrogate(charCode)) { // A surrogate pair must always be considered as a single unit, so it is never to be broken i++; charCodeClass = CharacterClass.NONE; charWidth = 2; + charPixelWidth = 2 * typicalHalfwidthCharacterWidth; } else { charCodeClass = classifier.get(charCode); charWidth = computeCharWidth(charCode, visibleColumn, tabSize, columnsForFullWidthChar); + charPixelWidth = computeCharPixelWidth(charCode, visibleColumn, tabSize, fontInfo); } // literal \n shall trigger a softwrap - if (wrapOnEscapedLineFeeds && isEscapedLineBreakAtPosition(lineText, i)) { + if (wrapOnEscapedLineFeeds && isEscapedLineBreakAtPosition(lineText, charStartOffset)) { breakOffset = charStartOffset; breakOffsetVisibleColumn = visibleColumn; + breakOffsetPixelWidth = currentLinePixelWidth; wrapEscapedLineFeed = true; } else if (canBreak(prevCharCode, prevCharCodeClass, charCode, charCodeClass, isKeepAll)) { breakOffset = charStartOffset; breakOffsetVisibleColumn = visibleColumn; + breakOffsetPixelWidth = currentLinePixelWidth; } visibleColumn += charWidth; + currentLinePixelWidth += charPixelWidth; - // check if adding character at `i` will go over the breaking column - if (visibleColumn > breakingColumn || wrapEscapedLineFeed) { + // check if adding character at `i` will go over the breaking width + if (currentLinePixelWidth > breakingPixelWidth || wrapEscapedLineFeed) { // We need to break at least before character at `i`: - if (breakOffset === 0 || visibleColumn - breakOffsetVisibleColumn > wrappedLineBreakColumn) { + if (breakOffset === 0 || currentLinePixelWidth - breakOffsetPixelWidth > wrappedLineBreakPixelWidth) { // Cannot break at `breakOffset`, must break at `i` breakOffset = charStartOffset; breakOffsetVisibleColumn = visibleColumn - charWidth; + breakOffsetPixelWidth = currentLinePixelWidth - charPixelWidth; } - breakingOffsets[breakingOffsetsCount] = breakOffset; - breakingOffsetsVisibleColumn[breakingOffsetsCount] = breakOffsetVisibleColumn; - breakingOffsetsCount++; - breakingColumn = breakOffsetVisibleColumn + wrappedLineBreakColumn; - breakOffset = 0; + const currentLineStartOffset = breakingOffsetsCount > 0 ? breakingOffsets[breakingOffsetsCount - 1] : 0; + if (breakOffset > currentLineStartOffset) { + // Breaking at the start of the current output line would emit an empty line, which + // happens when an oversized leading width-only injection cannot fit before the first + // character. In that case keep the content on the current line and defer the break. + breakingOffsets[breakingOffsetsCount] = breakOffset; + breakingOffsetsVisibleColumn[breakingOffsetsCount] = breakOffsetVisibleColumn; + breakingOffsetsCount++; + breakingPixelWidth = breakOffsetPixelWidth + wrappedLineBreakPixelWidth; + breakOffset = 0; + } } prevCharCode = charCode; @@ -491,6 +536,38 @@ function tabCharacterWidth(visibleColumn: number, tabSize: number): number { return (tabSize - (visibleColumn % tabSize)); } +/** + * The width in pixels a character occupies. Used for the wrapping decision, which must reason + * in real widths because injected text can request an arbitrary width via `widthInEm`. + */ +function computeCharPixelWidth(charCode: number, visibleColumn: number, tabSize: number, fontInfo: FontInfo): number { + if (charCode === CharCode.Tab) { + return tabCharacterWidth(visibleColumn, tabSize) * fontInfo.typicalHalfwidthCharacterWidth; + } + if (strings.isFullWidthCharacter(charCode)) { + return fontInfo.typicalFullwidthCharacterWidth; + } + if (charCode < 32) { + // when using `editor.renderControlCharacters`, the substitutions are often wide + return fontInfo.typicalFullwidthCharacterWidth; + } + return fontInfo.typicalHalfwidthCharacterWidth; +} + +/** + * The number of columns the characters of a fixed width injected text range occupy, which is 0 for + * a width-only injection. `widthInEm` is deliberately ignored here: `visibleColumn` only drives tab + * expansion, which must stay in sync with the line rendering, and the rendering does not know about + * `widthInEm`. + */ +function computeFixedWidthRangeColumnWidth(lineText: string, range: FixedWidthInjectedTextRange, visibleColumn: number, tabSize: number, columnsForFullWidthChar: number): number { + let width = 0; + for (let i = range.startOffset; i < range.endOffset; i++) { + width += computeCharWidth(lineText.charCodeAt(i), visibleColumn + width, tabSize, columnsForFullWidthChar); + } + return width; +} + /** * Checks if the current position in the text should trigger a soft wrap due to escaped line feeds. * This handles the wrapOnEscapedLineFeeds feature which allows \n sequences in strings to trigger wrapping. @@ -526,7 +603,7 @@ function canBreak(prevCharCode: number, prevCharCodeClass: CharacterClass, charC ); } -function computeWrappedTextIndentLength(lineText: string, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent): number { +function computeWrappedTextIndentLength(lineText: string, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, fixedWidthRanges?: readonly FixedWidthInjectedTextRange[]): number { let wrappedTextIndentLength = 0; if (wrappingIndent !== WrappingIndent.None) { const firstNonWhitespaceIndex = strings.firstNonWhitespaceIndex(lineText); @@ -534,6 +611,9 @@ function computeWrappedTextIndentLength(lineText: string, tabSize: number, first // Track existing indent for (let i = 0; i < firstNonWhitespaceIndex; i++) { + if (fixedWidthRanges?.[0]?.startOffset === i) { + break; + } const charWidth = (lineText.charCodeAt(i) === CharCode.Tab ? tabCharacterWidth(wrappedTextIndentLength, tabSize) : 1); wrappedTextIndentLength += charWidth; } diff --git a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts index 506be419193df5..a605d3c978965b 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts @@ -7,7 +7,7 @@ import { CancelablePromise, createCancelablePromise, TimeoutTimer } from '../../ import { RGBA } from '../../../../base/common/color.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { noBreakWhitespace } from '../../../../base/common/strings.js'; import { ICodeEditor } from '../../../browser/editorBrowser.js'; @@ -49,6 +49,9 @@ export class ColorDetector extends Disposable implements IEditorContribution { private readonly _decoratorLimitReporter = this._register(new DecoratorLimitReporter()); + private static readonly colorDecoratorInnerWidthInEm = 0.8; + private static readonly colorDecoratorMarginInEm = 0.2; + constructor( private readonly _editor: ICodeEditor, @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -58,6 +61,13 @@ export class ColorDetector extends Disposable implements IEditorContribution { super(); this._colorDecoratorIds = this._editor.createDecorationsCollection(); this._ruleFactory = this._register(new DynamicCssRules(this._editor)); + const editorDomNode = this._editor.getContainerDomNode(); + editorDomNode.style.setProperty('--vscode-colorPicker-colorDecoratorWidth', `${ColorDetector.colorDecoratorInnerWidthInEm}em`); + editorDomNode.style.setProperty('--vscode-colorPicker-colorDecoratorMargin', `${ColorDetector.colorDecoratorMarginInEm}em`); + this._register(toDisposable(() => { + editorDomNode.style.removeProperty('--vscode-colorPicker-colorDecoratorWidth'); + editorDomNode.style.removeProperty('--vscode-colorPicker-colorDecoratorMargin'); + })); this._debounceInformation = languageFeatureDebounceService.for(_languageFeaturesService.colorProvider, 'Document Colors', { min: ColorDetector.RECOMPUTE_TIME }); this._register(_editor.onDidChangeModel(() => { this._isColorDecoratorsEnabled = this.isEnabled(); @@ -204,6 +214,7 @@ export class ColorDetector extends Disposable implements IEditorContribution { this._colorDecorationClassRefs.clear(); const decorations: IModelDeltaDecoration[] = []; + const widthInEm = ColorDetector.colorDecoratorInnerWidthInEm + 2 * ColorDetector.colorDecoratorMarginInEm; const limit = this._editor.getOption(EditorOption.colorDecoratorsLimit); @@ -231,7 +242,8 @@ export class ColorDetector extends Disposable implements IEditorContribution { content: noBreakWhitespace, inlineClassName: `${ref.className} colorpicker-color-decoration`, inlineClassNameAffectsLetterSpacing: true, - attachedData: ColorDecorationInjectedTextMarker + attachedData: ColorDecorationInjectedTextMarker, + widthInEm, } } }); diff --git a/src/vs/editor/contrib/colorPicker/browser/colorPicker.css b/src/vs/editor/contrib/colorPicker/browser/colorPicker.css index f484517caba972..7f09b70cb98b08 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorPicker.css +++ b/src/vs/editor/contrib/colorPicker/browser/colorPicker.css @@ -15,8 +15,8 @@ .hc-light .colorpicker-color-decoration { border: solid 0.1em #000; box-sizing: border-box; - margin: 0.1em 0.2em 0 0.2em; - width: 0.8em; + margin: 0.1em var(--vscode-colorPicker-colorDecoratorMargin) 0; + width: var(--vscode-colorPicker-colorDecoratorWidth); height: 0.8em; line-height: 0.8em; display: inline-block; @@ -206,6 +206,6 @@ cursor: pointer; } -.colorpicker-body .insert-button:hover{ +.colorpicker-body .insert-button:hover { background: var(--vscode-button-hoverBackground); } diff --git a/src/vs/editor/contrib/inlayHints/browser/inlayHintsController.ts b/src/vs/editor/contrib/inlayHints/browser/inlayHintsController.ts index 6c1c58c4f8ac14..e09a3807fbd9e2 100644 --- a/src/vs/editor/contrib/inlayHints/browser/inlayHintsController.ts +++ b/src/vs/editor/contrib/inlayHints/browser/inlayHintsController.ts @@ -532,14 +532,17 @@ export class InlayHintsController implements IEditorContribution { } // utils to collect/create injected text decorations + const { fontSize, fontFamily, padding, isUniform } = this._getLayoutInfo(); + const editorFontSize = this._editor.getOption(EditorOption.fontSize); const newDecorationsData: InlayHintDecorationRenderInfo[] = []; - const addInjectedText = (item: InlayHintItem, ref: ClassNameReference, content: string, cursorStops: InjectedTextCursorStops, attachedData?: RenderedInlayHintLabelPart | object): void => { + const addInjectedText = (item: InlayHintItem, ref: ClassNameReference, content: string, cursorStops: InjectedTextCursorStops, attachedData?: RenderedInlayHintLabelPart | object, widthInEm?: number): void => { const opts: InjectedTextOptions = { content, inlineClassNameAffectsLetterSpacing: true, inlineClassName: ref.className, cursorStops, - attachedData + attachedData, + widthInEm }; newDecorationsData.push({ item, @@ -559,16 +562,17 @@ export class InlayHintsController implements IEditorContribution { }; const addInjectedWhitespace = (item: InlayHintItem, isLast: boolean): void => { + const widthInPixels = (fontSize / 3) | 0; + const widthInEm = widthInPixels / editorFontSize; const marginRule = this._ruleFactory.createClassNameRef({ - width: `${(fontSize / 3) | 0}px`, + width: `${widthInPixels}px`, display: 'inline-block' }); - addInjectedText(item, marginRule, '\u200a', isLast ? InjectedTextCursorStops.Right : InjectedTextCursorStops.None, InlayHintsController._whitespaceData); + addInjectedText(item, marginRule, '', isLast ? InjectedTextCursorStops.Right : InjectedTextCursorStops.None, InlayHintsController._whitespaceData, widthInEm); }; // - const { fontSize, fontFamily, padding, isUniform } = this._getLayoutInfo(); const maxLength = this._editor.getOption(EditorOption.inlayHints).maximumLength; const fontFamilyVar = '--code-editorInlayHintsFontFamily'; this._editor.getContainerDomNode().style.setProperty(fontFamilyVar, fontFamily); diff --git a/src/vs/editor/contrib/inlineProgress/browser/inlineProgress.ts b/src/vs/editor/contrib/inlineProgress/browser/inlineProgress.ts index 5b238c214768d7..e111516e10f821 100644 --- a/src/vs/editor/contrib/inlineProgress/browser/inlineProgress.ts +++ b/src/vs/editor/contrib/inlineProgress/browser/inlineProgress.ts @@ -27,6 +27,7 @@ const inlineProgressDecoration = ModelDecorationOptions.register({ content: noBreakWhitespace, inlineClassName: 'inline-editor-progress-decoration', inlineClassNameAffectsLetterSpacing: true, + widthInEm: 1, } }); diff --git a/src/vs/editor/standalone/browser/standaloneLanguages.ts b/src/vs/editor/standalone/browser/standaloneLanguages.ts index c06acad60f182c..f9227c222ef000 100644 --- a/src/vs/editor/standalone/browser/standaloneLanguages.ts +++ b/src/vs/editor/standalone/browser/standaloneLanguages.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Color } from '../../../base/common/color.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; import { Position } from '../../common/core/position.js'; import { Range } from '../../common/core/range.js'; import { MetadataConsts } from '../../common/encodedTokenAttributes.js'; @@ -14,7 +15,7 @@ import { ILanguageExtensionPoint, ILanguageService } from '../../common/language import { LanguageConfiguration } from '../../common/languages/languageConfiguration.js'; import { ILanguageConfigurationService } from '../../common/languages/languageConfigurationRegistry.js'; import { ModesRegistry } from '../../common/languages/modesRegistry.js'; -import { LanguageSelector } from '../../common/languageSelector.js'; +import { LanguageSelector, score as scoreLanguageSelector } from '../../common/languageSelector.js'; import * as model from '../../common/model.js'; import { ILanguageFeaturesService } from '../../common/services/languageFeatures.js'; import * as standaloneEnums from '../../common/standalone/standaloneEnums.js'; @@ -50,6 +51,13 @@ export function getEncodedLanguageId(languageId: string): number { return languageService.languageIdCodec.encodeLanguageId(languageId); } +/** + * Compute the score of a language selector against a candidate URI and language. + */ +export function score(selector: LanguageSelector | undefined, candidateUri: URI, candidateLanguage: string): number { + return scoreLanguageSelector(selector, candidateUri, candidateLanguage, true, undefined, undefined); +} + /** * An event emitted when a language is associated for the first time with a text model. * @event @@ -760,6 +768,8 @@ export function createMonacoLanguagesAPI(): typeof monaco.languages { onLanguageEncountered: onLanguageEncountered, // eslint-disable-next-line local/code-no-any-casts getEncodedLanguageId: getEncodedLanguageId, + // eslint-disable-next-line local/code-no-any-casts + score: score, // provider methods // eslint-disable-next-line local/code-no-any-casts diff --git a/src/vs/editor/standalone/test/browser/standaloneLanguages.test.ts b/src/vs/editor/standalone/test/browser/standaloneLanguages.test.ts index 92943556aaa4b1..833036c59790d7 100644 --- a/src/vs/editor/standalone/test/browser/standaloneLanguages.test.ts +++ b/src/vs/editor/standalone/test/browser/standaloneLanguages.test.ts @@ -7,18 +7,34 @@ import assert from 'assert'; import { Color } from '../../../../base/common/color.js'; import { Emitter } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { LanguageId, MetadataConsts } from '../../../common/encodedTokenAttributes.js'; import { IState, Token } from '../../../common/languages.js'; import { TokenTheme } from '../../../common/languages/supports/tokenization.js'; import { LanguageService } from '../../../common/services/languageService.js'; -import { ILineTokens, IToken, TokenizationSupportAdapter, TokensProvider } from '../../browser/standaloneLanguages.js'; +import { ILineTokens, IToken, score, TokenizationSupportAdapter, TokensProvider } from '../../browser/standaloneLanguages.js'; import { IStandaloneTheme, IStandaloneThemeData, IStandaloneThemeService } from '../../common/standaloneTheme.js'; import { UnthemedProductIconTheme } from '../../../../platform/theme/browser/iconsStyleSheet.js'; import { ColorIdentifier } from '../../../../platform/theme/common/colorRegistry.js'; import { ColorScheme } from '../../../../platform/theme/common/theme.js'; import { IColorTheme, IFileIconTheme, IProductIconTheme, ITokenStyle } from '../../../../platform/theme/common/themeService.js'; +suite('LanguageSelector', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('score', () => { + const uri = URI.parse('file:///test/file.ts'); + + assert.deepStrictEqual([ + score('typescript', uri, 'typescript'), + score({ language: 'typescript', scheme: 'file', pattern: '**/*.ts' }, uri, 'typescript'), + score('javascript', uri, 'typescript') + ], [10, 10, 0]); + }); +}); + suite('TokenizationSupport2Adapter', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/editor/test/common/model/modelDecorations.test.ts b/src/vs/editor/test/common/model/modelDecorations.test.ts index 142bbead17a39c..cf786fd4fe17e0 100644 --- a/src/vs/editor/test/common/model/modelDecorations.test.ts +++ b/src/vs/editor/test/common/model/modelDecorations.test.ts @@ -113,6 +113,26 @@ suite('Editor Model - Model Decorations', () => { lineHasNoDecorations(thisModel, 5); }); + test('injected text width must be finite and non-negative', () => { + const widths = [NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, -1, 0, 1]; + const decorationIds = thisModel.deltaDecorations([], widths.map(widthInEm => ({ + range: new Range(1, 1, 1, 1), + options: { + description: 'test', + after: { content: 'x', widthInEm } + } + }))); + + assert.deepStrictEqual(decorationIds.map(id => thisModel.getDecorationOptions(id)?.after?.widthInEm), [ + undefined, + undefined, + undefined, + undefined, + 0, + 1 + ]); + }); + test('line decoration', () => { addDecoration(thisModel, 1, 1, 1, 14, 'myType'); lineHasDecoration(thisModel, 1, 1, 14, 'myType'); diff --git a/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts b/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts index efe951f345715a..f19d4fcd653156 100644 --- a/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts +++ b/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts @@ -63,6 +63,40 @@ suite('Editor ViewLayout - ViewLineParts', () => { ]); }); + test('empty width only decorations are kept, empty regular ones are not', () => { + const result = LineDecoration.filter([ + new InlineDecoration(new Range(4, 3, 4, 3), 'spacer', InlineDecorationType.WidthOnly), + new InlineDecoration(new Range(4, 3, 4, 3), 'regular', InlineDecorationType.Regular), + ], 4, 1, 500); + + assert.deepStrictEqual(result, [ + new LineDecoration(3, 3, 'spacer', InlineDecorationType.WidthOnly), + ]); + }); + + test('width only decorations come before other decorations at the same position', () => { + const decorations = [ + new LineDecoration(3, 3, 'regular', InlineDecorationType.Regular), + new LineDecoration(3, 3, 'after', InlineDecorationType.After), + new LineDecoration(3, 3, 'spacer', InlineDecorationType.WidthOnly), + new LineDecoration(3, 3, 'before', InlineDecorationType.Before), + ]; + decorations.sort(LineDecoration.compare); + + assert.deepStrictEqual(decorations.map(d => d.className), ['spacer', 'before', 'after', 'regular']); + }); + + test('width only decorations are not turned into pseudo elements', () => { + const result = LineDecorationsNormalizer.normalize('abc', [ + new LineDecoration(2, 2, 'spacer', InlineDecorationType.WidthOnly), + ]); + + // Metadata stays `0`: unlike a before or after decoration, this is the injected content itself. + assert.deepStrictEqual(result, [ + new DecorationSegment(1, 0, 'spacer', 0), + ]); + }); + test('ViewLineParts', () => { assert.deepStrictEqual(LineDecorationsNormalizer.normalize('abcabcabcabcabcabcabcabcabcabc', [ diff --git a/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts b/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts index efc948f669e3cc..28b01611eb066c 100644 --- a/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts +++ b/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts @@ -316,6 +316,24 @@ suite('InjectedTextInlineDecorationsComputer', () => { ]); }); + test('fixed width injection uses its regular inline decoration', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '\xa0', inlineClassName: 'fixed-width', widthInEm: 3 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [11], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 6, 1, 7), 'fixed-width', InlineDecorationType.Regular)] + ]); + }); + test('injection with inlineClassNameAffectsLetterSpacing', () => { const injectionOptions: InjectedTextOptions[] = [ { content: 'abc', inlineClassName: 'ls-class', inlineClassNameAffectsLetterSpacing: true } @@ -488,4 +506,137 @@ suite('InjectedTextInlineDecorationsComputer', () => { [new InlineDecoration(new Range(6, 1, 6, 3), 'wrap-class', InlineDecorationType.Regular)], ]); }); + + test('spacing-only injection in the middle of a line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 6, 1, 6), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection at the beginning of a line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [0], + getBreakOffsets: () => [10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 1, 1, 1), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection on an empty line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [0], + getBreakOffsets: () => [0], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 1, 1, 1), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection at a wrap boundary belongs to the following line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [5, 10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [], + [new InlineDecoration(new Range(2, 1, 2, 1), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection at the end of a line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [10], + getBreakOffsets: () => [10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + // There is no following output line to move it to, so it stays at the end of this one. + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 11, 1, 11), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection next to an injection with content', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 }, + { content: 'AB', inlineClassName: 'text-class' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [2, 5], + getBreakOffsets: () => [12], // 10 (original) + 0 + 2 + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + // The spacing-only injection must not shift the offsets of the injections that follow it. + assert.deepStrictEqual(result, [ + [ + new InlineDecoration(new Range(1, 3, 1, 3), 'spacer', InlineDecorationType.WidthOnly), + new InlineDecoration(new Range(1, 6, 1, 8), 'text-class', InlineDecorationType.Regular), + ] + ]); + }); + + test('spacing-only injection without inlineClassName produces no inline decorations', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [] + ]); + }); }); diff --git a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts b/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts index 70d687d30e4427..70019f302fea89 100644 --- a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts +++ b/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts @@ -7,6 +7,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { EditorOptions, WrappingIndent } from '../../../common/config/editorOptions.js'; import { FontInfo } from '../../../common/config/fontInfo.js'; import { ILineBreaksComputerContext, ILineBreaksComputerFactory, ModelLineProjectionData } from '../../../common/modelLineProjectionData.js'; +import { LineInjectedText } from '../../../common/textModelEvents.js'; import { MonospaceLineBreaksComputerFactory } from '../../../common/viewModel/monospaceLineBreaksComputer.js'; function parseAnnotatedText(annotatedText: string): { text: string; indices: number[] } { @@ -44,7 +45,7 @@ function toAnnotatedText(text: string, lineBreakData: ModelLineProjectionData | return actualAnnotatedText; } -function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, breakAfter: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean, text: string, previousLineBreakData: ModelLineProjectionData | null): ModelLineProjectionData | null { +function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, breakAfter: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean, text: string, previousLineBreakData: ModelLineProjectionData | null, injectedText: LineInjectedText[] | null = null): ModelLineProjectionData | null { const fontInfo = new FontInfo({ pixelRatio: 1, fontFamily: 'testFontFamily', @@ -68,7 +69,7 @@ function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, return text; }, getLineInjectedText(lineNumber) { - return null; + return injectedText; } }; const lineBreaksComputer = factory.createLineBreaksComputer(context, fontInfo, tabSize, breakAfter, wrappingIndent, wordBreak, wrapOnEscapedLineFeeds); @@ -134,6 +135,211 @@ suite('Editor ViewModel - MonospaceLineBreaksComputer', () => { assertLineBreaks(factory, 4, 5, 'aa.|(.).|aaa'); }); + test('accounts for fixed injected text width when wrapping', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 5, { content: '\xa0', widthInEm: 1 }, 0) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [4, 7], + breakOffsetsVisibleColumn: [4, 7] + }); + }); + + test('treats multi-character fixed-width injected text as an atomic span', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 4, { content: 'hello', widthInEm: 1 }, 0) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [8, 11], + breakOffsetsVisibleColumn: [8, 11] + }); + }); + + test('wraps after an escaped line feed followed by fixed-width injected text', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 100, 2, WrappingIndent.None, 'normal', true, '"a\\nb"', null, [ + new LineInjectedText(0, 1, 5, { content: 'hint', widthInEm: 1 }, 0) + ]); + + assert.deepStrictEqual(lineBreakData?.breakOffsets, [4, 10]); + }); + + test('treats adjacent fixed-width injected texts as separate atomic spans', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 4, { content: 'x', widthInEm: 0.5 }, 0), + new LineInjectedText(0, 1, 4, { content: 'y', widthInEm: 1 }, 1) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [4, 8], + breakOffsetsVisibleColumn: [4, 8] + }); + }); + + test('keeps fixed-width injected text wider than the wrap column atomic', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 4, { content: 'x', widthInEm: 3 }, 0) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [3, 4, 7], + breakOffsetsVisibleColumn: [3, 4, 7] + }); + }); + + test('wraps before a tab that no longer fits after fixed-width injected text', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 4, 2, WrappingIndent.None, 'normal', false, 'ab\tcd', null, [ + new LineInjectedText(0, 1, 3, { content: '\xa0', widthInEm: 0.75 }, 0) + ]); + + // `ab` plus a 0.75em wide injection fills 2.75 columns worth of pixels, so the tab (which + // expands from character column 3 to character column 4) no longer fits and the line wraps + // right before it. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [3, 6], + breakOffsetsVisibleColumn: [3, 6] + }); + }); + + test('expands a tab following fixed-width injected text from the character column', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 10, 2, WrappingIndent.None, 'normal', false, 'ab\tcdefgh', null, [ + new LineInjectedText(0, 1, 3, { content: '\xa0', widthInEm: 1.5 }, 0) + ]); + + // The injection counts as a single character column, so the tab at character column 3 expands + // to character column 4 and the whole line spans 10 columns. Its 1.5em is spent on the wrapping + // decision alone, which runs out of room at `g`. Were that width to leak into the column + // accumulator, the injection would count as 3 columns, the tab would expand to character + // column 8, and the line would already run out of room at `e`. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [8, 10], + breakOffsetsVisibleColumn: [8, 10] + }); + }); + + test('reserves width for a spacing-only injection at the beginning of a line', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 6, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 1, { content: '', widthInEm: 1 }, 0) + ]); + + // 1em is two columns wide here, so the spacer pushes the last two characters onto a second + // line. It consumes no character, so the offsets still refer to the original text. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [4, 6], + breakOffsetsVisibleColumn: [4, 6] + }); + }); + + test('does not create empty lines for oversized spacing-only injected text', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 2, 2, WrappingIndent.None, 'normal', false, 'abc', null, [ + new LineInjectedText(0, 1, 1, { content: '', widthInEm: 2 }, 0) + ]); + + // The spacer alone already overflows the wrap width, but there is no character before it to + // wrap, so it must share the first line with `a` rather than emit an empty leading line. + assert.deepStrictEqual(lineBreakData?.breakOffsets, [1, 3]); + }); + + test('lets a spacing-only injection in the middle of a line inherit surrounding break opportunities', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ' '); + const lineBreakData = getLineBreakData(factory, 4, 8, 2, WrappingIndent.None, 'normal', false, 'ab cdef', null, [ + new LineInjectedText(0, 1, 4, { content: '', widthInEm: 1 }, 0) + ]); + + // The spacer is transparent for break opportunities: it neither creates nor suppresses one, so + // the break after the space is still recorded even though the spacer sits on top of it, and the + // line breaks there rather than at `f` where it runs out of room. Its 1em is what makes the line + // run out of room at all: without it the seven characters fit in the eight available columns. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [3, 7], + breakOffsetsVisibleColumn: [3, 7] + }); + }); + + test('ignores a spacing-only injection at the end of a line', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 6, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 7, { content: '', widthInEm: 1 }, 0) + ]); + + // The line is exactly full, and there is no character after the spacer that could be moved to a + // following line, so the spacer cannot introduce a break. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [6], + breakOffsetsVisibleColumn: [6] + }); + }); + + test('expands a tab following a spacing-only injection from the character column', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 6, 2, WrappingIndent.None, 'normal', false, 'ab\tcd', null, [ + new LineInjectedText(0, 1, 3, { content: '', widthInEm: 1 }, 0) + ]); + + // The spacer occupies no character column, so the tab still expands from character column 2 to + // character column 4. Were its 1em width to leak into the column accumulator, the tab would + // start at character column 4 and expand to character column 8 instead. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [3, 5], + breakOffsetsVisibleColumn: [4, 6] + }); + }); + + test('does not use fixed-width injected whitespace as continuation indentation', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.Same, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 1, { content: ' ', widthInEm: 0.75 }, 0) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + wrappedTextIndentLength: lineBreakData?.wrappedTextIndentLength + }, { + breakOffsets: [4, 7], + wrappedTextIndentLength: 0 + }); + }); + function assertLineBreakDataEqual(a: ModelLineProjectionData | null, b: ModelLineProjectionData | null): void { if (!a || !b) { assert.deepStrictEqual(a, b); @@ -142,7 +348,7 @@ suite('Editor ViewModel - MonospaceLineBreaksComputer', () => { assert.deepStrictEqual(a.breakOffsets, b.breakOffsets); assert.deepStrictEqual(a.wrappedTextIndentLength, b.wrappedTextIndentLength); for (let i = 0; i < a.breakOffsetsVisibleColumn.length; i++) { - const diff = a.breakOffsetsVisibleColumn[i] - b.breakOffsetsVisibleColumn[i]; + const diff = Math.abs(a.breakOffsetsVisibleColumn[i] - b.breakOffsetsVisibleColumn[i]); assert.ok(diff < 0.001); } } diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index a2691d0626ce26..09915a982eb45f 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -6612,6 +6612,11 @@ declare namespace monaco.languages { export function getEncodedLanguageId(languageId: string): number; + /** + * Compute the score of a language selector against a candidate Uri and language. + */ + export function score(selector: LanguageSelector | undefined, candidateUri: Uri, candidateLanguage: string): number; + /** * An event emitted when a language is associated for the first time with a text model. * @event diff --git a/src/vs/platform/actions/browser/buttonbar.css b/src/vs/platform/actions/browser/buttonbar.css new file mode 100644 index 00000000000000..8dccfffd985047 --- /dev/null +++ b/src/vs/platform/actions/browser/buttonbar.css @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Spinner shown on a button whose action is in flight. The button lays its + * content out with flex, so the spinner only needs to be separated from the + * label it precedes. It inherits the button foreground via `currentColor`. */ +.monaco-text-button > .monaco-pixel-spinner { + flex: 0 0 auto; + margin-right: 4px; +} diff --git a/src/vs/platform/actions/browser/buttonbar.ts b/src/vs/platform/actions/browser/buttonbar.ts index 95477819904e6b..06e2a8d81d3954 100644 --- a/src/vs/platform/actions/browser/buttonbar.ts +++ b/src/vs/platform/actions/browser/buttonbar.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ButtonBar, IButton } from '../../../base/browser/ui/button/button.js'; +import { ButtonBar, ButtonWithDropdown, IButton } from '../../../base/browser/ui/button/button.js'; +import { createPixelSpinner } from '../../../base/browser/ui/pixelSpinner/pixelSpinner.js'; +import './buttonbar.css'; import { createInstantHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { ActionRunner, IAction, IActionRunner, IRunEvent, SubmenuAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../base/common/actions.js'; import { Codicon } from '../../../base/common/codicons.js'; @@ -31,6 +33,11 @@ export type IButtonConfigProvider = (action: IAction, index: number) => { customLabel?: string | IMarkdownString; customLabelObs?: IObservable; customClass?: string; + /** + * Renders an animated spinner ahead of the label, for a button whose work + * is currently in flight rather than waiting to be started. + */ + showSpinner?: boolean; } | undefined; export interface IWorkbenchButtonBarOptions { @@ -163,6 +170,17 @@ export class WorkbenchButtonBar extends ButtonBar { return labelValue; }; + // Setting the label resets the button's children, so the spinner is + // (re-)attached after every label write rather than once up front. + const spinner = config?.showSpinner + ? this._updateStore.add(createPixelSpinner()) + : undefined; + const applySpinner = () => { + if (spinner) { + (btn instanceof ButtonWithDropdown ? btn.primaryButton.element : btn.element).prepend(spinner.element); + } + }; + const applyLabel = (labelValue: string | IMarkdownString) => { if (showLabel) { btn.label = composeLabel(labelValue); @@ -173,6 +191,7 @@ export class WorkbenchButtonBar extends ButtonBar { btn.setTitle(ariaLabelWithKeybinding); btn.setAriaLabel(ariaLabelWithKeybinding); + applySpinner(); }; if (showLabel) { @@ -180,6 +199,7 @@ export class WorkbenchButtonBar extends ButtonBar { } else { btn.element.classList.add('monaco-text-button'); } + applySpinner(); if (showIcon) { if (action instanceof MenuItemAction && ThemeIcon.isThemeIcon(action.item.icon)) { diff --git a/src/vs/platform/actions/test/browser/buttonbar.test.ts b/src/vs/platform/actions/test/browser/buttonbar.test.ts new file mode 100644 index 00000000000000..3d622222cbd8bc --- /dev/null +++ b/src/vs/platform/actions/test/browser/buttonbar.test.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { $ } from '../../../../base/browser/dom.js'; +import { Separator, SubmenuAction, toAction, type IAction } from '../../../../base/common/actions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IContextMenuService } from '../../../contextview/browser/contextView.js'; +import { IHoverService } from '../../../hover/browser/hover.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { IKeybindingService } from '../../../keybinding/common/keybinding.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { WorkbenchButtonBar, type IButtonConfigProvider } from '../../browser/buttonbar.js'; + +const SPINNER_SELECTOR = '.monaco-pixel-spinner'; + +suite('WorkbenchButtonBar', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createButtonBar(buttonConfigProvider: IButtonConfigProvider): { readonly bar: WorkbenchButtonBar; readonly container: HTMLElement } { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IContextMenuService, new class extends mock() { + override showContextMenu(): void { } + }()); + instantiationService.stub(IKeybindingService, new class extends mock() { + override appendKeybinding(label: string): string { return label; } + }()); + instantiationService.stub(IHoverService, new class extends mock() { + override setupManagedHover() { return Disposable.None as never; } + }()); + + const container = $('div'); + const bar = disposables.add(instantiationService.createInstance(WorkbenchButtonBar, container, { buttonConfigProvider })); + return { bar, container }; + } + + function action(id: string): IAction { + return toAction({ id, label: id, run: () => { } }); + } + + test('renders the spinner only for buttons that ask for it', () => { + const { bar, container } = createButtonBar((_action, index) => ({ showLabel: true, showSpinner: index === 0 })); + + bar.update([action('busy'), action('idle')], []); + const button = bar.buttons[0].element; + + assert.deepStrictEqual({ + total: container.querySelectorAll(SPINNER_SELECTOR).length, + onIdle: bar.buttons[1].element.querySelectorAll(SPINNER_SELECTOR).length, + // The spinner leads the label rather than replacing it. + leads: button.firstElementChild?.classList.contains('monaco-pixel-spinner'), + label: button.textContent, + }, { + total: 1, + onIdle: 0, + leads: true, + label: 'busy', + }); + }); + + test('renders the spinner on the primary half of a dropdown button', () => { + const { bar } = createButtonBar(() => ({ showLabel: true, showSpinner: true })); + + bar.update([new SubmenuAction('id', 'Merge', [action('Merge'), new Separator(), action('Other')])], []); + const button = bar.buttons[0].element; + + assert.deepStrictEqual({ + spinners: button.querySelectorAll(SPINNER_SELECTOR).length, + // Not on the dropdown chevron, which is a sibling button. + onDropdown: button.querySelector('.monaco-dropdown-button')?.querySelectorAll(SPINNER_SELECTOR).length, + }, { + spinners: 1, + onDropdown: 0, + }); + }); +}); diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 44c4dc8da3c6c7..169c1511b12a0d 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -650,7 +650,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return; } - const subscriptions = this._subscriptionManager.currentSubscriptionChannels(); + const subscriptions = this._subscriptionManager.currentSubscriptionUris().map(u => u.toString()); // Always include the always-live root state alongside getSubscription-managed entries. if (!subscriptions.includes(ROOT_STATE_URI)) { subscriptions.unshift(ROOT_STATE_URI); @@ -746,12 +746,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private async _restoreSubscriptionsAfterFreshInitialize(initialSnapshots: readonly IStateSnapshot[]): Promise { const restored = new Set(initialSnapshots.map(snapshot => snapshot.resource)); const active = this._subscriptionManager.getActiveSubscriptions() - .filter(subscription => !restored.has(subscription.channel)); + .filter(subscription => !restored.has(subscription.resource.toString())); const restoreGroup = async (subscriptions: typeof active) => { await Promise.all(subscriptions.map(async subscription => { try { const result = await this._dispatchRequest('subscribe', { - channel: subscription.channel, + channel: subscription.resource.toString(), }, { bypassReconnectGate: true }); if (result.snapshot) { this._subscriptionManager.applyReconnectSnapshot( @@ -766,8 +766,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (error instanceof ProtocolError && error.code === AHP_CLIENT_CONNECTION_CLOSED) { throw error; } - this._logService.warn(`[AgentHostProtocolClient] Failed to restore subscription ${subscription.channel} after host restart: ${error instanceof Error ? error.message : String(error)}`); - this._subscriptionManager.markSubscriptionsMissing([subscription.channel]); + this._logService.warn(`[AgentHostProtocolClient] Failed to restore subscription ${subscription.resource.toString()} after host restart: ${error instanceof Error ? error.message : String(error)}`); + this._subscriptionManager.markSubscriptionsMissing([subscription.resource]); } })); }; @@ -868,7 +868,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._serverSeq = maxSeq; if (result.missing.length > 0) { this._logService.info(`[RemoteAgentHostProtocol] Server cannot resume ${result.missing.length} subscription(s) after reconnect.`); - this._subscriptionManager.markSubscriptionsMissing(result.missing); + this._subscriptionManager.markSubscriptionsMissing(result.missing.map(u => URI.parse(u))); } } else { let maxSeq = this._serverSeq; @@ -945,10 +945,6 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return this._subscriptionManager.getSubscription(kind, resource, owner); } - getSubscriptionByChannel(kind: StateComponents, channel: string, owner: string): IReference> { - return this._subscriptionManager.getSubscriptionByChannel(kind, channel, owner); - } - getSubscriptionUnmanaged(_kind: StateComponents, resource: URI): IAgentSubscription | undefined { return this._subscriptionManager.getSubscriptionUnmanaged(resource); } @@ -979,16 +975,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * response. */ async subscribe(resource: URI): Promise { - return this._subscribeChannel(resource.toString()); - } - - private async _subscribeChannel(channel: string): Promise { - this._logService.trace(`[RemoteAgentHostProtocol] subscribe start: ${channel}`); - const result = await this._sendRequest('subscribe', { channel }); + this._logService.trace(`[RemoteAgentHostProtocol] subscribe start: ${resource.toString()}`); + const result = await this._sendRequest('subscribe', { channel: resource.toString() }); if (!result.snapshot) { - throw new Error(`subscribe to ${channel} returned no snapshot`); + throw new Error(`subscribe to ${resource.toString()} returned no snapshot`); } - this._logService.trace(`[RemoteAgentHostProtocol] subscribe done: ${channel}`); + this._logService.trace(`[RemoteAgentHostProtocol] subscribe done: ${resource.toString()}`); return result.snapshot; } @@ -1010,11 +1002,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * Unsubscribe from state at a URI. */ unsubscribe(resource: URI): void { - this._unsubscribeChannel(resource.toString()); - } - - private _unsubscribeChannel(channel: string): void { - this._sendNotification('unsubscribe', { channel }); + this._sendNotification('unsubscribe', { channel: resource.toString() }); } /** diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index 30a80fab4c7697..49869e700610bd 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -46,7 +46,6 @@ export class NullAgentHostService implements IAgentHostService { get rootState(): IAgentSubscription { return notSupported(); } getSubscription(_kind: T, _resource: URI, _owner: string): IReference> { return notSupported(); } - getSubscriptionByChannel(_kind: T, _channel: string, _owner: string): IReference> { return notSupported(); } getSubscriptionUnmanaged(_kind: T, _resource: URI): IAgentSubscription | undefined { return undefined; } getInflightSessionCreate(_resource: URI): Promise | undefined { return undefined; } getActiveSubscriptions(): readonly IActiveSubscriptionInfo[] { return []; } diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 74efe8db9a3a8e..54056ea00f9af6 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -14,6 +14,20 @@ export const IAgentHostChangesetOperationService = createDecorator = new Set(Object.values(AgentHostPullRequestOperationId)); + /** * Server-side handler for a changeset operation advertised via * `changeset/operationsChanged`. diff --git a/src/vs/platform/agentHost/common/agentHostChangesetService.ts b/src/vs/platform/agentHost/common/agentHostChangesetService.ts index 7349a8c45706e7..ac350d71c5de6c 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetService.ts @@ -201,9 +201,9 @@ export interface IAgentHostChangesetService { /** * Lazy refresh of the branch changeset, kicked off when a client - * first subscribes to `/changeset/branch`. Self-defers when the - * session's working directory is not yet known; the deferred refresh is - * drained by {@link onWorkingDirectoryAvailable}. + * first subscribes to `/changeset/branch`. Skips computation while + * the working directory is unavailable; {@link onWorkingDirectoryAvailable} + * recomputes the current subscriptions after materialization or restore. */ refreshBranchChangeset(session: ProtocolURI): void; @@ -212,19 +212,14 @@ export interface IAgentHostChangesetService { * client first subscribes to `/changeset/session` or the * session URI itself (e.g. Agents Window observing the session). The * recompute keeps the catalogue chip fresh across session opens even - * when no turn has run since process start. Self-defers when the - * session's working directory is not yet known. + * when no turn has run since process start. Skips computation while the + * working directory is unavailable. */ refreshSessionChangeset(session: ProtocolURI): void; /** - * Drains static changeset refreshes (`branch` / `session` / - * `uncommitted`) that were deferred because the session's working - * directory was not yet known. Called when a session is materialized or - * restored. Recomputes every changeset currently subscribed for the - * session via {@link recomputeSubscribedChangesets}; subscriptions that - * dropped while the working directory was unknown are naturally skipped. - * Idempotent. + * Recomputes every changeset currently subscribed when a session is + * materialized or restored. */ onWorkingDirectoryAvailable(session: ProtocolURI): void; @@ -232,18 +227,12 @@ export interface IAgentHostChangesetService { * Recomputes every changeset currently subscribed for `session`, read * from the shared changeset subscription service. Each subscribed changeset * is dispatched to its kind-specific recompute (branch / session / uncommitted - * / turn); the individual recomputes self-defer when the working directory is - * not yet known. Used as the session-level refresh entry point (drain on - * materialization, git-state change). + * / turn); the individual recomputes skip when the working directory is + * not yet known. Used as the session-level refresh entry point after + * materialization and git-state changes. */ recomputeSubscribedChangesets(session: ProtocolURI): void; - /** - * Forgets any deferred static changeset refreshes queued for a session - * that is being disposed. - */ - onSessionDisposed(session: ProtocolURI): void; - /** * Computes and publishes the per-turn changeset for `turnId` on `session`. * Per-turn changesets are not persisted. diff --git a/src/vs/platform/agentHost/common/agentHostChangesetSubscriptionService.ts b/src/vs/platform/agentHost/common/agentHostChangesetSubscriptionService.ts index 023441a065faa8..cca63854c5a7f3 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetSubscriptionService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetSubscriptionService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { URI as ProtocolURI } from './state/sessionState.js'; +import type { Event } from '../../../base/common/event.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IAgentHostChangesetSubscriptionService = createDecorator('agentHostChangesetSubscriptionService'); @@ -15,6 +16,14 @@ export const IAgentHostChangesetSubscriptionService = createDecorator; + /** * Returns the set of changeset URIs currently subscribed for `session`. * Empty when the session has no active changeset subscribers. diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 4b865ed6fc6541..14694fc1755b9c 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -254,6 +254,8 @@ export interface IAgentHostGitService { * recreating the worktree. */ branchExists(repositoryRoot: URI, branchName: string): Promise; + /** Creates a new branch and optionally checks it out while preserving the working tree. */ + createBranch(workingDirectory: URI, branchName: string, options?: { readonly checkout?: boolean }): Promise; /** * Returns true when the working tree has any tracked, staged, or * untracked changes. Used by archive cleanup to skip removing a diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 80b9d4ede3e90f..467867a1dc372a 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -47,7 +47,7 @@ import { AgentHostMarkdownPlanRichLinksEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, } from './agentHostSchema.js'; -import { AgentMergeConfigKey, AgentMergeSettingId } from './agentMerge.js'; +import { AgentMergeConfigKey, AgentMergeSettingId, AGENT_MERGE_SETTING_TAG } from './agentMerge.js'; // Settings consumed by the agent host starter (`electronAgentHostStarter.ts` // and `nodeAgentHostStarter.ts`) to populate the spawned agent host process's @@ -116,7 +116,7 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentMerge.enabled', "Enables the experimental Agent Merge controller and its commands. Agent Merge can monitor an agent session's pull request, ask the agent to address selected blockers, and optionally merge the pull request when it is ready."), default: product.quality !== 'stable', scope: ConfigurationScope.APPLICATION, - tags: ['experimental'], + tags: ['experimental', AGENT_MERGE_SETTING_TAG], agentHost: { key: AgentMergeConfigKey.Enabled }, }, [AgentMergeSettingId.AddressReviews]: { @@ -124,7 +124,7 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentMerge.addressReviews', "Controls whether enabled Agent Merge sessions address unresolved review threads, changes-requested reviews, and new pull request comments from repository maintainers or the Copilot pull request reviewer."), default: true, scope: ConfigurationScope.APPLICATION, - tags: ['experimental'], + tags: ['experimental', AGENT_MERGE_SETTING_TAG], agentHost: { key: AgentMergeConfigKey.AddressReviews }, }, [AgentMergeSettingId.FixCI]: { @@ -132,7 +132,7 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentMerge.fixCI', "Controls whether enabled Agent Merge sessions ask the agent to fix failed required CI checks."), default: true, scope: ConfigurationScope.APPLICATION, - tags: ['experimental'], + tags: ['experimental', AGENT_MERGE_SETTING_TAG], agentHost: { key: AgentMergeConfigKey.FixCI }, }, [AgentMergeSettingId.ResolveConflicts]: { @@ -140,15 +140,21 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentMerge.resolveConflicts', "Controls whether enabled Agent Merge sessions ask the agent to update branches that are behind or resolve merge conflicts."), default: true, scope: ConfigurationScope.APPLICATION, - tags: ['experimental'], + tags: ['experimental', AGENT_MERGE_SETTING_TAG], agentHost: { key: AgentMergeConfigKey.ResolveConflicts }, }, [AgentMergeSettingId.MergePullRequest]: { - type: 'boolean', + type: 'string', + enum: ['always', 'ifUnchanged', 'never'], + enumDescriptions: [ + nls.localize('chat.agentMerge.mergePullRequest.always', "Merges the pull request whenever it is ready, including after Agent Merge has fixed CI failures or addressed review feedback."), + nls.localize('chat.agentMerge.mergePullRequest.ifUnchanged', "Merges the pull request only while Agent Merge has not changed it. Once a repair turn lands a commit, automatic merging turns itself off for that session so the changes can be reviewed."), + nls.localize('chat.agentMerge.mergePullRequest.never', "Never merges the pull request automatically."), + ], description: nls.localize('chat.agentMerge.mergePullRequest', "Controls whether the Agent Host automatically merges or enqueues pull requests for enabled Agent Merge sessions after all selected maintenance work is complete."), - default: false, + default: 'never', scope: ConfigurationScope.APPLICATION, - tags: ['experimental'], + tags: ['experimental', AGENT_MERGE_SETTING_TAG], agentHost: { key: AgentMergeConfigKey.MergePullRequest }, }, [AgentMergeSettingId.MergeMethod]: { @@ -163,7 +169,7 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentMerge.mergeMethod', "Controls the native merge method used by Agent Merge."), default: 'auto', scope: ConfigurationScope.APPLICATION, - tags: ['experimental'], + tags: ['experimental', AGENT_MERGE_SETTING_TAG], agentHost: { key: AgentMergeConfigKey.MergeMethod }, }, [AgentMergeSettingId.ReplyAttribution]: { @@ -171,7 +177,7 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentMerge.replyAttribution', "Controls whether review-thread replies posted by Agent Merge include an automated-reply attribution note."), default: true, scope: ConfigurationScope.APPLICATION, - tags: ['experimental'], + tags: ['experimental', AGENT_MERGE_SETTING_TAG], agentHost: { key: AgentMergeConfigKey.ReplyAttribution }, }, [AgentHostActiveAgentTitleGenerationSettingId]: { diff --git a/src/vs/platform/agentHost/common/agentMerge.ts b/src/vs/platform/agentHost/common/agentMerge.ts index d5ef31e49afad3..a801edc1ce96eb 100644 --- a/src/vs/platform/agentHost/common/agentMerge.ts +++ b/src/vs/platform/agentHost/common/agentMerge.ts @@ -30,6 +30,12 @@ export const AgentMergeSettingId = { ReplyAttribution: 'chat.agentMerge.replyAttribution', } as const; +/** + * Settings-editor tag carried by every Agent Merge setting, so the UI can open + * settings filtered to exactly this group with `@tag:agentMerge`. + */ +export const AGENT_MERGE_SETTING_TAG = 'agentMerge'; + /** * Work the agent itself can be asked to perform. Merging is deliberately absent: * it is executed by the host, never delegated to a model. @@ -40,11 +46,23 @@ export type AgentMergeRepairAction = 'addressReviews' | 'fixCI' | 'resolveConfli export type AgentMergeAction = AgentMergeRepairAction | 'mergePullRequest'; export type AgentMergeMethod = 'auto' | 'squash' | 'merge' | 'rebase'; +/** + * When Agent Merge may merge the pull request once it is ready. + * + * `ifUnchanged` is self-demoting rather than evaluated at merge time: the + * moment a repair turn lands a commit the session's value is rewritten to + * `never`, so the chosen value always reflects what will actually happen. + * Re-selecting `ifUnchanged` starts a fresh baseline from that point. + */ +export type AgentMergeMergePullRequest = 'always' | 'ifUnchanged' | 'never'; + +export const agentMergeMergePullRequestValues: readonly AgentMergeMergePullRequest[] = ['always', 'ifUnchanged', 'never']; + export interface AgentMergeActions { readonly addressReviews: boolean; readonly fixCI: boolean; readonly resolveConflicts: boolean; - readonly mergePullRequest: boolean; + readonly mergePullRequest: AgentMergeMergePullRequest; } export interface AgentMergeConfiguration extends AgentMergeActions { @@ -56,7 +74,7 @@ export interface AgentMergeSessionOverrides { readonly addressReviews?: boolean; readonly fixCI?: boolean; readonly resolveConflicts?: boolean; - readonly mergePullRequest?: boolean; + readonly mergePullRequest?: AgentMergeMergePullRequest; } export interface AgentMergeTarget { @@ -93,6 +111,17 @@ export interface AgentMergeSessionState { readonly lastPromptAt?: string; readonly repeatedPromptCount?: number; readonly totalPromptCount?: number; + /** + * Local worktree commit observed when the most recent repair turn started. + * A different local commit afterwards means that turn produced work, which + * is what demotes an `ifUnchanged` session to `never`. + * + * Deliberately the local commit rather than the pull request's published + * head: GitHub reports a push only after some replication delay, and a turn + * ends by evaluating immediately, so a published comparison would routinely + * read a stale head and merge changes it should have held back. + */ + readonly repairBaseCommit?: string; } export interface AgentMergeControllerState { @@ -102,6 +131,7 @@ export interface AgentMergeControllerState { readonly lastPromptAt?: string; readonly repeatedPromptCount?: number; readonly totalPromptCount?: number; + readonly repairBaseCommit?: string; } export type AgentMergeRequiredChecks = @@ -129,10 +159,11 @@ export const agentMergeRootConfigSchema = createSchema({ title: localize('agentMerge.config.resolveConflicts', "Resolve Conflicts"), default: true, }), - [AgentMergeConfigKey.MergePullRequest]: schemaProperty({ - type: 'boolean', + [AgentMergeConfigKey.MergePullRequest]: schemaProperty({ + type: 'string', title: localize('agentMerge.config.mergePullRequest', "Merge Pull Request"), - default: false, + enum: ['always', 'ifUnchanged', 'never'], + default: 'never', }), [AgentMergeConfigKey.MergeMethod]: schemaProperty({ type: 'string', @@ -151,7 +182,7 @@ export const defaultAgentMergeConfiguration: AgentMergeConfiguration = { addressReviews: true, fixCI: true, resolveConflicts: true, - mergePullRequest: false, + mergePullRequest: 'never', mergeMethod: 'auto', replyAttribution: true, }; @@ -200,6 +231,19 @@ export function resolveAgentMergeConfiguration(defaults: AgentMergeConfiguration }; } +/** + * Maps the configured merge method onto a method the repository actually + * allows. `auto` prefers squash, then a merge commit, then rebase; an + * explicitly configured method is only used when the repository permits it. + */ +export function resolveMergeMethod(configured: AgentMergeMethod, allowed: readonly ('MERGE' | 'SQUASH' | 'REBASE')[]): 'MERGE' | 'SQUASH' | 'REBASE' | undefined { + if (configured !== 'auto') { + const method = configured.toUpperCase() as 'MERGE' | 'SQUASH' | 'REBASE'; + return allowed.includes(method) ? method : undefined; + } + return (['SQUASH', 'MERGE', 'REBASE'] as const).find(method => allowed.includes(method)); +} + /** * Why Agent Merge stopped monitoring a session. Keeping both strings together * lets the controller log a stable English detail while the transcript shows a @@ -271,6 +315,14 @@ export function agentMergeDisabledNotice(): string { return localize('agentMerge.notice.disabled', "Agent Merge was turned off for this session."); } +/** + * The transcript notice shown when Agent Merge stops merging automatically + * because its own repair work changed the pull request. + */ +export function agentMergeMergePullRequestDemotedNotice(): string { + return localize('agentMerge.notice.mergeDemoted', "Agent Merge changed this pull request, so automatic merging was turned off for this session. Review the changes, then turn it back on if you want it merged automatically."); +} + export function readAgentMergeSessionState(values: Record | undefined): AgentMergeSessionState | undefined { const value = values?.[SessionConfigKey.AgentMerge]; if (!isRecord(value) || typeof value.enabled !== 'boolean') { @@ -289,6 +341,7 @@ export function readAgentMergeSessionState(values: Record | und ...(typeof controller.lastPromptAt === 'string' ? { lastPromptAt: controller.lastPromptAt } : {}), ...(typeof controller.repeatedPromptCount === 'number' && Number.isInteger(controller.repeatedPromptCount) && controller.repeatedPromptCount >= 0 ? { repeatedPromptCount: controller.repeatedPromptCount } : {}), ...(typeof controller.totalPromptCount === 'number' && Number.isInteger(controller.totalPromptCount) && controller.totalPromptCount >= 0 ? { totalPromptCount: controller.totalPromptCount } : {}), + ...(typeof controller.repairBaseCommit === 'string' ? { repairBaseCommit: controller.repairBaseCommit } : {}), }; } @@ -337,13 +390,61 @@ function readOverrides(value: unknown): AgentMergeSessionOverrides | undefined { if (!isRecord(value)) { return undefined; } - const result: Record = {}; - for (const action of ['addressReviews', 'fixCI', 'resolveConflicts', 'mergePullRequest'] as const) { + const result: Record = {}; + for (const action of ['addressReviews', 'fixCI', 'resolveConflicts'] as const) { if (typeof value[action] === 'boolean') { result[action] = value[action]; } } - return Object.keys(result).length > 0 ? result : undefined; + // Migrates sessions written before this was an enum, so an explicit opt-in + // is not silently downgraded to the more conservative default. + const mergePullRequest = value.mergePullRequest; + if (typeof mergePullRequest === 'boolean') { + result.mergePullRequest = mergePullRequest ? 'always' : 'never'; + } else if (isAgentMergeMergePullRequest(mergePullRequest)) { + result.mergePullRequest = mergePullRequest; + } + return Object.keys(result).length > 0 ? result as AgentMergeSessionOverrides : undefined; +} + +/** + * Recorded as the repair baseline when the session worktree cannot be read. + * No git object id can equal it, so a session that reached this state always + * counts as changed — see {@link shouldStopMergingAfterAgentChanges}. + */ +export const AGENT_MERGE_UNKNOWN_COMMIT = 'unknown'; + +/** + * Whether a session that only authorized merging while the pull request is + * unchanged must stop merging automatically, because a repair turn produced + * work since it started. + * + * Compares local worktree commits, not the pull request's published head: the + * commit exists locally the moment the agent makes it, whereas GitHub reports + * it only after a replication delay that a turn-completion check would race. + * + * Fails closed. Once a repair turn has run, a commit that cannot be resolved + * counts as a change: a missed automatic merge is cheap, whereas merging + * agent-written changes the user wanted to review first is not. + * + * Callers rewrite the chosen value to `never` rather than gating on this, so + * the value always reflects what will actually happen. Clearing + * {@link AgentMergeSessionState.repairBaseCommit} at the same time is what lets + * a later re-selection start from a fresh baseline. + */ +export function shouldStopMergingAfterAgentChanges( + configuration: AgentMergeConfiguration, + agentMerge: AgentMergeSessionState, + currentCommit: string | undefined, +): boolean { + if (configuration.mergePullRequest !== 'ifUnchanged' || agentMerge.repairBaseCommit === undefined) { + return false; + } + return agentMerge.repairBaseCommit !== currentCommit; +} + +export function isAgentMergeMergePullRequest(value: unknown): value is AgentMergeMergePullRequest { + return typeof value === 'string' && (agentMergeMergePullRequestValues as readonly string[]).includes(value); } function readTarget(value: unknown): AgentMergeTarget | undefined { @@ -479,7 +580,7 @@ export function evaluateAgentMerge(snapshot: PullRequestSnapshot, configuration: && mergeability.mergeable === 'MERGEABLE' && mergeability.viewerCanMerge && mergeableStates.has(mergeability.mergeStateStatus?.toUpperCase() ?? 'CLEAN'); - if (configuration.mergePullRequest && mergeReady) { + if (configuration.mergePullRequest !== 'never' && mergeReady) { return { kind: 'merge', fingerprint }; } return { kind: 'noWork', waitingOnChecks: checks.pending, fingerprint }; diff --git a/src/vs/platform/agentHost/common/agentModelPricing.ts b/src/vs/platform/agentHost/common/agentModelPricing.ts index d4f122e580a294..9fd6bee9664b63 100644 --- a/src/vs/platform/agentHost/common/agentModelPricing.ts +++ b/src/vs/platform/agentHost/common/agentModelPricing.ts @@ -46,6 +46,7 @@ export interface IAgentModelPricingMeta { /** ISO 8601 end date; absent for open-ended promotions. */ readonly endsAt?: string; readonly message: string; + readonly showBanner?: boolean; }; } @@ -94,6 +95,7 @@ export function readAgentModelPricingMeta(model: IAgentModelInfo | SessionModelI discountPercent: p.discountPercent, message: p.message, ...(typeof p.endsAt === 'string' ? { endsAt: p.endsAt } : {}), + ...(typeof p.showBanner === 'boolean' ? { showBanner: p.showBanner } : {}), }; } } @@ -181,8 +183,11 @@ function normalizePromo(billing: Record): ICAPIModelBilling['pr : typeof raw.ends_at === 'string' ? raw.ends_at : undefined; const message = typeof raw.message === 'string' ? raw.message : undefined; + const showBanner = typeof raw.showBanner === 'boolean' ? raw.showBanner + : typeof raw.show_banner === 'boolean' ? raw.show_banner + : undefined; if (id && typeof discountPercent === 'number' && message) { - return { id, discountPercent, message, ...(endsAt ? { endsAt } : {}) }; + return { id, discountPercent, message, ...(endsAt ? { endsAt } : {}), ...(showBanner !== undefined ? { showBanner } : {}) }; } return undefined; } @@ -204,6 +209,8 @@ export interface ICAPIModelBilling { /** ISO 8601 end date; absent for open-ended promotions. */ readonly endsAt?: string; readonly message: string; + /** Whether the promo may be surfaced as a chat input banner. Absent means eligible; only `false` suppresses it. */ + readonly showBanner?: boolean; }; readonly tokenPrices?: { readonly contextMax?: number; diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 075c4aeeb32d6f..faaffbd8d81b5a 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -1058,8 +1058,6 @@ export interface IAgentConnection { * acquiring class name. */ getSubscription(kind: T, resource: URI, owner: string): IReference>; - /** Acquire a subscription using an exact protocol channel string that must not be URI-normalized. */ - getSubscriptionByChannel(kind: T, channel: string, owner: string): IReference>; getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined; /** diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 60f424a76df893..acd40c5a356853 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -55,7 +55,7 @@ const COMPARE_MODIFIED_TEMPLATE_VARIABLE = '{modifiedTurnId}'; export const branchChangesetLabel = (): string => localize('branchChangeset.label', "Branch Changes"); /** Localized human-readable label for the session-wide changeset entry. */ -export const sessionChangesetLabel = (): string => localize('sessionChangeset.label', "All Changes"); +export const sessionChangesetLabel = (): string => localize('sessionChangeset.label', "Session Changes"); /** Localized human-readable description for the session-wide changeset entry. */ export const sessionChangesetDescription = (): string => localize('sessionChangeset.description', "Show all changes made in this session"); diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 96e0406b5d86aa..0ae471a3f56620 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -65,8 +65,6 @@ export interface IAgentSubscription { export interface IActiveSubscriptionInfo { /** The protocol resource URI subscribed to. */ readonly resource: URI; - /** Exact protocol channel string used on the wire. */ - readonly channel: string; /** Which state component this subscription tracks. */ readonly kind: StateComponents; /** Number of outstanding {@link IReference} holders. */ @@ -852,14 +850,7 @@ export class AnnotationsStateSubscription extends BaseAgentSubscription; -}; +type ManagedSubscriptionEntry = { sub: ManagedSubscription; kind: StateComponents; refCount: number; holders: Map }; // --- Subscription Manager ---------------------------------------------------- @@ -963,38 +954,30 @@ export class AgentSubscriptionManager extends Disposable { * acquiring class name. */ getSubscription(kind: StateComponents, resource: URI, owner: string): IReference> { - return this._getSubscription(kind, this._subscriptionResource(resource), owner); - } - - /** Get or create a subscription using an exact protocol channel string. */ - getSubscriptionByChannel(kind: StateComponents, channel: string, owner: string): IReference> { - return this._getSubscription(kind, this._subscriptionResource(URI.parse(channel)), owner); - } - - private _getSubscription(kind: StateComponents, resolved: Pick, owner: string): IReference> { - const existing = this._subscriptions.get(resolved.resource); + const existing = this._subscriptions.get(resource); if (existing) { if (existing.sub.value instanceof Error) { // Failed subscriptions should not poison the resource forever. Evict // the errored entry so this acquire performs a fresh subscribe. - this._subscriptions.delete(resolved.resource); - this._disposeSubscriptionEntry(existing); + this._subscriptions.delete(resource); + this._disposeSubscriptionEntry(resource, existing); } else { existing.refCount++; - return this._acquireReference(existing, owner); + return this._acquireReference(resource, existing, owner); } } // Create new subscription based on caller-specified kind - const sub = this._createSubscription(kind, resolved.channel); - const entry: ManagedSubscriptionEntry = { ...resolved, sub, kind, refCount: 1, holders: new Map() }; - this._subscriptions.set(resolved.resource, entry); + const key = resource.toString(); + const sub = this._createSubscription(kind, key); + const entry: ManagedSubscriptionEntry = { sub, kind, refCount: 1, holders: new Map() }; + this._subscriptions.set(resource, entry); // Kick off server subscription asynchronously. // Capture the entry reference so we can validate it hasn't been // replaced by a new subscription for the same key (race guard). void (async () => { - const inflight = this._inflightCreates.get(resolved.resource); + const inflight = this._inflightCreates.get(resource); if (inflight) { try { await inflight; @@ -1005,18 +988,18 @@ export class AgentSubscriptionManager extends Disposable { } } try { - const snapshot = await this._subscribe(resolved.resource); - if (this._subscriptions.get(resolved.resource) === entry) { + const snapshot = await this._subscribe(resource); + if (this._subscriptions.get(resource) === entry) { sub.handleSnapshot(snapshot.state as never, snapshot.fromSeq); } } catch (err) { - if (this._subscriptions.get(resolved.resource) === entry) { + if (this._subscriptions.get(resource) === entry) { sub.setError(err instanceof Error ? err : new Error(String(err))); } } })(); - return this._acquireReference(entry, owner); + return this._acquireReference(resource, entry, owner); } /** @@ -1025,7 +1008,7 @@ export class AgentSubscriptionManager extends Disposable { * caller is responsible for the matching refcount increment (a fresh * entry starts at 1; an existing entry is bumped before calling this). */ - private _acquireReference(entry: ManagedSubscriptionEntry, owner: string): IReference> { + private _acquireReference(resource: URI, entry: ManagedSubscriptionEntry, owner: string): IReference> { const ownerId = ++this._referenceOwnerIds; entry.holders.set(ownerId, owner); @@ -1038,13 +1021,13 @@ export class AgentSubscriptionManager extends Disposable { } isDisposed = true; entry.holders.delete(ownerId); - this._releaseSubscription(entry); + this._releaseSubscription(resource, entry); }, }; } - private _disposeSubscriptionEntry(entry: ManagedSubscriptionEntry): void { - this._tryUnsubscribe(entry.resource); + private _disposeSubscriptionEntry(resource: URI, entry: ManagedSubscriptionEntry): void { + this._tryUnsubscribe(resource); if (entry.sub instanceof SessionStateSubscription || entry.sub instanceof ChatStateSubscription || entry.sub instanceof AnnotationsStateSubscription) { entry.sub.clearPending(); } @@ -1112,8 +1095,8 @@ export class AgentSubscriptionManager extends Disposable { * Does NOT include the always-live root state, which the protocol * client manages separately. */ - currentSubscriptionChannels(): string[] { - return [...this._subscriptions.values()].map(entry => entry.channel); + currentSubscriptionUris(): URI[] { + return [...this._subscriptions.keys()]; } /** @@ -1123,10 +1106,10 @@ export class AgentSubscriptionManager extends Disposable { */ getActiveSubscriptions(): readonly IActiveSubscriptionInfo[] { const out: IActiveSubscriptionInfo[] = []; - for (const entry of this._subscriptions.values()) { + for (const [resource, entry] of this._subscriptions) { const value = entry.sub.value; const status = value === undefined ? 'pending' : value instanceof Error ? 'error' : 'snapshot'; - out.push({ resource: entry.resource, channel: entry.channel, kind: entry.kind, refCount: entry.refCount, holders: this._summarizeHolders(entry), status }); + out.push({ resource, kind: entry.kind, refCount: entry.refCount, holders: this._summarizeHolders(entry), status }); } return out; } @@ -1202,14 +1185,14 @@ export class AgentSubscriptionManager extends Disposable { * themselves stay alive so consumers continue to hold valid references, * but their value transitions to an `Error` until they're recreated. */ - markSubscriptionsMissing(missing: readonly string[]): void { - for (const channel of missing) { - const entry = this._subscriptions.get(URI.parse(channel)); + markSubscriptionsMissing(missing: readonly URI[]): void { + for (const resource of missing) { + const entry = this._subscriptions.get(resource); if (entry) { if (entry.sub instanceof SessionStateSubscription || entry.sub instanceof ChatStateSubscription || entry.sub instanceof AnnotationsStateSubscription) { entry.sub.clearPending(); } - entry.sub.setError(new Error(`Subscription no longer available after reconnect: ${entry.channel}`)); + entry.sub.setError(new Error(`Subscription no longer available after reconnect: ${resource.toString()}`)); } } } @@ -1237,35 +1220,28 @@ export class AgentSubscriptionManager extends Disposable { } } - private _releaseSubscription(expected: ManagedSubscriptionEntry): void { - const entry = this._subscriptions.get(expected.resource); + private _releaseSubscription(resource: URI, expected?: ManagedSubscriptionEntry): void { + const entry = this._subscriptions.get(resource); // A failed subscription can be evicted and replaced while old references // still exist; stale disposals must not release the replacement entry. - if (!entry || entry !== expected) { + if (!entry || (expected && entry !== expected)) { return; } entry.refCount--; if (entry.refCount <= 0) { - this._subscriptions.delete(entry.resource); - this._disposeSubscriptionEntry(entry); + this._subscriptions.delete(resource); + this._disposeSubscriptionEntry(resource, entry); } } override dispose(): void { - for (const entry of this._subscriptions.values()) { - this._tryUnsubscribe(entry.resource); + for (const [resource, entry] of this._subscriptions) { + this._tryUnsubscribe(resource); entry.sub.dispose(); } this._subscriptions.clear(); super.dispose(); } - - private _subscriptionResource(resource: URI): Pick { - return { - resource, - channel: resource.toString(), - }; - } } /** Returns whether an action envelope targets one of the subscribed channel URIs. */ diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index f6724952f66cf1..d7a7d057a31c74 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -367,10 +367,6 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._requireClient().getSubscription(kind, resource, owner); } - getSubscriptionByChannel(kind: T, channel: string, owner: string): IReference> { - return this._requireClient().getSubscriptionByChannel(kind, channel, owner); - } - getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined { return this._protocolClient?.getSubscriptionUnmanaged(kind, resource); } diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index 0370d76118b8fc..c6ff31388aba0a 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -193,6 +193,9 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt )); agentService = agentServiceComposition.agentService; services.set(IAgentService, agentService); + // Freeze the migrate-legacy gate at host startup, before a setting toggled + // without a full restart can be live-propagated into the shared host. + agentService.primeMigrateLegacyGate(); agentServiceComposition.setContributions(instantiationService.invokeFunction(accessor => activateAgentHostContributions(accessor, instantiationService!))); const agentSdkDownloader = instantiationService.invokeFunction(accessor => accessor.get(IAgentSdkDownloader)); diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index cd458bb9879894..a1cae52c6855de 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -33,7 +33,7 @@ export type IChangesetSessionMetadata = Record; * * Owns only URI routing and forwards lifecycle signals. Subscription state is * recorded in the shared changeset subscription service. All computation, - * working-directory gating, and the deferred-refresh state machine live in + * working-directory gating, and materialization refreshes live in * {@link IAgentHostChangesetService}. * * No per-session controllers — the cross-cutting concerns (listSessions @@ -85,17 +85,15 @@ export class AgentHostChangesetCoordinator extends Disposable { sessionRaw: metadata[META_CHANGESET_SESSION], legacyRaw: metadata[META_LEGACY_DIFFS], }); - // `addSubscriber`'s 0→1 trigger may have fired before the session - // state existed; now that `summary.workingDirectory` is populated, - // drain the deferred refresh. + // Recompute the current subscriptions now that the restored working + // directory is available. this._changesets.onWorkingDirectoryAvailable(sessionStr); this._changesetFileMonitor.onSessionRestored(sessionStr); } /** * Called when a provisional session is materialized (working directory - * becomes known). Drains any static changeset refresh that was deferred - * because the working directory was not yet known. + * becomes known). Recomputes every current changeset subscription. */ onSessionMaterialized(sessionStr: string): void { this._changesets.refreshChangesetCatalog(sessionStr); @@ -104,12 +102,7 @@ export class AgentHostChangesetCoordinator extends Disposable { this._changesetFileMonitor.onSessionMaterialized(sessionStr); } - /** - * Called when a session is disposed. Forgets any pending refresh - * queued for that session. - */ onSessionDisposed(sessionStr: string): void { - this._changesets.onSessionDisposed(sessionStr); this._changesetFileMonitor.onSessionDisposed(sessionStr); this._changesetSubscriptions.clearSessionSubscriptions(sessionStr); @@ -129,7 +122,7 @@ export class AgentHostChangesetCoordinator extends Disposable { /** * Called on every `addSubscriber` 0→1 transition. When `resource` is a * static changeset URI, triggers the first git-diff refresh (the - * changeset service self-defers it when the working directory is not yet + * changeset service skips it when the working directory is not yet * known). * * Both {@link AgentService.subscribe} and the handshake fast-path diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 63bbfd33262dab..a2ee98b7b65d26 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -174,9 +174,6 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC private readonly _activeStaticComputes = new Set(); private static readonly _DIFF_DEBOUNCE_MS = 5000; - /** Sessions whose subscribed static changesets must be recomputed after materialization. */ - private readonly _pendingMaterialization = new Set(); - private readonly _worktree: IAgentHostWorktreePendingState; constructor( @@ -385,7 +382,6 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC refreshBranchChangeset(session: ProtocolURI): void { if (!this._hasWorkingDirectory(session)) { - this._pendingMaterialization.add(session); return; } this._scheduleStaticRecompute(session, 'branch', undefined, this._markStaticChangesetComputing(session, 'branch')); @@ -393,29 +389,23 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC refreshSessionChangeset(session: ProtocolURI): void { if (!this._hasWorkingDirectory(session)) { - this._pendingMaterialization.add(session); return; } this._scheduleStaticRecompute(session, 'session', undefined, this._markStaticChangesetComputing(session, 'session')); } /** - * Drains static changeset refreshes that were deferred because the - * session's working directory was not yet known. Called by the - * coordinator once a session is materialized or restored. Recomputes - * every changeset still subscribed for the session; subscriptions that - * dropped while the working directory was unknown are naturally skipped. + * Recomputes every changeset currently subscribed when a session is + * materialized or restored. */ onWorkingDirectoryAvailable(session: ProtocolURI): void { - if (this._pendingMaterialization.delete(session)) { - this.recomputeSubscribedChangesets(session); - } + this.recomputeSubscribedChangesets(session); } /** * Recomputes every changeset currently subscribed for `session`. Each * subscribed changeset is dispatched to its kind-specific recompute; the - * recomputes self-defer when the working directory is still unknown. + * recomputes skip while the working directory is still unknown. */ recomputeSubscribedChangesets(session: ProtocolURI): void { const subscriptions = this._changesetSubscriptions.getSessionSubscriptions(session); @@ -452,14 +442,6 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } - /** - * Forgets any deferred static changeset refreshes queued for a session - * that is being disposed. - */ - onSessionDisposed(session: ProtocolURI): void { - this._pendingMaterialization.delete(session); - } - computeTurnChangeset(session: ProtocolURI, turnId: string): Promise { // Turn telemetry is emitted at the turn boundary (onTurnComplete); this subscribe-triggered recompute does not report. return this._computeTurnChangeset(session, turnId, false); @@ -625,15 +607,9 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { - this._pendingMaterialization.add(session); return uncommittedUri; } - if (this._worktree.isWorkingDirectoryPending(AgentSession.id(session))) { - // Recompute the source-repository preview after the worktree replaces it. - this._pendingMaterialization.add(session); - } - const statusBeforeCompute = this._stateManager.getChangesetState(uncommittedUri)?.status; if (statusBeforeCompute !== ChangesetStatus.Computing) { this._stateManager.dispatchServerAction(uncommittedUri, { diff --git a/src/vs/platform/agentHost/node/agentHostChangesetSubscriptionService.ts b/src/vs/platform/agentHost/node/agentHostChangesetSubscriptionService.ts index 3c56b0cd26d92e..175ab560f07b5b 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetSubscriptionService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetSubscriptionService.ts @@ -4,15 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import type { URI as ProtocolURI } from '../common/state/sessionState.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; const EMPTY_SUBSCRIPTIONS: ReadonlySet = new Set(); -export class AgentHostChangesetSubscriptionService implements IAgentHostChangesetSubscriptionService { +export class AgentHostChangesetSubscriptionService extends Disposable implements IAgentHostChangesetSubscriptionService { declare readonly _serviceBrand: undefined; private readonly _subscriptions = new Map>(); + private readonly _onDidChangeSessionSubscriptions = this._register(new Emitter()); + readonly onDidChangeSessionSubscriptions = this._onDidChangeSessionSubscriptions.event; + getSessionSubscriptions(session: ProtocolURI): ReadonlySet { return this._subscriptions.get(session) ?? EMPTY_SUBSCRIPTIONS; } @@ -24,6 +29,9 @@ export class AgentHostChangesetSubscriptionService implements IAgentHostChangese this._subscriptions.set(session, subscriptions); } subscriptions.add(changeset); + if (subscriptions.size === 1) { + this._onDidChangeSessionSubscriptions.fire(session); + } } removeSubscription(session: ProtocolURI, changeset: ProtocolURI): void { @@ -35,10 +43,13 @@ export class AgentHostChangesetSubscriptionService implements IAgentHostChangese subscriptions.delete(changeset); if (subscriptions.size === 0) { this._subscriptions.delete(session); + this._onDidChangeSessionSubscriptions.fire(session); } } clearSessionSubscriptions(session: ProtocolURI): void { - this._subscriptions.delete(session); + if (this._subscriptions.delete(session)) { + this._onDidChangeSessionSubscriptions.fire(session); + } } } diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts index bf52441efde7cd..6d8480074a710d 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts @@ -7,6 +7,8 @@ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/l import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; +import { ChangesetKind } from '../common/changesetUri.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ChangesetOperationScope, ChangesetOperationStatus, hasSessionPullRequestForBranch, type ChangesetOperation } from '../common/state/sessionState.js'; import { AgentHostCommitOperationHandler } from './agentHostCommitOperationHandler.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; @@ -32,18 +34,19 @@ export class AgentHostCommitOperationContribution extends Disposable implements return store; } - getOperations({ changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] { + getOperations({ sessionKey, changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] { if ((gitState?.uncommittedChanges ?? 0) <= 0) { return []; } - if (!hasSessionPullRequestForBranch(gitHubState, gitState?.branchName) && changesetKind !== 'uncommitted') { + const isFolderSession = this._stateManager.getSessionState(sessionKey)?.config?.values[SessionConfigKey.Isolation] === 'folder'; + if (!isFolderSession && !hasSessionPullRequestForBranch(gitHubState, gitState?.branchName) && changesetKind !== ChangesetKind.Uncommitted) { return []; } return [{ id: AgentHostCommitOperationHandler.OPERATION_COMMIT, - label: localize('agentHost.changeset.commit', "Commit Changes"), + label: localize('agentHost.changeset.commit', "Commit"), icon: 'git-commit', group: 'commit', scopes: [ChangesetOperationScope.Changeset], diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index f7576e8d416e33..126bb5a0f8c776 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -356,6 +356,14 @@ export class AgentHostGitService implements IAgentHostGitService { return output !== undefined; } + async createBranch(workingDirectory: URI, branchName: string, options?: { readonly checkout?: boolean }): Promise { + const args = options?.checkout + ? ['checkout', '-q', '-b', branchName, '--no-track'] + : ['branch', '-q', branchName]; + + await this._runGit(workingDirectory, args, { throwOnError: true }); + } + async hasUncommittedChanges(workingDirectory: URI): Promise { const output = await this._runGitStatus(workingDirectory, ['--porcelain']); return !!output && output.trim().length > 0; diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts new file mode 100644 index 00000000000000..48e26fb4ead7cc --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { localize } from '../../../nls.js'; +import type { PullRequestRef } from '../../github/common/githubPullRequestService.js'; +import { IGitHubService } from '../../github/common/githubService.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentHostPullRequestOperationId, type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; +import { AgentMergeConfigKey, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, resolveMergeMethod } from '../common/agentMerge.js'; +import { parseChangesetUri } from '../common/changesetUri.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { parsePullRequestUrl } from './agentMergeController.js'; +import { IAgentHostPullRequestStatusService, type IAgentHostPullRequestStatus } from './agentHostPullRequestStatusService.js'; + +/** + * The pull request lifecycle actions this handler can execute. Each maps 1:1 + * onto a changeset operation advertised by + * `AgentHostPullRequestOperationContribution`. + */ +export type PullRequestLifecycleAction = 'mark-ready' | 'merge' | 'enable-auto-merge' | 'disable-auto-merge'; + +/** + * Server-side handler for the pull request lifecycle operations advertised once + * a session's branch has a pull request: marking a draft ready, merging, + * and toggling GitHub's native auto-merge. + * + * Unlike `AgentHostPullRequestOperationHandler` — which drives the local git + * repository before creating a pull request — every action here is a pure + * GitHub mutation issued through {@link IGitHubService}, so it inherits that + * service's credential handling, request queueing, and merge-safety gates. + */ +export class AgentHostPullRequestLifecycleOperationHandler implements IChangesetOperationHandler { + + public static readonly OPERATION_MARK_READY = AgentHostPullRequestOperationId.MarkReady; + public static readonly OPERATION_MERGE = AgentHostPullRequestOperationId.Merge; + public static readonly OPERATION_ENABLE_AUTO_MERGE = AgentHostPullRequestOperationId.EnableAutoMerge; + public static readonly OPERATION_DISABLE_AUTO_MERGE = AgentHostPullRequestOperationId.DisableAutoMerge; + + constructor( + private readonly _action: PullRequestLifecycleAction, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IAgentHostPullRequestStatusService private readonly _statusService: IAgentHostPullRequestStatusService, + @IGitHubService private readonly _gitHubService: IGitHubService, + @ILogService private readonly _logService: ILogService, + ) { } + + async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise { + const abortController = new AbortController(); + if (token.isCancellationRequested) { + abortController.abort(); + } + const cancellationListener = token.onCancellationRequested(() => abortController.abort()); + try { + return await this._invoke(params, abortController.signal); + } finally { + cancellationListener.dispose(); + } + } + + private async _invoke(params: InvokeChangesetOperationParams, signal: AbortSignal): Promise { + const parsed = parseChangesetUri(params.channel); + if (!parsed) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a changeset URI: ${params.channel}`); + } + const sessionUri = parsed.sessionUri; + const status = this._statusService.getPullRequestStatus(sessionUri); + if (!status) { + this._logService.warn(`[AgentHostPullRequestLifecycleOperationHandler] Rejected '${this._action}': session=${sessionUri}, reason=pull request state is not available`); + throw new ProtocolError( + JsonRpcErrorCodes.InvalidParams, + localize('agentHost.changeset.pr.stateUnknown', "The pull request state is not available yet."), + ); + } + this._logService.info(`[AgentHostPullRequestLifecycleOperationHandler] Invoking '${this._action}': session=${sessionUri}, pr=${status.url}, state=${status.state}, draft=${status.draft}, mergeReady=${status.mergeReady}, autoMergeEnabled=${status.autoMergeEnabled}`); + const ref = await this._resolveRef(status, signal); + + const startedAt = Date.now(); + try { + const message = await this._runAction(sessionUri, ref, status, signal); + await this._statusService.refresh(sessionUri); + return { message }; + } catch (error) { + this._logService.error(`[AgentHostPullRequestLifecycleOperationHandler] Failed '${this._action}': session=${sessionUri}, pr=${status.url}, durationMs=${Date.now() - startedAt}, error=${error instanceof Error ? error.message : String(error)}`); + // The advertised operations are derived from cached pull request + // state, so a rejection usually means that state has drifted. A + // refresh re-derives the button bar rather than leaving the user on + // an action GitHub already refuses. + await this._statusService.refresh(sessionUri); + throw error; + } + } + + private async _runAction(sessionUri: string, ref: PullRequestRef, status: IAgentHostPullRequestStatus, signal: AbortSignal): Promise { + switch (this._action) { + case 'mark-ready': { + await this._gitHubService.mutations.markReadyForReview(ref, { pullRequestId: this._requireNodeId(status) }, signal); + this._logService.info(`[AgentHostPullRequestLifecycleOperationHandler] Marked pull request ready: session=${sessionUri}, pr=${status.url}`); + return localize('agentHost.changeset.pr.markedReady', "Pull request is ready for review."); + } + case 'enable-auto-merge': { + const method = this._requireMergeMethod(status.allowedMergeMethods); + await this._gitHubService.mutations.enableAutoMerge(ref, { pullRequestId: this._requireNodeId(status), method }, signal); + this._logService.info(`[AgentHostPullRequestLifecycleOperationHandler] Enabled auto-merge: session=${sessionUri}, pr=${status.url}, method=${method}`); + return localize('agentHost.changeset.pr.autoMergeEnabled', "Auto-merge is enabled. GitHub merges the pull request once it is ready."); + } + case 'disable-auto-merge': { + await this._gitHubService.mutations.disableAutoMerge(ref, { pullRequestId: this._requireNodeId(status) }, signal); + this._logService.info(`[AgentHostPullRequestLifecycleOperationHandler] Disabled auto-merge: session=${sessionUri}, pr=${status.url}`); + return localize('agentHost.changeset.pr.autoMergeDisabled', "Auto-merge is disabled."); + } + case 'merge': + return await this._merge(sessionUri, ref, status, signal); + } + } + + /** + * Merges through the gated preparation flow: `prepareMerge` captures an + * authoritative snapshot that the mutation service validates the merge + * against, so a pull request that stopped being mergeable between the button + * being rendered and clicked is rejected rather than force-merged. + */ + private async _merge(sessionUri: string, ref: PullRequestRef, status: IAgentHostPullRequestStatus, signal: AbortSignal): Promise { + if (!status.headSha) { + throw new ProtocolError( + JsonRpcErrorCodes.InternalError, + localize('agentHost.changeset.pr.headShaMissing', "Could not determine the pull request head commit."), + ); + } + const preparation = await this._gitHubService.mutations.prepareMerge(ref, status.headSha, signal); + // The user clicking the button is the authorization; the id only has to + // be stable for the lifetime of this single merge. + const authorization = { confirmed: true as const, authorizationId: generateUuid() }; + const mergeability = preparation.snapshot.mergeability.value; + this._logService.debug(`[AgentHostPullRequestLifecycleOperationHandler] Merge preparation complete: session=${sessionUri}, pr=${status.url}, headSha=${status.headSha}, mergeQueueRequired=${mergeability?.mergeQueueRequired ?? 'unknown'}, allowedMergeMethods=${mergeability?.allowedMergeMethods.join('|') || 'none'}`); + + if (mergeability?.mergeQueueRequired) { + const result = await this._gitHubService.mutations.enqueue(preparation, authorization, signal); + this._logService.info(`[AgentHostPullRequestLifecycleOperationHandler] Pull request enqueued: session=${sessionUri}, pr=${status.url}, outcome=${result.outcome}`); + return localize('agentHost.changeset.pr.enqueued', "Pull request was added to the merge queue."); + } + + const method = this._requireMergeMethod(mergeability?.allowedMergeMethods ?? []); + const result = await this._gitHubService.mutations.merge(preparation, { method, authorization }, signal); + this._logService.info(`[AgentHostPullRequestLifecycleOperationHandler] Pull request merged: session=${sessionUri}, pr=${status.url}, method=${method}, outcome=${result.outcome}`); + return localize('agentHost.changeset.pr.merged', "Pull request was merged."); + } + + /** + * Resolves the configured merge method against what the repository allows. + */ + private _requireMergeMethod(allowed: readonly ('MERGE' | 'SQUASH' | 'REBASE')[]): 'MERGE' | 'SQUASH' | 'REBASE' { + const configured = this._configurationService.getRootValue(agentMergeRootConfigSchema, AgentMergeConfigKey.MergeMethod) ?? defaultAgentMergeConfiguration.mergeMethod; + const method = resolveMergeMethod(configured, allowed); + if (!method) { + this._logService.warn(`[AgentHostPullRequestLifecycleOperationHandler] No usable merge method: configured=${configured}, allowedByRepository=${allowed.join('|') || 'none'}`); + throw new ProtocolError( + JsonRpcErrorCodes.InternalError, + localize('agentHost.changeset.pr.noMergeMethod', "The repository does not allow the configured merge method."), + ); + } + this._logService.trace(`[AgentHostPullRequestLifecycleOperationHandler] Resolved merge method: configured=${configured}, resolved=${method}, allowedByRepository=${allowed.join('|')}`); + return method; + } + + private _requireNodeId(status: IAgentHostPullRequestStatus): string { + if (!status.pullRequestId) { + throw new ProtocolError( + JsonRpcErrorCodes.InternalError, + localize('agentHost.changeset.pr.nodeIdMissing', "Could not resolve the pull request on GitHub."), + ); + } + return status.pullRequestId; + } + + private async _resolveRef(status: IAgentHostPullRequestStatus, signal: AbortSignal): Promise { + const parsed = parsePullRequestUrl(status.url); + if (!parsed) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Not a pull request URL: ${status.url}`); + } + const credential = await this._gitHubService.credentials.getCredential(signal); + if (credential.account.host.toLowerCase() !== parsed.apiHost.toLowerCase()) { + throw new ProtocolError( + JsonRpcErrorCodes.InternalError, + localize('agentHost.changeset.pr.accountMismatch', "The signed in GitHub account does not host this pull request."), + ); + } + return { ...credential.account, owner: parsed.owner, repo: parsed.repo, number: parsed.number }; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 5c485d5e0b698f..3dbfca1d1c7426 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -18,6 +18,8 @@ import { type AutoMergeMethod, type CreatedPullRequest, IAgentHostOctoKitService import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js'; import { buildConversationContext } from '../common/agentHostConversationContext.js'; +import { IAgentBranchNameGenerator } from './shared/agentBranchNameGenerator.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; /** * Soft upper bound, in characters, for the conversation context fed to the @@ -51,13 +53,14 @@ export interface PullRequestCreatedEvent { * * 1. Resolve session → working directory + current/base branch from * {@link ISessionGitState}. - * 2. Commit any uncommitted working-tree changes. - * 3. Push the current branch to its GitHub upstream remote (with `--set-upstream` when missing). - * 4. Resolve `owner` / `repo` from {@link ISessionGitState.githubOwner} + * 2. If the current branch is the base branch, create a generated session branch. + * 3. Commit any uncommitted working-tree changes. + * 4. Push the current branch to its GitHub upstream remote (with `--set-upstream` when missing). + * 5. Resolve `owner` / `repo` from {@link ISessionGitState.githubOwner} * / {@link ISessionGitState.githubRepo} (populated by the git probe). - * 5. Reuse an existing PR for the branch, or POST `/repos/{owner}/{repo}/pulls` + * 6. Reuse an existing PR for the branch, or POST `/repos/{owner}/{repo}/pulls` * via {@link IAgentHostOctoKitService}. - * 6. Return the PR URL as an {@link InvokeChangesetOperationResult.followUp}. + * 7. Return the PR URL as an {@link InvokeChangesetOperationResult.followUp}. */ export class AgentHostPullRequestOperationHandler implements IChangesetOperationHandler { @@ -78,6 +81,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @IAgentBranchNameGenerator private readonly _branchNameGenerator: IAgentBranchNameGenerator, @ILogService private readonly _logService: ILogService, ) { } @@ -100,8 +104,8 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a changeset URI: ${params.channel}`); } this._throwIfCancelled(token); - const sessionUri = parsed.sessionUri; + const sessionUri = parsed.sessionUri; const sessionState = this._getSessionState(sessionUri); if (!sessionState) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); @@ -123,17 +127,18 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation const workingDirectory = URI.parse(workingDirectoryStr); const storedGitState = readSessionGitState(sessionState._meta); const effectiveBaseBranch = await this._resolveBaseBranchName(sessionUri); - const gitState = await this._gitService.getSessionGitState(workingDirectory, effectiveBaseBranch) ?? storedGitState; - const branchName = gitState?.branchName ?? await this._gitService.getCurrentBranch(workingDirectory); + + let gitState = await this._gitService.getSessionGitState(workingDirectory, effectiveBaseBranch) ?? storedGitState; + let branchName = gitState?.branchName ?? await this._gitService.getCurrentBranch(workingDirectory); if (!branchName) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine current branch for ${workingDirectory}`); } - const baseBranchName = effectiveBaseBranch ?? gitState?.baseBranchName ?? (await this._gitService.getDefaultBranch(workingDirectory))?.name; + const defaultBranch = await this._gitService.getDefaultBranch(workingDirectory); + const baseBranchName = effectiveBaseBranch ?? gitState?.baseBranchName ?? defaultBranch?.name; if (!baseBranchName) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine base branch for ${workingDirectory}`); } - const base = baseBranchName; const repoResource = this._gitHubEndpointService.getRepoResource(); const authToken = this._authenticationService.getAuthToken({ @@ -149,9 +154,39 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation } const hasUncommitted = await this._gitService.hasUncommittedChanges(workingDirectory); + + // Create a new branch if the current branch is the same + // as the base branch and there are uncommitted changes + if (hasUncommitted && branchName === baseBranchName) { + const branchPrefix = sessionState.config?.values[SessionConfigKey.WorktreeBranchPrefix]; + + try { + const generatedBranchName = await this._branchNameGenerator.generateBranchName({ + sessionId: URI.parse(sessionUri).path.split('/').filter(Boolean).pop() ?? sessionUri, + message: sessionState.turns.find(turn => turn.message.text.trim())?.message.text, + githubToken: authToken, + signal, + branchPrefix: typeof branchPrefix === 'string' ? branchPrefix : undefined, + branchNameCollides: candidate => this._gitService.branchExists(workingDirectory, candidate).catch(() => true), + }); + + this._throwIfCancelled(token); + this._logService.info(`[AgentHostPullRequestOperationHandler] Creating branch ${generatedBranchName} for session ${sessionUri}`); + + await this._gitService.createBranch(workingDirectory, generatedBranchName, { checkout: true }); + branchName = generatedBranchName; + + gitState = await this._gitService.getSessionGitState(workingDirectory, effectiveBaseBranch); + } catch (err) { + this._throwIfCancelled(token); + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to create a branch before creating a pull request: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (hasUncommitted) { this._throwIfCancelled(token); this._logService.info(`[AgentHostPullRequestOperationHandler] Committing uncommitted changes for session ${sessionUri}`); + try { await this._gitService.commitAll(workingDirectory, this._formatCommitMessage(branchName)); } catch (err) { @@ -161,7 +196,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation } this._throwIfCancelled(token); - const branchChanges = await this._gitService.computeSessionFileDiffs(workingDirectory, { sessionUri, baseBranch: base }); + const branchChanges = await this._gitService.computeSessionFileDiffs(workingDirectory, { sessionUri, baseBranch: baseBranchName }); if (branchChanges === undefined) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.computeChangesFailed', "Could not compute branch changes to create a pull request.")); } @@ -171,7 +206,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation this._throwIfCancelled(token); const githubHeadOwner = gitState?.githubHeadOwner; - const upstreamBranch = githubHeadOwner ? parseUpstreamBranchName(gitState.upstreamBranchName) : undefined; + const upstreamBranch = githubHeadOwner ? parseUpstreamBranchName(gitState?.upstreamBranchName) : undefined; const headOwner = upstreamBranch && githubHeadOwner ? githubHeadOwner : gitHubState.owner; const headBranch = upstreamBranch?.branch ?? branchName; const pushRef = headBranch === branchName ? branchName : `${branchName}:${headBranch}`; @@ -195,12 +230,12 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation } this._throwIfCancelled(token); - const generated = await this._generateTitleAndDescription(sessionState, branchName, base, branchChanges, signal, token); - this._throwIfCancelled(token); + const generated = await this._generateTitleAndDescription(sessionState, branchName, baseBranchName, branchChanges, signal, token); const title = generated?.title ?? this._formatTitle(branchName); - const body = generated?.description ?? this._formatBody(branchName, base); + const body = generated?.description ?? this._formatBody(branchName, baseBranchName); + this._throwIfCancelled(token); - this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitHubState.owner}/${gitHubState.repo} ${createHead} -> ${base}`); + this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitHubState.owner}/${gitHubState.repo} ${createHead} -> ${baseBranchName}`); let created: CreatedPullRequest; try { created = await this._octoKitService.createPullRequest( @@ -209,7 +244,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation title, body, createHead, - base, + baseBranchName, this._draft, authToken, signal, diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts index 63dd2160c56547..303f73c6335840 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -6,22 +6,32 @@ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ChangesetOperationScope, ChangesetOperationStatus, hasSessionPullRequestForBranch, readSessionGitHubState, SessionLifecycle, withMostRecentRelatedSessionPullRequest, type ChangesetOperation } from '../common/state/sessionState.js'; import { AgentHostPullRequestOperationHandler, type PullRequestCreatedEvent } from './agentHostPullRequestOperationHandler.js'; +import { AgentHostPullRequestLifecycleOperationHandler } from './agentHostPullRequestLifecycleOperationHandler.js'; +import { IAgentHostPullRequestStatusService } from './agentHostPullRequestStatusService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { private _registry: IChangesetOperationRegistry | undefined; + /** Last advertised operation ids per session, so only changes are logged. */ + private readonly _lastAdvertisedOperations = new Map(); + constructor( @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService + @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService, + @IAgentHostPullRequestStatusService private readonly _pullRequestStatusService: IAgentHostPullRequestStatusService, + @ILogService private readonly _logService: ILogService, ) { super(); + this._register(this._stateManager.onDidRemoveSession(sessionKey => this._lastAdvertisedOperations.delete(sessionKey))); } registerHandlers(registry: IChangesetOperationRegistry): IDisposable { @@ -40,20 +50,43 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_MERGE, createAutoMergePrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_SQUASH, createAutoSquashPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_REBASE, createAutoRebasePrHandler)); + + for (const [operationId, action] of [ + [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY, 'mark-ready'], + [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MERGE, 'merge'], + [AgentHostPullRequestLifecycleOperationHandler.OPERATION_ENABLE_AUTO_MERGE, 'enable-auto-merge'], + [AgentHostPullRequestLifecycleOperationHandler.OPERATION_DISABLE_AUTO_MERGE, 'disable-auto-merge'], + ] as const) { + store.add(registry.registerChangesetOperationHandler(operationId, this._instantiationService.createInstance(AgentHostPullRequestLifecycleOperationHandler, action))); + } + + store.add(this._pullRequestStatusService.onDidChangePullRequestStatus(sessionKey => registry.onDidChangeOperations(sessionKey))); store.add({ dispose: () => { this._registry = undefined; } }); return store; } - getOperations({ sessionKey, gitState, gitHubState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + getOperations(context: IChangesetOperationContext): ChangesetOperation[] | undefined { + const operations = this._computeOperations(context); + this._logAdvertisedOperations(context.sessionKey, operations); + return operations; + } + + private _computeOperations({ sessionKey, gitState, gitHubState }: IChangesetOperationContext): ChangesetOperation[] | undefined { // New Session const state = this._stateManager.getSessionState(sessionKey); if (state?.lifecycle === SessionLifecycle.Creating) { return undefined; } + // Folder session that has outgoing changes + const isFolderSession = state?.config?.values[SessionConfigKey.Isolation] === 'folder'; + if (isFolderSession && gitState?.outgoingChanges && gitState.outgoingChanges > 0) { + return undefined; + } + // Pull request already exists for the currently checked out branch if (hasSessionPullRequestForBranch(gitHubState, gitState?.branchName)) { - return undefined; + return this._getPullRequestLifecycleOperations(sessionKey); } const hasBranchChanges = gitState?.hasBaseBranchChanges ?? (gitState?.outgoingChanges ?? 0) > 0; @@ -105,6 +138,101 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem }] satisfies ChangesetOperation[]; } + /** + * Operations for a branch that already has a pull request. + * + * The order matters: the client renders the first operation as the primary + * button and the rest as dropdown entries, so the state's headline action + * comes first. Auto-merge is driven purely off GitHub's own capability + * flags — `viewerCanEnableAutoMerge` is already false for a pull request + * that can simply be merged — which keeps the three states falling out + * without duplicating GitHub's rules here. + * + * Returns `undefined` while the pull request state has not resolved yet, so + * the button bar stays hidden rather than flashing the wrong action, and + * once the pull request is merged or closed, when nothing is left to do. + */ + private _getPullRequestLifecycleOperations(sessionKey: string): ChangesetOperation[] | undefined { + const status = this._pullRequestStatusService.getPullRequestStatus(sessionKey); + if (!status) { + this._logService.trace(`[AgentHostPullRequestOperationContribution] No pull request operations: session=${sessionKey}, reason=pull request state has not resolved yet`); + return undefined; + } + if (status.state !== 'open') { + this._logService.trace(`[AgentHostPullRequestOperationContribution] No pull request operations: session=${sessionKey}, reason=pull request is ${status.state}`); + return undefined; + } + + const operations: ChangesetOperation[] = []; + if (status.draft) { + operations.push({ + id: AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY, + label: localize('agentHost.changeset.markReady', "Mark Ready"), + description: localize('agentHost.changeset.markReady.description', "Take the pull request out of draft so it can be reviewed and merged."), + icon: 'git-pull-request', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }); + } + if (status.mergeReady) { + operations.push({ + id: AgentHostPullRequestLifecycleOperationHandler.OPERATION_MERGE, + label: localize('agentHost.changeset.mergePR', "Merge"), + description: localize('agentHost.changeset.mergePR.description', "Merge the pull request using the configured merge method."), + icon: 'git-merge', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }); + } + if (status.autoMergeEnabled) { + operations.push({ + id: AgentHostPullRequestLifecycleOperationHandler.OPERATION_DISABLE_AUTO_MERGE, + label: localize('agentHost.changeset.disableAutoMerge', "Disable Auto Merge"), + description: localize('agentHost.changeset.disableAutoMerge.description', "Stop GitHub from merging the pull request automatically once it is ready."), + icon: 'git-merge', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }); + } else if (status.viewerCanEnableAutoMerge) { + operations.push({ + id: AgentHostPullRequestLifecycleOperationHandler.OPERATION_ENABLE_AUTO_MERGE, + label: localize('agentHost.changeset.enableAutoMerge', "Enable Auto Merge"), + description: localize('agentHost.changeset.enableAutoMerge.description', "Let GitHub merge the pull request automatically once all requirements pass."), + icon: 'git-merge', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }); + } + + if (operations.length === 0) { + this._logService.trace(`[AgentHostPullRequestOperationContribution] No pull request operations: session=${sessionKey}, reason=the pull request is open but is not draft, not mergeable, and auto-merge is unavailable`); + return undefined; + } + return operations; + } + + /** + * Logs the advertised operations whenever the set changes for a session. + * `getOperations` is recomputed on every git/GitHub state change and once + * per changeset, so only transitions are logged — the steady state would + * otherwise drown out everything else. + */ + private _logAdvertisedOperations(sessionKey: string, operations: readonly ChangesetOperation[] | undefined): void { + const advertised = operations?.map(operation => operation.id).join(', ') ?? 'none'; + if (this._lastAdvertisedOperations.get(sessionKey) === advertised) { + return; + } + this._lastAdvertisedOperations.set(sessionKey, advertised); + // The first id is what the client renders as the primary button, so it + // is called out separately from the rest of the dropdown. + const primary = operations?.[0]?.id ?? 'none'; + this._logService.info(`[AgentHostPullRequestOperationContribution] Advertised operations changed: session=${sessionKey}, primary=${primary}, operations=[${advertised}]`); + } + private _onPullRequestCreated(event: PullRequestCreatedEvent): void { const sessionKey = event.sessionKey; diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts new file mode 100644 index 00000000000000..8079f47406210f --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts @@ -0,0 +1,412 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { structuralEquals } from '../../../base/common/equals.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, type IDisposable } from '../../../base/common/lifecycle.js'; +import { autorun } from '../../../base/common/observable.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../github/common/githubPullRequestService.js'; +import type { GitHubCredentialInvalidation } from '../../github/common/githubCredentialService.js'; +import type { GitHubAccountHandle } from '../../github/common/githubTypes.js'; +import { IGitHubService } from '../../github/common/githubService.js'; +import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; +import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; +import { getSessionRelatedPullRequestUrls, hasSessionPullRequestForBranch, isSessionStatusArchived, readSessionGitHubState, readSessionGitState } from '../common/state/sessionState.js'; +import { ActionType } from '../common/state/sessionActions.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { parsePullRequestUrl } from './agentMergeController.js'; + +/** + * Merge states GitHub reports for a pull request that can still be merged + * directly. `UNSTABLE` means only non-required checks are failing, which does + * not block a merge. + */ +const MERGEABLE_STATES = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE']); + +/** + * Live GitHub state of a session's pull request, reduced to what the changeset + * operation provider needs to decide which pull request operations to + * advertise. + */ +export interface IAgentHostPullRequestStatus { + /** GraphQL node id, required by the node-scoped mutations. */ + readonly pullRequestId?: string; + readonly number: number; + readonly url: string; + readonly headSha?: string; + readonly state: 'open' | 'closed' | 'merged'; + readonly draft: boolean; + /** True once the pull request is open and can be merged as-is. */ + readonly mergeReady: boolean; + readonly viewerCanEnableAutoMerge: boolean; + readonly autoMergeEnabled: boolean; + readonly allowedMergeMethods: readonly ('MERGE' | 'SQUASH' | 'REBASE')[]; +} + +export const IAgentHostPullRequestStatusService = createDecorator('agentHostPullRequestStatusService'); + +/** + * Tracks the live GitHub state of the pull request belonging to each session a + * client is currently watching. + * + * A pull request subscription costs GitHub API budget, so the watcher is scoped + * to sessions that have at least one changeset subscriber — in practice the + * session whose changes the user has open. It also deliberately subscribes to + * the `core` and `mergeability` fragments only: everything the pull request + * button bar needs is in those two, and the expensive conversation and check + * fragments stay reserved for Agent Merge. + */ +export interface IAgentHostPullRequestStatusService extends IDisposable { + readonly _serviceBrand: undefined; + + /** Fires with the session key whose pull request status changed. */ + readonly onDidChangePullRequestStatus: Event; + + /** + * The last observed pull request status for `sessionKey`, or `undefined` + * while the session has no watched pull request or its first snapshot has + * not resolved yet. Callers MUST treat `undefined` as "unknown" rather than + * "no pull request". + */ + getPullRequestStatus(sessionKey: string): IAgentHostPullRequestStatus | undefined; + + /** + * Re-reads the pull request from GitHub, bypassing cached fragments. Used + * after a mutation so the advertised operations reflect the new state + * without waiting for the next poll. + */ + refresh(sessionKey: string): Promise; +} + +interface IWatch extends IDisposable { + readonly ref: PullRequestRef; + readonly subscription: PullRequestSubscription; + status?: IAgentHostPullRequestStatus; +} + +/** Whether a session should be watched, or why it should not be. */ +type IWatchTarget = + | { readonly kind: 'watch'; readonly pullRequestUrl: string } + | { readonly kind: 'skip'; readonly reason: string }; + +export class AgentHostPullRequestStatusService extends Disposable implements IAgentHostPullRequestStatusService { + + declare readonly _serviceBrand: undefined; + + private readonly _watches = new Map(); + private readonly _pendingSyncs = new Map>(); + private readonly _staleSyncs = new Set(); + private readonly _abortController = new AbortController(); + + private readonly _onDidChangePullRequestStatus = this._register(new Emitter()); + readonly onDidChangePullRequestStatus = this._onDidChangePullRequestStatus.event; + + constructor( + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService, + @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService, + @IGitHubService private readonly _gitHubService: IGitHubService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._register(this._changesetSubscriptions.onDidChangeSessionSubscriptions(session => this._sync(session))); + this._register(this._gitStateService.onDidRefreshSessionGitState(session => this._sync(session))); + this._register(this._gitStateService.onDidChangeSessionGitHubState(session => this._sync(session))); + this._register(this._stateManager.onDidRemoveSession(session => this._stopWatch(session))); + this._register(this._stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.SessionIsArchivedChanged) { + this._sync(envelope.channel); + } + })); + this._register(this._gitHubService.credentials.onDidInvalidate(event => this._handleCredentialInvalidation(event))); + } + + /** + * Rebuilds watches whose backing pull request resource the resource service + * has thrown away. + * + * An account or endpoint change disposes the resource outright, leaving a + * subscription whose snapshot can never update — the button bar would then + * advertise stale operations until some unrelated git or subscription event + * happened to trigger a resync. A replaced or re-authenticated credential + * keeps the resource and refreshes it in place, so those need nothing here. + */ + private _handleCredentialInvalidation(event: GitHubCredentialInvalidation): void { + if (event.reason === 'replacement' || event.reason === 'authentication') { + return; + } + for (const [sessionKey, watch] of [...this._watches]) { + if (event.credential && !sameAccount(event.credential.account, watch.ref)) { + continue; + } + this._stopWatch(sessionKey, `the GitHub credential was invalidated (${event.reason})`); + if (event.reason !== 'shutdown') { + this._sync(sessionKey); + } + } + } + + getPullRequestStatus(sessionKey: string): IAgentHostPullRequestStatus | undefined { + return this._watches.get(sessionKey)?.status; + } + + async refresh(sessionKey: string): Promise { + const watch = this._watches.get(sessionKey); + if (!watch) { + this._logService.trace(`[AgentHostPullRequestStatusService] Refresh skipped because no pull request is being watched: session=${sessionKey}`); + return; + } + try { + this._logService.trace(`[AgentHostPullRequestStatusService] Refreshing pull request: session=${sessionKey}, pr=${describeRef(watch.ref)}`); + await watch.subscription.refresh(undefined, undefined, { authoritative: true }); + } catch (error) { + this._logService.warn(`[AgentHostPullRequestStatusService] Refresh failed: session=${sessionKey}, pr=${describeRef(watch.ref)}, error=${error}`); + } + } + + override dispose(): void { + this._abortController.abort(); + this._staleSyncs.clear(); + for (const watch of this._watches.values()) { + watch.dispose(); + } + this._watches.clear(); + super.dispose(); + } + + /** + * Starts, replaces, or stops the watch for `sessionKey` so it matches the + * session's current eligibility. Serialized per session because resolving + * the pull request ref needs a credential, and two overlapping syncs would + * otherwise race to install different subscriptions. Changes observed while + * a sync is in flight are coalesced into one follow-up run so the watch + * never settles on state that was already stale when it was read. + */ + private _sync(sessionKey: string): void { + if (this._pendingSyncs.has(sessionKey)) { + this._staleSyncs.add(sessionKey); + return; + } + this._runSync(sessionKey); + } + + private _runSync(sessionKey: string): void { + this._staleSyncs.delete(sessionKey); + const run = this._doSync(sessionKey).catch(error => { + this._logService.debug(`[AgentHostPullRequestStatusService] Sync failed: session=${sessionKey}, error=${error}`); + }).finally(() => { + if (this._pendingSyncs.get(sessionKey) !== run) { + return; + } + this._pendingSyncs.delete(sessionKey); + if (this._staleSyncs.delete(sessionKey) && !this._abortController.signal.aborted) { + this._runSync(sessionKey); + } + }); + this._pendingSyncs.set(sessionKey, run); + } + + private async _doSync(sessionKey: string): Promise { + if (this._abortController.signal.aborted) { + return; + } + + const target = this._getWatchTarget(sessionKey); + if (target.kind === 'skip') { + this._stopWatch(sessionKey, target.reason); + return; + } + + const parsed = parsePullRequestUrl(target.pullRequestUrl); + if (!parsed) { + this._stopWatch(sessionKey, `pull request URL could not be parsed: ${target.pullRequestUrl}`); + return; + } + + const existing = this._watches.get(sessionKey); + if (existing && sameRef(existing.ref, parsed)) { + return; + } + + const credential = await this._gitHubService.credentials.getCredential(this._abortController.signal); + if (this._abortController.signal.aborted) { + return; + } + // The pull request URL carries its own host: after a restore or an + // endpoint switch the same owner/repo/number can name a different GitHub + // instance, which must never be read with this account's credential. + if (credential.account.host.toLowerCase() !== parsed.apiHost.toLowerCase()) { + const reason = `the signed in account (${credential.account.host}) does not host ${parsed.owner}/${parsed.repo}#${parsed.number} (${parsed.apiHost})`; + this._stopWatch(sessionKey, reason); + this._logService.debug(`[AgentHostPullRequestStatusService] Not watching pull request: session=${sessionKey}, reason=${reason}`); + return; + } + const ref: PullRequestRef = { ...credential.account, owner: parsed.owner, repo: parsed.repo, number: parsed.number }; + + // Eligibility can have changed while the credential was in flight; the + // follow-up run installs the watch the session actually needs. + const current = this._getWatchTarget(sessionKey); + if (current.kind === 'skip' || current.pullRequestUrl !== target.pullRequestUrl) { + this._logService.trace(`[AgentHostPullRequestStatusService] Retrying sync because the session changed while resolving credentials: session=${sessionKey}`); + this._staleSyncs.add(sessionKey); + return; + } + + this._stopWatch(sessionKey, `replaced by ${describeRef(ref)}`); + const store = new DisposableStore(); + const subscription = store.add(this._gitHubService.pullRequests.subscribePullRequest(ref, { + priority: 'visible', + core: true, + mergeability: true, + })); + const watch: IWatch = { + ref, + subscription, + dispose: () => store.dispose(), + }; + this._watches.set(sessionKey, watch); + store.add(autorun(reader => { + const snapshot = subscription.resource.snapshot.read(reader); + this._updateStatus(sessionKey, watch, snapshot); + })); + this._logService.debug(`[AgentHostPullRequestStatusService] Watching pull request: session=${sessionKey}, pr=${describeRef(ref)}`); + } + + /** + * The pull request this session should be watching, or the reason it is not + * eligible. The reason is carried rather than collapsed into `undefined` so + * a missing button bar can be explained from the logs alone. + */ + private _getWatchTarget(sessionKey: string): IWatchTarget { + const state = this._stateManager.getSessionState(sessionKey); + if (!state) { + return { kind: 'skip', reason: 'session is unknown' }; + } + if (isSessionStatusArchived(state.status)) { + return { kind: 'skip', reason: 'session is archived' }; + } + if (this._changesetSubscriptions.getSessionSubscriptions(sessionKey).size === 0) { + return { kind: 'skip', reason: 'no client is subscribed to the session changes' }; + } + const gitHubState = readSessionGitHubState(state._meta); + const gitState = readSessionGitState(state._meta); + if (!hasSessionPullRequestForBranch(gitHubState, gitState?.branchName)) { + return { kind: 'skip', reason: `no pull request is known for branch '${gitState?.branchName ?? 'unknown'}'` }; + } + const pullRequestUrl = getSessionRelatedPullRequestUrls(gitHubState)[0] ?? gitHubState?.pullRequestUrls?.[0]; + return pullRequestUrl + ? { kind: 'watch', pullRequestUrl } + : { kind: 'skip', reason: 'the session has no pull request URL' }; + } + + private _updateStatus(sessionKey: string, watch: IWatch, snapshot: PullRequestSnapshot): void { + const status = toPullRequestStatus(snapshot); + if (structuralEquals(watch.status, status)) { + return; + } + const previous = watch.status; + watch.status = status; + if (this._watches.get(sessionKey) !== watch) { + return; + } + // The single most useful line when a button bar shows the "wrong" + // action: it names every flag the operation provider branches on. + this._logService.debug(`[AgentHostPullRequestStatusService] Status changed: session=${sessionKey}, pr=${describeRef(watch.ref)}, from=[${describeStatus(previous)}], to=[${describeStatus(status)}]`); + this._onDidChangePullRequestStatus.fire(sessionKey); + } + + private _stopWatch(sessionKey: string, reason?: string): void { + const watch = this._watches.get(sessionKey); + if (!watch) { + return; + } + this._watches.delete(sessionKey); + watch.dispose(); + this._logService.debug(`[AgentHostPullRequestStatusService] Stopped watching pull request: session=${sessionKey}, pr=${describeRef(watch.ref)}, reason=${reason ?? 'session removed'}`); + this._onDidChangePullRequestStatus.fire(sessionKey); + } +} + +/** Compact `owner/repo#number` form used in every log line. */ +function describeRef(ref: PullRequestRef): string { + return `${ref.owner}/${ref.repo}#${ref.number}`; +} + +/** Renders the flags the operation provider branches on, for log lines. */ +function describeStatus(status: IAgentHostPullRequestStatus | undefined): string { + if (!status) { + return 'unresolved'; + } + return [ + `state=${status.state}`, + `draft=${status.draft}`, + `mergeReady=${status.mergeReady}`, + `autoMergeEnabled=${status.autoMergeEnabled}`, + `canEnableAutoMerge=${status.viewerCanEnableAutoMerge}`, + `allowedMergeMethods=${status.allowedMergeMethods.join('|') || 'none'}`, + ].join(', '); +} + +/** + * Reduces a snapshot to {@link IAgentHostPullRequestStatus}, or `undefined` + * while either fragment the button bar depends on is still unresolved. Holding + * back on partial data keeps the client from flashing a wrong primary button. + */ +function toPullRequestStatus(snapshot: PullRequestSnapshot): IAgentHostPullRequestStatus | undefined { + const core = snapshot.core.value; + if (!core) { + return undefined; + } + + // A merged or closed pull request has nothing left to act on, so it is + // reported without waiting for mergeability — which GitHub stops updating. + if (core.state !== 'open') { + return { + ...(core.id ? { pullRequestId: core.id } : {}), + number: core.number, + url: core.url, + headSha: core.headSha, + state: core.state, + draft: core.draft, + mergeReady: false, + viewerCanEnableAutoMerge: false, + autoMergeEnabled: false, + allowedMergeMethods: [], + }; + } + + const mergeability = snapshot.mergeability.value; + if (!mergeability || snapshot.mergeability.headSha !== core.headSha) { + return undefined; + } + + return { + ...(core.id ? { pullRequestId: core.id } : {}), + number: core.number, + url: core.url, + headSha: core.headSha, + state: core.state, + draft: core.draft, + mergeReady: !core.draft + && mergeability.mergeable === 'MERGEABLE' + && mergeability.viewerCanMerge + && MERGEABLE_STATES.has(mergeability.mergeStateStatus?.toUpperCase() ?? 'CLEAN'), + viewerCanEnableAutoMerge: mergeability.viewerCanEnableAutoMerge, + autoMergeEnabled: mergeability.autoMergeEnabled, + allowedMergeMethods: mergeability.allowedMergeMethods, + }; +} + +function sameRef(left: PullRequestRef, right: { readonly owner: string; readonly repo: string; readonly number: number }): boolean { + return left.owner.toLowerCase() === right.owner.toLowerCase() + && left.repo.toLowerCase() === right.repo.toLowerCase() + && left.number === right.number; +} + +function sameAccount(left: GitHubAccountHandle, right: GitHubAccountHandle): boolean { + return left.host.toLowerCase() === right.host.toLowerCase() && left.accountId === right.accountId; +} diff --git a/src/vs/platform/agentHost/node/agentHostServices.ts b/src/vs/platform/agentHost/node/agentHostServices.ts index a35f54e70e66f0..fcaef8c309305e 100644 --- a/src/vs/platform/agentHost/node/agentHostServices.ts +++ b/src/vs/platform/agentHost/node/agentHostServices.ts @@ -47,6 +47,7 @@ import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablem import { AgentHostGitStateService } from './agentHostGitStateService.js'; import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; import { AgentHostPromptCache, IAgentHostPromptCache } from './agentHostPromptCache.js'; +import { AgentHostPullRequestStatusService, IAgentHostPullRequestStatusService } from './agentHostPullRequestStatusService.js'; import { AgentHostReviewService } from './agentHostReviewService.js'; import { AgentHostSubscriptionService } from './agentHostSubscriptionService.js'; import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; @@ -89,6 +90,7 @@ export function registerAgentHostCoreServices(services: ServiceCollection, input services.set(IAgentHostSessionTitleSignal, new SyncDescriptor(AgentHostSessionTitleSignal)); services.set(IAgentHostSessionOpenTelemetry, new SyncDescriptor(AgentHostSessionOpenTelemetry)); services.set(IAgentHostChangesetSubscriptionService, new SyncDescriptor(AgentHostChangesetSubscriptionService)); + services.set(IAgentHostPullRequestStatusService, new SyncDescriptor(AgentHostPullRequestStatusService)); services.set(IAgentHostSubscriptionService, new SyncDescriptor(AgentHostSubscriptionService)); services.set(IAgentHostChangesetOperationService, new SyncDescriptor(AgentHostChangesetOperationService)); services.set(IAgentHostReviewService, new SyncDescriptor(AgentHostReviewService)); diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index dc26fb9d1d327b..52b9b48cb113fb 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -15,9 +15,10 @@ import { IGitHubService } from '../../github/common/githubService.js'; import { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../github/common/githubPullRequestService.js'; import { GitHubRequestError } from '../../github/common/githubTransport.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentMergeConfigKey, AgentMergeConfiguration, AgentMergeDisableReason, AgentMergeSessionState, AgentMergeTarget, agentMergeDisableReasons, agentMergeDisabledNotice, agentMergeEnabledNotice, agentMergeGateFragments, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, evaluateAgentMerge, readAgentMergeSessionState, resolveAgentMergeConfiguration } from '../common/agentMerge.js'; +import { AgentMergeConfigKey, AgentMergeConfiguration, AgentMergeDisableReason, AgentMergeSessionState, AgentMergeTarget, AGENT_MERGE_UNKNOWN_COMMIT, agentMergeDisableReasons, agentMergeDisabledNotice, agentMergeEnabledNotice, agentMergeGateFragments, agentMergeMergePullRequestDemotedNotice, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, evaluateAgentMerge, readAgentMergeSessionState, resolveAgentMergeConfiguration, resolveMergeMethod, shouldStopMergingAfterAgentChanges } from '../common/agentMerge.js'; import { buildAgentMergePrompt } from '../common/agentMergePrompt.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; +import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentSystemNotificationKind } from '../common/meta/agentSystemNotificationMeta.js'; import { deriveGitHubEndpoints } from '../common/githubEndpoints.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; @@ -86,6 +87,7 @@ export class AgentMergeController extends Disposable { private readonly _runtimes = this._register(new DisposableMap()); private readonly _evaluations = new SequencerByKey(); + private readonly _evaluatingSessions = new Set(); private readonly _activeTurns = new Map(); private readonly _onDidReleaseHold = this._register(new Emitter()); @@ -107,6 +109,7 @@ export class AgentMergeController extends Disposable { @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IGitHubService private readonly _gitHubService: IGitHubService, @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @ILogService private readonly _logService: ILogService, @@ -116,9 +119,15 @@ export class AgentMergeController extends Disposable { this._register(this._stateManager.onDidChangeSessionConfig(event => { const previous = readAgentMergeSessionState(event.previous?.values); const current = readAgentMergeSessionState(event.current?.values); - if (!structuralEquals(previous, current)) { - this._syncSession(event.session.toString()); + if (structuralEquals(previous, current)) { + return; + } + const session = event.session.toString(); + if (this._resetRepairBaselineOnReselection(session, previous, current)) { + // The reset re-enters this listener, which then syncs. + return; } + this._syncSession(session); })); this._register(this._stateManager.onDidChangeSessionActiveTurn(event => { if (event.active) { @@ -130,7 +139,11 @@ export class AgentMergeController extends Disposable { this._monitoredSessions.delete(session); this._stopRuntime(session); })); - this._register(this._gitStateService.onDidRefreshSessionGitState(session => this._schedule(session, 0))); + this._register(this._gitStateService.onDidRefreshSessionGitState(session => { + if (!this._evaluatingSessions.has(session)) { + this._schedule(session, 0); + } + })); this._register(this._gitStateService.onDidChangeSessionGitHubState(session => this._schedule(session, 0))); this._register(this._configurationService.onDidRootConfigChange(() => { for (const session of this._stateManager.getSessionUris()) { @@ -350,6 +363,7 @@ export class AgentMergeController extends Disposable { private _queueEvaluation(session: string): void { void this._evaluations.queue(session, async () => { + this._evaluatingSessions.add(session); try { this._logService.trace(`[AgentMergeController] Evaluation started: session=${session}`); await this._evaluate(session); @@ -363,6 +377,8 @@ export class AgentMergeController extends Disposable { } this._logService.error(error, `[AgentMergeController] Evaluation failed: session=${session}, kind=${githubErrorKind(error)}`); this._runtimes.get(session)?.backstopScheduler.schedule(); + } finally { + this._evaluatingSessions.delete(session); } }); } @@ -445,6 +461,17 @@ export class AgentMergeController extends Disposable { } const snapshot = subscription.resource.snapshot.get(); const configuration = this._getConfiguration(agentMerge); + // Backstop only: `_completeTurn` normally decides this the moment a + // repair turn ends. This catches a host restart that lost the in-flight + // turn, since the baseline commit is persisted with the session. + if (await this._demoteMergePullRequestIfChanged(session, agentMerge, configuration)) { + // The config write re-enters evaluation with the demoted value, so + // this pass must not go on to merge under the old one. + return; + } + if (!this._isCurrentRuntime(session, runtime)) { + return; + } const gate = evaluateAgentMerge(snapshot, configuration, target.commentWatermark); this._logGateResult(session, gate); if (gate.kind !== 'indeterminate') { @@ -485,6 +512,11 @@ export class AgentMergeController extends Disposable { return; } const turnId = generateUuid(); + // Captured before the turn is claimed so the baseline reflects + // the worktree the agent is about to act on. An unreadable + // worktree records a sentinel that no commit can match, so the + // session fails closed rather than authorizing a later merge. + const repairBaseCommit = await this._resolveLocalCommit(session) ?? AGENT_MERGE_UNKNOWN_COMMIT; const context: IAgentMergeTurnContext = { session, turnId, @@ -511,6 +543,7 @@ export class AgentMergeController extends Disposable { lastPromptAt: new Date().toISOString(), repeatedPromptCount, totalPromptCount, + repairBaseCommit, }); return; } @@ -750,6 +783,17 @@ export class AgentMergeController extends Disposable { target: { ...agentMerge.target, commentWatermark: context.commentWatermark }, }); } + + // Decided here rather than on the next evaluation because the local + // commit is authoritative the instant the agent makes it, while the + // pull request's published head lags behind the push. Re-read the state + // so an advanced watermark is not written back stale. + const current = readAgentMergeSessionState(this._stateManager.getSessionState(session)?.config?.values) ?? agentMerge; + if (await this._demoteMergePullRequestIfChanged(session, current, this._getConfiguration(current))) { + // The config write re-enters evaluation with the demoted value. + return; + } + try { await runtime.subscription.value.refresh(undefined, runtime.cancellation.token, { authoritative: true }); } catch (error) { @@ -758,6 +802,89 @@ export class AgentMergeController extends Disposable { this._schedule(session, 0); } + /** + * Resolves the session worktree's current commit, or `undefined` when the + * worktree cannot be read. Callers treat `undefined` as "changed" so an + * unreadable worktree can never authorize an automatic merge. + */ + private async _resolveLocalCommit(session: string): Promise { + try { + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; + if (!workingDirectory) { + return undefined; + } + const repositoryRoot = await this._gitService.getRepositoryRoot(URI.parse(workingDirectory)); + return repositoryRoot ? await this._gitService.revParse(repositoryRoot, 'HEAD') : undefined; + } catch (error) { + this._logService.warn(`[AgentMergeController] Failed to resolve the local commit: session=${session}`, error); + return undefined; + } + } + + /** + * Turns automatic merging off once a repair turn has produced work, for + * sessions that only authorized merging while the pull request is unchanged. + * + * The chosen value is rewritten rather than merely gated so the dropdown + * always shows what will actually happen, and so re-selecting the option + * establishes a fresh baseline. Returns whether the value was demoted. + */ + private async _demoteMergePullRequestIfChanged( + session: string, + agentMerge: AgentMergeSessionState, + configuration: AgentMergeConfiguration, + ): Promise { + if (configuration.mergePullRequest !== 'ifUnchanged' || agentMerge.repairBaseCommit === undefined) { + return false; + } + const currentCommit = await this._resolveLocalCommit(session); + if (!shouldStopMergingAfterAgentChanges(configuration, agentMerge, currentCommit)) { + return false; + } + this._logService.info(`[AgentMergeController] Turning automatic merge off because a repair turn changed the worktree: session=${session}, repairBaseCommit=${agentMerge.repairBaseCommit}, currentCommit=${currentCommit ?? 'unresolved'}`); + this._postNotice(session, AgentSystemNotificationKind.AgentMergeDisabled, agentMergeMergePullRequestDemotedNotice()); + this._configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { + enabled: agentMerge.enabled, + overrides: { ...agentMerge.overrides, mergePullRequest: 'never' }, + }, + // Dropping the baseline is what makes re-selecting the option start + // fresh: without it the next evaluation would demote again against + // this very same commit. + [SessionConfigKey.AgentMergeController]: toControllerState(agentMerge, { repairBaseCommit: undefined }), + }); + return true; + } + + /** + * Drops the repair baseline when the user selects "merge only while + * unchanged" afresh. + * + * The client writes only its own Agent Merge state, so a baseline recorded + * by an earlier repair turn survives the selection. Without this reset the + * next evaluation would immediately demote the choice back to `never` + * against work the user has already seen, and the option could never be + * turned back on. Returns whether a reset was written. + */ + private _resetRepairBaselineOnReselection( + session: string, + previous: AgentMergeSessionState | undefined, + current: AgentMergeSessionState | undefined, + ): boolean { + if (!current || current.repairBaseCommit === undefined) { + return false; + } + if (this._getConfiguration(current).mergePullRequest !== 'ifUnchanged') { + return false; + } + if (previous && this._getConfiguration(previous).mergePullRequest === 'ifUnchanged') { + return false; + } + this._logService.info(`[AgentMergeController] Starting a fresh unchanged-merge baseline after the choice was reselected: session=${session}`); + this._updateAgentMergeState(session, current, { repairBaseCommit: undefined }); + return true; + } + private _updateAgentMergeState(session: string, current: AgentMergeSessionState, patch: Partial): void { this._configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMergeController]: toControllerState(current, patch), @@ -960,14 +1087,6 @@ function shouldRunFingerprint(state: AgentMergeSessionState, fingerprint: string return !Number.isFinite(lastPromptAt) || Date.now() - lastPromptAt >= backstopInterval; } -function resolveMergeMethod(configured: AgentMergeConfiguration['mergeMethod'], allowed: readonly ('MERGE' | 'SQUASH' | 'REBASE')[]): 'MERGE' | 'SQUASH' | 'REBASE' | undefined { - if (configured !== 'auto') { - const method = configured.toUpperCase() as 'MERGE' | 'SQUASH' | 'REBASE'; - return allowed.includes(method) ? method : undefined; - } - return (['SQUASH', 'MERGE', 'REBASE'] as const).find(method => allowed.includes(method)); -} - function toControllerState(current: AgentMergeSessionState, patch: Partial): Omit { const next = { ...current, ...patch }; return { @@ -977,6 +1096,7 @@ function toControllerState(current: AgentMergeSessionState, patch: Partial this._syncAgentMergeIndex(URI.parse(session), previous, current))); let externalSessionsMode = this._getExternalSessionsMode(); - this._lastMigrateLegacyEnabled = this._isMigrateLegacyEnabled(); let agentMergeEnabled = this._isAgentMergeEnabled(); this._register(this._configurationService.onDidRootConfigChange(() => { const nextMode = this._getExternalSessionsMode(); @@ -733,7 +732,6 @@ export class AgentService extends Disposable implements IAgentService { .catch(err => this._logService.warn('[AgentService] Failed to restore Agent-Merge-enabled sessions', err)); } } - this._onMigrateLegacySettingChanged(); })); this._register(this._gitHubEndpointService.onDidChange(() => { this._stateManager.emitAuthRequired({ @@ -2406,11 +2404,14 @@ export class AgentService extends Disposable implements IAgentService { now = Date.now(), recentSessionKeys?: ReadonlySet, ): boolean { - // While migration is off, un-adopted adoptable-legacy sessions belong to the extension-host provider — exclude so a refresh cannot re-surface an unopenable row. - if (readSessionEhcliAdoptable(session._meta) && !this._isMigrateLegacyEnabled()) { + // An un-adopted adoptable-legacy session belongs to the extension-host provider + // until it is opened (and thereby adopted): the agent host never surfaces it, so it + // keeps showing under the legacy Copilot CLI provider. Adoption clears this marker + // (and sets `ehcliAdopted`), so an adopted session surfaces normally below. + if (readSessionEhcliAdoptable(session._meta)) { return false; } - if (!readSessionExternal(session._meta) || readSessionEhcliAdoptable(session._meta)) { + if (!readSessionExternal(session._meta)) { return true; } switch (mode) { @@ -2433,14 +2434,12 @@ export class AgentService extends Disposable implements IAgentService { * external session under `mode`, letting {@link _computeSessions} drop them * on the registry's `external` flag alone. * - * `None` is the only mode that rejects external sessions outright. The - * adoptable-legacy exemption is the single way one could still be visible, - * and while migration is off that marker forces exclusion as well — which - * matters because the marker is only discoverable from the provider - * metadata read this skip avoids. + * `None` is the only mode that rejects external sessions outright. Adoptable-legacy + * rows are always excluded now (they belong to the extension host until opened), so + * they can never keep an external session visible. */ private _hidesAllExternalSessions(mode: AgentHostExternalSessionsMode): boolean { - return mode === AgentHostExternalSessionsMode.None && !this._isMigrateLegacyEnabled(); + return mode === AgentHostExternalSessionsMode.None; } /** @@ -2469,84 +2468,64 @@ export class AgentService extends Disposable implements IAgentService { private readonly _announcedSurfacedKeys = new Set(); private readonly _broadcastExternalSessions = new Set(); private _sessionListReconciliation = Promise.resolve(); + /** Coalescing state for storm-driven (mode-agnostic) reconciliations. */ + private _reconciliationInFlight = false; + private _reconciliationDirty = false; - /** Tracks the migrate-legacy setting so the config listener acts only on transitions. */ - private _lastMigrateLegacyEnabled = false; - /** Adoptable keys retracted in this window; re-enabling also recovers earlier ones from the catalog. */ - private readonly _retractedAdoptableKeys = new Set(); + private _migrateLegacyEnabledSnapshot: boolean | undefined; - /** Serializes adoptable re-surfacing, kept off the external-reconciliation chain. */ - private _adoptableResurface: Promise = Promise.resolve(); + /** + * Freezes the migrate-legacy gate at host startup. The host is a shared + * process that survives window reloads, so a setting toggled without a full + * restart is live-propagated into its config; capturing the value once at + * bootstrap (before any such live change can arrive) is what makes the + * "requires a restart" contract hold. Called once from {@link agentHostBootstrap}; + * tests never call it and keep the lazy first-read fallback. + */ + primeMigrateLegacyGate(): void { + this._isMigrateLegacyEnabled(); + } private _isMigrateLegacyEnabled(): boolean { - return this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; + // Frozen at host startup (see `primeMigrateLegacyGate`): the gate never + // flips mid-process, so there is no live discovery re-run / retract storm + // to reconcile. + return this._migrateLegacyEnabledSnapshot ??= this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; } private _isAgentMergeEnabled(): boolean { return this._configurationService.getRootValue(agentMergeRootConfigSchema, AgentMergeConfigKey.Enabled) === true; } - /** Retracts un-opened adoptable-legacy entries when migration is turned off (deletes no data). */ - private _onMigrateLegacySettingChanged(): void { - const enabled = this._isMigrateLegacyEnabled(); - if (enabled === this._lastMigrateLegacyEnabled) { - return; - } - this._lastMigrateLegacyEnabled = enabled; - if (enabled) { - // Discovery skips chats already in the registry, so it cannot re-announce - // what disabling retracted. `_retractedAdoptableKeys` is process-local, so - // after a restart the catalog is the only record of them. Runs on its own - // chain: this scan on `_sessionListReconciliation` would stall external - // session reconciliation behind it. - this._adoptableResurface = this._adoptableResurface - .then(() => this._resurfaceAdoptableSessions()) - .catch(error => this._logService.warn('[AgentService] Re-surfacing adoptable legacy sessions failed', error)); + private _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode): void { + // A mode change carries specific previous-mode state and is rare/user-driven, + // so run it directly rather than collapsing it into a coalesced pass. + if (previousMode !== undefined) { + this._sessionListReconciliation = this._sessionListReconciliation + .then(() => this._reconcileExternalSessions(previousMode)) + .catch(error => this._logService.warn('[AgentService] External session reconciliation failed', error)); return; } - for (const key of [...this._announcedSurfacedKeys]) { - if (this._stateManager.getSessionState(key)) { - continue; // already adopted / restored — keep it - } - if (!readSessionEhcliAdoptable(this._stateManager.getSurfacedSessionSummary(key)?._meta)) { - continue; // only retract adoptable-legacy entries, never native / external ones - } - this._announcedSurfacedKeys.delete(key); - this._broadcastExternalSessions.delete(key); - this._retractedAdoptableKeys.add(key); - this._stateManager.retractSurfacedSession(key); - } - } - - /** - * A key is forgotten only once it is confirmed surfaced, so a failed listing — - * or migration being disabled again before this runs — leaves it restorable. - * Covers both what this process retracted and what the catalog still reports as - * adoptable, so rows retracted before a restart are recovered too. - */ - private async _resurfaceAdoptableSessions(): Promise { - if (!this._isMigrateLegacyEnabled()) { + // Storm-driven reconciliations (discovery batches, adoptions, prune, summary + // changes) all recompute the same current-state list, so a burst can collapse + // to a single trailing run instead of one O(catalog) pass per trigger: while + // one is in flight, further requests just mark it dirty to re-run once after. + if (this._reconciliationInFlight) { + this._reconciliationDirty = true; return; } - for (const metadata of await this.listSessions()) { - const key = metadata.session.toString(); - if (!this._retractedAdoptableKeys.has(key) && !readSessionEhcliAdoptable(metadata._meta)) { - continue; - } - const provider = AgentSession.provider(metadata.session); - if (provider) { - await this._announceSurfacedSession(metadata, provider); - } - if (this._announcedSurfacedKeys.has(key) || this._stateManager.getSessionState(key)) { - this._retractedAdoptableKeys.delete(key); - } - } - } - - private _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode): void { + this._reconciliationInFlight = true; + this._reconciliationDirty = false; this._sessionListReconciliation = this._sessionListReconciliation - .then(() => this._reconcileExternalSessions(previousMode)) - .catch(error => this._logService.warn('[AgentService] External session reconciliation failed', error)); + .then(() => this._reconcileExternalSessions()) + .catch(error => this._logService.warn('[AgentService] External session reconciliation failed', error)) + .finally(() => { + this._reconciliationInFlight = false; + if (this._reconciliationDirty) { + this._reconciliationDirty = false; + this._queueSessionListReconciliation(); + } + }); } private async _reconcileExternalSessions(previousMode?: AgentHostExternalSessionsMode): Promise { @@ -2652,7 +2631,7 @@ export class AgentService extends Disposable implements IAgentService { this._announcedSurfacedKeys.delete(key); return; } - // The migrate setting may have flipped off during the await above; re-check so an adoptable-legacy session is never surfaced while migration is off. + // The external-sessions mode may have changed during the await above; re-check so a row that is no longer visible is not surfaced. if (!this._shouldIncludeSession(meta)) { this._announcedSurfacedKeys.delete(key); return; @@ -3606,8 +3585,8 @@ export class AgentService extends Disposable implements IAgentService { void this._gitStateService.refreshSessionGitState(sessionKey, e.workingDirectories?.[0]); // If a client subscribed to this session's uncommitted changeset - // before the working directory was known, the coordinator drains - // the deferred refresh now that the working directory is set. + // before the working directory was known, recompute the current + // subscriptions now that the working directory is set. this._changesetCoordinator.onSessionMaterialized(sessionKey); } @@ -5037,8 +5016,8 @@ export class AgentService extends Disposable implements IAgentService { let external = registeredSession?.external ?? false; this._logService.trace(`[AgentService] restore: catalog and registry resolved for ${sessionStr} (registered=${!!registeredSession}, external=${external})`); - // Adopt-on-open for a surfaced un-adopted legacy Copilot CLI session, strictly gated on the live migrate setting (a no-op for native / already-adopted sessions). - const migrateLegacyEnabled = this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; + // Adopt-on-open for a surfaced un-adopted legacy Copilot CLI session, strictly gated on the startup-frozen migrate setting (a no-op for native / already-adopted sessions). + const migrateLegacyEnabled = this._isMigrateLegacyEnabled(); const migrationStartTime = Date.now(); let adoption: IAgentChatAdoptionResult = { adopted: false, eligible: false }; if (!external && migrateLegacyEnabled && agent.ensureChatAdopted) { @@ -5059,7 +5038,13 @@ export class AgentService extends Disposable implements IAgentService { // unknown sessions, so without this an external chat (e.g. one the GitHub app // created, hidden while `showExternalSessions` is `none`) would be // materialized here and thereby claimed away from the extension host's list. - if (!registeredSession && migrateLegacyEnabled && agent.ensureChatAdopted && !adoption.eligible && !adoption.native) { + // Refuse an unregistered chat that is neither an adoptable legacy chat nor + // one that already has Agent Host metadata. This is independent of the + // migrate gate: while migration is frozen off, adoption is never attempted + // above, so a client that still probes the twin (e.g. the setting was toggled + // on without the required window reload) is cleanly declined and opens the + // legacy session unmigrated, rather than materializing an unowned session. + if (!registeredSession && agent.ensureChatAdopted && !adoption.eligible && !adoption.native) { // The registry was read before the deferred catalog wait, so absence is only authoritative once that catalog is readable (#331721). await awaitCatalogReadable(); registeredSession = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); @@ -5089,6 +5074,18 @@ export class AgentService extends Disposable implements IAgentService { ); registeredAfterAdoption = true; this._invalidateSessionList(); + // Surface-before-retract: adoption already wrote `session.db`, which is + // what makes the extension-host list drop this chat. Announce the adopted + // row now — before the slower `_restoreSessionState` — so the session is + // never absent from both lists during the handoff. + try { + const surfaced = await this._registeredSessionMetadata(agent, session, /* external */ false); + if (surfaced) { + await this._announceSurfacedSession(surfaced, agent.id); + } + } catch (err) { + this._logService.warn(`[AgentService] Failed to surface adopted session ${sessionStr} before restore`, err); + } } const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', registeredSession, awaitCatalogReadable, !!registeredSession, adoption.worktree); await this._restoreAnnotations(session); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 797e29a59e4aa3..573e0ad7666979 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -966,21 +966,8 @@ export class CopilotAgent extends Disposable implements IAgent { ); })); this._register(this._configurationService.onDidRootConfigChange(() => { - const enabled = this._isMigrateLegacyCopilotCliEnabled(); - if (enabled !== this._lastMigrateLegacyEnabled) { - this._lastMigrateLegacyEnabled = enabled; - if (enabled) { - // Only the adoptable legacy extension-host half of discovery is - // gated on this setting, so a fresh pass is needed to surface it. - void this._runCopilotChatDiscovery(); - } else { - for (const [chat, discovered] of this._discoveredChats) { - if (!discovered.external) { - this._discoveredChats.delete(chat); - } - } - } - } + // The migrate-legacy gate is snapshotted at startup (a change requires a + // window reload), so nothing reacts to it here; only BYOK models refresh. this._refreshByokModels(); })); @@ -1021,7 +1008,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private _lastStartupConfig: CopilotAgentStartupConfig; - private _lastMigrateLegacyEnabled: boolean = this._isMigrateLegacyCopilotCliEnabled(); + private _migrateLegacyEnabledSnapshot: boolean | undefined; private _isSessionSyncEnabled(): boolean { return this._configurationService.getRootValue(platformRootSchema, AgentHostSessionSyncEnabledConfigKey) === true; @@ -1056,7 +1043,9 @@ export class CopilotAgent extends Disposable implements IAgent { } private _isMigrateLegacyCopilotCliEnabled(): boolean { - return this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; + // Frozen at startup: changing the setting requires a window reload, so a + // single discovery pass sees one stable gate value for the process lifetime. + return this._migrateLegacyEnabledSnapshot ??= this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; } private _readClientStartupConfig(): CopilotAgentStartupConfig { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 066f8ce816b237..c1153265d72a06 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -748,6 +748,11 @@ export class CopilotAgentSession extends Disposable { private readonly _rootTurnIdBySubagentToolCallId = new Map(); private readonly _subagentDirectUsageByToolCallId = new Map(); private readonly _lastSubagentUsageByToolCallId = new Map(); + /** + * Auto's routing decision per subagent. Keyed by tool call rather than turn: a + * subagent outlives the root turn that spawned it when steering mints a new one. + */ + private readonly _autoModeResolvedByToolCallId = new Map>(); private readonly _activeSubagentAgentIds = new Set(); private readonly _unroutableSubagentToolCallIds = new Set(); private readonly _autoApprovals = new Map(); @@ -1336,6 +1341,7 @@ export class CopilotAgentSession extends Disposable { this._rootTurnIdBySubagentToolCallId.delete(parentToolCallId); this._subagentDirectUsageByToolCallId.delete(parentToolCallId); this._lastSubagentUsageByToolCallId.delete(parentToolCallId); + this._autoModeResolvedByToolCallId.delete(parentToolCallId); return; } this._onDidSessionProgress.fire({ @@ -1346,6 +1352,7 @@ export class CopilotAgentSession extends Disposable { this._rootTurnIdBySubagentToolCallId.delete(parentToolCallId); this._subagentDirectUsageByToolCallId.delete(parentToolCallId); this._lastSubagentUsageByToolCallId.delete(parentToolCallId); + this._autoModeResolvedByToolCallId.delete(parentToolCallId); } private _directUsageFor(parentToolCallId: string | undefined, create: boolean): DirectUsageAccumulator | undefined { @@ -5020,7 +5027,9 @@ export class CopilotAgentSession extends Disposable { this._lastSeenModelId = e.data.chosenModel; const turnId = this._turnId; this._logService.info(`[Copilot:${sessionId}] Auto mode resolved to ${e.data.chosenModel}${e.data.reasoningBucket ? ` (${e.data.reasoningBucket})` : ''}`); - if (!turnId) { + // A subagent's routing is recorded against its tool call, so unlike the + // parent's it does not need an active root turn to attach to. + if (!turnId && !e.agentId) { return; } if (!e.agentId) { @@ -5044,6 +5053,18 @@ export class CopilotAgentSession extends Disposable { hasImage: e.data.hasImage, }); } + // A subagent routes its own model calls, so record the decision against the + // subagent rather than letting it describe the parent turn. Auto routes + // before the model call, so the usage event that follows picks this up. + if (e.agentId) { + const subagentToolCallId = this._parentToolCallIdForSubagentEvent(e); + if (!subagentToolCallId) { + this._logService.warn(`[Copilot:${sessionId}] Unable to attribute Auto mode resolution for unknown subagent agentId=${e.agentId}; leaving the parent turn's routing untouched`); + } else { + this._autoModeResolvedByToolCallId.set(subagentToolCallId, e.data); + } + return; + } autoModeResolved = { turnId, data: e.data }; const priorUsage = lastParentUsageTurnId === turnId ? lastParentUsage : undefined; const usage: UsageInfo = { @@ -5139,6 +5160,11 @@ export class CopilotAgentSession extends Disposable { } if (isParentScope && autoModeResolved?.turnId === this._turnId) { metadata.autoModeResolved = autoModeResolved.data; + } else if (!isParentScope && directOwnerToolCallId) { + const subagentAutoMode = this._autoModeResolvedByToolCallId.get(directOwnerToolCallId); + if (subagentAutoMode) { + metadata.autoModeResolved = subagentAutoMode; + } } if (scopedCopilotUsage) { metadata.copilotUsage = scopedCopilotUsage; @@ -5340,6 +5366,9 @@ export class CopilotAgentSession extends Disposable { if (parentToolCallId && directUsage) { const priorUsage = this._lastSubagentUsageByToolCallId.get(parentToolCallId); const metadata: UsageInfoMeta = { ...(priorUsage?._meta ?? {}) }; + // A compaction can be the first usage this subagent reports, so carry + // its routing across rather than describing it by a concrete model. + metadata.autoModeResolved ??= this._autoModeResolvedByToolCallId.get(parentToolCallId); if (directUsage.tokenTotals) { metadata.directTurnTokenTotals = directUsage.tokenTotals; } diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 8b7e9dffff0d3a..0cb4c8e162186c 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -67,6 +67,7 @@ function isSyntheticUserMessage(event: SessionEvent): boolean { function stripPromptScaffolding(text: string): string { const withoutAux = text .replace(/[\s\S]*?<\/reminder>\s*/g, '') + .replace(/[\s\S]*?<\/system[-_]reminder>\s*/g, '') .replace(/[\s\S]*?<\/attachments>\s*/g, '') .replace(/[\s\S]*?<\/context>\s*/g, '') .replace(/[\s\S]*?<\/current_datetime>\s*/g, '') @@ -411,6 +412,8 @@ export async function mapSessionEvents( let rootAssistantTurnActive = false; let rootRequestActive = false; let pendingAutoModeResolved: Extract['data'] | undefined; + /** Same, per subagent tool call: applied when that subagent's turn is built. */ + const pendingSubagentAutoModeResolved = new Map['data']>(); /** Envelope timestamp of the event currently being processed. */ let currentEventTimestamp: string | undefined; @@ -458,6 +461,11 @@ export async function mapSessionEvents( if (!subagentTurnStates.has(parentToolCallId)) { subagentTurnStates.set(parentToolCallId, TurnState.Complete); } + const autoModeResolved = pendingSubagentAutoModeResolved.get(parentToolCallId); + if (autoModeResolved) { + builder.usage = { model: autoModeResolved.chosenModel, _meta: { autoModeResolved } }; + pendingSubagentAutoModeResolved.delete(parentToolCallId); + } } touch(builder); return builder; @@ -518,7 +526,13 @@ export async function mapSessionEvents( break; } case 'session.auto_mode_resolved': { - if (!e.agentId) { + // A subagent routes its own model calls, so the decision belongs to the + // subagent's turn. Both are held until the turn that uses them starts, + // so routing never pulls a turn's start time earlier than its content. + const parentToolCallId = resolveParentToolCallId(e.agentId, undefined); + if (parentToolCallId) { + pendingSubagentAutoModeResolved.set(parentToolCallId, e.data); + } else if (!e.agentId) { pendingAutoModeResolved = e.data; } break; diff --git a/src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts b/src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts index 39ef386d9fb566..5f5d101b8db047 100644 --- a/src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts +++ b/src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts @@ -40,10 +40,12 @@ export interface IAgentBranchNameGeneratorRequest { export const IAgentBranchNameGenerator = createDecorator('agentBranchNameGenerator'); export interface IAgentBranchNameGenerator { + readonly _serviceBrand: undefined; generateBranchName(request: IAgentBranchNameGeneratorRequest): Promise; } export class AgentBranchNameGenerator implements IAgentBranchNameGenerator { + declare readonly _serviceBrand: undefined; constructor( @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index 33ddc48ef5b4d7..98a8d3b6f72ad5 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -903,7 +903,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI } try { - await this._gitService.removeWorktree(repositoryRoot, worktreePath); + await this._gitService.removeWorktree(repositoryRoot, worktreePath, { force: true }); this._logService.info(`[${this._logLabel}:${sessionId}] Removed worktree '${worktreePath.fsPath}' on archive`); this._materializedWorktrees.delete(sessionId); } catch (error) { diff --git a/src/vs/platform/agentHost/test/common/agentMerge.test.ts b/src/vs/platform/agentHost/test/common/agentMerge.test.ts index 4e525609058100..39d47f5baa67c3 100644 --- a/src/vs/platform/agentHost/test/common/agentMerge.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMerge.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AgentMergeConfiguration, evaluateAgentMerge, getNonMergeSessionConfigValues, readAgentMergeSessionState } from '../../common/agentMerge.js'; +import { AgentMergeConfiguration, AGENT_MERGE_UNKNOWN_COMMIT, evaluateAgentMerge, getNonMergeSessionConfigValues, readAgentMergeSessionState, shouldStopMergingAfterAgentChanges } from '../../common/agentMerge.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { PullRequestSnapshot } from '../../../github/common/githubPullRequestService.js'; @@ -16,7 +16,7 @@ suite('Agent Merge gate', () => { addressReviews: true, fixCI: true, resolveConflicts: true, - mergePullRequest: true, + mergePullRequest: 'always', mergeMethod: 'auto', replyAttribution: true, }; @@ -192,6 +192,64 @@ suite('Agent Merge gate', () => { }); }); + test('reads the merge choice as an enum, migrating the retired boolean form', () => { + const overridesFor = (mergePullRequest: unknown) => readAgentMergeSessionState({ + [SessionConfigKey.AgentMerge]: { enabled: true, overrides: { mergePullRequest } }, + })?.overrides; + + assert.deepStrictEqual({ + legacyTrue: overridesFor(true), + legacyFalse: overridesFor(false), + ifUnchanged: overridesFor('ifUnchanged'), + unknown: overridesFor('bogus'), + }, { + legacyTrue: { mergePullRequest: 'always' }, + legacyFalse: { mergePullRequest: 'never' }, + ifUnchanged: { mergePullRequest: 'ifUnchanged' }, + unknown: undefined, + }); + }); + + test('only merges automatically when the merge choice is not "never"', () => { + const gateFor = (mergePullRequest: AgentMergeConfiguration['mergePullRequest']) => + evaluateAgentMerge(readySnapshot(), { ...configuration, mergePullRequest }, '2026-08-02T00:00:00.000Z').kind; + + assert.deepStrictEqual({ + always: gateFor('always'), + ifUnchanged: gateFor('ifUnchanged'), + never: gateFor('never'), + }, { + always: 'merge', + ifUnchanged: 'merge', + never: 'noWork', + }); + }); + + test('stops merging automatically once a repair turn changes the worktree', () => { + const ifUnchanged: AgentMergeConfiguration = { ...configuration, mergePullRequest: 'ifUnchanged' }; + const enabled = { enabled: true }; + + assert.deepStrictEqual({ + noRepairYet: shouldStopMergingAfterAgentChanges(ifUnchanged, enabled, 'sha1'), + repairCommittedNothing: shouldStopMergingAfterAgentChanges(ifUnchanged, { ...enabled, repairBaseCommit: 'sha1' }, 'sha1'), + repairCommitted: shouldStopMergingAfterAgentChanges(ifUnchanged, { ...enabled, repairBaseCommit: 'sha1' }, 'sha2'), + // Fails closed: an unreadable worktree after a repair turn, and an + // unreadable one when the baseline was taken, both count as changed. + worktreeUnreadable: shouldStopMergingAfterAgentChanges(ifUnchanged, { ...enabled, repairBaseCommit: 'sha1' }, undefined), + baselineUnknown: shouldStopMergingAfterAgentChanges(ifUnchanged, { ...enabled, repairBaseCommit: AGENT_MERGE_UNKNOWN_COMMIT }, 'sha1'), + always: shouldStopMergingAfterAgentChanges({ ...configuration, mergePullRequest: 'always' }, { ...enabled, repairBaseCommit: 'sha1' }, 'sha2'), + never: shouldStopMergingAfterAgentChanges({ ...configuration, mergePullRequest: 'never' }, { ...enabled, repairBaseCommit: 'sha1' }, 'sha2'), + }, { + noRepairYet: false, + repairCommittedNothing: false, + repairCommitted: true, + worktreeUnreadable: true, + baselineUnknown: true, + always: false, + never: false, + }); + }); + test('returns pre-merge picker values when merge-injected values are active', () => { const values = { [SessionConfigKey.AgentMerge]: { enabled: true }, diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 4207edea9d1c49..c7b4b5b9fa4f86 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -1003,22 +1003,22 @@ suite('AgentSubscriptionManager', () => { ref.dispose(); }); - test('preserves the exact authority-less automation catalogue channel', async () => { + test('uses the round-trippable automation catalogue URI', async () => { const mgr = createManager(async resource => { subscribedResources.push(resource.toString()); return { resource: resource.toString(), state: { automations: [] }, fromSeq: 0 }; }); - const ref = mgr.getSubscriptionByChannel(StateComponents.AutomationCatalog, AUTOMATION_CATALOG_URI, 'AutomationHolder'); + const ref = mgr.getSubscription(StateComponents.AutomationCatalog, URI.parse(AUTOMATION_CATALOG_URI), 'AutomationHolder'); await Event.toPromise(ref.object.onDidChange); assert.deepStrictEqual({ subscribedResources, - channels: mgr.currentSubscriptionChannels(), - activeChannel: mgr.getActiveSubscriptions()[0].channel, + resources: mgr.currentSubscriptionUris().map(resource => resource.toString()), + activeResource: mgr.getActiveSubscriptions()[0].resource.toString(), }, { subscribedResources: [AUTOMATION_CATALOG_URI], - channels: [AUTOMATION_CATALOG_URI], - activeChannel: AUTOMATION_CATALOG_URI, + resources: [AUTOMATION_CATALOG_URI], + activeResource: AUTOMATION_CATALOG_URI, }); ref.dispose(); @@ -1262,30 +1262,30 @@ suite('AgentSubscriptionManager', () => { mgr.dispatchOptimistic(sessionUri, { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///ws2' }); - mgr.markSubscriptionsMissing([sessionUri]); + mgr.markSubscriptionsMissing([URI.parse(sessionUri)]); assert.ok(ref.object.value instanceof Error); assert.deepStrictEqual(mgr.getPendingActions(), []); ref.dispose(); }); - test('markSubscriptionsMissing preserves exact protocol channels', async () => { + test('markSubscriptionsMissing handles the Automation catalogue URI', async () => { const mgr = createManager(async resource => ({ resource: resource.toString(), state: { automations: [] }, fromSeq: 0, })); - const ref = mgr.getSubscriptionByChannel(StateComponents.AutomationCatalog, AUTOMATION_CATALOG_URI, 'test'); + const ref = mgr.getSubscription(StateComponents.AutomationCatalog, URI.parse(AUTOMATION_CATALOG_URI), 'test'); await Event.toPromise(ref.object.onDidChange); - mgr.markSubscriptionsMissing([AUTOMATION_CATALOG_URI]); + mgr.markSubscriptionsMissing([URI.parse(AUTOMATION_CATALOG_URI)]); assert.deepStrictEqual({ valueIsError: ref.object.value instanceof Error, - channel: mgr.getActiveSubscriptions()[0].channel, + resource: mgr.getActiveSubscriptions()[0].resource.toString(), }, { valueIsError: true, - channel: AUTOMATION_CATALOG_URI, + resource: AUTOMATION_CATALOG_URI, }); ref.dispose(); }); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 48b13c83f36548..5e043dc1af549a 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -350,6 +350,7 @@ export function createNoopGitService(): import('../../common/agentHostGitService addExistingWorktree: async () => { }, removeWorktree: async () => { }, branchExists: async () => false, + createBranch: async () => { }, hasUncommittedChanges: async () => false, commitAll: async () => { }, mergeBranch: async () => '', @@ -398,7 +399,6 @@ export function createNoopChangesetService(): import('../../common/agentHostChan refreshSessionChangeset: () => { }, onWorkingDirectoryAvailable: () => { }, recomputeSubscribedChangesets: () => { }, - onSessionDisposed: () => { }, computeTurnChangeset: async session => session, computeCompareTurnsChangeset: async session => session, computeUncommittedChangeset: async session => session, diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index bd42ae5bfcc7c1..602190a6bf14df 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -2500,13 +2500,13 @@ suite('AgentHostProtocolClient', () => { client.dispose(); }); - test('marks an exact-channel subscription missing when restore fails', async function () { + test('marks an Automation catalogue subscription missing when restore fails', async function () { this.timeout(10_000); const { client, transports } = createFactoryClient(); const connectPromise = client.connect(); await completeHandshake(transports[0], connectPromise); - const catalogRef = client.getSubscriptionByChannel(StateComponents.AutomationCatalog, AUTOMATION_CATALOG_URI, 'test'); + const catalogRef = client.getSubscription(StateComponents.AutomationCatalog, URI.parse(AUTOMATION_CATALOG_URI), 'test'); const initialSubscribe = await waitForRequest(transports[0], 'subscribe'); transports[0].fireMessage({ jsonrpc: '2.0', id: initialSubscribe.id, diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index a6b09a84c213f5..35bbf6cfe1944b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -68,7 +68,7 @@ suite('ChangesetSessionCoordinator', () => { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const logService = new NullLogService(); const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); - const subscriptions = new AgentHostChangesetSubscriptionService(); + const subscriptions = disposables.add(new AgentHostChangesetSubscriptionService()); const changesets = new TestChangesetService(subscriptions); const monitor = disposables.add(new TestFileMonitorService()); const gitService = gitServiceOverride ?? createGitService(root); @@ -220,7 +220,7 @@ suite('ChangesetSessionCoordinator', () => { }); }); - test('forwards session changeset refresh to the changeset service and drains pending work on materialization', async () => { + test('forwards session changeset refresh and recomputes current subscriptions on materialization', async () => { const session = AgentSession.uri('mock', 'session-1').toString(); const environment = createEnvironment(); createSession(environment.stateManager, session, undefined, false); @@ -947,7 +947,6 @@ class TestChangesetService implements IAgentHostChangesetService { readonly sessionRefreshes: string[] = []; readonly workingDirectoryAvailable: string[] = []; readonly recomputed: string[] = []; - readonly disposed: string[] = []; constructor(private readonly _subscriptions: IAgentHostChangesetSubscriptionService) { } @@ -991,9 +990,6 @@ class TestChangesetService implements IAgentHostChangesetService { } } } - onSessionDisposed(session: string): void { - this.disposed.push(session); - } async computeUncommittedChangeset(session: string): Promise { if (this._subscriptions.getSessionSubscriptions(session).has(URI.parse(buildUncommittedChangesetUri(session)).toString())) { this.uncommittedRefreshes.push(session); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index 83b14c748b0a9c..c84f0de91db0fc 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -184,7 +184,7 @@ suite('AgentHostChangesetOperationService', () => { return disposables.add(new AgentHostChangesetOperationService( stateManager, new TestGitStateService(), - new AgentHostChangesetSubscriptionService(), + disposables.add(new AgentHostChangesetSubscriptionService()), configurationService, )); } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 3c3c6877f46ada..5cf5dd3f399f91 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -50,6 +51,7 @@ function createSubscriptionService(...changesets: string[]): IAgentHostChangeset return { _serviceBrand: undefined, subscriptions, + onDidChangeSessionSubscriptions: Event.None, getSessionSubscriptions: () => subscriptions, addSubscription: (_session, changeset) => { subscriptions.add(changeset); }, removeSubscription: (_session, changeset) => { subscriptions.delete(changeset); }, @@ -628,7 +630,7 @@ suite.skip('AgentHostChangesetService', () => { }); }); - suite('deferred refresh (working directory unknown)', () => { + suite('materialization refresh (working directory unknown)', () => { function createDeferringService(subscriptions: Iterable = []): { service: AgentHostChangesetService; localStateManager: AgentHostStateManager; computes: string[]; subscriptions: Set } { const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); @@ -668,7 +670,7 @@ suite.skip('AgentHostChangesetService', () => { return sessionStr; } - test('refreshSessionChangeset / refreshBranchChangeset defer until the working directory is known, then drain the subscribed changesets', async () => { + test('refreshSessionChangeset / refreshBranchChangeset skip until the working directory is known, then recompute the subscribed changesets', async () => { const sessionStr = sessionUri.toString(); const { service, localStateManager, computes } = createDeferringService([ buildBranchChangesetUri(sessionStr), @@ -688,13 +690,13 @@ suite.skip('AgentHostChangesetService', () => { assert.deepStrictEqual(computes.sort(), ['session', 'session']); }); - test('computeUncommittedChangeset defers until the working directory is known, then drains', async () => { + test('computeUncommittedChangeset skips until the working directory is known, then recomputes', async () => { const sessionStr = sessionUri.toString(); const { service, localStateManager, computes } = createDeferringService([buildUncommittedChangesetUri(sessionStr)]); createSessionState(localStateManager, undefined); await service.computeUncommittedChangeset(sessionStr); - assert.deepStrictEqual(computes, [], 'uncommitted compute deferred while the working directory is unknown'); + assert.deepStrictEqual(computes, [], 'uncommitted compute skipped while the working directory is unknown'); const summary = localStateManager.getSessionSummary(sessionStr)!; localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); @@ -703,7 +705,7 @@ suite.skip('AgentHostChangesetService', () => { assert.deepStrictEqual(computes, ['uncommitted']); }); - test('a changeset unsubscribed before materialization is naturally skipped on drain', async () => { + test('a changeset unsubscribed before materialization is skipped', async () => { const sessionStr = sessionUri.toString(); const { service, localStateManager, computes, subscriptions } = createDeferringService([buildSessionChangesetUri(sessionStr)]); createSessionState(localStateManager, undefined); @@ -719,26 +721,6 @@ suite.skip('AgentHostChangesetService', () => { assert.deepStrictEqual(computes, []); }); - test('onSessionDisposed clears every pending refresh for the session', async () => { - const sessionStr = sessionUri.toString(); - const { service, localStateManager, computes } = createDeferringService([ - buildBranchChangesetUri(sessionStr), - buildSessionChangesetUri(sessionStr), - buildUncommittedChangesetUri(sessionStr), - ]); - createSessionState(localStateManager, undefined); - - service.refreshBranchChangeset(sessionStr); - service.refreshSessionChangeset(sessionStr); - await service.computeUncommittedChangeset(sessionStr); - service.onSessionDisposed(sessionStr); - - const summary = localStateManager.getSessionSummary(sessionStr)!; - localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); - service.onWorkingDirectoryAvailable(sessionStr); - await timeout(0); - assert.deepStrictEqual(computes, []); - }); }); suite('restorePersistedStaticChangesets', () => { @@ -1269,6 +1251,68 @@ suite.skip('AgentHostChangesetService', () => { }); }); +suite('AgentHostChangesetService - materialization refresh', () => { + + const disposables = new DisposableStore(); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('recomputes an uncommitted subscription added after worktree pending clears but before materialization', async () => { + const sessionStr = AgentSession.uri('mock', 'session-materialization').toString(); + const sourceDirectory = 'file:///repo/source'; + const worktreeDirectory = 'file:///repo/worktree'; + const computedWorkingDirectories: string[] = []; + const gitService = createNoopGitService(); + gitService.computeSessionFileDiffs = async workingDirectory => { + computedWorkingDirectories.push(workingDirectory.toString()); + return []; + }; + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const diffService = new TestDiffComputeService(); + class TestableChangesetService extends TestAgentHostChangesetService { + protected override _createDiffComputeService() { + return diffService; + } + } + const subscriptionService = createSubscriptionService(); + const service = disposables.add(new TestableChangesetService( + stateManager, + new NullLogService(), + createNullSessionDataService(), + gitService, + NULL_CHECKPOINT_SERVICE, + disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), + subscriptionService, + NULL_REVIEW_SERVICE, + NullTelemetryService, + )); + stateManager.createSession({ + resource: sessionStr, + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + workingDirectories: [sourceDirectory], + }, { emitNotification: false }); + + const uncommittedChangeset = buildUncommittedChangesetUri(sessionStr); + subscriptionService.addSubscription(sessionStr, uncommittedChangeset); + await service.computeUncommittedChangeset(sessionStr); + + const summary = stateManager.getSessionSummary(sessionStr)!; + stateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: [worktreeDirectory] }); + service.onWorkingDirectoryAvailable(sessionStr); + await timeout(0); + + assert.deepStrictEqual(computedWorkingDirectories, [sourceDirectory, worktreeDirectory]); + }); +}); + /** * A log service that records every warning/error message so multi-root tests * can assert the never-hard-fail path logged the expected per-folder failure. diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index a99716866c3155..3e2af826d5de99 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -47,6 +47,7 @@ class TestGitService implements IAgentHostGitService { async addExistingWorktree(): Promise { } async removeWorktree(): Promise { } async branchExists(): Promise { return false; } + async createBranch(): Promise { } async hasUncommittedChanges(): Promise { this.calls.push('hasUncommittedChanges'); return this.uncommitted; @@ -131,7 +132,6 @@ class TestChangesetService implements IAgentHostChangesetService { refreshSessionChangeset(session: string): void { this.calls.push(`refreshSession:${session}`); } onWorkingDirectoryAvailable(_session: string): void { } recomputeSubscribedChangesets(_session: string): void { } - onSessionDisposed(_session: string): void { } async computeUncommittedChangeset(session: string): Promise { this.calls.push(`computeUncommitted:${session}`); return `${session}/changeset/uncommitted`; } async computeTurnChangeset(_session: string, _turnId: string): Promise { return ''; } async computeCompareTurnsChangeset(_session: string, _originalTurnId: string, _modifiedTurnId: string): Promise { return ''; } diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts index 1187feaaf40506..970f822d8cc7dd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts @@ -7,12 +7,15 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { NullLogService } from '../../../log/common/log.js'; -import { buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind } from '../../common/changesetUri.js'; -import type { ISessionGitState } from '../../common/state/sessionState.js'; +import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind } from '../../common/changesetUri.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { SessionStatus, type ISessionGitState } from '../../common/state/sessionState.js'; import { AgentHostCommitOperationContribution } from '../../node/agentHostCommitOperationProvider.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; const sessionKey = 'agent:/session'; +const branchChangesetUri = buildBranchChangesetUri(sessionKey); +const sessionChangesetUri = buildSessionChangesetUri(sessionKey); const uncommittedChangesetUri = buildUncommittedChangesetUri(sessionKey); const gitStateWithUncommittedChanges: ISessionGitState = { @@ -23,9 +26,25 @@ const gitStateWithUncommittedChanges: ISessionGitState = { suite('AgentHostCommitOperationContribution', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(): AgentHostCommitOperationContribution { + function createContribution(isolation?: 'folder' | 'worktree'): AgentHostCommitOperationContribution { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + if (isolation) { + stateManager.createSession({ + resource: sessionKey, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + workingDirectories: ['file:///repo'], + }); + stateManager.setSessionConfig(sessionKey, { + schema: { type: 'object', properties: {} }, + values: { [SessionConfigKey.Isolation]: isolation }, + }); + } return disposables.add(new AgentHostCommitOperationContribution( - disposables.add(new AgentHostStateManager(new NullLogService())), + stateManager, disposables.add(new InstantiationService()), )); } @@ -46,17 +65,28 @@ suite('AgentHostCommitOperationContribution', () => { assert.deepStrictEqual(operations?.map(op => op.id), []); }); - test('advertises commit on the session changeset when there are uncommitted changes', () => { - const provider = createContribution(); + test('advertises commit on every folder session changeset when there are uncommitted changes', () => { + const provider = createContribution('folder'); - const operations = provider.getOperations({ sessionKey, changesetUri: buildSessionChangesetUri(sessionKey), changesetKind: ChangesetKind.Session, gitState: gitStateWithUncommittedChanges }); + const actual = [ + provider.getOperations({ sessionKey, changesetUri: branchChangesetUri, changesetKind: ChangesetKind.Branch, gitState: gitStateWithUncommittedChanges }), + provider.getOperations({ sessionKey, changesetUri: sessionChangesetUri, changesetKind: ChangesetKind.Session, gitState: gitStateWithUncommittedChanges }), + provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: gitStateWithUncommittedChanges }), + ]; - assert.deepStrictEqual(operations?.map(op => op.id), []); + assert.deepStrictEqual(actual.map(operations => operations.map(op => op.id)), [['commit'], ['commit'], ['commit']]); + }); + + test('does not advertise commit on a worktree branch changeset without a pull request', () => { + const provider = createContribution('worktree'); + + const operations = provider.getOperations({ sessionKey, changesetUri: branchChangesetUri, changesetKind: ChangesetKind.Branch, gitState: gitStateWithUncommittedChanges }); + + assert.deepStrictEqual(operations.map(op => op.id), []); }); test('advertises commit on the session changeset only for a pull request on the current branch', () => { const provider = createContribution(); - const sessionChangesetUri = buildSessionChangesetUri(sessionKey); const actual = [ provider.getOperations({ sessionKey, changesetUri: sessionChangesetUri, changesetKind: ChangesetKind.Session, gitState: gitStateWithUncommittedChanges, gitHubState: { pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], pullRequestBranchName: 'feature/test' } }), diff --git a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts index be4ad716ce7e2f..c19d4f2cd81759 100644 --- a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts @@ -9,9 +9,10 @@ import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; -import { NullLogService } from '../../../log/common/log.js'; +import { NullLogService, ILogService } from '../../../log/common/log.js'; import { IAgentHostChangesetOperationService, IChangesetOperationContribution } from '../../common/agentHostChangesetOperationService.js'; import { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; +import { IAgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; import { activateAgentHostContributions } from '../../node/agentHostContributions.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -49,6 +50,14 @@ const nullGitStateService: IAgentHostGitStateService = { async attachSessionGitHubPullRequest() { }, }; +const nullPullRequestStatusService: IAgentHostPullRequestStatusService = { + _serviceBrand: undefined, + onDidChangePullRequestStatus: Event.None, + getPullRequestStatus() { return undefined; }, + async refresh() { }, + dispose() { }, +}; + suite('AgentHostContributions', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -58,6 +67,8 @@ suite('AgentHostContributions', () => { [IAgentHostStateManager, disposables.add(new AgentHostStateManager(new NullLogService()))], [IAgentHostChangesetOperationService, changesetOperationService], [IAgentHostGitStateService, nullGitStateService], + [IAgentHostPullRequestStatusService, nullPullRequestStatusService], + [ILogService, new NullLogService()], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index ef0ee8715396df..3c68788aa2f0c7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -35,6 +35,7 @@ class TestGitService implements IAgentHostGitService { async addExistingWorktree(): Promise { } async removeWorktree(): Promise { } async branchExists(): Promise { return false; } + async createBranch(): Promise { } async hasUncommittedChanges(): Promise { return true; } async commitAll(): Promise { } async mergeBranch(): Promise { return ''; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 59e92517d4e58d..2ce0736749515e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -581,6 +581,25 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { assert.strictEqual(await svc!.branchExists(URI.file(dir), 'does-not-exist'), false); }); + (hasGit ? test : test.skip)('createBranch preserves dirty changes and leaves the base branch unchanged', async () => { + const dir = initRepo(); + const fs = await import('fs/promises'); + await fs.writeFile(join(dir, 'dirty.txt'), 'session changes'); + const baseHead = cp.execFileSync('git', ['rev-parse', 'main'], { cwd: dir, env, encoding: 'utf8' }).trim(); + + await svc!.createBranch(URI.file(dir), 'agents/session', { checkout: true }); + + const branchName = cp.execFileSync('git', ['branch', '--show-current'], { cwd: dir, env, encoding: 'utf8' }).trim(); + const currentHead = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, env, encoding: 'utf8' }).trim(); + const status = cp.execFileSync('git', ['status', '--porcelain'], { cwd: dir, env, encoding: 'utf8' }).trim(); + assert.deepStrictEqual({ branchName, currentHead, baseHead, status }, { + branchName: 'agents/session', + currentHead: baseHead, + baseHead, + status: '?? dirty.txt', + }); + }); + (hasGit ? test : test.skip)('hasUncommittedChanges flips with untracked and committed work', async () => { const dir = initRepo(); assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts new file mode 100644 index 00000000000000..480307d88f5e00 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts @@ -0,0 +1,239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { IGitHubService } from '../../../github/common/githubService.js'; +import type { PullRequestMergeOptions, PullRequestMergePreparation } from '../../../github/common/githubPullRequestMutationService.js'; +import type { IPullRequestMutations } from '../../../github/common/pullRequestMutationService.js'; +import { AgentMergeConfigKey } from '../../common/agentMerge.js'; +import { buildSessionChangesetUri } from '../../common/changesetUri.js'; +import type { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentHostPullRequestLifecycleOperationHandler, type PullRequestLifecycleAction } from '../../node/agentHostPullRequestLifecycleOperationHandler.js'; +import type { IAgentHostPullRequestStatus, IAgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; + +const sessionUri = 'copilot:/pr-lifecycle'; +const channel = buildSessionChangesetUri(sessionUri); +const pullRequestUrl = 'https://github.com/octo/repo/pull/7'; + +function status(overrides?: Partial): IAgentHostPullRequestStatus { + return { + pullRequestId: 'PR_1', + number: 7, + url: pullRequestUrl, + headSha: 'sha1', + state: 'open', + draft: false, + mergeReady: true, + viewerCanEnableAutoMerge: false, + autoMergeEnabled: false, + allowedMergeMethods: ['SQUASH'], + ...overrides, + }; +} + +function preparation(overrides?: { readonly mergeQueueRequired?: boolean; readonly allowedMergeMethods?: readonly ('MERGE' | 'SQUASH' | 'REBASE')[] }): PullRequestMergePreparation { + return { + token: 'token', + ref: { host: 'api.github.com', accountId: '1', owner: 'octo', repo: 'repo', number: 7 }, + expectedHeadSha: 'sha1', + resourceGeneration: 1, + headGeneration: 1, + snapshot: { + mergeability: { + status: 'ready', + complete: true, + value: { + mergeQueueRequired: overrides?.mergeQueueRequired ?? false, + allowedMergeMethods: overrides?.allowedMergeMethods ?? ['SQUASH'], + }, + }, + }, + } as unknown as PullRequestMergePreparation; +} + +interface IRecordedCalls { + readonly calls: string[]; + prepareMergeError?: Error; +} + +suite('AgentHostPullRequestLifecycleOperationHandler', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHandler( + action: PullRequestLifecycleAction, + options?: { + readonly status?: IAgentHostPullRequestStatus; + readonly preparation?: PullRequestMergePreparation; + readonly prepareMergeError?: Error; + readonly mergeMethod?: string; + }, + ): { readonly handler: AgentHostPullRequestLifecycleOperationHandler; readonly recorded: IRecordedCalls; readonly refreshes: string[] } { + const recorded: IRecordedCalls = { calls: [] }; + const refreshes: string[] = []; + const currentStatus = options?.status === null ? undefined : options?.status ?? status(); + + const mutations = new class extends mock() { + override async prepareMerge(): Promise { + recorded.calls.push('prepareMerge'); + if (options?.prepareMergeError) { + throw options.prepareMergeError; + } + return options?.preparation ?? preparation(); + } + override async merge(_p: PullRequestMergePreparation, mergeOptions: PullRequestMergeOptions) { + recorded.calls.push(`merge:${mergeOptions.method}`); + return { outcome: 'succeeded' as const }; + } + override async enqueue() { + recorded.calls.push('enqueue'); + return { outcome: 'succeeded' as const, mergeQueueEntryId: 'entry' }; + } + override async markReadyForReview(): Promise { recorded.calls.push('markReadyForReview'); } + override async enableAutoMerge(): Promise { recorded.calls.push('enableAutoMerge'); } + override async disableAutoMerge(): Promise { recorded.calls.push('disableAutoMerge'); } + }(); + + const gitHubService = new class extends mock() { + override readonly mutations = mutations; + override readonly credentials = { + onDidInvalidate: Event.None, + async getCredential() { + return { account: { host: 'api.github.com', accountId: '1' }, token: 't', generation: 1, signal: new AbortController().signal }; + }, + async resolveCredential(): Promise { throw new Error('not implemented'); }, + handleRequestError() { }, + }; + }(); + + const statusService: IAgentHostPullRequestStatusService = { + _serviceBrand: undefined, + onDidChangePullRequestStatus: Event.None, + getPullRequestStatus: () => currentStatus, + refresh: async (sessionKey: string) => { refreshes.push(sessionKey); }, + dispose: () => { }, + }; + + const configurationService = new class extends mock() { + override getRootValue(_schema: never, key: string) { + return (key === AgentMergeConfigKey.MergeMethod ? options?.mergeMethod : undefined) as never; + } + }(); + + const handler = new AgentHostPullRequestLifecycleOperationHandler( + action, + configurationService, + statusService, + gitHubService, + new NullLogService(), + ); + return { handler, recorded, refreshes }; + } + + function invoke(handler: AgentHostPullRequestLifecycleOperationHandler): Promise { + return handler.invoke({ channel, operationId: 'pr-merge' }, CancellationToken.None); + } + + test('merges directly with the repository-allowed method', async () => { + const { handler, recorded, refreshes } = createHandler('merge'); + disposables.add({ dispose: () => { } }); + + await invoke(handler); + + // The preparation gate runs before the merge, and the status is + // refreshed afterwards so the button bar re-derives. + assert.deepStrictEqual({ calls: recorded.calls, refreshed: refreshes.length }, { calls: ['prepareMerge', 'merge:SQUASH'], refreshed: 1 }); + }); + + test('enqueues instead of merging when the repository requires a merge queue', async () => { + const { handler, recorded } = createHandler('merge', { + preparation: preparation({ mergeQueueRequired: true }), + }); + + await invoke(handler); + + assert.deepStrictEqual(recorded.calls, ['prepareMerge', 'enqueue']); + }); + + test('honours the configured merge method and rejects one the repository forbids', async () => { + const allowed = createHandler('merge', { + mergeMethod: 'rebase', + preparation: preparation({ allowedMergeMethods: ['SQUASH', 'REBASE'] }), + }); + await invoke(allowed.handler); + + const forbidden = createHandler('merge', { + mergeMethod: 'rebase', + preparation: preparation({ allowedMergeMethods: ['SQUASH'] }), + }); + const rejection = await invoke(forbidden.handler).then(() => undefined, error => error as Error); + + assert.deepStrictEqual({ + allowed: allowed.recorded.calls, + forbidden: forbidden.recorded.calls, + // Never falls back to a method the repository did not allow. + rejected: rejection?.message, + refreshedAfterFailure: forbidden.refreshes.length, + }, { + allowed: ['prepareMerge', 'merge:REBASE'], + forbidden: ['prepareMerge'], + rejected: 'The repository does not allow the configured merge method.', + refreshedAfterFailure: 1, + }); + }); + + test('surfaces a stale preparation rather than merging', async () => { + const { handler, recorded, refreshes } = createHandler('merge', { + prepareMergeError: new Error('Merge preparation was invalidated by newer pull request state'), + }); + + const rejection = await invoke(handler).then(() => undefined, error => error as Error); + + assert.deepStrictEqual({ + calls: recorded.calls, + rejected: rejection?.message, + refreshed: refreshes.length, + }, { + calls: ['prepareMerge'], + rejected: 'Merge preparation was invalidated by newer pull request state', + refreshed: 1, + }); + }); + + test('refuses to act while the pull request state is unknown', async () => { + const { handler, recorded } = createHandler('merge', { status: null as never }); + + const rejection = await invoke(handler).then(() => undefined, error => error as Error); + + assert.deepStrictEqual({ calls: recorded.calls, rejected: rejection?.message }, { + calls: [], + rejected: 'The pull request state is not available yet.', + }); + }); + + test('runs the non-merge lifecycle actions through their own mutations', async () => { + const markReady = createHandler('mark-ready', { status: status({ draft: true }) }); + const enable = createHandler('enable-auto-merge', { status: status({ viewerCanEnableAutoMerge: true }) }); + const disable = createHandler('disable-auto-merge', { status: status({ autoMergeEnabled: true }) }); + + await invoke(markReady.handler); + await invoke(enable.handler); + await invoke(disable.handler); + + assert.deepStrictEqual({ + markReady: markReady.recorded.calls, + enable: enable.recorded.calls, + disable: disable.recorded.calls, + }, { + markReady: ['markReadyForReview'], + enable: ['enableAutoMerge'], + disable: ['disableAutoMerge'], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 005d8f058bb857..425d57871edd82 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -23,6 +23,7 @@ import type { ICopilotApiService, ICopilotApiServiceRequestOptions, ICopilotUtil import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; import type { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; +import type { IAgentBranchNameGenerator, IAgentBranchNameGeneratorRequest } from '../../node/shared/agentBranchNameGenerator.js'; class TestCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; @@ -50,6 +51,18 @@ class TestCopilotApiService implements ICopilotApiService { } } +class TestBranchNameGenerator implements IAgentBranchNameGenerator { + declare readonly _serviceBrand: undefined; + readonly requests: IAgentBranchNameGeneratorRequest[] = []; + + async generateBranchName(request: IAgentBranchNameGeneratorRequest): Promise { + this.requests.push(request); + const branchName = `${request.branchPrefix ?? ''}agents/add-retry-logic`; + await request.branchNameCollides?.(branchName); + return branchName; + } +} + class TestGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; @@ -59,6 +72,8 @@ class TestGitService implements IAgentHostGitService { uncommitted = false; upstream = false; gitState: ISessionGitState | undefined; + gitStateAfterBranchCreation: ISessionGitState | undefined; + createdBranch: string | undefined; branchChanges: readonly ISessionFileDiff[] | undefined = [{ after: { uri: 'file:///repo/file.ts', content: { uri: 'file:///repo/file.ts' } } }]; async getCurrentBranch(): Promise { return 'feature/test'; } @@ -72,7 +87,14 @@ class TestGitService implements IAgentHostGitService { async copyWorktreeIncludeFiles(): Promise { } async addExistingWorktree(): Promise { } async removeWorktree(): Promise { } - async branchExists(): Promise { return false; } + async branchExists(_repositoryRoot: URI, branchName: string): Promise { + this.calls.push(`branchExists:${branchName}`); + return false; + } + async createBranch(_workingDirectory: URI, branchName: string): Promise { + this.calls.push(`createBranch:${branchName}`); + this.createdBranch = branchName; + } async hasUncommittedChanges(): Promise { this.calls.push('hasUncommittedChanges'); return this.uncommitted; @@ -94,7 +116,7 @@ class TestGitService implements IAgentHostGitService { } async getSessionGitState(_workingDirectory: URI, baseBranchName?: string): Promise { this.requestedBaseBranches.push(baseBranchName); - return this.gitState; + return this.createdBranch ? this.gitStateAfterBranchCreation : this.gitState; } async computeSessionFileDiffs(): Promise { this.calls.push('computeSessionFileDiffs'); @@ -184,10 +206,11 @@ function createAuthenticationService(withCopilotToken = false): IAgentHostAuthen }; } -function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod; baseBranch?: string }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; copilotApiService: TestCopilotApiService } { +function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod; baseBranch?: string; branchPrefix?: string }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; createdBranches: string[]; copilotApiService: TestCopilotApiService; branchNameGenerator: TestBranchNameGenerator } { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const session = URI.parse('agent:/session'); const createdEvents: string[] = []; + const createdBranches: string[] = []; stateManager.createSession({ resource: session.toString(), provider: 'copilot', @@ -197,12 +220,13 @@ function setup(disposables: Pick, gitService: TestGitSer modifiedAt: new Date(1).toISOString(), workingDirectories: [URI.file('/repo').toString()], }); - if (options?.baseBranch) { + if (options?.baseBranch || options?.branchPrefix) { stateManager.setSessionConfig(session.toString(), { schema: { type: 'object', properties: {} }, values: { [SessionConfigKey.Isolation]: 'worktree', - [SessionConfigKey.Branch]: options.baseBranch, + ...(options.baseBranch ? { [SessionConfigKey.Branch]: options.baseBranch } : {}), + ...(options.branchPrefix ? { [SessionConfigKey.WorktreeBranchPrefix]: options.branchPrefix } : {}), }, }); } @@ -219,6 +243,7 @@ function setup(disposables: Pick, gitService: TestGitSer }); stateManager.setSessionMeta(session.toString(), sessionMeta); const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); + const branchNameGenerator = new TestBranchNameGenerator(); return { handler: new AgentHostPullRequestOperationHandler( options?.draft ?? false, @@ -231,11 +256,16 @@ function setup(disposables: Pick, gitService: TestGitSer return state; }, async () => options?.baseBranch ?? 'main', - event => createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`), - createAuthenticationService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, new NullLogService()), + event => { + createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`); + createdBranches.push(event.branchName); + }, + createAuthenticationService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, branchNameGenerator, new NullLogService()), session, createdEvents, + createdBranches, copilotApiService, + branchNameGenerator, }; } @@ -279,6 +309,67 @@ suite('AgentHostPullRequestOperationHandler', () => { }); }); + test('creates a generated branch before committing when the current branch is the base branch', async () => { + const gitService = new TestGitService(); + gitService.uncommitted = true; + gitService.gitState = { + branchName: 'main', + baseBranchName: 'main', + upstreamBranchName: 'origin/main', + githubHeadOwner: 'microsoft', + }; + gitService.gitStateAfterBranchCreation = { + branchName: 'users/test/agents/add-retry-logic', + baseBranchName: 'main', + }; + const octoKitService = new TestOctoKitService(); + const copilotApiService = new TestCopilotApiService(); + copilotApiService.response = 'add-retry-logic'; + const turns: Turn[] = [{ + id: 'turn-1', + message: { text: 'Add retry logic', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }]; + const { handler, session, createdBranches, branchNameGenerator } = setup(disposables, gitService, octoKitService, { + copilotApiService, + withCopilotToken: true, + turns, + branchPrefix: 'users/test/', + }); + + await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ + gitCalls: gitService.calls, + requestedBaseBranches: gitService.requestedBaseBranches, + branchGenerationTokens: branchNameGenerator.requests.map(request => request.githubToken), + utilityCallTokens: copilotApiService.calls.map(call => call.token), + pushOptions: gitService.pushOptions, + createHead: octoKitService.lastHead, + createBase: octoKitService.lastBase, + createdBranches, + }, { + gitCalls: [ + 'hasUncommittedChanges', + 'branchExists:users/test/agents/add-retry-logic', + 'createBranch:users/test/agents/add-retry-logic', + 'commitAll:Agent Host changes for users/test/agents/add-retry-logic', + 'computeSessionFileDiffs', + 'hasUpstream', + 'push:users/test/agents/add-retry-logic:true', + ], + requestedBaseBranches: ['main', 'main'], + branchGenerationTokens: ['gh-token'], + utilityCallTokens: ['copilot-token'], + pushOptions: [{ remote: undefined, ref: 'users/test/agents/add-retry-logic', setUpstream: true }], + createHead: 'users/test/agents/add-retry-logic', + createBase: 'main', + createdBranches: ['users/test/agents/add-retry-logic'], + }); + }); + test('pushes, finds, and creates with the same fork upstream', async () => { const gitService = new TestGitService(); gitService.upstream = true; diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index b63e514b158fc1..ab2c654bf4a3c8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -10,9 +10,11 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { NullLogService } from '../../../log/common/log.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPullRequestOperationContribution } from '../../node/agentHostPullRequestOperationProvider.js'; -import type { ISessionGitHubState, ISessionGitState } from '../../common/state/sessionState.js'; +import type { IAgentHostPullRequestStatus, IAgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; +import { SessionStatus, type ISessionGitHubState, type ISessionGitState } from '../../common/state/sessionState.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { ChangesetKind } from '../../common/changesetUri.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; const nullGitStateService = new class implements IAgentHostGitStateService { declare readonly _serviceBrand: undefined; @@ -26,6 +28,32 @@ const nullGitStateService = new class implements IAgentHostGitStateService { async attachSessionGitHubPullRequest(): Promise { } }; +function createStatusService(status?: IAgentHostPullRequestStatus): IAgentHostPullRequestStatusService { + return { + _serviceBrand: undefined, + onDidChangePullRequestStatus: Event.None, + getPullRequestStatus: () => status, + refresh: async () => { }, + dispose: () => { }, + }; +} + +function openPullRequest(overrides?: Partial): IAgentHostPullRequestStatus { + return { + pullRequestId: 'PR_1', + number: 1, + url: 'https://github.com/microsoft/vscode/pull/1', + headSha: 'sha1', + state: 'open', + draft: false, + mergeReady: false, + viewerCanEnableAutoMerge: false, + autoMergeEnabled: false, + allowedMergeMethods: ['SQUASH'], + ...overrides, + }; +} + const githubBranchWithUncommittedChanges: ISessionGitState = { hasGitHubRemote: true, branchName: 'feature/test', @@ -33,14 +61,37 @@ const githubBranchWithUncommittedChanges: ISessionGitState = { outgoingChanges: 0, }; +const pullRequestForBranch: ISessionGitHubState = { + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + pullRequestBranchName: 'feature/test', +}; + suite('AgentHostPullRequestOperationContribution', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(): AgentHostPullRequestOperationContribution { + function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree'): AgentHostPullRequestOperationContribution { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + if (isolation) { + stateManager.createSession({ + resource: 'agent:/session', + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + workingDirectories: ['file:///repo'], + }); + stateManager.setSessionConfig('agent:/session', { + schema: { type: 'object', properties: {} }, + values: { [SessionConfigKey.Isolation]: isolation }, + }); + } return disposables.add(new AgentHostPullRequestOperationContribution( - disposables.add(new AgentHostStateManager(new NullLogService())), + stateManager, disposables.add(new InstantiationService()), nullGitStateService, + createStatusService(status), + new NullLogService(), )); } @@ -52,6 +103,19 @@ suite('AgentHostPullRequestOperationContribution', () => { assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-draft-pr']); }); + test('does not advertise PR operations for folder sessions with outgoing changes', () => { + const provider = createContribution(undefined, 'folder'); + + const operations = provider.getOperations({ + sessionKey: 'agent:/session', + gitState: { ...githubBranchWithUncommittedChanges, uncommittedChanges: 0, outgoingChanges: 2 }, + changesetKind: ChangesetKind.Branch, + changesetUri: '', + }); + + assert.deepStrictEqual(operations, undefined); + }); + test('does not advertise PR operations without GitHub branch changes', () => { const provider = createContribution(); @@ -68,10 +132,34 @@ suite('AgentHostPullRequestOperationContribution', () => { const provider = createContribution(); const actual = [ - provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: { pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], pullRequestBranchName: 'feature/test' }, changesetKind: ChangesetKind.Session, changesetUri: '' }), - provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: { pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], pullRequestBranchName: 'feature/other' }, changesetKind: ChangesetKind.Session, changesetUri: '' }), + provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Session, changesetUri: '' }), + provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: { ...pullRequestForBranch, pullRequestBranchName: 'feature/other' }, changesetKind: ChangesetKind.Session, changesetUri: '' }), ]; assert.deepStrictEqual(actual.map(operations => operations?.map(op => op.id)), [undefined, ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-draft-pr']]); }); + + test('advertises lifecycle operations for a pull request on the current branch', () => { + const operationsFor = (status?: IAgentHostPullRequestStatus) => createContribution(status) + .getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Session, changesetUri: '' }) + ?.map(op => op.id); + + assert.deepStrictEqual({ + unresolved: operationsFor(undefined), + merged: operationsFor(openPullRequest({ state: 'merged' })), + draft: operationsFor(openPullRequest({ draft: true, viewerCanEnableAutoMerge: true })), + mergeable: operationsFor(openPullRequest({ mergeReady: true })), + blocked: operationsFor(openPullRequest({ viewerCanEnableAutoMerge: true })), + autoMerging: operationsFor(openPullRequest({ autoMergeEnabled: true })), + noAutoMerge: operationsFor(openPullRequest()), + }, { + unresolved: undefined, + merged: undefined, + draft: ['pr-mark-ready', 'pr-enable-auto-merge'], + mergeable: ['pr-merge'], + blocked: ['pr-enable-auto-merge'], + autoMerging: ['pr-disable-auto-merge'], + noAutoMerge: undefined, + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts new file mode 100644 index 00000000000000..b14cdece213ae5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts @@ -0,0 +1,302 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { GitHubCredential, GitHubCredentialInvalidation, IGitHubCredentials } from '../../../github/common/githubCredentialService.js'; +import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../../github/common/githubPullRequestService.js'; +import type { IGitHubService } from '../../../github/common/githubService.js'; +import type { IPullRequestResources } from '../../../github/common/pullRequestResourceService.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; +import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; +import { SessionStatus, withSessionGitHubState, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; + +const account = { host: 'api.github.com', accountId: '1' }; +const pullRequestUrl = 'https://github.com/octo/repo/pull/7'; + +function summary(resource: string): SessionSummary { + const now = new Date().toISOString(); + return { resource, provider: 'copilot', title: 'PR status', status: SessionStatus.Idle, createdAt: now, modifiedAt: now }; +} + +function snapshot(ref: PullRequestRef, overrides?: { readonly draft?: boolean; readonly headSha?: string }): PullRequestSnapshot { + const headSha = overrides?.headSha ?? 'sha1'; + const ready = { status: 'ready', complete: true } as const; + return { + ref, + generation: 1, + headGeneration: 1, + core: { + ...ready, + value: { + id: 'PR_1', + repositoryNameWithOwner: 'octo/repo', + number: 7, + title: 'Title', + url: pullRequestUrl, + state: 'open', + draft: overrides?.draft ?? false, + headSha, + headRef: 'feature', + baseSha: 'base', + baseRef: 'main', + }, + }, + topLevelComments: { status: 'missing', complete: false }, + submittedReviews: { status: 'missing', complete: false }, + inlineComments: { status: 'missing', complete: false }, + reviewThreads: { status: 'missing', complete: false }, + checks: { status: 'missing', complete: false }, + mergeability: { + ...ready, + headSha, + value: { + headSha, + baseSha: 'base', + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + viewerCanUpdate: true, + viewerCanMerge: true, + viewerCanEnableAutoMerge: false, + allowedMergeMethods: ['SQUASH'], + autoMergeEnabled: false, + mergeQueueRequired: false, + queueRequirementKnown: true, + }, + }, + participants: { status: 'missing', complete: false }, + } as PullRequestSnapshot; +} + +/** Records subscription lifecycle so tests can assert nothing is leaked. */ +class TestPullRequestResources implements IPullRequestResources { + + readonly subscribed: PullRequestRef[] = []; + disposedCount = 0; + private _snapshot = observableValue('snapshot', undefined); + + get liveSubscriptions(): number { return this.subscribed.length - this.disposedCount; } + + subscribePullRequest(ref: PullRequestRef): PullRequestSubscription { + this.subscribed.push(ref); + this._snapshot.set(snapshot(ref), undefined); + return { + resource: { ref, snapshot: this._snapshot as never }, + update: () => { }, + refresh: async () => { }, + dispose: () => { this.disposedCount++; }, + } as PullRequestSubscription; + } + + invalidatePullRequest(): void { } + clear(): void { } +} + +class TestCredentials implements IGitHubCredentials { + + private readonly _onDidInvalidate = new Emitter(); + readonly onDidInvalidate = this._onDidInvalidate.event; + + /** Resolved by the test so a sync can be suspended mid-flight. */ + pending: DeferredPromise | undefined; + + async getCredential(): Promise { + if (this.pending) { + await this.pending.p; + } + return { account, token: 'token', generation: 1, signal: new AbortController().signal }; + } + + async resolveCredential(): Promise { throw new Error('not implemented'); } + handleRequestError(): void { } + + invalidate(reason: GitHubCredentialInvalidation['reason']): void { + this._onDidInvalidate.fire({ credential: { account, token: 'token', generation: 1, signal: new AbortController().signal }, reason }); + } + + dispose(): void { this._onDidInvalidate.dispose(); } +} + +class TestChangesetSubscriptions implements IAgentHostChangesetSubscriptionService { + + declare readonly _serviceBrand: undefined; + + private readonly _subscriptions = new Map>(); + private readonly _onDidChange = new Emitter(); + readonly onDidChangeSessionSubscriptions = this._onDidChange.event; + + getSessionSubscriptions(session: string): ReadonlySet { + return this._subscriptions.get(session) ?? new Set(); + } + + addSubscription(session: string, changeset: string): void { + let set = this._subscriptions.get(session); + if (!set) { + set = new Set(); + this._subscriptions.set(session, set); + } + set.add(changeset); + if (set.size === 1) { + this._onDidChange.fire(session); + } + } + + removeSubscription(session: string, changeset: string): void { + const set = this._subscriptions.get(session); + if (!set) { + return; + } + set.delete(changeset); + if (set.size === 0) { + this._subscriptions.delete(session); + this._onDidChange.fire(session); + } + } + + clearSessionSubscriptions(session: string): void { + if (this._subscriptions.delete(session)) { + this._onDidChange.fire(session); + } + } + + dispose(): void { this._onDidChange.dispose(); } +} + +suite('AgentHostPullRequestStatusService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHarness() { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const subscriptions = disposables.add(new TestChangesetSubscriptions()); + const credentials = disposables.add(new TestCredentials()); + const resources = new TestPullRequestResources(); + const gitHubService = new class extends mock() { + override readonly credentials = credentials; + override readonly pullRequests = resources; + }(); + const gitStateService = new class extends mock() { + override readonly onDidRefreshSessionGitState = Event.None; + override readonly onDidChangeSessionGitHubState = Event.None; + }(); + const service = disposables.add(new AgentHostPullRequestStatusService( + stateManager, + subscriptions, + gitStateService, + gitHubService, + logService, + )); + + const session = 'copilot:/pr-status'; + stateManager.createSession(summary(session)); + // A session whose branch carries a pull request, which is the only + // shape the watcher is eligible for. + stateManager.setSessionMeta(session, withSessionGitHubState( + withSessionGitState(undefined, { branchName: 'feature' }), + { pullRequestUrls: [pullRequestUrl], pullRequestBranchName: 'feature' }, + )); + + return { service, stateManager, subscriptions, credentials, resources, session }; + } + + test('watches only while a client is subscribed to the session changes', async () => { + const { service, subscriptions, resources, session } = createHarness(); + + const beforeSubscribe = resources.liveSubscriptions; + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + const whileSubscribed = { live: resources.liveSubscriptions, status: service.getPullRequestStatus(session)?.state }; + + subscriptions.removeSubscription(session, `${session}/changes`); + + assert.deepStrictEqual({ + beforeSubscribe, + whileSubscribed, + afterUnsubscribe: resources.liveSubscriptions, + statusAfterUnsubscribe: service.getPullRequestStatus(session), + }, { + beforeSubscribe: 0, + whileSubscribed: { live: 1, status: 'open' }, + afterUnsubscribe: 0, + statusAfterUnsubscribe: undefined, + }); + }); + + test('does not install a watch when the session stopped being eligible mid-sync', async () => { + const { service, subscriptions, credentials, resources, session } = createHarness(); + + // Suspend credential resolution so the session can lose its subscriber + // while the first sync is still in flight. + credentials.pending = new DeferredPromise(); + subscriptions.addSubscription(session, `${session}/changes`); + subscriptions.removeSubscription(session, `${session}/changes`); + credentials.pending.complete(); + credentials.pending = undefined; + await pump(); + + assert.deepStrictEqual({ + live: resources.liveSubscriptions, + status: service.getPullRequestStatus(session), + }, { + live: 0, + status: undefined, + }); + }); + + test('rebuilds the watch when the credential account is invalidated', async () => { + const { subscriptions, credentials, resources, session } = createHarness(); + + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + + credentials.invalidate('account'); + await pump(); + + assert.deepStrictEqual({ + // The dead subscription is dropped and a fresh one installed, so a + // disposed resource can never keep publishing stale operations. + subscribed: resources.subscribed.length, + disposed: resources.disposedCount, + live: resources.liveSubscriptions, + }, { + subscribed: 2, + disposed: 1, + live: 1, + }); + }); + + test('keeps the watch when the credential is merely refreshed', async () => { + const { subscriptions, credentials, resources, session } = createHarness(); + + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + + // The resource service keeps these entries and refreshes them in place. + credentials.invalidate('authentication'); + await pump(); + + assert.deepStrictEqual({ subscribed: resources.subscribed.length, disposed: resources.disposedCount }, { subscribed: 1, disposed: 0 }); + }); +}); + +/** Lets queued microtasks (the serialized per-session sync) settle. */ +async function pump(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +} + +async function waitForWatch(resources: TestPullRequestResources): Promise { + for (let i = 0; i < 50 && resources.liveSubscriptions === 0; i++) { + await pump(); + } +} diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 6f46cdc73876a9..2ee5b66f567c01 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -65,7 +65,6 @@ class FakeChangesetService implements IAgentHostChangesetService { refreshChangesetCatalog(): void { } onWorkingDirectoryAvailable(): void { } recomputeSubscribedChangesets(): void { } - onSessionDisposed(): void { } async computeUncommittedChangeset(session: string): Promise { return `${session}/changeset/uncommitted`; } async computeTurnChangeset(session: string): Promise { return `${session}/x`; } async computeCompareTurnsChangeset(session: string): Promise { return `${session}/y`; } diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index d670f8588b9d9e..4713956c7518a3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -64,7 +64,6 @@ class FakeChangesetService implements IAgentHostChangesetService { refreshChangesetCatalog(): void { } onWorkingDirectoryAvailable(): void { } recomputeSubscribedChangesets(): void { } - onSessionDisposed(): void { } async computeUncommittedChangeset(session: string): Promise { return `${session}/changeset/uncommitted`; } async computeTurnChangeset(session: string): Promise { return `${session}/x`; } async computeCompareTurnsChangeset(session: string): Promise { return `${session}/y`; } diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 035a69391af796..6942962c3630a6 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -67,7 +67,6 @@ class FakeChangesetService implements IAgentHostChangesetService { refreshSessionChangeset(): void { } onWorkingDirectoryAvailable(): void { } recomputeSubscribedChangesets(): void { } - onSessionDisposed(): void { } async computeUncommittedChangeset(session: string): Promise { return `${session}/changeset/uncommitted`; } async computeTurnChangeset(session: string): Promise { return `${session}/x`; } async computeCompareTurnsChangeset(session: string): Promise { return `${session}/y`; } diff --git a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index 9a1c3436e0a24b..b2d8bdd5816f9d 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts @@ -5,13 +5,15 @@ import * as assert from 'assert'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { timeout } from '../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { NullLogService } from '../../../log/common/log.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { mock } from '../../../../base/test/common/mock.js'; import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; +import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { URI } from '../../../../base/common/uri.js'; import { AgentSystemNotificationKind } from '../../common/meta/agentSystemNotificationMeta.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; @@ -25,6 +27,15 @@ import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; let sessionCounter = 0; +/** + * The controller only reads git to resolve the worktree commit that backs the + * "merge only while unchanged" baseline. These tests never exercise that path, + * so the worktree simply reports as unreadable. + */ +const noopGitService = new class extends mock() { + override async getRepositoryRoot(): Promise { return undefined; } +}(); + suite('AgentMergeController', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -53,6 +64,7 @@ suite('AgentMergeController', () => { stateManager, configurationService, gitStateService, + noopGitService, new class extends mock() { }(), endpointService, logService, @@ -251,6 +263,7 @@ suite('AgentMergeController', () => { stateManager, configurationService, gitStateService, + noopGitService, new class extends mock() { }(), endpointService, logService, @@ -286,6 +299,60 @@ suite('AgentMergeController', () => { }); }); + test('does not reevaluate when its own git state refresh completes', async () => { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + configurationService.updateRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + const session = `copilot:/agent-merge-controller-${++sessionCounter}`; + const onDidRefreshSessionGitState = disposables.add(new Emitter()); + const firstAttach = new DeferredPromise(); + let attachCount = 0; + const gitStateService = new class extends mock() { + override readonly onDidRefreshSessionGitState = onDidRefreshSessionGitState.event; + override readonly onDidChangeSessionGitHubState = Event.None; + override async attachSessionGitHubPullRequest(sessionKey: string): Promise { + attachCount++; + if (attachCount === 1) { + onDidRefreshSessionGitState.fire(sessionKey); + firstAttach.complete(); + } + } + }(); + const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); + disposables.add(new AgentMergeController( + { + startTurn: () => false, + cancelTurn: () => { }, + postNotice: () => { }, + getAutonomousSessionConfig: () => ({}), + }, + stateManager, + configurationService, + gitStateService, + noopGitService, + new class extends mock() { }(), + endpointService, + logService, + )); + stateManager.createSession(summary(session)); + stateManager.setSessionConfig(session, { + schema: platformSessionSchema.toProtocol(), + values: {}, + }); + stateManager.setSessionMeta(session, withSessionGitState(undefined, { branchName: 'feature', baseBranchName: 'main' })); + configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { enabled: true }, + }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + + await firstAttach.p; + await timeout(0); + await timeout(0); + + assert.strictEqual(attachCount, 1); + }); + test('recomputes git state at most once per runtime and never for a detached HEAD', async () => { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); @@ -316,6 +383,7 @@ suite('AgentMergeController', () => { stateManager, configurationService, gitStateService, + noopGitService, new class extends mock() { }(), endpointService, logService, @@ -385,6 +453,7 @@ suite('AgentMergeController', () => { stateManager, configurationService, gitStateService, + noopGitService, new class extends mock() { }(), endpointService, logService, @@ -421,6 +490,7 @@ suite('AgentMergeController', () => { stateManager, configurationService, gitStateService, + noopGitService, new class extends mock() { }(), endpointService, logService, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index d327fb3072459b..745c2117517dd7 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -41,7 +41,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type INotification } from '../../common/state/sessionActions.js'; -import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; @@ -982,7 +982,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); setTestAgentHostWorktreeIsolation(localService, disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/test' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, nullSessionDataService, new NullLogService(), @@ -1027,7 +1027,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); setTestAgentHostWorktreeIsolation(localService, disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/test' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, nullSessionDataService, new NullLogService(), @@ -1158,7 +1158,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/test' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, nullSessionDataService, new NullLogService(), @@ -1489,7 +1489,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(new TestSessionDatabase()); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/test' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, sessionDataService, new NullLogService(), @@ -3696,6 +3696,37 @@ suite('AgentService (node dispatcher)', () => { }); }); + testWithExternalSessionClock('coalesces a burst of storm-driven reconciliations into a single trailing pass', async () => { + const now = Date.now(); + const svc = createExternalSessionService(); + const agent = disposables.add(new TimedExternalAgent('copilot')); + agent.addSession('a', now); + registerTestAgentProvider(svc, agent); + await svc.listSessions(); + await waitForSessionListReconciliation(svc); + + // Count reconciliation passes directly: each is one O(catalog) walk. + let reconcilePasses = 0; + const internal = svc as unknown as { + _reconcileExternalSessions(previousMode?: AgentHostExternalSessionsMode): Promise; + _queueSessionListReconciliation(): void; + }; + const reconcile = internal._reconcileExternalSessions.bind(svc); + internal._reconcileExternalSessions = previousMode => { + reconcilePasses++; + return reconcile(previousMode); + }; + + // Without coalescing, this synchronous burst chains one pass per call. + for (let i = 0; i < 10; i++) { + internal._queueSessionListReconciliation(); + } + await waitForSessionListReconciliation(svc); + + // A burst collapses to the in-flight pass plus at most one trailing pass. + assert.ok(reconcilePasses <= 2, `expected <= 2 reconcile passes, got ${reconcilePasses}`); + }); + testWithExternalSessionClock('recent replaces the oldest visible external session when a newer session is discovered', async () => { const now = Date.now(); const svc = createExternalSessionService(); @@ -4007,69 +4038,6 @@ suite('AgentService (node dispatcher)', () => { ); }); - test('discovery announces a registered session with provider metadata intact', async () => { - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - const agent = disposables.add(new MockAgent('copilot')); - registerTestAgentProvider(svc, agent); - getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - await svc.listSessions(); - - const session = AgentSession.uri('copilot', 'provider-announced'); - agent.fireDiscoveredChats([{ - ...discoveredChat(session, false), - summary: 'Provider chat', - _meta: withSessionEhcliAdoptable(undefined), - }]); - for (let i = 0; i < 50 && !getStateManager(svc).getSurfacedSessionSummary(session.toString()); i++) { - await timeout(0); - } - - const surfaced = getStateManager(svc).getSurfacedSessionSummary(session.toString()); - assert.deepStrictEqual({ - resource: surfaced?.resource, - title: surfaced?.title, - external: readSessionExternal(surfaced?._meta), - adoptable: readSessionEhcliAdoptable(surfaced?._meta), - }, { - resource: session.toString(), - title: 'Provider chat', - external: false, - adoptable: true, - }); - }); - - test('an adoptable chat retracted by disabling migration is re-surfaced when it is re-enabled', async () => { - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - const agent = disposables.add(new MockAgent('copilot')); - registerTestAgentProvider(svc, agent); - getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - await svc.listSessions(); - - const session = AgentSession.uri('copilot', 'toggled-adoptable'); - (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); - agent.fireDiscoveredChats([{ ...discoveredChat(session, false), _meta: withSessionEhcliAdoptable(undefined) }]); - for (let i = 0; i < 50 && !getStateManager(svc).getSurfacedSessionSummary(session.toString()); i++) { - await timeout(0); - } - const afterFirstEnable = !!getStateManager(svc).getSurfacedSessionSummary(session.toString()); - - getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); - await timeout(0); - const whileDisabled = !!getStateManager(svc).getSurfacedSessionSummary(session.toString()); - - // Discovery skips chats already in the registry, so re-enabling must restore - // them from the registry rather than waiting for another discovery pass. - getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - for (let i = 0; i < 50 && !getStateManager(svc).getSurfacedSessionSummary(session.toString()); i++) { - await timeout(0); - } - - assert.deepStrictEqual( - { afterFirstEnable, whileDisabled, afterReEnable: !!getStateManager(svc).getSurfacedSessionSummary(session.toString()) }, - { afterFirstEnable: true, whileDisabled: false, afterReEnable: true }, - ); - }); - test('rediscovering a registered chat with different provenance performs no per-session database I/O', async () => { const perSession = createPerSessionDataService(); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -4644,7 +4612,7 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(agent.listExternalChatsCalls, 1, 'event payload ingestion must not re-enumerate the provider'); }); - test('surfaces registered adoptable legacy metadata directly from the provider catalog', async () => { + test('does not surface adoptable legacy sessions (they belong to the extension host until opened)', async () => { class AdoptableLegacyAgent extends MockAgent { override async listChatsToMigrate(): Promise { return this.listExternalChats(); @@ -4663,16 +4631,9 @@ suite('AgentService (node dispatcher)', () => { getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); await svc.listSessions(); - const surfaced = getStateManager(svc).getSurfacedSessionSummary(legacy.toString()); - assert.deepStrictEqual({ - resource: surfaced?.resource, - provider: surfaced?.provider, - adoptable: readSessionEhcliAdoptable(surfaced?._meta), - }, { - resource: legacy.toString(), - provider: 'copilot', - adoptable: true, - }); + // Un-adopted adoptable-legacy rows stay with the extension-host provider until + // opened, so the agent host never surfaces them even with the gate on. + assert.strictEqual(getStateManager(svc).getSurfacedSessionSummary(legacy.toString()), undefined); }); test('does not surface a discovered session that was already deleted', async () => { @@ -5799,7 +5760,7 @@ suite('AgentService (node dispatcher)', () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); setTestAgentHostWorktreeIsolation(svc, disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/test' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, sessionDataService, new NullLogService(), @@ -5845,7 +5806,7 @@ suite('AgentService (node dispatcher)', () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); setTestAgentHostWorktreeIsolation(svc, disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/test' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, sessionDataService, new NullLogService(), @@ -6386,6 +6347,7 @@ suite('AgentService (node dispatcher)', () => { addExistingWorktree: async () => { }, removeWorktree: async () => { }, branchExists: async () => false, + createBranch: async () => { }, hasUncommittedChanges: async () => false, commitAll: async () => { }, mergeBranch: async () => '', @@ -6493,6 +6455,7 @@ suite('AgentService (node dispatcher)', () => { addExistingWorktree: async () => { }, removeWorktree: async () => { }, branchExists: async () => false, + createBranch: async () => { }, hasUncommittedChanges: async () => false, commitAll: async () => { }, mergeBranch: async () => '', @@ -7151,7 +7114,7 @@ suite('AgentService (node dispatcher)', () => { let removeWorktreeCalls = 0; gitService.removeWorktree = async () => { removeWorktreeCalls++; }; const isolation = disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/test' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, nullSessionDataService, new NullLogService(), @@ -8093,8 +8056,8 @@ suite('AgentService (node dispatcher)', () => { }); - test('adopts a surfaced legacy session on open only when the migrate setting is on', async () => { - // Open-adoption is strictly gated on the live migrate setting. + test('adopts a surfaced legacy session on open only when the migrate setting is on (frozen at startup)', async () => { + // Open-adoption is gated on the startup-frozen migrate snapshot. class AdoptOnOpenAgent extends MockAgent { adoptCalls = 0; private _adopted = false; @@ -8110,16 +8073,8 @@ suite('AgentService (node dispatcher)', () => { } } - const db = new TestSessionDatabase(); - const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - const agent = disposables.add(new AdoptOnOpenAgent()); - registerTestAgentProvider(localService, agent); - agent.sessionMessages = []; - - const session = AgentSession.uri('copilot', 'surfaced-legacy'); - const sessionStr = session.toString(); - getStateManager(localService).announceSurfacedSession({ - resource: sessionStr, + const surface = (svc: AgentService, session: URI) => getStateManager(svc).announceSurfacedSession({ + resource: session.toString(), provider: 'copilot', title: 'Legacy', status: SessionStatus.Idle, @@ -8128,17 +8083,64 @@ suite('AgentService (node dispatcher)', () => { _meta: withSessionEhcliAdoptable(undefined), }); - // Migrate setting off: opening must not adopt (dead-ends on missing backend metadata). - await assert.rejects(() => localService.restoreSession(session)); - assert.strictEqual(agent.adoptCalls, 0); + // Migrate setting off at startup: opening must not adopt (refused as a + // non-adoptable unregistered chat). + const offService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const offAgent = disposables.add(new AdoptOnOpenAgent()); + registerTestAgentProvider(offService, offAgent); + offAgent.sessionMessages = []; + const offSession = AgentSession.uri('copilot', 'surfaced-legacy-off'); + surface(offService, offSession); + await assert.rejects(() => offService.restoreSession(offSession)); + assert.strictEqual(offAgent.adoptCalls, 0); + + // Migrate setting on at startup: opening adopts in place. + const onService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const onAgent = disposables.add(new AdoptOnOpenAgent()); + registerTestAgentProvider(onService, onAgent); + onAgent.sessionMessages = []; + getConfigurationService(onService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + const onSession = AgentSession.uri('copilot', 'surfaced-legacy-on'); + surface(onService, onSession); + await onService.restoreSession(onSession); + + assert.deepStrictEqual( + { adoptCalls: onAgent.adoptCalls, restored: !!getStateManager(onService).getSessionState(onSession.toString()) }, + { adoptCalls: 1, restored: true }, + ); + }); + + test('surfaces an adopted legacy session on open so it is never absent from both lists', async () => { + // Surface-before-retract: adoption writes `session.db` (the extension host + // stops listing the chat), so the agent host must list the adopted row even + // for a session discovery never surfaced (migrate enabled after startup). + class AdoptOnOpenAgent extends MockAgent { + private _adopted = false; + constructor() { super('copilot'); } + async ensureChatAdopted(_chat: URI, _context: URI | IAgentChatContext): Promise { + this._adopted = true; + return { adopted: true, eligible: true }; + } + override async getChatMetadata(chat: URI, _context: URI | IAgentChatContext): Promise { + return this._adopted ? { chat, startTime: Date.now(), modifiedTime: Date.now(), summary: 'Adopted' } : undefined; + } + } - // Migrate setting on: opening adopts in place. + const db = new TestSessionDatabase(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new AdoptOnOpenAgent()); + registerTestAgentProvider(localService, agent); getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + + // Never surfaced by discovery; opened directly (the migrate-after-startup path). + const session = AgentSession.uri('copilot', 'adopt-on-open-unsurfaced'); + const sessionStr = session.toString(); await localService.restoreSession(session); + const listed = (await localService.listSessions()).some(s => s.session.toString() === sessionStr); assert.deepStrictEqual( - { adoptCalls: agent.adoptCalls, restored: !!getStateManager(localService).getSessionState(sessionStr) }, - { adoptCalls: 1, restored: true }, + { restored: !!getStateManager(localService).getSessionState(sessionStr), listed }, + { restored: true, listed: true }, ); }); @@ -8548,7 +8550,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('turning the migrate setting off un-surfaces adoptable legacy sessions that were never opened', async () => { + test('keeps a surfaced adoptable legacy session when the migrate setting is turned off at runtime (frozen until reload)', async () => { class AdoptOnOpenAgent extends MockAgent { constructor() { super('copilot'); } override async getChatMetadata(): Promise { @@ -8582,31 +8584,51 @@ suite('AgentService (node dispatcher)', () => { } })); - // Turn the setting off: the un-opened surfaced entry is dropped. + // The gate is snapshotted at startup, so turning the setting off at runtime + // does not un-surface the entry: it stays until a window reload. getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); assert.deepStrictEqual( - { surfaced: getStateManager(localService).getSurfacedSessionSummary(sessionStr), removed }, - { surfaced: undefined, removed: sessionStr }, + { stillSurfaced: !!getStateManager(localService).getSurfacedSessionSummary(sessionStr), removed }, + { stillSurfaced: true, removed: undefined }, ); }); - test('excludes adoptable-legacy sessions from the list while the migrate setting is off', async () => { - // Guards against a refresh re-surfacing a registry entry that can no longer be opened while migration is off. - const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + test('excludes adoptable-legacy sessions from the list (they belong to the extension host until opened)', async () => { + // The agent host never surfaces un-adopted adoptable-legacy rows, regardless of the migrate gate. const adoptable: IAgentSessionMetadata = { session: AgentSession.uri('copilot', 'adoptable-list-gate'), startTime: Date.now(), modifiedTime: Date.now(), _meta: withSessionEhcliAdoptable(undefined), }; - const shouldInclude = (localService as unknown as { _shouldIncludeSession(s: IAgentSessionMetadata): boolean })._shouldIncludeSession.bind(localService); - const includedWhileOff = shouldInclude(adoptable); - getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - const includedWhileOn = shouldInclude(adoptable); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + const included = (svc as unknown as { _shouldIncludeSession(s: IAgentSessionMetadata): boolean })._shouldIncludeSession.bind(svc)(adoptable); - assert.deepStrictEqual({ includedWhileOff, includedWhileOn }, { includedWhileOff: false, includedWhileOn: true }); + assert.strictEqual(included, false); + }); + + test('primeMigrateLegacyGate freezes the gate at startup, ignoring later runtime toggles', () => { + const read = (svc: AgentService) => (svc as unknown as { _isMigrateLegacyEnabled(): boolean })._isMigrateLegacyEnabled(); + + // Disabled at startup: a later runtime enable (a setting toggled without a + // host restart) must not flip the frozen gate. + const frozenOff = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + frozenOff.primeMigrateLegacyGate(); + getConfigurationService(frozenOff).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + + // Enabled at startup: a later runtime disable must likewise be ignored. + const frozenOn = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + getConfigurationService(frozenOn).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + frozenOn.primeMigrateLegacyGate(); + getConfigurationService(frozenOn).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); + + assert.deepStrictEqual( + { frozenOff: read(frozenOff), frozenOn: read(frozenOn) }, + { frozenOff: false, frozenOn: true }, + ); }); test('restores known session without listing all provider sessions', async () => { @@ -11471,7 +11493,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(new TestSessionDatabase()); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); setTestAgentHostWorktreeIsolation(localService, disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/test' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, sessionDataService, new NullLogService(), @@ -13454,17 +13476,16 @@ suite('AgentService (node dispatcher)', () => { localService.unsubscribe(sessionResource, 'client-2'); }); - test('restoreSession drains a pending uncommitted refresh deferred by an earlier addSubscriber', async () => { + test('restoreSession recomputes an uncommitted subscription added before restore', async () => { // Reproduces the cold-open race that broke §3: // 1. Client subscribes to `/changeset/uncommitted` // before the session has been restored on the server. // 2. addSubscriber's 0→1 trigger fires `_triggerUncommittedRefresh`, // which reads `summary.workingDirectory` from live state - // — finds nothing (session not restored yet) — and defers - // via `_pendingUncommittedRefreshes`. + // and skips computation because the session is not restored yet. // 3. restoreSession then runs (driven by the chat-view path or // a separate subscribe), populates `summary.workingDirectory` - // from disk, and MUST drain the pending refresh. + // from disk, and recomputes the current subscriptions. const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd-restore-drain' }); copilotAgent.resolvedWorkingDirectory = workingDirectory; copilotAgent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; @@ -13488,7 +13509,7 @@ suite('AgentService (node dispatcher)', () => { const sessionResource = sessions[0].session; const uncommittedUri = URI.parse(buildUncommittedChangesetUri(sessionResource.toString())); - // Step 1+2: subscribe before restore. Trigger defers. + // Step 1+2: subscribe before restore. The initial compute is skipped. localService.addSubscriber(uncommittedUri, 'client-1'); await new Promise(r => setTimeout(r, 20)); assert.strictEqual( @@ -13498,8 +13519,8 @@ suite('AgentService (node dispatcher)', () => { ); // Step 3: restoreSession runs (chat-view path / a parallel - // session-URI subscribe). After this, the pending refresh - // must drain and `_tryComputeGitDiffs` must run for the + // session-URI subscribe). After this, the subscription is + // recomputed and `_tryComputeGitDiffs` must run for the // uncommitted slot. copilotAgent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hi', toolRequests: [] }, @@ -13509,7 +13530,7 @@ suite('AgentService (node dispatcher)', () => { assert.ok( computeCalls.some(c => c.baseBranch === undefined && c.wd === workingDirectory.toString()), - `restoreSession must drain the pending refresh; got compute calls: ${JSON.stringify(computeCalls)}`, + `restoreSession must recompute current subscriptions; got compute calls: ${JSON.stringify(computeCalls)}`, ); localService.unsubscribe(uncommittedUri, 'client-1'); @@ -13769,7 +13790,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => localAgent.dispose())); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); setTestAgentHostWorktreeIsolation(localService, disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/test' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, sessionDataService, new NullLogService(), @@ -14129,7 +14150,7 @@ suite('AgentService (node dispatcher)', () => { const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( - { generateBranchName: async () => { throw new Error('should not generate a branch'); } }, + { _serviceBrand: undefined, generateBranchName: async () => { throw new Error('should not generate a branch'); } }, gitService, nullSessionDataService, new NullLogService(), @@ -14256,7 +14277,7 @@ suite('AgentService (node dispatcher)', () => { }; const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/failure' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/failure' }, gitService, sessionDataService, new NullLogService(), @@ -14322,7 +14343,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getRepositoryRoot = async () => undefined; const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( - { generateBranchName: async () => 'agents/fallback' }, + { _serviceBrand: undefined, generateBranchName: async () => 'agents/fallback' }, gitService, sessionDataService, new NullLogService(), diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 7ce59fb84d0a1c..7f1bd95924eee5 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -92,7 +92,6 @@ class FakeChangesetService implements IAgentHostChangesetService { refreshSessionChangeset(): void { /* no-op */ } onWorkingDirectoryAvailable(): void { /* no-op */ } recomputeSubscribedChangesets(): void { /* no-op */ } - onSessionDisposed(): void { /* no-op */ } async computeUncommittedChangeset(session: string): Promise { return `${session}/changeset/uncommitted`; } async computeTurnChangeset(session: string): Promise { return `${session}/changeset/turn/x`; } async computeCompareTurnsChangeset(session: string, originalTurnId: string, modifiedTurnId: string): Promise { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 4937894d2dfe3f..af1cf1070cfb76 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -286,6 +286,9 @@ class TestAgentHostGitService implements IAgentHostGitService { async branchExists(_repositoryRoot: URI, branchName: string): Promise { return this.existingBranches.has(branchName); } + async createBranch(_workingDirectory: URI, branchName: string): Promise { + this.existingBranches.add(branchName); + } async hasUncommittedChanges(workingDirectory: URI): Promise { return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); } @@ -5543,7 +5546,7 @@ suite('CopilotAgent', () => { suite('listSessions legacy-CLI surfacing (migration)', () => { - test('signals extension-host chats only after internal migration is enabled', async () => { + test('ignores a runtime enable of the migration setting (frozen until reload)', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/migration-event-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/migration-event-cwd-`); const sessionId = 'migration-event'; @@ -5558,16 +5561,13 @@ suite('CopilotAgent', () => { } assert.deepStrictEqual([...discoveredChats], []); + // The gate is snapshotted at startup, so enabling it at runtime has no + // effect until a window reload restarts the process. configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - for (let i = 0; i < 50 && discoveredChats.length === 0; i++) { + for (let i = 0; i < 50; i++) { await timeout(0); } - configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); - configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - for (let i = 0; i < 50 && discoveredChats.length < 2; i++) { - await timeout(0); - } - assert.deepStrictEqual(discoveredChats.map(chats => chats.map(chat => sessionIdOfChat(chat.chat))), [[sessionId], [sessionId]]); + assert.deepStrictEqual([...discoveredChats], []); } finally { listener.dispose(); await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -5602,7 +5602,7 @@ suite('CopilotAgent', () => { } }); - test('does not signal extension-host chats when migration is disabled during discovery', async () => { + test('ignores a runtime disable of the migration setting during discovery (frozen until reload)', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/disabled-during-migration-event-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/disabled-during-migration-event-cwd-`); const sessionId = 'disabled-during-migration-event'; @@ -5621,12 +5621,14 @@ suite('CopilotAgent', () => { const listener = agent.onDidDiscoverChats(chats => discoveredChats.push(chats)); try { await listStarted.p; + // The gate was snapshotted as enabled at startup, so disabling it mid + // discovery is ignored: the adoptable chat still surfaces. configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); releaseList.complete(); - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50 && discoveredChats.length === 0; i++) { await timeout(0); } - assert.deepStrictEqual(discoveredChats, []); + assert.deepStrictEqual(discoveredChats.flatMap(chats => chats.map(chat => sessionIdOfChat(chat.chat))), [sessionId]); } finally { listener.dispose(); await fs.rm(userHome.fsPath, { recursive: true, force: true }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index c436dce31c6ee3..6a2e8e8e28c531 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -3290,6 +3290,101 @@ suite('CopilotAgentSession', () => { }); }); + test('scopes a subagent Auto resolution to the subagent instead of the parent turn', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-1'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); + + // The subagent routes its own model call; the parent never picked this model. + mockSession.fire('session.auto_mode_resolved', { chosenModel: 'gpt-5.5' }, { agentId: 'agent-1' }); + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 5, + outputTokens: 7, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); + + const routed = signals.flatMap(signal => + signal.kind === 'action' && signal.action.type === ActionType.ChatUsage + ? [{ + parentToolCallId: signal.parentToolCallId, + chosenModel: readUsageInfoMeta(signal.action.usage).autoModeResolved?.chosenModel, + }] + : []); + + assert.deepStrictEqual(routed, [ + // The parent aggregate keeps describing the parent's own model call... + { parentToolCallId: undefined, chosenModel: undefined }, + // ...while the subagent's own component carries the routing. + { parentToolCallId: 'tc-subagent', chosenModel: 'gpt-5.5' }, + ]); + }); + + test('keeps a subagent Auto resolution when the root turn moves on beneath it', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-1'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); + + mockSession.fire('session.auto_mode_resolved', { chosenModel: 'gpt-5.5' }, { agentId: 'agent-1' }); + // Steering mints a new root turn while the subagent is still running. Losing + // the routing here would show the routed model to the hidden cohort, which + // reads "no routing reported" as "not routed". + session.resetTurnState('turn-2'); + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 5, + outputTokens: 7, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); + + const subagentRouting = signals.flatMap(signal => + signal.kind === 'action' && signal.action.type === ActionType.ChatUsage && signal.parentToolCallId === 'tc-subagent' + ? [readUsageInfoMeta(signal.action.usage).autoModeResolved?.chosenModel] + : []); + + assert.deepStrictEqual(subagentRouting, ['gpt-5.5']); + }); + + test('keeps a subagent Auto resolution when the root turn has already been cleared', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-1'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); + + // A root-scope error clears the active turn while the subagent runs on. + // Its routing must still be recorded, since the client reads "no routing + // reported" as "not routed" and would name the concrete model. + session.resetTurnState(''); + mockSession.fire('session.auto_mode_resolved', { chosenModel: 'gpt-5.5' }, { agentId: 'agent-1' }); + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 5, + outputTokens: 7, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); + + const subagentRouting = signals.flatMap(signal => + signal.kind === 'action' && signal.action.type === ActionType.ChatUsage && signal.parentToolCallId === 'tc-subagent' + ? [readUsageInfoMeta(signal.action.usage).autoModeResolved?.chosenModel] + : []); + + assert.deepStrictEqual(subagentRouting, ['gpt-5.5']); + }); + test('accumulates whole-turn token totals per model across parent and subagent calls', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index fcd81f0903ff46..36037d10ed78c1 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -31,6 +31,7 @@ class TestAgentHostGitService implements IAgentHostGitService { async addExistingWorktree(): Promise { } async removeWorktree(): Promise { } async branchExists(): Promise { return false; } + async createBranch(): Promise { } async hasUncommittedChanges(): Promise { return false; } async commitAll(): Promise { } async mergeBranch(): Promise { return ''; } diff --git a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts index ab468f5e75dfcd..eabc8b57a1d129 100644 --- a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts +++ b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts @@ -150,7 +150,7 @@ export type ISessionEvent = | ISessionEventAssistantTurn | ISessionEventSystemNotification | ISessionEventError - | { type: string; timestamp?: string; data?: unknown }; + | { type: string; agentId?: string; timestamp?: string; data?: unknown }; /** * Widens ergonomic {@link ISessionEvent} test fixtures to the real SDK diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index 14e9f3ae601d29..fb270ddc9be920 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -76,6 +76,37 @@ suite('mapSessionEvents — history replay', () => { ]); }); + test('restores a subagent Auto model resolution onto the subagent turn', async () => { + const autoModeResolved = { chosenModel: 'claude-opus-4.8' }; + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'turn-1', data: { interactionId: 'm1', content: 'summarize the service' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-task', name: 'task' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-task', toolName: 'task', arguments: { description: 'Summarize', agent_type: 'explore' } } }, + { type: 'subagent.started', agentId: 'agent-1', data: { toolCallId: 'tc-task', agentName: 'explore', agentDisplayName: 'Explore Agent', agentDescription: 'Explores' } }, + // Auto routes before the model call, so the decision lands before the + // subagent's first message. It must not pull the turn's start time back. + { type: 'session.auto_mode_resolved', agentId: 'agent-1', timestamp: '2025-01-01T00:00:10.000Z', data: autoModeResolved }, + { type: 'user.message', agentId: 'agent-1', timestamp: '2025-01-01T00:00:20.000Z', data: { interactionId: 'subagent-prompt', content: 'Inspect the implementation.' } }, + { type: 'assistant.message', agentId: 'agent-1', timestamp: '2025-01-01T00:00:30.000Z', data: { messageId: 'm3', content: 'Subagent is done.' } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-task', success: true } }, + ]; + + const { turns, subagentTurnsByToolCallId } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual({ + parentUsage: turns.map(turn => turn.usage), + subagentTurns: subagentTurnsByToolCallId.get('tc-task')?.map(turn => ({ text: turn.message.text, startedAt: turn.startedAt, duration: turn.duration, usage: turn.usage })), + }, { + parentUsage: [undefined], + subagentTurns: [{ + text: 'Inspect the implementation.', + startedAt: '2025-01-01T00:00:20.000Z', + duration: 10000, + usage: { model: 'claude-opus-4.8', _meta: { autoModeResolved } }, + }], + }); + }); + test('task_complete without a summary renders nothing', async () => { const events: ISessionEvent[] = [ { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, @@ -791,6 +822,10 @@ suite('mapSessionEvents — history replay', () => { test('strips prompt scaffolding from user message content', async () => { const wrapped = 'hi\n \nIMPORTANT: ignore this\n\n\nrepo\n\n\nhi\n\n'; + // The Copilot CLI injects context as a `` block (e.g. the + // `IMPORTANT: this context may or may not be relevant…` preamble); it must + // not leak into the reconstructed message (and thus the session title). + const systemReminder = 'hi\n\n\nIMPORTANT: this context may or may not be relevant\nAvailable tables: todos, todo_deps\n'; const events: ISessionEvent[] = [ { type: 'user.message', id: 'wrapped', data: { interactionId: 'interaction-1', content: wrapped } }, { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'Hello.', toolRequests: [] } }, @@ -800,11 +835,13 @@ suite('mapSessionEvents — history replay', () => { { type: 'assistant.message', data: { interactionId: 'interaction-3', content: 'Ok remote.', toolRequests: [] } }, { type: 'user.message', id: 'plain', data: { interactionId: 'interaction-4', content: 'just text' } }, { type: 'assistant.message', data: { interactionId: 'interaction-4', content: 'Ok.', toolRequests: [] } }, + { type: 'user.message', id: 'system-reminder', data: { interactionId: 'interaction-5', content: systemReminder } }, + { type: 'assistant.message', data: { interactionId: 'interaction-5', content: 'Hi.', toolRequests: [] } }, ]; const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); - assert.deepStrictEqual(turns.map(turn => turn.message.text), ['hi', 'hi5', '/remote', 'just text']); + assert.deepStrictEqual(turns.map(turn => turn.message.text), ['hi', 'hi5', '/remote', 'just text', 'hi']); }); test('terminal empty assistant message completes a tool-only turn', async () => { diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index adaf2b43bae118..f4049ce781b46a 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -121,6 +121,7 @@ suite('WorktreeIsolation', () => { function createIsolation(disposableStore: Pick, options?: { readonly branchNameGenerator?: IAgentBranchNameGenerator; readonly gitService?: IAgentHostGitService }): WorktreeIsolation { const branchNameGenerator = options?.branchNameGenerator ?? { + _serviceBrand: undefined, generateBranchName: async () => branchName, }; return disposableStore.add(new WorktreeIsolation( @@ -236,7 +237,7 @@ suite('WorktreeIsolation', () => { gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); const isolation = createIsolation(disposables, { gitService, - branchNameGenerator: { generateBranchName: async () => { throw new Error('should not generate a branch'); } }, + branchNameGenerator: { _serviceBrand: undefined, generateBranchName: async () => { throw new Error('should not generate a branch'); } }, }); const worktree = await isolation.resolveWorkingDirectory({ @@ -905,7 +906,7 @@ suite('WorktreeIsolation', () => { addExistingCalls: addExistingCalls.map(c => ({ worktree: c.worktree.toString(), branchName: c.branchName })), restoredDuringUnarchive, }, { - removeCalls: [{ worktree: worktree!.toString(), force: false }], + removeCalls: [{ worktree: worktree!.toString(), force: true }], removedDuringArchive: true, addExistingCalls: [{ worktree: worktree!.toString(), branchName }], restoredDuringUnarchive: true, diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index c6948d70c37132..7de2bb5cd3ea0a 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -564,6 +564,30 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa })); } + const spellingSuggestions = params.dictionarySuggestions ?? []; + const canAddToDictionary = !!params.misspelledWord && webContents.session.isPersistent(); + if (params.misspelledWord && (spellingSuggestions.length > 0 || canAddToDictionary)) { + if (menu.items.length > 0) { + menu.append(new MenuItem({ type: 'separator' })); + } + for (const suggestion of spellingSuggestions) { + menu.append(new MenuItem({ + label: suggestion, + click: () => webContents.replaceMisspelling(suggestion) + })); + } + if (canAddToDictionary) { + if (spellingSuggestions.length > 0) { + menu.append(new MenuItem({ type: 'separator' })); + } + menu.append(new MenuItem({ + label: localize('browser.contextMenu.addToDictionary', 'Add to Dictionary'), + click: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) + })); + } + menu.append(new MenuItem({ type: 'separator' })); + } + if (params.isEditable) { menu.append(new MenuItem({ role: 'cut', enabled: params.editFlags.canCut })); menu.append(new MenuItem({ role: 'copy', enabled: params.editFlags.canCopy })); diff --git a/src/vs/platform/github/common/githubPullRequestMutationService.ts b/src/vs/platform/github/common/githubPullRequestMutationService.ts index 3de72ecc50b7fc..010becf53dc5a7 100644 --- a/src/vs/platform/github/common/githubPullRequestMutationService.ts +++ b/src/vs/platform/github/common/githubPullRequestMutationService.ts @@ -43,6 +43,10 @@ export interface EnablePullRequestAutoMergeOptions { readonly method: 'MERGE' | 'SQUASH' | 'REBASE'; } +export interface PullRequestNodeOptions { + readonly pullRequestId: string; +} + export interface PullRequestCommentOptions extends PullRequestOperation { readonly body: string; } @@ -147,6 +151,8 @@ export interface PullRequestEnqueueResult { export interface PullRequestMutationApi { createPullRequest(ref: GitHubRepositoryRef, options: CreatePullRequestOptions, signal: AbortSignal): Promise; enableAutoMerge(ref: GitHubRepositoryRef, options: EnablePullRequestAutoMergeOptions, signal: AbortSignal): Promise; + disableAutoMerge(ref: PullRequestRef, options: PullRequestNodeOptions, signal: AbortSignal): Promise; + markReadyForReview(ref: PullRequestRef, options: PullRequestNodeOptions, signal: AbortSignal): Promise; addComment(ref: PullRequestRef, options: PullRequestCommentOptions, signal: AbortSignal): Promise>; replyToThread(ref: PullRequestRef, options: PullRequestReplyOptions, signal: AbortSignal): Promise>; resolveThread(ref: PullRequestRef, threadId: string, signal: AbortSignal): Promise; diff --git a/src/vs/platform/github/common/pullRequestMutationService.ts b/src/vs/platform/github/common/pullRequestMutationService.ts index 054e45e15012ef..e20e7bdd582542 100644 --- a/src/vs/platform/github/common/pullRequestMutationService.ts +++ b/src/vs/platform/github/common/pullRequestMutationService.ts @@ -25,6 +25,7 @@ import { PullRequestMergeResult, PullRequestMutationApi, PullRequestMutationResult, + PullRequestNodeOptions, PullRequestReplyAndResolveOptions, PullRequestReplyAndResolveResult, PullRequestReplyOptions, @@ -93,6 +94,18 @@ const enableAutoMergeMutation = `mutation AgentHostEnablePullRequestAutoMerge($p } }`; +const disableAutoMergeMutation = `mutation AgentHostDisablePullRequestAutoMerge($pullRequestId: ID!) { + disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { + pullRequest { id } + } +}`; + +const markReadyForReviewMutation = `mutation AgentHostMarkPullRequestReadyForReview($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { + pullRequest { id isDraft } + } +}`; + export class PullRequestMutationService extends Disposable implements IPullRequestMutations { private readonly _mutationTails = new Map>(); @@ -169,6 +182,28 @@ export class PullRequestMutationService extends Disposable implements IPullReque }); } + disableAutoMerge( + ref: PullRequestRef, + options: PullRequestNodeOptions, + signal: AbortSignal, + ): Promise { + return this._serialize(ref, 'disableAutoMerge', async () => { + await this._pullRequestNodeMutation(ref, options, disableAutoMergeMutation, signal); + this._resources.invalidatePullRequest(ref, ['mergeability']); + }); + } + + markReadyForReview( + ref: PullRequestRef, + options: PullRequestNodeOptions, + signal: AbortSignal, + ): Promise { + return this._serialize(ref, 'markReadyForReview', async () => { + await this._pullRequestNodeMutation(ref, options, markReadyForReviewMutation, signal); + this._resources.invalidatePullRequest(ref, ['core', 'mergeability']); + }); + } + addComment( ref: PullRequestRef, options: PullRequestCommentOptions, @@ -751,6 +786,29 @@ export class PullRequestMutationService extends Disposable implements IPullReque return values; } + private async _pullRequestNodeMutation( + ref: PullRequestRef, + options: PullRequestNodeOptions, + mutation: string, + signal: AbortSignal, + ): Promise { + if (!options.pullRequestId) { + throw new GitHubRequestError('Pull request node ID is required for this mutation', 'validation'); + } + await this._withCredential(ref, signal, async (credential, combinedSignal) => { + const response = await this._transport.graphql( + credential.account, + credential.token, + this._endpoint.getGraphQlUri(), + mutation, + { pullRequestId: options.pullRequestId }, + combinedSignal, + 'mutation', + ); + throwGraphQLErrors(response.errors); + }); + } + private async _withCredential( ref: GitHubRepositoryRef, signal: AbortSignal, diff --git a/src/vs/platform/github/test/node/pullRequestMutationService.test.ts b/src/vs/platform/github/test/node/pullRequestMutationService.test.ts index 37ffacfc383494..cbde2a96a7e8b6 100644 --- a/src/vs/platform/github/test/node/pullRequestMutationService.test.ts +++ b/src/vs/platform/github/test/node/pullRequestMutationService.test.ts @@ -233,6 +233,37 @@ suite('PullRequestMutationService', () => { }); }); + test('marks a pull request ready and toggles auto-merge off through node mutations', async () => { + await withServers(async server => { + server.enqueue( + gitHubGraphQLStep({ + queryIncludes: 'AgentHostMarkPullRequestReadyForReview', + assert: request => assert.deepStrictEqual(request.graphQl?.variables, { pullRequestId: 'PR7' }), + response: gitHubGraphQLResponse({ + markPullRequestReadyForReview: { pullRequest: { id: 'PR7', isDraft: false } }, + }), + }), + gitHubGraphQLStep({ + queryIncludes: 'AgentHostDisablePullRequestAutoMerge', + assert: request => assert.deepStrictEqual(request.graphQl?.variables, { pullRequestId: 'PR7' }), + response: gitHubGraphQLResponse({ + disablePullRequestAutoMerge: { pullRequest: { id: 'PR7' } }, + }), + }), + ); + const { ref, resources, service } = setup(server); + + await service.markReadyForReview(ref, { pullRequestId: 'PR7' }, signal()); + await service.disableAutoMerge(ref, { pullRequestId: 'PR7' }, signal()); + + assert.deepStrictEqual(resources.invalidations, [ + { fragments: ['core', 'mergeability'] }, + { fragments: ['mergeability'] }, + ]); + server.assertSatisfied(); + }); + }); + test('retries only after a complete refresh proves a comment marker absent', async () => { await withServers(async server => { server.enqueue( diff --git a/src/vs/platform/webWorker/browser/webWorkerServiceImpl.ts b/src/vs/platform/webWorker/browser/webWorkerServiceImpl.ts index d21f940b41f558..b60b03f8606946 100644 --- a/src/vs/platform/webWorker/browser/webWorkerServiceImpl.ts +++ b/src/vs/platform/webWorker/browser/webWorkerServiceImpl.ts @@ -34,8 +34,13 @@ export class WebWorkerService implements IWebWorkerService { const workerRunnerUrl = this.getWorkerUrl(descriptor); const workerUrlWithNls = getWorkerBootstrapUrl(descriptor.label, workerRunnerUrl, this._getWorkerLoadingFailedErrorMessage(descriptor)); - const worker = new Worker(ttPolicy ? ttPolicy.createScriptURL(workerUrlWithNls) as unknown as string : workerUrlWithNls, { name: descriptor.label, type: 'module' }); - return whenESMWorkerReady(worker); + try { + const worker = createBlobWorker(workerUrlWithNls, { name: descriptor.label, type: 'module' }); + return whenESMWorkerReady(worker).finally(() => URL.revokeObjectURL(workerUrlWithNls)); + } catch (error) { + URL.revokeObjectURL(workerUrlWithNls); + throw error; + } } protected _getWorkerLoadingFailedErrorMessage(_descriptor: WebWorkerDescriptor): string | undefined { diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 0dd04742fda43b..ddb2b834693cca 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -49,6 +49,10 @@ The service owns: Core workbench registrations may expose Local, Copilot CLI, and Claude harnesses when their backing agents are available. The Agents Window exposes harnesses backed by registered session content providers and does not assume a Local fallback. +### `ICustomizationMigrationService` + +This shared workbench service computes the customization files that need migration for an explicit chat session. Results include file URIs for consumers and migratable-configuration metadata for migration flows that need source type and storage. The service also produces a localized, harness-specific hint summarizing available migrations for UI consumers. + ### `IHarnessDescriptor` Descriptors declare presentation and discovery policy. Widgets consume the descriptor rather than branching on a harness identifier. diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index eb7bb93d069b56..499eaa83c0e17f 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -6,7 +6,7 @@ This document specifies how the session layout controllers manage workbench layo | File | Spec | Rules | |------|------|-------| -| `contrib/layout/browser/baseSessionLayoutController.ts` (`BaseLayoutController`) | [baseSessionLayoutController.md](contrib/layout/browser/baseSessionLayoutController.md) | `B1`–`B5` | +| `contrib/layout/browser/baseSessionLayoutController.ts` (`BaseLayoutController`) | [baseSessionLayoutController.md](contrib/layout/browser/baseSessionLayoutController.md) | `B1`–`B6` | | `contrib/layout/browser/desktopSessionLayoutController.ts` (`LayoutController`) | [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md) | `D1`–`D11` | | `contrib/layout/browser/mobileSessionLayoutController.ts` (`MobileLayoutController`) | [mobileSessionLayoutController.md](contrib/layout/browser/mobileSessionLayoutController.md) | `M1`–`M2` | @@ -20,14 +20,15 @@ It is the detailed companion to the [layout-controller boundary](LAYOUT.md#layou ## 1. Overview -The Agents window keeps a single **active session** but lets the user move between many. Each session owns its editor working set and bottom-panel visibility. The classic layout also keeps auxiliary-bar and editor-part visibility per session. The single-pane layout keeps a shared Editor/Details profile for Existing Sessions; New Sessions use a one-time Editor opening rule. +The Agents window keeps a single **active session** but lets the user move between many. Each session owns its editor working set. The classic layout also remembers bottom-panel visibility, auxiliary-bar, and editor-part visibility per session. The single-pane layout instead governs bottom-panel *visibility* at the workbench level (like the side pane, persisted in `workbench.ts`) and remembers only which *view* the panel showed per session; it also keeps a shared Editor/Details profile for Existing Sessions, and New Sessions use a one-time Editor opening rule. -`LayoutController` owns three independent pieces of per-session state, all keyed by session resource (`URI`) and persisted to workspace storage: +`LayoutController` owns per-session state, keyed by session resource (`URI`) and persisted to workspace storage: | State | Storage map | Scope | |-------|-------------|-------| | Auxiliary bar (secondary side bar) | `_viewStateBySession` | Classic layout only: visibility + active view container | -| Panel (terminal / debug output) | `_panelVisibilityBySession` | visibility only | +| Panel visibility (terminal / debug output) | `_panelVisibilityBySession` | Classic layout only: visibility. Single-pane governs panel visibility at the workbench level instead | +| Panel view | `_panelViewBySession` | Single-pane only: which pane composite the panel last showed (defaults to Terminal) | | Editor working set | `_workingSets` | open editors in the grid editor part | | Editor part visibility | `_editorPartHiddenBySession` | Classic layout only: whether the editor part was left hidden | @@ -107,6 +108,8 @@ The auxiliary-bar **part** is kept hidden whenever it has **no active view conta ## 4. Panel +### 4.1 Classic layout — visibility per session (B1) + `_syncPanelVisibility(resource)`: - No active session → hide the panel. @@ -114,6 +117,10 @@ The auxiliary-bar **part** is kept hidden whenever it has **no active view conta The per-session record is updated whenever the user toggles the panel: an `onDidChangePartVisibility` listener for `PANEL_PART` writes the new visibility for the active session (suppressed while multiple sessions are visible). Panel height is global workbench state, not per-session layout state. When Quick Chat hides the side pane, returning restores the panel first and then reveals the single-pane Editor. That Editor reveal must preserve the panel's current height; otherwise grid redistribution shrinks the panel to its minimum. +### 4.2 Single-pane layout — visibility at workbench level, view per session (B6) + +Single-pane sets `_isPanelVisibilityPerSession = false`. That single gate is the inverse of B1: the panel's **visibility** is no longer per session — it is owned by the workbench (like the side pane) and persisted in `workbench.ts` alongside the other parts (`_applyPersistedPartVisibility` / `_savePartVisibility`, saved on `setPanelHidden`). Instead, the base remembers which **view** (pane composite) the panel last showed per session in `_panelViewBySession`, captured from `IPaneCompositePartService.onDidPaneCompositeOpen`. `_syncPanelView(resource)` restores it on session switch and when the panel is revealed — but only while the panel is already visible, so restoring a view never forces the panel open. A session with no remembered view falls back to `TERMINAL_VIEW_ID`. The view is persisted per session as `panelViewContainerId`; on a draft→committed submit the draft's remembered view is copied to the committed resource (`_onSessionReplaced`). + --- ## 5. Editor Working Sets @@ -147,7 +154,7 @@ In single-pane mode, layout-driven managed Changes/File tab opens remain exclude - `_saveState` captures the active session's current view state, working set, and editor part hidden state (skipping untitled / multi-session cases) and writes one `ISessionLayoutEntry` per known session resource. - The classic layout's shared new-session view state (§3.2 step 2) is persisted separately under the workspace-scoped key `sessions.newSessionViewState` as an `INewSessionViewState` object, written immediately whenever the user toggles the aux bar on the new-session view (not on shutdown). - `_loadState` reads `sessions.newSessionViewState` and `sessions.layoutState`; if the latter is absent it performs a one-time migration from the legacy `sessions.workingSets` key and then removes it. Corrupted data is dropped defensively. -- Single-pane editor working sets use `sessions.singlePane.layoutState`; the Existing Session editor/detail profile is written immediately to `sessions.singlePane.sidePaneVisibility`. +- Single-pane editor working sets and per-session panel view (`panelViewContainerId`) use `sessions.singlePane.layoutState`; the Existing Session editor/detail profile is written immediately to `sessions.singlePane.sidePaneVisibility`. Single-pane bottom-panel **visibility** is not per-session state — it is persisted with the other workbench parts under the workbench part-visibility key (`workbench.ts`). --- diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 7bcddd91cc53ea..ab343c2293eed3 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -96,7 +96,7 @@ /* ---- Widget Customizations ---- */ /* Action Widget */ -.agent-sessions-workbench .action-widget .monaco-list .monaco-list-row { +.agent-sessions-workbench .action-widget .monaco-list .monaco-list-row:not(.has-standalone-toggle) { padding-right: 0; } diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index e893f52e7c4882..d5ab97a8281df1 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -59,6 +59,18 @@ export const Menus = { SessionsEditorTabsBarContext: new MenuId('SessionsEditorTabsBarContext'), SessionsEditorTabsBarAddTab: new MenuId('SessionsEditorTabsBarAddTab'), SessionHeaderMeta: new MenuId('SessionsSessionHeaderMeta'), + + /** + * Entries merged into the dropdown of the changes button bar's primary + * button. A submenu contributed to its `primary` group names a group of + * related actions and takes over the button when it applies. + */ + ChangesOperationsDropdown: new MenuId('SessionsChangesOperationsDropdown'), + /** Agent Merge's own entries, opened as a context menu from its button. */ + ChangesAgentMerge: new MenuId('SessionsChangesAgentMerge'), + /** Choices for when Agent Merge may merge the pull request. */ + ChangesAgentMergeMergePullRequest: new MenuId('SessionsChangesAgentMergeMergePullRequest'), + SessionHeaderContext: MenuId.SessionHeaderContext, SessionItemContextMenu: MenuId.SessionItemContextMenu, } as const; diff --git a/src/vs/sessions/browser/parts/media/sessionsPart.css b/src/vs/sessions/browser/parts/media/sessionsPart.css index 1cce8687f69cb9..f00e78c9b7dee2 100644 --- a/src/vs/sessions/browser/parts/media/sessionsPart.css +++ b/src/vs/sessions/browser/parts/media/sessionsPart.css @@ -8,6 +8,17 @@ visibility: hidden !important; } +.monaco-workbench .part.sessionspart { + position: relative; + isolation: isolate; +} + +.monaco-workbench .part.sessionspart > .title, +.monaco-workbench .part.sessionspart > .content { + position: relative; + z-index: 2; +} + .monaco-workbench .part.sessionspart > .content .monaco-editor, .monaco-workbench .part.sessionspart > .content .monaco-editor .margin, .monaco-workbench .part.sessionspart > .content .monaco-editor .monaco-editor-background { diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index 26e97ffe58c32a..6d8ad9b0ceb92a 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -29,6 +29,8 @@ import { IProgressIndicator } from '../../../platform/progress/common/progress.j import { AbstractProgressScope, ScopedProgressIndicator } from '../../../workbench/services/progress/browser/progressIndicator.js'; import { IAgentWorkbenchLayoutService } from '../workbench.js'; import { applyAgentsPartCardStyles, getAgentsPartCardContentSize } from './agentsPartCard.js'; +import { SessionsChatBackgroundRenderer } from '../../services/chatBackground/browser/chatBackgroundRenderer.js'; +import { ISessionsChatBackgroundService } from '../../services/chatBackground/browser/chatBackgroundService.js'; interface IGridSlot { readonly view: SessionView; @@ -92,6 +94,7 @@ export class SessionsPart extends Part { @IAgentWorkbenchLayoutService private readonly agentWorkbenchLayoutService: IAgentWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @ISessionsChatBackgroundService private readonly chatBackgroundService: ISessionsChatBackgroundService, ) { super( Parts.SESSIONS_PART, @@ -115,6 +118,11 @@ export class SessionsPart extends Part { } protected override createContentArea(parent: HTMLElement): HTMLElement { + const backgroundRenderer = this._register(new SessionsChatBackgroundRenderer(parent)); + const updateBackground = () => backgroundRenderer.setBackground(this.chatBackgroundService.getBackground()); + this._register(this.chatBackgroundService.onDidChangeBackground(updateBackground)); + updateBackground(); + const contentArea = $('.content'); parent.appendChild(contentArea); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index c51858724353b1..27158096898604 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -774,7 +774,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } } - private _loadPartVisibility(storageService: IStorageService): { editor?: boolean; auxiliaryBar?: boolean; sidebar?: boolean } { + private _loadPartVisibility(storageService: IStorageService): { editor?: boolean; auxiliaryBar?: boolean; sidebar?: boolean; panel?: boolean } { if (this.layoutPolicy.viewportClass.get() === 'phone') { return {}; } @@ -803,6 +803,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.partVisibility.editor = savedPartVisibility.editor ?? this.partVisibility.editor; this.partVisibility.auxiliaryBar = savedPartVisibility.auxiliaryBar ?? this.partVisibility.auxiliaryBar; this.partVisibility.sidebar = savedPartVisibility.sidebar ?? this.partVisibility.sidebar; + // The single-pane layout governs the bottom panel at the workbench level + // (like the side pane), so its visibility is restored here. The classic + // layout remembers the panel per session and never persists it here. + if (this.isSinglePaneLayoutEnabled) { + this.partVisibility.panel = savedPartVisibility.panel ?? this.partVisibility.panel; + } } protected _savePartVisibility(): void { @@ -814,6 +820,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic editor: this.partVisibility.editor, auxiliaryBar: this.partVisibility.auxiliaryBar, sidebar: this.partVisibility.sidebar, + // Only the single-pane layout persists panel visibility at the workbench + // level; the classic layout tracks it per session instead. + panel: this.isSinglePaneLayoutEnabled ? this.partVisibility.panel : undefined, }), StorageScope.WORKSPACE, StorageTarget.MACHINE); } @@ -2465,6 +2474,13 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.focusPart(Parts.PANEL_PART); } } + + // The single-pane layout governs the panel at the workbench level, so its + // visibility persists across reloads (like the side pane). The classic + // layout remembers it per session and never persists it here. + if (this.isSinglePaneLayoutEnabled) { + this._savePartVisibility(); + } } private setSessionsHidden(hidden: boolean): void { diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index d4986b0086689f..151124e19ced65 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -77,6 +77,29 @@ export const AutomationsHasItemsContext = new RawContextKey('automation //#endregion +//#region < --- Changes --- > + +/** + * Id of the first pull request operation the agent host currently advertises + * for the active session (for example `pr-merge`), or the empty string when + * none is advertised. Lets client-side contributions to the changes button bar + * react to the pull request's live state without duplicating it. + */ +export const SessionPrimaryPullRequestOperationContext = new RawContextKey('sessionPrimaryPullRequestOperation', '', localize('sessionPrimaryPullRequestOperation', "The id of the first pull request operation advertised for the active session, or empty when there is none")); + +/** Whether Agent Merge is currently enabled on the active session. */ +export const SessionAgentMergeEnabledContext = new RawContextKey('sessionAgentMergeEnabled', false, localize('sessionAgentMergeEnabled', "True when Agent Merge is enabled for the active agent session")); + +/** + * Whether the active session has an open pull request, independent of whether + * the agent host has any operation to offer for it. A pull request that is + * open but blocked — and whose repository does not allow auto-merge — offers + * no operation, yet is exactly when Agent Merge has work to do. + */ +export const SessionHasOpenPullRequestContext = new RawContextKey('sessionHasOpenPullRequest', false, localize('sessionHasOpenPullRequest', "Whether the active session's branch has an open pull request")); + +//#endregion + //#region < --- Welcome --- > export const SessionsWelcomeVisibleContext = new RawContextKey('sessionsWelcomeVisible', false, localize('sessionsWelcomeVisible', "Whether the sessions welcome overlay is visible")); diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts index 1be7f4d263631d..5d5b3d84455047 100644 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -85,7 +85,7 @@ type ChangesViewVersionModeChangeEvent = { type ChangesViewVersionModeChangeClassification = { owner: 'osortega'; - comment: 'Tracks when the user switches the version mode in the Changes panel (Branch Changes, All Changes, Last Turn).'; + comment: 'Tracks when the user switches the version mode in the Changes panel (Branch Changes, Session Changes, Last Turn).'; mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version mode selected by the user.' }; }; diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index b1a4fad565b4bc..68b63ba52b27c4 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -177,8 +177,6 @@ class TitleBarAccountWidget extends BaseActionViewItem { private container: HTMLElement | undefined; private avatarElement: HTMLImageElement | undefined; private iconElement: HTMLElement | undefined; - private codexAvatarElement: HTMLImageElement | undefined; - private codexIconElement: HTMLElement | undefined; private codexPanelAvatarElement: HTMLImageElement | undefined; private codexPanelIconElement: HTMLElement | undefined; private labelElement: HTMLElement | undefined; @@ -281,11 +279,6 @@ class TitleBarAccountWidget extends BaseActionViewItem { this.avatarElement.decoding = 'async'; this.avatarElement.referrerPolicy = 'no-referrer'; this.iconElement = append(container, $('.sessions-account-titlebar-widget-icon')); - this.codexAvatarElement = append(container, $('img.sessions-account-titlebar-widget-codex-avatar', { alt: localize('chatGPTAvatarAltFallback', "ChatGPT profile image"), draggable: 'false' })) as HTMLImageElement; - this.codexAvatarElement.decoding = 'async'; - this.codexAvatarElement.referrerPolicy = 'no-referrer'; - this.codexIconElement = append(container, $('.sessions-account-titlebar-widget-codex-icon')); - this.codexIconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.openai)); this.labelElement = append(container, $('span.sessions-account-titlebar-widget-label')); this.badgeElement = append(container, $('span.sessions-account-titlebar-widget-badge')); @@ -320,7 +313,7 @@ class TitleBarAccountWidget extends BaseActionViewItem { } private renderState(): void { - if (!this.container || !this.avatarElement || !this.iconElement || !this.codexAvatarElement || !this.codexIconElement || !this.labelElement || !this.badgeElement) { + if (!this.container || !this.avatarElement || !this.iconElement || !this.labelElement || !this.badgeElement) { return; } @@ -329,11 +322,6 @@ class TitleBarAccountWidget extends BaseActionViewItem { const entitlement = this.accountName && this.chatEntitlementService.entitlement === ChatEntitlement.Unknown ? ChatEntitlement.Unresolved : this.chatEntitlementService.entitlement; - const hasChatGPTAccount = hasSignedInCodexChatGPTAccount( - this.codexAccountService.account, - shouldShowCodexAccount(this.configurationService, true), - ); - const state = getAccountTitleBarState({ isAccountLoading: this.isAccountLoading, accountName: this.accountName, @@ -359,8 +347,6 @@ class TitleBarAccountWidget extends BaseActionViewItem { const shouldShowDotBadge = !!badgeKey && badgeKey !== this.dismissedBadgeKey; const loadedAvatarUrl = !this.isAccountLoading ? this.loadedAvatarUrl : undefined; const hasLoadedAvatar = !!loadedAvatarUrl; - const loadedCodexAvatarUrl = hasChatGPTAccount ? this.loadedCodexAvatarUrl : undefined; - const hasLoadedCodexAvatar = !!loadedCodexAvatarUrl; const titleBarIcon = state.dotBadge ? Codicon.account : state.icon; this.avatarElement.classList.toggle('visible', hasLoadedAvatar); @@ -375,17 +361,6 @@ class TitleBarAccountWidget extends BaseActionViewItem { this.iconElement.className = `sessions-account-titlebar-widget-icon ${ThemeIcon.asClassName(titleBarIcon)}`; this.iconElement.classList.toggle('hidden', hasLoadedAvatar); - this.container.classList.toggle('has-chatgpt-account', hasChatGPTAccount); - this.codexAvatarElement.classList.toggle('visible', hasLoadedCodexAvatar); - this.codexAvatarElement.alt = this.getCodexAvatarAltText(); - if (loadedCodexAvatarUrl) { - if (this.codexAvatarElement.src !== loadedCodexAvatarUrl) { - this.codexAvatarElement.src = loadedCodexAvatarUrl; - } - } else { - this.codexAvatarElement.removeAttribute('src'); - } - this.codexIconElement.classList.toggle('visible', hasChatGPTAccount && !hasLoadedCodexAvatar); this.labelElement.textContent = ''; this.badgeElement.textContent = ''; this.badgeElement.classList.toggle('dot-badge', shouldShowDotBadge); diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css index ea524016e1d87b..4d0320c78a23c1 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css @@ -20,12 +20,6 @@ overflow: hidden; } -.agent-sessions-workbench .sessions-account-titlebar-widget.has-chatgpt-account { - width: 28px; - min-width: 28px; - overflow: visible; -} - .agent-sessions-workbench .sessions-account-titlebar-widget:hover, .agent-sessions-workbench .action-item:focus-within .sessions-account-titlebar-widget { background: var(--vscode-toolbar-hoverBackground); @@ -67,59 +61,10 @@ display: block; } -.agent-sessions-workbench .sessions-account-titlebar-widget.has-chatgpt-account .sessions-account-titlebar-widget-avatar, -.agent-sessions-workbench .sessions-account-titlebar-widget.has-chatgpt-account .sessions-account-titlebar-widget-icon { - position: absolute; - left: 1px; - z-index: 2; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-codex-avatar:not(.visible), -.agent-sessions-workbench .sessions-account-titlebar-widget-codex-icon:not(.visible) { - display: none; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-codex-avatar, -.agent-sessions-workbench .sessions-account-titlebar-widget-codex-icon { - width: 18px; - height: 18px; - border: 1px solid var(--vscode-titleBar-activeBackground); - border-radius: 50%; - box-sizing: border-box; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-codex-avatar.visible { - display: block; - object-fit: cover; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-codex-icon { - align-items: center; - justify-content: center; - font-size: 15px; - background: var(--vscode-titleBar-activeBackground); - color: var(--vscode-foreground); -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-codex-icon.visible { - display: flex; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget.has-chatgpt-account .sessions-account-titlebar-widget-codex-avatar, -.agent-sessions-workbench .sessions-account-titlebar-widget.has-chatgpt-account .sessions-account-titlebar-widget-codex-icon { - position: absolute; - right: 0; - z-index: 1; -} - .monaco-workbench .part.titlebar.inactive .sessions-account-titlebar-widget-avatar { border-color: var(--vscode-commandCenter-inactiveBorder, var(--vscode-commandCenter-border, transparent)); } -.monaco-workbench .part.titlebar.inactive .sessions-account-titlebar-widget-codex-avatar { - border-color: var(--vscode-titleBar-inactiveBackground); -} - .agent-sessions-workbench .sessions-account-titlebar-widget-label { display: none; } diff --git a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css index 40261e5d9b84e3..63176fa2c5147f 100644 --- a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css +++ b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css @@ -8,23 +8,6 @@ /* --- Water --- */ -/* Make the chat bar a positioning context for the water layer (its first - * child) and keep its real children above the decorative water — without - * explicit z-index, positioned siblings paint after non-positioned ones. - * `isolation` keeps the raised children below a stacking context of their own, - * so they compete with the water rather than with workbench-level overlays such - * as context menus and editor overflow widgets, which would otherwise be - * painted behind this part. */ -.monaco-workbench .part.sessionspart { - position: relative; - isolation: isolate; -} -.monaco-workbench .part.sessionspart > .title, -.monaco-workbench .part.sessionspart > .content { - position: relative; - z-index: 1; -} - /* When the aquarium is active, the Sessions Grid wrappers (added between the * sessionspart and the chat widget) would otherwise paint their opaque * `--session-view-background` on top of the water layer and hide everything. @@ -40,7 +23,7 @@ inset: 0; pointer-events: none; overflow: hidden; - z-index: 0; + z-index: 1; border-radius: inherit; opacity: 0; transition: opacity 320ms ease-out; diff --git a/src/vs/sessions/contrib/aquarium/test/browser/aquariumOverlay.test.ts b/src/vs/sessions/contrib/aquarium/test/browser/aquariumOverlay.test.ts index 4cb0532fd73430..e903a1e5d09edb 100644 --- a/src/vs/sessions/contrib/aquarium/test/browser/aquariumOverlay.test.ts +++ b/src/vs/sessions/contrib/aquarium/test/browser/aquariumOverlay.test.ts @@ -3,7 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import '../../browser/media/aquarium.css'; +import '../../../../browser/parts/media/sessionsPart.css'; import assert from 'assert'; +import { getWindow } from '../../../../../base/browser/dom.js'; import { IManagedHover } from '../../../../../base/browser/ui/hover/hover.js'; import { toDisposable } from '../../../../../base/common/lifecycle.js'; import { mock } from '../../../../../base/test/common/mock.js'; @@ -14,7 +17,7 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { InMemoryStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; -import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { AquariumService, SESSIONS_DEVELOPER_JOY_ENABLED_SETTING } from '../../browser/aquariumOverlay.js'; import { disposeSharedFishDefs, Fish, FishSpecies } from '../../browser/fish.js'; @@ -89,6 +92,66 @@ suite('AquariumService', () => { }); }); + test('layers the aquarium below sessions content', () => { + const mainContainer = document.createElement('div'); + mainContainer.className = 'monaco-workbench'; + const sessionsContainer = document.createElement('div'); + sessionsContainer.className = 'part sessionspart'; + const content = document.createElement('div'); + content.className = 'content'; + const toggleContainer = document.createElement('div'); + sessionsContainer.append(content); + mainContainer.append(sessionsContainer, toggleContainer); + document.body.appendChild(mainContainer); + store.add(toDisposable(() => mainContainer.remove())); + + const storageService = store.add(new InMemoryStorageService()); + const layoutService = new class extends mock() { + override readonly mainContainer = mainContainer; + override getContainer(_targetWindow: Window, part?: Parts): HTMLElement { + return part === Parts.SESSIONS_PART ? sessionsContainer : mainContainer; + } + override isVisible(): boolean { + return true; + } + }(); + const hoverService = new class extends mock() { + override setupManagedHover(): IManagedHover { + return { + dispose() { }, + show() { }, + hide() { }, + update() { }, + }; + } + }(); + const configurationService = new TestConfigurationService({ [SESSIONS_DEVELOPER_JOY_ENABLED_SETTING]: true }); + store.add(configurationService.onDidChangeConfigurationEmitter); + const service = store.add(new AquariumService( + layoutService, + new MockContextKeyService(), + hoverService, + storageService, + configurationService, + new TestAccessibilityService(), + new NullTelemetryServiceShape(), + )); + store.add(service.mountToggle(toggleContainer)); + toggleContainer.querySelector('.agents-aquarium-toggle')?.click(); + + const water = sessionsContainer.querySelector(':scope > .agents-aquarium-water'); + const targetWindow = getWindow(sessionsContainer); + assert.deepStrictEqual({ + active: sessionsContainer.classList.contains('aquarium-active'), + waterZIndex: water ? targetWindow.getComputedStyle(water).zIndex : undefined, + contentZIndex: targetWindow.getComputedStyle(content).zIndex, + }, { + active: true, + waterZIndex: '1', + contentZIndex: '2', + }); + }); + test('creates aquarium elements in the main realm for an auxiliary window', () => { const iframe = document.createElement('iframe'); document.body.appendChild(iframe); diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index bd891bf0d159c0..a369dc3fa420be 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -17,6 +17,7 @@ import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../pl import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { ActiveEditorContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -27,7 +28,8 @@ import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/d import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; -import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasWorkspaceContext } from '../../../common/contextkeys.js'; +import { AGENT_HOST_PULL_REQUEST_OPERATION_IDS } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasOpenPullRequestContext, SessionHasWorkspaceContext, SessionPrimaryPullRequestOperationContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { SessionChangesetOperationScope } from '../../../services/sessions/common/session.js'; @@ -408,7 +410,8 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme constructor( @IChangesViewService changesViewService: IChangesViewService, - @IContextKeyService contextKeyService: IContextKeyService + @IContextKeyService contextKeyService: IContextKeyService, + @ILogService logService: ILogService ) { super(); @@ -440,6 +443,27 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme return clientReviewedFilesObs.read(reader) ?? agentHostReviewedFilesObs.read(reader); })); + let lastPullRequestOperation: string | undefined; + this._register(bindContextKey(SessionPrimaryPullRequestOperationContext, contextKeyService, reader => { + const operations = changesViewService.activeSessionChangesetObs.read(reader)?.operations.read(reader) ?? []; + const primary = operations.find(op => AGENT_HOST_PULL_REQUEST_OPERATION_IDS.has(op.id))?.id ?? ''; + if (lastPullRequestOperation !== primary) { + lastPullRequestOperation = primary; + // Gates which Agent Merge entries the dropdown offers, so it is + // logged alongside the button bar itself. + logService.info(`[ChangesetOperationsActionController] Primary pull request operation: ${primary || 'none'}`); + } + return primary; + })); + + // Bound globally, unlike the Changes view's scoped copy, so title bar + // menus can tell "has an open pull request" apart from "has a pull + // request operation to offer" — the two differ for a blocked pull + // request in a repository that does not allow auto-merge. + this._register(bindContextKey(SessionHasOpenPullRequestContext, contextKeyService, reader => { + return changesViewService.activeSessionStateObs.read(reader)?.hasOpenPullRequest === true; + })); + this._register(autorun(reader => { const changeset = changesViewService.activeSessionChangesetObs.read(reader); const resourceOperations = (changeset?.operations.read(reader) ?? []) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index f06c3296f56ca9..93e2f82afaa4f5 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -27,7 +27,7 @@ import { IActionViewItemService } from '../../../../platform/actions/browser/act import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ActionWidgetDropdownActionViewItem } from '../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; -import { MenuId, Action2, MenuItemAction, registerAction2, IMenuService } from '../../../../platform/actions/common/actions.js'; +import { MenuId, Action2, MenuItemAction, registerAction2, IMenuService, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider } from '../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -45,7 +45,7 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { SessionIsActiveContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; +import { SessionAgentMergeEnabledContext, SessionIsActiveContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; import { SessionChangesEditorInput } from './sessionChangesEditorInput.js'; import { defaultCountBadgeStyles, defaultProgressBarStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IWorkspaceContextService, WorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; @@ -290,12 +290,22 @@ class ChangesMenuWorkbenchButtonBarWidget extends Disposable implements IChanges // --- ButtonBar widget (Agent Host) +/** + * Menu group on {@link Menus.ChangesOperationsDropdown} whose action + * takes over the primary button of the changes button bar. Every other group + * on that menu only contributes dropdown entries. + */ +export const CHANGES_OPERATIONS_DROPDOWN_PRIMARY_GROUP = 'primary'; + class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButtonBarWidget { private readonly _buttonBar: WorkbenchButtonBar; readonly onDidChangeActions: Event; get hasActions(): boolean { return this._buttonBar.buttons.length > 0; } + /** Signature of the last logged button bar, so only changes are logged. */ + private _lastLoggedButtonBar: string | undefined; + constructor( container: HTMLElement, @IMenuService menuService: IMenuService, @@ -303,10 +313,18 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, @IChatPetService chatPetService: IChatPetService, + @IContextMenuService contextMenuService: IContextMenuService, + @ILogService private readonly logService: ILogService, ) { super(); const menu = this._register(menuService.createMenu(MenuId.AgentsChangesToolbar, contextKeyService, { emitEventsForSubmenuChanges: true })); + const dropdownMenu = this._register(menuService.createMenu(Menus.ChangesOperationsDropdown, contextKeyService, { emitEventsForSubmenuChanges: true })); + + // Whether the primary button's work is in flight. Read by the button + // config provider below, which `buttonBar.update` calls synchronously + // from the same autorun that computes it. + let primaryIsBusy = false; const buttonBar = this._buttonBar = this._register(instantiationService.createInstance( WorkbenchButtonBar, @@ -316,7 +334,7 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt renderSecondaryActions: false, buttonConfigProvider: (action, index) => { return index === 0 - ? { showIcon: false, showLabel: true, customLabel: stripIcons(action.label) } + ? { showIcon: false, showLabel: true, customLabel: stripIcons(action.label), showSpinner: primaryIsBusy } : { showIcon: true, showLabel: false }; } } @@ -328,10 +346,40 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt return getActionBarActions(menu.getActions({ shouldForwardArgs: true })); }); - const operationActionGroupsObs = derived(reader => { + const agentMergeEnabledObs = observableFromEvent(contextKeyService.onDidChangeContext, () => + contextKeyService.getContextKeyValue(SessionAgentMergeEnabledContext.key) === true); + + // Client-side entries that belong *inside* the operations dropdown rather + // than beside it. The `primary` group is special: an action contributed + // there takes over the primary button when it applies, which is how + // Agent Merge can own the button without the widget knowing about it. + // + // A submenu contributed to that group names a group of related actions + // rather than being an action itself, so clicking the button opens just + // those actions as a context menu — the button's own dropdown carries + // unrelated operations too. + const dropdownMenuActionsObs = observableFromEvent(dropdownMenu.onDidChange, () => { + const groups = dropdownMenu.getActions({ shouldForwardArgs: true }); + const primaryGroup = groups.find(([group]) => group === CHANGES_OPERATIONS_DROPDOWN_PRIMARY_GROUP)?.[1] ?? []; + const rest = groups.filter(([group]) => group !== CHANGES_OPERATIONS_DROPDOWN_PRIMARY_GROUP).map(([, actions]) => actions); + const contributed = primaryGroup[0]; + const primary = contributed instanceof SubmenuItemAction + ? toAction({ + id: contributed.item.submenu.id, + label: contributed.label, + run: () => contextMenuService.showContextMenu({ + getAnchor: () => buttonBar.buttons[0]?.element ?? container, + getActions: () => contributed.actions, + }), + }) + : contributed; + return { primary, contributed, isAgentMerge: contributed instanceof SubmenuItemAction && contributed.item.submenu === Menus.ChangesAgentMerge, groups: primaryGroup.length > 0 ? [primaryGroup, ...rest] : rest }; + }); + + const operationActionGroupsObs = derived<{ readonly groups: IAction[][]; readonly hasRunning: boolean }>(reader => { const changeset = changesViewService.activeSessionChangesetObs.read(reader); if (!changeset) { - return []; + return { groups: [], hasRunning: false }; } const operations = changesViewService.activeSessionChangesetOperationsObs.read(reader); @@ -340,16 +388,17 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt const toOperationAction = (op: ISessionChangesetOperation) => toAction({ id: op.id, - label: op.icon - ? op.status === SessionChangesetOperationStatus.Running - ? `$(loading) ${op.label}` - : `$(${op.icon.id}) ${op.label}` - : op.status === SessionChangesetOperationStatus.Running - ? `$(loading) ${op.label}` - : op.label, + // A running operation shows the animated spinner on the primary + // button instead of an icon, so no `$(loading)` prefix here. + label: op.icon && op.status !== SessionChangesetOperationStatus.Running + ? `$(${op.icon.id}) ${op.label}` + : op.label, tooltip: op.description ?? op.label, enabled: op.status !== SessionChangesetOperationStatus.Disabled && op.status !== SessionChangesetOperationStatus.Running, - run: () => changeset.invokeOperation(op.id), + run: () => { + this.logService.info(`[ChangesWorkbenchButtonBarWidget] Invoking changeset operation from the title bar: operation=${op.id}`); + return changeset.invokeOperation(op.id); + }, }); // Group the remaining changeset-scoped operations by their @@ -377,12 +426,15 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt .filter(op => op.status === SessionChangesetOperationStatus.Running) .map(toOperationAction); - return [ - ...(runningActions.length > 0 - ? [runningActions] - : []), - ...groups.values(), - ]; + return { + groups: [ + ...(runningActions.length > 0 + ? [runningActions] + : []), + ...groups.values(), + ], + hasRunning: runningActions.length > 0, + }; }); this._register(autorun(reader => { @@ -391,21 +443,31 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt return; } - const operationActionGroups = operationActionGroupsObs.read(reader); + const operations = operationActionGroupsObs.read(reader); const menuActions = menuActionsObs.read(reader); + const dropdownMenuActions = dropdownMenuActionsObs.read(reader); const primaryActions: IAction[] = []; - const operationActions = operationActionGroups.flat(); - - if (operationActions.length > 1) { - // The action groups are build so that the - // running action(s) appear in the first group - const primaryAction = operationActions[0]; - + // A running operation always keeps the primary button so its spinner + // stays visible; otherwise a contributed primary entry wins over the + // first advertised operation. + const usesContributedPrimary = !operations.hasRunning && dropdownMenuActions.primary !== undefined; + const primaryAction = usesContributedPrimary ? dropdownMenuActions.primary : operations.groups[0]?.[0]; + + // The button bar treats the first entry of a submenu as the button + // itself and the remainder as the dropdown, so the primary has to + // lead. A contributed primary only names its own actions, so the + // menu entry it came from is dropped rather than repeated below it. + const groups = [...operations.groups, ...dropdownMenuActions.groups] + .map(group => group.filter(action => action !== dropdownMenuActions.contributed)) + .filter(group => group.length > 0); + const entryCount = groups.reduce((count, group) => count + group.length, 0); + + if (primaryAction && (usesContributedPrimary ? entryCount > 0 : entryCount > 1)) { // Join the groups with separators to // visually separate related operations. - const dropdownActions: IAction[] = []; - for (const group of operationActionGroups) { + const dropdownActions: IAction[] = usesContributedPrimary ? [primaryAction] : []; + for (const group of groups) { if (dropdownActions.length > 0) { dropdownActions.push(new Separator()); } @@ -413,14 +475,58 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt } primaryActions.push(new SubmenuAction('changesView.operations.primary.dropdown', primaryAction.label, dropdownActions)); - } else { - primaryActions.push(...operationActions); + } else if (primaryAction) { + primaryActions.push(primaryAction); } primaryActions.push(...menuActions.primary); + + // A contributed primary is a group label rather than an action, so it + // cannot report progress itself. Agent Merge is busy for as long as + // it is enabled, since it watches the pull request continuously. + primaryIsBusy = usesContributedPrimary + ? dropdownMenuActions.isAgentMerge && agentMergeEnabledObs.read(reader) + : operations.hasRunning; buttonBar.update(primaryActions, menuActions.secondary); + + this._logButtonBar(primaryAction, usesContributedPrimary, operations.hasRunning, primaryIsBusy, groups, menuActions.primary); })); } + + /** + * Logs what the titlebar button bar actually renders, whenever that + * changes. The autorun below re-runs on every git, GitHub, menu and + * context-key change, so only transitions are logged. + */ + private _logButtonBar( + primaryAction: IAction | undefined, + usesContributedPrimary: boolean, + hasRunningOperation: boolean, + showsSpinner: boolean, + dropdownGroups: readonly IAction[][], + trailingActions: readonly IAction[], + ): void { + const primaryLabel = primaryAction ? stripIcons(primaryAction.label) : undefined; + const dropdownIds = dropdownGroups.flat().map(action => action.id); + const signature = JSON.stringify([primaryAction?.id, primaryLabel, usesContributedPrimary, hasRunningOperation, showsSpinner, dropdownIds, trailingActions.map(action => action.id)]); + if (this._lastLoggedButtonBar === signature) { + return; + } + this._lastLoggedButtonBar = signature; + + if (!primaryAction) { + this.logService.info(`[ChangesWorkbenchButtonBarWidget] Title bar button hidden: no primary action is available${trailingActions.length > 0 ? `, trailing=[${trailingActions.map(action => action.id).join(', ')}]` : ''}`); + return; + } + + // `source` answers "why is *this* button showing" at a glance: a running + // operation pins the button, a contributed primary (e.g. Agent Merge) + // takes it over, otherwise it is the host's first advertised operation. + const source = hasRunningOperation + ? 'running-operation' + : usesContributedPrimary ? 'contributed-menu' : 'advertised-operation'; + this.logService.info(`[ChangesWorkbenchButtonBarWidget] Title bar button: label="${primaryLabel}", id=${primaryAction.id}, source=${source}, spinner=${showsSpinner}, dropdown=[${dropdownIds.join(', ')}]`); + } } /** diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index 35ad8683c9775c..d32d1ef33b99fd 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -22,7 +22,7 @@ import { DiffEditorInput } from '../../../../workbench/common/editor/diffEditorI import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { Menus } from '../../../browser/menus.js'; -import { SessionHasChangesContext, SessionIsCreatedContext, SinglePaneDiffEditorInputActiveContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; +import { CustomViewVisibleContext, SessionHasChangesContext, SessionIsCreatedContext, SinglePaneDiffEditorInputActiveContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; import { logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; @@ -185,6 +185,7 @@ class ChangesHeaderActionsAction extends Action2 { when: ContextKeyExpr.and( IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated(), + CustomViewVisibleContext.negate(), SinglePaneLayoutEnabledContext, SessionIsCreatedContext, SessionHasChangesContext diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts index d2b881ebfe0205..e76ca3cccdbaf7 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -26,7 +26,7 @@ import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ChangesContextKeys, ChangesViewMode } from '../../common/changes.js'; -import { IsPhoneLayoutContext, SessionHasChangesContext, SessionHasWorkspaceContext, SessionIsCreatedContext, SinglePaneDiffEditorInputActiveContext, SinglePaneLayoutEnabledContext } from '../../../../common/contextkeys.js'; +import { CustomViewVisibleContext, IsPhoneLayoutContext, SessionHasChangesContext, SessionHasWorkspaceContext, SessionIsCreatedContext, SinglePaneDiffEditorInputActiveContext, SinglePaneLayoutEnabledContext } from '../../../../common/contextkeys.js'; import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; import { CHANGES_HEADER_ACTIONS_ID, unlockChatPetCreatePullRequestAchievement } from '../../browser/changesView.js'; import { SessionsChangesAccessibilityHelp } from '../../browser/sessionsChangesAccessibilityHelp.js'; @@ -358,7 +358,7 @@ suite('Changes View Actions', () => { }]); }); - test('Create Pull Request anchor is contributed to the right-side title bar menu for created sessions', () => { + test('Create Pull Request anchor is visible for created sessions but hidden for custom views', () => { const item = MenuRegistry.getMenuItems(Menus.TitleBarSessionMenu) .filter(isIMenuItem) .find(item => item.command.id === CHANGES_HEADER_ACTIONS_ID); @@ -368,6 +368,15 @@ suite('Changes View Actions', () => { assert.ok(item, 'expected the changes header actions anchor on the title bar session menu'); const when = item.when?.serialize() ?? ''; + const context = new Context(1, null); + context.setValue(IsSessionsWindowContext.key, true); + context.setValue(IsAuxiliaryWindowContext.key, false); + context.setValue(CustomViewVisibleContext.key, false); + context.setValue(SinglePaneLayoutEnabledContext.key, true); + context.setValue(SessionIsCreatedContext.key, true); + context.setValue(SessionHasChangesContext.key, true); + const visibleForSession = item.when?.evaluate(context) ?? false; + context.setValue(CustomViewVisibleContext.key, true); assert.deepStrictEqual({ editorTitleItem, group: item.group, @@ -377,6 +386,8 @@ suite('Changes View Actions', () => { hasSinglePaneLayoutGate: when.includes(SinglePaneLayoutEnabledContext.key), hasCreatedSessionGate: when.includes(SessionIsCreatedContext.key), hasChangesGate: when.includes(SessionHasChangesContext.key), + visibleForSession, + visibleForCustomView: item.when?.evaluate(context) ?? false, }, { editorTitleItem: undefined, group: 'navigation', @@ -386,6 +397,8 @@ suite('Changes View Actions', () => { hasSinglePaneLayoutGate: true, hasCreatedSessionGate: true, hasChangesGate: true, + visibleForSession: true, + visibleForCustomView: false, }); }); }); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 2821c08bc60ab2..4dab12cba0d7d6 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -28,7 +28,7 @@ import { ITtsPlaybackService } from '../../../../workbench/contrib/chat/browser/ import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../workbench/common/theme.js'; -import { ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; +import { chatPersistentContentVisibleClass, ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; import { setModelPreservingInputTypedWhileLoading } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatModelReference, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { isChatTranscriptContextVariableEntry, IChatRequestTranscriptContextVariableEntry, IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -52,8 +52,6 @@ import { INewChatVoiceTargetService } from './newChatVoice.js'; import { ISessionsChatViewStateService } from './chatViewStateService.js'; import { ExternalSessionBanner } from './externalSessionBanner.js'; import { Menus } from '../../../browser/menus.js'; -import { ISessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; -import { SessionsChatBackgroundRenderer } from './chatBackgroundRenderer.js'; import { ISessionOpenTelemetryService } from '../../../services/sessions/browser/sessionOpenTelemetryService.js'; export function shouldShowSessionChatTip(sessionStatus: SessionStatus | undefined): boolean { @@ -65,6 +63,10 @@ export function shouldShowSessionChatTip(sessionStatus: SessionStatus | undefine * shown before a session has been created. This is the default view that * the `SessionsPart` grid is seeded with. */ +export interface INewChatViewOptions extends IChatViewOptions { + readonly initialAttachments?: readonly IChatRequestVariableEntry[]; +} + export class NewChatView extends AbstractChatView { static readonly TYPE = 'sessions.newSession'; @@ -76,17 +78,12 @@ export class NewChatView extends AbstractChatView { constructor( isNewChatInSession: boolean, - options: IChatViewOptions, + options: INewChatViewOptions, @IInstantiationService instantiationService: IInstantiationService, - @ISessionsChatBackgroundService chatBackgroundService: ISessionsChatBackgroundService, ) { super(); this.element.classList.add('chat-view-new'); - const backgroundRenderer = this._register(new SessionsChatBackgroundRenderer(this.element)); - const updateBackground = () => backgroundRenderer.setBackground(chatBackgroundService.getBackground()); - this._register(chatBackgroundService.onDidChangeBackground(updateBackground)); - updateBackground(); this.kind = isNewChatInSession ? 'newChatInSession' : 'newSession'; const widgetOptions = { ...options, petHostPreferred: this._isVisibleObs }; this._widget = this._register(isNewChatInSession @@ -198,7 +195,6 @@ export class ChatView extends AbstractChatView { @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @IContextMenuService private readonly contextMenuService: IContextMenuService, - @ISessionsChatBackgroundService private readonly chatBackgroundService: ISessionsChatBackgroundService, @IChatService private readonly chatService: IChatService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -218,10 +214,6 @@ export class ChatView extends AbstractChatView { this._register(toDisposable(() => this._reportModelUnbound())); this.element.classList.add('chat-view-chat'); - const backgroundRenderer = this._register(new SessionsChatBackgroundRenderer(this.element)); - const updateBackground = () => backgroundRenderer.setBackground(this.chatBackgroundService.getBackground()); - this._register(this.chatBackgroundService.onDidChangeBackground(updateBackground)); - updateBackground(); this._widgetContainer = $('.chat-view-widget'); this.element.appendChild(this._widgetContainer); @@ -302,6 +294,11 @@ export class ChatView extends AbstractChatView { // Floating status pills above the input. this._chatPills = this._register(instantiationService.createInstance(SessionChatInputToolbar)); + const updateChatPillsVisibility = (visible: boolean) => { + this._widget.inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); + }; + this._register(this._chatPills.onDidChangeVisibility(updateChatPillsVisibility)); + updateChatPillsVisibility(this._chatPills.visible); this._register(this._widget.inputPart.registerChatPetHorizontalPlatformProvider({ onDidChange: this._chatPills.onDidChangeChatPetPlatform, getElements: () => this._chatPills.getChatPetPlatformElements(), diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 6b6a01464598ad..582b36fc902712 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -670,7 +670,7 @@ background-color: var(--vscode-toolbar-hoverBackground); } -.sessions-chat-attachment-remove .codicon { +.sessions-chat-attachment-pill .sessions-chat-attachment-remove .codicon { font-size: var(--vscode-codiconFontSize-compact, 12px); padding: 0; } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatView.css b/src/vs/sessions/contrib/chat/browser/media/chatView.css index 18e80f8b8b1c95..c48a05618ed109 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatView.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatView.css @@ -18,32 +18,11 @@ min-height: 0; } -.chat-view.has-chat-background { - isolation: isolate; -} - -.chat-view > .sessions-chat-codicon-background { - position: absolute; - inset: 0; - z-index: -1; - overflow: hidden; - pointer-events: none; - color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); -} - -.chat-view > .sessions-chat-codicon-background[hidden] { - display: none; -} - -.chat-view > .sessions-chat-codicon-background > .codicon { - position: absolute; -} - -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows { +.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows { background-color: transparent; } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-item-container.interactive-response { +.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-item-container.interactive-response { /* Match the response padding so wide content remains fully backed before the fade. */ background: linear-gradient( to right, @@ -59,7 +38,7 @@ overflow: hidden; } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .sessions-chat-widget:not(.new-chat-in-session) .new-chat-widget-content { +.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .sessions-chat-widget:not(.new-chat-in-session) .new-chat-widget-content { box-sizing: border-box; padding: var(--vscode-spacing-size120); background: color-mix(in srgb, var(--session-view-background) 86%, transparent); @@ -70,8 +49,8 @@ box-shadow: 0 var(--vscode-spacing-size80) var(--vscode-spacing-size240) color-mix(in srgb, var(--vscode-widget-shadow) 18%, transparent); } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .chat-secondary-toolbar .action-label, -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .chat-context-usage-widget { +.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .chat-secondary-toolbar .action-label, +.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .chat-context-usage-widget { background-color: var(--vscode-chat-list-background, var(--vscode-button-secondaryBackground)); background-image: linear-gradient(var(--vscode-button-secondaryBackground), var(--vscode-button-secondaryBackground)); border: var(--vscode-strokeThickness) solid var(--vscode-button-secondaryBorder); @@ -79,17 +58,17 @@ color: var(--vscode-button-secondaryForeground); } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .chat-secondary-toolbar .action-label:hover, -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .chat-context-usage-widget:hover { +.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .chat-secondary-toolbar .action-label:hover, +.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .chat-context-usage-widget:hover { background-image: linear-gradient(var(--vscode-button-secondaryHoverBackground), var(--vscode-button-secondaryHoverBackground)); } -.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown { +.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown { background-color: var(--session-view-background); background-image: linear-gradient(var(--vscode-chat-requestBubbleBackground), var(--vscode-chat-requestBubbleBackground)); } -.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown.clickable:hover { +.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown.clickable:hover { background-color: var(--session-view-background); background-image: linear-gradient(var(--vscode-chat-requestBubbleHoverBackground), var(--vscode-chat-requestBubbleHoverBackground)); } @@ -114,7 +93,7 @@ } } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container { +.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container { background-color: transparent; --vscode-chat-list-background: transparent; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 7fcc8dbecbb3fc..47335c7925c74a 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -72,6 +72,14 @@ display: none; } +.new-chat-widget-content .new-session-quick-chat-header { + display: none; +} + +.new-chat-widget-content.quick-chat .new-session-quick-chat-header { + display: flex; +} + .new-chat-widget-content { width: 100%; max-width: 800px; @@ -110,11 +118,6 @@ overflow: hidden; } -/* Collapse the controls-row picker host when empty so it adds no flex gap. */ -.new-chat-widget-container .new-chat-bottom-container .new-chat-session-type-picker-host:empty { - display: none; -} - .new-chat-widget-container .new-chat-bottom-container .new-chat-repo-config-container { display: flex; align-items: center; @@ -251,10 +254,6 @@ flex: 0 1 auto; } -.sessions-workspace-category-picker-slot.has-remove-action { - position: relative; -} - .sessions-workspace-category-picker-slot[hidden] { display: none; } @@ -267,50 +266,10 @@ border-radius: var(--vscode-cornerRadius-circle); box-sizing: border-box; color: var(--vscode-descriptionForeground); - font-size: var(--vscode-fontSize-body1); + font-size: var(--vscode-fontSize-body2); line-height: var(--vscode-spacing-size160); } -.sessions-chat-picker-slot.sessions-workspace-category-picker-slot.has-remove-action > .action-label { - padding-right: var(--vscode-spacing-size320); -} - -.sessions-workspace-picker-remove { - position: absolute; - top: 50%; - right: var(--vscode-spacing-size40); - display: flex; - align-items: center; - justify-content: center; - width: var(--vscode-spacing-size240); - height: var(--vscode-spacing-size240); - padding: 0; - border: 0; - background: transparent; - color: var(--vscode-descriptionForeground); - cursor: pointer; - transform: translateY(-50%); -} - -.sessions-workspace-picker-remove:hover { - color: var(--vscode-foreground); -} - -.sessions-workspace-picker-remove:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: var(--vscode-spacing-size20); -} - -.sessions-workspace-picker-remove > .codicon { - font-size: var(--vscode-codiconFontSize-compact); -} - -.sessions-chat-picker-slot.sessions-workspace-category-picker-slot:last-child .action-label { - justify-content: center; - min-width: var(--vscode-spacing-size320); - padding-inline: var(--vscode-spacing-size80); -} - .sessions-chat-picker-slot.sessions-workspace-category-picker-slot .action-label:hover, .sessions-chat-picker-slot.sessions-workspace-category-picker-slot .action-label[aria-expanded='true'] { background-color: var(--vscode-toolbar-hoverBackground); @@ -320,18 +279,23 @@ .sessions-chat-picker-slot.sessions-workspace-category-picker-slot .action-label:focus-visible { outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: var(--vscode-spacing-size20); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); } .sessions-chat-picker-slot.sessions-workspace-category-picker-slot .action-label > .codicon { font-size: var(--vscode-codiconFontSize); } +.sessions-workspace-category-picker .sessions-chat-dropdown-label { + margin-left: 0; +} + .sessions-workspace-category-picker:empty { display: none; } .sessions-chat-dropdown-label { + margin-left: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -447,30 +411,17 @@ text-overflow: ellipsis; } -.sessions-chat-picker-slot .action-label span + .chat-session-option-label { - margin-left: 2px; -} - -/* Session control pills (mode "Interactive", permission "Manual permissions") - * and the repo-config pills render an icon span directly before their - * .sessions-chat-dropdown-label inside a flex .action-label. Unlike the - * workspace category pills they have no `gap`, so the icon smashes into the - * label. Add the same small gap here (scoped to these toolbars so the model - * picker and workspace category pills keep their own spacing). */ -.new-chat-widget-container .new-chat-session-controls .sessions-chat-picker-slot .action-label, -.new-chat-widget-container .new-chat-repo-config-container .sessions-chat-picker-slot .action-label { - gap: var(--vscode-spacing-size40); -} - -.new-chat-input-area .sessions-chat-input-session-type-picker .action-label { - gap: var(--vscode-spacing-size40); - height: var(--vscode-spacing-size160); - padding: var(--vscode-spacing-size20) var(--vscode-spacing-size40); - font-size: var(--vscode-fontSize-label2); +.sessions-chat-picker-slot .action-label .monaco-count-badge { + min-width: var(--vscode-spacing-size160); + min-height: var(--vscode-spacing-size160); + padding: var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-circle); + font-size: var(--vscode-fontSize-label3); + line-height: normal; } -.new-chat-input-area .sessions-chat-input-session-type-picker .action-label > .codicon { - font-size: var(--vscode-codiconFontSize-compact); +.sessions-chat-picker-slot .action-label span + .chat-session-option-label { + margin-left: 2px; } .sessions-chat-picker-slot .action-label.warning { diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css index 5599c71a9f245b..7e6089f0fcdf36 100644 --- a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css +++ b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css @@ -18,7 +18,7 @@ width: 100%; min-width: 0; box-sizing: border-box; - padding: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size60) 0; + padding: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size40) 0; } .session-chat-input-toolbar-content > .chat-pills, @@ -26,6 +26,10 @@ flex-shrink: 0; } +.session-chat-input-toolbar-content > * { + pointer-events: auto; +} + .session-chat-input-toolbar.hidden { display: none; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 929f05ae5fabda..874e430e75d778 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -39,7 +39,7 @@ import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImag import { createImageHoverContent, openPastedTextArtifact } from '../../../../workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.js'; import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; -import { isAdditionalWorkspaceContextId } from '../common/newChatContextIds.js'; +import { ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX } from '../common/newChatContextIds.js'; /** * The attachment surface of the composer, as seen by its input plumbing @@ -120,7 +120,7 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt this._resourceLabels.clear(); dom.clearNode(this._container); - const visibleAttachments = this._attachedContext.filter(entry => !isAgentHostCompletionVariableEntry(entry) && !isAdditionalWorkspaceContextId(entry.id)); + const visibleAttachments = this._attachedContext.filter(entry => !isAgentHostCompletionVariableEntry(entry)); if (visibleAttachments.length === 0) { this._container.style.display = 'none'; return; @@ -146,6 +146,10 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt }); this._renderDisposables.add(preview.disposable); } + } else if (entry.id.startsWith(ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX)) { + const icon = dom.append(pill, renderIcon(Codicon.repo)); + icon.setAttribute('aria-hidden', 'true'); + dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); } else { const label = this._resourceLabels.create(pill, { supportIcons: true }); this._renderDisposables.add(label); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index f90929f169f5a9..5bcba276b8c31c 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -88,6 +88,7 @@ import { autorun, constObservable, derived, IObservable, observableFromEvent, ob import { isEqual } from '../../../../base/common/resources.js'; import { ChatInputNotificationWidget } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.js'; import { IChatInputNotificationContext, IChatInputNotificationService } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationService.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; import { registerChatInputOnboardingHosts } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputOnboardingHosts.js'; import { IChatInputNoticeHubService } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHub.js'; @@ -500,6 +501,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation @IThemeService private readonly themeService: IThemeService, @IChatPetWidgetService private readonly chatPetWidgetService: IChatPetWidgetService, @IChatInputNotificationService private readonly chatInputNotificationService: IChatInputNotificationService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, ) { super(); this._modelSelection = this._register(this.instantiationService.createInstance(SessionModelSelection, this.options.session)); @@ -598,6 +600,9 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation state: this._modelSelection.state, openPicker: () => this._newChatModelPickerService.openModelPicker(), selectModel: modelIdentifier => this._newChatModelPickerService.switchToModel(modelIdentifier), + // The model picker here has no scoped store and configures models globally, + // so a notification writes to the same place its "Thinking" rows do. + applyModelConfiguration: (modelIdentifier, values) => this.languageModelsService.setModelConfiguration(modelIdentifier, values), }, onDidChangeVisibility: (visible, focusTarget) => this.noticeHost.setOccupied(ChatInputNoticeLane.Notification, visible, focusTarget), focusInput: () => this.focus(), @@ -1027,9 +1032,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._createAttachButton(toolbar); - const sessionTypePickerHost = dom.append(toolbar, dom.$('.new-chat-session-type-picker-host')); - this.sessionTypePicker.render(sessionTypePickerHost, { className: 'sessions-chat-session-type-picker sessions-chat-input-session-type-picker' }); - // Session config pickers (such as model) — rendered via MenuWorkbenchToolBar // Visibility controlled by context keys (isActiveSessionBackgroundProvider, isNewChatSession) const configContainer = dom.append(toolbar, dom.$('.sessions-chat-config-toolbar')); diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index c909188f259c3c..53ef56f7f3c61d 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -23,7 +23,7 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { localize } from '../../../../nls.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISession, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../services/sessions/common/session.js'; +import { ISession, SESSION_WORKSPACE_GROUP_GITHUB } from '../../../services/sessions/common/session.js'; import { IOpenNewSessionResult, ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { isAllowSignedOutWhenUsableEnabled, shouldShowGitHubWorkspaceGroupSignIn } from '../../../browser/sessionsAuthGate.js'; import { AGENTIC_SIGN_IN_COMMAND_ID } from '../../../common/sessionCommands.js'; @@ -78,6 +78,8 @@ export class NewChatWidget extends Disposable { * the visible heading instead of the (hidden) chat input. */ private _activeEmptyState: NoAgentHostEmptyState | undefined; + private _workspacePickerRow: HTMLElement | undefined; + private _quickChatHeaderPickerHost: HTMLElement | undefined; private readonly _session: IObservable; @@ -98,7 +100,10 @@ export class NewChatWidget extends Disposable { private readonly _workspacePickerVisibleKey: IContextKey; constructor( - private readonly options: IChatViewOptions & { readonly petHostPreferred?: IObservable }, + private readonly options: IChatViewOptions & { + readonly petHostPreferred?: IObservable; + readonly initialAttachments?: readonly IChatRequestVariableEntry[]; + }, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @@ -205,12 +210,16 @@ export class NewChatWidget extends Disposable { loading, historyKey: constObservable(undefined), // no persisted history for the new-session view placeholder: localize('newSessionPromptPlaceholder', "Pitch your idea"), + sessionTypePickerOptions: { showChevron: false }, supportsBackground: true, deferredNotificationsEnabled, petHostPreferred: this.options.petHostPreferred, }); this._register(toDisposable(() => newChatInput.saveState())); this._newChatInput = this._register(newChatInput); + if (this.options.initialAttachments?.length) { + this._newChatInput.addAttachments(...this.options.initialAttachments); + } this._register(newSessionComposerService.registerComposer(this._newChatInput)); // Comment 3: Bind Agent mode in the scoped context so that Agent-only tips @@ -404,6 +413,10 @@ export class NewChatWidget extends Disposable { ? this._renderEmptyStateGate(workspacePickerContainer, chatWidgetContent) : this._renderWorkspacePicker(workspacePickerContainer)); + if (!isWeb) { + this._quickChatHeaderPickerHost = dom.append(chatWidgetContent, dom.$('.new-session-quick-chat-header.sessions-workspace-category-picker')); + } + this._renderFeedbackBanner(chatWidgetContent); this._newChatInput.render(chatWidgetContent, parent); @@ -447,6 +460,22 @@ export class NewChatWidget extends Disposable { } })); + if (!isWeb) { + this._register(autorun(reader => { + const isQuickChat = this._isQuickChatComposer.read(reader); + const target = isQuickChat ? this._quickChatHeaderPickerHost : this._workspacePickerRow; + if (!target) { + return; + } + this._newChatInput.sessionTypePicker.render(target, { + className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', + }); + if (!isQuickChat && target.lastElementChild) { + target.insertBefore(target.lastElementChild, target.firstElementChild); + } + })); + } + // Create initial session for any workspace already selected at construct time. // If the selection arrives later (provider registers asynchronously), the // picker fires onDidSelectWorkspace and our listener handles it. @@ -666,46 +695,39 @@ export class NewChatWidget extends Disposable { private _renderWorkspacePicker(container: HTMLElement): IDisposable { this._workspacePickerVisibleKey.set(true); - const remoteTrigger: IWorkspacePickerTrigger = { - label: localize('newSessionWorkspacePicker.remote', "Remote Setup"), - ariaLabel: localize('newSessionWorkspacePicker.remoteAriaLabel', "Choose a remote setup for the new session"), - icon: Codicon.add, - group: SESSION_WORKSPACE_GROUP_REMOTE, - hideWhenWorkspaceSelected: true, - }; - const moreTrigger: IWorkspacePickerTrigger = { - ariaLabel: localize('newSessionWorkspacePicker.moreAriaLabel', "More workspace options"), - icon: Codicon.ellipsis, - }; - const folderTrigger: IWorkspacePickerTrigger = { - label: localize('newSessionWorkspacePicker.folder', "Folder"), - ariaLabel: localize('newSessionWorkspacePicker.folderAriaLabel', "Choose a folder for the new session"), - icon: Codicon.add, - group: SESSION_WORKSPACE_GROUP_LOCAL, - }; - const repositoryTrigger: IWorkspacePickerTrigger = { - label: localize('newSessionWorkspacePicker.githubRepository', "Repository"), - ariaLabel: localize('newSessionWorkspacePicker.githubRepositoryAriaLabel', "Choose a GitHub repository for the new session"), - icon: Codicon.add, - group: SESSION_WORKSPACE_GROUP_GITHUB, + const workspaceTrigger: IWorkspacePickerTrigger = { + label: localize('newSessionWorkspacePicker.workspace', "Workspace"), + ariaLabel: localize('newSessionWorkspacePicker.workspaceAriaLabel', "Choose a workspace for the new session"), + tooltip: localize('newSessionWorkspacePicker.workspaceTooltip', "Choose where the new session runs"), + icon: Codicon.project, + reflectsWorkspace: true, attachesContext: false, }; const gitHubContextTrigger: IWorkspacePickerTrigger = { label: localize('newSessionWorkspacePicker.githubContext', "Issue/PR"), ariaLabel: localize('newSessionWorkspacePicker.githubContextAriaLabel', "Attach a GitHub issue or pull request to the new session"), + tooltip: localize('newSessionWorkspacePicker.githubContextTooltip', "Attach an issue or pull request as context"), icon: Codicon.add, group: SESSION_WORKSPACE_GROUP_GITHUB, attachesContext: true, hideWhenNoGitHubRepository: true, }; - this._workspacePicker.renderCategoryTriggers(container, [ - isWeb ? { ...folderTrigger, group: SESSION_WORKSPACE_GROUP_REMOTE, hideWhenNoWorkspaceSelected: true } : folderTrigger, - repositoryTrigger, + const row = this._workspacePicker.renderCategoryTriggers(container, [ + workspaceTrigger, gitHubContextTrigger, - remoteTrigger, - moreTrigger, ]); - return Disposable.None; + this._newChatInput.sessionTypePicker.render(row, { + className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', + }); + if (row.lastElementChild) { + row.insertBefore(row.lastElementChild, row.firstElementChild); + } + this._workspacePickerRow = row; + return toDisposable(() => { + if (this._workspacePickerRow === row) { + this._workspacePickerRow = undefined; + } + }); } private _renderEmptyState(container: HTMLElement): IDisposable { diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 64d721bf057e50..f7ab0528b44663 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -90,10 +90,10 @@ export function getSessionChatPillKindForAction(actionId: string): SessionChatPi /** * The row's rendered height, reserved below the transcript by its host because - * the row floats over it. Derived from the row's `2px`/`6px` padding here plus a + * the row floats over it. Derived from the row's `2px`/`4px` padding here plus a * 22px `.monaco-text-button.small` pill; keep in sync if either changes. */ -export const SESSION_CHAT_INPUT_TOOLBAR_HEIGHT = 30; +export const SESSION_CHAT_INPUT_TOOLBAR_HEIGHT = 28; /** A toolbar for session metadata, active-turn status, and background activity. */ export class SessionChatInputToolbar extends Disposable { @@ -103,6 +103,9 @@ export class SessionChatInputToolbar extends Disposable { private readonly _scrollable: DomScrollableElement; private readonly _onDidChangeChatPetPlatform = this._register(new Emitter()); readonly onDidChangeChatPetPlatform: Event = this._onDidChangeChatPetPlatform.event; + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; + private _visible = false; private readonly _pills: ChatPillsWidget; /** Sentinel distinguishing "no override" from an explicit `undefined` session. */ @@ -337,14 +340,23 @@ export class SessionChatInputToolbar extends Disposable { // Stay rendered while hidden pills have data: in read-only chats the // input part is only kept alive by a non-hidden persistent child. const anyHidden = kindsWithData.read(reader).size > 0; - this.element.classList.toggle('hidden', !anyVisible && !anyHidden); + const visible = anyVisible || anyHidden; + this.element.classList.toggle('hidden', !visible); // With no pill left to right-click, the row itself has to carry the // visibility menu or the hidden pills could never be restored. this.element.classList.toggle('empty', !anyVisible); + if (this._visible !== visible) { + this._visible = visible; + this._onDidChangeVisibility.fire(visible); + } this._scrollable.scanDomNode(); })); } + get visible(): boolean { + return this._visible; + } + getChatPetPlatformElements(): readonly HTMLElement[] { return this._pills.getPillElements(); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 55ff4c3d1ebee4..e4a60170eaedf6 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -6,6 +6,7 @@ import * as dom from '../../../../base/browser/dom.js'; import * as touch from '../../../../base/browser/touch.js'; import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js'; import { IAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; @@ -25,6 +26,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; @@ -38,6 +40,7 @@ import { SessionWorkspacePickerGroupContext } from '../../../common/contextkeys. import { getStatusHover, getStatusLabel, removeRemoteHost, showRemoteHostOptions } from '../../providers/remoteAgentHost/browser/remoteHostOptions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { defaultCountBadgeStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; @@ -73,6 +76,7 @@ export interface IWorkspacePickerItem { /** The resolved workspace (used for unavailable-provider checks). */ readonly providerId?: string; readonly browseActionIndex?: number; + readonly attachAsContext?: boolean; readonly checked?: boolean; /** Command to execute when this item is selected. */ readonly commandId?: string; @@ -99,15 +103,14 @@ export interface IWorkspacePickerGroupAction { export interface IWorkspacePickerTrigger { readonly label?: string; readonly ariaLabel: string; + readonly tooltip?: string; readonly icon: ThemeIcon; + readonly reflectsWorkspace?: boolean; readonly group?: string; readonly attachesContext?: boolean; readonly hideWhenWorkspaceSelected?: boolean; readonly hideWhenNoWorkspaceSelected?: boolean; readonly hideWhenNoGitHubRepository?: boolean; - readonly selectedWorkspace?: ISessionWorkspace; - readonly remove?: () => void; - readonly removeAriaLabel?: string; } export interface IResolvedBrowseSelection { @@ -131,6 +134,7 @@ interface IRestoredWorkspaceSelection { interface IWorkspacePickerTriggerElements { icon?: HTMLElement; label?: HTMLElement; + badge?: CountBadge; } type IWorkspacePickerAction = IAction & { icon?: ThemeIcon; hoverContent?: string; onRemove?: () => void }; @@ -186,11 +190,6 @@ export class WorkspacePicker extends Disposable { providerId: '', run: async () => (await this._browseForLocalFolder())?.workspace, }; - private readonly _localAddBrowseAction: ISessionWorkspaceBrowseAction = { - ...this._localBrowseAction, - label: localize('workspacePicker.browseAddLocal', "Add Folder..."), - }; - /** * "Primary" trigger. This is the most recently created entry. Preserved for subclass * read access (e.g. {@link WebWorkspacePicker} anchors its mobile sheet here) and for @@ -203,19 +202,12 @@ export class WorkspacePicker extends Disposable { private readonly _triggerContents = new Map(); private readonly _contextSelections = new Map(); private readonly _renderDisposables = this._register(new DisposableStore()); - private readonly _additionalRepositoryTriggerDisposables = this._register(new DisposableStore()); private readonly _additionalRepositorySelections = new Map(); - private readonly _additionalFolderTriggerDisposables = this._register(new DisposableStore()); private readonly _additionalFolderSelections = new Map(); private _attachedContext: readonly IChatRequestVariableEntry[] = []; private readonly _tabbedWidget: TabbedActionListWidget; private readonly _pickerGroupContext: IContextKey; private _activeTriggerElement: HTMLElement | undefined; - private _categoryRow: HTMLElement | undefined; - private _folderTriggerOptions: IWorkspacePickerTrigger | undefined; - private _repositoryTriggerOptions: IWorkspacePickerTrigger | undefined; - private _repositoryTriggerSlot: HTMLElement | undefined; - private _repositoryContextTriggerSlot: HTMLElement | undefined; protected _directPickerGroup: string | undefined; protected _directPickerAttachesContext: boolean | undefined; @@ -261,8 +253,7 @@ export class WorkspacePicker extends Disposable { } this._additionalFolderSelections.clear(); this._additionalRepositorySelections.clear(); - this._renderAdditionalFolderTriggers(); - this._renderAdditionalRepositoryTriggers(); + this._updateTriggerLabel(); this._onDidChangeSelection.fire(); } @@ -318,8 +309,6 @@ export class WorkspacePicker extends Disposable { } } if (changed) { - this._renderAdditionalFolderTriggers(); - this._renderAdditionalRepositoryTriggers(); this._updateTriggerLabel(); this._onDidChangeSelection.fire(); } @@ -344,6 +333,7 @@ export class WorkspacePicker extends Disposable { @IFileDialogService private readonly fileDialogService: IFileDialogService, @ITelemetryService private readonly telemetryService: ITelemetryService, @INotificationService private readonly notificationService: INotificationService, + @IHoverService private readonly hoverService: IHoverService, ) { super(); @@ -447,38 +437,17 @@ export class WorkspacePicker extends Disposable { renderCategoryTriggers(container: HTMLElement, triggers: readonly IWorkspacePickerTrigger[], label?: string): HTMLElement { this._renderDisposables.clear(); - this._additionalRepositoryTriggerDisposables.clear(); - this._additionalFolderTriggerDisposables.clear(); const row = dom.append(container, dom.$('.sessions-workspace-category-picker')); - this._categoryRow = row; this._renderDisposables.add({ dispose: () => row.remove() }); - this._renderDisposables.add({ - dispose: () => { - this._categoryRow = undefined; - this._folderTriggerOptions = undefined; - this._repositoryTriggerOptions = undefined; - this._repositoryTriggerSlot = undefined; - this._repositoryContextTriggerSlot = undefined; - }, - }); if (label) { const rowLabel = dom.append(row, dom.$('span.sessions-workspace-category-picker-label')); rowLabel.textContent = label; } for (const options of triggers) { const slot = dom.append(row, dom.$('.sessions-chat-picker-slot.sessions-workspace-category-picker-slot')); - if (options.group === SESSION_WORKSPACE_GROUP_LOCAL) { - this._folderTriggerOptions = options; - } else if (options.group === SESSION_WORKSPACE_GROUP_GITHUB && options.attachesContext === false) { - this._repositoryTriggerOptions = options; - this._repositoryTriggerSlot = slot; - } else if (options.group === SESSION_WORKSPACE_GROUP_GITHUB && options.attachesContext === true) { - this._repositoryContextTriggerSlot = slot; - } + slot.classList.toggle('sessions-workspace-picker-trigger', options.reflectsWorkspace === true); this._renderDisposables.add(this._addTrigger(slot, options)); } - this._renderAdditionalFolderTriggers(); - this._renderAdditionalRepositoryTriggers(); return row; } @@ -503,17 +472,8 @@ export class WorkspacePicker extends Disposable { } this._triggerElement = trigger; this._renderTriggerLabel(trigger); - if (options?.remove && options.removeAriaLabel) { - slot.classList.add('has-remove-action'); - const removeButton = dom.append(slot, dom.$('button.sessions-workspace-picker-remove')); - removeButton.type = 'button'; - removeButton.setAttribute('aria-label', options.removeAriaLabel); - const removeIcon = dom.append(removeButton, renderIcon(Codicon.closeCompact)); - removeIcon.setAttribute('aria-hidden', 'true'); - triggerDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.CLICK, e => { - dom.EventHelper.stop(e, true); - options.remove?.(); - })); + if (options?.tooltip) { + triggerDisposables.add(this.hoverService.setupDelayedHover(trigger, { content: options.tooltip })); } // Onboarding spotlight target — id is referenced by the "new session" tour // in vs/sessions/contrib/onboardingTours. @@ -537,6 +497,7 @@ export class WorkspacePicker extends Disposable { triggerDisposables.add({ dispose: () => { + this._triggerContents.get(trigger)?.badge?.dispose(); this._triggerElements.delete(trigger); this._triggerOptions.delete(trigger); this._triggerContents.delete(trigger); @@ -809,14 +770,16 @@ export class WorkspacePicker extends Disposable { return true; } } - if (action?.group === SESSION_WORKSPACE_GROUP_GITHUB - && action.attachesContext !== true - && this._attachAdditionalRepository(selection.workspace, selection.providerId)) { - return true; - } - if (action === this._localAddBrowseAction) { - this._attachAdditionalFolder(folderUri, selection.providerId); - return true; + if (item.attachAsContext) { + if (action?.group === SESSION_WORKSPACE_GROUP_GITHUB + && action.attachesContext !== true + && this._attachAdditionalRepository(selection.workspace, selection.providerId)) { + return true; + } + if (action === this._localBrowseAction) { + this._attachAdditionalFolder(folderUri, selection.providerId); + return true; + } } const relatedWorkspace = this._findRelatedLocalWorkspace(selection.workspace); this._selectFolder( @@ -838,11 +801,6 @@ export class WorkspacePicker extends Disposable { if (generation !== this._selectionGeneration) { return false; } - const resolved = this._resolveFolder(item.folderUri, item.providerId); - if (resolved?.workspace.group === SESSION_WORKSPACE_GROUP_GITHUB - && this._attachAdditionalRepository(resolved.workspace, resolved.providerId)) { - return true; - } this._selectFolder(item.folderUri, true, item.providerId); return true; } @@ -861,7 +819,7 @@ export class WorkspacePicker extends Disposable { if (!this._additionalFolderSelections.has(key)) { this._additionalFolderSelections.set(key, resolved); this.recentWorkspacesService.addRecentWorkspace(folderUri, providerId, false); - this._renderAdditionalFolderTriggers(); + this._updateTriggerLabel(); this._onDidSelectFolderContext.fire(folderUri); } return true; @@ -875,14 +833,14 @@ export class WorkspacePicker extends Disposable { if (!repositoryId) { return false; } - if (repositoryId !== this._getCurrentRepositoryId() && !this._additionalRepositorySelections.has(repositoryId)) { + if (!this._additionalRepositorySelections.has(repositoryId)) { const selection = { workspace, providerId }; this._additionalRepositorySelections.set(repositoryId, { ...selection, attachmentId: getAdditionalRepositoryContextId(workspace.uri), }); this.recentWorkspacesService.addRecentWorkspace(workspace.folders[0].root, providerId, false); - this._renderAdditionalRepositoryTriggers(); + this._updateTriggerLabel(); this._onDidSelectRepositoryContext.fire(selection); this._onDidChangeSelection.fire(); } @@ -1037,11 +995,9 @@ export class WorkspacePicker extends Disposable { const additionalRepository = repositoryId ? this._additionalRepositorySelections.get(repositoryId) : undefined; const removedAdditionalRepository = repositoryId ? this._additionalRepositorySelections.delete(repositoryId) : false; if (removedAdditionalFolder && additionalFolder) { - this._renderAdditionalFolderTriggers(); this._onDidRemoveAttachedContext.fire(getAdditionalFolderContextId(additionalFolder.workspace.folders[0].root)); } if (removedAdditionalRepository && additionalRepository) { - this._renderAdditionalRepositoryTriggers(); this._onDidRemoveAttachedContext.fire(additionalRepository.attachmentId); } this._selectedFolderUri = folderUri; @@ -1163,8 +1119,10 @@ export class WorkspacePicker extends Disposable { return undefined; } + // Let the action-list widget hide before a browse action opens another picker. + await Promise.resolve(); try { - if (action === this._localBrowseAction || action === this._localAddBrowseAction) { + if (action === this._localBrowseAction) { const selection = await this._browseForLocalFolder(); return selection ? { ...selection, action } : undefined; } @@ -1189,9 +1147,7 @@ export class WorkspacePicker extends Disposable { const all = this.sessionsProvidersService.getProviders().flatMap(p => p.browseActions); const hasLocalSupport = this.sessionsProvidersService.getProviders().some(p => p.supportsLocalWorkspaces); if (hasLocalSupport) { - all.unshift(...(this._selectedFolderUri && this._directPickerGroup === SESSION_WORKSPACE_GROUP_LOCAL - ? [this._localAddBrowseAction] - : [this._localBrowseAction])); + all.unshift(this._localBrowseAction); } if (!this._isTabFiltered()) { return all; @@ -1240,6 +1196,10 @@ export class WorkspacePicker extends Disposable { || (this._showTabs() && !!this._activeTab && this._getAvailableTabs().length > 1); } + private _getActivePickerGroup(): string | undefined { + return this._directPickerGroup ?? (this._directPickerAttachesContext !== undefined ? this._activeTab : undefined); + } + /** * Builds the picker items list from recent workspaces. * @@ -1327,23 +1287,30 @@ export class WorkspacePicker extends Disposable { && !RemoteAgentHostConnectionStatus.isConnected(connectionStatus) && !agentHostProvider?.canConnectOnDemand); const isRepositoryAction = action.group === SESSION_WORKSPACE_GROUP_GITHUB && action.attachesContext !== true; - if (isRepositoryAction && this._selectedFolderUri && this._directPickerGroup === SESSION_WORKSPACE_GROUP_GITHUB) { - items.push({ - kind: ActionListItemKind.Action, - label: localize('workspacePicker.addRepository', "Add Repository..."), - description: action.description, - group: { title: '', icon: action.icon }, - disabled: isUnavailable, - item: { browseActionIndex: index }, - }); - } else { + items.push({ + kind: ActionListItemKind.Action, + label: action.label, + description: action.description, + group: { title: '', icon: action.icon }, + disabled: isUnavailable, + item: { browseActionIndex: index }, + }); + const canAttachFolder = action === this._localBrowseAction + && this._selectedFolderUri + && this._getActivePickerGroup() === SESSION_WORKSPACE_GROUP_LOCAL; + const canAttachRepository = isRepositoryAction + && this._selectedFolderUri + && this._getActivePickerGroup() === SESSION_WORKSPACE_GROUP_GITHUB; + if (canAttachFolder || canAttachRepository) { items.push({ kind: ActionListItemKind.Action, - label: action.label, + label: canAttachFolder + ? localize('workspacePicker.attachFolder', "Attach Folder...") + : localize('workspacePicker.attachRepository', "Attach Repository..."), description: action.description, group: { title: '', icon: action.icon }, disabled: isUnavailable, - item: { browseActionIndex: index }, + item: { browseActionIndex: index, attachAsContext: true }, }); } }); @@ -1407,7 +1374,7 @@ export class WorkspacePicker extends Disposable { } } - if (items.length === 0 && this._directPickerGroup === SESSION_WORKSPACE_GROUP_GITHUB) { + if (items.length === 0 && this._getActivePickerGroup() === SESSION_WORKSPACE_GROUP_GITHUB) { items.push({ kind: ActionListItemKind.Action, label: localize('workspacePicker.githubLoading', "GitHub repositories are still loading"), @@ -1435,79 +1402,6 @@ export class WorkspacePicker extends Disposable { } } - private _renderAdditionalRepositoryTriggers(): void { - this._additionalRepositoryTriggerDisposables.clear(); - if (!this._categoryRow || !this._repositoryTriggerOptions) { - return; - } - for (const selection of this._additionalRepositorySelections.values()) { - const { workspace } = selection; - const slot = dom.$('.sessions-chat-picker-slot.sessions-workspace-category-picker-slot'); - this._categoryRow.insertBefore(slot, this._repositoryContextTriggerSlot ?? null); - this._additionalRepositoryTriggerDisposables.add({ dispose: () => slot.remove() }); - this._additionalRepositoryTriggerDisposables.add(this._addTrigger(slot, { - ...this._repositoryTriggerOptions, - label: workspace.label, - ariaLabel: localize('workspacePicker.attachedRepositoryAriaLabel', "Attached repository {0}", workspace.label), - selectedWorkspace: workspace, - remove: () => this._removeAdditionalRepository(selection), - removeAriaLabel: localize('workspacePicker.removeAttachedRepository', "Remove attached repository {0}", workspace.label), - })); - } - } - - private _removeAdditionalRepository(selection: IAttachedRepositorySelection): void { - const repositoryId = this._getRepositoryId(selection.workspace); - if (!repositoryId || !this._additionalRepositorySelections.delete(repositoryId)) { - return; - } - this._renderAdditionalRepositoryTriggers(); - this._updateTriggerLabel(); - this._onDidChangeSelection.fire(); - this._onDidRemoveAttachedContext.fire(selection.attachmentId); - this._focusCategoryTrigger(this._repositoryTriggerOptions); - } - - private _renderAdditionalFolderTriggers(): void { - this._additionalFolderTriggerDisposables.clear(); - if (!this._categoryRow || !this._folderTriggerOptions) { - return; - } - for (const selection of this._additionalFolderSelections.values()) { - const slot = dom.$('.sessions-chat-picker-slot.sessions-workspace-category-picker-slot'); - this._categoryRow.insertBefore(slot, this._repositoryTriggerSlot ?? null); - this._additionalFolderTriggerDisposables.add({ dispose: () => slot.remove() }); - this._additionalFolderTriggerDisposables.add(this._addTrigger(slot, { - ...this._folderTriggerOptions, - label: selection.workspace.label, - ariaLabel: localize('workspacePicker.attachedFolderAriaLabel', "Attached folder {0}", selection.workspace.label), - selectedWorkspace: selection.workspace, - remove: () => this._removeAdditionalFolder(selection.workspace.folders[0].root), - removeAriaLabel: localize('workspacePicker.removeAttachedFolder', "Remove attached folder {0}", selection.workspace.label), - })); - } - } - - private _removeAdditionalFolder(folderUri: URI): void { - const key = this.uriIdentityService.extUri.getComparisonKey(folderUri); - if (!this._additionalFolderSelections.delete(key)) { - return; - } - this._renderAdditionalFolderTriggers(); - this._onDidChangeSelection.fire(); - this._onDidRemoveAttachedContext.fire(getAdditionalFolderContextId(folderUri)); - this._focusCategoryTrigger(this._folderTriggerOptions); - } - - private _focusCategoryTrigger(options: IWorkspacePickerTrigger | undefined): void { - for (const [trigger, triggerOptions] of this._triggerOptions) { - if (triggerOptions === options) { - trigger.focus(); - return; - } - } - } - protected _renderTriggerLabel(trigger: HTMLElement): void { const options = this._triggerOptions.get(trigger); const contents = this._triggerContents.get(trigger); @@ -1516,40 +1410,48 @@ export class WorkspacePicker extends Disposable { } if (options) { const workspace = this._selectedResolved?.workspace; + const reflectsWorkspace = options.reflectsWorkspace === true; const isSelectedCategory = options.attachesContext !== true && options.group !== undefined && options.group === workspace?.group; const contextCount = options.group && options.attachesContext - ? this._contextSelections.get(`${options.group}:context`)?.length ?? 0 + ? Math.max( + this._contextSelections.get(`${options.group}:context`)?.length ?? 0, + options.group === SESSION_WORKSPACE_GROUP_GITHUB + ? this._attachedContext.filter(attachment => attachment.id.startsWith('github-context:')).length + : 0, + ) : 0; + const additionalWorkspaceCount = options.attachesContext !== true && (reflectsWorkspace || isSelectedCategory) + ? this._additionalFolderSelections.size + this._additionalRepositorySelections.size + : 0; + const badgeCount = Math.max(contextCount, additionalWorkspaceCount); const repositoryWorkspace = this._getSelectedRepositoryWorkspace(); const relatedGitHubInfo = options.group === SESSION_WORKSPACE_GROUP_GITHUB && options.attachesContext === false ? repositoryWorkspace?.folders.map(folder => folder.gitRepository?.gitHubInfo.get()).find(info => info !== undefined) : undefined; - const selectedWorkspace = options.selectedWorkspace; const hideForSelectedWorkspace = workspace !== undefined && options.hideWhenWorkspaceSelected === true && options.group !== workspace.group; const hideForMissingWorkspace = workspace === undefined && options.hideWhenNoWorkspaceSelected === true; - const hideForMissingGitHubRepository = workspace !== undefined - && options.hideWhenNoGitHubRepository === true + const hideForMissingGitHubRepository = options.hideWhenNoGitHubRepository === true && this._getCurrentRepositoryId() === undefined; trigger.parentElement?.toggleAttribute('hidden', hideForSelectedWorkspace || hideForMissingWorkspace || hideForMissingGitHubRepository); - trigger.classList.toggle('selected', selectedWorkspace !== undefined || isSelectedCategory || contextCount > 0 || relatedGitHubInfo !== undefined); - const icon = selectedWorkspace?.icon - ?? (contextCount > 0 ? Codicon.attach : undefined) + trigger.classList.toggle('selected', (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined); + const icon = (reflectsWorkspace ? workspace?.icon : undefined) ?? (relatedGitHubInfo ? Codicon.repo : (isSelectedCategory && workspace ? workspace.icon : options.icon)); if (!contents.icon) { contents.icon = renderIcon(icon); trigger.prepend(contents.icon); } contents.icon.className = ThemeIcon.asClassName(icon); - const label = selectedWorkspace?.label - ?? (contextCount > 0 ? localize('workspacePicker.attachedContextCount', "Attached {0}", contextCount) : undefined) + const label = (reflectsWorkspace ? workspace?.label : undefined) ?? (relatedGitHubInfo ? `${relatedGitHubInfo.owner}/${relatedGitHubInfo.repo}` : (isSelectedCategory && workspace ? workspace.label : options.label)); - trigger.setAttribute('aria-label', label && label !== options.label && !selectedWorkspace - ? localize('workspacePicker.categorySelectionAriaLabel', "{0}: {1}", options.label ?? options.ariaLabel, label) - : options.ariaLabel); + trigger.setAttribute('aria-label', badgeCount > 0 + ? localize('workspacePicker.attachedContextCountAriaLabel', "{0}, {1} attached", options.ariaLabel, badgeCount) + : label && label !== options.label + ? localize('workspacePicker.categorySelectionAriaLabel', "{0}: {1}", options.label ?? options.ariaLabel, label) + : options.ariaLabel); if (label) { contents.label ??= dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); contents.label.textContent = label; @@ -1557,6 +1459,13 @@ export class WorkspacePicker extends Disposable { contents.label?.remove(); contents.label = undefined; } + if (badgeCount > 0) { + contents.badge ??= new CountBadge(trigger, { count: badgeCount }, defaultCountBadgeStyles); + contents.badge.setCount(badgeCount); + } else { + contents.badge?.dispose(); + contents.badge = undefined; + } return; } diff --git a/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts index 18e6f9f47d707d..b88eccd3fd0313 100644 --- a/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts @@ -13,6 +13,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -60,6 +61,7 @@ export class WebWorkspacePicker extends WorkspacePicker { @IFileDialogService fileDialogService: IFileDialogService, @ITelemetryService telemetryService: ITelemetryService, @INotificationService notificationService: INotificationService, + @IHoverService hoverService: IHoverService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, ) { @@ -81,6 +83,7 @@ export class WebWorkspacePicker extends WorkspacePicker { fileDialogService, telemetryService, notificationService, + hoverService, ); // When the scoped host changes, if the current selection no longer diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 01a2b9ce375553..3a8fed1ac50230 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -14,8 +14,8 @@ import { IChatRequestTranscriptContextVariableEntry } from '../../../../../workb import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; import { isChatInputStackSlotShowing } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; +import { SessionsChatBackgroundRenderer } from '../../../../services/chatBackground/browser/chatBackgroundRenderer.js'; import { findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; -import { SessionsChatBackgroundRenderer } from '../../browser/chatBackgroundRenderer.js'; import { SessionsChatViewStateService } from '../../browser/chatViewStateService.js'; import { NewChatInSessionWidget } from '../../browser/newChatInSessionWidget.js'; import { NewChatWidget } from '../../browser/newChatWidget.js'; @@ -122,13 +122,12 @@ suite('Sessions - Chat View', () => { assert.strictEqual(isVisible.get(), false); }); - test('applies and clears background CSS on the chat view', () => { + test('applies and clears background CSS on the sessions part', () => { const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); const part = dom.append(workbench, dom.$('.part.sessionspart')); - const chatView = dom.append(part, dom.$('.chat-view')); dom.getWindow(workbench).document.body.appendChild(workbench); disposables.add(toDisposable(() => workbench.remove())); - const renderer = disposables.add(new SessionsChatBackgroundRenderer(chatView)); + const renderer = disposables.add(new SessionsChatBackgroundRenderer(part)); renderer.setBackground({ kind: 'image', backgroundImage: 'url("file:///textures/kirby.png")', @@ -136,36 +135,42 @@ suite('Sessions - Chat View', () => { backgroundSize: 'auto', backgroundPosition: 'right bottom', }); + const backgroundLayer = part.querySelector(':scope > .sessions-chat-background'); const applied = { - enabled: chatView.classList.contains('has-chat-background'), - imageEnabled: chatView.classList.contains('has-chat-background-image'), - image: chatView.style.backgroundImage, - repeat: chatView.style.backgroundRepeat, - size: chatView.style.backgroundSize, - position: chatView.style.backgroundPosition, + enabled: part.classList.contains('has-chat-background'), + imageEnabled: part.classList.contains('has-chat-background-image'), + hidden: backgroundLayer?.hidden, + image: backgroundLayer?.style.backgroundImage, + repeat: backgroundLayer?.style.backgroundRepeat, + size: backgroundLayer?.style.backgroundSize, + position: backgroundLayer?.style.backgroundPosition, + zIndex: backgroundLayer ? dom.getWindow(backgroundLayer).getComputedStyle(backgroundLayer).zIndex : undefined, }; renderer.setBackground(undefined); assert.deepStrictEqual({ applied, cleared: { - enabled: chatView.classList.contains('has-chat-background'), - imageEnabled: chatView.classList.contains('has-chat-background-image'), - image: chatView.style.backgroundImage, - repeat: chatView.style.backgroundRepeat, - size: chatView.style.backgroundSize, - position: chatView.style.backgroundPosition, + enabled: part.classList.contains('has-chat-background'), + imageEnabled: part.classList.contains('has-chat-background-image'), + hidden: backgroundLayer?.hidden, + image: backgroundLayer?.style.backgroundImage, + repeat: backgroundLayer?.style.backgroundRepeat, + size: backgroundLayer?.style.backgroundSize, + position: backgroundLayer?.style.backgroundPosition, }, }, { applied: { enabled: true, imageEnabled: true, + hidden: false, image: 'url("file:///textures/kirby.png")', repeat: 'no-repeat', size: 'auto', position: 'right bottom', + zIndex: '0', }, - cleared: { enabled: false, imageEnabled: false, image: '', repeat: '', size: '', position: '' }, + cleared: { enabled: false, imageEnabled: false, hidden: true, image: '', repeat: '', size: '', position: '' }, }); }); @@ -173,18 +178,19 @@ suite('Sessions - Chat View', () => { const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); workbench.style.setProperty('--vscode-foreground', '#202020'); const part = dom.append(workbench, dom.$('.part.sessionspart')); - const chatView = dom.append(part, dom.$('.chat-view')); dom.getWindow(workbench).document.body.appendChild(workbench); disposables.add(toDisposable(() => workbench.remove())); - const renderer = disposables.add(new SessionsChatBackgroundRenderer(chatView)); + const renderer = disposables.add(new SessionsChatBackgroundRenderer(part)); renderer.setBackground({ kind: 'codicons' }); - const layer = chatView.querySelector(':scope > .sessions-chat-codicon-background'); + const backgroundLayer = part.querySelector(':scope > .sessions-chat-background'); + const layer = backgroundLayer?.querySelector(':scope > .sessions-chat-codicon-background'); const firstIcon = layer?.querySelector('.codicon'); assert.deepStrictEqual({ - enabled: chatView.classList.contains('has-chat-background'), - imageEnabled: chatView.classList.contains('has-chat-background-image'), - backgroundImage: chatView.style.backgroundImage, + enabled: part.classList.contains('has-chat-background'), + imageEnabled: part.classList.contains('has-chat-background-image'), + backgroundImage: backgroundLayer?.style.backgroundImage, + backgroundLayerHidden: backgroundLayer?.hidden, layerHidden: layer?.hidden, layerAriaHidden: layer?.ariaHidden, layerColor: layer ? dom.getWindow(layer).getComputedStyle(layer).color : undefined, @@ -195,6 +201,7 @@ suite('Sessions - Chat View', () => { enabled: true, imageEnabled: false, backgroundImage: '', + backgroundLayerHidden: false, layerHidden: false, layerAriaHidden: 'true', layerColor: 'color(srgb 0.12549 0.12549 0.12549 / 0.1)', @@ -208,13 +215,14 @@ suite('Sessions - Chat View', () => { const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); workbench.style.setProperty('--session-view-background', '#202020'); workbench.style.setProperty('--vscode-chat-requestBubbleBackground', 'rgba(255, 255, 255, 0.3)'); - const part = dom.append(workbench, dom.$('.part.sessionspart')); - const chatView = dom.append(part, dom.$('.chat-view.has-chat-background')); + const part = dom.append(workbench, dom.$('.part.sessionspart.has-chat-background')); + const chatView = dom.append(part, dom.$('.chat-view')); const session = dom.append(chatView, dom.$('.interactive-session')); const request = dom.append(session, dom.$('.interactive-item-container.interactive-request')); const value = dom.append(request, dom.$('.value')); const bubble = dom.append(value, dom.$('.rendered-markdown')); - const plainChatView = dom.append(part, dom.$('.chat-view')); + const plainPart = dom.append(workbench, dom.$('.part.sessionspart')); + const plainChatView = dom.append(plainPart, dom.$('.chat-view')); const plainSession = dom.append(plainChatView, dom.$('.interactive-session')); const plainRequest = dom.append(plainSession, dom.$('.interactive-item-container.interactive-request')); const plainValue = dom.append(plainRequest, dom.$('.value')); @@ -243,13 +251,14 @@ suite('Sessions - Chat View', () => { workbench.style.setProperty('--vscode-cornerRadius-medium', '6px'); workbench.style.setProperty('--vscode-spacing-size160', '16px'); workbench.style.setProperty('--vscode-spacing-size320', '32px'); - const part = dom.append(workbench, dom.$('.part.sessionspart')); - const chatView = dom.append(part, dom.$('.chat-view.has-chat-background')); + const part = dom.append(workbench, dom.$('.part.sessionspart.has-chat-background')); + const chatView = dom.append(part, dom.$('.chat-view')); const session = dom.append(chatView, dom.$('.interactive-session')); const response = dom.append(session, dom.$('.interactive-item-container.interactive-response')); const value = dom.append(response, dom.$('.value')); const footer = dom.append(response, dom.$('.chat-footer-toolbar')); - const plainChatView = dom.append(part, dom.$('.chat-view')); + const plainPart = dom.append(workbench, dom.$('.part.sessionspart')); + const plainChatView = dom.append(plainPart, dom.$('.chat-view')); const plainSession = dom.append(plainChatView, dom.$('.interactive-session')); const plainResponse = dom.append(plainSession, dom.$('.interactive-item-container.interactive-response')); dom.getWindow(workbench).document.body.appendChild(workbench); @@ -300,8 +309,8 @@ suite('Sessions - Chat View', () => { workbench.style.setProperty('--vscode-cornerRadius-small', '4px'); workbench.style.setProperty('--vscode-spacing-size120', '12px'); workbench.style.setProperty('--vscode-strokeThickness', '1px'); - const part = dom.append(workbench, dom.$('.part.sessionspart')); - const chatView = dom.append(part, dom.$('.chat-view.has-chat-background')); + const part = dom.append(workbench, dom.$('.part.sessionspart.has-chat-background')); + const chatView = dom.append(part, dom.$('.chat-view')); const newChatWidget = dom.append(chatView, dom.$('.sessions-chat-widget')); const newChatContent = dom.append(newChatWidget, dom.$('.new-chat-widget-content')); const inSessionWidget = dom.append(chatView, dom.$('.sessions-chat-widget.new-chat-in-session')); @@ -310,7 +319,8 @@ suite('Sessions - Chat View', () => { const secondaryToolbar = dom.append(session, dom.$('.chat-secondary-toolbar')); const secondaryAction = dom.append(secondaryToolbar, dom.$('.action-label')); const contextUsage = dom.append(secondaryToolbar, dom.$('.chat-context-usage-widget')); - const plainChatView = dom.append(part, dom.$('.chat-view')); + const plainPart = dom.append(workbench, dom.$('.part.sessionspart')); + const plainChatView = dom.append(plainPart, dom.$('.chat-view')); const plainNewChatWidget = dom.append(plainChatView, dom.$('.sessions-chat-widget')); const plainNewChatContent = dom.append(plainNewChatWidget, dom.$('.new-chat-widget-content')); const plainSession = dom.append(plainChatView, dom.$('.interactive-session')); @@ -410,9 +420,10 @@ suite('Sessions - Chat View', () => { workbench.style.setProperty('--vscode-chat-list-background', '#ff0000'); workbench.style.setProperty('--session-view-background', '#202020'); workbench.style.setProperty('--vscode-chat-requestBubbleBackground', 'rgba(255, 255, 255, 0.3)'); - const part = dom.append(workbench, dom.$('.part.sessionspart')); - const createStickyRequest = (chatViewClassName: string) => { - const chatView = dom.append(part, dom.$(chatViewClassName)); + const part = dom.append(workbench, dom.$('.part.sessionspart.has-chat-background')); + const plainPart = dom.append(workbench, dom.$('.part.sessionspart')); + const createStickyRequest = (parent: HTMLElement) => { + const chatView = dom.append(parent, dom.$('.chat-view')); const session = dom.append(chatView, dom.$('.interactive-session')); const interactiveList = dom.append(session, dom.$('.interactive-list')); const list = dom.append(interactiveList, dom.$('.monaco-list')); @@ -426,8 +437,8 @@ suite('Sessions - Chat View', () => { const bubble = dom.append(value, dom.$('.rendered-markdown')); return { stickyContainer, stickyRow, treeContents, request, bubble }; }; - const background = createStickyRequest('.chat-view.has-chat-background'); - const plain = createStickyRequest('.chat-view'); + const background = createStickyRequest(part); + const plain = createStickyRequest(plainPart); dom.getWindow(workbench).document.body.appendChild(workbench); disposables.add(toDisposable(() => workbench.remove())); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts index 2c0ed4ae6a1298..ab6e6941220483 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -244,4 +244,50 @@ suite('NewChatInputWidget', () => { removed: entry.id, }); }); + + test('renders additional folder and repository context as attachment pills', () => { + const container = document.createElement('div'); + const folder = URI.file('/workspace/docs'); + const repositoryRoot = URI.parse('vscode-vfs://github/microsoft/typescript/HEAD'); + const entries: readonly IChatRequestVariableEntry[] = [ + { + kind: 'directory', + id: getAdditionalFolderContextId(folder), + name: 'docs', + value: folder, + }, + { + kind: 'generic', + id: getAdditionalRepositoryContextId(URI.parse('https://github.com/microsoft/typescript')), + name: 'microsoft/typescript', + value: repositoryRoot, + }, + ]; + const renderDisposables = disposables.add(new DisposableStore()); + updateAttachmentRendering.call({ + _container: container, + _attachedContext: entries, + _renderDisposables: renderDisposables, + _resourceLabels: { + clear: () => { }, + create: pill => ({ + dispose: () => { }, + setLabel: label => pill.textContent = label, + setFile: (_resource, _options) => pill.textContent = 'docs', + }), + }, + removeAttachment: () => { }, + }); + + assert.deepStrictEqual( + Array.from(container.querySelectorAll('.sessions-chat-attachment-pill')).map(pill => ({ + text: pill.textContent, + removeAriaLabel: pill.querySelector('.sessions-chat-attachment-remove')?.getAttribute('aria-label'), + })), + [ + { text: 'docs', removeAriaLabel: 'Remove docs' }, + { text: 'microsoft/typescript', removeAriaLabel: 'Remove microsoft/typescript' }, + ], + ); + }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index 39ad1cb652ef31..059dc41fb6a78f 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -25,6 +25,7 @@ import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browse import { IVoiceInputModeService, VoiceInputMode } from '../../../../../workbench/contrib/chat/browser/voiceInputMode/voiceInputMode.js'; import { IAICustomizationWorkspaceService } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IPromptsService } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IHistoryService } from '../../../../../workbench/services/history/common/history.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; @@ -38,12 +39,13 @@ import { ISessionsProvidersService } from '../../../../services/sessions/browser import { ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { ChatModelSource, IChat, ISession, ISessionWorkspace, ISessionType, SESSION_WORKSPACE_GROUP_LOCAL, SessionStatus, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; +import { ChatModelSource, IChat, ISession, ISessionWorkspace, ISessionType, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE, SessionStatus, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { AGENT_FEEDBACK_NEW_SESSION_RESOURCE, AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService } from '../../../agentFeedback/browser/agentFeedbackService.js'; import { IAquariumService } from '../../../aquarium/browser/aquariumOverlay.js'; import { computeIssueIcon, computePullRequestIcon, GitHubIssueState, GitHubPullRequestState } from '../../../github/common/types.js'; import { NewChatView } from '../../browser/chatView.js'; +import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; import { INewSessionComposerService, INewSessionPromptOption, NewSessionComposerService, NewSessionPromptOptionsState } from '../../browser/newSessionComposerService.js'; import { INewChatVoiceTargetService, NewChatVoiceTargetService } from '../../browser/newChatVoice.js'; @@ -52,6 +54,9 @@ import '../../../../browser/parts/media/sessionView.css'; const DEFAULT_WIDTH = 800; const DEFAULT_HEIGHT = 560; +const ATTACHED_FOLDER_URI = URI.file('/Code/docs'); +const ATTACHED_REPOSITORY_URI = URI.parse('https://github.com/microsoft/typescript'); +const ATTACHED_REPOSITORY_ROOT = URI.parse('vscode-vfs://github/microsoft/typescript/HEAD'); interface INewChatWidgetFixtureOptions { readonly width?: number; @@ -62,7 +67,10 @@ interface INewChatWidgetFixtureOptions { readonly selectedOptionIndex?: number; readonly editedInput?: string; readonly withWorkspace?: boolean; + readonly withRemoteWorkspace?: boolean; readonly openWorkspacePicker?: boolean; + readonly openGitHubContextPicker?: boolean; + readonly withAttachedContext?: boolean; } /** @@ -87,7 +95,10 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN selectedOptionIndex, editedInput, withWorkspace = false, + withRemoteWorkspace = false, openWorkspacePicker = false, + openGitHubContextPicker = false, + withAttachedContext = false, } = options; const feedbackItems: readonly IAgentFeedback[] = Array.from({ length: commentCount }, (_, index) => ({ id: `feedback-${index}`, @@ -98,10 +109,10 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN kind: AgentFeedbackKind.UserReview, state: AgentFeedbackState.Accepted, })); - const workspace = createFixtureWorkspace(); + const workspace = createFixtureWorkspace(withRemoteWorkspace); const sessionTypes = createFixtureSessionTypes(); const provider = createFixtureProvider(workspace, sessionTypes); - const activeSession = promptOptions || withWorkspace ? createFixtureActiveSession(workspace, sessionTypes[0]) : undefined; + const activeSession = promptOptions || withWorkspace || withRemoteWorkspace || withAttachedContext ? createFixtureActiveSession(workspace, sessionTypes[0]) : undefined; const activeSessionObservable = observableValue('activeSession', activeSession); const composerService = disposableStore.add(new NewSessionComposerService()); const sessionsService = new class extends mock() { @@ -266,11 +277,23 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN sessionViewContent.style.width = '100%'; sessionViewContent.style.height = '100%'; - const view = disposableStore.add(instantiationService.createInstance(NewChatView, false, {})); + const view = disposableStore.add(instantiationService.createInstance(NewChatView, false, { + initialAttachments: withAttachedContext ? createFixtureAttachments() : undefined, + })); sessionViewContent.appendChild(view.element); view.layout(width, height, 0, 0); if (openWorkspacePicker) { - view.element.querySelector('[aria-label="Choose a folder for the new session"]')?.click(); + const targetWindow = dom.getWindow(container); + const nextFrame = () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); + await nextFrame(); + await nextFrame(); + view.element.querySelector('.sessions-workspace-picker-trigger .action-label')?.click(); + } else if (openGitHubContextPicker) { + const targetWindow = dom.getWindow(container); + const nextFrame = () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); + await nextFrame(); + await nextFrame(); + view.element.querySelector('[aria-label="Attach a GitHub issue or pull request to the new session"]')?.click(); } if (promptOptions) { @@ -292,10 +315,26 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { labels: { kind: 'screenshot' }, render: context => renderNewChatWidget(context, { withWorkspace: true }), }), - NewSessionFolderPicker: defineComponentFixture({ - labels: { kind: 'screenshot' }, + NewSessionWorkspacePicker: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The microsoft/vscode workspace pill has the active treatment after opening the workspace picker.'], render: context => renderNewChatWidget(context, { withWorkspace: true, openWorkspacePicker: true }), }), + NewSessionGitHubContextPicker: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The Issue/PR pill has the active treatment after opening its picker.'], + render: context => renderNewChatWidget(context, { withWorkspace: true, openGitHubContextPicker: true }), + }), + NewSessionAttachedContext: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The new-session workspace row shows Copilot, microsoft/vscode with a count badge showing 2, and Issue/PR with a count badge showing 1. The composer attachment row shows removable docs, microsoft/typescript, and microsoft/vscode#333053 context pills with compact dismiss icons.'], + render: context => renderNewChatWidget(context, { withWorkspace: true, withAttachedContext: true }), + }), + NewSessionRemoteWorkspace: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The new-session composer shows Copilot and devbox · microsoft/vscode pills. No Issue/PR pill is visible because the remote workspace has no associated GitHub repository metadata.'], + render: context => renderNewChatWidget(context, { withRemoteWorkspace: true }), + }), NewSessionNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: context => renderNewChatWidget(context, { width: 420, height: 760, withWorkspace: true }), @@ -348,19 +387,21 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { }), }); -function createFixtureWorkspace(): ISessionWorkspace { - const resource = URI.file('C:\\Code\\vscode'); +function createFixtureWorkspace(remote: boolean): ISessionWorkspace { + const resource = remote + ? URI.parse('vscode-remote://ssh-remote+devbox/workspaces/vscode') + : URI.file('C:\\Code\\vscode'); return { uri: resource, - label: 'microsoft/vscode', - icon: Codicon.repo, - group: SESSION_WORKSPACE_GROUP_LOCAL, + label: remote ? 'devbox · microsoft/vscode' : 'microsoft/vscode', + icon: remote ? Codicon.remote : Codicon.repo, + group: remote ? SESSION_WORKSPACE_GROUP_REMOTE : SESSION_WORKSPACE_GROUP_LOCAL, folders: [{ root: resource, workingDirectory: resource, name: 'microsoft/vscode', description: undefined, - gitRepository: { + gitRepository: remote ? undefined : { uri: resource, workTreeUri: undefined, baseBranchName: 'main', @@ -399,7 +440,32 @@ function createFixtureProvider(workspace: ISessionWorkspace, sessionTypes: reado override readonly onDidChangeSessionTypes = Event.None; override readonly onDidChangeSessions = Event.None; override readonly onDidChangeModels = Event.None; - override readonly browseActions = []; + override readonly browseActions = [ + { + label: 'Repository...', + group: SESSION_WORKSPACE_GROUP_GITHUB, + icon: Codicon.repo, + providerId: this.id, + attachesContext: false, + run: async () => workspace, + }, + { + label: 'Issue...', + group: SESSION_WORKSPACE_GROUP_GITHUB, + icon: Codicon.issues, + providerId: this.id, + attachesContext: true, + run: async () => undefined, + }, + { + label: 'Pull Request...', + group: SESSION_WORKSPACE_GROUP_GITHUB, + icon: Codicon.gitPullRequest, + providerId: this.id, + attachesContext: true, + run: async () => undefined, + }, + ]; override readonly supportsLocalWorkspaces = true; override getSessions(): ISession[] { @@ -407,7 +473,16 @@ function createFixtureProvider(workspace: ISessionWorkspace, sessionTypes: reado } override resolveWorkspace(folderUri: URI): ISessionWorkspace | undefined { - return folderUri.toString() === workspace.folders[0].root.toString() ? workspace : undefined; + if (folderUri.toString() === workspace.folders[0].root.toString()) { + return workspace; + } + if (folderUri.toString() === ATTACHED_FOLDER_URI.toString()) { + return createAttachedFolderWorkspace(); + } + if (folderUri.toString() === ATTACHED_REPOSITORY_ROOT.toString()) { + return createAttachedRepositoryWorkspace(); + } + return undefined; } override getModelsSnapshot() { @@ -432,6 +507,65 @@ function createFixtureProvider(workspace: ISessionWorkspace, sessionTypes: reado }(); } +function createFixtureAttachments(): readonly IChatRequestVariableEntry[] { + const issueUri = URI.parse('https://github.com/microsoft/vscode/issues/333053'); + return [ + { + kind: 'directory', + id: getAdditionalFolderContextId(ATTACHED_FOLDER_URI), + name: 'docs', + value: ATTACHED_FOLDER_URI, + }, + { + kind: 'generic', + id: getAdditionalRepositoryContextId(ATTACHED_REPOSITORY_URI), + name: 'microsoft/typescript', + value: ATTACHED_REPOSITORY_ROOT, + icon: Codicon.repo, + }, + toPasteVariableEntry('microsoft/vscode#333053', `GitHub context: ${issueUri.toString()}`, { + id: `github-context:${issueUri.toString()}`, + icon: computeIssueIcon(GitHubIssueState.Open, undefined), + }), + ]; +} + +function createAttachedFolderWorkspace(): ISessionWorkspace { + return { + uri: ATTACHED_FOLDER_URI, + label: 'docs', + icon: Codicon.folder, + group: SESSION_WORKSPACE_GROUP_LOCAL, + folders: [{ + root: ATTACHED_FOLDER_URI, + workingDirectory: ATTACHED_FOLDER_URI, + name: 'docs', + description: undefined, + gitRepository: undefined, + }], + requiresWorkspaceTrust: true, + isVirtualWorkspace: false, + }; +} + +function createAttachedRepositoryWorkspace(): ISessionWorkspace { + return { + uri: ATTACHED_REPOSITORY_URI, + label: 'microsoft/typescript', + icon: Codicon.repo, + group: SESSION_WORKSPACE_GROUP_GITHUB, + folders: [{ + root: ATTACHED_REPOSITORY_ROOT, + workingDirectory: ATTACHED_REPOSITORY_ROOT, + name: 'typescript', + description: undefined, + gitRepository: undefined, + }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: true, + }; +} + function createFixtureActiveSession(workspace: ISessionWorkspace, sessionType: ISessionType): IActiveSession { const activeChat = new class extends mock() { override readonly resource = URI.parse('fixture-chat://new-session'); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index ca0fd2a05747e2..8ab06da47e3e49 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -114,6 +114,21 @@ interface ISendHarness { readonly logService: { error(message: string, ...args: unknown[]): void }; } +interface IRenderWorkspacePickerHarness { + readonly _workspacePickerVisibleKey: { set(value: boolean): void }; + readonly _workspacePicker: { + renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly attachesContext?: boolean }[]): HTMLElement; + }; + readonly _newChatInput: { + readonly sessionTypePicker: { + render(container: HTMLElement, options?: { className?: string }): void; + }; + }; + _workspacePickerRow: HTMLElement | undefined; +} + +const renderWorkspacePicker = Reflect.get(NewChatWidget.prototype, '_renderWorkspacePicker') as (this: IRenderWorkspacePickerHarness, container: HTMLElement) => IDisposable; + function createHarness( pendingPreferredUpgrade: MutableDisposable, newSessionCreation: MutableDisposable, @@ -143,6 +158,60 @@ function createHarness( suite('NewChatWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('workspace row hosts a multiple-harness picker first', () => { + const container = document.createElement('div'); + const harnessLabels = ['Copilot', 'Claude']; + const workspaceTriggers: { readonly tooltip: string | undefined; readonly attachesContext: boolean | undefined }[] = []; + const harness: IRenderWorkspacePickerHarness = { + _workspacePickerVisibleKey: { set: () => { } }, + _workspacePicker: { + renderCategoryTriggers: (target, triggers) => { + const row = document.createElement('div'); + target.appendChild(row); + for (const trigger of triggers) { + const item = document.createElement('div'); + item.textContent = trigger.label ?? 'More'; + row.appendChild(item); + workspaceTriggers.push({ tooltip: trigger.tooltip, attachesContext: trigger.attachesContext }); + } + return row; + }, + }, + _newChatInput: { + sessionTypePicker: { + render: (target, options) => { + if (harnessLabels.length <= 1) { + return; + } + const item = document.createElement('div'); + item.className = options?.className ?? ''; + item.textContent = harnessLabels[0]; + target.appendChild(item); + }, + }, + }, + _workspacePickerRow: undefined, + }; + + disposables.add(renderWorkspacePicker.call(harness, container)); + + assert.deepStrictEqual( + Array.from(harness._workspacePickerRow?.children ?? [], element => ({ + label: element.textContent, + className: element.className, + })), + [ + { label: 'Copilot', className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot' }, + { label: 'Workspace', className: '' }, + { label: 'Issue/PR', className: '' }, + ], + ); + assert.deepStrictEqual(workspaceTriggers, [ + { tooltip: 'Choose where the new session runs', attachesContext: false }, + { tooltip: 'Attach an issue or pull request as context', attachesContext: true }, + ]); + }); + test('replays a provider change that arrives while creating the draft', async () => { const sessionTypesChanged = disposables.add(new Emitter()); const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 86eb0f6a3efa73..d1c116b17ca060 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -56,6 +56,8 @@ import { TestNotificationService } from '../../../../../platform/notification/te import { AGENTIC_SIGN_IN_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { NullHoverService } from '../../../../../platform/hover/test/browser/nullHoverService.js'; // ---- Storage key (must match the one in sessionWorkspacePicker.ts) ---------- const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; @@ -255,6 +257,10 @@ class DispatchingWorkspacePicker extends WorkspacePicker { setDirectFilter(group: string | undefined, attachesContext?: boolean): void { this._setDirectPickerFilter(group, attachesContext); } + + selectTab(group: string): void { + this._selectWorkspaceGroup(group); + } } class TestAutomationsWorkspacePicker extends AutomationsWorkspacePicker { @@ -329,6 +335,7 @@ function createTestPicker( instantiationService.stub(IWorkspacesService, workspacesService); instantiationService.stub(ISessionsRecentWorkspacesService, recentWorkspacesService ?? disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IHoverService, NullHoverService); return disposables.add(instantiationService.createInstance(pickerCtor, options ?? {})); } @@ -1296,6 +1303,61 @@ suite('WorkspacePicker - Category Triggers', () => { }); }); + test('unified workspace trigger reflects the selected local or remote workspace', () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const localProvider = createMockProvider('local-1'); + const remoteProvider = createMockProvider('remote-1'); + providersService.setProviders([ + { + ...localProvider, + resolveWorkspace: uri => uri.scheme === 'file' + ? { ...localProvider.resolveWorkspace(uri)!, group: SESSION_WORKSPACE_GROUP_LOCAL, icon: Codicon.folder } + : undefined, + }, + { + ...remoteProvider, + resolveWorkspace: uri => uri.scheme === 'vscode-remote' + ? { ...remoteProvider.resolveWorkspace(uri)!, group: SESSION_WORKSPACE_GROUP_REMOTE, icon: Codicon.remote } + : undefined, + }, + ]); + const picker = createTestPicker(disposables, providersService); + const container = document.createElement('div'); + picker.renderCategoryTriggers(container, [{ + label: 'Workspace', + ariaLabel: 'Choose a workspace', + icon: Codicon.project, + reflectsWorkspace: true, + }]); + const trigger = container.querySelector('.action-label'); + const icon = trigger?.querySelector('.codicon'); + const label = trigger?.querySelector('.sessions-chat-dropdown-label'); + + const snapshots = [{ + icon: icon?.className, + label: label?.textContent, + ariaLabel: trigger?.getAttribute('aria-label'), + }]; + picker.setSelectedWorkspace(URI.file('/local/project'), { fireEvent: false, persist: false }); + snapshots.push({ + icon: icon?.className, + label: label?.textContent, + ariaLabel: trigger?.getAttribute('aria-label'), + }); + picker.setSelectedWorkspace(URI.parse('vscode-remote://ssh-remote+host/home/project'), { fireEvent: false, persist: false }); + snapshots.push({ + icon: icon?.className, + label: label?.textContent, + ariaLabel: trigger?.getAttribute('aria-label'), + }); + + assert.deepStrictEqual(snapshots, [ + { icon: 'codicon codicon-project', label: 'Workspace', ariaLabel: 'Choose a workspace' }, + { icon: 'codicon codicon-folder', label: 'local/project', ariaLabel: 'Workspace: local/project' }, + { icon: 'codicon codicon-remote', label: 'home/project', ariaLabel: 'Workspace: home/project' }, + ]); + }); + test('keeps the selected remote workspace visible in the remote trigger', () => { const providersService = disposables.add(new MockSessionsProvidersService()); const baseProvider = createMockProvider('agenthost-remote-1', { @@ -1419,7 +1481,7 @@ suite('WorkspacePicker - Category Triggers', () => { }); }); - test('hides issue and pull request context for a folder without a GitHub repository', () => { + test('hides issue and pull request context until a folder with a GitHub remote is selected', () => { const providersService = disposables.add(new MockSessionsProvidersService()); const baseProvider = createMockProvider('local-1'); providersService.setProviders([{ @@ -1440,13 +1502,13 @@ suite('WorkspacePicker - Category Triggers', () => { attachesContext: true, hideWhenNoGitHubRepository: true, }]); + const triggerSlot = container.querySelector('[aria-label="Choose an issue or pull request"]')?.parentElement; + const hiddenStates = [triggerSlot?.hidden]; picker.setSelectedWorkspace(URI.file('/local/project'), { fireEvent: false, persist: false }); + hiddenStates.push(triggerSlot?.hidden); - assert.strictEqual( - container.querySelector('[aria-label="Choose an issue or pull request"]')?.parentElement?.hidden, - true, - ); + assert.deepStrictEqual(hiddenStates, [true, true]); }); test('selects the first browsed folder as the primary workspace without attaching it', async () => { @@ -1479,7 +1541,8 @@ suite('WorkspacePicker - Category Triggers', () => { disposables.add(picker.onDidSelectWorkspace(uri => uri && workspaceSelections.push(uri))); disposables.add(picker.onDidSelectFolderContext(uri => folderContexts.push(uri))); - picker.setDirectFilter(SESSION_WORKSPACE_GROUP_LOCAL, false); + picker.setDirectFilter(undefined, false); + picker.selectTab(SESSION_WORKSPACE_GROUP_LOCAL); await picker.dispatchItem({ browseActionIndex: 0 }); assert.deepStrictEqual({ @@ -1495,7 +1558,7 @@ suite('WorkspacePicker - Category Triggers', () => { }); }); - test('keeps the primary folder and adds later folders as pills and context', async () => { + test('keeps the primary folder and counts later folders attached as context', async () => { const providersService = disposables.add(new MockSessionsProvidersService()); const baseProvider = createMockProvider('local-1'); providersService.setProviders([{ @@ -1508,61 +1571,80 @@ suite('WorkspacePicker - Category Triggers', () => { }]); const primaryFolder = URI.file('/local/primary'); const additionalFolder = URI.file('/local/additional'); + const otherFolder = URI.file('/local/other'); + let folderToAttach = additionalFolder; const picker = createTestPicker( disposables, providersService, undefined, new TestNotificationService(), DispatchingWorkspacePicker, - { showOpenDialog: async () => [additionalFolder] }, + { showOpenDialog: async () => [folderToAttach] }, ) as DispatchingWorkspacePicker; picker.setSelectedWorkspace(primaryFolder, { fireEvent: false, persist: false }); const container = document.createElement('div'); - picker.renderCategoryTriggers(container, [ - { label: 'Folder', ariaLabel: 'Choose a folder', icon: Codicon.add, group: SESSION_WORKSPACE_GROUP_LOCAL }, - { label: 'Repository', ariaLabel: 'Choose a repository', icon: Codicon.add, group: SESSION_WORKSPACE_GROUP_GITHUB, attachesContext: false }, - ]); + picker.renderCategoryTriggers(container, [{ + label: 'Workspace', + ariaLabel: 'Choose a workspace', + icon: Codicon.project, + reflectsWorkspace: true, + attachesContext: false, + }]); const folderContexts: URI[] = []; const removedContextIds: string[] = []; disposables.add(picker.onDidSelectFolderContext(uri => folderContexts.push(uri))); disposables.add(picker.onDidRemoveAttachedContext(id => removedContextIds.push(id))); - picker.setDirectFilter(SESSION_WORKSPACE_GROUP_LOCAL, false); - await picker.dispatchItem({ browseActionIndex: 0 }); - const pillsBeforeRemove = Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent); - const removeButton = container.querySelector('[aria-label="Remove attached folder local/additional"]'); - removeButton?.click(); - const pillsAfterRemove = Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent); + picker.setDirectFilter(undefined, false); + picker.selectTab(SESSION_WORKSPACE_GROUP_LOCAL); + await picker.dispatchItem({ browseActionIndex: 0, attachAsContext: true }); + folderToAttach = otherFolder; + picker.setDirectFilter(undefined, false); + picker.selectTab(SESSION_WORKSPACE_GROUP_LOCAL); + await picker.dispatchItem({ browseActionIndex: 0, attachAsContext: true }); + const labelsBeforeRemove = Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent); + const badgeBeforeRemove = container.querySelector('.monaco-count-badge')?.textContent; + const otherAttachment: IChatRequestVariableEntry = { + kind: 'directory', + id: getAdditionalFolderContextId(otherFolder), + name: 'other', + value: otherFolder, + }; + picker.syncAttachedContext([otherAttachment]); + const badgeAfterRemove = container.querySelector('.monaco-count-badge')?.textContent; const restoredAttachment: IChatRequestVariableEntry = { kind: 'directory', id: getAdditionalFolderContextId(additionalFolder), name: 'additional', value: additionalFolder, }; - picker.syncAttachedContext([restoredAttachment]); - const pillsAfterRestore = Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent); + picker.syncAttachedContext([otherAttachment, restoredAttachment]); + const badgeAfterRestore = container.querySelector('.monaco-count-badge')?.textContent; picker.setSelectedWorkspace(additionalFolder, { fireEvent: false, persist: false }); assert.deepStrictEqual({ selectedFolder: picker.selectedFolderUri?.toString(), - pillsBeforeRemove, - pillsAfterRemove, - pillsAfterRestore, - pills: Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent), + labelsBeforeRemove, + labels: Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent), + badgeBeforeRemove, + badgeAfterRemove, + badgeAfterRestore, + badgeAfterSelectingAttachedFolder: container.querySelector('.monaco-count-badge')?.textContent, folderContexts: folderContexts.map(uri => uri.toString()), additionalFolderUris: picker.additionalFolderUris.map(uri => uri.toString()), removedContextIds, }, { selectedFolder: additionalFolder.toString(), - pillsBeforeRemove: ['local/primary', 'local/additional', 'Repository'], - pillsAfterRemove: ['local/primary', 'Repository'], - pillsAfterRestore: ['local/primary', 'local/additional', 'Repository'], - pills: ['local/additional', 'Repository'], - folderContexts: [additionalFolder.toString()], - additionalFolderUris: [], + labelsBeforeRemove: ['local/primary'], + labels: ['local/additional'], + badgeBeforeRemove: '2', + badgeAfterRemove: '1', + badgeAfterRestore: '2', + badgeAfterSelectingAttachedFolder: '1', + folderContexts: [additionalFolder.toString(), otherFolder.toString()], + additionalFolderUris: [otherFolder.toString()], removedContextIds: [ getAdditionalFolderContextId(additionalFolder), - getAdditionalFolderContextId(additionalFolder), ], }); }); @@ -1597,10 +1679,12 @@ suite('WorkspacePicker - Category Triggers', () => { beforeProvider, afterProvider: picker.additionalFolderUris.map(uri => uri.toString()), pills: Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent), + badge: container.querySelector('.monaco-count-badge')?.textContent, }, { beforeProvider: [], afterProvider: [folder.toString()], - pills: ['Folder', 'local/delayed'], + pills: ['Folder'], + badge: undefined, }); }); @@ -1729,6 +1813,89 @@ suite('WorkspacePicker - Category Triggers', () => { }); }); + test('hides the category menu before opening an issue or pull request picker', async () => { + class SelectingActionWidgetService extends mock() { + override isVisible = false; + private readonly selections = new Map void>(); + private onHide: (() => void) | undefined; + + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { + this.selections.clear(); + for (const item of items) { + const actionItem = item.item; + if (item.label && actionItem) { + this.selections.set(item.label, () => delegate.onSelect(actionItem)); + } + } + this.onHide = delegate.onHide; + this.isVisible = true; + } + + override hide(): void { + this.isVisible = false; + this.onHide?.(); + this.onHide = undefined; + } + + select(label: string): void { + const select = this.selections.get(label); + assert.ok(select); + select(); + } + } + + const actionWidgetService = new SelectingActionWidgetService(); + const pickerVisibilityWhenBrowseRuns: boolean[] = []; + const providersService = disposables.add(new MockSessionsProvidersService()); + providersService.setProviders([createMockProvider('p1', { + browseActions: [{ + ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), + attachesContext: true, + run: async () => { + pickerVisibilityWhenBrowseRuns.push(actionWidgetService.isVisible); + return undefined; + }, + }], + })]); + const picker = createTestPicker( + disposables, + providersService, + undefined, + new TestNotificationService(), + WorkspacePicker, + {}, + undefined, + undefined, + {}, + upcastPartial({ + onDidChangeFileSystemProviderRegistrations: Event.None, + hasProvider: () => true, + exists: async () => true, + }), + actionWidgetService, + ); + const container = document.createElement('div'); + picker.renderCategoryTriggers(container, [{ + label: 'Issue/PR', + ariaLabel: 'Attach an issue or pull request', + icon: Codicon.add, + group: SESSION_WORKSPACE_GROUP_GITHUB, + attachesContext: true, + }]); + + container.querySelector('.action-label')?.click(); + actionWidgetService.select('Issue...'); + await Promise.resolve(); + + assert.deepStrictEqual({ + pickerVisibilityWhenBrowseRuns, + categoryMenuVisible: actionWidgetService.isVisible, + }, { + pickerVisibilityWhenBrowseRuns: [false], + categoryMenuVisible: false, + }); + }); + test('repository trigger respects the GitHub sign-in action before direct browsing', () => { class CapturingActionWidgetService extends mock() { override isVisible = false; @@ -1946,23 +2113,34 @@ suite('WorkspacePicker - Category Triggers', () => { const pullRequestSelection = picker.dispatchItem({ browseActionIndex: 1 }); picker.setDirectFilter(undefined); await pullRequestSelection; + const contextTrigger = container.querySelector('.action-label'); + const getContextTriggerSnapshot = () => ({ + label: contextTrigger?.querySelector('.sessions-chat-dropdown-label')?.textContent, + icon: contextTrigger?.querySelector('.codicon')?.className, + badge: contextTrigger?.querySelector('.monaco-count-badge')?.textContent, + ariaLabel: contextTrigger?.getAttribute('aria-label'), + }); + const triggerSnapshots = [getContextTriggerSnapshot()]; picker.setDirectFilter(SESSION_WORKSPACE_GROUP_GITHUB, true); const issueSelection = picker.dispatchItem({ browseActionIndex: 0 }); picker.setDirectFilter(undefined); await issueSelection; + triggerSnapshots.push(getContextTriggerSnapshot()); const attachments = contexts .filter(context => context.uri.path !== '/microsoft/vscode/pull/1') .map(context => toPasteVariableEntry(context.label, `GitHub context: ${context.uri.toString()}`, { id: `github-context:${context.uri.toString()}`, })); picker.syncAttachedContext(attachments); + triggerSnapshots.push(getContextTriggerSnapshot()); + picker.syncAttachedContext([]); + triggerSnapshots.push(getContextTriggerSnapshot()); assert.deepStrictEqual({ selectedFolder: picker.selectedFolderUri?.path, currentWorkspace: currentWorkspace?.folders[0]?.root.path, contexts: contexts.map(context => context.uri.toString()), - contextPill: container.querySelector('.sessions-chat-dropdown-label')?.textContent, - contextPillIcon: container.querySelector('.action-label .codicon')?.className, + triggerSnapshots, }, { selectedFolder: '/local/project', currentWorkspace: '/local/project', @@ -1970,8 +2148,12 @@ suite('WorkspacePicker - Category Triggers', () => { 'https://github.com/microsoft/vscode/pull/1', 'https://github.com/microsoft/vscode/issues/332805', ], - contextPill: 'Attached 1', - contextPillIcon: 'codicon codicon-attach', + triggerSnapshots: [ + { label: 'Issue/PR', icon: 'codicon codicon-add', badge: '1', ariaLabel: 'Choose an issue or pull request, 1 attached' }, + { label: 'Issue/PR', icon: 'codicon codicon-add', badge: '2', ariaLabel: 'Choose an issue or pull request, 2 attached' }, + { label: 'Issue/PR', icon: 'codicon codicon-add', badge: '1', ariaLabel: 'Choose an issue or pull request, 1 attached' }, + { label: 'Issue/PR', icon: 'codicon codicon-add', badge: undefined, ariaLabel: 'Choose an issue or pull request' }, + ], }); }); @@ -2020,15 +2202,34 @@ suite('WorkspacePicker - Category Triggers', () => { picker.setDirectFilter(SESSION_WORKSPACE_GROUP_GITHUB, false); await picker.dispatchItem({ browseActionIndex: 0 }); - - assert.deepStrictEqual({ + const firstSelection = { selectedFolder: picker.selectedFolderUri?.toString(), selectedProvider: picker.selectedResolved?.providerId, attachedContextWorkspaces: picker.attachedContextWorkspaces, + }; + + picker.setSelectedWorkspace(URI.file('/copilot/current'), { fireEvent: false, persist: false }); + picker.setDirectFilter(undefined); + await picker.dispatchItem({ browseActionIndex: 0 }); + + assert.deepStrictEqual({ + firstSelection, + replacementSelection: { + selectedFolder: picker.selectedFolderUri?.toString(), + selectedProvider: picker.selectedResolved?.providerId, + attachedContextWorkspaces: picker.attachedContextWorkspaces, + }, }, { - selectedFolder: repositoryUri.toString(), - selectedProvider: provider.id, - attachedContextWorkspaces: [], + firstSelection: { + selectedFolder: repositoryUri.toString(), + selectedProvider: provider.id, + attachedContextWorkspaces: [], + }, + replacementSelection: { + selectedFolder: repositoryUri.toString(), + selectedProvider: provider.id, + attachedContextWorkspaces: [], + }, }); }); @@ -2113,8 +2314,7 @@ suite('WorkspacePicker - Category Triggers', () => { picker.setSelectedWorkspace(localUri, { fireEvent: false, persist: false }); const container = document.createElement('div'); picker.renderCategoryTriggers(container, [ - { label: 'Folder', ariaLabel: 'Choose a folder', icon: Codicon.add, group: SESSION_WORKSPACE_GROUP_LOCAL }, - { label: 'Repository', ariaLabel: 'Choose a repository', icon: Codicon.add, group: SESSION_WORKSPACE_GROUP_GITHUB, attachesContext: false }, + { label: 'Workspace', ariaLabel: 'Choose a workspace', icon: Codicon.project, reflectsWorkspace: true, attachesContext: false }, { label: 'Issue/PR', ariaLabel: 'Choose an issue or pull request', icon: Codicon.add, group: SESSION_WORKSPACE_GROUP_GITHUB, attachesContext: true }, ]); const contexts: ISessionWorkspace[] = []; @@ -2125,51 +2325,59 @@ suite('WorkspacePicker - Category Triggers', () => { disposables.add(picker.onDidRemoveAttachedContext(id => removedContextIds.push(id))); browseHook.onBrowse = () => picker.setDirectFilter(undefined); - picker.setDirectFilter(SESSION_WORKSPACE_GROUP_GITHUB, false); - await picker.dispatchItem({ browseActionIndex: 0 }); + picker.setDirectFilter(undefined, false); + picker.selectTab(SESSION_WORKSPACE_GROUP_GITHUB); + await picker.dispatchItem({ browseActionIndex: 0, attachAsContext: true }); repositoryId = 'microsoft/other'; - picker.setDirectFilter(SESSION_WORKSPACE_GROUP_GITHUB, false); - await picker.dispatchItem({ browseActionIndex: 0 }); - const pillsBeforeRemove = Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent); - container.querySelector('[aria-label="Remove attached repository microsoft/other"]')?.click(); - const pillsAfterRemove = Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent); - const attachedRepository = repositoryContexts.at(-1); - assert.ok(attachedRepository); - const restoredAttachment: IChatRequestVariableEntry = { + picker.setDirectFilter(undefined, false); + picker.selectTab(SESSION_WORKSPACE_GROUP_GITHUB); + await picker.dispatchItem({ browseActionIndex: 0, attachAsContext: true }); + const labels = Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent); + const badgeBeforeRemove = container.querySelector('.sessions-workspace-picker-trigger .monaco-count-badge')?.textContent; + const firstRepository = repositoryContexts[0]; + const secondRepository = repositoryContexts[1]; + assert.ok(firstRepository); + assert.ok(secondRepository); + const firstAttachment: IChatRequestVariableEntry = { + kind: 'generic', + id: getAdditionalRepositoryContextId(firstRepository.workspace.uri), + name: firstRepository.workspace.label, + value: firstRepository.workspace.folders[0].root, + }; + const secondAttachment: IChatRequestVariableEntry = { kind: 'generic', - id: getAdditionalRepositoryContextId(attachedRepository.workspace.uri), - name: attachedRepository.workspace.label, - value: attachedRepository.workspace.folders[0].root, + id: getAdditionalRepositoryContextId(secondRepository.workspace.uri), + name: secondRepository.workspace.label, + value: secondRepository.workspace.folders[0].root, }; - picker.syncAttachedContext([restoredAttachment]); - const pillsAfterRestore = Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent); - picker.syncAttachedContext([restoredAttachment]); - container.querySelector('[aria-label="Remove attached repository microsoft/other"]')?.click(); - const pillsAfterRestoredRemove = Array.from(container.querySelectorAll('.sessions-chat-dropdown-label')).map(element => element.textContent); + picker.syncAttachedContext([firstAttachment]); + const badgeAfterRemove = container.querySelector('.sessions-workspace-picker-trigger .monaco-count-badge')?.textContent; + picker.syncAttachedContext([secondAttachment]); + const badgeAfterRestore = container.querySelector('.sessions-workspace-picker-trigger .monaco-count-badge')?.textContent; + picker.syncAttachedContext([]); assert.deepStrictEqual({ selectedFolder: picker.selectedFolderUri?.toString(), selectedProvider: picker.selectedResolved?.providerId, - pillsBeforeRemove, - pillsAfterRemove, - pillsAfterRestore, - pillsAfterRestoredRemove, + labels, + badgeBeforeRemove, + badgeAfterRemove, + badgeAfterRestore, + badgeAfterClear: container.querySelector('.sessions-workspace-picker-trigger .monaco-count-badge')?.textContent, contexts: contexts.map(context => context.uri.toString()), attachedContextWorkspaces: picker.attachedContextWorkspaces.map(context => context.uri.toString()), removedContextIds, }, { selectedFolder: localUri.toString(), selectedProvider: localProvider.id, - pillsBeforeRemove: ['local/vscode', 'microsoft/vscode', 'microsoft/other', 'Issue/PR'], - pillsAfterRemove: ['local/vscode', 'microsoft/vscode', 'Issue/PR'], - pillsAfterRestore: ['local/vscode', 'microsoft/vscode', 'microsoft/other', 'Issue/PR'], - pillsAfterRestoredRemove: ['local/vscode', 'microsoft/vscode', 'Issue/PR'], + labels: ['local/vscode', 'Issue/PR'], + badgeBeforeRemove: '2', + badgeAfterRemove: '1', + badgeAfterRestore: '1', + badgeAfterClear: undefined, contexts: [], attachedContextWorkspaces: [], - removedContextIds: [ - getAdditionalRepositoryContextId(URI.parse('https://github.com/microsoft/other')), - getAdditionalRepositoryContextId(URI.parse('https://github.com/microsoft/other')), - ], + removedContextIds: [], }); }); }); @@ -2657,6 +2865,14 @@ class TestablePicker extends WorkspacePicker { this._setDirectPickerFilter(group, attachesContext); } + selectWorkspaceActions(): void { + this._setDirectPickerFilter(undefined, false); + } + + selectTab(group: string): void { + this._selectWorkspaceGroup(group); + } + getItems() { return this._buildItems(); } @@ -2923,11 +3139,27 @@ suite('WorkspacePicker - Tab discovery', () => { repositoryItems, contextItems, }, { - repositoryItems: ['microsoft/vscode/HEAD', 'Add Repository...'], + repositoryItems: ['microsoft/vscode/HEAD', 'Repository...', 'Attach Repository...'], contextItems: ['Issue...', 'Pull Request...'], }); }); + test('excludes issue and pull request actions from the execution workspace picker', () => { + providersService.setProviders([createMockProvider('p1', { + browseActions: [ + { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }, + { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), attachesContext: true }, + { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Pull Request...'), attachesContext: true }, + ], + })]); + const picker = createTestablePicker(disposables, providersService); + + picker.selectWorkspaceActions(); + picker.selectTab(SESSION_WORKSPACE_GROUP_GITHUB); + + assert.deepStrictEqual(picker.getItemLabels(), ['Repository...']); + }); + test('does not offer clear actions in workspace pickers', () => { providersService.setProviders([{ ...createMockProvider('p1', { @@ -2953,8 +3185,8 @@ suite('WorkspacePicker - Tab discovery', () => { repositoryItems, contextItems, }, { - folderItems: ['Add Folder...'], - repositoryItems: ['Add Repository...'], + folderItems: ['Select...', 'Attach Folder...'], + repositoryItems: ['Repository...', 'Attach Repository...'], contextItems: ['Issue...'], }); }); diff --git a/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts b/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts index 0c8ef2f9ff46e4..9b380ba20de85c 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts @@ -38,13 +38,13 @@ export async function resolvePullRequestSessionRepository( const workspace = session.workspace.get(); for (const folder of workspace?.folders ?? []) { if (folder.root.scheme !== GITHUB_REMOTE_FILE_SCHEME) { - folderUri = folder.root; - break; + folderUri ??= folder.root; + const gitHubInfo = folder.gitRepository?.gitHubInfo.get(); + if (gitHubInfo) { + return { folderUri: folder.root, owner: gitHubInfo.owner, repo: gitHubInfo.repo }; + } } } - if (folderUri) { - break; - } } if (!folderUri) { return undefined; diff --git a/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts b/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts index a50e5c7230570b..d4479d95506b33 100644 --- a/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts @@ -281,12 +281,15 @@ suite('Create Session from Pull Request', () => { const remoteRoot = URI.parse('vscode-remote://ssh-remote+host/repos/alexr00/playground'); const cloudSession = sessionWithRepository(cloudRoot, 'alexr00', 'playground'); const localSession = sessionWithRepository(localRoot, 'alexr00', 'playground', false); + const localSessionWithMetadata = sessionWithRepository(localRoot, 'alexr00', 'playground'); + const otherCloudSession = sessionWithRepository(cloudRoot, 'microsoft', 'vscode'); const remoteSession = sessionWithRepository(remoteRoot, 'alexr00', 'playground'); assert.deepStrictEqual({ cloud: await resolvePullRequestSessionRepository([cloudSession]), local: await resolvePullRequestSessionRepository([localSession]), mixed: await resolvePullRequestSessionRepository([cloudSession, localSession]), + mixedRepositories: await resolvePullRequestSessionRepository([otherCloudSession, localSessionWithMetadata]), remote: await resolvePullRequestSessionRepository([remoteSession]), }, { cloud: undefined, @@ -296,6 +299,11 @@ suite('Create Session from Pull Request', () => { owner: 'alexr00', repo: 'playground', }, + mixedRepositories: { + folderUri: localRoot, + owner: 'alexr00', + repo: 'playground', + }, remote: { folderUri: remoteRoot, owner: 'alexr00', diff --git a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md index 17871ff731df67..67dee704da6364 100644 --- a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md +++ b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md @@ -17,7 +17,10 @@ This is the file-level companion to [LAYOUT_CONTROLLER.md](../../../LAYOUT_CONTR Switching to a session restores the layout you last had for it; switching away leaves the other session's layout untouched. #### B1 — Panel visibility -The bottom panel's visibility is remembered per session and defaults to hidden. Toggling the panel updates that session's remembered state. +The bottom panel's visibility is remembered per session and defaults to hidden. Toggling the panel updates that session's remembered state. Single-pane disables this scope: it governs the panel at the workbench level (like the side pane), so the panel is neither synced on a session switch nor recorded per session there. + +#### B6 — Panel view +Which view (pane composite) the panel shows is remembered per session and restored when you switch to that session or reveal the panel — but restoring the view never changes the panel's visibility (it applies only while the panel is already open). This is the inverse of [B1]: a layout either remembers the panel's visibility per session (and lets its view follow the global last-active) or governs visibility at the workbench level and remembers the *view* per session instead — never both. The single-pane layout is the latter. A session with no remembered view falls back to the Terminal. #### B2 — Open editors Each session restores its own set of open editors when you activate it. Switching sessions saves the editors you had open and applies the target session's. New / untitled sessions, and sessions with no saved editors, never force the editor area open or wipe it. In layouts that scope editor-part visibility per session, hiding the editor part is restored with that session. Single-pane disables that visibility scope: only editor tabs are per-session there. @@ -42,9 +45,10 @@ While multiple sessions are visible, per-session panel restore is paused and the ## Implementation notes - **Observable-driven** — all session-switch logic reacts to the `activeSession` / `visibleSessions` observables via `autorun` / `derived`, never events for control flow. Derives `activeSessionResourceObs` (resource-equality de-duplicated) and `multipleSessionsVisibleObs`. -- **Panel [B1]** — `_syncPanelVisibility(resource)` restores the record (default hidden); a live `onDidChangePartVisibility` listener for `PANEL_PART` updates it (suppressed while multiple sessions are visible). +- **Panel [B1]** — `_syncPanelVisibility(resource)` restores the record (default hidden); a live `onDidChangePartVisibility` listener for `PANEL_PART` updates it (suppressed while multiple sessions are visible). Gated on `_isPanelVisibilityPerSession` (base `true`): single-pane overrides it to `false` so the sync autorun, the record listener, and the [B5] cleanup all skip the panel, leaving its visibility to the workbench (persisted there alongside the side pane). +- **Panel view [B6]** — `_panelViewBySession` records the panel's active pane-composite id per session, captured from `IPaneCompositePartService.onDidPaneCompositeOpen` for `ViewContainerLocation.Panel` (suppressed while multiple sessions are visible or a custom view covers the grid). `_syncPanelView(resource)` restores it on session switch (an autorun) and on the panel being shown (a `PANEL_PART` visibility listener), but only while the panel is already visible — `openPaneComposite` would otherwise reveal the panel, whose visibility is governed elsewhere. A session with no record falls back to `TERMINAL_VIEW_ID`. Gated on the same single `_isPanelVisibilityPerSession` flag as [B1] but inverted: this scope is active exactly when panel visibility is *not* per session, so single-pane's one `_isPanelVisibilityPerSession = false` override enables it. Persisted per session as `panelViewContainerId` and cleared on session removal. - **Working sets [B2]** — always active, regardless of `workbench.editor.useModal`: browser editors dock in the shared grid editor part even when `useModal` is `'all'` (they except themselves from the modal part), so their tabs still need per-session capture/restore in that mode. `_useModalConfigObs` is only consulted inside `_applyWorkingSet` to decide whether to auto-reveal the editor part (skipped in modal mode, since modal editors manage their own visibility). `activeSessionForWorkingSet` (`derivedObservableWithCache`) holds back the new session until the workspace folders reflect its working directory. Save/apply on switch via a serializing `Sequencer`; initial restore applies a saved set under `suppressEditorPartAutoVisibility()` only. The editor part's hidden state is captured **eagerly** per session by a part-visibility listener the moment the user changes it (`_editorPartHiddenBySession`, only while a single session is visible — the editor area is shared in multi-session mode; captured lazily at switch-away it would race the switch derive), so a switch-back `_applyWorkingSet` skips the editor-part reveal for layouts that use per-session visibility. Single-pane overrides `_isEditorPartVisibilityPerSession` and `_isViewStatePerSession` to disable both maps; `SinglePaneExistingSessionStrategy` owns the shared Existing visibility profile, while `SinglePaneNewSessionStrategy` applies only a one-time entry rule and stores no visibility state. `onDidReplaceSession` copies a replaced active draft's editor-part hidden state to the committed resource before that resource's first working-set apply, avoiding a fall-through to the created-session default. Cleanup on `onDidChangeSessions` (`_deleteWorkingSet` drops only the working set, never view state). Clearing a session Changes pane releases its resolved multi-diff models, including models that finish resolving after the pane clears, while retaining the serializable editor input and persisted view state. A restored working set therefore re-resolves documents without keeping an inactive session's files open. -- **Persistence & migration [B3]** — per-session state is keyed by session `URI` and persisted to the workspace-scoped storage key `sessions.layoutState` (`StorageTarget.MACHINE`). `_loadState` restores on construction and drops corrupt data defensively; if the key is absent it migrates once from the legacy `sessions.workingSets` key and removes it. Maps: `_viewStateBySession` (opaque aux-bar state), `_panelVisibilityBySession`, `_workingSets`. +- **Persistence & migration [B3]** — per-session state is keyed by session `URI` and persisted to the workspace-scoped storage key `sessions.layoutState` (`StorageTarget.MACHINE`). `_loadState` restores on construction and drops corrupt data defensively; if the key is absent it migrates once from the legacy `sessions.workingSets` key and removes it. Maps: `_viewStateBySession` (opaque aux-bar state), `_panelVisibilityBySession`, `_panelViewBySession`, `_workingSets`. - **Save on exit [B4]** — `IStorageService.onWillSaveState` → `_saveState` captures the active session's view state (via the `_captureActiveSessionViewState` hook) and working set, skips untitled / multi-session cases, then writes one entry per known session resource. -- **Multi-session suppression [B5]** — a dedicated autorun clears `_viewStateBySession` and `_panelVisibilityBySession` for every visible session; editor working sets are left untouched. +- **Multi-session suppression [B5]** — a dedicated autorun clears `_viewStateBySession` and `_panelVisibilityBySession` for every visible session (the latter only while `_isPanelVisibilityPerSession`); editor working sets are left untouched. - **Subclass hook** — `_registerViewStateManagement()` runs at the end of the base constructor for platform-specific auxiliary bar wiring (no-op in the base); `_captureActiveSessionViewState(resource)` is the save-time hook (no-op in the base) invoked by [B4]; `_onSidePaneToggled()` runs at the end of `toggleSidePane()` (no-op in the base) so a subclass can record the resulting side-pane state, which the per-session capture listener deliberately ignores while the side pane is toggled. diff --git a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts index a71fe436a26e89..676b311cb0f1df 100644 --- a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts @@ -29,6 +29,7 @@ import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js' import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, MainEditorAreaVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../../workbench/common/views.js'; +import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; import { IEditorGroupsService, IEditorWorkingSet } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; @@ -67,6 +68,8 @@ interface ISessionLayoutEntry { readonly viewState?: ISessionViewState; readonly editorWorkingSet?: IEditorWorkingSet; readonly editorPartHidden?: boolean; + /** [B6] The panel view container id this session last showed in the panel. */ + readonly panelViewContainerId?: string; } /** New unified storage key for all per-session layout state. */ @@ -76,18 +79,20 @@ const WORKING_SETS_STORAGE_KEY = 'sessions.workingSets'; /** * Shared, platform-agnostic per-session layout state management. The behaviour - * specified here is enumerated as rules **B1-B5** in + * specified here is enumerated as rules **B1-B6** in * [baseSessionLayoutController.md](./baseSessionLayoutController.md). * - * It owns the panel visibility, editor working sets, persistence, and the - * multi-session suppression that every layout needs. Auxiliary bar management - * is platform-specific and supplied by subclasses through - * {@link _registerViewStateManagement} (see the desktop / mobile controllers). + * It owns the panel visibility (or, in single-pane, the panel view), editor + * working sets, persistence, and the multi-session suppression that every layout + * needs. Auxiliary bar management is platform-specific and supplied by subclasses + * through {@link _registerViewStateManagement} (see the desktop / mobile controllers). */ export abstract class BaseLayoutController extends Disposable { // [B3] Per-session state, keyed by session resource and persisted to storage. protected readonly _panelVisibilityBySession = new ResourceMap(); + // [B6] The panel view (pane composite) each session last showed. + protected readonly _panelViewBySession = new ResourceMap(); protected readonly _viewStateBySession = new ResourceMap(); protected readonly _workingSets = new ResourceMap(); /** @@ -157,6 +162,17 @@ export abstract class BaseLayoutController extends Disposable { return true; } + /** + * The single gate for per-session panel state. When `true` [B1] the panel's + * *visibility* is remembered per session; when `false` the panel is governed at + * the workbench level (like the side pane) and its *view* is remembered per + * session instead [B6] — never both. Restoring the view never changes the + * panel's visibility. + */ + protected get _isPanelVisibilityPerSession(): boolean { + return true; + } + constructor( @IAgentWorkbenchLayoutService protected readonly _layoutService: IAgentWorkbenchLayoutService, @@ -211,12 +227,17 @@ export abstract class BaseLayoutController extends Disposable { if (this._isViewStatePerSession) { this._viewStateBySession.delete(session.resource); } - this._panelVisibilityBySession.delete(session.resource); + if (this._isPanelVisibilityPerSession) { + this._panelVisibilityBySession.delete(session.resource); + } } })); // [B1] Switch between sessions — sync panel visibility this._register(autorun(reader => { + if (!this._isPanelVisibilityPerSession) { + return; + } const activeSessionResource = this.activeSessionResourceObs.read(reader); if (this.multipleSessionsVisibleObs.read(reader)) { return; @@ -226,7 +247,7 @@ export abstract class BaseLayoutController extends Disposable { // [B1] Track panel visibility changes by the user this._register(this._layoutService.onDidChangePartVisibility(e => { - if (e.partId !== Parts.PANEL_PART) { + if (!this._isPanelVisibilityPerSession || e.partId !== Parts.PANEL_PART) { return; } if (this.multipleSessionsVisibleObs.get() || this._isCustomViewVisible()) { @@ -238,6 +259,43 @@ export abstract class BaseLayoutController extends Disposable { } })); + // [B6] Capture the panel's active view per session (suppressed while + // multiple sessions are visible or a custom view covers the grid). + this._register(this._paneCompositePartService.onDidPaneCompositeOpen(e => { + if (this._isPanelVisibilityPerSession || e.viewContainerLocation !== ViewContainerLocation.Panel) { + return; + } + if (this.multipleSessionsVisibleObs.get() || this._isCustomViewVisible()) { + return; + } + const activeSession = this._sessionsService.activeSession.get(); + if (activeSession) { + this._panelViewBySession.set(activeSession.resource, e.composite.getId()); + } + })); + + // [B6] Restore the session's remembered panel view on switch and when the + // panel is shown. + this._register(autorun(reader => { + if (this._isPanelVisibilityPerSession) { + return; + } + const activeSessionResource = this.activeSessionResourceObs.read(reader); + if (this.multipleSessionsVisibleObs.read(reader)) { + return; + } + this._syncPanelView(activeSessionResource); + })); + this._register(this._layoutService.onDidChangePartVisibility(e => { + if (this._isPanelVisibilityPerSession || e.partId !== Parts.PANEL_PART || !e.visible) { + return; + } + if (this.multipleSessionsVisibleObs.get() || this._isCustomViewVisible()) { + return; + } + this._syncPanelView(this._sessionsService.activeSession.get()?.resource); + })); + // [B2] Track editor-part (docked side-pane) visibility changes by the user // so a session's closed/open editor state is captured at the moment it // changes — not lazily re-read at switch-away time, which races with the @@ -331,6 +389,7 @@ export abstract class BaseLayoutController extends Disposable { this._deleteWorkingSet(session.resource); this._viewStateBySession.delete(session.resource); this._editorPartHiddenBySession.delete(session.resource); + this._panelViewBySession.delete(session.resource); } })); this._register(this._sessionManagementService.onDidReplaceSession(({ from, to }) => this._onSessionReplaced(from, to))); @@ -436,22 +495,35 @@ export abstract class BaseLayoutController extends Disposable { protected _registerViewStateManagement(): void { } protected _onSessionReplaced(from: ISession, to: ISession): void { - if (!this._isEditorPartVisibilityPerSession) { - return; - } // `onDidReplaceSession` fires only when an untitled draft is atomically - // replaced by its committed session on submit, so it always means "the - // committed session inherits the draft's on-screen side-pane layout". - // Persist the draft's live editor-part visibility onto the committed - // session so the delayed working-set apply restores it as-left (instead of - // the created-session default, which would reveal the docked editor) and it - // also survives a reload. + // replaced by its committed session on submit, so the committed session + // inherits the draft's on-screen layout. const activeSession = this._sessionsService.activeSession.get(); const replacedSessionIsActive = isEqual(activeSession?.resource, from.resource) || isEqual(activeSession?.resource, to.resource); - const editorPartHidden = this._editorPartHiddenBySession.get(from.resource) - ?? (replacedSessionIsActive ? !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) : undefined); - if (editorPartHidden !== undefined) { - this._editorPartHiddenBySession.set(to.resource, editorPartHidden); + + // [B2] Carry the draft's editor-part visibility over so the delayed + // working-set apply restores it as-left (instead of the created-session + // default, which would reveal the docked editor) and it survives a reload. + if (this._isEditorPartVisibilityPerSession) { + const editorPartHidden = this._editorPartHiddenBySession.get(from.resource) + ?? (replacedSessionIsActive ? !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) : undefined); + if (editorPartHidden !== undefined) { + this._editorPartHiddenBySession.set(to.resource, editorPartHidden); + } + } + + // [B6] Carry the draft's remembered panel view over so the active-resource + // sync restores it rather than falling back to the Terminal, and resync now + // if the replacement is already active (the transfer may land after the + // active-resource autorun that switched to the committed session). + if (!this._isPanelVisibilityPerSession) { + const panelView = this._panelViewBySession.get(from.resource); + if (panelView !== undefined) { + this._panelViewBySession.set(to.resource, panelView); + } + if (replacedSessionIsActive) { + this._syncPanelView(to.resource); + } } } @@ -581,6 +653,9 @@ export abstract class BaseLayoutController extends Disposable { if (this._isViewStatePerSession && entry.viewState) { this._viewStateBySession.set(resource, entry.viewState); } + if (!this._isPanelVisibilityPerSession && entry.panelViewContainerId) { + this._panelViewBySession.set(resource, entry.panelViewContainerId); + } } return; } catch { @@ -642,6 +717,10 @@ export abstract class BaseLayoutController extends Disposable { this._editorPartHiddenBySession.forEach((_, r) => allResources.set(r, true)); } + if (!this._isPanelVisibilityPerSession) { + this._panelViewBySession.forEach((_, r) => allResources.set(r, true)); + } + if (allResources.size === 0) { this._storageService.remove(this._layoutStateStorageKey, StorageScope.WORKSPACE); return; @@ -654,6 +733,7 @@ export abstract class BaseLayoutController extends Disposable { editorWorkingSet: this._workingSets.get(resource), viewState: this._isViewStatePerSession ? this._viewStateBySession.get(resource) : undefined, editorPartHidden: this._isEditorPartVisibilityPerSession ? this._editorPartHiddenBySession.get(resource) : undefined, + panelViewContainerId: !this._isPanelVisibilityPerSession ? this._panelViewBySession.get(resource) : undefined, }); }); this._storageService.store(this._layoutStateStorageKey, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); @@ -672,6 +752,24 @@ export abstract class BaseLayoutController extends Disposable { this._layoutService.setPartHidden(wasVisible !== true, Parts.PANEL_PART); } + // --- Panel view [B6] --- + + /** + * Restores the session's remembered panel view (falling back to the Terminal), + * but only while the panel is already visible — opening a pane composite would + * otherwise reveal the panel, whose visibility is governed elsewhere. + */ + private _syncPanelView(sessionResource: URI | undefined): void { + if (!sessionResource || !this._layoutService.isVisible(Parts.PANEL_PART)) { + return; + } + const viewContainerId = this._panelViewBySession.get(sessionResource) ?? TERMINAL_VIEW_ID; + if (this._paneCompositePartService.getActivePaneComposite(ViewContainerLocation.Panel)?.getId() === viewContainerId) { + return; + } + this._paneCompositePartService.openPaneComposite(viewContainerId, ViewContainerLocation.Panel); + } + // --- Editor working sets [B2] --- private async _applyWorkingSet(sessionResource: URI | undefined, options?: { readonly isInitialRestore?: boolean }): Promise { diff --git a/src/vs/sessions/contrib/layout/browser/singlePaneLayoutController.ts b/src/vs/sessions/contrib/layout/browser/singlePaneLayoutController.ts index 64b6c89b964fdd..2610b183606d2e 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePaneLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePaneLayoutController.ts @@ -120,6 +120,15 @@ export class SinglePaneLayoutController extends BaseLayoutController { return false; } + /** + * Governs the panel at the workbench level (like the side pane). Flipping this + * single gate off makes the base remember the panel's *view* per session instead + * (defaulting to the Terminal). + */ + protected override get _isPanelVisibilityPerSession(): boolean { + return false; + } + protected override _shouldRevealEditorPartOnApply(_editorPartHidden: boolean, _isModal: boolean): boolean { return false; } diff --git a/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts index 12e806f513e814..63c8d5d048b97d 100644 --- a/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts @@ -11,12 +11,19 @@ import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { StorageScope, WillSaveStateReason } from '../../../../../platform/storage/common/storage.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { ViewContainerLocation } from '../../../../../workbench/common/views.js'; +import { TERMINAL_VIEW_ID } from '../../../../../workbench/contrib/terminal/common/terminal.js'; import { BaseLayoutController } from '../../browser/baseSessionLayoutController.js'; -import { createTestHarness, ICreateOptions, ITestLayoutHarness, makeSession } from './layoutControllerTestUtils.js'; +import { createTestHarness, ICreateOptions, ITestLayoutHarness, makePaneComposite, makeSession } from './layoutControllerTestUtils.js'; /** Concrete, behaviourless subclass so the abstract base (its view-state hook is a no-op) can be instantiated. */ class TestBaseLayoutController extends BaseLayoutController { } +/** Mirrors the single-pane panel model: workbench-level visibility, per-session view. */ +class TestWorkbenchPanelLayoutController extends BaseLayoutController { + protected override get _isPanelVisibilityPerSession(): boolean { return false; } +} + suite('BaseLayoutController', () => { const store = new DisposableStore(); @@ -27,6 +34,11 @@ suite('BaseLayoutController', () => { return store.add(harness.instaService.createInstance(TestBaseLayoutController)); } + function createWorkbenchPanelController(options: ICreateOptions = {}): TestWorkbenchPanelLayoutController { + harness = createTestHarness(store, options); + return store.add(harness.instaService.createInstance(TestWorkbenchPanelLayoutController)); + } + teardown(() => store.clear()); ensureNoDisposablesAreLeakedInTestSuite(); @@ -72,6 +84,175 @@ suite('BaseLayoutController', () => { ); }); + test('[B1] does not sync the panel on session switch when panel visibility is not per session', () => { + createWorkbenchPanelController(); + const session1 = makeSession(URI.parse('session:1')); + const session2 = makeSession(URI.parse('session:2')); + + harness.setPartHiddenCalls = []; + harness.activeSessionObs.set(session1, undefined); + harness.activeSessionObs.set(session2, undefined); + + assert.ok( + !harness.setPartHiddenCalls.some(c => c.part === Parts.PANEL_PART), + 'panel visibility should be left to the workbench, not synced on switch' + ); + }); + + test('[B1] does not restore a per-session panel record when panel visibility is not per session', () => { + createWorkbenchPanelController(); + const session1 = makeSession(URI.parse('session:1')); + const session2 = makeSession(URI.parse('session:2')); + + // Panel shown while on session 1 — must not be recorded against the session. + harness.activeSessionObs.set(session1, undefined); + harness.onDidChangePartVisibility.fire({ partId: Parts.PANEL_PART, visible: true }); + + harness.activeSessionObs.set(session2, undefined); + harness.setPartHiddenCalls = []; + harness.activeSessionObs.set(session1, undefined); + + assert.ok( + !harness.setPartHiddenCalls.some(c => c.part === Parts.PANEL_PART), + 'returning to session 1 should not restore a per-session panel state' + ); + }); + + // --- [B6] Panel view (which view the panel shows) --- + + test('[B6] restores the session\'s panel view on switch while the panel is visible', () => { + createWorkbenchPanelController(); + harness.partVisibility.set(Parts.PANEL_PART, true); + + const session1 = makeSession(URI.parse('session:1')); + const session2 = makeSession(URI.parse('session:2')); + + harness.activeSessionObs.set(session1, undefined); + harness.activePaneCompositeId = 'view.a'; + harness.onDidPaneCompositeOpen.fire({ composite: makePaneComposite('view.a'), viewContainerLocation: ViewContainerLocation.Panel }); + + harness.activeSessionObs.set(session2, undefined); + harness.activePaneCompositeId = 'view.b'; + harness.onDidPaneCompositeOpen.fire({ composite: makePaneComposite('view.b'), viewContainerLocation: ViewContainerLocation.Panel }); + + harness.openPaneCompositeCalls = []; + harness.activeSessionObs.set(session1, undefined); + + assert.deepStrictEqual(harness.openPaneCompositeCalls, [{ id: 'view.a', location: ViewContainerLocation.Panel }]); + }); + + test('[B6] does not force the panel view open while the panel is hidden', () => { + createWorkbenchPanelController(); + harness.partVisibility.set(Parts.PANEL_PART, true); + + const session1 = makeSession(URI.parse('session:1')); + const session2 = makeSession(URI.parse('session:2')); + + harness.activeSessionObs.set(session1, undefined); + harness.activePaneCompositeId = 'view.a'; + harness.onDidPaneCompositeOpen.fire({ composite: makePaneComposite('view.a'), viewContainerLocation: ViewContainerLocation.Panel }); + + harness.partVisibility.set(Parts.PANEL_PART, false); + harness.activeSessionObs.set(session2, undefined); + harness.openPaneCompositeCalls = []; + harness.activeSessionObs.set(session1, undefined); + + assert.deepStrictEqual(harness.openPaneCompositeCalls, [], 'the panel must not be forced open to restore a view'); + }); + + test('[B6] restores the session\'s panel view when the panel is shown', () => { + createWorkbenchPanelController(); + harness.partVisibility.set(Parts.PANEL_PART, true); + + const session1 = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session1, undefined); + harness.activePaneCompositeId = 'view.a'; + harness.onDidPaneCompositeOpen.fire({ composite: makePaneComposite('view.a'), viewContainerLocation: ViewContainerLocation.Panel }); + + // The panel is hidden and its active view diverges, then it is shown again. + harness.partVisibility.set(Parts.PANEL_PART, false); + harness.activePaneCompositeId = 'view.b'; + harness.openPaneCompositeCalls = []; + harness.partVisibility.set(Parts.PANEL_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.PANEL_PART, visible: true }); + + assert.deepStrictEqual(harness.openPaneCompositeCalls, [{ id: 'view.a', location: ViewContainerLocation.Panel }]); + }); + + test('[B6] persists the session\'s panel view', () => { + createWorkbenchPanelController(); + harness.partVisibility.set(Parts.PANEL_PART, true); + + const session1 = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session1, undefined); + harness.activePaneCompositeId = 'view.a'; + harness.onDidPaneCompositeOpen.fire({ composite: makePaneComposite('view.a'), viewContainerLocation: ViewContainerLocation.Panel }); + + harness.storageService.testEmitWillSaveState(WillSaveStateReason.SHUTDOWN); + + const stored = harness.storageService.get('sessions.layoutState', StorageScope.WORKSPACE); + assert.ok(stored, 'layout state should be written'); + const entry = JSON.parse(stored!).find((e: any) => e.sessionResource === 'session:1'); + assert.strictEqual(entry.panelViewContainerId, 'view.a'); + }); + + test('[B6] restores a persisted panel view on switch after reload', () => { + const layoutState = [{ sessionResource: 'session:1', panelViewContainerId: 'view.a' }]; + createWorkbenchPanelController({ layoutState }); + harness.partVisibility.set(Parts.PANEL_PART, true); + + const session1 = makeSession(URI.parse('session:1')); + harness.openPaneCompositeCalls = []; + harness.activeSessionObs.set(session1, undefined); + + assert.deepStrictEqual(harness.openPaneCompositeCalls, [{ id: 'view.a', location: ViewContainerLocation.Panel }]); + }); + + test('[B6] falls back to the Terminal when a session has no record, then prefers its remembered view', () => { + createWorkbenchPanelController(); + harness.partVisibility.set(Parts.PANEL_PART, true); + + const session1 = makeSession(URI.parse('session:1')); + + // No record for session 1 → the panel falls back to the Terminal. + harness.openPaneCompositeCalls = []; + harness.activeSessionObs.set(session1, undefined); + assert.deepStrictEqual(harness.openPaneCompositeCalls, [{ id: TERMINAL_VIEW_ID, location: ViewContainerLocation.Panel }]); + + // The user opens another view while on session 1, which is remembered. + harness.activePaneCompositeId = 'view.a'; + harness.onDidPaneCompositeOpen.fire({ composite: makePaneComposite('view.a'), viewContainerLocation: ViewContainerLocation.Panel }); + + const session2 = makeSession(URI.parse('session:2')); + harness.activeSessionObs.set(session2, undefined); + + // Returning to session 1 restores its remembered view, not the default. + harness.openPaneCompositeCalls = []; + harness.activeSessionObs.set(session1, undefined); + assert.deepStrictEqual(harness.openPaneCompositeCalls, [{ id: 'view.a', location: ViewContainerLocation.Panel }]); + }); + + test('[B6] carries a draft\'s remembered panel view to its committed session on submit', () => { + createWorkbenchPanelController(); + harness.partVisibility.set(Parts.PANEL_PART, true); + + const draft = makeSession(URI.parse('session:draft')); + const committed = makeSession(URI.parse('session:committed')); + + // The user opens a view while on the draft — remembered against the draft. + harness.activeSessionObs.set(draft, undefined); + harness.activePaneCompositeId = 'view.a'; + harness.onDidPaneCompositeOpen.fire({ composite: makePaneComposite('view.a'), viewContainerLocation: ViewContainerLocation.Panel }); + + // Submit atomically replaces the draft with its committed session: the + // remembered view transfers so the panel does not fall back to the Terminal. + harness.activeSessionObs.set(committed, undefined); + harness.openPaneCompositeCalls = []; + harness.onDidReplaceSession.fire({ from: draft, to: committed }); + + assert.deepStrictEqual(harness.openPaneCompositeCalls, [{ id: 'view.a', location: ViewContainerLocation.Panel }]); + }); + // --- [B2] Editor working sets --- test('[B2] does not reveal the editor part on reload when its working set is restored but the part was hidden', async () => { diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index c1a02c518746ba..1e337915b040af 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -45,6 +45,13 @@ export function makeChange(filePath: string): ISessionFileChange { return { uri: URI.file(filePath), insertions: 1, deletions: 0 }; } +/** A minimal pane composite identified only by its id, for the panel-view capture. */ +export function makePaneComposite(id: string): IPaneComposite { + return new class extends mock() { + override getId() { return id; } + }; +} + /** A minimal editor input for tests, identified only by its resource. */ export class TestStubEditorInput extends EditorInput { constructor(private readonly _resource: URI, private readonly _options?: { readonly dirty?: boolean; readonly nonRestorable?: boolean }) { super(); } @@ -185,6 +192,10 @@ export interface ITestLayoutHarness { onDidLayoutMainContainer: Emitter; onDidChangeViewContainerVisibility: Emitter<{ id: string; visible: boolean; location: ViewContainerLocation }>; onDidChangeActiveViewDescriptors: Emitter; + /** Fires a pane composite (view) opening in a part, driving the per-session panel-view capture. */ + onDidPaneCompositeOpen: Emitter<{ composite: IPaneComposite; viewContainerLocation: ViewContainerLocation }>; + /** Records every `openPaneComposite` call the controller makes to restore a session's panel view. */ + openPaneCompositeCalls: { id: string | undefined; location: ViewContainerLocation }[]; /** IDs of aux-bar view containers that are currently active (shown as a tab). */ activeAuxViewContainerIds: string[]; mainContainerWidth: number; @@ -301,6 +312,8 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption onDidLayoutMainContainer: store.add(new Emitter()), onDidChangeViewContainerVisibility: store.add(new Emitter<{ id: string; visible: boolean; location: ViewContainerLocation }>()), onDidChangeActiveViewDescriptors: store.add(new Emitter()), + onDidPaneCompositeOpen: store.add(new Emitter<{ composite: IPaneComposite; viewContainerLocation: ViewContainerLocation }>()), + openPaneCompositeCalls: [], activeAuxViewContainerIds: options.activeAuxViewContainerIds ? [...options.activeAuxViewContainerIds] : [CHANGES_VIEW_CONTAINER_ID, SESSIONS_FILES_CONTAINER_ID], mainContainerWidth: options.mainContainerWidth ?? 2000, editorMaximized: false, @@ -575,6 +588,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption } instaService.stub(IPaneCompositePartService, new class extends mock() { + override readonly onDidPaneCompositeOpen = harness.onDidPaneCompositeOpen.event; override getActivePaneComposite(_location: ViewContainerLocation): IPaneComposite | undefined { if (harness.activePaneCompositeId) { return new class extends mock() { @@ -583,6 +597,11 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption } return undefined; } + override async openPaneComposite(id: string | undefined, location: ViewContainerLocation): Promise { + harness.openPaneCompositeCalls.push({ id, location }); + harness.activePaneCompositeId = id; + return undefined; + } override getPinnedPaneCompositeIds(_location: ViewContainerLocation): string[] { return [...harness.pinnedAuxiliaryBarContainerIds]; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index f1565d6db13dd3..69c79ef99c660f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -40,9 +40,9 @@ export type IAgentHostAutomationConnection = Pick & { - getSubscriptionByChannel( + getSubscription( kind: StateComponents.AutomationCatalog, - channel: string, + resource: URI, owner: string, ): IReference>; }; @@ -98,9 +98,9 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro this._register(this._storageService.onDidChangeValue(StorageScope.APPLICATION, this._archiveKey, this._store)(() => { this._archivedRuns.set(this._loadArchivedRuns(), undefined); })); - this._catalogReference = this._register(_connection.getSubscriptionByChannel( + this._catalogReference = this._register(_connection.getSubscription( StateComponents.AutomationCatalog, - AUTOMATION_CATALOG_URI, + URI.parse(AUTOMATION_CATALOG_URI), 'AgentHostAutomationStore', )); this._catalog = this._catalogReference.object; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 7d7fdcb8575fe1..72725956f81a71 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -134,8 +134,8 @@ export function getConfigIcon(property: string, value: unknown | undefined, hasU function formatUncommittedChanges(count: number): string { return count === 1 - ? localize('agentHostSessionConfig.branchItemUncommittedSingular', "1 uncommitted change") - : localize('agentHostSessionConfig.branchItemUncommittedPlural', "{0} uncommitted changes", count); + ? localize('agentHostSessionConfig.branchItemUncommittedSingular', "1 uncommitted file") + : localize('agentHostSessionConfig.branchItemUncommittedPlural', "{0} uncommitted files", count); } function getBranchUncommittedChanges(branchName: string, repositoryBranchName: string | undefined, repositoryUncommittedChanges: number | undefined): number | undefined { @@ -248,8 +248,8 @@ async function confirmAutoApproveLevel(value: string, label: string, dialogServi function triggerAriaLabel(title: string, label: string, isReadOnly: boolean, hasUncommittedChanges: boolean): string { if (hasUncommittedChanges) { return isReadOnly - ? localize('agentHostSessionConfig.triggerAriaReadOnlyUncommitted', "{0}: {1}, Uncommitted Changes, Read-Only", title, label) - : localize('agentHostSessionConfig.triggerAriaUncommitted', "{0}: {1}, Uncommitted Changes", title, label); + ? localize('agentHostSessionConfig.triggerAriaReadOnlyUncommitted', "{0}: {1}, Uncommitted Files, Read-Only", title, label) + : localize('agentHostSessionConfig.triggerAriaUncommitted', "{0}: {1}, Uncommitted Files", title, label); } return isReadOnly @@ -661,8 +661,8 @@ export class AgentHostSessionConfigPicker extends Disposable { const content = !hasUncommittedChanges ? branchName : uncommittedChanges === 1 - ? localize('agentHostSessionConfig.branchHoverUncommittedChangeWithDescription', "{0}, {1} uncommitted change", branchName, uncommittedChanges) - : localize('agentHostSessionConfig.branchHoverUncommittedChangesWithDescription', "{0}, {1} uncommitted changes", branchName, uncommittedChanges); + ? localize('agentHostSessionConfig.branchHoverUncommittedChangeWithDescription', "{0}, {1} uncommitted file", branchName, uncommittedChanges) + : localize('agentHostSessionConfig.branchHoverUncommittedChangesWithDescription', "{0}, {1} uncommitted files", branchName, uncommittedChanges); // This autorun re-runs for any workspace change, not just an // uncommitted-changes flip, so only touch the hover when the text @@ -836,7 +836,7 @@ export class AgentHostSessionConfigPicker extends Disposable { : item.label ?? '', getWidgetAriaLabel: () => localize('agentHostSessionConfig.ariaLabel', "{0} Picker", schema.title), }, - actionItems.length > 10 + items.length > 10 ? { showFilter: true, filterPlaceholder: localize('agentHostSessionConfig.filter', "Filter options..."), minWidth: 255 } : { minWidth: 255 }, ); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts index e3accebddae8d7..420205e978e331 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts @@ -8,8 +8,9 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { AgentMergeAction, AgentMergeConfiguration, AgentMergeSessionOverrides, AgentMergeSettingId, defaultAgentMergeConfiguration, resolveAgentMergeConfiguration } from '../../../../../platform/agentHost/common/agentMerge.js'; +import { Action2, ISubmenuItem, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { AgentMergeConfiguration, AgentMergeMergePullRequest, AgentMergeRepairAction, AgentMergeSessionOverrides, AgentMergeSettingId, agentMergeMergePullRequestValues, AGENT_MERGE_SETTING_TAG, defaultAgentMergeConfiguration, isAgentMergeMergePullRequest, resolveAgentMergeConfiguration } from '../../../../../platform/agentHost/common/agentMerge.js'; +import { AgentHostPullRequestOperationId } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -19,8 +20,11 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js'; import { ANY_AGENT_HOST_PROVIDER_RE, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; -import { SessionIsArchivedContext, SessionProviderIdContext } from '../../../../common/contextkeys.js'; +import { SessionIsArchivedContext, SessionAgentMergeEnabledContext, SessionHasOpenPullRequestContext, SessionPrimaryPullRequestOperationContext, SessionProviderIdContext } from '../../../../common/contextkeys.js'; +import { CHANGES_OPERATIONS_DROPDOWN_PRIMARY_GROUP } from '../../../changes/browser/changesView.js'; +import { Menus } from '../../../../browser/menus.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -32,15 +36,76 @@ const agentMergeCommandPrecondition = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${AgentMergeSettingId.Enabled}`, true), ); -/** Whether Agent Merge is currently enabled on the active session. */ -const AgentMergeSessionEnabledContext = new RawContextKey('sessionAgentMergeEnabled', false, { - type: 'boolean', - description: localize('sessionAgentMergeEnabled', "True when Agent Merge is enabled for the active agent session."), +/** + * Agent Merge only has something to offer once the session's branch has an + * open pull request. + * + * An advertised operation implies an open pull request, but not the reverse: + * a blocked pull request in a repository without auto-merge has no operation + * to offer, and that is precisely when Agent Merge's repair actions matter. + * Both signals are accepted so neither pipeline resolving first can hide the + * entries. + */ +const agentMergeHasPullRequest = ContextKeyExpr.or( + SessionHasOpenPullRequestContext, + ...Object.values(AgentHostPullRequestOperationId).map(id => ContextKeyExpr.equals(SessionPrimaryPullRequestOperationContext.key, id)), +); + +const agentMergeMenuPrecondition = ContextKeyExpr.and(agentMergeCommandPrecondition, agentMergeHasPullRequest); + +/** + * Agent Merge owns the primary button only when neither marking the pull + * request ready nor merging it applies — the states the user is otherwise left + * waiting in, including a blocked pull request that offers no operation at all. + */ +const agentMergeOwnsPrimaryButton = ContextKeyExpr.or( + ContextKeyExpr.equals(SessionPrimaryPullRequestOperationContext.key, AgentHostPullRequestOperationId.EnableAutoMerge), + ContextKeyExpr.equals(SessionPrimaryPullRequestOperationContext.key, AgentHostPullRequestOperationId.DisableAutoMerge), + ContextKeyExpr.and(SessionHasOpenPullRequestContext, ContextKeyExpr.equals(SessionPrimaryPullRequestOperationContext.key, '')), +); + +/** + * The repair actions authorized for the active session, as a context key per + * action so the dropdown entries can render their own checked state + * declaratively. + */ +const AgentMergeSessionActionContexts: Record> = { + addressReviews: new RawContextKey('sessionAgentMergeAddressReviews', false, { type: 'boolean', description: localize('sessionAgentMergeAddressReviews', "True when Agent Merge may address reviews for the active agent session.") }), + fixCI: new RawContextKey('sessionAgentMergeFixCI', false, { type: 'boolean', description: localize('sessionAgentMergeFixCI', "True when Agent Merge may fix CI failures for the active agent session.") }), + resolveConflicts: new RawContextKey('sessionAgentMergeResolveConflicts', false, { type: 'boolean', description: localize('sessionAgentMergeResolveConflicts', "True when Agent Merge may resolve conflicts for the active agent session.") }), +}; + +/** When Agent Merge may merge the pull request for the active session. */ +const AgentMergeSessionMergePullRequestContext = new RawContextKey('sessionAgentMergeMergePullRequest', 'never', { + type: 'string', + description: localize('sessionAgentMergeMergePullRequest', "When Agent Merge may merge the pull request for the active agent session ('always', 'ifUnchanged' or 'never')."), }); +const agentMergeActionLabels: Record = { + addressReviews: localize('agentMerge.action.addressReviews', "Address Reviews"), + fixCI: localize('agentMerge.action.fixCI', "Fix CI Failures"), + resolveConflicts: localize('agentMerge.action.resolveConflicts', "Resolve Conflicts and Behind Branches"), +}; + +const agentMergeRepairActions = Object.keys(agentMergeActionLabels) as readonly AgentMergeRepairAction[]; + +/** Labels for the merge choice, short enough to read inside the submenu title. */ +const agentMergeMergePullRequestLabels: Record = { + always: localize('agentMerge.merge.always', "Always"), + ifUnchanged: localize('agentMerge.merge.ifUnchanged', "Only if Agent Merge Made No Changes"), + never: localize('agentMerge.merge.never', "Never"), +}; + +const agentMergeMergePullRequestDescriptions: Record = { + always: localize('agentMerge.merge.always.description', "Merge the pull request whenever it is ready."), + ifUnchanged: localize('agentMerge.merge.ifUnchanged.description', "Merge the pull request only while Agent Merge has not changed it. Once a repair turn lands a commit this switches itself to Never."), + never: localize('agentMerge.merge.never.description', "Never merge the pull request automatically."), +}; + /** - * Mirrors the active session's Agent Merge enablement into a context key so the - * command palette only offers the action that actually applies. + * Mirrors the active session's Agent Merge enablement and authorized actions + * into context keys so the command palette only offers the action that + * actually applies, and the button bar dropdown can render check marks. */ class AgentMergeContextContribution extends Disposable implements IWorkbenchContribution { @@ -48,24 +113,49 @@ class AgentMergeContextContribution extends Disposable implements IWorkbenchCont constructor( @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService configurationService: IConfigurationService, @ISessionsService sessionsService: ISessionsService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ILogService logService: ILogService, ) { super(); - const enabledKey = AgentMergeSessionEnabledContext.bindTo(contextKeyService); + const enabledKey = SessionAgentMergeEnabledContext.bindTo(contextKeyService); + const actionKeys = new Map(agentMergeRepairActions.map(action => [action, AgentMergeSessionActionContexts[action].bindTo(contextKeyService)])); + const mergePullRequestKey = AgentMergeSessionMergePullRequestContext.bindTo(contextKeyService); const providerListener = this._register(new MutableDisposable()); + const configurationListener = this._register(new MutableDisposable()); + let lastLogged: string | undefined; this._register(autorun(reader => { const session = sessionsService.activeSession.read(reader); const provider = session && sessionsProvidersService.getProvider(session.providerId); const agentHostProvider = provider && isAgentHostProvider(provider) ? provider : undefined; - const update = () => enabledKey.set( - !!session && !!agentHostProvider && agentHostProvider.getAgentMergeSessionState(session.sessionId)?.enabled === true, - ); + const update = () => { + const state = session && agentHostProvider ? agentHostProvider.getAgentMergeSessionState(session.sessionId) : undefined; + enabledKey.set(state?.enabled === true); + const effective = resolveAgentMergeConfiguration(getGlobalConfiguration(configurationService), state?.overrides); + for (const [action, key] of actionKeys) { + key.set(effective[action]); + } + mergePullRequestKey.set(effective.mergePullRequest); + // Explains both the toggle state of the dropdown entries and + // whether Agent Merge can claim the primary button at all. + const authorized = agentMergeRepairActions.filter(action => effective[action]); + const signature = `${session?.sessionId ?? 'none'}|${state?.enabled === true}|${authorized.join(',')}|${effective.mergePullRequest}`; + if (lastLogged !== signature) { + lastLogged = signature; + logService.info(`[AgentMergeActions] Session state: session=${session?.sessionId ?? 'none'}, enabled=${state?.enabled === true}, authorizedActions=[${authorized.join(', ') || 'none'}], mergePullRequest=${effective.mergePullRequest}`); + } + }; providerListener.value = agentHostProvider?.onDidChangeSessionConfig(changed => { if (changed === session?.sessionId) { update(); } }); + configurationListener.value = configurationService.onDidChangeConfiguration(event => { + if (Object.values(AgentMergeSettingId).some(settingId => event.affectsConfiguration(settingId))) { + update(); + } + }); update(); })); } @@ -74,7 +164,7 @@ class AgentMergeContextContribution extends Disposable implements IWorkbenchCont registerWorkbenchContribution2(AgentMergeContextContribution.ID, AgentMergeContextContribution, WorkbenchPhase.AfterRestored); interface IAgentMergeActionPick extends IQuickPickItem { - readonly action: AgentMergeAction; + readonly action: AgentMergeRepairAction; } abstract class AgentMergeActionBase extends Action2 { @@ -84,15 +174,201 @@ abstract class AgentMergeActionBase extends Action2 { const provider = session && accessor.get(ISessionsProvidersService).getProvider(session.providerId); return session && provider && isAgentHostProvider(provider) ? { session, provider } : undefined; } + + /** + * Writes the session's overrides with every value the user could see, + * not just the changed one, so the session keeps what was on screen rather + * than silently picking up a later change to the global defaults. + */ + protected async updateOverrides(accessor: ServicesAccessor, patch: Partial): Promise { + const active = this.getActiveSession(accessor); + if (!active) { + return; + } + const configurationService = accessor.get(IConfigurationService); + const logService = accessor.get(ILogService); + const state = active.provider.getAgentMergeSessionState(active.session.sessionId); + const effective = resolveAgentMergeConfiguration(getGlobalConfiguration(configurationService), state?.overrides); + const overrides: AgentMergeSessionOverrides = { + addressReviews: effective.addressReviews, + fixCI: effective.fixCI, + resolveConflicts: effective.resolveConflicts, + mergePullRequest: effective.mergePullRequest, + ...patch, + }; + await active.provider.setAgentMergeOverrides(active.session.sessionId, overrides); + logService.info(`[AgentMergeActions] Overrides updated: session=${active.session.sessionId}, change=${JSON.stringify(patch)}`); + } +} + +// The primary button only names the Agent Merge actions, so it is contributed +// as a submenu: the changes button bar opens exactly these entries as a context +// menu rather than its own dropdown, which also carries pull request operations. +MenuRegistry.appendMenuItem(Menus.ChangesOperationsDropdown, { + submenu: Menus.ChangesAgentMerge, + title: localize2('agentMerge.primary', "Agent Merge"), + group: CHANGES_OPERATIONS_DROPDOWN_PRIMARY_GROUP, + order: 1, + when: ContextKeyExpr.and(agentMergeMenuPrecondition, agentMergeOwnsPrimaryButton, SessionAgentMergeEnabledContext), +}); + +/** Menus the Agent Merge entries appear on: the operations dropdown, and their own context menu. */ +const agentMergeMenus = [Menus.ChangesOperationsDropdown, Menus.ChangesAgentMerge]; + +registerAction2(class EnableAgentMergeInSessionAction extends AgentMergeActionBase { + constructor() { + super({ + id: 'sessions.agentHost.agentMerge.enableInSession', + // Deliberately not a toggle: the label already says which way it + // goes, so a check mark next to "Enable" would only be ambiguous. + title: localize2('agentMerge.enableInSession', "Enable Agent Merge"), + f1: false, + menu: agentMergeMenus.map(id => ({ + id, + group: '1_agentMerge', + order: 1, + when: ContextKeyExpr.and(agentMergeMenuPrecondition, SessionAgentMergeEnabledContext.negate()), + })), + }); + } + + async run(accessor: ServicesAccessor): Promise { + const active = this.getActiveSession(accessor); + const logService = accessor.get(ILogService); + if (!active) { + return; + } + await active.provider.setAgentMergeEnabled(active.session.sessionId, true); + logService.info(`[AgentMergeActions] Enabled from the title bar: session=${active.session.sessionId}`); + } +}); + +registerAction2(class DisableAgentMergeInSessionAction extends AgentMergeActionBase { + constructor() { + super({ + id: 'sessions.agentHost.agentMerge.disableInSession', + title: localize2('agentMerge.disableInSession', "Disable Agent Merge"), + f1: false, + menu: agentMergeMenus.map(id => ({ + id, + group: '1_agentMerge', + order: 1, + when: ContextKeyExpr.and(agentMergeMenuPrecondition, SessionAgentMergeEnabledContext), + })), + }); + } + + async run(accessor: ServicesAccessor): Promise { + const active = this.getActiveSession(accessor); + const logService = accessor.get(ILogService); + if (!active) { + return; + } + await active.provider.setAgentMergeEnabled(active.session.sessionId, false); + logService.info(`[AgentMergeActions] Disabled from the title bar: session=${active.session.sessionId}`); + } +}); + +for (const [index, action] of agentMergeRepairActions.entries()) { + registerAction2(class ToggleAgentMergeRepairAction extends AgentMergeActionBase { + constructor() { + super({ + id: `sessions.agentHost.agentMerge.toggle.${action}`, + title: { value: agentMergeActionLabels[action], original: agentMergeActionLabels[action] }, + toggled: AgentMergeSessionActionContexts[action], + f1: false, + menu: agentMergeMenus.map(id => ({ + id, + group: '2_agentMergeActions', + order: index, + when: agentMergeMenuPrecondition, + })), + }); + } + + async run(accessor: ServicesAccessor): Promise { + const active = this.getActiveSession(accessor); + if (!active) { + return; + } + const state = active.provider.getAgentMergeSessionState(active.session.sessionId); + const effective = resolveAgentMergeConfiguration(getGlobalConfiguration(accessor.get(IConfigurationService)), state?.overrides); + await this.updateOverrides(accessor, { [action]: !effective[action] }); + } + }); } +// One submenu entry per value, so the title can name the current choice without +// the user having to open it. Exactly one is ever visible. +for (const value of agentMergeMergePullRequestValues) { + MenuRegistry.appendMenuItem(Menus.ChangesOperationsDropdown, mergePullRequestSubmenuItem(value)); + MenuRegistry.appendMenuItem(Menus.ChangesAgentMerge, mergePullRequestSubmenuItem(value)); +} + +function mergePullRequestSubmenuItem(value: AgentMergeMergePullRequest): ISubmenuItem { + const title = localize('agentMerge.merge.submenu', "Merge Pull Request ({0})", agentMergeMergePullRequestLabels[value]); + return { + submenu: Menus.ChangesAgentMergeMergePullRequest, + title: { value: title, original: title }, + group: '2_agentMergeActions', + order: agentMergeRepairActions.length, + when: ContextKeyExpr.and(agentMergeMenuPrecondition, AgentMergeSessionMergePullRequestContext.isEqualTo(value)), + }; +} + +for (const [index, value] of agentMergeMergePullRequestValues.entries()) { + registerAction2(class SetAgentMergeMergePullRequestAction extends AgentMergeActionBase { + constructor() { + super({ + id: `sessions.agentHost.agentMerge.mergePullRequest.${value}`, + title: { value: agentMergeMergePullRequestLabels[value], original: agentMergeMergePullRequestLabels[value] }, + tooltip: agentMergeMergePullRequestDescriptions[value], + toggled: AgentMergeSessionMergePullRequestContext.isEqualTo(value), + f1: false, + menu: [{ + id: Menus.ChangesAgentMergeMergePullRequest, + group: 'navigation', + order: index, + }], + }); + } + + async run(accessor: ServicesAccessor): Promise { + await this.updateOverrides(accessor, { mergePullRequest: value }); + } + }); +} + +registerAction2(class OpenAgentMergeDefaultsAction extends Action2 { + constructor() { + super({ + id: 'sessions.agentHost.agentMerge.openDefaults', + title: localize2('agentMerge.openDefaults', "Agent Merge Defaults"), + f1: false, + menu: agentMergeMenus.map(id => ({ + id, + group: '3_agentMergeDefaults', + order: 1, + when: agentMergeMenuPrecondition, + })), + }); + } + + async run(accessor: ServicesAccessor): Promise { + const preferencesService = accessor.get(IPreferencesService); + const logService = accessor.get(ILogService); + await preferencesService.openSettings({ jsonEditor: false, query: `@tag:${AGENT_MERGE_SETTING_TAG}` }); + logService.trace(`[AgentMergeActions] Opened Agent Merge defaults in the settings editor`); + } +}); + registerAction2(class EnableAgentMergeAction extends AgentMergeActionBase { constructor() { super({ id: 'sessions.agentHost.agentMerge.enable', title: localize2('agentMerge.enable', "Enable Agent Merge for Active Session"), f1: true, - precondition: ContextKeyExpr.and(agentMergeCommandPrecondition, AgentMergeSessionEnabledContext.negate()), + precondition: ContextKeyExpr.and(agentMergeCommandPrecondition, SessionAgentMergeEnabledContext.negate()), }); } @@ -115,7 +391,7 @@ registerAction2(class DisableAgentMergeAction extends AgentMergeActionBase { id: 'sessions.agentHost.agentMerge.disable', title: localize2('agentMerge.disable', "Disable Agent Merge for Active Session"), f1: true, - precondition: ContextKeyExpr.and(agentMergeCommandPrecondition, AgentMergeSessionEnabledContext), + precondition: ContextKeyExpr.and(agentMergeCommandPrecondition, SessionAgentMergeEnabledContext), }); } @@ -154,17 +430,18 @@ registerAction2(class ConfigureAgentMergeAction extends AgentMergeActionBase { const defaults = getGlobalConfiguration(configurationService); const current = active.provider.getAgentMergeSessionState(active.session.sessionId); const effective = resolveAgentMergeConfiguration(defaults, current?.overrides); - const picks: IAgentMergeActionPick[] = [ - { action: 'addressReviews', label: localize('agentMerge.action.addressReviews', "Address Reviews"), picked: effective.addressReviews }, - { action: 'fixCI', label: localize('agentMerge.action.fixCI', "Fix CI Failures"), picked: effective.fixCI }, - { action: 'resolveConflicts', label: localize('agentMerge.action.resolveConflicts', "Resolve Conflicts and Behind Branches"), picked: effective.resolveConflicts }, - { action: 'mergePullRequest', label: localize('agentMerge.action.mergePullRequest', "Automatically Merge When Ready"), picked: effective.mergePullRequest }, - ]; + const picks: IAgentMergeActionPick[] = agentMergeRepairActions.map(action => ({ + action, + label: agentMergeActionLabels[action], + picked: effective[action], + })); const result = await pickAgentMergeActions(quickInputService, picks); if (!result) { return; } - const overrides = result.reset ? undefined : toOverrides(result.actions); + // The merge choice is an enum rather than a checkbox, so it is carried + // through untouched instead of being reset by this multi-select. + const overrides = result.reset ? undefined : toOverrides(result.actions, effective.mergePullRequest); await active.provider.setAgentMergeOverrides(active.session.sessionId, overrides); logService.info(`[AgentMergeActions] Action overrides updated: session=${active.session.sessionId}, provider=${active.session.providerId}, reset=${result.reset}, enabledActions=${[...result.actions].sort().join(',') || 'none'}`); notificationService.info(result.reset @@ -180,7 +457,7 @@ registerAction2(class ConfigureAgentMergeAction extends AgentMergeActionBase { function pickAgentMergeActions( quickInputService: IQuickInputService, picks: readonly IAgentMergeActionPick[], -): Promise<{ readonly reset: boolean; readonly actions: ReadonlySet } | undefined> { +): Promise<{ readonly reset: boolean; readonly actions: ReadonlySet } | undefined> { const store = new DisposableStore(); return new Promise(resolve => { const quickPick = store.add(quickInputService.createQuickPick()); @@ -210,20 +487,27 @@ function pickAgentMergeActions( } function getGlobalConfiguration(configurationService: IConfigurationService): AgentMergeConfiguration { + const mergePullRequest = configurationService.getValue(AgentMergeSettingId.MergePullRequest); return { addressReviews: configurationService.getValue(AgentMergeSettingId.AddressReviews) ?? defaultAgentMergeConfiguration.addressReviews, fixCI: configurationService.getValue(AgentMergeSettingId.FixCI) ?? defaultAgentMergeConfiguration.fixCI, resolveConflicts: configurationService.getValue(AgentMergeSettingId.ResolveConflicts) ?? defaultAgentMergeConfiguration.resolveConflicts, - mergePullRequest: configurationService.getValue(AgentMergeSettingId.MergePullRequest) ?? defaultAgentMergeConfiguration.mergePullRequest, + // Tolerates the retired boolean form so a profile that has not run the + // settings migration yet still shows the right entry as selected. + mergePullRequest: isAgentMergeMergePullRequest(mergePullRequest) + ? mergePullRequest + : typeof mergePullRequest === 'boolean' + ? (mergePullRequest ? 'always' : 'never') + : defaultAgentMergeConfiguration.mergePullRequest, mergeMethod: configurationService.getValue(AgentMergeSettingId.MergeMethod) ?? defaultAgentMergeConfiguration.mergeMethod, replyAttribution: configurationService.getValue(AgentMergeSettingId.ReplyAttribution) ?? defaultAgentMergeConfiguration.replyAttribution, }; } -function toOverrides(selected: ReadonlySet): AgentMergeSessionOverrides { - const overrides: Record = {}; - for (const action of ['addressReviews', 'fixCI', 'resolveConflicts', 'mergePullRequest'] as const) { +function toOverrides(selected: ReadonlySet, mergePullRequest: AgentMergeMergePullRequest): AgentMergeSessionOverrides { + const overrides: Record = { mergePullRequest }; + for (const action of agentMergeRepairActions) { overrides[action] = selected.has(action); } - return overrides; + return overrides as AgentMergeSessionOverrides; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index ce5ee0627af2e1..bfaa520cf7beec 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -35,8 +35,7 @@ import { WorkspaceNotTrustedError } from '../../../../services/sessions/common/s import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { getCopilotCliSessionRawId, migratedCopilotCliResource } from '../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; -import { adoptLegacyCopilotCliResource, LEGACY_MIGRATION_RESTORE_TIMEOUT_MS, LEGACY_MIGRATION_TIMEOUT_MS } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.js'; -import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { adoptLegacyCopilotCliResource, isLegacyMigrationEnabledAtStartup, LEGACY_MIGRATION_RESTORE_TIMEOUT_MS, LEGACY_MIGRATION_TIMEOUT_MS } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../workbench/contrib/chat/common/languageModels.js'; @@ -137,7 +136,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide * sessions on this machine, so a remote host must never claim or probe them. */ async resolveSessionResource(resource: URI, reason?: SessionResourceResolveReason): Promise { - if (this._configurationService.getValue(ChatConfiguration.MigrateLegacyCopilotCliSessions) !== true) { + // Frozen at startup: enabling the setting only takes effect after a restart, + // so a live toggle never probes a host whose gate is still off. + if (!isLegacyMigrationEnabledAtStartup(this._configurationService)) { return undefined; } const rawId = getCopilotCliSessionRawId(migratedCopilotCliResource(resource)); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 48b76a3fb69ba6..f99dfccf4d80ae 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -362,7 +362,7 @@ suite('Agent Host Session Config Picker', () => { assert.deepStrictEqual({ initiallyDirty, clean, dirtyAfterUpdate, differentBranch }, { initiallyDirty: { icon: 'codicon-git-branch-changes', - ariaLabel: 'Base Branch: main, Uncommitted Changes', + ariaLabel: 'Base Branch: main, Uncommitted Files', }, clean: { icon: 'codicon-git-branch', @@ -370,7 +370,7 @@ suite('Agent Host Session Config Picker', () => { }, dirtyAfterUpdate: { icon: 'codicon-git-branch-changes', - ariaLabel: 'Base Branch: main, Uncommitted Changes', + ariaLabel: 'Base Branch: main, Uncommitted Files', }, differentBranch: { icon: 'codicon-git-branch', @@ -379,7 +379,7 @@ suite('Agent Host Session Config Picker', () => { }); }); - test('expanded branch picker marks only the checked-out branch as having uncommitted changes', async () => { + test('expanded branch picker marks only the checked-out branch as having uncommitted files', async () => { const services = setupServices(store); services.provider.config = makeDynamicBranchConfig('main'); services.provider.completions = [ @@ -441,13 +441,13 @@ suite('Agent Host Session Config Picker', () => { label: 'dev', icon: Codicon.gitBranchChanges.id, checked: false, - detail: '2 uncommitted changes', - ariaDescription: '2 uncommitted changes', + detail: '2 uncommitted files', + ariaDescription: '2 uncommitted files', }, ], singular: { - detail: '1 uncommitted change', - ariaDescription: '1 uncommitted change', + detail: '1 uncommitted file', + ariaDescription: '1 uncommitted file', }, singleResultKinds: [ActionListItemKind.Action], }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index 38dfdb81565d72..247d4d7cc39ed1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -82,13 +82,13 @@ class TestAutomationConnection { }); } - getSubscriptionByChannel( + getSubscription( kind: StateComponents.AutomationCatalog, - channel: string, + resource: URI, _owner: string, ): IReference> { assert.strictEqual(kind, StateComponents.AutomationCatalog); - this.subscribedChannel = channel; + this.subscribedChannel = resource.toString(); const connection = this; return { object: { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 0b1ea60840c191..a49074ff94e3cd 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -256,14 +256,10 @@ class MockAgentHostService extends mock() { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const key = resource.toString(); - return this._getSubscription(key); - } - - override getSubscriptionByChannel(_kind: StateComponents, channel: string): IReference> { - if (channel === AUTOMATION_CATALOG_URI && !this._sessionStateValues.has(channel)) { - this._sessionStateValues.set(channel, { automations: [] }); + if (key === AUTOMATION_CATALOG_URI && !this._sessionStateValues.has(key)) { + this._sessionStateValues.set(key, { automations: [] }); } - return this._getSubscription(channel); + return this._getSubscription(key); } private _getSubscription(key: string): IReference> { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts index 3e0ad8691dd923..513bbb1323075c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts @@ -41,6 +41,29 @@ suite('OpenSubagentChatActionViewItem', () => { ]); }); + test('shows a concrete model under an Auto parent but never shows Auto itself', () => { + assert.deepStrictEqual([ + // Auto names no real model, so it is never worth showing. + shouldShowSubagentModel('Auto', 'agent-host-copilotcli:auto', 'Auto', 'auto'), + shouldShowSubagentModel('Auto', 'agent-host-copilotcli:gpt-5.6-sol', 'GPT-5.6 Sol', 'gpt-5.6-sol'), + // Under Auto the routed model is new information, even when it is what Auto picked. + shouldShowSubagentModel('GPT-5.6 Sol', 'agent-host-copilotcli:auto', 'Auto', 'auto'), + shouldShowSubagentModel('Claude Opus 4.8', 'agent-host-copilotcli:auto', 'Auto', 'auto'), + // The parent resolved to this very model, yet its chip still only says "Auto". + shouldShowSubagentModel('gpt-5.6-sol', 'agent-host-copilotcli:auto', 'Auto', 'gpt-5.6-sol'), + // The picker moved to Auto after the request started, but the request itself + // ran on a concrete model, so a matching subagent model is still redundant. + shouldShowSubagentModel('gpt-5.6-sol', 'agent-host-copilotcli:gpt-5.6-sol', 'Auto', 'auto'), + ], [ + false, + false, + true, + true, + true, + false, + ]); + }); + test('disables and hides the action until its peer chat resolves', () => { const instantiationService = workbenchInstantiationService(undefined, store); instantiationService.stub(ISessionsService, { @@ -211,4 +234,64 @@ suite('OpenSubagentChatActionViewItem', () => { ariaLabel: 'Open Subagent. Model GPT-5.6 Sol', }); }); + + test('renders the credit cost alongside the model', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(ISessionsService, { + activeSession: observableValue('activeSession', undefined), + visibleSessions: observableValue('visibleSessions', []), + }); + instantiationService.stub(ILanguageModelsService, { + onDidChangeLanguageModels: Event.None, + lookupLanguageModel: () => undefined, + }); + const action = store.add(new Action('openSubagent', 'Open Subagent')); + const viewItem = store.add(instantiationService.createInstance( + TestOpenSubagentChatActionViewItem, + { + chatResource: 'ahp-chat://subagent/session/tool-call', + modelName: 'Claude Opus 4.8', + parentModelName: 'GPT-5.6 Sol', + credits: 2.5, + }, + action, + {}, + false, + )); + const container = document.createElement('div'); + + viewItem.render(container); + + const creditsElement = container.querySelector('.chat-subagent-pill-credits'); + const withCredits = { + text: creditsElement?.textContent, + hidden: creditsElement?.classList.contains('hidden'), + tooltip: viewItem.tooltip, + ariaLabel: container.getAttribute('aria-label'), + }; + + // A subagent that bills nothing should not carry an empty cost readout. + viewItem.setActionContext({ chatResource: 'ahp-chat://subagent/session/tool-call', credits: 0 }); + + assert.deepStrictEqual({ + withCredits, + withoutCredits: { + text: creditsElement?.textContent, + hidden: creditsElement?.classList.contains('hidden'), + tooltip: viewItem.tooltip, + }, + }, { + withCredits: { + text: '2.5 credits', + hidden: false, + tooltip: 'Open Subagent\nModel: Claude Opus 4.8\n2.5 credits', + ariaLabel: 'Open Subagent. Model Claude Opus 4.8. 2.5 credits', + }, + withoutCredits: { + text: '', + hidden: true, + tooltip: 'Open Subagent', + }, + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 4fda084efc622a..7bff9ee7361fad 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -143,14 +143,10 @@ class MockAgentConnection extends mock() { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const key = resource.toString(); - return this._getSubscription(key); - } - - override getSubscriptionByChannel(_kind: StateComponents, channel: string): IReference> { - if (channel === AUTOMATION_CATALOG_URI && !this._sessionStateValues.has(channel)) { - this._sessionStateValues.set(channel, { automations: [] }); + if (key === AUTOMATION_CATALOG_URI && !this._sessionStateValues.has(key)) { + this._sessionStateValues.set(key, { automations: [] }); } - return this._getSubscription(channel); + return this._getSubscription(key); } private _getSubscription(key: string): IReference> { diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 58943dd530738f..2296a4103e639a 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -14,6 +14,47 @@ width: calc(100% - 20px); } + &.session-list-row-spacing .monaco-list-row.session-list-inset-row { + padding-bottom: var(--vscode-spacing-size20); + background-clip: content-box; + outline: 0 !important; + + > .monaco-tl-row { + border-radius: inherit; + } + } + + &.session-list-row-spacing .monaco-list .monaco-list-row.session-list-inset-row { + &.selected > .monaco-tl-row { + outline: var(--vscode-strokeThickness) dotted var(--vscode-contrastActiveBorder, transparent); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + + &.focused > .monaco-tl-row { + outline: var(--vscode-strokeThickness) dotted var(--vscode-list-inactiveFocusOutline, transparent); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + + &.focused.selected > .monaco-tl-row { + outline-color: var(--vscode-contrastActiveBorder, var(--vscode-list-inactiveFocusOutline, transparent)); + } + + &:hover > .monaco-tl-row { + outline: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder, transparent); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + } + + &.session-list-row-spacing .monaco-list:focus .monaco-list-row.session-list-inset-row.focused > .monaco-tl-row, + .context-menu-visible &.session-list-row-spacing .monaco-list.last-focused .monaco-list-row.session-list-inset-row.focused > .monaco-tl-row { + outline: var(--vscode-strokeThickness) solid var(--vscode-list-focusOutline); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + + &.session-list-row-spacing .monaco-list:focus .monaco-list-row.session-list-inset-row.focused.selected > .monaco-tl-row { + outline-color: var(--vscode-list-focusAndSelectionOutline, var(--vscode-contrastActiveBorder, var(--vscode-list-focusOutline))); + } + /* Suppressed while selected so the selection highlight is not overpainted. */ .monaco-list-row:not(.selected):not(.focused):not(:hover):has(.session-item.needs-input), .monaco-list-row:not(.selected):not(.focused):not(:hover) .session-chat-item.needs-input { @@ -46,14 +87,13 @@ z-index: 1; } - .monaco-list-row:hover .session-chat-twistie.collapsible, - .monaco-list-row.focused .session-chat-twistie.collapsible { + /* Replace the status icon with the twistie only while hovering the session row. */ + .monaco-list-row:hover .session-chat-twistie.collapsible { opacity: 1; pointer-events: auto; } - .monaco-list-row:hover[aria-expanded] .session-item .session-icon, - .monaco-list-row.focused[aria-expanded] .session-item .session-icon { + .monaco-list-row:hover[aria-expanded] .session-item .session-icon { visibility: hidden; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 589a1ffd73c82e..ad7253ab392937 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -593,16 +593,10 @@ registerAction2(class OpenSessionListChatToSideAction extends Action2 { return; } const sessionsService = accessor.get(ISessionsService); - const sessionsPartService = accessor.get(ISessionsPartService); if (!await sessionsService.canOpenSession(context.session)) { return; } - sessionsService.showSession(context.session.resource); - const sessionView = sessionsPartService.getSessionView(context.session.sessionId); - if (!sessionView) { - throw new Error(`Unable to open chat to the side because session view '${context.session.sessionId}' is not mounted`); - } - await sessionView.openChatToSide(context.chat.resource); + await sessionsService.openChatToSide(context.session, context.chat.resource); } }); diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts index 4d4f2c4622b324..82256a6bc5e185 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts @@ -29,7 +29,7 @@ class AutomationsCustomViewAccessibilityHelp implements IAccessibleViewImplement const content = [ localize('automationsCustomView.help.overview', "You are in the Automations view. It contains automation cards followed by run history."), localize('automationsCustomView.help.cards', "Tab to a card's Edit control and action buttons. Use Left Arrow and Right Arrow to move between Run now and Delete. Press Enter or Space to activate a control. Edit, or clicking anywhere else on the card, opens the automation dialog. Run now starts a session immediately. Delete asks for confirmation."), - localize('automationsCustomView.help.history', "Run history is grouped by date. While a run is waiting for its session, a lightweight row shows the automation name with a Working... description. Once the session is available, use Up Arrow and Down Arrow to navigate the Sessions list, Enter to open, and Tab to reach Stop or Delete actions when available. Delete permanently deletes the session and removes it from run history after confirmation."), + localize('automationsCustomView.help.history', "Run history is grouped by date. While a run is waiting for its session, a lightweight row shows the automation name with a Working... description. Once the session is available, use Up Arrow and Down Arrow to navigate the Sessions list, Enter to open, and Tab to reach Stop, the configured Archive or Mark as Done action, or Delete when available. Delete permanently deletes the session and removes it from run history after confirmation."), localize('automationsCustomView.help.read', "Completed and failed runs that have not been opened are announced as unread. Use Mark all as read to clear all available unread runs."), localize('automationsCustomView.help.accessibleView', "Use Open Accessible View to read the current automations and run history as text."), ].join('\n'); diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index 5a9ca4eb575626..316fdef0690859 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -48,6 +48,7 @@ import { IActionViewItemService } from '../../../../../platform/actions/browser/ import { BaseActionViewItem, IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../../base/common/actions.js'; import { AutomationsCustomViewFocusContext, AutomationsHasItemsContext, SessionSupportsDeleteContext } from '../../../../common/contextkeys.js'; +import { ARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { SessionsFlatList, SessionItemStatusContext } from './sessionsList.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; @@ -857,6 +858,9 @@ class AutomationHistorySection extends Disposable { action.enabled = false; await this.stopRunSession(session, this.getAutomationName(run), action); return true; + case ARCHIVE_SESSION_COMMAND_ID: + await this.sessionsManagementService.archiveSession(session); + return true; case DELETE_AUTOMATION_RUN_SESSION_COMMAND_ID: await this.confirmDeleteRunSession(run, session, this.getAutomationName(run)); return true; @@ -1190,7 +1194,7 @@ function registerAutomationHistoryItemActions(): IDisposable { icon: Codicon.trash, }, group: 'navigation', - order: 1, + order: 3, when: ContextKeyExpr.and( SessionSupportsDeleteContext, ContextKeyExpr.or( diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 28e04b64534e87..5dbbb4f8ef0150 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -261,6 +261,7 @@ function isSessionItem(item: SessionListItem): item is ISession { const SHOW_MORE_FOLDERS_LABEL = '__more_folders__'; const FOUR_DAYS_MS = 4 * 24 * 60 * 60 * 1000; +const INSET_ROW_GAP = 2; /** * Default number of terminal-command lines shown in a session row's approval @@ -311,8 +312,13 @@ class SessionsTreeDelegate implements IListVirtualDelegate { * flat, chat-less lists (blocked sessions, automations) set it `true`. */ private readonly _aggregateChatApprovals = false, + private readonly _useInsetRowSpacing = false, ) { } + private withInsetRowSpacing(height: number): number { + return height + (this._useInsetRowSpacing ? INSET_ROW_GAP : 0); + } + getHeight(element: SessionListItem): number { if (isSessionChatItem(element)) { let chatHeight = this._isPhone() ? SessionsTreeDelegate.CHAT_ITEM_HEIGHT_PHONE : SessionsTreeDelegate.CHAT_ITEM_HEIGHT; @@ -326,13 +332,13 @@ class SessionsTreeDelegate implements IListVirtualDelegate { chatHeight += SessionItemRenderer.getApprovalRowHeight(approval.label, this._approvalRowMaxLines) + SessionsTreeDelegate.CHAT_APPROVAL_BOTTOM_SLACK; } } - return chatHeight; + return this.withInsetRowSpacing(chatHeight); } if (isSessionSection(element) || isSessionGroupItem(element)) { return SessionsTreeDelegate.SECTION_HEIGHT; } if (isSessionShowMore(element)) { - return SessionsTreeDelegate.SHOW_MORE_HEIGHT; + return this.withInsetRowSpacing(SessionsTreeDelegate.SHOW_MORE_HEIGHT); } if (isSessionPlaceholder(element)) { return SessionsTreeDelegate.PLACEHOLDER_HEIGHT; @@ -358,7 +364,7 @@ class SessionsTreeDelegate implements IListVirtualDelegate { if (this._ciFixModel && this._ciFixModel.getCIFix(element as ISession).get()) { height += SessionItemRenderer.CI_ROW_HEIGHT; } - return height; + return this.withInsetRowSpacing(height); } hasDynamicHeight(element: SessionListItem): boolean { @@ -673,6 +679,7 @@ interface ISessionItemTemplate { readonly contextKeyService: IContextKeyService; readonly statusContext: IContextKey; readonly isReadContext: IContextKey; + readonly isArchivedContext: IContextKey; readonly supportsDeleteContext: IContextKey; readonly disposables: DisposableStore; readonly elementDisposables: DisposableStore; @@ -837,6 +844,7 @@ class SessionItemRenderer implements ITreeRenderer, _index: number, template: ISessionItemTemplate): void { @@ -920,7 +928,6 @@ class SessionItemRenderer implements ITreeRenderer { this.listContainer.classList.add(SESSION_SECTION_FOCUS_FROM_POINTER_CLASS); })); @@ -2507,7 +2515,15 @@ export class SessionsList extends Disposable implements ISessionsList { // observe the workbench's value rather than shadowing it with a fresh // scoped default of `false`. The reactive height refresh below listens // on the same scoped service for changes. - const delegate = new SessionsTreeDelegate(approvalModel, () => !!IsPhoneLayoutContext.getValue(contextKeyService)); + const delegate = new SessionsTreeDelegate( + approvalModel, + () => !!IsPhoneLayoutContext.getValue(contextKeyService), + DEFAULT_APPROVAL_ROW_MAX_LINES, + undefined, + true /* useCompactQuickChatRows */, + false /* aggregateChatApprovals */, + true /* useInsetRowSpacing */, + ); this._delegate = delegate; this.tree = this._register(instantiationService.createInstance( diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index e82576e01a2ed5..dc530fc8137b81 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -39,7 +39,6 @@ import { IWorkbenchLayoutService, Parts } from '../../../../../workbench/service import { PANEL_SECTION_BORDER } from '../../../../../workbench/common/theme.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; import { Menus } from '../../../../browser/menus.js'; import { MobileSessionFilterChips } from '../../../../browser/parts/mobile/mobileSessionFilterChips.js'; @@ -97,7 +96,6 @@ export class SessionsView extends ViewPane { @IHoverService hoverService: IHoverService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, - @ISessionsPartService private readonly sessionsPartService: ISessionsPartService, @IHostService private readonly hostService: IHostService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IStorageService private readonly storageService: IStorageService, @@ -229,13 +227,7 @@ export class SessionsView extends ViewPane { } }; if (sideBySide) { - this.sessionsService.showSession(session.resource, { preserveFocus }); - const sessionView = this.sessionsPartService.getSessionView(session.sessionId); - if (!sessionView) { - onUnexpectedError(new Error(`Unable to open chat to the side because session view '${session.sessionId}' is not mounted`)); - return; - } - sessionView.openChatToSide(chat.resource).then(onOpened).catch(onUnexpectedError); + this.sessionsService.openChatToSide(session, chat.resource, { preserveFocus }).then(onOpened).catch(onUnexpectedError); return; } this.sessionsService.openChat(session, chat.resource, { preserveFocus }).then(onOpened).catch(onUnexpectedError); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index e2ee99a297e9d7..fd5e2f15cbb3b2 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -908,6 +908,11 @@ abstract class BaseArchiveSessionAction extends Action2 { group: 'navigation', order: 1, when: ContextKeyExpr.equals(SessionIsArchivedContext.key, false), + }, { + id: Menus.AutomationsHistoryItem, + group: 'navigation', + order: 2, + when: ContextKeyExpr.equals(SessionIsArchivedContext.key, false), }, { id: SessionItemContextMenuId, group: '1_edit', diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts index 21e5f6120f271c..dc133c7489099a 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts @@ -48,8 +48,9 @@ import { ISessionsListModelService } from '../../../../services/sessions/browser import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IMenuService, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { MenuService } from '../../../../../platform/actions/common/menuService.js'; +import { ArchiveSessionAction } from '../../browser/views/sessionsViewActions.js'; const AUTOMATION_ID = 'automation-1'; @@ -286,6 +287,7 @@ class FakeSessionsManagementService extends mock() i private firstSessionCataloged = true; readonly isRead = observableValue(this, false); readonly secondIsRead = observableValue(this, false); + readonly isArchived = observableValue(this, false); readonly sessionStatus = observableValue(this, SessionStatus.Completed); readonly capabilities = observableValue(this, { supportsMultipleChats: false, supportsDelete: true }); readonly session = upcastPartial({ @@ -314,7 +316,7 @@ class FakeSessionsManagementService extends mock() i modelId: constObservable(undefined), mode: constObservable(undefined), loading: constObservable(false), - isArchived: constObservable(false), + isArchived: this.isArchived, description: constObservable(undefined), lastTurnEnd: constObservable(undefined), chats: constObservable([]), @@ -356,6 +358,7 @@ class FakeSessionsManagementService extends mock() i markAllReadSessionCount = 0; getSessionCalls = 0; deleteSessionCalls = 0; + readonly archived: ISession[] = []; cancelCurrentRequestCalls = 0; deleteError: Error | undefined; cancelError: Error | undefined; @@ -403,6 +406,13 @@ class FakeSessionsManagementService extends mock() i this.sessionDeletedEmitter.fire(session); } + override async archiveSession(session: ISession): Promise { + this.archived.push(session); + if (session === this.session) { + this.isArchived.set(true, undefined); + } + } + override async cancelCurrentRequest(): Promise { this.cancelCurrentRequestCalls++; if (this.cancelError) { @@ -454,6 +464,10 @@ class FakeSessionsManagementService extends mock() i suite('AutomationsCardsWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + let archiveActionRegistration: IDisposable; + + suiteSetup(() => archiveActionRegistration = registerAction2(ArchiveSessionAction)); + suiteTeardown(() => archiveActionRegistration.dispose()); function getSessionAction(widget: AutomationsCardsWidget, label: string): HTMLElement | undefined { return [...widget.element.querySelectorAll('.automations-run-session-list .action-label')] @@ -1074,6 +1088,30 @@ suite('AutomationsCardsWidget', () => { }); }); + test('archives a run session without opening it and hides the action', async () => { + const { automationService, sessionsManagementService, sessionsService, widget } = setup(); + automationService.setAutomations([automation()]); + automationService.setRuns([run()]); + await waitForSessionActions(); + + const archiveButton = getSessionAction(widget, 'Archive'); + assert.ok(archiveButton); + archiveButton.click(); + await waitForSessionActions(); + + assert.deepStrictEqual({ + archived: sessionsManagementService.archived.map(session => session.sessionId), + openCalls: sessionsService.openCalls, + archiveButtonVisible: !!getSessionAction(widget, 'Archive'), + deleteButtonVisible: !!getSessionAction(widget, 'Delete'), + }, { + archived: ['test/session-1'], + openCalls: 0, + archiveButtonVisible: false, + deleteButtonVisible: true, + }); + }); + test('deleting the focused run moves focus to the next run', async () => { const { automationService, dialogService, widget } = setup(); automationService.setAutomations([automation()]); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index eaf3318799c4e9..b2c6f9d1313e04 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -920,6 +920,53 @@ suite('Sessions - SessionsList', () => { }); }); + suite('session row spacing', () => { + test('reserves spacing only in the main sessions list', () => { + const sessions = [ + createTestSession('First').session, + createTestSession('Second').session, + ]; + + const mainHarness = createListHarness(disposables, sessions); + const mainContainer = mainHarness.createContainer(); + const mainList = mainHarness.store.add(mainHarness.instantiationService.createInstance(SessionsList, mainContainer, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + mainList.layout(300, 400); + + const flatHarness = createListHarness(disposables, sessions); + const flatContainer = flatHarness.createContainer(); + const flatList = flatHarness.store.add(flatHarness.instantiationService.createInstance(SessionsFlatList, flatContainer, { + showSessionHover: false, + onSessionOpen: () => { }, + })); + flatList.setSessions(sessions); + flatList.layout(300, 400); + + const mainRows = [...mainContainer.querySelectorAll('.session-item')] + .map(item => item.closest('.monaco-list-row')!); + const flatRows = [...flatContainer.querySelectorAll('.session-item')] + .map(item => item.closest('.monaco-list-row')!); + assert.deepStrictEqual({ + mainHasSpacingClass: mainContainer.querySelector('.sessions-list-control')?.classList.contains('session-list-row-spacing'), + mainRowHeight: mainRows[0].style.height, + mainRowOffset: parseInt(mainRows[1].style.top) - parseInt(mainRows[0].style.top), + flatHasSpacingClass: flatContainer.querySelector('.sessions-list-control')?.classList.contains('session-list-row-spacing'), + flatRowHeight: flatRows[0].style.height, + flatRowOffset: parseInt(flatRows[1].style.top) - parseInt(flatRows[0].style.top), + }, { + mainHasSpacingClass: true, + mainRowHeight: '56px', + mainRowOffset: 56, + flatHasSpacingClass: false, + flatRowHeight: '54px', + flatRowOffset: 54, + }); + }); + }); + suite('session chat rows', () => { function createChat(title: string, origin?: ChatOriginKind, interactivity = ChatInteractivity.Full, status = SessionStatus.Completed): IChat { @@ -1218,8 +1265,8 @@ suite('Sessions - SessionsList', () => { assert.ok(phoneChatRow); assert.deepStrictEqual({ desktopHeight, phoneHeight: phoneChatRow.style.height }, { - desktopHeight: '28px', - phoneHeight: '44px', + desktopHeight: '30px', + phoneHeight: '46px', }); }); @@ -1467,7 +1514,7 @@ suite('Sessions - SessionsList', () => { const rows = Object.fromEntries([...container.querySelectorAll('.session-item')].map(item => { const row = item.closest('.monaco-list-row'); const twistie = row?.querySelector('.monaco-tl-twistie'); - row?.classList.add('focused'); + const icon = item.querySelector('.session-icon'); const twistieStyle = twistie?.classList.contains('session-chat-twistie') ? mainWindow.getComputedStyle(twistie) : undefined; @@ -1482,6 +1529,7 @@ suite('Sessions - SessionsList', () => { opacity: twistieStyle?.opacity, paddingLeft: twistie?.style.paddingLeft, pointerEvents: twistieStyle?.pointerEvents, + iconVisibility: icon ? mainWindow.getComputedStyle(icon).visibility : undefined, }]; })); @@ -1494,9 +1542,10 @@ suite('Sessions - SessionsList', () => { isCollapsible: true, isCollapsed: false, fontSize: '16px', - opacity: '1', + opacity: '0', paddingLeft: '0px', - pointerEvents: 'auto', + pointerEvents: 'none', + iconVisibility: 'visible', }, 'Single-chat session': { expanded: null, @@ -1509,6 +1558,7 @@ suite('Sessions - SessionsList', () => { opacity: undefined, paddingLeft: '0px', pointerEvents: undefined, + iconVisibility: 'visible', }, }); @@ -1534,6 +1584,52 @@ suite('Sessions - SessionsList', () => { }); }); + test('reveals the twistie only on real pointer hover, never on keyboard focus or selection alone', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const base = createTestSession('Multi-chat session').session; + const session: ISession = { + ...base, + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const harness = createListHarness(disposables, [session]); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(300, 400); + + const item = container.querySelector('.session-item'); + assert.ok(item); + const row = item.closest('.monaco-list-row'); + assert.ok(row); + const twistie = row.querySelector('.monaco-tl-twistie'); + assert.ok(twistie); + const icon = item.querySelector('.session-icon'); + assert.ok(icon); + + const snapshot = () => ({ + opacity: mainWindow.getComputedStyle(twistie).opacity, + pointerEvents: mainWindow.getComputedStyle(twistie).pointerEvents, + iconVisibility: mainWindow.getComputedStyle(icon).visibility, + }); + + row.classList.add('focused'); + assert.deepStrictEqual(snapshot(), { opacity: '0', pointerEvents: 'none', iconVisibility: 'visible' }); + row.classList.remove('focused'); + + row.classList.add('selected'); + assert.deepStrictEqual(snapshot(), { opacity: '0', pointerEvents: 'none', iconVisibility: 'visible' }); + + row.classList.add('focused'); + assert.deepStrictEqual(snapshot(), { opacity: '0', pointerEvents: 'none', iconVisibility: 'visible' }); + row.classList.remove('focused', 'selected'); + }); + suite('hierarchy indent/connector guides', () => { function twoSessionContainer(): { readonly container: HTMLElement; readonly session: ISession; readonly other: ISession } { @@ -1942,8 +2038,8 @@ suite('Sessions - SessionsList', () => { const row = targetRow(); assert.ok(row, 'target row should render after growing the viewport'); - // Base chat rows are 28px; a reconciled approval must reserve more. - assert.ok(parseInt(row.style.height) > 28, `expected reconciled height to reserve the approval row, got ${row.style.height}`); + // Base chat rows reserve 30px including spacing; an approval must reserve more. + assert.ok(parseInt(row.style.height) > 30, `expected reconciled height to reserve the approval row, got ${row.style.height}`); assert.ok(row.querySelector('.session-approval-row.visible'), 'approval row should be visible on the re-rendered target'); }); }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts index 6fcbe5ed35d8e5..874793f0643d67 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts @@ -203,6 +203,12 @@ suite('Sessions list context menus', () => { override readonly visibleSessions = constObservable([]); override async canOpenSession(): Promise { return true; } override showSession(): void { } + override async openChatToSide(_session: ISession, chatResource: URI): Promise { + const chat = session.chats.get().find(candidate => candidate.resource.toString() === chatResource.toString()); + if (chat) { + openedToSide.push(chat); + } + } }); instantiationService.stub(ISessionsPartService, new class extends mock() { override getSessionView(): SessionView { diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 26213a04989afc..12a18d43949b6e 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -6,11 +6,13 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, IReader } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; +import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; @@ -105,6 +107,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, @ILogService private readonly _logService: ILogService, @IPathService private readonly _pathService: IPathService, + @IFileService private readonly _fileService: IFileService, @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, ) { super(); @@ -397,6 +400,13 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben try { const generation = this._getTerminalOperationGeneration(session.sessionId); const info = getSessionTerminalInfo(session); + // A legacy session's worktree checkout may not be materialized yet (it is + // recreated lazily on the first send). Launching a local terminal into a + // missing cwd fails with "starting directory does not exist", so defer + // until the directory exists; a later session refresh retries. + if (info?.cwd && !info.agentHostCwd && info.cwd.scheme === Schemas.file && !(await this._fileService.exists(info.cwd))) { + return; + } const targetPath = info?.cwd ?? await this._pathService.userHome(); const targetKey = targetPath.fsPath.toLowerCase(); if (this._activeKey === targetKey && this._activeSessionId === session.sessionId) { diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 96be44c5a80dd5..b2d1cbc17ce85a 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -25,6 +25,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; @@ -269,6 +270,7 @@ suite('SessionsTerminalContribution', () => { let allSessions: ISession[]; let sessionProviders: Map; let instantiationService: TestInstantiationService; + let cwdExists: (uri: URI) => boolean; setup(() => { createdTerminals = []; @@ -375,6 +377,11 @@ suite('SessionsTerminalContribution', () => { instantiationService.stub(IPathService, new TestPathService(HOME_DIR)); + cwdExists = () => true; + instantiationService.stub(IFileService, new class extends mock() { + override async exists(resource: URI): Promise { return cwdExists(resource); } + }); + instantiationService.stub(IAgentHostTerminalService, new class extends mock() { override readonly profiles = constObservable([]); override getProfileForConnection() { return undefined; } @@ -440,6 +447,49 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); }); + // --- Missing local working directory: defer until it exists --- + + test('does not create a local terminal when the working directory does not exist', async () => { + const worktreeUri = URI.file('/missing-worktree'); + cwdExists = uri => uri.fsPath !== worktreeUri.fsPath; + const session = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Local }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 0); + }); + + test('creates the terminal once the missing working directory appears', async () => { + const worktreeUri = URI.file('/missing-worktree'); + let exists = false; + cwdExists = uri => uri.fsPath !== worktreeUri.fsPath || exists; + const session = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Local }); + activeSessionObs.set(session, undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 0); + + // The worktree is materialized; re-activating the session retries and now + // finds the directory (the early return never cached the active key). + exists = true; + activeSessionObs.set(undefined, undefined); + await tick(); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath); + }); + + test('creates an Agent Host terminal even when the local cwd check would fail', async () => { + const worktreeUri = toAgentHostUri(URI.file('/missing-worktree'), 'my-server'); + cwdExists = () => false; + const session = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Local }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + }); + // --- Workspace-backed sessions: use working directory --- test('uses worktree directory for a cloud agent session when workspace exists', async () => { diff --git a/src/vs/sessions/contrib/chat/browser/chatBackgroundRenderer.ts b/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts similarity index 80% rename from src/vs/sessions/contrib/chat/browser/chatBackgroundRenderer.ts rename to src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts index ef91d321a046b0..33594c23a8470a 100644 --- a/src/vs/sessions/contrib/chat/browser/chatBackgroundRenderer.ts +++ b/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import './media/chatBackground.css'; import { clearNode, DisposableResizeObserver, getWindow } from '../../../../base/browser/dom.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { ISessionsChatBackground } from '../../../services/chatBackground/browser/chatBackgroundService.js'; +import { ISessionsChatBackground } from './chatBackgroundService.js'; const codiconCellSize = 80; const codiconDefaults = { width: 960, height: 800 }; @@ -58,6 +59,7 @@ function hashCodiconCell(row: number, column: number, salt: number): number { export class SessionsChatBackgroundRenderer extends Disposable { + private readonly backgroundLayer: HTMLElement; private readonly codiconLayer: HTMLElement; private background: ISessionsChatBackground | undefined; private codiconGridSize: string | undefined; @@ -65,12 +67,21 @@ export class SessionsChatBackgroundRenderer extends Disposable { constructor(private readonly element: HTMLElement) { super(); + this.backgroundLayer = element.ownerDocument.createElement('div'); + this.backgroundLayer.className = 'sessions-chat-background'; + this.backgroundLayer.ariaHidden = 'true'; + this.backgroundLayer.hidden = true; + this.codiconLayer = element.ownerDocument.createElement('div'); this.codiconLayer.className = 'sessions-chat-codicon-background'; this.codiconLayer.ariaHidden = 'true'; this.codiconLayer.hidden = true; - this.element.prepend(this.codiconLayer); - this._register(toDisposable(() => this.codiconLayer.remove())); + this.backgroundLayer.appendChild(this.codiconLayer); + this.element.prepend(this.backgroundLayer); + this._register(toDisposable(() => { + this.element.classList.remove('has-chat-background', 'has-chat-background-image'); + this.backgroundLayer.remove(); + })); const resizeObserver = this._register(new DisposableResizeObserver( 'SessionsChatBackgroundRenderer', @@ -89,10 +100,11 @@ export class SessionsChatBackgroundRenderer extends Disposable { this.background = background; this.element.classList.toggle('has-chat-background', !!background); this.element.classList.toggle('has-chat-background-image', background?.kind === 'image'); - this.element.style.backgroundImage = background?.kind === 'image' ? background.backgroundImage : ''; - this.element.style.backgroundRepeat = background?.kind === 'image' ? background.backgroundRepeat : ''; - this.element.style.backgroundSize = background?.kind === 'image' ? background.backgroundSize : ''; - this.element.style.backgroundPosition = background?.kind === 'image' ? background.backgroundPosition : ''; + this.backgroundLayer.hidden = !background; + this.backgroundLayer.style.backgroundImage = background?.kind === 'image' ? background.backgroundImage : ''; + this.backgroundLayer.style.backgroundRepeat = background?.kind === 'image' ? background.backgroundRepeat : ''; + this.backgroundLayer.style.backgroundSize = background?.kind === 'image' ? background.backgroundSize : ''; + this.backgroundLayer.style.backgroundPosition = background?.kind === 'image' ? background.backgroundPosition : ''; const showCodicons = background?.kind === 'codicons'; this.codiconLayer.hidden = !showCodicons; diff --git a/src/vs/sessions/services/chatBackground/browser/media/chatBackground.css b/src/vs/sessions/services/chatBackground/browser/media/chatBackground.css new file mode 100644 index 00000000000000..626afd8a143b41 --- /dev/null +++ b/src/vs/sessions/services/chatBackground/browser/media/chatBackground.css @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench .part.sessionspart.has-chat-background .session-view, +.monaco-workbench .part.sessionspart.has-chat-background .session-view .session-view-content, +.monaco-workbench .part.sessionspart.has-chat-background .chat-group-view { + background-color: transparent; +} + +.part.sessionspart.has-chat-background > .sessions-chat-background { + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +.part.sessionspart > .sessions-chat-background[hidden] { + display: none; +} + +.sessions-chat-background > .sessions-chat-codicon-background { + position: absolute; + inset: 0; + color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); +} + +.sessions-chat-background > .sessions-chat-codicon-background[hidden] { + display: none; +} + +.sessions-chat-background > .sessions-chat-codicon-background > .codicon { + position: absolute; +} diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 989276d204b116..9896b563f6a5fb 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -185,6 +185,9 @@ export interface ISessionsService { /** Place a session to the right of the last visible session and activate it. */ openSessionToSide(session: ISession, options?: IOpenSessionOptions & { chatResource?: URI }): Promise; + /** Open a chat to the side of its session view, redirecting a superseded resource first. */ + openChatToSide(session: ISession, chatResource: URI, options?: { preserveFocus?: boolean }): Promise; + /** * Whether the given session may be opened, honoring workspace trust. Prompts * for trust on any untrusted folder the session runs in and resolves to @@ -735,6 +738,15 @@ export class SessionsService extends Disposable implements ISessionsService { const t0 = Date.now(); this._cancelRestore(); const token = this._startOpenSession(); + // Redirect a superseded resource (e.g. a legacy session adopted into another + // provider) before activating, the same way `openSession` does for a URI, so + // opening by object migrates rather than activating the old facade as-is. + const resolved = await this._resolveSessionForOpen(session, chatUri); + if (token.isCancellationRequested) { + return; + } + session = resolved.session; + chatUri = resolved.chatUri ?? chatUri; if (options?.source) { await this.sessionOpenTelemetryService.withOpenRequest(options.source, token, telemetryAttempt => this._openChat(session, chatUri, options.preserveFocus, token, t0, telemetryAttempt)); @@ -842,8 +854,10 @@ export class SessionsService extends Disposable implements ISessionsService { this._cancelRestore(); const token = this._startOpenSession(); await this.sessionOpenTelemetryService.withOpenRequest(options?.source ?? 'unknown', token, async telemetryAttempt => { - // Redirect a superseded resource (legacy Copilot CLI) before lookup, so an - // open by URI migrates rather than reaching the old provider. + // Redirect a superseded resource (legacy session adopted into another + // provider) before lookup, so an open by URI migrates rather than reaching + // the old provider. Providers decline unfamiliar resources and the caller + // keeps the original resource. const resolved = await this.sessionsManagementService.resolveSessionResource(sessionResource, 'open'); if (token.isCancellationRequested) { return; @@ -861,17 +875,10 @@ export class SessionsService extends Disposable implements ISessionsService { }); } - async openSessionToSide(session: ISession, options?: IOpenSessionOptions & { chatResource?: URI }): Promise { - const visible = this.visibleSessions.get(); - const lastVisible = visible[visible.length - 1]; - if (lastVisible && lastVisible.sessionId !== session.sessionId) { - this.insertAt(session, lastVisible.sessionId, 'right'); - } - if (options?.chatResource) { - await this.openChat(session, options.chatResource, { preserveFocus: options.preserveFocus, source: options.source }); - } else { - await this.openSession(session.resource, { preserveFocus: options?.preserveFocus, source: options?.source }); - } + showSession(sessionResource: URI, options?: { preserveFocus?: boolean }): void { + this._cancelRestore(); + this._startOpenSession(); + this._showSession(this._getSession(sessionResource), options); } async canOpenSession(session: ISession): Promise { @@ -917,10 +924,69 @@ export class SessionsService extends Disposable implements ISessionsService { return true; } - showSession(sessionResource: URI, options?: { preserveFocus?: boolean }): void { - this._cancelRestore(); - this._startOpenSession(); - this._showSession(this._getSession(sessionResource), options); + async openSessionToSide(session: ISession, options?: IOpenSessionOptions & { chatResource?: URI }): Promise { + const token = this._startOpenSession(); + // Redirect a superseded resource before inserting a slot, so the side-by-side + // view/terminal never briefly binds to the old facade. + const resolved = await this._resolveSessionForOpen(session, options?.chatResource); + if (token.isCancellationRequested) { + return; + } + session = resolved.session; + if (options?.chatResource && resolved.chatUri) { + options = { ...options, chatResource: resolved.chatUri }; + } + const visible = this.visibleSessions.get(); + const lastVisible = visible[visible.length - 1]; + if (lastVisible && lastVisible.sessionId !== session.sessionId) { + this.insertAt(session, lastVisible.sessionId, 'right'); + } + if (options?.chatResource) { + await this.openChat(session, options.chatResource, { preserveFocus: options.preserveFocus, source: options.source }); + } else { + await this.openSession(session.resource, { preserveFocus: options?.preserveFocus, source: options?.source }); + } + } + + /** + * Opens a chat to the side of its session view, redirecting a superseded + * resource first so a migrating session opens its adopted twin to the side + * rather than the old facade. Provider-neutral: the redirect is the + * `resolveSessionResource` hook, not a scheme check. + */ + async openChatToSide(session: ISession, chatResource: URI, options?: { preserveFocus?: boolean }): Promise { + const token = this._startOpenSession(); + const resolved = await this._resolveSessionForOpen(session, chatResource); + if (token.isCancellationRequested) { + return; + } + session = resolved.session; + chatResource = resolved.chatUri ?? chatResource; + this._showSession(this._getSession(session.resource), options); + const sessionView = this.sessionsPartService.getSessionView(session.sessionId); + if (!sessionView) { + throw new Error(`Unable to open chat to the side because session view '${session.sessionId}' is not mounted`); + } + await sessionView.openChatToSide(chatResource); + } + + /** + * Redirects a superseded session to its authoritative facade before it is + * shown (mirrors `openSession`'s URI resolution). Provider-neutral: it asks + * `resolveSessionResource`, which declines unfamiliar resources, rather than + * inspecting the provider's URI scheme. Returns the redirected session (and + * its main chat) when it changes, otherwise the inputs unchanged. + */ + private async _resolveSessionForOpen(session: ISession, chatUri: URI | undefined): Promise<{ session: ISession; chatUri: URI | undefined }> { + const resolved = await this.sessionsManagementService.resolveSessionResource(session.resource, 'open'); + if (this.uriIdentityService.extUri.isEqual(resolved, session.resource)) { + return { session, chatUri }; + } + const superseding = this.sessionsManagementService.getSession(resolved); + if (!superseding) { + return { session, chatUri }; + } + return { session: superseding, chatUri: chatUri ? superseding.mainChat.get().resource : undefined }; } private _getSession(sessionResource: URI): ISession { diff --git a/src/vs/sessions/test/browser/permissionPickerList.fixture.ts b/src/vs/sessions/test/browser/permissionPickerList.fixture.ts new file mode 100644 index 00000000000000..f5759e41ffe7c1 --- /dev/null +++ b/src/vs/sessions/test/browser/permissionPickerList.fixture.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { renderPermissionPickerList } from '../../../workbench/test/browser/componentFixtures/chat/permissionPickerList.fixture.js'; +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import '../../browser/media/style.css'; + +function render(context: ComponentFixtureContext, checked: boolean, width = 320): void { + context.container.classList.add('agent-sessions-workbench'); + renderPermissionPickerList(context, { showStandaloneSandboxToggle: true, sandboxingEnabled: checked, width }); + + const row = context.container.querySelector('.has-standalone-toggle'); + const toggle = row?.querySelector('.action-list-inline-switch'); + if (!row || !toggle) { + throw new Error('Expected a standalone sandbox toggle row'); + } + toggle.focus(); + + const rowBounds = row.getBoundingClientRect(); + const toggleBounds = toggle.getBoundingClientRect(); + if (toggleBounds.right > rowBounds.right - 6 || toggleBounds.top < rowBounds.top || toggleBounds.bottom > rowBounds.bottom) { + throw new Error(`The sandbox toggle must stay inset within its menu row: row=${JSON.stringify(rowBounds)}, toggle=${JSON.stringify(toggleBounds)}`); + } +} + +export default defineThemedFixtureGroup({ path: 'sessions/permissionPickerList' }, { + SandboxToggleOff: defineComponentFixture({ + render: context => render(context, false), + }), + SandboxToggleOn: defineComponentFixture({ + render: context => render(context, true), + }), + SandboxToggleNarrow: defineComponentFixture({ + render: context => render(context, false, 255), + }), +}); diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index b9aafbb0fc6b96..a12183173a9798 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -51,6 +51,7 @@ suite('Sessions - Workbench', () => { const restoreAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'restoreAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; const loadPartVisibility = Reflect.get(Workbench.prototype, '_loadPartVisibility') as (this: IWorkbenchTestHarness, storageService: { get(): string | undefined; remove(): void }) => { editor?: boolean; auxiliaryBar?: boolean; sidebar?: boolean }; const savePartVisibility = Reflect.get(Workbench.prototype, '_savePartVisibility') as (this: IWorkbenchTestHarness) => void; + const applyPersistedPartVisibility = Reflect.get(Workbench.prototype, '_applyPersistedPartVisibility') as (this: IWorkbenchTestHarness) => void; const revealEditorOnOpen = Reflect.get(Workbench.prototype, 'revealEditorOnOpen') as (this: IWillOpenTestHarness, e: { groupId: number; editor: unknown }) => void; const revealEditorOnOpenSinglePane = Reflect.get(SinglePaneWorkbench.prototype, 'revealEditorOnOpen') as (this: IWillOpenTestHarness, e: { groupId: number; editor: unknown }) => void; const createDesktopGridDescriptor = Reflect.get(Workbench.prototype, 'createDesktopGridDescriptor') as (this: IGridDescriptorTestHarness, width: number, height: number) => { root: { data: readonly unknown[] } }; @@ -2350,6 +2351,8 @@ suite('Sessions - Workbench', () => { partVisibility: { sidebar: boolean; auxiliaryBar: boolean; editor: boolean; panel: boolean; sessions: boolean }; layoutPolicy: { viewportClass: { get(): 'phone' | 'tablet' | 'desktop' } }; storageService: { store(...args: unknown[]): void }; + isSinglePaneLayoutEnabled: boolean; + _loadPartVisibility(storageService: unknown): { editor?: boolean; auxiliaryBar?: boolean; sidebar?: boolean; panel?: boolean }; _editorPartAutoVisibilitySuppressionCount: number; _editorMaximized: boolean; _restoreAttachedEditorMaximizedOnShow: boolean; @@ -2362,6 +2365,8 @@ suite('Sessions - Workbench', () => { partVisibility: { sidebar: true, auxiliaryBar: true, editor: true, panel: false, sessions: true }, layoutPolicy: { viewportClass: { get: () => 'desktop' } }, storageService: { store: () => { } }, + isSinglePaneLayoutEnabled: false, + _loadPartVisibility: () => ({}), _editorPartAutoVisibilitySuppressionCount: 0, _editorMaximized: false, _restoreAttachedEditorMaximizedOnShow: false, @@ -2888,4 +2893,39 @@ suite('Sessions - Workbench', () => { assert.strictEqual(storeCalled, false); }); + + test('restores saved panel visibility only in single-pane mode', () => { + function restoredPanel(isSinglePaneLayoutEnabled: boolean): boolean { + const workbench = createWorkbenchHarness(); + workbench.isSinglePaneLayoutEnabled = isSinglePaneLayoutEnabled; + workbench.partVisibility.panel = false; + workbench._loadPartVisibility = () => ({ panel: true }); + + applyPersistedPartVisibility.call(workbench); + return workbench.partVisibility.panel; + } + + assert.deepStrictEqual( + { singlePane: restoredPanel(true), classic: restoredPanel(false) }, + { singlePane: true, classic: false } + ); + }); + + test('persists panel visibility only in single-pane mode', () => { + function persistedPanel(isSinglePaneLayoutEnabled: boolean): boolean | undefined { + const workbench = createWorkbenchHarness(); + workbench.isSinglePaneLayoutEnabled = isSinglePaneLayoutEnabled; + workbench.partVisibility.panel = true; + let stored: { panel?: boolean } | undefined; + workbench.storageService.store = (_key, value) => { stored = JSON.parse(String(value)); }; + + savePartVisibility.call(workbench); + return stored?.panel; + } + + assert.deepStrictEqual( + { singlePane: persistedPanel(true), classic: persistedPanel(false) }, + { singlePane: true, classic: undefined } + ); + }); }); diff --git a/src/vs/workbench/browser/media/chatPills.css b/src/vs/workbench/browser/media/chatPills.css index 28c02a9f3962b5..0473fd0264f0f1 100644 --- a/src/vs/workbench/browser/media/chatPills.css +++ b/src/vs/workbench/browser/media/chatPills.css @@ -37,6 +37,7 @@ align-items: center; width: auto; gap: var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-medium); font-variant-numeric: tabular-nums; white-space: nowrap; touch-action: manipulation; diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 522f7967950e3a..ca467f2902a3d0 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -293,6 +293,30 @@ export class ModalEditorPart { editorPart.create(editorPartContainer); disposables.add(Event.once(editorPart.onWillClose)(() => disposables.dispose())); + const pendingBubbleCloseEvents = new WeakSet(); + disposables.add(addDisposableListener(mainWindow, EventType.KEY_DOWN, e => pendingBubbleCloseEvents.delete(e))); + // Capture close keybindings before focused controls can stop propagation. + disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + const resolved = this.keybindingService.softDispatch(event, event.target); + if (resolved.kind === ResultKind.KbFound && resolved.commandId === CLOSE_MODAL_EDITOR_COMMAND_ID) { + if (resolved.isBubble) { + pendingBubbleCloseEvents.add(e); + // Dispatch after target delivery only when propagation did not reach the window. + queueMicrotask(() => { + if (pendingBubbleCloseEvents.delete(e)) { + this.keybindingService.dispatchEvent(event, event.target); + } + }); + return; + } + const shouldPreventDefault = this.keybindingService.dispatchEvent(event, event.target); + event.stopPropagation(); + if (shouldPreventDefault) { + event.preventDefault(); + } + } + }, true)); disposables.add(Event.runAndSubscribe(editorPart.onDidChangeNavigation, ((navigation: IModalEditorNavigation | undefined) => { if (navigation && navigation.total > 1) { show(navigationContainer); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index a78d9912012b30..cece884371f10b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -65,6 +65,53 @@ function isNewEditSessionActionContext(arg: unknown): arg is INewEditSessionActi return false; } +export class NewChatAction extends Action2 { + constructor() { + super({ + id: ACTION_ID_NEW_CHAT, + title: localize2('chat.newEdits.label', "New Chat"), + category: CHAT_CATEGORY, + icon: Codicon.plus, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat)), + f1: true, + menu: [ + { + id: MenuId.ChatContext, + group: 'z_clear' + }, + { + id: MenuId.ChatNewMenu, + group: '1_open', + order: 1, + when: ContextKeyExpr.and( + ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('copilot'), + ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('new-session'), + ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('comment') + ) + } + ], + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + 1, + primary: KeyMod.CtrlCmd | KeyCode.KeyN, + secondary: [KeyMod.CtrlCmd | KeyCode.KeyL], + mac: { + primary: KeyMod.CtrlCmd | KeyCode.KeyN, + secondary: [KeyMod.WinCtrl | KeyCode.KeyL] + }, + when: ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.inChatEditor.negate()) + } + }); + } + + async run(accessor: ServicesAccessor, ...args: unknown[]) { + const executeCommandContext = isNewEditSessionActionContext(args[0]) ? args[0] : undefined; + + // Context from toolbar or lastFocusedWidget + const context = getEditingSessionContext(accessor, args); + await runNewChatAction(accessor, context, executeCommandContext); + } +} + export function registerNewChatActions() { // Add "New Chat" submenu to Chat view menu @@ -93,53 +140,7 @@ export function registerNewChatActions() { } }); - registerAction2(class NewChatAction extends Action2 { - constructor() { - super({ - id: ACTION_ID_NEW_CHAT, - title: localize2('chat.newEdits.label', "New Chat"), - category: CHAT_CATEGORY, - icon: Codicon.plus, - precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat)), - f1: true, - menu: [ - { - id: MenuId.ChatContext, - group: 'z_clear' - }, - { - id: MenuId.ChatNewMenu, - group: '1_open', - order: 1, - when: ContextKeyExpr.and( - ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('copilot'), - ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('new-session'), - ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('comment') - ) - } - ], - keybinding: { - weight: KeybindingWeight.WorkbenchContrib + 1, - primary: KeyMod.CtrlCmd | KeyCode.KeyN, - secondary: [KeyMod.CtrlCmd | KeyCode.KeyL], - mac: { - primary: KeyMod.CtrlCmd | KeyCode.KeyN, - secondary: [KeyMod.WinCtrl | KeyCode.KeyL] - }, - when: ChatContextKeys.inChatSession - } - }); - } - - async run(accessor: ServicesAccessor, ...args: unknown[]) { - const executeCommandContext = isNewEditSessionActionContext(args[0]) ? args[0] : undefined; - - // Context from toolbar or lastFocusedWidget - const context = getEditingSessionContext(accessor, args); - await runNewChatAction(accessor, context, executeCommandContext); - } - } - ); + registerAction2(NewChatAction); const iconVariants = [ { idSuffix: '.copilotIcon', iconValue: 'copilot', icon: Codicon.copilot }, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts index 50c6520f3e078a..1d569e62448cfd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts @@ -16,7 +16,9 @@ import { IAgentHostByokLmHandler } from '../../../../../../platform/agentHost/common/agentHostByokLm.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { registerWorkbenchContribution2, IWorkbenchContribution, WorkbenchPhase } from '../../../../../common/contributions.js'; import { AgentHostAllowSignedOutWhenUsableContribution } from './agentHostAllowSignedOutWhenUsableContribution.js'; import { AgentHostByokLmHandler } from './agentHostByokLmHandler.js'; import { AgentHostContribution } from './agentHostChatContribution.js'; @@ -27,10 +29,24 @@ import { AgentHostSdkSetupNotificationContribution } from './agentHostSdkSetupNo import { AgentHostSignedOutModelsNotificationContribution } from './agentHostSignedOutModelsNotification.js'; import { AgentHostTerminalContribution } from './agentHostTerminalContribution.js'; import { CopilotConfigSlashSubmitHandlerContribution } from './copilotConfigSlashSubmitHandler.js'; +import { primeLegacyMigrationStartupSnapshot } from './agentHostLegacyMigration.js'; import './agentHostSettings.contribution.js'; import './agentSessionSettings.contribution.js'; +/** + * Freezes the legacy-migration setting at startup so enabling it only takes effect + * on the next window reload. Runs before any session can be opened (BlockStartup). + */ +class AgentHostLegacyMigrationGateContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.chat.agentHostLegacyMigrationGate'; + constructor(@IConfigurationService configurationService: IConfigurationService) { + super(); + primeLegacyMigrationStartupSnapshot(configurationService); + } +} + registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostLegacyMigrationGateContribution.ID, AgentHostLegacyMigrationGateContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(CopilotConfigSlashSubmitHandlerContribution.ID, CopilotConfigSlashSubmitHandlerContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostSessionListContribution.ID, AgentHostSessionListContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostOpenSessionLinkOpenerContribution.ID, AgentHostOpenSessionLinkOpenerContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.ts index e7dc6e304d0fa0..b3c208798175aa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.ts @@ -16,18 +16,45 @@ import { StateComponents } from '../../../../../../platform/agentHost/common/sta import type { SessionState } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; import { COPILOT_CLI_AGENT_PROVIDER, getCopilotCliSessionRawId, migratedCopilotCliResource } from '../../copilotCliEventsUri.js'; +/** + * Startup-frozen value of {@link ChatConfiguration.MigrateLegacyCopilotCliSessions}. + * Migration is a one-time, one-way operation, so enabling the setting only takes + * effect on the next window reload: until then an open must behave as if it were + * still off. {@link primeLegacyMigrationStartupSnapshot} captures the value once at + * startup; before that (only in tests) the live value is used so each test's stub + * is honored in isolation. + */ +let _legacyMigrationEnabledAtStartup: boolean | undefined; + +export function primeLegacyMigrationStartupSnapshot(configurationService: IConfigurationService): void { + _legacyMigrationEnabledAtStartup = configurationService.getValue(ChatConfiguration.MigrateLegacyCopilotCliSessions) === true; +} + +export function isLegacyMigrationEnabledAtStartup(configurationService: IConfigurationService): boolean { + return _legacyMigrationEnabledAtStartup ?? (configurationService.getValue(ChatConfiguration.MigrateLegacyCopilotCliSessions) === true); +} + /** * How long an adoption probe may take before falling back to the legacy resource. * - * Interactive opens happen against a warm host and answer immediately, so a short - * bound keeps a wedged host from looking like a hang. Restore is the cold case: - * the host is still working through its initial catalogue, and a measured restore - * took ~28s on a large one — giving up early there is what makes a restored - * session silently stay on the legacy provider. + * Right after migration is enabled on a large catalogue the host is still busy + * discovering thousands of sessions, so even an interactive open can hit a cold + * host; a too-short bound makes it fall back to the read-only legacy session + * instead of the migrated one. Restore is the fully-cold case (startup), so it + * gets the larger budget. */ -export const LEGACY_MIGRATION_TIMEOUT_MS = 10_000; +export const LEGACY_MIGRATION_TIMEOUT_MS = 30_000; export const LEGACY_MIGRATION_RESTORE_TIMEOUT_MS = 60_000; +/** + * Budget for an explicit, user-initiated open. Migration is invisible to the user, so we + * prefer to wait for the adopted session to surface (shown under a subtle progress hint) + * rather than fall back to the pre-migration view they did not ask for. A declined or + * external session still resolves quickly via the error signal, so only a genuinely + * still-warming host uses the full budget. + */ +export const LEGACY_MIGRATION_OPEN_TIMEOUT_MS = 60_000; + /** Where a probe was triggered from, so outcomes can be attributed per entry point. */ export type LegacyMigrationProbeSource = 'open' | 'restore'; @@ -93,8 +120,10 @@ export async function adoptLegacyCopilotCliResource( // The host restores a session whether or not it adopts it, so a successful // probe does not by itself mean migration happened. Gate on the setting here: // without it we would move sessions onto the agent host for users who never - // opted in — including external ones, which are never adopted at all. - if (configurationService.getValue(ChatConfiguration.MigrateLegacyCopilotCliSessions) !== true) { + // opted in — including external ones, which are never adopted at all. The gate + // is the startup-frozen value: enabling migration takes effect on the next + // window reload, so an open before reload behaves as if it were still off. + if (!isLegacyMigrationEnabledAtStartup(configurationService)) { report('settingDisabled'); return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index f36879f5ff5a0a..6a36cfed66eb58 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -48,7 +48,7 @@ import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuth import { compareProtocolVersions } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, AHP_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -89,6 +89,7 @@ import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, ChatAgentLocation, ChatConfiguratio import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { HIDE_AUTO_EXPLAINABILITY_TREATMENT } from '../../../common/chatAutoModeExplainability.js'; import { AUTO_RAW_MODEL_ID, getLanguageModelDisplayNameWithProvider, ILanguageModelChatMetadata, ILanguageModelsService } from '../../../common/languageModels.js'; +import { getLanguageModelDisplayNameWithSubscriptionSource } from '../../../common/languageModelSourcePresentation.js'; import { ChatInputStateOrigin, reviveSerializableInputState, type IChatModel, type IChatModelInputState, type IChatRequestVariableData, type IInputModel, type ISerializableChatModelInputState } from '../../../common/model/chatModel.js'; import { ChatElicitationRequestPart } from '../../../common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -3529,9 +3530,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (opts.subAgentInvocationId !== undefined && opts.subAgentModelObservable) { const modelObservable = opts.subAgentModelObservable; store.add(autorun(reader => { - const rawUsage = usage$.read(reader); - const modelId = this._toLanguageModelId(opts.sessionResource, rawUsage?.model); - const modelName = this._getLanguageModelDisplayName(modelId); + // Hold off while the treatment is unknown: naming the routed model + // cannot be taken back once it is on screen. + const hideAutoExplainability = this._hideAutoExplainability.read(reader); + if (hideAutoExplainability === undefined) { + return; + } + const modelName = this._createTurnModelLookup(opts.sessionResource, undefined, hideAutoExplainability) + .toBilledModelDisplayName?.(usage$.read(reader)); if (modelName && modelName !== modelObservable.read(undefined)) { transaction(tx => modelObservable.set(modelName, tx)); } @@ -4857,6 +4863,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } let credits = 0; let modelName: string | undefined; + const lookup = this._createTurnModelLookup(sessionResource, undefined); const turns = childState.activeTurn && !childState.turns.some(turn => turn.id === childState.activeTurn?.id) ? [...childState.turns, childState.activeTurn] : childState.turns; @@ -4865,8 +4872,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (typeof turnCredits === 'number') { credits += turnCredits; } - const turnModelId = this._toLanguageModelId(sessionResource, turn.usage?.model); - const turnModelName = this._getLanguageModelDisplayName(turnModelId); + const turnModelName = lookup.toBilledModelDisplayName?.(turn.usage); if (turnModelName) { modelName = turnModelName; } @@ -4874,7 +4880,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (credits > 0) { part.toolSpecificData.credits = credits; } - if (modelName && !part.toolSpecificData.modelName) { + // Overwrite rather than fill in: a name persisted under one explainability + // assignment must not outlive a change to that assignment. + if (modelName) { part.toolSpecificData.modelName = modelName; } const timing = getSubagentTiming(childState); @@ -5708,14 +5716,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return rawModelId.startsWith(prefix) ? rawModelId : `${prefix}${rawModelId}`; } - private _getLanguageModelDisplayName(modelIdentifier: string | undefined): string | undefined { - if (!modelIdentifier) { - return undefined; - } - const metadata = this._languageModelsService.lookupLanguageModel(modelIdentifier); - return metadata ? getLanguageModelDisplayNameWithProvider({ identifier: modelIdentifier, metadata }, this._languageModelsService) : undefined; - } - private _getTurnResponseDetails(sessionResource: URI, backendSession: URI, turn: Turn | undefined): string | undefined { const fallbackRawModelId = turn?.message?.model?.id ?? lastTurnModelSelection(this._getSessionState(backendSession.toString()))?.id; return this._createTurnModelLookup(sessionResource, fallbackRawModelId).toResponseDetails(turn?.usage?.model, turn?.usage); @@ -5756,25 +5756,39 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } return undefined; }; + // Folds a routed pick back to Auto when explainability is hidden, so every + // readout tells the same story. + const resolveBilledModel = (rawModelId: string | undefined, usage: UsageInfo | undefined) => { + // Keyed off the reported routing decision so restored history only + // rewrites turns that actually used Auto. + const routed = !!readUsageInfoMeta(usage).autoModeResolved; + const billedId = routed && hideAutoExplainability ? AUTO_RAW_MODEL_ID : rawModelId; + return { billedId, resolved: lookupModel(billedId) }; + }; + const displayName = (resolved: { identifier: string; model: ILanguageModelChatMetadata }) => { + const model = { identifier: resolved.identifier, metadata: resolved.model }; + return getLanguageModelDisplayNameWithSubscriptionSource(model, getLanguageModelDisplayNameWithProvider(model, this._languageModelsService)); + }; return { toLanguageModelId: (rawModelId) => this._toLanguageModelId(sessionResource, resolveRaw(rawModelId)), toModelDisplayName: rawModelId => lookupRawModel(rawModelId)?.model.name, toResponseDetails: (rawModelId, usage) => { - // A routed turn bills to Auto rather than the model it picked when - // explainability is hidden. Keyed off the reported routing decision - // so restored history only rewrites turns that actually used Auto. - const routed = !!readUsageInfoMeta(usage).autoModeResolved; - const billedId = routed && hideAutoExplainability ? AUTO_RAW_MODEL_ID : rawModelId; - const resolved = lookupModel(billedId); + const { billedId, resolved } = resolveBilledModel(rawModelId, usage); // resolvedFromRaw=false means we fell back to the picked model; surface billedModelId so // e.g. an "Auto" pick reads "Auto (raptor-mini)". const billedModelId = resolved && !resolved.resolvedFromRaw ? billedId : undefined; const responseModel = resolved ? { - name: getLanguageModelDisplayNameWithProvider({ identifier: resolved.identifier, metadata: resolved.model }, this._languageModelsService), + name: displayName(resolved), pricing: resolved.model.pricing, } : undefined; return formatTurnResponseDetails(responseModel, billedModelId, usage); }, + toBilledModelDisplayName: usage => { + // Auto names the model it picked, which is the one worth showing. + const chosenModel = readUsageInfoMeta(usage).autoModeResolved?.chosenModel; + const { resolved } = resolveBilledModel(chosenModel ?? usage?.model, usage); + return resolved ? displayName(resolved) : undefined; + }, toAutoModeResolution: usage => { const resolution = readUsageInfoMeta(usage).autoModeResolved; const isAutoTurn = fallbackRawModelId === AUTO_RAW_MODEL_ID || !!resolution; @@ -5788,7 +5802,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const resolved = lookupRawModel(resolution.chosenModel); // Named the same way as the footer, so the two agree. - const name = resolved && getLanguageModelDisplayNameWithProvider({ identifier: resolved.identifier, metadata: resolved.model }, this._languageModelsService); + const name = resolved && displayName(resolved); return usageInfoToAutoModeResolution(usage, name); }, }; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 44352f6523e00f..405479cde338eb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -34,7 +34,7 @@ import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/f import { AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import product from '../../../../../../platform/product/common/product.js'; import { ConfigureAutomationToolReferenceName } from '../../../common/automations/automationService.js'; -import { formatCopilotCredits, ElicitationState, type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatAutomationConfiguredData, type IChatAutoModeResolutionPart, type IChatExternalEdit, type IChatGeneratedImageData, type IChatMcpAuthenticationRequiredServer, type IChatModifiedFilesConfirmationData, type IChatPlanReviewResult, type IChatProgress, type IChatQuestion, type IChatQuestionAnswerValue, type IChatQuestionAnswers, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatSessionCreatedData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, type IChatUsagePromptTokenDetail, ToolConfirmKind, AgentFeedbackReviewCommandId } from '../../../common/chatService/chatService.js'; +import { formatCopilotCreditsLabel, ElicitationState, type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatAutomationConfiguredData, type IChatAutoModeResolutionPart, type IChatExternalEdit, type IChatGeneratedImageData, type IChatMcpAuthenticationRequiredServer, type IChatModifiedFilesConfirmationData, type IChatPlanReviewResult, type IChatProgress, type IChatQuestion, type IChatQuestionAnswerValue, type IChatQuestionAnswers, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatSessionCreatedData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, type IChatUsagePromptTokenDetail, ToolConfirmKind, AgentFeedbackReviewCommandId } from '../../../common/chatService/chatService.js'; import { isTerminalCommandPrompt, type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { type IQuotaSnapshot, type IRateLimitSnapshot } from '../../../../../services/chat/common/chatEntitlementService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -543,6 +543,11 @@ export interface TurnModelLookup { toResponseDetails(rawModelId: string | undefined, usage: UsageInfo | undefined): string | undefined; /** Returns the Auto model routing part carried by this usage report, if any. */ toAutoModeResolution?(usage: UsageInfo | undefined): IChatAutoModeResolutionPart | undefined; + /** + * Returns the display name of the model a turn bills to, reading Auto's pick + * when it routed and folding back to "Auto" while explainability is hidden. + */ + toBilledModelDisplayName?(usage: UsageInfo | undefined): string | undefined; } /** Minimal model metadata needed to render a turn's response footer (kept small for unit testing). */ @@ -568,11 +573,7 @@ export function formatTurnResponseDetails( const displayName = formatTurnModelName(model, billedModelId); const credits = usageInfoToChatUsage(usage)?.copilotCredits; if (credits !== undefined) { - const formatted = formatCopilotCredits(credits); - const creditDetails = formatted === '1' - ? localize('agentHost.responseDetails.credit', "{0} credit", formatted) - : localize('agentHost.responseDetails.credits', "{0} credits", formatted); - return [displayName, creditDetails].join(' • '); + return [displayName, formatCopilotCreditsLabel(credits)].join(' • '); } return [displayName, model.pricing].filter(Boolean).join(' · '); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 550b01e7c285a9..8ebae10c25d7e5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -698,6 +698,12 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) { mapSessionContributionToType.set(contribution.type, contribution); } + // Providers that register their session items dynamically (notably the + // agent host, e.g. `agent-host-copilotcli` and remote `remote--…`) + // are neither built-in nor static contributions, so they are preserved + // via the live registration signal — otherwise a sibling provider's + // partial refresh would drop their rows (sessions vanishing mid-migration). + const registeredProviders = new Set(this.chatSessionsService.getRegisteredChatSessionItemProviders()); // Phase 1: Fetch new items for this provider (async, may interleave with other providers) const sessions = new ResourceMap(); @@ -758,15 +764,24 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode // Phase 2: Atomically update sessions (sync - reads latest this._sessions // so concurrent updateItems calls for other providers don't lose data) + let preservedViaRegistration = 0; for (const [, session] of this._sessions) { - if ( - session.providerType !== provider && - !sessions.has(session.resource) && - (isBuiltInAgentSessionProvider(session.providerType) || mapSessionContributionToType.has(session.providerType)) - ) { - sessions.set(session.resource, session); + if (session.providerType !== provider && !sessions.has(session.resource)) { + const knownProvider = isBuiltInAgentSessionProvider(session.providerType) || mapSessionContributionToType.has(session.providerType); + if (knownProvider || registeredProviders.has(session.providerType)) { + sessions.set(session.resource, session); + // Count rows kept only because their provider is live-registered + // (e.g. agent-host): the old condition dropped these, causing the + // mid-migration vanish. A non-zero count means the fix engaged. + if (!knownProvider) { + preservedViaRegistration++; + } + } } } + if (preservedViaRegistration > 0) { + this.logger.logIfTrace(`doResolveProvider(${provider}): preserved ${preservedViaRegistration} live-registered session(s) across a sibling refresh (would have dropped before the preservation fix)`); + } for (const resource of this.explicitlyMarkedUnreadSessions) { if (!sessions.has(resource)) { this.explicitlyMarkedUnreadSessions.delete(resource); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index 43b8d7e95a1f5c..c1191eb5f8282c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -23,7 +23,9 @@ import { IAgentSessionsService } from './agentSessionsService.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { adoptLegacyCopilotCliResource, reportLegacyMigrationOpen } from './agentHost/agentHostLegacyMigration.js'; +import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; +import { migratedCopilotCliResource } from '../copilotCliEventsUri.js'; +import { adoptLegacyCopilotCliResource, LEGACY_MIGRATION_OPEN_TIMEOUT_MS, reportLegacyMigrationOpen } from './agentHost/agentHostLegacyMigration.js'; //#region Session Opener Registry @@ -75,30 +77,55 @@ async function resolveMigratedSession(agentSessionsService: IAgentSessionsServic return agentSessionsService.getSession(migrated); } -export async function openSessionByResource(accessor: ServicesAccessor, resource: URI, openOptions?: ISessionOpenOptions): Promise { - const instantiationService = accessor.get(IInstantiationService); +/** + * For an explicit open, redirect a superseded legacy resource to its adopted twin and + * return the migrated session to open in its place (or `undefined` to open the original). + * + * Migration is invisible to the user — they always just open "their chat". So the whole + * adopt-and-surface step runs under a subtle status-bar progress hint and a generous + * budget, which keeps the *same* session opening in place instead of ever swapping in the + * pre-migration view. A declined/external session resolves quickly, so only a still-warming + * host actually waits. + */ +async function resolveMigratedSessionForOpen(accessor: ServicesAccessor, resource: URI): Promise { + // Only a superseded legacy resource can redirect; skip the progress wrapper for every + // normal open so they stay overhead-free. + if (!migratedCopilotCliResource(resource)) { + return undefined; + } + const logService = accessor.get(ILogService); const agentSessionsService = accessor.get(IAgentSessionsService); const telemetryService = accessor.get(ITelemetryService); + const configurationService = accessor.get(IConfigurationService); + const connection = accessor.get(IAgentHostConnectionsService).ambientConnection; + + return accessor.get(IProgressService).withProgress( + { location: ProgressLocation.Window, title: localize('chat.openingSession', "Opening chat…") }, + async () => { + const migrated = await adoptLegacyCopilotCliResource(connection, resource, logService, configurationService, telemetryService, 'open', LEGACY_MIGRATION_OPEN_TIMEOUT_MS); + if (!migrated) { + return undefined; + } + const surfaced = await resolveMigratedSession(agentSessionsService, migrated); + reportLegacyMigrationOpen(telemetryService, 'open', !!surfaced); + if (!surfaced) { + logService.warn(`[AgentHost] migrated ${resource.toString()} to ${migrated.toString()} but it is not in this window's list after refreshing provider '${getChatSessionType(migrated)}'; opening the legacy session instead.`); + } + return surfaced; + }, + ); +} + +export async function openSessionByResource(accessor: ServicesAccessor, resource: URI, openOptions?: ISessionOpenOptions): Promise { + const instantiationService = accessor.get(IInstantiationService); + const logService = accessor.get(ILogService); // A superseded legacy resource is redirected (and adopted) before anything // looks it up, so opening by URI migrates instead of reaching the old provider. - const migrated = await adoptLegacyCopilotCliResource( - accessor.get(IAgentHostConnectionsService).ambientConnection, - resource, - logService, - accessor.get(IConfigurationService), - accessor.get(ITelemetryService), - 'open', - ); - if (migrated) { - const surfaced = await resolveMigratedSession(agentSessionsService, migrated); - reportLegacyMigrationOpen(telemetryService, 'open', !!surfaced); - if (surfaced) { - resource = migrated; - } else { - logService.warn(`[AgentHost] migrated ${resource.toString()} to ${migrated.toString()} but it is not in this window's list after refreshing provider '${getChatSessionType(migrated)}'; opening the legacy session instead.`); - } + const migratedSession = await resolveMigratedSessionForOpen(accessor, resource); + if (migratedSession) { + resource = migratedSession.resource; } for (const participant of sessionOpenerRegistry.getParticipants()) { @@ -127,8 +154,6 @@ export async function openSessionByResource(accessor: ServicesAccessor, resource export async function openSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: ISessionOpenOptions, alreadyResolved?: boolean): Promise { const instantiationService = accessor.get(IInstantiationService); const logService = accessor.get(ILogService); - const agentSessionsService = accessor.get(IAgentSessionsService); - const telemetryService = accessor.get(ITelemetryService); logService.trace(`[AgentSessions] openSession start: ${session.resource.toString()}`); @@ -136,22 +161,9 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes // has to happen on this path too or those opens never migrate. A no-op for // anything that is not a superseded legacy resource. if (!alreadyResolved) { - const migrated = await adoptLegacyCopilotCliResource( - accessor.get(IAgentHostConnectionsService).ambientConnection, - session.resource, - logService, - accessor.get(IConfigurationService), - accessor.get(ITelemetryService), - 'open', - ); - if (migrated) { - const migratedSession = await resolveMigratedSession(agentSessionsService, migrated); - reportLegacyMigrationOpen(telemetryService, 'open', !!migratedSession); - if (migratedSession) { - session = migratedSession; - } else { - logService.warn(`[AgentHost] migrated ${session.resource.toString()} to ${migrated.toString()} but it is not in this window's list after refreshing provider '${getChatSessionType(migrated)}'; opening the legacy session instead.`); - } + const migratedSession = await resolveMigratedSessionForOpen(accessor, session.resource); + if (migratedSession) { + session = migratedSession; } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index ac1ceb7a4db0e1..033974e44d11d6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -42,7 +42,6 @@ import { URI } from '../../../../../base/common/uri.js'; import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; import { aiCustomizationManagementSectionRegistry, IAICustomizationManagementSectionWidget } from './aiCustomizationManagementSectionRegistry.js'; import { AICustomizationListWidget } from './aiCustomizationListWidget.js'; -import type { IAICustomizationItemSource } from './aiCustomizationItemSource.js'; import { IAICustomizationItemsModel, ITEMS_MODEL_SECTIONS } from './aiCustomizationItemsModel.js'; import { McpListWidget } from './mcpListWidget.js'; import { PluginListWidget } from './pluginListWidget.js'; @@ -65,7 +64,8 @@ import { import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, pluginIcon, toolsIcon } from './aiCustomizationIcons.js'; import { ChatModelsWidget } from '../chatManagement/chatModelsWidget.js'; import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; -import { IPromptsService, IPromptPath, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { CustomizationMigrationType, getCustomizationMigrationTargetType, ICustomizationMigrationService, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { IHeaderAttribute, IValue, ParsedPromptFile } from '../../common/promptSyntax/promptFileParser.js'; import { AGENT_MD_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; import { getAttributeDefinition, getTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; @@ -102,8 +102,8 @@ import { EmbeddedExtensionToolsDetail } from './embeddedExtensionToolsDetail.js' import { ICustomizationHarnessService, type ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage, type ICustomizationMigrationCategorySummary } from './aiCustomizationWelcomePage.js'; -import { type CustomizationMigrationTargetFolders, getCustomizationMigrationTargetType, migrateCustomizations } from './customizationMigration.js'; -import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId, getCustomizationMigrationCategory, getCustomizationMigrationSourceTypes, type ICustomizationMigrationBanner, type ICustomizationMigrationCategory } from './customizationMigrationCategories.js'; +import { type CustomizationMigrationTargetFolders, migrateCustomizations } from './customizationMigration.js'; +import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId, getCustomizationMigrationCategory, type ICustomizationMigrationBanner, type ICustomizationMigrationCategory } from './customizationMigrationCategories.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { showNoFoldersDialog } from '../promptSyntax/pickers/askForPromptSourceFolder.js'; @@ -380,7 +380,7 @@ export class AICustomizationManagementEditor extends EditorPane { // Welcome page private welcomePage: AICustomizationWelcomePage | undefined; - private customizationsByMigrationCategory = new Map(); + private customizationsByMigrationCategory = new Map(); private customizationMigrationTargetFoldersByType = new Map(); private customizationMigrationRefreshSequence = 0; private customizationMigrationLoading = false; @@ -414,6 +414,7 @@ export class AICustomizationManagementEditor extends EditorPane { @ICommandService private readonly commandService: ICommandService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IPromptsService private readonly promptsService: IPromptsService, + @ICustomizationMigrationService private readonly customizationMigrationService: ICustomizationMigrationService, @ITextModelService private readonly textModelService: ITextModelService, @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, @@ -1111,6 +1112,7 @@ export class AICustomizationManagementEditor extends EditorPane { private async refreshCustomizationMigrationInfo(): Promise { const activeHarnessId = this.harnessService.activeHarness.get(); + const activeSessionResource = this.harnessService.activeSessionResource.get(); const refreshSequence = ++this.customizationMigrationRefreshSequence; this.customizationMigrationLoading = true; this.customizationMigrationLoadError = undefined; @@ -1130,33 +1132,30 @@ export class AICustomizationManagementEditor extends EditorPane { return; } - const sourceTypes = getCustomizationMigrationSourceTypes(enabledCategories); - const customizationsByType = await Promise.all(sourceTypes.map(type => this.promptsService.listPromptFiles(type, CancellationToken.None))); - if (refreshSequence !== this.customizationMigrationRefreshSequence || activeHarnessId !== this.harnessService.activeHarness.get()) { + const migrationsByCategory = await Promise.all(enabledCategories.map(async category => { + const type = category.id === CustomizationMigrationCategoryId.PromptFiles + ? CustomizationMigrationType.PromptFiles + : CustomizationMigrationType.UserData; + const migration = await this.customizationMigrationService.computeMigration(activeSessionResource, type); + return [category.id, migration] as const; + })); + if (refreshSequence !== this.customizationMigrationRefreshSequence || activeHarnessId !== this.harnessService.activeHarness.get() || !isEqual(activeSessionResource, this.harnessService.activeSessionResource.get())) { return; } - const allCustomizations = customizationsByType.flat(); - const unfilteredCandidatesByCategory = new Map(); - for (const category of enabledCategories) { - unfilteredCandidatesByCategory.set(category.id, allCustomizations.filter(customization => category.isCandidate(customization))); - } - - const targetTypes = new Set([...unfilteredCandidatesByCategory.values()].flat().map(getCustomizationMigrationTargetType)); - const itemSource = this.itemsModel.getActiveItemSource(); + const candidatesByCategory = new Map( + migrationsByCategory.map(([categoryId, migration]) => [categoryId, migration.candidates]), + ); + const provider = this.harnessService.findHarnessById(activeHarnessId)?.itemProvider; + const targetTypes = new Set([...candidatesByCategory.values()].flat().map(getCustomizationMigrationTargetType)); const targetFolderEntries = await Promise.all([...targetTypes].map(async targetType => { - const folders = await itemSource.fetchSourceFolders(targetType); - return [targetType, folders] as const; + const folders = await provider?.provideSourceFolders?.(activeSessionResource, targetType, CancellationToken.None); + return [targetType, folders ?? []] as const; })); - if (refreshSequence !== this.customizationMigrationRefreshSequence || activeHarnessId !== this.harnessService.activeHarness.get()) { + if (refreshSequence !== this.customizationMigrationRefreshSequence || activeHarnessId !== this.harnessService.activeHarness.get() || !isEqual(activeSessionResource, this.harnessService.activeSessionResource.get())) { return; } - const targetFoldersByType = new Map(targetFolderEntries); - const candidatesByCategory = new Map(); - for (const [categoryId, candidates] of unfilteredCandidatesByCategory) { - candidatesByCategory.set(categoryId, this.filterCustomizationMigrationCandidatesByTargetFolders(candidates, targetFoldersByType)); - } this.customizationMigrationLoading = false; this.setCustomizationsToMigrate(candidatesByCategory, targetFoldersByType); } catch (error) { @@ -1170,7 +1169,7 @@ export class AICustomizationManagementEditor extends EditorPane { } private setCustomizationsToMigrate( - candidatesByCategory: Map, + candidatesByCategory: Map, targetFoldersByType: Map, ): void { const previousItems = this.createCustomizationMigrationItemMap(this.getAllMigrationCandidates()); @@ -1186,17 +1185,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.refreshCustomizationMigrationUi(); } - private filterCustomizationMigrationCandidatesByTargetFolders( - customizations: readonly IPromptPath[], - targetFoldersByType: ReadonlyMap, - ): readonly IPromptPath[] { - return customizations.filter(customization => { - const targetType = getCustomizationMigrationTargetType(customization); - return targetFoldersByType.get(targetType)?.some(folder => folder.source === customization.storage) === true; - }); - } - - private createCustomizationMigrationItemMap(customizations: readonly IPromptPath[]): ResourceMap> { + private createCustomizationMigrationItemMap(customizations: readonly MigratableConfiguration[]): ResourceMap> { const result = new ResourceMap>(); for (const customization of customizations) { this.addCustomizationMigrationItem(result, customization); @@ -1204,21 +1193,21 @@ export class AICustomizationManagementEditor extends EditorPane { return result; } - private hasCustomizationMigrationItem(items: ResourceMap>, customization: IPromptPath): boolean { + private hasCustomizationMigrationItem(items: ResourceMap>, customization: MigratableConfiguration): boolean { return items.get(customization.uri)?.has(customization.storage) === true; } - private addCustomizationMigrationItem(items: ResourceMap>, customization: IPromptPath): void { + private addCustomizationMigrationItem(items: ResourceMap>, customization: MigratableConfiguration): void { const storages = items.get(customization.uri) ?? new Set(); storages.add(customization.storage); items.set(customization.uri, storages); } - private isCustomizationSelectedForMigration(customization: IPromptPath): boolean { + private isCustomizationSelectedForMigration(customization: MigratableConfiguration): boolean { return this.hasCustomizationMigrationItem(this.selectedCustomizationMigrationItems, customization); } - private setCustomizationSelectedForMigration(customization: IPromptPath, selected: boolean): void { + private setCustomizationSelectedForMigration(customization: MigratableConfiguration, selected: boolean): void { if (selected) { this.addCustomizationMigrationItem(this.selectedCustomizationMigrationItems, customization); return; @@ -1231,14 +1220,14 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private getMigrationCandidates(category: ICustomizationMigrationCategory): readonly IPromptPath[] { + private getMigrationCandidates(category: ICustomizationMigrationCategory): readonly MigratableConfiguration[] { if (!this.isMigrationCategoryEnabled(category)) { return []; } return this.customizationsByMigrationCategory.get(category.id) ?? []; } - private getAllMigrationCandidates(): readonly IPromptPath[] { + private getAllMigrationCandidates(): readonly MigratableConfiguration[] { return [...this.customizationsByMigrationCategory.values()].flat(); } @@ -1300,14 +1289,13 @@ export class AICustomizationManagementEditor extends EditorPane { this.layoutSidebar(this.sidebarWidth, this.sidebarHeight); } - private async migrateSelectedCustomizations(category: ICustomizationMigrationCategory, customizations: readonly IPromptPath[]): Promise { + private async migrateSelectedCustomizations(category: ICustomizationMigrationCategory, customizations: readonly MigratableConfiguration[]): Promise { if (customizations.length === 0 || !this.isMigrationCategoryEnabled(category)) { return; } const sessionResource = this.harnessService.activeSessionResource.get(); - const itemSource = this.itemsModel.getActiveItemSource(); - const targetFolders = await this.resolveCustomizationMigrationTargetFolders(customizations, itemSource, sessionResource); + const targetFolders = await this.resolveCustomizationMigrationTargetFolders(customizations, this.customizationMigrationTargetFoldersByType, sessionResource); if (!targetFolders || !this.isCustomizationMigrationSessionActive(sessionResource)) { return; } @@ -1405,7 +1393,7 @@ export class AICustomizationManagementEditor extends EditorPane { return; } - const openCustomizationInEmbeddedEditor = (customization: IPromptPath): void => { + const openCustomizationInEmbeddedEditor = (customization: MigratableConfiguration): void => { const isWorkspaceFile = customization.storage === PromptsStorage.local; void this.showEmbeddedEditor( customization.uri, @@ -1415,7 +1403,7 @@ export class AICustomizationManagementEditor extends EditorPane { isWorkspaceFile, ); }; - const renderSelectionCheckbox = (row: HTMLElement, customization: IPromptPath, onSelectionChange?: () => void): Checkbox => { + const renderSelectionCheckbox = (row: HTMLElement, customization: MigratableConfiguration, onSelectionChange?: () => void): Checkbox => { const checkboxContainer = DOM.append(row, $('.item-sync-checkbox.prompt-migration-checkbox')); const checkboxTitle = localize('customizationMigrationSelectAriaLabel', "Select {0}", customization.name ?? basename(customization.uri)); const checkbox = this.migrationPageDisposables.add(new Checkbox(checkboxTitle, this.isCustomizationSelectedForMigration(customization), defaultCheckboxStyles)); @@ -1429,7 +1417,7 @@ export class AICustomizationManagementEditor extends EditorPane { return checkbox; }; - const renderItem = (container: HTMLElement, customization: IPromptPath, onSelectionChange?: () => void): Checkbox => { + const renderItem = (container: HTMLElement, customization: MigratableConfiguration, onSelectionChange?: () => void): Checkbox => { const row = DOM.append(container, $('div.ai-customization-list-item.prompt-migration-item')); const checkbox = renderSelectionCheckbox(row, customization, onSelectionChange); @@ -1477,7 +1465,7 @@ export class AICustomizationManagementEditor extends EditorPane { return checkbox; }; - const renderGroup = (groupKey: string, groupLabel: string, customizations: readonly IPromptPath[]): void => { + const renderGroup = (groupKey: string, groupLabel: string, customizations: readonly MigratableConfiguration[]): void => { const group = DOM.append(this.migrationListContainer!, $('.prompt-migration-group')); const groupHeader = DOM.append(group, $('.prompt-migration-group-header')); const groupHeading = DOM.append(groupHeader, $('.prompt-migration-group-heading')); @@ -1572,7 +1560,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.migrationListScrollable?.scanDomNode(); } - private updateCustomizationMigrationPageHeader(category: ICustomizationMigrationCategory, candidates: readonly IPromptPath[]): void { + private updateCustomizationMigrationPageHeader(category: ICustomizationMigrationCategory, candidates: readonly MigratableConfiguration[]): void { if (this.migrationTitleElement) { this.migrationTitleElement.textContent = category.pageTitle; } @@ -1656,7 +1644,7 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private async deleteCustomizationFile(customization: IPromptPath): Promise { + private async deleteCustomizationFile(customization: MigratableConfiguration): Promise { const fileName = customization.name ?? basename(customization.uri); const confirmation = await this.dialogService.confirm({ message: localize('confirmDeleteCustomizationFile', "Are you sure you want to delete '{0}'?", fileName), @@ -1678,7 +1666,7 @@ export class AICustomizationManagementEditor extends EditorPane { } } - const updatedCandidates = new Map(); + const updatedCandidates = new Map(); for (const [categoryId, candidates] of this.customizationsByMigrationCategory) { updatedCandidates.set(categoryId, candidates.filter(item => !isEqual(item.uri, customization.uri))); } @@ -1694,8 +1682,8 @@ export class AICustomizationManagementEditor extends EditorPane { } private async resolveCustomizationMigrationTargetFolders( - customizations: readonly IPromptPath[], - itemSource: IAICustomizationItemSource, + customizations: readonly MigratableConfiguration[], + availableSourceFolders: ReadonlyMap, sessionResource: URI, ): Promise { const requiredStorageByTargetType = new Map>(); @@ -1708,7 +1696,7 @@ export class AICustomizationManagementEditor extends EditorPane { const targetFolders = new Map>(); for (const [targetType, requiredStorages] of requiredStorageByTargetType) { - const availableFolders = await itemSource.fetchSourceFolders(targetType); + const availableFolders = availableSourceFolders.get(targetType) ?? []; if (!this.isCustomizationMigrationSessionActive(sessionResource)) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts index e43c9f63942314..57bd11e2c7f234 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts @@ -11,7 +11,8 @@ import { ResourceMap } from '../../../../../base/common/map.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { getCleanPromptName, getPromptFileExtension, SKILL_FILENAME, VALID_SKILL_NAME_REGEX } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IHeaderAttribute, ParsedPromptFile, PromptFileParser, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; -import { IPromptPath, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { getCustomizationMigrationTargetType, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; @@ -49,11 +50,7 @@ const retainedPromptHeaderKeys = new Set([ * Prompt files become skills because agent-host harnesses have no prompt-file concept; * every other customization keeps its type and only changes location. */ -export function getCustomizationMigrationTargetType(customization: IPromptPath): PromptsType { - return customization.type === PromptsType.prompt ? PromptsType.skill : customization.type; -} - -export function migratePromptFileToSkill(promptFile: IPromptPath, content: string, skillNameOverride?: string): IMigratedPromptFile { +export function migratePromptFileToSkill(promptFile: MigratableConfiguration, content: string, skillNameOverride?: string): IMigratedPromptFile { const parser = new PromptFileParser(); const parsed = parser.parse(promptFile.uri, content); const friendlyName = promptFile.name?.trim() || parsed.header?.name?.trim() || getCleanPromptName(promptFile.uri); @@ -102,7 +99,7 @@ function formatMigratedHeaderValue(value: string, sourceAttribute: IHeaderAttrib } export async function migrateCustomizations( - customizations: readonly IPromptPath[], + customizations: readonly MigratableConfiguration[], targetFolders: CustomizationMigrationTargetFolders, fileService: IFileService, onMigrationError?: (error: Error) => void, @@ -115,7 +112,7 @@ export async function migrateCustomizations( const migratedCustomizations: IMigratedCustomization[] = []; let migratedCount = 0; const deleteOriginalFiles = options?.deleteOriginalFiles ?? true; - const customizationsBySource = new ResourceMap(); + const customizationsBySource = new ResourceMap(); for (const customization of customizations) { const sourceCustomizations = customizationsBySource.get(customization.uri) ?? []; @@ -212,7 +209,7 @@ function getOrCreateReservedNames(folder: URI, reservedNames: Map, fileService: IFileService, ): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts index 261083fe00db4f..b43e2e9fc22fe1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts @@ -5,8 +5,9 @@ import { localize } from '../../../../../nls.js'; import { ChatConfiguration } from '../../common/constants.js'; -import { PromptFileSource, PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IPromptPath, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { isPromptFileMigrationCandidate, isUserDataMigrationCandidate, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; export const enum CustomizationMigrationCategoryId { PromptFiles = 'promptFiles', @@ -16,7 +17,7 @@ export const enum CustomizationMigrationCategoryId { export interface ICustomizationMigrationGroup { readonly key: string; readonly label: string; - readonly customizations: readonly IPromptPath[]; + readonly customizations: readonly MigratableConfiguration[]; } export interface ICustomizationMigrationConfirmation { @@ -56,14 +57,14 @@ export interface ICustomizationMigrationCategory { readonly migrateButtonTooltip: string; readonly backLabel: string; readonly noFilesMigratedMessage: string; - isCandidate(customization: IPromptPath): boolean; - group(customizations: readonly IPromptPath[]): readonly ICustomizationMigrationGroup[]; + isCandidate(customization: MigratableConfiguration): boolean; + group(customizations: readonly MigratableConfiguration[]): readonly ICustomizationMigrationGroup[]; getShortcutAriaLabel(count: number): string; - getCardDescription(customizations: readonly IPromptPath[], harnessLabel: string): string; - getPageDescription(customizations: readonly IPromptPath[], harnessLabel: string): string; + getCardDescription(customizations: readonly MigratableConfiguration[], harnessLabel: string): string; + getPageDescription(customizations: readonly MigratableConfiguration[], harnessLabel: string): string; /** When present, replaces the page description with a prominent banner. */ - getBanner?(customizations: readonly IPromptPath[], harnessLabel: string, destinationLabel?: string): ICustomizationMigrationBanner; - getConfirmation(customizations: readonly IPromptPath[], harnessLabel: string, destinationLabel?: string): ICustomizationMigrationConfirmation; + getBanner?(customizations: readonly MigratableConfiguration[], harnessLabel: string, destinationLabel?: string): ICustomizationMigrationBanner; + getConfirmation(customizations: readonly MigratableConfiguration[], harnessLabel: string, destinationLabel?: string): ICustomizationMigrationConfirmation; getMigratedMessage(migratedCount: number): string; getMigratedWithReviewMessage?(migratedCount: number, unsupportedHeaderKeys: string): string; getFailedMessage(failedFileNames: readonly string[], hiddenFileCount: number): string; @@ -93,10 +94,7 @@ const promptFilesMigrationCategory: ICustomizationMigrationCategory = { backLabel: localize('backToPromptMigration', "Back to Migrate Prompt Files"), noFilesMigratedMessage: localize('promptMigrationNoFilesConverted', "No prompt files were converted."), - isCandidate(customization) { - return customization.type === PromptsType.prompt - && (customization.storage === PromptsStorage.local || customization.storage === PromptsStorage.user); - }, + isCandidate: isPromptFileMigrationCandidate, group(customizations) { return [ @@ -233,10 +231,7 @@ const userDataMigrationCategory: ICustomizationMigrationCategory = { backLabel: localize('backToUserDataMigration', "Back to Migrate User Data Customizations"), noFilesMigratedMessage: localize('userDataMigrationNoFilesMigrated', "No user data customizations were migrated."), - isCandidate(customization) { - return customization.source === PromptFileSource.UserData - && (customization.type === PromptsType.agent || customization.type === PromptsType.instructions); - }, + isCandidate: isUserDataMigrationCandidate, group(customizations) { return [ @@ -413,13 +408,13 @@ export function getCustomizationMigrationSourceTypes(categories: readonly ICusto return Array.from(new Set(categories.flatMap(category => category.sourceTypes))); } -function countPromptStorages(customizations: readonly IPromptPath[]): { workspaceCount: number; userCount: number; totalCount: number } { +function countPromptStorages(customizations: readonly MigratableConfiguration[]): { workspaceCount: number; userCount: number; totalCount: number } { const workspaceCount = customizations.filter(customization => customization.storage === PromptsStorage.local).length; const userCount = customizations.filter(customization => customization.storage === PromptsStorage.user).length; return { workspaceCount, userCount, totalCount: workspaceCount + userCount }; } -function countUserDataTypes(customizations: readonly IPromptPath[]): { agentCount: number; instructionsCount: number; totalCount: number } { +function countUserDataTypes(customizations: readonly MigratableConfiguration[]): { agentCount: number; instructionsCount: number; totalCount: number } { const agentCount = customizations.filter(customization => customization.type === PromptsType.agent).length; const instructionsCount = customizations.filter(customization => customization.type === PromptsType.instructions).length; return { agentCount, instructionsCount, totalCount: agentCount + instructionsCount }; diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index b55fe8bc737475..4d2729d64eb192 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -77,6 +77,8 @@ import { PromptsConfig, isTildePath } from '../common/promptSyntax/config/config import { AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, CLAUDE_AGENTS_SOURCE_FOLDER, COPILOT_USER_AGENTS_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS, DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS, DEFAULT_SKILL_SOURCE_FOLDERS, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, SKILL_FILENAME } from '../common/promptSyntax/config/promptFileLocations.js'; import { HOOK_SCHEMA_URI, hookFileSchema } from '../common/promptSyntax/hookSchema.js'; import { AGENT_DOCUMENTATION_URL, AgentHostAgentDebugLogEnabledSettingId, AgentHostAgentDebugLogMaxEventsSettingId, HOOK_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, PromptFileSource, PromptsType, SKILL_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; +import { ICustomizationMigrationService } from '../common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationService } from '../common/promptSyntax/service/customizationMigrationServiceImpl.js'; import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js'; @@ -202,6 +204,7 @@ import { ChatPetAchievementsAccessibilityHelp, ChatPetContextContribution, ChatP import { ChatPetService, IChatPetService } from './chatPetService.js'; import { ChatPetWidgetService, IChatPetWidgetService } from './widget/chatPetWidgetService.js'; import { ChatPromoNotificationContribution } from './chatPromoNotification.js'; +import { ChatExpNotificationContribution } from './expNotification/chatExpNotificationContribution.js'; import { ChatQuotaNotificationContribution } from './chatQuotaNotification.js'; import { ChatRepoInfoContribution } from './chatRepoInfo.js'; import { ChatSetupContribution, ChatTeardownContribution } from './chatSetup/chatSetupContributions.js'; @@ -426,9 +429,12 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.MigrateLegacyCopilotCliSessions]: { type: 'boolean', - markdownDescription: nls.localize('chat.agentSessions.migrateLegacyCopilotCli', "Controls whether legacy extension host Copilot CLI chat sessions are migrated in place to the Agent host when opened, so their history becomes editable. When disabled, legacy sessions open as before."), + markdownDescription: nls.localize('chat.agentSessions.migrateLegacyCopilotCli', "Controls whether legacy extension host Copilot CLI chat sessions are migrated in place to the Agent host when opened, so their history becomes editable. When disabled, legacy sessions open as before.\n\nChanging this setting requires a restart to take effect."), default: false, tags: ['experimental'], + // Migration is a global, one-time behavior over the shared agent host, so it is a + // single value for all windows rather than a per-window preference. + scope: ConfigurationScope.APPLICATION, experiment: { mode: 'startup' }, @@ -681,7 +687,7 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.PermissionsSandboxToggleEnabled]: { type: 'boolean', - default: false, + default: true, markdownDescription: nls.localize('chat.experimental.permissionsSandboxToggle.enabled', "Controls whether the permissions picker shows a \"Sandboxing for terminal\" toggle. Local sessions show it on the Default permissions option; Copilot Agent Host sessions show it as a separate setting that applies to every permission mode. For Copilot SDK sessions using the built-in shell tool, the toggle reflects and updates `#chat.agentHost.sdkSandbox.enabled#` or `#chat.agentHost.sdkSandbox.enabledWindows#`."), tags: ['experimental'], experiment: { @@ -2462,6 +2468,11 @@ function migrateChatDefaultConfiguration(value: unknown): Record(Extensions.ConfigurationMigration).registerConfigurationMigrations([ { key: 'chat.agentSessions.defaultConfiguration', @@ -2567,12 +2578,23 @@ Registry.as(Extensions.ConfigurationMigration). // Never clobber an explicitly configured new key (e.g. after settings // sync brought both keys across versions). if (accessor(settingId) === undefined) { - pairs.push([settingId, { value }]); + pairs.push([settingId, { value: settingId === AgentMergeSettingId.MergePullRequest ? migrateAgentMergeMergePullRequest(value) : value }]); } return pairs; } }; }), + { + // `chat.agentMerge.mergePullRequest` became a three-way enum. An explicit + // `true` must keep merging rather than fall back to the `never` default. + key: AgentMergeSettingId.MergePullRequest, + migrateFn: (value: unknown): ConfigurationKeyValuePairs => { + if (typeof value !== 'boolean') { + return []; + } + return [[AgentMergeSettingId.MergePullRequest, { value: migrateAgentMergeMergePullRequest(value) }]]; + } + }, { // The on-device dictation runtime moved to Foundry Local; the old // transformers.js/onnxruntime model IDs no longer resolve and would fail @@ -3101,6 +3123,7 @@ registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingSta registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatQuotaNotificationContribution.ID, ChatQuotaNotificationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatPromoNotificationContribution.ID, ChatPromoNotificationContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatExpNotificationContribution.ID, ChatExpNotificationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(HasByokModelsContribution.ID, HasByokModelsContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); @@ -3215,6 +3238,7 @@ registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, Instant registerSingleton(IAgentNetworkFilterService, AgentNetworkFilterService, InstantiationType.Delayed); registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); +registerSingleton(ICustomizationMigrationService, CustomizationMigrationService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts b/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts index ba88bfc251564b..79542a26b4f9ef 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts @@ -5,11 +5,11 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { localChatSessionType } from '../common/chatSessionsService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js'; -import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotificationContext, IChatInputNotificationService, IChatInputNotificationSwitchToModelAction, matchesModelIdentifier } from './widget/input/chatInputNotificationService.js'; +import { addDismissedNotificationId, ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotificationContext, IChatInputNotificationService, IChatInputNotificationSwitchToModelAction, matchesModelIdentifier, readDismissedNotificationIds } from './widget/input/chatInputNotificationService.js'; const PROMO_NOTIFICATION_ID = 'copilot.promoNotification'; const DISMISSED_PROMOS_STORAGE_KEY = 'chat.dismissedPromoIds'; @@ -22,7 +22,8 @@ function isPromoVisible(context: IChatInputNotificationContext): boolean { * Surfaces a model's promo as a chat input notification, scoped to the harness * (chat session type) of the model that carries it. Promos only render where a * model switch is still plausible: persistent chat surfaces whose session has - * not started yet. Dismissals are persisted by promo id in application storage, + * not started yet, and only when the promo is banner-eligible (`showBanner` is + * not `false`). Dismissals are persisted by promo id in application storage, * so they survive reloads and apply to every open window. */ export class ChatPromoNotificationContribution extends Disposable implements IWorkbenchContribution { @@ -40,7 +41,7 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo this._register(this._chatInputNotificationService.onDidDismiss(id => { const promoId = this._shownNotifications.get(id)?.promoId; if (promoId) { - this._persistDismissedPromo(promoId); + addDismissedNotificationId(this._storageService, DISMISSED_PROMOS_STORAGE_KEY, promoId); this._update(); } })); @@ -56,15 +57,16 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo private readonly _shownNotifications = new Map(); private _update(): void { - const dismissed = this._getDismissedPromoIds(); + const dismissed = readDismissedNotificationIds(this._storageService, DISMISSED_PROMOS_STORAGE_KEY); const modelIds = this._languageModelsService.getLanguageModelIds(); // Bucket one non-dismissed promo per harness (a model's `targetChatSessionType`, // or the local pool when unset), preferring a discounted promo over a message-only one. + // Promos that opt out of the banner (`showBanner: false`) stay in the model picker only. const promoByHarness = new Map(); for (const id of modelIds) { const meta = this._languageModelsService.lookupLanguageModel(id); - if (!meta || !ILanguageModelChatMetadata.hasPromoMessage(meta) || dismissed.has(meta.promo.id)) { + if (!meta || !ILanguageModelChatMetadata.hasPromoBanner(meta) || dismissed.has(meta.promo.id)) { continue; } const harness = meta.targetChatSessionType ?? localChatSessionType; @@ -119,33 +121,5 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo } } - private _persistDismissedPromo(promoId: string): void { - const dismissed = this._getDismissedPromoIds(); - if (dismissed.has(promoId)) { - return; - } - dismissed.add(promoId); - this._storageService.store( - DISMISSED_PROMOS_STORAGE_KEY, - JSON.stringify([...dismissed]), - StorageScope.APPLICATION, - StorageTarget.USER, - ); - } - private _getDismissedPromoIds(): Set { - const raw = this._storageService.get(DISMISSED_PROMOS_STORAGE_KEY, StorageScope.APPLICATION); - if (!raw) { - return new Set(); - } - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return new Set(parsed.filter((v): v is string => typeof v === 'string')); - } - } catch { - // ignore malformed data - } - return new Set(); - } } diff --git a/src/vs/workbench/contrib/chat/browser/expNotification/chatExpNotificationConfig.ts b/src/vs/workbench/contrib/chat/browser/expNotification/chatExpNotificationConfig.ts new file mode 100644 index 00000000000000..b3e41ef0ccf662 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/expNotification/chatExpNotificationConfig.ts @@ -0,0 +1,293 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Contract for chat notifications delivered as an experiment treatment. + * + * A notification is fully described by a versioned JSON payload, so its words, its targeting and + * its buttons can be authored or retired without shipping code. The payload is a serializable + * spelling of {@link IChatInputNotification}, which it builds directly. + */ + +import { stableStringify } from '../../../../../base/common/objects.js'; +import { expandModelMatchCandidates, ID_PATTERN, isObject, MAX_ID_LENGTH, modelSelectorAliases, normalizeSelector, parseExpPayloadEnvelope, readSelectorList, readText } from '../../common/expPayload.js'; +import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationAction } from '../widget/input/chatInputNotificationService.js'; + +/** Payload versions this build understands. Bump when making a breaking shape change. */ +export const CHAT_EXP_NOTIFICATION_VERSION = 1; + +const MAX_NOTIFICATIONS = 8; +const MAX_ACTIONS = 3; +const MAX_CONFIG_KEYS = 8; + +const MATCH_KEYS = ['sessionTypes', 'selectedModels', 'excludeSelectedModels']; +const MAX_TITLE_LENGTH = 120; +const MAX_DESCRIPTION_LENGTH = 500; +const MAX_LABEL_LENGTH = 40; + +// A Map has no prototype keys, so a payload cannot reach `Object.prototype` members through it. +const SEVERITIES = new Map([ + ['info', ChatInputNotificationSeverity.Info], + ['warning', ChatInputNotificationSeverity.Warning], + ['error', ChatInputNotificationSeverity.Error], +]); + +/** + * Which chats a notification attaches to. An omitted or empty selector list means any. + * + * Both lists are matched against a set of candidates rather than one string, so a selector can + * name a session type or the harness behind it, and a model by id, family, name or vendor. + */ +export interface IChatExpNotificationMatch { + readonly sessionTypes: readonly string[]; + readonly selectedModels: readonly string[]; + /** Models that suppress the notification, so a nudge can skip users already on its target. */ + readonly excludeSelectedModels: readonly string[]; +} + +/** A parsed notification, ready to hand to the notification service once `when` is bound. */ +export type ChatExpNotification = Omit & { + readonly match: IChatExpNotificationMatch; + /** The entry's source text, used to tell a changed notification from an unchanged one. */ + readonly signature: string; +}; + +/** An action always carries its payload id, so duplicate ids can be reported. */ +type ChatExpNotificationAction = IChatInputNotificationAction & { telemetryActionId: string }; + +export type ChatExpNotificationParseResult = + | { readonly notifications: readonly ChatExpNotification[]; readonly error?: undefined } + | { readonly notifications?: undefined; readonly error: string }; + +/** Describes the chat a notification is matched against. The caller resolves the harness. */ +export interface IChatExpNotificationMatchContext { + readonly sessionType: string | undefined; + /** Agent host provider id, or `undefined` for sessions with no agent host. */ + readonly harness: string | undefined; + readonly selectedModelId: string | undefined; + /** Other identifiers for the selected model, such as its id, family, name and vendor. */ + readonly selectedModelAliases: readonly string[] | undefined; +} + +/** + * Parses and validates a notification payload. Never throws, and rejects a bad payload whole + * rather than in part, so a malformed entry cannot quietly change who sees what. + */ +export function parseChatExpNotifications(raw: string | undefined): ChatExpNotificationParseResult { + const parsed = parseExpPayloadEnvelope(raw, CHAT_EXP_NOTIFICATION_VERSION); + if (typeof parsed === 'string') { + return { error: parsed }; + } + // An empty list is how a running experiment retires its notification without being removed. + if (!Array.isArray(parsed.notifications)) { + return { error: 'notifications must be an array' }; + } + if (parsed.notifications.length > MAX_NOTIFICATIONS) { + return { error: `notifications exceeds ${MAX_NOTIFICATIONS} entries` }; + } + + const notifications: ChatExpNotification[] = []; + const seen = new Set(); + for (let i = 0; i < parsed.notifications.length; i++) { + const notification = readNotification(parsed.notifications[i], `notifications[${i}]`); + if (typeof notification === 'string') { + return { error: notification }; + } + if (seen.has(notification.id)) { + return { error: `notifications[${i}].id "${notification.id}" is duplicated` }; + } + seen.add(notification.id); + notifications.push(notification); + } + + return { notifications }; +} + +function readNotification(raw: unknown, path: string): ChatExpNotification | string { + if (!isObject(raw)) { + return `${path} must be an object`; + } + + const id = readText(raw.id, MAX_ID_LENGTH, ID_PATTERN); + if (!id) { + return `${path}.id is missing or malformed`; + } + + const message = readText(raw.title, MAX_TITLE_LENGTH); + if (!message) { + return `${path}.title is missing or too long`; + } + + const description = raw.description === undefined ? undefined : readText(raw.description, MAX_DESCRIPTION_LENGTH); + if (raw.description !== undefined && !description) { + return `${path}.description is empty or too long`; + } + + const severity = raw.severity === undefined ? ChatInputNotificationSeverity.Info : SEVERITIES.get(raw.severity as string); + if (severity === undefined) { + return `${path}.severity must be "info", "warning" or "error"`; + } + + const match = readMatch(raw.match, `${path}.match`); + if (typeof match === 'string') { + return match; + } + + const actions = readActions(raw.actions, `${path}.actions`); + if (typeof actions === 'string') { + return actions; + } + + const dismissible = readBoolean(raw.dismissible, true); + const autoDismissOnMessage = readBoolean(raw.autoDismissOnMessage, false); + if (dismissible === undefined || autoDismissOnMessage === undefined) { + return `${path}.dismissible and ${path}.autoDismissOnMessage must be booleans`; + } + + // A notice with no dismiss, no action and no auto-dismiss can never be cleared, and would + // outrank the permission and setup prompts a user needs to get on with their work. + if (!dismissible && !autoDismissOnMessage && !actions.length) { + return `${path} must be dismissible, auto-dismiss on message, or offer an action`; + } + + return { + id, + telemetryId: id, + severity, + message, + description, + match, + actions, + dismissible, + autoDismissOnMessage, + signature: stableStringify(raw), + }; +} + +function readBoolean(raw: unknown, fallback: boolean): boolean | undefined { + return raw === undefined ? fallback : typeof raw === 'boolean' ? raw : undefined; +} + +function readMatch(raw: unknown, path: string): IChatExpNotificationMatch | string { + if (raw !== undefined && !isObject(raw)) { + return `${path} must be an object`; + } + const source = isObject(raw) ? raw : {}; + + // A misspelled predicate would otherwise be ignored, silently widening the audience to + // everyone the remaining predicates allow. + const unknown = Object.keys(source).find(key => !MATCH_KEYS.includes(key)); + if (unknown) { + return `${path} has unknown key ${JSON.stringify(unknown)}`; + } + + const sessionTypes = readSelectorList(source.sessionTypes, `${path}.sessionTypes`); + if (typeof sessionTypes === 'string') { + return sessionTypes; + } + const selectedModels = readSelectorList(source.selectedModels, `${path}.selectedModels`); + if (typeof selectedModels === 'string') { + return selectedModels; + } + const excludeSelectedModels = readSelectorList(source.excludeSelectedModels, `${path}.excludeSelectedModels`); + if (typeof excludeSelectedModels === 'string') { + return excludeSelectedModels; + } + if (!sessionTypes.length && !selectedModels.length && !excludeSelectedModels.length) { + return `${path} must narrow at least one dimension`; + } + + return { sessionTypes, selectedModels, excludeSelectedModels }; +} + +function readActions(raw: unknown, path: string): ChatExpNotificationAction[] | string { + if (raw === undefined) { + return []; + } + if (!Array.isArray(raw)) { + return `${path} must be an array`; + } + if (raw.length > MAX_ACTIONS) { + return `${path} exceeds ${MAX_ACTIONS} entries`; + } + + const actions: ChatExpNotificationAction[] = []; + const seen = new Set(); + for (let i = 0; i < raw.length; i++) { + const action = readAction(raw[i], `${path}[${i}]`); + if (typeof action === 'string') { + return action; + } + if (seen.has(action.telemetryActionId)) { + return `${path}[${i}].id "${action.telemetryActionId}" is duplicated`; + } + seen.add(action.telemetryActionId); + actions.push(action); + } + return actions; +} + +function readAction(raw: unknown, path: string): ChatExpNotificationAction | string { + if (!isObject(raw)) { + return `${path} must be an object`; + } + + const telemetryActionId = readText(raw.id, MAX_ID_LENGTH, ID_PATTERN); + if (!telemetryActionId) { + return `${path}.id is missing or malformed`; + } + + const label = readText(raw.label, MAX_LABEL_LENGTH); + if (!label) { + return `${path}.label is missing or too long`; + } + + const base = { label, telemetryActionId }; + switch (raw.kind) { + case ChatInputNotificationActionKind.OpenModelPicker: + return { ...base, kind: ChatInputNotificationActionKind.OpenModelPicker }; + + case ChatInputNotificationActionKind.Command: { + if (typeof raw.commandId !== 'string' || !raw.commandId.trim()) { + return `${path}.commandId is missing`; + } + if (raw.args !== undefined && !Array.isArray(raw.args)) { + return `${path}.args must be an array`; + } + return { ...base, kind: ChatInputNotificationActionKind.Command, commandId: raw.commandId.trim(), commandArgs: raw.args ?? [] }; + } + + case ChatInputNotificationActionKind.SwitchToModel: { + if (typeof raw.model !== 'string' || !raw.model.trim()) { + return `${path}.model is missing`; + } + if (raw.config !== undefined && (!isObject(raw.config) || Object.keys(raw.config).length > MAX_CONFIG_KEYS)) { + return `${path}.config must be an object of at most ${MAX_CONFIG_KEYS} keys`; + } + // Normalized like every other selector, so a display name such as "Claude Sonnet 4.5" matches. + const target = normalizeSelector(raw.model); + return { + ...base, + kind: ChatInputNotificationActionKind.SwitchToModel, + matchesModel: model => expandModelMatchCandidates(model.identifier, modelSelectorAliases(model.metadata)).has(target), + config: raw.config, + requireUniqueModel: true, + }; + } + } + + return `${path}.kind must be "${ChatInputNotificationActionKind.Command}", "${ChatInputNotificationActionKind.OpenModelPicker}" or "${ChatInputNotificationActionKind.SwitchToModel}"`; +} + +/** Whether the chat described by `context` should be shown a notification matching `match`. */ +export function matchesChatExpNotification(match: IChatExpNotificationMatch, context: IChatExpNotificationMatchContext): boolean { + const { sessionTypes, selectedModels, excludeSelectedModels } = match; + const sessionCandidates = [context.sessionType, context.harness].map(value => value && normalizeSelector(value)); + const modelCandidates = expandModelMatchCandidates(context.selectedModelId, context.selectedModelAliases); + + return (!sessionTypes.length || sessionTypes.some(selector => sessionCandidates.includes(selector))) + && (!selectedModels.length || selectedModels.some(selector => modelCandidates.has(selector))) + && !excludeSelectedModels.some(selector => modelCandidates.has(selector)); +} diff --git a/src/vs/workbench/contrib/chat/browser/expNotification/chatExpNotificationContribution.ts b/src/vs/workbench/contrib/chat/browser/expNotification/chatExpNotificationContribution.ts new file mode 100644 index 00000000000000..84eaead1060231 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/expNotification/chatExpNotificationContribution.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ChatAIDisabledSettingId } from '../../../../../platform/chat/common/chatSettings.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IChatSessionsService } from '../../common/chatSessionsService.js'; +import { modelSelectorAliases } from '../../common/expPayload.js'; +import { addDismissedNotificationId, IChatInputNotificationContext, IChatInputNotificationService, readDismissedNotificationIds } from '../widget/input/chatInputNotificationService.js'; +import { ChatExpNotification, IChatExpNotificationMatch, matchesChatExpNotification, parseChatExpNotifications } from './chatExpNotificationConfig.js'; + +export const CHAT_EXP_NOTIFICATION_TREATMENT = 'chatNotifications'; + +const DISMISSED_STORAGE_KEY = 'chat.dismissedExpNotificationIds'; + +/** Payload ids share a global map with built-in notifications, so they are namespaced. */ +const ID_PREFIX = 'chat.expNotification.'; + +/** + * Renders chat input notifications described by an experiment treatment. + * + * The treatment carries the words, the targeting and the buttons, so a notification can be + * authored or retired without shipping code. Removing the assignment is the kill switch. + */ +export class ChatExpNotificationContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatExpNotification'; + + /** Registered payload ids, each mapped to the entry text that produced it. */ + private readonly _registered = new Map(); + private _notifications: readonly ChatExpNotification[] = []; + /** Increments on every refresh so a slow in-flight resolution cannot overwrite a newer one. */ + private _generation = 0; + + constructor( + @IWorkbenchAssignmentService private readonly _assignmentService: IWorkbenchAssignmentService, + @IChatInputNotificationService private readonly _notificationService: IChatInputNotificationService, + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, + @IStorageService private readonly _storageService: IStorageService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + void this._resolve(); + this._register(this._assignmentService.onDidRefetchAssignments(() => void this._resolve())); + this._register(this._notificationService.onDidDismiss(id => this._handleDismissed(id))); + this._register(this._storageService.onDidChangeValue(StorageScope.APPLICATION, DISMISSED_STORAGE_KEY, this._store)(() => this._update())); + // `when` reads both gates, so mounted widgets must be told to re-evaluate when either flips. + this._register(this._entitlementService.onDidChangeSentiment(() => this._notificationService.refresh())); + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatAIDisabledSettingId)) { + this._notificationService.refresh(); + } + })); + } + + private async _resolve(): Promise { + const generation = ++this._generation; + let payload: string | undefined; + try { + payload = await this._assignmentService.getTreatment(CHAT_EXP_NOTIFICATION_TREATMENT); + } catch (err) { + // A failed lookup is not a retired assignment, so leave what is showing in place. + this._logService.trace(`[chatExpNotification] failed to resolve treatment: ${err}`); + return; + } + + if (generation !== this._generation) { + return; + } + + // An absent treatment is how an assignment is removed, so it clears what is showing. + // Only a thrown lookup is treated as transient. + const result = payload === undefined ? undefined : parseChatExpNotifications(payload); + if (result?.error) { + this._logService.warn(`[chatExpNotification] ignoring invalid payload: ${result.error}`); + } + this._notifications = result?.notifications ?? []; + + this._update(); + } + + private _update(): void { + const dismissed = readDismissedNotificationIds(this._storageService, DISMISSED_STORAGE_KEY); + const active = this._notifications.filter(notification => !dismissed.has(notification.id)); + + for (const id of [...this._registered.keys()]) { + if (!active.some(notification => notification.id === id)) { + this._notificationService.deleteNotification(ID_PREFIX + id); + this._registered.delete(id); + } + } + + for (const { match, signature, ...notification } of active) { + // Persisted dismissals are already filtered out above, so what this protects is the + // in-memory `autoDismissOnMessage` state that setting a notification would clear. + if (this._registered.get(notification.id) === signature) { + continue; + } + this._registered.set(notification.id, signature); + this._notificationService.setNotification({ + ...notification, + id: ID_PREFIX + notification.id, + when: context => this._matches(match, context), + }); + } + } + + private _matches(match: IChatExpNotificationMatch, context: IChatInputNotificationContext): boolean { + // Checked here rather than by unregistering, so turning AI features off and on again + // does not discard a dismissal the service only holds in memory. + if (this._configurationService.getValue(ChatAIDisabledSettingId) === true + || this._entitlementService.sentiment.hidden) { + return false; + } + const model = context.modelState.currentModel; + return matchesChatExpNotification(match, { + sessionType: context.sessionType, + harness: context.sessionType ? this._chatSessionsService.getChatSessionContribution(context.sessionType)?.agentHostProviderId : undefined, + selectedModelId: model?.identifier, + selectedModelAliases: model && modelSelectorAliases(model.metadata), + }); + } + + private _handleDismissed(id: string): void { + if (!id.startsWith(ID_PREFIX)) { + return; + } + addDismissedNotificationId(this._storageService, DISMISSED_STORAGE_KEY, id.slice(ID_PREFIX.length)); + this._update(); + } + +} diff --git a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyService.ts b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyService.ts index b7d800da4dd5d1..571be31ba13644 100644 --- a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyService.ts +++ b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyService.ts @@ -14,7 +14,8 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { escapeModelIdForTelemetry, ITelemetryService, TelemetryLevel } from '../../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; -import { ChatModelFeedbackSurveyStepKind, expandModelMatchCandidates, IChatModelFeedbackSurveyConfig, matchesChatModelFeedbackSurvey, parseChatModelFeedbackSurveyConfig } from '../../common/feedbackSurvey/chatModelFeedbackSurveyConfig.js'; +import { ChatModelFeedbackSurveyStepKind, IChatModelFeedbackSurveyConfig, matchesChatModelFeedbackSurvey, parseChatModelFeedbackSurveyConfig } from '../../common/feedbackSurvey/chatModelFeedbackSurveyConfig.js'; +import { expandModelMatchCandidates, modelSelectorAliases } from '../../common/expPayload.js'; import { CHAT_MODEL_FEEDBACK_SURVEY_TELEMETRY_COMMAND_ID, ChatModelFeedbackSurveyEventKind, IChatModelFeedbackSurveyTelemetryEvent } from '../../common/feedbackSurvey/chatModelFeedbackSurveyTelemetry.js'; import { ILanguageModelsService } from '../../common/languageModels.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -317,7 +318,7 @@ export class ChatModelFeedbackSurveyService extends Disposable implements IChatM /** The other identifiers a selector may name a model by. */ private getModelAliases(modelId: string | undefined): string[] | undefined { const metadata = modelId ? this.languageModelsService.lookupLanguageModel(modelId) : undefined; - return metadata ? [metadata.id, metadata.family, metadata.name, metadata.vendor] : undefined; + return metadata ? modelSelectorAliases(metadata) : undefined; } answerChoice(response: IChatResponseViewModel, stepId: string, optionId: string): void { diff --git a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts index 6edfc77699f27b..a841497a924b3d 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts @@ -9,21 +9,23 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; import { Position } from '../../../../../editor/common/core/position.js'; -import { TextEdit } from '../../../../../editor/common/languages.js'; -import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; +import { TextEdit, WorkspaceEdit } from '../../../../../editor/common/languages.js'; +import { IBulkEditService, ResourceFileEdit, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { rename } from '../../../../../editor/contrib/rename/browser/rename.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { ChatModel } from '../../common/model/chatModel.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../../common/tools/languageModelToolsService.js'; import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; -import { errorResult, findLineNumber, findSymbolColumn, ISymbolToolInput, resolveSymbolToolFileUri } from './toolHelpers.js'; +import { errorResult, findLineNumber, findSymbolColumn, isSymbolToolResourceInScope, ISymbolToolInput, resolveSymbolToolFileUri } from './toolHelpers.js'; export const RenameToolId = 'vscode_renameSymbol'; @@ -36,8 +38,8 @@ const BaseModelDescription = `Rename a code symbol across the workspace using th Input: - "symbol": The exact current name of the symbol to rename. - "newName": The new name for the symbol. -- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file where the symbol appears. Provide either "uri" or "filePath". -- "filePath": A workspace-relative file path (e.g. "src/utils/helpers.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file within the current workspace or working directory where the symbol appears. Provide either "uri" or "filePath". +- "filePath": A file path relative to the current working directory when one is set, otherwise the first workspace folder (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath". - "lineContent": A substring of the line of code where the symbol appears. This is used to locate the exact position in the file. Must be the actual text from the file - do NOT fabricate it. IMPORTANT: The file and line do NOT need to be the definition of the symbol. Any occurrence works - a usage, an import, a call site, etc. You can pick whichever occurrence is most convenient. @@ -59,6 +61,7 @@ export class RenameTool extends Disposable implements IToolImpl { @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, @ITextModelService private readonly _textModelService: ITextModelService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, @IChatService private readonly _chatService: IChatService, @IBulkEditService private readonly _bulkEditService: IBulkEditService, ) { @@ -95,11 +98,11 @@ export class RenameTool extends Disposable implements IToolImpl { }, uri: { type: 'string', - description: 'A full URI of a file where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".' + description: 'A full URI of a file within the current workspace or working directory where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".' }, filePath: { type: 'string', - description: 'A workspace-relative file path where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".' + description: 'A file path relative to the current working directory when one is set, otherwise the first workspace folder, where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".' }, lineContent: { type: 'string', @@ -122,9 +125,9 @@ export class RenameTool extends Disposable implements IToolImpl { const input = invocation.parameters as IRenameToolInput; // --- resolve URI --- - const uri = resolveSymbolToolFileUri(input, this._workspaceContextService, invocation.context?.workingDirectory); + const uri = resolveSymbolToolFileUri(input, this._workspaceContextService, this._uriIdentityService, invocation.context?.workingDirectory); if (!uri) { - return errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); + return errorResult(localize('tool.rename.invalidFile', 'Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify a file within the current workspace or working directory.')); } // --- open text model --- @@ -162,12 +165,20 @@ export class RenameTool extends Disposable implements IToolImpl { return errorResult(`Rename produced no edits.`); } + if (this._containsOutOfScopeEdit(renameResult.edits, invocation.context?.workingDirectory)) { + return errorResult(localize('tool.rename.outOfScopeEdit', 'Rename was not applied because it would modify files outside the current workspace or working directory.')); + } + // --- apply edits via chat response stream --- if (invocation.context) { const chatModel = this._chatService.getSession(invocation.context.sessionResource) as ChatModel | undefined; const request = chatModel?.getRequests().at(-1); if (chatModel && request) { + if (renameResult.edits.some(edit => !ResourceTextEdit.is(edit))) { + return errorResult(localize('tool.rename.unsupportedEdit', 'Rename was not applied because it produced edits that cannot be reviewed in chat.')); + } + // Group text edits by URI const editsByUri = new ResourceMap(); for (const edit of renameResult.edits) { @@ -224,6 +235,16 @@ export class RenameTool extends Disposable implements IToolImpl { return result; } + private _containsOutOfScopeEdit(edits: WorkspaceEdit['edits'], workingDirectory: URI | undefined): boolean { + return edits.some(edit => { + if (ResourceFileEdit.is(edit)) { + return !!edit.oldResource && !isSymbolToolResourceInScope(edit.oldResource, this._workspaceContextService, this._uriIdentityService, workingDirectory) + || !!edit.newResource && !isSymbolToolResourceInScope(edit.newResource, this._workspaceContextService, this._uriIdentityService, workingDirectory); + } + return !isSymbolToolResourceInScope(edit.resource, this._workspaceContextService, this._uriIdentityService, workingDirectory); + }); + } + } diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts b/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts index 3f56ddc96e07d0..4512b12a24fee7 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts @@ -8,6 +8,7 @@ import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import type { ITerminalSandboxPrecheckInputs } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import type { IChatWidgetService } from '../chat.js'; import type { IChatService } from '../../common/chatService/chatService.js'; @@ -24,21 +25,29 @@ export interface ISymbolToolInput { } /** - * Resolves a URI from tool input. Accepts either a full URI string or a - * workspace-relative file path. When a {@link workingDirectory} is provided - * (agents window), relative paths are resolved against it first. - * - * Relative paths that escape the resolution base directory via parent-directory - * segments (e.g. `../outside.ts`) are rejected and `undefined` is returned, so a - * `filePath` cannot traverse out of the working directory boundary. + * Returns whether a resource is within the active working directory, or within + * a workspace folder when no explicit working directory is set. + */ +export function isSymbolToolResourceInScope(resource: URI, workspaceContextService: IWorkspaceContextService, uriIdentityService: IUriIdentityService, workingDirectory?: URI): boolean { + const normalizedResource = uriIdentityService.extUri.normalizePath(resource); + const scopeRoots = workingDirectory ? [workingDirectory] : workspaceContextService.getWorkspace().folders.map(folder => folder.uri); + return scopeRoots.some(root => uriIdentityService.extUri.isEqualOrParent(normalizedResource, uriIdentityService.extUri.normalizePath(root))); +} + +/** + * Resolves an in-scope URI from tool input. Accepts either a full URI string or + * a file path relative to the working directory when one is provided, or to the + * first workspace folder otherwise. */ -export function resolveSymbolToolFileUri(input: ISymbolToolInput, workspaceContextService: IWorkspaceContextService, workingDirectory?: URI): URI | undefined { +export function resolveSymbolToolFileUri(input: ISymbolToolInput, workspaceContextService: IWorkspaceContextService, uriIdentityService: IUriIdentityService, workingDirectory?: URI): URI | undefined { if (input.uri) { - return URI.parse(input.uri); + const uri = uriIdentityService.extUri.normalizePath(URI.parse(input.uri)); + return isSymbolToolResourceInScope(uri, workspaceContextService, uriIdentityService, workingDirectory) ? uri : undefined; } if (input.filePath) { const workingDir = new WorkingDirectory(workspaceContextService, workingDirectory); - return workingDir.resolveRelativePath(input.filePath); + const uri = workingDir.resolveRelativePath(input.filePath); + return uri && isSymbolToolResourceInScope(uri, workspaceContextService, uriIdentityService, workingDirectory) ? uri : undefined; } return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts index ed4f4160ffe7c3..d7ee5065388e67 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -10,7 +10,8 @@ import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { isEqual, relativePath } from '../../../../../base/common/resources.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; import { Position } from '../../../../../editor/common/core/position.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { Location, LocationLink } from '../../../../../editor/common/languages.js'; @@ -20,12 +21,13 @@ import { ITextModelService } from '../../../../../editor/common/services/resolve import { getDefinitionsAtPosition, getImplementationsAtPosition, getReferencesAtPosition } from '../../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ISearchService, QueryType, resultIsMatch } from '../../../../services/search/common/search.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, } from '../../common/tools/languageModelToolsService.js'; import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; -import { errorResult, findLineNumber, findSymbolColumn, ISymbolToolInput, resolveSymbolToolFileUri } from './toolHelpers.js'; +import { errorResult, findLineNumber, findSymbolColumn, isSymbolToolResourceInScope, ISymbolToolInput, resolveSymbolToolFileUri } from './toolHelpers.js'; export const UsagesToolId = 'vscode_listCodeUsages'; @@ -33,8 +35,8 @@ const BaseModelDescription = `Find all usages (references, definitions, and impl Input: - "symbol": The exact name of the symbol to search for (function, class, method, variable, type, etc.). -- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file where the symbol appears. Provide either "uri" or "filePath". -- "filePath": A workspace-relative file path (e.g. "src/utils/helpers.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file within the current workspace or working directory where the symbol appears. Provide either "uri" or "filePath". +- "filePath": A file path relative to the current working directory when one is set, otherwise the first workspace folder (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath". - "lineContent": A substring of the line of code where the symbol appears. This is used to locate the exact position in the file. Must be the actual text from the file - do NOT fabricate it. IMPORTANT: The file and line do NOT need to be the definition of the symbol. Any occurrence works - a usage, an import, a call site, etc. You can pick whichever occurrence is most convenient. @@ -58,6 +60,7 @@ export class UsagesTool extends Disposable implements IToolImpl { @ISearchService private readonly _searchService: ISearchService, @ITextModelService private readonly _textModelService: ITextModelService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); } @@ -88,11 +91,11 @@ export class UsagesTool extends Disposable implements IToolImpl { }, uri: { type: 'string', - description: 'A full URI of a file where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".' + description: 'A full URI of a file within the current workspace or working directory where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".' }, filePath: { type: 'string', - description: 'A workspace-relative file path where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".' + description: 'A file path relative to the current working directory when one is set, otherwise the first workspace folder, where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".' }, lineContent: { type: 'string', @@ -115,9 +118,9 @@ export class UsagesTool extends Disposable implements IToolImpl { const input = invocation.parameters as ISymbolToolInput; // --- resolve URI --- - const uri = resolveSymbolToolFileUri(input, this._workspaceContextService, invocation.context?.workingDirectory); + const uri = resolveSymbolToolFileUri(input, this._workspaceContextService, this._uriIdentityService, invocation.context?.workingDirectory); if (!uri) { - return errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); + return errorResult(localize('tool.usages.invalidFile', 'Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify a file within the current workspace or working directory.')); } // --- open text model --- @@ -145,11 +148,13 @@ export class UsagesTool extends Disposable implements IToolImpl { const position = new Position(lineNumber, column); // --- query references, definitions, implementations in parallel --- - const [definitions, references, implementations] = await Promise.all([ + const [definitions, allReferences, implementations] = await Promise.all([ getDefinitionsAtPosition(this._languageFeaturesService.definitionProvider, model, position, false, token), getReferencesAtPosition(this._languageFeaturesService.referenceProvider, model, position, false, false, token), getImplementationsAtPosition(this._languageFeaturesService.implementationProvider, model, position, false, token), ]); + const workingDirectory = invocation.context?.workingDirectory; + const references = allReferences.filter(ref => isSymbolToolResourceInScope(ref.uri, this._workspaceContextService, this._uriIdentityService, workingDirectory)); if (references.length === 0) { const result = createToolSimpleTextResult(`No usages found for \`${input.symbol}\`.`); @@ -158,7 +163,7 @@ export class UsagesTool extends Disposable implements IToolImpl { } // --- classify and format results with previews --- - const previews = await this._getLinePreviews(input.symbol, references, token); + const previews = await this._getLinePreviews(input.symbol, references, workingDirectory, token); const lines: string[] = []; lines.push(`${references.length} usages of \`${input.symbol}\`:\n`); @@ -192,7 +197,7 @@ export class UsagesTool extends Disposable implements IToolImpl { } } - private async _getLinePreviews(symbol: string, references: LocationLink[], token: CancellationToken): Promise<(string | undefined)[]> { + private async _getLinePreviews(symbol: string, references: LocationLink[], workingDirectory: URI | undefined, token: CancellationToken): Promise<(string | undefined)[]> { const previews: (string | undefined)[] = new Array(references.length); // Build a lookup: (uriString, lineNumber) → index in references array @@ -221,12 +226,12 @@ export class UsagesTool extends Disposable implements IToolImpl { // referenced files. This is backed by ripgrep for file:// URIs. try { // Build includePattern from workspace-relative paths - const folders = this._workspaceContextService.getWorkspace().folders; + const folders = workingDirectory ? [{ uri: workingDirectory }] : this._workspaceContextService.getWorkspace().folders; const relativePaths: string[] = []; for (const uri of needSearch) { - const folder = this._workspaceContextService.getWorkspaceFolder(uri); + const folder = folders.find(folder => isSymbolToolResourceInScope(uri, this._workspaceContextService, this._uriIdentityService, folder.uri)); if (folder) { - const rel = relativePath(folder.uri, uri); + const rel = this._uriIdentityService.extUri.relativePath(folder.uri, uri); if (rel) { relativePaths.push(rel); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index b1c14b252a2eb4..cfc9d39e03db19 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -28,7 +28,7 @@ import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/m import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID, ChatConfiguration } from '../../../common/constants.js'; import { isAgentHostTarget } from '../../../common/chatSessionsService.js'; -import { formatCopilotCredits, IChatHookPart, IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized, isLegacyChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; +import { formatCopilotCreditsLabel, IChatHookPart, IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized, isLegacyChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IChatRendererContent, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IRunSubagentToolInputParams } from '../../../common/tools/builtinTools/runSubagentTool.js'; @@ -385,6 +385,7 @@ export class ChatSubagentContentPart extends ChatThinkingStyleContentPart implem startedAt: data?.kind === 'subagent' ? data.startedAt : undefined, duration: data?.kind === 'subagent' ? data.duration : undefined, isActive: this.isActive, + ...(this.credits ? { credits: this.credits } : {}), ...(this.modelName ? { modelName: this.modelName } : {}), ...(parentModelId ? { parentModelId } : {}), ...(parentModelName ? { parentModelName } : {}), @@ -876,10 +877,7 @@ export class ChatSubagentContentPart extends ChatThinkingStyleContentPart implem parts.push(localize('chat.subagent.modelTooltip', 'Model: {0}', this.modelName)); } if (typeof this.credits === 'number' && this.credits > 0) { - const formatted = formatCopilotCredits(this.credits); - parts.push(formatted === '1' - ? localize('chat.subagent.creditTooltip', '{0} credit', formatted) - : localize('chat.subagent.creditsTooltip', '{0} credits', formatted)); + parts.push(formatCopilotCreditsLabel(this.credits)); } if (parts.length === 0) { @@ -906,6 +904,7 @@ export class ChatSubagentContentPart extends ChatThinkingStyleContentPart implem if (typeof credits === 'number' && credits !== this.credits) { this.credits = credits; this.updateHover(); + this._updateOpenChatToolbarContext(); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts index d70875615ad3ff..75a12af6f94818 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts @@ -29,8 +29,9 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ACTIVE_GROUP } from '../../../../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { formatElapsedTime } from '../../../common/chatProgressFormatting.js'; +import { formatCopilotCreditsLabel } from '../../../common/chatService/chatService.js'; import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID, CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; -import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { AUTO_RAW_MODEL_ID, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatWidgetService } from '../../chat.js'; import { getChatMarkdownRenderOptions } from '../chatContentMarkdownRenderer.js'; import { renderFileWidgets } from './chatInlineAnchorWidget.js'; @@ -49,6 +50,8 @@ export interface IOpenSubagentChatContext { readonly startedAt?: number; readonly duration?: number; readonly modelName?: string; + /** Copilot credits (AIC) this subagent has consumed so far. */ + readonly credits?: number; readonly parentModelId?: string; readonly parentModelName?: string; readonly parentResolvedModelId?: string; @@ -109,14 +112,23 @@ export function getSubagentEditorResource(context: IOpenSubagentChatContext): UR } } +/** + * Whether the subagent's model is worth showing next to the parent's. It is not when + * it merely repeats the parent's, nor when it is "Auto", which names no real model. + * Under an Auto parent a concrete model always shows, since the parent only says "Auto". + */ export function shouldShowSubagentModel(subagentModelName: string | undefined, parentModelId: string | undefined, parentModelName: string | undefined, parentModelMetadataId: string | undefined): boolean { - if (!subagentModelName) { + const normalize = (value: string | undefined) => value?.trim().toLowerCase(); + const subagent = normalize(subagentModelName); + if (!subagent || subagent === AUTO_RAW_MODEL_ID) { return false; } - const normalizedSubagentModel = subagentModelName.trim().toLowerCase(); - const parentModelIdSuffix = parentModelId?.slice(parentModelId.lastIndexOf(':') + 1); - return ![parentModelId, parentModelIdSuffix, parentModelName, parentModelMetadataId] - .some(candidate => candidate?.trim().toLowerCase() === normalizedSubagentModel); + const parentIdSuffix = parentModelId?.slice(parentModelId.lastIndexOf(':') + 1); + const parents = [parentModelId, parentIdSuffix, parentModelName, parentModelMetadataId].map(normalize); + // The picker can move to Auto after the request starts, so decide Auto from the + // request's own model id and consult the live selection only when it is absent. + const autoCandidates = parentModelId ? [parentModelId, parentIdSuffix].map(normalize) : parents; + return autoCandidates.includes(AUTO_RAW_MODEL_ID) || !parents.includes(subagent); } export function formatCompactSubagentDuration(startedAt: number, duration: number | undefined, now: number = Date.now()): string { @@ -197,6 +209,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { private _trackedEnabled: boolean | undefined; private _reportedAgentType: string | undefined; private _reportedModelName: string | undefined; + private _reportedCredits: number | undefined; private _renderedStatus: SubagentChatStatus | undefined; private _confirmationCount = 0; private readonly _spinner = this._register(new MutableDisposable()); @@ -212,6 +225,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { private _agentTypeElement: HTMLElement | undefined; private _pillContentElement: HTMLElement | undefined; private _modelElement: HTMLElement | undefined; + private _creditsElement: HTMLElement | undefined; private _durationElement: HTMLElement | undefined; private _activeToolElement: HTMLElement | undefined; private _activeToolIconElement: HTMLElement | undefined; @@ -271,6 +285,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { this._pillContentElement = pillContent; const pillHeader = $('span.chat-subagent-pill-header'); this._durationElement = $('span.chat-subagent-pill-duration.hidden'); + this._creditsElement = $('span.chat-subagent-pill-credits.hidden'); this._activeToolElement = $('span.chat-subagent-pill-active-tool.hidden'); this._activeToolElement.inert = true; const connector = $('span.chat-subagent-pill-active-tool-connector'); @@ -280,7 +295,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { this._activeToolLabelElement = $('.chat-subagent-pill-active-tool-label'); this._activeToolElement.append(connector, this._activeToolIconElement, this._activeToolLabelElement); pillContent.append(this._iconElement, this._agentTypeElement, this._labelElement, this._modelElement, this._confirmationCountElement); - pillHeader.append(pillContent, this._durationElement); + pillHeader.append(pillContent, this._durationElement, this._creditsElement); container.append(pillHeader, this._activeToolElement); this._pillHover.value = this.hoverService.setupDelayedHover(pillContent, () => ({ content: this.getTooltip() ?? '' })); if (this.options.draggable) { @@ -355,6 +370,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { ? context?.modelName : undefined; this._setModelName(contextModelName); + this._setCredits(context?.credits); this._updateConfirmationCount(context); this._updateStatus(context); this._updateDuration(context); @@ -405,6 +421,16 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { } } + private _setCredits(credits: number | undefined): void { + // Zero-cost subagents report 0 rather than nothing, so normalize both to hidden. + const show = typeof credits === 'number' && credits > 0; + this._reportedCredits = show ? credits : undefined; + if (this._creditsElement) { + this._creditsElement.textContent = show ? formatCopilotCreditsLabel(credits) : ''; + this._creditsElement.classList.toggle('hidden', !show); + } + } + private _setAgentType(agentType: string | undefined): void { this._reportedAgentType = agentType; if (this._agentTypeElement) { @@ -636,6 +662,9 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { if (this._reportedModelName) { details.push(localize('chat.subagent.modelTooltip', "Model: {0}", this._reportedModelName)); } + if (this._reportedCredits !== undefined) { + details.push(formatCopilotCreditsLabel(this._reportedCredits)); + } if (this._displayedToolAccessibleLabel && this._displayedActivityIsTool) { details.push(localize('chat.subagent.activeToolTooltip', "Active tool: {0}", this._displayedToolAccessibleLabel)); } @@ -677,7 +706,8 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { ? localize('chat.subagent.activeToolAria', "Active tool {0}", this._displayedToolAccessibleLabel) : undefined; const duration = this._durationElement?.textContent; - this.element.setAttribute('aria-label', [label, agentType, status, model, activeTool, duration].filter(Boolean).join('. ')); + const credits = this._creditsElement?.textContent; + this.element.setAttribute('aria-label', [label, agentType, status, model, activeTool, duration, credits].filter(Boolean).join('. ')); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts index 5a8f5679d21421..22e8af135ffdd2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts @@ -5,21 +5,29 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { $ } from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; import { combinedDisposable, Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, constObservable, derived, derivedObservableWithCache, IObservable } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../../../nls.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; import { IChatRendererContent, IChatTurnPillsPart } from '../../../common/model/chatViewModel.js'; import { ChatTreeItem } from '../../chat.js'; import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; import { EMPTY_DIFF_STATS, IDiffStats, observeTurnStatusPillsEnabled } from '../chatTurnPills.js'; +import { renderChangesSummaryFileList } from './chatChangesSummaryPart.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; /** - * Renders a single agent turn's aggregate changed-file and changed-line counts. + * Renders a single agent turn's changes as a checkpoint-style summary: a + * `N files changed +ins -del` header, where the counts open the changes and the + * rest of the header is a disclosure that expands to the list of changed files. */ export class ChatTurnPillsContentPart extends Disposable implements IChatContentPart { @@ -33,6 +41,8 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent @IChatResponseFileChangesService private readonly _chatResponseFileChangesService: IChatResponseFileChangesService, @IHoverService private readonly _hoverService: IHoverService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IEditorService private readonly _editorService: IEditorService, ) { super(); @@ -72,10 +82,17 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent const showChanges = derived(this, reader => changesEnabled.read(reader) && stats.read(reader).files > 0); const root = this.domNode.appendChild($('.checkpoint-file-changes-summary.checkpoint-file-changes-compact')); - const header = root.appendChild(document.createElement('div')); + const details = root.appendChild(document.createElement('details')); + details.classList.add('checkpoint-file-changes-disclosure'); + const header = details.appendChild(document.createElement('summary')); header.classList.add('checkpoint-file-changes-summary-header'); this._register(this._renderChangesHeader(header, stats)); + this._register(this._renderChevron(header, details)); + this._register(dom.addDisposableListener(header, 'click', () => { + this.domNode.dispatchEvent(new CustomEvent(ChatCollapsibleContentPart.userToggleEvent, { bubbles: true })); + })); + this._register(renderChangesSummaryFileList(details, this._diffs, this._instantiationService, this._editorService, this._configurationService)); this._register(autorun(reader => { this.domNode.style.display = showChanges.read(reader) ? '' : 'none'; @@ -113,9 +130,29 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent insertions, deletions )); + header.setAttribute('aria-label', localize( + 'chat.turnChanges.accessibleSummary', + '{0}, {1} lines added, {2} lines deleted', + fileCountLabel, + insertions, + deletions + )); })); } + private _renderChevron(header: HTMLElement, details: HTMLDetailsElement): IDisposable { + const chevron = header.appendChild($('span.chat-file-changes-chevron.chat-collapsible-hover-chevron', { 'aria-hidden': 'true' })); + chevron.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronRightCompact)); + + const setExpansionState = () => { + header.setAttribute('aria-expanded', String(details.open)); + chevron.classList.toggle('expanded', details.open); + }; + setExpansionState(); + + return dom.addDisposableListener(details, 'toggle', setExpansionState); + } + private _openChanges(): void { this._chatResponseFileChangesService.openChangesForRequest( this._content.sessionResource, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css index 886fe2bb119eb4..9c7d4eeed8ec7f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css @@ -3,13 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* When docked in the chat input area, hide the container when it's empty so - * we don't reserve space. */ -.interactive-session .interactive-input-part > .chat-plan-review-widget-container:empty, -.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-plan-review-widget-container:empty { - display: none; -} - .interactive-session .interactive-input-part > .chat-plan-review-widget-container, .interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-plan-review-widget-container { width: 100%; @@ -19,11 +12,6 @@ gap: 8px; } -.interactive-session .interactive-input-part > .chat-plan-review-widget-container:not(:empty), -.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-plan-review-widget-container:not(:empty) { - margin-top: 8px; -} - /* When the part is docked inside the input container, drop our own * margin-bottom so it sits flush against the input. */ .interactive-session .interactive-input-part .chat-plan-review-container > .chat-confirmation-widget2.chat-plan-review { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 1cf68c8535ddde..8d1e2b5cc84a6b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -3,12 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* question carousel - this is above edits and todos */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty, -.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container:empty { - display: none; -} - /* input specific styling - box look comes from the shared container rule below */ .interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container, .interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container .chat-question-carousel-container { @@ -41,11 +35,6 @@ gap: 8px; } -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:not(:empty), -.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container:not(:empty) { - margin-top: 8px; -} - .interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-content, .interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container .chat-question-carousel-content { min-height: 0; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentOpenChat.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentOpenChat.css index 9952625083617a..477ab4f23081a7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentOpenChat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentOpenChat.css @@ -323,7 +323,8 @@ height: var(--vscode-spacing-size120); } -.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-duration { +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-duration, +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-credits { flex-shrink: 0; color: var(--vscode-descriptionForeground); font-size: var(--vscode-fontSize-label2); @@ -334,7 +335,13 @@ pointer-events: none; } -.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-duration.hidden { +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-duration:not(.hidden) + .chat-subagent-pill-credits::before { + content: '\00b7'; + margin-right: var(--vscode-spacing-size40); +} + +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-duration.hidden, +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-credits.hidden { display: none; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css index 8e0357be42ac68..5982338faa1900 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css @@ -9,14 +9,6 @@ position: relative; display: flex; flex-direction: column; - - &:empty { - display: none; - } - - &:not(:empty) { - margin-top: 8px; - } } /* Card chrome comes from widget/media/chatCard.css. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 00c3dd63c63a94..1a565e315e8048 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2135,6 +2135,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0) { const timestamp = renderChatRequestTimestamp(templateData.requestTimestampContainer, element.requestTimestamp); if (timestamp?.hoverText) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index bb819440db5de6..65b23927b6d3ee 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -67,7 +67,6 @@ const DIZZY_STATE_DURATION = 2_200; const WAKE_STATE_DURATION = 880; const DIZZY_DIRECTION_CHANGE_COUNT = 8; const DIZZY_DIRECTION_CHANGE_MAX_INTERVAL = 600; -const SEARCH_INTERVAL = 10_000; const RESPAWN_EFFECT_DURATION = 800; const RESPAWN_EFFECT_REDUCED_MOTION_DURATION = 400; const DRAG_THRESHOLD = 2; @@ -1138,7 +1137,6 @@ export class ChatPetWidget extends Disposable { private readonly _idleScheduler = this._register(new RunOnceScheduler(() => this._idleExpired.set(true, undefined), CHAT_PET_IDLE_SLEEP_DELAY)); private readonly _confirmationAttentionScheduler = this._register(new RunOnceScheduler(() => this._confirmationAttentionExpired.set(true, undefined), CHAT_PET_CONFIRMATION_ATTENTION_DURATION)); private readonly _transientScheduler = this._register(new RunOnceScheduler(() => this._transientState.set(undefined, undefined), TRANSIENT_STATE_DURATION)); - private readonly _searchScheduler: RunOnceScheduler; private readonly _clickSuppressionScheduler = this._register(new RunOnceScheduler(() => this._suppressNextPointerClick = false, 0)); private readonly _spriteAnimation = this._register(new MutableDisposable()); private readonly _speechAnimation = this._register(new MutableDisposable()); @@ -1213,7 +1211,6 @@ export class ChatPetWidget extends Disposable { this._host = observableValue(this, host); this._variant = this.chatPetService.variant.get(); this._selectedAccessory = this.chatPetService.selectedAccessory.get(); - this._searchScheduler = this._register(new RunOnceScheduler(() => this._trySearch(), SEARCH_INTERVAL)); this.parent.classList.add('chat-pet-host'); this._overlay = dom.$(`.${CHAT_PET_OVERLAY_CLASS}`); this.parent.prepend(this._overlay); @@ -1305,8 +1302,6 @@ export class ChatPetWidget extends Disposable { this._finishDisable(); } else if (event.animationName === 'chat-pet-yapping-fall' && !this._isDragging.get() && event.target === this._activeSprite?.container && this._button.element.dataset.state === 'yapping') { this._transientState.set('yappingMouthOpen', undefined); - } else if (event.animationName === 'chat-pet-search-down' && this._button.element.dataset.state === 'searchingDown') { - this._transientState.set(undefined, undefined); } else if ((event.animationName === 'chat-pet-eye-blink' || event.animationName === 'chat-pet-tall-eye-blink') && event.target === this._pupils[0]) { this._blinkController.onAnimationComplete(); } @@ -1511,7 +1506,6 @@ export class ChatPetWidget extends Disposable { if (!enabled) { this._hopController.cancel(); this._idleScheduler.cancel(); - this._searchScheduler.cancel(); this._transientScheduler.cancel(); if (transientState !== undefined) { this._transientState.set(undefined, undefined); @@ -1525,7 +1519,6 @@ export class ChatPetWidget extends Disposable { if (isDead) { this._hopController.cancel(); this._idleScheduler.cancel(); - this._searchScheduler.cancel(); this._transientScheduler.cancel(); this._showRespawnSequence(); return; @@ -1534,14 +1527,9 @@ export class ChatPetWidget extends Disposable { if (onTheRun) { this._hopController.cancel(); this._idleScheduler.cancel(); - if (!this._searchScheduler.isScheduled()) { - this._searchScheduler.schedule(); - } - const state = transientState === 'searching' || transientState === 'searchingDown' ? transientState : 'onTheRun'; - this._renderState(state, variantChanged); + this._renderState('onTheRun', variantChanged); return; } - this._searchScheduler.cancel(); if (this._busy) { this._idleScheduler.cancel(); @@ -2549,19 +2537,6 @@ export class ChatPetWidget extends Disposable { } } - private _trySearch(): void { - if (!this._enabled || !this.chatPetService.onTheRun.get()) { - return; - } - if (this._motionReduced) { - this._searchScheduler.schedule(); - return; - } - this._transientState.set('searching', undefined); - this._renderState('searching', true); - this._searchScheduler.schedule(); - } - private _wake(): void { const wasSleeping = this._idleExpired.get() || this._renderedState === 'sleep'; this._idleExpired.set(false, undefined); @@ -2881,14 +2856,7 @@ export class ChatPetWidget extends Disposable { } if (state === 'jump') { this._hopController.onAnimationComplete(); - return; - } - if (state !== 'searching' || !this.chatPetService.onTheRun.get()) { - return; } - this._transientState.set('searchingDown', undefined); - this._button.element.dataset.state = 'searchingDown'; - this._renderedState = 'searchingDown'; } private _startSpriteAnimation(source: ChatPetSpriteSource, sprite: ChatPetSpriteElement, animationDisposable: MutableDisposable, onComplete?: () => void, reverse = false, onFrame?: (frameIndex: number) => void, state?: ChatPetState): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index bb1d09058d8b6d..abe5593812e653 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -296,6 +296,9 @@ const DISCLAIMER = localize('chatDisclaimer', "AI responses may be inaccurate"); /** Set on the container when {@link IChatWidgetViewOptions.persistentContentHeight} is, floating the persistent content. */ export const chatFloatingPersistentContentClass = 'chat-floating-persistent-content'; +/** Set on the persistent-content container while it has visible content. */ +export const chatPersistentContentVisibleClass = 'chat-persistent-content-visible'; + /** Carries {@link IChatWidgetViewOptions.persistentContentHeight} to `chat.css`. */ export const chatPersistentContentHeightVariable = '--vscode-chat-persistent-content-height'; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts index 5589178a1bb18c..8c40613c276215 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts @@ -5,6 +5,7 @@ import { status } from '../../../../../../base/browser/ui/aria/aria.js'; import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; +import { IStringDictionary } from '../../../../../../base/common/collections.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; @@ -13,6 +14,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; export const enum ChatInputNotificationSeverity { @@ -30,6 +32,8 @@ export const enum ChatInputNotificationActionKind { interface IChatInputNotificationActionBase { readonly label: string; readonly keepOpen?: boolean; + /** Stable id reported to telemetry, so two actions of the same kind can be told apart. */ + readonly telemetryActionId?: string; } export interface IChatInputNotificationCommandAction extends IChatInputNotificationActionBase { @@ -46,6 +50,10 @@ export interface IChatInputNotificationSwitchToModelAction extends IChatInputNot readonly kind: ChatInputNotificationActionKind.SwitchToModel; /** Matches the target against models available to the rendering input. */ readonly matchesModel: (model: ILanguageModelChatMetadataAndIdentifier) => boolean; + /** Applied to the target model once switched. Keys its schema does not declare are dropped. */ + readonly config?: IStringDictionary; + /** Requires the target to match exactly one model, rather than taking the first match. */ + readonly requireUniqueModel?: boolean; } export type IChatInputNotificationAction = @@ -187,6 +195,12 @@ export interface IChatInputNotificationService { */ deleteNotification(id: string): void; + /** + * Ask mounted inputs to re-evaluate what they render, without touching notification or + * dismissal state. Use when something a `when` predicate reads has changed. + */ + refresh(): void; + /** * Mark a notification as dismissed by the user. It will no longer be returned * by {@link getActiveNotification} until it is re-pushed with new content. @@ -249,6 +263,10 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif this._fireDidChange(); } + refresh(): void { + this._fireDidChange(); + } + deleteNotification(id: string): void { if (this._notifications.delete(id)) { this._dismissed.delete(id); @@ -348,3 +366,31 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif } registerSingleton(IChatInputNotificationService, ChatInputNotificationService, InstantiationType.Delayed); + +/** + * Reads the ids a user has dismissed for good, kept in application storage so a dismissal in one + * window applies to every other. Returns an empty set for absent or corrupt data. + * + * The set is stored under one key, so two windows dismissing different notices in the same + * instant can leave the later write without the earlier id. Re-reading before each write keeps + * that to a genuine race, and the cost is only that a notice may appear once more. + */ +export function readDismissedNotificationIds(storageService: IStorageService, key: string): Set { + const raw = storageService.get(key, StorageScope.APPLICATION); + try { + const parsed = raw ? JSON.parse(raw) : undefined; + return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []); + } catch { + return new Set(); + } +} + +/** Records one id as dismissed for good. */ +export function addDismissedNotificationId(storageService: IStorageService, key: string, id: string): void { + const dismissed = readDismissedNotificationIds(storageService, key); + if (dismissed.has(id)) { + return; + } + dismissed.add(id); + storageService.store(key, JSON.stringify([...dismissed]), StorageScope.APPLICATION, StorageTarget.USER); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts index b261c6b7d3254e..cd5b04d631d12f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts @@ -8,6 +8,7 @@ import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { IStringDictionary } from '../../../../../../base/common/collections.js'; import { isMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../base/common/observable.js'; @@ -22,9 +23,10 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { IChatInputNoticeFocusTarget } from './chatInputNoticeHost.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; +import { filterConfigurationToSchema } from './chatModelConfigurationLogic.js'; import { ChatInputNoticeVariant, ChatInputNoticeWidget } from './chatInputNoticeWidget.js'; import { ChatInputStackSlot, setChatInputStackSlot } from './chatInputStack.js'; -import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, getChatInputNotificationAnnouncementSignature, IChatInputNotification, IChatInputNotificationAction, IChatInputNotificationBody, IChatInputNotificationCommandAction, IChatInputNotificationContext, IChatInputNotificationModelState, IChatInputNotificationService, isChatInputNotificationApplicableToSession, resolveChatInputNotificationBody } from './chatInputNotificationService.js'; +import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, getChatInputNotificationAnnouncementSignature, IChatInputNotification, IChatInputNotificationAction, IChatInputNotificationBody, IChatInputNotificationCommandAction, IChatInputNotificationContext, IChatInputNotificationModelState, IChatInputNotificationService, IChatInputNotificationSwitchToModelAction, isChatInputNotificationApplicableToSession, resolveChatInputNotificationBody } from './chatInputNotificationService.js'; import './media/chatInputNotificationWidget.css'; const $ = dom.$; @@ -43,12 +45,14 @@ type ChatInputNotificationTelemetryClassification = { type ChatInputNotificationActionTelemetryEvent = ChatInputNotificationTelemetryEvent & { actionKind: ChatInputNotificationActionKind; + actionId: string; }; type ChatInputNotificationActionTelemetryClassification = { id: ChatInputNotificationTelemetryClassification['id']; telemetryId?: ChatInputNotificationTelemetryClassification['telemetryId']; actionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The kind of notification action selected by the user.' }; + actionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The feature-provided identifier of the notification action selected by the user.' }; owner: 'rfeltis'; comment: 'Tracks actions selected from chat input notifications.'; }; @@ -72,6 +76,8 @@ export interface IChatInputNotificationModelSelection { readonly state: IObservable; readonly openPicker: () => void; readonly selectModel: (modelIdentifier: string) => boolean; + /** Applies model configuration such as a thinking level, scoped to this input. */ + readonly applyModelConfiguration?: (modelIdentifier: string, values: IStringDictionary) => Promise; } /** Input-local capabilities used to filter and execute semantic notification actions. */ @@ -377,6 +383,7 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu this._telemetryService.publicLog2('chatInputNotificationAction', { ...this._getTelemetryData(notification), actionKind: action.kind, + actionId: action.telemetryActionId ?? '', }); switch (action.kind) { case ChatInputNotificationActionKind.Command: @@ -390,7 +397,7 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu this._openModelPicker(); break; case ChatInputNotificationActionKind.SwitchToModel: - this._switchToModel(this._resolveModel(action)?.identifier); + this._switchToModel(action); break; } if (!action.keepOpen) { @@ -399,7 +406,9 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu } private _resolveModel(action: Extract): ILanguageModelChatMetadataAndIdentifier | undefined { - return this._modelState.models.find(model => this._matchesModel(action, model)); + const matches = this._modelState.models.filter(model => this._matchesModel(action, model)); + // A broad selector could otherwise switch the user to a model the notification did not mean. + return action.requireUniqueModel && matches.length !== 1 ? undefined : matches[0]; } private _matchesModel(action: Extract, model: ILanguageModelChatMetadataAndIdentifier): boolean { @@ -411,18 +420,43 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu } } - private _switchToModel(modelIdentifier: string | undefined): void { + private _switchToModel(action: IChatInputNotificationSwitchToModelAction): void { + const model = this._resolveModel(action); let switched = false; - try { - switched = modelIdentifier ? this._delegate?.modelSelection?.selectModel(modelIdentifier) ?? false : false; - } catch (error) { - this._logError(error); + if (model) { + try { + switched = this._delegate?.modelSelection?.selectModel(model.identifier) ?? false; + } catch (error) { + this._logError(error); + } + // Only configure a model the user actually ended up on, and never block the + // switch on the profile-wide write this kicks off. + if (switched) { + this._applyModelConfiguration(model, action.config).catch(error => this._logError(error)); + } } if (!switched) { this._openModelPicker(); } } + /** Key names are model-specific (`thinkingLevel`, `reasoningEffort`, ...), so a key meant for another model is logged rather than stored. */ + private async _applyModelConfiguration(model: ILanguageModelChatMetadataAndIdentifier, config: IStringDictionary | undefined): Promise { + const modelSelection = this._delegate?.modelSelection; + if (!config || !modelSelection?.applyModelConfiguration) { + return; + } + const schema = model.metadata.configurationSchema; + const values = filterConfigurationToSchema(config, schema); + const dropped = Object.keys(config).filter(key => !Object.hasOwn(values, key)); + if (dropped.length) { + this._logService.warn(`[ChatInputNotificationWidget] ${model.identifier} does not accept ${dropped.join(', ')}; supported: ${Object.keys(schema?.properties ?? {}).join(', ') || 'none'}`); + } + if (Object.keys(values).length) { + await modelSelection.applyModelConfiguration(model.identifier, values); + } + } + private _openModelPicker(): void { try { this._delegate?.modelSelection?.openPicker(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 796dcf665506d5..bc07468326c781 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -162,7 +162,7 @@ import { registerChatInputOnboardingHosts } from './chatInputOnboardingHosts.js' import { IChatInputNoticeHubService } from './chatInputNoticeHub.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem, isChatInputPickerResponsiveState } from './chatInputPickerResponsiveLayout.js'; -import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, setChatInputStackInputFocused, setChatInputStackSlot } from './chatInputStack.js'; +import { chatInputStackClass, chatInputStackSlotClass, chatInputSurfaceStackClass, chatInputSurfaceStackSlotClass, ChatInputStackSlot, setChatInputStackInputFocused, setChatInputStackSlot } from './chatInputStack.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../chatPetAchievements.js'; import { IChatPetService } from '../../chatPetService.js'; @@ -2871,6 +2871,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge state: this._notificationModelState, openPicker: () => this.openModelPicker(), selectModel: modelIdentifier => this.selectNotificationModel(modelIdentifier), + applyModelConfiguration: (modelIdentifier, values) => this.applyNotificationModelConfiguration(modelIdentifier, values), }, onDidChangeVisibility: (visible, focusTarget) => this.noticeHost.setOccupied(ChatInputNoticeLane.Notification, visible, focusTarget), focusInput: () => this.focus(), @@ -2899,6 +2900,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return true; } + /** + * The store is scoped to the active session, so while a delegation is pending the write + * would land on the source session rather than the target the models belong to. + */ + private async applyNotificationModelConfiguration(modelIdentifier: string, values: IStringDictionary): Promise { + if (this._pendingDelegationTarget) { + return; + } + await this._modelConfigStore.setModelConfiguration(modelIdentifier, values); + } + /** * Lazy-instantiate the goal banner widget on first use. */ @@ -3129,12 +3141,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let elements; if (this.options.renderStyle === 'compact') { - elements = dom.h('.interactive-input-part', [ + elements = dom.h(`.interactive-input-part.${chatInputSurfaceStackClass}`, [ dom.h('.chat-input-persistent-content@persistentContentContainer'), - dom.h('.interactive-input-and-edit-session', [ - dom.h('.chat-plan-review-widget-container@chatPlanReviewContainer'), - dom.h('.chat-question-carousel-widget-container@chatQuestionCarouselContainer'), - dom.h('.chat-tool-confirmation-carousel-container@chatToolConfirmationCarouselContainer'), + dom.h(`.interactive-input-and-edit-session.${chatInputSurfaceStackClass}`, [ + dom.h(`.chat-plan-review-widget-container.${chatInputSurfaceStackSlotClass}@chatPlanReviewContainer`), + dom.h(`.chat-question-carousel-widget-container.${chatInputSurfaceStackSlotClass}@chatQuestionCarouselContainer`), + dom.h(`.chat-tool-confirmation-carousel-container.${chatInputSurfaceStackSlotClass}@chatToolConfirmationCarouselContainer`), dom.h(`.${chatInputStackClass}`, [ dom.h(`.chat-input-notification-container.${chatInputStackSlotClass}@chatInputNotificationContainer`), dom.h(`.voice-mode-onboarding-container.${chatInputStackSlotClass}@voiceModeOnboardingContainer`), @@ -3156,19 +3168,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), dom.h('.chat-input-status-container@statusToolbarContainer'), ]), - dom.h('.chat-attachments-container@attachmentsContainer', [ + dom.h(`.chat-attachments-container.${chatInputSurfaceStackSlotClass}@attachmentsContainer`, [ dom.h('.chat-attached-context@attachedContextContainer'), ]), - dom.h('.interactive-input-followups@followupsContainer'), + dom.h(`.interactive-input-followups.${chatInputSurfaceStackSlotClass}@followupsContainer`), ]) ]); } else { - elements = dom.h('.interactive-input-part', [ + elements = dom.h(`.interactive-input-part.${chatInputSurfaceStackClass}`, [ dom.h('.chat-input-persistent-content@persistentContentContainer'), - dom.h('.chat-plan-review-widget-container@chatPlanReviewContainer'), - dom.h('.chat-question-carousel-widget-container@chatQuestionCarouselContainer'), - dom.h('.chat-tool-confirmation-carousel-container@chatToolConfirmationCarouselContainer'), - dom.h('.interactive-input-followups@followupsContainer'), + dom.h(`.chat-plan-review-widget-container.${chatInputSurfaceStackSlotClass}@chatPlanReviewContainer`), + dom.h(`.chat-question-carousel-widget-container.${chatInputSurfaceStackSlotClass}@chatQuestionCarouselContainer`), + dom.h(`.chat-tool-confirmation-carousel-container.${chatInputSurfaceStackSlotClass}@chatToolConfirmationCarouselContainer`), + dom.h(`.interactive-input-followups.${chatInputSurfaceStackSlotClass}@followupsContainer`), dom.h(`.${chatInputStackClass}`, [ dom.h(`.chat-input-notification-container.${chatInputStackSlotClass}@chatInputNotificationContainer`), dom.h(`.voice-mode-onboarding-container.${chatInputStackSlotClass}@voiceModeOnboardingContainer`), diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputStack.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputStack.ts index bed6526e6b7a61..4cf1e0940da0ec 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputStack.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputStack.ts @@ -6,6 +6,16 @@ import { isHTMLElement } from '../../../../../../base/browser/dom.js'; import './media/chatInputStack.css'; +/** + * A regularly spaced column of standalone surfaces around the docked chat input + * stack. The stack owns the space between members so cards do not compound it + * with component-specific margins. + */ +export const chatInputSurfaceStackClass = 'chat-input-surface-stack'; + +/** Marks a dynamic member of the surface stack, which disappears while empty. */ +export const chatInputSurfaceStackSlotClass = 'chat-input-surface-stack-slot'; + /** * A column of surfaces above a chat input - notices, the goal banner, the todo * list, artifacts, the working set - plus the input itself. diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationLogic.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationLogic.ts index dc49852a147f84..d34a3dfe90f589 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationLogic.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationLogic.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IStringDictionary } from '../../../../../../base/common/collections.js'; +import { IJSONSchema } from '../../../../../../base/common/jsonSchema.js'; import { ILanguageModelConfigurationSchema } from '../../../common/languageModels.js'; /** @@ -23,15 +24,17 @@ export function extractSchemaDefaults(schema: ILanguageModelConfigurationSchema } /** - * Filters a (e.g. restored) configuration down to what the *current* schema - * accepts, so a value captured against an older schema cannot be re-pinned as a - * stale override. Two rules are applied: + * Filters a configuration down to what the *current* schema accepts, so neither a value + * captured against an older schema nor one supplied by an experiment treatment can be pinned + * as an invalid override. A value is kept only when its property is declared and every + * constraint this understands is satisfied: * 1. Keys absent from the current schema are dropped (removed properties). - * 2. Values that violate the property's `enum` constraint are dropped, so the - * property falls back to its live default instead of an invalid value. - * Properties without an `enum` keep their value (no constraint to validate - * against). When the schema is missing entirely, nothing can be validated and an - * empty configuration is returned. + * 2. `enum` and `const` must contain the value. + * 3. `type` must match, including a union of types. + * 4. Numeric bounds and string `pattern`/length are enforced when declared. + * A property using a composite schema (`anyOf`, `oneOf`, `allOf`, `not`, `$ref`) is failed + * closed, since a value cannot be checked against a shape this does not interpret. When the + * schema is missing entirely, nothing can be validated and an empty configuration is returned. */ export function filterConfigurationToSchema( values: IStringDictionary, @@ -43,18 +46,77 @@ export function filterConfigurationToSchema( } const result: IStringDictionary = {}; for (const [key, value] of Object.entries(values)) { - const propSchema = properties[key]; + const propSchema = Object.hasOwn(properties, key) ? properties[key] : undefined; if (!propSchema) { continue; } - if (Array.isArray(propSchema.enum) && !propSchema.enum.includes(value)) { - continue; + if (satisfiesPropertySchema(value, propSchema)) { + result[key] = value; } - result[key] = value; } return result; } +function satisfiesPropertySchema(value: unknown, schema: IJSONSchema): boolean { + // A shape this does not interpret cannot be checked, so the value is not kept. + if (schema.anyOf || schema.oneOf || schema.allOf || schema.not || schema.$ref) { + return false; + } + if (schema.const !== undefined && value !== schema.const) { + return false; + } + if (Array.isArray(schema.enum) && !schema.enum.includes(value)) { + return false; + } + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some(type => matchesJSONSchemaType(value, type))) { + return false; + } + } + if (typeof value === 'number') { + // `exclusiveMinimum`/`exclusiveMaximum` are a boolean modifier in draft 4 and a number + // in later drafts, so only the numeric form is comparable. + const exclusiveMinimum = typeof schema.exclusiveMinimum === 'number' ? schema.exclusiveMinimum : undefined; + const exclusiveMaximum = typeof schema.exclusiveMaximum === 'number' ? schema.exclusiveMaximum : undefined; + if ((schema.minimum !== undefined && value < schema.minimum) + || (schema.maximum !== undefined && value > schema.maximum) + || (exclusiveMinimum !== undefined && value <= exclusiveMinimum) + || (exclusiveMaximum !== undefined && value >= exclusiveMaximum)) { + return false; + } + } + if (typeof value === 'string') { + if ((schema.minLength !== undefined && value.length < schema.minLength) + || (schema.maxLength !== undefined && value.length > schema.maxLength)) { + return false; + } + if (schema.pattern !== undefined) { + try { + if (!new RegExp(schema.pattern).test(value)) { + return false; + } + } catch { + return false; + } + } + } + return true; +} + +function matchesJSONSchemaType(value: unknown, type: string): boolean { + switch (type) { + case 'string': return typeof value === 'string'; + case 'number': return typeof value === 'number' && Number.isFinite(value); + case 'integer': return typeof value === 'number' && Number.isInteger(value); + case 'boolean': return typeof value === 'boolean'; + case 'null': return value === null; + case 'array': return Array.isArray(value); + case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value); + default: return false; + } +} + /** * Resolves the effective configuration for a model within a single * `(location, sessionType)` scope. diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputStack.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputStack.css index 30d15b74fcf567..757d0d3a00310b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputStack.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputStack.css @@ -15,10 +15,28 @@ * Now members meet directly and a docked member squares the top corners of what * follows. Members only declare what they are showing - see `chatInputStack.ts`. * - * `display` is not set here; hosts own that, and their layout, using the classes - * below. `gap` is the exception, since members meeting is the point. + * The docked stack's `display` is not set here; hosts own that layout. Its `gap` + * is the exception, since members meeting is the point. */ +.chat-input-surface-stack { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size40); +} + +/* + * Standalone surfaces use one shared spacing tier. Their slots own visibility, + * while the stack owns adjacency, so any combination composes to the same gap. + */ +.interactive-session .chat-input-surface-stack > .chat-input-surface-stack-slot:empty { + display: none; +} + +.interactive-session .chat-input-surface-stack > .chat-input-surface-stack-slot:not(:empty) { + margin-block: 0; +} + .chat-input-stack { gap: 0; /* diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index 19ce341dfceb3c..292dae4404e156 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -31,6 +31,7 @@ import { ITelemetryService } from '../../../../../../../platform/telemetry/commo import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; import { TelemetryTrustedValue } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; +import { getLanguageModelDisplayNameWithSubscriptionSource } from '../../../../common/languageModelSourcePresentation.js'; import { IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; import { CHAT_SETUP_ACTION_ID } from '../../../actions/chatActions.js'; @@ -577,7 +578,9 @@ export class ModelPickerWidget extends Disposable { return; } - const { name } = this._selectedModel?.metadata || {}; + const name = this._selectedModel + ? getLanguageModelDisplayNameWithSubscriptionSource(this._selectedModel) + : undefined; const { reason, activating, genericNoModels, noModels: noModelsAvailable } = this._availability(); const restrictedMode = reason === ModelPickerUnavailableReason.Restricted; diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 087dbc5eb85d43..cb18b7ecf09885 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2270,14 +2270,11 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .interactive-input-part { margin: 0px 12px; padding: 4px 0 4px 0px; - display: flex; - flex-direction: column; - gap: 4px; position: relative; } -/* The stack lays out as a column here. Set by the host, since the stack itself - stays layout-agnostic. */ +/* The docked stack joins surfaces without gaps inside the regularly spaced + input-area surface stack. */ .interactive-session .chat-input-stack { display: flex; flex-direction: column; @@ -2288,24 +2285,49 @@ have to be updated for changes to the rules above, or to support more deeply nes } /* Opted in via `IChatWidgetViewOptions.persistentContentHeight`: the content - floats above the input so the transcript scrolls underneath it, landing where - it sat in flow - `bottom: 100%` leaves the flex `gap` it used to have below it, - since the gap and the input part's `padding-top` are both 4px. */ + floats above the input so the transcript scrolls underneath it. Its bottom + padding occupies the input part's top inset, leaving one shared spacing tier + before the first visible surface. */ .interactive-session.chat-floating-persistent-content .interactive-input-part > .chat-input-persistent-content { position: absolute; inset-inline: 0; - bottom: 100%; + bottom: calc(100% - var(--vscode-spacing-size40)); /* Match the input part's own horizontal padding. */ padding-inline: inherit; box-sizing: border-box; /* Clicks fall through to the transcript; the pills opt back in. */ pointer-events: none; + isolation: isolate; + z-index: 0; } -/* The composer is hidden, so there is no in-flow sibling left to gap against - and the row would sit one gap too high. */ -.interactive-session.chat-floating-persistent-content .interactive-input-part.chat-input-hidden > .chat-input-persistent-content { - bottom: calc(100% - var(--vscode-spacing-size40)); +/* Separate floating controls from transcript content without masking the list, + so the treatment remains stable while content streams or scrolls. */ +.interactive-session.chat-floating-persistent-content .interactive-input-part > .chat-input-persistent-content.chat-persistent-content-visible::before { + content: ''; + position: absolute; + inset-inline: 0; + top: calc(-1 * var(--vscode-spacing-size160)); + bottom: 0; + z-index: 0; + pointer-events: none; + background: linear-gradient( + to bottom, + transparent 0, + var(--vscode-chat-list-background, var(--vscode-editor-background)) var(--vscode-spacing-size160) + ); +} + +.interactive-session.chat-floating-persistent-content .interactive-input-part > .chat-input-persistent-content > * { + position: relative; + z-index: 1; +} + +.hc-black .interactive-session.chat-floating-persistent-content .interactive-input-part > .chat-input-persistent-content.chat-persistent-content-visible::before, +.hc-light .interactive-session.chat-floating-persistent-content .interactive-input-part > .chat-input-persistent-content.chat-persistent-content-visible::before { + background: var(--vscode-chat-list-background, var(--vscode-editor-background)); + border-top: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder); + box-sizing: border-box; } .interactive-session .interactive-input-part.chat-input-hidden > :not(.chat-input-persistent-content, .chat-tool-confirmation-carousel-container) { @@ -3064,9 +3086,9 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } -/* The turn summary exposes only aggregate file and line counts. The +/- counts are - the "View All File Changes" button, so reset the native button chrome and add - the standard toolbar hover/focus affordances. */ +/* Turn changes summary: reuses the compact checkpoint summary styling. The +/- counts + are themselves the "View All File Changes" button, so reset the native button chrome + and add the standard toolbar hover/focus affordances. */ .interactive-session .chat-turn-pills-part .chat-file-changes-counts { align-items: center; padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size20); @@ -3087,14 +3109,6 @@ have to be updated for changes to the rules above, or to support more deeply nes outline-offset: calc(-1 * var(--vscode-strokeThickness)); } -.interactive-session .chat-turn-pills-part .checkpoint-file-changes-summary-header { - cursor: default; -} - -.interactive-session .chat-turn-pills-part .checkpoint-file-changes-summary-header:hover { - color: var(--vscode-descriptionForeground); -} - /* The header already spaces its children with `gap`, so the label's own margin needed in the standalone checkpoint summary is redundant here. */ .interactive-session .chat-turn-pills-part .chat-file-changes-label { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css index ef04eb9274e13e..0b04705ed2b73a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css @@ -271,14 +271,6 @@ transform: translateY(var(--vscode-spacing-size400)); } -.chat-pet-button[data-state='searching']:not(.exiting) { - animation: chat-pet-search-up 160ms steps(4, end) forwards; -} - -.chat-pet-button[data-state='searchingDown']:not(.exiting) { - animation: chat-pet-search-down 160ms steps(4, end) forwards; -} - .chat-pet-button.dragging .chat-pet-sprite, .chat-pet-button.dragging .chat-pet-eyes, .chat-pet-button.dragging .chat-pet-eye-accessory { @@ -472,34 +464,6 @@ } } -@keyframes chat-pet-search-up { - 0% { - transform: translateY(var(--vscode-spacing-size400)); - } - - 50% { - transform: translateY(var(--vscode-spacing-size240)); - } - - 100% { - transform: translateY(var(--vscode-spacing-size60)); - } -} - -@keyframes chat-pet-search-down { - 0% { - transform: translateY(var(--vscode-spacing-size60)); - } - - 50% { - transform: translateY(var(--vscode-spacing-size240)); - } - - 100% { - transform: translateY(var(--vscode-spacing-size400)); - } -} - @keyframes chat-pet-eye-bob { 0%, 39.999% { @@ -552,8 +516,6 @@ .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='complete'] .chat-pet-eye-accessory, .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-sprite, .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-eye-accessory, -.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searching'], -.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searchingDown'], .monaco-workbench.monaco-reduce-motion .chat-pet-eyes, .monaco-workbench.monaco-reduce-motion .chat-pet-pupil { animation: none; @@ -564,11 +526,3 @@ .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-eye-accessory { transform: translateY(calc(-1 * var(--vscode-spacing-size40))) rotate(-90deg); } - -.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searching'] { - transform: translateY(var(--vscode-spacing-size400)); -} - -.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searchingDown'] { - transform: translateY(var(--vscode-spacing-size400)); -} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index 34033d792b81e5..f101075d5bf072 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -18,6 +18,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IProgressService, ProgressLocation } from '../../../../../../platform/progress/common/progress.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { registerIcon } from '../../../../../../platform/theme/common/iconRegistry.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; @@ -32,6 +33,7 @@ import { IChatModel } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri, getChatSessionType, getNewChatSessionResource, isUntitledChatSession } from '../../../common/model/chatUri.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { adoptLegacyCopilotCliResource, LEGACY_MIGRATION_RESTORE_TIMEOUT_MS } from '../../agentSessions/agentHost/agentHostLegacyMigration.js'; +import { migratedCopilotCliResource } from '../../copilotCliEventsUri.js'; import { IClearEditingSessionConfirmationOptions } from '../../actions/chatActions.js'; import type { IChatEditorOptions } from './chatEditor.js'; @@ -78,6 +80,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler @IAgentHostEnablementService private readonly agentHostEnablementService: IAgentHostEnablementService, @IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService, @ITelemetryService private readonly telemetryService: ITelemetryService, + @IProgressService private readonly progressService: IProgressService, ) { super(); @@ -246,18 +249,26 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler if (this._sessionResource) { // Restore addresses a session by URI, which for a legacy Copilot CLI // session names the extension-host provider. Redirect (and adopt) here, - // since `deserialize` is synchronous and cannot. - const migrated = await adoptLegacyCopilotCliResource( - this.agentHostConnectionsService.ambientConnection, - this._sessionResource, - this.logService, - this.configurationService, - this.telemetryService, - 'restore', - LEGACY_MIGRATION_RESTORE_TIMEOUT_MS, - ); - if (migrated) { - this._sessionResource = migrated; + // since `deserialize` is synchronous and cannot. Only a legacy resource can + // redirect, so the progress wrapper is skipped for every normal restore. + // Migration is invisible to the user, so the adopt runs under a subtle + // status-bar hint rather than a silent wait while the (cold) host settles. + if (migratedCopilotCliResource(this._sessionResource)) { + const migrated = await this.progressService.withProgress( + { location: ProgressLocation.Window, title: nls.localize('chat.openingSession', "Opening chat…") }, + () => adoptLegacyCopilotCliResource( + this.agentHostConnectionsService.ambientConnection, + this._sessionResource!, + this.logService, + this.configurationService, + this.telemetryService, + 'restore', + LEGACY_MIGRATION_RESTORE_TIMEOUT_MS, + ), + ); + if (migrated) { + this._sessionResource = migrated; + } } try { this.modelRef.value = await this.chatService.acquireOrLoadSession(this._sessionResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolve', this.options.sessionTypeSelectionReason); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts index da3e3fda0d3455..7f49493c21bb7a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -14,7 +14,7 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { WorkbenchButtonBar } from '../../../../../../platform/actions/browser/buttonbar.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { getActionBarActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { formatCopilotCredits } from '../../../common/chatService/chatService.js'; +import { formatCopilotCreditsLabel } from '../../../common/chatService/chatService.js'; import type { IChatWidget } from '../../chat.js'; const $ = dom.$; @@ -167,10 +167,7 @@ export class ChatContextUsageDetails extends Disposable { // Update session cost — hide section when no cost data is available if (typeof sessionCost === 'number' && sessionCost > 0) { - const formatted = formatCopilotCredits(sessionCost); - this.sessionCostValue.textContent = formatted === '1' - ? localize('sessionCostCredit', "{0} credit", formatted) - : localize('sessionCostCredits', "{0} credits", formatted); + this.sessionCostValue.textContent = formatCopilotCreditsLabel(sessionCost); this.sessionCostSection.style.display = ''; } else { this.sessionCostSection.style.display = 'none'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 86e2617792a02a..f82b3e2e7a656c 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../../../../nls.js'; import { IAction } from '../../../../../base/common/actions.js'; import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; @@ -218,6 +219,17 @@ export function formatCopilotCredits(credits: number): string { return parseFloat(credits.toFixed(1)).toString(); } +/** + * Formats a credit value as a pluralized label such as "1 credit" or "2.5 credits". + * Shared so every credit readout agrees. + */ +export function formatCopilotCreditsLabel(credits: number): string { + const formatted = formatCopilotCredits(credits); + return formatted === '1' + ? localize('chat.credit', "{0} credit", formatted) + : localize('chat.credits', "{0} credits", formatted); +} + export interface IChatContentInlineReference { resolveId?: string; inlineReference: URI | Location | IWorkspaceSymbol; diff --git a/src/vs/workbench/contrib/chat/common/expPayload.ts b/src/vs/workbench/contrib/chat/common/expPayload.ts new file mode 100644 index 00000000000000..b33b52bd503490 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/expPayload.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** Selector matching and payload reading shared by JSON payloads delivered as experiment treatments. */ + +import { ILanguageModelChatMetadata } from './languageModels.js'; + +const MAX_SELECTORS = 32; + +export const MAX_ID_LENGTH = 64; + +/** Ids appear in telemetry, so they are restricted to a shape that needs no sanitization. */ +export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Reads the versioned envelope every treatment payload shares, returning the object or the + * reason it was rejected. Never throws, so a malformed treatment cannot break a caller. + */ +export function parseExpPayloadEnvelope(raw: string | undefined, version: number): Record | string { + if (typeof raw !== 'string' || !raw.trim()) { + return 'empty payload'; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + return `payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`; + } + + if (!isObject(parsed)) { + return 'payload is not an object'; + } + if (parsed.version !== version) { + return `unsupported version ${JSON.stringify(parsed.version)}, expected ${version}`; + } + return parsed; +} + +/** Reads a trimmed string, optionally lower-cased and constrained to `pattern`. */ +export function readText(raw: unknown, maxLength: number, pattern?: RegExp): string | undefined { + if (typeof raw !== 'string') { + return undefined; + } + const trimmed = pattern ? raw.trim().toLowerCase() : raw.trim(); + if (!trimmed || trimmed.length > maxLength || (pattern && !pattern.test(trimmed))) { + return undefined; + } + return trimmed; +} + +export function normalizeSelector(value: string): string { + return value.trim().toLowerCase().replace(/[\s_]+/g, '-'); +} + +/** Reads a list of match selectors, normalizing each so authoring casing does not matter. */ +export function readSelectorList(raw: unknown, path: string): string[] | string { + if (raw === undefined) { + return []; + } + if (!Array.isArray(raw)) { + return `${path} must be an array of strings`; + } + if (raw.length > MAX_SELECTORS) { + return `${path} exceeds ${MAX_SELECTORS} entries`; + } + const out: string[] = []; + for (const entry of raw) { + if (typeof entry !== 'string') { + return `${path} must contain only strings`; + } + const normalized = normalizeSelector(entry); + if (!normalized) { + return `${path} must not contain empty strings`; + } + out.push(normalized); + } + return out; +} + +/** + * Builds every string a selector may match for a model. + * + * Identifiers are qualified differently across harnesses. The language model service uses + * `//` while agent host sessions use `:`, so a selector is + * compared against each segment as well as the whole id. That lets `auto` match both + * `copilot/auto` and `agent-host-copilotcli:auto`. + */ +export function expandModelMatchCandidates(modelId: string | undefined, aliases?: readonly string[]): Set { + const candidates = new Set(); + const add = (value: string | undefined): void => { + const normalized = value === undefined ? '' : normalizeSelector(value); + if (normalized) { + candidates.add(normalized); + } + }; + + if (modelId) { + add(modelId); + for (const segment of modelId.split(/[/:]/)) { + add(segment); + } + } + for (const alias of aliases ?? []) { + add(alias); + } + + return candidates; +} + +/** The identifiers a selector may name a model by, besides its qualified id. */ +export function modelSelectorAliases(metadata: ILanguageModelChatMetadata): string[] { + return [metadata.id, metadata.family, metadata.name, metadata.vendor]; +} diff --git a/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyConfig.ts b/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyConfig.ts index acf05a5f6513e5..47960f7ec4ccb9 100644 --- a/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyConfig.ts +++ b/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyConfig.ts @@ -12,6 +12,8 @@ * but cannot share code today because that renderer needs telemetry keys known at compile time. */ +import { expandModelMatchCandidates, ID_PATTERN, isObject, MAX_ID_LENGTH, normalizeSelector, parseExpPayloadEnvelope, readSelectorList, readText } from '../expPayload.js'; + /** Payload versions this build understands. Bump when making a breaking shape change. */ export const CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION = 1; @@ -21,18 +23,13 @@ export const CHAT_MODEL_FEEDBACK_SURVEY_NO_HARNESS = 'none'; const MAX_STEPS = 8; const MIN_OPTIONS = 2; const MAX_OPTIONS = 8; -const MAX_ID_LENGTH = 64; const MAX_TITLE_LENGTH = 200; const MAX_LABEL_LENGTH = 120; const MAX_PLACEHOLDER_LENGTH = 100; const MAX_COMMENT_LENGTH = 1000; -const MAX_SELECTORS = 32; const MATCH_FIELDS = ['selectedModels', 'resolvedModels', 'modes', 'harnesses', 'sessionTypes'] as const; -/** Ids appear in telemetry, so they are restricted to a shape that needs no sanitization. */ -const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; - export const enum ChatModelFeedbackSurveyStepKind { Choice = 'choice', Text = 'text', @@ -151,26 +148,12 @@ export interface IChatModelFeedbackSurveyMatchContext { * measures. */ export function parseChatModelFeedbackSurveyConfig(raw: string | undefined): ChatModelFeedbackSurveyParseResult { - if (typeof raw !== 'string' || !raw.trim()) { - return { error: 'empty payload' }; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (err) { - return { error: `payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }; - } - - if (!isObject(parsed)) { - return { error: 'payload is not an object' }; - } - - if (parsed.version !== CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION) { - return { error: `unsupported version ${JSON.stringify(parsed.version)}, expected ${CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION}` }; + const parsed = parseExpPayloadEnvelope(raw, CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION); + if (typeof parsed === 'string') { + return { error: parsed }; } - const id = readId(parsed.id); + const id = readText(parsed.id, MAX_ID_LENGTH, ID_PATTERN); if (!id) { return { error: 'missing or malformed survey id' }; } @@ -221,29 +204,6 @@ function readMatch(raw: unknown): IChatModelFeedbackSurveyMatch | string { }; } -function readSelectorList(raw: unknown, path: string): string[] | string { - if (raw === undefined) { - return []; - } - if (!Array.isArray(raw)) { - return `${path} must be an array of strings`; - } - if (raw.length > MAX_SELECTORS) { - return `${path} exceeds ${MAX_SELECTORS} entries`; - } - const out: string[] = []; - for (const entry of raw) { - if (typeof entry !== 'string') { - return `${path} must contain only strings`; - } - const normalized = normalizeSelector(entry); - if (!normalized) { - return `${path} must not contain empty strings`; - } - out.push(normalized); - } - return out; -} /** * Reads the automatic prompting rules. An omitted `prompt` block gives a manual only survey, so @@ -378,7 +338,7 @@ function readStep(raw: unknown, index: number): ChatModelFeedbackSurveyStep | st return `steps[${index}] must be an object`; } - const id = readId(raw.id); + const id = readText(raw.id, MAX_ID_LENGTH, ID_PATTERN); if (!id) { return `steps[${index}].id is missing or malformed`; } @@ -421,7 +381,7 @@ function readStep(raw: unknown, index: number): ChatModelFeedbackSurveyStep | st if (!isObject(option)) { return `steps[${index}].options[${i}] must be an object`; } - const optionId = readId(option.id); + const optionId = readText(option.id, MAX_ID_LENGTH, ID_PATTERN); if (!optionId) { return `steps[${index}].options[${i}].id is missing or malformed`; } @@ -439,27 +399,7 @@ function readStep(raw: unknown, index: number): ChatModelFeedbackSurveyStep | st return { kind: ChatModelFeedbackSurveyStepKind.Choice, id, title, options }; } -function readId(raw: unknown): string | undefined { - if (typeof raw !== 'string') { - return undefined; - } - const trimmed = raw.trim().toLowerCase(); - if (!trimmed || trimmed.length > MAX_ID_LENGTH || !ID_PATTERN.test(trimmed)) { - return undefined; - } - return trimmed; -} -function readText(raw: unknown, maxLength: number): string | undefined { - if (typeof raw !== 'string') { - return undefined; - } - const trimmed = raw.trim(); - if (!trimmed || trimmed.length > maxLength) { - return undefined; - } - return trimmed; -} function readPositiveInteger(raw: unknown, fallback: number): number | undefined { if (raw === undefined) { @@ -491,39 +431,7 @@ function readProbability(raw: unknown, fallback: number): number | undefined { return raw; } -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} -/** - * Builds every string a selector may match for a model. - * - * Identifiers are qualified differently across harnesses. The language model service uses - * `//` while agent host sessions use `:`, so a selector is - * compared against each segment as well as the whole id. That lets `auto` match both - * `copilot/auto` and `agent-host-copilotcli:auto`. - */ -export function expandModelMatchCandidates(modelId: string | undefined, aliases?: readonly string[]): Set { - const candidates = new Set(); - const add = (value: string | undefined): void => { - const normalized = value === undefined ? '' : normalizeSelector(value); - if (normalized) { - candidates.add(normalized); - } - }; - - if (modelId) { - add(modelId); - for (const segment of modelId.split(/[/:]/)) { - add(segment); - } - } - for (const alias of aliases ?? []) { - add(alias); - } - - return candidates; -} /** Whether the response described by `context` should be offered `config`'s survey. */ export function matchesChatModelFeedbackSurvey(config: IChatModelFeedbackSurveyConfig, context: IChatModelFeedbackSurveyMatchContext): boolean { @@ -563,6 +471,3 @@ function matchesScalar(selectors: readonly string[], value: string | undefined): return !!normalized && selectors.includes(normalized); } -function normalizeSelector(value: string): string { - return value.trim().toLowerCase().replace(/[\s_]+/g, '-'); -} diff --git a/src/vs/workbench/contrib/chat/common/languageModelSourcePresentation.ts b/src/vs/workbench/contrib/chat/common/languageModelSourcePresentation.ts index 2c832441074137..170f2b2a40c02c 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelSourcePresentation.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelSourcePresentation.ts @@ -5,6 +5,9 @@ import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID } from '../../../../platform/agentHost/common/agentModelSource.js'; +import { ILanguageModelChatMetadataAndIdentifier } from './languageModels.js'; /** Presentation for a trusted model source owned by one language-model vendor. */ export interface ILanguageModelSourcePresentation { @@ -46,3 +49,22 @@ class LanguageModelSourcePresentationRegistry implements ILanguageModelSourcePre } export const languageModelSourcePresentationRegistry: ILanguageModelSourcePresentationRegistry = new LanguageModelSourcePresentationRegistry(); + +/** + * Adds the source label to models provided by a ChatGPT subscription. Other model names remain + * unadorned. + */ +export function getLanguageModelDisplayNameWithSubscriptionSource( + model: ILanguageModelChatMetadataAndIdentifier, + displayName = model.metadata.name, +): string { + const modelGroup = model.metadata.modelGroup; + if (modelGroup?.sourceId !== CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID) { + return displayName; + } + + const sourceLabel = languageModelSourcePresentationRegistry.get(model.metadata.vendor, modelGroup.sourceId)?.label; + return sourceLabel + ? localize('chat.languageModelNameWithSubscriptionSource', "{0} ({1})", displayName, sourceLabel) + : displayName; +} diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 7391a7cb270516..b47809d7596efe 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -336,12 +336,14 @@ export interface ILanguageModelChatMetadata { * surfaces the full promotional UI; `0` is a message-only promo that features the * model without a price change; a negative value is malformed and is ignored. * `endsAt` is optional — open-ended promos omit it and render no end date. + * `showBanner` is optional — the promo is banner-eligible unless it is `false`. */ readonly promo?: { readonly id: string; readonly discountPercent: number; readonly endsAt?: string; readonly message: string; + readonly showBanner?: boolean; }; } @@ -371,6 +373,14 @@ export namespace ILanguageModelChatMetadata { return !!metadata.promo && metadata.promo.discountPercent >= 0 && !!metadata.promo.message; } + /** + * Whether the model's promo may also be surfaced as a banner above the chat input. + * Promos are banner-eligible by default; `showBanner: false` keeps them in the model picker only. + */ + export function hasPromoBanner(metadata: ILanguageModelChatMetadata): metadata is ILanguageModelChatMetadata & { readonly promo: NonNullable } { + return hasPromoMessage(metadata) && metadata.promo.showBanner !== false; + } + /** The localized "Ends {date}." sentence, or `undefined` for a missing or unparsable end date. */ export function getPromoEndsAtLabel(endsAt: string | undefined): string | undefined { if (!endsAt) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts new file mode 100644 index 00000000000000..8e7b3d89a1fe35 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { PromptFileSource, PromptsType } from '../promptTypes.js'; +import { PromptsStorage } from './promptsService.js'; + +export const ICustomizationMigrationService = createDecorator('customizationMigrationService'); + +export enum CustomizationMigrationType { + UserData = 'userData', + PromptFiles = 'promptFiles', +} + +export interface MigratableConfiguration { + readonly uri: URI; + readonly type: PromptsType; + readonly storage: PromptsStorage; + readonly name?: string; + readonly description?: string; + readonly source?: PromptFileSource; +} + +export function getCustomizationMigrationTargetType(customization: MigratableConfiguration): PromptsType { + return customization.type === PromptsType.prompt ? PromptsType.skill : customization.type; +} + +export function isPromptFileMigrationCandidate(customization: MigratableConfiguration): boolean { + return customization.type === PromptsType.prompt + && (customization.storage === PromptsStorage.local || customization.storage === PromptsStorage.user); +} + +export function isUserDataMigrationCandidate(customization: MigratableConfiguration): boolean { + return customization.source === PromptFileSource.UserData + && (customization.type === PromptsType.agent || customization.type === PromptsType.instructions); +} + +export interface CustomizationMigration { + readonly type: CustomizationMigrationType; + readonly files: readonly URI[]; + readonly candidates: readonly MigratableConfiguration[]; +} + +export interface ICustomizationMigrationService { + readonly _serviceBrand: undefined; + + computeMigration(sessionResource: URI, type: CustomizationMigrationType): Promise; + computeMigrations(sessionResource: URI): Promise; + computeMigrationHint(sessionResource: URI): Promise; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationServiceImpl.ts new file mode 100644 index 00000000000000..a6ebb411ed3414 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationServiceImpl.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { isAgentHostSessionResource } from '../../chatSessionsService.js'; +import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../customizationHarnessService.js'; +import { getChatSessionType } from '../../model/chatUri.js'; +import { PromptsType } from '../promptTypes.js'; +import { IPromptsService } from './promptsService.js'; +import { CustomizationMigration, CustomizationMigrationType, getCustomizationMigrationTargetType, ICustomizationMigrationService, isPromptFileMigrationCandidate, isUserDataMigrationCandidate, MigratableConfiguration } from './customizationMigrationService.js'; + +export class CustomizationMigrationService implements ICustomizationMigrationService { + declare readonly _serviceBrand: undefined; + + constructor( + @IPromptsService private readonly promptsService: IPromptsService, + @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, + ) { } + + async computeMigration(sessionResource: URI, type: CustomizationMigrationType): Promise { + if (!isAgentHostSessionResource(sessionResource)) { + return { type, files: [], candidates: [] }; + } + + switch (type) { + case CustomizationMigrationType.UserData: { + const customizations = (await Promise.all([ + this.promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None), + this.promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), + ])).flat(); + const candidates = customizations.filter(isUserDataMigrationCandidate); + return this.createMigration(sessionResource, type, candidates); + } + case CustomizationMigrationType.PromptFiles: { + const customizations = await this.promptsService.listPromptFiles(PromptsType.prompt, CancellationToken.None); + const candidates = customizations.filter(isPromptFileMigrationCandidate); + return this.createMigration(sessionResource, type, candidates); + } + } + } + + private async createMigration(sessionResource: URI, type: CustomizationMigrationType, candidates: readonly MigratableConfiguration[]): Promise { + const provider = this.customizationHarnessService.findHarnessById(getChatSessionType(sessionResource))?.itemProvider; + if (!provider?.provideSourceFolders) { + return { type, files: [], candidates: [] }; + } + + const targetTypes = new Set(candidates.map(getCustomizationMigrationTargetType)); + const sourceFolders = new Map(); + for (const targetType of targetTypes) { + const folders = await provider.provideSourceFolders(sessionResource, targetType, CancellationToken.None); + sourceFolders.set(targetType, folders ?? []); + } + const filteredCandidates = candidates.filter(customization => { + const targetType = getCustomizationMigrationTargetType(customization); + return sourceFolders.get(targetType)?.some(folder => folder.source === customization.storage) === true; + }); + return { type, files: filteredCandidates.map(customization => customization.uri), candidates: filteredCandidates }; + } + + async computeMigrations(sessionResource: URI): Promise { + return Promise.all([ + this.computeMigration(sessionResource, CustomizationMigrationType.UserData), + this.computeMigration(sessionResource, CustomizationMigrationType.PromptFiles), + ]); + } + + async computeMigrationHint(sessionResource: URI): Promise { + const harness = this.customizationHarnessService.findHarnessById(getChatSessionType(sessionResource)); + if (!harness) { + return undefined; + } + + const migrations = await this.computeMigrations(sessionResource); + const fileCount = migrations.reduce((total, migration) => total + migration.files.length, 0); + if (fileCount === 0) { + return undefined; + } + return fileCount === 1 + ? localize('customizationMigrationHintSingle', "Found 1 customization file that is present but not used by {0} and could be migrated.", harness.label) + : localize('customizationMigrationHintMultiple', "Found {0} customization files that are present but not used by {1} and could be migrated.", fileCount, harness.label); + } +} diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts new file mode 100644 index 00000000000000..dadeb3681a0ccd --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { KeyCode, KeyMod } from '../../../../../../base/common/keyCodes.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ContextKeyValue, IContext } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { ChatAgentLocation } from '../../../common/constants.js'; +import { NewChatAction } from '../../../browser/actions/chatNewActions.js'; + +function context(values: Record): IContext { + return { getValue: (key: string) => values[key] as T | undefined }; +} + +suite('Chat new actions', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('Ctrl+N opens a new editor from a chat editor without clearing the current chat', () => { + const action = new NewChatAction(); + const keybinding = Array.isArray(action.desc.keybinding) ? action.desc.keybinding[0] : action.desc.keybinding; + assert.ok(keybinding?.when); + + const chatContext = { + [ChatContextKeys.enabled.key]: true, + [ChatContextKeys.inChatSession.key]: true, + [ChatContextKeys.location.key]: ChatAgentLocation.Chat, + }; + const chatViewContext = context({ + ...chatContext, + [ChatContextKeys.inChatEditor.key]: false, + }); + const chatEditorContext = context({ + ...chatContext, + [ChatContextKeys.inChatEditor.key]: true, + }); + + assert.deepStrictEqual({ + primary: keybinding.primary, + newChatInView: keybinding.when.evaluate(chatViewContext), + newChatInEditor: keybinding.when.evaluate(chatEditorContext), + }, { + primary: KeyMod.CtrlCmd | KeyCode.KeyN, + newChatInView: true, + newChatInEditor: false, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 8ecfc8a742eb34..830e5aa4acf663 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -5266,6 +5266,79 @@ suite('AgentHostChatContribution', () => { }); })); + test('subagent model reads Auto when explainability is hidden and the routed model when it is not', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // The subagent bills to the model Auto routed it to. Which of the two + // names the pill shows is the whole point of the treatment. + const runWithTreatment = async (hideAutoExplainability: boolean): Promise => { + const languageModels = new Map([ + ['agent-host-copilot:auto', upcastPartial({ name: 'Auto' })], + ['agent-host-copilot:gpt-5.4-mini', upcastPartial({ name: 'GPT-5.4 mini' })], + ]); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { + languageModels, + hideAutoExplainability, + }); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + userSelectedModelId: 'agent-host-copilot:auto', + }); + + const parentToolCallId = `tc-sub-auto-${hideAutoExplainability}`; + const parentSession = parseDefaultChatUri(session); + assert.ok(parentSession); + const childSessionUri = buildSubagentChatUri(parentSession, parentToolCallId); + fire({ + type: 'chat/toolCallStart', session, turnId, + toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', + _meta: { toolKind: 'subagent', subagentDescription: 'research' }, + } as ChatAction); + fire({ + type: 'chat/toolCallReady', session, turnId, + toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', + confirmed: 'not-needed', + } as ChatAction); + fire({ + type: 'chat/toolCallContentChanged', session, turnId, + toolCallId: parentToolCallId, + content: [{ type: ToolResultContentType.Subagent, resource: childSessionUri, title: 'Subagent' }], + } as ChatAction); + + await timeout(50); + + // The subagent's own turn reports the model Auto routed it to. + const childTurnId = `child-turn-auto-${hideAutoExplainability}`; + const fireChild = (action: SessionAction | ChatAction) => { + agentHostService.fireAction({ channel: childSessionUri, action, serverSeq: 1000, origin: undefined }); + }; + fireChild({ + type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', + turnId: childTurnId, + message: { text: '', origin: { kind: MessageKind.User } }, + } as ChatAction); + fireChild({ + type: 'chat/usage', session: childSessionUri, turnId: childTurnId, + usage: { model: 'gpt-5.4-mini', _meta: { autoModeResolved: { chosenModel: 'gpt-5.4-mini' } } }, + } as ChatAction); + + await timeout(50); + + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + + const subagentInvocation = collected.flat() + .filter((p): p is IChatToolInvocation => p.kind === 'toolInvocation') + .find(p => p.toolSpecificData?.kind === 'subagent'); + return (subagentInvocation?.toolSpecificData as IChatSubagentToolInvocationData | undefined)?.modelName; + }; + + assert.deepStrictEqual({ + hidden: await runWithTreatment(true), + shown: await runWithTreatment(false), + }, { + hidden: 'Auto', + shown: 'GPT-5.4 mini', + }); + })); + test('tool_start events become toolInvocation progress', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts index 8a1ac7cff72481..d9abdeed601314 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts @@ -63,6 +63,7 @@ class FakeNotificationService implements IChatInputNotificationService { } dismissNotification(_id: string): void { /* */ } getActiveNotification(): IChatInputNotification | undefined { return undefined; } + refresh(): void { /* */ } handleMessageSent(): void { /* */ } announceRendered(): void { /* */ } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPromptCacheNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPromptCacheNotification.test.ts index d7002a247b53b7..c4a91427dc2f61 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPromptCacheNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPromptCacheNotification.test.ts @@ -147,6 +147,7 @@ class TestNotificationService implements IChatInputNotificationService { getActiveNotification(): IChatInputNotification | undefined { return [...this.notifications.values()].find(notification => !this.dismissed.has(notification.id)); } + refresh(): void { } handleMessageSent(): void { } announceRendered(): void { } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index 832e5ee08b23cf..d9751e4d897166 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -111,6 +111,29 @@ suite('AgentSessions', () => { }); }); + test('preserves a registered provider\'s sessions when a sibling provider refreshes with a partial list', async () => { + return runWithFakedTimers({}, async () => { + // Two providers registered only as item controllers (no static + // contribution) — mirrors the agent host (`agent-host-copilotcli`), + // which is neither built-in nor a static contribution. + const siblingController = new StaticChatSessionItemController([makeSimpleSessionItem('eh-1', { resource: URI.parse('type-eh://eh-1') })]); + const agentHostController = new StaticChatSessionItemController([makeSimpleSessionItem('ah-1', { resource: URI.parse('type-agenthost://ah-1') })]); + mockChatSessionsService.registerChatSessionItemController('type-eh', siblingController); + mockChatSessionsService.registerChatSessionItemController('type-agenthost', agentHostController); + viewModel = createViewModel(); + await viewModel.resolve(undefined); + assert.strictEqual(viewModel.sessions.length, 2); + + // The sibling provider refreshes with a partial (here empty) list — as + // the extension-host Copilot CLI provider does mid-migration. This must + // NOT drop the other provider's rows (regression: they used to vanish). + siblingController.setItems([]); + await viewModel.resolve('type-eh'); + + assert.deepStrictEqual(viewModel.sessions.map(s => s.resource.toString()), ['type-agenthost://ah-1']); + }); + }); + test('should preserve change summaries when lazy refresh omits changes', async () => { return runWithFakedTimers({}, async () => { const controller = new StaticChatSessionItemController([ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsOpener.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsOpener.test.ts index c1b7ccf18513e9..2f316e92808295 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsOpener.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsOpener.test.ts @@ -13,6 +13,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -99,6 +100,7 @@ suite('AgentSessionsOpener', () => { instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(ITelemetryService, NullTelemetryService); instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.MigrateLegacyCopilotCliSessions]: true })); + instantiationService.stub(IProgressService, upcastPartial({ withProgress: (_options, task) => task({ report() { } }) })); // A host that answers the adoption probe with state, i.e. migration succeeded. instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ ambientConnection: new class extends mock() { @@ -156,6 +158,7 @@ suite('AgentSessionsOpener', () => { instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(ITelemetryService, NullTelemetryService); instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.MigrateLegacyCopilotCliSessions]: true })); + instantiationService.stub(IProgressService, upcastPartial({ withProgress: (_options, task) => task({ report() { } }) })); instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ ambientConnection: new class extends mock() { override getSubscription(): IReference> { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index e2cee3a93651e2..3ec5fb6c8ef758 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -15,12 +15,13 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { URI } from '../../../../../../base/common/uri.js'; import { AICustomizationManagementEditor, isCurrentPluginContributionNavigation } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; import { ChatConfiguration } from '../../../common/constants.js'; -import { IPromptPath, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { MigratableConfiguration } from '../../../common/promptSyntax/service/customizationMigrationService.js'; +import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { IHeaderAttribute } from '../../../common/promptSyntax/promptFileParser.js'; import { PromptFileSource, PromptsType, Target } from '../../../common/promptSyntax/promptTypes.js'; import { AICustomizationManagementSection, AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; -import type { ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; import { CustomizationMigrationCategoryId } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; +import type { ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; import type { ICustomizationMigrationCategorySummary } from '../../../browser/aiCustomization/aiCustomizationWelcomePage.js'; import { AICustomizationManagementEditorInput } from '../../../browser/aiCustomization/aiCustomizationManagementEditorInput.js'; @@ -54,7 +55,7 @@ suite('aiCustomizationManagementEditor', () => { currentEditingPromptType: PromptsType | undefined; currentEditingSource: string | undefined; currentEditingReadOnly: boolean; - customizationsByMigrationCategory: Map; + customizationsByMigrationCategory: Map; customizationMigrationTargetFoldersByType: Map; activeMigrationCategoryId: CustomizationMigrationCategoryId | undefined; editorDisplayMode: 'preview' | 'raw'; @@ -89,10 +90,9 @@ suite('aiCustomizationManagementEditor', () => { refreshCustomizationMigrationInfo(): Promise; registerCustomizationMigrationSessionRefresh(): void; renderCustomizationMigrationPage(): void; - setCustomizationsToMigrate(candidates: Map, targetFoldersByType: Map): void; - filterCustomizationMigrationCandidatesByTargetFolders(customizations: readonly IPromptPath[], targetFoldersByType: ReadonlyMap): readonly IPromptPath[]; - isCustomizationSelectedForMigration(customization: IPromptPath): boolean; - setCustomizationSelectedForMigration(customization: IPromptPath, selected: boolean): void; + setCustomizationsToMigrate(candidates: Map, targetFoldersByType: Map): void; + isCustomizationSelectedForMigration(customization: MigratableConfiguration): boolean; + setCustomizationSelectedForMigration(customization: MigratableConfiguration, selected: boolean): void; updateContentVisibility(): void; setVisible(visible: boolean): void; }; @@ -277,13 +277,13 @@ suite('aiCustomizationManagementEditor', () => { storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace, - } as IPromptPath]], + } as MigratableConfiguration]], [CustomizationMigrationCategoryId.UserData, [{ uri: URI.file('/user-data/prompts/legacy.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, source: PromptFileSource.UserData, - } as IPromptPath]], + } as MigratableConfiguration]], ]); editor.welcomePage = { setMigrationCategories: categories => welcomePageCalls.push([...categories as readonly ICustomizationMigrationCategorySummary[]]), @@ -308,19 +308,19 @@ suite('aiCustomizationManagementEditor', () => { [ChatConfiguration.ChatCustomizationsPromptMigrationEnabled]: true, })); const sharedUri = URI.file('/home/user/shared.prompt.md'); - const workspacePrompt: IPromptPath = { + const workspacePrompt: MigratableConfiguration = { uri: sharedUri, storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.ConfigWorkspace, }; - const userPrompt: IPromptPath = { + const userPrompt: MigratableConfiguration = { uri: sharedUri, storage: PromptsStorage.user, type: PromptsType.prompt, source: PromptFileSource.ConfigPersonal, }; - const candidates = new Map([ + const candidates = new Map([ [CustomizationMigrationCategoryId.PromptFiles, [workspacePrompt, userPrompt]], ]); @@ -356,12 +356,12 @@ suite('aiCustomizationManagementEditor', () => { storage: PromptsStorage.user, type: PromptsType.instructions, source: PromptFileSource.UserData, - } as IPromptPath], + } as MigratableConfiguration], ]]); editor.customizationMigrationTargetFoldersByType = new Map([[ PromptsType.instructions, [{ - uri: URI.file(`/home/test/.test-harness${sessionResource.path}/instructions`), + uri: URI.file('/home/test/.test-harness' + sessionResource.path + '/instructions'), label: sessionResource.path, source: AICustomizationSources.user, }], @@ -383,37 +383,6 @@ suite('aiCustomizationManagementEditor', () => { editor.editorPreviewDisposables.dispose(); }); - test('only offers migration candidates with a supported destination folder', () => { - const editor = createTestEditor(); - const promptFile: IPromptPath = { - uri: URI.file('/user-data/prompts/review.prompt.md'), - storage: PromptsStorage.user, - type: PromptsType.prompt, - source: PromptFileSource.UserData, - }; - const agent: IPromptPath = { - uri: URI.file('/user-data/prompts/reviewer.agent.md'), - storage: PromptsStorage.user, - type: PromptsType.agent, - source: PromptFileSource.UserData, - }; - const instruction: IPromptPath = { - uri: URI.file('/user-data/prompts/style.instructions.md'), - storage: PromptsStorage.user, - type: PromptsType.instructions, - source: PromptFileSource.UserData, - }; - const targetFolders = new Map([ - [PromptsType.skill, [{ uri: URI.file('/home/test/.copilot/skills'), label: 'Skills', source: AICustomizationSources.user }]], - ]); - - assert.deepStrictEqual( - editor.filterCustomizationMigrationCandidatesByTargetFolders([promptFile, agent, instruction], targetFolders).map(customization => customization.uri.path), - ['/user-data/prompts/review.prompt.md'], - ); - editor.editorPreviewDisposables.dispose(); - }); - test('migration banners include destination consequences when applicable', () => { const editor = createTestEditor(undefined, createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsUserDataMigrationEnabled]: true, @@ -426,14 +395,14 @@ suite('aiCustomizationManagementEditor', () => { storage: PromptsStorage.user, type: PromptsType.agent, source: PromptFileSource.UserData, - } as IPromptPath, + } as MigratableConfiguration, { uri: URI.file('/user-data/prompts/style.instructions.md'), name: 'style.instructions.md', storage: PromptsStorage.user, type: PromptsType.instructions, source: PromptFileSource.UserData, - } as IPromptPath, + } as MigratableConfiguration, ]; const promptFiles = [ { @@ -442,7 +411,7 @@ suite('aiCustomizationManagementEditor', () => { storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace, - } as IPromptPath, + } as MigratableConfiguration, ]; editor.customizationsByMigrationCategory = new Map([ [CustomizationMigrationCategoryId.UserData, userDataCustomizations], @@ -505,7 +474,7 @@ suite('aiCustomizationManagementEditor', () => { const editor = createTestEditor(undefined, createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsPromptMigrationEnabled]: true, })); - const promptFile: IPromptPath = { + const promptFile: MigratableConfiguration = { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), name: 'Review', storage: PromptsStorage.local, @@ -563,14 +532,14 @@ suite('aiCustomizationManagementEditor', () => { storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace, - } as IPromptPath, + } as MigratableConfiguration, { uri: URI.file('/workspace/.github/prompts/workspace-b.prompt.md'), name: 'workspace-b.prompt.md', storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace, - } as IPromptPath, + } as MigratableConfiguration, ]; editor.customizationsByMigrationCategory = new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]]); editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; @@ -648,28 +617,28 @@ suite('aiCustomizationManagementEditor', () => { storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace, - } as IPromptPath, + } as MigratableConfiguration, { uri: URI.file('/workspace/.github/prompts/workspace-b.prompt.md'), name: 'workspace-b.prompt.md', storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace, - } as IPromptPath, + } as MigratableConfiguration, { uri: URI.file('/user-data/prompts/user-a.prompt.md'), name: 'user-a.prompt.md', storage: PromptsStorage.user, type: PromptsType.prompt, source: PromptFileSource.UserData, - } as IPromptPath, + } as MigratableConfiguration, { uri: URI.file('/user-data/prompts/user-b.prompt.md'), name: 'user-b.prompt.md', storage: PromptsStorage.user, type: PromptsType.prompt, source: PromptFileSource.UserData, - } as IPromptPath, + } as MigratableConfiguration, ]; editor.customizationsByMigrationCategory = new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]]); editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; @@ -714,14 +683,14 @@ suite('aiCustomizationManagementEditor', () => { storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace, - } as IPromptPath, + } as MigratableConfiguration, { uri: URI.file('/workspace/.github/prompts/workspace-b.prompt.md'), name: 'workspace-b.prompt.md', storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace, - } as IPromptPath, + } as MigratableConfiguration, ]; editor.customizationsByMigrationCategory = new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]]); editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts index d9b48ea649eab8..1e9360377f8fb3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts @@ -81,6 +81,7 @@ function createMockNotificationService(disposables: Pick } return active; }, + refresh() { }, handleMessageSent() { }, announceRendered() { }, }; @@ -235,6 +236,24 @@ suite('ChatPromoNotificationContribution', () => { assert.strictEqual(notifService.getNotification(), undefined); }); + test('skips a promo that opts out of the banner', () => { + const notifService = createMockNotificationService(disposables); + const { service: lmService } = createMockLanguageModelsService([ + { identifier: 'copilot:picker-only', metadata: { name: 'Picker Only', id: 'picker-only', promo: { id: 'promo-picker-only', discountPercent: 20, message: 'Get 20% off', showBanner: false } } }, + { identifier: 'copilot:featured', metadata: { name: 'Featured', id: 'featured', promo: { id: 'promo-featured', discountPercent: 0, message: 'Featured model' } } }, + ], disposables); + const storageService = disposables.add(new InMemoryStorageService()); + + disposables.add(new ChatPromoNotificationContribution( + lmService, + notifService.service, + storageService, + )); + + // The opt-out promo is skipped even though its discount would otherwise win the harness. + assert.strictEqual(notifService.getNotification()?.message, 'Featured model'); + }); + test('omits the end date when the promo has none', () => { const notifService = createMockNotificationService(disposables); const { service: lmService } = createMockLanguageModelsService([ diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 9e545ad99dd284..390c69fee5a7a9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -122,6 +122,7 @@ function createMockNotificationService() { } return !filter || filter(lastNotification) ? lastNotification : undefined; }, + refresh() { }, handleMessageSent() { }, announceRendered() { }, }; diff --git a/src/vs/workbench/contrib/chat/test/browser/expNotification/chatExpNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/expNotification/chatExpNotification.test.ts new file mode 100644 index 00000000000000..e64bda8c2d78b7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/expNotification/chatExpNotification.test.ts @@ -0,0 +1,355 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ChatAIDisabledSettingId } from '../../../../../../platform/chat/common/chatSettings.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IAssignmentFilter, IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; +import { CHAT_EXP_NOTIFICATION_VERSION, IChatExpNotificationMatchContext, matchesChatExpNotification, parseChatExpNotifications } from '../../../browser/expNotification/chatExpNotificationConfig.js'; +import { ChatExpNotificationContribution } from '../../../browser/expNotification/chatExpNotificationContribution.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationContext, IChatInputNotificationService } from '../../../browser/widget/input/chatInputNotificationService.js'; +import { getChatSessionType, LocalChatSessionUri } from '../../../common/model/chatUri.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; + +suite('ChatExpNotification', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const validNotification = { + id: 'auto-tier-nudge', + title: 'Auto now picks stronger models', + description: 'Auto routes each request to the best available model.', + match: { sessionTypes: ['copilotcli'], selectedModels: ['gpt-4o'] }, + actions: [ + { id: 'learn', kind: 'command', label: 'Learn More', commandId: 'vscode.open', args: ['https://aka.ms/x'] }, + { id: 'switch', kind: 'switchToModel', label: 'Switch to Auto', model: 'auto', config: { thinkingLevel: 'high' } }, + ], + }; + + function payload(...notifications: unknown[]): string { + return JSON.stringify({ version: CHAT_EXP_NOTIFICATION_VERSION, notifications }); + } + + function parseOne(notification: unknown): ReturnType { + return parseChatExpNotifications(payload(notification)); + } + + suite('config', () => { + + test('builds a runtime notification from a well formed payload', () => { + const parsed = parseOne(validNotification).notifications?.[0]; + + assert.deepStrictEqual({ + ...parsed, + signature: undefined, + actions: parsed?.actions.map(action => ({ ...action, matchesModel: undefined })), + }, { + id: 'auto-tier-nudge', + telemetryId: 'auto-tier-nudge', + severity: ChatInputNotificationSeverity.Info, + message: 'Auto now picks stronger models', + description: 'Auto routes each request to the best available model.', + match: { sessionTypes: ['copilotcli'], selectedModels: ['gpt-4o'], excludeSelectedModels: [] }, + signature: undefined, + actions: [ + { kind: ChatInputNotificationActionKind.Command, label: 'Learn More', telemetryActionId: 'learn', commandId: 'vscode.open', commandArgs: ['https://aka.ms/x'], matchesModel: undefined }, + { kind: ChatInputNotificationActionKind.SwitchToModel, label: 'Switch to Auto', telemetryActionId: 'switch', config: { thinkingLevel: 'high' }, requireUniqueModel: true, matchesModel: undefined }, + ], + dismissible: true, + autoDismissOnMessage: false, + }); + }); + + test('normalizes a switch target the same way a match selector is normalized', () => { + const model: ILanguageModelChatMetadataAndIdentifier = { + identifier: 'copilot/claude-sonnet-4.5', + metadata: { id: 'claude-sonnet-4.5', family: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', vendor: 'copilot' } as ILanguageModelChatMetadata, + }; + const parsed = parseOne({ + ...validNotification, + match: { selectedModels: ['Claude Sonnet 4.5'] }, + actions: [{ id: 'switch', kind: 'switchToModel', label: 'Switch', model: ' Claude Sonnet 4.5 ' }], + }).notifications?.[0]; + const action = parsed?.actions[0]; + + assert.deepStrictEqual({ + selector: parsed?.match.selectedModels, + switchMatches: action?.kind === ChatInputNotificationActionKind.SwitchToModel && action.matchesModel(model), + }, { + selector: ['claude-sonnet-4.5'], + switchMatches: true, + }); + }); + + test('rejects malformed payloads whole', () => { + assert.deepStrictEqual([ + parseChatExpNotifications(undefined).error, + parseChatExpNotifications('not json').error?.replace(/:.*/, ''), + parseChatExpNotifications(JSON.stringify({ version: 99, notifications: [validNotification] })).error, + parseChatExpNotifications(JSON.stringify({ version: CHAT_EXP_NOTIFICATION_VERSION, notifications: 'nope' })).error, + parseOne({ ...validNotification, id: 'Has Spaces' }).error, + parseOne({ ...validNotification, title: undefined }).error, + parseOne({ ...validNotification, match: {} }).error, + parseOne({ ...validNotification, severity: 'critical' }).error, + parseOne({ ...validNotification, severity: 'constructor' }).error, + parseOne({ ...validNotification, actions: [{ id: 'a', kind: 'explode', label: 'Go' }] }).error, + parseOne({ ...validNotification, actions: [{ id: 'a', kind: 'command', label: 'Go' }] }).error, + parseOne({ ...validNotification, actions: [{ id: 'a', kind: 'switchToModel', label: 'Go' }] }).error, + parseOne({ ...validNotification, dismissible: 'no' }).error, + parseChatExpNotifications(payload(validNotification, validNotification)).error, + ], [ + 'empty payload', + 'payload is not valid JSON', + 'unsupported version 99, expected 1', + 'notifications must be an array', + 'notifications[0].id is missing or malformed', + 'notifications[0].title is missing or too long', + 'notifications[0].match must narrow at least one dimension', + 'notifications[0].severity must be "info", "warning" or "error"', + 'notifications[0].severity must be "info", "warning" or "error"', + 'notifications[0].actions[0].kind must be "command", "openModelPicker" or "switchToModel"', + 'notifications[0].actions[0].commandId is missing', + 'notifications[0].actions[0].model is missing', + 'notifications[0].dismissible and notifications[0].autoDismissOnMessage must be booleans', + 'notifications[1].id "auto-tier-nudge" is duplicated', + ]); + }); + + suite('matching', () => { + + const context = (overrides: Partial = {}): IChatExpNotificationMatchContext => ({ + sessionType: 'agent-host-copilotcli', + harness: 'copilotcli', + selectedModelId: 'agent-host-copilotcli:claude-sonnet-4.5', + selectedModelAliases: ['claude-sonnet-4.5', 'claude-sonnet-4.5', 'Claude Sonnet 4.5', 'agent-host-copilotcli'], + ...overrides, + }); + + function matches(match: { sessionTypes?: string[]; selectedModels?: string[]; excludeSelectedModels?: string[] }, overrides?: Partial): boolean { + const parsed = parseOne({ ...validNotification, actions: [], match }).notifications?.[0]; + assert.ok(parsed); + return matchesChatExpNotification(parsed.match, context(overrides)); + } + + test('a session selector matches either the session type or its harness', () => { + assert.deepStrictEqual([ + matches({ sessionTypes: ['copilotcli'] }), + matches({ sessionTypes: ['agent-host-copilotcli'] }), + matches({ sessionTypes: ['copilotcli'] }, { sessionType: 'remote-foo-copilotcli' }), + matches({ sessionTypes: ['agent-host-copilotcli'] }, { sessionType: 'remote-foo-copilotcli' }), + matches({ sessionTypes: ['local'] }), + matches({ sessionTypes: ['local'] }, { sessionType: 'local', harness: undefined }), + ], [true, true, true, false, false, true]); + }); + + test('"local" selects the session type real in-editor chat resources produce', () => { + // Pins the authored selector against the resource schemes chat actually uses, so + // `local` cannot silently stop selecting normal chat. + const sessionTypeOf = (resource: URI) => getChatSessionType(resource); + assert.deepStrictEqual([ + sessionTypeOf(URI.from({ scheme: Schemas.vscodeChatEditor, authority: 'x', path: '/untitled-1' })), + sessionTypeOf(LocalChatSessionUri.forSession('untitled-1')), + matches({ sessionTypes: ['local'] }, { sessionType: sessionTypeOf(LocalChatSessionUri.forSession('untitled-1')), harness: undefined }), + // Before any session exists there is nothing to match against. + matches({ sessionTypes: ['local'] }, { sessionType: undefined, harness: undefined }), + ], ['local', 'local', true, false]); + }); + + test('an excluded model suppresses the notification', () => { + assert.deepStrictEqual([ + matches({ sessionTypes: ['copilotcli'], excludeSelectedModels: ['auto'] }), + matches({ sessionTypes: ['copilotcli'], excludeSelectedModels: ['claude-sonnet-4.5'] }), + matches({ sessionTypes: ['copilotcli'], excludeSelectedModels: ['Claude Sonnet 4.5'] }), + matches({ excludeSelectedModels: ['auto'] }, { selectedModelId: undefined, selectedModelAliases: undefined }), + ], [true, false, false, true]); + }); + + test('a model selector matches across identifier shapes, and every dimension must match', () => { + assert.deepStrictEqual([ + matches({ selectedModels: ['claude-sonnet-4.5'] }), + matches({ selectedModels: ['Claude Sonnet 4.5'] }), + matches({ selectedModels: ['claude-sonnet-4.5'] }, { selectedModelId: 'copilot/claude-sonnet-4.5', selectedModelAliases: undefined }), + matches({ selectedModels: ['gpt-4o'] }), + matches({ sessionTypes: ['copilotcli'], selectedModels: ['gpt-4o'] }), + ], [true, true, true, false, false]); + }); + }); + }); + + suite('contribution', () => { + + function createContribution(treatments: (string | undefined)[]) { + const store = disposables.add(new DisposableStore()); + const onDidRefetchAssignments = store.add(new Emitter()); + const onDidDismiss = store.add(new Emitter()); + const notifications = new Map(); + const setCalls: string[] = []; + const configurationService = new TestConfigurationService(); + const onDidChangeSentiment = store.add(new Emitter()); + const entitlementSentiment = { hidden: false }; + + const notificationService: IChatInputNotificationService = { + _serviceBrand: undefined, + onDidChange: Event.None, + onDidDismiss: onDidDismiss.event, + setNotification(notification) { setCalls.push(notification.id); notifications.set(notification.id, notification); }, + deleteNotification(id) { notifications.delete(id); }, + dismissNotification(id) { onDidDismiss.fire(id); }, + getActiveNotification() { return undefined; }, + refresh() { }, + handleMessageSent() { }, + announceRendered() { }, + }; + + store.add(new ChatExpNotificationContribution( + { + _serviceBrand: undefined, + onDidRefetchAssignments: onDidRefetchAssignments.event, + getCurrentExperiments: async () => [], + addTelemetryAssignmentFilter(_filter: IAssignmentFilter): void { }, + getTreatment: async () => (treatments.length > 1 ? treatments.shift() : treatments[0]) as T, + } satisfies IWorkbenchAssignmentService, + notificationService, + { getChatSessionContribution: (type: string) => type === 'agent-host-copilotcli' ? { agentHostProviderId: 'copilotcli' } : undefined } as IChatSessionsService, + configurationService, + { sentiment: entitlementSentiment, onDidChangeSentiment: onDidChangeSentiment.event } as IChatEntitlementService, + store.add(new InMemoryStorageService()), + new NullLogService(), + )); + + const settled = () => new Promise(resolve => setTimeout(resolve, 0)); + return { + notifications, setCalls, configurationService, notificationService, settled, entitlementSentiment, + refetch: () => { onDidRefetchAssignments.fire(); return settled(); }, + ids: () => [...notifications.keys()], + }; + } + + function context(overrides: Partial = {}): IChatInputNotificationContext { + return { + sessionType: 'agent-host-copilotcli', + sessionResource: undefined, + deferredNotificationsEnabled: true, + isTransientChat: false, + sessionStarted: false, + modelState: { currentModel: undefined, models: [] }, + ...overrides, + }; + } + + test('registers a namespaced notification bound to the payload targeting', async () => { + const test = createContribution([payload({ ...validNotification, match: { sessionTypes: ['copilotcli'] } })]); + await test.settled(); + const registered = test.notifications.get('chat.expNotification.auto-tier-nudge'); + + assert.deepStrictEqual({ + ids: test.ids(), + telemetryId: registered?.telemetryId, + message: registered?.message, + matchingSession: registered?.when?.(context()), + otherSession: registered?.when?.(context({ sessionType: 'local' })), + }, { + ids: ['chat.expNotification.auto-tier-nudge'], + telemetryId: 'auto-tier-nudge', + message: 'Auto now picks stronger models', + matchingSession: true, + otherSession: false, + }); + }); + + test('ignores an invalid payload rather than throwing', async () => { + const test = createContribution(['{ not json']); + await test.settled(); + + assert.deepStrictEqual(test.ids(), []); + }); + + test('a dismissal survives a refetch, and an unchanged payload is never re-set', async () => { + const test = createContribution([payload(validNotification)]); + await test.settled(); + + test.notificationService.dismissNotification('chat.expNotification.auto-tier-nudge'); + await test.refetch(); + + assert.deepStrictEqual({ ids: test.ids(), setCalls: test.setCalls }, { + ids: [], + setCalls: ['chat.expNotification.auto-tier-nudge'], + }); + }); + + test('an unrelated payload edit leaves an unchanged notification alone, even reformatted', async () => { + const reordered = { title: validNotification.title, actions: validNotification.actions, description: validNotification.description, match: validNotification.match, id: validNotification.id }; + const test = createContribution([payload(validNotification), payload(reordered, { ...validNotification, id: 'second' })]); + await test.settled(); + await test.refetch(); + + assert.deepStrictEqual(test.setCalls, ['chat.expNotification.auto-tier-nudge', 'chat.expNotification.second']); + }); + + test('removes notifications dropped from the treatment, and an empty list retires them', async () => { + const test = createContribution([payload(validNotification, { ...validNotification, id: 'second' }), payload(validNotification), payload()]); + await test.settled(); + const before = test.ids(); + + await test.refetch(); + const afterDrop = test.ids(); + + await test.refetch(); + assert.deepStrictEqual({ before, afterDrop, afterRetire: test.ids() }, { + before: ['chat.expNotification.auto-tier-nudge', 'chat.expNotification.second'], + afterDrop: ['chat.expNotification.auto-tier-nudge'], + afterRetire: [], + }); + }); + + test('removing the assignment clears what is showing', async () => { + const test = createContribution([payload(validNotification), undefined]); + await test.settled(); + await test.refetch(); + + assert.deepStrictEqual(test.ids(), []); + }); + + test('stops matching when chat UI is hidden by policy', async () => { + const test = createContribution([payload({ ...validNotification, match: { sessionTypes: ['copilotcli'] } })]); + await test.settled(); + const registered = test.notifications.get('chat.expNotification.auto-tier-nudge'); + const beforeHide = registered?.when?.(context()); + + test.entitlementSentiment.hidden = true; + + assert.deepStrictEqual({ beforeHide, afterHide: registered?.when?.(context()) }, { + beforeHide: true, + afterHide: false, + }); + }); + + test('stops matching when AI features are disabled, without unregistering', async () => { + const test = createContribution([payload({ ...validNotification, match: { sessionTypes: ['copilotcli'] } })]); + await test.settled(); + const registered = test.notifications.get('chat.expNotification.auto-tier-nudge'); + const beforeDisable = registered?.when?.(context()); + + await test.configurationService.setUserConfiguration(ChatAIDisabledSettingId, true); + + // Still registered, so the service keeps the in-memory dismissal it holds for + // `autoDismissOnMessage`, but the notification no longer matches any input. + assert.deepStrictEqual({ beforeDisable, afterDisable: registered?.when?.(context()), ids: test.ids() }, { + beforeDisable: true, + afterDisable: false, + ids: ['chat.expNotification.auto-tier-nudge'], + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts index 98d0328b10f7eb..d5d55ca77b5eab 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ExtUri, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { RenameProvider, WorkspaceEdit, Rejection } from '../../../../../../editor/common/languages.js'; @@ -14,6 +15,7 @@ import { LanguageFeaturesService } from '../../../../../../editor/common/service import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IBulkEditService, IBulkEditResult } from '../../../../../../editor/browser/services/bulkEditService.js'; import { RenameTool } from '../../../browser/tools/renameTool.js'; import { IChatService } from '../../../common/chatService/chatService.js'; @@ -29,6 +31,7 @@ suite('RenameTool', () => { const disposables = new DisposableStore(); let langFeatures: LanguageFeaturesService; + const uriIdentityService = { extUri: new ExtUri(() => false) } as Partial as IUriIdentityService; const testUri = URI.parse('file:///test/file.ts'); const testContent = [ @@ -65,12 +68,7 @@ suite('RenameTool', () => { return { _serviceBrand: undefined, getWorkspace: () => ({ folders: [folder] }), - getWorkspaceFolder: (uri: URI) => { - if (uri.toString().startsWith(folderUri.toString())) { - return folder; - } - return null; - }, + getWorkspaceFolder: (uri: URI) => extUriBiasedIgnorePathCase.isEqualOrParent(uri, folderUri) ? folder : null, } as unknown as IWorkspaceContextService; } @@ -100,12 +98,13 @@ suite('RenameTool', () => { const noopCountTokens = async () => 0; const noopProgress: ToolProgress = { report() { } }; - function createTool(textModelService: ITextModelService, options?: { bulkEditService?: IBulkEditService }): RenameTool { + function createTool(textModelService: ITextModelService, options?: { bulkEditService?: IBulkEditService; chatService?: IChatService }): RenameTool { return new RenameTool( langFeatures, textModelService, createMockWorkspaceService(), - createMockChatService(), + uriIdentityService, + options?.chatService ?? createMockChatService(), options?.bulkEditService ?? createMockBulkEditService(), ); } @@ -347,6 +346,107 @@ suite('RenameTool', () => { assert.strictEqual(bulkEditService.appliedEdits.length, 0); }); + test('rejects uri outside the workspace', async () => { + const outsideUri = URI.parse('file:///outside.ts'); + const outsideModel = disposables.add(createTextModel('const OutsideSymbol = 1;', 'typescript', undefined, outsideUri)); + const requestedUris: URI[] = []; + const textModelService = { + _serviceBrand: undefined, + createModelReference: async (uri: URI) => { + requestedUris.push(uri); + return { object: { textEditorModel: outsideModel }, dispose: () => { } }; + }, + registerTextModelContentProvider: () => ({ dispose: () => { } }), + canHandleResource: () => false, + } as unknown as ITextModelService; + const bulkEditService = createMockBulkEditService(); + const tool = disposables.add(createTool(textModelService, { bulkEditService })); + + const result = await tool.invoke( + createInvocation({ symbol: 'OutsideSymbol', newName: 'RenamedSymbol', uri: outsideUri.toString(), lineContent: 'const OutsideSymbol = 1;' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.deepStrictEqual({ + result: getTextContent(result), + requestedUris: requestedUris.map(uri => uri.toString()), + appliedEditCount: bulkEditService.appliedEdits.length, + }, { + result: 'Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify a file within the current workspace or working directory.', + requestedUris: [], + appliedEditCount: 0, + }); + }); + + test('rejects every rename edit kind outside the workspace', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const outsideUri = URI.parse('file:///outside.ts'); + const bulkEditService = createMockBulkEditService(); + const tool = disposables.add(createTool(createMockTextModelService(model), { bulkEditService })); + const externalEdits: WorkspaceEdit['edits'] = [ + makeEdit(outsideUri, new Range(1, 1, 1, 8), 'MyNewClass'), + { oldResource: testUri, newResource: outsideUri }, + { resource: outsideUri, undo() { }, redo() { } }, + ]; + + for (const edit of externalEdits) { + const provider = langFeatures.renameProvider.register('typescript', { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ edits: [edit] }), + }); + try { + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.strictEqual(getTextContent(result), 'Rename was not applied because it would modify files outside the current workspace or working directory.'); + } finally { + provider.dispose(); + } + } + + assert.strictEqual(bulkEditService.appliedEdits.length, 0); + }); + + test('rejects non-text edits in chat context', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.renameProvider.register('typescript', { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ + edits: [ + makeEdit(testUri, new Range(1, 10, 1, 17), 'MyNewClass'), + { oldResource: testUri, newResource: URI.parse('file:///test/renamed.ts') }, + ] + }), + })); + let progressCount = 0; + const chatService = { + _serviceBrand: undefined, + getSession: () => ({ + getRequests: () => [{}], + acceptResponseProgress: () => progressCount++, + }), + } as unknown as IChatService; + const bulkEditService = createMockBulkEditService(); + const tool = disposables.add(createTool(createMockTextModelService(model), { bulkEditService, chatService })); + + const result = await tool.invoke( + { + parameters: { symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }, + context: { sessionResource: URI.parse('chat-session:test') }, + } as unknown as IToolInvocation, + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.deepStrictEqual({ + result: getTextContent(result), + progressCount, + appliedEditCount: bulkEditService.appliedEdits.length, + }, { + result: 'Rename was not applied because it produced edits that cannot be reviewed in chat.', + progressCount: 0, + appliedEditCount: 0, + }); + }); + test('result includes toolResultMessage', async () => { const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); const edits = [ diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts index 6acdd0240686f4..6336549129606f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { ExtUri, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; @@ -13,10 +14,12 @@ import { resolveSymbolToolFileUri, findLineNumber, findSymbolColumn, errorResult import type { IChatService } from '../../../common/chatService/chatService.js'; import { ChatPermissionLevel } from '../../../common/constants.js'; import type { IChatWidgetService } from '../../../browser/chat.js'; +import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; suite('Tool Helpers', () => { const disposables = new DisposableStore(); + const uriIdentityService = { extUri: new ExtUri(() => false) } as Partial as IUriIdentityService; teardown(() => { disposables.clear(); @@ -24,21 +27,16 @@ suite('Tool Helpers', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createMockWorkspaceService(folderUri?: URI): IWorkspaceContextService { - const uri = folderUri ?? URI.parse('file:///workspace'); - const folder = { + function createMockWorkspaceService(folderUris: URI | readonly URI[] = URI.parse('file:///workspace')): IWorkspaceContextService { + const uris = URI.isUri(folderUris) ? [folderUris] : folderUris; + const folders = uris.map(uri => ({ uri, toResource: (relativePath: string) => URI.joinPath(uri, relativePath), - } as unknown as IWorkspaceFolder; + } as unknown as IWorkspaceFolder)); return { _serviceBrand: undefined, - getWorkspace: () => ({ folders: [folder] }), - getWorkspaceFolder: (u: URI) => { - if (u.toString().startsWith(uri.toString())) { - return folder; - } - return null; - }, + getWorkspace: () => ({ folders }), + getWorkspaceFolder: (uri: URI) => folders.find(folder => extUriBiasedIgnorePathCase.isEqualOrParent(uri, folder.uri)) ?? null, } as unknown as IWorkspaceContextService; } @@ -60,73 +58,105 @@ suite('Tool Helpers', () => { test('resolves full URI string', () => { const ws = createMockWorkspaceService(); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///test/file.ts' }, ws); - assert.strictEqual(result?.toString(), 'file:///test/file.ts'); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///workspace/test/file.ts' }, ws, uriIdentityService); + assert.strictEqual(result?.toString(), 'file:///workspace/test/file.ts'); }); test('resolves workspace-relative filePath', () => { const ws = createMockWorkspaceService(URI.parse('file:///project')); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'src/index.ts' }, ws); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'src/index.ts' }, ws, uriIdentityService); assert.strictEqual(result?.toString(), 'file:///project/src/index.ts'); }); test('prefers uri over filePath', () => { const ws = createMockWorkspaceService(); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///explicit.ts', filePath: 'other.ts' }, ws); - assert.strictEqual(result?.toString(), 'file:///explicit.ts'); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///workspace/explicit.ts', filePath: 'other.ts' }, ws, uriIdentityService); + assert.strictEqual(result?.toString(), 'file:///workspace/explicit.ts'); }); test('returns undefined when neither provided', () => { const ws = createMockWorkspaceService(); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x' }, ws); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x' }, ws, uriIdentityService); assert.strictEqual(result, undefined); }); test('resolves filePath against workingDirectory when provided', () => { const ws = createMockWorkspaceService(URI.parse('file:///other-workspace')); const workingDirectory = URI.parse('file:///session-dir'); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'src/index.ts' }, ws, workingDirectory); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'src/index.ts' }, ws, uriIdentityService, workingDirectory); assert.strictEqual(result?.toString(), 'file:///session-dir/src/index.ts'); }); test('workingDirectory takes precedence over workspace folders', () => { const ws = createMockWorkspaceService(URI.parse('file:///workspace')); const workingDirectory = URI.parse('file:///my-project'); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'file.ts' }, ws, workingDirectory); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'file.ts' }, ws, uriIdentityService, workingDirectory); assert.strictEqual(result?.toString(), 'file:///my-project/file.ts'); }); - test('uri field ignores workingDirectory', () => { + test('resolves uri within workingDirectory', () => { const ws = createMockWorkspaceService(); const workingDirectory = URI.parse('file:///session-dir'); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///absolute/path.ts' }, ws, workingDirectory); - assert.strictEqual(result?.toString(), 'file:///absolute/path.ts'); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///session-dir/path.ts' }, ws, uriIdentityService, workingDirectory); + assert.strictEqual(result?.toString(), 'file:///session-dir/path.ts'); + }); + + test('rejects uri outside workingDirectory', () => { + const ws = createMockWorkspaceService(); + const workingDirectory = URI.parse('file:///session-dir'); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///workspace/path.ts' }, ws, uriIdentityService, workingDirectory); + assert.strictEqual(result, undefined); + }); + + test('rejects uri outside workspace folders', () => { + const ws = createMockWorkspaceService(); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///outside/path.ts' }, ws, uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('resolves uri within any workspace folder', () => { + const ws = createMockWorkspaceService([URI.parse('file:///workspace-one'), URI.parse('file:///workspace-two')]); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///workspace-two/path.ts' }, ws, uriIdentityService); + assert.strictEqual(result?.toString(), 'file:///workspace-two/path.ts'); + }); + + test('rejects uri with parent segments that escapes the workspace folder', () => { + const ws = createMockWorkspaceService(); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'file:///workspace/src/../../outside.ts' }, ws, uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('respects case-sensitive remote paths', () => { + const workspaceUri = URI.parse('vscode-remote://host/project'); + const ws = createMockWorkspaceService(workspaceUri); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', uri: 'vscode-remote://host/PROJECT/path.ts' }, ws, uriIdentityService); + assert.strictEqual(result, undefined); }); test('rejects filePath that escapes the workingDirectory via parent segments', () => { const ws = createMockWorkspaceService(URI.parse('file:///workspace')); const workingDirectory = URI.parse('file:///my-project'); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: '../outside.ts' }, ws, workingDirectory); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: '../outside.ts' }, ws, uriIdentityService, workingDirectory); assert.strictEqual(result, undefined); }); test('rejects filePath that escapes the workingDirectory via nested parent segments', () => { const ws = createMockWorkspaceService(URI.parse('file:///workspace')); const workingDirectory = URI.parse('file:///my-project'); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'src/../../outside.ts' }, ws, workingDirectory); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'src/../../outside.ts' }, ws, uriIdentityService, workingDirectory); assert.strictEqual(result, undefined); }); test('allows filePath with interior parent segments that stays within the workingDirectory', () => { const ws = createMockWorkspaceService(URI.parse('file:///workspace')); const workingDirectory = URI.parse('file:///my-project'); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'src/../file.ts' }, ws, workingDirectory); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: 'src/../file.ts' }, ws, uriIdentityService, workingDirectory); assert.strictEqual(result?.toString(), 'file:///my-project/file.ts'); }); test('rejects filePath that escapes the workspace folder via parent segments', () => { const ws = createMockWorkspaceService(URI.parse('file:///project/sub')); - const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: '../../outside.ts' }, ws); + const result = resolveSymbolToolFileUri({ symbol: 'x', lineContent: 'x', filePath: '../../outside.ts' }, ws, uriIdentityService); assert.strictEqual(result, undefined); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts index 2981d4898a517b..f87136d979be21 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ExtUri, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { DefinitionProvider, ImplementationProvider, Location, ReferenceProvider } from '../../../../../../editor/common/languages.js'; @@ -15,6 +16,7 @@ import { IModelService } from '../../../../../../editor/common/services/model.js import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { FileMatch, ISearchComplete, ISearchService, ITextQuery, OneLineRange, TextSearchMatch } from '../../../../../services/search/common/search.js'; import { UsagesTool } from '../../../browser/tools/usagesTool.js'; import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; @@ -29,6 +31,7 @@ suite('UsagesTool', () => { const disposables = new DisposableStore(); let langFeatures: LanguageFeaturesService; + const uriIdentityService = { extUri: new ExtUri(() => false) } as Partial as IUriIdentityService; const testUri = URI.parse('file:///test/file.ts'); const testContent = [ @@ -75,12 +78,7 @@ suite('UsagesTool', () => { return { _serviceBrand: undefined, getWorkspace: () => ({ folders: [folder] }), - getWorkspaceFolder: (uri: URI) => { - if (uri.toString().startsWith(folderUri.toString())) { - return folder; - } - return null; - }, + getWorkspaceFolder: (uri: URI) => extUriBiasedIgnorePathCase.isEqualOrParent(uri, folderUri) ? folder : null, } as unknown as IWorkspaceContextService; } @@ -92,7 +90,7 @@ suite('UsagesTool', () => { const noopProgress: ToolProgress = { report() { } }; function createTool(textModelService: ITextModelService, workspaceService: IWorkspaceContextService, options?: { modelService?: IModelService; searchService?: ISearchService }): UsagesTool { - return new UsagesTool(langFeatures, options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); + return new UsagesTool(langFeatures, options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService, uriIdentityService); } setup(() => { @@ -361,5 +359,98 @@ suite('UsagesTool', () => { assert.ok(!text.includes(outsideContent)); assert.strictEqual(requestedUris.length, 0); }); + + test('rejects uri outside the workspace', async () => { + const outsideUri = URI.parse('file:///outside.ts'); + const outsideContent = 'export const OutsideSymbol = 1;'; + const outsideModel = disposables.add(createTextModel(outsideContent, 'typescript', undefined, outsideUri)); + const requestedUris: URI[] = []; + const textModelService = { + _serviceBrand: undefined, + createModelReference: async (uri: URI) => { + requestedUris.push(uri); + return { object: { textEditorModel: outsideModel }, dispose: () => { } }; + }, + registerTextModelContentProvider: () => ({ dispose: () => { } }), + canHandleResource: () => false, + } as unknown as ITextModelService; + const tool = disposables.add(createTool(textModelService, createMockWorkspaceService(), { modelService: createMockModelService([outsideModel]) })); + + const result = await tool.invoke( + createInvocation({ symbol: 'OutsideSymbol', uri: outsideUri.toString(), lineContent: outsideContent }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.deepStrictEqual({ + result: getTextContent(result), + requestedUris: requestedUris.map(uri => uri.toString()), + }, { + result: 'Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify a file within the current workspace or working directory.', + requestedUris: [], + }); + }); + + test('omits usages outside the workspace', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const outsideUri = URI.parse('file:///outside.ts'); + const outsideContent = 'export const outsideReference = new MyClass();'; + const outsideModel = disposables.add(createTextModel(outsideContent, 'typescript', undefined, outsideUri)); + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: outsideUri, range: new Range(1, 34, 1, 41) }, + ] + })); + const tool = disposables.add(createTool( + createMockTextModelService(model), + createMockWorkspaceService(), + { modelService: createMockModelService([model, outsideModel]) } + )); + + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.deepStrictEqual({ + result: getTextContent(result), + details: result.toolResultDetails, + }, { + result: 'No usages found for `MyClass`.', + details: undefined, + }); + }); + + test('searches only the explicit working directory for previews', async () => { + const workingDirectory = URI.parse('file:///session-dir'); + const sessionUri = URI.parse('file:///session-dir/file.ts'); + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, sessionUri)); + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: sessionUri, range: new Range(1, 10, 1, 17) }, + ] + })); + const queries: ITextQuery[] = []; + const searchService = createMockSearchService(query => { + queries.push(query); + return { results: [], messages: [] }; + }); + const tool = disposables.add(createTool( + createMockTextModelService(model), + createMockWorkspaceService(), + { searchService } + )); + + await tool.invoke( + { + parameters: { symbol: 'MyClass', uri: sessionUri.toString(), lineContent: 'import { MyClass }' }, + context: { workingDirectory }, + } as unknown as IToolInvocation, + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.deepStrictEqual(queries.map(query => query.folderQueries.map(folder => folder.folder.toString())), [ + [workingDirectory.toString()], + ]); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 5d64838f015790..5a11d77076abfb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -3049,6 +3049,34 @@ suite('ChatSubagentContentPart', () => { assert.ok(creditHover, 'Should set up hover with credits after completion'); }); + test('should forward late credits to the open-chat pill context', () => { + const toolSpecificData: IChatSubagentToolInvocationData = { + kind: 'subagent', + description: 'Working on task', + chatResource: 'ahp-chat://subagent/test/tool-call', + isActive: true, + startedAt: 1000, + }; + const toolInvocation = createMockToolInvocation({ + toolSpecificData, + stateType: IChatToolInvocation.StateKind.Executing, + }); + const state = observableValue('state', toolInvocation.state.get()); + (toolInvocation as unknown as { state: typeof state }).state = state; + const part = createPart(toolInvocation, createMockRenderContext(false)); + + const before = getOpenChatContext(part)?.credits; + + // Credits accumulate while the subagent is still running. + toolSpecificData.credits = 2.5; + state.set({ ...state.get() }, undefined); + + assert.deepStrictEqual( + { before, after: getOpenChatContext(part)?.credits }, + { before: undefined, after: 2.5 }, + ); + }); + test('should update hover with model name when it arrives after initial render', () => { const setupDelayedHoverCalls: { element: HTMLElement; content: string }[] = []; mockHoverService.setupDelayedHover = (element: HTMLElement, options: { content: string }) => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts index 173356e44ffdf4..f29b54cafc6967 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { Disposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { IChatResponseFileChangesService } from '../../../../browser/chatResponseFileChangesService.js'; +import { ChatCollapsibleContentPart } from '../../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { ChatTurnPillsContentPart } from '../../../../browser/widget/chatContentParts/chatTurnPillsPart.js'; import { emptySessionEntryDiff, IEditSessionEntryDiff } from '../../../../common/editing/chatEditingService.js'; @@ -113,7 +114,7 @@ suite('ChatTurnPillsContentPart', () => { additions: '+8', deletions: '-3', ariaLabel: 'View all file changes: 2 files changed, 8 lines added, 3 lines deleted', - hasDisclosure: false, + hasDisclosure: true, hasPreview: false, }, after: { @@ -122,9 +123,72 @@ suite('ChatTurnPillsContentPart', () => { additions: '+0', deletions: '-0', ariaLabel: 'View all file changes: 0 files changed, 0 lines added, 0 lines deleted', - hasDisclosure: false, + hasDisclosure: true, hasPreview: false, }, }); }); + + test('expands the changed files list from the header without opening the changes', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + let openChangesCount = 0; + instantiationService.stub(IChatResponseFileChangesService, { + _serviceBrand: undefined, + registerProvider: () => Disposable.None, + getChangesForRequest: () => observableValue('turnChanges', [ + { ...emptySessionEntryDiff(URI.file('/file1.ts'), URI.file('/file1.ts')), added: 5, removed: 2 }, + { ...emptySessionEntryDiff(URI.file('/file2.ts'), URI.file('/file2.ts')), added: 3, removed: 1 }, + ]), + getFileEditsForRequest: () => undefined, + getChangeStatsForRequest: () => undefined, + openChangesForRequest: () => { openChangesCount++; }, + }); + + const part = store.add(instantiationService.createInstance( + ChatTurnPillsContentPart, + { + kind: 'turnPills', + requestId: 'request', + sessionResource: URI.parse('vscode-chat-session://agent-host/session'), + isLastTurn: true, + }, + {} as IChatContentPartRenderContext, + )); + + let toggleCount = 0; + const listener = () => toggleCount++; + part.domNode.addEventListener(ChatCollapsibleContentPart.userToggleEvent, listener); + store.add(toDisposable(() => part.domNode.removeEventListener(ChatCollapsibleContentPart.userToggleEvent, listener))); + + const details = part.domNode.querySelector('details'); + const header = part.domNode.querySelector('summary'); + const counts = part.domNode.querySelector('.chat-file-changes-counts'); + const chevron = part.domNode.querySelector('.chat-file-changes-chevron'); + assert.ok(details && header && counts && chevron); + + const readState = () => ({ + open: details.open, + expandedChevron: chevron.classList.contains('expanded'), + ariaExpanded: header.getAttribute('aria-expanded'), + rows: part.domNode.querySelectorAll('.chat-summary-list .monaco-list-row').length, + toggleCount, + openChangesCount, + }); + const collapsed = readState(); + + // `
` does not toggle on a synthetic click, so mirror what the browser does. + header.click(); + details.open = true; + details.dispatchEvent(new Event('toggle')); + const expanded = readState(); + + // The counts button opens the changes view instead of toggling the disclosure. + counts.click(); + + assert.deepStrictEqual({ collapsed, expanded, afterCountsClick: readState() }, { + collapsed: { open: false, expandedChevron: false, ariaExpanded: 'false', rows: 2, toggleCount: 0, openChangesCount: 0 }, + expanded: { open: true, expandedChevron: true, ariaExpanded: 'true', rows: 2, toggleCount: 1, openChangesCount: 0 }, + afterCountsClick: { open: true, expandedChevron: true, ariaExpanded: 'true', rows: 2, toggleCount: 1, openChangesCount: 1 }, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts index 0037844fe203e8..881f5915fa69e7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts @@ -19,6 +19,7 @@ import { scrollbarShadow } from '../../../../../../platform/theme/common/colorRe import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { IChatAccessibilityService } from '../../../browser/chat.js'; +import { ChatAttachmentWidgetRegistry, IChatAttachmentWidgetRegistry } from '../../../browser/attachments/chatAttachmentWidgetRegistry.js'; import { computeScrollDownState, getAnchoredScrollTop, AutoScrollHolds, UserToggleResizeState, ChatListWidget, IChatListWidgetOptions } from '../../../browser/widget/chatListWidget.js'; import { ChatEditorOptions } from '../../../browser/widget/chatOptions.js'; import { IChatService } from '../../../common/chatService/chatService.js'; @@ -77,6 +78,7 @@ suite('ChatListWidget', () => { instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); + instantiationService.stub(IChatAttachmentWidgetRegistry, new ChatAttachmentWidgetRegistry()); instantiationService.stub(IAccessibleViewService, { getOpenAriaHint: () => '' }); instantiationService.stub(IChatAccessibilityService, { acceptRequest: () => { }, @@ -836,6 +838,39 @@ suite('ChatListWidget', () => { disposables.dispose(); }); + test('renders transcript context above the request message', async () => { + const { disposables, model, container, widget } = createWidget(); + const text = 'Tell me about this pull request'; + const attachment: IChatRequestVariableEntry = { + kind: 'transcriptContext', + id: 'pull-request-context', + name: '#42 Fix the issue', + value: '{}', + uri: URI.parse('https://github.com/owner/repo/pull/42'), + }; + model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)] + }, { variables: [attachment] }, 0); + + widget.refresh(); + widget.layout(300, 500); + await waitForStableLayout(widget); + + const requestValue = container.querySelector('.monaco-list-rows > .monaco-list-row.request .interactive-item-container > .value'); + assert.ok(requestValue); + const children = Array.from(requestValue.children); + assert.deepStrictEqual({ + attachmentIndex: children.findIndex(child => child.classList.contains('chat-attached-context')), + messageIndex: children.findIndex(child => child.classList.contains('rendered-markdown')), + }, { + attachmentIndex: 0, + messageIndex: 1, + }); + + disposables.dispose(); + }); + test('does not create an empty sticky row when request text has no renderable parts', async () => { const { disposables, model, container, widget } = createWidget({}, configurationService => { configurationService.setUserConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING, true); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index 89a2217d362802..69609fa4312a40 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -309,6 +309,47 @@ suite('ChatPetWidget', () => { }); }); + test('keeps the on-the-run pet still', () => { + const clock = sinon.useFakeTimers(); + const parent = mainWindow.document.createElement('div'); + const input = mainWindow.document.createElement('div'); + const movementBounds = mainWindow.document.createElement('div'); + parent.append(input); + mainWindow.document.body.append(parent, movementBounds); + const storageService = new TestStorageService(); + const service = new ChatPetService(storageService, new TestTelemetryService(), new NullLogService()); + service.toggle(); + const widget = new ChatPetWidget( + createPetHost(parent, input, movementBounds), + undefined, + service, + new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return false; } + }(), + new class extends mock() { }(), + new class extends mock() { }(), + new NullLogService(), + new class extends mock() { + override readonly hasFocus = true; + override readonly onDidChangeFocus = Event.None; + override readonly onDidChangeActiveWindow = Event.None; + }(), + ); + try { + service.setOnTheRun(true); + clock.tick(10_000); + + assert.strictEqual(Reflect.get(widget, '_transientState').get(), undefined); + } finally { + widget.dispose(); + service.dispose(); + storageService.dispose(); + parent.remove(); + movementBounds.remove(); + clock.restore(); + } + }); + test('moves one pet instance between chat hosts without respawning it', () => { const firstParent = mainWindow.document.createElement('div'); const firstBounds = mainWindow.document.createElement('div'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts index ffba57a33c6d21..4cb284cf340562 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; +import { IStringDictionary } from '../../../../../../../base/common/collections.js'; import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { constObservable, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; @@ -22,7 +23,7 @@ import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatIn import { ChatInputPart } from '../../../../browser/widget/input/chatInputPart.js'; import { ChatInputNotificationWidget, IChatInputNotificationDelegate, IChatInputNotificationModelSelection } from '../../../../browser/widget/input/chatInputNotificationWidget.js'; import { isByokModel } from '../../../../common/chatSelectedModel.js'; -import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelConfigurationSchema } from '../../../../common/languageModels.js'; import { localChatSessionType, SessionType } from '../../../../common/chatSessionsService.js'; import { getChatSessionType } from '../../../../common/model/chatUri.js'; @@ -89,11 +90,13 @@ suite('ChatInputNotificationWidget', () => { readonly models?: readonly ILanguageModelChatMetadataAndIdentifier[]; readonly openPicker?: () => void; readonly selectModel?: (modelIdentifier: string) => boolean; + readonly applyModelConfiguration?: (modelIdentifier: string, values: IStringDictionary) => Promise; } = {}): IChatInputNotificationModelSelection { return { state: options.state ?? constObservable({ currentModel: options.currentModel, models: options.models ?? [] }), openPicker: options.openPicker ?? (() => { }), selectModel: options.selectModel ?? (() => true), + applyModelConfiguration: options.applyModelConfiguration, }; } @@ -475,6 +478,7 @@ suite('ChatInputNotificationWidget', () => { } return active; }, + refresh() { }, handleMessageSent() { }, announceRendered(notification, body) { announced.push({ notification, body }); }, }; @@ -640,7 +644,135 @@ suite('ChatInputNotificationWidget', () => { }, { switchedModels: ['vendor/model'], pickerOpenCount: 0, - actionEvents: [{ id: 'promo', telemetryId: undefined, actionKind: ChatInputNotificationActionKind.SwitchToModel }], + actionEvents: [{ id: 'promo', telemetryId: undefined, actionKind: ChatInputNotificationActionKind.SwitchToModel, actionId: '' }], + }); + }); + + /** Clicks a switch-to-model action against one model and reports what the input did. */ + async function runSwitchAction(options: { + readonly schema: ILanguageModelConfigurationSchema; + readonly config: IStringDictionary; + readonly selectModel?: (modelIdentifier: string) => boolean; + readonly applyModelConfiguration?: (modelIdentifier: string, values: IStringDictionary) => Promise; + }): Promise<{ switchedModels: string[]; pickerOpenCount: number }> { + const switchedModels: string[] = []; + let pickerOpenCount = 0; + const model = makeModel('vendor', false, 'model'); + model.metadata = { ...model.metadata, configurationSchema: options.schema }; + + const { notificationService, widget } = createWidget({ + delegate: { + modelSelection: modelSelection({ + models: [model], + selectModel: modelIdentifier => { + const switched = options.selectModel?.(modelIdentifier) ?? true; + if (switched) { + switchedModels.push(modelIdentifier); + } + return switched; + }, + openPicker: () => pickerOpenCount++, + applyModelConfiguration: options.applyModelConfiguration, + }), + }, + }); + + showNotification(notificationService, { + id: 'promo', + message: 'Promo', + actions: [{ + label: 'Try Model', + kind: ChatInputNotificationActionKind.SwitchToModel, + matchesModel: matchesModelIdentifier(model.identifier), + config: options.config, + }], + }); + + clickAction(widget); + await new Promise(resolve => setTimeout(resolve, 0)); + return { switchedModels, pickerOpenCount }; + } + + test('configures the model it switched to, dropping keys the model does not declare', async () => { + const applied: { modelIdentifier: string; values: IStringDictionary }[] = []; + const order: string[] = []; + + await runSwitchAction({ + schema: { properties: { thinkingLevel: { enum: ['low', 'high'] }, contextSize: {} } }, + config: { thinkingLevel: 'high', contextSize: 272000, unknownKey: 1, thinkingLevelTypo: 'high' }, + selectModel: () => { order.push('select'); return true; }, + applyModelConfiguration: async (modelIdentifier, values) => { + order.push('configure'); + applied.push({ modelIdentifier, values }); + }, + }); + + assert.deepStrictEqual({ applied, order }, { + applied: [{ modelIdentifier: 'vendor/model', values: { thinkingLevel: 'high', contextSize: 272000 } }], + order: ['select', 'configure'], + }); + }); + + test('does not configure a model it failed to switch to', async () => { + let applyCount = 0; + + const { switchedModels } = await runSwitchAction({ + schema: { properties: { thinkingLevel: { enum: ['high'] } } }, + config: { thinkingLevel: 'high' }, + selectModel: () => false, + applyModelConfiguration: async () => { applyCount++; }, + }); + + assert.deepStrictEqual({ applyCount, switchedModels }, { applyCount: 0, switchedModels: [] }); + }); + + test('switches even when applying configuration fails', async () => { + const result = await runSwitchAction({ + schema: { properties: { thinkingLevel: { enum: ['high'] } } }, + config: { thinkingLevel: 'high' }, + applyModelConfiguration: async () => { throw new Error('storage failed'); }, + }); + + assert.deepStrictEqual(result, { switchedModels: ['vendor/model'], pickerOpenCount: 0 }); + }); + + test('an ambiguous unique-match target opens the picker and applies no configuration', async () => { + let applyCount = 0; + let pickerOpenCount = 0; + const switchedModels: string[] = []; + const first = makeModel('vendor', false, 'model'); + const second = { ...makeModel('vendor', false, 'model'), identifier: 'vendor/model-2' }; + + const { notificationService, widget } = createWidget({ + delegate: { + modelSelection: modelSelection({ + models: [first, second], + selectModel: modelIdentifier => { switchedModels.push(modelIdentifier); return true; }, + openPicker: () => pickerOpenCount++, + applyModelConfiguration: async () => { applyCount++; }, + }), + }, + }); + + showNotification(notificationService, { + id: 'promo', + message: 'Promo', + actions: [{ + label: 'Try Model', + kind: ChatInputNotificationActionKind.SwitchToModel, + matchesModel: model => model.metadata.id === 'model', + config: { thinkingLevel: 'high' }, + requireUniqueModel: true, + }], + }); + + clickAction(widget); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepStrictEqual({ switchedModels, applyCount, pickerOpenCount }, { + switchedModels: [], + applyCount: 0, + pickerOpenCount: 1, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationLogic.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationLogic.test.ts index f4a1e260c38544..a215f7c54ec08a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationLogic.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationLogic.test.ts @@ -106,6 +106,25 @@ suite('chatModelConfigurationLogic', () => { assert.deepStrictEqual(filtered, { contextSize: 2000 }); }); + test('drops prototype keys rather than resolving them through Object.prototype', () => { + assert.deepStrictEqual(filterConfigurationToSchema({ constructor: 'x', __proto__: 'y', toString: 'z' }, effortSchema), {}); + }); + + test('drops a value from another model, so an Auto tier is not set from a thinking level', () => { + // Auto's navigation-group property is its routing tier, not a thinking effort. + const autoSchema: ILanguageModelConfigurationSchema = { properties: { tier: { group: 'navigation', enum: ['eco', 'balanced', 'max'] } } }; + assert.deepStrictEqual(filterConfigurationToSchema({ thinkingLevel: 'high', tier: 'high' }, autoSchema), {}); + }); + + test('drops values that do not match a declared primitive type', () => { + const schema: ILanguageModelConfigurationSchema = { properties: { contextSize: { type: 'number' }, label: { type: 'string' } } }; + assert.deepStrictEqual([ + filterConfigurationToSchema({ contextSize: 2000 }, schema), + filterConfigurationToSchema({ contextSize: 'banana' }, schema), + filterConfigurationToSchema({ label: 42 }, schema), + ], [{ contextSize: 2000 }, {}, {}]); + }); + test('returns empty when the schema (or its properties) is missing', () => { assert.deepStrictEqual(filterConfigurationToSchema({ thinkingEffort: 'high' }, undefined), {}); assert.deepStrictEqual(filterConfigurationToSchema({ thinkingEffort: 'high' }, {}), {}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts index 06e874eb3d6bba..781939a566e21e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts @@ -20,6 +20,7 @@ import { IInstantiationService } from '../../../../../../../platform/instantiati import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IProgressService } from '../../../../../../../platform/progress/common/progress.js'; import { IStorageService } from '../../../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService } from '../../../../../../../platform/workspace/common/workspace.js'; import { isResourceEditorInput } from '../../../../../../common/editor.js'; @@ -75,6 +76,7 @@ suite('ChatEditorInput', () => { { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, + { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, ); try { @@ -135,6 +137,7 @@ suite('ChatEditorInput', () => { { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, + { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, ); try { @@ -189,6 +192,7 @@ suite('ChatEditorInput', () => { { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, + { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, ); try { @@ -234,6 +238,7 @@ suite('ChatEditorInput', () => { { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, + { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, ); try { @@ -291,6 +296,7 @@ suite('ChatEditorInput', () => { { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, + { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, ); try { diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 73ab9e7cdf2f74..56a6552bb6f952 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { AsyncIterableObject, AsyncIterableSource, DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; @@ -31,6 +32,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IRequestService } from '../../../../../platform/request/common/request.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { getLanguageModelDisplayNameWithSubscriptionSource, languageModelSourcePresentationRegistry } from '../../common/languageModelSourcePresentation.js'; suite('LanguageModels', function () { @@ -129,6 +131,45 @@ suite('LanguageModels', function () { assert.deepStrictEqual(result1[1], 'test-id-12'); }); + test('adds a source suffix only to ChatGPT subscription models', function () { + store.add(languageModelSourcePresentationRegistry.register({ + ownerVendor: 'test-agent-host-codex', + sourceId: 'chatgptSubscription', + label: 'ChatGPT', + icon: Codicon.openai, + description: 'Models provided by your ChatGPT subscription', + })); + const createModel = (identifier: string, modelGroup: NonNullable): ILanguageModelChatMetadataAndIdentifier => ({ + identifier, + metadata: { + extension: nullExtensionDescription.identifier, + id: identifier, + name: 'GPT-5.6 Sol', + vendor: 'test-agent-host-codex', + family: 'gpt-5.6-sol', + version: '1.0', + maxInputTokens: 1_000_000, + maxOutputTokens: 32_000, + isDefaultForLocation: {}, + targetChatSessionType: 'agent-host-codex', + modelGroup, + }, + }); + const copilot = createModel('agent-host-codex:@provider=vscode-proxy:gpt-5.6-sol', { id: 'copilot' }); + const chatGPT = createModel('agent-host-codex:@provider=openai:gpt-5.6-sol', { id: 'chatgpt', sourceId: 'chatgptSubscription' }); + const unrelated = createModel('agent-host-codex:anthropic/claude-opus', { id: 'anthropic' }); + + assert.deepStrictEqual({ + copilot: getLanguageModelDisplayNameWithSubscriptionSource(copilot), + chatGPT: getLanguageModelDisplayNameWithSubscriptionSource(chatGPT), + unrelated: getLanguageModelDisplayNameWithSubscriptionSource(unrelated), + }, { + copilot: 'GPT-5.6 Sol', + chatGPT: 'GPT-5.6 Sol (ChatGPT)', + unrelated: 'GPT-5.6 Sol', + }); + }); + test('selector with id works properly', async function () { const result1 = await languageModels.selectLanguageModels({ id: 'test-id-1' }); assert.deepStrictEqual(result1.length, 1); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationService.test.ts new file mode 100644 index 00000000000000..3059376385b7ce --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationService.test.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { Event } from '../../../../../../../base/common/event.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../../common/customizationHarnessService.js'; +import { SessionType } from '../../../../common/chatSessionsService.js'; +import { PromptFileSource, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; +import { CustomizationMigrationService } from '../../../../common/promptSyntax/service/customizationMigrationServiceImpl.js'; +import { IPromptPath, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { MockPromptsService } from './mockPromptsService.js'; + +class TestPromptsService extends MockPromptsService { + readonly requestedTypes: PromptsType[] = []; + + constructor(private readonly files: readonly IPromptPath[]) { + super(); + } + + override async listPromptFiles(type: PromptsType): Promise { + this.requestedTypes.push(type); + return this.files.filter(file => file.type === type); + } +} + +class TestCustomizationHarnessService extends mock() { + readonly requestedSourceFolderTypes: PromptsType[] = []; + + constructor( + private readonly sessionType = SessionType.AgentHostCopilot, + private readonly harnessLabel = 'Copilot', + ) { + super(); + } + + override findHarnessById(sessionType: string): IHarnessDescriptor | undefined { + if (sessionType !== this.sessionType) { + return undefined; + } + return { + id: sessionType, + label: this.harnessLabel, + icon: Codicon.copilot, + itemProvider: { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => [], + provideSourceFolders: async (_sessionResource, type) => { + this.requestedSourceFolderTypes.push(type); + switch (type) { + case PromptsType.agent: + return [{ uri: URI.file('/copilot/agents'), label: 'Agents', source: PromptsStorage.user }]; + case PromptsType.skill: + return [ + { uri: URI.file('/workspace/.github/skills'), label: 'Workspace Skills', source: PromptsStorage.local }, + { uri: URI.file('/copilot/skills'), label: 'User Skills', source: PromptsStorage.user }, + ]; + default: + return []; + } + }, + }, + }; + } +} + +suite('CustomizationMigrationService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('computes the dialog migration candidates for Agent Host sessions', async () => { + const promptsService = store.add(new TestPromptsService([ + { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace }, + { uri: URI.file('/user-data/prompts/release.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, source: PromptFileSource.UserData }, + { uri: URI.file('/user-data/prompts/reviewer.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, source: PromptFileSource.UserData }, + { uri: URI.file('/user-data/prompts/style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, source: PromptFileSource.UserData }, + { uri: URI.file('/home/test/.copilot/agents/planner.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, source: PromptFileSource.CopilotPersonal }, + { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, source: PromptFileSource.GitHubWorkspace }, + ])); + const harnessService = new TestCustomizationHarnessService(); + const service = new CustomizationMigrationService(promptsService, harnessService); + const agentHostSessionResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }); + const localSessionResource = URI.from({ scheme: SessionType.Local, path: '/session' }); + + const migrations = await service.computeMigrations(agentHostSessionResource); + const localMigrations = await service.computeMigrations(localSessionResource); + const hint = await service.computeMigrationHint(agentHostSessionResource); + const localHint = await service.computeMigrationHint(localSessionResource); + + assert.deepStrictEqual({ + migrations: migrations.map(migration => ({ + type: migration.type, + files: migration.files.map(file => file.path), + candidates: migration.candidates.map(candidate => candidate.uri.path), + })), + localMigrations, + hint, + localHint, + requestedTypes: promptsService.requestedTypes, + requestedSourceFolderTypes: harnessService.requestedSourceFolderTypes.toSorted(), + }, { + migrations: [ + { + type: 'userData', + files: [ + '/user-data/prompts/reviewer.agent.md', + ], + candidates: [ + '/user-data/prompts/reviewer.agent.md', + ], + }, + { + type: 'promptFiles', + files: [ + '/workspace/.github/prompts/review.prompt.md', + '/user-data/prompts/release.prompt.md', + ], + candidates: [ + '/workspace/.github/prompts/review.prompt.md', + '/user-data/prompts/release.prompt.md', + ], + }, + ], + localMigrations: [ + { type: 'userData', files: [], candidates: [] }, + { type: 'promptFiles', files: [], candidates: [] }, + ], + hint: 'Found 3 customization files that are present but not used by Copilot and could be migrated.', + localHint: undefined, + requestedTypes: [ + PromptsType.agent, PromptsType.instructions, PromptsType.prompt, + PromptsType.agent, PromptsType.instructions, PromptsType.prompt, + ], + requestedSourceFolderTypes: [ + PromptsType.agent, PromptsType.agent, PromptsType.instructions, + PromptsType.instructions, PromptsType.skill, PromptsType.skill, + ], + }); + }); + + test('uses the session harness label in migration hints', async () => { + const promptsService = store.add(new TestPromptsService([ + { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace }, + ])); + const harnessService = new TestCustomizationHarnessService(SessionType.AgentHostClaude, 'Claude'); + const service = new CustomizationMigrationService(promptsService, harnessService); + + const hint = await service.computeMigrationHint(URI.from({ scheme: SessionType.AgentHostClaude, path: '/session' })); + + assert.strictEqual(hint, 'Found 1 customization file that is present but not used by Claude and could be migrated.'); + }); +}); diff --git a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts index 6ee10c8b3f260b..a4b9d2ea45585f 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts @@ -142,7 +142,8 @@ function getBreakpointDecorationOptions(accessor: ServicesAccessor, model: IText before: renderInline ? { content: noBreakWhitespace, inlineClassName: `debug-breakpoint-placeholder`, - inlineClassNameAffectsLetterSpacing: true + inlineClassNameAffectsLetterSpacing: true, + widthInEm: 0.9 } : undefined, overviewRuler: overviewRulerDecoration, zIndex: 9999 @@ -195,7 +196,8 @@ function createCandidateDecorations(model: ITextModel, breakpointDecorations: IB before: breakpointAtPosition ? undefined : { content: noBreakWhitespace, inlineClassName: `debug-breakpoint-placeholder`, - inlineClassNameAffectsLetterSpacing: true + inlineClassNameAffectsLetterSpacing: true, + widthInEm: 0.9 }, }, breakpoint: breakpointAtPosition ? breakpointAtPosition.breakpoint : undefined diff --git a/src/vs/workbench/contrib/debug/test/browser/breakpoints.test.ts b/src/vs/workbench/contrib/debug/test/browser/breakpoints.test.ts index c53b662bf176f4..842a53873c0609 100644 --- a/src/vs/workbench/contrib/debug/test/browser/breakpoints.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/breakpoints.test.ts @@ -433,6 +433,7 @@ suite('Debug - Breakpoints', () => { assert.deepStrictEqual(decorations[2].range, new Range(3, 5, 3, 6)); assert.strictEqual(decorations[0].options.beforeContentClassName, undefined); assert.strictEqual(decorations[1].options.before?.inlineClassName, `debug-breakpoint-placeholder`); + assert.strictEqual(decorations[1].options.before?.widthInEm, 0.9); assert.strictEqual(decorations[0].options.overviewRuler?.position, OverviewRulerLane.Left); const expected = new MarkdownString(undefined, { isTrusted: true, supportThemeIcons: true }).appendCodeblock(languageId, 'Condition: x > 5'); assert.deepStrictEqual(decorations[0].options.glyphMarginHoverMessage, expected); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 604caadb715c78..6dc88cdd94a425 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -141,6 +141,7 @@ export class SettingsEditor2 extends EditorPane { '@tag:accessibility', '@tag:preview', '@tag:experimental', + '@tag:agentMerge', `@tag:${ADVANCED_SETTING_TAG}`, `@${ID_SETTING_TAG}`, `@${EXTENSION_SETTING_TAG}`, diff --git a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts index 54af76c3378504..2c957a9796f6ac 100644 --- a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts @@ -32,6 +32,7 @@ interface IConfiguration extends IWindowsConfiguration { telemetry?: { feedback?: { enabled?: boolean } }; chat?: { extensionUnification?: { enabled?: boolean }; + agentSessions?: { migrateLegacyCopilotCli?: boolean }; agentHost?: { enabled?: boolean; claudeAgent?: { enabled?: boolean }; @@ -68,6 +69,7 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo 'accessibility.verbosity.debug', 'telemetry.feedback.enabled', 'chat.extensionUnification.enabled', + 'chat.agentSessions.migrateLegacyCopilotCli', 'chat.agentHost.claudeAgent.enabled', 'chat.editor.codex.preferAgentHost', 'chat.agentHost.otel.enabled', @@ -92,6 +94,7 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo private readonly accessibilityVerbosityDebug = new ChangeObserver('boolean'); private readonly telemetryFeedbackEnabled = new ChangeObserver('boolean'); private readonly extensionUnificationEnabled = new ChangeObserver('boolean'); + private readonly agentSessionsMigrateLegacyCopilotCli = new ChangeObserver('boolean'); private readonly agentHostClaudeAgentEnabled = new ChangeObserver('boolean'); private readonly editorCodexPreferAgentHost = new ChangeObserver('boolean'); private readonly agentHostOTelEnabled = new ChangeObserver('boolean'); @@ -196,6 +199,10 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo processChanged(this.agentHostClaudeAgentEnabled.handleChange(config.chat?.agentHost?.claudeAgent?.enabled)); processChanged(this.editorCodexPreferAgentHost.handleChange(config.chat?.editor?.codex?.preferAgentHost)); + // The legacy Copilot CLI migration gate is snapshotted at startup by both the + // renderer and the shared agent-host process, so a change only applies after a restart. + processChanged(this.agentSessionsMigrateLegacyCopilotCli.handleChange(config.chat?.agentSessions?.migrateLegacyCopilotCli)); + // Agent Host OTel: settings are forwarded as env vars when the agent host // child process is spawned (see `electronAgentHostStarter.ts`). The child // is owned by the main process and is not respawned on window reload, so diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 86396758a14d4f..96822835104f5c 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -145,9 +145,6 @@ class MockAgentConnection implements IAgentConnection { }, }; } - getSubscriptionByChannel(_kind: StateComponents, _channel: string): IReference> { - throw new Error('Not implemented'); - } getSubscriptionUnmanaged(_kind: StateComponents, _resource: URI): IAgentSubscription | undefined { return undefined; } diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index be2db288af3412..e090558115a80c 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -195,10 +195,6 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._requireClient().getSubscription(kind, resource, owner); } - getSubscriptionByChannel(kind: T, channel: string, owner: string): IReference> { - return this._requireClient().getSubscriptionByChannel(kind, channel, owner); - } - getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined { return this._protocolClient?.getSubscriptionUnmanaged(kind, resource); } diff --git a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts index f36f1665bdc94b..1bb255403b1a06 100644 --- a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts +++ b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts @@ -511,7 +511,7 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { this.filePickBox.validationMessage = undefined; const filePickBoxUri = this.filePickBoxValue(); let updated: UpdateResult = UpdateResult.NotUpdated; - if (resources.extUriIgnorePathCase.isEqual(this.currentFolder, resources.dirname(filePickBoxUri))) { + if (!resources.hasTrailingPathSeparator(filePickBoxUri, this.separator) && resources.extUriIgnorePathCase.isEqual(this.currentFolder, resources.dirname(filePickBoxUri))) { this.setActiveItems(value); return; } else if (!resources.extUriIgnorePathCase.isEqual(this.currentFolder, filePickBoxUri)) { diff --git a/src/vs/workbench/services/dialogs/test/browser/simpleFileDialog.test.ts b/src/vs/workbench/services/dialogs/test/browser/simpleFileDialog.test.ts index 6d25c9ac844544..a1f31210f35622 100644 --- a/src/vs/workbench/services/dialogs/test/browser/simpleFileDialog.test.ts +++ b/src/vs/workbench/services/dialogs/test/browser/simpleFileDialog.test.ts @@ -179,6 +179,40 @@ suite('SimpleFileDialog', () => { }); }); + test('updates items when navigating from root into a child folder', async () => { + const root = URI.from({ scheme: Schemas.inMemory, path: '/' }); + const folder = URI.from({ scheme: Schemas.inMemory, path: '/folder/' }); + const filePickBox = { + value: '/folder/', + validationMessage: undefined + }; + let update: { value: string; folder: URI } | undefined; + + const dialog = Object.assign(Object.create(SimpleFileDialog.prototype), { + filePickBox, + currentFolder: root, + userEnteredPathSegment: '', + autoCompletePathSegment: 'folder', + separator: '/', + isWindows: false, + scheme: Schemas.inMemory, + tryUpdateItems: async (value: string, folder: URI) => { + update = { value, folder }; + return 0; + }, + setActiveItems: () => { } + }) as { + handleValueChange(value: string): Promise; + }; + + await dialog.handleValueChange(filePickBox.value); + + assert.deepStrictEqual(update && { value: update.value, folder: update.folder.toString() }, { + value: '/folder/', + folder: folder.toString() + }); + }); + test('does not let a canceled slow folder update overwrite a newer folder', async () => { const slowFolder = URI.file('/slow'); const fastFolder = URI.file('/fast'); diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index 7feca965db8b10..178816b51d3041 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -4,13 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { addDisposableListener, EventType } from '../../../../../base/browser/dom.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; import { workbenchInstantiationService, registerTestEditor, TestFileEditorInput, createEditorParts } from '../../../../test/browser/workbenchTestServices.js'; import { GroupsOrder, IEditorGroupsService } from '../../common/editorGroupsService.js'; import { EditorExtensions, EditorInputCapabilities, IEditorFactoryRegistry } from '../../../../common/editor.js'; import { URI } from '../../../../../base/common/uri.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { MockScopableContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IKeybindingService, IKeyboardEvent } from '../../../../../platform/keybinding/common/keybinding.js'; +import { ResultKind, ResolutionResult } from '../../../../../platform/keybinding/common/keybindingResolver.js'; +import { MockKeybindingService, MockScopableContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { SideBySideEditorInput } from '../../../../common/editor/sideBySideEditorInput.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -22,8 +28,9 @@ import { EditorService } from '../../browser/editorService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; import { Memento } from '../../../../common/memento.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService, IContextKeyServiceTarget } from '../../../../../platform/contextkey/common/contextkey.js'; import { EditorPartModalVisibleContext } from '../../../../common/contextkeys.js'; +import { CLOSE_MODAL_EDITOR_COMMAND_ID } from '../../../../browser/parts/editor/editorCommands.js'; suite('Modal Editor Group', () => { @@ -69,6 +76,99 @@ suite('Modal Editor Group', () => { await modalPart.close(); }); + test('Escape closes modal before focused controls can stop propagation', async () => { + let resolvedCommand = 'test.otherCommand'; + let dispatchCount = 0; + const keybindingService = new class extends MockKeybindingService { + override softDispatch(): ResolutionResult { + return { kind: ResultKind.KbFound, commandId: resolvedCommand, commandArgs: undefined, isBubble: false }; + } + override dispatchEvent(): boolean { + dispatchCount++; + dispatchClose?.(); + return true; + } + }(); + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.stub(IKeybindingService, keybindingService); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + const dispatchClose = () => void modalPart.close(); + const modalElement = modalPart.modalElement as HTMLElement; + const control = modalElement.appendChild(document.createElement('button')); + let controlKeydownCount = 0; + disposables.add(addDisposableListener(control, EventType.KEY_DOWN, event => { + controlKeydownCount++; + event.stopPropagation(); + })); + const states: { modalAttached: boolean; controlKeydownCount: number; dispatchCount: number }[] = []; + + control.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + states.push({ modalAttached: !!modalElement.parentElement, controlKeydownCount, dispatchCount }); + + resolvedCommand = CLOSE_MODAL_EDITOR_COMMAND_ID; + control.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + await timeout(0); + states.push({ modalAttached: !!modalElement.parentElement, controlKeydownCount, dispatchCount }); + + assert.deepStrictEqual(states, [ + { modalAttached: true, controlKeydownCount: 1, dispatchCount: 0 }, + { modalAttached: false, controlKeydownCount: 1, dispatchCount: 1 }, + ]); + }); + + test('bubble close keybindings reach focused controls and dispatch once', async () => { + let dispatchCount = 0; + const keybindingService = new class extends MockKeybindingService { + override softDispatch(): ResolutionResult { + return { kind: ResultKind.KbFound, commandId: CLOSE_MODAL_EDITOR_COMMAND_ID, commandArgs: undefined, isBubble: true }; + } + override dispatchEvent(_event: IKeyboardEvent, _target: IContextKeyServiceTarget): boolean { + dispatchCount++; + return false; + } + }(); + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.stub(IKeybindingService, keybindingService); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + disposables.add(addDisposableListener(mainWindow, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + keybindingService.dispatchEvent(event, event.target); + })); + + const modalPart = await parts.createModalEditorPart(); + const control = (modalPart.modalElement as HTMLElement).appendChild(document.createElement('button')); + let controlKeydownCount = 0; + let stopPropagation = false; + disposables.add(addDisposableListener(control, EventType.KEY_DOWN, event => { + controlKeydownCount++; + if (stopPropagation) { + event.stopPropagation(); + } + })); + const states: { controlKeydownCount: number; dispatchCount: number }[] = []; + + control.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + await timeout(0); + states.push({ controlKeydownCount, dispatchCount }); + + stopPropagation = true; + control.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + await timeout(0); + states.push({ controlKeydownCount, dispatchCount }); + + assert.deepStrictEqual(states, [ + { controlKeydownCount: 1, dispatchCount: 1 }, + { controlKeydownCount: 2, dispatchCount: 2 }, + ]); + await modalPart.close(); + }); + test('modal editor part has correct initial state', async () => { const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatTurnPills.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatTurnPills.fixture.ts index cc9af41d94a7c7..e701c161cd0415 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatTurnPills.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatTurnPills.fixture.ts @@ -49,6 +49,8 @@ function stubFileChangesService(diffs: readonly IEditSessionEntryDiff[]): IChatR interface IRenderTurnPillsOptions { readonly diffs: readonly IEditSessionEntryDiff[]; readonly setting?: ChatTurnStatusPillsSetting; + /** When `true`, the changed-files disclosure is expanded. */ + readonly expanded?: boolean; } function renderTurnPills(ctx: ComponentFixtureContext, options: IRenderTurnPillsOptions): void { @@ -74,6 +76,10 @@ function renderTurnPills(ctx: ComponentFixtureContext, options: IRenderTurnPills const part = disposableStore.add(instantiationService.createInstance(ChatTurnPillsContentPart, content, partContext)); + if (options.expanded) { + part.domNode.querySelector('.checkpoint-file-changes-disclosure')!.open = true; + } + // The turn changes summary reuses the checkpoint summary styling, which is // scoped under `.interactive-session` (and relies on `.monaco-workbench` for // codicon sizing custom properties). @@ -106,6 +112,17 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { }), }), + ChangesOnly_Expanded: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { + expanded: true, + diffs: [ + fileDiff('app.ts', 42, 7, false), + fileDiff('util.ts', 118, 64, false), + fileDiff('index.ts', 5, 0, true), + ], + }), + }), + WorkspaceMarkdown: defineComponentFixture({ render: (ctx) => renderTurnPills(ctx, { diffs: [ diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index e42e8bb512c27d..9de3e57d918467 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -22,7 +22,7 @@ import { chatFloatingPersistentContentClass, chatPersistentContentHeightVariable import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../contrib/chat/browser/widget/input/chatInputPart.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatWidget, IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; -import { ElicitationState, IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { ElicitationState, IChatQuestion, IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; import { ChatElicitationRequestPart } from '../../../../contrib/chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatToolInvocation } from '../../../../contrib/chat/common/model/chatProgressTypes/chatToolInvocation.js'; import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../../contrib/chat/common/tools/languageModelToolsService.js'; @@ -38,6 +38,7 @@ import { MockChatService } from '../../../../contrib/chat/test/common/chatServic import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import { FixtureMenuService, registerChatFixtureServices } from './chatFixtureUtils.js'; import { ChatTurnStatusPillsSetting, isChatTurnStatusPillsEnabled } from '../../../../contrib/chat/browser/widget/chatTurnPills.js'; +import { ITerminalChatService } from '../../../../contrib/terminal/browser/terminal.js'; import { ChatPetWidget } from '../../../../contrib/chat/browser/widget/chatPetWidget.js'; import '../../../../contrib/chat/browser/widget/media/chat.css'; @@ -57,6 +58,7 @@ export interface IFixtureMessage { readonly assistant?: ReadonlyArray< | { kind: 'markdown'; text: string } | { kind: 'progress'; text: string } + | { kind: 'questionCarousel'; questions: IChatQuestion[]; message?: string; allowSkip?: boolean } | { kind: 'terminalConfirmation'; command: string; title?: string; disclaimer?: string; requestUnsandboxedExecution?: boolean; requestUnsandboxedExecutionReason?: string; riskAssessment?: { risk: ToolRiskLevel; explanation: string }; riskLoading?: boolean; confirmation?: { commandLine: string; cwdLabel?: string; cdPrefix?: string } } | { kind: 'elicitation'; title: string; message: string; confirmation?: { commandLine: string; cwdLabel?: string; cdPrefix?: string }; riskAssessment?: { risk: ToolRiskLevel; explanation: string }; riskLoading?: boolean } >; @@ -163,6 +165,9 @@ export async function renderChatWidget(context: ComponentFixtureContext, options colorTheme: context.theme, additionalServices: (reg) => { registerChatFixtureServices(reg); + reg.definePartialInstance(ITerminalChatService, { + getTerminalInstanceByExecutionId: () => undefined, + }); if (options.linkPresentationService) { reg.defineInstance(ILinkPresentationService, options.linkPresentationService); } @@ -261,6 +266,13 @@ export async function renderChatWidget(context: ComponentFixtureContext, options model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(part.text) }); } else if (part.kind === 'progress') { model.acceptResponseProgress(request, { kind: 'progressMessage', content: new MarkdownString(part.text) }); + } else if (part.kind === 'questionCarousel') { + model.acceptResponseProgress(request, { + kind: 'questionCarousel', + questions: part.questions, + allowSkip: part.allowSkip ?? true, + message: part.message, + }); } else if (part.kind === 'elicitation') { const elicitation = new ChatElicitationRequestPart( part.title, @@ -378,6 +390,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options override readonly contribs = []; override readonly location = ChatAgentLocation.Chat; override readonly viewContext = {}; + override readonly input = inputPart; override readonly inputPart = inputPart; }(); widgetHolder.current = fixtureWidget; diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/permissionPickerList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/permissionPickerList.fixture.ts index 0daec321efa0db..8546a6c895a433 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/permissionPickerList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/permissionPickerList.fixture.ts @@ -19,6 +19,7 @@ interface PermissionPickerListFixtureOptions { readonly showStandaloneSandboxToggle?: boolean; /** Whether the inline toggle renders in the on (checked) state. */ readonly sandboxingEnabled?: boolean; + readonly width?: number; } function buildItems(options: PermissionPickerListFixtureOptions): IActionListItem[] { @@ -115,8 +116,9 @@ function buildItems(options: PermissionPickerListFixtureOptions): IActionListIte return items; } -function renderPermissionPickerList(context: ComponentFixtureContext, options: PermissionPickerListFixtureOptions = {}): void { +export function renderPermissionPickerList(context: ComponentFixtureContext, options: PermissionPickerListFixtureOptions = {}): void { const { container, disposableStore } = context; + const width = options.width ?? 320; const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme }); const items = buildItems(options); @@ -136,7 +138,7 @@ function renderPermissionPickerList(context: ComponentFixtureContext, options: P )); container.classList.add('monaco-workbench'); - container.style.width = '320px'; + container.style.width = `${width}px`; container.style.padding = '8px'; container.style.backgroundColor = 'var(--vscode-editor-background)'; @@ -152,7 +154,7 @@ function renderPermissionPickerList(context: ComponentFixtureContext, options: P const actionHeight = defaultItemHeight + 44 * 2 + 24 + (options.showStandaloneSandboxToggle ? 24 : 0); const separatorHeight = options.showStandaloneSandboxToggle ? 16 : 8; const totalHeight = actionHeight + separatorHeight; - widget.layout(totalHeight, 320); + widget.layout(totalHeight, width); } export default defineThemedFixtureGroup({ path: 'chat/input/permissionPickerList' }, { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index cf5c0186ed43f1..7c06fb799aaf76 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -47,6 +47,8 @@ import { IAICustomizationWorkspaceService, AICustomizationManagementSection, AIC import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, ICustomizationSourceFolder, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../../contrib/chat/common/customizationHarnessService.js'; import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; import { getChatSessionType, LocalChatSessionUri } from '../../../../contrib/chat/common/model/chatUri.js'; +import { ICustomizationMigrationService } from '../../../../contrib/chat/common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationService } from '../../../../contrib/chat/common/promptSyntax/service/customizationMigrationServiceImpl.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IAgentSkill, IChatPromptSlashCommand, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { IResolvedPromptSourceFolder } from '../../../../contrib/chat/common/promptSyntax/config/promptFileLocations.js'; import { ParsedPromptFile, PromptFileParser } from '../../../../contrib/chat/common/promptSyntax/promptFileParser.js'; @@ -851,7 +853,9 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor }(); override getSession() { return undefined; } }()); - reg.defineInstance(IPromptsService, createMockPromptsService(fixtureFiles, agentInstructions, fileContents, promptFilesDidChangeEmitter.event)); + const promptsService = createMockPromptsService(fixtureFiles, agentInstructions, fileContents, promptFilesDidChangeEmitter.event); + reg.defineInstance(IPromptsService, promptsService); + reg.defineInstance(ICustomizationMigrationService, new CustomizationMigrationService(promptsService, harnessService)); reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { override readonly isSessionsWindow = isSessionsWindow; override readonly welcomePageFeatures = { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts index 0ab99a9e91c666..0225c813b04982 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts @@ -10,6 +10,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; +import { chatPersistentContentVisibleClass } from '../../../../contrib/chat/browser/widget/chatWidget.js'; import { BrowserEditorInput } from '../../../../contrib/browserView/common/browserEditorInput.js'; import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../contrib/browserView/common/browserView.js'; // eslint-disable-next-line local/code-import-patterns @@ -174,10 +175,18 @@ function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndC container.style.backgroundColor = 'var(--vscode-sideBar-background)'; } -async function renderChatViewWithPills(ctx: ComponentFixtureContext, mock: IMockSessionAndChat, messages: IFixtureMessage[]): Promise { +async function renderChatViewWithPills(ctx: ComponentFixtureContext, mock: IMockSessionAndChat, messages: IFixtureMessage[], options?: { readonly height?: number; readonly scrollOffsetFromBottom?: number }): Promise { + const scrollOffsetFromBottom = options?.scrollOffsetFromBottom; await renderChatWidget(ctx, { messages, + height: options?.height, persistentContentHeight: SESSION_CHAT_INPUT_TOOLBAR_HEIGHT, + onRendered: scrollOffsetFromBottom + ? handle => { + const maximumScrollTop = Math.max(0, handle.listWidget.scrollHeight - handle.listWidget.renderHeight); + handle.listWidget.scrollTop = Math.max(0, maximumScrollTop - scrollOffsetFromBottom); + } + : undefined, decorateInputPart: (inputPart, instantiationService) => { // The fixture's test configuration has no product defaults, so opt in // explicitly to make sure the pills render. @@ -185,7 +194,12 @@ async function renderChatViewWithPills(ctx: ComponentFixtureContext, mock: IMock (accessor.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, true); }); const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar)); + const updateChatPillsVisibility = (visible: boolean) => { + inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); + }; + ctx.disposableStore.add(pills.onDidChangeVisibility(updateChatPillsVisibility)); pills.setSession(mock.session, mock.chat); + updateChatPillsVisibility(pills.visible); // Mount above the input, mirroring the sessions ChatView. inputPart.persistentContentContainerElement.appendChild(pills.element); }, @@ -207,6 +221,49 @@ const FULL_VIEW_MESSAGES: IFixtureMessage[] = [ }, ]; +const FADE_VIEW_MESSAGES: IFixtureMessage[] = [ + ...FULL_VIEW_MESSAGES, + { + user: 'Add a responsive navigation bar', + assistant: [ + { kind: 'markdown', text: 'Implemented a responsive navigation bar with accessible labels and keyboard focus styles.' }, + ], + }, + { + user: 'Verify the production build', + assistant: [ + { kind: 'markdown', text: 'The implementation is complete. The production build requires permission to run.' }, + { kind: 'terminalConfirmation', command: 'npm run build' }, + ], + responseComplete: false, + }, +]; + +const STACKED_SURFACES_VIEW_MESSAGES: IFixtureMessage[] = [ + { + user: 'Deploy the production build', + assistant: [ + { kind: 'markdown', text: 'Before deployment, I need a target and permission to run the build.' }, + { + kind: 'questionCarousel', + message: 'Choose a deployment target', + questions: [{ + id: 'target', + type: 'singleSelect', + title: 'Where should I deploy?', + allowFreeformInput: false, + options: [ + { id: 'staging', label: 'Staging', value: 'staging' }, + { id: 'production', label: 'Production', value: 'production' }, + ], + }], + }, + { kind: 'terminalConfirmation', command: 'npm run build' }, + ], + responseComplete: false, + }, +]; + // ============================================================================ // Fixtures // ============================================================================ @@ -441,6 +498,18 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { }), FULL_VIEW_MESSAGES), }), + SessionChatView_ArtifactPillAndPermission: defineComponentFixture({ + render: (ctx) => renderChatViewWithPills(ctx, createMockSession({ + artifacts: [{ id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }], + }), FADE_VIEW_MESSAGES, { height: 400, scrollOffsetFromBottom: 16 }), + }), + + SessionChatView_StackedInputSurfaces: defineComponentFixture({ + render: (ctx) => renderChatViewWithPills(ctx, createMockSession({ + artifacts: [{ id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }], + }), STACKED_SURFACES_VIEW_MESSAGES, { height: 720 }), + }), + SessionChatView_ReadOnlyPills: defineComponentFixture({ render: async (ctx) => { const mock = createMockSession({ diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index 613491eb43b7d3..b62a88e628f2d1 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -116,6 +116,11 @@ declare module 'vscode' { readonly endsAt?: string; /** A human-readable message about the promotion. */ readonly message: string; + /** + * Whether the promotion may also be surfaced as a banner above the chat input. + * Omit to allow the banner; set to `false` to keep the promotion in the model picker only. + */ + readonly showBanner?: boolean; }; } diff --git a/test/automation/src/quickaccess.ts b/test/automation/src/quickaccess.ts index abf4d22c7f7c70..300bb162757ecd 100644 --- a/test/automation/src/quickaccess.ts +++ b/test/automation/src/quickaccess.ts @@ -32,7 +32,7 @@ export class QuickAccess { // make sure the file quick access is not "polluted" // with entries from the editor history when opening - await this.runCommand('workbench.action.clearEditorHistoryWithoutConfirm'); + await this.runCommand('workbench.action.clearEditorHistoryWithoutConfirm', { match: 'fuzzy' }); const PollingStrategy = { Stop: true, @@ -274,7 +274,7 @@ export class QuickAccess { // open quick outline via keybinding await this.openQuickAccessWithRetry(QuickAccessKind.Symbols); - const { label } = await this.quickInput.waitForQuickInputElement(); + const label = await this.quickInput.waitForQuickInputElementText(); // Retry for as long as no symbols are found if (label === 'No symbol information for the file') { diff --git a/test/automation/src/quickinput.ts b/test/automation/src/quickinput.ts index ab5db8f00d5388..ddfe0e1a5553c5 100644 --- a/test/automation/src/quickinput.ts +++ b/test/automation/src/quickinput.ts @@ -18,6 +18,7 @@ export class QuickInput { private static QUICK_INPUT_FOCUSED_ENTRY = `${QuickInput.QUICK_INPUT_ROW}.focused .quick-input-list-entry`; // Note: this only grabs the label and not the description or detail private static QUICK_INPUT_ENTRY_LABEL = `${QuickInput.QUICK_INPUT_ROW} .quick-input-list-row > .monaco-icon-label .label-name`; + private static QUICK_INPUT_ENTRY_LABEL_SPAN = `${QuickInput.QUICK_INPUT_ROW} .monaco-highlighted-label`; private static QUICK_INPUT_FOCUSED_LABEL = `${QuickInput.QUICK_INPUT_ROW}.focused .quick-input-list-row > .monaco-icon-label .label-name`; private static QUICK_INPUT_FOCUSED_ELEMENTS = `${QuickInput.QUICK_INPUT_FOCUSED_ENTRY}, ${QuickInput.QUICK_INPUT_FOCUSED_LABEL}`; @@ -39,6 +40,10 @@ export class QuickInput { }; } + async waitForQuickInputElementText(): Promise { + return this.code.waitForTextContent(QuickInput.QUICK_INPUT_ENTRY_LABEL_SPAN); + } + async closeQuickInput(): Promise { await this.code.dispatchKeybinding('escape', () => this.waitForQuickInputClosed()); } diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index e4cf6c16b81eca..a1d3bfa5756e83 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -163,10 +163,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/7f70224f7733a2461eba63fa98234aab38b8804a73460deffa11f49cd6f7172c) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9a23d68d520d31525d56d8cfb444365a79f81b0ee3391de28e94bf32004ef778) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7ae2b4a47fd55deedfef3304fd218f5768fa4847a6ce8749a005755e6bec2d3f) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/0907edfda4ec6bb22618a9aa1d3ef106233edde64145f0c4e5ec0c3b14fcbf24) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/48d5afd53482fa7155777eaf6a6a2dc1f809f5b945d08a9befe9eee63eaf8b0b) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0752cf02ae3a4e21fce84b62859df32a5f41c13622bdec0083a3fd46832c2e0a) @@ -179,3 +179,27 @@ #### sessions/accountMenu/petAchievementBadges/chatPetAchievementBadges/AllBadges/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) + +#### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8c8eb6b803b02923698fc074ae21adba8ab9d19f0456c03b2a2702ef237608ba) + +#### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8f4a4dc0bd65c797d58dec40dc5b0a739152687e9192683e5560835c04728f1a) + +#### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3a93997e6f3e6ef7b63dcce185f571b7e048f217013fda13e7b6622c4bb4e9b1) + +#### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0baa16342facc6b2bbfec277ba966e283bcb5cc8522feadedbb0551d11b37af6) + +#### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9ce99acb0597d337c3629946531ad90143c739de94866b759c749e4c91dc1327) + +#### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/154e56d8556400167418dce9e0b5ae4355644be560a94f91ba5fbf502ef3696f) + +#### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/fac59f2e6a520bf03f063f35f83af3ddc575cf95cec659e7f4cdef963ff4e090) + +#### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a6b3c1c792dd0804bcbc2fb3819ba09bbdbe15f653e4b7f89efeb799d9cc11c9) diff --git a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts new file mode 100644 index 00000000000000..060ae6227dc063 --- /dev/null +++ b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect, test } from '@playwright/test'; +import { openFixture } from './utils.js'; + +test('reveals the nested chat twistie only while hovering the session row', async ({ page }) => { + await openFixture(page, 'sessions/sessionsList/SessionsList_NestedChatApprovals/Dark', '.session-item'); + + const sessionRow = page.locator('.monaco-list-row').filter({ has: page.locator('.session-item') }).first(); + const twistie = sessionRow.locator('.session-chat-twistie.collapsible'); + const statusIcon = sessionRow.locator('.session-icon'); + + await expect(twistie).toHaveCSS('opacity', '0'); + await expect(twistie).toHaveCSS('pointer-events', 'none'); + await expect(statusIcon).toHaveCSS('visibility', 'visible'); + + await sessionRow.hover(); + + await expect(twistie).toHaveCSS('opacity', '1'); + await expect(twistie).toHaveCSS('pointer-events', 'auto'); + await expect(statusIcon).toHaveCSS('visibility', 'hidden'); + + await page.mouse.move(0, 0); + + await expect(twistie).toHaveCSS('opacity', '0'); + await expect(statusIcon).toHaveCSS('visibility', 'visible'); +});