Files
goodbuddy/src/renderer/src/ProjectSwitcher.tsx
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

1903 lines
64 KiB
TypeScript

import {
Archive,
ArrowUp,
Check,
ChevronDown,
CircleAlert,
Folder,
FolderOpen,
LoaderCircle,
RadioTower,
RefreshCw,
Settings,
Trash2,
X
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, memo } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type {
AssistantProject,
ProjectCreateInput
} from '../../shared/assistant-contracts'
import {
projectChannelLabels
} from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
import {
remoteProjectRootPathSchema,
type RemoteProjectSavePhase
} from '../../shared/remote-project-candidate-contracts'
import type {
SshDirectoryBrowseResult,
SshHostAgentConnectionState,
SshHostsSnapshot
} from '../../shared/ssh-host-contracts'
import type {
RemoteProjectRecoveryState
} from '../../shared/remote-project-recovery-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import {
resolveRuntimeChoice,
type RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import {
channelProjectDraft,
ChannelProjectSettingsFields
} from './ChannelProjectSettingsFields'
import { getProjectDisplayText } from './project-display'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import { ProjectActivityCounts } from './ProjectActivity'
import type { ProjectActivityCountsProps } from './ProjectActivity'
import { ChannelIcon } from './ChannelIcon'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
import { WorkspaceMenu } from './WorkspaceMenu'
import type { ConversationActivity } from './conversation-activity'
import type { ConversationStore } from './conversation-store'
import { displayErrorMessage } from './error-message'
import type { AppNotificationInput } from './notifications'
type ProjectSwitcherProps = {
projects: AssistantProject[]
activeProjectId: string
conversationStore: ConversationStore
activities: ConversationActivity[]
visible?: boolean
onOpenConversation: (conversationId: string) => void
onNewConversation: (projectId: string) => void
activityByProjectId?: Record<string, ProjectActivityCountsProps>
remoteProjectsEnabled?: boolean
runtimeSettings?: RuntimeSettings
onArchive: (projectId: string) => Promise<void>
onRestore: (projectId: string) => Promise<void>
notify: (input: AppNotificationInput) => void
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
onDelete: (projectId: string, confirmation: string) => Promise<void>
onSelect: (projectId: string) => void
onSelectRoot: () => Promise<string | undefined>
onRemoteCommitted: (project: AssistantProject) => Promise<void>
onRetryRecovery?: (projectId: string) => Promise<void>
onUpdate: (
projectId: string,
input: ProjectCreateInput
) => Promise<AssistantProject>
recoveryByProjectId?: Readonly<
Record<string, RemoteProjectRecoveryState>
>
}
const remoteProjectPhases: readonly RemoteProjectSavePhase[] = [
'host',
'agent',
'workspace',
'runtime',
'saving'
]
type RemoteHostReadiness =
| { status: 'ready' }
| { status: 'unready' }
const emptyHosts: SshHostsSnapshot['hosts'] = []
function ProjectRecoveryStatus({
state
}: {
state: RemoteProjectRecoveryState
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const working =
state.stage === 'network' ||
state.stage === 'agent' ||
state.stage === 'runtime' ||
state.stage === 'cursor'
const text =
state.stage === 'cursor'
? t('projectSwitcher.recovery.stages.cursor', {
current: state.current
})
: state.stage === 'failed'
? t('projectSwitcher.recovery.failed', {
message: state.message
})
: t(`projectSwitcher.recovery.stages.${state.stage}`)
return (
<span
aria-busy={working ? 'true' : undefined}
aria-live={state.stage === 'failed' ? 'assertive' : 'polite'}
className={`project-recovery-status project-recovery-status--${state.stage}`}
role={state.stage === 'failed' ? 'alert' : 'status'}
>
{working ? (
<LoaderCircle aria-hidden="true" size={13} />
) : state.stage === 'completed' ? (
<Check aria-hidden="true" size={13} />
) : (
<CircleAlert aria-hidden="true" size={13} />
)}
<span>{text}</span>
</span>
)
}
function RemoteProjectProgress({
phase,
phases = remoteProjectPhases,
title
}: {
phase: RemoteProjectSavePhase
phases?: readonly RemoteProjectSavePhase[]
title: string
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const phaseIndex = phases.indexOf(phase)
return (
<section
aria-live="polite"
className="remote-project-progress"
role="status"
>
<div className="remote-project-progress__header">
<strong>{title}</strong>
<span>
{t('projectSwitcher.remote.phaseStatus', {
phase: t(
`projectSwitcher.remote.phases.${phase}`
)
})}
</span>
</div>
<progress
aria-label={t(
'projectSwitcher.remote.progress.progressLabel'
)}
max={phases.length}
value={phaseIndex + 1}
/>
<ol
aria-label={t(
'projectSwitcher.remote.progress.stepsLabel'
)}
>
{phases.map((candidate, index) => {
const state =
index < phaseIndex
? 'complete'
: index === phaseIndex
? 'current'
: 'pending'
return (
<li
aria-current={
state === 'current' ? 'step' : undefined
}
data-state={state}
key={candidate}
>
<span aria-hidden="true">
{state === 'complete' ? (
<Check size={11} />
) : (
index + 1
)}
</span>
<small>
{t(
`projectSwitcher.remote.phases.${candidate}`
)}
</small>
</li>
)
})}
</ol>
</section>
)
}
function isRemoteAbsolutePath(value: string): boolean {
return remoteProjectRootPathSchema.safeParse(value).success
}
function ProjectSwitcherView({
projects,
activeProjectId,
conversationStore,
activities,
visible = true,
onOpenConversation,
onNewConversation,
activityByProjectId = {},
remoteProjectsEnabled = false,
runtimeSettings,
onArchive,
onRestore,
notify,
onCreate,
onDelete,
onRemoteCommitted,
onRetryRecovery,
onSelect,
onSelectRoot,
onUpdate,
recoveryByProjectId = {}
}: ProjectSwitcherProps): React.JSX.Element {
const { t } = useTranslation('workspace')
useLayoutEffect(() => {
conversationStore.recordActivityStatuses(activities)
}, [activities, conversationStore])
const [dialogMode, setDialogMode] = useState<
'create' | 'settings'
>()
const [settingsProjectId, setSettingsProjectId] =
useState<string>()
const [saving, setSaving] = useState(false)
const [archiving, setArchiving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleteConfirmation, setDeleteConfirmation] = useState('')
const [error, setError] = useState<string>()
const [executionSpaceKind, setExecutionSpaceKind] = useState<
'local' | 'ssh'
>('local')
const [sshHosts, setSshHosts] = useState<SshHostsSnapshot>()
const [
agentConnectionStatusByHostId,
setAgentConnectionStatusByHostId
] = useState<Record<string, SshHostAgentConnectionState>>({})
const agentConnectionStatusRevisionRef = useRef(0)
const liveAgentConnectionStatusRef = useRef(
new Map<
string,
{
revision: number
state: SshHostAgentConnectionState
}
>()
)
const pendingSshHostsSnapshotsRef = useRef(0)
const [loadingSshHosts, setLoadingSshHosts] = useState(false)
const [remoteHostReadiness, setRemoteHostReadiness] = useState<
Record<string, RemoteHostReadiness>
>({})
const [remoteHostId, setRemoteHostId] = useState('')
const [remoteRootPath, setRemoteRootPath] = useState('')
const [remoteSaving, setRemoteSaving] = useState(false)
const [remoteSavePhase, setRemoteSavePhase] =
useState<RemoteProjectSavePhase>()
const [directoryPickerOpen, setDirectoryPickerOpen] = useState(false)
const [directoryPickerLoading, setDirectoryPickerLoading] =
useState(false)
const [directoryPickerError, setDirectoryPickerError] =
useState<string>()
const [remoteDirectoryListing, setRemoteDirectoryListing] =
useState<SshDirectoryBrowseResult>()
const directoryPickerTriggerRef = useRef<HTMLButtonElement>(null)
const directoryPickerRef = useRef<HTMLDivElement>(null)
const directoryBrowseRequestRef = useRef(0)
const projectPickerRef = useRef<HTMLDivElement>(null)
const projectPickerButtonRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
const dialogRef = useRef<HTMLDivElement>(null)
const restoreFocusTarget = useRef<
'create' | 'picker' | undefined
>(undefined)
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
if (!visible && projectMenuOpen) setProjectMenuOpen(false)
const closeProjectMenu = useCallback(() => setProjectMenuOpen(false), [])
const visibleActivities = useMemo(() => {
const visibleIds = new Set(projects.filter((project) => project.kind === 'channel' ||
project.executionSpace.kind !== 'ssh' || remoteProjectsEnabled).map((project) => project.id))
return activities.filter((activity) => !activity.projectId || visibleIds.has(activity.projectId))
}, [activities, projects, remoteProjectsEnabled])
const activityCounts = useMemo(() => {
const counts = { attention: 0, running: 0, completed: 0 }
for (const activity of visibleActivities) {
counts[activity.status === 'running' || activity.status === 'completed' ? activity.status : 'attention'] += 1
}
return counts
}, [visibleActivities])
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
useState<string>()
const [dismissedRecoveryByProjectId, setDismissedRecoveryByProjectId] =
useState<Record<string, string>>({})
const recoveryCompletionTimers = useRef(
new Map<string, { requestId: string; timer: ReturnType<typeof setTimeout> }>()
)
useEffect(() => {
const timers = recoveryCompletionTimers.current
for (const [projectId, entry] of timers) {
const state = recoveryByProjectId[projectId]
if (state?.stage !== 'completed' || state.requestId !== entry.requestId) {
clearTimeout(entry.timer)
timers.delete(projectId)
}
}
for (const [projectId, state] of Object.entries(recoveryByProjectId)) {
if (state.stage !== 'completed' || timers.has(projectId)) {
continue
}
// Keep the request entry after expiry so menu mounts and snapshots cannot replay it.
timers.set(projectId, {
requestId: state.requestId,
timer: setTimeout(() => {
setDismissedRecoveryByProjectId((current) => ({
...current,
[projectId]: state.requestId
}))
}, 3_000)
})
}
}, [recoveryByProjectId])
useEffect(() => {
const timers = recoveryCompletionTimers.current
return () => {
for (const entry of timers.values()) {
clearTimeout(entry.timer)
}
timers.clear()
}
}, [])
const visibleRecoveryByProjectId = useMemo(() => Object.fromEntries(
Object.entries(recoveryByProjectId).filter(
([projectId, state]) =>
state.stage !== 'completed' ||
dismissedRecoveryByProjectId[projectId] !== state.requestId
)
), [recoveryByProjectId, dismissedRecoveryByProjectId])
const applySshHostsSnapshot = useCallback(
(
snapshot: SshHostsSnapshot,
requestRevision: number
): void => {
setSshHosts(snapshot)
const next = {
...(snapshot.agentConnectionStatusByHostId ?? {})
}
for (const [
hostId,
update
] of liveAgentConnectionStatusRef.current) {
if (update.revision <= requestRevision) {
continue
}
if (update.state === 'disconnected') {
delete next[hostId]
} else {
next[hostId] = update.state
}
}
setAgentConnectionStatusByHostId((current) => {
const currentKeys = Object.keys(current)
const nextKeys = Object.keys(next)
return currentKeys.length === nextKeys.length &&
nextKeys.every((hostId) => current[hostId] === next[hostId])
? current
: next
})
},
[]
)
const beginSshHostsSnapshot = useCallback((): number => {
pendingSshHostsSnapshotsRef.current += 1
return agentConnectionStatusRevisionRef.current
}, [])
const finishSshHostsSnapshot = useCallback((): void => {
pendingSshHostsSnapshotsRef.current = Math.max(
0,
pendingSshHostsSnapshotsRef.current - 1
)
if (pendingSshHostsSnapshotsRef.current === 0) {
liveAgentConnectionStatusRef.current.clear()
}
}, [])
const retryRecovery = useCallback(
(projectId: string): void => {
if (!onRetryRecovery) {
return
}
setRetryingRecoveryProjectId(projectId)
void onRetryRecovery(projectId)
.catch(() => undefined)
.finally(() =>
setRetryingRecoveryProjectId((current) =>
current === projectId ? undefined : current
)
)
},
[onRetryRecovery]
)
const [draft, setDraft] = useState<ProjectCreateInput>({
name: '',
description: '',
rootPath: '',
})
const activeProject = projects.find(
(project) => project.id === activeProjectId
)
const activeProjectDisplay = activeProject
? getProjectDisplayText(activeProject, t)
: undefined
const activeProjectRecovery =
activeProject?.executionSpace.kind === 'ssh'
? visibleRecoveryByProjectId[activeProject.id]
: undefined
const settingsProject = projects.find(
(project) => project.id === settingsProjectId
)
const userProjects = useMemo(() => projects.filter((project) =>
project.kind === 'user' && (project.executionSpace.kind === 'local' || remoteProjectsEnabled)
), [projects, remoteProjectsEnabled])
const busy =
saving ||
remoteSaving ||
archiving ||
deleting
const remoteFieldsDisabled = busy
const remoteDirectoryBrowseDisabled =
remoteFieldsDisabled ||
loadingSshHosts ||
!remoteHostId ||
remoteHostReadiness[remoteHostId]?.status !== 'ready' ||
dialogMode === 'settings'
const selectedRemoteHostReadiness =
remoteHostReadiness[remoteHostId]
const remoteDraftReady =
Boolean(
draft.name.trim() &&
remoteHostId &&
selectedRemoteHostReadiness?.status === 'ready' &&
isRemoteAbsolutePath(remoteRootPath)
)
useEffect(() => {
if (
!remoteProjectsEnabled ||
!dialogMode ||
executionSpaceKind !== 'ssh'
) {
return
}
let active = true
const api = window.goodbuddy?.sshHosts
if (!api) {
queueMicrotask(() => {
if (active) {
setError(
t('projectSwitcher.remote.errors.hostsUnavailable')
)
setLoadingSshHosts(false)
}
})
return
}
const requestRevision = beginSshHostsSnapshot()
void api
.getSnapshot()
.then(
(snapshot) => {
if (!active) {
return
}
applySshHostsSnapshot(snapshot, requestRevision)
const readiness: Record<string, RemoteHostReadiness> =
Object.fromEntries(
snapshot.hosts.map((host) => [
host.id,
host.hostKey.state === 'verified' &&
host.lastValidatedAt !== undefined
? { status: 'ready' as const }
: { status: 'unready' as const }
])
)
setRemoteHostReadiness(readiness)
setRemoteHostId((current) =>
current &&
snapshot.hosts.some((host) => host.id === current)
? current
: snapshot.hosts.find(
(host) =>
readiness[host.id]?.status === 'ready'
)?.id ??
snapshot.hosts[0]?.id ??
''
)
setLoadingSshHosts(false)
},
() => {
if (active) {
setError(t('projectSwitcher.remote.errors.loadHosts'))
setLoadingSshHosts(false)
}
}
)
.finally(finishSshHostsSnapshot)
return () => {
active = false
}
}, [
applySshHostsSnapshot,
beginSshHostsSnapshot,
dialogMode,
executionSpaceKind,
finishSshHostsSnapshot,
remoteProjectsEnabled,
t
])
useEffect(() => {
if (!remoteProjectsEnabled || !projectMenuOpen) {
return
}
const api = window.goodbuddy?.sshHosts
if (!api) {
return
}
let active = true
const requestRevision = beginSshHostsSnapshot()
void api
.getSnapshot()
.then(
(snapshot) => {
if (!active) {
return
}
applySshHostsSnapshot(snapshot, requestRevision)
},
() => undefined
)
.finally(finishSshHostsSnapshot)
return () => {
active = false
}
}, [
applySshHostsSnapshot,
beginSshHostsSnapshot,
finishSshHostsSnapshot,
projectMenuOpen,
remoteProjectsEnabled
])
useEffect(() => {
const api = remoteProjectsEnabled
? window.goodbuddy?.sshHosts
: undefined
if (!api) {
return
}
return api.onAgentConnectionStatus(({ hostId, state }) => {
const revision =
agentConnectionStatusRevisionRef.current + 1
agentConnectionStatusRevisionRef.current = revision
liveAgentConnectionStatusRef.current.set(hostId, {
revision,
state
})
setAgentConnectionStatusByHostId((current) => {
if (state === 'disconnected') {
if (!(hostId in current)) {
return current
}
const next = { ...current }
delete next[hostId]
return next
}
return current[hostId] === state
? current
: { ...current, [hostId]: state }
})
})
}, [remoteProjectsEnabled])
useEffect(() => {
if (
!remoteProjectsEnabled ||
!dialogMode ||
executionSpaceKind !== 'ssh'
) {
return
}
const remoteApi = window.goodbuddy.projects.remote
if (!remoteApi) {
return
}
return remoteApi.onSaveProgress((progress) =>
setRemoteSavePhase(progress.phase)
)
}, [dialogMode, executionSpaceKind, remoteProjectsEnabled])
useEffect(() => {
if (
remoteProjectsEnabled ||
dialogMode !== 'settings' ||
settingsProject?.executionSpace.kind !== 'ssh'
) {
return
}
let active = true
queueMicrotask(() => {
if (active) {
setConfirmingDelete(false)
setDeleteConfirmation('')
setDialogMode(undefined)
}
})
return () => {
active = false
}
}, [dialogMode, remoteProjectsEnabled, settingsProject])
const resetRemoteDraft = (): void => {
directoryBrowseRequestRef.current += 1
setSshHosts(undefined)
setLoadingSshHosts(false)
setRemoteHostReadiness({})
setRemoteHostId('')
setRemoteRootPath('')
setRemoteSaving(false)
setRemoteSavePhase(undefined)
setDirectoryPickerOpen(false)
setDirectoryPickerLoading(false)
setDirectoryPickerError(undefined)
setRemoteDirectoryListing(undefined)
}
const cancelRemoteDirectoryBrowse = useCallback((): void => {
directoryBrowseRequestRef.current += 1
const api = remoteProjectsEnabled
? window.goodbuddy.sshHosts
: undefined
if (api) {
void api.cancelDirectoryBrowse().catch(() => undefined)
}
setDirectoryPickerOpen(false)
setDirectoryPickerLoading(false)
setDirectoryPickerError(undefined)
setRemoteDirectoryListing(undefined)
requestAnimationFrame(() =>
directoryPickerTriggerRef.current?.focus()
)
}, [remoteProjectsEnabled])
const browseRemoteDirectories = async (
path?: string
): Promise<void> => {
if (!remoteProjectsEnabled) {
return
}
const api = window.goodbuddy.sshHosts
if (!api || !remoteHostId) {
return
}
const requestId = ++directoryBrowseRequestRef.current
setDirectoryPickerLoading(true)
setDirectoryPickerError(undefined)
try {
const response = await api.browseDirectories(remoteHostId, path)
if (requestId !== directoryBrowseRequestRef.current) {
return
}
setRemoteDirectoryListing(response)
} catch (reason) {
if (requestId !== directoryBrowseRequestRef.current) {
return
}
const message = displayErrorMessage(
reason,
t('projectSwitcher.remote.directoryPicker.unknownError')
)
setDirectoryPickerError(
t('projectSwitcher.remote.directoryPicker.loadError', {
message
})
)
} finally {
if (requestId === directoryBrowseRequestRef.current) {
setDirectoryPickerLoading(false)
}
}
}
useEffect(() => {
if (!directoryPickerOpen) {
return
}
const focusFrame = requestAnimationFrame(() => {
const target =
directoryPickerRef.current?.querySelector<HTMLButtonElement>(
'button:not(:disabled)'
)
target?.focus()
})
return () => cancelAnimationFrame(focusFrame)
}, [directoryPickerOpen])
useEffect(() => {
if (!dialogMode) return
return activateModalFocus(
() => dialogRef.current?.querySelector<HTMLElement>('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)') ??
dialogRef.current?.querySelector<HTMLElement>('button:not(:disabled)') ?? null,
() => projectPickerButtonRef.current
)
}, [dialogMode])
useEffect(() => {
if (!dialogMode) {
if (restoreFocusTarget.current === 'create') {
projectPickerButtonRef.current?.focus()
} else if (restoreFocusTarget.current === 'picker') {
projectPickerButtonRef.current?.focus()
}
restoreFocusTarget.current = undefined
return
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.defaultPrevented || dialogRef.current?.closest('[inert]')) return
if (directoryPickerOpen) {
if (event.key === 'Escape') {
event.preventDefault()
cancelRemoteDirectoryBrowse()
return
}
trapTabFocus(event, directoryPickerRef.current)
return
}
if (event.key === 'Escape' && !archiving && !deleting) {
event.preventDefault()
if (remoteProjectsEnabled && remoteSaving) {
void window.goodbuddy.projects.remote
.cancelCurrent()
.catch(() => undefined)
}
setConfirmingDelete(false)
setDeleteConfirmation('')
setDialogMode(undefined)
return
}
trapTabFocus(event, dialogRef.current)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [
archiving,
cancelRemoteDirectoryBrowse,
deleting,
dialogMode,
directoryPickerOpen,
remoteProjectsEnabled,
remoteSaving
])
const closeDialog = (): void => {
directoryBrowseRequestRef.current += 1
if (directoryPickerOpen) {
const api = remoteProjectsEnabled
? window.goodbuddy.sshHosts
: undefined
if (api) {
void api.cancelDirectoryBrowse().catch(() => undefined)
}
}
if (remoteProjectsEnabled && remoteSaving) {
void window.goodbuddy.projects.remote
.cancelCurrent()
.catch(() => undefined)
}
setConfirmingDelete(false)
setDeleteConfirmation('')
setDialogMode(undefined)
setSettingsProjectId(undefined)
}
// The project form always shows a concrete execution mode, so save exactly that mode.
const withProjectProvider = (
layer: RuntimeSelectionLayer | undefined,
remote: boolean
): RuntimeSelectionLayer | undefined =>
runtimeSettings
? {
...layer,
provider: resolveRuntimeChoice(runtimeSettings, { project: layer }, { remote }).provider
}
: layer
const openProjectSettings = (project: AssistantProject): void => {
if (
!remoteProjectsEnabled &&
project.executionSpace.kind === 'ssh'
) {
return
}
setProjectMenuOpen(false)
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
resetRemoteDraft()
setSettingsProjectId(project.id)
const nextExecutionSpaceKind =
project.executionSpace.kind === 'ssh' ? 'ssh' : 'local'
setExecutionSpaceKind(nextExecutionSpaceKind)
if (project.executionSpace.kind === 'ssh') {
setLoadingSshHosts(true)
setRemoteHostId(project.executionSpace.hostId)
setRemoteRootPath(project.executionSpace.remoteRootPath)
}
const nextDraft: ProjectCreateInput = {
name: project.name,
description: project.description,
rootPath: project.rootPath,
// Keep "follow global" as absent so saving never freezes today's default.
runtimeSelection: project.runtimeSelection
}
setDraft(
project.kind === 'channel' && runtimeSettings
? channelProjectDraft(nextDraft, runtimeSettings)
: nextDraft
)
restoreFocusTarget.current = 'picker'
setDialogMode('settings')
}
const validateRemoteDraft = (): boolean => {
if (!remoteHostId) {
setError(t('projectSwitcher.remote.validation.host'))
return false
}
if (remoteHostReadiness[remoteHostId]?.status !== 'ready') {
setError(
t('projectSwitcher.remote.readiness.saveBlocked')
)
return false
}
if (!isRemoteAbsolutePath(remoteRootPath)) {
setError(t('projectSwitcher.remote.validation.root'))
return false
}
return true
}
const saveRemoteProject = async (): Promise<void> => {
setError(undefined)
if (
!remoteProjectsEnabled ||
!draft.name.trim() ||
!validateRemoteDraft()
) {
return
}
setRemoteSaving(true)
setRemoteSavePhase('host')
try {
const commonDraft = {
name: draft.name,
description: draft.description,
runtimeSelection: withProjectProvider(draft.runtimeSelection, true),
hostId: remoteHostId,
remoteRootPath
}
const result =
await window.goodbuddy.projects.remote.save(
dialogMode === 'settings' && settingsProject
? {
intent: 'update',
draft: {
...commonDraft,
projectId: settingsProject.id
}
}
: {
intent: 'create',
draft: commonDraft
}
)
await onRemoteCommitted(result)
closeDialog()
} catch (reason) {
setError(
displayErrorMessage(
reason,
t('projectSwitcher.remote.errors.save')
)
)
} finally {
setRemoteSaving(false)
setRemoteSavePhase(undefined)
}
}
const save = async (): Promise<void> => {
if (dialogMode === 'create' && !draft.rootPath.trim()) return
setSaving(true)
setError(undefined)
try {
const input = {
...draft,
runtimeSelection: withProjectProvider(draft.runtimeSelection, false)
}
if (dialogMode === 'settings' && settingsProject) {
await onUpdate(settingsProject.id, input)
} else {
await onCreate(input)
}
closeDialog()
} catch (reason) {
setError(
displayErrorMessage(
reason,
dialogMode === 'settings'
? t('projectSwitcher.errors.save')
: t('projectSwitcher.errors.create')
)
)
} finally {
setSaving(false)
}
}
const selectRoot = async (): Promise<void> => {
setError(undefined)
try {
const rootPath = await onSelectRoot()
if (rootPath) {
setDraft((current) => ({
...current,
rootPath
}))
}
} catch (reason) {
setError(
displayErrorMessage(
reason,
t('projectSwitcher.errors.selectRoot')
)
)
}
}
const archive = async (): Promise<void> => {
if (!settingsProject) {
return
}
setArchiving(true)
setError(undefined)
try {
await onArchive(settingsProject.id)
closeDialog()
} catch (reason) {
setError(
displayErrorMessage(
reason,
t('projectSwitcher.errors.archive')
)
)
} finally {
setArchiving(false)
}
}
const deleteProject = async (): Promise<void> => {
if (!settingsProject) {
return
}
setDeleting(true)
setError(undefined)
try {
await onDelete(settingsProject.id, deleteConfirmation)
closeDialog()
} catch (reason) {
setError(
displayErrorMessage(
reason,
t('projectSwitcher.errors.delete')
)
)
} finally {
setDeleting(false)
}
}
const renderProjectMenuItem = (
project: AssistantProject
): React.JSX.Element => {
const ProjectIcon: LucideIcon = project.kind === 'channel' ? RadioTower : Folder
const selected = project.id === activeProjectId
const projectDisplay = getProjectDisplayText(project, t)
const recovery =
project.executionSpace.kind === 'ssh'
? visibleRecoveryByProjectId[project.id]
: undefined
const detail =
project.kind === 'channel'
? t('projectSwitcher.selector.remoteDetail', {
channel: project.channel
? projectChannelLabels[project.channel]
: t('projectSwitcher.selector.remoteChannel'),
path: project.rootPath
})
: project.executionSpace.kind === 'ssh'
? t('projectSwitcher.selector.remotePath', {
path: project.rootPath
})
: t('projectSwitcher.selector.localDetail', {
path: project.rootPath
})
return (
<div
className={`project-switcher__menu-item${
recovery?.stage === 'failed' && recovery.retryable
? ' project-switcher__menu-item--with-retry'
: ''
}`}
key={project.id}
>
<button
aria-checked={selected}
onClick={() => {
onSelect(project.id)
setProjectMenuOpen(false)
}}
role="menuitemradio"
tabIndex={0}
type="button"
>
{project.kind === 'channel' && project.channel ? (
<ChannelIcon channel={project.channel} />
) : (
<ProjectIcon aria-hidden="true" size={16} />
)}
<span>
<span className="project-switcher__project-heading">
<b>{projectDisplay.name}</b>
</span>
{activityByProjectId[project.id] && (
<ProjectActivityCounts {...activityByProjectId[project.id]!} />
)}
<small>{detail}</small>
{recovery && <ProjectRecoveryStatus state={recovery} />}
</span>
{selected && <Check aria-hidden="true" size={14} />}
</button>
{recovery?.stage === 'failed' &&
recovery.retryable &&
onRetryRecovery && (
<button
aria-label={t(
'projectSwitcher.recovery.retryNamed',
{ name: projectDisplay.name }
)}
className="project-switcher__menu-retry"
disabled={retryingRecoveryProjectId === project.id}
onClick={() => retryRecovery(project.id)}
role="menuitem"
tabIndex={0}
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
)}
<button
aria-label={t(
'projectSwitcher.selector.settingsNamed',
{ name: projectDisplay.name }
)}
className="project-switcher__menu-settings"
onClick={() => openProjectSettings(project)}
role="menuitem"
tabIndex={0}
type="button"
>
<Settings aria-hidden="true" size={14} />
</button>
</div>
)
}
return (
<div className="project-switcher">
<div className="project-switcher__row">
<div
className="project-switcher__picker"
ref={projectPickerRef}
>
<button
aria-expanded={projectMenuOpen}
aria-haspopup="dialog"
aria-controls={projectMenuOpen ? menuId : undefined}
aria-label={t('projectSwitcher.selector.ariaLabel')}
aria-describedby={`${menuId}-activity${activeProjectRecovery ? ` ${menuId}-recovery` : ''}`}
className="project-switcher__trigger project-switcher__control"
onClick={(event) => {
event.currentTarget.focus()
setProjectMenuOpen((open) => !open)
}}
onKeyDown={(event) => {
if (
!projectMenuOpen &&
(event.key === 'ArrowDown' ||
event.key === 'Enter' ||
event.key === ' ')
) {
event.preventDefault()
setProjectMenuOpen(true)
}
}}
ref={projectPickerButtonRef}
title={activeProjectDisplay?.name}
type="button"
>
<Folder aria-hidden="true" size={16} />
<span className="project-switcher__trigger-copy">
<span>
{activeProjectDisplay?.name ??
t('projectSwitcher.selector.empty')}
</span>
<span className="project-switcher__activity" id={`${menuId}-activity`}>
{visibleActivities.length > 0
? <ProjectActivityCounts {...activityCounts} /> : t('projectActivity.idle')}
</span>
</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
{activeProjectRecovery && <span id={`${menuId}-recovery`} className="project-switcher__recovery-feedback">
<ProjectRecoveryStatus state={activeProjectRecovery} />
</span>}
{projectMenuOpen && <WorkspaceMenu
id={menuId} projects={projects} activeProjectId={activeProjectId}
activities={visibleActivities} conversationStore={conversationStore} remoteProjectsEnabled={remoteProjectsEnabled}
hosts={sshHosts?.hosts ?? emptyHosts} connectionStates={agentConnectionStatusByHostId}
anchorRef={projectPickerButtonRef} controlsRef={projectPickerRef}
renderProject={renderProjectMenuItem} onClose={closeProjectMenu}
onEnterProject={onSelect} onRestore={onRestore} notify={notify}
onNewConversation={onNewConversation} onOpenConversation={onOpenConversation}
onCreateProject={() => {
setProjectMenuOpen(false)
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
setSettingsProjectId(undefined)
resetRemoteDraft()
setExecutionSpaceKind('local')
setDraft({
name: '',
description: '',
rootPath: '',
})
restoreFocusTarget.current = 'create'
setDialogMode('create')
}} />}
</div>
</div>
{dialogMode && createPortal(
<div
className="project-create-backdrop"
onMouseDown={(event) => {
if (event.currentTarget === event.target && !busy) {
closeDialog()
}
}}
>
<div
aria-labelledby="project-dialog-title"
aria-modal="true"
className="project-create-card"
ref={dialogRef}
role="dialog"
>
<header>
<strong id="project-dialog-title">
{dialogMode === 'create'
? t('projectSwitcher.dialog.createTitle')
: t('projectSwitcher.dialog.settingsTitle')}
</strong>
<button
aria-label={
dialogMode === 'create'
? t('projectSwitcher.dialog.closeCreate')
: t('projectSwitcher.dialog.closeSettings')
}
className="icon-button project-create-card__close"
disabled={busy}
onClick={() => closeDialog()}
type="button"
>
<X size={18} />
</button>
</header>
<div className="project-create-card__body">
{dialogMode === 'settings' &&
settingsProject?.kind === 'channel' &&
runtimeSettings ? (
<ChannelProjectSettingsFields
autoFocus
disabled={busy}
onChange={setDraft}
onSelectRoot={() => void selectRoot()}
runtimeSettings={runtimeSettings}
value={draft}
variant="dialog"
/>
) : (
<>
<label>
<span>{t('projectSwitcher.dialog.fields.name')}</span>
<input
autoFocus={!confirmingDelete}
disabled={
executionSpaceKind === 'ssh'
? remoteFieldsDisabled
: busy
}
maxLength={120}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value
}))
}
value={draft.name}
/>
</label>
<label>
<span>
{t('projectSwitcher.dialog.fields.description')}
</span>
<textarea
disabled={
executionSpaceKind === 'ssh'
? remoteFieldsDisabled
: busy
}
maxLength={2_000}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value
}))
}
rows={3}
value={draft.description}
/>
</label>
<div className="project-execution-space">
<span>
{t(
'projectSwitcher.dialog.fields.executionSpace'
)}
</span>
<SegmentedControl
ariaLabel={t(
'projectSwitcher.dialog.fields.executionSpace'
)}
disabled={dialogMode === 'settings' || busy}
onChange={(kind) => {
setError(undefined)
setRemoteSavePhase(undefined)
setExecutionSpaceKind(kind)
if (kind === 'ssh') {
setLoadingSshHosts(true)
setDraft((current) => ({
...current,
runtimeSelection:
current.runtimeSelection?.provider === 'opencode' || current.runtimeSelection?.provider === 'continue'
? current.runtimeSelection
: { provider: 'opencode' }
}))
}
}}
options={[
{
value: 'local',
label: t(
'projectSwitcher.remote.executionSpaces.local'
)
},
...(remoteProjectsEnabled
? [
{
value: 'ssh' as const,
label: t(
'projectSwitcher.remote.executionSpaces.ssh'
)
}
]
: [])
]}
value={executionSpaceKind}
/>
{dialogMode === 'settings' && (
<small>
{t(
'projectSwitcher.remote.executionSpaceFixed'
)}
</small>
)}
</div>
{executionSpaceKind === 'local' ? (
<>
<label>
<span>
{t('projectSwitcher.dialog.fields.rootPath')}
</span>
<div className="project-create-card__path">
<input
aria-required={dialogMode === 'create' || undefined}
aria-describedby={dialogMode === 'create' ? 'project-root-required' : undefined}
readOnly
value={draft.rootPath}
/>
<button
aria-label={t(
'projectSwitcher.dialog.selectRoot'
)}
className="secondary-button"
disabled={busy}
onClick={() => void selectRoot()}
type="button"
>
<FolderOpen size={14} />
</button>
</div>
</label>
{dialogMode === 'create' && (
<small id="project-root-required">
{t('projectSwitcher.dialog.rootRequired')}
</small>
)}
{runtimeSettings && (
<>
<ProjectRuntimeSelector
disabled={busy}
help={t(
'projectSwitcher.dialog.defaultRuntimeHelp'
)}
label={t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
onChange={(runtimeSelection) =>
setDraft((current) => ({
...current,
runtimeSelection
}))
}
runtimeSettings={runtimeSettings}
selection={draft.runtimeSelection}
/>
</>
)}
</>
) : (
<div className="remote-project-fields">
<div className="field">
<div className="inline-help-label">
<label htmlFor="project-remote-host">
{t('projectSwitcher.remote.fields.host')}
</label>
<InlineHelp label={t('projectSwitcher.remote.fields.host')}>{t('projectSwitcher.remote.hostHelp')}</InlineHelp>
</div>
<select
id="project-remote-host"
aria-label={t(
'projectSwitcher.remote.fields.host'
)}
disabled={
remoteFieldsDisabled ||
loadingSshHosts ||
dialogMode === 'settings'
}
onChange={(event) => {
setError(undefined)
setRemoteHostId(event.target.value)
}}
value={remoteHostId}
>
{loadingSshHosts && (
<option value="">
{t(
'projectSwitcher.remote.loadingHosts'
)}
</option>
)}
{!loadingSshHosts &&
sshHosts?.hosts.length === 0 && (
<option value="">
{t(
'projectSwitcher.remote.noHosts'
)}
</option>
)}
{sshHosts?.hosts.map((host) => {
const readiness =
remoteHostReadiness[host.id]
const readinessLabel = readiness
? t(
`projectSwitcher.remote.readiness.options.${readiness.status}`
)
: t(
'projectSwitcher.remote.readiness.options.loading'
)
return (
<option
disabled={readiness?.status !== 'ready'}
key={host.id}
value={host.id}
>
{host.name} · {host.username}@
{host.hostname} · {readinessLabel}
</option>
)
})}
</select>
{remoteHostId &&
selectedRemoteHostReadiness && (
<small
aria-live="polite"
role={
selectedRemoteHostReadiness.status ===
'unready'
? 'alert'
: 'status'
}
>
{t(
`projectSwitcher.remote.readiness.${selectedRemoteHostReadiness.status}`
)}
</small>
)}
</div>
<label>
<span>
{t('projectSwitcher.remote.fields.root')}
</span>
<div className="project-create-card__path">
<input
aria-label={t(
'projectSwitcher.remote.fields.root'
)}
disabled={
remoteFieldsDisabled ||
dialogMode === 'settings'
}
maxLength={4_096}
onChange={(event) =>
setRemoteRootPath(event.target.value)
}
placeholder="/home/user/project"
value={remoteRootPath}
/>
<button
aria-label={t(
'projectSwitcher.remote.directoryPicker.browse'
)}
className="secondary-button"
disabled={remoteDirectoryBrowseDisabled}
onClick={() => {
setDirectoryPickerOpen(true)
setRemoteDirectoryListing(undefined)
void browseRemoteDirectories(
isRemoteAbsolutePath(remoteRootPath)
? remoteRootPath
: undefined
)
}}
ref={directoryPickerTriggerRef}
title={t(
'projectSwitcher.remote.directoryPicker.browse'
)}
type="button"
>
<FolderOpen aria-hidden="true" size={14} />
</button>
</div>
<small>
{t('projectSwitcher.remote.rootHelp')}
</small>
</label>
{runtimeSettings && (
<ProjectRuntimeSelector
disabled={remoteFieldsDisabled}
help={t('projectSwitcher.remote.runtimeHelp')}
label={t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
onChange={(runtimeSelection) =>
setDraft((current) => ({
...current,
runtimeSelection
}))
}
remote
runtimeSettings={runtimeSettings}
selection={draft.runtimeSelection}
/>
)}
{remoteSaving && remoteSavePhase && (
<RemoteProjectProgress
phase={remoteSavePhase}
title={t(
'projectSwitcher.remote.actions.saving'
)}
/>
)}
</div>
)}
{directoryPickerOpen && (
<div
className="remote-directory-picker-backdrop"
onMouseDown={(event) => {
if (event.currentTarget === event.target) {
cancelRemoteDirectoryBrowse()
}
}}
>
<section
aria-labelledby="remote-directory-picker-title"
aria-modal="true"
className="remote-directory-picker"
ref={directoryPickerRef}
role="dialog"
>
<header>
<strong id="remote-directory-picker-title">
{t(
'projectSwitcher.remote.directoryPicker.title'
)}
</strong>
<button
aria-label={t(
'projectSwitcher.remote.directoryPicker.close'
)}
className="icon-button"
onClick={() =>
cancelRemoteDirectoryBrowse()
}
title={t(
'projectSwitcher.remote.directoryPicker.close'
)}
type="button"
>
<X aria-hidden="true" size={14} />
</button>
</header>
<div className="remote-directory-picker__location">
<span>
{t(
'projectSwitcher.remote.directoryPicker.currentPath'
)}
</span>
<code>
{remoteDirectoryListing?.path ??
(isRemoteAbsolutePath(remoteRootPath)
? remoteRootPath
: '…')}
</code>
</div>
<div className="remote-directory-picker__toolbar">
<button
aria-label={t(
'projectSwitcher.remote.directoryPicker.parent'
)}
className="icon-button"
disabled={
directoryPickerLoading ||
!remoteDirectoryListing ||
!remoteDirectoryListing.parentPath
}
onClick={() => {
const parentPath =
remoteDirectoryListing?.parentPath
if (parentPath) {
void browseRemoteDirectories(parentPath)
}
}}
title={t(
'projectSwitcher.remote.directoryPicker.parent'
)}
type="button"
>
<ArrowUp aria-hidden="true" size={15} />
</button>
<button
aria-label={t(
'projectSwitcher.remote.directoryPicker.refresh'
)}
className="icon-button"
disabled={directoryPickerLoading}
onClick={() =>
void browseRemoteDirectories(
remoteDirectoryListing?.path ??
(isRemoteAbsolutePath(
remoteRootPath
)
? remoteRootPath
: undefined)
)
}
title={t(
'projectSwitcher.remote.directoryPicker.refresh'
)}
type="button"
>
<RefreshCw aria-hidden="true" size={15} />
</button>
</div>
<div
aria-busy={directoryPickerLoading}
className="remote-directory-picker__content"
>
{directoryPickerLoading ? (
<p aria-live="polite" role="status">
{t(
'projectSwitcher.remote.directoryPicker.loading'
)}
</p>
) : directoryPickerError ? (
<p role="alert">{directoryPickerError}</p>
) : remoteDirectoryListing?.entries.length ? (
<ul
aria-label={t(
'projectSwitcher.remote.directoryPicker.title'
)}
>
{remoteDirectoryListing.entries.map(
(directory) => (
<li key={directory.path}>
<button
aria-label={t(
'projectSwitcher.remote.directoryPicker.directory',
{ name: directory.name }
)}
onClick={() =>
void browseRemoteDirectories(
directory.path
)
}
type="button"
>
<Folder
aria-hidden="true"
size={16}
/>
<span>{directory.name}</span>
</button>
</li>
)
)}
</ul>
) : (
<p>
{t(
'projectSwitcher.remote.directoryPicker.empty'
)}
</p>
)}
</div>
<div className="remote-directory-picker__actions">
<button
className="secondary-button"
onClick={() =>
cancelRemoteDirectoryBrowse()
}
type="button"
>
{t(
'projectSwitcher.remote.directoryPicker.cancel'
)}
</button>
<button
className="primary-button"
disabled={
directoryPickerLoading ||
!remoteDirectoryListing
}
onClick={() => {
if (!remoteDirectoryListing) {
return
}
setRemoteRootPath(
remoteDirectoryListing.path
)
cancelRemoteDirectoryBrowse()
}}
type="button"
>
{t(
'projectSwitcher.remote.directoryPicker.select'
)}
</button>
</div>
</section>
</div>
)}
</>
)}
{error && (
<p className="project-create-card__error" role="alert">
{error}
</p>
)}
{dialogMode === 'settings' &&
settingsProject?.kind !== 'channel' && (
<section
aria-labelledby="project-danger-title"
className="project-danger-zone"
>
<div>
<strong id="project-danger-title">
{t('projectSwitcher.dialog.danger.title')}
</strong>
<p>
{t('projectSwitcher.dialog.danger.description')}
</p>
</div>
{!confirmingDelete ? (
<button
className="danger-button danger-button--quiet"
disabled={busy || userProjects.length <= 1}
onClick={() => {
setError(undefined)
setDeleteConfirmation('')
setConfirmingDelete(true)
}}
type="button"
>
<Trash2 size={13} />
{t('projectSwitcher.dialog.danger.delete')}
</button>
) : (
<div className="project-delete-confirmation">
<label>
<span>
{t(
'projectSwitcher.dialog.danger.confirmation',
{ projectName: settingsProject?.name }
)}
</span>
<input
autoFocus
disabled={busy}
onChange={(event) =>
setDeleteConfirmation(event.target.value)
}
value={deleteConfirmation}
/>
</label>
<div>
<button
className="secondary-button"
disabled={busy}
onClick={() => {
setError(undefined)
setDeleteConfirmation('')
setConfirmingDelete(false)
}}
type="button"
>
{t('projectSwitcher.dialog.danger.cancel')}
</button>
<button
className="danger-button"
disabled={
busy ||
deleteConfirmation !== settingsProject?.name
}
onClick={() => void deleteProject()}
type="button"
>
<Trash2 size={13} />
{deleting
? t(
'projectSwitcher.dialog.danger.deleting'
)
: t(
'projectSwitcher.dialog.danger.permanentlyDelete'
)}
</button>
</div>
</div>
)}
{userProjects.length <= 1 && (
<small>
{t('projectSwitcher.dialog.danger.keepOne')}
</small>
)}
</section>
)}
</div>
<div className="project-create-card__actions">
{dialogMode === 'settings' &&
settingsProject?.kind !== 'channel' &&
userProjects.length > 1 &&
settingsProject && (
<button
className="secondary-button"
disabled={busy}
onClick={() => void archive()}
type="button"
>
<Archive size={13} />
{archiving
? t('projectSwitcher.dialog.archiving')
: t('projectSwitcher.dialog.archive')}
</button>
)}
<button
className="secondary-button"
disabled={archiving || deleting}
onClick={() => closeDialog()}
type="button"
>
{t('projectSwitcher.dialog.cancel')}
</button>
{executionSpaceKind === 'ssh' ? (
<button
className="primary-button"
disabled={
busy ||
confirmingDelete ||
!remoteDraftReady
}
onClick={() => void saveRemoteProject()}
type="button"
>
{remoteSaving
? t('projectSwitcher.remote.actions.saving')
: t('projectSwitcher.remote.actions.save')}
</button>
) : (
<button
className="primary-button"
disabled={
busy || !draft.name.trim() || confirmingDelete ||
(dialogMode === 'create' && !draft.rootPath.trim())
}
onClick={() => void save()}
type="button"
>
{saving
? dialogMode === 'create'
? t('projectSwitcher.dialog.creating')
: t('projectSwitcher.dialog.saving')
: dialogMode === 'create'
? t('projectSwitcher.dialog.create')
: t('projectSwitcher.dialog.save')}
</button>
)}
</div>
</div>
</div>,
document.body
)}
</div>
)
}
/** Keep the hidden sidebar's project menus and activity rows out of the tree. */
const ProjectSwitcherWhenVisible = (props: ProjectSwitcherProps): React.JSX.Element | null =>
props.visible === false ? null : <ProjectSwitcherView {...props} />
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
export const ProjectSwitcher = memo(ProjectSwitcherWhenVisible)