mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(hub): agent-first onboarding on the no-projects empty state (#60)
This commit is contained in:
@@ -81,8 +81,7 @@ func TestE2EServe(t *testing.T) {
|
||||
if _, err := auth.signup(e2eMember, "E2E Member", e2ePassword); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// In no org: sees the onboarding empty state (and creating a project
|
||||
// from it mints a fresh org via orgForCreate).
|
||||
// In no org: sees the onboarding empty state (agent paste prompt).
|
||||
if _, err := auth.signup(e2eSolo, "E2E Solo", e2ePassword); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -69,16 +69,21 @@ test("join link accepts an invite after sign-in", async ({ page, browser }) => {
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test("no-org account gets the onboarding empty state and can create a project", async ({
|
||||
test("no-org account gets the onboarding empty state with the agent prompt", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page, "solo@example.com");
|
||||
await expect(page.locator(".onboard h1")).toHaveText("Welcome to BearDrive");
|
||||
await page.fill("#ob-name", "solo-notes");
|
||||
await page.click("#ob-create");
|
||||
await page.waitForURL(/\/p-[0-9a-f]{8}$/);
|
||||
await expect(page.locator("#project-select")).toContainText("solo-notes");
|
||||
await expect(page.locator("#accountbar")).toBeVisible(); // fresh org, owner
|
||||
await expect(page.locator(".ob-card h3")).toHaveText("Connect a new drive to your project");
|
||||
// The agent paste-prompt is the one path, with this hub's real origin
|
||||
// filled in; the by-hand route is a docs link.
|
||||
await expect(page.locator(".onboard .gd-code code")).toContainText(
|
||||
"to connect this folder to a new BearDrive project on http://localhost:8993.",
|
||||
);
|
||||
await expect(page.locator(".ob-alt a")).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.beardrive.ai/manual/setup-by-hand/",
|
||||
);
|
||||
});
|
||||
|
||||
test("new project via the sidebar + modal", async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { postJSON } from "../api/http";
|
||||
import type { InviteAccepted, Project, ProjectCreated, ServerConfig } from "../api/types";
|
||||
import type { InviteAccepted, Project, ServerConfig } from "../api/types";
|
||||
import { useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub";
|
||||
import { parseRoute, urlForView } from "../router";
|
||||
import { linkProps, navigate, Redirect, useLocationPath } from "../nav";
|
||||
@@ -118,23 +118,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
topbar={<Topbar />}
|
||||
>
|
||||
<Page>
|
||||
<EmptyState
|
||||
authEnabled={config.auth.enabled}
|
||||
onCreate={async (name) => {
|
||||
if (!name) {
|
||||
toast("Give the project a name.", true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const out = await postJSON<ProjectCreated>("/api/projects", { name });
|
||||
await refresh();
|
||||
navigate("/" + out.project.id);
|
||||
toast(`Created “${out.project.name}”.`);
|
||||
} catch (e) {
|
||||
toast("Could not create the project: " + (e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EmptyState />
|
||||
</Page>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { projColor } from "./ProjectNav";
|
||||
installed, no Homebrew, sign-in, wrong folder — so the page itself
|
||||
stays to one line of prose; detail lives in the collapsed sections. */
|
||||
|
||||
const INSTALL_DOC = "https://raw.githubusercontent.com/runbear-io/beardrive/main/INSTALL_FOR_AGENTS.md";
|
||||
export const INSTALL_DOC = "https://raw.githubusercontent.com/runbear-io/beardrive/main/INSTALL_FOR_AGENTS.md";
|
||||
|
||||
export function ConnectGuide({ project }: { project: Project }) {
|
||||
const origin = window.location.origin;
|
||||
@@ -86,7 +86,7 @@ export function ConnectGuide({ project }: { project: Project }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GuideCode({ code }: { code: string }) {
|
||||
export function GuideCode({ code }: { code: string }) {
|
||||
const [label, setLabel] = useState("Copy");
|
||||
return (
|
||||
<pre className="gd-code">
|
||||
|
||||
@@ -1,90 +1,34 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GuideCode, INSTALL_DOC } from "./ConnectGuide";
|
||||
|
||||
// Onboarding: a signed-in account with no projects shouldn't hit a blank
|
||||
// sidebar. Explain that access comes from an invite, let them paste one,
|
||||
// and — since any member can — offer to start a new project. Both inputs
|
||||
// are RHF+zod forms with inline errors (no toast-on-typo).
|
||||
|
||||
const joinSchema = z.object({
|
||||
invite: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => /join\/([0-9a-f]+)/.test(v) || /^[0-9a-f]{8,}$/.test(v), {
|
||||
message: "That doesn't look like an invite link.",
|
||||
}),
|
||||
});
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(1, "Give the project a name.").max(60, "Keep it under 60 characters."),
|
||||
});
|
||||
|
||||
export function EmptyState({
|
||||
authEnabled,
|
||||
onCreate,
|
||||
}: {
|
||||
authEnabled: boolean;
|
||||
onCreate: (name: string) => void;
|
||||
}) {
|
||||
const joinForm = useForm<z.infer<typeof joinSchema>>({
|
||||
resolver: zodResolver(joinSchema),
|
||||
defaultValues: { invite: "" },
|
||||
});
|
||||
const createForm = useForm<z.infer<typeof createSchema>>({
|
||||
resolver: zodResolver(createSchema),
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
// sidebar. One path in — paste the canonical install prompt into a coding
|
||||
// agent — with the by-hand route a docs link away.
|
||||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="onboard">
|
||||
<h1>Welcome to BearDrive</h1>
|
||||
<p>You're signed in, but you're not part of any project yet.</p>
|
||||
{authEnabled && (
|
||||
<div className="ob-card">
|
||||
<h3>Have an invite link?</h3>
|
||||
<p>A teammate can send you a join link. Paste it here:</p>
|
||||
<form
|
||||
className="ob-row"
|
||||
onSubmit={joinForm.handleSubmit(({ invite }) => {
|
||||
const m = invite.match(/join\/([0-9a-f]+)/) || invite.match(/^([0-9a-f]{8,})$/);
|
||||
location.href = "/join/" + m![1];
|
||||
})}
|
||||
>
|
||||
<input
|
||||
id="ob-invite"
|
||||
type="text"
|
||||
placeholder="https://…/join/…"
|
||||
autoComplete="off"
|
||||
{...joinForm.register("invite")}
|
||||
/>
|
||||
<Button id="ob-join" variant="primary" type="submit">
|
||||
Join
|
||||
</Button>
|
||||
</form>
|
||||
{joinForm.formState.errors.invite && (
|
||||
<p className="field-err">{joinForm.formState.errors.invite.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ob-card">
|
||||
<h3>Or start a new project</h3>
|
||||
<p>Create a shared space for your team's files.</p>
|
||||
<form className="ob-row" onSubmit={createForm.handleSubmit(({ name }) => onCreate(name))}>
|
||||
<input
|
||||
id="ob-name"
|
||||
type="text"
|
||||
placeholder="Project name, e.g. wiki"
|
||||
autoComplete="off"
|
||||
{...createForm.register("name")}
|
||||
/>
|
||||
<Button id="ob-create" variant="primary" type="submit">
|
||||
Create
|
||||
</Button>
|
||||
</form>
|
||||
{createForm.formState.errors.name && (
|
||||
<p className="field-err">{createForm.formState.errors.name.message}</p>
|
||||
)}
|
||||
<h3>Connect a new drive to your project</h3>
|
||||
<p>
|
||||
Paste into your coding agent — Claude Code, Cowork, Codex, Gemini CLI, Hermes — in the
|
||||
folder where you want the files. It creates the project and starts syncing:
|
||||
</p>
|
||||
<GuideCode
|
||||
code={
|
||||
"Follow " +
|
||||
INSTALL_DOC +
|
||||
"\nto connect this folder to a new BearDrive project on " +
|
||||
window.location.origin +
|
||||
"."
|
||||
}
|
||||
/>
|
||||
<p className="ob-alt">
|
||||
<a href="https://docs.beardrive.ai/manual/setup-by-hand/" target="_blank" rel="noreferrer">
|
||||
Or start a project manually →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -357,9 +357,9 @@ button, input, a.btn { font-family: inherit; }
|
||||
.ob-card { background: var(--bg-side); border: 1px solid var(--border); border-radius: var(--r-card); padding: 20px 22px; margin-bottom: 14px; }
|
||||
.ob-card h3 { margin: 0 0 6px; font-size: 14.5px; font-weight: 600; }
|
||||
.ob-card p { margin: 0 0 14px; font-size: 13px; color: var(--text-dim); }
|
||||
.ob-row { display: flex; gap: 9px; }
|
||||
.ob-row input { flex: 1; height: 34px; padding: 0 12px; border-radius: var(--r-ctl); border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; font-size: 13px; outline: none; }
|
||||
.ob-row input:focus { border-color: var(--accent); background: var(--hover); }
|
||||
.ob-alt { margin: 12px 0 0; }
|
||||
.ob-alt a { color: var(--text-faint); font-size: 12.5px; font-weight: 600; text-decoration: none; }
|
||||
.ob-alt a:hover { color: var(--text); }
|
||||
|
||||
/* primary + secondary buttons */
|
||||
.pbtn { display: inline-flex; align-items: center; gap: 6px; flex: none; height: 32px; padding: 0 14px; border-radius: var(--r-ctl); border: none; background: var(--accent); color: #241704; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
||||
@@ -695,8 +695,6 @@ a.ai-main:hover { color: var(--accent); }
|
||||
/* Columns already collapse on their own (.page is width: 100%); only
|
||||
content that can't shrink needs its own escape hatch. */
|
||||
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
|
||||
.ob-row { flex-direction: column; }
|
||||
.ob-row input { flex: none; min-height: 44px; }
|
||||
/* Admin rows: 27-28px selects/buttons are too small to tap; let rows
|
||||
wrap so the controls keep room next to long names/URLs. */
|
||||
.admin-item { flex-wrap: wrap; row-gap: 8px; padding: 12px 14px; }
|
||||
|
||||
+19
-18
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-DWHDxV9i.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BztPAPur.css">
|
||||
<script type="module" crossorigin src="/assets/index-DOG8Q4eQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ogK17qMq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user