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:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user