OpenWorker: initial import

Imported from andrewyng/aisuite@1b4bbf303e
(contents of its platform/ directory, hoisted to the repo root).
Development history prior to this commit lives in that repository.

Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
Rohit C Prasad
2026-07-21 11:09:41 -07:00
co-authored by Devika
commit 2b45018ffa
413 changed files with 93539 additions and 0 deletions
@@ -0,0 +1,590 @@
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 @ocw 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>
);
}