Skip to content

Commit 247e128

Browse files
committed
feat(desktop): add IDE workspace switcher with recent folders
Expose GET /api/workspaces and POST /api/workspaces/open so users can switch between on-disk project folders like VS Code/Cursor, with persisted recents and Nexus-inspired topbar UI including browser and pywebview open-folder flows.
1 parent 4a84d23 commit 247e128

10 files changed

Lines changed: 756 additions & 43 deletions

File tree

libs/naas-abi/naas_abi/apps/desktop/AGENTS.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ desktop/
9999

100100
## API surface (localhost only)
101101

102-
- `GET/PUT /api/settings` — workspace root, harness (`opencode` | `pi`), binaries, default model, agents, doctor dismissed, **active_org**, **active_model**
102+
- `GET/PUT /api/settings` — workspace root, harness (`opencode` | `pi`), binaries, default model, agents, doctor dismissed, **active_org**, **active_model**, **recent_workspaces**
103+
- `GET /api/workspaces` — active workspace + recent folders `{ active, recent: [{path, name, exists}] }`
104+
- `POST /api/workspaces/open``{ path }` opens an existing writable folder, sets active workspace, updates recent list
103105
- `GET /api/workspace/env``.env` / `.env.remote` presence and key names in workspace root
104106
- `GET /api/workspace/status` — git branch, workspace name, harness health, agents, default model (status bar)
105107
- `GET /api/workspace/orgs` — org folders under workspace root + active org/model
@@ -118,6 +120,19 @@ desktop/
118120
- `GET /api/graph/overview` — vis.js graph payload: nodes, edges, SQLite table snapshots
119121
- `POST /api/router/suggest` — intent-aware model suggestions from `instances.ttl` BFO7 realizabilities
120122

123+
## Workspace switcher (IDE folder semantics)
124+
125+
A workspace is a **folder on disk** (VS Code / Cursor semantics), not a Nexus tenant.
126+
127+
- **UI**: topbar-left button (logo + basename + chevron) opens a Nexus-inspired dropdown (square corners). Shows the current path (dimmed), recent workspaces, and **Open Folder…**.
128+
- **Switch / open**: `POST /api/workspaces/open` or `PUT /api/settings` with a new `workspace_root`. Triggers `ensure_workspace`, harness restart, terminal reconnect, file index refresh, org/model context reload, and graph rescaffold.
129+
- **Recent list**: `recent_workspaces` setting (JSON array, max 10 paths). Updated on every open/switch.
130+
- **First run**: `maybe_upgrade_workspace_setting()` auto-detects `~/abi` (git + `.env`) when still on the factory default.
131+
- **Open Folder**:
132+
- **Browser dev** (`--browser-only`): modal with absolute path input; server validates the folder exists and is writable.
133+
- **pywebview app**: same modal plus **Browse…** calling `pywebview.api.pick_workspace_folder()` (native `FOLDER_DIALOG` via `main.py` `DesktopApi` bridge).
134+
- **Nexus reference**: `apps/nexus/apps/web/src/components/shell/sidebar/index.tsx` (workspace logo menu, glass dropdown portal).
135+
121136
## Org/model workspace layout
122137

123138
Canonical context path under the workspace root::
@@ -267,7 +282,7 @@ Canonical command (pytest + coverage KPI):
267282
make test-desktop
268283
```
269284

270-
Expected footer: `237 passed, 0 failed` and `TOTAL coverage 90%` (approximate; run the script for the current number). CI one-liner: `scripts/coverage-kpi.sh``desktop_coverage=90.0 pass=237 fail=0`.
285+
Expected footer: `244 passed, 0 failed` and `TOTAL coverage 90%` (approximate; run the script for the current number). CI one-liner: `scripts/coverage-kpi.sh``desktop_coverage=90.0 pass=244 fail=0`.
271286

272287
See [README.md](README.md) for prerequisites, quick start, and folder map.
273288

libs/naas-abi/naas_abi/apps/desktop/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ uv run python libs/naas-abi/naas_abi/apps/desktop/run.py --browser-only
2727

2828
Open [http://127.0.0.1:54242](http://127.0.0.1:54242). Override the port with `ABI_DESKTOP_PORT` if needed.
2929

30+
Use the **workspace switcher** (top-left: logo + folder name + chevron) to open or switch IDE workspaces. Each workspace is a folder on disk (e.g. `~/abi`). In browser dev, **Open Folder…** accepts a typed path; in the pywebview app, **Browse…** opens the native folder picker.
31+
3032
Useful flags:
3133

3234
```bash
@@ -67,7 +69,7 @@ make test-desktop
6769
Expected output ends with:
6870

6971
```
70-
237 passed, 0 failed
72+
244 passed, 0 failed
7173
TOTAL coverage 90%
7274
```
7375

