mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
Unify new workspace source selection
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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<HTMLInputElement>) => void
|
||||
onNameValueChange: (value: string) => void
|
||||
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
|
||||
onSmartBranchSelect: (refName: string) => void
|
||||
onSmartLinearIssueSelect: (issue: LinearIssue) => void
|
||||
detectedAgentIds: Set<TuiAgent> | 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({
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Workspace Name <span className="text-muted-foreground/70">[Optional]</span>
|
||||
</label>
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
<SmartWorkspaceNameField
|
||||
inputRef={nameInputRef}
|
||||
repos={eligibleRepos}
|
||||
repoId={repoId}
|
||||
value={name}
|
||||
onChange={onNameChange}
|
||||
onKeyDown={(event) => {
|
||||
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<HTMLElement>(
|
||||
'[data-agent-combobox-root="true"][role="combobox"]'
|
||||
)
|
||||
agentTrigger?.focus()
|
||||
}}
|
||||
placeholder="Workspace name"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<span className="text-[10px] font-normal tracking-wide text-muted-foreground/70">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLElement>(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<ComposerModalData | null>(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<Record<TabKey, HTMLElement | null>>({
|
||||
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<HTMLElement>('[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<TabKey | null>(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<HTMLElement>(`[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 (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
// Why: pin a single width across both tabs. Create-from needs the
|
||||
// extra horizontal room for PR titles + branch names; Quick tolerates
|
||||
// it fine. Animating between widths was jarring and made the modal
|
||||
// feel unstable every time the user toggled tabs.
|
||||
className="flex flex-col sm:max-w-lg"
|
||||
onOpenAutoFocus={(event) => {
|
||||
// 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<HTMLElement>(`[data-composer-panel="${activeTab}"]`)
|
||||
getComposerPanelFocusTarget(
|
||||
panel ?? content,
|
||||
lastFocusedByTabRef.current[activeTab]
|
||||
)?.focus({ preventScroll: true })
|
||||
const trigger = content.querySelector<HTMLElement>(
|
||||
'[data-repo-combobox-root="true"][role="combobox"]'
|
||||
)
|
||||
trigger?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(next) => 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. */}
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="-mt-4 h-8 w-full justify-start gap-6 border-b border-border/60 px-0 pr-8"
|
||||
>
|
||||
<TabsTrigger value="quick" className="flex-none gap-2 px-0 text-xs font-medium">
|
||||
Create
|
||||
<ShortcutHint>{tabShortcut.quick}</ShortcutHint>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="create-from" className="flex-none gap-2 px-0 text-xs font-medium">
|
||||
Create from…
|
||||
<ShortcutHint>{tabShortcut['create-from']}</ShortcutHint>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<DialogHeader className="gap-1">
|
||||
<DialogTitle className="text-base font-semibold">Create Workspace</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogHeader className="gap-1 pt-4">
|
||||
<DialogTitle className="text-base font-semibold">Create Workspace</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<AnimatedTabPanels active={activeTab}>
|
||||
{{
|
||||
quick: (
|
||||
<QuickTabBody
|
||||
key={quickKey}
|
||||
modalData={effectiveQuickData}
|
||||
onClose={onClose}
|
||||
active={activeTab === 'quick'}
|
||||
/>
|
||||
),
|
||||
'create-from': (
|
||||
<CreateFromTab
|
||||
onLaunched={handleCreateFromLaunched}
|
||||
onFallbackToQuick={handleFallbackToQuick}
|
||||
active={activeTab === 'create-from'}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</AnimatedTabPanels>
|
||||
</Tabs>
|
||||
<QuickTabBody modalData={modalData} onClose={onClose} active />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -428,5 +210,3 @@ function QuickTabBody({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type TabKey = 'quick' | 'create-from'
|
||||
|
||||
@@ -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<TabKey, React.ReactNode>
|
||||
}): React.JSX.Element {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
const quickRef = useRef<HTMLDivElement | null>(null)
|
||||
const createFromRef = useRef<HTMLDivElement | null>(null)
|
||||
const previousActiveRef = useRef<TabKey>(active)
|
||||
const [wrapperHeight, setWrapperHeight] = useState<number | null>(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 (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
// Why: `overflow: clip` isolates the absolutely-positioned inactive
|
||||
// panel so the wrapper's measured height drives the dialog (not the
|
||||
// stacked panel heights). We avoid plain `overflow: hidden` because
|
||||
// that also clips the 3px focus rings painted by nested inputs
|
||||
// (RepoCombobox, workspace name field, etc.) — the inner panels are
|
||||
// `inset-x-0` so their triggers sit flush against the wrapper edges,
|
||||
// leaving no room for a ring to paint. `overflow-clip-margin` gives
|
||||
// the ring breathing room on every side without re-introducing scroll
|
||||
// containers or letting the inactive panel leak layout.
|
||||
className={cn(
|
||||
'relative overflow-clip',
|
||||
isAnimating && 'transition-[height] duration-200 ease-out'
|
||||
)}
|
||||
style={{
|
||||
...(wrapperHeight !== null ? { height: wrapperHeight } : null),
|
||||
overflowClipMargin: '8px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={quickRef}
|
||||
data-composer-panel="quick"
|
||||
className={cn(
|
||||
'pt-4',
|
||||
active === 'quick'
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none invisible absolute inset-x-0 top-0'
|
||||
)}
|
||||
aria-hidden={active !== 'quick'}
|
||||
>
|
||||
{children.quick}
|
||||
</div>
|
||||
<div
|
||||
ref={createFromRef}
|
||||
data-composer-panel="create-from"
|
||||
className={cn(
|
||||
'pt-3',
|
||||
active === 'create-from'
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none invisible absolute inset-x-0 top-0'
|
||||
)}
|
||||
aria-hidden={active !== 'create-from'}
|
||||
>
|
||||
{children['create-from']}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<typeof useAppStore.getState>['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<HTMLInputElement | null>
|
||||
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 }) => (
|
||||
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
|
||||
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{ 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<SmartNameMode>('smart')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(value)
|
||||
const [githubItems, setGithubItems] = useState<GitHubWorkItem[]>([])
|
||||
const [branches, setBranches] = useState<string[]>([])
|
||||
const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([])
|
||||
const [githubLoading, setGithubLoading] = useState(false)
|
||||
const [branchesLoading, setBranchesLoading] = useState(false)
|
||||
const [linearLoading, setLinearLoading] = useState(false)
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
const localInputRef = useRef<HTMLInputElement | null>(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<RowEntry[]>(() => {
|
||||
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 (
|
||||
<div className="space-y-1.5">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(next) => {
|
||||
setMode(next as SmartNameMode)
|
||||
setOpen(next !== 'text')
|
||||
requestAnimationFrame(() => localInputRef.current?.focus({ preventScroll: true }))
|
||||
}}
|
||||
className="gap-0"
|
||||
>
|
||||
<TabsList
|
||||
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">
|
||||
<Icon className="size-3.5" />
|
||||
<span className={id === 'text' ? 'sr-only' : undefined}>{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Popover open={open && mode !== 'text'} onOpenChange={setOpen}>
|
||||
<Command
|
||||
value={commandValue}
|
||||
onValueChange={setCommandValue}
|
||||
shouldFilter={false}
|
||||
className="overflow-visible bg-transparent"
|
||||
>
|
||||
<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)
|
||||
}
|
||||
return
|
||||
}
|
||||
onPlainEnter?.()
|
||||
}
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.stopPropagation()
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="h-9 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
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()}
|
||||
>
|
||||
<CommandList className="!max-h-none min-h-0 flex-1 scrollbar-sleek">
|
||||
{loading && rows.length === 0 ? (
|
||||
<div className="space-y-1 p-1">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div key={index} className="h-8 animate-pulse rounded bg-muted/40" />
|
||||
))}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{mode === 'linear' && !linearStatus.connected
|
||||
? 'Connect Linear in Settings to search issues.'
|
||||
: 'Start typing to create a name or find a source.'}
|
||||
</div>
|
||||
) : (
|
||||
<CommandGroup className="p-1">
|
||||
{rows.map((row) => (
|
||||
<CommandItem
|
||||
key={row.value}
|
||||
value={row.value}
|
||||
onSelect={() => handleSelect(row)}
|
||||
className="gap-2 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<RowIcon row={row} />
|
||||
<RowLabel row={row} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</PopoverContent>
|
||||
</Command>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowIcon({ row }: { row: RowEntry }): React.JSX.Element {
|
||||
if (row.kind === 'use-name') {
|
||||
return <CaseSensitive className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
if (row.kind === 'github') {
|
||||
return row.item.type === 'pr' ? (
|
||||
<GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)
|
||||
}
|
||||
if (row.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-[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 (
|
||||
<span className="min-w-0 truncate">
|
||||
Use <span className="font-medium text-foreground">“{row.name}”</span> as
|
||||
workspace name
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'github') {
|
||||
return (
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium text-foreground">#{row.item.number}</span> {row.item.title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'branch') {
|
||||
return <span className="min-w-0 truncate font-mono text-[11px]">{row.refName}</span>
|
||||
}
|
||||
return (
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium text-foreground">{row.issue.identifier}</span> {row.issue.title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLInputElement>) => 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<HTMLTextAreaElement>) => 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<HTMLInputElement>): 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<void> => {
|
||||
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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -158,17 +158,6 @@ export type UISlice = {
|
||||
modalData: Record<string, unknown>
|
||||
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => 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<AppState, [], [], UISlice> = (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) =>
|
||||
|
||||
@@ -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' }]
|
||||
]
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user