refactor(settings): unify settings and speed up exports (#11789)
## Thinking Path > - Paperclip is the open source app that people use to manage AI agents for work. > - Operators use the settings area to control a company and its Paperclip instance. > - The current navigation separates related settings and uses duplicate instance pages. > - Company exports also do independent reads in sequence and do extra work for previews. > - Hardened workspace commands can differ from their saved command after loopback binding. > - This pull request makes these related operator workflows consistent and faster. > - The benefit is one clear settings area, faster exports, and stable runtime command matching. ## Linked Issues or Issue Description Refs #338 Related: #9834 **What existing behavior does this improve?** This improves the company settings UI, company export preparation, and workspace runtime command matching. **Current behavior** Company and instance settings use separate navigation and duplicate pages. Export preparation reads many independent records in sequence. Preview generation can also build an unused organization image. A command with a forced loopback bind can fail to match its saved runtime command. **Proposed behavior** Use one settings navigation and put general instance controls on the company General page. Load independent export data with bounded concurrency, skip unused preview image work, and load the export page only when it is needed. Treat the loopback-bound form of a command as the same runtime command. **Reason and benefit** Operators get one clear settings area. Large company exports need fewer serialized reads. Export previews and initial UI loads do less work. Hardened runtime services remain linked to their saved command definitions. **Breaking changes** The obsolete instance General URL redirects to the unified settings page. Access and Heartbeats remain available, and legacy bookmarks keep their destinations. No API response shape or database schema changes. ## What Changed - Unified company and instance settings navigation and removed duplicate instance settings pages. - Embedded general instance controls in the company General page and kept access-sensitive navigation behavior. - Preserved instance Access and Heartbeats controls in the unified navigation and normalized old bookmarks to those destinations. - Improved environment and access-state handling when workspace seed requests overlap. - Added bounded export reads, a lighter preview path, deferred export preparation, and lazy export-page loading. - Matched loopback-bound runtime commands to their saved command definitions. - Added focused shared, server, and UI regression tests. ## Verification - `pnpm exec vitest run <18 changed test files>`: 18 files and 256 tests passed. - `pnpm check:token-gates`: passed all four token gates. - `pnpm -r typecheck`: passed for all workspace projects. - `pnpm build`: passed for all workspace projects. - `pnpm test:run`: tests ran without a reported failure, but the runner did not close after the server handoff tests. The process closed with status 0 after an interrupt. - Focused latest-head route tests: 2 files and 4 tests passed. - GitHub latest-head checks: all completed without failure. - Greptile: 5/5 with no unresolved review threads. ## Risks - Medium risk: settings routes and navigation changed across several operator roles. - Medium risk: bounded export concurrency increases simultaneous database reads. The limits stay below the normal pool size. - Low risk: runtime command matching accepts only the known Tailscale HTTPS loopback transformation. - No migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with a GPT-5-family coding model. The runtime does not expose the exact deployed model ID or context-window size. Reasoning, tool use, and local code execution were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details Exception: This task requires the existing execution branch. The harness does not permit a branch rename. - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
416a273366
commit
e826188e82
|
|
@ -75,4 +75,55 @@ describe("workspace command helpers", () => {
|
|||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("matches an exposed dev runtime whose bind command was hardened to loopback", () => {
|
||||
const workspaceRuntime = {
|
||||
commands: [
|
||||
{ id: "web", name: "paperclip-dev", kind: "service", command: "pnpm dev --bind lan" },
|
||||
],
|
||||
};
|
||||
const command = findWorkspaceCommandDefinition(workspaceRuntime, "web");
|
||||
expect(command).not.toBeNull();
|
||||
|
||||
const match = matchWorkspaceRuntimeServiceToCommand(command!, [
|
||||
{
|
||||
id: "runtime-web",
|
||||
serviceName: "paperclip-dev",
|
||||
command: "pnpm dev --bind loopback",
|
||||
cwd: "/repo",
|
||||
configIndex: null,
|
||||
exposure: {
|
||||
provider: "tailscale_https",
|
||||
state: "ready",
|
||||
publicUrl: "https://paperclip-dev.example.ts.net:42012",
|
||||
hostname: "paperclip-dev.example.ts.net",
|
||||
listeners: [],
|
||||
brokerRef: "broker-1",
|
||||
lastError: null,
|
||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(match).toEqual(expect.objectContaining({ id: "runtime-web" }));
|
||||
});
|
||||
|
||||
it("does not equate a loopback command with a lan command without managed exposure", () => {
|
||||
const command = findWorkspaceCommandDefinition({
|
||||
services: [{ name: "paperclip-dev", command: "pnpm dev --bind lan" }],
|
||||
}, "service:paperclip-dev");
|
||||
|
||||
const match = matchWorkspaceRuntimeServiceToCommand(command!, [
|
||||
{
|
||||
id: "runtime-web",
|
||||
serviceName: "paperclip-dev",
|
||||
command: "pnpm dev --bind loopback",
|
||||
cwd: "/repo",
|
||||
configIndex: null,
|
||||
exposure: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import type { WorkspaceCommandDefinition, WorkspaceRuntimeService } from "./types/workspace-runtime.js";
|
||||
import { forceLoopbackBindInCommand } from "./runtime-exposure/loopback-bind.js";
|
||||
|
||||
type WorkspaceRuntimeServiceMatchCandidate =
|
||||
& Pick<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">
|
||||
& Pick<Partial<WorkspaceRuntimeService>, "exposure">;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
|
@ -164,9 +169,20 @@ export function findWorkspaceCommandDefinition(
|
|||
|
||||
export function scoreWorkspaceRuntimeServiceMatch(
|
||||
command: Pick<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
|
||||
runtimeService: Pick<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">,
|
||||
runtimeService: WorkspaceRuntimeServiceMatchCandidate,
|
||||
) {
|
||||
if (command.command && runtimeService.command && runtimeService.command !== command.command) {
|
||||
const exposedCommandMatches = Boolean(
|
||||
command.command
|
||||
&& runtimeService.command
|
||||
&& runtimeService.exposure?.provider === "tailscale_https"
|
||||
&& runtimeService.command === forceLoopbackBindInCommand(command.command),
|
||||
);
|
||||
if (
|
||||
command.command
|
||||
&& runtimeService.command
|
||||
&& runtimeService.command !== command.command
|
||||
&& !exposedCommandMatches
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +204,7 @@ export function scoreWorkspaceRuntimeServiceMatch(
|
|||
}
|
||||
|
||||
export function matchWorkspaceRuntimeServiceToCommand<
|
||||
T extends Pick<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">,
|
||||
T extends WorkspaceRuntimeServiceMatchCandidate,
|
||||
>(
|
||||
command: Pick<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
|
||||
runtimeServices: T[] | null | undefined,
|
||||
|
|
|
|||
|
|
@ -517,6 +517,7 @@ describe("company portability", () => {
|
|||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
skills: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -551,6 +552,25 @@ describe("company portability", () => {
|
|||
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
|
||||
});
|
||||
|
||||
it("does not load or emit skills when agents are included but skills are disabled", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
skills: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(companySkillSvc.listFull).not.toHaveBeenCalled();
|
||||
expect(Object.keys(exported.files).some((filePath) => filePath.startsWith("skills/"))).toBe(false);
|
||||
expect(exported.manifest.skills).toEqual([]);
|
||||
expect(asTextFile(exported.files["agents/claudecoder/AGENTS.md"])).toContain(`- "${paperclipKey}"`);
|
||||
});
|
||||
|
||||
it("exports agent permission grants through the Paperclip extension and manifest", async () => {
|
||||
const db = {
|
||||
select: vi.fn((selection: Record<string, unknown>) => ({
|
||||
|
|
@ -757,6 +777,7 @@ describe("company portability", () => {
|
|||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
skills: true,
|
||||
},
|
||||
expandReferencedSkills: true,
|
||||
});
|
||||
|
|
@ -1023,6 +1044,7 @@ describe("company portability", () => {
|
|||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
skills: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -1075,6 +1097,79 @@ describe("company portability", () => {
|
|||
|
||||
expect(preview.counts.issues).toBe(0);
|
||||
expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false);
|
||||
expect(preview.fileInventory.some((entry) => entry.path === "images/org-chart.png")).toBe(false);
|
||||
|
||||
const downloaded = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
expect(downloaded.files["images/org-chart.png"]).toMatchObject({
|
||||
encoding: "base64",
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefetches task export records with bounded concurrency", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const issues = ["issue-1", "issue-2", "issue-3"].map((id, index) => ({
|
||||
id,
|
||||
identifier: `PAP-${index + 1}`,
|
||||
title: `Task ${index + 1}`,
|
||||
description: null,
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
parentId: null,
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
}));
|
||||
issueSvc.list.mockResolvedValue(issues);
|
||||
|
||||
let releaseFirstWave!: (comments: never[]) => void;
|
||||
const firstWave = new Promise<never[]>((resolve) => {
|
||||
releaseFirstWave = resolve;
|
||||
});
|
||||
issueSvc.listComments.mockImplementation(async (issueId: string) => {
|
||||
if (issueId === "issue-1" || issueId === "issue-2") return firstWave;
|
||||
return [];
|
||||
});
|
||||
|
||||
const exportPromise = portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: false,
|
||||
projects: false,
|
||||
issues: true,
|
||||
skills: false,
|
||||
},
|
||||
});
|
||||
|
||||
let waitError: unknown;
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(issueSvc.listComments).toHaveBeenCalledTimes(2);
|
||||
}, { timeout: 500 });
|
||||
expect(issueSvc.listComments.mock.calls.map(([issueId]) => issueId)).toEqual([
|
||||
"issue-1",
|
||||
"issue-2",
|
||||
]);
|
||||
} catch (error) {
|
||||
waitError = error;
|
||||
} finally {
|
||||
releaseFirstWave([]);
|
||||
}
|
||||
|
||||
await exportPromise;
|
||||
if (waitError) throw waitError;
|
||||
expect(issueSvc.listComments).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("exports portable project workspace metadata and remaps it on import", async () => {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,31 @@ import type {
|
|||
ImportIssueAttachmentRow,
|
||||
} from "./import-write-types.js";
|
||||
|
||||
const EXPORT_READ_CONCURRENCY = 8;
|
||||
const EXPORT_ISSUE_READ_CONCURRENCY = 2;
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await mapper(items[index]!);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
|
||||
function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
|
|
@ -3738,6 +3763,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
async function exportBundle(
|
||||
companyId: string,
|
||||
input: CompanyPortabilityExport,
|
||||
options: { preview?: boolean } = {},
|
||||
): Promise<CompanyPortabilityExportResult> {
|
||||
const include = normalizeInclude({
|
||||
...input.include,
|
||||
|
|
@ -3783,7 +3809,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
||||
const builtInAgentRows = liveAgentRows.filter((agent) => readBuiltInAgentMarker(agent.metadata));
|
||||
const portableAgentRows = liveAgentRows.filter((agent) => !readBuiltInAgentMarker(agent.metadata));
|
||||
const companySkillRowsRaw = include.skills || include.agents ? await companySkills.listFull(companyId) : [];
|
||||
const companySkillRowsRaw = include.skills ? await companySkills.listFull(companyId) : [];
|
||||
const managedSkillRows = companySkillRowsRaw.filter((skill) => managedSkillIds.has(skill.id));
|
||||
const companySkillRows = companySkillRowsRaw.filter((skill) => !managedSkillIds.has(skill.id));
|
||||
if (include.agents) {
|
||||
|
|
@ -4094,27 +4120,50 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
const skillExportDirs = buildSkillExportDirMap(selectedSkillRows, company.issuePrefix);
|
||||
const skillFileJobs: Array<{ filePath: string; load: () => Promise<string | null> }> = [];
|
||||
for (const skill of selectedSkillRows) {
|
||||
const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`;
|
||||
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
||||
files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
|
||||
skillFileJobs.push({
|
||||
filePath: `${packageDir}/SKILL.md`,
|
||||
load: () => buildReferencedSkillMarkdown(skill),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const inventoryEntry of skill.fileInventory) {
|
||||
const fileDetail = await companySkills.readFile(companyId, skill.id, inventoryEntry.path).catch(() => null);
|
||||
if (!fileDetail) continue;
|
||||
const filePath = `${packageDir}/${inventoryEntry.path}`;
|
||||
files[filePath] = inventoryEntry.path === "SKILL.md"
|
||||
? await withSkillSourceMetadata(skill, fileDetail.content)
|
||||
: fileDetail.content;
|
||||
skillFileJobs.push({
|
||||
filePath: `${packageDir}/${inventoryEntry.path}`,
|
||||
load: async () => {
|
||||
const fileDetail = await companySkills
|
||||
.readFile(companyId, skill.id, inventoryEntry.path)
|
||||
.catch(() => null);
|
||||
if (!fileDetail) return null;
|
||||
return inventoryEntry.path === "SKILL.md"
|
||||
? withSkillSourceMetadata(skill, fileDetail.content)
|
||||
: fileDetail.content;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const skillFileResults = await mapWithConcurrency(
|
||||
skillFileJobs,
|
||||
EXPORT_READ_CONCURRENCY,
|
||||
async (job) => ({ filePath: job.filePath, content: await job.load() }),
|
||||
);
|
||||
for (const result of skillFileResults) {
|
||||
if (result.content !== null) files[result.filePath] = result.content;
|
||||
}
|
||||
|
||||
if (include.agents) {
|
||||
const agentInstructionsById = new Map(
|
||||
await mapWithConcurrency(agentRows, EXPORT_READ_CONCURRENCY, async (agent) => (
|
||||
[agent.id, await instructions.exportFiles(agent)] as const
|
||||
)),
|
||||
);
|
||||
for (const agent of agentRows) {
|
||||
const slug = idToSlug.get(agent.id)!;
|
||||
const exportedInstructions = await instructions.exportFiles(agent);
|
||||
const exportedInstructions = agentInstructionsById.get(agent.id)!;
|
||||
warnings.push(...exportedInstructions.warnings);
|
||||
|
||||
const envInputsStart = envInputs.length;
|
||||
|
|
@ -4262,7 +4311,36 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
let unexportedParentEdgeCount = 0;
|
||||
let unportableWorkProductRefCount = 0;
|
||||
const exportedBlobs = new Map<string, CompanyPortabilityBlobManifestEntry>();
|
||||
// A task export needs several independent relations per task. Load a
|
||||
// bounded number of task groups in parallel so large companies do not pay
|
||||
// thousands of serialized database round trips, while still respecting
|
||||
// the default database pool size.
|
||||
const issueExportDetails = new Map(
|
||||
await mapWithConcurrency(
|
||||
selectedIssueRows,
|
||||
EXPORT_ISSUE_READ_CONCURRENCY,
|
||||
async (issue) => {
|
||||
const [comments, relationSummaries, issueDocumentRows, workProductRows, attachmentRows] = await Promise.all([
|
||||
issuesSvc.listComments(issue.id, { order: "asc" }),
|
||||
issuesSvc.getRelationSummaries(issue.id),
|
||||
documentsSvc.listIssueDocuments(issue.id, { includeSystem: true }),
|
||||
workProductsSvc.listForIssue(issue.id),
|
||||
issuesSvc.listAttachments(issue.id),
|
||||
]);
|
||||
return [issue.id, {
|
||||
comments,
|
||||
relationSummaries,
|
||||
issueDocumentRows,
|
||||
workProductRows,
|
||||
attachmentRows: attachmentRows
|
||||
.slice()
|
||||
.sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime()),
|
||||
}] as const;
|
||||
},
|
||||
),
|
||||
);
|
||||
for (const issue of selectedIssueRows) {
|
||||
const details = issueExportDetails.get(issue.id)!;
|
||||
const taskSlug = taskSlugByIssueId.get(issue.id)!;
|
||||
const projectSlug = issue.projectId ? (projectSlugById.get(issue.projectId) ?? null) : null;
|
||||
// All tasks go in top-level tasks/ folder, never nested under projects/
|
||||
|
|
@ -4283,10 +4361,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
});
|
||||
}
|
||||
}
|
||||
const comments = await issuesSvc.listComments(issue.id, { order: "asc" });
|
||||
const comments = details.comments;
|
||||
// Blocker edges travel by task slug; only edges with both endpoints in
|
||||
// the export can be carried.
|
||||
const relationSummaries = await issuesSvc.getRelationSummaries(issue.id);
|
||||
const relationSummaries = details.relationSummaries;
|
||||
const blockedBySlugs: string[] = [];
|
||||
for (const blocker of relationSummaries.blockedBy) {
|
||||
const blockerSlug = taskSlugByIssueId.get(blocker.id);
|
||||
|
|
@ -4307,7 +4385,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
parentTaskSlug = taskSlugByIssueId.get(issue.parentId) ?? null;
|
||||
if (!parentTaskSlug) unexportedParentEdgeCount += 1;
|
||||
}
|
||||
const issueDocumentRows = await documentsSvc.listIssueDocuments(issue.id, { includeSystem: true });
|
||||
const issueDocumentRows = details.issueDocumentRows;
|
||||
const documentEntries = issueDocumentRows.map((document) => {
|
||||
const documentPath = `tasks/${taskSlug}/documents/${document.key}.md`;
|
||||
files[documentPath] = document.body ?? "";
|
||||
|
|
@ -4318,7 +4396,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
path: documentPath,
|
||||
};
|
||||
});
|
||||
const workProductRows = await workProductsSvc.listForIssue(issue.id);
|
||||
const workProductRows = details.workProductRows;
|
||||
const workProductEntries = workProductRows.map((workProduct) => {
|
||||
if (workProduct.executionWorkspaceId || workProduct.runtimeServiceId || workProduct.createdByRunId) {
|
||||
unportableWorkProductRefCount += 1;
|
||||
|
|
@ -4340,9 +4418,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
// Attachment bytes travel as content-addressed blobs/<sha256> entries,
|
||||
// deduped across the bundle; each per-task entry references its blob by
|
||||
// hash and its comment by index into the exported comments array.
|
||||
const attachmentRows = (await issuesSvc.listAttachments(issue.id))
|
||||
.slice()
|
||||
.sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime());
|
||||
const attachmentRows = details.attachmentRows;
|
||||
const commentIndexById = new Map(comments.map((comment, index) => [comment.id, index] as const));
|
||||
const attachmentEntries: Array<Record<string, unknown>> = [];
|
||||
if (attachmentRows.length > 0 && !storage) {
|
||||
|
|
@ -4627,7 +4703,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
resolved.warnings.unshift(...warnings);
|
||||
|
||||
// Generate org chart PNG from manifest agents
|
||||
if (resolved.manifest.agents.length > 0) {
|
||||
if (!options.preview && resolved.manifest.agents.length > 0) {
|
||||
try {
|
||||
const orgNodes = buildOrgTreeFromManifest(resolved.manifest.agents);
|
||||
const pngBuffer = await renderOrgChartPng(orgNodes);
|
||||
|
|
@ -4686,7 +4762,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
if (previewInput.include && previewInput.include.issues === undefined) {
|
||||
previewInput.include.issues = false;
|
||||
}
|
||||
const exported = await exportBundle(companyId, previewInput);
|
||||
const exported = await exportBundle(companyId, previewInput, { preview: true });
|
||||
return {
|
||||
...exported,
|
||||
fileInventory: Object.keys(exported.files)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,32 @@ describe("selectConfiguredRuntimeServiceRows", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("keeps an exposed dev runtime tracked after its bind command is hardened", () => {
|
||||
const exposedWeb = runtimeServiceRow({
|
||||
serviceName: "paperclip-dev",
|
||||
command: "pnpm dev --bind loopback",
|
||||
exposure: {
|
||||
provider: "tailscale_https",
|
||||
state: "ready",
|
||||
publicUrl: "https://paperclip-dev.example.ts.net:42012",
|
||||
hostname: "paperclip-dev.example.ts.net",
|
||||
listeners: [],
|
||||
brokerRef: "broker-1",
|
||||
lastError: null,
|
||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const selected = selectConfiguredRuntimeServiceRows(
|
||||
[exposedWeb],
|
||||
{ services: [{ name: "paperclip-dev", command: "pnpm dev --bind lan" }] },
|
||||
);
|
||||
|
||||
expect(selected).toEqual([
|
||||
expect.objectContaining({ id: exposedWeb.id, configIndex: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches configured services only to rows with the configured reuse scope", () => {
|
||||
const staleProjectScopedWorker = runtimeServiceRow({
|
||||
serviceName: "worker",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Outlet, Route, Routes, useActiveCompanyPrefix, useLocation, useParams } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "@/i18n";
|
||||
|
|
@ -68,13 +69,11 @@ import { CompanyInvites } from "./pages/CompanyInvites";
|
|||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { SkillStudio } from "./pages/SkillStudio";
|
||||
import { Secrets } from "./pages/Secrets";
|
||||
import { CompanyExport } from "./pages/CompanyExport";
|
||||
import { CompanyImport } from "./pages/CompanyImport";
|
||||
import { DesignGuide } from "./pages/DesignGuide";
|
||||
import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings";
|
||||
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
|
||||
import { InstanceAccess } from "./pages/InstanceAccess";
|
||||
import { InstanceSettings } from "./pages/InstanceSettings";
|
||||
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
|
||||
import { ProfileSettings } from "./pages/ProfileSettings";
|
||||
import { PluginManager } from "./pages/PluginManager";
|
||||
import { PluginSettings } from "./pages/PluginSettings";
|
||||
|
|
@ -99,6 +98,10 @@ import {
|
|||
import { useCompanyMission } from "./hooks/useCompanyMission";
|
||||
import { normalizeRememberedInstanceSettingsPath } from "./lib/instance-settings";
|
||||
|
||||
const CompanyExport = lazy(() =>
|
||||
import("./pages/CompanyExport").then((module) => ({ default: module.CompanyExport })),
|
||||
);
|
||||
|
||||
function boardRoutes() {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -114,7 +117,14 @@ function boardRoutes() {
|
|||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route
|
||||
path="company/export/*"
|
||||
element={(
|
||||
<Suspense fallback={<PaperclipLoading />}>
|
||||
<CompanyExport />
|
||||
</Suspense>
|
||||
)}
|
||||
/>
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
<Route path="company/settings/secrets" element={<Secrets />} />
|
||||
<Route path="company/settings/tools" element={<LegacyToolsSettingsRedirect />} />
|
||||
|
|
@ -144,9 +154,9 @@ function boardRoutes() {
|
|||
<Route path="apps/:connectionId" element={<Navigate to="setup" replace />} />
|
||||
<Route path="apps/:connectionId/:tab" element={<AppDetail />} />
|
||||
</Route>
|
||||
<Route path="company/settings/instance" element={<Navigate to="general" replace />} />
|
||||
<Route path="company/settings/instance" element={<Navigate to="/company/settings" replace />} />
|
||||
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
|
||||
<Route path="company/settings/instance/general" element={<InstanceGeneralSettings />} />
|
||||
<Route path="company/settings/instance/general" element={<Navigate to="/company/settings" replace />} />
|
||||
<Route path="company/settings/instance/environments" element={<CompanyEnvironments />} />
|
||||
<Route path="company/settings/instance/environments/new" element={<CompanyEnvironments mode="create" />} />
|
||||
<Route path="company/settings/instance/environments/:environmentId/edit" element={<CompanyEnvironments mode="edit" />} />
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
type CompanyImportTransferPartUploadResult,
|
||||
type CompanyImportTransferStatus,
|
||||
} from "@paperclipai/shared/company-import-transfer";
|
||||
import { api, detachInflightGet } from "./client";
|
||||
import { api, detachInflightGet, type RequestOptions } from "./client";
|
||||
|
||||
const COMPANIES_LIST_PATH = "/companies";
|
||||
|
||||
|
|
@ -122,8 +122,9 @@ export const companiesApi = {
|
|||
exportPreview: (
|
||||
companyId: string,
|
||||
data: CompanyPortabilityExportRequest,
|
||||
options?: RequestOptions,
|
||||
) =>
|
||||
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
|
||||
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data, options),
|
||||
exportFidelity: (companyId: string) =>
|
||||
api.get<ExportFidelityReport>(`/companies/${companyId}/export/fidelity`),
|
||||
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@ export function IssueChatFeedbackButtons({
|
|||
<span className="font-medium text-foreground">Don't allow</span> to keep this vote
|
||||
and future votes local.
|
||||
</p>
|
||||
<p>You can change this later in Instance Settings > General.</p>
|
||||
<p>You can change this later in Settings > General.</p>
|
||||
{termsUrl ? (
|
||||
<a
|
||||
href={termsUrl}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function claimErrorCopy(error: BootstrapPendingPageProps["claimError"]) {
|
|||
if (error?.status === 409) {
|
||||
return {
|
||||
title: "Someone else has already claimed this instance.",
|
||||
body: "Refresh to sign in, or ask the existing admin to invite you from Instance settings -> Access.",
|
||||
body: "Refresh to sign in, or ask the existing admin to invite you from Settings -> Access.",
|
||||
};
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ describe("CompanySettingsSidebar", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the company back link and the settings sections in the sidebar", async () => {
|
||||
it("renders one unified settings list without company or instance headers", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
|
|
@ -137,9 +137,8 @@ describe("CompanySettingsSidebar", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip");
|
||||
expect(container.textContent).toContain("Company Settings");
|
||||
expect(container.textContent).toContain("Company settings");
|
||||
expect(container.textContent).toContain("Instance settings");
|
||||
expect(container.textContent).not.toContain("Company Settings");
|
||||
expect(container.textContent).not.toContain("Instance Settings");
|
||||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).toContain("Environments");
|
||||
expect(container.textContent).toContain("Export");
|
||||
|
|
@ -147,6 +146,8 @@ describe("CompanySettingsSidebar", () => {
|
|||
expect(container.textContent).toContain("Members");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(container.textContent).toContain("Secrets");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Heartbeats");
|
||||
expect(container.textContent).not.toContain("Tools & Access");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -175,6 +176,20 @@ describe("CompanySettingsSidebar", () => {
|
|||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/instance/access",
|
||||
label: "Access",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/instance/heartbeats",
|
||||
label: "Heartbeats",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/members",
|
||||
|
|
@ -204,13 +219,11 @@ describe("CompanySettingsSidebar", () => {
|
|||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/instance/general",
|
||||
label: "General",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(new Set(
|
||||
sidebarNavItemMock.mock.calls
|
||||
.filter(([props]) => props.label === "General")
|
||||
.map(([props]) => props.to),
|
||||
)).toEqual(new Set(["/company/settings"]));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/instance/plugins",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
MailPlus,
|
||||
MonitorCog,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
Upload,
|
||||
|
|
@ -86,22 +85,17 @@ export function CompanySettingsSidebar() {
|
|||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{selectedCompany?.name ?? "Company"}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<Settings className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">
|
||||
Company Settings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
|
||||
<div className="px-3 pb-1 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Company settings
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem to="/company/export" label="Export" icon={Download} />
|
||||
<SidebarNavItem to="/company/import" label="Import" icon={Upload} end />
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`}
|
||||
label="Profile"
|
||||
icon={UserRoundPen}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to="/company/settings/members"
|
||||
label="Members"
|
||||
|
|
@ -122,23 +116,6 @@ export function CompanySettingsSidebar() {
|
|||
))}
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||
</div>
|
||||
<div className="mt-5 px-3 pb-1 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Instance settings
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`}
|
||||
label="Profile"
|
||||
icon={UserRoundPen}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/general`}
|
||||
label="General"
|
||||
icon={SlidersHorizontal}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`}
|
||||
label="Environments"
|
||||
|
|
@ -157,6 +134,8 @@ export function CompanySettingsSidebar() {
|
|||
icon={Clock3}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem to="/company/export" label="Export" icon={Download} />
|
||||
<SidebarNavItem to="/company/import" label="Import" icon={Upload} end />
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`}
|
||||
label="Experimental"
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export function CompanySwitcher({ open: controlledOpen, onOpenChange }: CompanyS
|
|||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings" className="no-underline text-inherit">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Company Settings
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
|
|
|
|||
|
|
@ -137,17 +137,29 @@ describe("InboxAgentPolicyControl", () => {
|
|||
expect(save?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
// Switch to allowlist — only non-terminated agents are selectable.
|
||||
// Switch to allowlist — agents stay in the compact selector instead of a
|
||||
// permanently expanded list.
|
||||
await act(async () => optionByTitle(container, "Only chosen agents")!.click());
|
||||
await flush();
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Gardener");
|
||||
expect(container.textContent).toContain("Coder");
|
||||
expect(container.textContent).toContain("Select agents");
|
||||
expect(container.textContent).not.toContain("Gardener");
|
||||
expect(container.textContent).not.toContain("Coder");
|
||||
expect(container.textContent).not.toContain("Retired");
|
||||
});
|
||||
|
||||
const gardenerCheckbox = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Allow Gardener to tidy my inbox"]',
|
||||
const selector = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Select agents"));
|
||||
await act(async () => selector!.click());
|
||||
await flush();
|
||||
await waitForAssertion(() => {
|
||||
expect(document.body.textContent).toContain("Gardener");
|
||||
expect(document.body.textContent).toContain("Coder");
|
||||
expect(document.body.textContent).not.toContain("Retired");
|
||||
});
|
||||
|
||||
const gardenerCheckbox = document.body.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Allow Gardener"]',
|
||||
);
|
||||
expect(gardenerCheckbox).toBeTruthy();
|
||||
await act(async () => gardenerCheckbox!.click());
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ import { agentsApi } from "@/api/agents";
|
|||
import { inboxAgentPolicyApi } from "@/api/inbox-agent-policy";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { isAgentTaskTarget } from "@/lib/company-members";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadioCardGroup, type RadioCardOption } from "@/components/ui/radio-card";
|
||||
|
||||
|
|
@ -66,6 +65,19 @@ export function InboxAgentPolicyControl({ companyId }: { companyId: string | nul
|
|||
() => (agentsQuery.data ?? []).filter(isAgentTaskTarget),
|
||||
[agentsQuery.data],
|
||||
);
|
||||
const agentOptions = useMemo(
|
||||
() => selectableAgents.map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
title: agent.title ?? agent.role,
|
||||
icon: agent.icon,
|
||||
})),
|
||||
[selectableAgents],
|
||||
);
|
||||
const selectedAgentIds = useMemo(
|
||||
() => new Set(draft?.allowedAgentIds ?? []),
|
||||
[draft?.allowedAgentIds],
|
||||
);
|
||||
|
||||
// Adopt server state on first load, or on refetch when the user has not
|
||||
// diverged from the previously-synced snapshot (so a background refetch never
|
||||
|
|
@ -109,16 +121,6 @@ export function InboxAgentPolicyControl({ companyId }: { companyId: string | nul
|
|||
return <div className="text-sm text-muted-foreground">Loading inbox agent policy…</div>;
|
||||
}
|
||||
|
||||
const toggleAgent = (agentId: string, checked: boolean) => {
|
||||
setDraft((current) => {
|
||||
if (!current) return current;
|
||||
const set = new Set(current.allowedAgentIds);
|
||||
if (checked) set.add(agentId);
|
||||
else set.delete(agentId);
|
||||
return { ...current, allowedAgentIds: [...set] };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4" aria-label="Let agents tidy my inbox">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -141,31 +143,25 @@ export function InboxAgentPolicyControl({ companyId }: { companyId: string | nul
|
|||
/>
|
||||
|
||||
{draft.mode === "allowlist" ? (
|
||||
<div className="max-w-2xl space-y-2 rounded-md border border-border p-3">
|
||||
<div className="max-w-2xl space-y-2">
|
||||
<div className="text-sm font-medium">Agents allowed to tidy my inbox</div>
|
||||
{selectableAgents.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">You don't manage any agents yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{selectableAgents.map((agent) => {
|
||||
const checked = draft.allowedAgentIds.includes(agent.id);
|
||||
return (
|
||||
<li key={agent.id}>
|
||||
<label className="flex cursor-pointer items-center gap-2.5 rounded-md px-1.5 py-1 transition-colors hover:bg-accent/40">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleAgent(agent.id, next === true)}
|
||||
aria-label={`Allow ${agent.name} to tidy my inbox`}
|
||||
/>
|
||||
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate text-sm">{agent.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{agent.role}</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<AgentMultiSelect
|
||||
agents={agentOptions}
|
||||
selectedAgentIds={selectedAgentIds}
|
||||
onChange={(next) =>
|
||||
setDraft((current) =>
|
||||
current ? { ...current, allowedAgentIds: [...next] } : current,
|
||||
)
|
||||
}
|
||||
triggerLabel={
|
||||
selectedAgentIds.size === 0
|
||||
? "Select agents"
|
||||
: `${selectedAgentIds.size} ${selectedAgentIds.size === 1 ? "agent" : "agents"} selected`
|
||||
}
|
||||
triggerFullWidth={false}
|
||||
showSelectionPreview={false}
|
||||
emptyMessage="You don’t manage any agents yet."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ describe("InstanceSidebar", () => {
|
|||
const pluginLinks = await findPluginLinks(container, 1);
|
||||
expect(pluginLinks[0]?.getAttribute("href")).toBe("/company/settings/instance/plugins/linear");
|
||||
expect(pluginLinks[0]?.textContent).toBe("Linear");
|
||||
expect(container.textContent).not.toContain("Access");
|
||||
expect(container.textContent).not.toContain("Heartbeats");
|
||||
});
|
||||
|
||||
it("keeps plugins that mix sandbox-provider with other contributions", async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, Cpu, FlaskConical, MonitorCog, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
|
||||
import { Cpu, FlaskConical, MonitorCog, Puzzle, SlidersHorizontal, UserRoundPen } from "lucide-react";
|
||||
import type { PluginRecord } from "@paperclipai/shared";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
|
|
@ -30,20 +30,11 @@ export function InstanceSidebar() {
|
|||
|
||||
return (
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
|
||||
<Settings className="h-4 w-4 text-muted-foreground shrink-0 ml-1" />
|
||||
<span className="flex-1 text-sm font-bold text-foreground truncate">
|
||||
Instance Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`} label="Profile" icon={UserRoundPen} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/general`} label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`} label="Environments" icon={MonitorCog} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`} label="Access" icon={Shield} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`} label="Heartbeats" icon={Clock3} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`} label="Experimental" icon={FlaskConical} />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/plugins`} label="Plugins" icon={Puzzle} />
|
||||
{sidebarPlugins.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ export function InteractionGovernancePanel({
|
|||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Interaction governance
|
||||
</div>
|
||||
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thread interactions are open by default:{" "}
|
||||
<span className="font-medium text-foreground">Anyone</span> in the company — the
|
||||
|
|
|
|||
|
|
@ -2222,7 +2222,7 @@ function IssueChatFeedbackButtons({
|
|||
<span className="font-medium text-foreground">Don't allow</span> to keep this vote
|
||||
and future votes local.
|
||||
</p>
|
||||
<p>You can change this later in Instance Settings > General.</p>
|
||||
<p>You can change this later in Settings > General.</p>
|
||||
{termsUrl ? (
|
||||
<a
|
||||
href={termsUrl}
|
||||
|
|
|
|||
|
|
@ -469,9 +469,10 @@ describe("Layout", () => {
|
|||
expect(selectorText).toContain("members");
|
||||
expect(selectorText).toContain("invites");
|
||||
expect(selectorText).toContain("secrets");
|
||||
expect(selectorText).toContain("instance general");
|
||||
expect(selectorText).toContain("instance environments");
|
||||
expect(selectorText).toContain("instance plugins");
|
||||
expect(selectorText).toContain("profile");
|
||||
expect(selectorText).toContain("environments");
|
||||
expect(selectorText).toContain("plugins");
|
||||
expect(selectorText).not.toContain("instance general");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -506,6 +507,35 @@ describe("Layout", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["/PAP/company/export", "/PAP/company/import"])(
|
||||
"renders the shared settings sidebar on %s",
|
||||
async (pathname) => {
|
||||
currentPathname = pathname;
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the app sidebar and shows the Apps sidebar in the secondary pane on legacy tools routes", async () => {
|
||||
currentPathname = "/PAP/tools/runtime";
|
||||
const root = createRoot(container);
|
||||
|
|
|
|||
|
|
@ -113,7 +113,11 @@ export function Layout() {
|
|||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const { enabled: appsEnabled } = useAppsEnabled();
|
||||
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
|
||||
const isCompanySettingsRoute = [
|
||||
"/company/settings",
|
||||
"/company/export",
|
||||
"/company/import",
|
||||
].some((settingsPath) => location.pathname.includes(settingsPath));
|
||||
const companyPathSegments = getCompanyPathSegments(location.pathname, companyPrefix);
|
||||
const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools";
|
||||
const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps";
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ export function OutputFeedbackButtons({
|
|||
and future votes local.
|
||||
</p>
|
||||
<p>
|
||||
You can change this later in Instance Settings > General.
|
||||
You can change this later in Settings > General.
|
||||
</p>
|
||||
{termsUrl ? (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -78,10 +78,12 @@ describe("CompanySettingsNav", () => {
|
|||
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/secrets")).toBe("secrets");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/profile")).toBe("instance-profile");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/instance/general")).toBe("instance-general");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/instance/general")).toBe("general");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/environments")).toBe("instance-environments");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/access")).toBe("instance-access");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/instance/access")).toBe("instance-access");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/heartbeats")).toBe("instance-heartbeats");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/instance/heartbeats")).toBe("instance-heartbeats");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/experimental")).toBe("instance-experimental");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/instance/plugins/example")).toBe("instance-plugins");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/adapters")).toBe("instance-adapters");
|
||||
|
|
@ -106,14 +108,13 @@ describe("CompanySettingsNav", () => {
|
|||
{ value: "members", label: "Members" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
{ value: "secrets", label: "Secrets" },
|
||||
{ value: "instance-profile", label: "Instance profile" },
|
||||
{ value: "instance-general", label: "Instance general" },
|
||||
{ value: "instance-environments", label: "Instance environments" },
|
||||
{ value: "instance-access", label: "Instance access" },
|
||||
{ value: "instance-heartbeats", label: "Instance heartbeats" },
|
||||
{ value: "instance-experimental", label: "Instance experimental" },
|
||||
{ value: "instance-plugins", label: "Instance plugins" },
|
||||
{ value: "instance-adapters", label: "Instance adapters" },
|
||||
{ value: "instance-profile", label: "Profile" },
|
||||
{ value: "instance-environments", label: "Environments" },
|
||||
{ value: "instance-access", label: "Access" },
|
||||
{ value: "instance-heartbeats", label: "Heartbeats" },
|
||||
{ value: "instance-experimental", label: "Experimental" },
|
||||
{ value: "instance-plugins", label: "Plugins" },
|
||||
{ value: "instance-adapters", label: "Adapters" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@ const items = [
|
|||
{ value: "members", label: "Members", href: "/company/settings/members" },
|
||||
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
|
||||
{ value: "secrets", label: "Secrets", href: "/company/settings/secrets" },
|
||||
{ value: "instance-profile", label: "Instance profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` },
|
||||
{ value: "instance-general", label: "Instance general", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/general` },
|
||||
{ value: "instance-environments", label: "Instance environments", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/environments` },
|
||||
{ value: "instance-access", label: "Instance access", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/access` },
|
||||
{ value: "instance-heartbeats", label: "Instance heartbeats", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats` },
|
||||
{ value: "instance-experimental", label: "Instance experimental", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/experimental` },
|
||||
{ value: "instance-plugins", label: "Instance plugins", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/plugins` },
|
||||
{ value: "instance-adapters", label: "Instance adapters", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/adapters` },
|
||||
{ value: "instance-profile", label: "Profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` },
|
||||
{ value: "instance-environments", label: "Environments", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/environments` },
|
||||
{ value: "instance-access", label: "Access", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/access` },
|
||||
{ value: "instance-heartbeats", label: "Heartbeats", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats` },
|
||||
{ value: "instance-experimental", label: "Experimental", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/experimental` },
|
||||
{ value: "instance-plugins", label: "Plugins", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/plugins` },
|
||||
{ value: "instance-adapters", label: "Adapters", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/adapters` },
|
||||
] as const;
|
||||
|
||||
type CompanySettingsTab = (typeof items)[number]["value"];
|
||||
|
|
@ -27,14 +26,14 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
|||
return "instance-profile";
|
||||
}
|
||||
|
||||
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/access`)) {
|
||||
return "instance-access";
|
||||
}
|
||||
|
||||
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`)) {
|
||||
return "instance-environments";
|
||||
}
|
||||
|
||||
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/access`)) {
|
||||
return "instance-access";
|
||||
}
|
||||
|
||||
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`)) {
|
||||
return "instance-heartbeats";
|
||||
}
|
||||
|
|
@ -52,7 +51,7 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
|||
}
|
||||
|
||||
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/general`)) {
|
||||
return "instance-general";
|
||||
return "general";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/environments")) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ describe("normalizeRememberedInstanceSettingsPath", () => {
|
|||
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/environments")).toBe(
|
||||
"/company/settings/instance/environments",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/settings/access?tab=users#admins")).toBe(
|
||||
"/company/settings/instance/access?tab=users#admins",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/heartbeats")).toBe(
|
||||
"/company/settings/instance/heartbeats",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/plugins/example?tab=config#logs")).toBe(
|
||||
"/company/settings/instance/plugins/example?tab=config#logs",
|
||||
);
|
||||
|
|
@ -30,9 +36,6 @@ describe("normalizeRememberedInstanceSettingsPath", () => {
|
|||
expect(normalizeRememberedInstanceSettingsPath("/company/settings/environments")).toBe(
|
||||
"/company/settings/instance/environments",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/settings/access?tab=users#admins")).toBe(
|
||||
"/company/settings/instance/access?tab=users#admins",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/PAP/settings/plugins/example")).toBe(
|
||||
"/company/settings/instance/plugins/example",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -199,6 +199,81 @@ describe("resolveWorkspaceAccessState", () => {
|
|||
expect(access.description).toContain("restore");
|
||||
});
|
||||
|
||||
it("returns to ready after a failed seed when a later repair succeeded and a healthy runtime is serving", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService({ startedAt: new Date("2026-08-20T00:18:00.000Z") })],
|
||||
operations: [
|
||||
operation({
|
||||
id: "repair-1",
|
||||
phase: "workspace_repair",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-08-20T00:10:00.000Z"),
|
||||
finishedAt: new Date("2026-08-20T00:17:31.000Z"),
|
||||
}),
|
||||
operation({
|
||||
id: "seed-1",
|
||||
phase: "workspace_seed",
|
||||
status: "failed",
|
||||
metadata: { seedFailurePhase: "manifest_verification" },
|
||||
startedAt: new Date("2026-08-20T00:02:00.000Z"),
|
||||
finishedAt: new Date("2026-08-20T00:04:37.000Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(access).toMatchObject({
|
||||
state: "ready",
|
||||
action: { kind: "open", label: "Open workspace" },
|
||||
secondaryNotice: {
|
||||
title: "Database provisioning failed",
|
||||
action: { kind: "view_logs", label: "View provisioning log" },
|
||||
},
|
||||
});
|
||||
expect(access.secondaryNotice?.description).toContain("manifest_verification");
|
||||
expect(access.secondaryNotice?.description).toContain("later became usable");
|
||||
});
|
||||
|
||||
it("does not offer repair for a stale failed seed after a later successful repair", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [],
|
||||
operations: [
|
||||
operation({
|
||||
id: "repair-1",
|
||||
phase: "workspace_repair",
|
||||
status: "succeeded",
|
||||
finishedAt: new Date("2026-08-20T00:17:31.000Z"),
|
||||
}),
|
||||
operation({
|
||||
id: "seed-1",
|
||||
phase: "workspace_seed",
|
||||
status: "failed",
|
||||
finishedAt: new Date("2026-08-20T00:04:37.000Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(access).toMatchObject({
|
||||
state: "provisioning",
|
||||
action: { kind: "start", label: "Start workspace" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to ready when a healthy runtime proves a failed seed is stale", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService()],
|
||||
operations: [operation({ status: "failed" })],
|
||||
});
|
||||
|
||||
expect(access).toMatchObject({
|
||||
state: "ready",
|
||||
action: { kind: "open", label: "Open workspace" },
|
||||
secondaryNotice: {
|
||||
title: "Database provisioning failed",
|
||||
action: { kind: "view_logs" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validating, not degraded, while a fresh clone is still being confirmed", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService()],
|
||||
|
|
|
|||
|
|
@ -81,6 +81,19 @@ function failedRepairNotice(repair: WorkspaceOperation): WorkspaceAccessNotice {
|
|||
};
|
||||
}
|
||||
|
||||
function failedProvisionNotice(provision: WorkspaceOperation): WorkspaceAccessNotice {
|
||||
const phase = typeof provision.metadata?.seedFailurePhase === "string"
|
||||
? provision.metadata.seedFailurePhase
|
||||
: null;
|
||||
return {
|
||||
title: "Database provisioning failed",
|
||||
description: phase
|
||||
? `The earlier clone attempt failed during ${phase}. The workspace later became usable.`
|
||||
: "An earlier clone attempt failed, but the workspace later became usable.",
|
||||
action: { kind: "view_logs", label: "View provisioning log" },
|
||||
};
|
||||
}
|
||||
|
||||
const HANDOFF_REASON_COPY: Record<string, string> = {
|
||||
handoff_not_configured:
|
||||
"This instance has no workspace login handoff configured, so opening the board falls back to snapshot-local credentials.",
|
||||
|
|
@ -136,6 +149,7 @@ export function resolveWorkspaceAccessState(input: {
|
|||
);
|
||||
const repairFinishedAt = timestampMs(repair?.finishedAt);
|
||||
const servingServiceStartedAt = timestampMs(servingService?.startedAt);
|
||||
const provisionFinishedAt = timestampMs(provision?.finishedAt);
|
||||
const readinessConfirmsServing = Boolean(servingService && failure?.readiness?.state === "ready");
|
||||
const runtimeStartedAfterRepair = repairFinishedAt !== null
|
||||
&& servingServiceStartedAt !== null
|
||||
|
|
@ -144,9 +158,24 @@ export function resolveWorkspaceAccessState(input: {
|
|||
servingService
|
||||
&& (readinessConfirmsServing || runtimeStartedAfterRepair),
|
||||
);
|
||||
const successfulRepairFinishedAt = repair?.status === "succeeded"
|
||||
? timestampMs(repair.finishedAt)
|
||||
: null;
|
||||
// A failed seed is historical once the workspace is demonstrably serving,
|
||||
// or once a later repair has replaced and revalidated that database.
|
||||
const provisionFailureWasSuperseded = provision?.status === "failed" && Boolean(
|
||||
servingService
|
||||
|| (
|
||||
provisionFinishedAt !== null
|
||||
&& successfulRepairFinishedAt !== null
|
||||
&& provisionFinishedAt < successfulRepairFinishedAt
|
||||
),
|
||||
);
|
||||
const secondaryNotice = repair?.status === "failed" && repairFailureWasSuperseded
|
||||
? failedRepairNotice(repair)
|
||||
: undefined;
|
||||
: provision?.status === "failed" && provisionFailureWasSuperseded
|
||||
? failedProvisionNotice(provision)
|
||||
: undefined;
|
||||
|
||||
// A live repair outranks everything: it is already changing the answer.
|
||||
if (repair?.status === "running") {
|
||||
|
|
@ -179,7 +208,7 @@ export function resolveWorkspaceAccessState(input: {
|
|||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
if (provision?.status === "failed") {
|
||||
if (provision?.status === "failed" && !provisionFailureWasSuperseded) {
|
||||
const seedPhase = typeof provision.metadata?.seedFailurePhase === "string"
|
||||
? provision.metadata.seedFailurePhase
|
||||
: null;
|
||||
|
|
|
|||
|
|
@ -271,7 +271,6 @@ export function AdapterManager() {
|
|||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Instance settings", href: "/company/settings/instance/general" },
|
||||
{ label: "Adapters" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
|
@ -397,7 +396,7 @@ export function AdapterManager() {
|
|||
const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || overrideMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div className="max-w-6xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ function ClaimErrorPrivate() {
|
|||
<p className="font-medium">Someone else has already claimed this instance.</p>
|
||||
<p className="mt-1 text-destructive/90">
|
||||
Refresh to sign in, or ask the existing admin to invite you from{" "}
|
||||
<span className="font-mono">Instance settings → Access</span>.
|
||||
<span className="font-mono">Settings → Access</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ describe("CompanyAccess", () => {
|
|||
id: "user-1",
|
||||
email: "codexcoder@paperclip.local",
|
||||
name: "Codex Coder",
|
||||
image: null,
|
||||
image: "/api/assets/avatar-1/content",
|
||||
},
|
||||
grants: [],
|
||||
},
|
||||
|
|
@ -191,7 +191,7 @@ describe("CompanyAccess", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps the page human-focused and hides advanced permission controls", async () => {
|
||||
it("renders a compact member table without redundant explanatory copy", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
|
|
@ -207,12 +207,14 @@ describe("CompanyAccess", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Manage the people who can work in Paperclip");
|
||||
expect(container.textContent).toContain("Members can collaborate across the company by default");
|
||||
expect(container.textContent).toContain("Core keeps this page focused on membership");
|
||||
expect(container.textContent).toContain("Humans");
|
||||
expect(container.textContent).not.toContain("Manage the people who can work in Paperclip");
|
||||
expect(container.textContent).not.toContain("Members can collaborate across the company by default");
|
||||
expect(container.textContent).not.toContain("Core keeps this page focused on membership");
|
||||
expect(container.textContent).not.toContain("Manage human company memberships and status here");
|
||||
expect(container.textContent).toContain("Pending human joins");
|
||||
expect(container.textContent).toContain("User account");
|
||||
expect(container.textContent).toContain("Name");
|
||||
expect(container.textContent).toContain("Email");
|
||||
expect(container.querySelector('[data-slot="avatar"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Grants");
|
||||
expect(container.textContent).not.toContain("explicit grants");
|
||||
expect(container.textContent).not.toContain("Assign scoped tasks");
|
||||
|
|
@ -392,12 +394,13 @@ describe("CompanyAccess", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company admins cannot be removed from company access.");
|
||||
expect(container.textContent).not.toContain("Company admins cannot be removed from company access.");
|
||||
const removeButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Remove"),
|
||||
);
|
||||
expect(removeButton).toBeTruthy();
|
||||
expect(removeButton).toHaveProperty("disabled", true);
|
||||
expect(removeButton?.getAttribute("title")).toBe("Company admins cannot be removed from company access.");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
|
||||
type Agent,
|
||||
} from "@paperclipai/shared";
|
||||
import { Shield, ShieldCheck, Trash2, Users } from "lucide-react";
|
||||
import { Shield, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { accessApi, type CompanyMember } from "@/api/access";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { ApiError } from "@/api/client";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
|
|
@ -235,38 +236,20 @@ export function CompanyAccess() {
|
|||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Company Members</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.
|
||||
</p>
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
Core keeps this page focused on membership, invite approvals, and safe member removal.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Company Members</h1>
|
||||
</div>
|
||||
|
||||
{access && !access.currentUserRole && (
|
||||
<div className="rounded-xl border border-amber-500/40 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
|
||||
<div className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
|
||||
This account can manage access here through instance-admin privileges, but it does not currently hold an active company membership.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">Humans</h2>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage human company memberships and status here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{access?.canApproveJoinRequests && pendingHumanJoinRequests.length > 0 ? (
|
||||
<div className="space-y-3 rounded-xl border border-border px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Pending human joins</h3>
|
||||
|
|
@ -309,62 +292,79 @@ export function CompanyAccess() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="grid grid-cols-(--gtc-24) gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div>User account</div>
|
||||
<div>Role</div>
|
||||
<div>Status</div>
|
||||
<div className="text-right">Action</div>
|
||||
</div>
|
||||
{members.length === 0 ? (
|
||||
<div className="px-4 py-8 text-sm text-muted-foreground">No user memberships found for this company yet.</div>
|
||||
) : (
|
||||
members.map((member) => {
|
||||
const removalReason = member.removal?.reason ?? null;
|
||||
const canArchive = member.removal?.canArchive ?? true;
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="grid grid-cols-(--gtc-24) gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{member.user?.email || member.principalId}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{member.membershipRole
|
||||
? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole]
|
||||
: "Unset"}
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant={member.status === "active" ? "secondary" : member.status === "suspended" ? "destructive" : "outline"}>
|
||||
{member.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRemovingMemberId(member.id)}
|
||||
disabled={!canArchive}
|
||||
title={removalReason ?? undefined}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{removalReason ? (
|
||||
<div className="text-xs text-muted-foreground">{removalReason}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-(--sz-44rem) text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground">
|
||||
<th className="px-3 py-2 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Email</th>
|
||||
<th className="px-3 py-2 font-medium">Role</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-8 text-muted-foreground">
|
||||
No user memberships found for this company yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : members.map((member) => {
|
||||
const removalReason = member.removal?.reason ?? null;
|
||||
const canArchive = member.removal?.canArchive ?? true;
|
||||
const displayName = memberDisplayName(member);
|
||||
return (
|
||||
<tr key={member.id} className="border-b border-border last:border-b-0">
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar size="sm">
|
||||
{member.user?.image ? <AvatarImage src={member.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback>{memberInitials(member)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium">{displayName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-muted-foreground">
|
||||
{member.user?.email || member.principalId}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
{member.membershipRole
|
||||
? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole]
|
||||
: "Unset"}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<Badge variant={member.status === "active" ? "secondary" : member.status === "suspended" ? "destructive" : "outline"}>
|
||||
{member.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
||||
Edit
|
||||
</Button>
|
||||
<span
|
||||
className="inline-flex"
|
||||
title={!canArchive ? removalReason ?? undefined : undefined}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRemovingMemberId(member.id)}
|
||||
disabled={!canArchive}
|
||||
title={!canArchive ? removalReason ?? undefined : undefined}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -588,6 +588,15 @@ function memberDisplayName(member: CompanyMember | null) {
|
|||
return member.user?.name?.trim() || member.user?.email || member.principalId;
|
||||
}
|
||||
|
||||
function memberInitials(member: CompanyMember) {
|
||||
const value = memberDisplayName(member).trim();
|
||||
const parts = value.split(/\s+/).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase();
|
||||
}
|
||||
return value.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function isAssignableAgent(agent: Agent) {
|
||||
return agent.status !== "terminated" && agent.status !== "pending_approval";
|
||||
}
|
||||
|
|
@ -620,7 +629,7 @@ function PendingJoinRequestCard({
|
|||
onReject: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border px-4 py-4">
|
||||
<div className="py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -631,7 +631,7 @@ describe("CompanyEnvironments — test provider button", () => {
|
|||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
click(findAction(container, "Add environment"));
|
||||
click(container.querySelector('[aria-label="Add environment"]'));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Check, Link2, Lock, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react";
|
||||
import { ArrowLeft, Check, Link2, Lock, Play, Plus, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal as XTermTerminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
|
@ -1285,7 +1285,6 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
useEffect(() => {
|
||||
const crumbs = [
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Instance settings", href: "/company/settings/instance/general" },
|
||||
isEnvironmentFormPage
|
||||
? { label: "Environments", href: ENVIRONMENTS_PATH }
|
||||
: { label: "Environments" },
|
||||
|
|
@ -1753,8 +1752,8 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
|
||||
if (!environmentsEnabled) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
|
||||
<div className="max-w-6xl space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Enable Environments in instance experimental settings to manage shared execution targets.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1762,17 +1761,16 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6" data-testid="instance-settings-environments-section">
|
||||
<div className="max-w-6xl space-y-6" data-testid="instance-settings-environments-section">
|
||||
{!isEnvironmentFormPage ? (
|
||||
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Default</div>
|
||||
</div>
|
||||
<div className="min-w-(--sz-18rem) flex-1">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="flex flex-wrap items-center gap-3 text-sm font-medium">
|
||||
<span>Default</span>
|
||||
<span>
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
aria-label="Default environment"
|
||||
className="min-w-(--sz-12rem) max-w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm font-normal outline-none"
|
||||
value={instanceDefaultEnvironmentId}
|
||||
onChange={(event) =>
|
||||
defaultEnvironmentMutation.mutate(event.target.value || null)}
|
||||
|
|
@ -1796,16 +1794,16 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<Button size="icon-sm" variant="ghost" asChild>
|
||||
<Link to={`${ENVIRONMENTS_PATH}/new`} aria-label="Add environment" title="Add environment">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" asChild>
|
||||
<Link to={`${ENVIRONMENTS_PATH}/new`}>Add environment</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{savedEnvironments.map((environment) => {
|
||||
const probe = probeResults[environment.id] ?? null;
|
||||
const sandboxProvider = readEnvironmentSandboxProvider(environment);
|
||||
|
|
@ -1817,7 +1815,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
return (
|
||||
<div
|
||||
key={environment.id}
|
||||
className="rounded-md border border-border/70 px-3 py-3"
|
||||
className="py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -1826,7 +1824,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
{environment.name} <span className="text-muted-foreground">· {environment.driver}</span>
|
||||
</span>
|
||||
{isPlatformManagedEnvironment(environment) ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 px-2 py-0.5 text-xs font-normal text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
|
||||
<Lock className="h-3 w-3" aria-hidden />
|
||||
Managed by Paperclip
|
||||
</span>
|
||||
|
|
@ -1875,8 +1873,8 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
<div
|
||||
className={
|
||||
probe.ok
|
||||
? "mt-3 rounded border border-green-500/30 bg-green-500/5 px-2.5 py-2 text-xs text-green-700"
|
||||
: "mt-3 rounded border border-destructive/30 bg-destructive/5 px-2.5 py-2 text-xs text-destructive"
|
||||
? "mt-3 rounded bg-green-500/5 px-2.5 py-2 text-xs text-green-700"
|
||||
: "mt-3 rounded bg-destructive/5 px-2.5 py-2 text-xs text-destructive"
|
||||
}
|
||||
>
|
||||
<div className="font-medium">{probe.summary}</div>
|
||||
|
|
@ -1893,13 +1891,13 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
) : null}
|
||||
|
||||
{isEnvironmentFormPage && mode === "edit" && environments === undefined ? (
|
||||
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading environment...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEnvironmentFormPage && mode === "edit" && environments !== undefined && !editingEnvironment ? (
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4 text-sm">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="font-medium">Environment not found</div>
|
||||
<div className="text-muted-foreground">The environment may have been removed or is not available in this company.</div>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
|
|
@ -1910,8 +1908,8 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
|
||||
{isEnvironmentFormPage && mode === "edit" && editingEnvironment && isPlatformManagedEnvironment(editingEnvironment) ? (
|
||||
<SecretRefHintsContext.Provider value={environmentSecretRefHints}>
|
||||
<div className="rounded-md border border-border bg-background" data-testid="managed-environment-form-page">
|
||||
<div className="border-b border-border/60 px-6 pb-4 pt-6">
|
||||
<div data-testid="managed-environment-form-page">
|
||||
<div className="pb-4">
|
||||
<div className="mb-4">
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link to={ENVIRONMENTS_PATH}>
|
||||
|
|
@ -1922,7 +1920,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">{editingEnvironment.name}</h1>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Lock className="h-3 w-3" aria-hidden />
|
||||
Managed by Paperclip
|
||||
</span>
|
||||
|
|
@ -1935,7 +1933,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
variables for your agents; its name and configuration are managed by Paperclip.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="py-4">
|
||||
<Field
|
||||
label="Environment variables"
|
||||
hint="Injected into runs that resolve through this environment. Use plain values or company secrets."
|
||||
|
|
@ -1958,7 +1956,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 bg-background px-6 py-4">
|
||||
<div className="flex flex-wrap justify-end gap-2 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeEnvironmentForm}
|
||||
|
|
@ -1980,8 +1978,8 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
{isEnvironmentFormPage &&
|
||||
(mode === "create" || (editingEnvironment && !isPlatformManagedEnvironment(editingEnvironment))) ? (
|
||||
<SecretRefHintsContext.Provider value={environmentSecretRefHints}>
|
||||
<div className="rounded-md border border-border bg-background" data-testid="environment-form-page">
|
||||
<div className="border-b border-border/60 px-6 pb-4 pt-6">
|
||||
<div data-testid="environment-form-page">
|
||||
<div className="pb-4">
|
||||
<div className="mb-4">
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link to={ENVIRONMENTS_PATH}>
|
||||
|
|
@ -1996,7 +1994,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div className="py-4">
|
||||
<div className="space-y-4">
|
||||
<Field label="Name" hint="Operator-facing name for this execution target.">
|
||||
<input
|
||||
|
|
@ -2172,7 +2170,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
errors={sandboxConfigErrors}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
This provider does not declare additional configuration fields.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2193,7 +2191,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
editingEnvironment.driver === "sandbox" &&
|
||||
environmentForm.driver === "sandbox" &&
|
||||
selectedCompanyId ? (
|
||||
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="space-y-2 py-3">
|
||||
<div className="text-sm font-medium">Custom image</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Start a setup sandbox, SSH in to customize the instance, then capture the
|
||||
|
|
@ -2238,7 +2236,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 bg-background px-6 py-4">
|
||||
<div className="flex flex-wrap justify-end gap-2 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeEnvironmentForm}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ExportFidelityReport } from "@paperclipai/shared/portability-fidelity";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanyExport } from "./CompanyExport";
|
||||
import { CompanyExport, resolveExportPreviewImageSrc } from "./CompanyExport";
|
||||
|
||||
const mockCompaniesApi = vi.hoisted(() => ({
|
||||
exportPreview: vi.fn(),
|
||||
|
|
@ -238,11 +238,85 @@ describe("CompanyExport", () => {
|
|||
await flushReact();
|
||||
}
|
||||
|
||||
it("selects every file by default and requests them all on download", async () => {
|
||||
it("loads the no-task export automatically without an interstitial", async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).not.toContain("Prepare export preview");
|
||||
expect(mockAuthApi.getSession).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.list).toHaveBeenCalledWith("company-1");
|
||||
expect(mockProjectsApi.list).toHaveBeenCalledWith("company-1");
|
||||
expect(mockCompaniesApi.exportPreview).toHaveBeenCalledTimes(1);
|
||||
expect(mockCompaniesApi.exportPreview.mock.calls[0]?.[1]).toMatchObject({
|
||||
include: { company: true, agents: true, projects: true, issues: false, skills: true },
|
||||
});
|
||||
expect(mockCompaniesApi.exportFidelity).toHaveBeenCalledWith("company-1");
|
||||
});
|
||||
|
||||
it("shows a retryable error instead of a false loading state", async () => {
|
||||
mockCompaniesApi.exportPreview
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
|
||||
.mockResolvedValueOnce(buildExportPreviewResult());
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Export preview failed");
|
||||
expect(container.textContent).toContain("Failed to fetch");
|
||||
expect(container.textContent).not.toContain("Loading export data");
|
||||
|
||||
const retry = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Retry preview"),
|
||||
);
|
||||
expect(retry).toBeDefined();
|
||||
await clickElement(retry!);
|
||||
|
||||
expect(mockCompaniesApi.exportPreview).toHaveBeenCalledTimes(2);
|
||||
expect(container.textContent).toContain("Paperclip export");
|
||||
});
|
||||
|
||||
it("starts the preview without waiting for sidebar-order dependencies", async () => {
|
||||
let resolveSession!: (value: { user: { id: string } }) => void;
|
||||
let resolveAgents!: (value: never[]) => void;
|
||||
let resolveProjects!: (value: never[]) => void;
|
||||
mockAuthApi.getSession.mockReturnValue(new Promise((resolve) => { resolveSession = resolve; }));
|
||||
mockAgentsApi.list.mockReturnValue(new Promise((resolve) => { resolveAgents = resolve; }));
|
||||
mockProjectsApi.list.mockReturnValue(new Promise((resolve) => { resolveProjects = resolve; }));
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(mockCompaniesApi.exportPreview).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Paperclip export");
|
||||
|
||||
await act(async () => {
|
||||
resolveSession({ user: { id: "user-1" } });
|
||||
resolveAgents([]);
|
||||
resolveProjects([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the generated org chart through the independent SVG endpoint", () => {
|
||||
expect(resolveExportPreviewImageSrc({
|
||||
src: "images/org-chart.png",
|
||||
selectedFile: "README.md",
|
||||
allFiles: {},
|
||||
orgChartPreviewUrl: "/api/companies/company-1/org.svg",
|
||||
})).toBe("/api/companies/company-1/org.svg");
|
||||
});
|
||||
|
||||
it("keeps task history opt-in, then requests all selected files on download", async () => {
|
||||
mockCompaniesApi.exportPreview.mockResolvedValue(buildRichExportPreviewResult());
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(mockCompaniesApi.exportPreview.mock.calls[0]?.[1]).toMatchObject({
|
||||
include: { issues: false, skills: true },
|
||||
});
|
||||
expect(container.textContent).toContain("Exporting 3 of 6 files");
|
||||
|
||||
await clickElement(categoryInput("tasks"));
|
||||
await clickElement(categoryInput("routines"));
|
||||
await clickElement(categoryInput("attachments"));
|
||||
|
||||
expect(mockCompaniesApi.exportPreview.mock.calls.some(([, request]) => request.include.issues === true)).toBe(true);
|
||||
expect(container.textContent).toContain("Exporting 6 of 6 files");
|
||||
// The tree is a pure browser now — no per-file checkboxes.
|
||||
expect(container.querySelector('[role="tree"] input[type="checkbox"]')).toBeNull();
|
||||
|
|
@ -261,11 +335,92 @@ describe("CompanyExport", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("keeps controls mounted and aborts a slow task refetch when Tasks is unticked", async () => {
|
||||
let requestCount = 0;
|
||||
let slowRequestSignal: AbortSignal | undefined;
|
||||
mockCompaniesApi.exportPreview.mockImplementation((_companyId, _request, options) => {
|
||||
requestCount += 1;
|
||||
if (requestCount !== 2) return Promise.resolve(buildRichExportPreviewResult());
|
||||
slowRequestSignal = options?.signal;
|
||||
return new Promise((_resolve, reject) => {
|
||||
options?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("The operation was aborted.", "AbortError"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
await clickElement(categoryInput("tasks"));
|
||||
|
||||
expect(container.textContent).toContain("Updating export preview");
|
||||
expect(categoryInput("tasks").checked).toBe(true);
|
||||
expect(container.querySelector('[role="tree"]')).not.toBeNull();
|
||||
expect(exportButton()).toBeDefined();
|
||||
|
||||
await clickElement(categoryInput("tasks"));
|
||||
|
||||
expect(slowRequestSignal?.aborted).toBe(true);
|
||||
expect(mockCompaniesApi.exportPreview).toHaveBeenCalledTimes(3);
|
||||
expect(mockCompaniesApi.exportPreview.mock.calls[2]?.[1]).toMatchObject({
|
||||
include: { issues: false },
|
||||
});
|
||||
expect(categoryInput("tasks").checked).toBe(false);
|
||||
expect(container.textContent).not.toContain("Updating export preview");
|
||||
});
|
||||
|
||||
it("lets the user cancel a slow refetch without losing the export surface", async () => {
|
||||
let requestCount = 0;
|
||||
let slowRequestSignal: AbortSignal | undefined;
|
||||
mockCompaniesApi.exportPreview.mockImplementation((_companyId, _request, options) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return Promise.resolve(buildRichExportPreviewResult());
|
||||
slowRequestSignal = options?.signal;
|
||||
return new Promise((_resolve, reject) => {
|
||||
options?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("The operation was aborted.", "AbortError"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
await clickElement(categoryInput("tasks"));
|
||||
|
||||
const cancel = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Cancel update"),
|
||||
);
|
||||
expect(cancel).toBeDefined();
|
||||
await clickElement(cancel!);
|
||||
|
||||
expect(slowRequestSignal?.aborted).toBe(true);
|
||||
expect(container.textContent).toContain("Preview update cancelled");
|
||||
expect(categoryInput("tasks")).toBeDefined();
|
||||
expect(container.querySelector('[role="tree"]')).not.toBeNull();
|
||||
expect(exportButton()).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses the Skills category for preview and bundle generation", async () => {
|
||||
mockCompaniesApi.exportPreview.mockResolvedValue(buildRichExportPreviewResult());
|
||||
|
||||
await renderPage();
|
||||
await clickElement(categoryInput("skills"));
|
||||
|
||||
expect(mockCompaniesApi.exportPreview.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||
include: { skills: false },
|
||||
});
|
||||
await clickElement(exportButton());
|
||||
expect(mockCompaniesApi.exportBundle.mock.calls[0]?.[1]).toMatchObject({
|
||||
include: { skills: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("toggling Tasks off drops one-off task files and their blobs but keeps routines", async () => {
|
||||
mockCompaniesApi.exportPreview.mockResolvedValue(buildRichExportPreviewResult());
|
||||
|
||||
await renderPage();
|
||||
await clickElement(categoryInput("tasks"));
|
||||
await clickElement(categoryInput("routines"));
|
||||
await clickElement(categoryInput("attachments"));
|
||||
await clickElement(categoryInput("tasks"));
|
||||
|
||||
expect(container.textContent).toContain("Exporting 4 of 6 files");
|
||||
// Routines can carry attachments, so Attachments stays enabled while routines remain.
|
||||
|
|
@ -290,8 +445,6 @@ describe("CompanyExport", () => {
|
|||
mockCompaniesApi.exportPreview.mockResolvedValue(buildRichExportPreviewResult());
|
||||
|
||||
await renderPage();
|
||||
await clickElement(categoryInput("tasks"));
|
||||
await clickElement(categoryInput("routines"));
|
||||
|
||||
const attachments = categoryInput("attachments");
|
||||
expect(attachments.disabled).toBe(true);
|
||||
|
|
@ -304,6 +457,8 @@ describe("CompanyExport", () => {
|
|||
mockCompaniesApi.exportPreview.mockResolvedValue(buildRichExportPreviewResult());
|
||||
|
||||
await renderPage();
|
||||
await clickElement(categoryInput("tasks"));
|
||||
await clickElement(categoryInput("attachments"));
|
||||
|
||||
const sizeText = () =>
|
||||
container.textContent?.match(/Exporting [\d,]+ of [\d,]+ files \(~([\d.]+ [KMGT]?B)\)/)?.[1] ?? null;
|
||||
|
|
@ -313,7 +468,7 @@ describe("CompanyExport", () => {
|
|||
|
||||
await clickElement(categoryInput("tasks"));
|
||||
|
||||
expect(container.textContent).toContain("Exporting 4 of 6 files");
|
||||
expect(container.textContent).toContain("Exporting 3 of 6 files");
|
||||
const toggledSize = sizeText();
|
||||
expect(toggledSize).not.toBeNull();
|
||||
// Dropping the one-off task and its blob shrinks the estimated zip.
|
||||
|
|
|
|||
|
|
@ -41,8 +41,11 @@ import { buildPortableSidebarOrder } from "../lib/company-portability-sidebar";
|
|||
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
||||
import {
|
||||
Download,
|
||||
LoaderCircle,
|
||||
Package,
|
||||
RotateCcw,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FileTreeNode,
|
||||
|
|
@ -498,15 +501,40 @@ function generateReadmeFromSelection(
|
|||
|
||||
// ── Preview pane ──────────────────────────────────────────────────────
|
||||
|
||||
export function resolveExportPreviewImageSrc(input: {
|
||||
src: string;
|
||||
selectedFile: string;
|
||||
allFiles: Record<string, CompanyPortabilityFileEntry>;
|
||||
orgChartPreviewUrl?: string;
|
||||
}): string | null {
|
||||
const { src, selectedFile, allFiles, orgChartPreviewUrl } = input;
|
||||
if (/^(?:https?:|data:)/i.test(src)) return null;
|
||||
|
||||
// Preview generation deliberately avoids the comparatively expensive PNG
|
||||
// renderer. Use the independently generated SVG endpoint so the README
|
||||
// never shows a broken image; downloaded bundles still contain the PNG.
|
||||
if (src.replace(/^\.\//, "") === "images/org-chart.png" && orgChartPreviewUrl) {
|
||||
return orgChartPreviewUrl;
|
||||
}
|
||||
|
||||
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
|
||||
const resolved = dir + src;
|
||||
const entry = allFiles[resolved] ?? allFiles[src];
|
||||
if (!entry) return null;
|
||||
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
|
||||
}
|
||||
|
||||
function ExportPreviewPane({
|
||||
selectedFile,
|
||||
content,
|
||||
allFiles,
|
||||
orgChartPreviewUrl,
|
||||
onSkillClick,
|
||||
}: {
|
||||
selectedFile: string | null;
|
||||
content: CompanyPortabilityFileEntry | null;
|
||||
allFiles: Record<string, CompanyPortabilityFileEntry>;
|
||||
orgChartPreviewUrl?: string;
|
||||
onSkillClick?: (skill: string) => void;
|
||||
}) {
|
||||
if (!selectedFile || content === null) {
|
||||
|
|
@ -522,16 +550,7 @@ function ExportPreviewPane({
|
|||
|
||||
// Resolve relative image paths within the export package (e.g. images/org-chart.png)
|
||||
const resolveImageSrc = isMarkdown
|
||||
? (src: string) => {
|
||||
// Skip absolute URLs and data URIs
|
||||
if (/^(?:https?:|data:)/i.test(src)) return null;
|
||||
// Resolve relative to the directory of the current markdown file
|
||||
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
|
||||
const resolved = dir + src;
|
||||
const entry = allFiles[resolved] ?? allFiles[src];
|
||||
if (!entry) return null;
|
||||
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
|
||||
}
|
||||
? (src: string) => resolveExportPreviewImageSrc({ src, selectedFile, allFiles, orgChartPreviewUrl })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
|
@ -589,22 +608,40 @@ function expandAncestors(filePath: string): string[] {
|
|||
return dirs;
|
||||
}
|
||||
|
||||
interface ExportPreviewMutationInput {
|
||||
includeIssues: boolean;
|
||||
includeSkills: boolean;
|
||||
requestId: number;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException
|
||||
? error.name === "AbortError"
|
||||
: error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function previewErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Failed to load export data.";
|
||||
}
|
||||
|
||||
export function CompanyExport() {
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToastActions();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { data: session, isFetched: isSessionFetched } = useQuery({
|
||||
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { data: agents = [], isFetched: areAgentsFetched } = useQuery({
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: projects = [], isFetched: areProjectsFetched } = useQuery({
|
||||
const { data: projects = [] } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
|
|
@ -618,11 +655,21 @@ export function CompanyExport() {
|
|||
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [categories, setCategories] = useState<ExportCategorySelection>(buildDefaultExportCategorySelection);
|
||||
const [categories, setCategories] = useState<ExportCategorySelection>(() => ({
|
||||
...buildDefaultExportCategorySelection(),
|
||||
// Task history is the expensive part of a large-company export. Keep it
|
||||
// opt-in so opening this settings page uses the lightweight preview path.
|
||||
tasks: false,
|
||||
routines: false,
|
||||
attachments: false,
|
||||
}));
|
||||
const [treeSearch, setTreeSearch] = useState("");
|
||||
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
||||
const [previewCancelled, setPreviewCancelled] = useState(false);
|
||||
const savedExpandedRef = useRef<Set<string> | null>(null);
|
||||
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
||||
const previewAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const previewRequestIdRef = useRef(0);
|
||||
const previewCompanyIdRef = useRef<string | null>(null);
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const visibleAgents = useMemo(
|
||||
() => agents.filter((agent: Agent) => agent.status !== "terminated"),
|
||||
|
|
@ -651,10 +698,7 @@ export function CompanyExport() {
|
|||
}),
|
||||
[orderedAgents, orderedProjects, visibleAgents, visibleProjects],
|
||||
);
|
||||
const sidebarOrderKey = useMemo(
|
||||
() => JSON.stringify(sidebarOrder ?? null),
|
||||
[sidebarOrder],
|
||||
);
|
||||
const includeIssues = categories.tasks || categories.routines;
|
||||
|
||||
// Navigate-aware file selection: updates state + URL without page reload.
|
||||
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
|
||||
|
|
@ -696,12 +740,13 @@ export function CompanyExport() {
|
|||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
const exportPreviewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: ({ includeIssues: withIssues, includeSkills, signal }: ExportPreviewMutationInput) =>
|
||||
companiesApi.exportPreview(selectedCompanyId!, {
|
||||
include: { company: true, agents: true, projects: true, issues: true },
|
||||
include: { company: true, agents: true, projects: true, issues: withIssues, skills: includeSkills },
|
||||
sidebarOrder,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
}, { signal }),
|
||||
onSuccess: (result, request) => {
|
||||
if (request.requestId !== previewRequestIdRef.current) return;
|
||||
setExportData(result);
|
||||
// Expand top-level dirs (except tasks — collapsed by default)
|
||||
const tree = buildFileTree(result.files);
|
||||
|
|
@ -727,19 +772,36 @@ export function CompanyExport() {
|
|||
setExpandedDirs(topDirs);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: (err, request) => {
|
||||
if (request.requestId !== previewRequestIdRef.current || isAbortError(err)) return;
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Export failed",
|
||||
body: err instanceof Error ? err.message : "Failed to load export data.",
|
||||
body: previewErrorMessage(err),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function startPreviewRequest() {
|
||||
if (!selectedCompanyId) return;
|
||||
previewAbortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
const requestId = previewRequestIdRef.current + 1;
|
||||
previewRequestIdRef.current = requestId;
|
||||
previewAbortControllerRef.current = controller;
|
||||
setPreviewCancelled(false);
|
||||
exportPreviewMutation.mutate({
|
||||
includeIssues,
|
||||
includeSkills: categories.skills,
|
||||
requestId,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
companiesApi.exportBundle(selectedCompanyId!, {
|
||||
include: { company: true, agents: true, projects: true, issues: true },
|
||||
include: { company: true, agents: true, projects: true, issues: includeIssues, skills: categories.skills },
|
||||
selectedFiles: Array.from(checkedFiles).sort(),
|
||||
sidebarOrder,
|
||||
}),
|
||||
|
|
@ -762,12 +824,20 @@ export function CompanyExport() {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
|
||||
if (!isSessionFetched || !areAgentsFetched || !areProjectsFetched) return;
|
||||
setExportData(null);
|
||||
exportPreviewMutation.mutate();
|
||||
if (!selectedCompanyId) return;
|
||||
if (previewCompanyIdRef.current !== selectedCompanyId) {
|
||||
previewCompanyIdRef.current = selectedCompanyId;
|
||||
setExportData(null);
|
||||
setSelectedFile(null);
|
||||
}
|
||||
startPreviewRequest();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCompanyId, isSessionFetched, areAgentsFetched, areProjectsFetched, sidebarOrderKey]);
|
||||
}, [selectedCompanyId, includeIssues, categories.skills]);
|
||||
|
||||
useEffect(() => () => {
|
||||
previewRequestIdRef.current += 1;
|
||||
previewAbortControllerRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const tree = useMemo(
|
||||
() => (exportData ? buildFileTree(exportData.files) : []),
|
||||
|
|
@ -945,6 +1015,14 @@ export function CompanyExport() {
|
|||
downloadMutation.mutate();
|
||||
}
|
||||
|
||||
function handleCancelPreview() {
|
||||
previewRequestIdRef.current += 1;
|
||||
previewAbortControllerRef.current?.abort();
|
||||
previewAbortControllerRef.current = null;
|
||||
exportPreviewMutation.reset();
|
||||
setPreviewCancelled(true);
|
||||
}
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Package} message="Select a company to export." />;
|
||||
}
|
||||
|
|
@ -953,8 +1031,44 @@ export function CompanyExport() {
|
|||
return <PageSkeleton variant="detail" />;
|
||||
}
|
||||
|
||||
if (previewCancelled && !exportData) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Export preview cancelled"
|
||||
message="The preview request was cancelled. Your export settings are unchanged."
|
||||
action="Retry preview"
|
||||
onAction={startPreviewRequest}
|
||||
hideActionIcon
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (exportPreviewMutation.isError && !exportData) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Export preview failed"
|
||||
message={previewErrorMessage(exportPreviewMutation.error)}
|
||||
description="Retry the preview. You do not need to reload this page."
|
||||
action="Retry preview"
|
||||
onAction={startPreviewRequest}
|
||||
hideActionIcon
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!exportData) {
|
||||
return <EmptyState icon={Package} message="Loading export data..." />;
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Export preview unavailable"
|
||||
message="No export preview is loaded."
|
||||
action="Load preview"
|
||||
onAction={startPreviewRequest}
|
||||
hideActionIcon
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const previewContent = selectedFile
|
||||
|
|
@ -964,7 +1078,7 @@ export function CompanyExport() {
|
|||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-6xl">
|
||||
{/* Sticky top action bar */}
|
||||
<div className="sticky top-0 z-10 border-b border-border bg-background px-5 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
|
|
@ -985,7 +1099,13 @@ export function CompanyExport() {
|
|||
<Button
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={selectedCount === 0 || downloadMutation.isPending}
|
||||
disabled={
|
||||
selectedCount === 0
|
||||
|| downloadMutation.isPending
|
||||
|| exportPreviewMutation.isPending
|
||||
|| exportPreviewMutation.isError
|
||||
|| previewCancelled
|
||||
}
|
||||
>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
{downloadMutation.isPending
|
||||
|
|
@ -1036,6 +1156,8 @@ export function CompanyExport() {
|
|||
const disabled = isAttachments && !isAttachmentsCategoryEnabled(categories);
|
||||
const checked = categories[key] && !disabled;
|
||||
const count = categoryCounts?.[key] ?? 0;
|
||||
const countLoaded = exportData.manifest.includes.issues
|
||||
|| (key !== "tasks" && key !== "routines" && key !== "attachments");
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
|
|
@ -1058,11 +1180,16 @@ export function CompanyExport() {
|
|||
data-export-category={key}
|
||||
/>
|
||||
<span className="min-w-0 truncate">{EXPORT_CATEGORY_LABELS[key]}</span>
|
||||
<span className="text-xs text-muted-foreground">{count.toLocaleString()}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{countLoaded ? count.toLocaleString() : "—"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Task and routine history is opt-in because it can be large.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-b border-border px-3 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border px-2 py-1">
|
||||
|
|
@ -1101,8 +1228,74 @@ export function CompanyExport() {
|
|||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 overflow-y-auto xl:pl-6">
|
||||
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
|
||||
<div className="relative min-w-0 overflow-y-auto xl:pl-6">
|
||||
<ExportPreviewPane
|
||||
selectedFile={selectedFile}
|
||||
content={previewContent}
|
||||
allFiles={effectiveFiles}
|
||||
orgChartPreviewUrl={`/api/companies/${encodeURIComponent(selectedCompanyId)}/org.svg`}
|
||||
onSkillClick={handleSkillClick}
|
||||
/>
|
||||
{exportPreviewMutation.isPending ? (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex min-h-(--sz-520px) items-center justify-center bg-background/90 px-6 text-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
data-export-preview-state="loading"
|
||||
>
|
||||
<div className="flex max-w-md flex-col items-center gap-3">
|
||||
<LoaderCircle className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Updating export preview…</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Large task histories can take a minute. You can untick Tasks or cancel this update.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleCancelPreview}>
|
||||
<X />
|
||||
Cancel update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : previewCancelled ? (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex min-h-(--sz-520px) items-center justify-center bg-background/90 px-6 text-center"
|
||||
data-export-preview-state="cancelled"
|
||||
>
|
||||
<div className="flex max-w-md flex-col items-center gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Preview update cancelled</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
The previous preview remains available. Retry when you are ready.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={startPreviewRequest}>
|
||||
<RotateCcw />
|
||||
Retry preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : exportPreviewMutation.isError ? (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex min-h-(--sz-520px) items-center justify-center bg-background/90 px-6 text-center"
|
||||
role="alert"
|
||||
data-export-preview-state="error"
|
||||
>
|
||||
<div className="flex max-w-md flex-col items-center gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">Export preview failed</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{previewErrorMessage(exportPreviewMutation.error)}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={startPreviewRequest}>
|
||||
<RotateCcw />
|
||||
Retry preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1621,7 +1621,7 @@ export function CompanyImport() {
|
|||
// company list has been refreshed, so the imported company is available
|
||||
// from the switcher.
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<div className="max-w-6xl space-y-4 px-5 py-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Import completed</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
|
|
@ -1641,7 +1641,7 @@ export function CompanyImport() {
|
|||
(item) => activationChecked.has(item.key) && !activatedKeys.has(item.key),
|
||||
).length;
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<div className="max-w-6xl space-y-4 px-5 py-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Import complete</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
|
|
@ -1749,7 +1749,7 @@ export function CompanyImport() {
|
|||
// outcome above; failure returns to the form with an error toast).
|
||||
if (resumedWatchJobId) {
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<div className="max-w-6xl space-y-4 px-5 py-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Resume watching import</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
|
|
@ -1771,7 +1771,7 @@ export function CompanyImport() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-6xl">
|
||||
{/* Source form section */}
|
||||
<div className="border-b border-border px-5 py-5 space-y-4">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export function CompanyInvites() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-8">
|
||||
<div className="max-w-6xl space-y-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MailPlus className="h-5 w-5 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ describe("CompanyEnvironments", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const addEnvironmentButton = findAction(container, "Add environment");
|
||||
const addEnvironmentButton = container.querySelector('[aria-label="Add environment"]');
|
||||
expect(addEnvironmentButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|||
import { companiesApi } from "../api/companies";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, Download, Upload } from "lucide-react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import {
|
||||
InteractionGovernancePanel,
|
||||
applyGovernanceChange,
|
||||
|
|
@ -25,6 +24,7 @@ import {
|
|||
Field,
|
||||
ToggleField,
|
||||
} from "../components/agent-config-primitives";
|
||||
import { InstanceGeneralSettings } from "./InstanceGeneralSettings";
|
||||
|
||||
const BYTES_PER_MIB = 1024 * 1024;
|
||||
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
|
|
@ -195,18 +195,18 @@ export function CompanySettings() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="max-w-6xl space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Company Settings</h1>
|
||||
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">General</h1>
|
||||
</div>
|
||||
|
||||
{/* General */}
|
||||
<div className="space-y-4">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
General
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
<Field label="Company name" hint="The display name for your company.">
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
|
|
@ -231,11 +231,11 @@ export function CompanySettings() {
|
|||
</div>
|
||||
|
||||
{/* Appearance */}
|
||||
<div className="space-y-4">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Appearance
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="shrink-0">
|
||||
<CompanyPatternIcon
|
||||
|
|
@ -376,11 +376,11 @@ export function CompanySettings() {
|
|||
)}
|
||||
|
||||
{/* Hiring */}
|
||||
<div className="space-y-4" data-testid="company-settings-team-section">
|
||||
<div className="max-w-2xl space-y-4" data-testid="company-settings-team-section">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Hiring
|
||||
</div>
|
||||
<div className="rounded-md border border-border px-4 py-3">
|
||||
<div>
|
||||
<ToggleField
|
||||
label="Require board approval for new hires"
|
||||
hint="New agent hires stay pending until approved by board."
|
||||
|
|
@ -405,35 +405,14 @@ export function CompanySettings() {
|
|||
}
|
||||
/>
|
||||
|
||||
{/* Import / Export */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Company Packages
|
||||
</div>
|
||||
<div className="rounded-md border border-border px-4 py-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/company/export">
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/company/import">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InstanceGeneralSettings embedded />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
||||
Danger Zone
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
|
||||
<div className="space-y-3 bg-destructive/5 px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Archive this company to hide it from the sidebar. This persists in
|
||||
the database.
|
||||
|
|
|
|||
|
|
@ -78,11 +78,13 @@ export function CompanySettingsPluginPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<PluginSlotMount
|
||||
slot={pageSlot}
|
||||
context={{ companyId: resolvedCompanyId, companyPrefix }}
|
||||
className="min-h-(--sz-200px)"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
<div className="max-w-6xl">
|
||||
<PluginSlotMount
|
||||
slot={pageSlot}
|
||||
context={{ companyId: resolvedCompanyId, companyPrefix }}
|
||||
className="min-h-(--sz-200px)"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,7 +234,6 @@ export function InstanceExperimentalSettings() {
|
|||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Instance settings", href: "/company/settings/instance/general" },
|
||||
{ label: "Experimental" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
|
@ -427,7 +426,7 @@ export function InstanceExperimentalSettings() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ describe("InstanceGeneralSettings sign-out", () => {
|
|||
);
|
||||
});
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("Deployment and auth"));
|
||||
expect(container.querySelector('[data-slot="card"]')).toBeNull();
|
||||
}
|
||||
|
||||
function signOutButton() {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { healthApi } from "@/api/health";
|
|||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { ModeBadge } from "@/components/access/ModeBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
|
|
@ -21,7 +20,7 @@ import { useSignOut } from "@/hooks/useSignOut";
|
|||
|
||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
|
||||
export function InstanceGeneralSettings() {
|
||||
export function InstanceGeneralSettings({ embedded = false }: { embedded?: boolean }) {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
|
@ -29,12 +28,12 @@ export function InstanceGeneralSettings() {
|
|||
const signOutMutation = useSignOut();
|
||||
|
||||
useEffect(() => {
|
||||
if (embedded) return;
|
||||
setBreadcrumbs([
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Instance settings" },
|
||||
{ label: "General" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
}, [embedded, setBreadcrumbs]);
|
||||
|
||||
const generalQuery = useQuery({
|
||||
queryKey: queryKeys.instance.generalSettings,
|
||||
|
|
@ -87,17 +86,19 @@ export function InstanceGeneralSettings() {
|
|||
: actionError;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">General</h1>
|
||||
<div className={embedded ? "space-y-8" : "max-w-4xl space-y-8"}>
|
||||
{!embedded ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">General</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure instance-wide preferences including log display, keyboard shortcuts, backup
|
||||
retention, and data sharing.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure instance-wide preferences including log display, keyboard shortcuts, backup
|
||||
retention, and data sharing.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleActionError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
|
|
@ -105,7 +106,7 @@ export function InstanceGeneralSettings() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Card className="block p-5">
|
||||
<section>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold">Deployment and auth</h2>
|
||||
|
|
@ -136,9 +137,9 @@ export function InstanceGeneralSettings() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card className="block p-5">
|
||||
<section>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Censor username in logs</h2>
|
||||
|
|
@ -155,9 +156,9 @@ export function InstanceGeneralSettings() {
|
|||
aria-label="Toggle username log censoring"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card className="block p-5">
|
||||
<section>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||
|
|
@ -173,9 +174,9 @@ export function InstanceGeneralSettings() {
|
|||
aria-label="Toggle keyboard shortcuts"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card className="block p-5">
|
||||
<section>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Backup retention</h2>
|
||||
|
|
@ -275,9 +276,9 @@ export function InstanceGeneralSettings() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card className="block p-5">
|
||||
<section>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">AI feedback sharing</h2>
|
||||
|
|
@ -297,7 +298,7 @@ export function InstanceGeneralSettings() {
|
|||
) : null}
|
||||
</div>
|
||||
{feedbackDataSharingPreference === "prompt" ? (
|
||||
<div className="rounded-lg border border-border/70 bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
<div className="rounded-lg bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
No default is saved yet. The next thumbs up or thumbs down choice will ask once and
|
||||
then save the answer here.
|
||||
</div>
|
||||
|
|
@ -351,9 +352,9 @@ export function InstanceGeneralSettings() {
|
|||
chosen yet.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card className="block p-5">
|
||||
<section>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Sign out</h2>
|
||||
|
|
@ -374,16 +375,16 @@ export function InstanceGeneralSettings() {
|
|||
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBox({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-background px-3 py-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className="mt-2 text-sm font-medium">{value}</div>
|
||||
<div className="text-sm font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function humanize(value: string) {
|
||||
return value.replaceAll("_", " ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -549,22 +549,22 @@ describe("InviteLandingPage", () => {
|
|||
expect(container.textContent).toContain("Request to join Acme Robotics");
|
||||
expect(container.textContent).toContain("A company admin must approve your request to join.");
|
||||
expect(container.textContent).toContain(
|
||||
"Ask them to visit Company Settings → Members to approve your request.",
|
||||
"Ask them to visit Settings → Members to approve your request.",
|
||||
);
|
||||
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("http://localhost/company/settings/members");
|
||||
|
||||
// The "Company Settings → Members" guidance addresses the company admin,
|
||||
// The "Settings → Members" guidance addresses the company admin,
|
||||
// not the requester. It must render as plain text so the requester cannot
|
||||
// navigate themselves to /company/settings/members — a route they have no
|
||||
// permission to view, which renders a misleading "No company access"
|
||||
// panel and makes the invite flow look broken. See #6784.
|
||||
const approvalAnchors = Array.from(container.querySelectorAll("a")).filter(
|
||||
(link) => link.textContent === "Company Settings → Members",
|
||||
(link) => link.textContent === "Settings → Members",
|
||||
);
|
||||
expect(approvalAnchors).toHaveLength(0);
|
||||
const approvalMentions =
|
||||
container.textContent?.match(/Company Settings → Members/g) ?? [];
|
||||
container.textContent?.match(/Settings → Members/g) ?? [];
|
||||
expect(approvalMentions).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -180,10 +180,10 @@ function AwaitingJoinApprovalPanel({
|
|||
</p>
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<p className="text-xs text-zinc-500 mb-1">Approval page</p>
|
||||
<p className="text-sm text-zinc-200">Company Settings → Members</p>
|
||||
<p className="text-sm text-zinc-200">Settings → Members</p>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Ask them to visit <span className="text-zinc-200">Company Settings → Members</span> to approve your request.
|
||||
Ask them to visit <span className="text-zinc-200">Settings → Members</span> to approve your request.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Refresh this page after you've been approved — you'll be redirected automatically.
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@ function InviteResultPreview({
|
|||
<div className="border border-zinc-800 p-3">
|
||||
<p className="mb-1 text-xs text-zinc-500">Approval page</p>
|
||||
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/members">
|
||||
Company Settings → Members
|
||||
Settings → Members
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
|
|
@ -902,7 +902,7 @@ export function InviteUxLab() {
|
|||
/>
|
||||
<InviteResultPreview
|
||||
title="Request to join Acme Robotics"
|
||||
description="Ask them to visit Company Settings → Members to approve your request."
|
||||
description="Ask them to visit Settings → Members to approve your request."
|
||||
/>
|
||||
</div>
|
||||
</LabSection>
|
||||
|
|
@ -920,7 +920,7 @@ export function InviteUxLab() {
|
|||
</LabSection>
|
||||
|
||||
<LabSection
|
||||
eyebrow="Company settings"
|
||||
eyebrow="Settings"
|
||||
title="Company invite management"
|
||||
description="This section captures the board-side invite creation flow, copied-link state, audit table, and the edge states that are otherwise tedious to stage."
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(244,114,182,0.06),transparent_28%),var(--background)]"
|
||||
|
|
|
|||
|
|
@ -101,7 +101,6 @@ export function PluginManager() {
|
|||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Instance settings", href: "/company/settings/instance/general" },
|
||||
{ label: "Plugins" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
|
@ -190,7 +189,7 @@ export function PluginManager() {
|
|||
if (error) return <div className="p-4 text-sm text-destructive">Failed to load plugins.</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Puzzle className="h-6 w-6 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ describe("PluginSettings", () => {
|
|||
it("routes environment-provider plugins to instance environments when they have no instance config", async () => {
|
||||
const root = await renderSettings(container);
|
||||
|
||||
expect(container.textContent).toContain("Configure this plugin from Instance Settings → Environments.");
|
||||
expect(container.textContent).toContain("Configure this plugin from Settings → Environments.");
|
||||
expect(container.textContent).toContain("secret bindings still resolve through the selected company context");
|
||||
const link = container.querySelector('a[href="/company/settings/instance/environments"]');
|
||||
expect(link?.textContent).toContain("Open Environments");
|
||||
|
|
|
|||
|
|
@ -122,7 +122,6 @@ export function PluginSettings() {
|
|||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Instance settings", href: "/company/settings/instance/general" },
|
||||
{ label: "Plugins", href: "/company/settings/instance/plugins" },
|
||||
{ label: plugin?.manifestJson?.displayName ?? plugin?.packageName ?? "Plugin Details" },
|
||||
]);
|
||||
|
|
@ -158,7 +157,7 @@ export function PluginSettings() {
|
|||
const driverLabel = environmentDriverNames.join(", ");
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/company/settings/instance/plugins">
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
|
|
@ -259,7 +258,7 @@ export function PluginSettings() {
|
|||
/>
|
||||
) : environmentDrivers.length > 0 ? (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm">
|
||||
<p className="font-medium text-foreground">Configure this plugin from Instance Settings → Environments.</p>
|
||||
<p className="font-medium text-foreground">Configure this plugin from Settings → Environments.</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{driverLabel || "This plugin"} registers environment runtime settings there so the execution target
|
||||
stays instance-scoped while secret bindings still resolve through the selected company context.
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ export function ProfileSettings() {
|
|||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Instance settings", href: "/company/settings/instance/general" },
|
||||
{ label: "Profile" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
|
@ -141,7 +140,7 @@ export function ProfileSettings() {
|
|||
: "Select a company to upload an avatar into Paperclip storage.";
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserRoundPen className="h-5 w-5 text-muted-foreground" />
|
||||
|
|
@ -271,9 +270,7 @@ export function ProfileSettings() {
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<Card className="rounded-(--rad-28) border-border/70 p-6">
|
||||
<InboxAgentPolicyControl companyId={selectedCompanyId} />
|
||||
</Card>
|
||||
<InboxAgentPolicyControl companyId={selectedCompanyId} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -778,6 +778,7 @@ describe("Secrets page layout", () => {
|
|||
|
||||
const listContainer = container.querySelector('[data-testid="secrets-list-container"]');
|
||||
const tableView = container.querySelector('[data-testid="secrets-table-view"]');
|
||||
expect(listContainer?.parentElement?.className).not.toContain("overflow-y-auto");
|
||||
const cardView = container.querySelector('[data-testid="secrets-card-view"]');
|
||||
expect(listContainer?.className).toContain("@container");
|
||||
expect(tableView?.className).toContain("@min-[40rem]:block");
|
||||
|
|
|
|||
|
|
@ -1793,7 +1793,7 @@ export function Secrets() {
|
|||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<div className="flex max-w-6xl flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Secrets</h1>
|
||||
|
|
@ -1802,7 +1802,7 @@ export function Secrets() {
|
|||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as SecretsTab)}
|
||||
className="flex min-h-0 flex-1 flex-col gap-4"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<PageTabBar
|
||||
items={[
|
||||
|
|
@ -1831,7 +1831,7 @@ export function Secrets() {
|
|||
onValueChange={(value) => setActiveTab(value as SecretsTab)}
|
||||
/>
|
||||
|
||||
<TabsContent value="secrets" className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
||||
<TabsContent value="secrets" className="flex flex-col gap-3">
|
||||
<SecretsHowToUse />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-48 sm:w-64 md:w-80">
|
||||
|
|
@ -1936,7 +1936,7 @@ export function Secrets() {
|
|||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div>
|
||||
{secretsQuery.isError || userDefinitionsQuery.isError ? (
|
||||
<div className="text-sm text-destructive flex items-center gap-2 py-4">
|
||||
<AlertCircle className="h-4 w-4" /> Failed to load secrets:{" "}
|
||||
|
|
@ -2183,11 +2183,11 @@ export function Secrets() {
|
|||
</TabsContent>
|
||||
<TabsContent
|
||||
value="my-secrets"
|
||||
className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden"
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<MyUserSecretsTab companyId={selectedCompanyId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="vaults" className="min-h-0 flex-1 overflow-y-auto">
|
||||
<TabsContent value="vaults">
|
||||
<ProviderVaultsTab
|
||||
providers={providers}
|
||||
providerConfigs={providerConfigs}
|
||||
|
|
@ -2210,7 +2210,7 @@ export function Secrets() {
|
|||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="proposals" className="min-h-0 flex-1 overflow-y-auto">
|
||||
<TabsContent value="proposals">
|
||||
{selectedCompanyId ? (
|
||||
<ProposalsTab companyId={selectedCompanyId} providerConfigs={providerConfigs} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function MyUserSecretsTab({ companyId }: { companyId: string }) {
|
|||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-2 rounded-md border border-violet-500/30 bg-violet-500/5 px-4 py-3 text-xs text-violet-800 dark:text-violet-200">
|
||||
<UserRound className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
|
|
@ -69,7 +69,7 @@ export function MyUserSecretsTab({ companyId }: { companyId: string }) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div>
|
||||
{mySecretsQuery.isError ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" /> Failed to load your secrets:{" "}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export function SmokeLabTab({ companyId }: { companyId: string }) {
|
|||
<p className="mt-1.5 max-w-xl text-sm text-muted-foreground">
|
||||
The Smoke Lab is an experimental developer surface for exercising the integration paths
|
||||
against deterministic local fixtures. Turn on <code className="rounded bg-muted px-1 py-0.5 text-xs">Smoke Lab</code>{" "}
|
||||
under Instance settings → Experimental to enable it.
|
||||
under Settings → Experimental to enable it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue