mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
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:
parent
19bae1b9e0
commit
c7542358f2
11 changed files with 163 additions and 64 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue