diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index f8e9bdd4a..0e1429853 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -63,6 +63,8 @@ interface ComposerProps { isInputActive: boolean; onSubmitMessage?: (payload: MessagePayload) => Promise; allowEmptySubmit?: boolean; + /** Label to show on the submit button when the input is empty and allowEmptySubmit is true. */ + emptySubmitLabel?: string; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; /** When true, blurs the input immediately when submitting. */ @@ -101,6 +103,7 @@ export function Composer({ isInputActive, onSubmitMessage, allowEmptySubmit = false, + emptySubmitLabel, isSubmitLoading = false, blurOnSubmit = false, value, @@ -737,6 +740,7 @@ export function Composer({ onChangeText={setUserInput} onSubmit={handleSubmit} allowEmptySubmit={allowEmptySubmit} + emptySubmitLabel={emptySubmitLabel} isSubmitDisabled={isProcessing || isSubmitLoading} isSubmitLoading={isProcessing || isSubmitLoading} images={selectedImages} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index f60e0eb05..07e527770 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -63,6 +63,8 @@ export interface MessageInputProps { onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; allowEmptySubmit?: boolean; + /** Label to show on the submit button when the input is empty and allowEmptySubmit is true. */ + emptySubmitLabel?: string; isSubmitDisabled?: boolean; isSubmitLoading?: boolean; images?: ImageAttachment[]; @@ -195,6 +197,7 @@ export const MessageInput = forwardRef(funct onChangeText, onSubmit, allowEmptySubmit = false, + emptySubmitLabel, isSubmitDisabled = false, isSubmitLoading = false, images = [], @@ -558,7 +561,7 @@ export const MessageInput = forwardRef(funct const handleSendMessage = useCallback(() => { const trimmed = value.trim(); - if (!trimmed && images.length === 0) return; + if (!trimmed && images.length === 0 && !allowEmptySubmit) return; const payload = { text: trimmed, images: images.length > 0 ? images : undefined, @@ -568,7 +571,7 @@ export const MessageInput = forwardRef(funct inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT); - }, [value, images, onSubmit, isAgentRunning, onHeightChange]); + }, [value, images, onSubmit, isAgentRunning, onHeightChange, allowEmptySubmit]); const handleQueueMessage = useCallback(() => { if (!onQueue) return; @@ -894,8 +897,10 @@ export const MessageInput = forwardRef(funct } const hasImages = images.length > 0; - const hasSendableContent = value.trim().length > 0 || hasImages || allowEmptySubmit; + const hasRealContent = value.trim().length > 0 || hasImages; + const hasSendableContent = hasRealContent || allowEmptySubmit; const shouldShowSendButton = hasSendableContent || isSubmitLoading; + const showEmptySubmitLabel = allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const isSendButtonDisabled = disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); @@ -1112,19 +1117,24 @@ export const MessageInput = forwardRef(funct {isSubmitLoading ? ( + ) : showEmptySubmitLabel ? ( + {emptySubmitLabel} ) : ( )} - Send + {showEmptySubmitLabel ? emptySubmitLabel : "Send"} {sendKeys ? : null} @@ -1304,6 +1314,18 @@ const styles = StyleSheet.create(((theme: any) => ({ alignItems: "center", justifyContent: "center", }, + emptySubmitButton: { + height: 28, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[3], + }, + emptySubmitLabel: { + color: "white", + fontSize: theme.fontSize.sm, + }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index b11d001fc..6768060b3 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -3,7 +3,7 @@ import { Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { createNameId } from "mnemonic-id"; import { useQuery } from "@tanstack/react-query"; -import { ChevronDown, GitBranch } from "lucide-react-native"; +import { ChevronDown, CircleDot, GitBranch, GitPullRequest, Plus, X } from "lucide-react-native"; import { Composer } from "@/components/composer"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox"; @@ -28,6 +28,24 @@ import { } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { ImageAttachment, MessagePayload } from "@/components/message-input"; +import type { GitHubSearchItem } from "@server/shared/messages"; + +function buildInitialPrompt(userText: string, githubItems: GitHubSearchItem[]): string { + const parts: string[] = []; + + for (const item of githubItems) { + const kind = item.kind === "pr" ? "Pull Request" : "Issue"; + const header = `GitHub ${kind} #${item.number}: ${item.title}`; + const body = item.body?.trim(); + parts.push(body ? `${header}\n\n${body}` : header); + } + + if (userText) { + parts.push(userText); + } + + return parts.join("\n\n---\n\n"); +} interface NewWorkspaceScreenProps { serverId: string; @@ -52,6 +70,10 @@ export function NewWorkspaceScreen({ const [selectedBranch, setSelectedBranch] = useState(null); const [branchPickerOpen, setBranchPickerOpen] = useState(false); const branchAnchorRef = useRef(null); + const [githubContextItems, setGithubContextItems] = useState([]); + const [githubPickerOpen, setGithubPickerOpen] = useState(false); + const [githubSearchQuery, setGithubSearchQuery] = useState(""); + const githubAnchorRef = useRef(null); const displayName = displayNameProp?.trim() ?? ""; const workspace = createdWorkspace; @@ -98,6 +120,52 @@ export function NewWorkspaceScreen({ enabled: isConnected && !!client, }); + const githubSearchQuery_trimmed = githubSearchQuery.trim(); + const githubSearchResultsQuery = useQuery({ + queryKey: ["github-search", serverId, sourceDirectory, githubSearchQuery_trimmed], + queryFn: async () => { + const connectedClient = withConnectedClient(); + return connectedClient.searchGitHub({ + cwd: sourceDirectory, + query: githubSearchQuery_trimmed, + limit: 20, + }); + }, + enabled: isConnected && !!client && githubSearchQuery_trimmed.length >= 2, + }); + + const githubSearchOptions: ComboboxOptionType[] = useMemo(() => { + const items = githubSearchResultsQuery.data?.items ?? []; + const selectedNumbers = new Set(githubContextItems.map((i) => `${i.kind}:${i.number}`)); + return items + .filter((item) => !selectedNumbers.has(`${item.kind}:${item.number}`)) + .map((item) => ({ + id: `${item.kind}:${item.number}`, + label: `#${item.number} ${item.title}`, + // Include search query so the Combobox's client-side filter doesn't + // discard server-searched results that match on body but not title. + description: githubSearchQuery_trimmed, + })); + }, [githubSearchResultsQuery.data?.items, githubContextItems, githubSearchQuery_trimmed]); + + const handleSelectGithubItem = useCallback( + (id: string) => { + const items = githubSearchResultsQuery.data?.items ?? []; + const [kind, numberStr] = id.split(":"); + const item = items.find((i) => i.kind === kind && i.number === Number(numberStr)); + if (item) { + setGithubContextItems((prev) => [...prev, item]); + } + setGithubPickerOpen(false); + setGithubSearchQuery(""); + }, + [githubSearchResultsQuery.data?.items], + ); + + const handleRemoveGithubItem = useCallback((index: number) => { + setGithubContextItems((prev) => prev.filter((_, i) => i !== index)); + }, []); + const branchOptions: ComboboxOptionType[] = useMemo( () => (branchSuggestionsQuery.data?.branches ?? []).map((branch) => ({ @@ -145,6 +213,7 @@ export function NewWorkspaceScreen({ throw new Error("Composer state is required"); } + const initialPrompt = buildInitialPrompt(text.trim(), githubContextItems); const encodedImages = await encodeImages(images); const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; const agent = await connectedClient.createAgent({ @@ -158,7 +227,7 @@ export function NewWorkspaceScreen({ ...(composerState.effectiveThinkingOptionId ? { thinkingOptionId: composerState.effectiveThinkingOptionId } : {}), - ...(text.trim() ? { initialPrompt: text.trim() } : {}), + ...(initialPrompt ? { initialPrompt } : {}), ...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}), }); @@ -181,7 +250,7 @@ export function NewWorkspaceScreen({ setPendingAction(null); } }, - [composerState, ensureWorkspace, serverId, setAgents, toast, withConnectedClient], + [composerState, ensureWorkspace, githubContextItems, serverId, setAgents, toast, withConnectedClient], ); const workspaceTitle = @@ -223,6 +292,8 @@ export function NewWorkspaceScreen({ serverId={serverId} isInputActive={true} onSubmitMessage={handleCreateChatAgent} + allowEmptySubmit + emptySubmitLabel="Create" isSubmitLoading={pendingAction === "chat"} blurOnSubmit={true} value={chatDraft.text} @@ -291,6 +362,93 @@ export function NewWorkspaceScreen({ )} /> + {githubContextItems.map((item, index) => ( + + {item.kind === "pr" ? ( + + ) : ( + + )} + + #{item.number} {item.title} + + handleRemoveGithubItem(index)} + accessibilityLabel={`Remove #${item.number}`} + hitSlop={4} + > + + + + ))} + + setGithubPickerOpen(true)} + style={({ pressed, hovered }) => [ + styles.badge, + hovered && styles.badgeHovered, + pressed && styles.badgePressed, + ]} + accessibilityRole="button" + accessibilityLabel="Add GitHub issue or PR" + > + + + Issue or PR + + + { + setGithubPickerOpen(open); + if (!open) setGithubSearchQuery(""); + }} + onSearchQueryChange={setGithubSearchQuery} + desktopPlacement="bottom-start" + anchorRef={githubAnchorRef} + emptyText={ + githubSearchQuery_trimmed.length < 2 + ? "Type to search..." + : githubSearchResultsQuery.isFetching + ? "Searching..." + : "No results found." + } + renderOption={({ option, selected, active, onPress }) => { + const item = (githubSearchResultsQuery.data?.items ?? []).find( + (i) => `${i.kind}:${i.number}` === option.id, + ); + return ( + + ) : ( + + ) + } + /> + ); + }} + /> + {errorMessage ? {errorMessage} : null} @@ -351,6 +509,7 @@ const styles = StyleSheet.create((theme) => ({ optionsRow: { flexDirection: "row", alignItems: "center", + flexWrap: "wrap", gap: theme.spacing[2], paddingHorizontal: theme.spacing[4] + theme.spacing[4] - 6, marginTop: -theme.spacing[2], @@ -373,4 +532,20 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, + githubChip: { + flexDirection: "row", + alignItems: "center", + height: 28, + paddingLeft: theme.spacing[2], + paddingRight: theme.spacing[1], + borderRadius: theme.borderRadius["2xl"], + backgroundColor: theme.colors.surface1, + gap: theme.spacing[1], + maxWidth: 240, + }, + githubChipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, })); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 420fb77ad..6cac9a1c4 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -30,6 +30,7 @@ import type { CheckoutPrStatusResponse, ValidateBranchResponse, BranchSuggestionsResponse, + GitHubSearchResponse, DirectorySuggestionsResponse, PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, @@ -225,6 +226,7 @@ type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type ValidateBranchPayload = ValidateBranchResponse["payload"]; type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"]; +type GitHubSearchPayload = GitHubSearchResponse["payload"]; type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"]; type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"]; type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"]; @@ -2495,6 +2497,23 @@ export class DaemonClient { }); } + async searchGitHub( + options: { cwd: string; query: string; limit?: number }, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "github_search_request", + cwd: options.cwd, + query: options.query, + limit: options.limit, + }, + responseType: "github_search_response", + timeout: 15000, + }); + } + async getDirectorySuggestions( options: { query: string; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b8b83d927..2f7db4879 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -147,6 +147,7 @@ import { pushCurrentBranch, createPullRequest, getPullRequestStatus, + searchGitHubIssuesAndPrs, warmCheckoutShortstatInBackground, } from "../utils/checkout-git.js"; import { getProjectIcon } from "../utils/project-icon.js"; @@ -1619,6 +1620,10 @@ export class Session { await this.handleBranchSuggestionsRequest(msg); break; + case "github_search_request": + await this.handleGitHubSearchRequest(msg); + break; + case "directory_suggestions_request": await this.handleDirectorySuggestionsRequest(msg); break; @@ -4034,6 +4039,36 @@ export class Session { } } + private async handleGitHubSearchRequest( + msg: Extract, + ): Promise { + const { cwd, query, limit, requestId } = msg; + + try { + const resolvedCwd = expandTilde(cwd); + const result = await searchGitHubIssuesAndPrs(resolvedCwd, query, limit); + this.emit({ + type: "github_search_response", + payload: { + items: result.items, + githubFeaturesEnabled: result.githubFeaturesEnabled, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "github_search_response", + payload: { + items: [], + githubFeaturesEnabled: true, + error: error instanceof Error ? error.message : String(error), + requestId, + }, + }); + } + } + private async handleDirectorySuggestionsRequest(msg: DirectorySuggestionsRequest): Promise { const { query, limit, requestId, cwd, includeFiles, includeDirectories } = msg; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index de3e3e478..24db1c24f 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1073,6 +1073,24 @@ export const BranchSuggestionsRequestSchema = z.object({ requestId: z.string(), }); +export const GitHubSearchItemSchema = z.object({ + kind: z.enum(["issue", "pr"]), + number: z.number(), + title: z.string(), + url: z.string(), + state: z.string(), + body: z.string().nullable(), + labels: z.array(z.string()), +}); + +export const GitHubSearchRequestSchema = z.object({ + type: z.literal("github_search_request"), + cwd: z.string(), + query: z.string(), + limit: z.number().int().min(1).max(50).optional(), + requestId: z.string(), +}); + export const DirectorySuggestionsRequestSchema = z.object({ type: z.literal("directory_suggestions_request"), query: z.string(), @@ -1400,6 +1418,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusRequestSchema, ValidateBranchRequestSchema, BranchSuggestionsRequestSchema, + GitHubSearchRequestSchema, DirectorySuggestionsRequestSchema, PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, @@ -2322,6 +2341,16 @@ export const BranchSuggestionsResponseSchema = z.object({ }), }); +export const GitHubSearchResponseSchema = z.object({ + type: z.literal("github_search_response"), + payload: z.object({ + items: z.array(GitHubSearchItemSchema), + githubFeaturesEnabled: z.boolean(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + export const DirectorySuggestionsResponseSchema = z.object({ type: z.literal("directory_suggestions_response"), payload: z.object({ @@ -2696,6 +2725,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusResponseSchema, ValidateBranchResponseSchema, BranchSuggestionsResponseSchema, + GitHubSearchResponseSchema, DirectorySuggestionsResponseSchema, PaseoWorktreeListResponseSchema, PaseoWorktreeArchiveResponseSchema, @@ -2923,6 +2953,9 @@ export type ValidateBranchRequest = z.infer; export type ValidateBranchResponse = z.infer; export type BranchSuggestionsRequest = z.infer; export type BranchSuggestionsResponse = z.infer; +export type GitHubSearchItem = z.infer; +export type GitHubSearchRequest = z.infer; +export type GitHubSearchResponse = z.infer; export type DirectorySuggestionsRequest = z.infer; export type DirectorySuggestionsResponse = z.infer; export type PaseoWorktreeListRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 3ca4a4115..74d6a37ea 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -2297,3 +2297,110 @@ async function getPullRequestStatusUncached(cwd: string): Promise; + githubFeaturesEnabled: boolean; +} + +export async function searchGitHubIssuesAndPrs( + cwd: string, + query: string, + limit = 20, +): Promise { + await requireGitRepo(cwd); + let ghPath: string; + try { + ghPath = resolveGhPath(); + } catch { + return { items: [], githubFeaturesEnabled: false }; + } + + try { + const [issuesResult, prsResult] = await Promise.allSettled([ + execFileAsync( + ghPath, + [ + "issue", + "list", + "--search", + query, + "--json", + "number,title,url,state,body,labels", + "--limit", + String(limit), + ], + { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, + ), + execFileAsync( + ghPath, + [ + "pr", + "list", + "--search", + query, + "--json", + "number,title,url,state,body,labels", + "--limit", + String(limit), + ], + { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, + ), + ]); + + const items: GitHubSearchResult["items"] = []; + + if (issuesResult.status === "fulfilled") { + const parsed = JSON.parse(issuesResult.value.stdout.trim() || "[]"); + if (Array.isArray(parsed)) { + for (const item of parsed) { + items.push({ + kind: "issue", + number: item.number, + title: item.title ?? "", + url: item.url ?? "", + state: item.state ?? "", + body: item.body ?? null, + labels: Array.isArray(item.labels) + ? item.labels.map((l: { name?: string }) => l.name ?? "").filter(Boolean) + : [], + }); + } + } + } + + if (prsResult.status === "fulfilled") { + const parsed = JSON.parse(prsResult.value.stdout.trim() || "[]"); + if (Array.isArray(parsed)) { + for (const item of parsed) { + items.push({ + kind: "pr", + number: item.number, + title: item.title ?? "", + url: item.url ?? "", + state: item.state ?? "", + body: item.body ?? null, + labels: Array.isArray(item.labels) + ? item.labels.map((l: { name?: string }) => l.name ?? "").filter(Boolean) + : [], + }); + } + } + } + + return { items, githubFeaturesEnabled: true }; + } catch (error) { + if (isGhAuthError(error)) { + return { items: [], githubFeaturesEnabled: false }; + } + throw error; + } +}