Compare commits

..
Author SHA1 Message Date
posthog-eu[bot]andGitHub 1fcc958460 Fix Prettier formatting in PdfEngineBoundary test
frontend:format:check failed CI because the new test file wasn't
Prettier-formatted. No logic change.

Generated-By: PostHog Code
Task-Id: 3aa5a9d0-e661-4860-8e51-4610b2599780
2026-07-15 11:40:54 +00:00
posthog-eu[bot]andGitHub a835e68ac2 Recover from PDF engine load failures instead of spinning forever
The viewer gated on `isLoading || !engine || !pdfUrl` and only rendered an
error when the `usePdfiumEngine` hook rejected. When the WASM engine load
hung (never resolving or rejecting), the center pane stayed on
"Loading PDF Engine..." indefinitely with no error and no way to recover, so
no selected/dropped PDF ever rendered for the whole session.

- Add `PdfEngineBoundary`, which owns the engine load, times it out, and
  surfaces an error state with a Retry button (nudging the user to update the
  app) instead of an infinite spinner. Retry remounts the boundary so the
  load re-runs from scratch.
- Harden `wasmPrecompiler` to fall back to fetch + `arrayBuffer()` +
  `WebAssembly.compile` when `compileStreaming` is unavailable or fails
  (e.g. wrong MIME type / content-encoding under the `tauri://` protocol).
- Add component tests reproducing the hang → error + retry transition.

