fix(desktop): remote project picker UX and profile-scoped fs/git routing

Route FS/git REST through the active profile, mount the remote folder picker
at app root, keep the project dialog open while picking, show a first-run
blank state, flip into grouped view on create, and constrain the picker scroll
area so Select stays reachable.
This commit is contained in:
Brooklyn Nicholson 2026-06-28 16:23:39 -05:00
parent 19bae1b9e0
commit c7542358f2
11 changed files with 163 additions and 64 deletions

View file

@ -1149,7 +1149,8 @@ export function ChatSidebar({
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const showSessionSections =
showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0
// Each reorderable list reports its OWN new id order; persisting is a direct,
// typed write — no id-prefix sniffing to figure out which level moved.
@ -1537,7 +1538,7 @@ export function ChatSidebar({
</div>
)}
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
{contentVisible && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
@ -1618,6 +1619,29 @@ function SidebarSessionSkeletons() {
)
}
function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) {
const { t } = useI18n()
const s = t.sidebar
return (
<div className="grid min-h-0 flex-1 place-items-center px-4 text-center">
<div className="flex flex-col items-center gap-2">
<Codicon className="text-(--ui-text-quaternary)" name="root-folder" size="1.25rem" />
<p className="text-xs text-(--ui-text-tertiary)">{s.noSessions}</p>
<Button
className="mt-0.5 text-(--ui-text-secondary)"
onClick={onNewProject}
size="sm"
variant="ghost"
>
<Codicon name="add" size="0.75rem" />
{s.projects.newButton}
</Button>
</div>
</div>
)
}
function SidebarPinnedEmptyState() {
const { t } = useI18n()

View file

@ -87,21 +87,25 @@ export function ProjectDialog() {
}
const pickFolder = async () => {
const dir = await pickProjectFolder()
try {
const dir = await pickProjectFolder()
if (!dir) {
return
if (!dir) {
return
}
const projectId = state?.projectId
if (mode === 'add-folder' && projectId) {
await runSubmit(() => addProjectFolder(projectId, dir))
return
}
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
} catch (err) {
notifyError(err, p.createFailed)
}
const projectId = state?.projectId
if (mode === 'add-folder' && projectId) {
await runSubmit(() => addProjectFolder(projectId, dir))
return
}
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
}
const submit = async () => {
@ -145,7 +149,10 @@ export function ProjectDialog() {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogContent
className="max-w-md"
onInteractOutside={event => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}

View file

@ -124,6 +124,7 @@ import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
import { RightSidebarPane } from './right-sidebar'
import { FileActionDialogs } from './right-sidebar/file-actions'
import { RemoteFolderPicker } from './right-sidebar/files/remote-picker'
import { ReviewPane } from './right-sidebar/review'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
@ -1127,6 +1128,7 @@ export function DesktopController() {
<PetGenerateOverlay />
<SessionSwitcher />
<FileActionDialogs />
<RemoteFolderPicker />
{settingsOpen && (
<Suspense fallback={null}>

View file

@ -120,14 +120,14 @@ export function RemoteFolderPicker() {
return (
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
<div className="border-b border-border/70 px-4 py-3">
<DialogContent className="flex h-[min(36rem,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<div className="shrink-0 border-b border-border/70 px-4 py-3">
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
</div>
<div className="flex min-h-[22rem] flex-col">
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
{crumbs.map((crumb, index) => (
<button
className={cn(
@ -166,7 +166,7 @@ export function RemoteFolderPicker() {
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
<div className="shrink-0 flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => close()} size="sm" variant="ghost">

View file

@ -16,7 +16,6 @@ import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { RemoteFolderPicker } from './files/remote-picker'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
@ -82,8 +81,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder }: RightSide
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RemoteFolderPicker />
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}

View file

@ -112,6 +112,16 @@ describe('desktop filesystem facade', () => {
expect(gitRoot).not.toHaveBeenCalled()
})
it('targets the active profile backend so a remote profile never reads local disk', async () => {
$connection.set({ mode: 'remote', profile: 'remote-docker' } as never)
await readDesktopDir('/srv/project')
await desktopDefaultCwd()
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fsrv%2Fproject', profile: 'remote-docker' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd', profile: 'remote-docker' })
})
it('routes file diffs through backend git in remote mode', async () => {
$connection.set({ mode: 'remote' } as never)

View file

@ -32,6 +32,12 @@ export function isDesktopFsRemoteMode() {
return $connection.get()?.mode === 'remote'
}
// Active profile for FS/git REST calls. Without it the Electron api bridge
// hits the primary (local) backend even when the user switched to a remote profile.
export function desktopFsProfile(): string | undefined {
return $connection.get()?.profile || undefined
}
function fsPath(endpoint: string, filePath: string) {
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
}
@ -46,24 +52,26 @@ function bridge() {
return desktop
}
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
const desktop = bridge()
function remoteFsApi<T>(path: string, body?: Record<string, unknown>): Promise<T> {
return bridge().api<T>(
body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() }
)
}
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
if (!isDesktopFsRemoteMode()) {
return desktop.readDir(path)
return bridge().readDir(path)
}
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
return remoteFsApi<HermesReadDirResult>(fsPath('list', path))
}
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileText(path)
return bridge().readFileText(path)
}
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
return remoteFsApi<HermesReadFileTextResult>(fsPath('read-text', path))
}
// Save UTF-8 text back to a file. Local writes go through the hardened Electron
@ -81,23 +89,17 @@ export async function writeDesktopFileText(path: string, content: string): Promi
return desktop.writeTextFile(path, content)
}
const result = await desktop.api<{ ok?: boolean; path?: string }>({
body: { content, path },
method: 'POST',
path: '/api/fs/write-text'
})
const result = await remoteFsApi<{ ok?: boolean; path?: string }>('/api/fs/write-text', { content, path })
return { path: result.path || path }
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileDataUrl(path)
return bridge().readFileDataUrl(path)
}
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
const result = await remoteFsApi<string | { dataUrl?: string }>(fsPath('read-data-url', path))
return typeof result === 'string' ? result : result.dataUrl || ''
}
@ -109,9 +111,7 @@ export async function desktopGitRoot(path: string): Promise<string | null> {
return desktop.gitRoot ? desktop.gitRoot(path) : null
}
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
return result.root
return (await remoteFsApi<{ root: string | null }>(fsPath('git-root', path))).root
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
@ -119,7 +119,7 @@ export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string
return null
}
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
return remoteFsApi<{ branch: string; cwd: string }>('/api/fs/default-cwd')
}
// Reveal a path in the OS file manager (Finder / Explorer / Files). Local only.
@ -158,17 +158,17 @@ export async function copyTextToClipboard(text: string): Promise<void> {
// Working-tree-vs-HEAD diff for one file. Empty when unchanged / not a repo.
// Remote gateway → backend git (/api/git/file-diff); local → Electron git.
export async function desktopFileDiff(repoRoot: string, filePath: string): Promise<string> {
const desktop = bridge()
if (isDesktopFsRemoteMode()) {
const result = await desktop.api<{ diff: string }>({
path: `/api/git/file-diff?path=${encodeURIComponent(repoRoot)}&file=${encodeURIComponent(filePath)}`
})
const result = await remoteFsApi<{ diff: string }>(
`/api/git/file-diff?path=${encodeURIComponent(repoRoot)}&file=${encodeURIComponent(filePath)}`
)
return result.diff || ''
}
return desktop.git?.fileDiff ? desktop.git.fileDiff(repoRoot, filePath) : ''
const git = bridge().git
return git?.fileDiff ? git.fileDiff(repoRoot, filePath) : ''
}
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {

View file

@ -63,6 +63,21 @@ describe('desktop git facade', () => {
expect(repoStatus).not.toHaveBeenCalled()
})
it('targets the active profile backend so a remote profile never touches the local repo', async () => {
$connection.set({ mode: 'remote', profile: 'remote-docker' } as never)
await desktopGit()?.repoStatus('/srv/work')
await desktopGit()?.review.stage('/srv/work', 'a.txt')
expect(api).toHaveBeenCalledWith({ path: '/api/git/status?path=%2Fsrv%2Fwork', profile: 'remote-docker' })
expect(api).toHaveBeenCalledWith({
body: { file: 'a.txt', path: '/srv/work' },
method: 'POST',
path: '/api/git/review/stage',
profile: 'remote-docker'
})
})
it('sends mutations as POST bodies on a remote gateway', async () => {
$connection.set({ mode: 'remote' } as never)

View file

@ -6,7 +6,7 @@ import type {
HermesReviewShipInfo
} from '@/global'
import { isDesktopFsRemoteMode } from './desktop-fs'
import { desktopFsProfile, isDesktopFsRemoteMode } from './desktop-fs'
// Remote-aware git facade. Locally the desktop runs git through Electron
// (window.hermesDesktop.git); on a remote gateway that's the wrong filesystem,
@ -23,7 +23,9 @@ function desktopApi<T>(path: string, body?: Record<string, unknown>): Promise<T>
throw new Error('Hermes Desktop bridge is unavailable')
}
return desktop.api<T>(body ? { body, method: 'POST', path } : { path })
return desktop.api<T>(
body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() }
)
}
function gitGet<T>(route: string, params: Record<string, boolean | null | string | undefined>): Promise<T> {

View file

@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $sidebarAgentsGrouped } from '@/store/layout'
import {
$activeProjectId,
$projectScope,
$worktreeRefreshToken,
ALL_PROJECTS,
createProject,
enterProject,
exitProjectScope,
pickProjectFolder,
@ -13,7 +17,13 @@ import {
vi.mock('@/lib/desktop-fs', () => ({
desktopDefaultCwd: vi.fn(),
isDesktopFsRemoteMode: vi.fn(),
selectDesktopPaths: vi.fn()
selectDesktopPaths: vi.fn(),
writeDesktopFileText: vi.fn()
}))
vi.mock('@/store/gateway', () => ({
activeGateway: vi.fn(),
ensureActiveGatewayOpen: vi.fn()
}))
const fs = await import('@/lib/desktop-fs')
@ -21,6 +31,9 @@ const desktopDefaultCwd = vi.mocked(fs.desktopDefaultCwd)
const isDesktopFsRemoteMode = vi.mocked(fs.isDesktopFsRemoteMode)
const selectDesktopPaths = vi.mocked(fs.selectDesktopPaths)
const gw = await import('@/store/gateway')
const activeGateway = vi.mocked(gw.activeGateway)
describe('project scope', () => {
beforeEach(() => {
window.localStorage.clear()
@ -96,3 +109,34 @@ describe('pickProjectFolder', () => {
await expect(pickProjectFolder()).resolves.toBeNull()
})
})
describe('createProject', () => {
beforeEach(() => {
vi.clearAllMocks()
$sidebarAgentsGrouped.set(false)
$activeProjectId.set(null)
})
it('creates the project and flips into the grouped view so a blank slate shows it', async () => {
const created = { folders: [], id: 'p_new', name: 'Demo', primary_path: '/srv/demo' }
const request = vi.fn(async (method: string) => {
if (method === 'projects.create') {
return { project: created }
}
// Reconcile (fire-and-forget) re-reads list + tree; echo the project back
// so the optimistic state survives instead of being wiped to empty.
return { active_id: 'p_new', projects: [created], scoped_session_ids: [] }
})
activeGateway.mockReturnValue({ connectionState: 'open', request } as never)
const result = await createProject({ folders: ['/srv/demo'], name: 'Demo', use: true })
expect(result).toEqual(created)
expect(request).toHaveBeenCalledWith('projects.create', expect.objectContaining({ name: 'Demo' }))
expect($sidebarAgentsGrouped.get()).toBe(true)
expect($activeProjectId.get()).toBe('p_new')
})
})

View file

@ -444,6 +444,8 @@ export async function createProject(input: CreateProjectInput): Promise<ProjectI
if (input.use) {
$activeProjectId.set(created.id)
}
setSidebarAgentsGrouped(true)
}
reconcileProjects()
@ -737,15 +739,11 @@ export async function copyPath(path: null | string): Promise<void> {
// the backend filesystem (seeded at its default cwd) where sessions run; local
// mode opens the native dialog. Returns the absolute path, or null if cancelled.
export async function pickProjectFolder(): Promise<null | string> {
try {
const [dir] = await selectDesktopPaths({
defaultPath: (await desktopDefaultCwd())?.cwd,
directories: true,
multiple: false
})
const [dir] = await selectDesktopPaths({
defaultPath: (await desktopDefaultCwd())?.cwd,
directories: true,
multiple: false
})
return dir || null
} catch {
return null
}
return dir || null
}