From 97888ce85927ab003333142452163b6edbebeef7 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:20:50 +0000 Subject: [PATCH] feat(web): Company Brain proactiveness (automations) settings (#1207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the **Proactiveness** settings tab for Company Brain channel/DM automations. - Accordion list of automations — collapsed rows with an instant enable toggle + **Run now**, expand to edit. - Two-column editor: prompt (left) / deliver-to channel or DM, frequency, day, time (right), with local-timezone-aware cron. - Preset gallery (connection-first, category-diverse) for empty state + a New-automation menu. - DM delivery option with a tooltip explaining channel visibility + personal-connection fallback. - Profile-menu entry (gated on Company Brain). Pairs with the API automations work: **supermemoryai/mono#2480**. --- .../settings/company-brain-automations.tsx | 1084 +++++++++++++++++ .../settings/proactiveness-icon.tsx | 29 + .../web/components/settings/proactiveness.tsx | 26 + .../components/settings/settings-content.tsx | 21 +- apps/web/components/user-profile-menu.tsx | 10 + apps/web/lib/analytics.ts | 1 + 6 files changed, 1170 insertions(+), 1 deletion(-) create mode 100644 apps/web/components/settings/company-brain-automations.tsx create mode 100644 apps/web/components/settings/proactiveness-icon.tsx create mode 100644 apps/web/components/settings/proactiveness.tsx diff --git a/apps/web/components/settings/company-brain-automations.tsx b/apps/web/components/settings/company-brain-automations.tsx new file mode 100644 index 000000000..26d1fda39 --- /dev/null +++ b/apps/web/components/settings/company-brain-automations.tsx @@ -0,0 +1,1084 @@ +"use client" + +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { cn } from "@lib/utils" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import type { LucideIcon } from "lucide-react" +import { + CalendarClock, + ChevronDown, + ChevronUp, + FilePlus2, + FileText, + GitPullRequest, + Info, + LifeBuoy, + ListTodo, + Loader2, + Lock, + MessageCircleQuestion, + Plus, + Trash2, +} from "lucide-react" +import { useRef, useState } from "react" +import { toast } from "sonner" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ui/components/dropdown-menu" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/select" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@ui/components/tooltip" +import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import { dmSans125ClassName } from "@/lib/fonts" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" +const BASE = `${BACKEND}/brain/automations` + +type Automation = { + id: string + enabled: boolean + title: string + channelId: string + deliverTo: "channel" | "dm" + prompt: string + cron: string + timezone: string | null + createdBy: string | null +} +type Channel = { id: string; name: string; isPrivate: boolean } +type Frequency = "daily" | "weekly" + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + +const DEFAULT_PROMPT = + "Summarize what's happened recently across the connected tools and channels: open items, unanswered questions, decisions, and anything the team should know. Keep it a short, scannable recap." + +// Local day/time -> UTC cron; the Date roundtrip carries any day rollover. +function toUtcCron( + time: string, + frequency: Frequency, + weekday: number, +): string | null { + const [hh, mm] = time.split(":").map(Number) + if ( + hh === undefined || + mm === undefined || + Number.isNaN(hh) || + Number.isNaN(mm) + ) + return null + const d = new Date() + d.setHours(hh, mm, 0, 0) + if (frequency === "weekly") + d.setDate(d.getDate() + ((weekday - d.getDay() + 7) % 7)) + const m = d.getUTCMinutes() + const h = d.getUTCHours() + return frequency === "daily" + ? `${m} ${h} * * *` + : `${m} ${h} * * ${d.getUTCDay()}` +} + +function fromUtcCron( + cron: string, +): { frequency: Frequency; weekday: number; time: string } | null { + const parts = cron.trim().split(/\s+/) + if (parts.length !== 5) return null + const [min, hr, , , dow] = parts + const mm = Number(min) + const hh = Number(hr) + if (Number.isNaN(mm) || Number.isNaN(hh)) return null + const d = new Date() + d.setUTCHours(hh, mm, 0, 0) + const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` + if (dow === "*") return { frequency: "daily", weekday: 1, time } + const targetDow = Number(dow) + if (Number.isNaN(targetDow)) return null + d.setUTCDate(d.getUTCDate() + ((targetDow - d.getUTCDay() + 7) % 7)) + return { frequency: "weekly", weekday: d.getDay(), time } +} + +type Draft = { + title: string + channelId: string + deliverTo: "channel" | "dm" + prompt: string + frequency: Frequency + weekday: number + time: string + enabled: boolean +} + +function toDraft(a: Automation): Draft { + const parsed = fromUtcCron(a.cron) + return { + title: a.title, + channelId: a.channelId, + deliverTo: a.deliverTo === "dm" ? "dm" : "channel", + prompt: a.prompt, + frequency: parsed?.frequency ?? "daily", + weekday: parsed?.weekday ?? 1, + time: parsed?.time ?? "09:00", + enabled: a.enabled, + } +} + +const emptyDraft = (): Draft => ({ + title: "", + channelId: "", + deliverTo: "channel", + prompt: DEFAULT_PROMPT, + frequency: "daily", + weekday: 1, + time: "09:00", + enabled: true, +}) + +type Category = "team" | "engineering" | "support" | "product" + +type Preset = { + id: string + label: string + description: string + icon: LucideIcon + category: Category + requiresApps?: string[] + prompt: string + frequency: Frequency + weekday?: number + time: string +} + +const PRESETS: Preset[] = [ + { + id: "standup", + category: "team", + label: "Daily standup", + description: + "Shipped work, decisions, blockers & open questions from the last 24h.", + icon: CalendarClock, + prompt: + "Give a short standup for this channel: what happened in the last 24 hours across our connected tools and this channel — work shipped, decisions made, blockers, and open questions. Keep it tight and scannable.", + frequency: "daily", + time: "09:00", + }, + { + id: "weekly-recap", + category: "team", + label: "Weekly team recap", + description: + "Decisions, shipped work & unresolved threads from the past week.", + icon: CalendarClock, + prompt: + "Weekly recap for the team: decisions made, work shipped, and unresolved threads across our connected tools and channels over the past 7 days.", + frequency: "weekly", + weekday: 1, + time: "09:00", + }, + { + id: "unanswered", + category: "team", + label: "Unanswered questions", + description: "Questions in this channel from the last 24h with no reply.", + icon: MessageCircleQuestion, + prompt: + "Surface questions asked in this channel in the last 24 hours that haven't gotten a reply yet, so nothing slips through.", + frequency: "daily", + time: "16:00", + }, + { + id: "prs-review", + category: "engineering", + label: "PRs awaiting review", + description: "Open PRs waiting on review; flags stale ones.", + icon: GitPullRequest, + requiresApps: ["github"], + prompt: + "List open pull requests awaiting review. Flag any with no activity for 2+ days. Group by repository.", + frequency: "daily", + time: "09:30", + }, + { + id: "issue-triage", + category: "engineering", + label: "Issue triage", + description: "New or unassigned issues that need a response.", + icon: ListTodo, + requiresApps: ["github", "linear"], + prompt: + "Summarize new or unassigned issues from the last 24 hours that need triage or a response.", + frequency: "daily", + time: "09:00", + }, + { + id: "customer-signal", + category: "support", + label: "Customer signal", + description: "Recent customer issues & feedback and their status.", + icon: LifeBuoy, + requiresApps: ["plain", "linear"], + prompt: + "Recap customer issues and feedback raised recently across our tools and channels, with their current status.", + frequency: "daily", + time: "09:00", + }, + { + id: "release-notes", + category: "product", + label: "Release notes draft", + description: "Draft notes from PRs merged since the last digest.", + icon: FileText, + requiresApps: ["github"], + prompt: + "Draft release notes from pull requests merged since the last digest, grouped into features, fixes, and chores.", + frequency: "weekly", + weekday: 5, + time: "16:00", + }, +] + +function presetToDraft(p: Preset): Draft { + return { + title: p.label, + channelId: "", + deliverTo: "channel", + prompt: p.prompt, + frequency: p.frequency, + weekday: p.weekday ?? 1, + time: p.time, + enabled: true, + } +} + +// Connected-app presets first, universal next, unconnected-app presets last. +function sortPresets(connected: Set): Preset[] { + const rank = (p: Preset) => { + if (!p.requiresApps) return 1 + return p.requiresApps.some((a) => connected.has(a)) ? 0 : 2 + } + return [...PRESETS].sort((a, b) => rank(a) - rank(b)) +} + +// Up to 3 presets spanning distinct categories (connection-preferred order). +function galleryPresets(sorted: Preset[]): Preset[] { + const seen = new Set() + const out: Preset[] = [] + for (const p of sorted) { + if (seen.has(p.category)) continue + seen.add(p.category) + out.push(p) + if (out.length === 3) break + } + return out +} + +function cadenceLabel(p: Preset): string { + if (p.frequency === "weekly") + return `Weekly · ${WEEKDAYS[p.weekday ?? 1] ?? "Mon"} ${p.time}` + return `Daily · ${p.time}` +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +const controlClass = cn( + dmSans125ClassName(), + "h-9 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50", +) +const fieldLabel = cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]") +const selectContentClass = cn( + dmSans125ClassName(), + "rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]", +) +const selectItemClass = + "cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white" +const DM_VALUE = "__dm__" + +const menuItemClass = + "cursor-pointer gap-2.5 rounded-lg px-2.5 py-2 text-[13px] font-medium text-white/85 hover:bg-white/[0.06] hover:text-white data-[highlighted]:bg-white/[0.06] data-[highlighted]:text-white focus:bg-white/[0.06] focus:text-white" + +function AutomationCard({ + initial, + id, + channels, + ownerLabel, + onDone, + onCancelNew, + onCollapse, +}: { + initial: Draft + id: string | null + channels: Channel[] + ownerLabel?: string + onDone: () => void + onCancelNew?: () => void + onCollapse?: () => void +}) { + const [draft, setDraft] = useState(initial) + const set = (k: K, v: Draft[K]) => + setDraft((d) => ({ ...d, [k]: v })) + + const body = () => { + if (!draft.title.trim()) throw new Error("Give the automation a name.") + if (draft.deliverTo === "channel" && !draft.channelId) + throw new Error("Pick a channel to post to.") + const cron = toUtcCron(draft.time, draft.frequency, draft.weekday) + if (!cron) throw new Error("Pick a valid time.") + return { + title: draft.title.trim(), + deliverTo: draft.deliverTo, + channelId: draft.deliverTo === "dm" ? null : draft.channelId, + prompt: draft.prompt.trim() || DEFAULT_PROMPT, + cron, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + enabled: draft.enabled, + } + } + + const save = useMutation({ + mutationFn: async () => { + const payload = body() + const res = await fetch(id ? `${BASE}/${id}` : `${BASE}/`, { + method: id ? "PUT" : "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }) + if (res.status === 403) + throw new Error("You can only manage automations you created.") + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string } + throw new Error(b.error ?? "Couldn't save.") + } + }, + onSuccess: () => { + toast.success("Automation saved.") + onDone() + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "Couldn't save."), + }) + + const trigger = useMutation({ + mutationFn: async () => { + if (!id) throw new Error("Save the automation first.") + const res = await fetch(`${BASE}/${id}/run-now`, { + method: "POST", + credentials: "include", + }) + const b = (await res.json().catch(() => ({}))) as { + ok?: boolean + reason?: string + error?: string + } + if (!res.ok || b.ok === false) + throw new Error(b.reason ?? b.error ?? "Couldn't run.") + }, + onSuccess: () => toast.success("Automation triggered — check the channel."), + onError: (err) => + toast.error(err instanceof Error ? err.message : "Couldn't run."), + }) + + const remove = useMutation({ + mutationFn: async () => { + if (!id) return + const res = await fetch(`${BASE}/${id}`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok) throw new Error("Couldn't delete.") + }, + onSuccess: () => { + toast.success("Automation removed.") + onDone() + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "Couldn't delete."), + }) + + const busy = save.isPending || trigger.isPending || remove.isPending + const disabled = busy + + return ( +
+
+
+ set("title", e.target.value)} + /> + {ownerLabel ? ( + + {ownerLabel} + + ) : null} +
+ {onCollapse ? ( + + ) : null} + +
+ +
+