From 30d57b29719f64a68c7699ae2e270d0e0a28356b Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 05:45:37 -0500 Subject: [PATCH] fix(ui): bound manual agent selection to the switch transition Reproduce the page/sidebar mismatch after a real manual switch and browser history navigation. Dismiss covering announcements through the normal UI. Preserve dense ZIP bytes with a CRC table and bound cold CLI health inside the existing setup budget. Co-Authored-By: Paperclip --- .../__tests__/company-import-export-e2e.test.ts | 4 +++- .../__tests__/company-import-transfer.test.ts | 9 +++++++-- tests/e2e/chat-adapters-ui-messaging.spec.ts | 17 +++++++++++++++++ ui/src/pages/AgentDetail.tsx | 9 +++++++-- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 79172615e4..34ecb32503 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -250,7 +250,9 @@ async function waitForServer( output: { stdout: string[]; stderr: string[] }, ) { const startedAt = Date.now(); - while (Date.now() - startedAt < 30_000) { + // The source CLI runs doctor and imports the cold server graph. Keep this + // health deadline inside the existing 60-second database/setup hook budget. + while (Date.now() - startedAt < 45_000) { if (child.exitCode !== null) { throw new Error( `paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`, diff --git a/cli/src/__tests__/company-import-transfer.test.ts b/cli/src/__tests__/company-import-transfer.test.ts index 37c0b150bc..99e312f3d6 100644 --- a/cli/src/__tests__/company-import-transfer.test.ts +++ b/cli/src/__tests__/company-import-transfer.test.ts @@ -67,6 +67,12 @@ describe("buildImportTransferManifest", () => { }); }); +const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + return crc >>> 0; +}); + // Minimal single-entry DEFLATE zip, byte-compatible with the shared reader — // the stored-zip helper cannot model a small-compressed/large-inflated entry. function buildDeflateZip(entryPath: string, text: string): Uint8Array { @@ -75,8 +81,7 @@ function buildDeflateZip(entryPath: string, text: string): Uint8Array { const name = Buffer.from(entryPath, "utf8"); let crc = 0xffffffff; for (const byte of raw) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 0xff]!; } crc = (crc ^ 0xffffffff) >>> 0; const local = Buffer.alloc(30 + name.length); diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index d169b4031e..9a73e722a2 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -1349,6 +1349,8 @@ test.describe("Exact failed chat run retry", () => { await expect(page.getByText(foreignTitle, { exact: true }).first()).toBeVisible(); await expect.poll(() => page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId"))) .toBe(other.companyId); + const dismissAnnouncement = page.getByRole("button", { name: "Dismiss announcement", exact: true }); + if (await dismissAnnouncement.isVisible()) await dismissAnnouncement.click(); await page.getByRole("button", { name: `Open ${foreignCompany.name} organization switcher`, exact: true }).click(); const menuItems = await page.getByRole("menuitem").allTextContents(); const scopedOption = page.getByRole("menuitem").filter({ hasText: scopedCompany.name }); @@ -1362,6 +1364,7 @@ test.describe("Exact failed chat run retry", () => { afterSwitchSelectedCompany: await page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId")), }, null, 2)); await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/dashboard$`)); + if (await dismissAnnouncement.isVisible()) await dismissAnnouncement.click(); await page.getByRole("link", { name: "Agents", exact: true }).first().click(); await page.getByRole("link", { name: "Maya", exact: true }).first().click(); await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/agents/maya(?:/overview)?$`)); @@ -1379,6 +1382,20 @@ test.describe("Exact failed chat run retry", () => { await expect.poll(() => page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId"))) .toBe(seed.companyId); await page.screenshot({ path: testInfo.outputPath("cross-company-canonical-agent.png") }); + // A manual selection must not prevent a later history navigation from + // following the authorized agent's company in the sidebar as well. + await page.goBack(); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/agents/all$`)); + await page.goBack(); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/dashboard$`)); + await page.goBack(); + await expect(page).toHaveURL(new RegExp(`/${other.prefix}/agents/maya/overview$`)); + await expect(page.getByText(foreignTitle, { exact: true }).first()).toBeVisible(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId"))) + .toBe(other.companyId); + await expect(page.getByRole("button", { name: `Open ${foreignCompany.name} organization switcher`, exact: true })) + .toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("cross-company-history-selection.png") }); } finally { release(); } diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index db28428a66..d7a4bbe715 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -797,6 +797,7 @@ export function AgentDetail() { const [configSaving, setConfigSaving] = useState(false); const saveConfigActionRef = useRef<(() => void) | null>(null); const cancelConfigActionRef = useRef<(() => void) | null>(null); + const previousSelectedCompanyId = useRef(selectedCompanyId); const { isMobile } = useSidebar(); const routeAgentRef = agentId ?? ""; const routeCompanyId = useMemo(() => { @@ -993,9 +994,13 @@ export function AgentDetail() { }, [agent, agentCompany, companyPrefix, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, queryClient]); useEffect(() => { + const manualSelectionChanged = selectionSource === "manual" + && previousSelectedCompanyId.current !== selectedCompanyId; + previousSelectedCompanyId.current = selectedCompanyId; if (!agent?.companyId || agent.companyId === selectedCompanyId) return; - // Let an explicit organization switch finish its navigation first. - if (selectionSource === "manual") return; + // Yield only for the selection change itself. Later agent links and history + // navigation must follow the authorized company even if the source is manual. + if (manualSelectionChanged) return; // The route owns selection until canonical navigation reaches this company. // Competing with Layout's old-prefix selection can cause an update loop. if (routeCompanyId && routeCompanyId !== agent.companyId) return;