Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ on:
- main
pull_request:
workflow_dispatch:
inputs:
search-profile-base:
description: Commit to compare with this revision using the search profiling fixture
type: string
default: ""

env:
PRIMARY_NODE_VERSION: "22.x"
Expand All @@ -20,6 +25,45 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
search-profile:
if: github.event_name == 'workflow_dispatch' && inputs.search-profile-base != ''
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
cache-prefix: search-profile
- name: Compare search timings and collect CPU profiles
env:
SEARCH_PROFILE_BASE: ${{ inputs.search-profile-base }}
SEARCH_PROFILE_HEAD: ${{ github.sha }}
run: |
mkdir -p .search-profile
cp scripts/search-profile.mjs .search-profile/runner.mjs
node .search-profile/runner.mjs compare "$SEARCH_PROFILE_BASE" "$SEARCH_PROFILE_HEAD"
- name: Preserve measurements and profiles
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: search-profile-${{ github.sha }}
include-hidden-files: true
retention-days: 90
path: |
.search-profile/runner.mjs
.search-profile/manifest.json
.search-profile/results/**
.search-profile/profiles/**
.search-profile/logs/**
.search-profile/*.tgz
.search-profile/summary.md

checks:
name: Checks (ubuntu-latest, Node 22.x)
runs-on: blacksmith-4vcpu-ubuntu-2404
Expand Down
6 changes: 5 additions & 1 deletion apps/app/bundle-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"Chunks behind a dynamic import are deliberately not counted. Moving code",
"behind React.lazy or import() is the fix this budget exists to encourage.",
"",
"On 2026-09-26 the SplitWorkspaceRoute raw limit rose 1 KiB for",
"independent mention-provider queries (about 0.3 KiB) and the existing",
"0.3 KiB overage. The measured brotli payload shrank; its limit is unchanged.",
"",
"On 2026-09-25 the SplitWorkspaceRoute limits rose by 2 KiB raw and",
"256 bytes brotli for sanitized HTML video support in thread Markdown.",
"",
Expand Down Expand Up @@ -109,7 +113,7 @@
},
"routeClosures": {
"SplitWorkspaceRoute": {
"maxBytes": 2341134,
"maxBytes": 2342158,
"maxBrotliBytes": 630625,
"forbiddenPackages": [
"@pierre/diffs",
Expand Down
78 changes: 75 additions & 3 deletions apps/app/src/hooks/queries/plugin-contribution-queries.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom

import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { createDeferredPromise } from "@bb/test-helpers";
import { afterEach, describe, expect, it, vi } from "vitest";
import { sdk } from "@/lib/sdk";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
Expand Down Expand Up @@ -110,6 +111,67 @@ describe("usePluginContributions", () => {
});

describe("usePluginMentionSearch", () => {
it("publishes fast provider results while a slow provider is still pending", async () => {
const slow = createDeferredPromise<Response>();
const group = (providerId: string) => ({
pluginId: "fixture",
providerId,
label: providerId,
items: [
{
itemId: `${providerId}:one`,
title: providerId,
subtitle: null,
icon: null,
},
],
});
vi.stubGlobal(
"fetch",
vi.fn((input: string) =>
input.includes("providerId=slow")
? slow.promise
: Promise.resolve(
new Response(JSON.stringify({ groups: [group("fast")] })),
),
),
);
const { wrapper } = createQueryClientTestHarness();
const { result } = renderHook(
() =>
usePluginMentionSearch(
{ trigger: "@", query: "one", projectId: null, threadId: null },
{
enabled: true,
providers: [
{
pluginId: "fixture",
id: "slow",
label: "Slow",
triggers: ["@"],
},
{
pluginId: "fixture",
id: "fast",
label: "Fast",
triggers: ["@"],
},
],
},
),
{ wrapper },
);
await waitFor(() => expect(result.current.data).toEqual([group("fast")]));
expect(result.current.isFetching).toBe(true);
await act(async () =>
slow.resolve(new Response(JSON.stringify({ groups: [group("slow")] }))),
);
await waitFor(() =>
expect(result.current.data).toEqual([group("slow"), group("fast")]),
);
expect(result.current.isFetching).toBe(false);
});

it("includes the active trigger in the search request", async () => {
const fetchMock = mockFetchJsonOnce({
ok: true,
Expand Down Expand Up @@ -140,7 +202,17 @@ describe("usePluginMentionSearch", () => {
projectId: "proj_1",
threadId: null,
},
{ enabled: true },
{
enabled: true,
providers: [
{
pluginId: "github",
id: "issue",
label: "GitHub issues",
triggers: ["#"],
},
],
},
),
{ wrapper },
);
Expand All @@ -163,7 +235,7 @@ describe("usePluginMentionSearch", () => {
]);
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/plugins/mentions/search?q=42&trigger=%23&projectId=proj_1",
"/api/v1/plugins/mentions/search?q=42&trigger=%23&pluginId=github&providerId=issue&projectId=proj_1",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
Expand Down
57 changes: 42 additions & 15 deletions apps/app/src/hooks/queries/plugin-contribution-queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import {
useQueries,
useQuery,
type UseQueryResult,
} from "@tanstack/react-query";
import {
normalizePluginMentionTriggers,
type PluginMentionTrigger,
Expand Down Expand Up @@ -117,11 +121,14 @@ interface PluginMentionSearchArgs {

async function fetchPluginMentionSearch(
args: PluginMentionSearchArgs,
provider: PluginMentionProviderContribution,
signal: AbortSignal,
): Promise<PluginMentionSearchGroup[]> {
const params = new URLSearchParams({
q: args.query,
trigger: args.trigger,
pluginId: provider.pluginId,
providerId: provider.id,
});
if (args.projectId !== null) params.set("projectId", args.projectId);
if (args.threadId !== null) params.set("threadId", args.threadId);
Expand All @@ -138,20 +145,40 @@ async function fetchPluginMentionSearch(

export function usePluginMentionSearch(
args: PluginMentionSearchArgs,
options: { enabled: boolean },
options: {
enabled: boolean;
providers: readonly PluginMentionProviderContribution[];
},
) {
return useQuery({
queryKey: [
"plugin-mention-search",
args.trigger,
args.query,
args.projectId,
args.threadId,
],
queryFn: ({ signal }) => fetchPluginMentionSearch(args, signal),
enabled: options.enabled,
staleTime: 15_000,
placeholderData: (previous, previousQuery) =>
previousQuery?.queryKey[1] === args.trigger ? previous : undefined,
return useQueries({
queries: options.providers
.filter((provider) => provider.triggers.includes(args.trigger))
.map((provider) => ({
queryKey: [
"plugin-mention-search",
args.trigger,
args.query,
args.projectId,
args.threadId,
provider.pluginId,
provider.id,
],
queryFn: ({ signal }: { signal: AbortSignal }) =>
fetchPluginMentionSearch(args, provider, signal),
enabled: options.enabled,
staleTime: 15_000,
})),
combine: combineMentionSearches,
});
}

function combineMentionSearches(
queries: UseQueryResult<PluginMentionSearchGroup[]>[],
) {
return {
data: queries.flatMap((query) => query.data ?? []),
isLoading: queries.some((query) => query.isLoading),
isFetching: queries.some((query) => query.isFetching),
isError: queries.some((query) => query.isError),
};
}
1 change: 1 addition & 0 deletions apps/app/src/hooks/usePromptMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export function usePromptMentions(
threadId: options.currentThreadId ?? null,
},
{
providers: pluginContributions.data?.mentionProviders ?? [],
enabled:
hasMentionProviders &&
pluginSearchMatchesInput &&
Expand Down
82 changes: 74 additions & 8 deletions apps/host-daemon/src/command-handlers/file-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,45 @@ interface ListWorkspacePathsArgs extends PathListInclusion {
includeHidden: boolean;
excludeNames: readonly string[];
respectGitIgnore: boolean;
maxAgeMs: number;
}

const pendingListings = new Map<string, Promise<ListedPath[]>>();
interface WorkspacePathListing {
root: string;
promise: Promise<ListedPath[]>;
expiresAt: number | null;
pathCount: number;
}

const workspaceListings = new Map<string, WorkspacePathListing>();
const MAX_CACHED_LISTINGS = 32;
const MAX_CACHED_PATHS = 100_000;

export function invalidateWorkspacePathListings(root: string): void {
for (const [key, entry] of workspaceListings) {
if (
entry.root === root ||
entry.root.startsWith(`${root}${path.sep}`) ||
root.startsWith(`${entry.root}${path.sep}`)
) {
workspaceListings.delete(key);
}
}
}

function trimWorkspaceListings(): void {
let pathCount = 0;
for (const entry of workspaceListings.values()) pathCount += entry.pathCount;
for (const [key, entry] of workspaceListings) {
if (
workspaceListings.size <= MAX_CACHED_LISTINGS &&
pathCount <= MAX_CACHED_PATHS
)
break;
workspaceListings.delete(key);
pathCount -= entry.pathCount;
}
}

export function listWorkspacePaths(
args: ListWorkspacePathsArgs,
Expand All @@ -73,13 +109,43 @@ export function listWorkspacePaths(
args.includeFiles,
args.includeDirectories,
]);
const existing = pendingListings.get(key);
if (existing) return existing;
const listing = discoverWorkspacePaths(args).finally(() => {
pendingListings.delete(key);
});
pendingListings.set(key, listing);
return listing;
const existing = workspaceListings.get(key);
if (
existing &&
(existing.expiresAt === null ||
(args.maxAgeMs > 0 && existing.expiresAt > Date.now()))
) {
workspaceListings.delete(key);
workspaceListings.set(key, existing);
return existing.promise;
}
const entry: WorkspacePathListing = {
root: args.root,
expiresAt: null,
pathCount: 0,
promise: discoverWorkspacePaths(args).then(
(paths) => {
if (workspaceListings.get(key) === entry) {
if (args.maxAgeMs > 0 && paths.length <= MAX_CACHED_PATHS) {
entry.expiresAt = Date.now() + args.maxAgeMs;
entry.pathCount = paths.length;
trimWorkspaceListings();
} else {
workspaceListings.delete(key);
}
}
return paths;
},
(error: unknown) => {
if (workspaceListings.get(key) === entry) workspaceListings.delete(key);
throw error;
},
),
};
workspaceListings.delete(key);
workspaceListings.set(key, entry);
trimWorkspaceListings();
return entry.promise;
}

async function discoverWorkspacePaths(
Expand Down
2 changes: 2 additions & 0 deletions apps/host-daemon/src/command-handlers/host-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export async function listHostFiles(
respectGitIgnore: command.respectGitIgnore,
includeFiles: true,
includeDirectories: false,
maxAgeMs: command.query ? 2_000 : 0,
})
).map((entry) => entry.path),
limit: command.limit,
Expand Down Expand Up @@ -113,6 +114,7 @@ export async function listHostPaths(
includeHidden: command.includeHidden,
excludeNames: command.excludeNames,
respectGitIgnore: command.respectGitIgnore,
maxAgeMs: command.query ? 2_000 : 0,
}),
limit: command.limit,
includeFiles: command.includeFiles,
Expand Down
Loading
Loading