mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user