Files
openworker/surfaces/gui/src/components/AutomationQuickstart.tsx
T
Rohit C Prasad 71b0df8ea2 Rename bot references from ocw to OpenWorker
Correlation token now emits [ow:id]; legacy [ocw:id] replies still parse.
Invite hints and docstrings say @OpenWorker.
2026-07-23 07:43:24 -07:00

591 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { label: string; dow: string }> = {
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 = () => (
<span className="inline-block w-3 h-3 rounded-full border-[1.5px] border-line2 border-t-accent animate-spin" />
);
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<string | null>(null);
const picked = TEMPLATES.find((t) => t.key === pickedKey) || null;
const [connectors, setConnectors] = useState<Connector[]>([]);
const [cloud, setCloud] = useState<CloudStatus | null>(null);
const [pendingConn, setPendingConn] = useState<string | null>(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<RecentChannel[]>([]);
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<ReturnType<typeof setInterval> | 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<Record<string, { name: string; workspace?: string }>>({});
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<HTMLDivElement | null>(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 (
<div className="mb-4">
<div className="text-[11px] uppercase tracking-[0.05em] text-faint mb-2.5">
Start from a template
</div>
{/* Equal-height cards (owner ask 2026-07-12): 1fr rows + h-full — <button> grid items
don't stretch like divs. */}
<div className="grid grid-cols-3 auto-rows-fr gap-3">
{TEMPLATES.map((t) => (
<button
key={t.key}
data-testid={`qs-template-${t.key}`}
className={
"h-full text-left rounded-xl2 border bg-panel p-4 flex flex-col gap-1.5 " +
(pickedKey === t.key
? "border-accent ring-2 ring-accentSoft"
: "border-line hover:border-lineStrong")
}
onClick={() => pick(t)}
>
<span className="text-[13.5px] font-semibold">{t.title}</span>
<span className="text-[12px] text-muted leading-relaxed flex-1">{t.blurb}</span>
<span className="flex items-center gap-1.5 mt-1">
{t.conns.map((c) => {
const cs = connState(c.name);
const on = !!cs?.connected;
return (
<span
key={c.name}
title={`${cs?.title || c.name}${on ? "connected" : "not connected yet"}`}
style={on ? undefined : { filter: "grayscale(1)", opacity: 0.55 }}
>
{cs ? (
<ConnectorBadge connector={cs} size={16} title={cs.title} />
) : (
<span className="inline-block w-4 h-4 rounded-full border border-line2" />
)}
</span>
);
})}
<span className="text-[11px] text-faint ml-0.5">
{t.conns.length === 0 ? `No connections needed · ${t.cadence}` : t.cadence}
</span>
</span>
</button>
))}
</div>
{picked && (
<div
ref={cfgRef}
className="mt-3 rounded-xl2 border border-line bg-panel p-4"
data-testid="qs-configure"
>
{/* §30: the card names its template — without this it starts abruptly after the grid. */}
<div className="flex items-baseline gap-2 pb-2.5 mb-1 border-b border-line">
<span className="text-[11px] uppercase tracking-[0.05em] text-accent font-semibold">
Set up
</span>
<span className="text-[14px] font-semibold">{picked.title}</span>
<span className="ml-auto text-[12px] text-faint max-sm:hidden">
{picked.conns.length ? "Connections, delivery & schedule" : "Delivery & schedule"} ·{" "}
{picked.cadence}
</span>
</div>
{picked.conns.map(({ name, why }) => {
const c = connState(name);
const flow = connFlow?.name === name ? connFlow : null;
return (
<div key={name} className="border-b border-line last:border-b-0">
<div className="flex items-center gap-3 py-2.5">
{c && <ConnectorBadge connector={c} size={26} title={c.title} />}
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-medium">{c?.title || name}</span>
<span className="block text-[11.5px] text-faint">{why}</span>
</span>
{c?.connected ? (
<span className="text-[12.5px] text-ok"> Connected</span>
) : flow ? (
<span className="inline-flex items-center gap-2 text-[12px] text-muted">
<Spinner />
{flow.phase === "opening"
? "Opening browser…"
: `Waiting for ${c?.title || name}…`}
</span>
) : (
<button
className="px-3.5 py-1 rounded-full border border-line text-[12.5px] hover:bg-paper"
onClick={() => startConnect(name)}
data-testid={`ob-connect-${name}`}
>
Connect
</button>
)}
</div>
{/* §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" && (
<div
className="flex items-start gap-2 bg-accentSoft/50 rounded-lg px-3 py-2 mb-2.5 text-[12px] text-muted"
data-testid="ob-connect-wait"
>
<span></span>
<span className="flex-1 min-w-0">
<b className="text-ink font-medium">
Finish connecting {c?.title || name} in your browser.
</b>{" "}
Approve it there, then come back this page updates by itself.
</span>
<button
className="text-faint underline hover:text-muted shrink-0"
onClick={() => setConnFlow(null)}
data-testid="ob-connect-cancel"
>
Cancel
</button>
</div>
)}
</div>
);
})}
{pendingConn && !cloud?.signed_in && (
<div
className="bg-accentSoft/50 rounded-xl px-4 py-3 mt-3 text-[12.5px] text-muted"
data-testid="ob-cloudpane"
>
<span className="block text-[13px] text-ink font-medium">
One sign-in unlocks every one-click connection
</span>
Connections are brokered by OpenWorker Cloud your tokens stay on this Mac.
<div className="flex items-center gap-3 mt-2">
{signinPhase ? (
<>
<span className="inline-flex items-center gap-2 text-[12px]">
<Spinner />
{signinPhase === "opening" ? "Opening browser…" : "Waiting for sign-in…"}
</span>
{signinPhase === "waiting" && (
<span className="text-[11.5px] text-faint">
Finish signing in in your browser this page updates by itself.{" "}
<button
className="underline hover:text-muted"
onClick={cancelSignin}
data-testid="ob-signin-cancel"
>
Cancel
</button>
</span>
)}
</>
) : (
<button
className="px-3.5 py-1 rounded-full border border-line text-[12.5px] text-accent hover:bg-panel"
onClick={signInThenConnect}
data-testid="ob-cloud-signin"
>
Sign in to OpenWorker Cloud
</button>
)}
</div>
</div>
)}
{allConnected && (
<div className={picked.conns.length ? "bg-paper rounded-xl px-4 py-3.5 mt-3" : ""} data-testid="ob-recipe">
{picked.needsRepo && (
<>
<label className={label}>Repository</label>
<input
className={input}
placeholder="owner/repo"
value={repo}
onChange={(e) => setRepo(e.target.value)}
data-testid="ob-repo"
/>
</>
)}
{picked.needsChannel && (
<>
<label className={label}>Post to channel</label>
<div data-testid="ob-channel">
<ChannelPicker
value={channel}
onChange={setChannel}
recent={recent}
onPickName={(address, name, workspace) =>
setPickedNames((m) => ({ ...m, [address]: { name, workspace } }))
}
/>
</div>
<p className="text-[11px] text-warnInk mt-1">
The bot must be a member of the channel invite @OpenWorker in Slack if it isn't.
</p>
</>
)}
<label className={label}>When</label>
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<SelectMenu
ariaLabel="Day"
value={day}
options={Object.entries(DAYS).map(([k, v]) => ({ value: k, label: v.label }))}
onChange={setDay}
/>
</div>
<input
className="w-28 px-3 py-2 rounded-lg border border-line bg-panel text-[13.5px] outline-none focus:border-accent"
type="time"
aria-label="Time"
value={time}
onChange={(e) => setTime(e.target.value)}
/>
</div>
{picked.deliver && (
<>
<label className={label}>Deliver to</label>
<SelectMenu
ariaLabel="Deliver to"
value={deliver}
options={[
{ value: "app", label: "In the app" },
{ value: "slack", label: "Slack DM (connect Slack later)" },
]}
onChange={(v) => setDeliver(v as "app" | "slack")}
/>
</>
)}
{picked.consent ? (
<label className="flex items-start gap-2.5 mt-3.5 text-[12.5px] text-muted select-none">
<input
type="checkbox"
className="mt-0.5"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
data-testid="ob-consent"
/>
<span>
Allow this automation to post its digest to{" "}
<b className="text-ink" title={channel || undefined}>
{channelLabel || "the channel"}
{channelWorkspace ? ` (${channelWorkspace})` : ""}
</b>{" "}
without asking each time. Anything else still asks first.
</span>
</label>
) : picked.conns.length > 0 ? (
<p className="text-[12.5px] text-muted mt-3">
This automation only <b className="text-ink">reads</b> on schedule reading
never needs approval.
</p>
) : null}
</div>
)}
<div className="flex items-center gap-3 mt-4">
<button
className="text-[12.5px] text-faint hover:text-muted"
onClick={() => setPickedKey(null)}
>
Cancel
</button>
{/* A silently-disabled primary reads as a bug — always name the missing piece. */}
{gateHint && (
<span className="ml-auto text-[11.5px] text-faint" data-testid="ob-create-hint">
{gateHint}
</span>
)}
<button
className={
(gateHint ? "" : "ml-auto ") +
"px-5 py-2 rounded-full bg-ink text-panel text-[13px] disabled:opacity-40"
}
disabled={busy || !allConnected || (picked.needsChannel && !channel)}
onClick={create}
data-testid="ob-create"
>
{busy ? "Creating…" : "Create automation"}
</button>
</div>
</div>
)}
</div>
);
}