Files
ai-agent/backend/newproject/form.py
Gabriel Vidal deb01427d5 feat(backend): New projects — owner and guests start a project from a template
newproject/: the policy (guestDefaults.newProjects — quota, templates, the
ones the owner approves first, visibility, level, domain — and a per-guest
createProjects + address bucket), the New project form built from it, and the
flow: POST /api/new-project/form (the run's guesses as defaults) → submit →
shape/policy and host checks (a problem sends a corrected form back) → an
owner ask for a backend template → sidecar /scaffold → grant → /relocate +
resume the same session inside the project with an app-authored turn.
SpawnBody.newProject starts a guest's run in ~/projects/.new-project/<uid>
(staging_scope: /tmp only); /api/me reports newProjects; the thread follows
ConversationDetail.continuedIn.

Fix: a guest's turn into an existing conversation resumed with no cwd, so the
sidecar ran it in the lab checkout (its CLAUDE.md loaded) — it now resumes
where the session lives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:42:07 +02:00

95 lines
4.8 KiB
Python

"""
The *New project* form: the run's guesses as defaults, the requester's policy
as the choices. The same form for the owner and for guests — a guest's
address bucket is fixed (their own), the owner picks one.
"""
from __future__ import annotations
import re
from newproject.policy import VISIBILITY_LABELS, template_blurb
from users.caller import OWNER_NAME
TITLE = "New project"
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def slugify(s: str, limit: int = 40) -> str:
return _SLUG_RE.sub("-", (s or "").lower()).strip("-")[:limit].strip("-")
def _help(base: str, field: str, problems: list[dict]) -> str:
msgs = [p["message"] for p in problems if p.get("field") == field]
return "\n".join([*(f"⚠ {m}" for m in msgs), base]) if msgs else base
def fields(opts: dict, guesses: dict, problems: list[dict] | None = None) -> list[dict]:
problems = problems or []
name = slugify(guesses.get("name") or guesses.get("title") or "") or "my-project"
templates = opts["templates"]
template = guesses.get("template") if guesses.get("template") in templates else templates[0]
visibility = (guesses.get("visibility") if guesses.get("visibility") in opts["visibility"]
else opts["visibilityDefault"])
label = slugify(guesses.get("label") or "", 63) or name
out: list[dict] = []
if problems:
out.append({"type": "markdown", "label": "Not created yet",
"text": "Fix this and submit again:\n\n"
+ "\n".join(f"- {p['message']}" for p in problems)})
if (guesses.get("summary") or "").strip():
out.append({"type": "markdown", "label": "What I understood",
"text": guesses["summary"].strip()[:1500]})
out += [
{"key": "name", "label": "Project name", "type": "text", "required": True,
"default": name,
"help": _help("Lowercase letters, digits and dashes — the project's folder "
"and repository.", "name", problems)},
{"key": "title", "label": "Title", "type": "text", "required": True,
"default": (guesses.get("title") or name.replace("-", " ").title())[:80],
"help": "Shown in the browser tab and as the page heading."},
{"key": "description", "label": "Description", "type": "textarea",
"default": (guesses.get("description") or "")[:400],
"help": "A sentence or two — the page's description and the README's intro."},
{"key": "template", "label": "Start from", "type": "radio", "required": True,
"other": False, "default": template,
"options": [{"value": t, "label": f"**{t}** — {template_blurb(t)}"
+ (f" · *{OWNER_NAME} approves it first*" if t in opts["approve"] else "")}
for t in templates],
"help": _help("One of the project templates — the agent builds from there.",
"template", problems)},
]
if opts.get("bucket"):
out.append({"key": "label", "label": "Web address", "type": "text", "required": True,
"default": label,
"help": _help(f"The site goes live at **https://{label}.{opts['bucket']}"
f".{opts['domain']}** — `<this>.{opts['bucket']}."
f"{opts['domain']}`.", "label", problems)})
else:
out += [
{"key": "label", "label": "Web address", "type": "text", "required": True,
"default": label,
"help": _help(f"`<this>.<under>.{opts['domain']}` — usually the project name.",
"label", problems)},
{"key": "bucket", "label": "Under", "type": "radio", "required": True,
"other": True, "default": guesses.get("bucket") or opts["buckets"][0],
"options": [{"value": b, "label": f"`{b}` — `<address>.{b}.{opts['domain']}`"}
for b in opts["buckets"]],
"help": _help("`dev` for apps and tools, `game` for games — or type another.",
"subdomain", problems)},
]
if len(opts["visibility"]) > 1:
out.append({"key": "visibility", "label": "Who can open it", "type": "radio",
"required": True, "other": False, "default": visibility,
"options": [{"value": v, "label": VISIBILITY_LABELS[v]}
for v in opts["visibility"]]})
return out
def description(opts: dict, requester_name: str | None) -> str:
where = (f"`<address>.{opts['bucket']}.{opts['domain']}`" if opts.get("bucket")
else f"`<address>.<under>.{opts['domain']}`")
return ("Pre-filled from the request — change anything, then submit. The project "
f"is created from a template, hosted at {where}, and this conversation "
"carries on inside it to build the first version.")