@@ -80,7 +82,7 @@ CI one-liner (after `test.sh`):
8082

8183
```bash
8284
./libs/naas-abi/naas_abi/apps/desktop/scripts/coverage-kpi.sh
83-
# desktop_coverage=90.0 pass=237 fail=0
85+
# desktop_coverage=90.0 pass=244 fail=0
8486
```
8587

8688
Single file or keyword runs pass through to pytest:

libs/naas-abi/naas_abi/apps/desktop/api/server.py

Lines changed: 94 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,15 @@
4646
DB_PATH,
4747
GRAPH_DIR,
4848
WORKSPACE_ROOT_VISIBLE,
49+
add_recent_workspace,
4950
build_shell_env_source,
5051
ensure_dirs,
5152
ensure_workspace,
5253
maybe_upgrade_workspace_setting,
54+
parse_recent_workspaces,
55+
serialize_recent_workspaces,
56+
validate_workspace_dir,
57+
workspace_entry,
5358
workspace_env_report,
5459
)
5560
from ..core.doctor import OpencodeProbe, run_doctor
@@ -116,6 +121,10 @@ def _git_branch(workspace_root: str | Path) -> str | None:
116121
return None
117122

118123

124+
class OpenWorkspaceRequest(BaseModel):
125+
path: str
126+
127+
119128
class SettingsUpdate(BaseModel):
120129
workspace_root: str | None = None
121130
active_org: str | None = None
@@ -427,6 +436,83 @@ async def workspace_status() -> dict[str, Any]:
427436
}
428437
return payload
429438

439+
def _workspaces_payload() -> dict[str, Any]:
440+
settings = store.get_settings()
441+
active_root = settings.get("workspace_root") or ""
442+
active = workspace_entry(active_root) if active_root else None
443+
recent_paths = parse_recent_workspaces(settings.get("recent_workspaces"))
444+
recent = [workspace_entry(path) for path in recent_paths]
445+
return {"active": active, "recent": recent}
446+
447+
async def _restart_harness_for_workspace(
448+
updated: dict[str, str],
449+
prior: dict[str, str],
450+
values: dict[str, str],
451+
) -> None:
452+
nonlocal harness
453+
harness_keys = ("workspace_root", "harness", "opencode_bin", "pi_bin")
454+
if not any(key in values for key in harness_keys):
455+
return
456+
ensure_workspace(Path(updated["workspace_root"]))
457+
if "harness" in values and values["harness"] != prior.get("harness"):
458+
await harness.stop()
459+
harness = create_harness(updated)
460+
app.state.harness = harness
461+
else:
462+
binary = (
463+
updated.get("opencode_bin")
464+
if (updated.get("harness") or "opencode") == "opencode"
465+
else updated.get("pi_bin")
466+
)
467+
try:
468+
await harness.restart(
469+
workspace_root=updated.get("workspace_root"),
470+
binary=binary,
471+
)
472+
except HarnessUnavailableError:
473+
pass
474+
475+
async def _reload_workspace_context(updated: dict[str, str]) -> None:
476+
workspace = Path(updated["workspace_root"]).resolve()
477+
org = updated.get("active_org") or DEFAULT_ORG
478+
model = updated.get("active_model") or DEFAULT_MODEL
479+
scaffold_org_model(workspace, org, model)
480+
try:
481+
_reload_active_context()
482+
except Exception:
483+
pass
484+
485+
@app.get("/api/workspaces")
486+
def list_workspaces() -> dict[str, Any]:
487+
return _workspaces_payload()
488+
489+
@app.post("/api/workspaces/open")
490+
async def open_workspace(body: OpenWorkspaceRequest) -> dict[str, Any]:
491+
nonlocal harness
492+
try:
493+
resolved = validate_workspace_dir(body.path)
494+
except ValueError as exc:
495+
raise HTTPException(400, str(exc)) from exc
496+
prior = store.get_settings()
497+
recent = add_recent_workspace(
498+
parse_recent_workspaces(prior.get("recent_workspaces")),
499+
resolved,
500+
)
501+
updated = store.update_settings(
502+
{
503+
"workspace_root": str(resolved),
504+
"recent_workspaces": serialize_recent_workspaces(recent),
505+
}
506+
)
507+
await _restart_harness_for_workspace(
508+
updated, prior, {"workspace_root": str(resolved)}
509+
)
510+
await _reload_workspace_context(updated)
511+
return {
512+
"settings": updated,
513+
"workspaces": _workspaces_payload(),
514+
}
515+
430516
@app.put("/api/settings")
431517
async def put_settings(update: SettingsUpdate) -> dict[str, str]:
432518
nonlocal harness
@@ -436,41 +522,23 @@ async def put_settings(update: SettingsUpdate) -> dict[str, str]:
436522
values["active_org"] = sanitize_segment(values["active_org"])
437523
if "active_model" in values:
438524
values["active_model"] = sanitize_segment(values["active_model"])
525+
if "workspace_root" in values:
526+
recent = add_recent_workspace(
527+
parse_recent_workspaces(prior.get("recent_workspaces")),
528+
values["workspace_root"],
529+
)
530+
values["recent_workspaces"] = serialize_recent_workspaces(recent)
439531
updated = store.update_settings(values)
440532
harness_keys = ("workspace_root", "harness", "opencode_bin", "pi_bin")
441533
if any(key in values for key in harness_keys):
442-
ensure_workspace(Path(updated["workspace_root"]))
443-
if "harness" in values and values["harness"] != prior.get("harness"):
444-
await harness.stop()
445-
harness = create_harness(updated)
446-
app.state.harness = harness
447-
else:
448-
binary = (
449-
updated.get("opencode_bin")
450-
if (updated.get("harness") or "opencode") == "opencode"
451-
else updated.get("pi_bin")
452-
)
453-
try:
454-
await harness.restart(
455-
workspace_root=updated.get("workspace_root"),
456-
binary=binary,
457-
)
458-
except HarnessUnavailableError:
459-
pass
534+
await _restart_harness_for_workspace(updated, prior, values)
460535
context_changed = (
461536
"workspace_root" in values
462537
or "active_org" in values
463538
or "active_model" in values
464539
)
465540
if context_changed:
466-
workspace = Path(updated["workspace_root"]).resolve()
467-
org = updated.get("active_org") or DEFAULT_ORG
468-
model = updated.get("active_model") or DEFAULT_MODEL
469-
scaffold_org_model(workspace, org, model)
470-
try:
471-
_reload_active_context()
472-
except Exception:
473-
pass
541+
await _reload_workspace_context(updated)
474542
return updated
475543

