feat: restructure new-workspace flow and add Tasks sidebar nav (#792)

Split the "new workspace" and "tasks" entry points: the Plus button and Cmd/Ctrl+N
now open the lightweight composer modal, while the Tasks page is reached via a
dedicated Codex-style button above the Workspaces header in the sidebar.

- Add SidebarNav with a Tasks button (GH + Linear icons on the right)
- NewWorkspacePage becomes a pure tasks list (drops embedded composer card)
- Landing, AddRepoDialog, WorktreeList, SidebarHeader, App shortcut all switched
  to openModal('new-workspace-composer')
- Normalize SidebarHeader row height so it matches the Tasks button above
This commit is contained in:
Neil 2026-04-17 23:08:47 -07:00 committed by GitHub
parent 481bbeb985
commit 2499497f54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 150 additions and 135 deletions

View file

@ -91,8 +91,7 @@ function App(): React.JSX.Element {
toggleRightSidebar: s.toggleRightSidebar,
setRightSidebarOpen: s.setRightSidebarOpen,
setRightSidebarTab: s.setRightSidebarTab,
updateSettings: s.updateSettings,
openNewWorkspacePage: s.openNewWorkspacePage
updateSettings: s.updateSettings
}))
)
@ -523,14 +522,14 @@ function App(): React.JSX.Element {
return
}
// Cmd/Ctrl+N — new workspace
// Cmd/Ctrl+N — new workspace (opens the lightweight composer modal)
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') {
if (!repos.some((repo) => isGitRepoKind(repo))) {
return
}
dispatchClearModifierHints()
e.preventDefault()
actions.openNewWorkspacePage()
actions.openModal('new-workspace-composer')
return
}

View file

