diff --git a/src/main/github/client.ts b/src/main/github/client.ts index ebf866f3248..81d427efb70 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -829,6 +829,25 @@ export async function getWorkItem( } } +export async function getWorkItemByOwnerRepo( + repoPath: string, + ownerRepo: OwnerRepo, + number: number, + type: 'issue' | 'pr' +): Promise { + await acquire() + try { + if (type === 'issue') { + return await fetchIssueWorkItem(repoPath, ownerRepo, number) + } + return await fetchPullRequestWorkItem(repoPath, ownerRepo, number) + } catch { + return null + } finally { + release() + } +} + /** * Get PR info for a given branch using gh CLI. * Returns null if gh is not installed, or no PR exists for the branch. diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 371bf6cd280..ca19aaa1c95 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -15,6 +15,7 @@ import { listWorkItems, countWorkItems, getWorkItem, + getWorkItemByOwnerRepo, createIssue, updateIssue, addIssueComment, @@ -107,6 +108,25 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItem) ) + ipcMain.handle( + 'gh:workItemByOwnerRepo', + ( + _event, + args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + } + ) => + getWorkItemByOwnerRepo( + assertRegisteredRepo(args.repoPath, store).path, + { owner: args.owner, repo: args.repo }, + args.number, + args.type + ) + ) ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItemDetails) ) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9c0c7e2cbc5..fe5cec1382b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -460,6 +460,13 @@ export type PreloadApi = { number: number type?: 'issue' | 'pr' }) => Promise | null> + workItemByOwnerRepo: (args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + }) => Promise | null> workItemDetails: (args: { repoPath: string number: number diff --git a/src/preload/index.ts b/src/preload/index.ts index 12973ebacb5..f500805e37b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -439,6 +439,14 @@ const api = { type?: 'issue' | 'pr' }): Promise => ipcRenderer.invoke('gh:workItem', args), + workItemByOwnerRepo: (args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + workItemDetails: (args: { repoPath: string number: number diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 2694cd01af3..b473181be46 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -19,7 +19,9 @@ import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { GitHubWorkItem, LinearIssue, SparsePreset, TuiAgent } from '../../../shared/types' import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect' -import SmartWorkspaceNameField from '@/components/new-workspace/SmartWorkspaceNameField' +import SmartWorkspaceNameField, { + type SmartWorkspaceNameSelection +} from '@/components/new-workspace/SmartWorkspaceNameField' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -39,6 +41,8 @@ type NewWorkspaceComposerCardProps = { onSmartGitHubItemSelect: (item: GitHubWorkItem) => void onSmartBranchSelect: (refName: string) => void onSmartLinearIssueSelect: (issue: LinearIssue) => void + smartNameSelection: SmartWorkspaceNameSelection | null + onClearSmartNameSelection: () => void detectedAgentIds: Set | null onOpenAgentSettings: () => void advancedOpen: boolean @@ -182,6 +186,8 @@ export default function NewWorkspaceComposerCard({ onSmartGitHubItemSelect, onSmartBranchSelect, onSmartLinearIssueSelect, + smartNameSelection, + onClearSmartNameSelection, detectedAgentIds, onOpenAgentSettings, advancedOpen, @@ -292,17 +298,21 @@ export default function NewWorkspaceComposerCard({
{ // Why: Enter on the workspace name advances focus to the next // field (Agent combobox) rather than submitting, letting the user @@ -353,6 +363,21 @@ export default function NewWorkspaceComposerCard({ />
+
+ +
+
) : null} -
- -
-
+
+ ) : ( + <> + + { + onValueChange(event.target.value) + if (mode !== 'text') { + setOpen(true) } - return - } - onPlainEnter?.() - } - if (event.key === 'Escape' && open) { - event.stopPropagation() - setOpen(false) - } - }} - placeholder={placeholder} - className="h-9 pl-8 text-sm" - /> + }} + onFocus={() => { + if (mode !== 'text') { + setOpen(true) + } + }} + onKeyDown={(event) => { + if (event.key === 'Tab' && event.shiftKey) { + const activeTrigger = tabsListRef.current?.querySelector( + `[data-smart-name-mode="${mode}"]` + ) + if (activeTrigger) { + event.preventDefault() + activeTrigger.focus() + return + } + } + if ( + event.key === 'Enter' && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey + ) { + if (open && rows.length > 0) { + const row = rows.find((entry) => entry.value === commandValue) + if (row) { + event.preventDefault() + handleSelect(row) + } + return + } + onPlainEnter?.() + } + if (event.key === 'Escape' && open) { + event.stopPropagation() + setOpen(false) + } + }} + placeholder={placeholder} + className="h-9 pl-8 text-sm" + /> + + )}
event.preventDefault()} + onPointerDownOutside={(event) => { + // Why: the input is a PopoverAnchor, not a PopoverTrigger, so + // Radix treats clicks on it as outside the popover. Keep focus + // clicks and mode-tab clicks from immediately closing results. + const target = event.target as Node + if ( + localInputRef.current?.contains(target) || + tabsListRef.current?.contains(target) + ) { + event.preventDefault() + } + }} + onFocusOutside={(event) => { + const target = event.target as Node + if ( + localInputRef.current?.contains(target) || + tabsListRef.current?.contains(target) + ) { + event.preventDefault() + } + }} > {loading && rows.length === 0 ? ( @@ -456,6 +683,35 @@ export default function SmartWorkspaceNameField({ + !next && dismissCrossRepoPrompt()} + > + + + Switch project? + + The GitHub URL points to {crossRepoPrompt?.link.slug.owner}/ + {crossRepoPrompt?.link.slug.repo}, which is different from the selected project. + + + + + + {crossRepoPrompt?.matchingRepo ? ( + + ) : ( + + )} + + + ) } @@ -481,6 +737,20 @@ function RowIcon({ row }: { row: RowEntry }): React.JSX.Element { ) } +function SelectionIcon({ kind }: { kind: SmartWorkspaceNameSelection['kind'] }): React.JSX.Element { + if (kind === 'github') { + return + } + if (kind === 'branch') { + return + } + return ( + + L + + ) +} + function RowLabel({ row }: { row: RowEntry }): React.JSX.Element { if (row.kind === 'use-name') { return ( @@ -506,3 +776,45 @@ function RowLabel({ row }: { row: RowEntry }): React.JSX.Element { ) } + +function sameSlug(left: RepoSlug, right: RepoSlug): boolean { + return ( + left.owner.toLowerCase() === right.owner.toLowerCase() && + left.repo.toLowerCase() === right.repo.toLowerCase() + ) +} + +async function getRepoSlugCached( + repo: RepoOption, + cache: Map +): Promise { + if (cache.has(repo.id)) { + return cache.get(repo.id) ?? null + } + if (repo.connectionId) { + cache.set(repo.id, null) + return null + } + try { + const slug = await window.api.gh.repoSlug({ repoPath: repo.path }) + cache.set(repo.id, slug) + return slug + } catch { + cache.set(repo.id, null) + return null + } +} + +async function findMatchingRepoForSlug( + repos: RepoOption[], + slug: RepoSlug, + cache: Map +): Promise { + for (const repo of repos) { + const candidate = await getRepoSlugCached(repo, cache) + if (candidate && sameSlug(candidate, slug)) { + return repo + } + } + return null +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index fd1108791cc..81893b5e151 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -36,6 +36,7 @@ import { type LinkedWorkItemSummary } from '@/lib/new-workspace' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' +import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths' @@ -70,6 +71,8 @@ export type ComposerCardProps = { onSmartGitHubItemSelect: (item: GitHubWorkItem) => void onSmartBranchSelect: (refName: string) => void onSmartLinearIssueSelect: (issue: LinearIssue) => void + smartNameSelection: SmartWorkspaceNameSelection | null + onClearSmartNameSelection: () => void agentPrompt: string onAgentPromptChange: (value: string) => void onPromptKeyDown: (event: React.KeyboardEvent) => void @@ -1048,12 +1051,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS (item: GitHubWorkItem): void => { applyLinkedWorkItem(item) setStartFromResetHint(null) - if (item.type !== 'pr' || !selectedRepo) { + const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo + if (item.type !== 'pr' || !repoForItem) { return } void window.api.worktrees .resolvePrBase({ - repoId: selectedRepo.id, + repoId: repoForItem.id, prNumber: item.number, ...(item.branchName ? { headRefName: item.branchName } : {}), ...(item.isCrossRepository !== undefined @@ -1067,7 +1071,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS handleBaseBranchPrSelect(result.baseBranch, item) }) }, - [applyLinkedWorkItem, handleBaseBranchPrSelect, selectedRepo] + [applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo] ) const handleSmartBranchSelect = useCallback( @@ -1117,6 +1121,44 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS [name] ) + const handleClearSmartNameSelection = useCallback((): void => { + setLinkedIssue('') + setLinkedPR(null) + setLinkedWorkItem(null) + setBaseBranch(undefined) + setStartFromResetHint(null) + if (name === lastAutoNameRef.current) { + setName('') + lastAutoNameRef.current = '' + } + if (noteRef.current === lastAutoNoteRef.current) { + setNote('') + lastAutoNoteRef.current = '' + } + }, [name]) + + const smartNameSelection = useMemo(() => { + if (linkedWorkItem) { + const isLinear = linkedWorkItem.number === 0 && !linkedWorkItem.url.includes('github.com') + return { + kind: isLinear ? 'linear' : 'github', + label: + isLinear || linkedWorkItem.number === 0 + ? linkedWorkItem.title + : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, + description: isLinear + ? 'Linear' + : linkedWorkItem.type === 'pr' + ? 'GitHub PR' + : 'GitHub issue' + } + } + if (baseBranch) { + return { kind: 'branch', label: baseBranch, description: 'Branch' } + } + return null + }, [baseBranch, linkedWorkItem]) + const handleOpenAgentSettings = useCallback((): void => { openSettingsTarget({ pane: 'agents', repoId: null }) openSettingsPage() @@ -1418,6 +1460,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS onSmartGitHubItemSelect: handleSmartGitHubItemSelect, onSmartBranchSelect: handleSmartBranchSelect, onSmartLinearIssueSelect: handleSmartLinearIssueSelect, + smartNameSelection, + onClearSmartNameSelection: handleClearSmartNameSelection, agentPrompt, onAgentPromptChange: setAgentPrompt, onPromptKeyDown: handlePromptKeyDown, diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index e6db2b488f8..d8701171827 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -52,6 +52,7 @@ export function parseGitHubIssueOrPRNumber(input: string): number | null { export function parseGitHubIssueOrPRLink(input: string): { slug: RepoSlug number: number + type: 'issue' | 'pr' } | null { const trimmed = input.trim() if (!trimmed) { @@ -76,6 +77,7 @@ export function parseGitHubIssueOrPRLink(input: string): { return { slug: { owner: match[1], repo: match[2] }, + type: url.pathname.toLowerCase().includes('/pull/') ? 'pr' : 'issue', number: Number.parseInt(match[3], 10) } }