feat(hub): agent-first onboarding on the no-projects empty state (#60)

This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-27 21:11:25 +09:00
committed by GitHub
parent e629ae4ab0
commit 849794a8e5
9 changed files with 64 additions and 133 deletions
+1 -2
View File
@@ -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)
}
+11 -6
View File
@@ -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 }) => {
+2 -18
View File
@@ -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>
);
+3 -5
View File
@@ -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; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>