Generated-By: PostHog Code
Task-Id: 3aa5a9d0-e661-4860-8e51-4610b2599780
2026-07-15 11:17:55 +00:00
110 changed files with 4957 additions and 5655 deletions
@@ -1,61 +0,0 @@
package stirling.software.proprietary.classification;
import java.io.InputStream;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import tools.jackson.databind.ObjectMapper;
/**
* Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a
* fixed, built-in list bundled with the application ({@code
* classification/classification-labels.json}) and shared by everyone — there is no per-team
* customization or database. Loaded once at startup.
*/
@Slf4j
@Component
public class ClassificationLabelProvider {
private static final String RESOURCE = "classification/classification-labels.json";
private final List<ClassificationLabel> labels;
// Explicit @Autowired: the class has a second (private) constructor for tests, so Spring
// can't infer which to use without it.
@Autowired
public ClassificationLabelProvider(ObjectMapper objectMapper) {
this(load(objectMapper));
}
private ClassificationLabelProvider(List<ClassificationLabel> labels) {
this.labels = List.copyOf(labels);
}
/** Build a provider with an explicit label set (tests). */
public static ClassificationLabelProvider withLabels(List<ClassificationLabel> labels) {
return new ClassificationLabelProvider(labels);
}
/** The built-in vocabulary, in file order. */
public List<ClassificationLabel> labels() {
return labels;
}
private static List<ClassificationLabel> load(ObjectMapper objectMapper) {
try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) {
ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class);
return parsed.labels();
} catch (Exception e) {
log.error("Failed to load classification labels from {}", RESOURCE, e);
return List.of();
}
}
}
@@ -0,0 +1,136 @@
package stirling.software.proprietary.classification;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.model.LabelsValidator;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.classification.store.TeamLabelsEntity;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read/write the team's classification label set — the flat vocabulary the document classifier runs
* against. Shared and team-scoped exactly like policies: every user reads their own team's labels,
* and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
* {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since
* single-user deployments trust the local operator. A team with no stored labels reads as {@code
* 204}; that team has no vocabulary, so its documents are not classified (there is no built-in
* default on the backend or the engine — the label data lives only in the frontend).
*/
@RestController
@RequestMapping("/api/v1/classification/labels")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Classification", description = "Team-scoped document-classification labels")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class ClassificationLabelsController {
private final ClassificationLabelStore labelStore;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
private final UserServiceInterface userService;
@GetMapping
@Operation(
summary = "Get the team's classification labels",
description =
"Returns the caller's team label set, or 204 when the team has none (its"
+ " documents are then not classified).")
public ResponseEntity<ClassificationLabels> getTeamLabels() {
return labelStore
.findByTeam(currentTeamId())
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.noContent().build());
}
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Save the team's classification labels",
description =
"Validates and stores the label set for the caller's team, shared by everyone"
+ " on the team. Requires the policy-editor role for the team.")
public ResponseEntity<ClassificationLabels> saveTeamLabels(
@RequestBody ClassificationLabels labels) {
requireEditingAllowed();
validate(labels);
ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername());
return ResponseEntity.ok(saved);
}
@DeleteMapping
@Operation(
summary = "Reset the team's classification labels",
description =
"Removes the team's stored label set; its documents are then not classified"
+ " until labels are saved again. Requires the policy-editor role for the"
+ " team.")
public ResponseEntity<Void> resetTeamLabels() {
requireEditingAllowed();
labelStore.deleteByTeam(currentTeamId());
return ResponseEntity.noContent().build();
}
private static void validate(ClassificationLabels labels) {
try {
LabelsValidator.validate(labels);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
/**
* Editing the team labels requires the editor role for the caller's team — the same gate
* policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login
* disabled) have no such role, so they trust the local operator.
*/
private void requireEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"The team classification labels may only be changed by a team leader");
}
}
/**
* The caller's team key. With login disabled the single operator owns the {@link
* TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team
* is an error rather than being dropped into the shared sentinel bucket (which would let
* unteamed users read and overwrite each other's "team" labels).
*/
private Long currentTeamId() {
Long teamId = policyManagementAuthority.currentUserTeamId();
if (teamId != null) {
return teamId;
}
if (!applicationProperties.getSecurity().isEnableLogin()) {
return TeamLabelsEntity.NO_TEAM;
}
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team");
}
private String currentUsername() {
return userService == null ? null : userService.getCurrentUsername();
}
}
@@ -3,10 +3,10 @@ package stirling.software.proprietary.classification.model;
import java.util.List;
/**
* A flat multi-label classification vocabulary — the set of labels a document may be assigned. The
* classifier runs against these label names. The vocabulary is a fixed, built-in set shared by
* everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider});
* this record is the JSON parse target for that bundled resource.
* A flat multi-label classification vocabulary — the set of labels a document may be assigned.
* Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these
* label names. A team with no stored set has no vocabulary, so its documents are not classified —
* neither the backend nor the engine holds a default of its own.
*/
public record ClassificationLabels(List<ClassificationLabel> labels) {
@@ -0,0 +1,71 @@
package stirling.software.proprietary.classification.model;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Structural validation for a user- or admin-supplied label set, run before it is stored so a
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
* non-blank ids and names, each unique within the set (ids exactly, names case-insensitively).
*/
public final class LabelsValidator {
private LabelsValidator() {}
// Generous upper bounds so a legitimate label set is never blocked, but a single team or user
// can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or
// exhaust memory on deserialize.
static final int MAX_LABELS = 500;
static final int MAX_TEXT_LENGTH = 128;
// Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side —
// the exact allowlist lives in the frontend — so a client bypassing the UI can't store
// arbitrary
// text that would render as garbage (or worse) in every teammate's sidebar.
private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$");
/**
* @throws IllegalArgumentException with a human-readable message when the label set is invalid.
*/
public static void validate(ClassificationLabels labels) {
if (labels == null || labels.labels() == null) {
throw new IllegalArgumentException("Labels are required");
}
if (labels.labels().size() > MAX_LABELS) {
throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")");
}
Set<String> ids = new HashSet<>();
Set<String> names = new HashSet<>();
for (ClassificationLabel label : labels.labels()) {
requireText(label.id(), "Label id");
requireText(label.name(), "Label name");
if (label.icon() != null && !label.icon().isEmpty()) {
if (label.icon().length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
"Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
if (!ICON_KEY.matcher(label.icon()).matches()) {
throw new IllegalArgumentException("Invalid label icon: " + label.icon());
}
}
if (!ids.add(label.id().trim())) {
throw new IllegalArgumentException("Duplicate label id: " + label.id());
}
if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("Duplicate label name: " + label.name());
}
}
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
if (value.trim().length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
}
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.classification.store;
import java.util.Optional;
import stirling.software.proprietary.classification.model.ClassificationLabels;
/**
* Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the
* unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null
* team.
*/
public interface ClassificationLabelStore {
/** The team's stored labels, or empty when it has none (callers then skip classification). */
Optional<ClassificationLabels> findByTeam(Long teamId);
/** Create or replace the team's labels. Returns the stored value. */
ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy);
/** Remove the team's labels (reset to default). Returns whether a set existed. */
boolean deleteByTeam(Long teamId);
}
@@ -0,0 +1,36 @@
package stirling.software.proprietary.classification.store;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.classification.model.ClassificationLabels;
/**
* In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link
* JpaClassificationLabelStore} is the runtime bean.
*/
public class InProcessClassificationLabelStore implements ClassificationLabelStore {
private final Map<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
@Override
public Optional<ClassificationLabels> findByTeam(Long teamId) {
return Optional.ofNullable(byTeam.get(key(teamId)));
}
@Override
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
byTeam.put(key(teamId), labels);
return labels;
}
@Override
public boolean deleteByTeam(Long teamId) {
return byTeam.remove(key(teamId)) != null;
}
private static long key(Long teamId) {
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
}
}
@@ -0,0 +1,76 @@
package stirling.software.proprietary.classification.store;
import java.time.Instant;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code
* policies.enabled} — stored labels only matter when the Classification policy can run — so it
* shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link
* TeamLabelsEntity}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaClassificationLabelStore implements ClassificationLabelStore {
private final TeamLabelsRepository teamRepository;
private final ObjectMapper objectMapper;
@Override
public Optional<ClassificationLabels> findByTeam(Long teamId) {
return teamRepository
.findById(key(teamId))
.flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId));
}
@Override
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
TeamLabelsEntity entity = new TeamLabelsEntity();
entity.setTeamId(key(teamId));
entity.setLabelsJson(objectMapper.writeValueAsString(labels));
entity.setUpdatedAt(Instant.now());
entity.setUpdatedBy(updatedBy);
teamRepository.save(entity);
return labels;
}
@Override
public boolean deleteByTeam(Long teamId) {
long id = key(teamId);
if (!teamRepository.existsById(id)) {
return false;
}
teamRepository.deleteById(id);
return true;
}
private Optional<ClassificationLabels> parse(String json, String owner) {
try {
return Optional.of(objectMapper.readValue(json, ClassificationLabels.class));
} catch (JacksonException e) {
// A stored label set that no longer parses (corruption / manual DB edit) must not break
// classification: drop it so the caller treats the team as having no labels (and skips
// classification) rather than surfacing a 500 on every upload.
log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage());
return Optional.empty();
}
}
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
private static long key(Long teamId) {
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
}
}
@@ -0,0 +1,47 @@
package stirling.software.proprietary.classification.store;
import java.io.Serializable;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a team's classification labels — one row per team. The label set lives as JSON in
* {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
* not a foreign key — so classification can be enabled or disabled without touching them.
*/
@Entity
@Table(name = "classification_labels")
@NoArgsConstructor
@Getter
@Setter
public class TeamLabelsEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** Sentinel key for the unteamed label set (login disabled / no resolvable team). */
public static final long NO_TEAM = 0L;
@Id
@Column(name = "team_id")
private long teamId;
@Column(name = "labels_json", columnDefinition = "text")
private String labelsJson;
@Column(name = "updated_at")
private Instant updatedAt;
@Column(name = "updated_by")
private String updatedBy;
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.classification.store;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
@@ -31,9 +31,10 @@ import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.classification.ClassificationLabelProvider;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.PdfContentExtractor;
@@ -45,7 +46,7 @@ import tools.jackson.databind.node.ObjectNode;
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
*
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
* engine to classify the document against the built-in label set, and stores the engine's JSON
* engine to classify the document against the caller's team label set, and stores the engine's JSON
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
* client use.
@@ -71,9 +72,13 @@ public class ClassifyLabelController {
private final UserServiceInterface userService;
/**
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
* team authority are gated on it. Null otherwise, in which case there are no team labels to
* classify against and the document is passed through unlabelled.
*/
private final ClassificationLabelProvider labelProvider;
private final ClassificationLabelStore labelStore;
private final PolicyManagementAuthority policyManagementAuthority;
public ClassifyLabelController(
CustomPDFDocumentFactory pdfDocumentFactory,
@@ -82,16 +87,18 @@ public class ClassifyLabelController {
PdfMetadataService pdfMetadataService,
AiEngineClient aiEngineClient,
ObjectMapper objectMapper,
ClassificationLabelProvider labelProvider,
@Autowired(required = false) UserServiceInterface userService) {
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) ClassificationLabelStore labelStore,
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.tempFileManager = tempFileManager;
this.pdfContentExtractor = pdfContentExtractor;
this.pdfMetadataService = pdfMetadataService;
this.aiEngineClient = aiEngineClient;
this.objectMapper = objectMapper;
this.labelProvider = labelProvider;
this.userService = userService;
this.labelStore = labelStore;
this.policyManagementAuthority = policyManagementAuthority;
}
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -109,9 +116,9 @@ public class ClassifyLabelController {
List<EngineLabel> allowed = resolveAllowedLabels();
if (allowed.isEmpty()) {
// No vocabulary to classify against: pass the file through unlabelled rather than
// ask the engine to classify against nothing.
log.debug("[classify-and-label] {} has no labels; skipping", fileName);
// No vocabulary to classify against (the team stored no labels): pass the file
// through unlabelled rather than ask the engine to classify against nothing.
log.debug("[classify-and-label] {} has no team labels; skipping", fileName);
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
}
@@ -168,13 +175,24 @@ public class ClassifyLabelController {
}
/**
* The built-in vocabulary as {@code {id, name}} pairs, de-duplicated by id. The engine shows
* the model the names and returns the ids (icons are presentational and never sent). The engine
* holds no default vocabulary of its own, so this bundled set is the only source.
* The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id.
* The engine shows the model the names and returns the ids (icons are presentational and never
* sent). Returns an empty list — the caller then skips classification — when the policy
* subsystem is disabled (no store) or the team has no stored labels. The engine holds no
* default vocabulary of its own, so a team's stored labels are the only source.
*/
private List<EngineLabel> resolveAllowedLabels() {
if (labelStore == null) {
return List.of();
}
Long teamId =
policyManagementAuthority == null
? null
: policyManagementAuthority.currentUserTeamId();
Map<String, EngineLabel> byId = new LinkedHashMap<>();
collectLabels(labelProvider.labels(), byId);
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
return List.copyOf(byId.values());
}
@@ -37,7 +37,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.repository",
"stirling.software.proprietary.integration.repository"
"stirling.software.proprietary.integration.repository",
"stirling.software.proprietary.classification.store"
})
@EntityScan({
"stirling.software.proprietary.security.model",
@@ -49,7 +50,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.model",
"stirling.software.proprietary.integration.model"
"stirling.software.proprietary.integration.model",
"stirling.software.proprietary.classification.store"
})
public class DatabaseConfig {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
package stirling.software.proprietary.classification;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
@ExtendWith(MockitoExtension.class)
@DisplayName("ClassificationLabelsController")
class ClassificationLabelsControllerTest {
private static final Long TEAM = 7L;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private UserServiceInterface userService;
private ClassificationLabelStore store;
private ApplicationProperties applicationProperties;
private ClassificationLabelsController controller;
@BeforeEach
void setUp() {
store = new InProcessClassificationLabelStore();
applicationProperties = new ApplicationProperties();
controller =
new ClassificationLabelsController(
store, policyManagementAuthority, applicationProperties, userService);
}
private static ClassificationLabels sample() {
return new ClassificationLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
new ClassificationLabel("contract", "Contract", null)));
}
private void loginEnabled(boolean enabled) {
applicationProperties.getSecurity().setEnableLogin(enabled);
}
@Test
@DisplayName("GET returns 204 when the team has no labels")
void getEmpty() {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
ResponseEntity<ClassificationLabels> response = controller.getTeamLabels();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("PUT then GET round-trips the team's labels (login disabled)")
void saveThenGet() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTeamLabels(sample());
ResponseEntity<ClassificationLabels> got = controller.getTeamLabels();
assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(got.getBody()).isNotNull();
assertThat(got.getBody().labels()).hasSize(2);
assertThat(got.getBody().labels().getFirst().name()).isEqualTo("Invoice");
assertThat(got.getBody().labels().getFirst().icon()).isEqualTo("receipt-long");
}
@Test
@DisplayName("PUT is scoped per team")
void perTeam() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTeamLabels(sample());
when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L);
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("PUT is rejected for a non-editor when login is enabled")
void putForbiddenForNonEditor() {
loginEnabled(true);
when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.saveTeamLabels(sample()))
.isInstanceOf(ResponseStatusException.class)
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("PUT rejects an invalid label set with 400")
void putInvalid() {
loginEnabled(false);
ClassificationLabels duplicate =
new ClassificationLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", null),
new ClassificationLabel("invoice", "Invoice", null)));
assertThatThrownBy(() -> controller.saveTeamLabels(duplicate))
.isInstanceOf(ResponseStatusException.class)
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("DELETE resets the team back to no stored labels")
void deleteResets() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTeamLabels(sample());
ResponseEntity<Void> response = controller.resetTeamLabels();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.classification.model;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import java.util.Locale;
import java.util.stream.IntStream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("LabelsValidator")
class LabelsValidatorTest {
private static ClassificationLabels labels(ClassificationLabel... labels) {
return new ClassificationLabels(List.of(labels));
}
private static ClassificationLabel label(String name) {
return new ClassificationLabel(slug(name), name, null);
}
private static String slug(String name) {
return name.trim()
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
}
@Test
@DisplayName("accepts a well-formed label set")
void acceptsValid() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
label("Contract"));
assertThatCode(() -> LabelsValidator.validate(set)).doesNotThrowAnyException();
}
@Test
@DisplayName("accepts an empty label set (reads as: use the default)")
void acceptsEmpty() {
assertThatCode(() -> LabelsValidator.validate(labels())).doesNotThrowAnyException();
}
@Test
@DisplayName("rejects a null label set")
void rejectsNull() {
assertThatThrownBy(() -> LabelsValidator.validate(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Labels are required");
}
@Test
@DisplayName("rejects duplicate names (distinct ids)")
void rejectsDuplicateNames() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice-a", "Invoice", null),
new ClassificationLabel("invoice-b", "Invoice", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate label name");
}
@Test
@DisplayName("rejects duplicate names differing only by case")
void rejectsDuplicateNamesCaseInsensitive() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice-a", "Invoice", null),
new ClassificationLabel("invoice-b", "INVOICE", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate label name");
}
@Test
@DisplayName("rejects duplicate ids")
void rejectsDuplicateIds() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice", "Invoice", null),
new ClassificationLabel("invoice", "Sales invoice", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate label id");
}
@Test
@DisplayName("rejects a blank name")
void rejectsBlankName() {
ClassificationLabels set = labels(new ClassificationLabel("blank", " ", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Label name must not be blank");
}
@Test
@DisplayName("rejects a blank id")
void rejectsBlankId() {
ClassificationLabels set = labels(new ClassificationLabel(" ", "Invoice", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Label id must not be blank");
}
@Test
@DisplayName("rejects an over-long name")
void rejectsOverLongName() {
ClassificationLabels set =
labels(
new ClassificationLabel(
"x", "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1), null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("too long");
}
@Test
@DisplayName("rejects an over-long icon (a null icon is fine)")
void rejectsOverLongIcon() {
ClassificationLabels set =
labels(
new ClassificationLabel(
"invoice",
"Invoice",
"x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1)));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("icon is too long");
}
@Test
@DisplayName("rejects more labels than the cap")
void rejectsTooManyLabels() {
List<ClassificationLabel> tooMany =
IntStream.rangeClosed(0, LabelsValidator.MAX_LABELS)
.mapToObj(i -> label("label" + i))
.toList();
assertThatThrownBy(() -> LabelsValidator.validate(new ClassificationLabels(tooMany)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Too many labels");
}
}
@@ -14,6 +14,7 @@ import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -26,8 +27,10 @@ import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.classification.ClassificationLabelProvider;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.PdfContentExtractor;
@@ -39,16 +42,22 @@ import tools.jackson.databind.json.JsonMapper;
@MockitoSettings(strictness = Strictness.LENIENT)
class ClassifyLabelControllerTest {
private static final Long TEAM = 7L;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private PdfMetadataService pdfMetadataService;
@Mock private AiEngineClient aiEngineClient;
@Mock private PolicyManagementAuthority policyManagementAuthority;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private InProcessClassificationLabelStore labelStore;
private ClassifyLabelController controller;
private void withLabels(List<ClassificationLabel> labels) {
@BeforeEach
void setUp() {
labelStore = new InProcessClassificationLabelStore();
controller =
new ClassifyLabelController(
pdfDocumentFactory,
@@ -57,8 +66,9 @@ class ClassifyLabelControllerTest {
pdfMetadataService,
aiEngineClient,
objectMapper,
ClassificationLabelProvider.withLabels(labels),
null);
null,
labelStore,
policyManagementAuthority);
}
private void stubSinglePageDocument() throws Exception {
@@ -88,7 +98,12 @@ class ClassifyLabelControllerTest {
@Test
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
labelStore.save(
TEAM,
new ClassificationLabels(
List.of(new ClassificationLabel("invoice", "Invoice", null))),
"admin");
stubSinglePageDocument();
@@ -103,12 +118,16 @@ class ClassifyLabelControllerTest {
}
@Test
void classifyAndLabel_sendsLabelIdsAndNames() throws Exception {
withLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
new ClassificationLabel("contract", "Contract", null),
new ClassificationLabel("timesheet", "Timesheet", null)));
void classifyAndLabel_sendsTeamLabelIdsAndNames() throws Exception {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
labelStore.save(
TEAM,
new ClassificationLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
new ClassificationLabel("contract", "Contract", null),
new ClassificationLabel("timesheet", "Timesheet", null))),
"admin");
stubSinglePageDocument();
@@ -133,13 +152,13 @@ class ClassifyLabelControllerTest {
}
@Test
void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception {
withLabels(List.of());
void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
stubSinglePageDocument();
// No vocabulary, and the engine holds no default of its own, so the file is passed through
// unlabelled: neither the engine nor the metadata write is invoked.
// No team labels stored, and the engine holds no default of its own, so the file is passed
// through unlabelled: neither the engine nor the metadata write is invoked.
verify(aiEngineClient, never()).post(anyString(), anyString(), any());
verify(pdfMetadataService, never())
.setClassificationMetadata(any(PDDocument.class), anyString());
@@ -1,10 +0,0 @@
-- Classification labels are now a fixed, built-in set bundled with the app and sent to the engine
-- per request (see ClassificationLabelProvider); the per-team classification_labels table (created
-- in V30) is no longer read or written. Drop it.
--
-- Forward migration: V30 is kept so any DB that already applied it still validates. This runs after
-- V30 in every case, so it drops the table whether V30 just created it (fresh DB) or it was created
-- and populated on an earlier deploy. IF EXISTS only guards the edge case where the table is already
-- absent, keeping the migration safe to apply regardless of prior state.
DROP TABLE IF EXISTS classification_labels;
@@ -116,8 +116,8 @@ class DocumentClassifierAgent:
)
async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
# The caller (the backend) always supplies the allowed vocabulary — its
# fixed built-in label set — so the engine holds no vocabulary of its own.
# The caller (the backend) always supplies the allowed vocabulary — the
# team's stored labels — so the engine holds no vocabulary of its own.
allowed = request.labels
window = select_window(request.pages)
prompt = self._build_prompt(request.file_name, allowed, window)
@@ -3708,6 +3708,8 @@ openFromComputer = "Open from computer"
openSettings = "Open settings"
other = "Other"
recent = "Recent"
search = "Search"
searchPlaceholder = "Search files..."
viewAll = "View all {{count}} files"
[fileSidebar.fileItem]
@@ -3747,6 +3749,7 @@ backToMyFiles = "Back to My Files"
breadcrumbs = "Folder path"
cancel = "Cancel"
classification = "Classification"
clearSearch = "Clear search"
clearSelection = "Clear selection"
closeDetails = "Close details"
create = "Create"
@@ -3810,6 +3813,8 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
save = "Save"
saveToServer = "Save to server"
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
search = "Search"
searchPlaceholder = "Search this folder & subfolders"
selectAll = "Select all"
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
selectedCount = "{{count}} selected"
@@ -3869,10 +3874,6 @@ title = "No cloud files yet"
hint = "Files saved without uploading stay here. Drop a file to add one."
title = "No local-only files"
[filesPage.empty.noResults]
hint = "No files in this folder match your filter. Try a different term or clear the filter."
title = "No matching files"
[filesPage.empty.recent]
hint = "Files you open or edit will appear here."
title = "Nothing modified yet"
@@ -3944,11 +3945,6 @@ localHint = "Only stored in this browser"
shared = "Shared"
sharedHint = "Shared with you via link"
[filesPage.search]
clear = "Clear filter"
label = "Filter files by name"
placeholder = "Filter files…"
[filesPage.sort]
modifiedAsc = "Oldest first"
modifiedDesc = "Recent first"
@@ -7895,6 +7891,15 @@ viewAll = "View all"
description = "Pipeline runs, deploys and agent events will appear here."
title = "Nothing here yet"
[portal.search]
ariaLabel = "Search"
placeholder = "Search Stirling — endpoints, pipelines, docs…"
[portal.search.empty]
noActionsDescription = "Quick actions will appear here once they're available."
noActionsTitle = "No quick actions"
noMatches = "No matches for \"{{query}}\""
noMatchesDescription = "Try a different keyword or browse the catalogue."
[portal.settings.groups]
admin = "Admin"
@@ -9717,17 +9722,6 @@ title = "Upload to Server"
updateButton = "Update on Server"
uploadButton = "Upload to Server"
[superSearch]
ariaLabel = "Super search"
hint = "Type to search"
placeholder = "Search Stirling"
[superSearch.group]
files = "Files"
processor = "Processor"
settings = "Settings"
tools = "Tools"
[survey]
title = "Stirling-PDF Survey"
@@ -10717,7 +10711,6 @@ exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
hideToolbar = "Hide toolbar"
multiTool = "Multi-Tool"
panMode = "Pan Mode"
print = "Print PDF"
@@ -10737,7 +10730,6 @@ selectAll = "Select All"
selectByNumber = "Select by Page Numbers"
selectLanguage = "Select language"
share = "Share"
showToolbar = "Show toolbar"
toggleAnnotations = "Toggle Annotations Visibility"
toggleAttachments = "Toggle Attachments"
toggleBookmarks = "Toggle Bookmarks"
@@ -3719,6 +3719,8 @@ openFromComputer = "Open from computer"
openSettings = "Open settings"
other = "Other"
recent = "Recent"
search = "Search"
searchPlaceholder = "Search files..."
viewAll = "View all {{count}} files"
[fileSidebar.fileItem]
@@ -3732,9 +3734,20 @@ uploadToServer = "Upload to server"
versionHistory = "Version history"
[fileSidebar.groupsModal]
add = "Add"
addLabel = "Add a label…"
categoryIconAria = "Choose an icon for {{name}}"
createCategory = "Create category"
delete = "Delete category"
done = "Done"
reset = "Show all"
subtitle = "Show or hide categories in the files sidebar."
hide = "Hide category"
newCategory = "New category name…"
removeLabel = "Remove {{name}}"
rename = "Rename"
reset = "Reset to defaults"
search = "Search labels…"
show = "Show category"
subtitle = "Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under “Other”."
title = "Sidebar categories"
[filesPage]
@@ -3747,6 +3760,7 @@ backToMyFiles = "Back to My Files"
breadcrumbs = "Folder path"
cancel = "Cancel"
classification = "Classification"
clearSearch = "Clear search"
clearSelection = "Clear selection"
closeDetails = "Close details"
create = "Create"
@@ -3810,6 +3824,8 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
save = "Save"
saveToServer = "Save to server"
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
search = "Search"
searchPlaceholder = "Search this folder & subfolders"
selectAll = "Select all"
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
selectedCount = "{{count}} selected"
@@ -3869,10 +3885,6 @@ title = "No cloud files yet"
hint = "Files saved without uploading stay here. Drop a file to add one."
title = "No local-only files"
[filesPage.empty.noResults]
hint = "No files in this folder match your filter. Try a different term or clear the filter."
title = "No matching files"
[filesPage.empty.recent]
hint = "Files you open or edit will appear here."
title = "Nothing modified yet"
@@ -3944,11 +3956,6 @@ localHint = "Only stored in this browser"
shared = "Shared"
sharedHint = "Shared with you via link"
[filesPage.search]
clear = "Clear filter"
label = "Filter files by name"
placeholder = "Filter files…"
[filesPage.sort]
modifiedAsc = "Oldest first"
modifiedDesc = "Recent first"
@@ -6007,11 +6014,31 @@ summaryMore = "{{first}}, {{second}} and {{more}} more"
summaryTwo = "{{first}} and {{second}}"
[policies.labels]
categoryCount = "categories"
hideCategory = "Hide category"
labelCount = "labels"
sharedNote = "These labels are built in and shared across your whole team."
showCategory = "Show category"
add = "Add"
addPlaceholder = "Add a label…"
customNote = "Customized for your team."
defaultNote = "Using the built-in default, shared with your team."
duplicate = "\"{{name}}\" already exists."
edit = "Edit labels"
empty = "No labels yet."
export = "Export JSON"
iconAria = "Choose an icon for {{name}}"
import = "Import JSON"
importError = "Couldn't import that file."
managedNote = "Team labels are managed by your team leader."
modalSubtitle = "Shared with your whole team. The classifier picks the labels that fit each document."
modalTitle = "Classification labels"
pickIcon = "Use {{label}} icon"
removeAria = "Remove {{name}}"
resetToDefault = "Reset to default"
saveForTeam = "Save for team"
saving = "Saving…"
sectionLabel = "Classification labels"
startFromScratch = "Start from scratch"
teamCount = "team labels"
tooLong = "Labels can be at most {{max}} characters."
ungrouped = "Ungrouped"
view = "View labels"
[policies.pii]
account = "Account numbers (labelled)"
@@ -7424,11 +7451,6 @@ title = "Policies"
[portal.policies.card]
comingSoon = "Upgrade to Enterprise"
notSetUp = "Not set up"
requiresAiEngine = "Requires AI engine"
[portal.policies.categories.classification]
desc = "Identify each document's type on upload and tag its metadata for filing and search."
label = "Classification"
[portal.policies.categories.compliance]
desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document."
@@ -7453,13 +7475,6 @@ label = "Security"
[portal.policies.config]
scopeAll = "All documents"
[portal.policies.config.classification]
summary = "Classifies every uploaded document against your team's labels and tags its metadata."
[portal.policies.config.classification.rules]
0 = "Classify"
1 = "Tag metadata"
[portal.policies.config.compliance]
summary = "Validates documents against regulatory frameworks before they leave the system."
@@ -7554,7 +7569,6 @@ title = "No activity yet"
[portal.policies.endpoints]
addWatermark = "Watermark"
autoRedact = "Redact PII"
classifyAndLabel = "Classify"
compressPdf = "Compress"
flatten = "Flatten"
ocrPdf = "OCR"
@@ -7597,10 +7611,6 @@ continue = "Continue"
enablePolicy = "Enable policy"
saveChanges = "Save changes"
[portal.policies.wizard.capability.classify]
desc = "Identifies the document's type from your team's labels and tags it, so it files and searches by category."
label = "Classify the document"
[portal.policies.wizard.capability.compress]
desc = "Compresses the document to a smaller file size."
label = "Reduce file size"
@@ -7625,10 +7635,6 @@ label = "Strip active content"
desc = "Stamps a visible mark (e.g. “Confidential”) across every page."
label = "Apply a watermark"
[portal.policies.wizard.classification]
description = "Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team."
labelsHeading = "Classification labels"
[portal.policies.wizard.errors]
noTools = "Enable at least one tool in the workflow first."
saveFailed = "Couldn't save the policy. Please try again."
@@ -7985,6 +7991,16 @@ viewAll = "View all"
description = "Pipeline runs, deploys and agent events will appear here."
title = "Nothing here yet"
[portal.search]
ariaLabel = "Search"
placeholder = "Search Stirling — endpoints, pipelines, docs…"
[portal.search.empty]
noActionsDescription = "Quick actions will appear here once they're available."
noActionsTitle = "No quick actions"
noMatches = "No matches for \"{{query}}\""
noMatchesDescription = "Try a different keyword or browse the catalogue."
[portal.settings.groups]
admin = "Admin"
@@ -9838,17 +9854,6 @@ title = "Upload to Server"
updateButton = "Update on Server"
uploadButton = "Upload to Server"
[superSearch]
ariaLabel = "Super search"
hint = "Type to search"
placeholder = "Search Stirling"
[superSearch.group]
files = "Files"
processor = "Processor"
settings = "Settings"
tools = "Tools"
[survey]
title = "Stirling-PDF Survey"
@@ -10407,6 +10412,9 @@ disableColorFilter = "Disable Color Filter"
dualPageView = "Dual Page View"
enableDarkFilter = "Enable Dark Filter"
enableSepiaFilter = "Enable Sepia Filter"
engineLoadErrorBody = "The PDF engine couldn't be loaded. If this keeps happening, make sure the app is up to date, then try again."
engineLoadErrorRetry = "Retry"
engineLoadErrorTitle = "Couldn't load the PDF viewer"
firstPage = "First Page"
lastPage = "Last Page"
moreOptions = "More"
@@ -10871,7 +10879,6 @@ exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
hideToolbar = "Hide toolbar"
multiTool = "Multi-Tool"
panMode = "Pan Mode"
print = "Print PDF"
@@ -10891,7 +10898,6 @@ selectAll = "Select All"
selectByNumber = "Select by Page Numbers"
selectLanguage = "Select language"
share = "Share"
showToolbar = "Show toolbar"
toggleAnnotations = "Toggle Annotations Visibility"
toggleAttachments = "Toggle Attachments"
toggleBookmarks = "Toggle Bookmarks"
@@ -16,7 +16,6 @@ import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutli
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import SearchIcon from "@mui/icons-material/Search";
import { FileId } from "@app/types/file";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
@@ -90,8 +89,6 @@ interface FileGridProps {
onChangeSortMode?: (mode: FilesPageSortMode) => void;
/** Drives the empty-state copy. */
currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
/** A filter is applied; an empty result then means "no matches", not "no files". */
searchActive?: boolean;
/** Cloud reachability; switches the cloud empty-state copy. */
serverReachable?: boolean;
/** Empty-state CTA handlers; if absent the matching button hides. */
@@ -107,7 +104,6 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
entries,
loading,
currentTab,
searchActive,
serverReachable,
onEmptyUpload,
onEmptyCreateFolder,
@@ -119,28 +115,15 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
}
if (entries.length === 0) {
const emptyState = (
return (
<EmptyState
tab={currentTab}
searchActive={searchActive}
serverReachable={serverReachable}
onUpload={onEmptyUpload}
onCreateFolder={onEmptyCreateFolder}
newFolderDisabledReason={newFolderDisabledReason}
/>
);
// When a filter empties the list view, keep the column headers in place and
// show the no-results message beneath them, rather than replacing the whole
// table. Grid view (cards, no headers) just shows the empty state.
if (viewMode === "list" && searchActive) {
return (
<>
<ListView {...props} />
{emptyState}
</>
);
}
return emptyState;
}
if (viewMode === "list") {
@@ -204,8 +187,6 @@ function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) {
interface EmptyStateProps {
/** Drives copy + iconography. */
tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
/** When true the empty list is the result of a filter, not a bare folder. */
searchActive?: boolean;
/** Switches the cloud empty-state copy. */
serverReachable?: boolean;
/** CTA handlers; absent => button hidden. */
@@ -217,35 +198,12 @@ interface EmptyStateProps {
function EmptyState({
tab = "all",
searchActive = false,
serverReachable = true,
onUpload,
onCreateFolder,
newFolderDisabledReason,
}: EmptyStateProps) {
const { t } = useTranslation();
// A filter with no matches isn't an empty folder - say so, and skip the
// upload / new-folder CTAs since clearing the filter is the way out.
if (searchActive) {
return (
<div className="files-page-empty">
<span className="files-page-empty-icon">
<SearchIcon style={{ fontSize: "2.5rem" }} />
</span>
<div className="files-page-empty-title">
{t("filesPage.empty.noResults.title", "No matching files")}
</div>
<div className="files-page-empty-hint">
{t(
"filesPage.empty.noResults.hint",
"No files in this folder match your filter. Try a different term or clear the filter.",
)}
</div>
</div>
);
}
const { titleKey, titleFallback, hintKey, hintFallback } = (() => {
switch (tab) {
case "local":
@@ -7,19 +7,11 @@ import React, {
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import {
Drawer,
Group,
MultiSelect,
Select,
TextInput,
Tooltip,
} from "@mantine/core";
import { Drawer, Group, MultiSelect, Select, Tooltip } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useMediaQuery } from "@mantine/hooks";
import CloseIcon from "@mui/icons-material/Close";
import SearchIcon from "@mui/icons-material/Search";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import QrCode2Icon from "@mui/icons-material/QrCode2";
@@ -59,7 +51,6 @@ import { StirlingFileStub } from "@app/types/fileContext";
import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
@@ -694,19 +685,13 @@ export default function FileManagerView() {
}, [navigate]);
// ─── keyboard shortcuts ─────────────────────────────────────────────────
// Focus the super-search input (stable id), used by the "/" shortcut and the
// FileSidebar rail Search button.
const focusSearch = useCallback(() => {
(
document.getElementById("super-search-input") as HTMLInputElement | null
)?.focus();
}, []);
const searchInputRef = useRef<HTMLInputElement | null>(null);
// External focus trigger (used by the FileSidebar rail Search button).
useEffect(() => {
window.addEventListener("files-page:focus-search", focusSearch);
return () =>
window.removeEventListener("files-page:focus-search", focusSearch);
}, [focusSearch]);
const onFocus = () => searchInputRef.current?.focus();
window.addEventListener("files-page:focus-search", onFocus);
return () => window.removeEventListener("files-page:focus-search", onFocus);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const active = document.activeElement as HTMLElement | null;
@@ -749,19 +734,13 @@ export default function FileManagerView() {
// "/" focuses the search field.
if (e.key === "/" && !inInput) {
e.preventDefault();
focusSearch();
searchInputRef.current?.focus();
return;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [
visibleFiles,
selectedFileIds,
removeFiles,
setSelectedFileIds,
focusSearch,
]);
}, [visibleFiles, selectedFileIds, removeFiles, setSelectedFileIds]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -910,7 +889,11 @@ export default function FileManagerView() {
};
return (
<>
<SuperSearch />
<SearchField
ref={searchInputRef}
value={search}
onChange={setSearch}
/>
<div className="files-page-header-actions">
<Tooltip
label={
@@ -1359,27 +1342,6 @@ export default function FileManagerView() {
aria-label={t("filesPage.typeFilter.label", "Filter by type")}
/>
)}
<TextInput
size="xs"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder={t("filesPage.search.placeholder", "Filter files…")}
leftSection={<SearchIcon sx={{ fontSize: "1rem" }} />}
rightSection={
search ? (
<ActionIcon
variant="tertiary"
size="sm"
onClick={() => setSearch("")}
aria-label={t("filesPage.search.clear", "Clear filter")}
>
<CloseIcon sx={{ fontSize: "0.9rem" }} />
</ActionIcon>
) : null
}
aria-label={t("filesPage.search.label", "Filter files by name")}
style={{ width: 180 }}
/>
<Select
size="xs"
value={sortMode}
@@ -1471,7 +1433,6 @@ export default function FileManagerView() {
entries={entries}
loading={loading}
currentTab={currentTab}
searchActive={search.trim().length > 0}
serverReachable={folders.serverReachable}
selectedFileIds={selectedFileIds}
activeWorkspaceFileIds={activeWorkspaceFileIdSet}
@@ -1694,6 +1655,39 @@ export default function FileManagerView() {
);
}
const SearchField = React.forwardRef<
HTMLInputElement,
{ value: string; onChange: (v: string) => void }
>(function SearchField({ value, onChange }, ref) {
const { t } = useTranslation();
return (
<div className="files-page-search">
<SearchIcon fontSize="small" style={{ color: "var(--text-muted)" }} />
<input
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={t(
"filesPage.searchPlaceholder",
"Search this folder & subfolders",
)}
aria-label={t("filesPage.search", "Search")}
/>
{value && (
<ActionIcon
variant="tertiary"
size="sm"
onClick={() => onChange("")}
aria-label={t("filesPage.clearSearch", "Clear search")}
>
&times;
</ActionIcon>
)}
</div>
);
});
function Breadcrumbs() {
const { t } = useTranslation();
const folders = useFolders();
@@ -1,40 +1,3 @@
/* Positioning context for the viewer toolbar's reopen tab, which hangs below
the bar and so must sit outside the overflow-clipped wrapper below. */
.workbenchBarShell {
position: relative;
flex-shrink: 0;
z-index: 51;
}
/* Little pull-tab shown while the viewer tool row is retracted. Anchored to the
bar's bottom-right edge (where the retract handle sat). Small by design - it
just brings the row back. */
.workbenchBarReopenTab {
position: absolute;
top: 100%;
right: 12px;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 0.875rem;
border: 1px solid var(--border-subtle);
border-top: none;
border-radius: 0 0 7px 7px;
background: var(--bg-toolbar);
color: var(--text-muted);
cursor: pointer;
z-index: 52;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.workbenchBarReopenTab:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
/* WorkbenchBar slide-in/out animation using CSS grid trick */
.workbenchBarWrapper {
display: grid;
@@ -1,8 +1,5 @@
import { useEffect, useState, Suspense, lazy } from "react";
import { useTranslation } from "react-i18next";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { Box, Loader, Center } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileState } from "@app/contexts/FileContext";
@@ -70,13 +67,10 @@ export default function Workbench() {
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addFiles } = useFileHandler();
const hasFiles = activeFiles.length > 0;
const { t } = useTranslation();
// The viewer's tool row can be retracted to give the document more height.
// State lives here (not in WorkbenchBar) so the reopen tab can hang below the
// bar, outside the bar's overflow-clipped wrapper. Scoped to the viewer.
const [viewerToolbarCollapsed, setViewerToolbarCollapsed] = useState(false);
const showReopenTab = currentView === "viewer" && viewerToolbarCollapsed;
// Custom workbench views (e.g. Watched Folders) manage their own content and may
// have no workbench files, but still need the bar's view switcher so users can
// navigate back out.
const isCustomViewActive = !isBaseWorkbench(currentView);
// Enable bar transitions after first paint so the initial hidden state shows
// without animating (landing page on load shouldn't animate the bar up).
@@ -230,38 +224,18 @@ export default function Workbench() {
{currentView !== "myFiles" &&
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<div className={styles.workbenchBarShell}>
<div
className={styles.workbenchBarWrapper}
data-hidden="false"
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
viewerToolbarCollapsed={viewerToolbarCollapsed}
onCollapseViewerToolbar={setViewerToolbarCollapsed}
/>
</div>
</div>
{/* Reopen tab: a little handle hanging off the bar's bottom-right
while the viewer tool row is retracted. */}
{showReopenTab && (
<Button
type="button"
variant="quiet"
className={styles.workbenchBarReopenTab}
onClick={() => setViewerToolbarCollapsed(false)}
aria-expanded={false}
aria-label={t("workbenchBar.showToolbar", "Show toolbar")}
title={t("workbenchBar.showToolbar", "Show toolbar")}
leftSection={
<KeyboardArrowDownIcon sx={{ fontSize: "1rem" }} />
}
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles && !isCustomViewActive)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
/>
)}
</div>
</div>
)}
@@ -1,36 +0,0 @@
// Shared source of truth for a policy category's outline icon, keyed by category
// id (not a parallel icon-name vocabulary). Used by the editor's policy
// definitions and the portal's catalogue cards, summaries, and setup wizard.
import type { ReactNode } from "react";
import type { SxProps, Theme } from "@mui/material";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
type MuiIcon = React.ComponentType<{ sx?: SxProps<Theme>; className?: string }>;
/** Policy category id → outline glyph. */
const POLICY_CATEGORY_ICONS: Record<string, MuiIcon> = {
ingestion: LayersOutlinedIcon,
security: ShieldOutlinedIcon,
classification: LabelOutlinedIcon,
compliance: CheckCircleOutlinedIcon,
routing: AltRouteOutlinedIcon,
retention: ScheduleOutlinedIcon,
};
const FALLBACK_ICON = LabelOutlinedIcon;
// Defaults to inheriting the surrounding font-size so a wrapping box controls size.
export function policyCategoryIcon(
categoryId: string,
sx: SxProps<Theme> = { fontSize: "inherit" },
className?: string,
): ReactNode {
const Icon = POLICY_CATEGORY_ICONS[categoryId] ?? FALLBACK_ICON;
return <Icon sx={sx} className={className} />;
}
@@ -1,28 +1,4 @@
/* AppConfigModal styles */
/* Deep-link highlight: pulses the control the super search jumped to
(navigated via /settings/{section}?focus={anchor}). */
@keyframes settings-focus-pulse {
0% {
box-shadow: 0 0 0 3px var(--mantine-color-blue-5);
background: color-mix(
in srgb,
var(--mantine-color-blue-5) 16%,
transparent
);
}
100% {
box-shadow: 0 0 0 6px transparent;
background: transparent;
}
}
.settings-focus-target {
animation: settings-focus-pulse 1.8s ease-out;
border-radius: 8px;
scroll-margin: 1rem;
}
.modal-container {
display: flex;
gap: 0;
@@ -146,33 +146,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
[navigate, urlSync],
);
// Deep-link: /settings/{section}?focus={anchor} scrolls to and briefly
// highlights the matching control (used by the global super search to jump
// straight to an individual setting row).
useEffect(() => {
if (!opened) return;
const focus = new URLSearchParams(location.search).get("focus");
if (!focus) return;
let raf = 0;
// Wait for the (possibly just-switched) section to render before scrolling.
const timer = window.setTimeout(() => {
raf = window.requestAnimationFrame(() => {
const el = document.getElementById(focus);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("settings-focus-target");
window.setTimeout(
() => el.classList.remove("settings-focus-target"),
1800,
);
});
}, 150);
return () => {
window.clearTimeout(timer);
if (raf) window.cancelAnimationFrame(raf);
};
}, [opened, active, location.search]);
// Backwards-compat: external `appConfig:navigate` events route through the
// same switchSection path so they get the no-flash treatment too.
useEffect(() => {
@@ -33,9 +33,11 @@ import { Wordmark } from "@app/components/shared/Wordmark";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import type { StirlingFileStub } from "@app/types/fileContext";
import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from "@mui/icons-material/Search";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SettingsIcon from "@mui/icons-material/Settings";
@@ -99,6 +101,8 @@ export interface FileSidebarProps {
onUploadFiles?: (files: File[]) => void | Promise<void>;
/** Override the Google Drive handler. */
onPickGoogleDriveFiles?: (files: File[]) => void | Promise<void>;
/** Override the Search row click (e.g. focus the /files search input). */
onSearchClick?: () => void;
/** Extra action row inserted under Open-from-computer (e.g. New folder). */
extraAction?: {
icon: React.ReactNode;
@@ -152,6 +156,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
toggleIcon,
onUploadFiles,
onPickGoogleDriveFiles,
onSearchClick,
extraAction,
},
ref,
@@ -162,6 +167,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
// Classification off (non-SaaS / AI-off) → never show the per-row label chip,
// even if a stub carries labels from an imported PDF; keeps the row plain.
const classificationEnabled = useClassificationEnabled();
const [searchActive, setSearchActive] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const nativeFileInputRef = useRef<HTMLInputElement>(null);
// State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates.
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
@@ -430,9 +438,14 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
}
}, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]);
// File filtering now lives in the global super search (top bar); the
// sidebar simply lists every stored file.
const filteredFileStubs = allFileStubs;
// Memoized so an unrelated re-render (e.g. a policy-run store tick) keeps a
// stable array identity — avoids re-running the grouping memo + backfill effect.
const filteredFileStubs = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
return q
? allFileStubs.filter((stub) => stub.name.toLowerCase().includes(q))
: allFileStubs;
}, [allFileStubs, searchQuery]);
// SaaS groups by classification label; core returns null → one flat, recency-sorted list.
const fileGroups = useFileSidebarGroups(filteredFileStubs);
@@ -459,6 +472,29 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
[],
);
// Handle search activation
const handleSearchClick = useCallback(() => {
if (onSearchClick) {
onSearchClick();
return;
}
if (collapsed && onToggleCollapse) {
onToggleCollapse();
}
setSearchActive(true);
}, [collapsed, onToggleCollapse, onSearchClick]);
const handleSearchClose = useCallback(() => {
setSearchActive(false);
setSearchQuery("");
}, []);
useEffect(() => {
if (searchActive && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchActive]);
// Handle Google Drive
const handleGoogleDriveClick = useCallback(async () => {
if (!isGoogleDriveEnabled) return;
@@ -849,6 +885,56 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</div>
</Tooltip>
{/* Search row */}
<Tooltip
label={t("fileSidebar.search", "Search")}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
onClick={!searchActive ? handleSearchClick : undefined}
role={!searchActive ? "button" : undefined}
tabIndex={!searchActive ? 0 : undefined}
onKeyDown={
!searchActive
? (e) => e.key === "Enter" && handleSearchClick()
: undefined
}
>
{searchActive && !collapsed ? (
<CloseIcon
className="file-sidebar-search-icon"
onClick={(e) => {
e.stopPropagation();
handleSearchClose();
}}
/>
) : (
<SearchIcon className="file-sidebar-search-icon" />
)}
{!collapsed &&
(searchActive ? (
<input
ref={searchInputRef}
className="file-sidebar-search-input sidebar-content-fade"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t(
"fileSidebar.searchPlaceholder",
"Search files...",
)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="file-sidebar-search-label sidebar-content-fade">
{t("fileSidebar.search", "Search")}
</span>
))}
</div>
</Tooltip>
{/* Scrollable content */}
<div className="file-sidebar-scroll">
{/* Hidden native file input - kept outside the !collapsed gate so
@@ -1196,14 +1282,16 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
)}
</div>
) : (
<div className="file-sidebar-empty">
<p className="file-sidebar-empty-text">
{t("fileSidebar.noFiles", "No files yet")}
</p>
<p className="file-sidebar-empty-hint">
{t("fileSidebar.dropHint", "Open files to get started")}
</p>
</div>
!searchActive && (
<div className="file-sidebar-empty">
<p className="file-sidebar-empty-text">
{t("fileSidebar.noFiles", "No files yet")}
</p>
<p className="file-sidebar-empty-hint">
{t("fileSidebar.dropHint", "Open files to get started")}
</p>
</div>
)
)}
</div>
)}
@@ -2,17 +2,11 @@
/* Horizontal toolbar at the top of the workbench area. */
.workbench-bar {
/* Flex-wrap layout that reflows by content width (measured in JS, which sets
data-wrapped). Roomy: row 1 is [views | search | globals] and the tool
icons sit on their own full-width row below. Crowded (data-wrapped): the
search bumps to its own row, leaving [views | globals] on row 1 and the
tools on row 3 — three rows total. `order` drives the reflow. */
display: flex;
flex-wrap: wrap;
align-items: center;
align-content: flex-start;
column-gap: 8px;
min-height: 48px;
min-height: 40px;
padding: 0 8px;
background-color: var(--bg-toolbar);
border-bottom: 1px solid var(--border-subtle);
@@ -22,16 +16,12 @@
/* ---- View switcher (left) ---- */
.workbench-bar-views {
order: 1;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
/* min-width:0 lets it shrink instead of forcing a wrap; overflow clips labels
on very narrow viewports. */
min-width: 0;
overflow: hidden;
height: 48px;
flex-shrink: 0;
order: 1;
height: 40px;
z-index: 0;
}
@@ -95,88 +85,53 @@
height: 16px;
}
/* ---- Super search ---- */
/* Roomy: grows to fill the centre between views and globals. */
.workbench-bar-search {
/* ---- Center: tool buttons ---- */
/* Single-row: center sits between views and globals */
.workbench-bar-center {
order: 2;
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
height: 48px;
gap: 2px;
height: 40px;
overflow: hidden;
}
/* Crowded: drops to its own full-width row between the top row and the tools. */
.workbench-bar[data-wrapped="true"] .workbench-bar-search {
/* Two-row: center drops below views+globals */
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
order: 3;
flex: 0 0 100%;
height: auto;
padding: 4px 0;
}
/* ---- Tool buttons (own full-width row, always below the top row) ---- */
.workbench-bar-center {
order: 4;
flex: 0 0 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 2px;
/* Symmetric side padding leaves room for the retract handle pinned right
without knocking the centred tool icons off-centre. */
padding: 4px 36px;
border-top: 1px solid var(--border-subtle);
justify-content: flex-start;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
/* Retract / reopen handle for the viewer tool row. */
.workbench-bar-toolbar-handle {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
padding: 0;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.workbench-bar-toolbar-handle:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
/* Pinned to the right edge of the tool row. */
.workbench-bar-toolbar-handle-retract {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
}
/* ---- Right: global buttons (print / download / save / close), row 1 ---- */
.workbench-bar-globals {
order: 3;
.workbench-bar[data-wrapped="true"] .workbench-bar-center > * {
flex-shrink: 0;
}
/* ---- Right: global buttons (theme / language / download) ---- */
.workbench-bar-globals {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
height: 48px;
flex-shrink: 0;
order: 3;
height: 40px;
margin-left: auto;
}
/* Crowded: globals stays on row 1, moving up beside the views. */
/* In two-row mode globals moves to row 1 right side */
.workbench-bar[data-wrapped="true"] .workbench-bar-globals {
order: 2;
margin-left: auto;
}
/* Shared action icon style - applies to both center and global buttons.
@@ -32,7 +32,6 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import ViewerShareButton from "@app/components/viewer/ViewerShareButton";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
@@ -53,7 +52,6 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import CloseIcon from "@mui/icons-material/Close";
import PrintIcon from "@mui/icons-material/Print";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import "@app/components/shared/WorkbenchBar.css";
@@ -69,10 +67,6 @@ interface WorkbenchBarProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
hasFiles: boolean;
/** Whether the viewer's tool row is currently retracted. */
viewerToolbarCollapsed?: boolean;
/** Setter for the viewer tool-row retract state (owned by Workbench). */
onCollapseViewerToolbar?: (collapsed: boolean) => void;
}
function renderWithTooltip(
@@ -97,8 +91,6 @@ export default function WorkbenchBar({
currentView,
setCurrentView,
hasFiles,
viewerToolbarCollapsed = false,
onCollapseViewerToolbar,
}: WorkbenchBarProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -122,7 +114,6 @@ export default function WorkbenchBar({
} = useToolWorkflow();
const { selectedTool } = useNavigationState();
const isCustomView = !isBaseWorkbench(currentView);
const isViewer = currentView === "viewer";
const disableForFullscreen =
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const terminology = useFileActionTerminology();
@@ -443,31 +434,34 @@ export default function WorkbenchBar({
})),
];
// Reflow the top row by content width: when the views + globals leave too
// little room for a usable search, bump the search to its own row
const barRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const bar = barRef.current;
if (!bar) return;
const MIN_SEARCH_WIDTH = 320;
const measure = () => {
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
const globalsEl = bar.querySelector<HTMLElement>(
".workbench-bar-globals",
);
const centerEl = bar.querySelector<HTMLElement>(".workbench-bar-center");
const viewsWidth = viewsEl?.offsetWidth ?? 0;
const globalsWidth = globalsEl?.offsetWidth ?? 0;
// clientWidth minus the two side clusters, the bar's 16px h-padding and
// the two 8px column gaps flanking the search.
const available = bar.clientWidth - viewsWidth - globalsWidth - 16 - 16;
bar.dataset.wrapped = String(available < MIN_SEARCH_WIDTH);
const centerChildren = centerEl
? (Array.from(centerEl.children) as HTMLElement[])
: [];
const centerWidth =
centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) +
Math.max(0, centerChildren.length - 1) * 2; // gap: 2px
const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding
bar.dataset.wrapped = String(needed > bar.clientWidth);
};
const ro = new ResizeObserver(measure);
ro.observe(bar);
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
const globalsEl = bar.querySelector<HTMLElement>(".workbench-bar-globals");
if (viewsEl) ro.observe(viewsEl);
if (globalsEl) ro.observe(globalsEl);
measure();
return () => ro.disconnect();
}, []);
@@ -529,50 +523,27 @@ export default function WorkbenchBar({
)}
</div>
{/* Global super search - always present, even on the homepage */}
<div className="workbench-bar-search">
<SuperSearch />
</div>
{/* Tool buttons - second row, only rendered when buttons exist. In the
viewer the row is retractable: a handle on its right edge hides the
whole row; Workbench then shows a tab below the bar to bring it back. */}
{sectionsWithButtons.length > 0 &&
!(isViewer && viewerToolbarCollapsed) && (
<div className="workbench-bar-center">
{sectionsWithButtons.map(
({ section, buttons: sectionButtons }, idx) => (
<React.Fragment key={section}>
{idx > 0 && <div className="workbench-bar-divider" />}
{sectionButtons.map((btn) => {
const content = renderButton(btn);
if (!content) return null;
return (
<div
key={btn.id}
className="workbench-bar-action-wrapper"
>
{content}
</div>
);
})}
</React.Fragment>
),
)}
{isViewer && onCollapseViewerToolbar && (
<Button
type="button"
variant="quiet"
className="workbench-bar-toolbar-handle workbench-bar-toolbar-handle-retract"
onClick={() => onCollapseViewerToolbar(true)}
aria-expanded
aria-label={t("workbenchBar.hideToolbar", "Hide toolbar")}
title={t("workbenchBar.hideToolbar", "Hide toolbar")}
leftSection={<KeyboardArrowUpIcon sx={{ fontSize: "1rem" }} />}
/>
)}
</div>
)}
{/* Tool buttons - second row, only rendered when buttons exist */}
{sectionsWithButtons.length > 0 && (
<div className="workbench-bar-center">
{sectionsWithButtons.map(
({ section, buttons: sectionButtons }, idx) => (
<React.Fragment key={section}>
{idx > 0 && <div className="workbench-bar-divider" />}
{sectionButtons.map((btn) => {
const content = renderButton(btn);
if (!content) return null;
return (
<div key={btn.id} className="workbench-bar-action-wrapper">
{content}
</div>
);
})}
</React.Fragment>
),
)}
</div>
)}
{/* Right: Global buttons - export group left, close anchored right */}
<div className="workbench-bar-globals">
@@ -4,10 +4,6 @@ import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import {
buildMatchSnippet,
getSettingsSectionContent,
} from "@app/data/settingsContentSearch";
import type {
ConfigNavSection,
ConfigNavItem,
@@ -28,6 +24,102 @@ interface SettingsSearchOption {
matchedContext?: string;
}
const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
{
general: ["settings.general"],
hotkeys: ["settings.hotkeys"],
account: ["account"],
people: ["settings.workspace"],
teams: ["settings.workspace", "settings.team"],
"api-keys": ["settings.developer"],
connectionMode: ["settings.connection"],
planBilling: ["settings.planBilling"],
adminGeneral: ["admin.settings.general"],
adminFeatures: ["admin.settings.features"],
adminEndpoints: ["admin.settings.endpoints"],
adminDatabase: ["admin.settings.database"],
adminAdvanced: ["admin.settings.advanced"],
adminSecurity: ["admin.settings.security"],
adminMcp: ["admin.settings.mcp"],
adminConnections: [
"admin.settings.connections",
"admin.settings.mail",
"admin.settings.security",
"admin.settings.telegram",
"admin.settings.premium",
"admin.settings.general",
"settings.securityAuth",
"settings.connection",
],
adminPlan: [
"settings.planBilling",
"admin.settings.premium",
"settings.licensingAnalytics",
],
adminAudit: ["settings.licensingAnalytics"],
adminUsage: ["settings.licensingAnalytics"],
adminLegal: ["admin.settings.legal"],
adminPrivacy: ["admin.settings.privacy"],
};
const getTranslationPrefixesForNavKey = (key: string): string[] => {
const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? [];
const inferredPrefixes: string[] = [];
if (key.startsWith("admin")) {
const adminSuffix = key.replace(/^admin/, "");
const normalizedAdminSuffix =
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
} else {
inferredPrefixes.push(`settings.${key}`);
}
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
};
const flattenTranslationStrings = (value: unknown): string[] => {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (Array.isArray(value)) {
return value.flatMap(flattenTranslationStrings);
}
if (value && typeof value === "object") {
return Object.values(value as Record<string, unknown>).flatMap(
flattenTranslationStrings,
);
}
return [];
};
const buildMatchSnippet = (text: string, query: string): string => {
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase();
const matchIndex = normalizedText.indexOf(normalizedQuery);
if (matchIndex === -1) {
return text;
}
const maxLength = 84;
const contextPadding = 28;
const start = Math.max(0, matchIndex - contextPadding);
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
const snippet = text.slice(start, end);
if (snippet.length <= maxLength) {
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
}
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
};
export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
configNavSections,
onNavigate,
@@ -43,12 +135,19 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
section.items
.filter((item: ConfigNavItem) => !item.disabled)
.map((item: ConfigNavItem) => {
const translationPrefixes = getTranslationPrefixesForNavKey(item.key);
const translationContent = translationPrefixes.flatMap((prefix) =>
flattenTranslationStrings(
t(prefix, { returnObjects: true, defaultValue: {} } as any),
),
);
const searchableContent = Array.from(
new Set([
item.label,
section.title,
`/settings/${item.key}`,
...getSettingsSectionContent(item.key, t),
...translationContent,
]),
);
@@ -469,7 +469,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div
id="setting-theme"
style={{
display: "flex",
alignItems: "center",
@@ -507,7 +506,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</div>
<div
id="setting-language"
style={{
display: "flex",
alignItems: "center",
@@ -533,7 +531,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div
id="setting-tool-picker-mode"
style={{
display: "flex",
alignItems: "center",
@@ -572,7 +569,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</div>
<div
id="setting-startup-view"
style={{
display: "flex",
alignItems: "center",
@@ -615,7 +611,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</div>
<div
id="setting-reader-zoom"
style={{
display: "flex",
alignItems: "center",
@@ -671,7 +666,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</div>
<div
id="setting-hide-unavailable-tools"
style={{
display: "flex",
alignItems: "center",
@@ -703,7 +697,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</div>
<div
id="setting-hide-unavailable-conversions"
style={{
display: "flex",
alignItems: "center",
@@ -744,7 +737,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
withArrow
>
<div
id="setting-auto-unzip"
style={{
display: "flex",
alignItems: "center",
@@ -782,7 +774,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
withArrow
>
<div
id="setting-auto-unzip-file-limit"
style={{
display: "flex",
alignItems: "center",
@@ -146,7 +146,7 @@ const HotkeysSection: React.FC = () => {
return (
<Stack gap="lg">
<div id="setting-hotkeys-search">
<div>
<Text fw={600} size="lg">
{t("settings.hotkeys.title", "Keyboard Shortcuts")}
</Text>
@@ -1,118 +0,0 @@
.super-search {
position: relative;
flex: 1 1 auto;
min-width: 0;
max-width: 560px;
}
.super-search input {
box-sizing: border-box;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background-color: var(--bg-muted);
/* Tighter than the shared TextInput default (8px/14px) for a compact,
professional bar — height lands in line with the 28px action icons. */
padding-top: 6px;
padding-bottom: 6px;
font-size: 13px;
}
[data-mantine-color-scheme="dark"] .super-search input {
background-color: var(--input-bg);
}
.super-search input:focus {
border-color: var(--accent-interactive);
}
/* Keyboard-shortcut hint pinned to the right of the input. */
.super-search-kbd {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 0.7rem;
line-height: 1;
padding: 0.15rem 0.35rem;
border-radius: 4px;
color: var(--text-muted);
background: var(--hover-bg);
border: 1px solid var(--border-subtle);
/* Tiny breathing room between the ⌘ glyph and the K. */
letter-spacing: 0.06em;
}
/* Portalled to <body>; coords are set inline from the input's viewport rect. */
.super-search-dropdown {
position: fixed;
z-index: 1000;
max-height: 60vh;
overflow-y: auto;
padding: 0.35rem;
background: var(--bg-toolbar);
border: 1px solid var(--border-subtle);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.super-search-empty {
padding: 0.75rem 0.6rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.super-search-group + .super-search-group {
margin-top: 0.25rem;
}
.super-search-group-label {
padding: 0.35rem 0.5rem 0.2rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.super-search-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.45rem 0.5rem;
border: none;
border-radius: 7px;
background: transparent;
text-align: left;
cursor: pointer;
color: var(--text-primary);
}
.super-search-item.active,
.super-search-item:hover {
background: var(--hover-bg);
}
.super-search-item-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.4rem;
height: 1.4rem;
color: var(--text-secondary);
}
.super-search-item-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.super-search-item-title {
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -1,265 +0,0 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { TextInput } from "@app/components/shared/TextInput";
import LocalIcon from "@app/components/shared/LocalIcon";
import { isMacLike } from "@app/utils/hotkeys";
import {
useSuperSearch,
SuperSearchResult,
type UseSuperSearchResult,
} from "@app/hooks/useSuperSearch";
import "@app/components/shared/superSearch/SuperSearch.css";
interface DropdownRect {
top: number;
left: number;
width: number;
}
interface SuperSearchProps {
/**
* Results provider, called as a hook — it MUST be referentially stable for
* the component's lifetime. Defaults to the editor's files/tools/settings/
* Processor provider; the portal passes its own destinations provider.
*/
useResults?: (query: string, active: boolean) => UseSuperSearchResult;
}
/**
* Global "super search": a single entry point that searches across the host
* app's destinations from a persistent bar. The results hang in a dropdown
* directly below the input; Cmd/Ctrl+K focuses and opens it.
*
* The dropdown is portalled to <body>: the host bar's inner wrapper may set
* `overflow: hidden`, which would otherwise clip it.
*/
export default function SuperSearch({
useResults = useSuperSearch,
}: SuperSearchProps = {}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(0);
const [rect, setRect] = useState<DropdownRect | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const { groups, flatResults, loadingFiles } = useResults(query, open);
const trimmed = query.trim();
const hasQuery = trimmed.length > 0;
// Keep the highlighted index within bounds as results change.
useEffect(() => {
setHighlight((h) =>
flatResults.length === 0 ? 0 : Math.min(h, flatResults.length - 1),
);
}, [flatResults.length]);
const close = useCallback(() => {
setOpen(false);
setHighlight(0);
}, []);
// Position the portalled dropdown directly under the input while open.
const updateRect = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setRect({ top: r.bottom + 6, left: r.left, width: r.width });
}, []);
useLayoutEffect(() => {
if (!open) return;
updateRect();
window.addEventListener("resize", updateRect);
window.addEventListener("scroll", updateRect, true);
return () => {
window.removeEventListener("resize", updateRect);
window.removeEventListener("scroll", updateRect, true);
};
}, [open, updateRect]);
const selectResult = useCallback(
(result: SuperSearchResult | undefined) => {
if (!result) return;
void result.onSelect();
setQuery("");
close();
inputRef.current?.blur();
},
[close],
);
// Global Cmd/Ctrl+K to focus + open the search.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const combo =
(isMacLike() ? e.metaKey : e.ctrlKey) && !e.altKey && !e.shiftKey;
if (combo && (e.key === "k" || e.key === "K")) {
e.preventDefault();
setOpen(true);
inputRef.current?.focus();
inputRef.current?.select();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// Close on click outside the input or the (portalled) dropdown.
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if (
containerRef.current?.contains(target) ||
dropdownRef.current?.contains(target)
) {
return;
}
close();
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open, close]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) setOpen(true);
setHighlight((h) =>
flatResults.length === 0 ? 0 : (h + 1) % flatResults.length,
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) =>
flatResults.length === 0
? 0
: (h - 1 + flatResults.length) % flatResults.length,
);
} else if (e.key === "Enter") {
e.preventDefault();
selectResult(flatResults[highlight]);
} else if (e.key === "Escape") {
e.preventDefault();
if (hasQuery) {
setQuery("");
} else {
close();
inputRef.current?.blur();
}
}
};
const shortcutHint = useMemo(() => (isMacLike() ? "⌘K" : "Ctrl+K"), []);
const showNoResults =
open && hasQuery && !loadingFiles && flatResults.length === 0;
const dropdown =
open && rect ? (
<div
ref={dropdownRef}
className="super-search-dropdown"
role="listbox"
style={{ top: rect.top, left: rect.left, width: rect.width }}
// Keep focus on the input when clicking inside the dropdown.
onMouseDown={(e) => e.preventDefault()}
>
{!hasQuery && (
<div className="super-search-empty">
{t("superSearch.hint", "Type to search")}
</div>
)}
{showNoResults && (
<div className="super-search-empty">
{t("search.noResults", "No results found")}
</div>
)}
{hasQuery &&
groups.map((group) => (
<div key={group.id} className="super-search-group">
<div className="super-search-group-label">{group.label}</div>
{group.results.map((result) => {
const index = flatResults.indexOf(result);
const active = index === highlight;
return (
<Button
key={result.key}
type="button"
variant="quiet"
justify="start"
fullWidth
role="option"
aria-selected={active}
className={`super-search-item${active ? " active" : ""}`}
onMouseEnter={() => setHighlight(index)}
onClick={() => selectResult(result)}
>
<span className="super-search-item-icon">
{result.icon ?? (
<LocalIcon
icon={result.iconName ?? "search-rounded"}
width="1.1rem"
height="1.1rem"
/>
)}
</span>
<span className="super-search-item-text">
<span className="super-search-item-title">
{result.title}
</span>
{result.subtitle && (
<Text size="xs" c="dimmed" lineClamp={1}>
{result.subtitle}
</Text>
)}
</span>
</Button>
);
})}
</div>
))}
</div>
) : null;
return (
<div className="super-search" ref={containerRef} onKeyDown={handleKeyDown}>
<TextInput
id="super-search-input"
name="super-search-input"
ref={inputRef}
value={query}
onChange={setQuery}
placeholder={t("superSearch.placeholder", "Search Stirling")}
icon={
<LocalIcon icon="search-rounded" width="1.1rem" height="1.1rem" />
}
autoComplete="off"
aria-label={t("superSearch.ariaLabel", "Super search")}
onFocus={() => setOpen(true)}
/>
{!open && !hasQuery && (
<kbd className="super-search-kbd" aria-hidden="true">
{shortcutHint}
</kbd>
)}
{dropdown && createPortal(dropdown, document.body)}
</div>
);
}
@@ -4,6 +4,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import ToolPanel from "@app/components/tools/ToolPanel";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { usePoliciesEnabled } from "@app/components/policies/usePoliciesEnabled";
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
@@ -42,6 +43,7 @@ export default function RightSidebar() {
const {
leftPanelView,
isPanelVisible,
searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
@@ -94,6 +96,7 @@ 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;
const showHeaderSearch = showCloseButton || leftPanelView === "toolPicker";
const handleHeaderBack = () => {
if (inToolView) {
@@ -107,17 +110,25 @@ export default function RightSidebar() {
withViewTransition(() => handleToolSelect(id));
};
// Typing in the header search while inside a tool exits the tool and lifts the
// panel into the all-tools view so the user immediately sees search results.
const handleHeaderSearchChange = (value: string) => {
if (inToolView) {
withViewTransition(() => {
handleBackToTools();
setAllToolsView(true);
setSearchQuery(value);
});
return;
}
setSearchQuery(value);
};
const activeTool: ToolRegistryEntry | null =
inToolView && selectedToolKey
? (toolRegistry[selectedToolKey as ToolId] ?? null)
: null;
// Tool search now lives in the global super search, so the panel header
// carries only the active-tool pill or the collapse/close control. When it's
// just the collapse chevron, float it into the corner (see CSS) so the tool
// list starts at the top instead of below an empty band.
const headerCollapseOnly = !activeTool && !showCloseButton;
const expandedWidth = "18.5rem";
const computedWidth = () => {
@@ -223,7 +234,6 @@ export default function RightSidebar() {
flexShrink: 0,
display: "flex",
flexDirection: "column",
position: "relative",
}}
>
<>
@@ -245,13 +255,18 @@ export default function RightSidebar() {
}
/>
) : (
<div
className={`tool-panel__compact-header${
headerCollapseOnly
? " tool-panel__compact-header--collapse-only"
: ""
}`}
>
<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="tertiary"
@@ -266,18 +266,6 @@
box-sizing: border-box;
}
/* When the header holds only the collapse chevron (no active tool, no search),
float it into the corner instead of reserving an empty band, so the tool list
starts at the top of the panel. */
.tool-panel__compact-header--collapse-only {
position: absolute;
top: 0;
right: 0;
min-height: 0;
padding: 1rem 0.5rem 0.5rem;
z-index: 5;
}
.tool-panel__compact-header .tool-panel__expand-btn {
margin-left: auto;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { ReactNode } from "react";
// Controllable stand-in for the library engine hook so we can simulate the
// three states that matter: still loading, loaded, and errored.
let engineState: { engine: unknown; isLoading: boolean; error: Error | null } =
{
engine: null,
isLoading: true,
error: null,
};
vi.mock("@embedpdf/engines/react", () => ({
usePdfiumEngine: () => engineState,
}));
import { PdfEngineBoundary } from "@app/components/viewer/PdfEngineBoundary";
const renderBoundary = (onRetry = vi.fn(), timeoutMs = 1000) => {
const utils = render(
<MantineProvider>
<PdfEngineBoundary
wasmUrl="pdfium.wasm"
onRetry={onRetry}
timeoutMs={timeoutMs}
>
{() => <div>PDF CONTENT</div>}
</PdfEngineBoundary>
</MantineProvider>,
);
return { onRetry, ...utils };
};
const wrap = (node: ReactNode) => <MantineProvider>{node}</MantineProvider>;
describe("PdfEngineBoundary", () => {
beforeEach(() => {
engineState = { engine: null, isLoading: true, error: null };
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("shows the loading fallback while the engine is initialising", () => {
renderBoundary();
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
expect(screen.queryByText("viewer.engineLoadErrorTitle")).toBeNull();
});
it("surfaces an error with a retry after the load times out (no infinite spinner)", () => {
const { onRetry } = renderBoundary(vi.fn(), 1000);
// Still spinning before the timeout elapses.
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(1000);
});
// The spinner is replaced by an actionable error state.
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
const retry = screen.getByText("viewer.engineLoadErrorRetry");
fireEvent.click(retry);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it("surfaces an error immediately when the engine load rejects", () => {
engineState = {
engine: null,
isLoading: false,
error: new Error("boom"),
};
render(
wrap(
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
{() => <div>PDF CONTENT</div>}
</PdfEngineBoundary>,
),
);
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
});
it("renders children once the engine is ready", () => {
engineState = { engine: {}, isLoading: false, error: null };
render(
wrap(
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
{() => <div>PDF CONTENT</div>}
</PdfEngineBoundary>,
),
);
expect(screen.getByText("PDF CONTENT")).toBeInTheDocument();
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
});
});
@@ -0,0 +1,82 @@
import React, { useEffect, useState } from "react";
import { usePdfiumEngine } from "@embedpdf/engines/react";
import { Center, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback";
/** Engine instance produced by usePdfiumEngine once initialisation succeeds. */
type PdfiumEngine = NonNullable<ReturnType<typeof usePdfiumEngine>["engine"]>;
/**
* How long to wait for the PDFium WASM engine to initialise before treating the
* load as failed. The engine normally initialises in well under a second; if it
* has not resolved after this window it has almost certainly hung (e.g. the WASM
* fetch stalled or the worker never reported back), so we surface an error with
* a retry instead of spinning forever.
*/
const DEFAULT_ENGINE_LOAD_TIMEOUT_MS = 30_000;
interface PdfEngineBoundaryProps {
/** Absolute URL of the pdfium.wasm binary to load. */
wasmUrl: string;
/** Invoked when the user asks to retry after a failed/hung load. */
onRetry: () => void;
/** Override the load timeout (mainly for tests). */
timeoutMs?: number;
/** Rendered once the engine is ready. */
children: (engine: PdfiumEngine) => React.ReactNode;
}
/**
* Loads the PDFium WASM engine and gates its children on success.
*
* The underlying `usePdfiumEngine` hook only re-initialises when its `wasmUrl`
* changes, so retrying is handled by the parent remounting this component via a
* `key`. Because the boundary owns the hook, a fresh mount runs the load again
* from scratch.
*
* Without this boundary a failed or hung WASM load left the viewer showing an
* infinite "Loading PDF Engine..." spinner with no error and no way to recover.
*/
export function PdfEngineBoundary({
wasmUrl,
onRetry,
timeoutMs = DEFAULT_ENGINE_LOAD_TIMEOUT_MS,
children,
}: PdfEngineBoundaryProps) {
const { t } = useTranslation();
const { engine, isLoading, error } = usePdfiumEngine({ wasmUrl });
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
if (engine || error) return;
const timer = setTimeout(() => setTimedOut(true), timeoutMs);
return () => clearTimeout(timer);
}, [engine, error, isLoading, timeoutMs]);
if (engine) {
return <>{children(engine)}</>;
}
if (error || timedOut) {
return (
<Center h="100%" w="100%">
<Stack align="center" gap="md" style={{ maxWidth: 420, padding: 16 }}>
<div style={{ fontSize: "32px" }}>⚠️</div>
<Text fw={600} size="md" style={{ textAlign: "center" }}>
{t("viewer.engineLoadErrorTitle")}
</Text>
<Text c="dimmed" size="sm" style={{ textAlign: "center" }}>
{t("viewer.engineLoadErrorBody")}
</Text>
<Button onClick={onRetry} variant="primary">
{t("viewer.engineLoadErrorRetry")}
</Button>
</Stack>
</Center>
);
}
return <ToolLoadingFallback toolName="PDF Engine" />;
}
@@ -176,11 +176,7 @@ export function useViewerWorkbenchBarButtons(
aria-label={searchLabel}
onClick={viewer.searchInterfaceActions.toggle}
>
<LocalIcon
icon="find-in-page-rounded"
width="1.25rem"
height="1.25rem"
/>
<LocalIcon icon="search" width="1rem" height="1rem" />
</ActionIcon>
</div>
</Popover.Target>
@@ -1,21 +0,0 @@
/**
* Search index for the admin portal ("Processor") destinations offered by the
* global super search. Core and desktop builds ship no portal, so the index is
* empty and the Processor group never renders; the proprietary build (which
* mounts the portal as a route-set) shadows this with the real view list.
*/
export interface ProcessorSearchEntry {
/** Portal view id — stable key for the result row. */
id: string;
/** i18n key for the view's display name (shared with the portal sidebar). */
labelKey: string;
labelFallback: string;
/** In-app path to navigate to. Empty when externalUrl is set. */
path: string;
/** Opens in a new tab instead of navigating (e.g. hosted docs). */
externalUrl?: string;
/** Extra fuzzy-match terms beyond the label. */
keywords?: string[];
}
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = [];
@@ -1,157 +0,0 @@
import i18n from "i18next";
import type { TFunction } from "i18next";
/**
* Content-level settings search: matches a query against every translation
* string a settings section renders, so a term that appears anywhere on a
* settings page ("SMTP", "OCR", a field label…) finds that section without a
* curated keyword.
*
* This is the single source for the technique — the in-modal settings search
* bar and the global super search both build on it. It is intentionally
* component-free (translation subtrees only) so the always-mounted super
* search bar can use it without pulling the lazy settings modal into the main
* bundle.
*/
/**
* Translation subtrees whose strings appear on each settings section, for the
* sections where the nav key doesn't map 1:1 onto a toml prefix. Keys missing
* here fall back to the inferred `settings.<key>` / `admin.settings.<key>`
* prefix.
*/
const SECTION_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> = {
general: ["settings.general"],
hotkeys: ["settings.hotkeys"],
account: ["account"],
people: ["settings.workspace"],
teams: ["settings.workspace", "settings.team"],
"api-keys": ["settings.developer"],
connectionMode: ["settings.connection"],
planBilling: ["settings.planBilling"],
adminGeneral: ["admin.settings.general"],
adminFeatures: ["admin.settings.features"],
adminEndpoints: ["admin.settings.endpoints"],
adminDatabase: ["admin.settings.database"],
adminAdvanced: ["admin.settings.advanced"],
adminSecurity: ["admin.settings.security"],
adminMcp: ["admin.settings.mcp"],
adminConnections: [
"admin.settings.connections",
"admin.settings.mail",
"admin.settings.security",
"admin.settings.telegram",
"admin.settings.premium",
"admin.settings.general",
"settings.securityAuth",
"settings.connection",
],
adminPlan: [
"settings.planBilling",
"admin.settings.premium",
"settings.licensingAnalytics",
],
adminAudit: ["settings.licensingAnalytics"],
adminUsage: ["settings.licensingAnalytics"],
adminLegal: ["admin.settings.legal"],
adminPrivacy: ["admin.settings.privacy"],
};
export const getTranslationPrefixesForNavKey = (key: string): string[] => {
const explicitPrefixes = SECTION_TRANSLATION_PREFIXES[key] ?? [];
const inferredPrefixes: string[] = [];
if (key.startsWith("admin")) {
const adminSuffix = key.replace(/^admin/, "");
const normalizedAdminSuffix =
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
} else {
inferredPrefixes.push(`settings.${key}`);
}
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
};
export const flattenTranslationStrings = (value: unknown): string[] => {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (Array.isArray(value)) {
return value.flatMap(flattenTranslationStrings);
}
if (value && typeof value === "object") {
return Object.values(value as Record<string, unknown>).flatMap(
flattenTranslationStrings,
);
}
return [];
};
/** Trims a matched string to a short snippet centred on the query hit. */
export const buildMatchSnippet = (text: string, query: string): string => {
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase();
const matchIndex = normalizedText.indexOf(normalizedQuery);
if (matchIndex === -1) {
return text;
}
const maxLength = 84;
const contextPadding = 28;
const start = Math.max(0, matchIndex - contextPadding);
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
const snippet = text.slice(start, end);
if (snippet.length <= maxLength) {
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
}
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
};
// Flattening every subtree on each keystroke would be wasteful; sections'
// content is static per language, so cache it and drop the cache on switch.
const contentCache = new Map<string, string[]>();
let contentCacheLanguage: string | undefined;
export function getSettingsSectionContent(key: string, t: TFunction): string[] {
if (contentCacheLanguage !== i18n.language) {
contentCache.clear();
contentCacheLanguage = i18n.language;
}
const cached = contentCache.get(key);
if (cached) return cached;
const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) =>
flattenTranslationStrings(
t(prefix, { returnObjects: true, defaultValue: {} }),
),
);
contentCache.set(key, content);
return content;
}
/**
* First content string of the section containing the query
* (case-insensitive), or null. Substring only — fuzzy matching across whole
* paragraphs of copy produces junk hits.
*/
export function findSettingsContentMatch(
key: string,
query: string,
t: TFunction,
): string | null {
const normalizedQuery = query.toLocaleLowerCase();
return (
getSettingsSectionContent(key, t).find((text) =>
text.toLocaleLowerCase().includes(normalizedQuery),
) ?? null
);
}
@@ -1,105 +0,0 @@
import { NavKey } from "@app/components/shared/config/types";
/**
* A single, searchable setting *row* inside the settings modal.
*
* The existing in-modal SettingsSearchBar searches section content but can only
* navigate to a whole section. This index lets the global super search deep-link
* to an individual control: navigating to `/settings/{section}?focus={anchor}`,
* where `anchor` is the DOM `id` placed on that control's row (see
* AppConfigModal's focus-scroll effect and the `id=` attributes added to the
* matching section components).
*/
export interface SettingsSearchEntry {
/** Settings section this row lives in (nav key, e.g. "general"). */
section: NavKey;
/** DOM id on the control's row; used as the `?focus=` anchor. */
anchor: string;
/** i18n key for the display label. */
labelKey: string;
/** English fallback / default for the label. */
labelFallback: string;
/** Extra English terms to match against (synonyms, related words). */
keywords?: string[];
}
/**
* Curated row-level entries for the high-value, user-facing settings sections.
* Section-level results (every other tab) come from the nav sections directly,
* so this list only needs the rows worth jumping straight to.
*/
export const SETTINGS_SEARCH_INDEX: SettingsSearchEntry[] = [
// --- General > Appearance ---
{
section: "general",
anchor: "setting-theme",
labelKey: "settings.general.theme",
labelFallback: "Theme",
keywords: ["dark", "light", "mode", "appearance", "colour", "color"],
},
{
section: "general",
anchor: "setting-language",
labelKey: "settings.general.language",
labelFallback: "Language",
keywords: ["locale", "translation", "i18n"],
},
// --- General > Behaviour ---
{
section: "general",
anchor: "setting-tool-picker-mode",
labelKey: "settings.general.defaultToolPickerMode",
labelFallback: "Default tool picker mode",
keywords: ["sidebar", "fullscreen", "tools", "panel"],
},
{
section: "general",
anchor: "setting-startup-view",
labelKey: "settings.general.defaultStartupView",
labelFallback: "Default view on launch",
keywords: ["startup", "launch", "home", "reader", "automate"],
},
{
section: "general",
anchor: "setting-reader-zoom",
labelKey: "settings.general.defaultViewerZoom",
labelFallback: "Default reader zoom",
keywords: ["zoom", "viewer", "fit width", "fit page", "magnification"],
},
{
section: "general",
anchor: "setting-hide-unavailable-tools",
labelKey: "settings.general.hideUnavailableTools",
labelFallback: "Hide unavailable tools",
keywords: ["disabled", "greyed", "tools"],
},
{
section: "general",
anchor: "setting-hide-unavailable-conversions",
labelKey: "settings.general.hideUnavailableConversions",
labelFallback: "Hide unavailable conversions",
keywords: ["disabled", "convert", "conversions"],
},
{
section: "general",
anchor: "setting-auto-unzip",
labelKey: "settings.general.autoUnzip",
labelFallback: "Auto-unzip API responses",
keywords: ["zip", "extract", "unzip", "archive"],
},
{
section: "general",
anchor: "setting-auto-unzip-file-limit",
labelKey: "settings.general.autoUnzipFileLimit",
labelFallback: "Auto-unzip file limit",
keywords: ["zip", "limit", "files", "extract"],
},
// --- Keyboard Shortcuts ---
{
section: "hotkeys",
anchor: "setting-hotkeys-search",
labelKey: "settings.hotkeys.title",
labelFallback: "Keyboard Shortcuts",
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
},
];
@@ -1,67 +0,0 @@
import { NavKey } from "@app/components/shared/config/types";
/**
* A whole settings section (nav tab), described as pure data so the global
* super search can offer section-level results.
*
* This is the single source of truth for *which settings sections are
* searchable* in a given build. It is intentionally **component-free**: the
* always-mounted top bar imports it (via `@app/data/settingsSectionRegistry`)
* to feed the super search, and pulling the heavy settings component tree in
* here would defeat the lazy-loaded settings modal (AppConfigModalLazy).
*
* Layering mirrors the nav builders (`configNavSections`): core lists the
* always-present sections; higher layers shadow this module to add their own
* (proprietary admin sections, saas cloud sections, …). A section only belongs
* in a layer's registry if that layer's settings modal can actually render it —
* otherwise search would deep-link to a tab that doesn't exist.
*
* Per-user visibility (admin / login) is expressed by the flags below and
* applied by the super search at query time, mirroring the modal's own gating.
*/
export interface SettingsSectionEntry {
/** Nav key; used for the `/settings/{key}` deep link. */
key: NavKey;
/** i18n key for the display label. */
labelKey: string;
/** English fallback / default for the label. */
labelFallback: string;
/** Extra English terms to match against (synonyms, related words). */
keywords?: string[];
/** Surface only when login mode is on (e.g. account, API keys). */
requiresLogin?: boolean;
/**
* Admin-area section: the self-hosted modal surfaces it when the user is an
* admin OR login mode is off (single-user self-host). Mirrors the proprietary
* nav builder's `isAdmin || !loginEnabled` gate.
*/
adminArea?: boolean;
}
/** Core (OSS) sections — always present in every build. */
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
{
key: "general",
labelKey: "settings.general.title",
labelFallback: "General",
keywords: ["theme", "language", "appearance", "preferences", "startup"],
},
{
key: "hotkeys",
labelKey: "settings.hotkeys.title",
labelFallback: "Keyboard Shortcuts",
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
},
{
key: "help",
labelKey: "settings.help.label",
labelFallback: "Tours",
keywords: ["help", "tour", "guide", "support", "docs"],
},
{
key: "legal",
labelKey: "settings.legal.label",
labelFallback: "Legal",
keywords: ["legal", "terms", "privacy", "licenses"],
},
];
@@ -1,423 +0,0 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { ViewerContext } from "@app/contexts/ViewerContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
import { fileStorage } from "@app/services/fileStorage";
import { rankByFuzzy, idToWords } from "@app/utils/fuzzySearch";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { ToolId } from "@app/types/toolId";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
import {
buildMatchSnippet,
findSettingsContentMatch,
} from "@app/data/settingsContentSearch";
import {
PROCESSOR_SEARCH_INDEX,
type ProcessorSearchEntry,
} from "@app/data/processorSearchIndex";
export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor";
export interface SuperSearchResult {
/** Stable unique key across all groups. */
key: string;
/** Group id — the editor uses SuperSearchGroupId; other hosts use their own. */
group: string;
title: string;
subtitle?: string;
/** LocalIcon name (files/settings); tools provide a React node via `icon`. */
iconName?: string;
icon?: React.ReactNode;
score: number;
onSelect: () => void | Promise<void>;
}
export interface SuperSearchGroup {
id: string;
label: string;
results: SuperSearchResult[];
}
/** Per-group result caps so the dropdown stays scannable. */
const GROUP_LIMIT = 6;
/** Group display order in the dropdown. */
const GROUP_ORDER: SuperSearchGroupId[] = [
"files",
"tools",
"settings",
"processor",
];
export interface UseSuperSearchResult {
/** Non-empty groups, in display order. */
groups: SuperSearchGroup[];
/** All results flattened in display order (for keyboard navigation). */
flatResults: SuperSearchResult[];
/** True while the My Files store is loading for the first time. */
loadingFiles: boolean;
}
/** Visibility gates shared by the settings and Processor sources. */
export interface SuperSearchGates {
isAdmin: boolean;
loginEnabled: boolean;
}
// ---------------------------------------------------------------------------
// Shared sources. Every host bar (editor workbench, portal shell) builds its
// results from these, so a query ranks identically everywhere — only the
// select actions differ (in-app contexts vs cross-app navigation).
// ---------------------------------------------------------------------------
/** Loads the My Files stubs whenever the search surface is open. */
export function useMyFilesStubs(active: boolean): {
stubs: StirlingFileStub[];
loadingFiles: boolean;
} {
const [stubs, setStubs] = useState<StirlingFileStub[]>([]);
const [loadingFiles, setLoadingFiles] = useState(false);
const loadedOnceRef = useRef(false);
useEffect(() => {
if (!active) return;
// Refresh whenever the surface opens so newly added files appear.
let cancelled = false;
if (!loadedOnceRef.current) setLoadingFiles(true);
fileStorage
.getLeafStirlingFileStubs()
.then((all) => {
if (cancelled) return;
setStubs(all);
loadedOnceRef.current = true;
})
.catch((err) => {
console.error("[SuperSearch] Failed to load file stubs:", err);
})
.finally(() => {
if (!cancelled) setLoadingFiles(false);
});
return () => {
cancelled = true;
};
}, [active]);
return { stubs, loadingFiles };
}
export function rankFileResults(
stubs: StirlingFileStub[],
trimmed: string,
openFile: (stub: StirlingFileStub) => void | Promise<void>,
): SuperSearchResult[] {
if (!trimmed) return [];
return rankByFuzzy(stubs, trimmed, [(s) => s.name])
.slice(0, GROUP_LIMIT)
.map(({ item, score }) => ({
key: `file:${item.id}`,
group: "files",
title: item.name,
iconName: "insert-drive-file-rounded",
score,
onSelect: () => openFile(item),
}));
}
export function rankToolResults(
registry: Partial<ToolRegistry>,
trimmed: string,
openTool: (id: ToolId) => void,
): SuperSearchResult[] {
if (!trimmed) return [];
const entries = Object.entries(registry) as [
ToolId,
ToolRegistry[ToolId] | undefined,
][];
return rankByFuzzy(entries, trimmed, [
([id]) => idToWords(id),
([, v]) => v?.name ?? "",
([, v]) => v?.description ?? "",
([, v]) => v?.synonyms?.join(" ") ?? "",
])
.slice(0, GROUP_LIMIT)
.map(({ item: [id, tool], score }) => ({
key: `tool:${id}`,
group: "tools",
title: tool?.name ?? id,
subtitle: tool?.description,
icon: tool?.icon,
score,
onSelect: () => openTool(id),
}));
}
export function rankSettingsResults(
trimmed: string,
t: TFunction,
gates: SuperSearchGates,
openSettings: (section: string, anchor?: string) => void,
): SuperSearchResult[] {
if (!trimmed) return [];
const { isAdmin, loginEnabled } = gates;
// Row-level entries (deep-link with ?focus=) take priority.
const rows = rankByFuzzy(SETTINGS_SEARCH_INDEX, trimmed, [
(e) => t(e.labelKey, e.labelFallback),
(e) => e.labelFallback,
(e) => e.keywords?.join(" ") ?? "",
]).map(({ item, score }) => ({
key: `setting:${item.section}:${item.anchor}`,
group: "settings",
title: t(item.labelKey, item.labelFallback),
subtitle: t(`settings.${item.section}.title`, item.section),
iconName: "settings-rounded",
score: score + 1, // nudge rows above bare section matches
onSelect: () => openSettings(item.section, item.anchor),
}));
// Section-level entries (whole tab), gated like the modal nav. The registry
// resolves per build (core / proprietary / saas / desktop), so this only
// ever sees sections the current build's settings modal can actually show.
const visibleSections = SETTINGS_SECTION_REGISTRY.filter((s) => {
if (s.requiresLogin && !loginEnabled) return false;
// Admin-area sections mirror the builder's `isAdmin || !loginEnabled` gate.
if (s.adminArea && !(isAdmin || !loginEnabled)) return false;
return true;
});
const sectionMatches = rankByFuzzy(visibleSections, trimmed, [
(s) => t(s.labelKey, s.labelFallback),
(s) => s.labelFallback,
(s) => s.keywords?.join(" ") ?? "",
]);
const sections = sectionMatches.map(({ item, score }) => ({
key: `setting-section:${item.key}`,
group: "settings",
title: t(item.labelKey, item.labelFallback),
iconName: "settings-rounded",
score,
onSelect: () => openSettings(item.key),
}));
// Content matches: sections whose rendered copy contains the query (the
// in-modal settings search technique), so terms with no curated keyword
// ("SMTP", a field label) still find their section. Ranked below every
// label/keyword match; 3+ chars so a single letter doesn't match half the
// modal.
const labelMatchedKeys = new Set(sectionMatches.map(({ item }) => item.key));
const contentMatches =
trimmed.length < 3
? []
: visibleSections
.filter((s) => !labelMatchedKeys.has(s.key))
.flatMap((s) => {
const match = findSettingsContentMatch(s.key, trimmed, t);
if (!match) return [];
return [
{
key: `setting-content:${s.key}`,
group: "settings",
title: t(s.labelKey, s.labelFallback),
subtitle: buildMatchSnippet(match, trimmed),
iconName: "settings-rounded",
score: 20, // below the fuzzy thresholds (30/40)
onSelect: () => openSettings(s.key),
},
];
});
return [...rows, ...sections, ...contentMatches]
.sort((a, b) => b.score - a.score)
.slice(0, GROUP_LIMIT);
}
export function rankProcessorResults(
trimmed: string,
t: TFunction,
gates: SuperSearchGates,
selectEntry: (entry: ProcessorSearchEntry) => void,
): SuperSearchResult[] {
if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
// The portal is an admin surface; mirror the settings modal's admin gate
// (a login-disabled single-user deployment has a full-access operator).
if (!(gates.isAdmin || !gates.loginEnabled)) return [];
return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
(e) => t(e.labelKey, e.labelFallback),
(e) => e.labelFallback,
(e) => e.keywords?.join(" ") ?? "",
])
.slice(0, GROUP_LIMIT)
.map(({ item, score }) => ({
key: `processor:${item.id}`,
group: "processor",
title: t(item.labelKey, item.labelFallback),
iconName: "grid-view-rounded",
score,
onSelect: () => selectEntry(item),
}));
}
/**
* Orders the sources into the shared group layout, dropping empties. Hosts
* pass their own order so local results lead (the editor puts its own
* files/tools first and Processor pages last; the portal the reverse).
*/
export function assembleSuperSearchGroups(
byId: Partial<Record<SuperSearchGroupId, SuperSearchResult[]>>,
t: TFunction,
order: SuperSearchGroupId[] = GROUP_ORDER,
): SuperSearchGroup[] {
const labels: Record<SuperSearchGroupId, string> = {
files: t("superSearch.group.files", "Files"),
tools: t("superSearch.group.tools", "Tools"),
settings: t("superSearch.group.settings", "Settings"),
processor: t("superSearch.group.processor", "Processor"),
};
return order
.map((id) => ({
id,
label: labels[id],
results: byId[id] ?? [],
}))
.filter((g) => g.results.length > 0);
}
/**
* The editor's results provider: the shared sources wired to in-app select
* actions (open file → viewer, select tool in the workbench, deep-link into
* the settings modal, route into the Processor).
*
* @param query current search text
* @param active whether the search surface is open; gates the My Files load
*/
export function useSuperSearch(
query: string,
active: boolean,
): UseSuperSearchResult {
const { t } = useTranslation();
const navigate = useNavigate();
const {
toolRegistry,
handleToolSelect,
handleToolSelectForced,
toolAvailability,
} = useToolWorkflow();
const { actions: navActions } = useNavigationActions();
const { actions: fileActions } = useFileActions();
const { config } = useAppConfig();
// ViewerContext is only present once the viewer subtree mounts; treat as optional.
const viewer = useContext(ViewerContext);
const trimmed = query.trim();
const { stubs, loadingFiles } = useMyFilesStubs(active);
// --- Actions -----------------------------------------------------------
const openFile = useCallback(
async (stub: StirlingFileStub) => {
try {
// The file already lives in storage — load it as a stub so its id and
// metadata are preserved (addFiles would persist a duplicate record).
await fileActions.addStirlingFileStubs([stub], { selectFiles: true });
navActions.setWorkbench("viewer");
viewer?.setActiveFileId?.(stub.id);
} catch (err) {
console.error("[SuperSearch] Failed to open file:", stub.name, err);
}
},
[fileActions, navActions, viewer],
);
const openTool = useCallback(
(id: ToolId) => {
// Tools whose backend endpoint isn't served in this environment are
// flagged unavailable; handleToolSelect silently no-ops them. For a
// search ("take me to Repair") we still want the click to open the
// tool's UI, so fall back to the forced path for those. Available tools
// keep the normal path so the unsaved-changes guard still applies.
const available = toolAvailability[id]?.available !== false;
if (available) {
handleToolSelect(id);
} else {
handleToolSelectForced(id);
}
},
[handleToolSelect, handleToolSelectForced, toolAvailability],
);
const openSettings = useCallback(
(section: string, anchor?: string) => {
const path = anchor
? `/settings/${section}?focus=${encodeURIComponent(anchor)}`
: `/settings/${section}`;
navigate(path);
},
[navigate],
);
const selectProcessorEntry = useCallback(
(item: ProcessorSearchEntry) => {
if (item.externalUrl) {
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
} else {
navigate(item.path);
}
},
[navigate],
);
// --- Assemble ----------------------------------------------------------
const gates = useMemo<SuperSearchGates>(
() => ({
isAdmin: config?.isAdmin ?? false,
loginEnabled: config?.enableLogin ?? false,
}),
[config],
);
const groups = useMemo<SuperSearchGroup[]>(
() =>
assembleSuperSearchGroups(
{
files: rankFileResults(stubs, trimmed, openFile),
tools: rankToolResults(toolRegistry, trimmed, openTool),
settings: rankSettingsResults(trimmed, t, gates, openSettings),
processor: rankProcessorResults(
trimmed,
t,
gates,
selectProcessorEntry,
),
},
t,
),
[
stubs,
trimmed,
openFile,
toolRegistry,
openTool,
gates,
openSettings,
selectProcessorEntry,
t,
],
);
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
return { groups, flatResults, loadingFiles };
}
@@ -597,6 +597,11 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
<FileSidebar
ref={ref}
{...props}
onSearchClick={() => {
// Just focus the central search field; don't toggle collapse
// (which on /files navigates back home).
window.dispatchEvent(new Event("files-page:focus-search"));
}}
onUploadFiles={handleUpload}
onPickGoogleDriveFiles={handleUpload}
extraAction={{
@@ -28,24 +28,53 @@ export const pdfiumWasmModulePromise = new Promise<WebAssembly.Module | null>(
},
);
/**
* Compile the WASM without streaming by fetching the whole binary first.
*
* `compileStreaming` requires the response to be served with the
* `application/wasm` MIME type and no incompatible `Content-Encoding`. Under the
* `tauri://` asset protocol (and behind some proxies) those headers aren't
* guaranteed, which makes streaming compilation throw. Fetching the bytes and
* compiling them directly sidesteps the MIME/encoding requirement entirely.
*/
async function compileFromArrayBuffer(): Promise<WebAssembly.Module | null> {
try {
const response = await fetch(pdfiumWasmUrl);
if (!response.ok) {
throw new Error(`Unexpected response ${response.status} for pdfium.wasm`);
}
const bytes = await response.arrayBuffer();
return await WebAssembly.compile(bytes);
} catch (err) {
console.warn("Eager WASM ArrayBuffer compilation failed:", err);
return null;
}
}
export function startEagerWasmCompilation(): void {
if (compilationStarted) return;
compilationStarted = true;
if (
typeof WebAssembly === "object" &&
typeof WebAssembly.compileStreaming === "function"
) {
if (typeof WebAssembly !== "object") {
resolvePromise(null);
return;
}
// Prefer streaming compilation, but fall back to fetching the bytes and
// compiling them directly when streaming isn't available or fails (e.g. wrong
// MIME type / content-encoding under the tauri:// protocol). Resolving null on
// total failure lets pdfiumService fall back to its own instantiation path.
if (typeof WebAssembly.compileStreaming === "function") {
WebAssembly.compileStreaming(fetch(pdfiumWasmUrl))
.then(resolvePromise)
.catch((err) => {
console.warn(
"Eager WASM compilation failed or not supported in this environment:",
"Eager WASM streaming compilation failed, falling back to ArrayBuffer:",
err,
);
resolvePromise(null);
compileFromArrayBuffer().then(resolvePromise);
});
} else {
resolvePromise(null);
compileFromArrayBuffer().then(resolvePromise);
}
}
@@ -565,6 +565,25 @@ test.describe("Files page", () => {
test.describe("Side-rail integration with /files", () => {
test.use({ autoGoto: false });
test("Rail Search focuses the central search field, no navigation", async ({
page,
}) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await gotoFilesPage(page);
// Click the search row in the rail.
await page.locator(".file-sidebar-search-row").click();
// The central search input should be focused.
const focused = await page.evaluate(
() => document.activeElement?.getAttribute("aria-label") ?? "",
);
expect(focused).toMatch(/Search/i);
// And we must still be on /files (i.e. didn't navigate home).
await expect(page).toHaveURL(/\/files/);
});
test("Rail New folder button visible on /files", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
@@ -1,215 +0,0 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Route } from "@playwright/test";
import path from "path";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
/** Stub the two signing list endpoints with empty arrays. */
async function stubSigningApis(page: import("@playwright/test").Page) {
await page.route("**/api/v1/security/cert-sign/sign-requests", (r: Route) =>
r.fulfill({ json: [] }),
);
await page.route("**/api/v1/security/cert-sign/sessions", (r: Route) =>
r.fulfill({ json: [] }),
);
await page.route("**/api/v1/storage/teams/**", (r: Route) =>
r.fulfill({ json: [] }),
);
await page.route("**/api/v1/storage/files", (r: Route) =>
r.fulfill({ json: [] }),
);
}
test.describe("SharedSign tool — gating", () => {
test("shows 'not enabled' notice when storageGroupSigningEnabled is false", async ({
page,
}) => {
// Default stub has no storageGroupSigningEnabled — feature is off.
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
const alert = page.getByRole("alert");
await expect(alert).toBeVisible({ timeout: 10_000 });
await expect(alert).toContainText(/not enabled|collaborative signing/i);
});
});
test.describe("SharedSign tool — feature enabled", () => {
test.use({
stubOptions: {
enableLogin: true,
},
seedJwt: true,
});
test("renders Active/Completed tabs and Request signatures button", async ({
page,
}) => {
// Override app-config to include storageGroupSigningEnabled.
await page.route("**/api/v1/config/app-config", (r: Route) =>
r.fulfill({
json: {
enableLogin: true,
isAdmin: false,
languages: ["en-US"],
defaultLocale: "en-US",
storageGroupSigningEnabled: true,
},
}),
);
await stubSigningApis(page);
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
// Mantine SegmentedControl hides the underlying radio inputs via CSS;
// assert on the visible label text instead.
await expect(
page
.locator("label")
.filter({ hasText: /^active$/i })
.first(),
).toBeVisible({ timeout: 10_000 });
await expect(
page
.locator("label")
.filter({ hasText: /^completed$/i })
.first(),
).toBeVisible();
// "Request signatures" action button should be visible.
const requestBtn = page.getByRole("button", {
name: /request signatures/i,
});
await expect(requestBtn).toBeVisible();
});
test("empty-state message shows when no sessions exist", async ({ page }) => {
await page.route("**/api/v1/config/app-config", (r: Route) =>
r.fulfill({
json: {
enableLogin: true,
isAdmin: false,
languages: ["en-US"],
defaultLocale: "en-US",
storageGroupSigningEnabled: true,
},
}),
);
await stubSigningApis(page);
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
// Empty-state copy for the active tab.
await expect(
page.getByText(/no pending sign requests|no active sessions/i),
).toBeVisible({ timeout: 10_000 });
});
test("clicking 'Request signatures' shows the create-session wizard", async ({
page,
}) => {
await page.route("**/api/v1/config/app-config", (r: Route) =>
r.fulfill({
json: {
enableLogin: true,
isAdmin: false,
languages: ["en-US"],
defaultLocale: "en-US",
storageGroupSigningEnabled: true,
},
}),
);
await stubSigningApis(page);
// Team members endpoint for participant picker.
await page.route("**/api/v1/proprietary/team/**", (r: Route) =>
r.fulfill({ json: [] }),
);
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
const requestBtn = page.getByRole("button", {
name: /request signatures/i,
});
await expect(requestBtn).toBeVisible({ timeout: 10_000 });
await requestBtn.click();
// Wizard should appear — "Back to sessions" back-button is the marker.
await expect(
page.getByRole("button", { name: /back to sessions/i }),
).toBeVisible({ timeout: 10_000 });
});
test("Completed tab shows empty state after switching", async ({ page }) => {
await page.route("**/api/v1/config/app-config", (r: Route) =>
r.fulfill({
json: {
enableLogin: true,
isAdmin: false,
languages: ["en-US"],
defaultLocale: "en-US",
storageGroupSigningEnabled: true,
},
}),
);
await stubSigningApis(page);
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
// Mantine SegmentedControl: click the visible label, not the hidden radio input.
const completedTab = page
.locator("label")
.filter({ hasText: /^completed$/i })
.first();
await expect(completedTab).toBeVisible({ timeout: 10_000 });
await completedTab.click();
await expect(page.getByText(/no completed sessions/i)).toBeVisible({
timeout: 10_000,
});
});
});
test.describe("Viewer Share button — gating", () => {
test("Share button absent when storageSharingEnabled is false", async ({
page,
}) => {
// Default app-config has no storageSharingEnabled.
await page.goto("/", { waitUntil: "domcontentloaded" });
await uploadFiles(page, SAMPLE_PDF);
// The share button should not be in the DOM.
await expect(
page.getByRole("button", { name: /share/i }).first(),
).not.toBeAttached({ timeout: 8_000 });
});
test("Share button present when storageSharingEnabled is true", async ({
page,
}) => {
// storageSharingEnabled is a pure config flag — the WorkbenchBar renders
// the Share button based solely on it, with no login-required check.
await page.route("**/api/v1/config/app-config", (r: Route) =>
r.fulfill({
json: {
enableLogin: false,
isAdmin: false,
languages: ["en-US"],
defaultLocale: "en-US",
storageSharingEnabled: true,
storageEnabled: true,
},
}),
);
await page.route("**/api/v1/storage/files", (r: Route) =>
r.fulfill({ json: [] }),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
await uploadFiles(page, SAMPLE_PDF);
// Share button appears in the workbench toolbar once a file is open.
await expect(
page.getByRole("button", { name: /share/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
});
@@ -0,0 +1,78 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("3. Tool Search", () => {
test.describe("3.1 Search - Happy Path", () => {
test("should filter tools in real time based on search input", async ({
page,
}) => {
// Step 1: Click on the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.click();
// Step 2: Type "merge"
await searchBox.fill("merge");
// Step 3: Verify search results filter to show relevant tools
await expect(
page.locator("text=/merge|menggabungkan/i").first(),
).toBeVisible({ timeout: 5000 });
// Step 5: Clear the search field
await searchBox.clear();
// Step 6: Verify all tools reappear (check for multiple categories)
await expect(
page.locator("text=/recommended|direkomendasikan/i").first(),
).toBeVisible({ timeout: 5000 });
});
});
test.describe("3.2 Search - No Results", () => {
test("should handle queries with no matching tools gracefully", async ({
page,
}) => {
// Step 1: Click on the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.click();
// Step 2: Type xyznonexistent123
await searchBox.fill("xyznonexistent123");
// Step 3: Verify the search field accepted the input
await expect(searchBox).toHaveValue("xyznonexistent123");
// The app uses fuzzy search with a fallback that shows all tools when nothing
// matches, so we verify the search state is active (no "recommended" section)
// and the page remains functional without errors.
await expect(
page.locator("text=/recommended|direkomendasikan/i"),
).toHaveCount(0, { timeout: 5000 });
// Step 4: Clear the search field
await searchBox.clear();
// Step 5: Verify all tools reappear (recommended section comes back)
await expect(
page.locator("text=/recommended|direkomendasikan/i").first(),
).toBeVisible({ timeout: 5000 });
});
});
test.describe("3.3 Search - Special Characters", () => {
test("should sanitize search input against XSS", async ({ page }) => {
// Step 1: Type XSS payload into the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.fill("<script>alert(1)</script>");
// Step 2: Verify no script execution occurs (no alert dialog)
// If an alert appeared, Playwright would throw an unhandled dialog error
await page.waitForTimeout(1000);
// Step 3: Verify the search treats the input as plain text
await expect(searchBox).toHaveValue("<script>alert(1)</script>");
// Step 4: Clear the search field
await searchBox.clear();
});
});
});
+57
View File
@@ -0,0 +1,57 @@
/* Shared classification-label pill (team labels editor + sidebar category manager). */
.sui-labelchip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0.2rem 0.3rem 0.2rem 0.25rem;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--color-surface);
max-width: 16rem;
}
.sui-labelchip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
color: var(--color-text-2);
}
.sui-labelchip-name {
font-size: 0.8125rem;
color: var(--color-text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sui-labelchip-count {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-3);
padding-left: 0.1rem;
}
.sui-labelchip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
border-radius: 999px;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.sui-labelchip-remove:hover {
background: var(--color-bg-hover);
color: var(--color-red);
}
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LabelChip } from "@app/ui/LabelChip";
const meta: Meta<typeof LabelChip> = {
title: "Primitives/LabelChip",
component: LabelChip,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: { label: "Invoice", icon: "receipt-long" },
argTypes: {
label: { control: "text" },
icon: { control: "text" },
count: { control: "number" },
onRemove: { action: "removed" },
},
};
export default meta;
type Story = StoryObj<typeof LabelChip>;
/** The classification-label pill shared by the labels editor and the sidebar category manager. */
export const Playground: Story = {};
/** With a file count, as shown in the sidebar category manager. */
export const WithCount: Story = {
args: { label: "Contract", icon: "handshake", count: 12 },
};
/** Removable — the trailing × appears when `onRemove` is set. */
export const Removable: Story = {
args: { label: "NDA", icon: "lock", onRemove: () => {} },
};
/** Falls back to the default "sell" icon when none is given. */
export const DefaultIcon: Story = {
args: { label: "Uncategorised label", icon: undefined },
};
/** Long names truncate rather than overflow the pill. */
export const LongName: Story = {
args: { label: "Memorandum of understanding and mutual agreement", count: 3 },
};
export const Row: Story = {
render: () => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", maxWidth: 420 }}>
<LabelChip label="Invoice" icon="receipt-long" count={12} />
<LabelChip label="Contract" icon="handshake" onRemove={() => {}} />
<LabelChip label="Lab report" icon="science" count={2} />
<LabelChip label="Payslip" icon="payments" />
</div>
),
};
+60
View File
@@ -0,0 +1,60 @@
import type { ReactNode } from "react";
import CloseIcon from "@mui/icons-material/Close";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import "@app/ui/LabelChip.css";
export interface LabelChipProps {
/** The label text. */
label: string;
/** Material Symbols key for a leading icon; ignored when `leading` is given. */
icon?: string;
/** Custom leading node (e.g. an icon picker), overriding `icon`. */
leading?: ReactNode;
/** Optional trailing count (e.g. how many files carry this label). */
count?: number;
/** Show a trailing `×`; called on click. */
onRemove?: () => void;
/** Accessible name for the remove button. */
removeAriaLabel?: string;
}
/**
* A classification-label pill: leading icon (or a custom control like an icon
* picker) + name, with an optional count and remove button. The shared look for
* every place labels are shown as chips — the team labels editor and the
* sidebar category manager both render this so they stay visually identical.
*/
export function LabelChip({
label,
icon,
leading,
count,
onRemove,
removeAriaLabel,
}: LabelChipProps) {
return (
<span className="sui-labelchip" role="listitem">
{leading ?? (
<span className="sui-labelchip-icon">
<LocalIcon icon={icon || "sell"} width="1rem" />
</span>
)}
<span className="sui-labelchip-name" title={label}>
{label}
</span>
{count != null && count > 0 && (
<span className="sui-labelchip-count">{count}</span>
)}
{onRemove && (
<button
type="button"
className="sui-labelchip-remove"
onClick={onRemove}
aria-label={removeAriaLabel ?? `Remove ${label}`}
>
<CloseIcon sx={{ fontSize: "0.85rem" }} />
</button>
)}
</span>
);
}
@@ -1,7 +0,0 @@
/**
* Desktop inherits proprietary's app but must NOT ship the portal (see the
* admin-route seam) — shadow the index back to core's empty list so the super
* search never offers Processor destinations that don't exist on desktop.
*/
export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
export { PROCESSOR_SEARCH_INDEX } from "@core/data/processorSearchIndex";
@@ -1,42 +0,0 @@
import { type SettingsSectionEntry } from "@core/data/settingsSectionRegistry";
export type { SettingsSectionEntry };
/**
* Desktop settings sections. The desktop modal reflows heavily by connection
* mode (`configNavSections`): local mode shows only Preferences + Connection
* Mode + Legal, while SaaS mode swaps in the cloud Plan/Team sections and hides
* the self-hosted admin area. To stay safe across both modes without threading
* the (async) connection state into the always-mounted search, this lists only
* the sections guaranteed to render in every desktop mode.
*
* Cloud Plan/Team search on desktop-SaaS is a deliberate Tier-0 gap, not a
* regression — better to omit them than to deep-link to a dead tab in local
* mode.
*/
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
{
key: "general",
labelKey: "settings.general.title",
labelFallback: "General",
keywords: ["theme", "language", "appearance", "preferences", "startup"],
},
{
key: "hotkeys",
labelKey: "settings.hotkeys.title",
labelFallback: "Keyboard Shortcuts",
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
},
{
key: "connectionMode",
labelKey: "settings.connection.title",
labelFallback: "Connection Mode",
keywords: ["connection", "local", "cloud", "server", "sign in"],
},
{
key: "legal",
labelKey: "settings.legal.label",
labelFallback: "Legal",
keywords: ["legal", "terms", "privacy", "licenses"],
},
];
+8 -28
View File
@@ -56,11 +56,11 @@ export interface PolicyField {
export interface PolicyCategory {
id: string;
label: string;
icon: string;
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
desc: string;
providesClassification?: boolean;
comingSoon?: boolean;
requiresAiEngine?: boolean;
}
export interface PolicyConfigDef {
@@ -133,21 +133,14 @@ export interface CatalogueEntry {
/* Endpoint display labels */
/* ──────────────────────────────────────────────────────────────────────── */
/**
* i18n keys keyed by endpoint; labels stored steps in the detail view. Mostly
* {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union.
*/
export const ENDPOINT_LABELS: Partial<
Record<ToolEndpoint | "/api/v1/ai/tools/classify-and-label", string>
> = {
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
"/api/v1/misc/ocr-pdf": "portal.policies.endpoints.ocrPdf",
"/api/v1/misc/flatten": "portal.policies.endpoints.flatten",
"/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf",
"/api/v1/ai/tools/classify-and-label":
"portal.policies.endpoints.classifyAndLabel",
};
export function humanizeEndpoint(
@@ -177,6 +170,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "ingestion",
label: "portal.policies.categories.ingestion.label",
icon: "layers",
tone: "blue",
desc: "portal.policies.categories.ingestion.desc",
providesClassification: true,
@@ -185,20 +179,14 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "security",
label: "portal.policies.categories.security.label",
icon: "shield",
tone: "purple",
desc: "portal.policies.categories.security.desc",
},
{
id: "classification",
label: "portal.policies.categories.classification.label",
tone: "blue",
desc: "portal.policies.categories.classification.desc",
providesClassification: true,
requiresAiEngine: true,
},
{
id: "compliance",
label: "portal.policies.categories.compliance.label",
icon: "check",
tone: "amber",
desc: "portal.policies.categories.compliance.desc",
comingSoon: true,
@@ -206,6 +194,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "routing",
label: "portal.policies.categories.routing.label",
icon: "route",
tone: "green",
desc: "portal.policies.categories.routing.desc",
comingSoon: true,
@@ -213,6 +202,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "retention",
label: "portal.policies.categories.retention.label",
icon: "schedule",
tone: "neutral",
desc: "portal.policies.categories.retention.desc",
comingSoon: true,
@@ -274,16 +264,6 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
],
fields: [],
},
classification: {
summary: "portal.policies.config.classification.summary",
rules: [
"portal.policies.config.classification.rules.0",
"portal.policies.config.classification.rules.1",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("classify")],
fields: [],
},
compliance: {
summary: "portal.policies.config.compliance.summary",
rules: [
+13
View File
@@ -0,0 +1,13 @@
import { apiClient } from "@portal/api/http";
export interface QuickAction {
group: "Jump to" | "Create" | "Theme";
label: string;
/** Keyboard hint shown to the right. */
hint: string;
}
/** GET /v1/search/quick-actions */
export async function fetchQuickActions(): Promise<QuickAction[]> {
return apiClient.local.json<QuickAction[]>("/v1/search/quick-actions");
}
@@ -1,19 +1,17 @@
import type { ReactNode } from "react";
import { Sidebar } from "@portal/components/Sidebar";
import { PortalSearchBar } from "@portal/components/PortalSearchBar";
import "@portal/components/AppShell.css";
/**
* Two-column layout: fixed-width sidebar on the left, a scrolling main column on
* the right (topped by the global search bar). The Sidebar reads its state from
* context, so this shell stays prop-free.
* the right. The Sidebar reads its state from context, so this shell stays
* prop-free.
*/
export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="portal-shell">
<Sidebar />
<div className="portal-shell__main">
<PortalSearchBar />
<main className="portal-shell__view">{children}</main>
</div>
</div>
@@ -15,7 +15,7 @@ import {
type CatalogueEntry,
type PoliciesResponse,
} from "@portal/api/policies";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import { policyIcon } from "@portal/components/policies/policyIcons";
import "@portal/components/PolicySummary.css";
/**
@@ -64,7 +64,7 @@ export function PolicySummary() {
render: ({ entry }) => (
<div className="portal-policysum__cat">
<span className="portal-policysum__icon" aria-hidden>
{policyCategoryIcon(entry.category.id)}
{policyIcon(entry.category.icon)}
</span>
<div className="portal-policysum__cat-text">
<strong>{t(entry.category.label)}</strong>
@@ -1,10 +1,39 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { useUI } from "@portal/contexts/UIContext";
import { AppShell } from "@portal/components/AppShell";
import { SearchModal } from "@portal/components/SearchModal";
import { PortalSettingsHost } from "@portal/components/PortalSettingsHost";
import { ViewRouter } from "@portal/ViewRouter";
/**
* Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into
* the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette.
*/
function GlobalShortcuts() {
const { toggleSearch, closeSearch } = useUI();
useEffect(() => {
function onKey(e: KeyboardEvent) {
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
if (isCmdK) {
e.preventDefault();
toggleSearch();
return;
}
if (e.key === "Escape") {
closeSearch();
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [toggleSearch, closeSearch]);
return null;
}
/**
* The routed view, wrapped in an error boundary so a single view crashing can't
* white-screen the portal (the shell + nav stay alive). Keyed by route so
@@ -20,21 +49,23 @@ function RoutedContent() {
}
/**
* The flavor-agnostic portal chrome: the shell (sidebar + search bar + routed
* view) plus the global overlays that every flavor shares. Requires only the
* Tier and UI contexts above it — both flavors provide those. Flavor-specific
* overlays (e.g. the self-hosted account-link modal) are mounted by
* PortalProviders, not here.
* The flavor-agnostic portal chrome: the shell (sidebar + header + routed view)
* plus the global overlays that every flavor shares. Requires only the Tier and
* UI contexts above it — both flavors provide those. Flavor-specific overlays
* (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not
* here.
*/
export function PortalChrome() {
return (
<>
<GlobalShortcuts />
{/* The pipeline builder reads the tool registry to list and configure operations. */}
<ToolRegistryProvider>
<AppShell>
<RoutedContent />
</AppShell>
</ToolRegistryProvider>
<SearchModal />
<PortalSettingsHost />
</>
);
@@ -1,11 +0,0 @@
/* Slim strip at the top of the main column hosting the shared search bar. */
.portal-searchbar {
display: flex;
justify-content: center;
padding: var(--space-2) var(--space-3) 0;
}
.portal-searchbar .super-search {
width: 100%;
max-width: 34rem;
}
@@ -1,21 +0,0 @@
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import { usePortalSearchResults } from "@portal/hooks/usePortalSearchResults";
import "@portal/components/PortalSearchBar.css";
/**
* The portal face of the global super search — the same bar the editor's
* workbench shows, fed by the portal-wired results provider. Cmd/Ctrl+K
* focuses it (the bar registers its own shortcut). The config provider
* supplies the admin/login gates the settings and Processor sources share
* with the editor bar.
*/
export function PortalSearchBar() {
return (
<AppConfigProvider bootstrapMode="non-blocking">
<div className="portal-searchbar">
<SuperSearch useResults={usePortalSearchResults} />
</div>
</AppConfigProvider>
);
}
@@ -0,0 +1,70 @@
.portal-search__input-row {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.25rem 0.25rem 0.75rem;
border-bottom: 1px solid var(--color-border-light);
color: var(--color-text-4);
}
.portal-search__input {
flex: 1 1 auto;
font: inherit;
font-size: 0.9375rem;
background: transparent;
border: none;
outline: none;
color: var(--color-text-1);
}
.portal-search__input::placeholder {
color: var(--color-text-placeholder);
}
.portal-search__esc {
font-family: var(--font-mono);
font-size: 0.6875rem;
padding: 0.0625rem 0.375rem;
border-radius: var(--radius-xs);
background: var(--color-bg-muted);
color: var(--color-text-4);
}
.portal-search__results {
padding-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 24rem;
overflow-y: auto;
}
.portal-search__group-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-section-label);
padding: 0 0.5rem 0.375rem;
}
.portal-search__item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.625rem;
border-radius: var(--radius-sm);
color: var(--color-text-2);
font-size: 0.8125rem;
text-align: left;
}
.portal-search__item:hover {
background: var(--color-bg-hover);
}
.portal-search__item-hint {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-5);
}
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect } from "react";
import { http, HttpResponse } from "msw";
import { SearchModal } from "@portal/components/SearchModal";
import { useUI } from "@portal/contexts/UIContext";
function ForceOpen() {
const { openSearch } = useUI();
useEffect(() => {
openSearch();
}, [openSearch]);
return null;
}
const meta: Meta<typeof SearchModal> = {
title: "Portal/Header/SearchModal",
component: SearchModal,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<ForceOpen />
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SearchModal>;
export const Default: Story = {};
export const EmptyCatalogue: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
],
},
},
};
@@ -0,0 +1,125 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, EmptyState, Modal, Skeleton } from "@app/ui";
import { useTranslation } from "react-i18next";
import { useUI } from "@portal/contexts/UIContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
import { SearchIcon } from "@portal/components/icons";
import "@portal/components/SearchModal.css";
export function SearchModal() {
const { t } = useTranslation();
const { searchOpen, closeSearch } = useUI();
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState("");
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
const { data: actions } = state;
const { isLoading } = useSectionFlags(state);
useEffect(() => {
if (searchOpen) {
setQuery("");
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
}
return undefined;
}, [searchOpen]);
const filtered = useMemo(() => {
if (!actions) return [] as QuickAction[];
if (!query.trim()) return actions;
const needle = query.trim().toLowerCase();
return actions.filter((item) => item.label.toLowerCase().includes(needle));
}, [actions, query]);
const groups = useMemo(
() =>
filtered.reduce<Record<string, QuickAction[]>>((acc, item) => {
if (!acc[item.group]) acc[item.group] = [];
acc[item.group].push(item);
return acc;
}, {}),
[filtered],
);
const groupKeys = Object.keys(groups);
const isEmpty = !isLoading && groupKeys.length === 0;
return (
<Modal
open={searchOpen}
onClose={closeSearch}
width="lg"
ariaLabel={t("portal.search.ariaLabel")}
>
<div className="portal-search">
<div className="portal-search__input-row">
<SearchIcon size={16} />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("portal.search.placeholder")}
aria-label={t("portal.search.ariaLabel")}
className="portal-search__input"
autoComplete="off"
spellCheck={false}
/>
<span className="portal-search__esc" aria-hidden>
ESC
</span>
</div>
<div className="portal-search__results">
{isLoading && (
<div className="portal-search__loading">
<Skeleton height="1rem" width="40%" />
<Skeleton height="0.875rem" />
<Skeleton height="0.875rem" width="80%" />
<Skeleton height="0.875rem" width="65%" />
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title={
query.trim()
? t("portal.search.empty.noMatches", { query: query.trim() })
: t("portal.search.empty.noActionsTitle")
}
description={
query.trim()
? t("portal.search.empty.noMatchesDescription")
: t("portal.search.empty.noActionsDescription")
}
/>
)}
{!isLoading &&
!isEmpty &&
Object.entries(groups).map(([group, items]) => (
<div key={group} className="portal-search__group">
<div className="portal-search__group-label">{group}</div>
{items.map((item) => (
<Button
key={item.label}
variant="tertiary"
justify="start"
fullWidth
className="portal-search__item"
onClick={closeSearch}
>
<span className="portal-search__item-label">
{item.label}
</span>
<span className="portal-search__item-hint">
{item.hint}
</span>
</Button>
))}
</div>
))}
</div>
</div>
</Modal>
);
}
@@ -1,53 +0,0 @@
/* Read-only classification-vocabulary viewer in the policy wizard. */
.classification-summary {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.classification-summary-stats {
display: flex;
gap: var(--space-3);
font-size: 0.8125rem;
color: var(--color-text-2);
}
.classification-summary-note {
font-size: 0.75rem;
color: var(--color-text-3);
}
/* Expandable category → labels list. */
.classification-categories {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.classification-category {
border-top: 1px solid var(--border-default);
}
.classification-category:last-child {
border-bottom: 1px solid var(--border-default);
}
.classification-category-header {
min-height: 2.5rem;
}
.classification-category-lead {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.classification-category-name {
font-weight: 600;
font-size: 0.875rem;
}
.classification-category-count {
font-size: 0.75rem;
color: var(--color-text-3);
}
.classification-category-labels {
display: flex;
flex-wrap: wrap;
gap: var(--space-1_5);
padding: 0 var(--space-2) var(--space-2) 2rem;
}
@@ -1,97 +0,0 @@
// Read-only view of the classification vocabulary shown in the policy wizard. The labels and their
// categories are a fixed, built-in set shared across the whole team — there's nothing to edit, but
// the full vocabulary is browsable: expand a category to see the labels it groups.
import { useState } from "react";
import { useTranslation } from "react-i18next";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import { Button, Card, Chip } from "@app/ui";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import {
DEFAULT_CLASSIFICATION_LABELS,
LABEL_FAMILIES,
} from "@app/data/classificationLabels";
import "@portal/components/policies/ClassificationLabelsSection.css";
export function ClassificationLabelsSection() {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<Card>
<div className="classification-summary">
<div className="classification-summary-stats">
<span>
<strong>{DEFAULT_CLASSIFICATION_LABELS.length}</strong>{" "}
{t("policies.labels.labelCount", "labels")}
</span>
<span>
<strong>{LABEL_FAMILIES.length}</strong>{" "}
{t("policies.labels.categoryCount", "categories")}
</span>
</div>
<ul className="classification-categories">
{LABEL_FAMILIES.map((family) => {
const open = expanded.has(family.id);
return (
<li key={family.id} className="classification-category">
<Button
variant="quiet"
fullWidth
justify="between"
className="classification-category-header"
aria-expanded={open}
onClick={() => toggle(family.id)}
leftSection={
<span className="classification-category-lead">
{open ? (
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
) : (
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
)}
<LocalIcon icon={family.icon} width="1.1rem" />
<span className="classification-category-name">
{family.name}
</span>
</span>
}
rightSection={
<span className="classification-category-count">
{family.labels.length}
</span>
}
/>
{open && (
<div className="classification-category-labels">
{family.labels.map((label) => (
<Chip key={label.id} accent="neutral" size="sm">
{t(`classification.labels.${label.id}`, label.name)}
</Chip>
))}
</div>
)}
</li>
);
})}
</ul>
<span className="classification-summary-note">
{t(
"policies.labels.sharedNote",
"These labels are built in and shared across your whole team.",
)}
</span>
</div>
</Card>
);
}
@@ -1,28 +1,19 @@
import { useTranslation } from "react-i18next";
import { Card, Chip, StatusBadge } from "@app/ui";
import type { CatalogueEntry } from "@portal/api/policies";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import { policyIcon } from "@portal/components/policies/policyIcons";
import "@portal/views/Policies.css";
interface PolicyCategoryCardProps {
entry: CatalogueEntry;
onOpen: (entry: CatalogueEntry) => void;
/** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */
locked?: boolean;
/** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */
lockedLabel?: string;
}
export function PolicyCategoryCard({
entry,
onOpen,
locked = false,
lockedLabel,
}: PolicyCategoryCardProps) {
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
const { t } = useTranslation();
const { category, config, policy } = entry;
const comingSoon = category.comingSoon === true;
const openable = !comingSoon && !locked;
const openable = !comingSoon;
const status = policy?.state.status;
const enforces = config.rules.map((r) => t(r)).join(" · ");
@@ -30,7 +21,7 @@ export function PolicyCategoryCard({
<Card
className={
"portal-policies__card" +
(comingSoon || locked ? " portal-policies__card--locked" : "")
(comingSoon ? " portal-policies__card--locked" : "")
}
interactive={openable}
onClick={openable ? () => onOpen(entry) : undefined}
@@ -48,7 +39,7 @@ export function PolicyCategoryCard({
}
>
<span className="portal-policies__cat-icon" aria-hidden>
{policyCategoryIcon(category.id)}
{policyIcon(category.icon)}
</span>
<div className="portal-policies__card-identity">
@@ -62,10 +53,6 @@ export function PolicyCategoryCard({
<Chip accent="neutral" size="sm">
{t("portal.policies.card.comingSoon")}
</Chip>
) : locked ? (
<Chip accent="neutral" size="sm">
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
</Chip>
) : policy ? (
<div className="portal-policies__card-meta">
<span className="portal-policies__card-statpair">
@@ -8,9 +8,6 @@ import {
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const classification = POLICY_CATEGORIES.find(
(c) => c.id === "classification",
)!;
const meta: Meta<typeof PolicySetupWizard> = {
title: "Portal/Policies/PolicySetupWizard",
@@ -50,14 +47,3 @@ export const Edit: Story = {
},
},
};
/** Classification: the workflow step shows the team label editor, not tool toggles. */
export const Classification: Story = {
args: {
entry: {
category: classification,
config: POLICY_CONFIG.classification,
policy: null,
},
},
};
@@ -1,10 +1,5 @@
import { useMemo, useState, type ReactNode } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import CheckIcon from "@mui/icons-material/Check";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import StorageOutlinedIcon from "@mui/icons-material/StorageOutlined";
import {
Banner,
Button,
@@ -34,27 +29,12 @@ import {
import { fetchSources } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection";
import "@portal/views/Policies.css";
/** Outline icon for a source tile, keyed by the backend source `type`. */
function sourceIcon(type: string): ReactNode {
const sx = { fontSize: "1.1rem" } as const;
switch (type) {
case "editor":
return <EditOutlinedIcon sx={sx} />;
case "folder":
return <FolderOutlinedIcon sx={sx} />;
case "s3":
return <CloudOutlinedIcon sx={sx} />;
default:
return <StorageOutlinedIcon sx={sx} />;
}
}
interface PolicySetupWizardProps {
/** The category being configured, or null when closed. */
entry: CatalogueEntry | null;
@@ -139,13 +119,6 @@ const CAPABILITY_META: Record<
descKey: "portal.policies.wizard.capability.compress.desc",
descEn: "Compresses the document to a smaller file size.",
},
classify: {
labelKey: "portal.policies.wizard.capability.classify.label",
labelEn: "Classify the document",
descKey: "portal.policies.wizard.capability.classify.desc",
descEn:
"Identifies the document's type from your team's labels and tags it, so it files and searches by category.",
},
};
function seedTools(entry: CatalogueEntry): ToolState[] {
@@ -206,18 +179,9 @@ function PolicySetupWizardBody({
const { category, config, policy } = entry;
const isEdit = policy != null;
const isClassification = category.id === "classification";
const [step, setStep] = useState<Step>("workflow");
const [tools, setTools] = useState<ToolState[]>(() => {
const seeded = seedTools(entry);
// Classification's single tool has no toggle in the workflow step, so keep it
// enabled unconditionally — otherwise editing a policy whose saved steps
// somehow lack it would strand submit with no way to re-enable it.
return isClassification
? seeded.map((t) => ({ ...t, enabled: true }))
: seeded;
});
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(entry),
);
@@ -226,25 +190,11 @@ function PolicySetupWizardBody({
);
const sourcesAsync = useAsync(() => fetchSources(), []);
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
);
// The editor is always an available source. The backend now returns it as a
// virtual source too, so take that when present (avoids a duplicate tile) and
// otherwise fall back to a synthetic one; keep it first, selected by default.
const editorSource = backendSources.find((s) => s.id === "editor") ?? {
id: "editor",
name: t("portal.sources.types.editor.label"),
type: "editor",
status: "active" as const,
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: null,
};
return [editorSource, ...backendSources.filter((s) => s.id !== "editor")];
}, [sourcesAsync.data, t]);
const availableSources = useMemo(
() =>
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
[sourcesAsync.data],
);
// Document-type scoping has no UI; preserve any saved scope on edit and
// default new policies to all document types.
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
@@ -335,7 +285,7 @@ function PolicySetupWizardBody({
title={
<span className="portal-policies__wizard-title">
<span className="portal-policies__cat-icon" aria-hidden>
{policyCategoryIcon(category.id)}
{policyIcon(category.icon)}
</span>
{isEdit
? t("portal.policies.wizard.title.edit", {
@@ -399,25 +349,7 @@ function PolicySetupWizardBody({
/>
)}
{step === "workflow" && isClassification && (
<div className="portal-policies__wizard-section">
<p className="portal-policies__wizard-desc">
{t(
"portal.policies.wizard.classification.description",
"Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team.",
)}
</p>
<h3 className="portal-policies__wizard-heading">
{t(
"portal.policies.wizard.classification.labelsHeading",
"Classification labels",
)}
</h3>
<ClassificationLabelsSection />
</div>
)}
{step === "workflow" && !isClassification && (
{step === "workflow" && (
<div className="portal-policies__wizard-section">
<p className="portal-policies__wizard-desc">
{t(
@@ -516,38 +448,35 @@ function PolicySetupWizardBody({
// The backend always returns the editor as a virtual source, so the
// loaded list is never empty - no "no sources" state exists.
<div className="portal-policies__sources">
{availableSources.map((src) => {
const on = sources.includes(src.id);
return (
<Button
key={src.id}
variant={on ? "secondary" : "quiet"}
justify="between"
fullWidth
className={
"portal-policies__source" +
(on ? " portal-policies__source--on" : "")
}
// The check keeps its slot when unselected (hidden) so the
// icon + name stay put whether or not the tile is selected.
rightSection={
<CheckIcon
sx={{
fontSize: "1.1rem",
visibility: on ? "visible" : "hidden",
}}
/>
}
onClick={() => toggleSource(src.id)}
aria-pressed={on}
>
{availableSources.map((src) => (
// A selectable multi-line tile (icon + name + type + check).
// Uses the shared Button (raw <button> is lint-banned); the tile
// CSS overrides the Button's fixed height for the two-line layout.
<Button
key={src.id}
variant="quiet"
justify="start"
className={
"portal-policies__source" +
(sources.includes(src.id)
? " portal-policies__source--on"
: "")
}
onClick={() => toggleSource(src.id)}
>
<span className="portal-policies__source-icon" aria-hidden>
{sourceTypeMeta(src.type).icon}
</span>
<span className="portal-policies__source-text">
<span className="portal-policies__source-label">
{sourceIcon(src.type)}
{src.name}
</span>
</Button>
);
})}
<span className="portal-policies__source-desc">
{src.type}
</span>
</span>
</Button>
))}
</div>
)}
@@ -0,0 +1,25 @@
/**
* Glyphs for the policy catalogue's string icon keys. The catalogue model
* carries semantic keys (e.g. "shield", "layers") rather than React nodes so
* the data stays portable; the portal owns the rendering and maps each key to a
* glyph here.
*/
export const POLICY_ICON_GLYPHS: Record<string, string> = {
layers: "▤",
shield: "🛡",
check: "✓",
route: "⇉",
clock: "⏲",
// Source icons.
file: "▢",
device: "▣",
globe: "◍",
cloud: "☁",
mail: "✉",
folder: "▤",
};
/** Resolve an icon key to its glyph, falling back to a neutral dot. */
export function policyIcon(key: string): string {
return POLICY_ICON_GLYPHS[key] ?? "•";
}
@@ -7,6 +7,11 @@ import {
} from "react";
interface UIContextValue {
searchOpen: boolean;
openSearch: () => void;
closeSearch: () => void;
toggleSearch: () => void;
assistantOpen: boolean;
openAssistant: () => void;
closeAssistant: () => void;
@@ -42,6 +47,7 @@ interface UIContextValue {
const UIContext = createContext<UIContextValue | null>(null);
export function UIProvider({ children }: { children: ReactNode }) {
const [searchOpen, setSearchOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<
@@ -57,6 +63,11 @@ export function UIProvider({ children }: { children: ReactNode }) {
const value = useMemo<UIContextValue>(
() => ({
searchOpen,
openSearch: () => setSearchOpen(true),
closeSearch: () => setSearchOpen(false),
toggleSearch: () => setSearchOpen((o) => !o),
assistantOpen,
openAssistant: () => setAssistantOpen(true),
closeAssistant: () => setAssistantOpen(false),
@@ -97,6 +108,7 @@ export function UIProvider({ children }: { children: ReactNode }) {
},
}),
[
searchOpen,
assistantOpen,
settingsOpen,
settingsInitialSection,
@@ -1,26 +0,0 @@
// AI-engine flag from the backend's public app-config. Gates Classification
// setup: the card always shows but can't be enabled until the engine is on.
// `loading` lets callers hold the decision rather than flash a locked card.
import { apiClient } from "@portal/api/http";
import { useAsync } from "@portal/hooks/useAsync";
interface AppConfigShape {
aiEngineEnabled?: boolean;
}
export interface AiEngineState {
enabled: boolean;
loading: boolean;
}
export function useAiEngineEnabled(): AiEngineState {
const state = useAsync<AppConfigShape>(
() => apiClient.local.json<AppConfigShape>("/api/v1/config/app-config"),
[],
);
return {
enabled: Boolean(state.data?.aiEngineEnabled),
loading: state.loading && state.data === null,
};
}
@@ -1,292 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { withBasePath } from "@app/constants/app";
import { getToolUrlPath } from "@app/data/toolsTaxonomy";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { rankByFuzzy } from "@app/utils/fuzzySearch";
import type { ToolId } from "@app/types/toolId";
import type { ProcessorSearchEntry } from "@app/data/processorSearchIndex";
import {
assembleSuperSearchGroups,
rankProcessorResults,
rankSettingsResults,
rankToolResults,
type SuperSearchGates,
type SuperSearchGroup,
type SuperSearchGroupId,
type UseSuperSearchResult,
} from "@app/hooks/useSuperSearch";
import { useUI } from "@portal/contexts/UIContext";
import { useTier } from "@portal/contexts/TierContext";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { EDITOR_IS_SAME_APP, EDITOR_URL } from "@portal/auth/editorUrl";
import { fetchUsers, type Member } from "@portal/api/users";
import { fetchPolicies, type CatalogueEntry } from "@portal/api/policies";
import { fetchPipelines, type PipelineView } from "@portal/api/pipelines";
import { fetchSources, type SourceView } from "@portal/api/sources";
import {
UsersIcon,
PoliciesIcon,
PipelinesIcon,
SourcesIcon,
} from "@portal/components/icons";
/** Processor pages lead in the portal; the editor bar orders its own first. */
const PORTAL_GROUP_ORDER: SuperSearchGroupId[] = [
"processor",
"tools",
"settings",
];
/** Entity groups cap lower than the shared GROUP_LIMIT — with up to seven
* groups in the portal dropdown, six rows each stops being scannable. */
const ENTITY_GROUP_LIMIT = 4;
/**
* Tool results live in the editor app, so selecting one is a full page load
* there (the editor initialises its tool state from the URL on boot —
* client-side routing can't reach that init once mounted).
*/
function editorHref(path: string): string {
if (EDITOR_IS_SAME_APP) return withBasePath(path);
return EDITOR_URL.replace(/\/$/, "") + path;
}
interface PortalEntities {
users: Member[];
policies: CatalogueEntry[];
pipelines: PipelineView[];
sources: SourceView[];
}
const NO_ENTITIES: PortalEntities = {
users: [],
policies: [],
pipelines: [],
sources: [],
};
/**
* Loads the portal's searchable entities whenever the search surface opens,
* then ranking happens client-side per keystroke. allSettled so one failing
* endpoint (e.g. policies on a backend without them) doesn't drop the rest.
*/
function usePortalEntities(active: boolean): PortalEntities {
const { tier } = useTier();
const [entities, setEntities] = useState<PortalEntities>(NO_ENTITIES);
useEffect(() => {
if (!active) return;
let cancelled = false;
Promise.allSettled([
fetchUsers(tier),
fetchPolicies(),
fetchPipelines(),
fetchSources(),
]).then(([users, policies, pipelines, sources]) => {
if (cancelled) return;
setEntities({
users: users.status === "fulfilled" ? users.value.members : [],
policies:
policies.status === "fulfilled" ? policies.value.catalogue : [],
pipelines:
pipelines.status === "fulfilled" ? pipelines.value.pipelines : [],
sources: sources.status === "fulfilled" ? sources.value.sources : [],
});
});
return () => {
cancelled = true;
};
}, [active, tier]);
return entities;
}
/**
* The portal's results provider for the shared super search bar: the same
* sources the editor bar ranks minus files (a file only opens inside the
* editor), with Processor pages leading and the portal's own entities —
* users, policies, pipelines, sources — ranked between the pages and the
* shared groups. Tools hand over to the editor, settings opens the portal's
* settings modal, everything else navigates in-app.
*/
export function usePortalSearchResults(
query: string,
active: boolean,
): UseSuperSearchResult {
const { t } = useTranslation();
const navigate = useNavigate();
const { openSettings } = useUI();
const { allTools } = useToolRegistry();
const { config } = useAppConfig();
const trimmed = query.trim();
const entities = usePortalEntities(active);
const openTool = useCallback((id: ToolId) => {
window.location.assign(editorHref(getToolUrlPath(id)));
}, []);
const openSettingsSection = useCallback(
(section: string) => openSettings(section),
[openSettings],
);
const selectProcessorEntry = useCallback(
(item: ProcessorSearchEntry) => {
if (item.externalUrl) {
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
} else {
navigate(item.path);
}
},
[navigate],
);
const gates = useMemo<SuperSearchGates>(
() => ({
isAdmin: config?.isAdmin ?? false,
loginEnabled: config?.enableLogin ?? false,
}),
[config],
);
const entityGroups = useMemo<SuperSearchGroup[]>(() => {
if (!trimmed) return [];
const groups: SuperSearchGroup[] = [];
const users = rankByFuzzy(entities.users, trimmed, [
(m) => m.name,
(m) => m.email,
])
.slice(0, ENTITY_GROUP_LIMIT)
.map(({ item, score }) => ({
key: `portal-user:${item.id}`,
group: "portal-users",
title: item.name,
subtitle: item.email,
icon: <UsersIcon />,
score,
onSelect: () => navigate(toPortalPath(VIEW_PATHS.users)),
}));
if (users.length > 0) {
groups.push({
id: "portal-users",
label: t("portal.nav.users"),
results: users,
});
}
const policies = rankByFuzzy(
entities.policies.filter((e) => !e.category.comingSoon),
trimmed,
[(e) => e.category.label, (e) => e.category.desc],
)
.slice(0, ENTITY_GROUP_LIMIT)
.map(({ item, score }) => ({
key: `portal-policy:${item.category.id}`,
group: "portal-policies",
title: item.category.label,
subtitle: item.category.desc,
icon: <PoliciesIcon />,
score,
onSelect: () => navigate(toPortalPath(VIEW_PATHS.policies)),
}));
if (policies.length > 0) {
groups.push({
id: "portal-policies",
label: t("portal.nav.policies"),
results: policies,
});
}
const pipelines = rankByFuzzy(entities.pipelines, trimmed, [
(p) => p.name,
(p) => p.trigger,
])
.slice(0, ENTITY_GROUP_LIMIT)
.map(({ item, score }) => ({
key: `portal-pipeline:${item.id}`,
group: "portal-pipelines",
title: item.name,
subtitle: item.trigger,
icon: <PipelinesIcon />,
score,
onSelect: () =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${item.id}`),
}));
if (pipelines.length > 0) {
groups.push({
id: "portal-pipelines",
label: t("portal.nav.pipelines"),
results: pipelines,
});
}
const sources = rankByFuzzy(entities.sources, trimmed, [
(s) => s.name,
(s) => s.type,
])
.slice(0, ENTITY_GROUP_LIMIT)
.map(({ item, score }) => ({
key: `portal-source:${item.id}`,
group: "portal-sources",
title: item.name,
subtitle: item.type,
icon: <SourcesIcon />,
score,
onSelect: () =>
navigate(`${toPortalPath(VIEW_PATHS.sources)}/${item.id}`),
}));
if (sources.length > 0) {
groups.push({
id: "portal-sources",
label: t("portal.nav.sources"),
results: sources,
});
}
return groups;
}, [entities, trimmed, t, navigate]);
const groups = useMemo(() => {
const shared = assembleSuperSearchGroups(
{
tools: rankToolResults(allTools, trimmed, openTool),
settings: rankSettingsResults(trimmed, t, gates, openSettingsSection),
processor: rankProcessorResults(
trimmed,
t,
gates,
selectProcessorEntry,
),
},
t,
PORTAL_GROUP_ORDER,
);
// Entities slot in right after the Processor pages (index -1 + 1 = 0
// puts them first when no page matched).
const out = [...shared];
out.splice(
out.findIndex((g) => g.id === "processor") + 1,
0,
...entityGroups,
);
return out;
}, [
trimmed,
allTools,
openTool,
gates,
openSettingsSection,
selectProcessorEntry,
entityGroups,
t,
]);
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
return { groups, flatResults, loadingFiles: false };
}
@@ -1,10 +0,0 @@
import { http, HttpResponse } from "msw";
// The Classification policy only needs the app-config `aiEngineEnabled` flag to be
// on; its label vocabulary is a fixed built-in set (no team endpoint anymore).
export const classificationHandlers = [
http.get("/api/v1/config/app-config", () =>
HttpResponse.json({ aiEngineEnabled: true }),
),
];
@@ -1,6 +1,7 @@
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
import { authHandlers } from "@portal/mocks/handlers/auth";
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
import { searchHandlers } from "@portal/mocks/handlers/search";
import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
@@ -11,7 +12,6 @@ import { usersHandlers } from "@portal/mocks/handlers/users";
import { teamSaasHandlers } from "@portal/mocks/handlers/teamSaas";
import { agentsHandlers } from "@portal/mocks/handlers/agents";
import { policiesHandlers } from "@portal/mocks/handlers/policies";
import { classificationHandlers } from "@portal/mocks/handlers/classification";
import { documentsHandlers } from "@portal/mocks/handlers/documents";
import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents";
import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy";
@@ -21,6 +21,7 @@ export const handlers = [
...authHandlers,
...notificationsHandlers,
...assistantHandlers,
...searchHandlers,
...pipelinesHandlers,
...sourcesHandlers,
...infrastructureHandlers,
@@ -31,7 +32,6 @@ export const handlers = [
...teamSaasHandlers,
...agentsHandlers,
...policiesHandlers,
...classificationHandlers,
...documentsHandlers,
...sdkComponentsHandlers,
...editorDeployHandlers,
@@ -0,0 +1,8 @@
import { http, HttpResponse } from "msw";
import { QUICK_ACTIONS } from "@portal/mocks/search";
export const searchHandlers = [
http.get("/v1/search/quick-actions", () => {
return HttpResponse.json(QUICK_ACTIONS);
}),
];
@@ -0,0 +1,17 @@
/**
* Mock quick-action catalogue for the ⌘K search palette. The QuickAction type
* lives in api/search.ts (the backend contract); this module only builds fake
* data for Storybook and tests.
*/
import type { QuickAction } from "@portal/api/search";
export const QUICK_ACTIONS: QuickAction[] = [
{ group: "Jump to", label: "Home", hint: "G H" },
{ group: "Jump to", label: "Pipelines", hint: "G P" },
{ group: "Jump to", label: "Sources", hint: "G S" },
{ group: "Jump to", label: "Documents", hint: "G D" },
{ group: "Create", label: "New pipeline", hint: "N P" },
{ group: "Create", label: "New API key", hint: "N K" },
{ group: "Theme", label: "Toggle dark / light", hint: "T" },
];
+101 -8
View File
@@ -266,23 +266,116 @@
}
/* Sources picker */
/* Selectable source tiles: a vertical stack of full-width shared Buttons
(icon · name · check). Layout is the Button's own leftSection/label/
rightSection — only the selected border/tint is added here. */
.portal-policies__sources {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
@media (max-width: 40rem) {
.portal-policies__sources {
grid-template-columns: 1fr;
}
}
.portal-policies__source {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem;
text-align: left;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
/* Shared Button pins a fixed control height and natural width; this tile is a
full-width, two-line card, so fill the grid cell and grow to fit content. */
width: 100%;
height: auto;
font-weight: inherit;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
/* The shared Button wraps children in an inner/label node. Let the inner grow
(so the ::after check sits at the far right) and the label carry the
icon + two-line-text row. */
.portal-policies__source .mantine-Button-inner {
flex: 1;
min-width: 0;
}
.portal-policies__source .mantine-Button-label {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
white-space: normal;
}
.portal-policies__source::after {
content: "";
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
width: 1rem;
height: 1rem;
border-radius: var(--radius-sm);
border: 1.5px solid var(--color-border-strong);
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-policies__source:hover {
background: var(--color-bg-hover);
}
.portal-policies__source--on {
border-color: var(--color-blue);
background: var(--color-blue-light);
}
.portal-policies__source--on::after {
content: "✓";
font-size: 0.6875rem;
font-weight: 700;
color: #fff;
border-color: var(--color-blue);
background: var(--color-blue);
}
.portal-policies__source-icon {
font-size: 1rem;
line-height: 1.2;
flex-shrink: 0;
color: var(--color-text-3);
}
.portal-policies__source--on .portal-policies__source-icon {
color: var(--color-blue);
}
.portal-policies__source-text {
display: flex;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
}
.portal-policies__source-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-policies__source-desc {
font-size: 0.6875rem;
color: var(--color-text-4);
line-height: 1.4;
}
.portal-policies__link {
@@ -20,7 +20,6 @@ import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
import { PolicyCategoryCard } from "@portal/components/policies/PolicyCategoryCard";
import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled";
import "@portal/views/Policies.css";
export function Policies() {
@@ -35,18 +34,6 @@ export function Policies() {
const [busy, setBusy] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const { enabled: aiEngineEnabled, loading: aiEngineLoading } =
useAiEngineEnabled();
function isLocked(entry: CatalogueEntry): boolean {
return (
entry.category.requiresAiEngine === true &&
!aiEngineEnabled &&
!aiEngineLoading &&
!entry.policy
);
}
const catalogue = data?.catalogue ?? [];
const refetch = useCallback(() => setVersion((v) => v + 1), []);
// The catalogue cards are always shown (they're the "configure a policy" CTAs),
@@ -70,11 +57,6 @@ export function Policies() {
}));
function openEntry(entry: CatalogueEntry) {
// Block setup of an AI-required policy until the engine is confirmed on (so a
// click during the app-config load can't open a wizard for a disabled
// feature); a configured policy stays openable so it can be paused/deleted.
if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy)
return;
if (entry.policy) setDetail(entry);
else setWizard(entry);
}
@@ -175,8 +157,6 @@ export function Policies() {
key={entry.category.id}
entry={entry}
onOpen={openEntry}
locked={isLocked(entry)}
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
))}
</div>
@@ -1,37 +0,0 @@
/* Category show/hide list (Files-sidebar visibility picker). */
.category-visibility {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.category-visibility-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 2.25rem;
padding: 0 var(--space-1);
}
.category-visibility-icon {
display: inline-flex;
align-items: center;
flex: none;
color: var(--color-text-2);
}
.category-visibility-name {
flex: 1;
min-width: 0;
font-size: 0.875rem;
color: var(--color-text-1);
}
.category-visibility-count {
font-size: 0.75rem;
color: var(--color-text-3);
}
/* Hidden categories read as dimmed until re-shown. */
.category-visibility-row--hidden .category-visibility-icon,
.category-visibility-row--hidden .category-visibility-name,
.category-visibility-row--hidden .category-visibility-count {
opacity: 0.5;
}
@@ -1,46 +0,0 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ClassificationCategoryManager } from "@app/components/policies/ClassificationCategoryManager";
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
const CATEGORIES: SidebarCategory[] = [
{ id: "finance", name: "Financial", icon: "payments", labelKeys: [] },
{ id: "legal", name: "Legal", icon: "gavel", labelKeys: [] },
{ id: "hr", name: "HR", icon: "badge", labelKeys: [] },
{ id: "reports", name: "Reports", icon: "monitoring", labelKeys: [] },
];
const COUNTS = new Map<string, number>([
["finance", 12],
["legal", 4],
["reports", 7],
]);
// The store is device-local; the story holds the hidden state so the toggle is live.
function Harness() {
const [categories, setCategories] = useState(CATEGORIES);
return (
<div style={{ maxWidth: 420 }}>
<ClassificationCategoryManager
categories={categories}
counts={COUNTS}
onToggleHidden={(id, hidden) =>
setCategories((prev) =>
prev.map((c) => (c.id === id ? { ...c, hidden } : c)),
)
}
/>
</div>
);
}
const meta: Meta<typeof Harness> = {
title: "Policies/ClassificationCategoryManager",
component: Harness,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Harness>;
/** Show/hide the fixed, shared categories in the Files sidebar. */
export const Default: Story = {};
@@ -1,68 +0,0 @@
// Uber-simple category visibility list for the Files sidebar. Categories are the fixed, shared set
// (the built-in label families) — they can't be created, renamed, re-grouped, or have their labels
// edited. The only choice is showing or hiding each one in this device's sidebar; a hidden category
// forms no group, so its files fall to "Other".
import { useTranslation } from "react-i18next";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import { ActionIcon } from "@app/ui/ActionIcon";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
import "@app/components/policies/ClassificationCategoryManager.css";
interface ClassificationCategoryManagerProps {
categories: SidebarCategory[];
onToggleHidden: (id: string, hidden: boolean) => void;
/** Optional per-category file counts. */
counts?: Map<string, number>;
}
export function ClassificationCategoryManager({
categories,
onToggleHidden,
counts,
}: ClassificationCategoryManagerProps) {
const { t } = useTranslation();
return (
<ul className="category-visibility">
{categories.map((category) => {
const count = counts?.get(category.id);
return (
<li
key={category.id}
className={
category.hidden
? "category-visibility-row category-visibility-row--hidden"
: "category-visibility-row"
}
>
<span className="category-visibility-icon">
<LocalIcon icon={category.icon} width="1.1rem" />
</span>
<span className="category-visibility-name">{category.name}</span>
{count !== undefined && (
<span className="category-visibility-count">{count}</span>
)}
<ActionIcon
variant="quiet"
aria-pressed={!category.hidden}
aria-label={
category.hidden
? t("policies.labels.showCategory", "Show category")
: t("policies.labels.hideCategory", "Hide category")
}
onClick={() => onToggleHidden(category.id, !category.hidden)}
>
{category.hidden ? (
<VisibilityOffIcon sx={{ fontSize: "1.1rem" }} />
) : (
<VisibilityIcon sx={{ fontSize: "1.1rem" }} />
)}
</ActionIcon>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,176 @@
/**
* The Classification policy's team-labels control, shown in its Edit-Settings
* view: a compact summary (count + chips) with an Expand button that opens the
* fat {@link LabelsEditorModal}. Team-shared; only users who can configure
* policies may edit it. Owns the editable draft and the load/save/import/export
* wiring via {@link useClassificationLabels}.
*/
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Card } from "@app/ui/Card";
import { Button } from "@app/ui/Button";
import { Chip } from "@app/ui/Chip";
import { Banner } from "@app/ui/Banner";
import { useClassificationLabels } from "@app/hooks/useClassificationLabels";
import { LabelsEditorModal } from "@app/components/policies/LabelsEditorModal";
import {
downloadLabels,
parseLabelsFile,
validateLabels,
} from "@app/services/labelsFile";
import {
DEFAULT_CLASSIFICATION_LABELS,
type ClassificationLabel,
} from "@app/data/classificationLabels";
import "@app/components/policies/LabelsEditor.css";
/** Chips shown in the collapsed team summary before it gets noisy. */
const SUMMARY_CHIP_COUNT = 12;
interface ClassificationLabelsSectionProps {
canConfigure: boolean;
}
export function ClassificationLabelsSection({
canConfigure,
}: ClassificationLabelsSectionProps) {
const { t } = useTranslation();
const { teamLabels, isCustom, loading, saving, error, saveTeam } =
useClassificationLabels(true);
const [draft, setDraft] = useState<ClassificationLabel[]>(teamLabels);
const [open, setOpen] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
// Sync the draft to server truth whenever it changes (load / save / reset).
// Local edits don't change `teamLabels`, so this never clobbers them mid-edit.
useEffect(() => setDraft(teamLabels), [teamLabels]);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(teamLabels),
[draft, teamLabels],
);
const close = () => {
setDraft(teamLabels);
setLocalError(null);
setOpen(false);
};
const onImportFile = (file: File) => {
setLocalError(null);
void parseLabelsFile(file)
.then(setDraft)
.catch((e: unknown) =>
setLocalError(
e instanceof Error
? e.message
: t("policies.labels.importError", "Couldn't import that file."),
),
);
};
const onSave = () => {
const errors = validateLabels({ labels: draft });
if (errors.length > 0) {
setLocalError(errors[0]);
return;
}
setLocalError(null);
void saveTeam(draft).then(() => setOpen(false));
};
return (
<div className="labels-summary">
<p className="pol-section-label">
{t("policies.labels.sectionLabel", "Classification labels")}
</p>
<Card>
{loading ? (
<span className="labels-empty">{t("loading", "Loading…")}</span>
) : (
<div className="labels-summary">
<div className="labels-summary-stats">
<span>
<strong>{teamLabels.length}</strong>{" "}
{t("policies.labels.teamCount", "team labels")}
</span>
</div>
<div className="labels-chips">
{teamLabels.slice(0, SUMMARY_CHIP_COUNT).map((label) => (
<Chip key={label.id} accent="neutral" size="sm">
{label.name}
</Chip>
))}
{teamLabels.length > SUMMARY_CHIP_COUNT && (
<Chip accent="neutral" size="sm">
+{teamLabels.length - SUMMARY_CHIP_COUNT}
</Chip>
)}
</div>
<span className="labels-summary-note">
{isCustom
? t("policies.labels.customNote", "Customized for your team.")
: t(
"policies.labels.defaultNote",
"Using the built-in default, shared with your team.",
)}
</span>
<Button
variant="secondary"
size="sm"
leftSection={<OpenInFullIcon sx={{ fontSize: "1rem" }} />}
onClick={() => setOpen(true)}
style={{ alignSelf: "flex-start" }}
>
{canConfigure
? t("policies.labels.edit", "Edit labels")
: t("policies.labels.view", "View labels")}
</Button>
</div>
)}
</Card>
{!canConfigure && (
<Banner
tone="neutral"
icon={<LockOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={t(
"policies.labels.managedNote",
"Team labels are managed by your team leader.",
)}
/>
)}
{error && !open && <Banner tone="danger" description={error} />}
<LabelsEditorModal
open={open}
onClose={close}
draft={draft}
onDraftChange={setDraft}
onImportFile={onImportFile}
onExport={() => downloadLabels(draft)}
onReset={() => {
// Stage the built-in default into the draft — reversible via Cancel,
// only persisted on Save (no immediate destructive server delete).
setLocalError(null);
setDraft(DEFAULT_CLASSIFICATION_LABELS);
}}
onClear={() => {
// Stage an empty list to build from scratch — also reversible until Save.
setLocalError(null);
setDraft([]);
}}
onSave={onSave}
dirty={dirty}
saving={saving}
readOnly={!canConfigure}
error={localError ?? error}
/>
</div>
);
}
@@ -0,0 +1,92 @@
/**
* Small popover picker for a classification label's icon: a trigger button
* showing the current icon, opening a grid of the curated
* {@link LABEL_ICON_OPTIONS}. Icons render via {@link LocalIcon} (Material
* Symbols). No search yet — the palette is small enough to scan.
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Popover, SimpleGrid, Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
import { LABEL_ICON_OPTIONS, DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
interface LabelIconPickerProps {
/** Current icon key, or undefined to show the default placeholder. */
value?: string;
onChange: (icon: string) => void;
ariaLabel: string;
}
export function LabelIconPicker({
value,
onChange,
ariaLabel,
}: LabelIconPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<Popover
opened={open}
onChange={setOpen}
onDismiss={() => setOpen(false)}
position="bottom-start"
withArrow
trapFocus
withinPortal
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
>
<Popover.Target>
<ActionIcon
variant="quiet"
className="labels-icon-pick"
onClick={() => setOpen((o) => !o)}
aria-label={ariaLabel}
aria-haspopup="true"
>
<LocalIcon icon={value || DEFAULT_LABEL_ICON} width="1.15rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="xs" style={{ maxHeight: 280, overflowY: "auto" }}>
<SimpleGrid cols={10} spacing={4} verticalSpacing={4}>
{LABEL_ICON_OPTIONS.map((option) => {
const iconLabel = t(
`policies.labels.iconName.${option.icon}`,
option.label,
);
return (
<Tooltip
key={option.icon}
label={iconLabel}
withArrow
openDelay={300}
>
<ActionIcon
variant="quiet"
className={`labels-icon-option${option.icon === value ? " is-selected" : ""}`}
onClick={() => {
onChange(option.icon);
setOpen(false);
}}
aria-label={t(
"policies.labels.pickIcon",
"Use {{label}} icon",
{
label: iconLabel,
},
)}
aria-pressed={option.icon === value}
>
<LocalIcon icon={option.icon} width="1.25rem" />
</ActionIcon>
</Tooltip>
);
})}
</SimpleGrid>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,183 @@
/* Classification-labels editor: add box, chip grid, icon picker, and the
summary/modal chrome around them. */
.labels-editor {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-height: 0;
}
/* ---- Add box ---- */
.labels-add {
display: flex;
align-items: center;
gap: var(--space-2);
}
.labels-add-input {
flex: 1;
min-width: 0;
padding: 0.4rem 0.6rem;
font-size: 0.8125rem;
font-family: inherit;
color: var(--color-text-1);
background: var(--color-surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--motion-fast);
}
.labels-add-input:focus {
border-color: var(--color-blue);
}
.labels-add-error {
margin: 0;
font-size: 0.75rem;
color: var(--color-red);
}
.labels-empty {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-3);
}
/* ---- Chip grid (chips themselves are the shared @app/ui/LabelChip) ---- */
.labels-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1_5);
overflow-y: auto;
min-height: 0;
}
/* ---- Icon picker (trigger + popover options) ---- */
.labels-icon-pick {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 1.5rem;
height: 1.5rem;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-2);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.labels-icon-pick:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.labels-icon-option {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: transparent;
border-radius: var(--radius-md);
color: var(--color-text-2);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.labels-icon-option:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.labels-icon-option.is-selected {
background: var(--color-blue-light);
color: var(--color-blue);
}
/* ---- Section summary (Edit-Settings card) ---- */
.labels-summary {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.labels-summary-stats {
display: flex;
gap: var(--space-3);
font-size: 0.8125rem;
color: var(--color-text-2);
}
.labels-summary-note {
font-size: 0.75rem;
color: var(--color-text-3);
}
/* ---- Fat modal ---- */
.labels-modal {
width: min(1100px, 94vw);
max-width: min(1100px, 94vw);
}
.labels-modal-body {
max-height: min(70vh, 640px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 0;
}
.labels-toolbar {
display: flex;
gap: var(--space-2);
}
.labels-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
width: 100%;
}
.labels-footer-left,
.labels-footer-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ---- Grouped mode: chips under collapsible parent categories ---- */
.labels-groups {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.labels-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.labels-group-header {
display: flex;
align-items: center;
gap: var(--space-2);
border: none;
background: transparent;
padding: 0;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-1, inherit);
}
.labels-group-name {
flex: 1;
text-align: left;
}
.labels-group-count {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-3, rgba(128, 128, 128, 0.9));
}
.labels-group-ungrouped {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-2, inherit);
}
@@ -0,0 +1,258 @@
// Editor for a classification-label list: an add box on top, then the labels as chips (icon picker + remove), duplicate names rejected case-insensitively (optionally also against `reservedNames`). In `grouped` mode the chips are organised under collapsible parent categories (the device-local sidebar categories), matching the sidebar's category picker; labels in no category fall under "Ungrouped". Grouping is presentational — editing still mutates the flat list.
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import { Button } from "@app/ui/Button";
import { LabelChip } from "@app/ui/LabelChip";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import { LabelIconPicker } from "@app/components/policies/LabelIconPicker";
import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
import {
getSidebarCategories,
subscribeSidebarCategories,
} from "@app/services/fileSidebarCategories";
import {
labelId,
type ClassificationLabel,
} from "@app/data/classificationLabels";
const MAX_TEXT_LENGTH = 128;
interface LabelsEditorProps {
value: ClassificationLabel[];
onChange: (next: ClassificationLabel[]) => void;
readOnly?: boolean;
/** Names that can't be added here because another set already owns them. */
reservedNames?: string[];
addPlaceholder?: string;
emptyText?: string;
/** Render chips under collapsible parent categories (like the sidebar picker). */
grouped?: boolean;
}
export function LabelsEditor({
value,
onChange,
readOnly = false,
reservedNames,
addPlaceholder,
emptyText,
grouped = false,
}: LabelsEditorProps) {
const { t } = useTranslation();
const [pending, setPending] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const add = () => {
const name = pending.trim();
if (!name) return;
if (name.length > MAX_TEXT_LENGTH) {
setAddError(
t(
"policies.labels.tooLong",
"Labels can be at most {{max}} characters.",
{ max: MAX_TEXT_LENGTH },
),
);
return;
}
const key = name.toLowerCase();
const taken =
value.some((label) => label.name.toLowerCase() === key) ||
(reservedNames ?? []).some((reserved) => reserved.toLowerCase() === key);
if (taken) {
setAddError(
t("policies.labels.duplicate", '"{{name}}" already exists.', { name }),
);
return;
}
setAddError(null);
setPending("");
onChange([...value, { id: labelId(name), name }]);
};
const removeById = (id: string) =>
onChange(value.filter((label) => label.id !== id));
const setIconById = (id: string, icon: string) =>
onChange(
value.map((label) => (label.id === id ? { ...label, icon } : label)),
);
const renderChip = (label: ClassificationLabel) => {
const key = label.id;
return (
<LabelChip
key={key}
label={label.name}
leading={
readOnly ? (
<span className="sui-labelchip-icon">
<LocalIcon icon={label.icon || DEFAULT_LABEL_ICON} width="1rem" />
</span>
) : (
<LabelIconPicker
value={label.icon}
onChange={(icon) => setIconById(key, icon)}
ariaLabel={t(
"policies.labels.iconAria",
"Choose an icon for {{name}}",
{ name: label.name },
)}
/>
)
}
onRemove={readOnly ? undefined : () => removeById(key)}
removeAriaLabel={t("policies.labels.removeAria", "Remove {{name}}", {
name: label.name,
})}
/>
);
};
return (
<div className="labels-editor">
{!readOnly && (
<div className="labels-add">
<input
className="labels-add-input"
value={pending}
maxLength={MAX_TEXT_LENGTH + 1}
placeholder={
addPlaceholder ??
t("policies.labels.addPlaceholder", "Add a label…")
}
onChange={(e) => {
setPending(e.target.value);
if (addError) setAddError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
/>
<Button
variant="secondary"
size="sm"
leftSection={<AddIcon sx={{ fontSize: "1rem" }} />}
onClick={add}
disabled={!pending.trim()}
>
{t("policies.labels.add", "Add")}
</Button>
</div>
)}
{addError && <p className="labels-add-error">{addError}</p>}
{value.length === 0 ? (
<p className="labels-empty">
{emptyText ?? t("policies.labels.empty", "No labels yet.")}
</p>
) : grouped ? (
<GroupedLabels value={value} renderChip={renderChip} />
) : (
<div className="labels-chips" role="list">
{value.map(renderChip)}
</div>
)}
</div>
);
}
interface GroupedLabelsProps {
value: ClassificationLabel[];
renderChip: (label: ClassificationLabel) => ReactNode;
}
/** Chips laid out under collapsible parent categories (device-local structure). */
function GroupedLabels({ value, renderChip }: GroupedLabelsProps) {
const { t } = useTranslation();
const categories = useSyncExternalStore(
subscribeSidebarCategories,
getSidebarCategories,
);
// Category sections that actually contain labels from `value`, plus the leftovers.
const { sections, ungrouped } = useMemo(() => {
const byId = new Map(value.map((l) => [l.id, l]));
const claimed = new Set<string>();
const sections = categories
.map((category) => {
const members = category.labelKeys
.map((id) => byId.get(id))
.filter((l): l is ClassificationLabel => !!l);
members.forEach((m) => claimed.add(m.id));
return { category, members };
})
.filter((s) => s.members.length > 0);
const ungrouped = value.filter((l) => !claimed.has(l.id));
return { sections, ungrouped };
}, [value, categories]);
// Only the first section starts expanded — a scannable overview.
const [expanded, setExpanded] = useState<ReadonlySet<string>>(
() => new Set(sections.length > 0 ? [sections[0].category.id] : []),
);
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div className="labels-groups">
{sections.map(({ category, members }) => {
const isOpen = expanded.has(category.id);
return (
<section key={category.id} className="labels-group">
<Button
variant="quiet"
fullWidth
justify="between"
className="labels-group-header"
aria-expanded={isOpen}
onClick={() => toggle(category.id)}
leftSection={
<>
{isOpen ? (
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
) : (
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
)}
<LocalIcon icon={category.icon} width="1.05rem" />
</>
}
rightSection={
<span className="labels-group-count">{members.length}</span>
}
>
<span className="labels-group-name">{category.name}</span>
</Button>
{isOpen && (
<div className="labels-chips" role="list">
{members.map(renderChip)}
</div>
)}
</section>
);
})}
{ungrouped.length > 0 && (
<section className="labels-group">
<p className="labels-group-ungrouped">
{t("policies.labels.ungrouped", "Ungrouped")}
</p>
<div className="labels-chips" role="list">
{ungrouped.map(renderChip)}
</div>
</section>
)}
</div>
);
}
@@ -0,0 +1,175 @@
/**
* Full-screen ("fat") editor for the team's classification labels — the roomy
* view the settings summary's Edit button opens. Hosts the {@link LabelsEditor}
* chip grid plus an Import/Export toolbar and a footer holding the destructive
* actions (reset / start-from-scratch) and the Save/Cancel buttons. Editing is
* staged: nothing is persisted until Save, which stays disabled until the draft
* actually changes. The draft is owned by the caller so the settings summary
* reflects saved changes.
*/
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import DeleteSweepOutlinedIcon from "@mui/icons-material/DeleteSweepOutlined";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { Modal } from "@app/ui/Modal";
import { Button } from "@app/ui/Button";
import { Banner } from "@app/ui/Banner";
import { LabelsEditor } from "@app/components/policies/LabelsEditor";
import type { ClassificationLabel } from "@app/data/classificationLabels";
interface LabelsEditorModalProps {
open: boolean;
onClose: () => void;
draft: ClassificationLabel[];
onDraftChange: (next: ClassificationLabel[]) => void;
onImportFile: (file: File) => void;
onExport: () => void;
/** Stage the built-in default into the draft. */
onReset: () => void;
/** Stage an empty list into the draft (build from scratch). */
onClear: () => void;
onSave: () => void;
dirty: boolean;
saving: boolean;
readOnly: boolean;
/** Save (server) or import (file) failure to surface, if any. */
error: string | null;
}
export function LabelsEditorModal({
open,
onClose,
draft,
onDraftChange,
onImportFile,
onExport,
onReset,
onClear,
onSave,
dirty,
saving,
readOnly,
error,
}: LabelsEditorModalProps) {
const { t } = useTranslation();
const fileInput = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file twice still fires onChange.
e.target.value = "";
if (file) onImportFile(file);
};
return (
<Modal
open={open}
onClose={onClose}
width="xl"
className="labels-modal"
title={t("policies.labels.modalTitle", "Classification labels")}
subtitle={t(
"policies.labels.modalSubtitle",
"Shared with your whole team. The classifier picks the labels that fit each document.",
)}
footer={
<div className="labels-footer">
<div className="labels-footer-left">
{!readOnly && (
<>
<Button
variant="tertiary"
accent="danger"
size="sm"
leftSection={
<DeleteSweepOutlinedIcon sx={{ fontSize: "1rem" }} />
}
onClick={onClear}
disabled={saving}
>
{t("policies.labels.startFromScratch", "Start from scratch")}
</Button>
<Button
variant="tertiary"
accent="danger"
size="sm"
leftSection={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
onClick={onReset}
disabled={saving}
>
{t("policies.labels.resetToDefault", "Reset to default")}
</Button>
</>
)}
</div>
<div className="labels-footer-right">
<Button variant="tertiary" size="sm" onClick={onClose}>
{readOnly ? t("close", "Close") : t("cancel", "Cancel")}
</Button>
{!readOnly && (
<Button
variant="primary"
size="sm"
onClick={onSave}
disabled={!dirty || saving}
>
{saving
? t("policies.labels.saving", "Saving…")
: t("policies.labels.saveForTeam", "Save for team")}
</Button>
)}
</div>
</div>
}
>
<div className="labels-modal-body">
{error && (
<Banner
tone="danger"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={error}
/>
)}
{!readOnly && (
<div className="labels-toolbar">
<Button
variant="secondary"
size="sm"
leftSection={<FileUploadOutlinedIcon sx={{ fontSize: "1rem" }} />}
onClick={() => fileInput.current?.click()}
>
{t("policies.labels.import", "Import JSON")}
</Button>
<Button
variant="secondary"
size="sm"
leftSection={
<FileDownloadOutlinedIcon sx={{ fontSize: "1rem" }} />
}
onClick={onExport}
>
{t("policies.labels.export", "Export JSON")}
</Button>
<input
ref={fileInput}
type="file"
accept="application/json,.json"
hidden
onChange={handleFile}
/>
</div>
)}
<LabelsEditor
value={draft}
onChange={onDraftChange}
readOnly={readOnly}
grouped
/>
</div>
</Modal>
);
}
@@ -0,0 +1,19 @@
/**
* The fixed, configurable tool chain per policy category — the locked set the
* config page shows (one section per tool). Tools can be configured + toggled
* on/off, but not added or removed. Each id is a frontend tool-registry key (and
* maps to that tool's backend endpoint via the registry's operationConfig).
*/
export const POLICY_TOOL_CHAINS: Record<string, string[]> = {
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
// by default comes from the preset's defaultOperations, not this list.
security: ["redact", "watermark", "sanitize"],
// Classification: a single backend step that classifies the document and
// writes the result into its metadata.
classification: ["classify"],
};
/** The configurable tool chain for a category, or null if it has none yet. */
export function getPolicyToolChain(categoryId: string): string[] | null {
return POLICY_TOOL_CHAINS[categoryId] ?? null;
}
@@ -1,54 +0,0 @@
import fs from "fs";
import path from "path";
import { describe, it, expect } from "vitest";
import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels";
// The classifier vocabulary is bundled in TWO places that must not drift:
// - this frontend JSON (source of truth for sidebar categories + display names)
// - a backend copy the classify tool sends to the AI engine per request
// (app/proprietary/src/main/resources/classification/classification-labels.json).
// The backend sends label IDS the engine stores on the document and the frontend
// then displays, so a divergence means the classifier picks a label the UI can't
// render (or the UI advertises labels the classifier never uses). This guards it.
const REPO_ROOT = path.resolve(__dirname, "../../../../..");
const BACKEND_LABELS_FILE = path.join(
REPO_ROOT,
"app/proprietary/src/main/resources/classification/classification-labels.json",
);
interface Label {
id: string;
name: string;
icon?: string | null;
}
function readBackendLabels(): Label[] {
const raw = JSON.parse(fs.readFileSync(BACKEND_LABELS_FILE, "utf8")) as {
labels: Label[];
};
return raw.labels;
}
describe("classification label vocabulary (frontend ↔ backend)", () => {
const frontend = DEFAULT_CLASSIFICATION_LABELS;
const backend = readBackendLabels();
it("has the identical set of label ids on both sides", () => {
const frontendIds = [...new Set(frontend.map((l) => l.id))].sort();
const backendIds = [...new Set(backend.map((l) => l.id))].sort();
expect(backendIds).toEqual(frontendIds);
});
it("has matching name and icon per id on both sides", () => {
const backendById = new Map(backend.map((l) => [l.id, l]));
for (const label of frontend) {
const other = backendById.get(label.id);
expect(other, `backend missing label "${label.id}"`).toBeDefined();
expect({ name: other!.name, icon: other!.icon ?? null }).toEqual({
name: label.name,
icon: label.icon ?? null,
});
}
});
});
@@ -1,14 +1,14 @@
// Classification labels. The SOURCE OF TRUTH is the co-located static JSON
// (`classificationLabels.json`), imported here and shaped into typed objects —
// edit THAT file, not this one. It's a fixed, built-in vocabulary shared by
// everyone (no per-team customization); it drives the sidebar's grouping, icons,
// and display names.
// Default classification labels. The SOURCE OF TRUTH is the co-located static
// JSON (`classificationLabels.json`), imported here and shaped into typed
// objects — edit THAT file, not this one. This is the ONLY copy of the label
// data: it seeds a team's editable set and drives the sidebar's grouping,
// icons, and display names. Neither the backend nor the engine keeps a copy.
//
// The backend keeps a SECOND copy of this list
// (`app/proprietary/src/main/resources/classification/classification-labels.json`)
// which it sends to the engine per classify request (the engine holds no
// vocabulary of its own). The two copies must not drift — `classificationLabels.drift.test.ts`
// guards that. When you edit the labels here, update the backend copy too.
// NOTE: this is only the built-in default vocabulary. A team's own
// (admin-editable) labels live in the backend store and are what the backend
// sends to the engine per classify request — the engine holds no vocabulary of
// its own. Edits to a team's set reach Python on the next run, independent of
// this file.
//
// `labels` is the flat set: each has a stable `id` (slug — the value on the wire,
// in storage and keyed on) and a human `name` (display, translatable via
@@ -1,21 +1,22 @@
/**
* Curated palette of icons the classification labels use, rendered in the file
* sidebar's label/category groups. Keys are Material Symbols names rendered via
* {@link LocalIcon}.
* Curated palette of icons a classification label can use, shown in the label
* icon picker and rendered in the file sidebar's label groups. Keys are
* Material Symbols names rendered via {@link LocalIcon}.
*
* IMPORTANT — this list is also the icon BUNDLING MANIFEST for classification.
* The label/family icons live in `classificationLabels.json`, but the bundler
* (`scripts/generate-icons.js`) only regex-scans `.ts/.tsx` for `icon: "…"`
* literals, not JSON — so every icon those labels use must appear here as a
* literal or it falls back to the CDN and renders blank offline. Do NOT delete
* `LABEL_ICON_OPTIONS` as "unused": nothing imports it, but it's load-bearing
* for offline icons. After editing, run `task frontend:prepare:icons`.
* IMPORTANT: each entry is written as an `icon: "…"` literal so the icon-bundler
* (`scripts/generate-icons.js`, which regex-scans for icon literals) picks every
* one up and bundles it — otherwise a picked icon would fall back to the CDN and
* render blank offline. After adding/removing entries run `task frontend:prepare:icons`.
*
* Search is intentionally omitted for now (no synonyms to maintain); the palette
* is small enough to eyeball.
*/
export interface LabelIconOption {
/** Material Symbols key (no `material-symbols:` prefix). */
icon: string;
/** Short English name for the icon (documentation only). */
/** Short English name — the en-US default for the icon's tooltip/aria; the
* picker translates it via `policies.labels.iconName.<icon>`. */
label: string;
}
@@ -187,3 +188,8 @@ export const LABEL_ICON_OPTIONS: LabelIconOption[] = [
{ icon: "recycling", label: "Recycling" },
{ icon: "agriculture", label: "Agriculture" },
];
/** Set of valid palette keys, for validating a stored/imported icon. */
export const LABEL_ICON_KEYS: ReadonlySet<string> = new Set(
LABEL_ICON_OPTIONS.map((option) => option.icon),
);
@@ -5,13 +5,18 @@
* user's real files, not defined here.
*/
import LayersIcon from "@mui/icons-material/Layers";
import ShieldIcon from "@mui/icons-material/Shield";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import StorageIcon from "@mui/icons-material/Storage";
import DescriptionIcon from "@mui/icons-material/Description";
import ComputerIcon from "@mui/icons-material/Computer";
import PublicIcon from "@mui/icons-material/Public";
import CloudIcon from "@mui/icons-material/Cloud";
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
import type {
PolicyCategory,
PolicyConfigDef,
@@ -25,7 +30,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "ingestion",
label: "Ingestion",
icon: policyCategoryIcon("ingestion", ICON_SX),
icon: <LayersIcon sx={ICON_SX} />,
desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.",
// The classifier the wizard's "Set up Classification" action routes to.
providesClassification: true,
@@ -35,13 +40,13 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "security",
label: "Security",
icon: policyCategoryIcon("security", ICON_SX),
icon: <ShieldIcon sx={ICON_SX} />,
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
},
{
id: "classification",
label: "Classification",
icon: policyCategoryIcon("classification", ICON_SX),
icon: <LabelOutlinedIcon sx={ICON_SX} />,
desc: "Identify each document's type on upload and tag its metadata for filing and search.",
// Needs the AI engine to classify; hidden from the policy list when it's off.
requiresAiEngine: true,
@@ -49,21 +54,21 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "compliance",
label: "Compliance",
icon: policyCategoryIcon("compliance", ICON_SX),
icon: <CheckCircleIcon sx={ICON_SX} />,
desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.",
comingSoon: true,
},
{
id: "routing",
label: "Routing",
icon: policyCategoryIcon("routing", ICON_SX),
icon: <ArrowForwardIcon sx={ICON_SX} />,
desc: "Auto-route documents to the right team, folder, or system.",
comingSoon: true,
},
{
id: "retention",
label: "Retention",
icon: policyCategoryIcon("retention", ICON_SX),
icon: <StorageIcon sx={ICON_SX} />,
desc: "Set how long documents are kept, when to archive, and when to delete.",
comingSoon: true,
},
@@ -1,94 +0,0 @@
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
export type { ProcessorSearchEntry };
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
// offer destinations that would 404 elsewhere.
const includePortal =
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
/**
* The portal's in-app views. Deliberately a static mirror of the portal's nav
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
* import from @portal/* — referencing the portal package here would pull the
* lazy portal chunk into the main bundle.
*/
const VIEWS: ProcessorSearchEntry[] = [
{
id: "home",
labelKey: "portal.nav.home",
labelFallback: "Home",
path: PORTAL_BASENAME,
keywords: ["portal", "processor", "admin"],
},
{
id: "users",
labelKey: "portal.nav.users",
labelFallback: "Users",
path: `${PORTAL_BASENAME}/users`,
keywords: ["team", "members", "roles", "admin"],
},
{
id: "sources",
labelKey: "portal.nav.sources",
labelFallback: "Sources",
path: `${PORTAL_BASENAME}/sources`,
keywords: ["s3", "connections", "webhooks", "folders"],
},
{
id: "policies",
labelKey: "portal.nav.policies",
labelFallback: "Policies",
path: `${PORTAL_BASENAME}/policies`,
keywords: ["enforcement", "redact", "compliance"],
},
{
id: "pipelines",
labelKey: "portal.nav.pipelines",
labelFallback: "Pipelines",
path: `${PORTAL_BASENAME}/pipelines`,
keywords: ["automation", "workflows", "operations"],
},
{
id: "documents",
labelKey: "portal.nav.documents",
labelFallback: "Documents",
path: `${PORTAL_BASENAME}/documents`,
keywords: ["audit", "files"],
},
{
id: "components",
labelKey: "portal.nav.components",
labelFallback: "Components",
path: `${PORTAL_BASENAME}/components`,
keywords: ["sdk", "embed"],
},
{
id: "infrastructure",
labelKey: "portal.nav.infrastructure",
labelFallback: "Infrastructure",
path: `${PORTAL_BASENAME}/infrastructure`,
keywords: ["deployment", "instances", "health"],
},
{
id: "usage",
labelKey: "portal.nav.usage",
labelFallback: "Usage & Billing",
path: `${PORTAL_BASENAME}/usage`,
keywords: ["billing", "invoices", "plan", "wallet"],
},
{
id: "docs",
labelKey: "portal.nav.docs",
labelFallback: "Developer Docs",
path: "",
externalUrl: "https://docs.stirlingpdf.com/",
keywords: ["api", "documentation", "reference"],
},
];
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
? VIEWS
: [];
@@ -1,151 +0,0 @@
import {
SETTINGS_SECTION_REGISTRY as CORE_SETTINGS_SECTION_REGISTRY,
type SettingsSectionEntry,
} from "@core/data/settingsSectionRegistry";
export type { SettingsSectionEntry };
/**
* Self-hosted (proprietary) settings sections. Extends the core list with the
* account tab, the developer API keys tab, and the admin area — mirroring the
* sections added by the proprietary nav builder (`configNavSections`). Keep the
* two in sync: a section here that the modal can't render would deep-link to a
* dead tab, and a modal section missing here isn't searchable.
*
* Gating flags mirror the builder: `account`/`api-keys` need login; the admin
* sections are surfaced when the user is an admin OR login mode is off.
*/
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
...CORE_SETTINGS_SECTION_REGISTRY,
{
key: "account",
labelKey: "account.accountSettings",
labelFallback: "Account",
keywords: ["profile", "email", "password", "user"],
requiresLogin: true,
},
{
key: "api-keys",
labelKey: "settings.developer.apiKeys",
labelFallback: "API Keys",
keywords: ["api", "token", "developer", "key"],
requiresLogin: true,
},
// --- Workspace ---
{
key: "people",
labelKey: "settings.workspace.people",
labelFallback: "People",
keywords: ["members", "users", "invite", "add member", "roles"],
adminArea: true,
},
{
key: "teams",
labelKey: "settings.workspace.teams",
labelFallback: "Teams",
keywords: ["team", "group", "create team"],
adminArea: true,
},
// --- Configuration ---
{
key: "adminGeneral",
labelKey: "settings.configuration.systemSettings",
labelFallback: "System Settings",
keywords: ["system", "config", "server"],
adminArea: true,
},
{
key: "adminFeatures",
labelKey: "settings.configuration.features",
labelFallback: "Features",
keywords: ["features", "toggles", "flags"],
adminArea: true,
},
{
key: "adminStorageSharing",
labelKey: "settings.configuration.storageSharing",
labelFallback: "File Storage & Sharing",
keywords: ["storage", "sharing", "files"],
adminArea: true,
},
{
key: "adminEndpoints",
labelKey: "settings.configuration.endpoints",
labelFallback: "Endpoints",
keywords: ["endpoints", "api", "routes"],
adminArea: true,
},
{
key: "adminMcp",
labelKey: "settings.configuration.mcp",
labelFallback: "MCP Server",
keywords: ["mcp", "server", "ai", "model context protocol"],
adminArea: true,
},
{
key: "adminDatabase",
labelKey: "settings.configuration.database",
labelFallback: "Database",
keywords: ["database", "db", "backup"],
adminArea: true,
},
{
key: "adminAdvanced",
labelKey: "settings.configuration.advanced",
labelFallback: "Advanced",
keywords: ["advanced", "expert"],
adminArea: true,
},
// --- Security & Authentication ---
{
key: "adminSecurity",
labelKey: "settings.securityAuth.security",
labelFallback: "Security",
keywords: ["security", "authentication", "auth", "password", "sessions"],
adminArea: true,
},
{
key: "adminConnections",
labelKey: "settings.securityAuth.connections",
labelFallback: "Connections",
keywords: ["connections", "oauth", "sso", "integrations"],
adminArea: true,
},
// --- Licensing & Analytics ---
{
key: "adminPlan",
labelKey: "settings.licensingAnalytics.plan",
labelFallback: "Plan",
keywords: ["plan", "license", "billing", "subscription", "enterprise"],
adminArea: true,
},
{
key: "adminAudit",
labelKey: "settings.licensingAnalytics.audit",
labelFallback: "Audit",
keywords: ["audit", "logs", "events", "history"],
adminArea: true,
},
{
key: "adminUsage",
labelKey: "settings.licensingAnalytics.usageAnalytics",
labelFallback: "Usage Analytics",
keywords: ["usage", "analytics", "stats", "metrics"],
adminArea: true,
},
// --- Policies & Privacy ---
{
key: "adminLegal",
labelKey: "settings.policiesPrivacy.legal",
labelFallback: "Legal",
keywords: ["legal", "terms", "agreement"],
adminArea: true,
},
{
key: "adminPrivacy",
labelKey: "settings.policiesPrivacy.privacy",
labelFallback: "Privacy",
keywords: ["privacy", "gdpr", "data"],
adminArea: true,
},
];
@@ -0,0 +1,85 @@
/**
* Loads and persists the team's classification labels
* (`/api/v1/classification/labels`) — one server-truth set shared by the whole
* team. When the team has none, this shows the built-in default as a starting
* point for the editor/sidebar; note the classifier itself does NOT use that
* default — the backend only classifies against a team's saved set (an unsaved
* team is not classified). Editing is gated to team leaders / admins by the
* backend — callers pass `canConfigure` (the policy gate) to keep read-only
* users out of the save path.
*
* `teamLabels` is also what the sidebar/editor display uses — the team set, or
* the built-in default when the team has none.
*/
import { useCallback, useEffect, useState } from "react";
import {
DEFAULT_CLASSIFICATION_LABELS,
type ClassificationLabel,
} from "@app/data/classificationLabels";
import { fetchTeamLabels, saveTeamLabels } from "@app/services/labelsBackend";
export interface UseClassificationLabels {
/** Server-truth team labels (or the built-in default when the team has none). */
teamLabels: ClassificationLabel[];
/** Whether the team has a stored set (vs. the built-in default). */
isCustom: boolean;
loading: boolean;
saving: boolean;
/** Last save failure, cleared on the next attempt. */
error: string | null;
/** Persist the team set; resolves once server state is updated. */
saveTeam: (next: ClassificationLabel[]) => Promise<void>;
}
export function useClassificationLabels(
enabled: boolean,
): UseClassificationLabels {
const [teamLabels, setTeamLabels] = useState<ClassificationLabel[]>(
DEFAULT_CLASSIFICATION_LABELS,
);
const [isCustom, setIsCustom] = useState(false);
const [loading, setLoading] = useState(enabled);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setLoading(true);
void (async () => {
const team = await fetchTeamLabels().catch(() => null);
if (cancelled) return;
setTeamLabels(team ?? DEFAULT_CLASSIFICATION_LABELS);
setIsCustom(team != null);
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [enabled]);
const saveTeam = useCallback(async (next: ClassificationLabel[]) => {
setSaving(true);
setError(null);
try {
const saved = await saveTeamLabels(next);
setTeamLabels(saved);
setIsCustom(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't save the labels.");
throw e;
} finally {
setSaving(false);
}
}, []);
return {
teamLabels,
isCustom,
loading,
saving,
error,
saveTeam,
};
}
@@ -35,6 +35,8 @@ import {
removePolicy,
} from "@app/services/policyBackend";
import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi";
import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend";
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
import type { PolicyToStore } from "@app/services/policyPipeline";
import type {
PoliciesByCategory,
@@ -137,6 +139,13 @@ export function usePolicies() {
async (id: string, result: PolicyWizardResult) => {
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
// A policy whose chain classifies runs against the team's stored label set
// (the engine has no default). Seed it with the built-in defaults now so
// the very first enforced file has a vocabulary to classify against; no-op
// if the team already has a set (never clobbers admin edits).
if (getPolicyToolChain(id)?.includes("classify")) {
await seedTeamLabelsIfEmpty();
}
// One policy per category, ever: reuse any existing backend record.
const existingBackendId =
loadPolicies()[id]?.backendId ??
@@ -216,6 +225,12 @@ export function usePolicies() {
async (id: string, result: PolicyConfigResult) => {
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
// Seed the team's default label set on first classification-policy setup
// (see enablePolicy) — the engine has no default, so the stored set is the
// only vocabulary. No-op once the team has any set.
if (getPolicyToolChain(id)?.includes("classify")) {
await seedTeamLabelsIfEmpty();
}
const current = loadPolicies()[id];
// One policy per category, ever: reuse the existing backend record (even
// if the local link was lost) so a save never creates a duplicate.
@@ -13,9 +13,8 @@ const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
describe("POLICY_OPERATIONS", () => {
test("every category operation is a typed descriptor with a known endpoint", () => {
// The catalogue uses these across all categories; each must be wired.
// The catalogue uses these six across all categories; each must be wired.
expect(ALL_TOOL_IDS.sort()).toEqual([
"classify",
"compress",
"flatten",
"ocr",
@@ -11,39 +11,10 @@ import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAd
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
import type { WirePipelineStep } from "@app/policies/types";
/**
* Endpoints for AI-dispatched policy tools. Intentionally NOT part of the generated
* {@link ToolEndpoint} union: the backend controllers are `@Hidden` and `/api/v1/ai/tools/` is
* deliberately excluded from tool-model generation, so they can't be typed like standard tools.
*/
export type AiPolicyEndpoint = "/api/v1/ai/tools/classify-and-label";
/** An AI tool usable in a policy — the AI-endpoint analogue of a standard tool descriptor. */
export interface AiToolDescriptor<TParams> {
readonly endpoint: AiPolicyEndpoint;
readonly defaultParameters: TParams;
toApi(params: TParams): Record<string, unknown>;
fromApi(api: Record<string, unknown>): TParams;
}
/**
* Describe an AI-dispatched policy tool. AI tools carry no tunable parameters today, so params are
* empty and the (de)serializers are identity over an empty object.
*/
export function describeAiToolOperation(
endpoint: AiPolicyEndpoint,
): AiToolDescriptor<Record<string, never>> {
return {
endpoint,
defaultParameters: {},
toApi: () => ({}),
fromApi: () => ({}),
};
}
export const POLICY_OPERATIONS = {
redact: describeToolOperation(
"/api/v1/security/auto-redact",
@@ -66,13 +37,17 @@ export const POLICY_OPERATIONS = {
"/api/v1/misc/compress-pdf",
compressOperationConfig,
),
classify: describeAiToolOperation("/api/v1/ai/tools/classify-and-label"),
} as const;
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
export type PolicyParams<Id extends PolicyToolId> =
(typeof POLICY_OPERATIONS)[Id]["defaultParameters"];
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
ToolEndpoint,
infer P
>
? P
: never;
/** Discriminated on `toolId` so `params` matches the tool. */
export type PolicyToolStep = {
@@ -90,10 +65,7 @@ const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
);
/** A policy step's endpoint: a standard {@link ToolEndpoint} or an {@link AiPolicyEndpoint}. */
export type PolicyEndpoint = ToolEndpoint | AiPolicyEndpoint;
export function policyEndpoint(toolId: PolicyToolId): PolicyEndpoint {
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
return POLICY_OPERATIONS[toolId].endpoint;
}
@@ -118,29 +90,19 @@ export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
return serializeStep(step);
}
/**
* Minimal runtime shape shared by standard tool descriptors and {@link AiToolDescriptor}s — enough
* to (de)serialize a step at the wire boundary regardless of how its endpoint is typed.
*/
interface PolicyOperation<TParams> {
readonly endpoint: string;
readonly defaultParameters: TParams;
toApi(params: TParams): Record<string, unknown>;
fromApi(api: Record<string, unknown>): TParams;
}
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
// the union, so `op` is widened here (a contained cast at the wire boundary).
function serializeStep<Id extends PolicyToolId>(step: {
toolId: Id;
params: PolicyParams<Id>;
}): WirePipelineStep {
const op = POLICY_OPERATIONS[step.toolId] as unknown as PolicyOperation<
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
return {
operation: op.endpoint,
parameters: op.toApi(step.params),
parameters: op.toApi(step.params) as Record<string, unknown>,
};
}
@@ -157,10 +119,13 @@ function deserializeStep<Id extends PolicyToolId>(
toolId: Id,
parameters: Record<string, unknown>,
): PolicyToolStepOf<Id> {
const op = POLICY_OPERATIONS[toolId] as unknown as PolicyOperation<
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
// Wire params are untyped JSON; this is the one point they enter the typed model.
const params = op.fromApi(parameters);
const params = op.fromApi(
parameters as unknown as ToolApiParams[ToolEndpoint],
);
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
}
@@ -1,80 +1,95 @@
// The Files-sidebar categories are a fixed, built-in set shared by everyone (derived from
// LABEL_FAMILIES) — the team can't create, rename, or re-group them. The only per-user choice is
// which categories to SHOW or HIDE in the sidebar, kept device-local in localStorage. A hidden
// category isn't rendered as a group; its files fall to "Other". Presentational only — the
// classifier never sees categories.
// Device-local (localStorage) category structure for the Files sidebar: which parent
// categories exist, their name/icon/order, and which labels roll up into each. It's an editable
// override of the built-in LABEL_FAMILIES default — until the user customizes it, the default is
// used verbatim (so the store stays empty and the default can evolve). A label may sit in more
// than one category (multi-membership); a category with `hidden` set stays defined but isn't shown
// as a sidebar group. This is presentational only — the classifier never sees categories.
import { LABEL_FAMILIES } from "@app/data/classificationLabels";
export interface SidebarCategory {
/** Stable id (the label family's id). */
/** Stable id — built-ins reuse their family id; custom ones get `custom:<n>`. */
id: string;
name: string;
icon: string;
/** Label ids in this category (matches a file's stored classification ids). */
labelKeys: string[];
/** Hidden from the sidebar (device-local, personal). */
/** Defined but not rendered as a sidebar group. */
hidden?: boolean;
}
const HIDDEN_STORAGE_KEY = "stirling.fileSidebarHiddenCategories.v1";
// v2: labelKeys hold label ids (was lower-cased names in v1); bumping discards
// stale name-keyed prefs so they don't silently stop matching.
const STORAGE_KEY = "stirling.fileSidebarCategories.v2";
/** The fixed, shared category set — never mutated. */
const BASE_CATEGORIES: readonly Omit<SidebarCategory, "hidden">[] =
LABEL_FAMILIES.map((family) => ({
/** The built-in default, derived from LABEL_FAMILIES. Fresh copy per call (callers may mutate). */
export function defaultCategories(): SidebarCategory[] {
return LABEL_FAMILIES.map((family) => ({
id: family.id,
name: family.name,
icon: family.icon,
labelKeys: family.labels.map((label) => label.id),
}));
}
function readHidden(): Set<string> {
function readStorage(): SidebarCategory[] | null {
try {
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
if (!raw) return new Set();
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed)
? new Set(parsed.filter((id): id is string => typeof id === "string"))
: new Set();
if (!Array.isArray(parsed)) return null;
return parsed
.filter((c): c is SidebarCategory => {
const cat = c as Partial<SidebarCategory>;
return (
typeof cat.id === "string" &&
typeof cat.name === "string" &&
typeof cat.icon === "string" &&
Array.isArray(cat.labelKeys)
);
})
.map((c) => ({
id: c.id,
name: c.name,
icon: c.icon,
labelKeys: c.labelKeys.filter((k) => typeof k === "string"),
hidden: c.hidden === true,
}));
} catch {
return new Set();
return null;
}
}
let hiddenIds = readHidden();
// null = using the built-in default (not yet customized). Cached so useSyncExternalStore sees a
// stable reference between writes.
let stored: SidebarCategory[] | null = readStorage();
const listeners = new Set<() => void>();
// Recomputed only on write so its identity is stable for memo/useSyncExternalStore.
let effective: SidebarCategory[] = compute();
// Effective list, recomputed only on write so its identity is stable for memo/useSyncExternalStore.
let effective: SidebarCategory[] = stored ?? defaultCategories();
function compute(): SidebarCategory[] {
return BASE_CATEGORIES.map((c) => ({
...c,
labelKeys: [...c.labelKeys],
hidden: hiddenIds.has(c.id),
}));
function recompute() {
effective = stored ?? defaultCategories();
}
function persist() {
function write(next: SidebarCategory[]) {
stored = next;
recompute();
try {
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...hiddenIds]));
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// Quota/private-mode failures degrade to session-only visibility.
// Quota/private-mode failures degrade to session-only categories.
}
effective = compute();
for (const listener of listeners) listener();
}
/** The shared categories, each flagged with the user's device-local visibility. */
export function getSidebarCategories(): SidebarCategory[] {
return effective;
/** Mutate the current effective list (snapshotting the default on first edit). */
function mutate(fn: (categories: SidebarCategory[]) => SidebarCategory[]) {
write(fn(effective.map((c) => ({ ...c, labelKeys: [...c.labelKeys] }))));
}
/** Show or hide a category in this device's sidebar. */
export function setCategoryHidden(id: string, hidden: boolean) {
if (hidden) hiddenIds.add(id);
else hiddenIds.delete(id);
persist();
export function getSidebarCategories(): SidebarCategory[] {
return effective;
}
export function subscribeSidebarCategories(listener: () => void) {
@@ -84,8 +99,98 @@ export function subscribeSidebarCategories(listener: () => void) {
};
}
/** Show every category again (clear the device-local hidden set). */
export function resetHiddenCategories() {
hiddenIds = new Set();
persist();
export function isCustomized(): boolean {
return stored !== null;
}
/** Map each label key to the ids of every VISIBLE category it belongs to. */
export function labelCategoryMap(
categories: SidebarCategory[],
): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const category of categories) {
if (category.hidden) continue;
for (const key of category.labelKeys) {
const ids = map.get(key);
if (ids) ids.push(category.id);
else map.set(key, [category.id]);
}
}
return map;
}
/** Set of every label key that belongs to any category (visible or not). */
export function categorizedLabelKeys(
categories: SidebarCategory[],
): Set<string> {
const keys = new Set<string>();
for (const category of categories) {
for (const key of category.labelKeys) keys.add(key);
}
return keys;
}
// ---- editing ----
/** Create a new empty category; returns its id. */
export function addCategory(name: string, icon: string): string {
const id = `custom:${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${
effective.length
}`;
mutate((categories) => [...categories, { id, name, icon, labelKeys: [] }]);
return id;
}
export function renameCategory(id: string, name: string) {
mutate((categories) =>
categories.map((c) => (c.id === id ? { ...c, name } : c)),
);
}
export function setCategoryIcon(id: string, icon: string) {
mutate((categories) =>
categories.map((c) => (c.id === id ? { ...c, icon } : c)),
);
}
export function setCategoryHidden(id: string, hidden: boolean) {
mutate((categories) =>
categories.map((c) => (c.id === id ? { ...c, hidden } : c)),
);
}
export function deleteCategory(id: string) {
mutate((categories) => categories.filter((c) => c.id !== id));
}
export function addLabelToCategory(id: string, labelId: string) {
mutate((categories) =>
categories.map((c) =>
c.id === id && !c.labelKeys.includes(labelId)
? { ...c, labelKeys: [...c.labelKeys, labelId] }
: c,
),
);
}
export function removeLabelFromCategory(id: string, labelId: string) {
mutate((categories) =>
categories.map((c) =>
c.id === id
? { ...c, labelKeys: c.labelKeys.filter((k) => k !== labelId) }
: c,
),
);
}
/** Restore the built-in default and clear the customized flag (undoes any prior `mutate`). */
export function resetSidebarCategories() {
stored = null;
recompute();
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Quota/private-mode failures degrade to session-only categories.
}
for (const listener of listeners) listener();
}

Some files were not shown because too many files have changed in this diff Show More