mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
WIP: selected source pill in smart workspace name field
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -829,6 +829,25 @@ export async function getWorkItem(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkItemByOwnerRepo(
|
||||
repoPath: string,
|
||||
ownerRepo: OwnerRepo,
|
||||
number: number,
|
||||
type: 'issue' | 'pr'
|
||||
): Promise<MainWorkItem | null> {
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -460,6 +460,13 @@ export type PreloadApi = {
|
||||
number: number
|
||||
type?: 'issue' | 'pr'
|
||||
}) => Promise<Omit<GitHubWorkItem, 'repoId'> | null>
|
||||
workItemByOwnerRepo: (args: {
|
||||
repoPath: string
|
||||
owner: string
|
||||
repo: string
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
}) => Promise<Omit<GitHubWorkItem, 'repoId'> | null>
|
||||
workItemDetails: (args: {
|
||||
repoPath: string
|
||||
number: number
|
||||
|
||||
@@ -439,6 +439,14 @@ const api = {
|
||||
type?: 'issue' | 'pr'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gh:workItem', args),
|
||||
|
||||
workItemByOwnerRepo: (args: {
|
||||
repoPath: string
|
||||
owner: string
|
||||
repo: string
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gh:workItemByOwnerRepo', args),
|
||||
|
||||
workItemDetails: (args: {
|
||||
repoPath: string
|
||||
number: number
|
||||
|
||||
@@ -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<TuiAgent> | 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({
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Workspace Name <span className="text-muted-foreground/70">[Optional]</span>
|
||||
Name or 'Create From'{' '}
|
||||
<span className="text-muted-foreground/70">[Optional]</span>
|
||||
</label>
|
||||
<SmartWorkspaceNameField
|
||||
inputRef={nameInputRef}
|
||||
repos={eligibleRepos}
|
||||
repoId={repoId}
|
||||
onRepoChange={onRepoChange}
|
||||
value={name}
|
||||
onValueChange={onNameValueChange}
|
||||
onGitHubItemSelect={onSmartGitHubItemSelect}
|
||||
onBranchSelect={onSmartBranchSelect}
|
||||
onLinearIssueSelect={onSmartLinearIssueSelect}
|
||||
selectedSource={smartNameSelection}
|
||||
onClearSelectedSource={onClearSmartNameSelection}
|
||||
onPlainEnter={() => {
|
||||
// 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({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleAdvanced}
|
||||
className="-ml-2 text-xs"
|
||||
>
|
||||
Advanced
|
||||
<ChevronDown
|
||||
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
|
||||
@@ -501,21 +526,6 @@ export default function NewWorkspaceComposerCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleAdvanced}
|
||||
className="-ml-2 text-xs"
|
||||
>
|
||||
Advanced
|
||||
<ChevronDown
|
||||
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => void onCreate()}
|
||||
|
||||
@@ -10,15 +10,29 @@ import {
|
||||
Github,
|
||||
LoaderCircle,
|
||||
Search,
|
||||
Sparkles
|
||||
Sparkles,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useAppStore } from '@/store'
|
||||
import { normalizeGitHubLinkQuery } from '@/lib/github-links'
|
||||
import {
|
||||
normalizeGitHubLinkQuery,
|
||||
parseGitHubIssueOrPRLink,
|
||||
type RepoSlug
|
||||
} from '@/lib/github-links'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types'
|
||||
|
||||
@@ -29,15 +43,24 @@ type RepoOption = ReturnType<typeof useAppStore.getState>['repos'][number]
|
||||
type SmartWorkspaceNameFieldProps = {
|
||||
repos: RepoOption[]
|
||||
repoId: string
|
||||
onRepoChange: (repoId: string) => void
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
onGitHubItemSelect: (item: GitHubWorkItem) => void
|
||||
onBranchSelect: (refName: string) => void
|
||||
onLinearIssueSelect: (issue: LinearIssue) => void
|
||||
selectedSource: SmartWorkspaceNameSelection | null
|
||||
onClearSelectedSource: () => void
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
onPlainEnter?: () => void
|
||||
}
|
||||
|
||||
export type SmartWorkspaceNameSelection = {
|
||||
kind: 'github' | 'branch' | 'linear'
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 200
|
||||
const RESULT_LIMIT = 12
|
||||
|
||||
@@ -48,7 +71,7 @@ const MODES: {
|
||||
}[] = [
|
||||
{ id: 'smart', label: 'Smart', Icon: Sparkles },
|
||||
{ id: 'github', label: 'GitHub', Icon: Github },
|
||||
{ id: 'branches', label: 'Branches', Icon: GitBranch },
|
||||
{ id: 'branches', label: 'Branch', Icon: GitBranch },
|
||||
{
|
||||
id: 'linear',
|
||||
label: 'Linear',
|
||||
@@ -70,24 +93,34 @@ type RowEntry =
|
||||
export default function SmartWorkspaceNameField({
|
||||
repos,
|
||||
repoId,
|
||||
onRepoChange,
|
||||
value,
|
||||
onValueChange,
|
||||
onGitHubItemSelect,
|
||||
onBranchSelect,
|
||||
onLinearIssueSelect,
|
||||
selectedSource,
|
||||
onClearSelectedSource,
|
||||
inputRef,
|
||||
onPlainEnter
|
||||
}: SmartWorkspaceNameFieldProps): React.JSX.Element {
|
||||
const { fetchWorkItems, getCachedWorkItems, linearStatus, listLinearIssues, searchLinearIssues } =
|
||||
useAppStore(
|
||||
useShallow((s) => ({
|
||||
fetchWorkItems: s.fetchWorkItems,
|
||||
getCachedWorkItems: s.getCachedWorkItems,
|
||||
linearStatus: s.linearStatus,
|
||||
listLinearIssues: s.listLinearIssues,
|
||||
searchLinearIssues: s.searchLinearIssues
|
||||
}))
|
||||
)
|
||||
const {
|
||||
addRepo,
|
||||
fetchWorkItems,
|
||||
getCachedWorkItems,
|
||||
linearStatus,
|
||||
listLinearIssues,
|
||||
searchLinearIssues
|
||||
} = useAppStore(
|
||||
useShallow((s) => ({
|
||||
addRepo: s.addRepo,
|
||||
fetchWorkItems: s.fetchWorkItems,
|
||||
getCachedWorkItems: s.getCachedWorkItems,
|
||||
linearStatus: s.linearStatus,
|
||||
listLinearIssues: s.listLinearIssues,
|
||||
searchLinearIssues: s.searchLinearIssues
|
||||
}))
|
||||
)
|
||||
|
||||
const selectedRepo = useMemo(
|
||||
() => repos.find((repo) => repo.id === repoId) ?? null,
|
||||
@@ -104,6 +137,13 @@ export default function SmartWorkspaceNameField({
|
||||
const [linearLoading, setLinearLoading] = useState(false)
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
const localInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const tabsListRef = useRef<HTMLDivElement | null>(null)
|
||||
const repoSlugCacheRef = useRef<Map<string, RepoSlug | null>>(new Map())
|
||||
const handledCrossRepoUrlRef = useRef<string | null>(null)
|
||||
const [crossRepoPrompt, setCrossRepoPrompt] = useState<{
|
||||
link: NonNullable<ReturnType<typeof parseGitHubIssueOrPRLink>>
|
||||
matchingRepo: RepoOption | null
|
||||
} | null>(null)
|
||||
|
||||
const setInputNode = useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
@@ -120,10 +160,17 @@ export default function SmartWorkspaceNameField({
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSource) {
|
||||
setOpen(false)
|
||||
}
|
||||
}, [selectedSource])
|
||||
|
||||
const normalizedGhQuery = useMemo(
|
||||
() => normalizeGitHubLinkQuery(debouncedQuery),
|
||||
[debouncedQuery]
|
||||
)
|
||||
const parsedGhLink = useMemo(() => parseGitHubIssueOrPRLink(debouncedQuery), [debouncedQuery])
|
||||
const shouldQueryGithub = mode === 'smart' || mode === 'github'
|
||||
const shouldQueryBranches = mode === 'smart' || mode === 'branches'
|
||||
const shouldQueryLinear = mode === 'smart' || mode === 'linear'
|
||||
@@ -136,10 +183,61 @@ export default function SmartWorkspaceNameField({
|
||||
}
|
||||
let stale = false
|
||||
const directNumber = normalizedGhQuery.directNumber
|
||||
const directLink = parsedGhLink
|
||||
if (directLink !== null && handledCrossRepoUrlRef.current !== debouncedQuery.trim()) {
|
||||
setGithubLoading(true)
|
||||
void getRepoSlugCached(selectedRepo, repoSlugCacheRef.current)
|
||||
.then(async (selectedSlug) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
if (!selectedSlug || sameSlug(selectedSlug, directLink.slug)) {
|
||||
handledCrossRepoUrlRef.current = debouncedQuery.trim()
|
||||
const item = await window.api.gh.workItemByOwnerRepo({
|
||||
repoPath: selectedRepo.path,
|
||||
owner: directLink.slug.owner,
|
||||
repo: directLink.slug.repo,
|
||||
number: directLink.number,
|
||||
type: directLink.type
|
||||
})
|
||||
if (!stale) {
|
||||
setGithubItems(item ? [{ ...item, repoId: selectedRepo.id } as GitHubWorkItem] : [])
|
||||
}
|
||||
return
|
||||
}
|
||||
const matchingRepo = await findMatchingRepoForSlug(
|
||||
repos,
|
||||
directLink.slug,
|
||||
repoSlugCacheRef.current
|
||||
)
|
||||
if (!stale) {
|
||||
setGithubItems([])
|
||||
setOpen(false)
|
||||
setCrossRepoPrompt({ link: directLink, matchingRepo })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stale) {
|
||||
setGithubLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}
|
||||
if (directNumber !== null) {
|
||||
setGithubLoading(true)
|
||||
void window.api.gh
|
||||
.workItem({ repoPath: selectedRepo.path, number: directNumber })
|
||||
const request =
|
||||
directLink !== null
|
||||
? window.api.gh.workItemByOwnerRepo({
|
||||
repoPath: selectedRepo.path,
|
||||
owner: directLink.slug.owner,
|
||||
repo: directLink.slug.repo,
|
||||
number: directLink.number,
|
||||
type: directLink.type
|
||||
})
|
||||
: window.api.gh.workItem({ repoPath: selectedRepo.path, number: directNumber })
|
||||
void request
|
||||
.then((item) => {
|
||||
if (!stale) {
|
||||
setGithubItems(item ? [{ ...item, repoId: selectedRepo.id } as GitHubWorkItem] : [])
|
||||
@@ -188,7 +286,16 @@ export default function SmartWorkspaceNameField({
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [fetchWorkItems, getCachedWorkItems, normalizedGhQuery, selectedRepo, shouldQueryGithub])
|
||||
}, [
|
||||
debouncedQuery,
|
||||
fetchWorkItems,
|
||||
getCachedWorkItems,
|
||||
normalizedGhQuery,
|
||||
parsedGhLink,
|
||||
repos,
|
||||
selectedRepo,
|
||||
shouldQueryGithub
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldQueryBranches || !selectedRepo) {
|
||||
@@ -321,11 +428,67 @@ export default function SmartWorkspaceNameField({
|
||||
onLinearIssueSelect(row.issue)
|
||||
}
|
||||
setOpen(false)
|
||||
requestAnimationFrame(() => localInputRef.current?.focus({ preventScroll: true }))
|
||||
},
|
||||
[onBranchSelect, onGitHubItemSelect, onLinearIssueSelect, onValueChange]
|
||||
)
|
||||
|
||||
const acceptGitHubLink = useCallback(
|
||||
async (targetRepo: RepoOption): Promise<void> => {
|
||||
if (!crossRepoPrompt) {
|
||||
return
|
||||
}
|
||||
handledCrossRepoUrlRef.current = debouncedQuery.trim()
|
||||
setGithubLoading(true)
|
||||
try {
|
||||
const item = await window.api.gh.workItemByOwnerRepo({
|
||||
repoPath: targetRepo.path,
|
||||
owner: crossRepoPrompt.link.slug.owner,
|
||||
repo: crossRepoPrompt.link.slug.repo,
|
||||
number: crossRepoPrompt.link.number,
|
||||
type: crossRepoPrompt.link.type
|
||||
})
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
onRepoChange(targetRepo.id)
|
||||
onGitHubItemSelect({ ...item, repoId: targetRepo.id } as GitHubWorkItem)
|
||||
setOpen(false)
|
||||
setCrossRepoPrompt(null)
|
||||
} finally {
|
||||
setGithubLoading(false)
|
||||
}
|
||||
},
|
||||
[crossRepoPrompt, debouncedQuery, onGitHubItemSelect, onRepoChange]
|
||||
)
|
||||
|
||||
const handleUseCurrentRepo = useCallback(async (): Promise<void> => {
|
||||
if (!selectedRepo) {
|
||||
return
|
||||
}
|
||||
setCrossRepoPrompt(null)
|
||||
await acceptGitHubLink(selectedRepo)
|
||||
}, [acceptGitHubLink, selectedRepo])
|
||||
|
||||
const handleAddMatchingRepo = useCallback(async (): Promise<void> => {
|
||||
if (!crossRepoPrompt) {
|
||||
return
|
||||
}
|
||||
const added = await addRepo()
|
||||
if (!added) {
|
||||
return
|
||||
}
|
||||
repoSlugCacheRef.current.delete(added.id)
|
||||
const slug = await getRepoSlugCached(added, repoSlugCacheRef.current)
|
||||
if (slug && sameSlug(slug, crossRepoPrompt.link.slug)) {
|
||||
await acceptGitHubLink(added)
|
||||
}
|
||||
}, [acceptGitHubLink, addRepo, crossRepoPrompt])
|
||||
|
||||
const dismissCrossRepoPrompt = useCallback((): void => {
|
||||
handledCrossRepoUrlRef.current = debouncedQuery.trim()
|
||||
setCrossRepoPrompt(null)
|
||||
}, [debouncedQuery])
|
||||
|
||||
const placeholder =
|
||||
mode === 'smart'
|
||||
? 'Type a name, #1234, branch, GitHub or Linear URL'
|
||||
@@ -349,13 +512,20 @@ export default function SmartWorkspaceNameField({
|
||||
className="gap-0"
|
||||
>
|
||||
<TabsList
|
||||
ref={tabsListRef}
|
||||
variant="line"
|
||||
className="h-7 w-full justify-start gap-4 border-b border-border/40 px-0"
|
||||
>
|
||||
{MODES.map(({ id, label, Icon }) => (
|
||||
<TabsTrigger key={id} value={id} className="flex-none gap-1.5 px-0 text-xs">
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
tabIndex={-1}
|
||||
data-smart-name-mode={id}
|
||||
className="flex-none gap-1.5 px-0 text-xs"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span className={id === 'text' ? 'sr-only' : undefined}>{label}</span>
|
||||
<span>{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
@@ -370,51 +540,87 @@ export default function SmartWorkspaceNameField({
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="relative">
|
||||
<ActiveInputIcon
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground',
|
||||
loading && mode !== 'text' && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
ref={setInputNode}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
onValueChange(event.target.value)
|
||||
if (mode !== 'text') {
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (mode !== 'text') {
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
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)
|
||||
{selectedSource ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 text-sm shadow-xs focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50">
|
||||
<SelectionIcon kind={selectedSource.kind} />
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
<span className="font-medium text-foreground">{selectedSource.label}</span>
|
||||
{selectedSource.description ? (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{selectedSource.description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onClearSelectedSource}
|
||||
className="size-6 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear selected source"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ActiveInputIcon
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground',
|
||||
loading && mode !== 'text' && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
ref={setInputNode}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
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<HTMLElement>(
|
||||
`[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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
@@ -423,6 +629,27 @@ export default function SmartWorkspaceNameField({
|
||||
className="popover-scroll-content flex w-[var(--radix-popover-trigger-width)] flex-col p-0"
|
||||
style={{ maxHeight: 'min(var(--radix-popover-content-available-height,22rem),22rem)' }}
|
||||
onOpenAutoFocus={(event) => 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()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CommandList className="!max-h-none min-h-0 flex-1 scrollbar-sleek">
|
||||
{loading && rows.length === 0 ? (
|
||||
@@ -456,6 +683,35 @@ export default function SmartWorkspaceNameField({
|
||||
</PopoverContent>
|
||||
</Command>
|
||||
</Popover>
|
||||
<Dialog
|
||||
open={crossRepoPrompt !== null}
|
||||
onOpenChange={(next) => !next && dismissCrossRepoPrompt()}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Switch project?</DialogTitle>
|
||||
<DialogDescription>
|
||||
The GitHub URL points to {crossRepoPrompt?.link.slug.owner}/
|
||||
{crossRepoPrompt?.link.slug.repo}, which is different from the selected project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={dismissCrossRepoPrompt}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void handleUseCurrentRepo()}>
|
||||
Keep {selectedRepo?.displayName ?? 'current project'}
|
||||
</Button>
|
||||
{crossRepoPrompt?.matchingRepo ? (
|
||||
<Button onClick={() => void acceptGitHubLink(crossRepoPrompt.matchingRepo!)}>
|
||||
Switch to {crossRepoPrompt.matchingRepo.displayName}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => void handleAddMatchingRepo()}>Add project...</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
if (kind === 'branch') {
|
||||
return <GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
return (
|
||||
<span className="size-3.5 shrink-0 rounded-sm bg-muted text-center text-[8px] font-semibold leading-3.5 text-muted-foreground">
|
||||
L
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, RepoSlug | null>
|
||||
): Promise<RepoSlug | null> {
|
||||
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<string, RepoSlug | null>
|
||||
): Promise<RepoOption | null> {
|
||||
for (const repo of repos) {
|
||||
const candidate = await getRepoSlugCached(repo, cache)
|
||||
if (candidate && sameSlug(candidate, slug)) {
|
||||
return repo
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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<HTMLTextAreaElement>) => 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<SmartWorkspaceNameSelection | null>(() => {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user