diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index ce063abfd7..dab7f78bfd 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -31,6 +31,7 @@ import { resolveWorktreeReseedTargetPaths, resolveGitWorktreeAddArgs, resolvePnpmInstallInvocation, + resolveCurrentWorktreeEndpoint, resolveWorktreeSeedBackupEngine, resolveWorktreeMakeTargetPath, worktreeRepairCommand, @@ -167,6 +168,49 @@ function buildSourceConfig(): PaperclipConfig { } describe("worktree helpers", () => { + it("uses the repo-local config for the current worktree", () => { + const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-")); + try { + const localConfig = path.join(targetRoot, ".paperclip", "config.json"); + fs.mkdirSync(path.dirname(localConfig), { recursive: true }); + fs.writeFileSync(localConfig, "{}\n"); + process.env.PAPERCLIP_CONFIG = "/tmp/ambient-paperclip/config.json"; + process.chdir(targetRoot); + + expect(resolveCurrentWorktreeEndpoint()).toMatchObject({ + rootPath: targetRoot, + configPath: localConfig, + isCurrent: true, + }); + } finally { + process.chdir(ORIGINAL_CWD); + fs.rmSync(targetRoot, { recursive: true, force: true }); + } + }); + + it("uses the repository config from a nested working directory", () => { + const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-nested-")); + try { + execFileSync("git", ["init", "-q"], { cwd: targetRoot }); + const nestedDirectory = path.join(targetRoot, "packages", "example", "src"); + const localConfig = path.join(targetRoot, ".paperclip", "config.json"); + fs.mkdirSync(nestedDirectory, { recursive: true }); + fs.mkdirSync(path.dirname(localConfig), { recursive: true }); + fs.writeFileSync(localConfig, "{}\n"); + process.env.PAPERCLIP_CONFIG = "/tmp/ambient-paperclip/config.json"; + process.chdir(nestedDirectory); + + expect(resolveCurrentWorktreeEndpoint()).toMatchObject({ + rootPath: targetRoot, + configPath: localConfig, + isCurrent: true, + }); + } finally { + process.chdir(ORIGINAL_CWD); + fs.rmSync(targetRoot, { recursive: true, force: true }); + } + }); + it("sanitizes instance ids", () => { expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support"); expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree"); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 676ee7d52f..fe07f3ce61 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -2218,10 +2218,13 @@ async function closeDb(db: ClosableDb): Promise { await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); } -function resolveCurrentEndpoint(): ResolvedWorktreeEndpoint { +export function resolveCurrentWorktreeEndpoint(): ResolvedWorktreeEndpoint { + const cwd = path.resolve(process.cwd()); + const rootPath = detectGitWorkspaceInfo(cwd)?.root ?? cwd; + const localConfigPath = path.join(rootPath, ".paperclip", "config.json"); return { - rootPath: path.resolve(process.cwd()), - configPath: resolveConfigPath(), + rootPath, + configPath: existsSync(localConfigPath) ? localConfigPath : resolveConfigPath(), label: "current", isCurrent: true, }; @@ -2233,7 +2236,7 @@ function resolveAttachmentLookupStorages(input: { }): ConfiguredStorage[] { const orderedConfigPaths = [ input.sourceEndpoint.configPath, - resolveCurrentEndpoint().configPath, + resolveCurrentWorktreeEndpoint().configPath, input.targetEndpoint.configPath, ...toMergeSourceChoices(process.cwd()) .filter((choice) => choice.hasPaperclipConfig) @@ -2804,7 +2807,7 @@ export async function worktreeListCommand(opts: WorktreeListOptions): Promise { const excluded = excludeWorktreePath ? path.resolve(excludeWorktreePath) : null; - const currentEndpoint = resolveCurrentEndpoint(); + const currentEndpoint = resolveCurrentWorktreeEndpoint(); const choices = toMergeSourceChoices(process.cwd()) .filter((choice) => choice.hasPaperclipConfig || choice.isCurrent) .filter((choice) => path.resolve(choice.worktree) !== excluded) @@ -3295,7 +3298,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, const targetEndpoint = opts.to ? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true }) - : resolveCurrentEndpoint(); + : resolveCurrentWorktreeEndpoint(); const sourceEndpoint = opts.from ? resolveWorktreeEndpointFromSelector(opts.from, { allowCurrent: true }) : sourceArg @@ -3400,7 +3403,7 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { const targetEndpoint = opts.to ? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true }) - : resolveCurrentEndpoint(); + : resolveCurrentWorktreeEndpoint(); const source = resolveWorktreeReseedSource(opts); if (path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath)) { diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 79af9ef36a..ac53ac1b4f 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -471,6 +471,7 @@ The default `worktree init` still seeds eagerly and writes `seed-complete` immed - `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: a present `seed-complete` marker or a missing `seed-pending` marker short-circuits it, so it is safe to call repeatedly and from concurrent processes. It reads the source instance from the `seed-pending` marker unless you pass `--from-config`. - `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot, so `run` transparently seeds a lean worktree on first launch. +- Managed git-worktree runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when a legacy workspace policy has no explicit runtime provision command and the worktree is still `seed-pending`. An explicitly configured runtime provision command always takes precedence. - Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned). **Seed-pending guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose database is still `seed-pending` and points you at the fix: diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index d9726d495a..cf052cb437 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -37,6 +37,7 @@ import { refreshRemoteTrackingBaseRef, releaseRuntimeServicesForRun, resetRuntimeServicesForTests, + resolveRuntimeProvisionCommand, resolveWorkspaceRuntimeReadinessTimeoutSec, resolveShell, sanitizeRuntimeServiceBaseEnv, @@ -380,6 +381,41 @@ describe("sanitizeRuntimeServiceBaseEnv", () => { }); }); +describe("resolveRuntimeProvisionCommand", () => { + it("backfills deferred seeding for legacy managed git worktrees", async () => { + const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-provision-")); + const cwd = path.join(baseCwd, "worktree"); + try { + await fs.mkdir(path.join(baseCwd, "scripts"), { recursive: true }); + await fs.writeFile( + path.join(baseCwd, "scripts", "provision-worktree-runtime.sh"), + "#!/usr/bin/env bash\n", + ); + await fs.mkdir(path.join(cwd, ".paperclip"), { recursive: true }); + await fs.writeFile(path.join(cwd, ".paperclip", "seed-pending"), "{}\n"); + const workspace = { + ...buildWorkspace(cwd), + baseCwd, + strategy: "git_worktree" as const, + worktreePath: cwd, + }; + + expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe( + "bash ./scripts/provision-worktree-runtime.sh", + ); + expect(resolveRuntimeProvisionCommand({ + config: { runtimeProvisionCommand: "./custom-provision.sh" }, + workspace, + })).toBe("./custom-provision.sh"); + + await fs.writeFile(path.join(cwd, ".paperclip", "seed-complete"), "{}\n"); + expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(""); + } finally { + await fs.rm(baseCwd, { recursive: true, force: true }); + } + }); +}); + describe("refreshRemoteTrackingBaseRef git auth", () => { it("offers the remote URL to the provider and keeps ambient behavior when it returns null", async () => { const { remotePath, repoRoot } = await createClonedRepoWithRemote(); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 81a18e5be2..cb96c5f9d4 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -4109,6 +4109,34 @@ function readRuntimeProvisionCommand(config: Record) { ).trim(); } +export function resolveRuntimeProvisionCommand(input: { + config: Record; + workspace: RealizedExecutionWorkspace; +}) { + const configuredCommand = readRuntimeProvisionCommand(input.config); + if (configuredCommand) return configuredCommand; + + if (input.workspace.strategy !== "git_worktree") return ""; + + const stateDir = path.join(input.workspace.cwd, ".paperclip"); + const pendingMarker = path.join(stateDir, "seed-pending"); + const completeMarker = path.join(stateDir, "seed-complete"); + const provisionScript = path.join( + input.workspace.baseCwd, + "scripts", + "provision-worktree-runtime.sh", + ); + if ( + !existsSync(pendingMarker) + || existsSync(completeMarker) + || !existsSync(provisionScript) + ) { + return ""; + } + + return "bash ./scripts/provision-worktree-runtime.sh"; +} + function runtimeProvisionWorkspaceKey(input: StartLocalRuntimeServiceInput) { return input.executionWorkspaceId ? `execution-workspace:${input.executionWorkspaceId}` @@ -4794,7 +4822,7 @@ export async function ensureRuntimeServicesForRun(input: { }); const acquiredServiceIds: string[] = []; const refs: RuntimeServiceRef[] = []; - const runtimeProvisionCommand = readRuntimeProvisionCommand(input.config); + const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input); const provisionCoordinator = createRuntimeProvisionCoordinator(); runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); @@ -5007,7 +5035,7 @@ export async function startRuntimeServicesForWorkspaceControl( serviceStates: readConfiguredServiceStates(input.config), }); const invocationId = input.invocationId ?? randomUUID(); - const runtimeProvisionCommand = readRuntimeProvisionCommand(input.config); + const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input); const provisionCoordinator = createRuntimeProvisionCoordinator(); if (rawServices.length === 0 || !input.db || (!input.executionWorkspaceId && !input.workspace.workspaceId)) { diff --git a/tests/e2e/app-not-connected.spec.ts b/tests/e2e/app-not-connected.spec.ts index fac657543d..e795d03f41 100644 --- a/tests/e2e/app-not-connected.spec.ts +++ b/tests/e2e/app-not-connected.spec.ts @@ -86,7 +86,7 @@ test.describe.serial("not-connected app page", () => { applicationId = body.application.id as string; // Archive the connection (Remove app), then resurrect the application so - // it shows on /apps as "Not connected" — the state in Dotta's screenshot. + // it shows on /apps/connections as "Not connected" — the state in Dotta's screenshot. const archive = await request.delete(`/api/tool-connections/${connectionId}`); expect(archive.ok(), `archive failed ${archive.status()}: ${await archive.text()}`).toBe(true); const revive = await request.patch(`/api/tool-applications/${applicationId}`, { @@ -100,7 +100,7 @@ test.describe.serial("not-connected app page", () => { }); test("not-connected row opens the app page, not the generic wizard", async ({ page }) => { - await page.goto(`/${seed.prefix}/apps`); + await page.goto(`/${seed.prefix}/apps/connections`); const row = page.locator("tbody tr", { hasText: "Bla" }); await expect(row).toBeVisible({ timeout: 30_000 }); await expect(row.getByText("Not connected")).toBeVisible(); @@ -147,7 +147,7 @@ test.describe.serial("not-connected app page", () => { await page.goto(`/${seed.prefix}/apps/app/${applicationId}`); await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/${connectionId}/setup$`), { timeout: 20_000 }); - await page.goto(`/${seed.prefix}/apps`); + await page.goto(`/${seed.prefix}/apps/connections`); const row = page.locator("tbody tr", { hasText: "Bla" }); await expect(row).toBeVisible({ timeout: 30_000 }); await expect(row.getByRole("button", { name: /Open|Review/ })).toBeVisible(); @@ -173,8 +173,9 @@ test.describe.serial("not-connected app page", () => { await page.getByRole("button", { name: "Remove app", exact: true }).click(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-04-app-page-danger.png`, fullPage: true }); await page.getByRole("button", { name: "Yes, remove it" }).click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 }); await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible(); await expect(page.locator("tbody tr", { hasText: "Doomed app" })).toHaveCount(0); }); }); diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts index 4abe456f96..073ab69c71 100644 --- a/tests/e2e/applications-crud.spec.ts +++ b/tests/e2e/applications-crud.spec.ts @@ -57,7 +57,7 @@ async function createConnection( } async function gotoApps(page: Page, prefix: string) { - await page.goto(`/${prefix}/apps`); + await page.goto(`/${prefix}/apps/connections`); await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); } @@ -143,8 +143,9 @@ test.describe.serial("applications lifecycle", () => { await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-connected.png`, fullPage: true }); await page.getByRole("button", { name: "Yes, remove it" }).click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 }); await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible(); await expect(page.locator("tbody tr", { hasText: renamed })).toHaveCount(0); }); @@ -158,8 +159,9 @@ test.describe.serial("applications lifecycle", () => { await page.getByRole("button", { name: "Remove app", exact: true }).click(); await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-not-connected.png`, fullPage: true }); await page.getByRole("button", { name: "Yes, remove it" }).click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 }); await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible(); await expect(page.locator("tbody tr", { hasText: cleanAppName })).toHaveCount(0); }); }); diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index cc2d789fdc..18937694a4 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -127,7 +127,7 @@ test.describe.serial("dark-mode Apps surfaces", () => { test("apps list dark mode with attention banner", async ({ page }) => { await forceDark(page); - await page.goto(`/${seed.prefix}/apps`); + await page.goto(`/${seed.prefix}/apps/connections`); await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/needs attention/i).first()).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-01-apps-dark.png`, fullPage: true }); @@ -135,7 +135,7 @@ test.describe.serial("dark-mode Apps surfaces", () => { test("attention banner dark mode", async ({ page }) => { await forceDark(page); - await page.goto(`/${seed.prefix}/apps`); + await page.goto(`/${seed.prefix}/apps/connections`); await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/app needs attention/i).first()).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-02-attention-dark.png`, fullPage: true }); @@ -166,7 +166,7 @@ test.describe.serial("dark-mode Apps surfaces", () => { await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toBeVisible(); await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0); // Apps section lives in the same sidebar now. - await expect(page.locator('a[href$="/apps"]', { hasText: "Connections" })).toBeVisible(); + await expect(page.locator('a[href$="/apps/connections"]', { hasText: "Connections" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-05-developer-overview-dark.png`, fullPage: true }); }); @@ -183,8 +183,9 @@ test.describe.serial("dark-mode Apps surfaces", () => { await page.getByRole("button", { name: "Remove app", exact: true }).click(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-06-danger-zone-dark.png`, fullPage: true }); await page.getByRole("button", { name: "Yes, remove it" }).click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 }); await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-07-after-remove-dark.png`, fullPage: true }); }); }); diff --git a/tests/e2e/apps-prosumer-mcp-flow.spec.ts b/tests/e2e/apps-prosumer-mcp-flow.spec.ts index 6ab439592c..aa1cd89213 100644 --- a/tests/e2e/apps-prosumer-mcp-flow.spec.ts +++ b/tests/e2e/apps-prosumer-mcp-flow.spec.ts @@ -113,11 +113,11 @@ async function startMockMcp(options: { expectedHeader?: string } = {}): Promise< // ---- Helpers ---------------------------------------------------------------- async function gotoApps(page: Page, prefix: string) { - await page.goto(`/${prefix}/apps`); + await page.goto(`/${prefix}/apps/connections`); } async function gotoConnect(page: Page, prefix: string) { - await page.goto(`/${prefix}/apps/browse`); + await page.goto(`/${prefix}/apps`); await expect(page.getByRole("heading", { name: "Browse" })).toBeVisible({ timeout: 30_000 }); await page.getByRole("button", { name: /Connect your own tool/i }).click(); } @@ -127,7 +127,7 @@ async function gotoAdvanced(page: Page, prefix: string) { } async function gotoNeedsAttention(page: Page, prefix: string) { - await page.goto(`/${prefix}/apps`); + await page.goto(`/${prefix}/apps/connections`); } // ---- Tests ------------------------------------------------------------------ @@ -212,7 +212,7 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => { // Verify the mock saw a tools/list call from the catalog refresh. expect(mock.captures.some((c) => c.method === "tools/list")).toBe(true); - // The new connection should show up on /apps. + // The new connection should show up on /apps/connections. await gotoApps(page, seed.prefix); await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 15_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-06-apps-list.png`, fullPage: true }); diff --git a/tests/e2e/mcp-user-stories.spec.ts b/tests/e2e/mcp-user-stories.spec.ts index 4e1de84d34..7e5d4600cd 100644 --- a/tests/e2e/mcp-user-stories.spec.ts +++ b/tests/e2e/mcp-user-stories.spec.ts @@ -403,7 +403,7 @@ test.describe.serial("MCP prod Phase 5a user-story harness", () => { const health = await request.post(`/api/tool-connections/${connectionId}/health-check`); expect(health.status()).toBe(502); - await page.goto(`/${seed.prefix}/apps`); + await page.goto(`/${seed.prefix}/apps/connections`); await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); await screenshot(page, "US-8", "01-needs-attention"); diff --git a/tests/e2e/smoke-lab.spec.ts b/tests/e2e/smoke-lab.spec.ts index 09ae358164..1b2fed21b3 100644 --- a/tests/e2e/smoke-lab.spec.ts +++ b/tests/e2e/smoke-lab.spec.ts @@ -164,7 +164,7 @@ async function navigateForEvidence(page: Page, seed: Seed, connectionId: string, return; } if (scenario.uiEntryPath === "attention") { - await page.goto(`/${seed.prefix}/apps`); + await page.goto(`/${seed.prefix}/apps/connections`); await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 20_000 }); return; } @@ -391,7 +391,7 @@ test.describe.serial("Smoke Lab scenario catalog mirror", () => { await request.post(`/api/tool-connections/${connection.id}/catalog/refresh`), ); expect(refresh.quarantinedCount).toBeGreaterThan(0); - await page.goto(`/${seed.prefix}/apps`); + await page.goto(`/${seed.prefix}/apps/connections`); return `Catalog refresh quarantined ${refresh.quarantinedCount} changed entries.`; }); diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 9f7b3cd32d..f1d6fb1c2e 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -242,3 +242,13 @@ describe("Skill Studio routes", () => { expect(createIndexes[1]).toBeLessThan(detailIndexes[1]!); }); }); + +describe("Apps routes", () => { + it("uses browse as the Apps landing page and gives connections a canonical URL", () => { + expect(appSource).toContain('} />'); + expect(appSource).toContain('} />'); + expect(appSource).toContain('} />'); + expect(appSource).toContain('} />'); + expect(appSource).toContain('} />'); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0e309f4fa2..3a5c387323 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -122,14 +122,15 @@ function boardRoutes() { } /> } /> }> - } /> - } /> + } /> + } /> + } /> } /> - } /> - } /> + } /> + } /> } /> {/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */} - } /> + } /> } /> } /> } /> @@ -309,7 +310,7 @@ function boardRoutes() { function AppsConnectEntryRoute() { const location = useLocation(); const searchParams = new URLSearchParams(location.search); - return canEnterAppsConnect(searchParams) ? : ; + return canEnterAppsConnect(searchParams) ? : ; } function InboxRootRedirect() { @@ -403,7 +404,7 @@ function LegacyToolsRedirect() { function legacyToolsRedirectTarget(tab?: string) { if (!tab) return "/apps/advanced/profiles"; - if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps"; + if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections"; return `/apps/advanced/${tab}`; } diff --git a/ui/src/components/AppConnectionSidebar.test.tsx b/ui/src/components/AppConnectionSidebar.test.tsx index 68fe3f7c9e..db0cb79dfb 100644 --- a/ui/src/components/AppConnectionSidebar.test.tsx +++ b/ui/src/components/AppConnectionSidebar.test.tsx @@ -167,7 +167,7 @@ describe("AppConnectionSidebar", () => { it("renders a back link and the connected app tabs (including Test)", async () => { await renderSidebar(); - expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps"); + expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); expect(container.textContent).toContain("GitHub"); expect(container.querySelectorAll("[data-to]").length).toBe(6); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true })); @@ -190,7 +190,7 @@ describe("AppConnectionSidebar", () => { await renderSidebar(); - expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps"); + expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); expect(container.textContent).toContain("GitHub"); expect(mockToolsApi.getConnection).not.toHaveBeenCalled(); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/setup", label: "Setup", end: true })); @@ -212,7 +212,7 @@ describe("AppConnectionSidebar", () => { await renderSidebar(); expect(container.textContent).toContain("App"); - expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps"); + expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); expect(container.querySelectorAll("[data-to]").length).toBe(6); }); @@ -225,7 +225,7 @@ describe("AppConnectionSidebar", () => { await renderSidebar(); expect(container.textContent).toContain("App"); - expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps"); + expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); expect(container.querySelectorAll("[data-to]").length).toBe(5); }); }); diff --git a/ui/src/components/AppConnectionSidebar.tsx b/ui/src/components/AppConnectionSidebar.tsx index adc50eef97..406ce39b03 100644 --- a/ui/src/components/AppConnectionSidebar.tsx +++ b/ui/src/components/AppConnectionSidebar.tsx @@ -83,7 +83,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {