mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
fa1262a92c
commit
1011cea268
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user