feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)

* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nllptrx
2026-07-17 15:03:26 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 04d1ebdce0
commit a8ebd390fa
187 changed files with 20988 additions and 2907 deletions
+42 -13
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -38,7 +38,7 @@ const imageMetadata: AttachmentMetadata = {
createdAt: 1,
};
const issueItem: GitHubSearchItem = {
const issueItem: ForgeSearchItem = {
kind: "issue",
number: 101,
title: "Fix composer attachments",
@@ -50,8 +50,8 @@ const issueItem: GitHubSearchItem = {
headRefName: null,
};
const prItem: GitHubSearchItem = {
kind: "pr",
const prItem: ForgeSearchItem = {
kind: "change_request",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
@@ -357,8 +357,9 @@ describe("dispatchComposerAgentMessage", () => {
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
expect(call.options.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
type: "forge_change_request",
mimeType: "application/paseo-forge-change-request",
forge: "github",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
@@ -380,6 +381,34 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.optimistic).toBe(true);
});
it("can send legacy GitHub attachment payloads for old daemons", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "send old attachment",
attachments: [{ kind: "forge_change_request", item: prItem }],
attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls[0].options.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
body: "PR body",
baseRefName: "main",
headRefName: "composer-attachments",
},
]);
});
it("appends to the existing head when one is present", async () => {
const existingItem: StreamItem = {
kind: "user_message",
@@ -694,12 +723,12 @@ describe("openComposerAttachment", () => {
describe("toggleGithubAttachment", () => {
it("appends a GitHub issue when not already attached", () => {
const next = toggleGithubAttachment([], issueItem);
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
});
it("appends a GitHub PR when not already attached", () => {
const next = toggleGithubAttachment([], prItem);
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
expect(next).toEqual([{ kind: "forge_change_request", item: prItem }]);
});
it("removes an existing GitHub item with the same kind+number", () => {
@@ -712,12 +741,12 @@ describe("toggleGithubAttachment", () => {
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
];
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
const otherIssue: ForgeSearchItem = { ...issueItem, number: 999 };
const next = toggleGithubAttachment(start, otherIssue);
expect(next).toEqual([
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
{ kind: "github_issue", item: otherIssue },
{ kind: "forge_issue", item: otherIssue },
]);
});
});
@@ -747,7 +776,7 @@ describe("toggleGithubAttachmentFromPicker", () => {
markGithubAttachmentRemoved,
});
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
});
});
@@ -755,8 +784,8 @@ describe("toggleGithubAttachmentFromPicker", () => {
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
it("locates items via their composite kind:number id", () => {
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
expect(findGithubItemByOption([issueItem, prItem], "change_request:202")).toBe(prItem);
expect(findGithubItemByOption([issueItem], "change_request:404")).toBeUndefined();
});
it("recognizes when an attachment list already contains a matching GitHub item", () => {
+38 -19
View File
@@ -1,4 +1,4 @@
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -8,7 +8,10 @@ import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
splitComposerAttachmentsForSubmit,
type ComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
@@ -162,6 +165,7 @@ export interface DispatchComposerAgentMessageInput {
agentId: string;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
encodeImages: (
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
@@ -171,7 +175,9 @@ export interface DispatchComposerAgentMessageInput {
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<void> {
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
const messageId = generateMessageId();
const userMessage = buildOptimisticUserMessage({
id: messageId,
@@ -342,33 +348,44 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.openExternalUrl(input.attachment.item.url);
}
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
export function buildForgeAttachment(item: ForgeSearchItem): UserComposerAttachment {
return item.kind === "change_request"
? { kind: "forge_change_request", item }
: { kind: "forge_issue", item };
}
function isGithubAttachment(
function isForgeAttachment(
attachment: UserComposerAttachment,
): attachment is Extract<UserComposerAttachment, { kind: "github_issue" } | { kind: "github_pr" }> {
return attachment.kind === "github_issue" || attachment.kind === "github_pr";
): attachment is Extract<
UserComposerAttachment,
{ kind: "forge_issue" | "forge_change_request" | "github_issue" | "github_pr" }
> {
return (
attachment.kind === "forge_issue" ||
attachment.kind === "forge_change_request" ||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
attachment.kind === "github_issue" ||
attachment.kind === "github_pr"
);
}
export function toggleGithubAttachment(
export function toggleForgeAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
item: ForgeSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
return [...current, buildGithubAttachment(item)];
return [...current, buildForgeAttachment(item)];
}
interface ToggleGithubAttachmentFromPickerInput {
current: UserComposerAttachment[];
item: GitHubSearchItem;
item: ForgeSearchItem;
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
}
@@ -379,31 +396,33 @@ export function toggleGithubAttachmentFromPicker({
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
if (existingAttachment) {
markGithubAttachmentRemoved(existingAttachment);
}
return toggleGithubAttachment(current, item);
return toggleForgeAttachment(current, item);
}
export function findGithubItemByOption(
items: readonly GitHubSearchItem[],
items: readonly ForgeSearchItem[],
optionId: string,
): GitHubSearchItem | undefined {
): ForgeSearchItem | undefined {
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
}
export function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
item: ForgeSearchItem,
): boolean {
return userAttachmentsOnly(current).some(
(attachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
export const toggleGithubAttachment = toggleForgeAttachment;
@@ -5,14 +5,38 @@ import {
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
import {
buildForgeAttachmentFromSearchItem,
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
interface SplitComposerAttachmentsOptions {
format?: ComposerAttachmentSubmitFormat;
}
export function resolveComposerAttachmentSubmitFormat(input: {
supportsForgeAttachments?: boolean;
}): ComposerAttachmentSubmitFormat {
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
return input.supportsForgeAttachments === false ? "legacy-github" : "forge";
}
export function splitComposerAttachmentsForSubmit(
attachments: ComposerAttachment[],
options: SplitComposerAttachmentsOptions = {},
): {
images: ImageAttachment[];
attachments: AgentAttachment[];
} {
const images: ImageAttachment[] = [];
const agentAttachments: AgentAttachment[] = [];
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
const buildSearchAttachment =
options.format === "legacy-github"
? buildLegacyGitHubAttachmentFromSearchItem
: buildForgeAttachmentFromSearchItem;
for (const attachment of attachments) {
if (attachment.kind === "image") {
@@ -36,7 +60,7 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
const reviewAttachment = buildSearchAttachment(attachment.item);
if (reviewAttachment) {
agentAttachments.push(reviewAttachment);
}
+19 -10
View File
@@ -1,7 +1,10 @@
import { useCallback, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -236,10 +239,23 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const trimmedPrompt = text.trim();
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const supportsForgeSearch =
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
true;
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
});
const images = wirePayload.images;
const trimmedPrompt = text.trim();
const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
const error = new Error(t("composer.errors.initialPromptRequired"));
@@ -258,13 +274,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
throw error;
}
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const attempt: CreateAttempt = {
clientMessageId: generateMessageId(),
text: trimmedPrompt,
@@ -7,17 +7,17 @@ import React, { type ReactNode } from "react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchClient } from "@/git/use-github-search-query";
import type { GitHubSearchItem, GitHubSearchResponse } from "@getpaseo/protocol/messages";
import type { ForgeSearchClient } from "@/git/use-forge-search-query";
import type { ForgeSearchItem, ForgeSearchResponse } from "@getpaseo/protocol/messages";
import { useComposerGithubAutoAttach } from "./auto-attach";
type GitHubSearchPayload = GitHubSearchResponse["payload"];
type ForgeSearchPayload = ForgeSearchResponse["payload"];
const remoteUrl = "git@github.com:acme/paseo.git";
const cwd = "/repo";
const pr101: GitHubSearchItem = {
kind: "pr",
const pr101: ForgeSearchItem = {
kind: "change_request",
number: 101,
title: "Attach PR",
url: "https://github.com/acme/paseo/pull/101",
@@ -28,7 +28,7 @@ const pr101: GitHubSearchItem = {
headRefName: "feature",
};
const issue202: GitHubSearchItem = {
const issue202: ForgeSearchItem = {
kind: "issue",
number: 202,
title: "Attach issue",
@@ -52,22 +52,20 @@ interface HarnessInput {
remote?: string | null;
}
function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload {
function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearchPayload {
return {
items,
githubFeaturesEnabled: true,
authState: "authenticated",
error: null,
requestId,
};
}
function createSearchClient(
items: GitHubSearchItem[],
): GitHubSearchClient & { calls: SearchCall[] } {
function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = [];
return {
calls,
async searchGitHub(options) {
async searchForge(options) {
calls.push(options);
return githubPayload(items, `search-${options.query}`);
},
@@ -87,7 +85,7 @@ function createWrapper() {
};
}
function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) {
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? "");
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [],
@@ -130,7 +128,7 @@ describe("useComposerGithubAutoAttach", () => {
});
await flushDebounce();
expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]);
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
vi.useRealTimers();
});
@@ -201,8 +199,8 @@ describe("useComposerGithubAutoAttach", () => {
await flushDebounce();
expect(result.current.attachments).toEqual([
{ kind: "github_pr", item: pr101 },
{ kind: "github_issue", item: issue202 },
{ kind: "forge_change_request", item: pr101 },
{ kind: "forge_issue", item: issue202 },
]);
expect(client.calls).toEqual([
{ cwd, query: "101", limit: 20 },
+14 -11
View File
@@ -9,12 +9,9 @@ import {
type SetStateAction,
} from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import {
buildGithubSearchQueryOptions,
type GitHubSearchClient,
} from "@/git/use-github-search-query";
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
const AUTO_ATTACH_DEBOUNCE_MS = 300;
@@ -23,10 +20,11 @@ interface ComposerGithubAutoAttachInput {
text: string;
remoteUrl: string | null | undefined;
attachments: UserComposerAttachment[];
client: GitHubSearchClient | null;
client: ForgeSearchClient | null;
isConnected: boolean;
serverId: string;
cwd: string;
supportsForgeSearch?: boolean;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
}
@@ -75,6 +73,7 @@ export function useComposerGithubAutoAttach(
params.isConnected,
params.serverId,
params.cwd,
params.supportsForgeSearch,
queryClient,
]);
@@ -193,11 +192,12 @@ async function fetchGithubRefSearch({
try {
return await queryClient.fetchQuery(
buildGithubSearchQueryOptions({
buildForgeSearchQueryOptions({
client: snapshot.client,
serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number),
supportsForgeSearch: snapshot.supportsForgeSearch,
enabled: true,
}),
);
@@ -216,12 +216,12 @@ function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubR
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
}
function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean {
function githubItemMatchesRef(item: ForgeSearchItem, ref: GithubRef): boolean {
return item.kind === githubItemKind(ref) && item.number === ref.number;
}
function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] {
return ref.kind === "pull" ? "pr" : "issue";
function githubItemKind(ref: GithubRef): ForgeSearchItem["kind"] {
return ref.kind === "pull" ? "change_request" : "issue";
}
function githubRefKey(ref: GithubRef): string {
@@ -232,7 +232,10 @@ function attachmentKey(attachment: ComposerAttachment | undefined): string | nul
if (
!attachment ||
attachment.kind === "image" ||
(attachment.kind !== "github_pr" && attachment.kind !== "github_issue")
(attachment.kind !== "forge_change_request" &&
attachment.kind !== "forge_issue" &&
attachment.kind !== "github_pr" &&
attachment.kind !== "github_issue")
) {
return null;
}
+98 -35
View File
@@ -28,7 +28,6 @@ import {
CircleDot,
FileText,
GitPullRequest,
Github,
Image as ImageIcon,
Paperclip,
} from "lucide-react-native";
@@ -94,7 +93,7 @@ import { submitAgentInput } from "@/composer/submit";
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -102,6 +101,7 @@ import type {
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
@@ -111,8 +111,11 @@ import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/componen
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
@@ -281,8 +284,8 @@ interface RenderAttachmentTrayArgs {
openImage: string;
removeImage: string;
removeFile: string;
openGithub: (kind: string, number: number) => string;
removeGithub: (kind: string, number: number) => string;
openGithub: (kind: string, numberLabel: string) => string;
removeGithub: (kind: string, numberLabel: string) => string;
};
}
@@ -616,13 +619,16 @@ function ImageAttachmentPill({
}
interface GithubAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
attachment: Extract<
ComposerAttachment,
{ kind: "forge_change_request" | "forge_issue" | "github_pr" | "github_issue" }
>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, number: number) => string;
removeLabel: (kind: string, number: number) => string;
openLabel: (kind: string, numberLabel: string) => string;
removeLabel: (kind: string, numberLabel: string) => string;
}
function GithubAttachmentPill({
@@ -635,7 +641,11 @@ function GithubAttachmentPill({
removeLabel,
}: GithubAttachmentPillProps) {
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
const presentation = getForgePresentation(item.forge ?? "github");
const isChangeRequest = item.kind === "change_request";
const kindLabel = isChangeRequest ? presentation.changeRequestAbbrev : "issue";
const subtitleKind = isChangeRequest ? presentation.changeRequestAbbrev : "Issue";
const numberPrefix = isChangeRequest ? presentation.numberPrefix : presentation.issueNumberPrefix;
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
@@ -647,14 +657,14 @@ function GithubAttachmentPill({
testID="composer-github-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={openLabel(kindLabel, item.number)}
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
openAccessibilityLabel={openLabel(kindLabel, `${numberPrefix}${item.number}`)}
removeAccessibilityLabel={removeLabel(kindLabel, `${numberPrefix}${item.number}`)}
disabled={disabled}
>
<AttachmentLabel
icon={item.kind === "pr" ? githubPrPillIcon : githubIssuePillIcon}
icon={isChangeRequest ? githubPrPillIcon : githubIssuePillIcon}
title={item.title}
subtitle={`${item.kind === "pr" ? "PR" : "Issue"} #${item.number}`}
subtitle={`${subtitleKind} ${numberPrefix}${item.number}`}
/>
</AttachmentPill>
);
@@ -703,8 +713,8 @@ interface GithubPickerOptionProps {
testID: string;
active: boolean;
selected: boolean;
item: GitHubSearchItem;
onToggle: (item: GitHubSearchItem) => void;
item: ForgeSearchItem;
onToggle: (item: ForgeSearchItem) => void;
}
function GithubPickerOption({
@@ -720,7 +730,7 @@ function GithubPickerOption({
}, [onToggle, item]);
const leadingSlot = useMemo(
() =>
item.kind === "pr" ? (
item.kind === "change_request" ? (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
@@ -1045,6 +1055,9 @@ export function Composer({
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
const githubAutoAttach = useComposerGithubAutoAttach({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
@@ -1053,6 +1066,7 @@ export function Composer({
isConnected,
serverId,
cwd,
supportsForgeSearch,
setAttachments: setSelectedAttachments,
});
const [cursorIndex, setCursorIndex] = useState(0);
@@ -1212,12 +1226,23 @@ export function Composer({
agentId: targetAgentId,
text,
attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
stream,
});
onAttentionPromptSend?.();
};
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
}, [
client,
onAttentionPromptSend,
serverId,
setAgentStreamTail,
setAgentStreamHead,
supportsForgeSearch,
t,
]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -1711,12 +1736,33 @@ export function Composer({
[contextWindowMeter, isCompactLayout],
);
const hasGithubAttachment = useMemo(
() =>
selectedAttachments.some(
(attachment) =>
attachment.kind === "forge_change_request" ||
attachment.kind === "forge_issue" ||
attachment.kind === "github_pr" ||
attachment.kind === "github_issue",
),
[selectedAttachments],
);
// Composer stays mounted for each focused agent, so avoid a forge CLI call
// until the forge-specific picker or attachment presentation is visible.
const { forge } = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment),
});
const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useGithubSearchQuery({
const githubSearchResultsQuery = useForgeSearchQuery({
client,
serverId,
cwd,
query: githubSearchQueryTrimmed,
supportsForgeSearch,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
});
@@ -1724,11 +1770,18 @@ export function Composer({
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
const githubSearchOptions: ComboboxOption[] = useMemo(
() =>
githubSearchItems.map((item) => ({
id: `${item.kind}:${item.number}`,
label: `#${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
})),
githubSearchItems.map((item) => {
const presentation = getForgePresentation(item.forge ?? "github");
const numberPrefix =
item.kind === "change_request"
? presentation.numberPrefix
: presentation.issueNumberPrefix;
return {
id: `${item.kind}:${item.number}`,
label: `${numberPrefix}${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
};
}),
[githubSearchItems, githubSearchQueryTrimmed],
);
@@ -1744,8 +1797,10 @@ export function Composer({
},
{
id: "github",
label: t("composer.attachments.addIssueOrPr"),
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
label: t("composer.attachments.addIssueOrPr", {
context: forgePresentation.changeRequestContext,
}),
icon: renderForgeAttachmentIcon(forgePresentation.icon),
onSelect: () => {
setIsGithubPickerOpen(true);
},
@@ -1759,11 +1814,11 @@ export function Composer({
},
},
],
[handlePickImage, handlePickFile, t],
[handlePickImage, handlePickFile, t, forgePresentation],
);
const handleToggleGithubItem = useCallback(
(item: GitHubSearchItem) => {
(item: ForgeSearchItem) => {
const nextAttachments = toggleGithubAttachmentFromPicker({
current: attachments,
item,
@@ -1868,10 +1923,10 @@ export function Composer({
openImage: t("composer.attachments.openImage"),
removeImage: t("composer.attachments.removeImage"),
removeFile: t("composer.attachments.removeFile"),
openGithub: (kind: string, number: number) =>
t("composer.attachments.openGithub", { kind, number }),
removeGithub: (kind: string, number: number) =>
t("composer.attachments.removeGithub", { kind, number }),
openGithub: (kind: string, numberLabel: string) =>
t("composer.attachments.openGithub", { kind, number: numberLabel }),
removeGithub: (kind: string, numberLabel: string) =>
t("composer.attachments.removeGithub", { kind, number: numberLabel }),
},
}),
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
@@ -1983,8 +2038,12 @@ export function Composer({
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
searchPlaceholder={t("composer.github.searchPlaceholder", {
context: forgePresentation.changeRequestContext,
})}
title={t("composer.github.title", {
context: forgePresentation.changeRequestContext,
})}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
@@ -2185,12 +2244,16 @@ const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedImageIcon = withUnistyles(ImageIcon);
const ThemedFileText = withUnistyles(FileText);
const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
function renderForgeAttachmentIcon(icon: string): ReactElement {
return (
<ForgeBrandIcon iconKind={icon} size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />
);
}
const githubPrPillIcon = (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);