spaces.create linked any workspacePath to the new project, and computer.files and computer.readFile then listed and read files under that folder for anyone in the space. On a deployment with signups, any member could link / or the API's own checkout and read whatever the API process can read, such as its .env file. Linking also ran git in the chosen folder and stored its remote URL. A linked folder lives on the host, and the host is the deployment owner's machine, the rule the onboarding scan already follows: - spaces.create refuses a folder from anyone but the deployment owner, before the space is created or the folder is touched. - Project files are listed and read only for the deployment owner, so a space a member linked before this change stays closed too. Space navigation no longer shows a linked folder's path and git remote to other members. - A linked folder is stored as its real path, and listing and reading follow symlinks only while they stay inside it. Symlinks that leave the folder or lead nowhere are left out of a listing instead of failing it. - The web New project dialog offers the folder field only to the deployment owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
559 lines
18 KiB
TypeScript
559 lines
18 KiB
TypeScript
import { Trans, useLingui } from "@lingui/react/macro";
|
||
import type { Bot, BotSection } from "@rakazo/contracts";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
Button,
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
Input,
|
||
} from "@rakazo/ui-web";
|
||
import { FolderOpen, Lock, Users } from "lucide-react";
|
||
import { useId, useState } from "react";
|
||
import { desktopBridge } from "../../lib/desktop";
|
||
|
||
/** Each dialog is mounted only while open, so `open` is always true and the
|
||
* parent unmounts it from `onCancel`. Escape and backdrop presses are ignored
|
||
* while a request is in flight. */
|
||
function closeUnlessBusy(busy: boolean, onCancel: () => void) {
|
||
return (open: boolean) => {
|
||
if (!open && !busy) onCancel();
|
||
};
|
||
}
|
||
|
||
export type NewSpaceConfirmInput = {
|
||
name: string;
|
||
workspacePath?: string;
|
||
};
|
||
|
||
export function NewSpaceDialog({
|
||
variant = "space",
|
||
canLinkFolder,
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
variant?: "space" | "project";
|
||
/** Folders live on the host, which only the deployment owner may link. */
|
||
canLinkFolder: boolean;
|
||
onCancel: () => void;
|
||
onConfirm: (input: NewSpaceConfirmInput) => Promise<void>;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const nameId = useId();
|
||
const folderId = useId();
|
||
const [name, setName] = useState("");
|
||
const [workspacePath, setWorkspacePath] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const isProject = variant === "project";
|
||
const showFolder = isProject && canLinkFolder;
|
||
const desktop = desktopBridge();
|
||
const trimmedWorkspacePath = workspacePath.trim();
|
||
|
||
const create = () => {
|
||
const trimmed = name.trim();
|
||
if (!trimmed || saving) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
void onConfirm({
|
||
name: trimmed,
|
||
...(showFolder && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}),
|
||
}).catch((reason: unknown) => {
|
||
setError(
|
||
reason instanceof Error
|
||
? reason.message
|
||
: isProject
|
||
? t`Could not create project`
|
||
: t`Could not create space`,
|
||
);
|
||
setSaving(false);
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Dialog open onOpenChange={closeUnlessBusy(saving, onCancel)}>
|
||
<DialogContent showCloseButton={false}>
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2.5">
|
||
<Lock
|
||
size={17}
|
||
strokeWidth={1.8}
|
||
className="text-muted-foreground"
|
||
aria-hidden="true"
|
||
/>
|
||
{isProject ? <Trans>New project</Trans> : <Trans>New space</Trans>}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<label htmlFor={nameId} className="block text-ui-body text-foreground/75">
|
||
<Trans>Name</Trans>
|
||
<Input
|
||
id={nameId}
|
||
maxLength={60}
|
||
value={name}
|
||
onChange={(event) => setName(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") create();
|
||
}}
|
||
placeholder={t`Customer support`}
|
||
className="mt-2"
|
||
/>
|
||
</label>
|
||
{showFolder ? (
|
||
<div className="space-y-2">
|
||
<label htmlFor={folderId} className="block text-ui-body text-foreground/75">
|
||
<Trans>Project folder</Trans>
|
||
<span className="ml-1 text-ui-body-sm text-muted-foreground">
|
||
<Trans>(optional)</Trans>
|
||
</span>
|
||
<div className="mt-2 flex gap-2">
|
||
<Input
|
||
id={folderId}
|
||
value={workspacePath}
|
||
onChange={(event) => setWorkspacePath(event.target.value)}
|
||
placeholder={t`/Users/you/projects/acme`}
|
||
spellCheck={false}
|
||
/>
|
||
{desktop?.files?.pickDirectory ? (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={saving}
|
||
aria-label={t`Choose folder`}
|
||
onClick={() => {
|
||
void desktop.files
|
||
?.pickDirectory()
|
||
.then((picked) => {
|
||
if (picked) setWorkspacePath(picked);
|
||
})
|
||
.catch(() => undefined);
|
||
}}
|
||
>
|
||
<FolderOpen size={16} strokeWidth={1.8} aria-hidden="true" />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</label>
|
||
<p className="text-ui-body-sm text-muted-foreground">
|
||
{desktop?.files?.pickDirectory ? (
|
||
<Trans>
|
||
Choose an existing folder on this computer. Git remotes are linked automatically
|
||
when a repository is present.
|
||
</Trans>
|
||
) : (
|
||
<Trans>
|
||
Enter an absolute folder path on the server. Git remotes are linked automatically
|
||
when a repository is present.
|
||
</Trans>
|
||
)}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
|
||
<DialogFooter>
|
||
<Button variant="outline" disabled={saving} onClick={onCancel}>
|
||
<Trans>Cancel</Trans>
|
||
</Button>
|
||
<Button disabled={saving || !name.trim()} onClick={create}>
|
||
{saving ? (
|
||
<Trans>Creating…</Trans>
|
||
) : isProject ? (
|
||
<Trans>Create project</Trans>
|
||
) : (
|
||
<Trans>Create space</Trans>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export function PickerInfoDialog({
|
||
topic,
|
||
onClose,
|
||
}: {
|
||
topic: "group" | "space";
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<Dialog
|
||
open
|
||
onOpenChange={(open) => {
|
||
if (!open) onClose();
|
||
}}
|
||
>
|
||
<DialogContent data-testid="picker-info-dialog">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2.5">
|
||
{topic === "group" ? (
|
||
<Users
|
||
size={17}
|
||
strokeWidth={1.8}
|
||
className="text-muted-foreground"
|
||
aria-hidden="true"
|
||
/>
|
||
) : (
|
||
<Lock
|
||
size={17}
|
||
strokeWidth={1.8}
|
||
className="text-muted-foreground"
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
{topic === "group" ? <Trans>Groups</Trans> : <Trans>Spaces</Trans>}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{topic === "group" ? (
|
||
<Trans>Shared chat with 2-6 bots. They all reply in the same thread.</Trans>
|
||
) : (
|
||
<Trans>Private workspace with its own bots and groups.</Trans>
|
||
)}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export function NewBotSectionDialog({
|
||
bot,
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
bot: Pick<Bot, "name">;
|
||
onCancel: () => void;
|
||
onConfirm: (name: string) => Promise<void>;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const nameId = useId();
|
||
const [name, setName] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
return (
|
||
<Dialog open onOpenChange={closeUnlessBusy(saving, onCancel)}>
|
||
<DialogContent showCloseButton={false}>
|
||
<form
|
||
className="contents"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
const trimmed = name.trim();
|
||
if (!trimmed || saving) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
void onConfirm(trimmed).catch((err: unknown) => {
|
||
setError(err instanceof Error ? err.message : t`Could not create section`);
|
||
setSaving(false);
|
||
});
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
<Trans>New section</Trans>
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
<Trans>Create a section and move {bot.name} into it.</Trans>
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<label htmlFor={nameId} className="block text-ui-body text-foreground/75">
|
||
<Trans>Name</Trans>
|
||
<Input
|
||
id={nameId}
|
||
maxLength={60}
|
||
value={name}
|
||
onChange={(event) => setName(event.target.value)}
|
||
className="mt-2"
|
||
/>
|
||
</label>
|
||
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" disabled={saving} onClick={onCancel}>
|
||
<Trans>Cancel</Trans>
|
||
</Button>
|
||
<Button type="submit" disabled={saving || !name.trim()}>
|
||
{saving ? <Trans>Creating…</Trans> : <Trans>Create</Trans>}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export function RenameBotSectionDialog({
|
||
section,
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
section: Pick<BotSection, "name">;
|
||
onCancel: () => void;
|
||
onConfirm: (name: string) => Promise<void>;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const nameId = useId();
|
||
const [name, setName] = useState(section.name);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const trimmed = name.trim();
|
||
const unchanged = trimmed === section.name;
|
||
|
||
return (
|
||
<Dialog open onOpenChange={closeUnlessBusy(saving, onCancel)}>
|
||
<DialogContent showCloseButton={false} aria-describedby={undefined}>
|
||
<form
|
||
className="contents"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (!trimmed || saving || unchanged) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
void onConfirm(trimmed).catch((err: unknown) => {
|
||
setError(err instanceof Error ? err.message : t`Could not rename section`);
|
||
setSaving(false);
|
||
});
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
<Trans>Rename section</Trans>
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<label htmlFor={nameId} className="block text-ui-body text-foreground/75">
|
||
<Trans>Name</Trans>
|
||
<Input
|
||
id={nameId}
|
||
maxLength={60}
|
||
value={name}
|
||
onChange={(event) => setName(event.target.value)}
|
||
className="mt-2"
|
||
autoFocus
|
||
/>
|
||
</label>
|
||
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" disabled={saving} onClick={onCancel}>
|
||
<Trans>Cancel</Trans>
|
||
</Button>
|
||
<Button type="submit" disabled={saving || !trimmed || unchanged}>
|
||
{saving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export function ClearConversationDialog({
|
||
bot,
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
bot: Pick<Bot, "name">;
|
||
onCancel: () => void;
|
||
onConfirm: () => Promise<void>;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const [clearing, setClearing] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
return (
|
||
<AlertDialog open onOpenChange={closeUnlessBusy(clearing, onCancel)}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle className="break-words">
|
||
<Trans>Clear {bot.name}’s conversation?</Trans>
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
<Trans>
|
||
This permanently removes every message and stops current work. The chat remains
|
||
available.
|
||
</Trans>
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={clearing}>
|
||
<Trans>Cancel</Trans>
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
variant="destructive"
|
||
disabled={clearing}
|
||
onClick={() => {
|
||
setClearing(true);
|
||
setError(null);
|
||
void onConfirm().catch((err: unknown) => {
|
||
setError(err instanceof Error ? err.message : t`Could not clear conversation`);
|
||
setClearing(false);
|
||
});
|
||
}}
|
||
>
|
||
{clearing ? <Trans>Clearing…</Trans> : <Trans>Clear</Trans>}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
);
|
||
}
|
||
|
||
export function DeleteBotDialog({
|
||
bot,
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
bot: Bot;
|
||
onCancel: () => void;
|
||
onConfirm: (deleteMemories: boolean) => Promise<void>;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [deleteMemories, setDeleteMemories] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
return (
|
||
<AlertDialog open onOpenChange={closeUnlessBusy(deleting, onCancel)}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle className="break-words">
|
||
<Trans>Delete {bot.name}?</Trans>
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
<Trans>
|
||
Its conversation, files, and routines will be permanently deleted. Bots it created
|
||
stay in your list.
|
||
</Trans>
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<fieldset className="space-y-2">
|
||
<legend className="mb-2 text-ui-body text-foreground/75">
|
||
<Trans>What about its memories?</Trans>
|
||
</legend>
|
||
<label className="flex cursor-pointer gap-3 rounded-lg border border-border p-3">
|
||
<input
|
||
type="radio"
|
||
name="delete-memory"
|
||
checked={!deleteMemories}
|
||
onChange={() => setDeleteMemories(false)}
|
||
/>
|
||
<span>
|
||
<span className="block text-ui-body text-foreground">
|
||
<Trans>Keep memories</Trans>
|
||
</span>
|
||
<span className="mt-0.5 block text-ui-body-sm text-muted-foreground">
|
||
<Trans>Move them to your shared memory.</Trans>
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<label className="flex cursor-pointer gap-3 rounded-lg border border-border p-3">
|
||
<input
|
||
type="radio"
|
||
name="delete-memory"
|
||
checked={deleteMemories}
|
||
onChange={() => setDeleteMemories(true)}
|
||
/>
|
||
<span>
|
||
<span className="block text-ui-body text-foreground">
|
||
<Trans>Delete memories too</Trans>
|
||
</span>
|
||
<span className="mt-0.5 block text-ui-body-sm text-muted-foreground">
|
||
<Trans>This cannot be undone.</Trans>
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</fieldset>
|
||
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={deleting}>
|
||
<Trans>Cancel</Trans>
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
variant="destructive"
|
||
disabled={deleting}
|
||
onClick={() => {
|
||
setDeleting(true);
|
||
setError(null);
|
||
void onConfirm(deleteMemories).catch((err: unknown) => {
|
||
setError(err instanceof Error ? err.message : t`Could not delete bot`);
|
||
setDeleting(false);
|
||
});
|
||
}}
|
||
>
|
||
{deleting ? <Trans>Deleting…</Trans> : <Trans>Delete</Trans>}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
);
|
||
}
|
||
|
||
export function DeleteItemDialog({
|
||
item,
|
||
noun,
|
||
description,
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
item: { name: string };
|
||
noun: "group" | "routine" | "space";
|
||
description?: React.ReactNode;
|
||
onCancel: () => void;
|
||
onConfirm: () => Promise<void>;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
return (
|
||
<AlertDialog open onOpenChange={closeUnlessBusy(deleting, onCancel)}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>
|
||
<Trans>Delete {item.name}?</Trans>
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{description ?? <Trans>This cannot be undone.</Trans>}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={deleting}>
|
||
<Trans>Cancel</Trans>
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
variant="destructive"
|
||
disabled={deleting}
|
||
onClick={() => {
|
||
setDeleting(true);
|
||
setError(null);
|
||
void onConfirm().catch((err: unknown) => {
|
||
setError(
|
||
err instanceof Error
|
||
? err.message
|
||
: noun === "group"
|
||
? t`Could not delete group`
|
||
: noun === "space"
|
||
? t`Could not delete space`
|
||
: t`Could not delete routine`,
|
||
);
|
||
setDeleting(false);
|
||
});
|
||
}}
|
||
>
|
||
{deleting ? <Trans>Deleting…</Trans> : <Trans>Delete</Trans>}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
);
|
||
}
|