diff --git a/README.md b/README.md
index fde92e4..eb0426b 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,12 @@
> coding agents, and a **local-first visual map** for humans. No LLM in the engine,
> no network, your code never leaves your machine.
+
+
+
+ The viewer's Atlas view — every file and symbol as a constellation linked by imports, definitions, and calls (here: a 93K-file repo).
+
+
OpenVisio parses any repository with tree-sitter into a symbol + import graph,
ranks it with PageRank, and serves it two ways:
@@ -68,8 +74,11 @@ openvisio view ../other # …or any other local repo
```
The viewer ships in the `openvisio-viewer` package: the same React/Three.js
-Atlas + City views, as a self-contained static bundle. Toggle between them, click
-to focus a file, and re-index any other local repo from the path box. Nothing
+Atlas + City views, as a self-contained static bundle. It opens on the **Atlas**
+by default; switch to **City** with the view toggle (top-right). Click **Index**
+to point it at any local repo — browse the filesystem in the built-in folder
+picker (git repos are flagged) or type a path — and a staged progress loader runs
+while the deterministic engine indexes. Click any node to focus it. Nothing
leaves your machine.
**Watch your agent think.** `view` defaults to the spotlight port (7077), so it
diff --git a/core/package.json b/core/package.json
index bed01a3..8418620 100644
--- a/core/package.json
+++ b/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@openvisio/core",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Deterministic, local-first code-graph engine for OpenVisio: tree-sitter symbol/import extraction, PageRank ranking, token-budgeted skeletons and slices. No LLM, no network.",
"type": "module",
"license": "MIT",
diff --git a/core/src/build.ts b/core/src/build.ts
index e0169e1..1eecd5a 100644
--- a/core/src/build.ts
+++ b/core/src/build.ts
@@ -35,6 +35,47 @@ interface AliasRule {
targets: string[]
}
+/**
+ * Parse a tsconfig/jsconfig body into alias rules. `dirRel` is the config's own
+ * directory, repo-relative ('' for the root) — it's prefixed onto `baseUrl` so
+ * the rule targets (e.g. `src/*`) resolve repo-relative even for a config that
+ * lives in a sub-package (e.g. `viewer/tsconfig.json` → baseUrl `viewer`). This
+ * is what makes `@/…` imports resolve across a monorepo with no root tsconfig.
+ */
+function parseTsAliases(raw: string, dirRel: string): TsAliases | null {
+ let config: any
+ try {
+ config = parseJsonc(raw)
+ } catch {
+ return null
+ }
+ const co = config?.compilerOptions ?? {}
+ const baseUrlRaw: string = typeof co.baseUrl === 'string' ? co.baseUrl : '.'
+ const baseRel = posix.normalize(baseUrlRaw).replace(/^\.\/?/, '').replace(/\/$/, '')
+ let baseUrl = posix.join(dirRel || '.', baseRel === '.' ? '' : baseRel)
+ if (baseUrl === '.') baseUrl = ''
+ const rules: AliasRule[] = []
+ const paths = co.paths
+ if (paths && typeof paths === 'object') {
+ for (const key of Object.keys(paths)) {
+ const star = key.indexOf('*')
+ const targets = (Array.isArray(paths[key]) ? paths[key] : []).filter(
+ (t: unknown): t is string => typeof t === 'string',
+ )
+ if (targets.length === 0) continue
+ if (star === -1) {
+ rules.push({ prefix: key, suffix: '\0exact', targets })
+ } else {
+ rules.push({ prefix: key.slice(0, star), suffix: key.slice(star + 1), targets })
+ }
+ }
+ }
+ const excludes = (Array.isArray(config?.exclude) ? config.exclude : []).filter(
+ (e: unknown): e is string => typeof e === 'string',
+ )
+ return { baseUrl, rules, excludes }
+}
+
function loadTsAliases(absRoot: string): TsAliases {
const empty: TsAliases = { baseUrl: '', rules: [], excludes: [] }
for (const name of ['tsconfig.json', 'jsconfig.json']) {
@@ -44,35 +85,7 @@ function loadTsAliases(absRoot: string): TsAliases {
} catch {
continue
}
- let config: any
- try {
- config = parseJsonc(raw)
- } catch {
- return empty
- }
- const co = config?.compilerOptions ?? {}
- const baseUrlRaw: string = typeof co.baseUrl === 'string' ? co.baseUrl : '.'
- const baseUrl = posix.normalize(baseUrlRaw).replace(/^\.\/?/, '').replace(/\/$/, '')
- const rules: AliasRule[] = []
- const paths = co.paths
- if (paths && typeof paths === 'object') {
- for (const key of Object.keys(paths)) {
- const star = key.indexOf('*')
- const targets = (Array.isArray(paths[key]) ? paths[key] : []).filter(
- (t: unknown): t is string => typeof t === 'string',
- )
- if (targets.length === 0) continue
- if (star === -1) {
- rules.push({ prefix: key, suffix: '\0exact', targets })
- } else {
- rules.push({ prefix: key.slice(0, star), suffix: key.slice(star + 1), targets })
- }
- }
- }
- const excludes = (Array.isArray(config?.exclude) ? config.exclude : []).filter(
- (e: unknown): e is string => typeof e === 'string',
- )
- return { baseUrl: baseUrl === '.' ? '' : baseUrl, rules, excludes }
+ return parseTsAliases(raw, '') ?? empty
}
return empty
}
@@ -225,10 +238,35 @@ export async function assembleGraph(
console.error(`[build] resolving imports across ${files.length} files...`)
const bySet = new Set(fileIdByPath.keys())
- const aliases = ctx.aliases ?? loadTsAliases(absRoot)
+
+ // Per-package alias resolution. A monorepo can have many tsconfig/jsconfig
+ // files (one per sub-package, often with no root config at all), each mapping
+ // `@/*` to its own `src/*`. Discover them all from the scanned set and resolve
+ // every file's imports against its NEAREST enclosing config — otherwise all
+ // `@/…` imports go unresolved and the import graph collapses to relative-only.
+ const aliasEntries: { dir: string; aliases: TsAliases }[] = []
+ for (const sf of scanned) {
+ const base = posix.basename(sf.relPath)
+ if (base !== 'tsconfig.json' && base !== 'jsconfig.json') continue
+ const dirRaw = posix.dirname(sf.relPath)
+ const dir = dirRaw === '.' ? '' : dirRaw
+ const parsed = parseTsAliases(sf.content, dir)
+ if (parsed) aliasEntries.push({ dir, aliases: parsed })
+ }
+ // Longest dir first so the nearest config wins the prefix match.
+ aliasEntries.sort((a, b) => b.dir.length - a.dir.length)
+ const rootAliases = ctx.aliases ?? loadTsAliases(absRoot)
+ const aliasesFor = (relPath: string): TsAliases => {
+ for (const e of aliasEntries) {
+ if (e.dir === '' || relPath === e.dir || relPath.startsWith(e.dir + '/')) return e.aliases
+ }
+ return rootAliases
+ }
+
const edgeWeights = new Map()
for (const file of files) {
const raws = rawImportsByFile.get(file.id) ?? []
+ const aliases = aliasesFor(file.path)
for (const raw of raws) {
const targetPath = resolveImport(file, raw.specifier, bySet, aliases)
if (targetPath == null) continue
@@ -357,6 +395,22 @@ export interface IndexChanges {
changed: string[]
}
+// Persistent-cache schema version. The LMDB cache stores parse results (keyed by
+// content SHA) and file IDs (keyed by path). Those are only valid for the engine
+// that wrote them — when the parser's output shape changes (new symbol fields,
+// import/call extraction, id allocation), a same-SHA entry from an OLDER engine
+// would be served verbatim, silently dropping imports/edges or aliasing symbols
+// to the wrong file. BUMP THIS whenever parse output or id allocation changes;
+// the Indexer wipes a cache whose stored version doesn't match.
+export const CACHE_VERSION = 2
+const CACHE_VERSION_KEY = 'meta:cacheVersion'
+
+function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
+ if (a.length !== b.length) return false
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
+ return true
+}
+
function encodeU32(n: number): Uint8Array {
const buf = new Uint8Array(4)
const dv = new DataView(buf.buffer)
@@ -390,10 +444,27 @@ export class Indexer {
if (this.dbPath && !this.store) {
const { LmdbStore } = await import('./stores/lmdb.js')
this.store = new LmdbStore(this.dbPath)
+ this.ensureCacheVersion(this.store)
}
return (await this.run()).graph
}
+ /**
+ * Wipe the cache if it was written by a different engine version. A same-SHA
+ * parse result (or persisted file id) from an older engine is NOT safe to
+ * reuse — it can drop import/call edges or alias symbols to the wrong file —
+ * so on a version mismatch we clear everything and start the cache fresh.
+ */
+ private ensureCacheVersion(store: Store): void {
+ const want = new TextEncoder().encode(String(CACHE_VERSION))
+ const have = store.get(CACHE_VERSION_KEY)
+ if (!have || !bytesEqual(have, want)) {
+ store.clear()
+ store.set(CACHE_VERSION_KEY, want)
+ store.sync()
+ }
+ }
+
async reindex(): Promise<{ graph: CodeGraph; changes: IndexChanges }> {
return this.run()
}
@@ -414,8 +485,25 @@ export class Indexer {
if (this.store && this.idByPath.size === 0) {
for (const sf of scanned) {
const idBuf = this.store.get(`id:${sf.relPath}`)
- if (idBuf) this.idByPath.set(sf.relPath, decodeU32(idBuf))
+ // Only a well-formed 4-byte id is trustworthy; a truncated/corrupt
+ // value from a legacy cache must never reach decodeU32 (it throws) or
+ // seed a bogus id.
+ if (idBuf && idBuf.length === 4) this.idByPath.set(sf.relPath, decodeU32(idBuf))
+ }
+ // Defensive: a corrupt/legacy store can hand back the SAME id for two
+ // distinct paths, which would alias one file's symbols onto another file
+ // (e.g. a function anchored to a tsconfig). If the restored ids aren't
+ // unique, discard them all and reallocate fresh in scan order.
+ const seen = new Set()
+ let collision = false
+ for (const id of this.idByPath.values()) {
+ if (seen.has(id)) {
+ collision = true
+ break
+ }
+ seen.add(id)
}
+ if (collision) this.idByPath.clear()
}
const changes: IndexChanges = { added: [], removed: [], changed: [] }
diff --git a/core/src/store.ts b/core/src/store.ts
index d4aff7c..52e0d8f 100644
--- a/core/src/store.ts
+++ b/core/src/store.ts
@@ -2,6 +2,8 @@ export interface Store {
get(key: string): Uint8Array | null
set(key: string, value: Uint8Array): void
delete(key: string): void
+ /** Drop every entry — used to invalidate a cache built by an older engine. */
+ clear(): void
sync(): void
close(): void
}
diff --git a/core/src/stores/lmdb.ts b/core/src/stores/lmdb.ts
index de8f511..fcbf32f 100644
--- a/core/src/stores/lmdb.ts
+++ b/core/src/stores/lmdb.ts
@@ -25,6 +25,10 @@ export class LmdbStore implements Store {
this.db.removeSync(key)
}
+ clear(): void {
+ this.db.clearSync()
+ }
+
sync(): void {
this.db.transactionSync(() => {})
}
diff --git a/docs/images/viewer.png b/docs/images/viewer.png
index 7a8ecc5..d5085fc 100644
Binary files a/docs/images/viewer.png and b/docs/images/viewer.png differ
diff --git a/mcp/README.md b/mcp/README.md
index 823ee6e..126f1fd 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -40,6 +40,12 @@ network, your code never leaves your machine.**
> [viewer](https://github.com/syntaxpriest/openvisio-oss) is for the human. They
> share the same index.
+
+
+
+ The viewer's Atlas view (openvisio view) — every file and symbol as a constellation linked by imports, definitions, and calls.
+
+
---
## Why
@@ -129,10 +135,13 @@ openvisio export [path] [--out=.openvisio/graph.json] # emit the graph
package) on `127.0.0.1`, then opens your browser. City is a 3D treemap
(districts = folders, buildings = files, sized by LOC, colored by language);
Atlas is a file/symbol constellation linked by imports, definitions, and calls.
- Toggle between them, click to focus, and re-index any other local repo from the
- path box. `--no-open` just prints the URL. It defaults to the spotlight port
- (7077) and acts as the highlight hub: with `view` running, start your agent
- with `openvisio mcp . --spotlight` and its tool calls focus the graph live.
+ It opens on the Atlas by default; switch to City with the view toggle. Click
+ **Index** to browse the filesystem (git repos are flagged) or type a path and
+ index any other local repo, with a staged progress loader while it builds, then
+ click to focus a node. `--no-open` just prints the URL. It defaults to the
+ spotlight port (7077) and acts as the highlight hub: with `view` running, start
+ your agent with `openvisio mcp . --spotlight` and its tool calls focus the graph
+ live.
---
diff --git a/mcp/package.json b/mcp/package.json
index a2931d2..86bc669 100644
--- a/mcp/package.json
+++ b/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "openvisio",
- "version": "0.1.5",
+ "version": "0.2.0",
"description": "See any codebase as a graph. MCP server + CLI that serves coding agents (Claude Code, Codex, Cursor) a token-cheap, ranked, graph-native query surface over a local repo — agents query structure instead of crawling files. Local-first, read-only, no network.",
"type": "module",
"license": "MIT",
@@ -45,7 +45,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"lmdb": "^3.2.6",
- "openvisio-viewer": "^0.1.0",
+ "openvisio-viewer": "^0.2.0",
"tree-sitter-wasms": "^0.1.13",
"web-tree-sitter": "~0.25.10",
"zod": "^4.4.3"
diff --git a/mcp/src/spotlight.ts b/mcp/src/spotlight.ts
index 6da48f1..81e5f3e 100644
--- a/mcp/src/spotlight.ts
+++ b/mcp/src/spotlight.ts
@@ -12,6 +12,7 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { randomUUID } from 'node:crypto'
import * as fs from 'node:fs'
+import * as os from 'node:os'
import * as nodePath from 'node:path'
/**
@@ -325,6 +326,10 @@ export function startSpotlightServer(
handleEmitRoute(req, res, bus)
return
}
+ if (url.startsWith('/api/fs/browse')) {
+ handleFsBrowseRoute(req, res, url)
+ return
+ }
if (url.startsWith('/api/spotlight')) {
res.writeHead(200, {
...CORS_HEADERS,
@@ -521,3 +526,106 @@ function handleIndexRoute(req: IncomingMessage, res: ServerResponse, url: string
res.writeHead(405, CORS_HEADERS)
res.end('method not allowed')
}
+
+interface FsDirEntry {
+ name: string
+ path: string
+ isGitRepo: boolean
+ isHidden: boolean
+}
+
+/** Resolve raw user input to an absolute dir, expanding `~` and rooting relative
+ * paths at $HOME (the server cwd is an implementation detail the user can't see). */
+function resolveBrowseInput(raw: string | null): string {
+ const home = os.homedir()
+ if (!raw || raw.trim().length === 0) return home
+ let p = raw.trim()
+ if (p === '~') return home
+ if (p.startsWith('~/') || p.startsWith('~\\')) p = nodePath.join(home, p.slice(2))
+ if (!nodePath.isAbsolute(p)) p = nodePath.join(home, p)
+ return nodePath.resolve(p)
+}
+
+/**
+ * Local filesystem directory browser — powers the "browse" folder picker in the
+ * viewer's indexing dialog. Lists subdirectories only (never file contents),
+ * flags which folders look like git repos, and resolves `~`/relative input
+ * against $HOME. Read-only and local-only, consistent with the indexer's trust
+ * boundary (the server already reads local repos to index them).
+ * GET /api/fs/browse?path= → 200 { path, parent, home, separator, entries[] }
+ */
+function handleFsBrowseRoute(req: IncomingMessage, res: ServerResponse, url: string): void {
+ const json = (status: number, body: unknown) => {
+ res.writeHead(status, { ...CORS_HEADERS, 'Content-Type': 'application/json' })
+ res.end(JSON.stringify(body))
+ }
+ if (req.method !== 'GET') {
+ res.writeHead(405, CORS_HEADERS)
+ res.end('method not allowed')
+ return
+ }
+
+ const q = url.indexOf('?')
+ const params = new URLSearchParams(q === -1 ? '' : url.slice(q + 1))
+ const target = resolveBrowseInput(params.get('path'))
+
+ void (async () => {
+ try {
+ const stat = await fs.promises.stat(target)
+ if (!stat.isDirectory()) {
+ json(400, { error: `Not a directory: ${target}` })
+ return
+ }
+ } catch {
+ json(404, { error: `Cannot access: ${target}` })
+ return
+ }
+
+ let dirents
+ try {
+ dirents = await fs.promises.readdir(target, { withFileTypes: true })
+ } catch {
+ json(403, { error: `Permission denied: ${target}` })
+ return
+ }
+
+ const entries: FsDirEntry[] = []
+ for (const d of dirents) {
+ // Symlinks report isDirectory() === false; resolve them so symlinked
+ // repos still appear as navigable folders.
+ let isDir = d.isDirectory()
+ if (d.isSymbolicLink()) {
+ try {
+ isDir = (await fs.promises.stat(nodePath.join(target, d.name))).isDirectory()
+ } catch {
+ isDir = false
+ }
+ }
+ if (!isDir) continue
+
+ const full = nodePath.join(target, d.name)
+ let isGitRepo = false
+ try {
+ isGitRepo = (await fs.promises.stat(nodePath.join(full, '.git'))).isDirectory()
+ } catch {
+ // not a git repo (or .git is a file/worktree pointer we don't probe)
+ }
+ entries.push({ name: d.name, path: full, isGitRepo, isHidden: d.name.startsWith('.') })
+ }
+
+ // Git repos first, then alphabetical, case-insensitive.
+ entries.sort((a, b) => {
+ if (a.isGitRepo !== b.isGitRepo) return a.isGitRepo ? -1 : 1
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
+ })
+
+ const parent = nodePath.dirname(target)
+ json(200, {
+ path: target,
+ parent: parent === target ? null : parent,
+ home: os.homedir(),
+ separator: nodePath.sep,
+ entries,
+ })
+ })()
+}
diff --git a/package.json b/package.json
index 0e7a3e2..6ce21e1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openvisio",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"description": "OpenVisio monorepo. The deterministic code-graph core, the standalone MCP CLI published to npm as `openvisio`, and a local-first viewer (Atlas + City). See any codebase as a graph; give coding agents a token-cheap, graph-native query surface.",
"license": "MIT",
@@ -19,7 +19,7 @@
],
"scripts": {
"build": "npm run build -w @openvisio/core && npm run build -w openvisio-viewer && npm run build -w openvisio",
- "typecheck": "npm run typecheck -w @openvisio/core && npm run typecheck -w openvisio",
+ "typecheck": "npm run build -w @openvisio/core && npm run typecheck -w @openvisio/core && npm run typecheck -w openvisio",
"bench": "npm run build -w @openvisio/core && tsx bench/estimate.ts",
"smoke": "npm run build && node mcp/smoke.mjs"
},
diff --git a/viewer/index.html b/viewer/index.html
index 835680f..b1aecbc 100644
--- a/viewer/index.html
+++ b/viewer/index.html
@@ -5,6 +5,12 @@
OpenVisio — graph viewer
+
+
+
diff --git a/viewer/package.json b/viewer/package.json
index 2044f88..e34b341 100644
--- a/viewer/package.json
+++ b/viewer/package.json
@@ -1,6 +1,6 @@
{
"name": "openvisio-viewer",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Self-contained Atlas + City graph viewer for OpenVisio — the same React/Three.js views from the app, served locally by `openvisio view`.",
"license": "MIT",
"type": "module",
diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx
index c16e79c..ace7fa3 100644
--- a/viewer/src/App.tsx
+++ b/viewer/src/App.tsx
@@ -4,21 +4,37 @@
// `openvisio` server's /api/graph endpoint, with no account, narrator, or AI.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Building2, FolderSearch, Orbit, type LucideIcon } from 'lucide-react'
import { AtlasView } from '@/components/graph/AtlasView'
import { CityView } from '@/components/city/CityView'
+import { IndexingDialog } from '@/components/workspace/IndexingDialog'
+import { IndexingProgress } from '@/components/workspace/IndexingProgress'
import { GraphResponseSchema, type GraphResponse } from '@/lib/api/types'
import { cn } from '@/lib/utils'
type Mode = 'city' | 'atlas'
+interface ModeDef {
+ id: Mode
+ label: string
+ icon: LucideIcon
+ hint: string
+}
+
+// Atlas first — it's the default landing view (the whole codebase at a glance).
+const MODES: ModeDef[] = [
+ { id: 'atlas', label: 'Atlas', icon: Orbit, hint: 'The whole codebase' },
+ { id: 'city', label: 'City', icon: Building2, hint: 'Your code as a city' },
+]
+
export function App() {
const [source, setSource] = useState(() => new URLSearchParams(location.search).get('path') ?? '')
- const [input, setInput] = useState(source)
const [graph, setGraph] = useState(null)
- const [mode, setMode] = useState('city')
+ const [mode, setMode] = useState('atlas')
const [focusedFileId, setFocusedFileId] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
+ const [dialogOpen, setDialogOpen] = useState(false)
const [agent, setAgent] = useState<'off' | 'idle' | 'live'>('off')
const [agentTool, setAgentTool] = useState('')
@@ -35,6 +51,7 @@ export function App() {
if (!res.ok) throw new Error(body?.error ?? 'index failed (' + res.status + ')')
setGraph(GraphResponseSchema.parse(body.graph))
setFocusedFileId(null)
+ setDialogOpen(false) // success — drop the dialog and reveal the map
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setGraph(null)
@@ -43,9 +60,22 @@ export function App() {
}
}, [])
- // Index whatever ?path= we booted with (or the user re-indexes via the box).
+ // Index whatever ?path= we booted with (or the user re-indexes via the dialog).
useEffect(() => { if (source) void indexRepo(source) }, [source, indexRepo])
+ // Open the index dialog by hand when there's no repo yet (first run, no ?path=).
+ useEffect(() => { if (!source) setDialogOpen(true) }, [source])
+
+ const startIndex = useCallback((repoPath: string) => {
+ const p = repoPath.trim()
+ if (!p) return
+ const url = new URL(location.href)
+ url.searchParams.set('path', p)
+ history.replaceState(null, '', url)
+ setSource(p)
+ if (p === source) void indexRepo(p) // same path → effect won't refire; re-run manually
+ }, [source, indexRepo])
+
// Live agent spotlight — same server, same origin. A tool call's focus[0]
// drives the focused file so the building/node lights up, exactly like the app.
const byPath = useMemo(() => {
@@ -76,33 +106,27 @@ export function App() {
return () => es.close()
}, [byPath])
- const onSubmit = (e: React.FormEvent) => {
- e.preventDefault()
- const p = input.trim()
- if (!p) return
- const url = new URL(location.href)
- url.searchParams.set('path', p)
- history.replaceState(null, '', url)
- setSource(p)
- }
-
const repo = graph?.repo
return (
OpenVisio · viewer
-
+
+ {/* Index button — opens the folder browser / path dialog. */}
+
+
{repo && (
+ The path is read in place — nothing is copied or sent anywhere.
+
+ >
+ )}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ {/* In browse mode the FolderBrowser footer has its own
+ "use this folder" confirm, so skip the duplicate here. */}
+ {mode === 'type' ? (
+
+ ) : null}
+
+
+ )}
+
+
+ )
+}
diff --git a/viewer/src/components/workspace/IndexingProgress.tsx b/viewer/src/components/workspace/IndexingProgress.tsx
new file mode 100644
index 0000000..9d20717
--- /dev/null
+++ b/viewer/src/components/workspace/IndexingProgress.tsx
@@ -0,0 +1,172 @@
+import { useEffect, useState } from 'react'
+import { Check, CircleDot, Loader2 } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+export interface IndexingProgressProps {
+ target: string
+ message?: string
+ onCancel: () => void
+}
+
+interface Stage {
+ id: string
+ label: string
+ hint: string
+ minSec: number
+}
+
+// The deterministic OpenVisio engine doesn't stream progress, so these are
+// believable cumulative milestones — `minSec` is roughly how many wall-clock
+// seconds in we'd expect that stage to be in flight. Indexing is fast (no LLM):
+// most repos finish in seconds; only very large ones reach the later stages.
+const STAGES: Stage[] = [
+ { id: 'scan', label: 'Walking files', hint: 'Listing files and applying exclusion rules.', minSec: 0 },
+ { id: 'parse', label: 'Parsing with tree-sitter', hint: 'Extracting symbols from each source file.', minSec: 3 },
+ { id: 'analyze', label: 'Resolving imports', hint: 'Tracing module relationships into edges.', minSec: 10 },
+ { id: 'rank', label: 'Ranking the graph', hint: 'PageRank centrality over the import graph.', minSec: 18 },
+ { id: 'layout', label: 'Computing layout', hint: 'Placing nodes for the City + Atlas views.', minSec: 28 },
+]
+
+const HINTS = [
+ 'Indexing is deterministic and local — no LLM, no network.',
+ 'The same engine the MCP serves builds this graph.',
+ 'Large repos take a little longer; the graph is cached after.',
+ 'Same repo bytes always produce the same graph.',
+]
+
+export function IndexingProgress({ target, message, onCancel }: IndexingProgressProps) {
+ const [elapsedMs, setElapsedMs] = useState(0)
+ const [hintIdx, setHintIdx] = useState(0)
+
+ useEffect(() => {
+ const start = Date.now()
+ const tick = setInterval(() => setElapsedMs(Date.now() - start), 200)
+ return () => clearInterval(tick)
+ }, [])
+
+ useEffect(() => {
+ const rotate = setInterval(() => setHintIdx((i) => (i + 1) % HINTS.length), 6000)
+ return () => clearInterval(rotate)
+ }, [])
+
+ const elapsedSec = elapsedMs / 1000
+ // Pick the highest stage whose minSec has been reached.
+ let currentIdx = 0
+ for (let i = 0; i < STAGES.length; i++) {
+ if (elapsedSec >= (STAGES[i]?.minSec ?? Infinity)) currentIdx = i
+ }
+
+ // Progress bar: smoothly fills toward 92% over the expected duration so
+ // the user always sees motion. The last 8% completes when the index
+ // actually returns.
+ const estimatedTotal = STAGES[STAGES.length - 1]?.minSec ?? 40
+ const pct = Math.min(92, (elapsedSec / estimatedTotal) * 100)
+
+ return (
+