From e826188e82aa3371ec428ebae467b7aa93493227 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:15:16 -0500 Subject: [PATCH] refactor(settings): unify settings and speed up exports (#11789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../shared/src/workspace-commands.test.ts | 51 ++++ packages/shared/src/workspace-commands.ts | 22 +- .../src/__tests__/company-portability.test.ts | 95 +++++++ server/src/services/company-portability.ts | 112 ++++++-- .../workspace-runtime-read-model.test.ts | 26 ++ ui/src/App.tsx | 22 +- ui/src/api/companies.ts | 5 +- ui/src/components/AgentBubbleActionRow.tsx | 2 +- ui/src/components/BootstrapPendingPage.tsx | 2 +- .../CompanySettingsSidebar.test.tsx | 35 ++- ui/src/components/CompanySettingsSidebar.tsx | 37 +-- ui/src/components/CompanySwitcher.tsx | 2 +- .../InboxAgentPolicyControl.test.tsx | 22 +- ui/src/components/InboxAgentPolicyControl.tsx | 68 +++-- ui/src/components/InstanceSidebar.test.tsx | 2 + ui/src/components/InstanceSidebar.tsx | 11 +- .../components/InteractionGovernancePanel.tsx | 2 +- ui/src/components/IssueChatThread.tsx | 2 +- ui/src/components/Layout.test.tsx | 36 ++- ui/src/components/Layout.tsx | 6 +- ui/src/components/OutputFeedbackButtons.tsx | 2 +- .../access/CompanySettingsNav.test.tsx | 19 +- .../components/access/CompanySettingsNav.tsx | 25 +- ui/src/lib/instance-settings.test.ts | 9 +- ui/src/lib/workspace-access-state.test.ts | 75 +++++ ui/src/lib/workspace-access-state.ts | 33 ++- ui/src/pages/AdapterManager.tsx | 3 +- ui/src/pages/BootstrapSetupUxLab.tsx | 2 +- ui/src/pages/CompanyAccess.test.tsx | 19 +- ui/src/pages/CompanyAccess.tsx | 171 +++++------ ui/src/pages/CompanyEnvironments.test.tsx | 2 +- ui/src/pages/CompanyEnvironments.tsx | 74 +++-- ui/src/pages/CompanyExport.test.tsx | 165 ++++++++++- ui/src/pages/CompanyExport.tsx | 267 +++++++++++++++--- ui/src/pages/CompanyImport.tsx | 8 +- ui/src/pages/CompanyInvites.tsx | 2 +- ui/src/pages/CompanySettings.test.tsx | 2 +- ui/src/pages/CompanySettings.tsx | 47 +-- ui/src/pages/CompanySettingsPluginPage.tsx | 14 +- ui/src/pages/InstanceExperimentalSettings.tsx | 3 +- ui/src/pages/InstanceGeneralSettings.test.tsx | 1 + ui/src/pages/InstanceGeneralSettings.tsx | 59 ++-- ui/src/pages/InstanceSettings.tsx | 1 - ui/src/pages/InviteLanding.test.tsx | 8 +- ui/src/pages/InviteLanding.tsx | 4 +- ui/src/pages/InviteUxLab.tsx | 6 +- ui/src/pages/PluginManager.tsx | 3 +- ui/src/pages/PluginSettings.test.tsx | 2 +- ui/src/pages/PluginSettings.tsx | 5 +- ui/src/pages/ProfileSettings.tsx | 7 +- ui/src/pages/Secrets.render.test.tsx | 1 + ui/src/pages/Secrets.tsx | 14 +- ui/src/pages/secrets/MyUserSecretsTab.tsx | 4 +- ui/src/pages/tools/SmokeLabTab.tsx | 2 +- 54 files changed, 1181 insertions(+), 438 deletions(-) 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 - -