From 574e4069e9615d6429ac55793ce4be4b90c8f65a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 4 May 2026 13:38:14 -0700 Subject: [PATCH] Unify new workspace source selection --- src/main/browser/browser-guest-ui.ts | 2 +- src/main/window/createMainWindow.ts | 4 +- src/preload/api-types.ts | 2 +- src/preload/index.ts | 9 +- .../components/NewWorkspaceComposerCard.tsx | 33 +- .../components/NewWorkspaceComposerModal.tsx | 242 +-- .../new-workspace/AnimatedTabPanels.tsx | 162 -- .../new-workspace/CreateFromTab.tsx | 1334 ----------------- .../new-workspace/SmartWorkspaceNameField.tsx | 508 +++++++ src/renderer/src/hooks/useComposerState.ts | 118 +- src/renderer/src/hooks/useIpcEvents.ts | 9 +- .../src/lib/launch-work-item-direct.ts | 2 +- src/renderer/src/store/slices/ui.ts | 32 +- src/shared/window-shortcut-policy.test.ts | 15 +- src/shared/window-shortcut-policy.ts | 9 +- 15 files changed, 664 insertions(+), 1817 deletions(-) delete mode 100644 src/renderer/src/components/new-workspace/AnimatedTabPanels.tsx delete mode 100644 src/renderer/src/components/new-workspace/CreateFromTab.tsx create mode 100644 src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index a10dfc4a340..4d700a3a8ef 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -302,7 +302,7 @@ export function setupGuestShortcutForwarding(args: { } else if (action?.type === 'openQuickOpen') { renderer.send('ui:openQuickOpen') } else if (action?.type === 'openNewWorkspace') { - renderer.send('ui:openNewWorkspace', action.tab) + renderer.send('ui:openNewWorkspace') } else if (action?.type === 'jumpToWorktreeIndex') { renderer.send('ui:jumpToWorktreeIndex', action.index) } else { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index a47d7d0886a..ec92d3a93ab 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -514,9 +514,7 @@ export function createMainWindow( // Why: routed through the main process so focus contexts that bypass // the renderer's window-level keydown (contentEditable markdown editor, // browser-guest webContents) still reach the new-workspace composer. - // Forward the target tab so Cmd/Ctrl+Shift+N lands on the - // "Create from…" tab instead of the default quick-create form. - mainWindow.webContents.send('ui:openNewWorkspace', action.tab) + mainWindow.webContents.send('ui:openNewWorkspace') return } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index aad6e227c3d..9c0c7e2cbc5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -839,7 +839,7 @@ export type PreloadApi = { onToggleRightSidebar: (callback: () => void) => () => void onToggleWorktreePalette: (callback: () => void) => () => void onOpenQuickOpen: (callback: () => void) => () => void - onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void) => () => void + onOpenNewWorkspace: (callback: () => void) => () => void onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void onNewBrowserTab: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 162f16293c6..12973ebacb5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1344,13 +1344,8 @@ const api = { ipcRenderer.on('ui:openQuickOpen', listener) return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) }, - onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, tab: 'quick' | 'create-from') => { - // Why: older main-process builds may send this event without a payload - // — default to 'quick' so the preload contract stays forward-compatible - // during a partial rollout where only one side has shipped the tab arg. - callback(tab ?? 'quick') - } + onOpenNewWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openNewWorkspace', listener) return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) }, diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 78230e9d879..2694cd01af3 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -11,15 +11,15 @@ import { Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' -import type { SparsePreset, TuiAgent } from '../../../shared/types' +import type { GitHubWorkItem, LinearIssue, SparsePreset, TuiAgent } from '../../../shared/types' import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect' +import SmartWorkspaceNameField from '@/components/new-workspace/SmartWorkspaceNameField' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -35,7 +35,10 @@ type NewWorkspaceComposerCardProps = { repoId: string onRepoChange: (value: string) => void name: string - onNameChange: (event: React.ChangeEvent) => void + onNameValueChange: (value: string) => void + onSmartGitHubItemSelect: (item: GitHubWorkItem) => void + onSmartBranchSelect: (refName: string) => void + onSmartLinearIssueSelect: (issue: LinearIssue) => void detectedAgentIds: Set | null onOpenAgentSettings: () => void advancedOpen: boolean @@ -175,7 +178,10 @@ export default function NewWorkspaceComposerCard({ repoId, onRepoChange, name, - onNameChange, + onNameValueChange, + onSmartGitHubItemSelect, + onSmartBranchSelect, + onSmartLinearIssueSelect, detectedAgentIds, onOpenAgentSettings, advancedOpen, @@ -288,26 +294,25 @@ export default function NewWorkspaceComposerCard({ - { + onValueChange={onNameValueChange} + onGitHubItemSelect={onSmartGitHubItemSelect} + onBranchSelect={onSmartBranchSelect} + onLinearIssueSelect={onSmartLinearIssueSelect} + onPlainEnter={() => { // Why: Enter on the workspace name advances focus to the next // field (Agent combobox) rather than submitting, letting the user // progress through the form with just the keyboard. - if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey) { - return - } - event.preventDefault() const root = composerRef?.current const agentTrigger = root?.querySelector( '[data-agent-combobox-root="true"][role="combobox"]' ) agentTrigger?.focus() }} - placeholder="Workspace name" - className="h-9 text-sm" /> diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index ee031a0ff22..0e0687736e3 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -1,11 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard' import AgentSettingsDialog from '@/components/agent/AgentSettingsDialog' -import CreateFromTab from '@/components/new-workspace/CreateFromTab' -import AnimatedTabPanels from '@/components/new-workspace/AnimatedTabPanels' import { useComposerState } from '@/hooks/useComposerState' import { AGENT_CATALOG } from '@/lib/agent-catalog' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' @@ -17,55 +14,6 @@ type ComposerModalData = { initialRepoId?: string linkedWorkItem?: LinkedWorkItemSummary | null initialBaseBranch?: string - initialTab?: 'quick' | 'create-from' -} - -const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') -const tabShortcut = { - quick: isMac ? '⌘N' : 'Ctrl+N', - 'create-from': isMac ? '⌘⇧N' : 'Ctrl+Shift+N' -} as const - -function ShortcutHint({ children }: { children: React.ReactNode }): React.JSX.Element { - // Why: a flat muted string reads as "secondary hint" rather than the - // bordered kbd chip, which drew too much attention for a label most users - // will learn once and forget. Stays inside the tab trigger so the hit - // target covers it too. - return ( - - {children} - - ) -} - -function isRestorablePanelFocusTarget(panel: HTMLElement, target: HTMLElement | null): boolean { - return Boolean( - target && - target.isConnected && - panel.contains(target) && - !target.closest('[aria-hidden="true"]') && - !target.hasAttribute('disabled') && - target.getAttribute('aria-disabled') !== 'true' - ) -} - -function getComposerPanelFocusTarget( - panel: HTMLElement, - remembered: HTMLElement | null -): HTMLElement | null { - if (isRestorablePanelFocusTarget(panel, remembered)) { - return remembered - } - for (const selector of [ - '[data-repo-combobox-root="true"][role="combobox"]', - '[data-create-from-search-input="true"]' - ]) { - const target = panel.querySelector(selector) - if (isRestorablePanelFocusTarget(panel, target)) { - return target - } - } - return null } export default function NewWorkspaceComposerModal(): React.JSX.Element | null { @@ -107,194 +55,28 @@ function ComposerModalBody({ onClose: () => void onOpenChange: (open: boolean) => void }): React.JSX.Element { - const activeTab = useAppStore((s) => s.newWorkspaceComposerTab) - const setActiveTab = useAppStore((s) => s.setNewWorkspaceComposerTab) - - // Why: when the user starts something on Create-from that needs to fall - // back to Quick (setup policy = 'ask', PR head resolution failed, ...) we - // feed the prefill through this local override and remount the Quick - // composer via a bumped key so its initial state absorbs the new data. - // Without the key bump the useComposerState hook would keep its first - // snapshot and the Quick tab would appear empty after fallback. - const [prefillOverride, setPrefillOverride] = useState(null) - const [quickKey, setQuickKey] = useState(0) - - const effectiveQuickData = prefillOverride ?? modalData - - const handleFallbackToQuick = useCallback( - (data: { - initialRepoId?: string - linkedWorkItem?: LinkedWorkItemSummary | null - prefilledName?: string - initialBaseBranch?: string - }) => { - setPrefillOverride({ ...data }) - setQuickKey((k) => k + 1) - setActiveTab('quick') - }, - [setActiveTab] - ) - - const handleCreateFromLaunched = useCallback(() => { - onClose() - }, [onClose]) - - // Why: the composer preserves both tab panels across swaps, so users expect - // their focus context to survive the round-trip too — switching Quick → Create-from - // → Quick should land them back on the Quick field they were last on, not blow - // focus back to the top of the form. Track the most recent focused element - // inside each panel and restore it on tab activation, falling back to the - // panel's RepoCombobox trigger when nothing is remembered yet. - const lastFocusedByTabRef = useRef>({ - quick: null, - 'create-from': null - }) - - useEffect(() => { - const handler = (event: FocusEvent): void => { - const target = event.target - if (!(target instanceof HTMLElement)) { - return - } - // Why: Radix popovers (RepoCombobox list, search results) portal outside - // the panels, so their focus events resolve to `closest()` → null and - // we don't pollute the per-panel memory with transient popover focus. - const panel = target.closest('[data-composer-panel]') - if (!panel) { - return - } - const tab = panel.dataset.composerPanel - if (tab === 'quick' || tab === 'create-from') { - lastFocusedByTabRef.current[tab] = target - } - } - document.addEventListener('focusin', handler, true) - return () => document.removeEventListener('focusin', handler, true) - }, []) - - const prevActiveTabRef = useRef(null) - useEffect(() => { - const prev = prevActiveTabRef.current - prevActiveTabRef.current = activeTab - // Why: skip the initial mount — the dialog's onOpenAutoFocus below handles - // that case. This effect only runs for user-driven tab swaps. - if (prev === null || prev === activeTab) { - return - } - // Why: when the panel class flips from visible → invisible, Chrome blurs - // whatever element was focused inside the leaving panel. Radix Dialog's - // FocusScope notices the blur and synchronously focuses the first tabbable - // inside the dialog (a tab trigger) on a microtask. A plain synchronous - // focus in this effect lands first, then FocusScope clobbers it. - // setTimeout(..., 0) schedules our focus as a macrotask, which runs after - // FocusScope's microtask restoration settles. - let frame = 0 - const restoreFocus = (): void => { - const panel = document.querySelector(`[data-composer-panel="${activeTab}"]`) - if (!panel) { - return - } - // Why: the Quick panel is remounted via `quickKey` after a Create-from - // fallback, and Create-from can hide parts of itself while preserving - // DOM. Validate the remembered node before restoring it. - const target = getComposerPanelFocusTarget(panel, lastFocusedByTabRef.current[activeTab]) - target?.focus({ preventScroll: true }) - } - const timer = window.setTimeout(() => { - restoreFocus() - // Why: Radix popover close-auto-focus and tab panel visibility changes - // can run in the same turn as the tab swap. A frame-late restore makes - // the active panel win without remounting either tab. - frame = window.requestAnimationFrame(restoreFocus) - }, 0) - return () => { - window.clearTimeout(timer) - if (frame !== 0) { - window.cancelAnimationFrame(frame) - } - } - }, [activeTab]) - return ( { // Why: Radix's FocusScope fires this once the dialog has mounted. // preventDefault stops it from focusing whatever first-tabbable it - // picks (tab trigger, close button), and we instead focus the - // RepoCombobox inside the active panel so both Quick and Create-from - // open with the same top-of-form anchor. + // picks (close button), and we instead focus the repo picker so the + // keyboard flow starts at the top of the unified create form. event.preventDefault() const content = event.currentTarget as HTMLElement - const panel = content.querySelector(`[data-composer-panel="${activeTab}"]`) - getComposerPanelFocusTarget( - panel ?? content, - lastFocusedByTabRef.current[activeTab] - )?.focus({ preventScroll: true }) + const trigger = content.querySelector( + '[data-repo-combobox-root="true"][role="combobox"]' + ) + trigger?.focus({ preventScroll: true }) }} > - setActiveTab(next as 'quick' | 'create-from')} - // Why: both panels are force-mounted so switching tabs preserves - // their local state (typed query on Create-from, repo pick / - // workspace name on Quick) instead of remounting each time. - // Height is driven by the active panel's intrinsic size — the - // DialogContent handles overflow if the viewport is too short. - className="flex flex-col gap-0" - > - {/* Why: use the shared underline variant so both levels of tabs - read as "tabs" — the default pill variant fought the sub-tabs - inside Create-from for visual weight. The bottom border on the - list gives it clear separation from the content below. */} - {/* Why: DialogContent has p-6 (24px top) and the close button sits - absolutely at top-4 (16px), so its 16px icon centers around 24px - from the top. Pull the h-8 tab list up with -mt-4 so its center - (8 + 16 = 24px) lines up with the X on the same row. Reserve - right padding so the last trigger doesn't slide under the X. */} - - - Create - {tabShortcut.quick} - - - Create from… - {tabShortcut['create-from']} - - + + Create Workspace + - - Create Workspace - - - - {{ - quick: ( - - ), - 'create-from': ( - - ) - }} - - + ) @@ -428,5 +210,3 @@ function QuickTabBody({ ) } - -type TabKey = 'quick' | 'create-from' diff --git a/src/renderer/src/components/new-workspace/AnimatedTabPanels.tsx b/src/renderer/src/components/new-workspace/AnimatedTabPanels.tsx deleted file mode 100644 index 298156e8a54..00000000000 --- a/src/renderer/src/components/new-workspace/AnimatedTabPanels.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { cn } from '@/lib/utils' - -type TabKey = 'quick' | 'create-from' - -/** - * Keeps both tab panels mounted so their local state (typed query on - * Create-from, repo pick / workspace name on Quick) survives a tab swap. - * Only the active panel is in normal flow; the inactive panel is - * absolutely positioned + `visibility: hidden` + `pointer-events-none` - * so it - * (a) doesn't contribute to DialogContent's scrollHeight (otherwise the - * host dialog grows a scrollbar whenever the inactive panel is - * taller than the active one), and - * (b) keeps its React subtree mounted (no remount, so input focus, - * typed query, and selection survive). - * - * The wrapper's height is JS-driven: a ResizeObserver on the active - * panel's inner node keeps wrapper height in sync with content so the - * wrapper never clips. On tab change we capture the pre-swap wrapper - * height and transition to the new active panel's measured height — a - * FLIP-style animation so the modal resizes smoothly rather than - * snapping. - */ -export default function AnimatedTabPanels({ - active, - children -}: { - active: TabKey - children: Record -}): React.JSX.Element { - const wrapperRef = useRef(null) - const quickRef = useRef(null) - const createFromRef = useRef(null) - const previousActiveRef = useRef(active) - const [wrapperHeight, setWrapperHeight] = useState(null) - const [isAnimating, setIsAnimating] = useState(false) - - // Why: track the active panel's intrinsic height via ResizeObserver so - // the wrapper follows content (Advanced drawer open/close, async search - // results landing). When active changes, the FLIP effect below runs - // first (capturing the outgoing height), then this observer swings the - // wrapper height toward the new active panel's height via the CSS - // transition on `height`. - // - // Why offsetHeight and not getBoundingClientRect().height: Radix's - // dialog open animation (`data-[state=open]:zoom-in-95`) scales the - // dialog from 0.95 → 1.0 over 200ms. getBoundingClientRect reports the - // *visual* (transformed) height, so the very first measurement lands at - // ~95 % of the real layout size and pins that into the inline height. - // The ResizeObserver never refires because actual layout size didn't - // change — only the transform did — so the wrapper stays permanently - // ~16 px short and clips the Create Workspace button at the bottom. - // offsetHeight returns the un-transformed layout box, which is what the - // wrapper should match. - useLayoutEffect(() => { - const target = active === 'quick' ? quickRef.current : createFromRef.current - if (!target) { - return - } - const update = (): void => { - const next = target.offsetHeight - if (next > 0) { - setWrapperHeight(next) - } - } - update() - const observer = new ResizeObserver(update) - observer.observe(target) - return () => observer.disconnect() - }, [active]) - - // Why: on tab swap, override the observer-driven height for one frame - // so the transition starts from the outgoing panel's size. Without this - // the wrapper would snap to the new panel's height before the ResizeObserver - // could read it. - useLayoutEffect(() => { - const prev = previousActiveRef.current - previousActiveRef.current = active - if (prev === active) { - return - } - const wrapper = wrapperRef.current - if (!wrapper) { - return - } - // Why: offsetHeight, not getBoundingClientRect — see comment on the - // observer effect above. Same transform-scale trap applies to this - // FLIP capture if a tab swap happens while any ancestor transform is - // still animating. - const from = wrapper.offsetHeight - if (from > 0) { - setWrapperHeight(from) - setIsAnimating(true) - } - }, [active]) - - useEffect(() => { - const wrapper = wrapperRef.current - if (!wrapper || !isAnimating) { - return - } - const onEnd = (event: TransitionEvent): void => { - if (event.propertyName !== 'height') { - return - } - setIsAnimating(false) - } - wrapper.addEventListener('transitionend', onEnd) - return () => wrapper.removeEventListener('transitionend', onEnd) - }, [isAnimating]) - - return ( -
-
- {children.quick} -
-
- {children['create-from']} -
-
- ) -} diff --git a/src/renderer/src/components/new-workspace/CreateFromTab.tsx b/src/renderer/src/components/new-workspace/CreateFromTab.tsx deleted file mode 100644 index 49f6dd8455f..00000000000 --- a/src/renderer/src/components/new-workspace/CreateFromTab.tsx +++ /dev/null @@ -1,1334 +0,0 @@ -/* eslint-disable max-lines -- Why: this tab co-locates GitHub PR/issue/branch -and Linear sub-tabs + their fetch/resolve/launch plumbing so the "Create from…" -entry point lives in one file. Splitting would scatter debounce/caching logic -that only these sub-tabs use. */ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useShallow } from 'zustand/react/shallow' -import { - CircleDot, - CornerDownLeft, - GitBranch, - GitPullRequest, - LoaderCircle, - Search -} from 'lucide-react' -import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' -import { Input } from '@/components/ui/input' -import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' -import RepoCombobox from '@/components/repo/RepoCombobox' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import IssueSourceSelector from '@/components/github/IssueSourceSelector' -import { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' -import { useAppStore } from '@/store' -import { cn } from '@/lib/utils' -import { normalizeGitHubLinkQuery } from '@/lib/github-links' -import { launchWorkItemDirect, launchFromBranch } from '@/lib/launch-work-item-direct' -import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types' - -export type CreateFromSubTab = 'prs' | 'issues' | 'branches' | 'linear' - -type CreateFromTabProps = { - /** Invoked after a successful workspace launch so the parent can close the - * dialog. */ - onLaunched: () => void - /** Called when any code path needs to fall back to the Quick-tab composer - * (setup policy = 'ask', or the launch failed mid-flight). The parent is - * responsible for switching tabs and prefilling whatever context is - * available (linked work item, base branch, etc.). */ - onFallbackToQuick: (data: { - initialRepoId?: string - linkedWorkItem?: { - type: 'issue' | 'pr' - number: number - title: string - url: string - } | null - prefilledName?: string - initialBaseBranch?: string - }) => void - /** Whether this tab is currently visible. Used by effects that should only - * run while the user can actually see the results (fetching, autoFocus). */ - active?: boolean -} - -const SUB_TABS: { - id: CreateFromSubTab - label: string - Icon: React.ComponentType<{ className?: string }> -}[] = [ - { id: 'prs', label: 'Pull requests', Icon: GitPullRequest }, - { id: 'issues', label: 'Issues', Icon: CircleDot }, - { id: 'branches', label: 'Branches', Icon: GitBranch }, - { - id: 'linear', - label: 'Linear', - Icon: ({ className }: { className?: string }) => ( - - - - ) - } -] - -const PR_LIST_LIMIT = 36 -const ISSUE_LIST_LIMIT = 36 -const BRANCH_CACHE_TTL_MS = 60_000 -// Why: branches change rarely during a composer session. A module-scoped -// cache keyed on repoId + query (60s TTL) means switching sub-tabs or -// re-opening the modal returns the prior result instantly instead of -// re-shelling out to `git for-each-ref` (which can be slow on large repos). -const branchCache = new Map() -// Why: the unqualified empty-query path returns PRs + issues merged and -// sliced to `limit`. Use the sum so each tab can render up to its own cap -// after filtering by type client-side — and because both effects pass the -// same limit, they share one cached IPC call and one gh invocation. -const COMBINED_WORK_ITEM_LIMIT = PR_LIST_LIMIT + ISSUE_LIST_LIMIT -const LINEAR_LIST_LIMIT = 36 -const SEARCH_DEBOUNCE_MS = 200 - -export default function CreateFromTab({ - onLaunched, - onFallbackToQuick, - active = true -}: CreateFromTabProps): React.JSX.Element { - const { - activeRepoId, - repos, - linearStatus, - listLinearIssues, - searchLinearIssues, - rememberedSubTab, - setRememberedSubTab, - fetchWorkItems, - getCachedWorkItems, - getWorkItemsSourcesAndError, - setIssueSourcePreference, - workItemsInvalidationNonce - } = useAppStore( - useShallow((s) => ({ - activeRepoId: s.activeRepoId, - repos: s.repos, - linearStatus: s.linearStatus, - listLinearIssues: s.listLinearIssues, - searchLinearIssues: s.searchLinearIssues, - rememberedSubTab: s.createFromSubTab, - setRememberedSubTab: s.setCreateFromSubTab, - fetchWorkItems: s.fetchWorkItems, - getCachedWorkItems: s.getCachedWorkItems, - getWorkItemsSourcesAndError: s.getWorkItemsSourcesAndError, - setIssueSourcePreference: s.setIssueSourcePreference, - // Why: re-run the issues fetch effect after `setIssueSourcePreference` - // evicts this repo's cache entries — otherwise flipping the selector - // clears the cache but the effect's deps wouldn't force a refetch and - // the Issues sub-tab would briefly show stale (then empty-once-cached- - // expires) data until the user typed or switched tabs. - workItemsInvalidationNonce: s.workItemsInvalidationNonce - })) - ) - - const eligibleRepos = useMemo(() => repos.filter((r) => isGitRepoKind(r)), [repos]) - - // Why: seed from the remembered sub-tab so users returning to Create-from - // land on whichever source they worked from last (GitHub Issues, Linear, - // …). A writer-through setter keeps the store in sync whenever the user - // switches, which persists across composer closes within the same session. - const searchInputRef = useRef(null) - const subTabsListRef = useRef(null) - const [subTab, setSubTabLocal] = useState(rememberedSubTab) - // Why: results live in a popover anchored to the search input so the - // dialog stays compact while the list sizes to content and can show - // async-loading skeletons without reserving a 320px block inside the - // modal. The popover opens as soon as the input is focused so users - // see their options immediately — opening only on keystroke felt - // unresponsive given this flow starts by clicking the field. - const [resultsOpen, setResultsOpen] = useState(false) - // Why: the results popover auto-opens on focus, but clicking a sub-tab - // pulls focus away from the search input and Radix closes the popover - // on the pointer-down. Re-open on sub-tab change so the new sub-tab's - // results appear immediately rather than forcing the user to click the - // input again; also refocus the input so typing continues the flow. - const setSubTab = useCallback( - (next: CreateFromSubTab) => { - setSubTabLocal(next) - setRememberedSubTab(next) - setResultsOpen(true) - // Why: preemptively flip the new sub-tab's loading flag so the single - // render between sub-tab change and the fetch effect doesn't flash - // "No open PRs / No issues / …" while stale state from the previous - // sub-tab still claims nothing is loading. The fetch effect sets the - // flag to true again on mount, then to false when data resolves — so - // this just patches the one-frame gap. - if (next === 'prs') { - setPrLoading(true) - } else if (next === 'issues') { - setIssueLoading(true) - } else if (next === 'branches') { - setBranchesLoading(true) - } else if (next === 'linear') { - setLinearLoading(true) - } - requestAnimationFrame(() => { - searchInputRef.current?.focus({ preventScroll: true }) - }) - }, - [setRememberedSubTab] - ) - const [query, setQuery] = useState('') - const [debouncedQuery, setDebouncedQuery] = useState('') - const [selectedRepoId, setSelectedRepoId] = useState(() => { - if (activeRepoId && eligibleRepos.some((r) => r.id === activeRepoId)) { - return activeRepoId - } - return eligibleRepos[0]?.id ?? '' - }) - - useEffect(() => { - const timer = window.setTimeout(() => setDebouncedQuery(query), SEARCH_DEBOUNCE_MS) - return () => window.clearTimeout(timer) - }, [query]) - - // Why: the results popover is portaled outside this panel, so switching - // to the Quick tab (via click or the ⌘N hotkey) doesn't unmount or hide - // it — users would see the CreateFrom dropdown floating over the Quick - // tab. Force-close when this tab goes inactive. - useEffect(() => { - if (!active) { - setResultsOpen(false) - } - }, [active]) - - // Why: PopoverContent's default `onCloseAutoFocus` restores focus to the - // anchor (the search input) when the popover closes. When the parent - // switches tabs it closes the popover as a side effect, which would steal - // focus away from whatever target the parent's tab-switch focus logic just - // focused on the newly-active panel. Track active via a ref so the handler - // below can preventDefault the restore only in the tab-swap case without - // affecting normal close flows (Escape, row select) where returning focus - // to the input is correct. - const activeRef = useRef(active) - useLayoutEffect(() => { - activeRef.current = active - }, [active]) - - const selectedRepo = useMemo( - () => eligibleRepos.find((r) => r.id === selectedRepoId) ?? null, - [eligibleRepos, selectedRepoId] - ) - const isRemoteRepo = Boolean(selectedRepo?.connectionId) - - const normalizedGhQuery = useMemo( - () => normalizeGitHubLinkQuery(debouncedQuery), - [debouncedQuery] - ) - - // --------------------------------------------------------------------- - // GitHub PRs - // --------------------------------------------------------------------- - const [prItems, setPrItems] = useState([]) - const [prLoading, setPrLoading] = useState(false) - const [prError, setPrError] = useState(null) - const [directPr, setDirectPr] = useState(null) - - useEffect(() => { - if (subTab !== 'prs' || !selectedRepo?.path || isRemoteRepo) { - return - } - if (normalizedGhQuery.directNumber !== null) { - return // handled by direct-lookup effect below - } - const trimmed = debouncedQuery.trim() - // Why: when the user hasn't typed anything, use the unqualified listing — - // the backend routes that to `listRecentWorkItems` which hits - // `gh api --cache 120s` (fast, cached). Adding `is:pr is:open` forces the - // slow `gh pr list --search` path every time and skips the 60s renderer - // cache in the store. Same shortcut is used for the issues effect below. - const q = trimmed ? `is:pr is:open ${normalizedGhQuery.query}` : '' - // Why: empty-query path returns PRs+issues merged, so use the combined - // cap so the PR and Issue effects collapse onto the same cache key and - // dedupe into a single gh invocation via the store's inflight tracker. - const effectiveLimit = trimmed ? PR_LIST_LIMIT : COMBINED_WORK_ITEM_LIMIT - - // Why: route through the store so the 60s workItemsCache + inflight dedup - // kick in. Re-opening the modal or toggling PR↔Issue sub-tabs returns - // cached data instantly instead of re-running gh search. - const cached = getCachedWorkItems(selectedRepo.path, effectiveLimit, q) - if (cached) { - setPrItems(cached.filter((i) => i.type === 'pr').slice(0, PR_LIST_LIMIT)) - setPrLoading(false) - setPrError(null) - } - - let stale = false - if (!cached) { - setPrLoading(true) - } - setPrError(null) - void fetchWorkItems(selectedRepo.id, selectedRepo.path, effectiveLimit, q) - .then((items) => { - if (stale) { - return - } - setPrItems(items.filter((i) => i.type === 'pr').slice(0, PR_LIST_LIMIT)) - setPrLoading(false) - }) - .catch((err) => { - if (stale) { - return - } - setPrError(err instanceof Error ? err.message : 'Failed to load PRs.') - setPrLoading(false) - }) - return () => { - stale = true - } - }, [ - subTab, - selectedRepo?.id, - selectedRepo?.path, - isRemoteRepo, - debouncedQuery, - normalizedGhQuery.query, - normalizedGhQuery.directNumber, - fetchWorkItems, - getCachedWorkItems - ]) - - // --------------------------------------------------------------------- - // GitHub Issues - // --------------------------------------------------------------------- - const [issueItems, setIssueItems] = useState([]) - const [issueLoading, setIssueLoading] = useState(false) - const [issueError, setIssueError] = useState(null) - - useEffect(() => { - if (subTab !== 'issues' || !selectedRepo?.path || isRemoteRepo) { - return - } - if (normalizedGhQuery.directNumber !== null) { - return - } - const trimmed = debouncedQuery.trim() - // Why: empty query → backend's fast cached path (see PR effect above). - // When no query is typed this is the SAME IPC call as the PR effect, so - // the store's inflight dedup collapses them into a single gh invocation. - const q = trimmed ? `is:issue is:open ${normalizedGhQuery.query}` : '' - const effectiveLimit = trimmed ? ISSUE_LIST_LIMIT : COMBINED_WORK_ITEM_LIMIT - - const cached = getCachedWorkItems(selectedRepo.path, effectiveLimit, q) - if (cached) { - setIssueItems(cached.filter((i) => i.type === 'issue').slice(0, ISSUE_LIST_LIMIT)) - setIssueLoading(false) - setIssueError(null) - } - - let stale = false - if (!cached) { - setIssueLoading(true) - } - setIssueError(null) - void fetchWorkItems(selectedRepo.id, selectedRepo.path, effectiveLimit, q) - .then((items) => { - if (stale) { - return - } - setIssueItems(items.filter((i) => i.type === 'issue').slice(0, ISSUE_LIST_LIMIT)) - // Why: partial failures (e.g. a 403 on a private upstream's issues) - // must surface here, otherwise the subtab shows an empty list and - // re-creates the silent-wrongness the parent feature aims to eliminate. - // fetchWorkItems stores envelope-level errors on the cache entry; read - // them via the selector so the banner surfaces rather than the subtab - // silently showing no items. This branch is mutually exclusive with the - // .catch below: a cached error implies the IPC call resolved, so - // there's no double-surface risk between the two setIssueError paths. - const { error } = getWorkItemsSourcesAndError(selectedRepo.path, effectiveLimit, q) - if (error) { - setIssueError(error.message) - } - setIssueLoading(false) - }) - .catch((err) => { - if (stale) { - return - } - setIssueError(err instanceof Error ? err.message : 'Failed to load issues.') - setIssueLoading(false) - }) - return () => { - stale = true - } - }, [ - subTab, - selectedRepo?.id, - selectedRepo?.path, - isRemoteRepo, - debouncedQuery, - normalizedGhQuery.query, - normalizedGhQuery.directNumber, - fetchWorkItems, - getCachedWorkItems, - getWorkItemsSourcesAndError, - // Why: flipping the issue-source selector evicts this repo's cache and - // bumps the nonce so this effect re-runs against the new preference. - // Matches the identical pattern in TaskPage's Tasks-list fetch effect. - workItemsInvalidationNonce - ]) - - // --------------------------------------------------------------------- - // Direct `#N` / URL lookup across PR + Issue tabs - // --------------------------------------------------------------------- - const [directLoading, setDirectLoading] = useState(false) - useEffect(() => { - if ((subTab !== 'prs' && subTab !== 'issues') || !selectedRepo?.path || isRemoteRepo) { - setDirectPr(null) - return - } - const directNumber = normalizedGhQuery.directNumber - if (directNumber === null) { - setDirectPr(null) - return - } - let stale = false - setDirectLoading(true) - void window.api.gh - .workItem({ repoPath: selectedRepo.path, number: directNumber }) - .then((item) => { - if (stale) { - return - } - const gh = item as GitHubWorkItem | null - if (!gh) { - setDirectPr(null) - } else { - const wantedType = subTab === 'prs' ? 'pr' : 'issue' - setDirectPr( - gh.type === wantedType - ? ({ ...gh, repoId: selectedRepo.id } as unknown as GitHubWorkItem) - : null - ) - } - }) - .catch(() => { - if (!stale) { - setDirectPr(null) - } - }) - .finally(() => { - if (!stale) { - setDirectLoading(false) - } - }) - return () => { - stale = true - } - }, [subTab, selectedRepo?.id, selectedRepo?.path, isRemoteRepo, normalizedGhQuery.directNumber]) - - // --------------------------------------------------------------------- - // Branches - // --------------------------------------------------------------------- - const [branches, setBranches] = useState([]) - const [branchesLoading, setBranchesLoading] = useState(false) - useEffect(() => { - if (subTab !== 'branches' || !selectedRepo) { - return - } - const trimmed = debouncedQuery.trim() - const cacheKey = `${selectedRepo.id}::${trimmed}` - const cached = branchCache.get(cacheKey) - const fresh = cached && Date.now() - cached.fetchedAt < BRANCH_CACHE_TTL_MS - if (cached) { - // Why: show stale cache immediately (SWR-style); the fetch below keeps - // the list current. Only skip the loader flash if the entry is fresh. - setBranches(cached.data) - if (fresh) { - setBranchesLoading(false) - return - } - } - let stale = false - if (!cached) { - setBranchesLoading(true) - } - void window.api.repos - .searchBaseRefs({ repoId: selectedRepo.id, query: trimmed, limit: 30 }) - .then((results) => { - branchCache.set(cacheKey, { data: results, fetchedAt: Date.now() }) - if (!stale) { - setBranches(results) - } - }) - .catch(() => { - if (!stale) { - setBranches([]) - } - }) - .finally(() => { - if (!stale) { - setBranchesLoading(false) - } - }) - return () => { - stale = true - } - }, [subTab, selectedRepo, debouncedQuery]) - - // --------------------------------------------------------------------- - // Linear - // --------------------------------------------------------------------- - const [linearIssues, setLinearIssues] = useState([]) - const [linearLoading, setLinearLoading] = useState(false) - const [linearError, setLinearError] = useState(null) - useEffect(() => { - if (subTab !== 'linear' || !linearStatus.connected) { - return - } - let stale = false - setLinearLoading(true) - setLinearError(null) - const trimmed = debouncedQuery.trim() - const request = trimmed - ? searchLinearIssues(trimmed, LINEAR_LIST_LIMIT) - : listLinearIssues('assigned', LINEAR_LIST_LIMIT) - void request - .then((items) => { - if (!stale) { - setLinearIssues(items) - setLinearLoading(false) - } - }) - .catch((err) => { - if (!stale) { - setLinearError(err instanceof Error ? err.message : 'Failed to load Linear issues.') - setLinearLoading(false) - } - }) - return () => { - stale = true - } - // Why: list/search methods are stable store selectors; depending on them - // would re-run the effect on unrelated store updates. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [subTab, linearStatus.connected, debouncedQuery]) - - // --------------------------------------------------------------------- - // Selection handlers — each defers to launchWorkItemDirect / launchFromBranch - // and falls back to the Quick tab on policy=ask. - // --------------------------------------------------------------------- - // Why: a single in-flight token guards against double-clicks and tab - // switches racing with slow network calls (PR base-ref resolution can take - // multiple seconds on cold caches). - const inflightRef = useRef(0) - const [launching, setLaunching] = useState(false) - // Why: once the user selects a row the popover closes and the search input - // would otherwise fall back to the (often empty) query, making the field - // look blank while "Creating workspace…" shows below. Pin the selected - // row's label into the input for the duration of the launch so the user - // still sees what they picked. - const [selectedLabel, setSelectedLabel] = useState(null) - const beginLaunch = useCallback((label: string) => { - setSelectedLabel(label) - setResultsOpen(false) - }, []) - - const handlePrSelect = useCallback( - async (item: GitHubWorkItem) => { - if (!selectedRepo || item.type !== 'pr') { - return - } - const token = ++inflightRef.current - setLaunching(true) - beginLaunch(`#${item.number} ${item.title}`) - try { - const result = await window.api.worktrees.resolvePrBase({ - repoId: selectedRepo.id, - prNumber: item.number, - ...(item.branchName ? { headRefName: item.branchName } : {}), - ...(item.isCrossRepository !== undefined - ? { isCrossRepository: item.isCrossRepository } - : {}) - }) - if (token !== inflightRef.current) { - return - } - if ('error' in result) { - // Why: failed head resolution still gives the user something to do — - // hand off to Quick with the linked PR so they can pick a different - // base branch manually instead of being stuck on this tab. - onFallbackToQuick({ - initialRepoId: selectedRepo.id, - linkedWorkItem: { - type: 'pr', - number: item.number, - title: item.title, - url: item.url - }, - prefilledName: item.title - }) - return - } - await launchWorkItemDirect({ - item: { - title: item.title, - url: item.url, - type: 'pr', - number: item.number - }, - repoId: selectedRepo.id, - baseBranch: result.baseBranch, - openModalFallback: () => - onFallbackToQuick({ - initialRepoId: selectedRepo.id, - linkedWorkItem: { - type: 'pr', - number: item.number, - title: item.title, - url: item.url - }, - prefilledName: item.title, - initialBaseBranch: result.baseBranch - }) - }) - if (token === inflightRef.current) { - onLaunched() - } - } finally { - if (token === inflightRef.current) { - setLaunching(false) - setSelectedLabel(null) - } - } - }, - [beginLaunch, onFallbackToQuick, onLaunched, selectedRepo] - ) - - const handleIssueSelect = useCallback( - async (item: GitHubWorkItem) => { - if (!selectedRepo || item.type !== 'issue') { - return - } - const token = ++inflightRef.current - setLaunching(true) - beginLaunch(`#${item.number} ${item.title}`) - try { - await launchWorkItemDirect({ - item: { - title: item.title, - url: item.url, - type: 'issue', - number: item.number - }, - repoId: selectedRepo.id, - openModalFallback: () => - onFallbackToQuick({ - initialRepoId: selectedRepo.id, - linkedWorkItem: { - type: 'issue', - number: item.number, - title: item.title, - url: item.url - }, - prefilledName: item.title - }) - }) - if (token === inflightRef.current) { - onLaunched() - } - } finally { - if (token === inflightRef.current) { - setLaunching(false) - setSelectedLabel(null) - } - } - }, - [beginLaunch, onFallbackToQuick, onLaunched, selectedRepo] - ) - - const handleBranchSelect = useCallback( - async (refName: string) => { - if (!selectedRepo) { - return - } - const token = ++inflightRef.current - setLaunching(true) - beginLaunch(refName) - try { - await launchFromBranch({ - repoId: selectedRepo.id, - baseBranch: refName, - openModalFallback: () => onFallbackToQuick({ initialRepoId: selectedRepo.id }) - }) - if (token === inflightRef.current) { - onLaunched() - } - } finally { - if (token === inflightRef.current) { - setLaunching(false) - setSelectedLabel(null) - } - } - }, - [beginLaunch, onFallbackToQuick, onLaunched, selectedRepo] - ) - - const handleLinearSelect = useCallback( - async (issue: LinearIssue) => { - // Why: Linear issues aren't scoped to a git repo, so pick the active - // repo (or the first eligible) as a target for the worktree. Users can - // still override via the Quick tab fallback if setup policy is `ask`. - const repoForLaunch = - eligibleRepos.find((r) => r.id === selectedRepoId) ?? eligibleRepos[0] ?? null - if (!repoForLaunch) { - onFallbackToQuick({}) - return - } - const token = ++inflightRef.current - setLaunching(true) - beginLaunch(`${issue.identifier} ${issue.title}`) - try { - const parts = [ - `[${issue.identifier}] ${issue.title}`, - `Status: ${issue.state.name} · Team: ${issue.team.name}`, - issue.assignee ? `Assignee: ${issue.assignee.displayName}` : null, - issue.labels.length > 0 ? `Labels: ${issue.labels.join(', ')}` : null, - `URL: ${issue.url}`, - issue.description ? `\n${issue.description}` : null - ] - const pasteContent = parts.filter(Boolean).join('\n') - await launchWorkItemDirect({ - item: { - title: issue.title, - url: issue.url, - type: 'issue', - number: null, - pasteContent, - linearIdentifier: issue.identifier - }, - repoId: repoForLaunch.id, - openModalFallback: () => - onFallbackToQuick({ - initialRepoId: repoForLaunch.id, - linkedWorkItem: { - type: 'issue', - // Why: Linear identifiers are strings (e.g. "ENG-123") — use 0 - // as a placeholder since the URL is what the agent acts on. - number: 0, - title: issue.title, - url: issue.url - }, - prefilledName: issue.title - }) - }) - if (token === inflightRef.current) { - onLaunched() - } - } finally { - if (token === inflightRef.current) { - setLaunching(false) - setSelectedLabel(null) - } - } - }, - [beginLaunch, eligibleRepos, onFallbackToQuick, onLaunched, selectedRepoId] - ) - - // --------------------------------------------------------------------- - // Render - // --------------------------------------------------------------------- - const visiblePrItems = useMemo(() => { - if (normalizedGhQuery.directNumber !== null) { - return directPr && directPr.type === 'pr' ? [directPr] : [] - } - return prItems - }, [directPr, normalizedGhQuery.directNumber, prItems]) - const visibleIssueItems = useMemo(() => { - if (normalizedGhQuery.directNumber !== null) { - return directPr && directPr.type === 'issue' ? [directPr] : [] - } - return issueItems - }, [directPr, normalizedGhQuery.directNumber, issueItems]) - - const placeholderBySubTab: Record = { - prs: 'Search PRs, paste #N or URL…', - issues: 'Search issues, paste #N or URL…', - branches: 'Search branches…', - linear: 'Search Linear issues…' - } - - const showGhRepoPicker = subTab !== 'linear' - - // Why: render the issue-source selector inline with the Issues sub-tab's - // search input so a user who lands on an upstream/origin mismatch can - // correct it in place instead of closing the modal, flipping from the - // Tasks page, and re-opening. Sources come from whatever `workItemsCache` - // entry exists for this repo (empty-query or a past search) — the per-repo - // `listWorkItems` envelope always stamps both `prs` and `upstreamCandidate` - // so we can re-use the same derived shape the Tasks header consumes. - // Subscribing through `getWorkItemsAnySourcesForRepo` (a selector that - // returns a stable reference for unchanged entries) keeps this cheap — - // unrelated cache writes don't re-render the tab. - const selectorSources = useAppStore((s) => - selectedRepo?.path && !isRemoteRepo - ? s.getWorkItemsAnySourcesForRepo(selectedRepo.path, COMBINED_WORK_ITEM_LIMIT) - : null - ) - const selectorOrigin = selectorSources?.prs ?? null - const selectorUpstream = selectorSources?.upstreamCandidate ?? null - const selectorRenderable = - subTab === 'issues' && - !!selectorOrigin && - !!selectorUpstream && - !sameGitHubOwnerRepo(selectorOrigin, selectorUpstream) - - // Why: each row gets a stable, unique `value` so cmdk's keyboard - // navigation can track selection across sub-tab changes. Values also - // feed the controlled `commandValue` state below. - type RowEntry = - | { kind: 'pr'; value: string; item: GitHubWorkItem } - | { kind: 'issue'; value: string; item: GitHubWorkItem } - | { kind: 'branch'; value: string; refName: string } - | { kind: 'linear'; value: string; issue: LinearIssue } - - type ResultState = - | { kind: 'empty'; message: React.ReactNode } - | { kind: 'loading' } - | { kind: 'rows'; rows: RowEntry[] } - - const resultState = useMemo(() => { - if (subTab === 'prs') { - if (isRemoteRepo) { - return { kind: 'empty', message: "PR start points aren't supported for remote repos yet." } - } - if (prError) { - return { - kind: 'empty', - message: prError.includes('gh') ? 'gh not available — Branches tab still works' : prError - } - } - if ((prLoading || directLoading) && visiblePrItems.length === 0) { - return { kind: 'loading' } - } - if (visiblePrItems.length === 0) { - return { - kind: 'empty', - message: - normalizedGhQuery.directNumber !== null - ? `No PR #${normalizedGhQuery.directNumber}` - : 'No open PRs' - } - } - return { - kind: 'rows', - rows: visiblePrItems.map((item) => ({ - kind: 'pr' as const, - value: `pr-${item.number}`, - item - })) - } - } - if (subTab === 'issues') { - if (isRemoteRepo) { - return { - kind: 'empty', - message: "Issue start points aren't supported for remote repos yet." - } - } - if (issueError) { - return { - kind: 'empty', - message: issueError.includes('gh') - ? 'gh not available — Branches tab still works' - : issueError - } - } - if ((issueLoading || directLoading) && visibleIssueItems.length === 0) { - return { kind: 'loading' } - } - if (visibleIssueItems.length === 0) { - return { - kind: 'empty', - message: - normalizedGhQuery.directNumber !== null - ? `No issue #${normalizedGhQuery.directNumber}` - : 'No open issues' - } - } - return { - kind: 'rows', - rows: visibleIssueItems.map((item) => ({ - kind: 'issue' as const, - value: `issue-${item.number}`, - item - })) - } - } - if (subTab === 'branches') { - if (branchesLoading && branches.length === 0) { - return { kind: 'loading' } - } - if (branches.length === 0) { - return { - kind: 'empty', - message: query.trim() ? 'No branches match' : 'No branches found' - } - } - return { - kind: 'rows', - rows: branches.map((refName) => ({ - kind: 'branch' as const, - value: `branch-${refName}`, - refName - })) - } - } - // linear - if (!linearStatus.connected) { - return { - kind: 'empty', - message: - 'Connect Linear from Settings → Integrations to create workspaces from Linear issues.' - } - } - if (linearError) { - return { kind: 'empty', message: linearError } - } - if (linearLoading && linearIssues.length === 0) { - return { kind: 'loading' } - } - if (linearIssues.length === 0) { - return { - kind: 'empty', - message: query.trim() ? 'No Linear issues match' : 'No Linear issues assigned to you' - } - } - return { - kind: 'rows', - rows: linearIssues.map((issue) => ({ - kind: 'linear' as const, - value: `linear-${issue.id}`, - issue - })) - } - }, [ - branches, - branchesLoading, - directLoading, - isRemoteRepo, - issueError, - issueLoading, - linearError, - linearIssues, - linearLoading, - linearStatus.connected, - normalizedGhQuery.directNumber, - prError, - prLoading, - query, - subTab, - visibleIssueItems, - visiblePrItems - ]) - - const handleRowSelect = useCallback( - (row: RowEntry) => { - if (row.kind === 'pr') { - void handlePrSelect(row.item) - } else if (row.kind === 'issue') { - void handleIssueSelect(row.item) - } else if (row.kind === 'branch') { - void handleBranchSelect(row.refName) - } else { - void handleLinearSelect(row.issue) - } - }, - [handleBranchSelect, handleIssueSelect, handleLinearSelect, handlePrSelect] - ) - - // Why: cmdk tracks the highlighted row via a controlled `value`. We - // seed it to the first row on list change so Enter works immediately - // and the top row has the visible selected style — matching every - // shadcn combobox flow. - const [commandValue, setCommandValue] = useState('') - useEffect(() => { - if (resultState.kind === 'rows' && resultState.rows.length > 0) { - setCommandValue((prev) => - resultState.rows.some((r) => r.value === prev) ? prev : resultState.rows[0].value - ) - } - }, [resultState]) - - return ( -
- {/* Why: give the Repository selector a stable row above the tabs so - switching to Linear (which hides the picker) doesn't rearrange the - sub-tab row, and the sub-tabs get the full width for themselves. - Matches the field-label style used on the Quick tab so the two - surfaces feel like the same form dialect. - - The grid-template-rows 0fr↔1fr trick animates the picker's - collapse/expand smoothly when the user moves between Linear (no - repo picker) and GH sub-tabs. We keep the DOM mounted across the - transition so focus/state inside the combobox survives, and clip - the overflow so the shrinking row never peeks past the borders. */} -
-
-
- - -
-
-
- - setSubTab(v as CreateFromSubTab)} - className="gap-0" - > - - {SUB_TABS.map(({ id, label, Icon }) => ( - - - {label} - - ))} - - -
- - - -
- {launching ? ( - - ) : ( - - )} - { - if (launching) { - return - } - setQuery(e.target.value) - setResultsOpen(true) - }} - onFocus={() => { - if (!launching) { - setResultsOpen(true) - } - }} - onKeyDown={(e) => { - if (launching) { - return - } - if (e.key === 'Escape' && resultsOpen) { - // Why: first Escape dismisses the suggestions; only - // if the popover is already closed should Escape - // bubble up to close the dialog. - e.stopPropagation() - setResultsOpen(false) - return - } - if ( - e.key === 'ArrowDown' || - e.key === 'ArrowUp' || - e.key === 'Home' || - e.key === 'End' - ) { - if (!resultsOpen) { - setResultsOpen(true) - } - // Why: cmdk handles arrow/Home/End on its root via - // its own listener. The event reaching here already - // got processed; just make sure the popover is open - // so the user sees the movement. - } - if (e.key === 'Enter' && resultsOpen && resultState.kind === 'rows') { - const row = resultState.rows.find((r) => r.value === commandValue) - if (row) { - e.preventDefault() - handleRowSelect(row) - } - } - }} - placeholder={placeholderBySubTab[subTab]} - className={cn( - 'h-9 pl-8 text-sm', - // Why: reserve trailing space for the absolute-positioned - // IssueSourceSelector so typed text never slides under - // the pills. Only widen the padding when the selector - // is actually rendered. - selectorRenderable && !launching ? 'pr-[140px]' : '' - )} - /> - {selectorRenderable && selectedRepo && !launching ? ( - // Why: right-aligned inside the input so the selector - // stays visually attached to the surface whose contents - // it controls. `pointer-events-auto` is needed because - // the sibling Search/Loader icons set - // `pointer-events-none` on the absolute-positioned layer - // — we override that for the clickable pills. - // `suppressTooltip`: the selector is only visible on the - // Issues sub-tab, so "Showing issues from X" restates - // what's already implied (same reasoning as the Create - // Issue composer mirror in TaskPage). -
{ - // Why: clicking a pill would otherwise blur the input - // and close the results popover before the click - // lands on the button. Blocking the default focus - // shift keeps the popover open across a flip so the - // user sees the list repopulate against the new - // source without re-clicking the input. - e.preventDefault() - }} - > - { - void setIssueSourcePreference(selectedRepo.id, selectedRepo.path, next) - }} - suppressTooltip - /> -
- ) : null} -
-
- { - // Why: the input keeps focus so typing continues to - // filter results. Without preventDefault the popover - // would pull focus to its first child on open. - event.preventDefault() - }} - onCloseAutoFocus={(event) => { - // Why: the parent closes this popover as a side effect of - // tab-switching (⌘N / ⌘⇧N / clicking the other tab), and the - // default focus-restore to the search input would then - // clobber the focus the parent just placed on the newly- - // active panel. Suppress the restore whenever this tab is - // no longer active; normal closes (Escape, row select) still - // get the default return-to-input behavior. - if (!activeRef.current) { - event.preventDefault() - } - }} - onPointerDownOutside={(event) => { - // Why: the search input is the popover's anchor (not its - // trigger), so Radix treats clicks on it as "outside" and - // would close the popover. Sub-tab triggers are also - // outside — clicking them should just switch source and - // keep results visible rather than dismissing the list. - const target = event.target as Node - if ( - searchInputRef.current?.contains(target) || - subTabsListRef.current?.contains(target) - ) { - event.preventDefault() - } - }} - onFocusOutside={(event) => { - // Why: sub-tab clicks move focus to a tab trigger and we - // refocus the search input on the next frame. Both of - // those focus destinations are "outside" the popover - // content — if we don't suppress them Radix dismisses the - // list and the user has to click the input again to see - // the new sub-tab's results. - const target = event.target as Node - if ( - searchInputRef.current?.contains(target) || - subTabsListRef.current?.contains(target) - ) { - event.preventDefault() - } - }} - > - - {resultState.kind === 'loading' ? ( - - ) : resultState.kind === 'empty' ? ( - {resultState.message} - ) : ( - - {resultState.rows.map((row) => ( - handleRowSelect(row)} - disabled={launching} - className="group gap-2 px-2 py-1.5 text-xs data-[disabled=true]:opacity-60" - > - {row.kind === 'pr' ? ( - - ) : row.kind === 'issue' ? ( - - ) : row.kind === 'branch' ? ( - - ) : ( - - )} - - - ))} - - )} - - -
-
-
-
- - {launching ? ( -
- - Creating workspace… -
- ) : null} -
- ) -} - -function LoadingRows(): React.JSX.Element { - return ( -
- {[0, 1, 2, 3].map((i) => ( -
- ))} -
- ) -} - -function EmptyMessage({ children }: { children: React.ReactNode }): React.JSX.Element { - return
{children}
-} - -function PrItemContent({ item }: { item: GitHubWorkItem }): React.JSX.Element { - const isFork = item.isCrossRepository === true - return ( - <> - - - - #{item.number} - {item.title} - - {item.branchName ? ( - - {item.branchName} - {isFork ? ' · fork' : ''} - - ) : null} - - - ) -} - -function IssueItemContent({ item }: { item: GitHubWorkItem }): React.JSX.Element { - return ( - <> - - - - #{item.number} - {item.title} - - {item.author ? ( - - {item.author} - - ) : null} - - - ) -} - -function BranchItemContent({ refName }: { refName: string }): React.JSX.Element { - return ( - <> - - {refName} - - ) -} - -function LinearItemContent({ issue }: { issue: LinearIssue }): React.JSX.Element { - return ( - <> - - {issue.identifier} - - - {issue.title} - - {issue.state.name} - {issue.team.name ? ` · ${issue.team.name}` : ''} - - - - ) -} diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx new file mode 100644 index 00000000000..4f8ee4dd714 --- /dev/null +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -0,0 +1,508 @@ +/* eslint-disable max-lines -- Why: the smart name field owns source tabs, +search orchestration, and result rendering so the unified create flow stays +in one predictable form control instead of splitting state across fragments. */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + CaseSensitive, + CircleDot, + GitBranch, + GitPullRequest, + Github, + LoaderCircle, + Search, + Sparkles +} from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +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 { cn } from '@/lib/utils' +import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types' + +type SmartNameMode = 'smart' | 'github' | 'branches' | 'linear' | 'text' + +type RepoOption = ReturnType['repos'][number] + +type SmartWorkspaceNameFieldProps = { + repos: RepoOption[] + repoId: string + value: string + onValueChange: (value: string) => void + onGitHubItemSelect: (item: GitHubWorkItem) => void + onBranchSelect: (refName: string) => void + onLinearIssueSelect: (issue: LinearIssue) => void + inputRef?: React.RefObject + onPlainEnter?: () => void +} + +const SEARCH_DEBOUNCE_MS = 200 +const RESULT_LIMIT = 12 + +const MODES: { + id: SmartNameMode + label: string + Icon: React.ComponentType<{ className?: string }> +}[] = [ + { id: 'smart', label: 'Smart', Icon: Sparkles }, + { id: 'github', label: 'GitHub', Icon: Github }, + { id: 'branches', label: 'Branches', Icon: GitBranch }, + { + id: 'linear', + label: 'Linear', + Icon: ({ className }: { className?: string }) => ( + + + + ) + }, + { id: 'text', label: 'Name', Icon: CaseSensitive } +] + +type RowEntry = + | { kind: 'use-name'; value: string; name: string } + | { kind: 'github'; value: string; item: GitHubWorkItem } + | { kind: 'branch'; value: string; refName: string } + | { kind: 'linear'; value: string; issue: LinearIssue } + +export default function SmartWorkspaceNameField({ + repos, + repoId, + value, + onValueChange, + onGitHubItemSelect, + onBranchSelect, + onLinearIssueSelect, + 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 selectedRepo = useMemo( + () => repos.find((repo) => repo.id === repoId) ?? null, + [repoId, repos] + ) + const [mode, setMode] = useState('smart') + const [open, setOpen] = useState(false) + const [debouncedQuery, setDebouncedQuery] = useState(value) + const [githubItems, setGithubItems] = useState([]) + const [branches, setBranches] = useState([]) + const [linearIssues, setLinearIssues] = useState([]) + const [githubLoading, setGithubLoading] = useState(false) + const [branchesLoading, setBranchesLoading] = useState(false) + const [linearLoading, setLinearLoading] = useState(false) + const [commandValue, setCommandValue] = useState('') + const localInputRef = useRef(null) + + const setInputNode = useCallback( + (node: HTMLInputElement | null) => { + localInputRef.current = node + if (inputRef) { + inputRef.current = node + } + }, + [inputRef] + ) + + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedQuery(value), SEARCH_DEBOUNCE_MS) + return () => window.clearTimeout(timer) + }, [value]) + + const normalizedGhQuery = useMemo( + () => normalizeGitHubLinkQuery(debouncedQuery), + [debouncedQuery] + ) + const shouldQueryGithub = mode === 'smart' || mode === 'github' + const shouldQueryBranches = mode === 'smart' || mode === 'branches' + const shouldQueryLinear = mode === 'smart' || mode === 'linear' + + useEffect(() => { + if (!shouldQueryGithub || !selectedRepo?.path || selectedRepo.connectionId) { + setGithubItems([]) + setGithubLoading(false) + return + } + let stale = false + const directNumber = normalizedGhQuery.directNumber + if (directNumber !== null) { + setGithubLoading(true) + void window.api.gh + .workItem({ repoPath: selectedRepo.path, number: directNumber }) + .then((item) => { + if (!stale) { + setGithubItems(item ? [{ ...item, repoId: selectedRepo.id } as GitHubWorkItem] : []) + } + }) + .catch(() => { + if (!stale) { + setGithubItems([]) + } + }) + .finally(() => { + if (!stale) { + setGithubLoading(false) + } + }) + return () => { + stale = true + } + } + + const trimmed = normalizedGhQuery.query.trim() + const query = trimmed ? normalizedGhQuery.query : '' + const cached = getCachedWorkItems(selectedRepo.path, RESULT_LIMIT, query) + if (cached) { + setGithubItems(cached.slice(0, RESULT_LIMIT)) + setGithubLoading(false) + } else { + setGithubLoading(true) + } + void fetchWorkItems(selectedRepo.id, selectedRepo.path, RESULT_LIMIT, query) + .then((items) => { + if (!stale) { + setGithubItems(items.slice(0, RESULT_LIMIT)) + } + }) + .catch(() => { + if (!stale) { + setGithubItems([]) + } + }) + .finally(() => { + if (!stale) { + setGithubLoading(false) + } + }) + return () => { + stale = true + } + }, [fetchWorkItems, getCachedWorkItems, normalizedGhQuery, selectedRepo, shouldQueryGithub]) + + useEffect(() => { + if (!shouldQueryBranches || !selectedRepo) { + setBranches([]) + setBranchesLoading(false) + return + } + let stale = false + setBranchesLoading(true) + void window.api.repos + .searchBaseRefs({ + repoId: selectedRepo.id, + query: debouncedQuery.trim(), + limit: RESULT_LIMIT + }) + .then((results) => { + if (!stale) { + setBranches(results) + } + }) + .catch(() => { + if (!stale) { + setBranches([]) + } + }) + .finally(() => { + if (!stale) { + setBranchesLoading(false) + } + }) + return () => { + stale = true + } + }, [debouncedQuery, selectedRepo, shouldQueryBranches]) + + useEffect(() => { + if (!shouldQueryLinear || !linearStatus.connected) { + setLinearIssues([]) + setLinearLoading(false) + return + } + let stale = false + setLinearLoading(true) + const trimmed = debouncedQuery.trim() + const request = trimmed + ? searchLinearIssues(trimmed, RESULT_LIMIT) + : listLinearIssues('assigned', RESULT_LIMIT) + void request + .then((issues) => { + if (!stale) { + setLinearIssues(issues) + } + }) + .catch(() => { + if (!stale) { + setLinearIssues([]) + } + }) + .finally(() => { + if (!stale) { + setLinearLoading(false) + } + }) + return () => { + stale = true + } + // Why: list/search actions are stable store methods; depending on them + // would refetch on unrelated store writes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedQuery, linearStatus.connected, shouldQueryLinear]) + + const rows = useMemo(() => { + const trimmed = value.trim() + const nextRows: RowEntry[] = trimmed + ? [{ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }] + : [] + if (mode === 'text') { + return nextRows + } + if (mode === 'smart' || mode === 'github') { + nextRows.push( + ...githubItems.map((item) => ({ + kind: 'github' as const, + value: `github-${item.type}-${item.number}`, + item + })) + ) + } + if (mode === 'smart' || mode === 'branches') { + nextRows.push( + ...branches.map((refName) => ({ + kind: 'branch' as const, + value: `branch-${refName}`, + refName + })) + ) + } + if (mode === 'smart' || mode === 'linear') { + nextRows.push( + ...linearIssues.map((issue) => ({ + kind: 'linear' as const, + value: `linear-${issue.id}`, + issue + })) + ) + } + return nextRows.slice(0, RESULT_LIMIT + 1) + }, [branches, githubItems, linearIssues, mode, value]) + + useEffect(() => { + if (rows.length > 0) { + setCommandValue((current) => + rows.some((row) => row.value === current) ? current : rows[0].value + ) + } + }, [rows]) + + const loading = githubLoading || branchesLoading || linearLoading + const ActiveInputIcon = mode === 'text' ? CaseSensitive : loading ? LoaderCircle : Search + + const handleSelect = useCallback( + (row: RowEntry) => { + if (row.kind === 'use-name') { + onValueChange(row.name) + } else if (row.kind === 'github') { + onGitHubItemSelect(row.item) + } else if (row.kind === 'branch') { + onBranchSelect(row.refName) + } else { + onLinearIssueSelect(row.issue) + } + setOpen(false) + requestAnimationFrame(() => localInputRef.current?.focus({ preventScroll: true })) + }, + [onBranchSelect, onGitHubItemSelect, onLinearIssueSelect, onValueChange] + ) + + const placeholder = + mode === 'smart' + ? 'Type a name, #1234, branch, GitHub or Linear URL' + : mode === 'github' + ? 'Search GitHub PRs and issues' + : mode === 'branches' + ? 'Search branches' + : mode === 'linear' + ? 'Search Linear issues' + : 'Workspace name' + + return ( +
+ { + setMode(next as SmartNameMode) + setOpen(next !== 'text') + requestAnimationFrame(() => localInputRef.current?.focus({ preventScroll: true })) + }} + className="gap-0" + > + + {MODES.map(({ id, label, Icon }) => ( + + + {label} + + ))} + + + + + + +
+ + { + 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) + } + return + } + onPlainEnter?.() + } + if (event.key === 'Escape' && open) { + event.stopPropagation() + setOpen(false) + } + }} + placeholder={placeholder} + className="h-9 pl-8 text-sm" + /> +
+
+ event.preventDefault()} + > + + {loading && rows.length === 0 ? ( +
+ {[0, 1, 2].map((index) => ( +
+ ))} +
+ ) : rows.length === 0 ? ( +
+ {mode === 'linear' && !linearStatus.connected + ? 'Connect Linear in Settings to search issues.' + : 'Start typing to create a name or find a source.'} +
+ ) : ( + + {rows.map((row) => ( + handleSelect(row)} + className="gap-2 px-2 py-1.5 text-xs" + > + + + + ))} + + )} + + + + +
+ ) +} + +function RowIcon({ row }: { row: RowEntry }): React.JSX.Element { + if (row.kind === 'use-name') { + return + } + if (row.kind === 'github') { + return row.item.type === 'pr' ? ( + + ) : ( + + ) + } + if (row.kind === 'branch') { + return + } + return ( + + L + + ) +} + +function RowLabel({ row }: { row: RowEntry }): React.JSX.Element { + if (row.kind === 'use-name') { + return ( + + Use “{row.name}” as + workspace name + + ) + } + if (row.kind === 'github') { + return ( + + #{row.item.number} {row.item.title} + + ) + } + if (row.kind === 'branch') { + return {row.refName} + } + return ( + + {row.issue.identifier} {row.issue.title} + + ) +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 3f38a873122..fd1108791cc 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -13,6 +13,7 @@ import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { isGitRepoKind } from '../../../shared/repo-kind' import type { GitHubWorkItem, + LinearIssue, OrcaHooks, SetupDecision, SetupRunPolicy, @@ -65,7 +66,10 @@ export type ComposerCardProps = { repoId: string onRepoChange: (value: string) => void name: string - onNameChange: (event: React.ChangeEvent) => void + onNameValueChange: (value: string) => void + onSmartGitHubItemSelect: (item: GitHubWorkItem) => void + onSmartBranchSelect: (refName: string) => void + onSmartLinearIssueSelect: (issue: LinearIssue) => void agentPrompt: string onAgentPromptChange: (value: string) => void onPromptKeyDown: (event: React.KeyboardEvent) => void @@ -675,8 +679,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // discriminant, so the union-preserving shape must be asserted. // Why: the link popover intentionally does NOT surface // `envelope.errors?.issues`. Per-surface error copy lives in the - // Tasks view (TaskPage) and the new-workspace Create tab - // (CreateFromTab) — a partial-failure banner inside the small + // Tasks view (TaskPage) and the smart workspace-name field — a + // partial-failure banner inside the small // @-mention popover would crowd the input and the user would // already see the same error on the originating Tasks page. If a // future UX decision flips this, add an error row to the popover's @@ -807,9 +811,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } }, [name]) - const handleNameChange = useCallback( - (event: React.ChangeEvent): void => { - const nextName = event.target.value + const handleNameValueChange = useCallback( + (nextName: string): void => { // Why: linked GitHub items should keep refreshing the suggested workspace // name only while the current value is still auto-managed. As soon as the // user edits the field by hand, later issue/PR selections must stop @@ -824,7 +827,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [name] ) - const handleAddAttachment = useCallback(async (): Promise => { try { const selectedPath = await window.api.shell.pickAttachment() @@ -1042,6 +1044,79 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS [applyLinkedWorkItem] ) + const handleSmartGitHubItemSelect = useCallback( + (item: GitHubWorkItem): void => { + applyLinkedWorkItem(item) + setStartFromResetHint(null) + if (item.type !== 'pr' || !selectedRepo) { + return + } + void window.api.worktrees + .resolvePrBase({ + repoId: selectedRepo.id, + prNumber: item.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + .then((result) => { + if ('error' in result) { + return + } + handleBaseBranchPrSelect(result.baseBranch, item) + }) + }, + [applyLinkedWorkItem, handleBaseBranchPrSelect, selectedRepo] + ) + + const handleSmartBranchSelect = useCallback( + (refName: string): void => { + setBaseBranch(refName) + setStartFromResetHint(null) + if (!name.trim() || name === lastAutoNameRef.current) { + setName(refName) + lastAutoNameRef.current = refName + } + }, + [name] + ) + + const handleSmartLinearIssueSelect = useCallback( + (issue: LinearIssue): void => { + setLinkedIssue('') + setLinkedPR(null) + setLinkedWorkItem({ + type: 'issue', + // Why: Linear identifiers are strings (e.g. ENG-123); keep GitHub + // numeric metadata empty and carry the real source through the URL. + number: 0, + title: issue.title, + url: issue.url + }) + const suggestedName = issue.title + if (!name.trim() || name === lastAutoNameRef.current) { + setName(suggestedName) + lastAutoNameRef.current = suggestedName + } + const details = [ + `[${issue.identifier}] ${issue.title}`, + `Status: ${issue.state.name} · Team: ${issue.team.name}`, + issue.assignee ? `Assignee: ${issue.assignee.displayName}` : null, + issue.labels.length > 0 ? `Labels: ${issue.labels.join(', ')}` : null, + `URL: ${issue.url}`, + issue.description ? `\n${issue.description}` : null + ] + .filter(Boolean) + .join('\n') + if (!noteRef.current.trim() || noteRef.current === lastAutoNoteRef.current) { + setNote(details) + lastAutoNoteRef.current = details + } + }, + [name] + ) + const handleOpenAgentSettings = useCallback((): void => { openSettingsTarget({ pane: 'agents', repoId: null }) openSettingsPage() @@ -1202,8 +1277,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const workspaceName = getWorkspaceSeedName({ explicitName: name, prompt: '', - linkedIssueNumber: null, - linkedPR: null, + linkedIssueNumber: parsedLinkedIssueNumber, + linkedPR, fallbackName: fallbackCreatureName }) if ( @@ -1241,14 +1316,27 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const worktree = result.worktree const trimmedNote = note.trim() - await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {}) + await applyWorktreeMeta(worktree.id, { + ...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}), + ...(linkedPR !== null ? { linkedPR } : {}), + ...(trimmedNote ? { comment: trimmedNote } : {}) + }) + + const quickPrompt = linkedWorkItem + ? linkedWorkItem.number === 0 && trimmedNote + ? trimmedNote + : renderIssueCommandTemplate(DEFAULT_ISSUE_COMMAND_TEMPLATE, { + issueNumber: parsedLinkedIssueNumber, + artifactUrl: linkedWorkItem.url + }) + : '' const startupPlan = agent === null ? null : buildAgentStartupPlan({ agent, - prompt: '', + prompt: quickPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true @@ -1287,10 +1375,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS clearNewWorkspaceDraft, createWorktree, fallbackCreatureName, + linkedPR, + linkedWorkItem, name, normalizedSparseDirectories, note, onCreated, + parsedLinkedIssueNumber, persistDraft, repoId, requiresExplicitSetupChoice, @@ -1323,7 +1414,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS repoId, onRepoChange: handleRepoChange, name, - onNameChange: handleNameChange, + onNameValueChange: handleNameValueChange, + onSmartGitHubItemSelect: handleSmartGitHubItemSelect, + onSmartBranchSelect: handleSmartBranchSelect, + onSmartLinearIssueSelect: handleSmartLinearIssueSelect, agentPrompt, onAgentPromptChange: setAgentPrompt, onPromptKeyDown: handlePromptKeyDown, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 4106b81ee37..f89d26de256 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -129,7 +129,7 @@ export function useIpcEvents(): void { ) unsubs.push( - window.api.ui.onOpenNewWorkspace((tab) => { + window.api.ui.onOpenNewWorkspace(() => { // Why: mirror the renderer's App.tsx Cmd+N guard — only open the // composer when there is at least one real git repo configured, so // users on a fresh install don't get a modal with nothing to target. @@ -138,15 +138,10 @@ export function useIpcEvents(): void { return } dispatchClearModifierHints() - // Why: if the composer is already open, switch tabs in place so - // repeated Cmd+N / Cmd+Shift+N presses toggle between Quick and - // Create-from without remounting and losing in-flight composer state - // (repo pick, note drafts). Opening when closed seeds the initial tab. if (store.activeModal === 'new-workspace-composer') { - store.setNewWorkspaceComposerTab(tab) return } - store.openModal('new-workspace-composer', { initialTab: tab }) + store.openModal('new-workspace-composer') }) ) diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index decb9219d13..daf74dbdbc8 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -47,7 +47,7 @@ export type LaunchWorkItemDirectArgs = { openModalFallback: () => void /** Optional base branch to start the worktree from. When omitted the * worktree inherits the repo's effective base ref. Used by the - * "Create from…" PR row to branch from the PR's head so the first + * smart workspace-name PR selection to branch from the PR's head so the first * commit lands on the correct base without the user touching the UI. */ baseBranch?: string } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 37728199c18..d286f2f8542 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -158,17 +158,6 @@ export type UISlice = { modalData: Record openModal: (modal: UISlice['activeModal'], data?: Record) => void closeModal: () => void - /** Active tab inside the new-workspace composer modal. Mutable while the - * modal is open so Cmd+N / Cmd+Shift+N can toggle tabs without tearing - * down the composer state. */ - newWorkspaceComposerTab: 'quick' | 'create-from' - setNewWorkspaceComposerTab: (tab: 'quick' | 'create-from') => void - /** Remembered sub-tab inside the Create-from tab (PRs / Issues / Branches / - * Linear). Persists across composer opens within a session so users who - * always start from Linear (for example) don't have to click back to that - * tab every time. */ - createFromSubTab: 'prs' | 'issues' | 'branches' | 'linear' - setCreateFromSubTab: (tab: 'prs' | 'issues' | 'branches' | 'linear') => void trustedOrcaHooks: PersistedTrustedOrcaHooks markOrcaHookScriptConfirmed: ( repoId: string, @@ -369,27 +358,8 @@ export const createUISlice: StateCreator = (set, get) activeModal: 'none', modalData: {}, - openModal: (modal, data = {}) => { - // Why: when the new-workspace composer opens, seed its active tab from - // modalData.initialTab so Cmd+Shift+N lands directly on the "Create from…" - // tab without the Quick tab flashing first. Default to 'quick' when no - // explicit target is provided so existing callers keep their behavior. - if (modal === 'new-workspace-composer') { - const requestedTab = (data as { initialTab?: 'quick' | 'create-from' }).initialTab - set({ - activeModal: modal, - modalData: data, - newWorkspaceComposerTab: requestedTab ?? 'quick' - }) - return - } - set({ activeModal: modal, modalData: data }) - }, + openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }), closeModal: () => set({ activeModal: 'none', modalData: {} }), - newWorkspaceComposerTab: 'quick', - setNewWorkspaceComposerTab: (tab) => set({ newWorkspaceComposerTab: tab }), - createFromSubTab: 'prs', - setCreateFromSubTab: (tab) => set({ createFromSubTab: tab }), trustedOrcaHooks: {}, markOrcaHookScriptConfirmed: (repoId, kind, contentHash) => diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts index 4d30d73c0a0..06cae2cc09f 100644 --- a/src/shared/window-shortcut-policy.test.ts +++ b/src/shared/window-shortcut-policy.test.ts @@ -270,23 +270,22 @@ describe('resolveWindowShortcutAction', () => { ).toBeNull() }) - it('routes Cmd/Ctrl+Shift+N to the Create-from tab of the new-workspace composer', () => { - // Why: the shift variant of the new-workspace shortcut jumps straight to - // the "Create from…" tab so users can start from an existing GH/Linear - // item without a detour through the quick-create form. + it('routes Cmd/Ctrl+Shift+N to the unified new-workspace composer', () => { + // Why: keep the former Create-from shortcut accepted so muscle memory + // still opens the composer; source switching now lives in the smart name field. expect( resolveWindowShortcutAction( { code: 'KeyN', key: 'n', meta: true, control: false, alt: false, shift: true }, 'darwin' ) - ).toEqual({ type: 'openNewWorkspace', tab: 'create-from' }) + ).toEqual({ type: 'openNewWorkspace' }) expect( resolveWindowShortcutAction( { code: 'KeyN', key: 'n', meta: false, control: true, alt: false, shift: true }, 'linux' ) - ).toEqual({ type: 'openNewWorkspace', tab: 'create-from' }) + ).toEqual({ type: 'openNewWorkspace' }) // Alt must still be rejected — the allowlist is alt-free for Cmd/Ctrl+N // so future chords like Cmd+Alt+Shift+N remain available. @@ -320,7 +319,7 @@ describe('resolveWindowShortcutAction', () => { [{ code: 'KeyR', key: 'p', meta: true, alt: false, shift: false }, { type: 'openQuickOpen' }], [ { code: 'KeyL', key: 'n', meta: true, alt: false, shift: false }, - { type: 'openNewWorkspace', tab: 'quick' } + { type: 'openNewWorkspace' } ], [ { code: 'KeyC', key: 'j', meta: true, alt: false, shift: false }, @@ -350,7 +349,7 @@ describe('resolveWindowShortcutAction', () => { ], [ { code: 'KeyN', key: 'Dead', meta: true, alt: false, shift: false }, - { type: 'openNewWorkspace', tab: 'quick' } + { type: 'openNewWorkspace' } ], [{ code: 'KeyP', meta: true, alt: false, shift: false }, { type: 'openQuickOpen' }] ] diff --git a/src/shared/window-shortcut-policy.ts b/src/shared/window-shortcut-policy.ts index 4b742caaa06..2d2c751e16d 100644 --- a/src/shared/window-shortcut-policy.ts +++ b/src/shared/window-shortcut-policy.ts @@ -13,7 +13,7 @@ export type WindowShortcutAction = | { type: 'toggleLeftSidebar' } | { type: 'toggleRightSidebar' } | { type: 'openQuickOpen' } - | { type: 'openNewWorkspace'; tab: 'quick' | 'create-from' } + | { type: 'openNewWorkspace' } | { type: 'jumpToWorktreeIndex'; index: number } | { type: 'worktreeHistoryNavigate'; direction: 'back' | 'forward' } @@ -167,12 +167,11 @@ export function resolveWindowShortcutAction( // main process so it reaches the renderer even when focus lives inside // a contentEditable surface (markdown rich editor) or a browser guest // webContents, both of which bypass the renderer's window-level keydown. - // Cmd/Ctrl+Shift+N opens the composer on the "Create from…" tab so users - // can start a workspace directly from an existing PR, issue, branch, or - // Linear ticket without going through the quick-create flow first. + // Shift is accepted for compatibility with the former Create-from shortcut; + // the unified composer now exposes source switching inside the name field. if (matchesLetterShortcut(input, 'n', 'KeyN')) { if (!input.alt) { - return { type: 'openNewWorkspace', tab: input.shift ? 'create-from' : 'quick' } + return { type: 'openNewWorkspace' } } }