476544
@app.get("/api/workspace/orgs")

libs/naas-abi/naas_abi/apps/desktop/api/server_test.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,62 @@ def test_workspace_env_preview_uses_query_param(
201201
assert "ANTHROPIC_API_KEY" in payload["provider_keys"]
202202

203203

204+
def test_workspaces_list_returns_active_and_recent(
205+
client: TestClient, workspace: Path, tmp_path: Path
206+
) -> None:
207+
other = tmp_path / "other-project"
208+
other.mkdir()
209+
client.put("/api/settings", json={"workspace_root": str(workspace)})
210+
client.post("/api/workspaces/open", json={"path": str(other)})
211+
212+
payload = client.get("/api/workspaces").json()
213+
assert payload["active"]["path"] == str(other.resolve())
214+
assert payload["active"]["name"] == "other-project"
215+
assert payload["active"]["exists"] is True
216+
recent_paths = [item["path"] for item in payload["recent"]]
217+
assert str(other.resolve()) in recent_paths
218+
assert str(workspace.resolve()) in recent_paths
219+
220+
221+
def test_workspaces_open_sets_active_and_adds_recent(
222+
client: TestClient, workspace: Path, tmp_path: Path, opencode: StubOpencode
223+
) -> None:
224+
target = tmp_path / "opened"
225+
target.mkdir()
226+
(target / "marker.txt").write_text("ok")
227+
228+
response = client.post("/api/workspaces/open", json={"path": str(target)})
229+
assert response.status_code == 200
230+
body = response.json()
231+
assert body["settings"]["workspace_root"] == str(target.resolve())
232+
assert body["workspaces"]["active"]["name"] == "opened"
233+
assert opencode.restarts[-1]["workdir"] == str(target.resolve())
234+
235+
236+
def test_workspaces_open_invalid_path_400(client: TestClient) -> None:
237+
response = client.post(
238+
"/api/workspaces/open", json={"path": "/no/such/workspace/folder"}
239+
)
240+
assert response.status_code == 400
241+
242+
243+
def test_workspaces_switch_updates_file_listing_root(
244+
client: TestClient, workspace: Path, tmp_path: Path
245+
) -> None:
246+
other = tmp_path / "listing-root"
247+
other.mkdir()
248+
(other / "only-here.txt").write_text("x")
249+
(workspace / "only-here.txt").write_text("y")
250+
251+
client.post("/api/workspaces/open", json={"path": str(other)})
252+
listing = client.get("/api/files").json()
253+
names = [entry["name"] for entry in listing["entries"]]
254+
assert "only-here.txt" in names
255+
assert client.get("/api/files/content", params={"path": "only-here.txt"}).json()[
256+
"content"
257+
] == "x"
258+
259+
204260
def test_workspace_status_reports_git_and_harness(
205261
client: TestClient, workspace: Path, opencode: StubOpencode
206262
) -> None:

0 commit comments

Comments
 (0)