WIP: selected source pill in smart workspace name field

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-05-04 16:26:14 -07:00
co-authored by Orca
parent 574e4069e9
commit 15cdd7e29f
8 changed files with 505 additions and 83 deletions
+19
View File
@@ -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.
+20
View File
@@ -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)
)
+7
View File
@@ -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
+8
View File
@@ -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 &apos;Create From&apos;{' '}
<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
}
+47 -3
View File
@@ -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,
+2
View File
@@ -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)
}
}