setTab("settings")} />
{!teamId ? (
{bootstrapping || teams === undefined
diff --git a/app/src/styles.css b/app/src/styles.css
index 2cd125f..d877531 100644
--- a/app/src/styles.css
+++ b/app/src/styles.css
@@ -551,3 +551,52 @@ a:hover { color: var(--otto-ink); text-decoration-color: var(--otto-ink); }
font-style: italic;
font-weight: 400;
}
+
+/* Project grid — clickable cards in the merged Projects tab. */
+.project-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: var(--otto-space-3);
+ margin-top: var(--otto-space-3);
+ margin-bottom: var(--otto-space-6);
+}
+.project-card {
+ appearance: none;
+ text-align: left;
+ width: 100%;
+ background: var(--otto-cream);
+ border: var(--otto-border-hair);
+ padding: var(--otto-space-4) var(--otto-space-5);
+ cursor: pointer;
+ font: inherit;
+ color: inherit;
+ transition: background 0.1s ease, border-color 0.1s ease;
+}
+.project-card:hover {
+ background: var(--otto-bg);
+ border-color: var(--otto-ink);
+}
+.project-card:focus-visible {
+ outline: 2px solid var(--otto-amber, #c89045);
+ outline-offset: 2px;
+}
+
+/* Modal — used by CreateProjectModal in the projects tab. */
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(28, 26, 22, 0.55);
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--otto-space-4);
+}
+.modal-panel {
+ background: var(--otto-cream);
+ border: var(--otto-border-hair);
+ padding: var(--otto-space-5) var(--otto-space-6);
+ width: 100%;
+ max-width: 420px;
+ box-shadow: 6px 6px 0 rgba(28, 26, 22, 0.15);
+}
diff --git a/app/src/tabs/AuditTab.tsx b/app/src/tabs/AuditTab.tsx
deleted file mode 100644
index 2fdabc8..0000000
--- a/app/src/tabs/AuditTab.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import { useQuery } from "convex/react";
-import { api } from "../convexApi";
-import { OttoHero, StatCard } from "../Otto";
-import { useTeam } from "../teamContext";
-
-export function AuditTab() {
- const { teamId } = useTeam();
- const items = useQuery(
- api.admin.recentItems,
- teamId ? { teamId, limit: 100 } : "skip",
- );
- const log = useQuery(
- api.admin.recentAuditLog,
- teamId ? { teamId, limit: 200 } : "skip",
- );
-
- const stats = items ? computeStats(items) : null;
-
- return (
- <>
- {stats && (
-
-
- ITEMS // ALL TIME
- >
- }
- value={stats.total}
- caption={`${stats.last7d} in the last 7 days`}
- />
-
- PRS DRAFTED // ALL TIME
- >
- }
- value={stats.prsOpened}
- caption={`${stats.prsLast7d} in the last 7 days`}
- accent
- />
-
- AWAITING // SLACK QUEUE
- >
- }
- value={stats.queued}
- caption={stats.queued === 0 ? "all clear" : "needs review"}
- />
-
- )}
-
-
- ACTIVITY // RECENT ITEMS
-
-
- {!items ? (
- Loading…
- ) : items.length === 0 ? (
-
-
-
No items yet.
-
- Drop the widget onto a page to wake Otto up.
-
-
- ) : (
-
-
-
- status
- what otto's doing
- source
- confidence
- pr
-
-
-
- {items.slice(0, 50).map((it) => (
-
-
-
- {statusLabel(it.status)}
-
-
- {it.description}
-
- {shortSource(it.sourceRef)}
-
-
- {(it.parserConfidence * (it.routerConfidence ?? 0)).toFixed(2)}
-
-
- {it.prUrl ? (
-
- open
-
- ) : (
- —
- )}
-
-
- ))}
-
-
- )}
-
-
- AUDIT LOG // RAW EVENTS
-
- {!log ? (
- Loading…
- ) : log.length === 0 ? (
-
- ) : (
-
-
-
- when
- event
- actor
- item
-
-
-
- {log.map((row) => (
-
-
- {relativeTime(row.at)}
-
-
- {row.event}
-
-
- {row.actor}
-
-
- {row.itemId ? row.itemId.slice(-8) : "—"}
-
-
- ))}
-
-
- )}
- >
- );
-}
-
-function computeStats(items: { createdAt: number; status: string }[]) {
- const now = Date.now();
- const sevenDays = 7 * 24 * 60 * 60 * 1000;
- const recent = items.filter((i) => now - i.createdAt < sevenDays);
- return {
- total: items.length,
- last7d: recent.length,
- prsOpened: items.filter((i) => i.status === "pr_opened").length,
- prsLast7d: recent.filter((i) => i.status === "pr_opened").length,
- queued: items.filter((i) => i.status === "queued").length,
- };
-}
-
-function statusLabel(status: string): string {
- switch (status) {
- case "parsed": return "parsed";
- case "queued": return "awaiting review";
- case "approved": return "approved";
- case "fired": return "working";
- case "pr_opened": return "pr open";
- case "failed": return "failed";
- case "rejected": return "rejected";
- default: return status;
- }
-}
-
-function shortSource(ref: string): string {
- try {
- const u = new URL(ref);
- return `${u.host}${u.pathname === "/" ? "" : u.pathname}`;
- } catch {
- return ref;
- }
-}
-
-function relativeTime(ms: number): string {
- const diff = (Date.now() - ms) / 1000;
- if (diff < 60) return `${Math.round(diff)}s ago`;
- if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
- if (diff < 86400) return `${Math.round(diff / 3600)}h ago`;
- return new Date(ms).toLocaleDateString();
-}
diff --git a/app/src/tabs/ProjectsTab.tsx b/app/src/tabs/ProjectsTab.tsx
index ca57d7e..805a771 100644
--- a/app/src/tabs/ProjectsTab.tsx
+++ b/app/src/tabs/ProjectsTab.tsx
@@ -1,24 +1,23 @@
-import { useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { api } from "../convexApi";
import { useTeam } from "../teamContext";
+import { OttoHero, StatCard } from "../Otto";
import type { Doc, Id } from "../../../convex/_generated/dataModel";
-// projects.list returns the stored project shape plus two joined
-// fields the query computes: primaryRepoName + repoCount.
+// projects.list returns the stored project shape plus two joined fields
+// the query computes: primaryRepoName + repoCount.
type ProjectRow = Doc<"projects"> & {
primaryRepoName: string | null;
repoCount: number;
};
-const BLANK_DRAFT = {
- name: "",
- slug: "",
- description: "",
- urlPatterns: "",
- primaryRepoId: "",
- enabled: true,
-};
+type Item = Doc<"items">;
+
+// Tab-internal "view" — grid (everyone) or detail (one project zoomed in).
+// We keep this as component state rather than wiring real URL routing;
+// when we want shareable links we'll bolt on a router.
+type View = { kind: "grid" } | { kind: "detail"; projectId: Id<"projects"> };
export function ProjectsTab() {
const { teamId } = useTeam();
@@ -26,16 +25,504 @@ export function ProjectsTab() {
api.projects.list,
teamId ? { teamId } : "skip",
) as ProjectRow[] | undefined;
+ const items = useQuery(
+ api.admin.recentItems,
+ teamId ? { teamId, limit: 200 } : "skip",
+ ) as Item[] | undefined;
+
+ const [view, setView] = useState({ kind: "grid" });
+
+ if (view.kind === "detail") {
+ const project = projects?.find((p) => p._id === view.projectId);
+ if (!project) {
+ // Project deleted out from under us, or still loading.
+ return (
+
+ {projects ? "project not found." : "loading…"}
+
+ );
+ }
+ return (
+ setView({ kind: "grid" })}
+ />
+ );
+ }
+
+ return (
+ setView({ kind: "detail", projectId: id })}
+ />
+ );
+}
+
+/* ─────────────────────── grid view ─────────────────────── */
+
+function ProjectsGrid({
+ projects,
+ items,
+ onOpen,
+}: {
+ projects: ProjectRow[] | undefined;
+ items: Item[] | undefined;
+ onOpen: (id: Id<"projects">) => void;
+}) {
+ const stats = useMemo(() => (items ? computeStats(items) : null), [items]);
+ const [creating, setCreating] = useState(false);
+
+ return (
+ <>
+ {stats && (
+
+
+ ITEMS // ALL TIME
+ >
+ }
+ value={stats.total}
+ caption={`${stats.last7d} in the last 7 days`}
+ />
+
+ PRS DRAFTED // ALL TIME
+ >
+ }
+ value={stats.prsOpened}
+ caption={`${stats.prsLast7d} in the last 7 days`}
+ accent
+ />
+
+ AWAITING // SLACK QUEUE
+ >
+ }
+ value={stats.queued}
+ caption={stats.queued === 0 ? "all clear" : "needs review"}
+ />
+
+ )}
+
+
+
projects
+ setCreating(true)}>
+ + new project
+
+
+
+ {creating && (
+ setCreating(false)}
+ onCreated={(id) => {
+ setCreating(false);
+ onOpen(id);
+ }}
+ />
+ )}
+
+ {!projects ? (
+ loading…
+ ) : projects.length === 0 ? (
+
+
+
+ create a project to start collecting widget feedback.
+
+
+ ) : (
+
+ {projects.map((p) => (
+
it.projectId === p._id) ?? []}
+ loading={!items}
+ onClick={() => onOpen(p._id)}
+ />
+ ))}
+
+ )}
+ >
+ );
+}
+
+function ProjectCard({
+ project,
+ items,
+ loading,
+ onClick,
+}: {
+ project: ProjectRow;
+ items: Item[];
+ loading: boolean;
+ onClick: () => void;
+}) {
+ const sevenDays = 7 * 24 * 60 * 60 * 1000;
+ const recent = items.filter((i) => Date.now() - i.createdAt < sevenDays);
+ const drafts = items.filter((i) => i.status === "pr_opened").length;
+ const installed = items.length > 0 || loading;
+
+ return (
+
+
+
{project.name}
+ {!installed && (
+
+ no widget yet
+
+ )}
+
+
+ {project.urlPatterns.length === 0 ? (
+ no url patterns
+ ) : (
+ project.urlPatterns.slice(0, 2).join(" · ")
+ )}
+
+
+
+ {items.length} {" "}
+ items
+
+
+ {recent.length} {" "}
+ 7d
+
+
+ {drafts} {" "}
+ drafts
+
+
+
+ );
+}
+
+/* ─────────────────────── detail view ─────────────────────── */
+
+function ProjectDetail({
+ project,
+ items,
+ onBack,
+}: {
+ project: ProjectRow;
+ items: Item[] | undefined;
+ onBack: () => void;
+}) {
+ const [editing, setEditing] = useState(false);
+ const projectItems =
+ items?.filter((it) => it.projectId === project._id) ?? [];
+ const projectStats = items ? computeStats(projectItems) : null;
+ const noEventsYet = items && projectItems.length === 0;
+
+ return (
+ <>
+
+
+
+ ← all projects
+
+
{project.name}
+
+ {project.urlPatterns.length === 0 ? (
+ no url patterns
+ ) : (
+ project.urlPatterns.join(" · ")
+ )}
+
+
+ {!editing && (
+
setEditing(true)}>edit project
+ )}
+
+
+ {editing && (
+ setEditing(false)}
+ onSaved={() => setEditing(false)}
+ onDeleted={onBack}
+ />
+ )}
+
+ {projectStats && (
+
+
+ ITEMS // ALL TIME
+ >
+ }
+ value={projectStats.total}
+ caption={`${projectStats.last7d} in the last 7 days`}
+ />
+
+ PRS DRAFTED // ALL TIME
+ >
+ }
+ value={projectStats.prsOpened}
+ caption={`${projectStats.prsLast7d} in the last 7 days`}
+ accent
+ />
+
+ AWAITING // SLACK QUEUE
+ >
+ }
+ value={projectStats.queued}
+ caption={
+ projectStats.queued === 0 ? "all clear" : "needs review"
+ }
+ />
+
+ )}
+
+ {noEventsYet && (
+
+
install the widget
+
+ otto isn't seeing events for this project yet. drop the snippet
+ below into your app and otto will route feedback here whenever
+ the page url matches one of your patterns.
+
+
+ grab the snippet (with your team secret baked in) from{" "}
+ settings → drop the widget .
+
+
+ )}
+
+
+ ACTIVITY // RECENT ITEMS
+
+ {!items ? (
+ loading…
+ ) : projectItems.length === 0 ? (
+
+
no items yet for this project.
+
+ ) : (
+
+
+
+ status
+ what otto's doing
+ source
+ pr
+
+
+
+ {projectItems.slice(0, 50).map((it) => (
+
+
+
+ {statusLabel(it.status)}
+
+
+ {it.description}
+
+ {shortSource(it.sourceRef)}
+
+
+ {it.prUrl ? (
+
+ open
+
+ ) : (
+ —
+ )}
+
+
+ ))}
+
+
+ )}
+ >
+ );
+}
+
+/* ─────────────────────── create modal ─────────────────────── */
+
+// Minimal create flow: just the project name. URL patterns, description,
+// primary repo, and enabled-toggle are all configurable inside the
+// project once it exists. Slug auto-derives from name.
+function CreateProjectModal({
+ onClose,
+ onCreated,
+}: {
+ onClose: () => void;
+ onCreated: (id: Id<"projects">) => void;
+}) {
+ const { teamId } = useTeam();
+ const upsert = useMutation(api.projects.upsert);
+ const [name, setName] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState(null);
+
+ // Esc to close.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ const onCreate = async () => {
+ if (!teamId || !name.trim()) return;
+ setBusy(true);
+ setErr(null);
+ try {
+ const id = await upsert({
+ teamId,
+ name: name.trim(),
+ slug: name.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-"),
+ description: "",
+ urlPatterns: [],
+ primaryRepoId: null,
+ enabled: true,
+ });
+ onCreated(id as Id<"projects">);
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : "failed to create");
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+
new project
+
+ give it a name. you can wire up a repo and url patterns once
+ it's created.
+
+
name
+
setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && name.trim() && !busy) onCreate();
+ }}
+ style={{ width: "100%" }}
+ />
+ {err && (
+
+ {err}
+
+ )}
+
+
+ cancel
+
+
+ {busy ? "creating…" : "create"}
+
+
+
+
+ );
+}
+
+/* ─────────────────────── edit form ─────────────────────── */
+
+// The full project form, shown inline inside ProjectDetail. Lets you
+// rename, edit url patterns, swap primary repo, toggle enabled, delete.
+function ProjectEditForm({
+ existing,
+ onCancel,
+ onSaved,
+ onDeleted,
+}: {
+ existing: ProjectRow;
+ onCancel: () => void;
+ onSaved: () => void;
+ onDeleted: () => void;
+}) {
+ const { teamId } = useTeam();
const repos = useQuery(
api.reposDb.list,
teamId ? { teamId } : "skip",
) as Doc<"repos">[] | undefined;
const upsert = useMutation(api.projects.upsert);
const remove = useMutation(api.projects.remove);
- const setRepoProject = useMutation(api.projects.setRepoProject);
- const [draft, setDraft] = useState(BLANK_DRAFT);
- const [editing, setEditing] = useState | null>(null);
+ const [draft, setDraft] = useState({
+ name: existing.name,
+ slug: existing.slug,
+ description: existing.description,
+ urlPatterns: existing.urlPatterns.join("\n"),
+ primaryRepoId: existing.primaryRepoId ?? "",
+ enabled: existing.enabled,
+ });
const onSave = async () => {
if (!teamId) return;
@@ -45,9 +532,11 @@ export function ProjectsTab() {
.filter(Boolean);
await upsert({
teamId,
- id: editing ?? undefined,
+ id: existing._id,
name: draft.name,
- slug: draft.slug || draft.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-"),
+ slug:
+ draft.slug ||
+ draft.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-"),
description: draft.description,
urlPatterns: patterns,
primaryRepoId: draft.primaryRepoId
@@ -55,265 +544,166 @@ export function ProjectsTab() {
: null,
enabled: draft.enabled,
});
- setDraft(BLANK_DRAFT);
- setEditing(null);
+ onSaved();
};
- const onEdit = (p: ProjectRow) => {
- setEditing(p._id);
- setDraft({
- name: p.name,
- slug: p.slug,
- description: p.description,
- urlPatterns: p.urlPatterns.join("\n"),
- primaryRepoId: p.primaryRepoId ?? "",
- enabled: p.enabled,
- });
- window.scrollTo({ top: 0, behavior: "smooth" });
+ const onDelete = async () => {
+ if (!teamId) return;
+ if (!confirm(`delete project "${existing.name}"?`)) return;
+ await remove({ teamId, id: existing._id });
+ onDeleted();
};
return (
- <>
-
-
- {editing ? "edit project" : "add project"}
-
-
- projects group repos by what they are for the team — orders,
- customers, refunds. otto routes widget feedback to a project by
- matching the page URL against its patterns.
-
-
-
- name
- setDraft({ ...draft, name: e.target.value })}
- style={{ width: "100%" }}
- />
-
-
- slug
- setDraft({ ...draft, slug: e.target.value })}
- style={{ width: "100%" }}
- />
-
+
+
edit project
+
+
+ name
+ setDraft({ ...draft, name: e.target.value })}
+ style={{ width: "100%" }}
+ />
-
- description
-
-
+
+ url patterns · one per line
+
+
+ setDraft({ ...draft, urlPatterns: e.target.value })
+ }
+ style={{ width: "100%", fontFamily: "var(--otto-font-mono)" }}
+ />
+
+
+ primary repo
+
+ setDraft({ ...draft, primaryRepoId: e.target.value })
+ }
+ style={{ width: "100%" }}
+ >
+ — none yet —
+ {(repos ?? []).map((r) => (
+
+ {r.name} ({r.githubFullName})
+
+ ))}
+
+
+
-
- primary repo
-
- setDraft({ ...draft, primaryRepoId: e.target.value })
- }
- style={{ width: "100%" }}
- >
- — none yet —
- {(repos ?? []).map((r) => (
-
- {r.name} ({r.githubFullName})
-
- ))}
-
-
-
-
- setDraft({ ...draft, enabled: e.target.checked })
- }
- style={{ width: "auto" }}
- />{" "}
- enabled
-
-
-
- {editing && (
- {
- setEditing(null);
- setDraft(BLANK_DRAFT);
- }}
- >
- cancel
-
- )}
+
+ setDraft({ ...draft, enabled: e.target.checked })
+ }
+ style={{ width: "auto" }}
+ />{" "}
+ enabled
+
+
+
+
+ delete
+
+
+ cancel
- {editing ? "save" : "create"}
+ save
+
+ );
+}
-
projects
- {!projects ? (
-
loading…
- ) : projects.length === 0 ? (
-
-
no projects yet.
-
- add one above. each project owns url patterns + a primary
- repo so otto can route widget feedback without asking.
-
-
- ) : (
-
-
-
- name
- slug
- url patterns
- primary repo
- repos
- enabled
-
-
-
-
- {projects.map((p) => (
-
- {p.name}
- {p.slug}
-
- {p.urlPatterns.length === 0 ? (
- —
- ) : (
- p.urlPatterns.join(", ")
- )}
-
-
- {p.primaryRepoName ?? — }
-
- {p.repoCount}
- {p.enabled ? "✓" : "—"}
-
-
- onEdit(p)}>edit
- {
- if (
- teamId &&
- confirm(`delete project "${p.name}"?`)
- ) {
- void remove({
- teamId,
- id: p._id,
- });
- }
- }}
- >
- delete
-
-
-
-
- ))}
-
-
- )}
+/* ─────────────────────── helpers ─────────────────────── */
- {projects && projects.length > 0 && repos && repos.length > 0 && (
- <>
-
repo → project assignments
-
-
-
- repo
- project
-
-
-
- {repos.map((r) => (
-
-
- {r.name}{" "}
-
- ({r.githubFullName})
-
-
-
-
- teamId &&
- void setRepoProject({
- teamId,
- repoId: r._id,
- projectId: e.target.value
- ? (e.target.value as Id<"projects">)
- : null,
- })
- }
- >
- — unassigned —
- {projects.map((p) => (
-
- {p.name}
-
- ))}
-
-
-
- ))}
-
-
- >
- )}
- >
- );
+function computeStats(items: { createdAt: number; status: string }[]) {
+ const now = Date.now();
+ const sevenDays = 7 * 24 * 60 * 60 * 1000;
+ const recent = items.filter((i) => now - i.createdAt < sevenDays);
+ return {
+ total: items.length,
+ last7d: recent.length,
+ prsOpened: items.filter((i) => i.status === "pr_opened").length,
+ prsLast7d: recent.filter((i) => i.status === "pr_opened").length,
+ queued: items.filter((i) => i.status === "queued").length,
+ };
+}
+
+function statusLabel(status: string): string {
+ switch (status) {
+ case "parsed":
+ return "parsed";
+ case "queued":
+ return "awaiting review";
+ case "approved":
+ return "approved";
+ case "fired":
+ return "working";
+ case "pr_opened":
+ return "pr open";
+ case "failed":
+ return "failed";
+ case "rejected":
+ return "rejected";
+ default:
+ return status;
+ }
+}
+
+function shortSource(ref: string): string {
+ try {
+ const u = new URL(ref);
+ return `${u.host}${u.pathname === "/" ? "" : u.pathname}`;
+ } catch {
+ return ref;
+ }
}