fix(desktop): ground agent workspace, migrate legacy nest, configurable repos_dir (#1194)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-23 18:30:12 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent fa1262a92c
commit 1011cea268
20 changed files with 1034 additions and 29 deletions
+7
View File
@@ -10,6 +10,13 @@ export type Workspace = {
*/
pubkey?: string;
addedAt: string;
/**
* Absolute directory the agent's `~/.buzz/REPOS` symlinks to, so agents
* work in the user's existing checkouts instead of re-cloning. `~` is
* expanded to an absolute path before save. Unset = the default real
* `REPOS` directory inside the nest.
*/
reposDir?: string;
/**
* @deprecated Never read. Kept on the type so old localStorage entries
* deserialise without errors. New entries never set this field, and
@@ -3,6 +3,7 @@ import * as React from "react";
import type { Workspace } from "@/features/workspaces/types";
import {
deriveWorkspaceName,
expandTilde,
normalizeRelayUrl,
} from "@/features/workspaces/workspaceStorage";
import { Button } from "@/shared/ui/button";
@@ -29,33 +30,40 @@ export function AddWorkspaceDialog({
const [name, setName] = React.useState("");
const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState("");
const [reposDir, setReposDir] = React.useState("");
const handleClose = React.useCallback(() => {
onOpenChange(false);
setName("");
setRelayUrl("");
setToken("");
setReposDir("");
}, [onOpenChange]);
const handleSubmit = React.useCallback(
(e: React.FormEvent) => {
async (e: React.FormEvent) => {
e.preventDefault();
if (!relayUrl.trim()) {
return;
}
// Expand `~` before save — the backend rejects tilde paths. Empty input
// resolves to `undefined` so REPOS keeps its default location.
const expandedReposDir = await expandTilde(reposDir);
const workspace: Workspace = {
id: crypto.randomUUID(),
name: name.trim() || deriveWorkspaceName(relayUrl.trim()),
relayUrl: normalizeRelayUrl(relayUrl.trim()),
token: token.trim() || undefined,
reposDir: expandedReposDir,
addedAt: new Date().toISOString(),
};
onSubmit(workspace);
handleClose();
},
[name, relayUrl, token, onSubmit, handleClose],
[name, relayUrl, token, reposDir, onSubmit, handleClose],
);
return (
@@ -68,7 +76,10 @@ export function AddWorkspaceDialog({
messages, and identity.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void handleSubmit(e)}
>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
@@ -121,6 +132,29 @@ export function AddWorkspaceDialog({
value={token}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="ws-repos-dir"
>
Repos Directory
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional)
</span>
</label>
<Input
id="ws-repos-dir"
onChange={(e) => setReposDir(e.target.value)}
placeholder="~/Development"
type="text"
value={reposDir}
/>
<p className="text-xs text-muted-foreground">
Point the agent's <code>REPOS</code> directory at an existing
folder so agents work in your local checkouts. Leave blank to use
the default location.
</p>
</div>
<p className="text-xs text-muted-foreground">
Workspaces share your active identity. To use a different key,
import it on the profile step (or in settings).
@@ -1,7 +1,10 @@
import * as React from "react";
import type { Workspace } from "@/features/workspaces/types";
import { normalizeRelayUrl } from "@/features/workspaces/workspaceStorage";
import {
expandTilde,
normalizeRelayUrl,
} from "@/features/workspaces/workspaceStorage";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -18,7 +21,9 @@ type EditWorkspaceDialogProps = {
onOpenChange: (open: boolean) => void;
onSave: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "reposDir">
>,
) => void;
onRemove?: (id: string) => void;
canRemove?: boolean;
@@ -35,6 +40,7 @@ export function EditWorkspaceDialog({
const [name, setName] = React.useState("");
const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState("");
const [reposDir, setReposDir] = React.useState("");
// Sync form state when the dialog opens with a workspace
React.useEffect(() => {
@@ -42,6 +48,7 @@ export function EditWorkspaceDialog({
setName(workspace.name);
setRelayUrl(workspace.relayUrl);
setToken(workspace.token ?? "");
setReposDir(workspace.reposDir ?? "");
}
}, [workspace, open]);
@@ -50,14 +57,15 @@ export function EditWorkspaceDialog({
}, [onOpenChange]);
const handleSubmit = React.useCallback(
(e: React.FormEvent) => {
async (e: React.FormEvent) => {
e.preventDefault();
if (!workspace || !relayUrl.trim()) {
return;
}
const updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">> =
{};
const updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "reposDir">
> = {};
const trimmedName = name.trim();
if (trimmedName && trimmedName !== workspace.name) {
@@ -74,13 +82,22 @@ export function EditWorkspaceDialog({
updates.token = trimmedToken;
}
// Expand `~` to an absolute path before save — the backend rejects
// tilde paths. An empty field clears the override (REPOS reverts to a
// real dir). Only emit when the resolved value actually changed so a
// no-op edit doesn't trigger a backend re-apply.
const expandedReposDir = await expandTilde(reposDir);
if (expandedReposDir !== workspace.reposDir) {
updates.reposDir = expandedReposDir;
}
if (Object.keys(updates).length > 0) {
onSave(workspace.id, updates);
}
handleClose();
},
[workspace, name, relayUrl, token, onSave, handleClose],
[workspace, name, relayUrl, token, reposDir, onSave, handleClose],
);
const handleRemove = React.useCallback(() => {
@@ -103,7 +120,10 @@ export function EditWorkspaceDialog({
Update this workspace's name or relay URL.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void handleSubmit(e)}
>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
@@ -153,6 +173,29 @@ export function EditWorkspaceDialog({
value={token}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-ws-repos-dir"
>
Repos Directory
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional)
</span>
</label>
<Input
id="edit-ws-repos-dir"
onChange={(e) => setReposDir(e.target.value)}
placeholder="~/Development"
type="text"
value={reposDir}
/>
<p className="text-xs text-muted-foreground">
Point the agent's <code>REPOS</code> directory at an existing
folder so agents work in your local checkouts. Leave blank to use
the default location.
</p>
</div>
<div className="flex items-center justify-between pt-2">
<div>
{canRemove && onRemove ? (
@@ -0,0 +1,46 @@
import { listen } from "@tauri-apps/api/event";
import { useEffect } from "react";
import { toast } from "sonner";
const MIGRATION_TOAST_KEY = "buzz-legacy-nest-migrated-notified";
/**
* Surface nest-related backend events as toasts.
*
* - `repos-dir-error`: a configured `repos_dir` failed to validate or its
* symlink could not be applied (invalid path, downgrade refused, external
* target gone). Emitted by `apply_workspace` on both the validate-reject
* and the runtime symlink-failure paths, so a bad `repos_dir` is always
* visibly surfaced rather than silently logged to console.
* - `legacy-nest-migrated`: the agent's knowledge was carried over from a
* legacy `~/.sprout` nest. Shown once per machine (deduped via
* localStorage); the backend re-emits each launch while `~/.sprout` exists,
* which also covers the event being emitted before this listener mounts.
*
* Mounted at the app root ahead of the workspace-init effect so the listener
* is registered before the first `apply_workspace` call.
*/
export function useNestNotifications(): void {
useEffect(() => {
const unlistenReposError = listen<string>("repos-dir-error", (event) => {
toast.error("Repos directory not applied", {
description: event.payload,
});
});
const unlistenMigrated = listen("legacy-nest-migrated", () => {
if (localStorage.getItem(MIGRATION_TOAST_KEY) === "true") {
return;
}
localStorage.setItem(MIGRATION_TOAST_KEY, "true");
toast.success("Migrated notes from ~/.sprout", {
description: "You can delete it to reclaim disk space.",
});
});
return () => {
void unlistenReposError.then((fn) => fn());
void unlistenMigrated.then((fn) => fn());
};
}, []);
}
@@ -67,7 +67,7 @@ export function useWorkspaceInit(
// On the initial mount we skip resetting singletons (they're fresh).
const hasInitializedRef = useRef(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token) — depending on the whole object would trigger resets on name-only changes
// biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes
useEffect(() => {
let cancelled = false;
@@ -136,9 +136,20 @@ export function useWorkspaceInit(
activeWorkspace.relayUrl,
undefined,
activeWorkspace.token,
activeWorkspace.reposDir,
);
} catch (error) {
// apply_workspace validates repos_dir before touching relay/keys, so a
// hard reject (bad path) leaves the backend on the previous/default
// relay with nothing applied. Marking the workspace ready here would
// render workspace-scoped UI against the wrong relay — a silent
// split-brain. Park on the loading gate (isReady:false, no appliedKey)
// instead; the backend already toasts a `repos-dir-error`.
console.error("Failed to apply workspace to backend:", error);
if (!cancelled) {
setResult({ isReady: false, needsSetup: false, appliedKey: null });
}
return;
}
if (!cancelled) {
@@ -159,6 +170,7 @@ export function useWorkspaceInit(
activeWorkspace?.id,
activeWorkspace?.relayUrl,
activeWorkspace?.token,
activeWorkspace?.reposDir,
isSharedIdentity,
workspaceKey,
]);
@@ -32,7 +32,9 @@ export type UseWorkspacesReturn = {
reconnectWorkspace: () => void;
updateWorkspace: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey">>,
updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
) => void;
};
@@ -154,7 +156,7 @@ function useWorkspacesInternal(): UseWorkspacesReturn {
(
id: string,
updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey">
Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
) => {
setWorkspacesState((prev) => {
@@ -1,8 +1,31 @@
import type { Workspace } from "./types";
import { homeDir } from "@tauri-apps/api/path";
const WORKSPACES_KEY = "buzz-workspaces";
const ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id";
/**
* Expand a leading `~` to the user's home directory. The backend rejects
* `~`-prefixed paths (`std::fs` does not expand the shell tilde), so the UI
* resolves it before save. Returns non-`~` input unchanged. Empty/whitespace
* input returns `undefined` so callers can clear the override.
*/
export async function expandTilde(input: string): Promise<string | undefined> {
const trimmed = input.trim();
if (!trimmed) {
return undefined;
}
if (trimmed === "~") {
return homeDir();
}
if (trimmed.startsWith("~/")) {
const home = await homeDir();
const base = home.endsWith("/") ? home.slice(0, -1) : home;
return `${base}/${trimmed.slice(2)}`;
}
return trimmed;
}
export function loadWorkspaces(): Workspace[] {
try {
const raw = localStorage.getItem(WORKSPACES_KEY);