Unify new workspace source selection

This commit is contained in:
Neil
2026-05-04 13:38:14 -07:00
parent cecbd1ad93
commit 574e4069e9
15 changed files with 664 additions and 1817 deletions
+1 -1
View File
@@ -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 {
+1 -3
View File
@@ -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
}
+1 -1
View File
@@ -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
+2 -7
View File
@@ -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">&ldquo;{row.name}&rdquo;</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>
)
}
+106 -12
View File
@@ -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,
+2 -7
View File
@@ -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
}
+1 -31
View File
@@ -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) =>
+7 -8
View File
@@ -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' }]
]
+4 -5
View File
@@ -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' }
}
}