diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx
index 0154701b0..d3c4d5157 100644
--- a/src/renderer/src/components/sidebar/SidebarHeader.tsx
+++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx
@@ -1,49 +1,12 @@
import React, { useCallback, useEffect, useState } from 'react'
-import { Kanban, Plus, SlidersHorizontal } from 'lucide-react'
+import { Kanban, Plus } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
-import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { isGitRepoKind } from '../../../../shared/repo-kind'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuTrigger,
- DropdownMenuCheckboxItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem
-} from '@/components/ui/dropdown-menu'
-import type { WorktreeCardProperty } from '../../../../shared/types'
-import SidebarFilter from './SidebarFilter'
+import SidebarWorkspaceOptionsMenu from './SidebarWorkspaceOptionsMenu'
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
-const GROUP_BY_OPTIONS = [
- { id: 'none', label: 'None' },
- { id: 'workspace-status', label: 'Status' },
- { id: 'pr-status', label: 'PR' },
- { id: 'repo', label: 'Repo' }
-] as const
-
-const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
- // Why: toggles the inline "Agent activity" list rendered below each
- // workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides
- // the list; there is no alternate surface.
- { id: 'inline-agents', label: 'Agent activity' }
-]
-
-const SORT_OPTIONS = [
- { id: 'name', label: 'Name', description: null },
- {
- id: 'smart',
- label: 'Smart',
- description: 'Agents that need attention, then most recent activity.'
- },
- { id: 'recent', label: 'Recent', description: null },
- { id: 'repo', label: 'Repo', description: null }
-] as const
-
const isMac = navigator.userAgent.includes('Mac')
const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N'
@@ -54,13 +17,6 @@ const SidebarHeader = React.memo(function SidebarHeader() {
const repos = useAppStore((s) => s.repos)
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
- const worktreeCardProperties = useAppStore((s) => s.worktreeCardProperties)
- const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty)
- const sortBy = useAppStore((s) => s.sortBy)
- const setSortBy = useAppStore((s) => s.setSortBy)
- const groupBy = useAppStore((s) => s.groupBy)
- const setGroupBy = useAppStore((s) => s.setGroupBy)
-
const handleWorkspaceBoardOpenChange = useCallback((open: boolean) => {
setWorkspaceBoardOpen(open)
if (!open) {
@@ -116,105 +72,10 @@ const SidebarHeader = React.memo(function SidebarHeader() {
-
-
-
-
-
-
-
-
-
- View options
-
-
-
- Group by
-
- {
- if (v) {
- setGroupBy(v as typeof groupBy)
- }
- }}
- variant="outline"
- size="sm"
- className="h-6 w-full justify-start"
- >
- {GROUP_BY_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
-
-
- Sort by
- setSortBy(v as typeof sortBy)}
- >
- {SORT_OPTIONS.map((opt) => {
- const radioItem = (
- e.preventDefault()}
- >
- {opt.label}
-
- )
- if (!opt.description) {
- return radioItem
- }
- return (
-
- {radioItem}
-
- {opt.description}
-
-
- )
- })}
-
-
-
- Show properties
- {PROPERTY_OPTIONS.map((opt) => (
- toggleWorktreeCardProperty(opt.id)}
- onSelect={(e) => e.preventDefault()}
- >
- {opt.label}
-
- ))}
-
-
+
diff --git a/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx
new file mode 100644
index 000000000..63cba1a8a
--- /dev/null
+++ b/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx
@@ -0,0 +1,268 @@
+import React, { useCallback, useMemo, useState } from 'react'
+import { Server, X } from 'lucide-react'
+import { useAppStore } from '@/store'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Command,
+ CommandEmpty,
+ CommandInput,
+ CommandItem,
+ CommandList
+} from '@/components/ui/command'
+import RepoDotLabel from '@/components/repo/RepoDotLabel'
+import { searchRepos } from '@/lib/repo-search'
+import type { Repo } from '../../../../shared/types'
+
+function projectCommandFilter(_value: string, search: string, keywords?: string[]): number {
+ const query = search.trim().toLowerCase()
+ if (!query) {
+ return 1
+ }
+
+ const [displayName = '', path = ''] = keywords ?? []
+ const displayNameIndex = displayName.toLowerCase().indexOf(query)
+ if (displayNameIndex !== -1) {
+ return 2 + 1 / (displayNameIndex + 1)
+ }
+
+ const pathIndex = path.toLowerCase().indexOf(query)
+ if (pathIndex !== -1) {
+ return 1 + 1 / (pathIndex + 1)
+ }
+
+ return 0
+}
+
+const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilterSection() {
+ const filterRepoIds = useAppStore((s) => s.filterRepoIds)
+ const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
+ const repos = useAppStore((s) => s.repos)
+
+ const [query, setQuery] = useState('')
+ const [highlightedRepoId, setHighlightedRepoId] = useState('')
+
+ const canFilterRepos = repos.length > 1
+ // Why: derive from current repos so stale ids (e.g. lingering after a repo
+ // is removed) don't inflate counts or falsely signal an applied filter.
+ const selectedRepoIdSet = useMemo(() => {
+ const set = new Set()
+ for (const repo of repos) {
+ if (filterRepoIds.includes(repo.id)) {
+ set.add(repo.id)
+ }
+ }
+ return set
+ }, [repos, filterRepoIds])
+ const selectedCount = selectedRepoIdSet.size
+ const hasRepoFilter = selectedCount > 0
+ const selectedRepos = useMemo(
+ () => repos.filter((repo) => selectedRepoIdSet.has(repo.id)),
+ [repos, selectedRepoIdSet]
+ )
+ const availableRepos = useMemo(
+ () => repos.filter((repo) => !selectedRepoIdSet.has(repo.id)),
+ [repos, selectedRepoIdSet]
+ )
+ const matchingAvailableRepos = useMemo(
+ () => searchRepos(availableRepos, query),
+ [availableRepos, query]
+ )
+
+ const handleSelectRepo = useCallback(
+ (repoId: string) => {
+ if (!filterRepoIds.includes(repoId)) {
+ setFilterRepoIds([...filterRepoIds, repoId])
+ }
+ setQuery('')
+ },
+ [filterRepoIds, setFilterRepoIds]
+ )
+
+ const handleRemoveRepo = useCallback(
+ (repoId: string) => {
+ setFilterRepoIds(filterRepoIds.filter((id) => id !== repoId))
+ },
+ [filterRepoIds, setFilterRepoIds]
+ )
+
+ const clearRepos = useCallback(() => setFilterRepoIds([]), [setFilterRepoIds])
+
+ const handleInputKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ // Why: this command is embedded in a Radix dropdown; text keys should
+ // stay in the search field instead of triggering menu typeahead.
+ if (event.key === 'Backspace' && query === '' && selectedRepos.length > 0) {
+ const lastRepo = selectedRepos.at(-1)
+ if (lastRepo) {
+ event.preventDefault()
+ event.stopPropagation()
+ handleRemoveRepo(lastRepo.id)
+ }
+ return
+ }
+
+ if (event.key === 'Enter') {
+ const highlightedRepo = availableRepos.find((repo) => repo.id === highlightedRepoId)
+ const repo = highlightedRepo ?? matchingAvailableRepos[0]
+ if (repo) {
+ event.preventDefault()
+ event.stopPropagation()
+ handleSelectRepo(repo.id)
+ }
+ return
+ }
+
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
+ event.stopPropagation()
+ }
+ },
+ [
+ availableRepos,
+ handleRemoveRepo,
+ handleSelectRepo,
+ highlightedRepoId,
+ matchingAvailableRepos,
+ query,
+ selectedRepos
+ ]
+ )
+
+ if (!canFilterRepos) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+
+ 0 ? 'Add project...' : 'Filter projects...'}
+ value={query}
+ onValueChange={setQuery}
+ onKeyDown={handleInputKeyDown}
+ className="h-8 py-2 text-xs"
+ wrapperClassName="mx-1 rounded-[7px] border border-border/70 px-2"
+ iconClassName="h-3.5 w-3.5"
+ />
+
+
+ {hasRepoFilter ? 'No unselected projects match' : 'No projects match'}
+
+ {availableRepos.map((repo) => (
+ handleSelectRepo(repo.id)}
+ className="mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1 text-[12px] leading-5 font-medium data-[selected=true]:bg-black/8 dark:data-[selected=true]:bg-white/14"
+ >
+
+
+ {repo.connectionId && (
+
+
+ SSH
+
+ )}
+
+
+ ))}
+
+
+ >
+ )
+})
+
+function SelectedProjectPills({
+ selectedRepos,
+ onRemoveRepo
+}: {
+ selectedRepos: Repo[]
+ onRemoveRepo: (repoId: string) => void
+}) {
+ if (selectedRepos.length === 0) {
+ return null
+ }
+
+ return (
+
+ {selectedRepos.map((repo) => (
+
+
+
+
+ ))}
+
+ )
+}
+
+function ProjectFilterHeader({
+ hasRepoFilter,
+ selectedCount,
+ onClear
+}: {
+ hasRepoFilter: boolean
+ selectedCount: number
+ onClear: () => void
+}) {
+ return (
+
+
+ Projects
+ {hasRepoFilter && (
+
+ {selectedCount}
+
+ )}
+
+
+
+ )
+}
+
+export default SidebarRepositoryFilterSection
diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx
new file mode 100644
index 000000000..ffe0a610a
--- /dev/null
+++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx
@@ -0,0 +1,74 @@
+import React from 'react'
+import { Activity, GitBranch } from 'lucide-react'
+import { useAppStore } from '@/store'
+import { cn } from '@/lib/utils'
+
+const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilterSection() {
+ const showActiveOnly = useAppStore((s) => s.showActiveOnly)
+ const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly)
+ const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
+ const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
+
+ return (
+ <>
+
+ Filters
+
+ }
+ label="Active only"
+ checked={showActiveOnly}
+ onChange={setShowActiveOnly}
+ />
+ }
+ label="Hide default branch"
+ checked={hideDefaultBranchWorkspace}
+ onChange={setHideDefaultBranchWorkspace}
+ />
+ >
+ )
+})
+
+function FilterToggleRow({
+ icon,
+ label,
+ checked,
+ onChange
+}: {
+ icon: React.ReactNode
+ label: string
+ checked: boolean
+ onChange: (next: boolean) => void
+}) {
+ return (
+
+ )
+}
+
+export default SidebarWorkspaceFilterSection
diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx
new file mode 100644
index 000000000..31893d0e0
--- /dev/null
+++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx
@@ -0,0 +1,257 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { SlidersHorizontal } from 'lucide-react'
+import { useAppStore } from '@/store'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import type { WorktreeCardProperty } from '../../../../shared/types'
+import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection'
+import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection'
+
+type SidebarWorkspaceOptionsMenuProps = {
+ preserveWorkspaceBoardOpen?: boolean
+ onMenuOpenChange?: (open: boolean) => void
+}
+
+const GROUP_BY_OPTIONS = [
+ { id: 'none', label: 'None' },
+ { id: 'workspace-status', label: 'Status' },
+ { id: 'pr-status', label: 'PR' },
+ { id: 'repo', label: 'Repo' }
+] as const
+
+const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
+ // Why: toggles the inline "Agent activity" list rendered below each
+ // workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides
+ // the list; there is no alternate surface.
+ { id: 'inline-agents', label: 'Agent activity' }
+]
+
+const SORT_OPTIONS = [
+ { id: 'name', label: 'Name', description: null },
+ {
+ id: 'smart',
+ label: 'Smart',
+ description: 'Agents that need attention, then most recent activity.'
+ },
+ { id: 'recent', label: 'Recent', description: null },
+ { id: 'repo', label: 'Repo', description: null }
+] as const
+
+const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({
+ preserveWorkspaceBoardOpen = false,
+ onMenuOpenChange
+}: SidebarWorkspaceOptionsMenuProps) {
+ const showActiveOnly = useAppStore((s) => s.showActiveOnly)
+ const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
+ const filterRepoIds = useAppStore((s) => s.filterRepoIds)
+ const repos = useAppStore((s) => s.repos)
+ const worktreeCardProperties = useAppStore((s) => s.worktreeCardProperties)
+ const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty)
+ const sortBy = useAppStore((s) => s.sortBy)
+ const setSortBy = useAppStore((s) => s.setSortBy)
+ const groupBy = useAppStore((s) => s.groupBy)
+ const setGroupBy = useAppStore((s) => s.setGroupBy)
+
+ const [open, setOpen] = useState(false)
+
+ const handleOpenChange = useCallback(
+ (next: boolean) => {
+ setOpen(next)
+ onMenuOpenChange?.(next)
+ },
+ [onMenuOpenChange]
+ )
+
+ useEffect(() => {
+ return () => {
+ onMenuOpenChange?.(false)
+ }
+ }, [onMenuOpenChange])
+
+ // Why: derive from current repos so stale ids (e.g. lingering after a repo
+ // is removed) don't inflate counts or falsely signal an applied filter.
+ const selectedCount = useMemo(() => {
+ let count = 0
+ for (const repo of repos) {
+ if (filterRepoIds.includes(repo.id)) {
+ count += 1
+ }
+ }
+ return count
+ }, [repos, filterRepoIds])
+ const hasRepoFilter = selectedCount > 0
+ const hasAnyFilter = showActiveOnly || hideDefaultBranchWorkspace || hasRepoFilter
+ const activeFilterCount =
+ (showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
+ const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}`
+ const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort'
+ const visiblePropertyCount = PROPERTY_OPTIONS.filter((opt) =>
+ worktreeCardProperties.includes(opt.id)
+ ).length
+
+ return (
+
+
+
+
+
+
+
+
+ {hasAnyFilter ? `Workspace options (${activeFilterLabel})` : 'Workspace options'}
+
+
+
+ Group by
+
+ {
+ if (v) {
+ setGroupBy(v as typeof groupBy)
+ }
+ }}
+ variant="outline"
+ size="sm"
+ className="h-6 w-full justify-stretch"
+ >
+ {GROUP_BY_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+
+ Sort by
+ {sortLabel}
+
+
+
+ setSortBy(v as typeof sortBy)}
+ >
+ {SORT_OPTIONS.map((opt) => {
+ const radioItem = (
+ e.preventDefault()}
+ >
+ {opt.label}
+
+ )
+ if (!opt.description) {
+ return radioItem
+ }
+ return (
+
+ {radioItem}
+
+ {opt.description}
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ Show properties
+ {visiblePropertyCount > 0 && (
+
+ {visiblePropertyCount}
+
+ )}
+
+
+
+ {PROPERTY_OPTIONS.map((opt) => (
+ toggleWorktreeCardProperty(opt.id)}
+ onSelect={(e) => e.preventDefault()}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+
+ )
+})
+
+export default SidebarWorkspaceOptionsMenu