@ -3,7 +3,6 @@ import { AlertTriangle, ExternalLink, FolderPlus, GitBranchPlus, Star } from 'lu
import { cn } from '../lib/utils'
import { useAppStore } from '../store'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { getTaskPresetQuery } from '../lib/new-workspace'
import { ShortcutKeyCombo } from './ShortcutKeyCombo'
import logo from '../../../../resources/logo.svg'
@ -149,26 +148,10 @@ function PreflightBanner({ issues }: { issues: PreflightIssue[] }): React.JSX.El
export default function Landing(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const openModal = useAppStore((s) => s.openModal)
const prefetchWorkItems = useAppStore((s) => s.prefetchWorkItems)
const defaultTaskViewPreset = useAppStore((s) => s.settings?.defaultTaskViewPreset ?? 'all')
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
// Why: warm the exact cache key NewWorkspacePage will read on mount — the
// default-preset query must match or the page pays a full round-trip after
// click.
const handlePrefetchNewWorkspace = (): void => {
if (!canCreateWorktree) {
return
}
const firstGit = repos.find((r) => isGitRepoKind(r))
if (firstGit?.path) {
prefetchWorkItems(firstGit.path, 36, getTaskPresetQuery(defaultTaskViewPreset))
}
}
const [preflightIssues, setPreflightIssues] = useState<PreflightIssue[]>([])
useEffect(() => {
@ -264,9 +247,7 @@ export default function Landing(): React.JSX.Element {
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-accent"
disabled={!canCreateWorktree}
title={!canCreateWorktree ? 'Add a Git repo first' : undefined}
onClick={() => openNewWorkspacePage()}
onPointerEnter={handlePrefetchNewWorkspace}
onFocus={handlePrefetchNewWorkspace}
onClick={() => openModal('new-workspace-composer')}
>
<GitBranchPlus className="size-3.5" />
Create Worktree

View file

@ -1,6 +1,6 @@
/* eslint-disable max-lines -- Why: the new-workspace page keeps the composer,
/* eslint-disable max-lines -- Why: the tasks page keeps the repo selector,
task source controls, and GitHub task list co-located so the wiring between the
selected repo, the draft composer, and the work-item list stays readable in one
selected repo, the task filters, and the work-item list stays readable in one
place while this surface is still evolving. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
@ -27,13 +27,12 @@ import {
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import RepoCombobox from '@/components/repo/RepoCombobox'
import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard'
import GitHubItemDrawer from '@/components/GitHubItemDrawer'
import { cn } from '@/lib/utils'
import { LightRays } from '@/components/ui/light-rays'
import { useComposerState } from '@/hooks/useComposerState'
import { getLinkedWorkItemSuggestedName, getTaskPresetQuery } from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { isGitRepoKind } from '../../../shared/repo-kind'
import type { GitHubWorkItem, TaskViewPresetId } from '../../../shared/types'
type TaskSource = 'github' | 'linear'
@ -134,24 +133,43 @@ export default function NewWorkspacePage(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const pageData = useAppStore((s) => s.newWorkspacePageData)
const closeNewWorkspacePage = useAppStore((s) => s.closeNewWorkspacePage)
const clearNewWorkspaceDraft = useAppStore((s) => s.clearNewWorkspaceDraft)
const activeModal = useAppStore((s) => s.activeModal)
const repos = useAppStore((s) => s.repos)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const openModal = useAppStore((s) => s.openModal)
const updateSettings = useAppStore((s) => s.updateSettings)
const fetchWorkItems = useAppStore((s) => s.fetchWorkItems)
const getCachedWorkItems = useAppStore((s) => s.getCachedWorkItems)
const { cardProps, composerRef, promptTextareaRef, submit, createDisabled } = useComposerState({
persistDraft: true,
initialRepoId: pageData.preselectedRepoId,
initialName: pageData.prefilledName,
onCreated: () => {
clearNewWorkspaceDraft()
closeNewWorkspacePage()
}
})
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
// Why: resolve the initial repo from (1) explicit page data, (2) the app's
// currently active repo, (3) the first eligible repo. Falls back to '' so
// RepoCombobox renders its placeholder until the user picks one.
const resolvedInitialRepoId = useMemo(() => {
const preferred = pageData.preselectedRepoId
if (preferred && eligibleRepos.some((repo) => repo.id === preferred)) {
return preferred
}
if (activeRepoId && eligibleRepos.some((repo) => repo.id === activeRepoId)) {
return activeRepoId
}
return eligibleRepos[0]?.id ?? ''
}, [activeRepoId, eligibleRepos, pageData.preselectedRepoId])
const [repoId, setRepoId] = useState<string>(resolvedInitialRepoId)
// Why: if the repo list changes such that the current repoId is no longer
// eligible (e.g. repo removed), fall back to a valid one.
useEffect(() => {
if (!repoId && eligibleRepos[0]?.id) {
setRepoId(eligibleRepos[0].id)
return
}
if (repoId && !eligibleRepos.some((repo) => repo.id === repoId)) {
setRepoId(eligibleRepos[0]?.id ?? '')
}
}, [eligibleRepos, repoId])
const { repoId, eligibleRepos, onRepoChange } = cardProps
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
// Why: seed the preset + query from the user's saved default synchronously
@ -214,11 +232,6 @@ export default function NewWorkspacePage(): React.JSX.Element {
})
}, [activeTaskPreset, workItems])
// Autofocus prompt on mount so the user can start typing immediately.
useEffect(() => {
promptTextareaRef.current?.focus()
}, [promptTextareaRef])
useEffect(() => {
const timeout = window.setTimeout(() => {
setAppliedTaskSearch(taskSearchInput)
@ -346,29 +359,17 @@ export default function NewWorkspacePage(): React.JSX.Element {
[openModal, repoId]
)
const handleDiscardDraft = useCallback((): void => {
clearNewWorkspaceDraft()
closeNewWorkspacePage()
}, [clearNewWorkspaceDraft, closeNewWorkspacePage])
useEffect(() => {
// Why: when the global composer modal is on top, let its own scoped key
// handler own Enter/Esc so we don't double-fire (e.g. modal Esc closes
// itself *and* this handler tries to discard the underlying page draft).
if (activeModal === 'new-workspace-composer') {
return
}
// Why: when the GitHub preview sheet is open, Radix's Dialog owns Esc —
// it closes the sheet on its own. Page-level capture would otherwise fire
// first and discard the whole draft while the user just meant to dismiss
// the preview.
// first and pop the tasks page while the user just meant to dismiss the
// preview.
if (drawerWorkItem) {
return
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Enter' && event.key !== 'Escape') {
if (event.key !== 'Escape') {
return
}
@ -377,45 +378,27 @@ export default function NewWorkspacePage(): React.JSX.Element {
return
}
if (event.key === 'Escape') {
// Why: Esc should first dismiss the focused control so users can back
// out of text entry without accidentally closing the whole composer.
// Once focus is already outside an input, Esc becomes the discard shortcut.
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
event.preventDefault()
target.blur()
return
}
// Why: Esc should first dismiss the focused control so users can back
// out of text entry without accidentally closing the whole page.
// Once focus is already outside an input, Esc closes the tasks page.
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
event.preventDefault()
handleDiscardDraft()
return
}
if (!composerRef.current?.contains(target)) {
return
}
if (createDisabled) {
return
}
if (target instanceof HTMLTextAreaElement && event.shiftKey) {
target.blur()
return
}
event.preventDefault()
void submit()
closeNewWorkspacePage()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [activeModal, composerRef, createDisabled, drawerWorkItem, handleDiscardDraft, submit])
}, [closeNewWorkspacePage, drawerWorkItem])
return (
<div className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-background dark:bg-[#1a1a1a] text-foreground">
@ -452,24 +435,20 @@ export default function NewWorkspacePage(): React.JSX.Element {
variant="ghost"
size="icon"
className="size-8 rounded-full z-10"
onClick={handleDiscardDraft}
aria-label="Discard draft and go back"
onClick={closeNewWorkspacePage}
aria-label="Close tasks"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Discard draft · Esc
Close · Esc
</TooltipContent>
</Tooltip>
</div>
<div className="mx-auto flex w-full max-w-[1120px] flex-1 flex-col min-h-0 px-5 pb-5 md:px-8 md:pb-7">
<div className="flex-none flex flex-col gap-5">
<section className="mx-auto w-full max-w-[860px] border-b border-border/50 pb-5">
<NewWorkspaceComposerCard composerRef={composerRef} {...cardProps} />
</section>
<section className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
@ -506,7 +485,7 @@ export default function NewWorkspacePage(): React.JSX.Element {
<RepoCombobox
repos={eligibleRepos}
value={repoId}
onValueChange={onRepoChange}
onValueChange={setRepoId}
placeholder="Select a repository"
triggerClassName="h-11 w-full rounded-[10px] border border-border/50 bg-background/50 backdrop-blur-md px-3 text-sm font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none supports-[backdrop-filter]:bg-background/50"
/>

View file

@ -23,7 +23,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const openModal = useAppStore((s) => s.openModal)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
@ -185,14 +185,14 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
)
const handleCreateWorktree = useCallback(() => {
// Why: small delay so the Add Repo dialog close animation finishes before
// the composer modal takes focus; otherwise the dialog teardown can steal
// the first focus frame from the composer's prompt textarea.
closeModal()
// Why: small delay so the close animation finishes before the full-page create
// view takes focus; otherwise the dialog teardown can steal the first focus
// frame from the workspace form.
setTimeout(() => {
openNewWorkspacePage({ preselectedRepoId: repoId })
openModal('new-workspace-composer', { initialRepoId: repoId })
}, 150)
}, [closeModal, openNewWorkspacePage, repoId])
}, [closeModal, openModal, repoId])
const handleConfigureRepo = useCallback(() => {
closeModal()

View file

@ -4,7 +4,6 @@ import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getTaskPresetQuery } from '@/lib/new-workspace'
import {
DropdownMenu,
DropdownMenuContent,
@ -37,7 +36,7 @@ const isMac = navigator.userAgent.includes('Mac')
const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N'
const SidebarHeader = React.memo(function SidebarHeader() {
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
@ -46,29 +45,8 @@ const SidebarHeader = React.memo(function SidebarHeader() {
const sortBy = useAppStore((s) => s.sortBy)
const setSortBy = useAppStore((s) => s.setSortBy)
// Why: start warming the GitHub work-item cache on hover/focus/pointerdown so
// by the time the user's click finishes the round-trip has either completed
// or is already in-flight. Shaves ~200600ms off perceived page-load latency.
const prefetchWorkItems = useAppStore((s) => s.prefetchWorkItems)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const defaultTaskViewPreset = useAppStore((s) => s.settings?.defaultTaskViewPreset ?? 'all')
const handlePrefetch = React.useCallback(() => {
if (!canCreateWorktree) {
return
}
const activeRepo = repos.find((r) => r.id === activeRepoId && isGitRepoKind(r))
const firstGitRepo = activeRepo ?? repos.find((r) => isGitRepoKind(r))
if (firstGitRepo?.path) {
// Why: warm the exact cache key the page will read on mount — must
// match NewWorkspacePage's `initialTaskQuery` derived from the same
// default preset, otherwise the prefetch lands in a key the page
// never reads and we pay the full round-trip after click.
prefetchWorkItems(firstGitRepo.path, 36, getTaskPresetQuery(defaultTaskViewPreset))
}
}, [activeRepoId, canCreateWorktree, defaultTaskViewPreset, prefetchWorkItems, repos])
return (
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<div className="flex h-8 items-center justify-between px-4 mt-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground select-none">
Workspaces
</span>
@ -134,11 +112,9 @@ const SidebarHeader = React.memo(function SidebarHeader() {
if (!canCreateWorktree) {
return
}
openNewWorkspacePage()
openModal('new-workspace-composer')
}}
onPointerEnter={handlePrefetch}
onFocus={handlePrefetch}
aria-label="Add worktree"
aria-label="New workspace"
disabled={!canCreateWorktree}
>
<Plus className="size-3.5" />

View file

@ -0,0 +1,78 @@
import React from 'react'
import { Github, ListChecks } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getTaskPresetQuery } from '@/lib/new-workspace'
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
return (
<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>
)
}
const SidebarNav = React.memo(function SidebarNav() {
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const activeView = useAppStore((s) => s.activeView)
const repos = useAppStore((s) => s.repos)
const canBrowseTasks = repos.some((repo) => isGitRepoKind(repo))
// Why: warm the GitHub work-item cache on hover/focus so by the time the
// user's click finishes the round-trip has either completed or is already
// in-flight. Shaves ~200600ms off perceived page-load latency.
const prefetchWorkItems = useAppStore((s) => s.prefetchWorkItems)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const defaultTaskViewPreset = useAppStore((s) => s.settings?.defaultTaskViewPreset ?? 'all')
const handlePrefetch = React.useCallback(() => {
if (!canBrowseTasks) {
return
}
const activeRepo = repos.find((r) => r.id === activeRepoId && isGitRepoKind(r))
const firstGitRepo = activeRepo ?? repos.find((r) => isGitRepoKind(r))
if (firstGitRepo?.path) {
// Why: warm the exact cache key the page will read on mount — must
// match NewWorkspacePage's `initialTaskQuery` derived from the same
// default preset, otherwise the prefetch lands in a key the page
// never reads and we pay the full round-trip after click.
prefetchWorkItems(firstGitRepo.path, 36, getTaskPresetQuery(defaultTaskViewPreset))
}
}, [activeRepoId, canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repos])
const tasksActive = activeView === 'new-workspace'
return (
<div className="flex flex-col gap-0.5 px-2 pt-2 pb-1">
<button
type="button"
onClick={() => {
if (!canBrowseTasks) {
return
}
openNewWorkspacePage()
}}
onPointerEnter={handlePrefetch}
onFocus={handlePrefetch}
disabled={!canBrowseTasks}
aria-current={tasksActive ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
tasksActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground',
!canBrowseTasks && 'cursor-not-allowed opacity-50 hover:bg-transparent'
)}
>
<ListChecks className="size-4 shrink-0" />
<span className="flex-1">Tasks</span>
<span className="flex items-center gap-1 text-muted-foreground/70">
<Github className="size-3.5" aria-hidden />
<LinearIcon className="size-3.5" />
</span>
</button>
</div>
)
})
export default SidebarNav

View file

@ -404,7 +404,7 @@ const WorktreeList = React.memo(function WorktreeList() {
const sortBy = useAppStore((s) => s.sortBy)
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const openModal = useAppStore((s) => s.openModal)
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const pendingRevealWorktreeId = useAppStore((s) => s.pendingRevealWorktreeId)
@ -657,9 +657,9 @@ const WorktreeList = React.memo(function WorktreeList() {
const handleCreateForRepo = useCallback(
(repoId: string) => {
openNewWorkspacePage({ preselectedRepoId: repoId })
openModal('new-workspace-composer', { initialRepoId: repoId })
},
[openNewWorkspacePage]
[openModal]
)
const hasFilters = !!(searchQuery || showActiveOnly || filterRepoIds.length)

View file

@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import SidebarHeader from './SidebarHeader'
import SidebarNav from './SidebarNav'
import SearchBar from './SearchBar'
import GroupControls from './GroupControls'
import WorktreeList from './WorktreeList'
@ -51,6 +52,7 @@ function Sidebar(): React.JSX.Element {
)}
>
{/* Fixed controls */}
<SidebarNav />
<SidebarHeader />
<SearchBar />
<GroupControls />