diff --git a/packages/shared/src/workspace-commands.test.ts b/packages/shared/src/workspace-commands.test.ts index 9a5c610b68..11eca81c40 100644 --- a/packages/shared/src/workspace-commands.test.ts +++ b/packages/shared/src/workspace-commands.test.ts @@ -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(); + }); }); diff --git a/packages/shared/src/workspace-commands.ts b/packages/shared/src/workspace-commands.ts index dd98ef3dc0..e044e95ab2 100644 --- a/packages/shared/src/workspace-commands.ts +++ b/packages/shared/src/workspace-commands.ts @@ -1,4 +1,9 @@ import type { WorkspaceCommandDefinition, WorkspaceRuntimeService } from "./types/workspace-runtime.js"; +import { forceLoopbackBindInCommand } from "./runtime-exposure/loopback-bind.js"; + +type WorkspaceRuntimeServiceMatchCandidate = + & Pick + & Pick, "exposure">; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -164,9 +169,20 @@ export function findWorkspaceCommandDefinition( export function scoreWorkspaceRuntimeServiceMatch( command: Pick, - runtimeService: Pick, + 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, + T extends WorkspaceRuntimeServiceMatchCandidate, >( command: Pick, runtimeServices: T[] | null | undefined, diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 494087e704..a01036839a 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -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) => ({ @@ -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((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 () => { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index c86c2bbbef..f3770d504e 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -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( + items: readonly T[], + concurrency: number, + mapper: (item: T) => Promise, +): Promise { + const results = new Array(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 = { @@ -3738,6 +3763,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { async function exportBundle( companyId: string, input: CompanyPortabilityExport, + options: { preview?: boolean } = {}, ): Promise { 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 }> = []; 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(); + // 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/ 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> = []; 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) diff --git a/server/src/services/workspace-runtime-read-model.test.ts b/server/src/services/workspace-runtime-read-model.test.ts index c4184a253e..76ad542aad 100644 --- a/server/src/services/workspace-runtime-read-model.test.ts +++ b/server/src/services/workspace-runtime-read-model.test.ts @@ -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", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2960932266..a3a42f2778 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> } /> - } /> + }> + + + )} + /> } /> } /> } /> @@ -144,9 +154,9 @@ function boardRoutes() { } /> } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index 39671839f5..1b3787f427 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -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(`/companies/${companyId}/exports/preview`, data), + api.post(`/companies/${companyId}/exports/preview`, data, options), exportFidelity: (companyId: string) => api.get(`/companies/${companyId}/export/fidelity`), importPreview: (data: CompanyPortabilityPreviewRequest) => diff --git a/ui/src/components/AgentBubbleActionRow.tsx b/ui/src/components/AgentBubbleActionRow.tsx index f761d942ae..6ca8df5d58 100644 --- a/ui/src/components/AgentBubbleActionRow.tsx +++ b/ui/src/components/AgentBubbleActionRow.tsx @@ -341,7 +341,7 @@ export function IssueChatFeedbackButtons({ Don't allow to keep this vote and future votes local.

-

You can change this later in Instance Settings > General.

+

You can change this later in Settings > General.

{termsUrl ? ( Access.", + body: "Refresh to sign in, or ask the existing admin to invite you from Settings -> Access.", }; } if (error?.status === 401) { diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index 92073fce3e..282280ddb4 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -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", diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index 2d1cec667c..3bf6883719 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -9,7 +9,6 @@ import { MailPlus, MonitorCog, Puzzle, - Settings, Shield, SlidersHorizontal, Upload, @@ -86,22 +85,17 @@ export function CompanySettingsSidebar() { {selectedCompany?.name ?? "Company"} -
- - - Company Settings - -