diff --git a/README.md b/README.md index fde776c0..92032d50 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Run official Claude Code / Codex / Gemini / OpenCode sessions locally and contro - **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Gemini, OpenCode—different models, one unified workflow. - **Terminal Anywhere** - Run commands from your phone or browser, directly connected to the working machine. - **Voice Control** - Talk to your AI agent hands-free using the built-in voice assistant. +- **Workspace Browser** - Opt-in via `hapi runner start --workspace-root `: browse a scoped file tree from the web and start sessions in any subdirectory. ## Demo diff --git a/cli/README.md b/cli/README.md index c111e0af..a2c0623c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -52,6 +52,14 @@ See `src/commands/auth.ts`. - `hapi runner stop-session ` - Terminate specific session. - `hapi runner logs` - Print path to latest runner log file. +Both `start` and `start-sync` accept `--workspace-root ` (or `--workspace-root=`). When set: + +- The web `/browse` page surfaces a scoped file tree rooted at that path. +- The runner refuses `list-directory` and `spawn-session` requests for paths outside the root. +- `~` and `~/foo` are expanded. + +Omitting the flag keeps the legacy behavior: no scoping, no `/browse` feature. + See `src/runner/run.ts`. ### Diagnostics diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index ddbea808..dc27f89c 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -38,14 +38,15 @@ export type SessionBootstrapResult = { workingDirectory: string } -export function buildMachineMetadata(): MachineMetadata { +export function buildMachineMetadata(options?: { workspaceRoot?: string }): MachineMetadata { return { host: process.env.HAPI_HOSTNAME || os.hostname(), platform: os.platform(), happyCliVersion: packageJson.version, homeDir: os.homedir(), happyHomeDir: configuration.happyHomeDir, - happyLibDir: runtimePath() + happyLibDir: runtimePath(), + workspaceRoot: options?.workspaceRoot } } diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 5e05e77e..d327c5e5 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -142,7 +142,7 @@ export class ApiClient { return new ApiSessionClient(this.token, session) } - machineSyncClient(machine: Machine): ApiMachineClient { - return new ApiMachineClient(this.token, machine) + machineSyncClient(machine: Machine, options?: { workspaceRoot?: string }): ApiMachineClient { + return new ApiMachineClient(this.token, machine, options?.workspaceRoot) } } diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index da7fab7a..cebf3c0e 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -3,7 +3,9 @@ */ import { io, type Socket } from 'socket.io-client' -import { stat } from 'node:fs/promises' +import { readdir, realpath, stat } from 'node:fs/promises' +import { realpathSync } from 'node:fs' +import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path' import { logger } from '@/ui/logger' import { configuration } from '@/configuration' import type { Update, UpdateMachineBody } from '@hapi/protocol' @@ -65,15 +67,50 @@ interface PathExistsResponse { exists: Record } +interface ListMachineDirectoryRequest { + path: string +} + +interface ListMachineDirectoryEntry { + name: string + type: 'file' | 'directory' | 'other' + size?: number + modified?: number + isGitRepo?: boolean +} + +interface ListMachineDirectoryResponse { + success: boolean + entries?: ListMachineDirectoryEntry[] + error?: string +} + export class ApiMachineClient { private socket!: Socket private keepAliveInterval: NodeJS.Timeout | null = null private rpcHandlerManager: RpcHandlerManager + private readonly normalizedWorkspaceRoot: string | undefined + constructor( private readonly token: string, - private readonly machine: Machine + private readonly machine: Machine, + private readonly workspaceRoot?: string ) { + // realpath the root once so all subsequent comparisons are against + // the canonical, symlink-resolved path. Falls back to a lexical + // resolve if realpath fails (e.g. unusual permission setup) so we + // still get *some* protection rather than skipping the check. + if (workspaceRoot) { + try { + this.normalizedWorkspaceRoot = realpathSync(workspaceRoot) + } catch { + this.normalizedWorkspaceRoot = resolvePath(workspaceRoot) + } + } else { + this.normalizedWorkspaceRoot = undefined + } + this.rpcHandlerManager = new RpcHandlerManager({ scopePrefix: this.machine.id, logger: (msg, data) => logger.debug(msg, data) @@ -99,6 +136,113 @@ export class ApiMachineClient { return { exists } }) + + this.rpcHandlerManager.registerHandler('list-directory', async (params) => { + if (!this.normalizedWorkspaceRoot) { + return { success: false, error: 'Workspace browsing is not enabled for this machine' } + } + + const rawPath = typeof params?.path === 'string' ? params.path.trim() : '' + if (!rawPath) { + return { success: false, error: 'Path is required' } + } + + const targetPath = await this.resolveForWorkspaceCheck(rawPath) + if (!this.isWithinWorkspaceRoot(targetPath)) { + return { success: false, error: 'Path is outside workspace root' } + } + + try { + const dirStat = await stat(targetPath) + if (!dirStat.isDirectory()) { + return { success: false, error: 'Path is not a directory' } + } + + const dirEntries = await readdir(targetPath, { withFileTypes: true }) + const entries: ListMachineDirectoryEntry[] = [] + + await Promise.all(dirEntries.map(async (entry) => { + if (entry.name.startsWith('.')) return + + const fullPath = join(targetPath, entry.name) + let type: 'file' | 'directory' | 'other' = 'other' + let size: number | undefined + let modified: number | undefined + let isGitRepo = false + + if (entry.isDirectory()) { + type = 'directory' + try { + const gitStat = await stat(join(fullPath, '.git')) + isGitRepo = gitStat.isDirectory() || gitStat.isFile() + } catch { + // not a git repo + } + } else if (entry.isFile()) { + type = 'file' + } + + if (!entry.isSymbolicLink()) { + try { + const stats = await stat(fullPath) + size = stats.size + modified = stats.mtime.getTime() + } catch { + // ignore stat errors + } + } + + entries.push({ name: entry.name, type, size, modified, isGitRepo }) + })) + + entries.sort((a, b) => { + if (a.type === 'directory' && b.type !== 'directory') return -1 + if (a.type !== 'directory' && b.type === 'directory') return 1 + return a.name.localeCompare(b.name) + }) + + return { success: true, entries } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Failed to list directory' } + } + }) + } + + private isWithinWorkspaceRoot(absolutePath: string): boolean { + if (!this.normalizedWorkspaceRoot) return true + const rel = relative(this.normalizedWorkspaceRoot, absolutePath) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) + } + + /** + * Canonicalize a path for workspace-root containment checks. Resolves + * symlinks via realpath so a symlink such as `/safe/out -> /etc` cannot + * be used to escape the configured root with a lexical-only check. + * + * If the path doesn't exist (e.g. a session is being spawned in a + * directory we'll create), walks up to the nearest existing ancestor + * and realpaths *that*, joining the missing tail back on. This way the + * check still runs against the real on-disk location once any + * intermediate symlink in the parent chain has been resolved. + */ + private async resolveForWorkspaceCheck(path: string): Promise { + const absolute = resolvePath(path) + try { + return await realpath(absolute) + } catch { + const missing: string[] = [] + let cursor = absolute + while (cursor !== dirname(cursor)) { + missing.unshift(basename(cursor)) + cursor = dirname(cursor) + try { + return join(await realpath(cursor), ...missing) + } catch { + // keep walking to the nearest existing parent + } + } + return absolute + } } setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { @@ -109,6 +253,11 @@ export class ApiMachineClient { throw new Error('Directory is required') } + const resolvedDirectory = await this.resolveForWorkspaceCheck(directory) + if (!this.isWithinWorkspaceRoot(resolvedDirectory)) { + return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace root' } + } + const result = await spawnSession({ directory, sessionId, @@ -249,6 +398,34 @@ export class ApiMachineClient { })).catch((error) => { logger.debug('[API MACHINE] Failed to update runner state on connect', error) }) + + const hubWorkspaceRoot = this.machine.metadata?.workspaceRoot + const desiredWorkspaceRoot = this.workspaceRoot + if (desiredWorkspaceRoot !== hubWorkspaceRoot) { + if (desiredWorkspaceRoot) { + console.log(`[HAPI] Syncing workspace root to hub: ${desiredWorkspaceRoot} (current hub value: ${hubWorkspaceRoot ?? 'none'})`) + } else { + console.log(`[HAPI] Clearing workspace root on hub (was: ${hubWorkspaceRoot})`) + } + this.updateMachineMetadata((current) => { + const base = current ?? this.machine.metadata + if (!base) { + return { workspaceRoot: desiredWorkspaceRoot } as MachineMetadata + } + if (desiredWorkspaceRoot) { + return { ...base, workspaceRoot: desiredWorkspaceRoot } + } + const { workspaceRoot: _omit, ...rest } = base + return rest as MachineMetadata + }).then(() => { + console.log(`[HAPI] Workspace root synced: ${this.machine.metadata?.workspaceRoot ?? '(none)'}`) + }).catch((error) => { + console.error('[HAPI] Failed to sync workspace root:', error instanceof Error ? error.message : error) + }) + } else if (desiredWorkspaceRoot) { + console.log(`[HAPI] Workspace root already up to date on hub: ${desiredWorkspaceRoot}`) + } + this.startKeepAlive() }) diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index e2a90a31..ddb8c3e4 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -36,7 +36,8 @@ export const MachineMetadataSchema = z.object({ displayName: z.string().optional(), homeDir: z.string(), happyHomeDir: z.string(), - happyLibDir: z.string() + happyLibDir: z.string(), + workspaceRoot: z.string().optional() }) export type MachineMetadata = z.infer diff --git a/cli/src/commands/runner.ts b/cli/src/commands/runner.ts index 218bf638..2b251c6d 100644 --- a/cli/src/commands/runner.ts +++ b/cli/src/commands/runner.ts @@ -1,4 +1,7 @@ import chalk from 'chalk' +import { existsSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { isAbsolute, resolve } from 'node:path' import { startRunner } from '@/runner/run' import { checkIfRunnerRunningAndCleanupStaleState, @@ -12,11 +15,59 @@ import { runDoctorCommand } from '@/ui/doctor' import { initializeToken } from '@/ui/tokenInit' import type { CommandDefinition } from './types' +/** + * Parses `--workspace-root ` / `--workspace-root=` from the + * runner's positional args. Returns the resolved absolute path or exits + * the process with a clear error. Mutates `args` to remove the consumed + * entries so subcommand dispatch still works. + */ +function extractWorkspaceRootArg(args: string[]): string | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i] + let value: string | undefined + if (arg === '--workspace-root') { + const next = args[i + 1] + if (next === undefined || next.startsWith('--')) { + console.error('--workspace-root requires a path argument') + process.exit(1) + } + value = next + args.splice(i, 2) + } else if (arg?.startsWith('--workspace-root=')) { + value = arg.slice('--workspace-root='.length) + args.splice(i, 1) + } + if (value === undefined) continue + + const trimmed = value.trim() + if (!trimmed) { + console.error('--workspace-root requires a non-empty path') + process.exit(1) + } + // Handle `~` / `~/foo` since the shell only expands unquoted tildes. + let expanded = trimmed + if (expanded === '~') { + expanded = homedir() + } else if (expanded.startsWith('~/')) { + expanded = resolve(homedir(), expanded.slice(2)) + } + const absolute = isAbsolute(expanded) ? expanded : resolve(expanded) + if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { + console.error(`--workspace-root path does not exist or is not a directory: ${absolute}`) + process.exit(1) + } + return absolute + } + return undefined +} + export const runnerCommand: CommandDefinition = { name: 'runner', requiresRuntimeAssets: true, run: async ({ commandArgs }) => { - const runnerSubcommand = commandArgs[0] + const mutableArgs = [...commandArgs] + const workspaceRoot = extractWorkspaceRootArg(mutableArgs) + const runnerSubcommand = mutableArgs[0] if (runnerSubcommand === 'list') { try { @@ -35,7 +86,7 @@ export const runnerCommand: CommandDefinition = { } if (runnerSubcommand === 'stop-session') { - const sessionId = commandArgs[1] + const sessionId = mutableArgs[1] if (!sessionId) { console.error('Session ID required') process.exit(1) @@ -51,7 +102,11 @@ export const runnerCommand: CommandDefinition = { } if (runnerSubcommand === 'start') { - const child = spawnHappyCLI(['runner', 'start-sync'], { + const childArgs = ['runner', 'start-sync'] + if (workspaceRoot) { + childArgs.push('--workspace-root', workspaceRoot) + } + const child = spawnHappyCLI(childArgs, { detached: true, stdio: 'ignore', env: process.env @@ -78,7 +133,7 @@ export const runnerCommand: CommandDefinition = { if (runnerSubcommand === 'start-sync') { await initializeToken() - await startRunner() + await startRunner({ workspaceRoot }) process.exit(0) } @@ -111,6 +166,12 @@ ${chalk.bold('Usage:')} hapi runner status Show runner status hapi runner list List active sessions +${chalk.bold('Options:')} + --workspace-root Restrict the runner to this directory. + Browse & spawn will reject paths outside it. + Supports \`~\` / \`~/foo\` expansion. + Omit to leave browsing off (legacy mode). + If you want to kill all hapi related processes run ${chalk.cyan('hapi doctor clean')} diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index e48b1256..2e636110 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -22,9 +22,10 @@ import { startRunnerControlServer } from './controlServer'; import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree'; import { join } from 'path'; import { buildMachineMetadata } from '@/agent/sessionFactory'; +import { resolveWorkspaceRoot } from '@/utils/workspaceRoot'; import { hashRunnerCliApiToken } from './runnerIdentity'; -export async function startRunner(): Promise { +export async function startRunner(options: { workspaceRoot?: string } = {}): Promise { // We don't have cleanup function at the time of server construction // Control flow is: // 1. Create promise that will resolve when shutdown is requested @@ -684,11 +685,14 @@ export async function startRunner(): Promise { // Create API client const api = await ApiClient.create(); + const workspaceRoot = resolveWorkspaceRoot(options.workspaceRoot); + logger.debug(`[RUNNER RUN] Workspace root: ${workspaceRoot ?? '(not set)'}`); + // Get or create machine (with retry for transient connection errors) const machine = await withRetry( () => api.getOrCreateMachine({ machineId, - metadata: buildMachineMetadata(), + metadata: buildMachineMetadata({ workspaceRoot }), runnerState: initialRunnerState }), { @@ -705,7 +709,7 @@ export async function startRunner(): Promise { logger.debug(`[RUNNER RUN] Machine registered: ${machine.id}`); // Create realtime machine session - const apiMachine = api.machineSyncClient(machine); + const apiMachine = api.machineSyncClient(machine, { workspaceRoot }); // Set RPC handlers apiMachine.setRPCHandlers({ @@ -717,6 +721,17 @@ export async function startRunner(): Promise { // Connect to server apiMachine.connect(); + // Visible startup banner. Use console.log so it always appears on stdout, + // regardless of the verbose/quiet logger setting. + console.log(''); + console.log('Hapi runner started.'); + console.log(` Workspace root: ${workspaceRoot ?? '(not set — browse disabled; pass --workspace-root to enable)'}`); + console.log(` Hub URL: ${configuration.apiUrl}`); + console.log(` Machine ID: ${machine.id}`); + console.log(` Control port: ${controlPort}`); + console.log('Waiting for sessions. Press Ctrl+C to stop.'); + console.log(''); + reportSpawnOutcomeToHub = (outcome) => { void apiMachine.updateRunnerState((state: RunnerState | null) => { const baseState: RunnerState = state diff --git a/cli/src/utils/workspaceRoot.ts b/cli/src/utils/workspaceRoot.ts new file mode 100644 index 00000000..29fabdf3 --- /dev/null +++ b/cli/src/utils/workspaceRoot.ts @@ -0,0 +1,17 @@ +import { isAbsolute } from 'node:path' + +/** + * Resolves the runner's workspace root — the directory tree the runner is + * allowed to browse and spawn sessions in. Returns `undefined` when the + * user hasn't explicitly opted in; in that case the runner behaves like + * the legacy hapi (no scoping, no /browse feature surfaced in the web UI). + * + * The only signal is the `explicit` argument — typically the resolved + * `--workspace-root` flag. Non-absolute values are ignored. + */ +export function resolveWorkspaceRoot(explicit?: string): string | undefined { + if (explicit && isAbsolute(explicit)) { + return explicit + } + return undefined +} diff --git a/hub/src/sync/machineCache.ts b/hub/src/sync/machineCache.ts index c8eecd2e..977dfa80 100644 --- a/hub/src/sync/machineCache.ts +++ b/hub/src/sync/machineCache.ts @@ -10,7 +10,8 @@ const machineMetadataSchema = z.object({ displayName: z.string().optional(), homeDir: z.string().optional(), happyHomeDir: z.string().optional(), - happyLibDir: z.string().optional() + happyLibDir: z.string().optional(), + workspaceRoot: z.string().optional() }) export interface Machine { @@ -29,6 +30,7 @@ export interface Machine { homeDir?: string happyHomeDir?: string happyLibDir?: string + workspaceRoot?: string } | null metadataVersion: number runnerState: unknown | null @@ -101,7 +103,8 @@ export class MachineCache { const homeDir = typeof data.homeDir === 'string' ? data.homeDir : undefined const happyHomeDir = typeof data.happyHomeDir === 'string' ? data.happyHomeDir : undefined const happyLibDir = typeof data.happyLibDir === 'string' ? data.happyLibDir : undefined - return { host, platform, happyCliVersion, displayName, homeDir, happyHomeDir, happyLibDir } + const workspaceRoot = typeof data.workspaceRoot === 'string' ? data.workspaceRoot : undefined + return { host, platform, happyCliVersion, displayName, homeDir, happyHomeDir, happyLibDir, workspaceRoot } })() const storedActiveAt = stored.activeAt ?? stored.createdAt diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index dfc78d89..7899261d 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -172,6 +172,14 @@ export class RpcGateway { } } + async listMachineDirectory(machineId: string, path: string): Promise { + const result = await this.machineRpc(machineId, 'list-directory', { path }) as RpcListDirectoryResponse | unknown + if (!result || typeof result !== 'object') { + return { success: false, error: 'Unexpected list-directory result' } + } + return result as RpcListDirectoryResponse + } + async checkPathsExist(machineId: string, paths: string[]): Promise> { const result = await this.machineRpc(machineId, 'path-exists', { paths }) as RpcPathExistsResponse | unknown if (!result || typeof result !== 'object') { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 284160ba..d9203796 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -527,6 +527,10 @@ export class SyncEngine { return await this.rpcGateway.checkPathsExist(machineId, paths) } + async listMachineDirectory(machineId: string, path: string): Promise { + return await this.rpcGateway.listMachineDirectory(machineId, path) + } + async getGitStatus(sessionId: string, cwd?: string): Promise { return await this.rpcGateway.getGitStatus(sessionId, cwd) } diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index ea6dedf0..cf4a4605 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -66,6 +66,32 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json(result) }) + app.post('/machines/:id/list-directory', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + const body = await c.req.json().catch(() => null) + const parsed = z.object({ path: z.string().min(1) }).safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + try { + const result = await engine.listMachineDirectory(machineId, parsed.data.path) + return c.json(result) + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : 'Failed to list directory' }, 500) + } + }) + app.post('/machines/:id/paths/exists', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 80252886..19c577b5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -7,6 +7,7 @@ import type { FileReadResponse, FileSearchResponse, GitCommandResponse, + MachineListDirectoryResponse, MachinePathsExistsResponse, MachinesResponse, MessagesResponse, @@ -378,6 +379,19 @@ export class ApiClient { return await this.request('/api/machines') } + async listMachineDirectory( + machineId: string, + path: string + ): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/list-directory`, + { + method: 'POST', + body: JSON.stringify({ path }) + } + ) + } + async checkMachinePathsExists( machineId: string, paths: string[] diff --git a/web/src/components/NewSession/DirectorySection.tsx b/web/src/components/NewSession/DirectorySection.tsx index 6b2d302b..4bc0ae87 100644 --- a/web/src/components/NewSession/DirectorySection.tsx +++ b/web/src/components/NewSession/DirectorySection.tsx @@ -4,6 +4,14 @@ import { Autocomplete } from '@/components/ChatInput/Autocomplete' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { useTranslation } from '@/lib/use-translation' +function FolderIcon(props: { className?: string }) { + return ( + + + + ) +} + export function DirectorySection(props: { directory: string suggestions: readonly Suggestion[] @@ -18,6 +26,7 @@ export function DirectorySection(props: { onDirectoryKeyDown: (event: ReactKeyboardEvent) => void onSuggestionSelect: (index: number) => void onPathClick: (path: string) => void + onChooseFolder?: () => void }) { const { t } = useTranslation() @@ -26,28 +35,42 @@ export function DirectorySection(props: { -
- props.onDirectoryChange(event.target.value)} - onKeyDown={props.onDirectoryKeyDown} - onFocus={props.onDirectoryFocus} - onBlur={props.onDirectoryBlur} - disabled={props.isDisabled} - className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50" - /> - {props.suggestions.length > 0 && ( -
- - - -
+
+
+ props.onDirectoryChange(event.target.value)} + onKeyDown={props.onDirectoryKeyDown} + onFocus={props.onDirectoryFocus} + onBlur={props.onDirectoryBlur} + disabled={props.isDisabled} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50" + /> + {props.suggestions.length > 0 && ( +
+ + + +
+ )} +
+ {props.onChooseFolder && ( + )}
diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 20e3ab81..721596b8 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -34,6 +34,9 @@ export function NewSession(props: { isLoading?: boolean onSuccess: (sessionId: string) => void onCancel: () => void + onChooseFolder?: (args: { machineId: string | null; directory: string }) => void + initialDirectory?: string + initialMachineId?: string }) { const { haptic } = usePlatform() const { t } = useTranslation() @@ -42,8 +45,8 @@ export function NewSession(props: { const isFormDisabled = Boolean(isPending || props.isLoading) const { getRecentPaths, addRecentPath, getLastUsedMachineId, setLastUsedMachineId } = useRecentPaths() - const [machineId, setMachineId] = useState(null) - const [directory, setDirectory] = useState('') + const [machineId, setMachineId] = useState(props.initialMachineId ?? null) + const [directory, setDirectory] = useState(props.initialDirectory ?? '') const [suppressSuggestions, setSuppressSuggestions] = useState(false) const [isDirectoryFocused, setIsDirectoryFocused] = useState(false) const [agent, setAgent] = useState(loadPreferredAgent) @@ -85,12 +88,14 @@ export function NewSession(props: { if (foundLast) { setMachineId(foundLast.id) - const paths = getRecentPaths(foundLast.id) - if (paths[0]) setDirectory(paths[0]) + if (!props.initialDirectory) { + const paths = getRecentPaths(foundLast.id) + if (paths[0]) setDirectory(paths[0]) + } } else if (props.machines[0]) { setMachineId(props.machines[0].id) } - }, [props.machines, machineId, getLastUsedMachineId, getRecentPaths]) + }, [props.machines, machineId, getLastUsedMachineId, getRecentPaths, props.initialDirectory]) const selectedMachine = useMemo( () => (machineId ? props.machines.find((machine) => machine.id === machineId) ?? null : null), @@ -246,6 +251,13 @@ export function NewSession(props: { } }, [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect]) + const chooseFolderCallback = props.onChooseFolder + const workspaceRootAvailable = Boolean(selectedMachine?.metadata?.workspaceRoot) + const handleChooseFolder = useMemo(() => { + if (!chooseFolderCallback || !workspaceRootAvailable) return undefined + return () => chooseFolderCallback({ machineId, directory: trimmedDirectory }) + }, [chooseFolderCallback, workspaceRootAvailable, machineId, trimmedDirectory]) + async function handleCreate() { if (!machineId || !trimmedDirectory) return @@ -328,6 +340,7 @@ export function NewSession(props: { onDirectoryKeyDown={handleDirectoryKeyDown} onSuggestionSelect={handleSuggestionSelect} onPathClick={handlePathClick} + onChooseFolder={handleChooseFolder} /> void + onBrowse?: () => void +}) { + const { t } = useTranslation() + return ( +
+ + + + + + +
+ {t('sessions.empty.title')} +
+
+ {t('sessions.empty.hint')} +
+
+ + {props.onBrowse && ( + + )} +
+
+ ) +} + type MachineGroup = { machineId: string | null label: string @@ -476,6 +528,7 @@ export function SessionList(props: { sessions: SessionSummary[] onSelect: (sessionId: string) => void onNewSession: () => void + onBrowse?: () => void onRefresh: () => void isLoading: boolean renderHeader?: boolean @@ -608,6 +661,13 @@ export function SessionList(props: {
) : null} + {props.sessions.length === 0 && ( + + )} +
{machineGroups.map((mg) => { const machineCollapsed = isMachineCollapsed(mg) diff --git a/web/src/components/WorkspaceBrowser.tsx b/web/src/components/WorkspaceBrowser.tsx new file mode 100644 index 00000000..1b1e8f87 --- /dev/null +++ b/web/src/components/WorkspaceBrowser.tsx @@ -0,0 +1,351 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { Machine, MachineDirectoryEntry } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' +import { useTranslation } from '@/lib/use-translation' + +function FolderIcon(props: { className?: string }) { + return ( + + + + ) +} + +function GitIcon(props: { className?: string }) { + return ( + + + + + + + + ) +} + +function ChevronLeftIcon(props: { className?: string }) { + return ( + + + + ) +} + +function MachineIcon(props: { className?: string }) { + return ( + + + + + + ) +} + +function RefreshIcon(props: { className?: string }) { + return ( + + + + + ) +} + +function getMachineTitle(machine: Machine): string { + if (machine.metadata?.displayName) return machine.metadata.displayName + if (machine.metadata?.host) return machine.metadata.host + return machine.id.slice(0, 8) +} + +function joinPath(base: string, name: string): string { + return base.endsWith('/') ? base + name : base + '/' + name +} + +function parentPath(path: string): string { + const stripped = path.replace(/\/+$/, '') + const idx = stripped.lastIndexOf('/') + if (idx <= 0) return '/' + return stripped.slice(0, idx) +} + +function isPathWithin(candidate: string, root: string): boolean { + const c = candidate.replace(/\/+$/, '') || '/' + const r = root.replace(/\/+$/, '') || '/' + return c === r || c.startsWith(r + '/') +} + +function buildBreadcrumbs(currentPath: string, root: string): { label: string; path: string }[] { + const rootTrimmed = root.replace(/\/+$/, '') + const relative = currentPath.slice(rootTrimmed.length).replace(/^\/+/, '') + const crumbs: { label: string; path: string }[] = [{ label: rootTrimmed.split('/').pop() || '/', path: rootTrimmed || '/' }] + if (!relative) return crumbs + const parts = relative.split('/').filter(Boolean) + let acc = rootTrimmed + for (const part of parts) { + acc = acc + '/' + part + crumbs.push({ label: part, path: acc }) + } + return crumbs +} + +export function WorkspaceBrowser(props: { + api: ApiClient + machines: Machine[] + machinesLoading: boolean + onStartSession: (machineId: string, directory: string) => void + initialMachineId?: string +}) { + const { t } = useTranslation() + const { api, machines, machinesLoading, initialMachineId } = props + const queryClient = useQueryClient() + + const [machineId, setMachineId] = useState(initialMachineId ?? null) + const [currentPath, setCurrentPath] = useState(null) + const [entries, setEntries] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (machines.length === 0) { + if (machineId !== null) setMachineId(null) + return + } + if (machineId && machines.find(m => m.id === machineId)) return + // Honor an explicit initial machine before falling back to the + // last-used or first-available machine. + if (initialMachineId && machines.find(m => m.id === initialMachineId)) { + setMachineId(initialMachineId) + return + } + try { + const lastUsed = localStorage.getItem('hapi:lastMachineId') + const found = lastUsed ? machines.find(m => m.id === lastUsed) : null + setMachineId(found ? found.id : machines[0].id) + } catch { + setMachineId(machines[0].id) + } + }, [machines, machineId, initialMachineId]) + + const selectedMachine = useMemo( + () => machineId ? machines.find(m => m.id === machineId) ?? null : null, + [machineId, machines] + ) + const workspaceRoot = selectedMachine?.metadata?.workspaceRoot ?? null + + const loadDirectory = useCallback(async (path: string) => { + if (!machineId) return + setIsLoading(true) + setError(null) + try { + const result = await api.listMachineDirectory(machineId, path) + if (result.success && result.entries) { + setEntries(result.entries) + setCurrentPath(path) + } else { + setError(result.error ?? 'Failed to list directory') + // CLI may have just pushed new metadata (e.g. a workspaceRoot) + // that we haven't picked up yet — refetch so the UI can + // transition out of the no-root state if applicable. + void queryClient.invalidateQueries({ queryKey: queryKeys.machines }) + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to list directory') + void queryClient.invalidateQueries({ queryKey: queryKeys.machines }) + } finally { + setIsLoading(false) + } + }, [api, machineId, queryClient]) + + // Auto-load workspace root when a machine with a root is selected + useEffect(() => { + if (!machineId || !workspaceRoot) return + if (currentPath && isPathWithin(currentPath, workspaceRoot)) return + void loadDirectory(workspaceRoot) + }, [machineId, workspaceRoot, currentPath, loadDirectory]) + + // If switching machines, reset view + useEffect(() => { + setCurrentPath(null) + setEntries([]) + setError(null) + }, [machineId]) + + const handleEntryClick = useCallback((entry: MachineDirectoryEntry) => { + if (entry.type !== 'directory' || !currentPath) return + void loadDirectory(joinPath(currentPath, entry.name)) + }, [currentPath, loadDirectory]) + + const handleGoUp = useCallback(() => { + if (!currentPath || !workspaceRoot) return + if (currentPath.replace(/\/+$/, '') === workspaceRoot.replace(/\/+$/, '')) return + const parent = parentPath(currentPath) + if (!isPathWithin(parent, workspaceRoot)) return + void loadDirectory(parent) + }, [currentPath, workspaceRoot, loadDirectory]) + + const handleRefresh = useCallback(() => { + if (currentPath) void loadDirectory(currentPath) + }, [currentPath, loadDirectory]) + + const handleStartSession = useCallback(() => { + if (!machineId || !currentPath) return + props.onStartSession(machineId, currentPath) + }, [machineId, currentPath, props]) + + const breadcrumbs = useMemo(() => { + if (!currentPath || !workspaceRoot) return [] + return buildBreadcrumbs(currentPath, workspaceRoot) + }, [currentPath, workspaceRoot]) + + const directories = useMemo(() => entries.filter(e => e.type === 'directory'), [entries]) + const atRoot = !!(currentPath && workspaceRoot && currentPath.replace(/\/+$/, '') === workspaceRoot.replace(/\/+$/, '')) + + const machineSelector = ( +
+ + +
+ ) + + // No machines connected + if (machines.length === 0 && !machinesLoading) { + return ( +
+
{machineSelector}
+
+
{t('browse.noMachinesConnected')}
+
+
+ ) + } + + // Selected machine hasn't reported a workspaceRoot — show an info state. + // Browsing is opt-in, triggered by `--workspace-root`. + if (selectedMachine && !workspaceRoot) { + return ( +
+
{machineSelector}
+
+
{t('browse.noRootTitle')}
+
{t('browse.noRootHint')}
+ + hapi runner start --workspace-root /path/to/folder + +
+ {t('browse.noRootFooter')} +
+
+
+ ) + } + + return ( +
+
+ {machineSelector} + + {currentPath && ( +
+ + {breadcrumbs.map((crumb, i) => ( + + {i > 0 && /} + + + ))} + +
+ )} +
+ + {error && ( +
{error}
+ )} + +
+ {isLoading && entries.length === 0 ? ( +
{t('loading')}
+ ) : directories.length === 0 ? ( +
{t('browse.empty')}
+ ) : ( +
+ {directories.map(entry => ( + + ))} +
+ )} +
+ + {currentPath && ( +
+
+
+ {currentPath} +
+ +
+
+ )} +
+ ) +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 6753d030..1596adbd 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -41,6 +41,10 @@ export default { // Sessions page 'sessions.count': '{n} sessions in {m} projects', 'sessions.new': 'New Session', + 'sessions.empty.title': 'No sessions yet', + 'sessions.empty.hint': 'Start a coding session in any folder under your workspace, or browse the tree first.', + 'sessions.empty.startSession': 'Start a session', + 'sessions.empty.browse': 'Browse workspace', // Session list 'session.item.path': 'path', @@ -94,6 +98,7 @@ export default { 'newSession.machine': 'Machine', 'newSession.directory': 'Directory', 'newSession.placeholder': '/path/to/project', + 'newSession.browse': 'Browse', 'newSession.recent': 'Recent paths', 'newSession.type': 'Session type', 'newSession.type.simple': 'Simple', @@ -274,6 +279,18 @@ export default { 'settings.about.appVersion': 'App Version', 'settings.about.protocolVersion': 'Protocol Version', + // Browse / Workspace + 'browse.title': 'Browse', + 'browse.goUp': 'Go up', + 'browse.empty': 'No subdirectories found', + 'browse.refresh': 'Refresh', + 'browse.startSession': 'Start Session', + 'browse.nav': 'Browse', + 'browse.noRootTitle': 'Workspace browsing is off', + 'browse.noRootHint': 'Browsing is opt-in. Restart the runner with a workspace root to enable file-tree navigation and scoped session spawning.', + 'browse.noRootFooter': 'You can still create sessions from the “New Session” page.', + 'browse.noMachinesConnected': 'No CLI connected. Run `hapi runner start --workspace-root /path` on a machine to get started.', + // Misc 'misc.noMachines': 'No machines available', 'misc.machine': 'Machine', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index cda0511e..8539a00d 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -41,6 +41,10 @@ export default { // Sessions page 'sessions.count': '{n} 个会话,{m} 个项目', 'sessions.new': '新建会话', + 'sessions.empty.title': '还没有会话', + 'sessions.empty.hint': '在 workspace 下任意目录启动一个会话,或先浏览目录树看看。', + 'sessions.empty.startSession': '启动会话', + 'sessions.empty.browse': '浏览 workspace', // Session list 'session.item.path': '路径', @@ -96,6 +100,7 @@ export default { 'newSession.machine': '机器', 'newSession.directory': '目录', 'newSession.placeholder': '/path/to/project', + 'newSession.browse': '浏览', 'newSession.recent': '最近路径', 'newSession.type': '会话类型', 'newSession.type.simple': '简单', @@ -276,6 +281,18 @@ export default { 'settings.about.appVersion': '应用版本', 'settings.about.protocolVersion': '协议版本', + // Browse / Workspace + 'browse.title': '浏览', + 'browse.goUp': '返回上层', + 'browse.empty': '未找到子目录', + 'browse.refresh': '刷新', + 'browse.startSession': '启动会话', + 'browse.nav': '浏览', + 'browse.noRootTitle': '未启用 workspace 浏览', + 'browse.noRootHint': '浏览功能是可选的。带 --workspace-root 参数重启 runner,即可启用文件树浏览和受限的会话启动。', + 'browse.noRootFooter': '你仍然可以在「新建会话」页面直接创建会话。', + 'browse.noMachinesConnected': '没有已连接的 CLI。在某台机器上运行 `hapi runner start --workspace-root /path` 来开始。', + // Misc 'misc.noMachines': '无可用机器', 'misc.machine': '机器', diff --git a/web/src/router.tsx b/web/src/router.tsx index 15f8f33c..37adac84 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -15,6 +15,7 @@ import { App } from '@/App' import { SessionChat } from '@/components/SessionChat' import { SessionList } from '@/components/SessionList' import { NewSession } from '@/components/NewSession' +import { WorkspaceBrowser } from '@/components/WorkspaceBrowser' import { LoadingState } from '@/components/LoadingState' import { useAppContext } from '@/lib/app-context' import { useAppGoBack } from '@/hooks/useAppGoBack' @@ -77,6 +78,25 @@ function PlusIcon(props: { className?: string }) { ) } +function FolderOpenIcon(props: { className?: string }) { + return ( + + + + ) +} + function SettingsIcon(props: { className?: string }) { return (
+ + )} +
{t('browse.title')}
+
+ +
+
@@ -509,12 +596,39 @@ const sessionFileRoute = createRoute({ component: FilePage, }) +type NewSessionSearch = { + directory?: string + machineId?: string +} + const newSessionRoute = createRoute({ getParentRoute: () => sessionsRoute, path: 'new', + validateSearch: (search: Record): NewSessionSearch => { + const result: NewSessionSearch = {} + if (typeof search.directory === 'string' && search.directory) { + result.directory = search.directory + } + if (typeof search.machineId === 'string' && search.machineId) { + result.machineId = search.machineId + } + return result + }, component: NewSessionPage, }) +const browseRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/browse', + validateSearch: (search: Record): { machineId?: string } => { + if (typeof search.machineId === 'string' && search.machineId) { + return { machineId: search.machineId } + } + return {} + }, + component: BrowsePage, +}) + const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', @@ -532,6 +646,7 @@ export const routeTree = rootRoute.addChildren([ sessionFileRoute, ]), ]), + browseRoute, settingsRoute, ]) diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 39d814c5..0dd55158 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -66,6 +66,7 @@ export type Machine = { platform: string happyCliVersion: string displayName?: string + workspaceRoot?: string } | null runnerState?: RunnerState | null } @@ -95,6 +96,20 @@ export type MessagesResponse = { export type MachinesResponse = { machines: Machine[] } export type MachinePathsExistsResponse = { exists: Record } +export type MachineDirectoryEntry = { + name: string + type: 'file' | 'directory' | 'other' + size?: number + modified?: number + isGitRepo?: boolean +} + +export type MachineListDirectoryResponse = { + success: boolean + entries?: MachineDirectoryEntry[] + error?: string +} + export type SpawnResponse = | { type: 'success'; sessionId: string } | { type: 'error'; message: string }