From 010dc4136986b03d64baf52c8e3c4f322ce64f57 Mon Sep 17 00:00:00 2001 From: Everett Date: Sat, 25 Apr 2026 18:59:18 -0700 Subject: [PATCH] feat: workspace browser with --workspace-root opt-in scoping (#526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add workspace browser for multi-directory navigation Add /browse route with a folder browser that lets users navigate filesystem directories on connected machines and launch sessions from any folder. Supports saved workspace paths and direct path input. The "Start Session" action pre-fills the NewSession form. - CLI: register machine-level `list-directory` RPC handler - Hub: add POST /machines/:id/list-directory route - Web: add WorkspaceBrowser component with git repo detection - Web: add /browse route with navigation from sessions sidebar - Web: support initialDirectory/initialMachineId in NewSession Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add --workspace-root opt-in scoping for /browse and session spawn Adds a single new flag, \`--workspace-root \` (with \`~\` / \`~/foo\` expansion), on \`hapi runner start\` and \`hapi runner start-sync\`. When set: - The runner reports the path in machine metadata. - The list-directory and spawn-session RPC handlers reject paths outside the root, so the web UI can't escape the configured tree even if someone crafts a request manually. - The /browse page in the web UI auto-opens that root, restricts the breadcrumb / go-up to its subtree, and shows directory entries with git-repo annotations. - The /sessions/new form keeps its existing free-text directory input plus autocomplete + recent-paths chips, and gains a small "Browse" button (next to the input) that opens /browse for picking a folder. - Reconnect-time metadata sync ensures stale records get the field filled in (or cleared when the flag is dropped on a later restart), so the hub state matches the CLI's intent. When unset: - Runner behaves like the legacy hapi (no scoping, no browse feature). - /browse renders an informative state pointing at the flag instead of blocking the user. - The /sessions/new form looks identical to the pre-change behavior; the "Browse" button is hidden. Includes a startup banner so \`runner start-sync\` no longer looks like it hung, and surfaces the workspace-root sync result on stdout. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(hub): preserve workspaceRoot when rehydrating machines from store MachineCache.refreshMachine() rebuilt the metadata object from an explicit field allowlist, so any field not in the list (including the new workspaceRoot) was silently dropped on every read — even though it was correctly written to the store. Add workspaceRoot to the zod schema, the Machine interface, and the hand-rolled projection. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(web): friendlier empty state on /sessions When there are zero sessions the page used to be a vast blank rectangle with just the "0 sessions in 0 projects" caption. Render a centered empty state instead: a calendar/agenda icon, a short heading and hint, and two buttons — "Start a session" (→ /sessions/new) and "Browse workspace" (→ /browse). SessionList gains an optional onBrowse prop. Router wires it on the sessions page so the secondary button resolves; other callers can leave it unset to hide that button. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: document --workspace-root flag in cli/README and root README Add a short paragraph under "Runner management" in cli/README.md explaining what \`--workspace-root\` enables (scoped /browse tree, list/spawn enforcement, tilde expansion) and that omitting it keeps the legacy behavior. Mention the workspace browser in the top-level README's Features list. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address PR #526 review feedback Three findings from the review bot: 1. [Major] Workspace-scope check was lexical only. With workspaceRoot = /safe, a symlink such as /safe/out -> /etc would pass the relative- path test and let list-directory / spawn-happy-session reach paths outside the configured root. realpath the workspaceRoot at construction time, and resolve every incoming path through realpath (walking up to the nearest existing parent for spawn targets that haven't been created yet) before the containment check. 2. [Minor] \`hapi runner start --workspace-root\` with no value used to drop the flag silently and start the runner unscoped. Now treats a missing or flag-shaped next argument as an error. 3. [Minor] /sessions/new's "Browse" button always opened /browse using localStorage's last-used machine, ignoring the user's current selection. NewSession already passes machineId in its callback; forward it through the /browse search params and seed WorkspaceBrowser with it. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(cli): gate list-directory RPC behind --workspace-root opt-in Without a configured workspaceRoot, isWithinWorkspaceRoot() returns true unconditionally, leaving the new list-directory RPC able to enumerate any path on the runner. The Web UI already hides Browse for these machines, but the backend should enforce the opt-in too. Refuse the RPC up front when no workspace root is configured. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 1 + cli/README.md | 8 + cli/src/agent/sessionFactory.ts | 5 +- cli/src/api/api.ts | 4 +- cli/src/api/apiMachine.ts | 181 ++++++++- cli/src/api/types.ts | 3 +- cli/src/commands/runner.ts | 69 +++- cli/src/runner/run.ts | 21 +- cli/src/utils/workspaceRoot.ts | 17 + hub/src/sync/machineCache.ts | 7 +- hub/src/sync/rpcGateway.ts | 8 + hub/src/sync/syncEngine.ts | 4 + hub/src/web/routes/machines.ts | 26 ++ web/src/api/client.ts | 14 + .../NewSession/DirectorySection.tsx | 67 ++-- web/src/components/NewSession/index.tsx | 23 +- web/src/components/SessionList.tsx | 60 +++ web/src/components/WorkspaceBrowser.tsx | 351 ++++++++++++++++++ web/src/lib/locales/en.ts | 17 + web/src/lib/locales/zh-CN.ts | 17 + web/src/router.tsx | 115 ++++++ web/src/types/api.ts | 15 + 22 files changed, 990 insertions(+), 43 deletions(-) create mode 100644 cli/src/utils/workspaceRoot.ts create mode 100644 web/src/components/WorkspaceBrowser.tsx 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 }