feat(app,server): rank new-workspace picker by recency with debounced search

Plumb updatedAt through GitHub issue/PR search and committerDate through
branch suggestions so the new-workspace picker interleaves branches and
PRs sorted by last activity. Debounce the search query so remote GitHub
searches are driven by what the user typed rather than a static list.
This commit is contained in:
Mohamed Boudra
2026-04-19 14:26:51 +07:00
parent 43bbf8cb12
commit d3393b434a
6 changed files with 123 additions and 83 deletions
+4 -2
View File
@@ -4088,11 +4088,12 @@ export class Session {
try {
const resolvedCwd = expandTilde(cwd);
const branches = await listBranchSuggestions(resolvedCwd, { query, limit });
const branchDetails = await listBranchSuggestions(resolvedCwd, { query, limit });
this.emit({
type: "branch_suggestions_response",
payload: {
branches,
branches: branchDetails.map((branch) => branch.name),
branchDetails,
error: null,
requestId,
},
@@ -4102,6 +4103,7 @@ export class Session {
type: "branch_suggestions_response",
payload: {
branches: [],
branchDetails: [],
error: error instanceof Error ? error.message : String(error),
requestId,
},
@@ -20,6 +20,7 @@ const GitHubIssueSummarySchema = z.object({
state: z.string().catch(""),
body: z.string().nullable().catch(null),
labels: z.array(LabelSchema).catch([]),
updatedAt: z.string().catch(""),
});
const GitHubPullRequestSummarySchema = z.object({
@@ -31,6 +32,7 @@ const GitHubPullRequestSummarySchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
labels: z.array(LabelSchema).catch([]),
updatedAt: z.string().catch(""),
});
const PullRequestCheckRunNodeSchema = z.object({
@@ -123,6 +125,7 @@ export interface GitHubPullRequestSummary {
baseRefName: string;
headRefName: string;
labels: string[];
updatedAt: string;
}
export interface GitHubIssueSummary {
@@ -132,6 +135,7 @@ export interface GitHubIssueSummary {
state: string;
body: string | null;
labels: string[];
updatedAt: string;
}
export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped";
@@ -337,7 +341,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
"--search",
options.query ?? "",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
String(options.limit ?? 20),
],
@@ -361,7 +365,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
"--search",
options.query ?? "",
"--json",
"number,title,url,state,body,labels",
"number,title,url,state,body,labels,updatedAt",
"--limit",
String(options.limit ?? 20),
],
@@ -384,7 +388,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
"view",
String(options.number),
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
],
{ cwd: options.cwd },
);
+9
View File
@@ -1189,6 +1189,7 @@ export const GitHubSearchItemSchema = z.object({
labels: z.array(z.string()),
baseRefName: z.string().nullable().optional(),
headRefName: z.string().nullable().optional(),
updatedAt: z.string().optional(),
});
export const GitHubSearchKindSchema = z.enum(["github-issue", "github-pr"]);
@@ -2598,6 +2599,14 @@ export const BranchSuggestionsResponseSchema = z.object({
type: z.literal("branch_suggestions_response"),
payload: z.object({
branches: z.array(z.string()),
branchDetails: z
.array(
z.object({
name: z.string(),
committerDate: z.number(),
}),
)
.optional(),
error: z.string().nullable(),
requestId: z.string(),
}),
+23 -7
View File
@@ -765,12 +765,21 @@ const x = 1;
execSync("git fetch origin", { cwd: repoDir });
const branches = await listBranchSuggestions(repoDir, { limit: 50 });
expect(branches).toContain("main");
expect(branches).toContain("local-only");
expect(branches).toContain("remote-only");
expect(branches.filter((name) => name === "main")).toHaveLength(1);
expect(branches).not.toContain("HEAD");
expect(branches.some((name) => name.startsWith("origin/"))).toBe(false);
const branchNames = branches.map((branch) => branch.name);
expect(branchNames).toContain("main");
expect(branchNames).toContain("local-only");
expect(branchNames).toContain("remote-only");
expect(branchNames.filter((name) => name === "main")).toHaveLength(1);
expect(branchNames).not.toContain("HEAD");
expect(branchNames.some((name) => name.startsWith("origin/"))).toBe(false);
expect(branches).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "main",
committerDate: expect.any(Number),
}),
]),
);
});
it("filters branch suggestions by query and enforces result limit", async () => {
@@ -786,7 +795,8 @@ const x = 1;
limit: 1,
});
expect(branches).toHaveLength(1);
expect(branches[0]?.toLowerCase()).toContain("feature/");
expect(branches[0]?.name.toLowerCase()).toContain("feature/");
expect(branches[0]?.committerDate).toEqual(expect.any(Number));
});
it("disables GitHub features when gh is unavailable", async () => {
@@ -816,6 +826,7 @@ const x = 1;
state: "OPEN",
body: "issue body",
labels: ["bug"],
updatedAt: "2026-04-18T12:00:00Z",
},
];
};
@@ -832,6 +843,7 @@ const x = 1;
baseRefName: "main",
headRefName: "feature",
labels: ["enhancement"],
updatedAt: "2026-04-18T13:00:00Z",
},
];
};
@@ -853,6 +865,7 @@ const x = 1;
labels: ["bug"],
baseRefName: null,
headRefName: null,
updatedAt: "2026-04-18T12:00:00Z",
},
{
kind: "pr",
@@ -864,6 +877,7 @@ const x = 1;
labels: ["enhancement"],
baseRefName: "main",
headRefName: "feature",
updatedAt: "2026-04-18T13:00:00Z",
},
],
});
@@ -890,6 +904,7 @@ const x = 1;
baseRefName: "main",
headRefName: "feature",
labels: ["enhancement"],
updatedAt: "2026-04-18T13:00:00Z",
},
];
};
@@ -913,6 +928,7 @@ const x = 1;
labels: ["enhancement"],
baseRefName: "main",
headRefName: "feature",
updatedAt: "2026-04-18T13:00:00Z",
},
],
});
+13 -2
View File
@@ -128,6 +128,11 @@ interface GitRef {
committerDate: number;
}
export interface BranchSuggestion {
name: string;
committerDate: number;
}
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
const { stdout } = await runGitCommand(
[
@@ -181,7 +186,7 @@ function sortBranchSuggestions(
export async function listBranchSuggestions(
cwd: string,
options?: { query?: string; limit?: number },
): Promise<string[]> {
): Promise<BranchSuggestion[]> {
await requireGitRepo(cwd);
const requestedLimit = options?.limit ?? 50;
@@ -227,7 +232,10 @@ export async function listBranchSuggestions(
}
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
return ordered.slice(0, limit);
return ordered.slice(0, limit).map((name) => ({
name,
committerDate: branchMeta.get(name)?.committerDate ?? 0,
}));
}
async function listCheckoutFileChanges(
@@ -1936,6 +1944,7 @@ export interface GitHubSearchResult {
labels: string[];
baseRefName?: string | null;
headRefName?: string | null;
updatedAt?: string;
}>;
githubFeaturesEnabled: boolean;
}
@@ -1997,6 +2006,7 @@ export async function searchGitHubIssuesAndPrs(
labels: item.labels,
baseRefName: null,
headRefName: null,
updatedAt: item.updatedAt,
});
}
}
@@ -2013,6 +2023,7 @@ export async function searchGitHubIssuesAndPrs(
labels: item.labels,
baseRefName: item.baseRefName,
headRefName: item.headRefName,
updatedAt: item.updatedAt,
});
}
}