Merge master into runner paid matrix integrity
* commit 'a0a78ee60946a5f79f85b2bd0584fc766fae43bb': ci: bootstrap Node before pnpm setup (#12808) ci(runner): stamp paid target provenance (#12805) fix: guard listComments against non-UUID afterCommentId to prevent 500 errors (#8695) test(plugin-worker): remove the wall-clock race from the duplex buffered-replay tests (#12799) ci(runner): skip bootstrap registry telemetry (#12797) fix(ui): honor PAPERCLIP_HIDDEN_SETTINGS in the production switcher menu (#12788) chore(deps): bump motion from 12.43.0 to 13.1.1 (#12255) chore(deps): bump dompurify from 3.4.13 to 3.4.14 (#12266) chore(deps-dev): bump @types/react-dom from 19.2.4 to 19.2.5 (#12253) ci(runner): inspect Daytona image metadata remotely (#12795) fix(ui): polish core navigation and task layout (#12793) chore(deps): bump yjs from 13.6.29 to 13.6.32 (#12256) chore(deps): bump @aws-sdk/client-s3 from 3.1120.0 to 3.1122.0 (#12261) chore(deps-dev): bump vitest from 4.1.10 to 4.1.11 (#12262) chore(deps): bump react-i18next from 17.0.11 to 17.0.12 (#12263) fix(ui): drop the "Open invite" action from the invites section (#12787) # Conflicts: # tests/runner-e2e/workflow-security.test.ts
This commit is contained in:
commit
95aa8c514e
|
|
@ -280,8 +280,17 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
|
@ -369,8 +378,17 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -456,8 +474,17 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -534,8 +561,17 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -599,8 +635,17 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -636,8 +681,17 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -714,8 +768,17 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
|
|||
|
|
@ -158,14 +158,18 @@ jobs:
|
|||
ref: ${{ needs.authorize.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Resolve target lockfile without lifecycle scripts
|
||||
id: lock
|
||||
run: |
|
||||
|
|
@ -232,7 +236,15 @@ jobs:
|
|||
cp "$lock" pnpm-lock.yaml
|
||||
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -420,31 +432,25 @@ jobs:
|
|||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
"$IMAGE_TAG@$digest" >/dev/null
|
||||
immutable="${IMAGE_TAG%:*}@$digest"
|
||||
# The Daytona base image is large. The build cache plus a second full
|
||||
# anonymous pull can exhaust a standard GitHub-hosted runner before
|
||||
# Docker creates the tiny metadata-probe container. The pushed digest
|
||||
# is already immutable, so release the local builder/cache first.
|
||||
docker buildx prune --all --force >/dev/null
|
||||
docker system prune --all --force >/dev/null
|
||||
anonymous_config="$(mktemp -d)"
|
||||
docker --config "$anonymous_config" pull "$immutable"
|
||||
# The Dockerfile's final two RUN steps execute the runner metadata,
|
||||
# transport-mode, provider-pack JSON, and pinned ACP binary checks as
|
||||
# root and as the unprivileged Daytona user. Starting another
|
||||
# container after this full pull can exhaust the hosted runner's thin
|
||||
# writable layer even after pruning, so assert the published image
|
||||
# configuration here without creating a redundant container.
|
||||
image_config="$(docker image inspect "$immutable" \
|
||||
--format '{{json .}}')"
|
||||
published_content_id="$(jq -r '.Config.Labels["io.paperclip.runner.content-id"] // empty' <<< "$image_config")"
|
||||
source_revision="$(jq -r '.Config.Labels["org.opencontainers.image.revision"] // empty' <<< "$image_config")"
|
||||
# root and as the unprivileged Daytona user. Buildx reads the signed
|
||||
# digest's OCI config directly from GHCR, so verification does not
|
||||
# download the image's large filesystem layers. Logging out first
|
||||
# preserves the proof that Daytona can retrieve this public image
|
||||
# without the workflow's package credentials.
|
||||
docker logout ghcr.io >/dev/null
|
||||
image_config="$(docker buildx imagetools inspect "$immutable" \
|
||||
--format '{{json .Image}}')"
|
||||
published_content_id="$(jq -r '.config.Labels["io.paperclip.runner.content-id"] // empty' <<< "$image_config")"
|
||||
source_revision="$(jq -r '.config.Labels["org.opencontainers.image.revision"] // empty' <<< "$image_config")"
|
||||
test "$published_content_id" = "$IMAGE_CONTENT_ID"
|
||||
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]]
|
||||
jq -e \
|
||||
'.Architecture == "amd64" and
|
||||
.Os == "linux" and
|
||||
.Config.User == "daytona" and
|
||||
(.Config.Env | any(startswith("PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=")))' \
|
||||
'.architecture == "amd64" and
|
||||
.os == "linux" and
|
||||
.config.User == "daytona" and
|
||||
(.config.Env | any(startswith("PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=")))' \
|
||||
<<< "$image_config" >/dev/null
|
||||
echo "image=$immutable" >> "$GITHUB_OUTPUT"
|
||||
echo "source_revision=$source_revision" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -488,7 +494,15 @@ jobs:
|
|||
cp "$lock" pnpm-lock.yaml
|
||||
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -609,8 +623,17 @@ jobs:
|
|||
cp "$lock" pnpm-lock.yaml
|
||||
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
|
||||
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -759,14 +782,18 @@ jobs:
|
|||
cp "$lock" pnpm-lock.yaml
|
||||
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# This job receives provider credentials only in the final paid-test
|
||||
# step. Keep target-selected dependency lifecycle code from running in
|
||||
# the protected environment during setup.
|
||||
|
|
@ -928,7 +955,15 @@ jobs:
|
|||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -1034,14 +1069,18 @@ jobs:
|
|||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download access-controlled normalized campaign
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@
|
|||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
"drizzle-kit": "^0.31.10",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@
|
|||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"axe-core": "^4.12.1",
|
||||
"react": "^19.2.7",
|
||||
|
|
@ -187,6 +187,6 @@
|
|||
"tsx": "^4.23.12",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
"rollup": "^4.63.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"esbuild": "^0.28.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"typescript": "^7.0.2"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"esbuild": "^0.28.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"typescript": "^7.0.2"
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"rollup": "^4.63.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@
|
|||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"esbuild": "^0.28.2",
|
||||
"react-dom": "^19.2.8",
|
||||
"rollup": "^4.63.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
|
|
|
|||
|
|
@ -57,12 +57,12 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"esbuild": "^0.28.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
1107
pnpm-lock.yaml
1107
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -44,7 +44,7 @@
|
|||
"typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1120.0",
|
||||
"@aws-sdk/client-s3": "^3.1122.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
"better-auth": "1.7.2",
|
||||
"chokidar": "^5.0.0",
|
||||
"detect-port": "^2.1.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"dompurify": "^3.4.14",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
"tsx": "^4.23.12",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/auto-instrumentations-node": "0.79.0",
|
||||
|
|
|
|||
|
|
@ -2451,6 +2451,44 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||
expect(comments.map((comment) => comment.id)).toEqual([latestCommentId]);
|
||||
});
|
||||
|
||||
it("returns no comments for an anchor cursor that is not a UUID", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const commentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Malformed cursor issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId,
|
||||
companyId,
|
||||
issueId,
|
||||
body: "Only comment",
|
||||
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
const comments = await svc.listComments(issueId, {
|
||||
afterCommentId: commentId.slice(0, 8),
|
||||
order: "asc",
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
expect(comments).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists user comments when derived run attribution scans a timestamp window", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -130,17 +130,22 @@ describe("plugin worker manager duplex channel route", () => {
|
|||
const session = await handle.openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
data: [{ chunk: "one" }, { chunk: "two" }, { chunk: "three" }],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
// Wait so the three data notifications arrive and buffer before a listener
|
||||
// attaches. The drain then delivers them in order.
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
// The worker writes the three data notifications and the exit in one
|
||||
// stdout write. The host reads worker stdout line by line, so it buffers
|
||||
// all three data frames before it reads the exit. The exit settles the
|
||||
// wait, so the wait is a deterministic barrier: once it resolves, the
|
||||
// host holds all three frames and no listener has attached yet. This
|
||||
// barrier replaces a fixed sleep, so the test does not race the
|
||||
// subprocess start or the stdio latency.
|
||||
await session.wait();
|
||||
const chunks: string[] = [];
|
||||
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
||||
// text, so the assertion below compares the plain-text payload the
|
||||
// fixture directive scripted.
|
||||
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
||||
await vi.waitFor(() => expect(chunks.length).toBe(3));
|
||||
expect(chunks).toEqual(["one", "two", "three"]);
|
||||
await session.close();
|
||||
} finally {
|
||||
|
|
@ -213,11 +218,17 @@ describe("plugin worker manager duplex channel route", () => {
|
|||
const session = await handle.openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
data: [{ chunk: "one" }, { chunk: "boom" }, { chunk: "three" }],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
// Wait so the three data notifications arrive and buffer before a listener
|
||||
// attaches. The drain then delivers them in order.
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
// The worker writes the three data notifications and the exit in one
|
||||
// stdout write. The host reads worker stdout line by line, so it buffers
|
||||
// all three data frames before it reads the exit. The exit settles the
|
||||
// wait, so the wait is a deterministic barrier for "the host holds every
|
||||
// pre-bind frame and no listener has attached". This barrier replaces a
|
||||
// fixed sleep, so the test does not race the subprocess start or the
|
||||
// stdio latency.
|
||||
await session.wait();
|
||||
const chunks: string[] = [];
|
||||
// The listener throws on one buffered chunk. The manager catches the throw
|
||||
// inside the drain, so it does not escape `onData` and every buffered chunk
|
||||
|
|
|
|||
|
|
@ -8796,6 +8796,8 @@ export function issueService(db: Db) {
|
|||
|
||||
const conditions = [eq(issueComments.issueId, issueId)];
|
||||
if (afterCommentId) {
|
||||
// Guard: reject non-UUID cursors before hitting the DB to avoid Postgres type errors.
|
||||
if (!isUuidLike(afterCommentId)) return [];
|
||||
const anchor = await db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
|
|
|
|||
|
|
@ -115,12 +115,12 @@ test.describe.serial("dark-mode Apps surfaces", () => {
|
|||
await healthy?.close();
|
||||
});
|
||||
|
||||
test("sidebar says Apps and links to /apps", async ({ page }) => {
|
||||
test("sidebar says Connectors and links to /apps", async ({ page }) => {
|
||||
await forceDark(page);
|
||||
await page.goto(`/${seed.prefix}/dashboard`);
|
||||
const appsLink = page.getByRole("link", { name: "Apps", exact: true });
|
||||
await expect(appsLink).toBeVisible({ timeout: 30_000 });
|
||||
await expect(appsLink).toHaveAttribute("href", new RegExp(`/${seed.prefix}/apps$`));
|
||||
const connectorsLink = page.getByRole("link", { name: "Connectors", exact: true });
|
||||
await expect(connectorsLink).toBeVisible({ timeout: 30_000 });
|
||||
await expect(connectorsLink).toHaveAttribute("href", new RegExp(`/${seed.prefix}/apps$`));
|
||||
});
|
||||
|
||||
test("apps list dark mode with attention banner", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { test, expect, request as pwRequest, type APIRequestContext } from "@pla
|
|||
/**
|
||||
* E2E: contextual sidebar companion model.
|
||||
*
|
||||
* Contextual routes render their navigation beside the stable global sidebar.
|
||||
* The global company navigation and account menu remain available, and leaving
|
||||
* the surface restores the user's global sidebar preference.
|
||||
* Most contextual routes render their navigation beside the stable global
|
||||
* sidebar. Settings intentionally takes over that sidebar while preserving the
|
||||
* account menu, and its Back to app link restores the global navigation.
|
||||
*
|
||||
* Plugin route sidebars share the same Layout path. A live plugin-route test
|
||||
* requires a plugin fixture, so that branch remains covered by Layout tests.
|
||||
|
|
@ -60,7 +60,7 @@ test.describe("Contextual sidebar companion", () => {
|
|||
}, COLLAPSED_STORAGE_KEY);
|
||||
});
|
||||
|
||||
test("shows Settings beside the global navigation", async ({ page }) => {
|
||||
test("replaces global navigation with Settings navigation", async ({ page }) => {
|
||||
await page.goto(`/${prefix}/company/settings`);
|
||||
|
||||
const contextual = page.locator('[data-contextual-sidebar="settings"]');
|
||||
|
|
@ -70,10 +70,10 @@ test.describe("Contextual sidebar companion", () => {
|
|||
|
||||
await expect(contextual.getByRole("link", { name: "General" })).toBeVisible();
|
||||
await expect(contextual.getByText("Environments", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Back from Settings" })).toBeVisible();
|
||||
await expect(contextual.getByRole("link", { name: "Back to app" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Open account menu" })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Dashboard" })).toHaveCount(0);
|
||||
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ test.describe("Contextual sidebar companion", () => {
|
|||
test("keeps the retired collapse control absent across contextual navigation", async ({ page }) => {
|
||||
await page.goto(`/${prefix}/company/settings`);
|
||||
await expect(page.locator('[data-contextual-sidebar="settings"]')).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Back to app" })).toBeVisible();
|
||||
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
|
||||
|
||||
await page.goto(`/${prefix}/dashboard`);
|
||||
|
|
@ -101,9 +101,9 @@ test.describe("Contextual sidebar companion", () => {
|
|||
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("uses Dashboard as the safe fallback for a direct Settings link", async ({ page }) => {
|
||||
test("uses Dashboard as the destination for a direct Settings link", async ({ page }) => {
|
||||
await page.goto(`/${prefix}/company/settings`);
|
||||
await page.getByRole("button", { name: "Back from Settings" }).click();
|
||||
await page.getByRole("link", { name: "Back to app" }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${prefix}/dashboard$`));
|
||||
await expect(page.locator("[data-contextual-sidebar]")).toHaveCount(0);
|
||||
|
|
|
|||
|
|
@ -85,8 +85,16 @@ describe("runner E2E Daytona image contract", () => {
|
|||
);
|
||||
expect(workflow).not.toContain("e2e-git-${{ github.sha }}");
|
||||
expect(workflow).toContain("cosign sign --yes");
|
||||
expect(workflow).toContain("docker image inspect");
|
||||
expect(workflow).toContain('.Config.User == "daytona"');
|
||||
expect(workflow).toContain("docker logout ghcr.io");
|
||||
expect(workflow).toContain(`docker buildx imagetools inspect "$immutable"`);
|
||||
expect(workflow).toContain(`--format '{{json .Image}}'`);
|
||||
expect(workflow).not.toContain(`docker --config "$anonymous_config" pull`);
|
||||
expect(workflow).not.toContain("docker image inspect");
|
||||
expect(workflow).not.toContain("docker buildx prune --all --force");
|
||||
expect(workflow).not.toContain("docker system prune --all --force");
|
||||
expect(workflow).toContain('.architecture == "amd64"');
|
||||
expect(workflow).toContain('.os == "linux"');
|
||||
expect(workflow).toContain('.config.User == "daytona"');
|
||||
expect(workflow).toContain("PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=");
|
||||
expect(workflow).toContain(
|
||||
"node packages/paperclip-runner/scripts/build-provider-pack.mjs packages/paperclip-runner/provider-pack",
|
||||
|
|
@ -97,7 +105,12 @@ describe("runner E2E Daytona image contract", () => {
|
|||
expect(workflow).toContain(
|
||||
"PAPERCLIP_RUNNER_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }}",
|
||||
);
|
||||
expect(workflow).toContain("anonymous_config");
|
||||
expect(workflow.indexOf("cosign verify")).toBeLessThan(
|
||||
workflow.indexOf("docker logout ghcr.io"),
|
||||
);
|
||||
expect(workflow.indexOf("docker logout ghcr.io")).toBeLessThan(
|
||||
workflow.indexOf(`--format '{{json .Image}}'`),
|
||||
);
|
||||
});
|
||||
|
||||
it("hashes the audited image dependency closure rather than the repository revision", async () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,92 @@ const buildRemoteProviderPackNeeds =
|
|||
/needs:\s*\[\s*authorize,\s*target_lock,\s*catalog,\s*daytona_image,\s*build_runner_artifacts,?\s*\]/u;
|
||||
|
||||
describe("public repository paid workflow security", () => {
|
||||
it("keeps pnpm bootstrap registry telemetry out of trusted workflow setup", async () => {
|
||||
for (const workflowName of [
|
||||
"runner-full-stack-e2e.yml",
|
||||
"pr-trusted.yml",
|
||||
]) {
|
||||
const workflow = await readFile(
|
||||
path.join(repositoryRoot, ".github/workflows", workflowName),
|
||||
"utf8",
|
||||
);
|
||||
const pnpmSetupSteps = workflow
|
||||
.split(/\n(?= {6}- )/u)
|
||||
.filter((step) => step.includes("uses: pnpm/action-setup@"));
|
||||
|
||||
expect(pnpmSetupSteps, workflowName).toHaveLength(7);
|
||||
for (const step of pnpmSetupSteps) {
|
||||
expect(step, workflowName).toContain('NPM_CONFIG_AUDIT: "false"');
|
||||
expect(step, workflowName).toContain('NPM_CONFIG_FUND: "false"');
|
||||
expect(step, workflowName).toContain(
|
||||
'NPM_CONFIG_UPDATE_NOTIFIER: "false"',
|
||||
);
|
||||
}
|
||||
for (const variable of [
|
||||
"NPM_CONFIG_AUDIT",
|
||||
"NPM_CONFIG_FUND",
|
||||
"NPM_CONFIG_UPDATE_NOTIFIER",
|
||||
]) {
|
||||
expect(
|
||||
workflow.match(new RegExp(`${variable}:`, "gu")),
|
||||
workflowName,
|
||||
).toHaveLength(pnpmSetupSteps.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("installs a modern Node runtime before every trusted pnpm bootstrap", async () => {
|
||||
const workflows = [
|
||||
{
|
||||
name: "runner-full-stack-e2e.yml",
|
||||
expectedCachedSetupNodeSteps: 4,
|
||||
},
|
||||
{
|
||||
name: "pr-trusted.yml",
|
||||
expectedCachedSetupNodeSteps: 7,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, expectedCachedSetupNodeSteps } of workflows) {
|
||||
const workflow = await readFile(
|
||||
path.join(repositoryRoot, ".github/workflows", name),
|
||||
"utf8",
|
||||
);
|
||||
const steps = workflow.split(/\n(?= {6}- )/u);
|
||||
const pnpmSetupStepIndexes = steps.flatMap((step, index) =>
|
||||
step.includes("uses: pnpm/action-setup@") ? [index] : [],
|
||||
);
|
||||
|
||||
expect(pnpmSetupStepIndexes, name).toHaveLength(7);
|
||||
for (const pnpmSetupStepIndex of pnpmSetupStepIndexes) {
|
||||
const pnpmSetupStep = steps[pnpmSetupStepIndex]!;
|
||||
const nodeBootstrapStep = steps[pnpmSetupStepIndex - 1]!;
|
||||
expect(nodeBootstrapStep, name).toContain("uses: actions/setup-node@");
|
||||
expect(nodeBootstrapStep, name).not.toContain("cache: pnpm");
|
||||
|
||||
const nodeVersionMatch = nodeBootstrapStep.match(
|
||||
/^\s*node-version:\s*["']?(\d+)(?:\.(\d+))?/mu,
|
||||
);
|
||||
expect(nodeVersionMatch, name).not.toBeNull();
|
||||
const nodeMajor = Number(nodeVersionMatch![1]);
|
||||
const nodeMinor = Number(nodeVersionMatch![2] ?? 0);
|
||||
expect(
|
||||
nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 13),
|
||||
`${name} must install Node >=22.13 before pnpm/action-setup`,
|
||||
).toBe(true);
|
||||
|
||||
const conditionPattern = /^ {6}- if:\s*(.+)$/mu;
|
||||
expect(nodeBootstrapStep.match(conditionPattern)?.[1] ?? null).toBe(
|
||||
pnpmSetupStep.match(conditionPattern)?.[1] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
expect(workflow.match(/^\s+cache: pnpm$/gmu), name).toHaveLength(
|
||||
expectedCachedSetupNodeSteps,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("gates every provider-secret job with stable actor IDs", async () => {
|
||||
const workflows = await Promise.all(
|
||||
["runner-full-stack-e2e.yml", "runner-live-evals.yml", "e2e.yml"].map(
|
||||
|
|
|
|||
|
|
@ -61,17 +61,17 @@
|
|||
"lexical": "0.48.0",
|
||||
"lucide-react": "^1.38.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"motion": "^12.42.2",
|
||||
"motion": "^13.1.1",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.12.3",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"yjs": "13.6.29"
|
||||
"yjs": "13.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/browser": "10.71.0",
|
||||
|
|
@ -81,13 +81,13 @@
|
|||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"storybook": "10.5.10",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import { useReviewCount } from "@/pages/apps/useReviewCount";
|
|||
import { SidebarNavItem } from "./SidebarNavItem.production";
|
||||
|
||||
/**
|
||||
* Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA
|
||||
* Secondary sidebar for the prosumer Connectors area (PAP-10856; three-door IA
|
||||
* PAP-13254 / U3).
|
||||
*
|
||||
* ← Back · APPS: Browse / Review (n)
|
||||
* ← Back · CONNECTORS: Browse / Review (n)
|
||||
* DEVELOPER: Connections / Gateways / Profiles / Rules / Health / Activity
|
||||
*
|
||||
* "Browse" is the store and "Review" holds decisions waiting on the user's
|
||||
|
|
@ -19,7 +19,7 @@ import { SidebarNavItem } from "./SidebarNavItem.production";
|
|||
* "Needs attention" is no longer a door: health/error triage folds into
|
||||
* Connections as a status filter + banner, so approvals are never buried
|
||||
* behind an error label. The Developer section was folded in from the retired
|
||||
* ToolsSidebar (PAP-10915) so the whole Apps area shares one sidebar; a
|
||||
* ToolsSidebar (PAP-10915) so the whole Connectors area shares one sidebar; a
|
||||
* one-line caption frames who it's for (Finding A). "Run your own" and "Paste a
|
||||
* config" moved out of the sidebar into rows on the Connect-an-app page
|
||||
* (PAP-10922).
|
||||
|
|
@ -49,13 +49,13 @@ export function AppsSidebar() {
|
|||
</Link>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<AppWindow className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">Apps</span>
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">Connectors</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
|
||||
<div className="px-3 pb-1 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Apps
|
||||
Connectors
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/apps" label="Browse" icon={Store} end />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppsSidebar } from "./AppsSidebar";
|
||||
import { AppsSidebar as ProductionAppsSidebar } from "./AppsSidebar.production";
|
||||
import { contextualSidebarStyles } from "./contextual-sidebar-styles";
|
||||
|
||||
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -61,6 +62,19 @@ vi.mock("./SidebarNavItem", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarNavItem.production", () => ({
|
||||
SidebarNavItem: (props: {
|
||||
to: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
liveCount?: number;
|
||||
badge?: number;
|
||||
}) => {
|
||||
sidebarNavItemMock(props);
|
||||
return <div data-to={props.to}>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
|
@ -114,6 +128,7 @@ describe("AppsSidebar", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(container.textContent).not.toContain("Apps");
|
||||
expect(container.querySelector('nav[aria-label="Connectors"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Developer");
|
||||
expect(container.textContent).not.toContain("Advanced setup for developers");
|
||||
expect(container.textContent).not.toContain("Most teams");
|
||||
|
|
@ -122,7 +137,7 @@ describe("AppsSidebar", () => {
|
|||
// assert both advanced setup items remain absent at the item level below.
|
||||
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps", label: "Connectors", end: true }),
|
||||
expect.objectContaining({ to: "/apps", label: "Browse", end: true }),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/review", label: "Review" }),
|
||||
|
|
@ -166,4 +181,30 @@ describe("AppsSidebar", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Connectors terminology throughout the classic contextual sidebar", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProductionAppsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Connectors");
|
||||
expect(container.textContent).not.toContain("Apps");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps", label: "Browse", end: true }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ export function AppsSidebar() {
|
|||
return (
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<nav
|
||||
aria-label="Apps"
|
||||
aria-label="Connectors"
|
||||
data-slot="contextual-sidebar-nav"
|
||||
className={contextualSidebarStyles.nav}
|
||||
>
|
||||
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
|
||||
<SidebarNavItem to="/apps" label="Connectors" icon={Store} end />
|
||||
<SidebarNavItem to="/apps" label="Browse" icon={Store} end />
|
||||
<SidebarNavItem
|
||||
to="/apps/review"
|
||||
label="Review"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
import { primarySidebarStyles } from "./primary-sidebar-styles";
|
||||
|
||||
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarBadgesApi = vi.hoisted(() => ({
|
||||
|
|
@ -124,7 +125,7 @@ describe("CompanySettingsSidebar", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders one unified settings list without company or instance headers", async () => {
|
||||
it("renders a primary-style settings takeover with a back-to-app link", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
|
|
@ -139,7 +140,31 @@ describe("CompanySettingsSidebar", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip");
|
||||
expect(container.textContent).not.toContain("Paperclip");
|
||||
expect(container.textContent).not.toContain("Settings");
|
||||
expect(container.querySelector('[aria-label="Back from Settings"]')).toBeNull();
|
||||
const settingsSurface = container.querySelector('[data-contextual-sidebar="settings"]');
|
||||
expect(settingsSurface?.classList).toContain("bg-border/50");
|
||||
expect(settingsSurface?.classList).toContain("dark:bg-muted");
|
||||
expect(container.querySelector('[data-slot="contextual-sidebar-nav"]')?.className).toBe(
|
||||
primarySidebarStyles.nav,
|
||||
);
|
||||
const settingsHeader = container.querySelector('[data-slot="settings-sidebar-header"]');
|
||||
expect(settingsHeader?.classList).toContain("h-(--sz-60px)");
|
||||
expect(settingsHeader?.classList).toContain("items-center");
|
||||
expect(settingsHeader?.textContent).toContain("Back to app");
|
||||
const backGroup = container.querySelector('[data-slot="settings-back-group"]');
|
||||
expect(backGroup?.classList).toContain("w-full");
|
||||
for (const className of primarySidebarStyles.group.split(" ")) {
|
||||
expect(backGroup?.classList).toContain(className);
|
||||
}
|
||||
expect(container.querySelector('[data-slot="contextual-sidebar-group"]')?.className).toBe(
|
||||
primarySidebarStyles.group,
|
||||
);
|
||||
expect(container.textContent).toContain("Back to app");
|
||||
expect(container.querySelector('nav[aria-label="Settings"]')?.textContent).not.toContain(
|
||||
"Back to app",
|
||||
);
|
||||
expect(container.textContent).not.toContain("Company Settings");
|
||||
expect(container.textContent).not.toContain("Instance Settings");
|
||||
expect(container.textContent).toContain("General");
|
||||
|
|
@ -150,6 +175,12 @@ describe("CompanySettingsSidebar", () => {
|
|||
expect(container.textContent).toContain("Secrets");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).not.toContain("Tools & Access");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/dashboard",
|
||||
label: "Back to app",
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Cpu,
|
||||
Download,
|
||||
FlaskConical,
|
||||
|
|
@ -26,6 +27,7 @@ import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
|||
import { usePluginSlots } from "@/plugins/slots";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
|
||||
import { primarySidebarStyles } from "./primary-sidebar-styles";
|
||||
|
||||
/**
|
||||
* Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin
|
||||
|
|
@ -80,9 +82,26 @@ export function CompanySettingsSidebar() {
|
|||
const sidebarPlugins = (plugins ?? []).filter((plugin) => !isSandboxProviderOnly(plugin));
|
||||
|
||||
return (
|
||||
<ContextualSidebarFrame surface="settings" title="Settings" icon={SlidersHorizontal}>
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<ContextualSidebarFrame
|
||||
surface="settings"
|
||||
title="Settings"
|
||||
showHeader={false}
|
||||
className={primarySidebarStyles.surface}
|
||||
>
|
||||
<div
|
||||
data-slot="settings-sidebar-header"
|
||||
className="flex h-(--sz-60px) shrink-0 items-center px-3"
|
||||
>
|
||||
<div data-slot="settings-back-group" className={`${primarySidebarStyles.group} w-full`}>
|
||||
<SidebarNavItem to="/dashboard" label="Back to app" icon={ArrowLeft} />
|
||||
</div>
|
||||
</div>
|
||||
<nav
|
||||
aria-label="Settings"
|
||||
data-slot="contextual-sidebar-nav"
|
||||
className={primarySidebarStyles.nav}
|
||||
>
|
||||
<div data-slot="contextual-sidebar-group" className={primarySidebarStyles.group}>
|
||||
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
|
||||
{showPage("instance.profile") && (
|
||||
<SidebarNavItem
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ describe("Layout", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("scopes the Streamlined task-detail surface to the main pane and right sidebar row", async () => {
|
||||
it("scopes the Streamlined task-detail surface while preserving balanced horizontal gutters", async () => {
|
||||
currentPathname = "/PAP/issues/PAP-1";
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableApps: true,
|
||||
|
|
@ -347,8 +347,10 @@ describe("Layout", () => {
|
|||
expect(container.querySelector(".streamlined-task-detail-surface")).not.toBeNull();
|
||||
expect(container.querySelector("#main-content")?.classList.contains("pt-0")).toBe(true);
|
||||
expect(container.querySelector("#main-content")?.classList.contains("md:pt-0")).toBe(true);
|
||||
expect(container.querySelector("#main-content")?.classList.contains("pr-0")).toBe(true);
|
||||
expect(container.querySelector("#main-content")?.classList.contains("md:pr-0")).toBe(true);
|
||||
expect(container.querySelector("#main-content")?.classList.contains("p-4")).toBe(true);
|
||||
expect(container.querySelector("#main-content")?.classList.contains("md:p-6")).toBe(true);
|
||||
expect(container.querySelector("#main-content")?.classList.contains("pr-0")).toBe(false);
|
||||
expect(container.querySelector("#main-content")?.classList.contains("md:pr-0")).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -429,7 +431,7 @@ describe("Layout", () => {
|
|||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
|
||||
it("keeps the app sidebar beside settings navigation on settings routes", async () => {
|
||||
it("replaces the app sidebar with settings navigation on Streamlined settings routes", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
|
|
@ -471,10 +473,10 @@ describe("Layout", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
const secondaryRail = container.querySelector("[data-secondary-sidebar]");
|
||||
expect(secondaryRail?.classList.contains("w-60")).toBe(true);
|
||||
expect(secondaryRail?.classList.contains("bg-background")).toBe(true);
|
||||
expect(secondaryRail).not.toBeNull();
|
||||
expect(secondaryRail?.classList.contains("w-60")).toBe(false);
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
|
@ -553,7 +555,7 @@ describe("Layout", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps the company nav beside settings on instance settings routes", async () => {
|
||||
it("replaces the company nav on instance settings routes", async () => {
|
||||
currentPathname = "/PAP/company/settings/instance/general";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
|
|
@ -571,7 +573,7 @@ describe("Layout", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
||||
|
|
@ -581,7 +583,7 @@ describe("Layout", () => {
|
|||
});
|
||||
|
||||
it.each(["/PAP/company/export", "/PAP/company/import"])(
|
||||
"keeps the company nav beside the shared settings sidebar on %s",
|
||||
"replaces the company nav with the shared settings sidebar on %s",
|
||||
async (pathname) => {
|
||||
currentPathname = pathname;
|
||||
const root = createRoot(container);
|
||||
|
|
@ -600,7 +602,7 @@ describe("Layout", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -209,8 +209,7 @@ export function Layout() {
|
|||
) : sharedSecondarySidebar;
|
||||
const hasSecondarySidebar = secondarySidebar != null;
|
||||
const keepsPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && (
|
||||
shellRoute.builtInContextualSurface === "settings"
|
||||
|| shellRoute.builtInContextualSurface === "skills"
|
||||
shellRoute.builtInContextualSurface === "skills"
|
||||
|| shellRoute.builtInContextualSurface === "agent"
|
||||
|| shellRoute.builtInContextualSurface === "routine"
|
||||
|| isAppsRoute
|
||||
|
|
@ -752,7 +751,7 @@ export function Layout() {
|
|||
// The task thread owns its scrollable top spacing. Leaving the
|
||||
// page shell's top padding in place creates a stationary dark
|
||||
// strip below the breadcrumb while messages scroll behind it.
|
||||
!isMobile && useStreamlinedTaskDetailShell && "pt-0 pr-0 md:pt-0 md:pr-0",
|
||||
!isMobile && useStreamlinedTaskDetailShell && "pt-0 md:pt-0",
|
||||
// Reserve the scrollbar gutter on desktop so pages whose height
|
||||
// changes (e.g. switching skill-detail tabs) don't widen/shift
|
||||
// when the vertical scrollbar appears or disappears (PAP-10907).
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
Package,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
AppWindow,
|
||||
Unplug,
|
||||
MessagesSquare,
|
||||
GanttChartSquare,
|
||||
LayoutGrid,
|
||||
|
|
@ -230,7 +230,7 @@ export function Sidebar() {
|
|||
|
||||
<SidebarSection label="Company" collapsible={{ open: companyOpen, onOpenChange: setCompanyOpen }}>
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
{showApps ? <SidebarNavItem to="/apps" label="Apps" icon={AppWindow} /> : null}
|
||||
{showApps ? <SidebarNavItem to="/apps" label="Connectors" icon={Unplug} /> : null}
|
||||
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
{/* One entry — /audit merged into the rich Activity feed (PAP-16302). */}
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ describe("Sidebar", () => {
|
|||
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined")).toBe("undefined");
|
||||
expect(container.textContent).toContain("Organization");
|
||||
expect(labels).toEqual(expect.arrayContaining(["Org", "Apps", "Timeline", "Costs", "Activity", "Settings"]));
|
||||
expect(labels).toEqual(expect.arrayContaining(["Org", "Connectors", "Timeline", "Costs", "Activity", "Settings"]));
|
||||
expect(labels).not.toContain("Audit");
|
||||
expect(labels).not.toContain("Projects");
|
||||
expect(container.querySelector('a[href="/agents"]')).toBeNull();
|
||||
|
|
@ -406,7 +406,7 @@ describe("Sidebar", () => {
|
|||
.map((anchor) => anchor.textContent?.trim());
|
||||
|
||||
expect(labels(workSection)).toEqual(["Tasks", "Projects", "Routines", "Artifacts"]);
|
||||
expect(labels(orgSection)).toEqual(["Agents", "Skills", "Apps", "Audit"]);
|
||||
expect(labels(orgSection)).toEqual(["Agents", "Skills", "Connectors", "Audit"]);
|
||||
expect(sections.indexOf(workSection!)).toBeLessThan(sections.indexOf(orgSection!));
|
||||
expect(
|
||||
workSection?.querySelector('a[href="/issues"] svg')?.classList.contains("lucide-circle-check"),
|
||||
|
|
@ -532,17 +532,18 @@ describe("Sidebar", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("always shows Apps in the Org section", async () => {
|
||||
it("always shows Connectors in the Org section", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
const links = [...container.querySelectorAll("a")];
|
||||
const link = links.find((anchor) => anchor.textContent === "Apps");
|
||||
const link = links.find((anchor) => anchor.textContent === "Connectors");
|
||||
expect(link?.getAttribute("href")).toBe("/apps");
|
||||
expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeGreaterThan(
|
||||
expect(link?.querySelector("svg")?.classList).toContain("lucide-unplug");
|
||||
expect(links.findIndex((anchor) => anchor.textContent === "Connectors")).toBeGreaterThan(
|
||||
links.findIndex((anchor) => anchor.textContent === "Skills"),
|
||||
);
|
||||
expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeLessThan(
|
||||
expect(links.findIndex((anchor) => anchor.textContent === "Connectors")).toBeLessThan(
|
||||
links.findIndex((anchor) => anchor.textContent === "Audit"),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
Package,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
AppWindow,
|
||||
Unplug,
|
||||
MessagesSquare,
|
||||
GanttChartSquare,
|
||||
LayoutGrid,
|
||||
|
|
@ -46,6 +46,7 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
|||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
import { primarySidebarStyles } from "./primary-sidebar-styles";
|
||||
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialogActions();
|
||||
|
|
@ -117,7 +118,7 @@ export function Sidebar() {
|
|||
className={cn(
|
||||
"w-full h-full min-h-0 flex flex-col",
|
||||
streamlinedUiEnabled
|
||||
? "bg-border/50 dark:bg-muted"
|
||||
? primarySidebarStyles.surface
|
||||
: "border-r border-border bg-background",
|
||||
)}
|
||||
>
|
||||
|
|
@ -130,8 +131,8 @@ export function Sidebar() {
|
|||
<SidebarCompanyMenu />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<nav className={primarySidebarStyles.nav}>
|
||||
<div className={primarySidebarStyles.group}>
|
||||
{/* New Task button aligned with nav items */}
|
||||
{(() => {
|
||||
const newTaskButton = (
|
||||
|
|
@ -239,7 +240,7 @@ export function Sidebar() {
|
|||
>
|
||||
<SidebarNavItem to="/agents" label="Agents" icon={Users} />
|
||||
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
|
||||
<SidebarNavItem to="/apps" label="Apps" icon={AppWindow} />
|
||||
<SidebarNavItem to="/apps" label="Connectors" icon={Unplug} />
|
||||
<SidebarNavItem to="/activity" label="Audit" icon={History} />
|
||||
</SidebarSection>
|
||||
) : null}
|
||||
|
|
@ -255,7 +256,7 @@ export function Sidebar() {
|
|||
collapsible={{ open: organizationOpen, onOpenChange: setOrganizationOpen }}
|
||||
>
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
<SidebarNavItem to="/apps" label="Apps" icon={AppWindow} />
|
||||
<SidebarNavItem to="/apps" label="Connectors" icon={Unplug} />
|
||||
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { useState } from "react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
Flag,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
type LucideIcon,
|
||||
UserRound,
|
||||
UserRoundPen,
|
||||
|
|
@ -15,6 +15,7 @@ import { queryKeys } from "@/lib/queryKeys";
|
|||
import { useSignOut } from "@/hooks/useSignOut";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
|
@ -154,11 +155,15 @@ export function SidebarAccountMenu({
|
|||
|
||||
return (
|
||||
<div className="border-t border-r border-border bg-background px-3 py-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<div className={cn("flex items-center gap-0.5", !rail && "px-2")}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2.5 rounded-lg text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground",
|
||||
rail ? "w-full px-3 py-2" : "flex-1 px-2 py-1.5",
|
||||
)}
|
||||
aria-label="Open account menu"
|
||||
>
|
||||
<Avatar size="sm">
|
||||
|
|
@ -244,14 +249,6 @@ export function SidebarAccountMenu({
|
|||
external
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Feedback"
|
||||
description="Share feedback or report an issue."
|
||||
icon={Megaphone}
|
||||
href={FEEDBACK_URL}
|
||||
external
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<ThemeToggle variant="menu-action" onAfterToggle={() => setOpen(false)} />
|
||||
{deploymentMode === "authenticated" ? (
|
||||
<button
|
||||
|
|
@ -280,7 +277,24 @@ export function SidebarAccountMenu({
|
|||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Popover>
|
||||
{!rail ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={FEEDBACK_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Share feedback"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Share feedback</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { SidebarAccountMenu as ProductionSidebarAccountMenu } from "./SidebarAccountMenu.production";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
|
|
@ -107,7 +109,9 @@ describe("SidebarAccountMenu", () => {
|
|||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAccountMenu deploymentMode="local_trusted" />
|
||||
<TooltipProvider>
|
||||
<SidebarAccountMenu deploymentMode="local_trusted" />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
|
@ -119,6 +123,61 @@ describe("SidebarAccountMenu", () => {
|
|||
expect(accountSurface?.className).not.toContain("border-t");
|
||||
expect(accountSurface?.className).not.toContain("border-r");
|
||||
expect(accountSurface?.className).not.toContain("border-border");
|
||||
const accountTrigger = container.querySelector('button[aria-label="Open account menu"]');
|
||||
expect(accountTrigger?.classList).toContain("rounded-lg");
|
||||
expect(accountTrigger?.classList).toContain("hover:bg-background");
|
||||
|
||||
const feedbackButton = container.querySelector<HTMLAnchorElement>(
|
||||
'a[aria-label="Share feedback"]',
|
||||
);
|
||||
expect(feedbackButton?.getAttribute("href")).toBe("https://paperclip.ing/feedback");
|
||||
expect(feedbackButton?.getAttribute("target")).toBe("_blank");
|
||||
expect(feedbackButton?.classList).toContain("hover:bg-background");
|
||||
expect(feedbackButton?.querySelector("svg")?.classList).toContain("lucide-flag");
|
||||
expect(feedbackButton?.getAttribute("data-slot")).toBe("tooltip-trigger");
|
||||
expect(feedbackButton?.hasAttribute("title")).toBe(false);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the classic feedback control visible beside the profile trigger", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<ProductionSidebarAccountMenu deploymentMode="local_trusted" />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const accountTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Open account menu"]',
|
||||
);
|
||||
expect(accountTrigger?.classList).toContain("rounded-lg");
|
||||
expect(accountTrigger?.classList).toContain("hover:bg-accent/50");
|
||||
|
||||
const feedbackButton = container.querySelector<HTMLAnchorElement>(
|
||||
'a[aria-label="Share feedback"]',
|
||||
);
|
||||
expect(feedbackButton?.getAttribute("href")).toBe("https://paperclip.ing/feedback");
|
||||
expect(feedbackButton?.getAttribute("target")).toBe("_blank");
|
||||
expect(feedbackButton?.classList).toContain("hover:bg-accent/50");
|
||||
expect(feedbackButton?.querySelector("svg")?.classList).toContain("lucide-flag");
|
||||
expect(feedbackButton?.getAttribute("data-slot")).toBe("tooltip-trigger");
|
||||
|
||||
await act(async () => {
|
||||
accountTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
||||
expect(popover?.textContent).not.toContain("Feedback");
|
||||
expect(popover?.querySelector('a[href="https://paperclip.ing/feedback"]')).toBeNull();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
|
@ -136,10 +195,12 @@ describe("SidebarAccountMenu", () => {
|
|||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode="authenticated"
|
||||
version="1.2.3"
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode="authenticated"
|
||||
version="1.2.3"
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
|
@ -161,20 +222,16 @@ describe("SidebarAccountMenu", () => {
|
|||
expect(document.body.textContent).toContain("Settings");
|
||||
expect(document.body.textContent).not.toContain("Instance settings");
|
||||
expect(document.body.textContent).toContain("Documentation");
|
||||
expect(document.body.textContent).toContain("Feedback");
|
||||
|
||||
// Feedback link opens in a new tab pointing at the feedback URL
|
||||
const feedbackAnchor = document.body.querySelector('a[href="https://paperclip.ing/feedback"]') as HTMLAnchorElement | null;
|
||||
expect(feedbackAnchor).not.toBeNull();
|
||||
expect(feedbackAnchor?.getAttribute("target")).toBe("_blank");
|
||||
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
||||
expect(popover?.textContent).not.toContain("Feedback");
|
||||
expect(popover?.querySelector('a[href="https://paperclip.ing/feedback"]')).toBeNull();
|
||||
|
||||
// Feedback appears after Documentation and before the theme toggle
|
||||
const menuText = document.body.querySelector('[data-slot="popover-content"]')?.textContent ?? "";
|
||||
// Documentation still appears before the theme toggle.
|
||||
const menuText = popover?.textContent ?? "";
|
||||
const docsPos = menuText.indexOf("Documentation");
|
||||
const feedbackPos = menuText.indexOf("Feedback");
|
||||
const themePos = menuText.indexOf("Switch to");
|
||||
expect(docsPos).toBeLessThan(feedbackPos);
|
||||
expect(feedbackPos).toBeLessThan(themePos);
|
||||
expect(docsPos).toBeLessThan(themePos);
|
||||
|
||||
expect(document.body.textContent).toContain("Paperclip v1.2.3");
|
||||
expect(document.body.textContent).toContain("jane@example.com");
|
||||
|
|
@ -220,11 +277,13 @@ describe("SidebarAccountMenu", () => {
|
|||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode="authenticated"
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode="authenticated"
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
|
@ -257,7 +316,9 @@ describe("SidebarAccountMenu", () => {
|
|||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAccountMenu deploymentMode="local_trusted" open />
|
||||
<TooltipProvider>
|
||||
<SidebarAccountMenu deploymentMode="local_trusted" open />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
|
@ -279,26 +340,28 @@ describe("SidebarAccountMenu", () => {
|
|||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode="authenticated"
|
||||
version="2026.626.0+58.git.518fc71ce"
|
||||
serverGit={{
|
||||
available: true,
|
||||
fullSha: "518fc71ce1234567890abcdef1234567890abcde",
|
||||
shortSha: "518fc71",
|
||||
branchName: "feature/source-build-label",
|
||||
subject: "Show source build label",
|
||||
committedAt: "2026-06-26T00:00:00.000Z",
|
||||
localChanges: {
|
||||
<TooltipProvider>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode="authenticated"
|
||||
version="2026.626.0+58.git.518fc71ce"
|
||||
serverGit={{
|
||||
available: true,
|
||||
hasLocalChanges: false,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
},
|
||||
}}
|
||||
open
|
||||
/>
|
||||
fullSha: "518fc71ce1234567890abcdef1234567890abcde",
|
||||
shortSha: "518fc71",
|
||||
branchName: "feature/source-build-label",
|
||||
subject: "Show source build label",
|
||||
committedAt: "2026-06-26T00:00:00.000Z",
|
||||
localChanges: {
|
||||
available: true,
|
||||
hasLocalChanges: false,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
},
|
||||
}}
|
||||
open
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { useState } from "react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
Flag,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
UserRound,
|
||||
|
|
@ -16,6 +16,7 @@ import { queryKeys } from "@/lib/queryKeys";
|
|||
import { useSignOut } from "@/hooks/useSignOut";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
|
@ -158,140 +159,153 @@ export function SidebarAccountMenu({
|
|||
|
||||
return (
|
||||
<div className="bg-border/50 px-3 py-2 dark:bg-muted">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
aria-label="Open account menu"
|
||||
<div className={cn("flex items-center gap-0.5", !rail && "px-2")}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2.5 rounded-lg text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-background hover:text-foreground",
|
||||
rail ? "w-full px-3 py-2" : "flex-1 px-2 py-1.5",
|
||||
)}
|
||||
aria-label="Open account menu"
|
||||
>
|
||||
<Avatar size="sm">
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="w-(--sz-277px) max-w-(--sz-calc-24) overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
|
||||
>
|
||||
<Avatar size="sm">
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="w-(--sz-277px) max-w-(--sz-calc-24) overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
|
||||
>
|
||||
<div className="h-24 bg-(image:--gradient-extract-25)" />
|
||||
<div className="-mt-8 px-4 pb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-2xl border-4 border-popover bg-popover p-0.5 shadow-sm">
|
||||
<Avatar size="lg">
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
|
||||
<Badge variant="ghost" className="bg-accent text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{accountBadge}
|
||||
</Badge>
|
||||
<div className="h-24 bg-(image:--gradient-extract-25)" />
|
||||
<div className="-mt-8 px-4 pb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-2xl border-4 border-popover bg-popover p-0.5 shadow-sm">
|
||||
<Avatar size="lg">
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
|
||||
{sourceSha && sourceFullSha ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{sourceBranch ? (
|
||||
<a
|
||||
href={`${SOURCE_REPOSITORY_URL}/tree/${encodeURIComponent(sourceBranch)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block truncate transition-colors hover:text-foreground"
|
||||
>
|
||||
{sourceBranch}
|
||||
</a>
|
||||
) : null}
|
||||
<p>
|
||||
Paperclip{" "}
|
||||
<a
|
||||
href={`${SOURCE_REPOSITORY_URL}/commit/${sourceFullSha}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="transition-colors hover:text-foreground"
|
||||
>
|
||||
{sourceSha.slice(0, 7)}
|
||||
</a>
|
||||
</p>
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
|
||||
<Badge variant="ghost" className="bg-accent text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{accountBadge}
|
||||
</Badge>
|
||||
</div>
|
||||
) : version ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Paperclip v{version}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
|
||||
{sourceSha && sourceFullSha ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{sourceBranch ? (
|
||||
<a
|
||||
href={`${SOURCE_REPOSITORY_URL}/tree/${encodeURIComponent(sourceBranch)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block truncate transition-colors hover:text-foreground"
|
||||
>
|
||||
{sourceBranch}
|
||||
</a>
|
||||
) : null}
|
||||
<p>
|
||||
Paperclip{" "}
|
||||
<a
|
||||
href={`${SOURCE_REPOSITORY_URL}/commit/${sourceFullSha}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="transition-colors hover:text-foreground"
|
||||
>
|
||||
{sourceSha.slice(0, 7)}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : version ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Paperclip v{version}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1">
|
||||
<MenuAction
|
||||
label="Settings"
|
||||
description="Manage company and instance settings."
|
||||
icon={Settings}
|
||||
href="/company/settings"
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="View profile"
|
||||
description="Open your activity, task, and usage ledger."
|
||||
icon={UserRound}
|
||||
href={profileHref}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Edit profile"
|
||||
description="Update your display name and avatar."
|
||||
icon={UserRoundPen}
|
||||
href={PROFILE_SETTINGS_PATH}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Documentation"
|
||||
description="Open Paperclip docs in a new tab."
|
||||
icon={BookOpen}
|
||||
href={DOCS_URL}
|
||||
external
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<ThemeToggle variant="menu-action" onAfterToggle={() => setOpen(false)} />
|
||||
{deploymentMode === "authenticated" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-destructive/10",
|
||||
signOutMutation.isPending && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
onClick={handleSignOut}
|
||||
disabled={signOutMutation.isPending}
|
||||
>
|
||||
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
||||
<LogOut className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
End this browser session.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<SidebarServerInfo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1">
|
||||
<MenuAction
|
||||
label="Settings"
|
||||
description="Manage company and instance settings."
|
||||
icon={Settings}
|
||||
href="/company/settings"
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="View profile"
|
||||
description="Open your activity, task, and usage ledger."
|
||||
icon={UserRound}
|
||||
href={profileHref}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Edit profile"
|
||||
description="Update your display name and avatar."
|
||||
icon={UserRoundPen}
|
||||
href={PROFILE_SETTINGS_PATH}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Documentation"
|
||||
description="Open Paperclip docs in a new tab."
|
||||
icon={BookOpen}
|
||||
href={DOCS_URL}
|
||||
external
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Feedback"
|
||||
description="Share feedback or report an issue."
|
||||
icon={Megaphone}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!rail ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={FEEDBACK_URL}
|
||||
external
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<ThemeToggle variant="menu-action" onAfterToggle={() => setOpen(false)} />
|
||||
{deploymentMode === "authenticated" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-destructive/10",
|
||||
signOutMutation.isPending && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
onClick={handleSignOut}
|
||||
disabled={signOutMutation.isPending}
|
||||
>
|
||||
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
||||
<LogOut className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
End this browser session.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<SidebarServerInfo />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Share feedback"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-foreground/80 transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Share feedback</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { hidesCompanyPage, type Company } from "@paperclipai/shared";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { cloudApi, type CloudStackSummary } from "@/api/cloud";
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useDialogActions } from "@/context/DialogContext";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
|
||||
import { useSignOut } from "@/hooks/useSignOut";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
|
|
@ -234,6 +235,18 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
// exactly one company, and switching means leaving this tenant host entirely.
|
||||
const cloud = useCloudInstance();
|
||||
const isCloud = Boolean(cloud);
|
||||
// The invite shortcut points at the company Invites surface, so an operator
|
||||
// that hides that surface via PAPERCLIP_HIDDEN_SETTINGS (company.invites or
|
||||
// company.members) hides this shortcut too. This is the per-deployment knob
|
||||
// Paperclip Cloud uses to drop the shortcut on its managed stacks while
|
||||
// other hosters keep it; the streamlined menu already honors it, this shell
|
||||
// was the gap. Until the health response resolves the hidden set is unknown
|
||||
// — keep the shortcut out rather than flash it.
|
||||
const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings();
|
||||
const showInvitePeople =
|
||||
hiddenSettingsLoaded &&
|
||||
!hidesCompanyPage(hiddenSettings, "company.members") &&
|
||||
!hidesCompanyPage(hiddenSettings, "company.invites");
|
||||
const cloudBaseUrl = cloud?.cloudBaseUrl ?? null;
|
||||
const stacksQuery = useQuery({
|
||||
queryKey: queryKeys.cloud.stacks,
|
||||
|
|
@ -477,23 +490,25 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings/invites"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span className="truncate">
|
||||
{currentName ? `Invite people to ${currentName}` : "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{showInvitePeople ? (
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings/invites"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span className="truncate">
|
||||
{currentName ? `Invite people to ${currentName}` : "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{session?.session ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
import { SidebarCompanyMenu as SidebarCompanyMenuProduction } from "./SidebarCompanyMenu.production";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
|
|
@ -370,6 +371,56 @@ describe("SidebarCompanyMenu", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows the production-shell invite shortcut when no surface is hidden", async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(queryKeys.health, { status: "ok" });
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCompanyMenuProduction />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await openMenu("Open Acme Labs company switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the production-shell invite shortcut when the operator hides the invites surface", async () => {
|
||||
// The production shell (streamlined UI disabled) must honor
|
||||
// PAPERCLIP_HIDDEN_SETTINGS like the streamlined menu — this is the knob
|
||||
// Paperclip Cloud uses to drop the shortcut on its managed stacks.
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(queryKeys.health, { status: "ok", hiddenSettings: ["company.invites"] });
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCompanyMenuProduction />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await openMenu("Open Acme Labs company switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Switch");
|
||||
expect(document.body.textContent).not.toContain("Invite people");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the invite shortcut out of the menu until hidden settings resolve", async () => {
|
||||
// No health data in the cache: the hidden-settings set is unknown, so the
|
||||
// shortcut must not flash in and then disappear once the response lands.
|
||||
|
|
|
|||
|
|
@ -207,7 +207,9 @@ describe("InvitesSection", () => {
|
|||
"https://paperclip.local/invite/new-token",
|
||||
);
|
||||
expect(container.textContent).toContain("Copy link");
|
||||
expect(container.textContent).toContain("Open invite");
|
||||
// The inviter opening their own single-use link is never what they
|
||||
// mean, so the section deliberately offers no "Open invite" action.
|
||||
expect(container.textContent).not.toContain("Open invite");
|
||||
expect(pushToastMock).toHaveBeenCalledWith({
|
||||
title: "Invite created",
|
||||
body: "Invite ready below and copied to clipboard.",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink } from "lucide-react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -271,12 +271,6 @@ export function InvitesSection() {
|
|||
<Copy className="h-4 w-4" />
|
||||
Copy link
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={latestInviteUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open invite
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Shared visual contract for the Streamlined UI primary navigation surface.
|
||||
* Settings reuses this contract when it takes over the global sidebar.
|
||||
*/
|
||||
export const primarySidebarStyles = {
|
||||
surface: "bg-border/50 dark:bg-muted",
|
||||
nav: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto scrollbar-auto-hide px-3 py-2 pointer-coarse:gap-3",
|
||||
group: "flex flex-col gap-0.5",
|
||||
} as const;
|
||||
|
|
@ -133,11 +133,17 @@ describe("TaskMessageScroller", () => {
|
|||
expect(el.scrollTop).toBe(el.scrollHeight);
|
||||
});
|
||||
|
||||
it("keeps the scrollbar at the full-width thread viewport edge", () => {
|
||||
it("extends only the streamlined scroll box through the page gutter", () => {
|
||||
render();
|
||||
const frame = scroller().parentElement;
|
||||
const el = scroller();
|
||||
const frame = el.parentElement;
|
||||
|
||||
expect(frame?.className).toBe("relative min-h-0 flex-1");
|
||||
expect(el.classList).toContain("-right-4");
|
||||
expect(el.classList).toContain("pr-4");
|
||||
expect(el.classList).toContain("md:-right-6");
|
||||
expect(el.classList).toContain("md:pr-6");
|
||||
expect(el.classList).not.toContain("right-0");
|
||||
});
|
||||
|
||||
it("shows the scrollbar only while scroll activity is recent", () => {
|
||||
|
|
|
|||
|
|
@ -215,12 +215,17 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
|
|||
<div
|
||||
ref={ref}
|
||||
onScroll={handleScroll}
|
||||
// absolute inset-0 (not h-full): the viewport must equal the flex-sized
|
||||
// wrapper exactly — percentage heights don't reliably resolve against
|
||||
// flex-determined block heights, which let the thread overflow the page.
|
||||
// Keep the viewport tied to the flex-sized wrapper vertically —
|
||||
// percentage heights don't reliably resolve against flex-determined
|
||||
// block heights, which let the thread overflow the page. In the
|
||||
// streamlined shell, extend only the scroll box through the page's
|
||||
// right gutter; matching padding preserves the message column while
|
||||
// placing the scrollbar against the properties-panel boundary.
|
||||
className={cn(
|
||||
"scrollbar-while-scrolling absolute inset-0 overflow-y-auto",
|
||||
streamlined && "overflow-x-hidden",
|
||||
"scrollbar-while-scrolling absolute inset-y-0 left-0 overflow-y-auto",
|
||||
streamlined
|
||||
? "-right-4 overflow-x-hidden pr-4 md:-right-6 md:pr-6"
|
||||
: "right-0",
|
||||
className,
|
||||
)}
|
||||
data-testid="task-chat-scroller"
|
||||
|
|
|
|||
|
|
@ -174,14 +174,16 @@ describe("Connectors landing page", () => {
|
|||
it("renders one connector list with the requested header and no gallery sections", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
expect(setBreadcrumbsMock).toHaveBeenCalledWith([]);
|
||||
expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Connectors" }]);
|
||||
expect(setBreadcrumbsMock).not.toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ href: "/dashboard" }),
|
||||
]));
|
||||
expect(container.querySelector("header")?.textContent).toBe("Connectors");
|
||||
expect(container.querySelector("header")?.textContent).not.toContain("Connectors");
|
||||
expect(
|
||||
container.querySelector('header input[aria-label="Search connectors"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.querySelector("header")?.classList).toContain("justify-start");
|
||||
expect(container.querySelector("header")?.classList).not.toContain("justify-end");
|
||||
expect(container.querySelector('[aria-label="Popular apps"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Connected apps"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="All apps"]')).toBeNull();
|
||||
|
|
|
|||
|
|
@ -209,10 +209,7 @@ export function Browse() {
|
|||
const [connectionToRemove, setConnectionToRemove] = useState<ConnectionRemovalTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Apps is already its own navigation root and the page has its own title.
|
||||
// Leave the global bar empty instead of duplicating "Connectors" or
|
||||
// offering a path back out of Apps.
|
||||
setBreadcrumbs([]);
|
||||
setBreadcrumbs([{ label: "Connectors" }]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
|
|
@ -403,8 +400,7 @@ export function Browse() {
|
|||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-5 pb-12">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="shrink-0 text-xl font-bold text-foreground">Connectors</h1>
|
||||
<header className="flex justify-start">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
|
|
|
|||
Loading…
Reference in New Issue