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:
Dotta 2026-09-03 21:19:07 -05:00
commit 95aa8c514e
51 changed files with 1307 additions and 1007 deletions

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"
}
}

View File

@ -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"

View File

@ -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",

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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",

View File

@ -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"

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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();

View File

@ -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

View File

@ -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,

View File

@ -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 }) => {

View File

@ -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);

View File

@ -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 () => {

View File

@ -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(

View File

@ -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"

View File

@ -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 />

View File

@ -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();
});
});
});

View File

@ -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"

View File

@ -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",

View File

@ -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

View File

@ -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();

View File

@ -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).

View File

@ -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). */}

View File

@ -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"),
);

View File

@ -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} />

View File

@ -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>
);
}

View File

@ -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>,
);
});

View File

@ -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>
);
}

View File

@ -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 />

View File

@ -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.

View File

@ -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.",

View File

@ -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}

View File

@ -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;

View File

@ -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", () => {

View File

@ -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"

View File

@ -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();

View File

@ -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