merge main

This commit is contained in:
Frooodle
2026-06-24 11:44:11 +01:00
61 changed files with 871 additions and 692 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.13.1
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.13.1
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
@@ -222,7 +222,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.6.0
cache-disabled: true
- name: Set up Python
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Set up Docker Buildx
@@ -349,7 +349,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+3 -3
View File
@@ -73,7 +73,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -148,7 +148,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -252,7 +252,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+2 -2
View File
@@ -78,7 +78,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Set up Docker Buildx
id: buildx
@@ -155,9 +155,9 @@ jobs:
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# No BASE_VERSION pin: inherit the Dockerfile ARG default (single source of truth).
build-args: |
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
BASE_VERSION=1.0.0
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+2 -2
View File
@@ -122,7 +122,7 @@ jobs:
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: frontend/editor/src-tauri
# Stable key shared across workflows so the nightly warmer.
# Stable key shared across workflows so the nightly warmer.
# rust-cache still appends OS + rustc + Cargo.lock.
shared-key: tauri-${{ matrix.name }}
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -150,7 +150,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -106,7 +106,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Build with Gradle
run: ./gradlew build
+6 -6
View File
@@ -29,13 +29,13 @@ spotless {
}
}
dependencies {
api 'com.google.guava:guava:33.6.0-jre'
api "com.google.guava:guava:${guavaVersion}"
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.20.0'
api "org.apache.commons:commons-lang3:${commonsLang3}"
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
@@ -60,7 +60,7 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson'
}
api 'com.stirling:jpdfium:1.0.2'
api "com.stirling:jpdfium:${jpdfiumVersion}"
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
@@ -75,12 +75,12 @@ dependencies {
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
}
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
}
+6 -7
View File
@@ -67,7 +67,7 @@ dependencies {
exclude group: 'com.fasterxml.jackson.jaxrs'
exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
}
implementation 'commons-io:commons-io:2.22.0'
implementation "commons-io:commons-io:$commonsIoVersion"
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation 'io.micrometer:micrometer-core'
@@ -81,25 +81,24 @@ dependencies {
implementation 'org.verapdf:validation-model:1.28.2'
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
implementation 'org.mozilla:rhino:1.9.1'
implementation "org.mozilla:rhino:${rhinoVersion}"
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation 'com.google.code.gson:gson:2.13.2'
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
implementation "org.apache.xmlgraphics:batik-bridge:${batikVersion}"
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
runtimeOnly "org.apache.xmlgraphics:batik-transcoder:${batikVersion}"
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
+13 -17
View File
@@ -4,9 +4,6 @@ repositories {
}
ext {
jwtVersion = '0.13.0'
awsSdkVersion = '2.44.12'
testcontainersMinioVersion = '1.21.4'
// CPU build; self-extracting natives for linux/win/mac x64+arm64. Keep major.minor aligned
// with the frontend onnxruntime-web pin so the same .onnx runs identically on both paths.
// Must be >=1.21 to load opset-22 models: the FFDNet exports are stamped ai.onnx opset 22,
@@ -14,7 +11,6 @@ ext {
// onnxruntime build the upstream CommonForms inference reference runs on.
onnxruntimeVersion = '1.26.0'
}
bootRun {
enabled = false
}
@@ -47,7 +43,7 @@ spotless {
}
dependencies {
implementation project(':common')
api 'com.google.guava:guava:33.6.0-jre'
api "com.google.guava:guava:${guavaVersion}"
api 'org.springframework:spring-jdbc'
api 'org.springframework:spring-webmvc'
@@ -67,16 +63,16 @@ dependencies {
api 'com.github.ben-manes.caffeine:caffeine'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
// Lettuce-backed Bucket4j ProxyManager used by ValkeyRateLimitStore for cluster-wide
// token-bucket rate limiting (parity with in-process Bucket4j semantics; no fixed-window
// boundary doubling).
implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.19.0'
implementation "com.bucket4j:bucket4j_jdk17-lettuce:${bucket4jVersion}"
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation 'com.google.code.gson:gson:2.13.2'
implementation "com.google.code.gson:gson:${gsonVersion}"
// ONNX Runtime (Java) for SERVER-SIDE Auto Form Detection inference.
// Compiled against everywhere so the module always builds, but the multi-platform native
@@ -92,24 +88,24 @@ dependencies {
api 'io.micrometer:micrometer-registry-prometheus'
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jwtVersion}"
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database - file format incompatible with 2.4.x, would break existing user databases
runtimeOnly 'org.postgresql:postgresql:42.7.11'
implementation('com.coveo:saml-client:5.0.0') {
exclude group: 'org.opensaml', module: 'opensaml-core'
}
implementation "software.amazon.awssdk:s3:$awsSdkVersion"
implementation "software.amazon.awssdk:url-connection-client:$awsSdkVersion"
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}"
// Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without
// manually-started instances. Tests skip cleanly when Docker is unavailable.
testImplementation "org.testcontainers:testcontainers:$testcontainersMinioVersion"
testImplementation "org.testcontainers:minio:$testcontainersMinioVersion"
testImplementation "org.testcontainers:localstack:$testcontainersMinioVersion"
testImplementation "org.testcontainers:junit-jupiter:$testcontainersMinioVersion"
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:minio:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:localstack:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:junit-jupiter:${testcontainersMinioVersion}"
}
tasks.register('prepareKotlinBuildScriptModel') {}
+1 -1
View File
@@ -41,5 +41,5 @@ dependencies {
api 'org.flywaydb:flyway-core'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
}
+22 -10
View File
@@ -30,6 +30,19 @@ ext {
commonmarkVersion = "0.28.0"
googleJavaFormatVersion = "1.28.0"
logback = "1.5.32"
commonsIoVersion = "2.22.0"
commonsLang3 = "3.20.0"
rhinoVersion = "1.9.1"
okhttpBomVersion = "5.3.2"
gsonVersion = "2.14.0"
guavaVersion = "33.6.0-jre"
bucket4jVersion = "8.19.0"
archunitVersion = "1.4.2"
batikVersion = "1.19"
jpdfiumVersion = "1.0.2"
jwtVersion = "0.13.0"
awsSdkVersion = "2.44.12"
testcontainersMinioVersion = "1.21.4"
// junit-platform-launcher version managed by Spring Boot BOM
modernJavaVersion = 25
}
@@ -78,7 +91,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.13.1'
version = '2.13.2'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
@@ -203,12 +216,12 @@ subprojects {
// - CVE-2022-25647: gson 2.8.9+ (explicit dependency overrides tabula 2.8.7)
// - CVE-2025-66453: rhino 1.7.15 (explicit dependency overrides verapdf 1.7.13)
// Fallback strategy force declarations for additional safety:
resolutionStrategy.force 'com.google.code.gson:gson:2.13.2'
resolutionStrategy.force 'org.mozilla:rhino:1.9.1'
resolutionStrategy.force "com.google.code.gson:gson:${gsonVersion}"
resolutionStrategy.force "org.mozilla:rhino:${rhinoVersion}"
// CVE-2025-48924: commons-lang3 3.20.0 DoS prevention
resolutionStrategy.force 'org.apache.commons:commons-lang3:3.20.0'
// CVE-2024-47554: commons-io 2.21.0 DoS prevention
resolutionStrategy.force 'commons-io:commons-io:2.21.0'
resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}"
// CVE-2024-47554: commons-io DoS prevention
resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}"
// Keep BouncyCastle modules aligned to avoid runtime linkage errors
resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}"
resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}"
@@ -221,7 +234,7 @@ subprojects {
}
dependencies {
// Override BOM-managed commons-lang3 for CVE-2025-48924 fix
dependency 'org.apache.commons:commons-lang3:3.20.0'
dependency "org.apache.commons:commons-lang3:$commonsLang3"
}
}
@@ -243,7 +256,7 @@ subprojects {
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
testImplementation platform("com.squareup.okhttp3:okhttp-bom:${okhttpBomVersion}")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
@@ -599,7 +612,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
testImplementation platform("com.squareup.okhttp3:okhttp-bom:${okhttpBomVersion}")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
@@ -670,7 +683,6 @@ tasks.register('buildRestartHelper', Jar) {
}
}
tasks.withType(Test).configureEach {
// maxParallelForks: parallelise JUnit across cores
// Half of available CPUs is a safe default; bump if your tests are I/O-bound.
+1 -1
View File
@@ -4,7 +4,7 @@ ARG BASE_VERSION=1.0.2
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application (backend only, no frontend)
FROM gradle:9.3.1-jdk25@sha256:85aec999629f4774a383cb792da4b598bdf5a7e69c4b9570bb70c0f919179183 AS app-build
FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
+1 -1
View File
@@ -5,7 +5,7 @@ ARG BASE_VERSION=1.0.2
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.5.1-jdk25@sha256:8de3543f1772bb66be3b275893e5977b6d8bd2b0d25551faa5846a821d1f0600 AS app-build
FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build
ARG TASK_VERSION=3.49.1
RUN apt-get update \
+1 -1
View File
@@ -6,7 +6,7 @@ ARG BASE_VERSION=1.0.2
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.5.1-jdk25@sha256:8de3543f1772bb66be3b275893e5977b6d8bd2b0d25551faa5846a821d1f0600 AS app-build
FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build
ARG TASK_VERSION=3.49.1
RUN apt-get update \
+1 -1
View File
@@ -2,7 +2,7 @@
# Single JAR contains both frontend and backend with minimal dependencies
# Stage 1: Build application with embedded frontend
FROM gradle:9.5.1-jdk25@sha256:8de3543f1772bb66be3b275893e5977b6d8bd2b0d25551faa5846a821d1f0600 AS build
FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS build
# Install Node.js and npm for frontend build
ARG TASK_VERSION=3.49.1
@@ -5902,6 +5902,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Close"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organization. Contact a team leader to change this policy."
@@ -5955,6 +5956,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Close"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.13.1",
"version": "2.13.2",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -14,6 +14,11 @@ export function usePoliciesEnabled(): boolean {
return false;
}
/** Whether the Policies list should appear for the current user. False in core. */
export function usePoliciesVisible(): boolean {
return false;
}
/**
* Whether a policy is open (its detail should take over the rail). Always false
* in core; proprietary bridges to the policy-selection store.
@@ -11,6 +11,7 @@ import {
PoliciesSection,
PolicyDetailTakeover,
usePoliciesEnabled,
usePoliciesVisible,
usePolicyDetailActive,
} from "@app/components/policies/PoliciesSidebar";
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
@@ -18,6 +19,7 @@ import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import { useToolSections } from "@app/hooks/useToolSections";
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { ToolIcon } from "@app/components/shared/ToolIcon";
import { PanelHeader } from "@shared/components/PanelHeader";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { withViewTransition } from "@app/utils/viewTransition";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
@@ -64,6 +66,7 @@ export default function RightSidebar() {
} = useToolWorkflow();
const policiesEnabled = usePoliciesEnabled();
const policiesVisible = usePoliciesVisible();
const rawPolicyDetailActive = usePolicyDetailActive();
const fullscreenExpanded = useIsFullscreenExpanded();
const fullscreenGeometry = useToolPanelGeometry({
@@ -115,9 +118,14 @@ export default function RightSidebar() {
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
// Policies sit above the tool list in the default tool-picker view.
// Policies sit above the tool list in the default tool-picker view — but only
// when the current user actually has policies to see (see usePoliciesVisible),
// so regular users with none get the plain tool picker with no empty block.
const showPolicies =
policiesEnabled && !allToolsView && leftPanelView === "toolPicker";
policiesEnabled &&
policiesVisible &&
!allToolsView &&
leftPanelView === "toolPicker";
// When Policies are shown, the search moves OUT of the header to sit between
// the Policies and Tools sections (separating them); otherwise it stays in the
// header. Show the header search when there's a close button, or in the
@@ -281,65 +289,67 @@ export default function RightSidebar() {
</div>
) : (
<>
{!showPolicies && (
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
{!showPolicies &&
(activeTool ? (
<PanelHeader
icon={
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="currentColor"
/>
</div>
) : null}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
)}
}
title={activeTool.name}
onClose={handleHeaderBack}
closeLabel={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
/>
) : (
<div className="tool-panel__compact-header">
{showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
/>
</div>
) : null}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
))}
{showPolicies && (
<PoliciesSection
@@ -294,67 +294,6 @@
background-color: var(--bg-muted);
}
.tool-panel__active-tool-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 auto;
min-width: 0;
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
border: 1px solid var(--border-subtle);
border-radius: 9999px;
background: var(--mantine-color-body);
}
.tool-panel__active-tool-pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-pill-icon {
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* The inner .tool-button-icon wrapper carries its own transform/margin; reset
them so the icon glyph sits dead-centre in the circular background. */
.tool-panel__active-tool-pill-icon .tool-button-icon {
margin: 0 !important;
transform: none !important;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.tool-panel__active-tool-pill-icon svg {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
.tool-panel__active-tool-pill-label {
font-size: 0.9rem;
font-weight: 600;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
::view-transition-old(tool-rail) {
animation: vt-rail-fade-out 180ms ease-out forwards;
}
@@ -17,16 +17,16 @@ import CloseIcon from "@mui/icons-material/Close";
import WarningIcon from "@mui/icons-material/Warning";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
import { ToolId } from "@app/types/toolId";
import { getAvailableToExtensions } from "@app/utils/convertUtils";
import { ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
interface ToolConfigurationModalProps {
opened: boolean;
tool: {
id: string;
operation: string;
name: string;
parameters?: any;
parameters?: ErasedToolParams;
};
onSave: (parameters: any) => void;
onSave: (parameters: ErasedToolParams) => void;
onCancel: () => void;
toolRegistry: Partial<ToolRegistry>;
}
@@ -40,7 +40,7 @@ export default function ToolConfigurationModal({
}: ToolConfigurationModalProps) {
const { t } = useTranslation();
const [parameters, setParameters] = useState<any>({});
const [parameters, setParameters] = useState<ErasedToolParams>({});
// Get tool info from registry
const toolInfo = toolRegistry[tool.operation as ToolId];
@@ -74,26 +74,11 @@ export default function ToolConfigurationModal({
);
}
// Special handling for ConvertSettings which needs additional props
if (tool.operation === "convert") {
return (
<SettingsComponent
parameters={parameters}
onParameterChange={(key: string, value: any) => {
setParameters((prev: any) => ({ ...prev, [key]: value }));
}}
getAvailableToExtensions={getAvailableToExtensions}
selectedFiles={[]}
disabled={false}
/>
);
}
return (
<SettingsComponent
parameters={parameters}
onParameterChange={(key: string, value: any) => {
setParameters((prev: any) => ({ ...prev, [key]: value }));
onParameterChange={(key, value) => {
setParameters((prev) => ({ ...prev, [key]: value }));
}}
disabled={false}
/>
@@ -18,7 +18,7 @@ interface ToolListProps {
onToolConfigure: (index: number) => void;
onToolAdd: () => void;
getToolName: (operation: string) => string;
getToolDefaultParameters: (operation: string) => Record<string, any>;
getToolDefaultParameters: (operation: string) => Record<string, unknown>;
}
export default function ToolList({
@@ -10,7 +10,11 @@ import {
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { useTranslation } from "react-i18next";
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
import { isImageFormat, isWebFormat } from "@app/utils/convertUtils";
import {
isImageFormat,
isWebFormat,
getAvailableToExtensions as defaultGetAvailableToExtensions,
} from "@app/utils/convertUtils";
import { getConversionEndpoints } from "@app/data/toolsTaxonomy";
import { useFileSelection } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/FileContext";
@@ -47,18 +51,18 @@ interface ConvertSettingsProps {
key: K,
value: ConvertParameters[K],
) => void;
getAvailableToExtensions: (
getAvailableToExtensions?: (
fromExtension: string,
) => Array<{ value: string; label: string; group: string }>;
selectedFiles: StirlingFile[];
selectedFiles?: StirlingFile[];
disabled?: boolean;
}
const ConvertSettings = ({
parameters,
onParameterChange,
getAvailableToExtensions,
selectedFiles,
getAvailableToExtensions = defaultGetAvailableToExtensions,
selectedFiles = [],
disabled = false,
}: ConvertSettingsProps) => {
const { t } = useTranslation();
@@ -10,14 +10,20 @@ export interface FilesToolStepProps {
minFiles?: number;
}
export function createFilesToolStep(
interface StepBaseProps {
isVisible?: boolean;
isCollapsed?: boolean;
onCollapsedClick?: () => void;
}
export function createFilesToolStep<T>(
createStep: (
title: string,
props: any,
props: StepBaseProps,
children?: React.ReactNode,
) => React.ReactElement,
) => T,
props: FilesToolStepProps,
): React.ReactElement {
): T {
return createStep(
i18n.t("files.title", "Files"),
{
+12 -5
View File
@@ -1,6 +1,10 @@
import { type TFunction } from "i18next";
import React from "react";
import { ToolOperationConfig } from "@app/hooks/tools/shared/toolOperationTypes";
import {
type ErasedToolParams,
type ToolAutomationSettingsProps,
ToolOperationConfig,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { BaseToolProps } from "@app/types/tool";
import { WorkbenchType } from "@app/types/workbench";
import {
@@ -58,10 +62,13 @@ export type ToolRegistryEntry = {
kind?: ToolKind;
// Workbench type for navigation
workbench?: WorkbenchType;
// Operation configuration for automation
operationConfig?: ToolOperationConfig<any>;
// Settings component for automation configuration
automationSettings: React.ComponentType<any> | null;
// Operation configuration for automation. TParams is erased at the registry
// boundary; tools are authored type-safely via defineToolAutomation.
operationConfig?: ToolOperationConfig<ErasedToolParams>;
// Settings component for automation configuration.
automationSettings: React.ComponentType<
ToolAutomationSettingsProps<ErasedToolParams>
> | null;
// Whether this tool supports automation (defaults to true)
supportsAutomate?: boolean;
// Synonyms for search (optional)
@@ -12,6 +12,10 @@ import {
LinkToolRegistry,
} from "@app/data/toolsTaxonomy";
import { isSuperToolId, isLinkToolId } from "@app/types/toolId";
import {
asRegistryConfig,
lazySettings,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { adjustContrastOperationConfig } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation";
import { getSynonyms } from "@app/utils/toolSynonyms";
import { useProprietaryToolRegistry } from "@app/data/useProprietaryToolRegistry";
@@ -144,8 +148,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["merge-pdfs"],
operationConfig: mergeOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(mergeOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/merge/MergeSettings"),
),
synonyms: getSynonyms(t, "merge"),
@@ -170,8 +174,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "certSign"),
maxFiles: -1,
endpoints: ["cert-sign"],
operationConfig: certSignOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(certSignOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/certSign/CertSignAutomationSettings"),
),
@@ -194,7 +198,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.SIGNING,
maxFiles: -1,
endpoints: ["timestamp-pdf"],
operationConfig: timestampPdfOperationConfig,
operationConfig: asRegistryConfig(timestampPdfOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "timestampPdf"),
},
@@ -211,8 +215,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.SIGNING,
endpoints: ["sign"],
operationConfig: signOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(signOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/sign/SignSettings"),
), // TODO:: not all settings shown, suggested next tools shown
synonyms: getSynonyms(t, "sign"),
@@ -235,7 +239,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
endpoints: ["sign"],
operationConfig: signOperationConfig,
operationConfig: asRegistryConfig(signOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "addText"),
supportsAutomate: false,
@@ -254,7 +258,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
endpoints: ["add-image"],
operationConfig: signOperationConfig,
operationConfig: asRegistryConfig(signOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "addImage"),
supportsAutomate: false,
@@ -277,7 +281,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.GENERAL,
workbench: "viewer",
endpoints: ["view-pdf"],
operationConfig: signOperationConfig,
operationConfig: asRegistryConfig(signOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "annotate"),
supportsAutomate: false,
@@ -299,8 +303,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["add-password"],
operationConfig: addPasswordOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(addPasswordOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/addPassword/AddPasswordSettings"),
),
synonyms: getSynonyms(t, "addPassword"),
@@ -323,8 +327,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
endpoints: ["add-watermark"],
operationConfig: addWatermarkOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(addWatermarkOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/addWatermark/AddWatermarkSingleStepSettings"),
),
@@ -349,8 +353,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "addStamp"),
maxFiles: -1,
endpoints: ["add-stamp"],
operationConfig: addStampOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(addStampOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/addStamp/AddStampAutomationSettings"),
),
@@ -373,8 +377,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
"Remove potentially harmful elements from PDF files",
),
endpoints: ["sanitize-pdf"],
operationConfig: sanitizeOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(sanitizeOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/sanitize/SanitizeSettings"),
),
synonyms: getSynonyms(t, "sanitize"),
@@ -397,8 +401,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["flatten"],
operationConfig: flattenOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(flattenOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/flatten/FlattenSettings"),
),
synonyms: getSynonyms(t, "flatten"),
@@ -421,7 +425,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["unlock-pdf-forms"],
operationConfig: unlockPdfFormsOperationConfig,
operationConfig: asRegistryConfig(unlockPdfFormsOperationConfig),
synonyms: getSynonyms(t, "unlockPDFForms"),
automationSettings: null,
},
@@ -492,8 +496,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["add-password"],
operationConfig: changePermissionsOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(changePermissionsOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/changePermissions/ChangePermissionsSettings"),
),
@@ -585,8 +589,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
maxFiles: -1,
endpoints: ["update-metadata"],
operationConfig: changeMetadataOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(changeMetadataOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/changeMetadata/ChangeMetadataSingleStep"),
),
@@ -604,7 +608,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
maxFiles: 1,
endpoints: ["edit-table-of-contents"],
operationConfig: editTableOfContentsOperationConfig,
operationConfig: asRegistryConfig(editTableOfContentsOperationConfig),
automationSettings: null,
supportsAutomate: false,
synonyms: getSynonyms(t, "editTableOfContents"),
@@ -623,8 +627,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["crop"],
operationConfig: cropOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(cropOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/crop/CropAutomationSettings"),
),
},
@@ -643,8 +647,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["rotate-pdf"],
operationConfig: rotateOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(rotateOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/rotate/RotateAutomationSettings"),
),
synonyms: getSynonyms(t, "rotate"),
@@ -663,8 +667,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))),
operationConfig: splitOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(splitOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/split/SplitAutomationSettings"),
),
synonyms: getSynonyms(t, "split"),
@@ -682,7 +686,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: ["rearrange-pages"],
operationConfig: reorganizePagesOperationConfig,
operationConfig: asRegistryConfig(reorganizePagesOperationConfig),
synonyms: getSynonyms(t, "reorganizePages"),
automationSettings: null,
},
@@ -700,8 +704,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["scale-pages"],
operationConfig: adjustPageScaleOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(adjustPageScaleOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/adjustPageScale/AdjustPageScaleSettings"),
),
@@ -717,13 +721,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
automationSettings: lazy(
automationSettings: lazySettings(
() =>
import("@app/components/tools/addPageNumbers/AddPageNumbersAutomationSettings"),
),
maxFiles: -1,
endpoints: ["add-page-numbers"],
operationConfig: addPageNumbersOperationConfig,
operationConfig: asRegistryConfig(addPageNumbersOperationConfig),
synonyms: getSynonyms(t, "addPageNumbers"),
},
pageLayout: {
@@ -744,7 +748,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["multi-page-layout"],
automationSettings: lazy(
automationSettings: lazySettings(
() => import("@app/components/tools/pageLayout/PageLayoutSettings"),
),
synonyms: getSynonyms(t, "pageLayout"),
@@ -759,8 +763,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
),
name: t("home.bookletImposition.title", "Booklet Imposition"),
component: lazy(() => import("@app/tools/BookletImposition")),
operationConfig: bookletImpositionOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(bookletImpositionOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/bookletImposition/BookletImpositionSettings"),
),
@@ -791,7 +795,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["pdf-to-single-page"],
operationConfig: singleLargePageOperationConfig,
operationConfig: asRegistryConfig(singleLargePageOperationConfig),
synonyms: getSynonyms(t, "pdfToSinglePage"),
automationSettings: null,
},
@@ -810,8 +814,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "addAttachments"),
maxFiles: 1,
endpoints: ["add-attachments"],
operationConfig: addAttachmentsOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(addAttachmentsOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/addAttachments/AddAttachmentsSettings"),
),
@@ -832,11 +836,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EXTRACTION,
synonyms: getSynonyms(t, "extractPages"),
automationSettings: lazy(
automationSettings: lazySettings(
() =>
import("@app/components/tools/extractPages/ExtractPagesSettings"),
),
operationConfig: extractPagesOperationConfig,
operationConfig: asRegistryConfig(extractPagesOperationConfig),
endpoints: ["rearrange-pages"],
},
extractImages: {
@@ -857,8 +861,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.EXTRACTION,
maxFiles: -1,
endpoints: ["extract-images"],
operationConfig: extractImagesOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(extractImagesOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/extractImages/ExtractImagesSettings"),
),
@@ -886,8 +890,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
maxFiles: 1,
endpoints: ["remove-pages"],
synonyms: getSynonyms(t, "removePages"),
operationConfig: removePagesOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(removePagesOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/removePages/RemovePagesSettings"),
),
},
@@ -910,8 +914,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
maxFiles: 1,
endpoints: ["remove-blanks"],
synonyms: getSynonyms(t, "removeBlanks"),
operationConfig: removeBlanksOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(removeBlanksOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/removeBlanks/RemoveBlanksSettings"),
),
@@ -934,7 +938,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-annotations"],
operationConfig: removeAnnotationsOperationConfig,
operationConfig: asRegistryConfig(removeAnnotationsOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "removeAnnotations"),
},
@@ -978,8 +982,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.REMOVAL,
endpoints: ["remove-password"],
maxFiles: -1,
operationConfig: removePasswordOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(removePasswordOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/removePassword/RemovePasswordSettings"),
),
@@ -1003,7 +1007,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-cert-sign"],
operationConfig: removeCertificateSignOperationConfig,
operationConfig: asRegistryConfig(removeCertificateSignOperationConfig),
synonyms: getSynonyms(t, "removeCertSign"),
automationSettings: null,
},
@@ -1036,7 +1040,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
component: lazy(() => import("@app/tools/AutoRename")),
maxFiles: -1,
endpoints: ["auto-rename"],
operationConfig: autoRenameOperationConfig,
operationConfig: asRegistryConfig(autoRenameOperationConfig),
description: t(
"home.autoRename.desc",
"Automatically rename PDF files based on their content",
@@ -1062,8 +1066,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
endpoints: ["adjust-contrast"],
operationConfig: adjustContrastOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(adjustContrastOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/adjustContrast/AdjustContrastSingleStepSettings"),
),
@@ -1087,7 +1091,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
endpoints: ["repair"],
operationConfig: repairOperationConfig,
operationConfig: asRegistryConfig(repairOperationConfig),
synonyms: getSynonyms(t, "repair"),
automationSettings: null,
},
@@ -1112,8 +1116,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
endpoints: ["extract-image-scans"],
operationConfig: scannerImageSplitOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(scannerImageSplitOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/scannerImageSplit/ScannerImageSplitSettings"),
),
@@ -1136,9 +1140,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["overlay-pdf"],
operationConfig: overlayPdfsOperationConfig,
operationConfig: asRegistryConfig(overlayPdfsOperationConfig),
synonyms: getSynonyms(t, "overlay-pdfs"),
automationSettings: lazy(
automationSettings: lazySettings(
() => import("@app/components/tools/overlayPdfs/OverlayPdfsSettings"),
),
},
@@ -1160,8 +1164,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
endpoints: ["replace-invert-pdf"],
operationConfig: replaceColorOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(replaceColorOperationConfig),
automationSettings: lazySettings(
() =>
import("@app/components/tools/replaceColor/ReplaceColorSettings"),
),
@@ -1333,8 +1337,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["compress-pdf"],
operationConfig: compressOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(compressOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/compress/CompressSettings"),
),
synonyms: getSynonyms(t, "compress"),
@@ -1372,8 +1376,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
"pdf-to-epub",
],
operationConfig: convertOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(convertOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/convert/ConvertSettings"),
),
synonyms: getSynonyms(t, "convert"),
@@ -1397,8 +1401,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["ocr-pdf"],
operationConfig: ocrOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(ocrOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/ocr/OCRSettings"),
),
synonyms: getSynonyms(t, "ocr"),
@@ -1421,8 +1425,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["auto-redact"],
operationConfig: redactOperationConfig,
automationSettings: lazy(
operationConfig: asRegistryConfig(redactOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/redact/RedactSingleStepSettings"),
),
synonyms: getSynonyms(t, "redact"),
@@ -40,7 +40,7 @@ export function useAutomationForm({
);
const getToolDefaultParameters = useCallback(
(operation: string): Record<string, any> => {
(operation: string): Record<string, unknown> => {
const config = toolRegistry[operation as ToolId]?.operationConfig;
if (config?.defaultParameters) {
return { ...config.defaultParameters };
@@ -1,3 +1,4 @@
import { lazy, type ComponentType } from "react";
import { StirlingFile } from "@app/types/fileContext";
import type { ResponseHandler } from "@app/utils/toolResponseProcessor";
import { ToolId } from "@app/types/toolId";
@@ -147,6 +148,56 @@ export type ToolOperationConfig<TParams = void> =
| MultiFileToolOperationConfig<TParams>
| CustomToolOperationConfig<TParams>;
/**
* One generic source-of-truth for the props every automation settings component
* accepts: the tool's parameters plus a typed change handler.
*/
export interface ToolAutomationSettingsProps<TParams> {
parameters: TParams;
onParameterChange: <K extends keyof TParams>(
key: K,
value: TParams[K],
) => void;
disabled?: boolean;
}
/**
* Erased parameter shape stored in the registry. Spreadable and callable, so
* consumers can merge defaults and invoke buildFormData/customProcessor/endpoint
* without per-tool type knowledge.
*/
export type ErasedToolParams = Record<string, unknown>;
export type RegistryToolOperationConfig = ToolOperationConfig<ErasedToolParams>;
export type RegistryAutomationSettings = ComponentType<
ToolAutomationSettingsProps<ErasedToolParams>
> | null;
/**
* Store a tool's typed operationConfig in the registry. The input is validated as
* a real ToolOperationConfig<TParams>, then TParams is erased here. TParams is
* invariant in ToolOperationConfig, so the erasure cannot be a plain assignment;
* the `as unknown as` is the localized existential boundary.
*/
export function asRegistryConfig<TParams>(
config: ToolOperationConfig<TParams>,
): RegistryToolOperationConfig {
return config as unknown as RegistryToolOperationConfig;
}
/**
* Lazily load a tool's automation settings component for the registry. The loaded
* component is validated against ToolAutomationSettingsProps<TParams> (inferred
* from the module), then erased to the registry's shared props shape.
*/
export function lazySettings<TParams>(
loader: () => Promise<{
default: ComponentType<ToolAutomationSettingsProps<TParams>>;
}>,
): RegistryAutomationSettings {
return lazy(loader) as unknown as RegistryAutomationSettings;
}
/**
* Complete tool operation interface returned by useToolOperation.
*/
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.13.1",
appVersion: "2.13.2",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
+5 -2
View File
@@ -4,7 +4,10 @@ import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import {
createToolFlow,
type MiddleStepConfig,
} from "@app/components/tools/shared/createToolFlow";
import { createFilesToolStep } from "@app/components/tools/shared/FilesToolStep";
import AutomationSelection from "@app/components/tools/automate/AutomationSelection";
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
@@ -200,7 +203,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const createStep = (
title: string,
props: any,
props: Omit<MiddleStepConfig, "title" | "content">,
content?: React.ReactNode,
) => ({
title,
@@ -4,6 +4,11 @@ import { ToolId } from "@app/types/toolId";
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
import { AutomationFileProcessor } from "@app/utils/automationFileProcessor";
import { ToolType } from "@app/hooks/tools/shared/useToolOperation";
import {
type ErasedToolParams,
type MultiFileToolOperationConfig,
type SingleFileToolOperationConfig,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { zipFileService } from "@app/services/zipFileService";
import { processResponse } from "@app/utils/toolResponseProcessor";
@@ -76,8 +81,8 @@ const executeApiRequest = async (
* Execute single-file tool operation (processes files one at a time)
*/
const executeSingleFileOperation = async (
config: any,
parameters: any,
config: SingleFileToolOperationConfig<ErasedToolParams>,
parameters: ErasedToolParams,
files: File[],
filePrefix: string,
): Promise<File[]> => {
@@ -89,9 +94,7 @@ const executeSingleFileOperation = async (
? config.endpoint(parameters)
: config.endpoint;
const formData = (
config.buildFormData as (params: any, file: File) => FormData
)(parameters, file);
const formData = config.buildFormData(parameters, file);
const processedFiles = await executeApiRequest(
endpoint,
@@ -110,8 +113,8 @@ const executeSingleFileOperation = async (
* Execute multi-file tool operation (processes all files in one request)
*/
const executeMultiFileOperation = async (
config: any,
parameters: any,
config: MultiFileToolOperationConfig<ErasedToolParams>,
parameters: ErasedToolParams,
files: File[],
filePrefix: string,
): Promise<File[]> => {
@@ -120,9 +123,7 @@ const executeMultiFileOperation = async (
? config.endpoint(parameters)
: config.endpoint;
const formData = (
config.buildFormData as (params: any, files: File[]) => FormData
)(parameters, files);
const formData = config.buildFormData(parameters, files);
return await executeApiRequest(
endpoint,
@@ -138,7 +139,7 @@ const executeMultiFileOperation = async (
*/
export const executeToolOperation = async (
operationName: string,
parameters: any,
parameters: ErasedToolParams,
files: File[],
toolRegistry: ToolRegistry,
): Promise<File[]> => {
@@ -156,7 +157,7 @@ export const executeToolOperation = async (
*/
export const executeToolOperationWithPrefix = async (
operationName: string,
parameters: any,
parameters: ErasedToolParams,
files: File[],
toolRegistry: ToolRegistry,
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX,
@@ -25,20 +25,7 @@
flex-shrink: 0;
}
.chat-panel__agent-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
padding: 0.4rem 0.75rem 0.4rem 0.4rem;
border: 1px solid var(--border-subtle);
border-radius: 9999px;
background: var(--mantine-color-body);
cursor: pointer;
transition:
background 120ms ease-out,
border-color 120ms ease-out;
.chat-panel__agent-pill-vt {
view-transition-name: stirling-agent;
}
@@ -50,89 +37,6 @@
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
.chat-panel__agent-pill:hover {
background: var(--mantine-color-default-hover);
}
.chat-panel__agent-pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
position: relative;
}
/* Running / in-progress status dot, anchored to the agent pill icon. */
.agent-status-dot {
position: absolute;
bottom: 0;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mantine-color-blue-5);
border: 1.5px solid var(--mantine-color-body);
animation: agent-dot-pulse 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes agent-dot-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.agent-status-dot {
animation: none;
}
}
.chat-panel__agent-pill--loading {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-5) 60%,
var(--border-subtle)
);
}
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill--loading {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-4) 55%,
var(--border-subtle)
);
}
/* Dark-mode: let the pill blend into the rail. Just a thin border, no fill —
so it doesn't read as a clashing lighter card on the dark toolbar. */
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill {
background: transparent;
border-color: var(--border-subtle);
}
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill:hover {
background: rgba(255, 255, 255, 0.04);
}
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill-icon {
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* Same treatment for the input pill and quick-action cards. */
[data-mantine-color-scheme="dark"] .chat-panel-input {
background: transparent;
@@ -14,7 +14,6 @@ import {
Box,
Collapse,
Group,
Menu,
Paper,
ScrollArea,
Stack,
@@ -25,14 +24,12 @@ import {
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import CloseIcon from "@mui/icons-material/Close";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import {
useChat,
AiWorkflowPhase,
@@ -45,6 +42,7 @@ import { formatRelativeTime } from "@app/utils/timeUtils";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated";
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
import { PanelHeader } from "@shared/components/PanelHeader";
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
import "@app/components/chat/ChatPanel.css";
@@ -470,47 +468,25 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
return (
<Box className="chat-panel chat-panel--embedded">
<div className="chat-panel__header">
<Menu shadow="md" width={220} position="bottom-start" withinPortal>
<Menu.Target>
<button
type="button"
className={`chat-panel__agent-pill${isLoading ? " chat-panel__agent-pill--loading" : ""}`}
aria-label={t("chat.header.agentMenu", "Stirling agent options")}
>
<span className="chat-panel__agent-pill-icon">
<StirlingLogoOutline size={16} />
{isLoading && <span className="agent-status-dot" />}
</span>
<span className="chat-panel__agent-pill-label">
{t("agents.stirling_name", "Stirling")}
</span>
<KeyboardArrowDownIcon
sx={{ fontSize: 18, color: "var(--text-muted)" }}
/>
</button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<DeleteSweepIcon sx={{ fontSize: 18 }} />}
onClick={clearChat}
disabled={messages.length === 0 && !isLoading}
>
{t("chat.header.clearChat", "Clear chat")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon
variant="subtle"
color="gray"
size="md"
radius="xl"
onClick={onBack}
aria-label={backLabel}
>
<CloseIcon sx={{ fontSize: 18 }} />
</ActionIcon>
</div>
<PanelHeader
icon={<StirlingLogoOutline size={16} />}
title={t("agents.stirling_name", "Stirling")}
loading={isLoading}
className="chat-panel__header"
barClassName="chat-panel__agent-pill-vt"
menuLabel={t("chat.header.agentMenu", "Stirling agent options")}
menuItems={[
{
key: "clear-chat",
icon: <DeleteSweepIcon sx={{ fontSize: 18 }} />,
label: t("chat.header.clearChat", "Clear chat"),
onClick: clearChat,
disabled: messages.length === 0 && !isLoading,
},
]}
onClose={onBack}
closeLabel={backLabel}
/>
{showQuickActions && (
<div className="chat-panel-disclaimer chat-panel-disclaimer--banner">
@@ -8,6 +8,27 @@
/* scaffolding + the collapsed rail; spacing snaps to the SUI --space-* */
/* scale and colour to the SUI token set so it reads as one product. */
/* Dark mode only: remap SUI surface/border tokens to the app's neutral-grey values so policy cards read as one product with the rail; accent tokens are left alone. */
[data-theme="dark"] .pol-list,
[data-theme="dark"] .pol-takeover,
[data-theme="dark"] .pol-detail,
[data-theme="dark"] .pol-crail {
--color-bg: var(--bg-toolbar);
--color-bg-alt: var(--bg-toolbar);
--color-bg-subtle: var(--bg-toolbar);
--color-surface: var(--bg-surface);
--color-surface-alt: #323942;
--color-bg-hover: #323942;
--color-bg-muted: var(--bg-surface);
--color-border: var(--border-default);
--color-border-light: var(--border-subtle);
--color-border-input: var(--border-strong);
--color-border-hover: var(--border-strong);
--color-divider: var(--border-subtle);
--color-dropdown-bg: var(--bg-surface);
--color-dropdown-border: var(--border-default);
}
/* ---- List ---- */
.pol-list {
width: 100%;
@@ -69,6 +90,19 @@
outline: 2px solid var(--color-blue);
outline-offset: -2px;
}
/* Policy icons are colourless at rest; hovering or focusing the row reveals the
category colour (blue/purple/green/amber/red — see ROW_ACCENT). The accent
class sets --ib-base; we neutralise --ib-accent here and restore it on hover. */
.pol-row .sui-iconbadge {
--ib-accent: var(--color-text-3);
transition:
color var(--motion-fast),
background var(--motion-fast);
}
.pol-row:hover .sui-iconbadge,
.pol-row:focus-visible .sui-iconbadge {
--ib-accent: var(--ib-base);
}
.pol-row-label {
flex: 1;
min-width: 0;
@@ -126,20 +160,28 @@
text-decoration: underline;
}
/* Locked "Coming soon" row — muted, not interactive. */
/* Enterprise-only ("coming soon") row — shown to admins / team leaders but not
available on the current plan, so the whole box is dimmed to read as disabled.
The row itself isn't a button; its trailing "Upgrade to enterprise" link is. */
.pol-row--soon {
opacity: 0.55;
cursor: default;
opacity: 0.55;
}
.pol-row--soon:hover {
background: transparent;
}
.pol-row-soon {
/* Trailing "Upgrade to enterprise" link → contact us. Greyed to match the
disabled row; still clickable for admins who want to enquire. */
.pol-row-upgrade {
font-size: 0.6875rem;
font-weight: 500;
font-weight: 600;
color: var(--color-text-4);
text-decoration: none;
white-space: nowrap;
}
.pol-row-upgrade:hover {
text-decoration: underline;
}
/* In-progress activity icon spins gently. */
.pol-spin {
@@ -192,9 +234,17 @@
/* ---- Step indicator (wraps a SUI StepIndicator) ---- */
.pol-steps {
padding: var(--space-3) var(--space-5);
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0 var(--space-5) var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.pol-step-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-4);
}
/* ---- Scroll body ---- */
.pol-scroll {
@@ -211,6 +261,7 @@
line-height: 1.5;
color: var(--color-text-4);
margin: 0;
margin-bottom: var(--space-3);
}
.pol-section-label {
font-size: 0.6875rem;
@@ -169,11 +169,11 @@ describe("Policies right-sidebar surface", () => {
expect(await screen.findByText("No activity yet")).toBeInTheDocument();
});
it("returns to the list via the back button", () => {
it("returns to the list via the close button", () => {
renderHost();
fireEvent.click(screen.getByText("Security"));
expect(screen.getByText("Enforces")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Back"));
fireEvent.click(screen.getByLabelText("Close"));
expect(screen.getByText("Policies")).toBeInTheDocument();
});
});
@@ -61,6 +61,16 @@ export function usePoliciesEnabled(): boolean {
return POLICIES_ENABLED;
}
/**
* Whether the right rail should show the Policies section.
*/
export function usePoliciesVisible(): boolean {
const pol = usePolicies();
const { categories } = usePolicyCatalog();
if (!POLICIES_ENABLED) return false;
return pol.canConfigure || categories.some((c) => !c.comingSoon);
}
/**
* Whether the current user is a guest who can't open or configure policies —
* an anonymous user on a login-enabled deployment (i.e. a SaaS sign-up prompt
@@ -122,6 +132,14 @@ export function PoliciesSection({
if (!POLICIES_ENABLED) return null;
// Admins / team leads see the full catalogue (coming-soon rows greyed as an
// enterprise upsell); regular users only see the live policies — the
// coming-soon "Upgrade to enterprise" rows are hidden from them.
const visibleCategories = pol.canConfigure
? categories
: categories.filter((c) => !c.comingSoon);
if (visibleCategories.length === 0) return null;
// The header tally counts every CONFIGURED policy (active + paused), not just
// the active ones.
const configuredCount = categories.filter(
@@ -170,14 +188,10 @@ export function PoliciesSection({
{expanded && (
<>
<div className="pol-list-rows">
{categories.map((cat) => {
{visibleCategories.map((cat) => {
if (cat.comingSoon) {
return (
<div
key={cat.id}
className="pol-row pol-row--soon"
aria-disabled="true"
>
<div key={cat.id} className="pol-row pol-row--soon">
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon}
</IconBadge>
@@ -185,12 +199,17 @@ export function PoliciesSection({
{t(`policies.catalog.${cat.id}`, cat.label)}
</span>
<span className="pol-row-trail">
<span className="pol-row-soon">
<a
className="pol-row-upgrade"
href="https://stirling.com/contact"
target="_blank"
rel="noopener noreferrer"
>
{t(
"policies.sidebar.upgradeToEnterprise",
"Upgrade to enterprise",
)}
</span>
</a>
</span>
</div>
);
@@ -457,58 +476,64 @@ export function PoliciesCollapsedButton({
if (!POLICIES_ENABLED) return null;
// Coming-soon policies are excluded; admins see all real policies, others only see configured ones — renders nothing when empty.
const railCategories = categories.filter((cat) => {
if (cat.comingSoon) return false;
if (pol.canConfigure) return true;
return pol.policies[cat.id]?.configured;
});
if (railCategories.length === 0) return null;
return (
<>
<div className="pol-crail">
{categories
.filter((cat) => !cat.comingSoon)
.map((cat) => {
const status = deriveRowStatus(pol.policies[cat.id]);
const label = t(`policies.catalog.${cat.id}`, cat.label);
const statusLabel = t(
`policies.status.${status}`,
STATUS_LABEL[status],
);
const suffix =
status === "active"
? t("policies.sidebar.railSuffixActive", " (Active)")
: status === "paused"
? t("policies.sidebar.railSuffixPaused", " (Paused)")
: "";
return (
<AppTooltip
key={cat.id}
content={`${label}${suffix}`}
position="left"
arrow
delay={300}
{railCategories.map((cat) => {
const status = deriveRowStatus(pol.policies[cat.id]);
const label = t(`policies.catalog.${cat.id}`, cat.label);
const statusLabel = t(
`policies.status.${status}`,
STATUS_LABEL[status],
);
const suffix =
status === "active"
? t("policies.sidebar.railSuffixActive", " (Active)")
: status === "paused"
? t("policies.sidebar.railSuffixPaused", " (Paused)")
: "";
return (
<AppTooltip
key={cat.id}
content={`${label}${suffix}`}
position="left"
arrow
delay={300}
>
<button
type="button"
className="pol-crail-btn"
data-status={status}
aria-label={t(
"policies.sidebar.railAriaLabel",
"{{label}} policy — {{status}}",
{ label, status: statusLabel },
)}
onClick={() => {
if (guestBlocked) {
promptGuestSignup();
return;
}
selectPolicy(cat.id);
onExpand();
}}
>
<button
type="button"
className="pol-crail-btn"
data-status={status}
aria-label={t(
"policies.sidebar.railAriaLabel",
"{{label}} policy — {{status}}",
{ label, status: statusLabel },
)}
onClick={() => {
if (guestBlocked) {
promptGuestSignup();
return;
}
selectPolicy(cat.id);
onExpand();
}}
>
{cat.icon}
{(status === "active" || status === "paused") && (
<span className="pol-crail-dot" data-status={status} />
)}
</button>
</AppTooltip>
);
})}
{cat.icon}
{(status === "active" || status === "paused") && (
<span className="pol-crail-dot" data-status={status} />
)}
</button>
</AppTooltip>
);
})}
</div>
<div className="tool-panel__collapsed-divider" />
</>
@@ -132,9 +132,10 @@ export function PolicyDetailPanel({
<div className="pol-detail">
<PanelHeader
icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
accent={ROW_ACCENT[category.id]}
title={t(`policies.catalog.${category.id}`, category.label)}
onBack={onBack}
onClose={onBack}
closeLabel={t("policies.detail.close", "Close")}
actions={
<StatusBadge
tone={isPaused ? "warning" : "success"}
@@ -1,6 +1,5 @@
import { useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { PanelHeader } from "@shared/components/PanelHeader";
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
@@ -159,7 +158,7 @@ export function PolicySetupWizard({
<div className="pol-detail">
<PanelHeader
icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
accent={ROW_ACCENT[category.id]}
title={
isEdit
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
@@ -169,7 +168,8 @@ export function PolicySetupWizard({
label: t(`policies.catalog.${category.id}`, category.label),
})
}
onBack={onCancel}
onClose={onCancel}
closeLabel={t("policies.wizard.close", "Close")}
/>
<div className="pol-scroll">
<EmptyState
@@ -299,7 +299,7 @@ export function PolicySetupWizard({
<div className="pol-detail">
<PanelHeader
icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
accent={ROW_ACCENT[category.id]}
title={
isEdit
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
@@ -309,23 +309,17 @@ export function PolicySetupWizard({
label: t(`policies.catalog.${category.id}`, category.label),
})
}
subtitle={t("policies.wizard.stepOf", "Step {{step}} of {{total}}", {
step,
total: TOTAL_STEPS,
})}
onBack={back}
actions={
<Button
variant="ghost"
size="sm"
aria-label={t("cancel", "Cancel")}
onClick={onCancel}
leadingIcon={<CloseIcon sx={{ fontSize: "1.1rem" }} />}
/>
}
onClose={onCancel}
closeLabel={t("cancel", "Cancel")}
/>
<div className="pol-steps">
<span className="pol-step-label">
{t("policies.wizard.stepOf", "Step {{step}} of {{total}}", {
step,
total: TOTAL_STEPS,
})}
</span>
<StepIndicator total={TOTAL_STEPS} current={step} />
</div>
@@ -17,7 +17,9 @@ export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
setup: "Set up",
};
/** A soft tinted icon tile per category — gives each policy a calm identity colour. */
/**
* Per-category accent colour
*/
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
ingestion: "blue",
security: "purple",
@@ -55,6 +55,9 @@ export default function TeamDetailsSection({
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
const [processing, setProcessing] = useState(false);
const availableUsersForTeam = team
? availableUsers.filter((user) => user.team?.id !== team.id)
: [];
// License information
const [licenseInfo, setLicenseInfo] = useState<{
@@ -725,7 +728,7 @@ export default function TeamDetailsSection({
placeholder={t(
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
)}
data={availableUsers.map((user) => ({
data={availableUsersForTeam.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
@@ -739,8 +742,9 @@ export default function TeamDetailsSection({
/>
{selectedUserId &&
availableUsers.find((u) => u.id.toString() === selectedUserId)
?.team && (
availableUsersForTeam.find(
(u) => u.id.toString() === selectedUserId,
)?.team && (
<Text size="xs" c="orange">
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
@@ -46,6 +46,9 @@ export default function TeamsSection() {
const [newTeamName, setNewTeamName] = useState("");
const [renameTeamName, setRenameTeamName] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string>("");
const availableUsersForSelectedTeam = selectedTeam
? availableUsers.filter((user) => user.team?.id !== selectedTeam.id)
: [];
useEffect(() => {
fetchTeams();
@@ -604,7 +607,7 @@ export default function TeamsSection() {
placeholder={t(
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
)}
data={availableUsers.map((user) => ({
data={availableUsersForSelectedTeam.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
@@ -618,8 +621,9 @@ export default function TeamsSection() {
/>
{selectedUserId &&
availableUsers.find((u) => u.id.toString() === selectedUserId)
?.team && (
availableUsersForSelectedTeam.find(
(u) => u.id.toString() === selectedUserId,
)?.team && (
<Text size="xs" c="orange">
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.13.1",
appVersion: "2.13.2",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
@@ -8,6 +8,7 @@ import {
type PrototypeToolRegistry,
} from "@app/data/toolsTaxonomy";
import { pdfCommentAgentOperationConfig } from "@app/hooks/tools/pdfCommentAgent/pdfCommentAgentOperationConfig";
import { asRegistryConfig } from "@app/hooks/tools/shared/toolOperationTypes";
import PdfCommentAgent from "@app/tools/PdfCommentAgent";
import { getSynonyms } from "@app/utils/toolSynonyms";
@@ -42,7 +43,7 @@ export function usePrototypeToolRegistry(): PrototypeToolRegistry {
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
maxFiles: 1,
endpoints: ["pdf-comment-agent"],
operationConfig: pdfCommentAgentOperationConfig,
operationConfig: asRegistryConfig(pdfCommentAgentOperationConfig),
automationSettings: null,
synonyms: getSynonyms(t, "pdfCommentAgent"),
versionStatus: "beta",
-2
View File
@@ -214,10 +214,8 @@ export default defineConfig(
ignores: [
"editor/src/core/components/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/contexts/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/data/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/hooks/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/services/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/tools/Automate.tsx",
"editor/src/core/tools/annotate/useAnnotationSelection.ts",
"editor/src/core/types/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/utils/**/*.{js,mjs,jsx,ts,tsx}",
+11 -10
View File
@@ -4,6 +4,12 @@
justify-content: center;
border-radius: var(--radius-md);
flex-shrink: 0;
/* Resolved tint. Each accent class sets --ib-base; --ib-accent defaults to it
but a consumer can override --ib-accent alone (e.g. to neutralise the badge
until hover) without losing the per-accent base. */
--ib-accent: var(--ib-base, var(--color-blue));
color: var(--ib-accent);
background: color-mix(in srgb, var(--ib-accent) 14%, transparent);
}
.sui-iconbadge--sm {
width: 1.75rem;
@@ -14,22 +20,17 @@
height: 2rem;
}
.sui-iconbadge--blue {
color: var(--color-blue);
background: color-mix(in srgb, var(--color-blue) 14%, transparent);
--ib-base: var(--color-blue);
}
.sui-iconbadge--purple {
color: var(--color-purple);
background: color-mix(in srgb, var(--color-purple) 14%, transparent);
--ib-base: var(--color-purple);
}
.sui-iconbadge--green {
color: var(--color-green);
background: color-mix(in srgb, var(--color-green) 14%, transparent);
--ib-base: var(--color-green);
}
.sui-iconbadge--amber {
color: var(--color-amber);
background: color-mix(in srgb, var(--color-amber) 14%, transparent);
--ib-base: var(--color-amber);
}
.sui-iconbadge--red {
color: var(--color-red);
background: color-mix(in srgb, var(--color-red) 14%, transparent);
--ib-base: var(--color-red);
}
+159 -45
View File
@@ -1,54 +1,168 @@
/* ===================== PanelHeader ===================== */
/* The header shared by the active-tool panel, the AI chat panel and the */
/* Policies detail/wizard. Mirrors the AI chat header treatment so it reads well */
/* in both light and dark mode (thin border, no heavy fill). */
.sui-panelhdr {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.sui-panelhdr__left {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.sui-panelhdr__back {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 1rem 0.75rem;
flex-shrink: 0;
width: 1.875rem;
height: 1.875rem;
border: 1px solid var(--color-border);
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-2);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast),
border-color var(--motion-fast);
}
.sui-panelhdr__back:hover {
background: var(--color-bg-hover);
border-color: var(--color-text-4);
color: var(--color-text-1);
}
.sui-panelhdr__text {
min-width: 0;
}
.sui-panelhdr__title {
font-size: 1.0625rem;
font-weight: 600;
color: var(--color-text-1);
}
.sui-panelhdr__sub {
font-size: 0.8125rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.sui-panelhdr__actions {
.sui-panelhdr__bar {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
padding: 0.4rem 0.75rem 0.4rem 0.4rem;
border: 1px solid var(--border-subtle, var(--mantine-color-default-border));
border-radius: 9999px;
background: var(--mantine-color-body);
text-align: left;
color: inherit;
}
/* Only the menu-trigger variant is interactive. */
button.sui-panelhdr__bar {
cursor: pointer;
transition:
background 120ms ease-out,
border-color 120ms ease-out;
}
button.sui-panelhdr__bar:hover {
background: var(--mantine-color-default-hover);
}
.sui-panelhdr__icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
.sui-panelhdr__icon svg {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
/* ToolIcon wraps its glyph in .tool-button-icon with its own margin/transform;
reset them so the glyph sits dead-centre in the circular badge. */
.sui-panelhdr__icon .tool-button-icon {
margin: 0 !important;
transform: none !important;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.sui-panelhdr__label {
flex: 1;
min-width: 0;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--mantine-color-text);
}
.sui-panelhdr__chevron {
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
/* Right-aligned content inside the bar (e.g. a status badge); the flexible
label pushes it to the trailing edge of the pill. */
.sui-panelhdr__actions {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
/* Running / in-progress status dot, anchored to the header icon. */
.sui-panelhdr__dot {
position: absolute;
bottom: 0;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mantine-color-blue-5);
border: 1.5px solid var(--mantine-color-body);
animation: sui-panelhdr-dot-pulse 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes sui-panelhdr-dot-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.sui-panelhdr__dot {
animation: none;
}
}
.sui-panelhdr__bar--loading {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-5) 60%,
var(--border-subtle, var(--mantine-color-default-border))
);
}
/* Dark mode: let the header blend into the rail — just a thin border, no fill —
so it doesn't read as a clashing lighter card on the dark toolbar. */
[data-mantine-color-scheme="dark"] .sui-panelhdr__bar {
background: transparent;
border-color: var(--border-subtle, var(--mantine-color-default-border));
}
[data-mantine-color-scheme="dark"] button.sui-panelhdr__bar:hover {
background: rgba(255, 255, 255, 0.04);
}
[data-mantine-color-scheme="dark"] .sui-panelhdr__bar--loading {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-4) 55%,
var(--border-subtle, var(--mantine-color-default-border))
);
}
[data-mantine-color-scheme="dark"] .sui-panelhdr__icon {
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* Dark mode: the subtle gray close button is too dim against the dark rail —
brighten it to a clearly-visible light grey (near-white on hover). */
[data-mantine-color-scheme="dark"] .sui-panelhdr__close {
color: var(--mantine-color-gray-4);
}
[data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover {
color: var(--mantine-color-gray-2);
}
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import { PanelHeader } from "@shared/components/PanelHeader";
import { Button } from "@shared/components/Button";
import { StatusBadge } from "@shared/components/StatusBadge";
const meta: Meta<typeof PanelHeader> = {
@@ -9,53 +10,53 @@ const meta: Meta<typeof PanelHeader> = {
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
title: "Pipeline detail",
subtitle: "COI Compliance · us-east-1",
icon: <ShieldOutlinedIcon sx={{ fontSize: "1rem" }} />,
title: "Security",
closeLabel: "Close",
},
argTypes: { onBack: { action: "back" } },
argTypes: { onClose: { action: "close" } },
};
export default meta;
type Story = StoryObj<typeof PanelHeader>;
/** Toggle title / subtitle / onBack / actions in controls. */
/** Plain header pill with a trailing close button. */
export const Playground: Story = {};
export const WithActions: Story = {
/** Category-accented icon badge (blue / purple / green / amber / red). */
export const Accented: Story = {
args: { accent: "purple" },
};
/** Dropdown trigger — a disclosure chevron appears and clicking the pill opens
* the menu (e.g. the chat header's "Clear chat"). */
export const WithMenu: Story = {
args: {
subtitle: "Last deploy 14m ago · golden set 48/48",
actions: (
<>
<StatusBadge tone="success" pulse>
Healthy
</StatusBadge>
<Button size="sm" variant="outline">
Edit composition
</Button>
<Button size="sm" variant="gradient">
View runs
</Button>
</>
),
title: "Stirling",
menuLabel: "Stirling agent options",
menuItems: [
{
key: "clear",
icon: <DeleteSweepIcon sx={{ fontSize: 18 }} />,
label: "Clear chat",
onClick: () => {},
},
],
},
};
export const Everything: Story = {
/** Loading state — pulsing status dot on the icon + a tinted border. */
export const Loading: Story = {
args: { title: "Stirling", loading: true },
};
/** Right-aligned actions rendered before the close button. */
export const WithActions: Story = {
args: {
title: "Pipeline detail — COI Compliance",
subtitle: "Forked from Compliance Pack · 1,287 docs / 24h",
onBack: () => {},
accent: "purple",
actions: (
<>
<StatusBadge tone="success" pulse>
Healthy
</StatusBadge>
<Button size="sm" variant="outline">
Edit composition
</Button>
<Button size="sm" variant="gradient">
View runs
</Button>
</>
<StatusBadge tone="success" showDot pulse>
Active
</StatusBadge>
),
},
};
+130 -51
View File
@@ -1,73 +1,152 @@
import type { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import type { IconBadgeAccent } from "@shared/components/IconBadge";
import "@shared/components/PanelHeader.css";
import { IconBadge } from "@shared/components/IconBadge";
export interface PanelHeaderMenuItem {
/** Stable key; falls back to the item index. */
key?: string;
/** Optional leading glyph. */
icon?: ReactNode;
label: ReactNode;
onClick: () => void;
disabled?: boolean;
}
export interface PanelHeaderProps {
/** Glyph rendered in the tinted circular badge at the header's leading edge. */
icon: ReactNode;
/** Header title. */
title: ReactNode;
/** Sub-heading below the title. */
subtitle?: ReactNode;
/** Show a back chevron and trigger this callback when clicked. */
onBack?: () => void;
/** Optional leading visual (e.g. a category glyph) shown in a tinted box. */
icon?: ReactNode;
/** Accent tint for the leading icon box. Defaults to blue. */
iconAccent?: "blue" | "purple" | "green" | "amber" | "red";
/** Right-aligned action buttons / chips. */
/** Close (X) handler. The trailing close button renders only when supplied. */
onClose?: () => void;
/** aria-label for the close button. */
closeLabel?: string;
/**
* When provided, the header becomes a dropdown trigger: a disclosure chevron is
* shown and clicking it opens a menu of these items (e.g. "Clear chat").
*/
menuItems?: PanelHeaderMenuItem[];
/** aria-label for the header when it acts as a menu trigger. */
menuLabel?: string;
/**
* Tints the icon badge with a category colour (blue/purple/green/amber/red).
* Defaults to the standard blue when omitted (tool + AI chat headers).
*/
accent?: IconBadgeAccent;
/** Shows a pulsing status dot on the icon + a tinted border (e.g. AI running). */
loading?: boolean;
/** Right-aligned content rendered inside the header bar, after the title
* (e.g. a status badge). */
actions?: ReactNode;
/** Applied to the inner header element — e.g. to set a view-transition-name. */
barClassName?: string;
/** Applied to the outer header container. */
className?: string;
}
/**
* Header strip used by drill-down panels (admin tabs, agent detail, settings
* sub-pages). Back chevron renders only when `onBack` is supplied; an optional
* leading `icon` renders in a tinted box before the title.
* The header shared by the rail surfaces — the active tool panel, the AI chat
* panel, and the Policies detail/wizard. A tinted icon badge + title sit in a
* rounded bar, with an optional dropdown menu and a trailing close button. The
* styling stays legible in dark mode (thin border, no heavy fill) across every
* surface.
*/
export function PanelHeader({
title,
subtitle,
onBack,
icon,
iconAccent = "blue",
title,
onClose,
closeLabel,
menuItems,
menuLabel,
accent,
loading = false,
actions,
barClassName,
className,
}: PanelHeaderProps) {
const hasMenu = menuItems != null && menuItems.length > 0;
// Tint the icon badge with the category colour when an accent is given. Inline
// so it wins over the default blue treatment in both light and dark mode; the
// --color-* tokens are theme-aware and match the badge tint used elsewhere.
const iconStyle: CSSProperties | undefined = accent
? {
color: `var(--color-${accent})`,
background: `color-mix(in srgb, var(--color-${accent}) 14%, transparent)`,
}
: undefined;
const barClasses = [
"sui-panelhdr__bar",
loading ? "sui-panelhdr__bar--loading" : "",
barClassName ?? "",
]
.filter(Boolean)
.join(" ");
const barBody = (
<>
<span className="sui-panelhdr__icon" style={iconStyle}>
{icon}
{loading && <span className="sui-panelhdr__dot" />}
</span>
<span className="sui-panelhdr__label">{title}</span>
{actions != null && (
<span className="sui-panelhdr__actions">{actions}</span>
)}
{hasMenu && (
<KeyboardArrowDownIcon
className="sui-panelhdr__chevron"
sx={{ fontSize: 18 }}
/>
)}
</>
);
return (
<div
className={["sui-panelhdr", className ?? ""].filter(Boolean).join(" ")}
>
<div className="sui-panelhdr__left">
{onBack && (
<button
type="button"
className="sui-panelhdr__back"
onClick={onBack}
aria-label="Back"
>
<svg
viewBox="0 0 24 24"
width={18}
height={18}
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
)}
{icon && (
<IconBadge accent={iconAccent} size="md">
{icon}
</IconBadge>
)}
<div className="sui-panelhdr__text">
<div className="sui-panelhdr__title">{title}</div>
{subtitle && <div className="sui-panelhdr__sub">{subtitle}</div>}
</div>
</div>
{actions && <div className="sui-panelhdr__actions">{actions}</div>}
{hasMenu ? (
<Menu shadow="md" width={220} position="bottom-start" withinPortal>
<Menu.Target>
<button type="button" className={barClasses} aria-label={menuLabel}>
{barBody}
</button>
</Menu.Target>
<Menu.Dropdown>
{(menuItems ?? []).map((item, i) => (
<Menu.Item
key={item.key ?? i}
leftSection={item.icon}
onClick={item.onClick}
disabled={item.disabled}
>
{item.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
) : (
<div className={barClasses}>{barBody}</div>
)}
{onClose && (
<ActionIcon
className="sui-panelhdr__close"
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={onClose}
aria-label={closeLabel}
>
<CloseIcon sx={{ fontSize: 18 }} />
</ActionIcon>
)}
</div>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
Vendored
+2 -2
View File
@@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: