import { useEffect, useRef, useState } from "react"; import { cloudLogin, connectManaged, getCloudStatus, getConnectors, getRecentChannels, waitForCloudSignIn, type CloudStatus, type Connector, type RecentChannel, } from "../api"; import { ConnectorBadge } from "../connectors/ConnectorIcon"; import { ChannelPicker } from "./SubscriptionsChip"; import { SelectMenu } from "./SelectMenu"; // The Automations quickstart (UX-DECISIONS §29): ONE template system. The former onboarding // recipe step (§24's role recipes) merged into the page's "Start from a template" grid — every // card carries §27's connector-dot vocabulary (brand = connected, grayscale = needs connecting); // picking a card expands the configure card below the grid: connect rows (with the lazy cloud // sign-in pane), channel-by-name, day × time, and the §25 consent line for write recipes. // The `ob-*` testids moved here with the machinery. // "When" = day choice × free time (owner call 2026-07-11); the cron assembles from the two. const DAYS: Record = { mon: { label: "Mondays", dow: "1" }, tue: { label: "Tuesdays", dow: "2" }, wed: { label: "Wednesdays", dow: "3" }, thu: { label: "Thursdays", dow: "4" }, fri: { label: "Fridays", dow: "5" }, sat: { label: "Saturdays", dow: "6" }, sun: { label: "Sundays", dow: "0" }, weekdays: { label: "Weekdays", dow: "1-5" }, daily: { label: "Every day", dow: "*" }, }; // §30 connect-state spinner (the app has no other spinner — waits elsewhere are label swaps). // Exported for Onboarding page 2's sign-in button (same states, same look). export const Spinner = () => ( ); const cronFor = (dayKey: string, hhmm: string) => { const [h, m] = hhmm.split(":"); return `${Number(m) || 0} ${Number(h) || 9} * * ${DAYS[dayKey]?.dow ?? "*"}`; }; interface QuickTemplate { key: string; title: string; blurb: string; cadence: string; // the card's footer label conns: { name: string; why: string }[]; // [] = no connections needed needsRepo?: boolean; needsChannel?: boolean; consent?: boolean; // write recipes carry the §25 consent line; reads carry disclosure deliver?: boolean; // Morning brief's deliver-to choice day: string; time: string; instructions: (ctx: { repo: string; channel: string; deliver: "app" | "slack" }) => string; } const TEMPLATES: QuickTemplate[] = [ { key: "github", title: "GitHub digest", blurb: "Merged PRs and commits, posted to your team's Slack.", cadence: "Weekly", conns: [ { name: "slack", why: "Where the digest posts" }, { name: "github", why: "What the digest summarizes" }, ], needsRepo: true, needsChannel: true, consent: true, day: "mon", time: "09:00", instructions: ({ repo, channel }) => `Summarize activity since the last digest in the GitHub repository ${repo || "(the connected repository)"}: ` + `merged pull requests, notable commits, and anything needing attention. ` + `Post the digest to the Slack channel ${channel} using send_message.`, }, { key: "pipeline", title: "Pipeline digest", blurb: "Deals that moved — and deals going quiet — posted to Slack.", cadence: "Weekly", conns: [ { name: "slack", why: "Where the digest posts" }, { name: "hubspot", why: "Pipeline and deal activity" }, ], needsChannel: true, consent: true, day: "mon", time: "09:00", instructions: ({ channel }) => `Review HubSpot activity since the last digest: deals that changed stage, deals going ` + `quiet, and deals past their close date. Post a short pipeline digest to the Slack ` + `channel ${channel} using send_message.`, }, { key: "brief", title: "Morning brief", blurb: "Calendar and unread email, summarized before your day starts.", cadence: "Daily", conns: [ { name: "google_calendar", why: "Today's meetings and gaps" }, { name: "gmail", why: "What arrived overnight" }, ], deliver: true, day: "daily", time: "08:00", instructions: ({ deliver }) => `Prepare a short morning brief: today's calendar events and gaps, plus email that ` + `arrived since yesterday evening. ` + (deliver === "app" ? "Save it as the session deliverable." : "Send it to me as a Slack DM."), }, { key: "news", title: "Morning news briefing", blurb: "A 5-bullet tech & world news digest, saved as markdown.", cadence: "Daily", conns: [], day: "daily", time: "08:00", instructions: () => "Search the web for the most important technology and world news from the last 24 hours " + "and write a concise 5-bullet briefing, saved as a markdown file.", }, { key: "inboxdigest", title: "Inbox digest", blurb: "One short digest of your unread email.", cadence: "Weekdays", conns: [{ name: "gmail", why: "Your unread email" }], day: "weekdays", time: "09:00", instructions: () => "Summarize my unread email into one short digest note.", }, { key: "cleanup", title: "Folder cleanup", blurb: "Sort recent Downloads into tidy folders by type.", cadence: "Weekly", conns: [], day: "fri", time: "17:30", instructions: () => "Sort my recent Downloads into tidy folders by file type.", }, ]; export function AutomationQuickstart({ busy, onCreate, }: { busy: boolean; onCreate: (payload: { title: string; instructions: string; cron?: string; permissions?: { tool: string; target: string; access: "read" | "write" }[]; }) => void; }) { const [pickedKey, setPickedKey] = useState(null); const picked = TEMPLATES.find((t) => t.key === pickedKey) || null; const [connectors, setConnectors] = useState([]); const [cloud, setCloud] = useState(null); const [pendingConn, setPendingConn] = useState(null); // §30 connect states: "opening" while the broker POST is in flight (the browser hasn't // appeared yet), "waiting" once it has — the handoff strip explains the out-of-band finish. const [connFlow, setConnFlow] = useState<{ name: string; phase: "opening" | "waiting" } | null>( null, ); const [signinPhase, setSigninPhase] = useState<"opening" | "waiting" | null>(null); const [recent, setRecent] = useState([]); const [repo, setRepo] = useState(""); const [channel, setChannel] = useState(""); const [day, setDay] = useState("mon"); const [time, setTime] = useState("09:00"); const [deliver, setDeliver] = useState<"app" | "slack">("app"); const [consent, setConsent] = useState(true); const refresh = () => { getConnectors().then(setConnectors).catch(() => {}); getCloudStatus().then(setCloud).catch(() => {}); }; // Connector state drives the card dots, so load once up front; poll only while a template // is being configured (connects and the cloud sign-in land out-of-band). const pollRef = useRef | null>(null); useEffect(() => { refresh(); }, []); useEffect(() => { if (!picked) return; refresh(); getRecentChannels().then(setRecent).catch(() => {}); pollRef.current = setInterval(refresh, 3000); return () => { if (pollRef.current) clearInterval(pollRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [pickedKey]); const connState = (name: string) => connectors.find((c) => c.name === name); const allConnected = !picked || picked.conns.every((c) => connState(c.name)?.connected); // §25 consent line shows the HUMAN name (owner catch 2026-07-14: it echoed the raw // slack:T…/C… target). Names come from a picker pick (remembered per address) or the // recent list; a hand-typed raw address stays raw — we never guess. const [picked_names, setPickedNames] = useState>({}); const pickedInfo = picked_names[channel]; const channelName = pickedInfo?.name || recent.find((c) => c.channel === channel)?.name; const channelLabel = channelName ? `#${channelName}` : channel; const channelWorkspace = pickedInfo?.workspace; // The poll flipping a row to ✓ is what ends its waiting state. useEffect(() => { if (connFlow && connState(connFlow.name)?.connected) setConnFlow(null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [connectors]); // §30: the configure card scrolls into view on pick — it expands below the fold on // three-row grids and otherwise appears "nowhere". const cfgRef = useRef(null); useEffect(() => { if (pickedKey) cfgRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [pickedKey]); const pick = (t: QuickTemplate) => { setPickedKey(t.key); setDay(t.day); setTime(t.time); setConsent(true); setConnFlow(null); }; const startConnect = async (name: string) => { if (!cloud?.signed_in) { setPendingConn(name); // the pane appears; sign-in completes it return; } // §30: the broker round-trip takes seconds — narrate it on the row itself. setConnFlow({ name, phase: "opening" }); // GitHub is authorize-first at the BROKER: one connect links an existing // installation or lands on the install page — no flow choice here anymore. await connectManaged(name).catch(() => {}); // The POST resolves once the system browser is off; the poll ends the waiting state. setConnFlow((f) => (f?.name === name ? { name, phase: "waiting" } : f)); refresh(); }; const signinPollRef = useRef<(() => void) | null>(null); const cancelSignin = () => { signinPollRef.current?.(); signinPollRef.current = null; setSigninPhase(null); }; useEffect(() => cancelSignin, []); // never leave the poll running after unmount const signInThenConnect = async () => { setSigninPhase("opening"); await cloudLogin().catch(() => {}); setSigninPhase("waiting"); // Poll until the browser flow lands, then finish the pending connect (bounded). signinPollRef.current = waitForCloudSignIn(async (s) => { signinPollRef.current = null; setSigninPhase(null); if (!s?.signed_in) return; setCloud(s); if (pendingConn) { const name = pendingConn; setConnFlow({ name, phase: "opening" }); await connectManaged(name).catch(() => {}); setConnFlow((f) => (f?.name === name ? { name, phase: "waiting" } : f)); setPendingConn(null); refresh(); } }); }; const create = () => { if (!picked) return; onCreate({ title: picked.title, instructions: picked.instructions({ repo, channel, deliver }), cron: cronFor(day, time), permissions: picked.consent && consent && channel ? [{ tool: "send_message", target: channel, access: "write" }] : [], }); }; const gateHint = !allConnected ? `Connect ${picked?.conns .filter((c) => !connState(c.name)?.connected) .map((c) => connState(c.name)?.title || c.name) .join(" and ")} to continue` : picked?.needsChannel && !channel ? "Pick a channel to post to first" : ""; const label = "block text-[12px] text-muted mt-3 mb-1"; const input = "w-full px-3 py-2 rounded-lg border border-line bg-panel text-[13.5px] outline-none focus:border-accent"; return (
Start from a template
{/* Equal-height cards (owner ask 2026-07-12): 1fr rows + h-full — ))}
{picked && (
{/* §30: the card names its template — without this it starts abruptly after the grid. */}
Set up {picked.title} {picked.conns.length ? "Connections, delivery & schedule" : "Delivery & schedule"} ·{" "} {picked.cadence}
{picked.conns.map(({ name, why }) => { const c = connState(name); const flow = connFlow?.name === name ? connFlow : null; return (
{c && } {c?.title || name} {why} {c?.connected ? ( ✓ Connected ) : flow ? ( {flow.phase === "opening" ? "Opening browser…" : `Waiting for ${c?.title || name}…`} ) : ( )}
{/* §30 handoff strip: the flow finishes out-of-band in the browser — say so, and let Cancel clear the LOCAL state (the browser tab is the user's). */} {flow?.phase === "waiting" && (
Finish connecting {c?.title || name} in your browser. {" "} Approve it there, then come back — this page updates by itself.
)}
); })} {pendingConn && !cloud?.signed_in && (
One sign-in unlocks every one-click connection Connections are brokered by OpenWorker Cloud — your tokens stay on this Mac.
{signinPhase ? ( <> {signinPhase === "opening" ? "Opening browser…" : "Waiting for sign-in…"} {signinPhase === "waiting" && ( Finish signing in in your browser — this page updates by itself.{" "} )} ) : ( )}
)} {allConnected && (
{picked.needsRepo && ( <> setRepo(e.target.value)} data-testid="ob-repo" /> )} {picked.needsChannel && ( <>
setPickedNames((m) => ({ ...m, [address]: { name, workspace } })) } />

The bot must be a member of the channel — invite @OpenWorker in Slack if it isn't.

)}
({ value: k, label: v.label }))} onChange={setDay} />
setTime(e.target.value)} />
{picked.deliver && ( <> setDeliver(v as "app" | "slack")} /> )} {picked.consent ? ( ) : picked.conns.length > 0 ? (

This automation only reads on schedule — reading never needs approval.

) : null}
)}
{/* A silently-disabled primary reads as a bug — always name the missing piece. */} {gateHint && ( {gateHint} )}
)} ); }