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) {
Just a URL?{" "}
-
+
Browse planned app connections
{" "}
instead.
diff --git a/ui/src/pages/tools/ToolsAccess.test.tsx b/ui/src/pages/tools/ToolsAccess.test.tsx
index c333ae68eb..996c6a21f3 100644
--- a/ui/src/pages/tools/ToolsAccess.test.tsx
+++ b/ui/src/pages/tools/ToolsAccess.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { act } from "react";
+import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToolsAccess } from "./ToolsAccess";
@@ -52,6 +52,14 @@ vi.mock("./RunYourOwnTab", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+async function act(callback: () => void | Promise) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
async function flushReact() {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
@@ -88,7 +96,7 @@ describe("ToolsAccess", () => {
mockParams.tab = tab;
await render();
- expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps", replace: true }));
+ expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/connections", replace: true }));
},
);
diff --git a/ui/src/pages/tools/ToolsAccess.tsx b/ui/src/pages/tools/ToolsAccess.tsx
index efd002786a..1ffeff8049 100644
--- a/ui/src/pages/tools/ToolsAccess.tsx
+++ b/ui/src/pages/tools/ToolsAccess.tsx
@@ -75,7 +75,7 @@ export function ToolsAccess() {
params.tab === "overview" ||
params.tab === "examples"
) {
- return ;
+ return ;
}
if (advanced) {
diff --git a/ui/src/pages/tools/profiles/WizardToolsStep.tsx b/ui/src/pages/tools/profiles/WizardToolsStep.tsx
index 83cfa37cd6..07a4a5d91f 100644
--- a/ui/src/pages/tools/profiles/WizardToolsStep.tsx
+++ b/ui/src/pages/tools/profiles/WizardToolsStep.tsx
@@ -89,7 +89,7 @@ export function WizardToolsStep(props: WizardToolsStepProps) {
);