mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
Merge remote-tracking branch 'origin/main' into Prasanna721/dalat-v1
# Conflicts: # bun.lock
This commit is contained in:
commit
2c6d5f36ed
31 changed files with 1542 additions and 628 deletions
12
README.md
12
README.md
|
|
@ -28,6 +28,12 @@
|
|||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>#1 on every major AI memory benchmark — <a href="https://github.com/xiaowu0162/LongMemEval">LongMemEval</a>, <a href="https://github.com/snap-research/locomo">LoCoMo</a>, and <a href="https://github.com/Salesforce/ConvoMem">ConvoMem</a>.</strong><br/>
|
||||
<strong>95% Recall@15 with a 99.4% context reduction · ~50ms user profiles.</strong><br/>
|
||||
<a href="https://supermemory.ai/research">Read the research →</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Supermemory is the memory and context layer for AI. **#1 on [LongMemEval](https://github.com/xiaowu0162/LongMemEval), [LoCoMo](https://github.com/snap-research/locomo), and [ConvoMem](https://github.com/Salesforce/ConvoMem)** — the three major benchmarks for AI memory.
|
||||
|
|
@ -356,10 +362,14 @@ Supermemory is state of the art across all major AI memory benchmarks:
|
|||
|
||||
| Benchmark | What it measures | Result |
|
||||
|---|---|---|
|
||||
| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | Long-term memory across sessions with knowledge updates | **81.6% — #1** |
|
||||
| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | Long-term memory across sessions with knowledge updates | **#1** |
|
||||
| **[LoCoMo](https://github.com/snap-research/locomo)** | Fact recall across extended conversations (single-hop, multi-hop, temporal, adversarial) | **#1** |
|
||||
| **[ConvoMem](https://github.com/Salesforce/ConvoMem)** | Personalization and preference learning | **#1** |
|
||||
|
||||
On LongMemEval, supermemory reaches **95% Recall@15 while adding only ~720 tokens of context — a 99.4% context reduction** (99.6% at @10, 99.8% at @5). Recall by category: Knowledge Updates 99%, Assistant recall 100%, User recall 97%, Multi-session 93%, Temporal Reasoning 91%, Preference 90%.
|
||||
|
||||
We also built the **Supermemory Filesystem (SMFS)**, which uses **3.0× fewer tokens on Claude** (24M vs 72M) and **1.75× fewer on Codex** across the 110-question xAFS benchmark. See the full write-ups on our [research page](https://supermemory.ai/research).
|
||||
|
||||
We also built **[MemoryBench](https://supermemory.ai/docs/memorybench/overview)** — an open-source framework for standardized, reproducible benchmarks of memory providers. Compare Supermemory, Mem0, Zep, and others head-to-head:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ icon: "mail"
|
|||
Connect Gmail to automatically sync email threads into your supermemory knowledge base. Supports real-time updates via Google Cloud Pub/Sub webhooks and incremental synchronization.
|
||||
|
||||
<Note>
|
||||
**Scale Plan Required:** The Gmail connector is available on Scale and Enterprise plans only.
|
||||
**Max Plan Required:** The Gmail connector is available on Max plan and above.
|
||||
</Note>
|
||||
|
||||
## Quick Setup
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ icon: "/images/claude-code-icon.svg"
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Warning>
|
||||
This integration requires the **Supermemory Pro plan**. [Upgrade here](https://console.supermemory.ai/billing).
|
||||
</Warning>
|
||||
|
||||
[supermemory](https://github.com/supermemoryai/claude-supermemory) is a Claude Code plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
|
||||
|
||||
<Tip>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,6 @@ description: "OpenCode Supermemory Plugin — persistent memory across coding se
|
|||
icon: "/images/opencode-logo.png"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
This integration requires the **Supermemory Pro plan**. [Upgrade here](https://console.supermemory.ai/billing).
|
||||
</Warning>
|
||||
|
||||
[OpenCode-Supermemory](https://github.com/supermemoryai/opencode-supermemory) is an OpenCode plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
|
||||
|
||||
<Tip>
|
||||
|
|
|
|||
|
|
@ -86,8 +86,6 @@ export default function BrainOnboardingPage() {
|
|||
() => workspaceDomainFromEmail(user?.email),
|
||||
[user?.email],
|
||||
)
|
||||
|
||||
// Team (Company Brain) onboarding is gated behind a private-beta flag.
|
||||
const allowTeam = useFeatureFlagEnabled("company-brain-beta") ?? false
|
||||
const [mode, setMode] = useState<BrainMode>(detectedMode)
|
||||
const [about, setAbout] = useState<AboutValues>({
|
||||
|
|
@ -339,8 +337,6 @@ export default function BrainOnboardingPage() {
|
|||
setCreatingOrg(false)
|
||||
}
|
||||
}, [ensureOrg, goNext, forceCreate, organizations, router])
|
||||
|
||||
// Company Brain (team) onboarding is a single research surface, no stepper.
|
||||
const isCompanyBrain = allowTeam && mode === "team"
|
||||
|
||||
const handleBrainConfirm = useCallback(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
|||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
import { IntegrationsView, DetailWrapper } from "@/components/integrations-view"
|
||||
import { ConfigureView } from "@/components/configure-view"
|
||||
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
|
||||
import { ChromeDetail } from "@/components/integrations/chrome-detail"
|
||||
|
|
@ -698,6 +699,10 @@ export function AppExperience() {
|
|||
onOpenDocument={handleOpenDocument}
|
||||
/>
|
||||
</div>
|
||||
) : viewMode === "configure" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4 pt-2! md:p-6">
|
||||
<ConfigureView />
|
||||
</div>
|
||||
) : viewMode === "mcp" ? (
|
||||
<MCPDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ function StatsRow({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onInvite}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
className="hidden items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa] sm:inline-flex"
|
||||
>
|
||||
<UserPlus className="size-3" />
|
||||
Invite
|
||||
|
|
@ -235,31 +235,64 @@ function StatsRow({
|
|||
]
|
||||
return (
|
||||
<section
|
||||
className="grid grid-cols-2 divide-white/[0.04] rounded-[16px] bg-[#1B1F24] sm:grid-cols-4 sm:divide-x"
|
||||
className="grid grid-cols-4 divide-x divide-white/[0.04] overflow-hidden rounded-[16px] bg-[#1B1F24]"
|
||||
style={cardStyle}
|
||||
>
|
||||
{tiles.map((t) => (
|
||||
<div key={t.label} className="px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
|
||||
{t.label}
|
||||
{tiles.map((t, index) => (
|
||||
<div
|
||||
key={t.label}
|
||||
className={cn(
|
||||
"relative min-w-0 px-3 py-3 sm:px-5 sm:py-4",
|
||||
index === 2 && canInvite && "pr-8 sm:pr-5",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-[8px] font-semibold uppercase leading-tight tracking-[0.08em] text-[#737373] sm:text-[10px] sm:tracking-[0.12em]">
|
||||
<MobileStatLabel label={t.label} />
|
||||
</p>
|
||||
{t.action}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-[22px] font-semibold leading-none tabular-nums text-[#fafafa]",
|
||||
"mt-1 text-[17px] font-semibold leading-none tabular-nums text-[#fafafa] sm:mt-1.5 sm:text-[22px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{t.value}
|
||||
</p>
|
||||
{index === 2 && canInvite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInvite}
|
||||
aria-label="Invite teammates"
|
||||
title="Invite teammates"
|
||||
className="absolute right-1.5 bottom-1.5 inline-flex size-6 items-center justify-center rounded-md bg-white/[0.03] text-[#737373] transition-colors hover:bg-white/[0.07] hover:text-[#fafafa] sm:hidden"
|
||||
>
|
||||
<UserPlus className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileStatLabel({ label }: { label: string }) {
|
||||
const mobile =
|
||||
label === "Connected sources"
|
||||
? "Sources"
|
||||
: label === "Active members"
|
||||
? "Members"
|
||||
: label
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="sm:hidden">{mobile}</span>
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentMemories({
|
||||
docs,
|
||||
loading,
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import { ArrowRight, Loader2 } from "lucide-react"
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
|
||||
// Apps surfaced on the dashboard; the rest live behind "More" in settings.
|
||||
const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
|
||||
// Preferred ordering for the dashboard; only unconnected apps are surfaced.
|
||||
const FEATURED_SLUGS: readonly string[] = ["linear", "granola", "sentry"]
|
||||
// Example prompts on the right card — can include apps not shown on the left.
|
||||
const PREVIEW_PROMPT_SLUGS = ["linear", "granola", "github", "sentry"] as const
|
||||
|
||||
|
|
@ -155,76 +155,91 @@ export function ConnectionsBoard() {
|
|||
}
|
||||
}
|
||||
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { setViewMode } = useViewMode()
|
||||
const apps = catalog ?? []
|
||||
const loading = catalog === null
|
||||
const featured = FEATURED_SLUGS.map((slug) =>
|
||||
apps.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a))
|
||||
const unconnected = apps.filter((a) => !isConnected(a.slug))
|
||||
const featured = [
|
||||
...FEATURED_SLUGS.map((slug) =>
|
||||
unconnected.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a)),
|
||||
...unconnected.filter((a) => !FEATURED_SLUGS.includes(a.slug)),
|
||||
].slice(0, 3)
|
||||
const overflow = unconnected.filter((a) => !featured.includes(a))
|
||||
const previewApps = PREVIEW_PROMPT_SLUGS.map((slug) =>
|
||||
apps.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a))
|
||||
const remainingCount = Math.max(apps.length - featured.length, 0)
|
||||
const connectedCount = apps.filter((a) => isConnected(a.slug)).length
|
||||
const showBoard = loading || unconnected.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{slack && !slack.connected && <SlackBanner />}
|
||||
|
||||
<div className="grid items-start gap-4 lg:grid-cols-5">
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Connect your tools
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Give your Slack agent live access to the apps your team already
|
||||
uses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{featured.map((entry, i) => (
|
||||
<AppTile
|
||||
key={entry.slug}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name, "size-5")}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
connected={isConnected(entry.slug)}
|
||||
busy={busy === entry.slug}
|
||||
onConnect={() => connect(entry)}
|
||||
showDivider={i < featured.length - 1 || remainingCount > 0}
|
||||
/>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<MoreTile
|
||||
count={remainingCount}
|
||||
onClick={() => openSettings("company-brain")}
|
||||
/>
|
||||
{showBoard ? (
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
>
|
||||
Connect your tools
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Give your Slack agent live access to the apps your team already
|
||||
uses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{featured.map((entry, i) => (
|
||||
<AppTile
|
||||
key={entry.slug}
|
||||
icon={brainConnectorIcon(
|
||||
entry.slug,
|
||||
entry.name,
|
||||
"size-5",
|
||||
)}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
connected={isConnected(entry.slug)}
|
||||
busy={busy === entry.slug}
|
||||
onConnect={() => connect(entry)}
|
||||
showDivider={
|
||||
i < featured.length - 1 || overflow.length > 0
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<MoreTile
|
||||
count={overflow.length}
|
||||
names={overflow.slice(0, 3).map((a) => a.name)}
|
||||
onClick={() => void setViewMode("configure")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<AgentPreview
|
||||
apps={previewApps}
|
||||
isConnected={isConnected}
|
||||
connectedCount={connectedCount}
|
||||
wide={!showBoard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -235,10 +250,12 @@ function AgentPreview({
|
|||
apps,
|
||||
isConnected,
|
||||
connectedCount,
|
||||
wide = false,
|
||||
}: {
|
||||
apps: CatalogEntry[]
|
||||
isConnected: (slug: string) => boolean
|
||||
connectedCount: number
|
||||
wide?: boolean
|
||||
}) {
|
||||
const prompts = apps
|
||||
.filter((a) => AGENT_PROMPTS[a.slug])
|
||||
|
|
@ -252,7 +269,10 @@ function AgentPreview({
|
|||
|
||||
return (
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-2"
|
||||
className={cn(
|
||||
"relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5",
|
||||
wide ? "lg:col-span-5" : "lg:col-span-2",
|
||||
)}
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -359,7 +379,15 @@ function AppTile({
|
|||
)
|
||||
}
|
||||
|
||||
function MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
|
||||
function MoreTile({
|
||||
count,
|
||||
names,
|
||||
onClick,
|
||||
}: {
|
||||
count: number
|
||||
names: string[]
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -380,7 +408,8 @@ function MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
|
|||
{count} more {count === 1 ? "app" : "apps"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
|
||||
Notion, PostHog, Plain and more
|
||||
{names.join(", ")}
|
||||
{count > names.length ? " and more" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-[88px] shrink-0 justify-end">
|
||||
|
|
@ -411,7 +440,7 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
|
|||
function SlackBanner() {
|
||||
return (
|
||||
<section
|
||||
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
||||
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div
|
||||
|
|
@ -422,37 +451,45 @@ function SlackBanner() {
|
|||
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="flex items-center justify-between gap-3 sm:gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3 sm:gap-3.5">
|
||||
<div
|
||||
className="flex size-12 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F] sm:size-12"
|
||||
style={tileStyle}
|
||||
>
|
||||
<SlackMark className="size-7" />
|
||||
<SlackMark className="size-6 sm:size-7" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold text-[#fafafa]",
|
||||
"truncate text-[15px] font-semibold leading-tight text-[#fafafa] sm:text-[16px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Company Brain in Slack
|
||||
<span className="sm:hidden">Slack agent</span>
|
||||
<span className="hidden sm:inline">Company Brain in Slack</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] font-medium leading-[1.5] text-[#737373]">
|
||||
Install Supermemory so your team can{" "}
|
||||
<span className="text-[#A1A1AA]">@supermemory</span> in any
|
||||
channel.
|
||||
<p className="mt-1 truncate text-[12px] font-medium leading-[1.45] text-[#737373] sm:mt-0.5 sm:text-[13px] sm:leading-[1.5]">
|
||||
<span className="sm:hidden">
|
||||
Ask <span className="text-[#A1A1AA]">@supermemory</span> from
|
||||
any channel.
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
Install Supermemory so your team can{" "}
|
||||
<span className="text-[#A1A1AA]">@supermemory</span> in any
|
||||
channel.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className="inline-flex shrink-0 items-center gap-2 self-start rounded-lg bg-white px-4 py-2.5 text-[14px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:self-auto"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-white px-3 py-1.5 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:gap-2 sm:px-4 sm:py-2.5 sm:text-[14px]"
|
||||
>
|
||||
<SlackMark className="size-[18px]" />
|
||||
Add to Slack
|
||||
<SlackMark className="hidden sm:block sm:size-[18px]" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
<span className="hidden sm:inline">Add to Slack</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ import {
|
|||
ExternalLink,
|
||||
Home,
|
||||
LifeBuoy,
|
||||
Link2,
|
||||
LayoutGrid,
|
||||
MenuIcon,
|
||||
SearchIcon,
|
||||
Settings,
|
||||
Settings2,
|
||||
UserPlus,
|
||||
ChevronRight,
|
||||
Sun,
|
||||
|
|
@ -35,7 +35,7 @@ import { DomainLogo } from "@/components/onboarding-brain/step-about"
|
|||
import { FeedbackModal } from "@/components/feedback-modal"
|
||||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
|
||||
import { GraphIcon } from "@/components/integration-icons"
|
||||
import { SpaceSelector } from "@/components/space-selector"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
|
|
@ -121,7 +121,6 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
feedbackParam,
|
||||
)
|
||||
const [, setInvite] = useQueryState("invite")
|
||||
const [settingsTab] = useQueryState("settings")
|
||||
const { data: slackStatus } = useSlackStatus()
|
||||
|
||||
const planByOrgId = new Map(
|
||||
|
|
@ -140,10 +139,10 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
?.role?.toLowerCase()
|
||||
const canInvite = memberRole === "owner" || memberRole === "admin"
|
||||
|
||||
const isOverview = viewMode === "dashboard" && settingsTab !== "company-brain"
|
||||
const isOverview = viewMode === "dashboard"
|
||||
const isGraph = viewMode === "graph"
|
||||
const isMemories = viewMode === "list"
|
||||
const isConnections = settingsTab === "company-brain"
|
||||
const isConfigure = viewMode === "configure"
|
||||
const slackConnected = slackStatus?.connected ?? false
|
||||
|
||||
const selectOrg = useCallback(
|
||||
|
|
@ -166,9 +165,9 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
void setViewMode("list")
|
||||
}, [setViewMode])
|
||||
|
||||
const goConnections = useCallback(() => {
|
||||
openSettings("company-brain")
|
||||
}, [openSettings])
|
||||
const goConfigure = useCallback(() => {
|
||||
void setViewMode("configure")
|
||||
}, [setViewMode])
|
||||
|
||||
const goIntegrations = useCallback(() => {
|
||||
void setViewMode("integrations")
|
||||
|
|
@ -184,16 +183,16 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
}, [setFeedbackOpen])
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
|
||||
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1 px-2 py-2 md:grid md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:gap-2 md:p-3">
|
||||
<div className="z-10! flex min-w-0 flex-1 shrink items-center justify-start gap-1.5 md:justify-self-start md:gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex max-w-[min(52vw,240px)] shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none md:-ml-2 before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']"
|
||||
className="relative flex min-w-0 max-w-[31vw] shrink cursor-pointer items-center rounded-lg px-1 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none min-[380px]:max-w-[9rem] sm:max-w-[min(52vw,240px)] md:-ml-2 md:max-w-[min(52vw,240px)] md:shrink-0 md:px-1.5 before:absolute before:-inset-x-1 before:-inset-y-2 before:content-[''] md:before:-inset-x-2 md:before:-inset-y-2.5"
|
||||
>
|
||||
<div
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] sm:size-8"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
|
||||
|
|
@ -205,7 +204,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
<Building2 className="size-4 text-[#737373]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 min-w-0 flex flex-col items-start justify-center">
|
||||
<div className="ml-1.5 min-w-0 flex flex-col items-start justify-center max-[340px]:hidden sm:ml-2">
|
||||
<p className="max-w-full truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
|
||||
Company Brain
|
||||
</p>
|
||||
|
|
@ -264,9 +263,9 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
Home
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={goConnections} className={menuItemClass}>
|
||||
<Link2 className="size-4 text-[#737373]" />
|
||||
Connections
|
||||
<DropdownMenuItem onClick={goConfigure} className={menuItemClass}>
|
||||
<Settings2 className="size-4 text-[#737373]" />
|
||||
Configure
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goIntegrations}
|
||||
|
|
@ -318,7 +317,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
|
||||
<div className="z-10! flex min-w-0 max-w-full items-center justify-center gap-1.5 overflow-hidden px-1 md:justify-self-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -364,24 +363,24 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isConnections}
|
||||
onClick={goConnections}
|
||||
className={tabClass(isConnections)}
|
||||
aria-selected={isConfigure}
|
||||
onClick={goConfigure}
|
||||
className={tabClass(isConfigure)}
|
||||
>
|
||||
<IntegrationsIcon className="size-3.5 shrink-0 sm:size-4" />
|
||||
Connections
|
||||
<Settings2 className="size-3.5 shrink-0 sm:size-4" />
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
<SlackNavButton
|
||||
connected={slackConnected}
|
||||
teamName={slackStatus?.teamName ?? null}
|
||||
active={isConnections && slackConnected}
|
||||
onManage={goConnections}
|
||||
active={isConfigure && slackConnected}
|
||||
onManage={goConfigure}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="z-10! flex shrink-0 items-center gap-1.5">
|
||||
<div className="z-10! flex min-w-0 shrink-0 items-center gap-1.5 md:justify-self-end">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SpaceSelector
|
||||
|
|
@ -389,12 +388,14 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
onValueChange={setSelectedProjects}
|
||||
enableDelete={false}
|
||||
compact
|
||||
triggerClassName="max-w-[34vw] shrink min-[380px]:max-w-[9rem]"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10!"
|
||||
aria-label="Open navigation menu"
|
||||
className="size-9! min-h-9 min-w-9 rounded-full px-0! text-base"
|
||||
>
|
||||
<MenuIcon className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -429,11 +430,11 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
Memories
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goConnections}
|
||||
onClick={goConfigure}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<IntegrationsIcon className="size-4 text-[#737373]" />
|
||||
Connections
|
||||
<Settings2 className="size-4 text-[#737373]" />
|
||||
Configure
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goIntegrations}
|
||||
|
|
@ -444,7 +445,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
</DropdownMenuItem>
|
||||
{slackConnected ? (
|
||||
<DropdownMenuItem
|
||||
onClick={goConnections}
|
||||
onClick={goConfigure}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
|
|
|
|||
130
apps/web/components/configure-view.tsx
Normal file
130
apps/web/components/configure-view.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Blocks, CalendarClock, Cpu } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import CompanyBrainModels from "@/components/settings/company-brain-models"
|
||||
import Proactiveness from "@/components/settings/proactiveness"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
type ConfigureSection = "company-brain" | "models" | "automations"
|
||||
|
||||
const SECTIONS: {
|
||||
id: ConfigureSection
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Blocks
|
||||
}[] = [
|
||||
{
|
||||
id: "company-brain",
|
||||
label: "Integrations",
|
||||
description:
|
||||
"Connect the tools your brain works with. Your account covers your own actions and reads; workspace accounts are a shared fallback.",
|
||||
icon: Blocks,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
label: "Models",
|
||||
description:
|
||||
"Pick how fast or thorough your brain should be. Fine-tune each task under Advanced.",
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
label: "Automations",
|
||||
description:
|
||||
"Read-only scheduled summaries posted to Slack channels or DMs. You manage the ones you create.",
|
||||
icon: CalendarClock,
|
||||
},
|
||||
]
|
||||
|
||||
export function ConfigureView() {
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<ConfigureSection>("company-brain")
|
||||
const active = SECTIONS.find((section) => section.id === activeSection)
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mx-auto flex min-h-full w-full max-w-[88rem] flex-col",
|
||||
)}
|
||||
>
|
||||
<section
|
||||
aria-label="Configure Company Brain"
|
||||
className="flex flex-1 flex-col rounded-[14px] bg-[#191D24] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-6"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-5 md:flex-row md:gap-8">
|
||||
<nav
|
||||
aria-label="Configure sections"
|
||||
className="scrollbar-none flex shrink-0 gap-1 overflow-x-auto md:w-52 md:flex-col md:overflow-x-visible"
|
||||
>
|
||||
<p className="hidden px-3 pb-1.5 font-semibold text-[11px] text-[#5B6675] uppercase tracking-[0.08em] md:block">
|
||||
Configure
|
||||
</p>
|
||||
{SECTIONS.map((section) => {
|
||||
const isActive = section.id === activeSection
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2.5 rounded-[8px] px-3 py-2 text-left text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-white/[0.08] text-[#FAFAFA]"
|
||||
: "text-[#8B929E] hover:bg-white/[0.04] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isActive ? "text-[#FAFAFA]" : "text-[#737B87]",
|
||||
)}
|
||||
/>
|
||||
{section.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="mb-5">
|
||||
<h2
|
||||
id="configure-section-title"
|
||||
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
|
||||
>
|
||||
{active.label}
|
||||
</h2>
|
||||
<p className="mt-1 text-[12px] leading-5 text-[#737B87]">
|
||||
{active.description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ErrorBoundary
|
||||
key={activeSection}
|
||||
fallback={
|
||||
<p className="py-6 text-center text-[13px] text-[#8B929E]">
|
||||
Something went wrong loading this section.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{activeSection === "company-brain" ? (
|
||||
<CompanyBrainConnections />
|
||||
) : activeSection === "models" ? (
|
||||
<CompanyBrainModels showHeading={false} />
|
||||
) : (
|
||||
<Proactiveness />
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -43,12 +43,21 @@ const BACKEND =
|
|||
type Phase = "confirm" | "research"
|
||||
|
||||
function normalizeDomain(input: string): string {
|
||||
return input
|
||||
const host = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^www\./, "")
|
||||
.replace(/\/.*$/, "")
|
||||
|
||||
// The confirmation card is often filled with a company name (for example,
|
||||
// "Zomato") even though research needs a hostname. Preserve explicit TLDs,
|
||||
// and make the common single-label case usable without a failed API round trip.
|
||||
if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)) {
|
||||
return `${host}.com`
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
export function CompanyBrainOnboarding({
|
||||
|
|
@ -306,30 +315,35 @@ function DockedHeader({
|
|||
}) {
|
||||
const brandName = workspaceNameFromDomain(domain) || domain
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="size-8 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<DomainLogo domain={domain} />
|
||||
</div>
|
||||
<span className="text-[14px] font-semibold text-[#fafafa]">
|
||||
{brandName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] font-medium",
|
||||
done ? "text-[#5CD68A]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{done ? "Company Brain ready" : "Building your Company Brain…"}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col md:flex-row md:items-center md:gap-3">
|
||||
<span
|
||||
title={brandName}
|
||||
className="min-w-0 truncate text-[14px] font-semibold text-[#fafafa] md:flex-1"
|
||||
>
|
||||
{brandName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 whitespace-nowrap text-[12px] font-medium",
|
||||
done ? "text-[#5CD68A]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{done ? "Company Brain ready" : "Building your Company Brain…"}
|
||||
</span>
|
||||
</div>
|
||||
{done ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className={cn(
|
||||
"ml-auto rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
|
||||
"ml-auto shrink-0 rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -337,7 +351,7 @@ function DockedHeader({
|
|||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA] ml-auto" />
|
||||
<Loader2 className="ml-auto size-3.5 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
brainConnectorIcon,
|
||||
SlackMark,
|
||||
} from "@/components/brain-connector-icons"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useResearchStatus } from "@/hooks/use-research-status"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
|
||||
|
|
@ -83,7 +83,7 @@ export function ResearchActionRail({
|
|||
onStatsChange?: (stats: ParallelSetupStats) => void
|
||||
}) {
|
||||
const { org } = useAuth()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const router = useRouter()
|
||||
const { events } = useResearchStatus()
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
|
|
@ -448,7 +448,7 @@ export function ResearchActionRail({
|
|||
onConnect={connect}
|
||||
onBrowse={() => {
|
||||
pauseRotation()
|
||||
openSettings("company-brain")
|
||||
router.push("/?view=configure")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function BrainShell({ step, steps, children }: ShellProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
|
||||
"relative min-h-dvh overflow-hidden bg-[#05080D] text-[#FAFAFA]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -46,20 +46,25 @@ export function BrainShell({ step, steps, children }: ShellProps) {
|
|||
}}
|
||||
/>
|
||||
|
||||
<header className="relative z-10 grid grid-cols-[1fr_auto_1fr] items-center px-6 md:px-10 py-4 gap-4">
|
||||
<LogoFull className="h-5 md:h-6 text-[#fafafa] justify-self-start" />
|
||||
<StepIndicator step={step} visibleSteps={visibleSteps} />
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-20 grid grid-cols-[1fr_auto_1fr] items-center gap-4 px-4 py-4 md:px-10">
|
||||
<LogoFull className="h-5 text-[#fafafa] md:h-6" />
|
||||
<div className="hidden justify-center md:flex">
|
||||
<StepIndicator step={step} visibleSteps={visibleSteps} />
|
||||
</div>
|
||||
<span aria-hidden />
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
"relative z-10 flex-1 flex justify-center px-4 md:px-10 py-6 md:py-10",
|
||||
"relative z-10 flex min-h-dvh flex-col items-center px-4 md:px-10",
|
||||
step === "sources"
|
||||
? "items-start overflow-y-auto"
|
||||
: "items-center overflow-hidden",
|
||||
? "justify-start overflow-y-auto pt-20 pb-10"
|
||||
: "justify-center overflow-y-auto py-20 md:overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className="mb-5 flex justify-center md:hidden">
|
||||
<StepIndicator step={step} visibleSteps={visibleSteps} />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
|
|
@ -89,7 +94,7 @@ function StepIndicator({
|
|||
const isLast = i === visibleSteps.length - 1
|
||||
return (
|
||||
<div key={s} className="flex items-start">
|
||||
<div className="flex flex-col items-center gap-1.5 min-w-[58px]">
|
||||
<div className="flex min-w-[44px] flex-col items-center gap-1.5 md:min-w-[58px]">
|
||||
<StepDot done={isDone} current={isCurrent} />
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -105,7 +110,7 @@ function StepIndicator({
|
|||
</span>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="flex-1 h-px min-w-[28px] mt-[5px]">
|
||||
<div className="mt-[5px] h-px min-w-[18px] flex-1 md:min-w-[28px]">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
|
|
|
|||
|
|
@ -474,9 +474,9 @@ export function StepSources({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] pb-10">
|
||||
<section className="relative min-h-[calc(100dvh-136px)] py-4">
|
||||
<div className="absolute inset-x-0 top-[46%] -translate-y-1/2">
|
||||
<div className="mx-auto w-full max-w-[1400px] pb-28 md:pb-10">
|
||||
<section className="relative min-h-[calc(100dvh-136px)] py-3 md:py-4">
|
||||
<div className="md:absolute md:inset-x-0 md:top-[46%] md:-translate-y-1/2">
|
||||
<div className="mb-6 px-1">
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -494,7 +494,7 @@ export function StepSources({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="grid gap-3 md:grid-cols-3 md:gap-4">
|
||||
{mode === "personal" ? (
|
||||
<>
|
||||
<SourceCard
|
||||
|
|
@ -565,7 +565,7 @@ export function StepSources({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 right-0 top-full mt-6 px-1">
|
||||
<div className="mt-4 px-1 md:absolute md:left-0 md:right-0 md:top-full md:mt-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -579,7 +579,7 @@ export function StepSources({
|
|||
)}
|
||||
/>
|
||||
More integrations
|
||||
<span className="text-[#525D6E]">
|
||||
<span className="hidden text-[#525D6E] sm:inline">
|
||||
(Gmail, GitHub, OneDrive…)
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -591,7 +591,7 @@ export function StepSources({
|
|||
</div>
|
||||
|
||||
{moreOpen ? (
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-4">
|
||||
<div className="mt-4 grid gap-3 md:mt-10 md:grid-cols-3 md:gap-4">
|
||||
<MoreSourcesGrid
|
||||
mode={mode}
|
||||
values={values}
|
||||
|
|
@ -929,22 +929,21 @@ function SourceActions({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-6 flex items-center justify-end gap-[22px] px-1",
|
||||
"mt-4 flex items-center justify-end gap-3 px-1 md:mt-6 md:gap-[22px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
|
||||
className="hidden text-[#737373] font-medium text-[14px] transition-colors hover:text-[#999] sm:inline"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
disabled={connectedCount === 0}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
className="rounded-full px-4 py-2 text-[13px] font-medium text-[#fafafa] md:px-5 md:py-[10px]"
|
||||
>
|
||||
Continue
|
||||
{connectedCount > 0 && (
|
||||
|
|
@ -1276,16 +1275,16 @@ function SourceCard({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-[190px] rounded-[18px] p-5 transition-colors bg-[#1B1F24] flex flex-col",
|
||||
"min-h-[146px] rounded-[18px] bg-[#1B1F24] p-3.5 transition-colors flex flex-col md:min-h-[190px] md:p-5",
|
||||
isDone && "ring-1 ring-[#2261CA33]",
|
||||
)}
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
|
||||
<div className="grid grid-cols-[40px_minmax(0,1fr)_auto] items-start gap-2.5 md:grid-cols-[48px_minmax(0,1fr)_auto] md:gap-3">
|
||||
<div className="pt-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
"size-12 rounded-[12px] flex items-center justify-center shrink-0",
|
||||
"size-10 rounded-[12px] flex items-center justify-center shrink-0 md:size-12",
|
||||
bareIconFrame
|
||||
? "bg-transparent border border-transparent"
|
||||
: "bg-[#14161A] border border-[rgba(82,89,102,0.2)]",
|
||||
|
|
@ -1296,10 +1295,10 @@ function SourceCard({
|
|||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 pr-1">
|
||||
<p className="text-[15px] leading-tight font-semibold text-[#fafafa]">
|
||||
<p className="text-[14px] leading-tight font-semibold text-[#fafafa] md:text-[15px]">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] mt-1 leading-[1.35] font-medium">
|
||||
<p className="mt-0.5 text-[11.5px] text-[#737373] leading-[1.3] font-medium md:mt-1 md:text-[12px] md:leading-[1.35]">
|
||||
{blurb}
|
||||
</p>
|
||||
{headerNote}
|
||||
|
|
@ -1320,7 +1319,7 @@ function SourceCard({
|
|||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full h-9 px-4 text-[13px] font-medium text-[#fafafa] gap-1.5",
|
||||
"h-8 shrink-0 rounded-full px-3 text-[12px] font-medium text-[#fafafa] gap-1.5 md:h-9 md:px-4 md:text-[13px]",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
|
|
@ -1336,15 +1335,15 @@ function SourceCard({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-1.5">
|
||||
<ul className="mt-3 space-y-1 md:mt-4 md:space-y-1.5">
|
||||
{perks.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-start gap-2.5 text-[12px] text-[#737373] font-medium leading-[1.5]"
|
||||
className="flex items-start gap-2 text-[11px] text-[#737373] font-medium leading-[1.35] md:gap-2.5 md:text-[12px] md:leading-[1.5]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-1 rounded-full bg-[#525D6E] shrink-0 mt-[7px]"
|
||||
className="size-1 rounded-full bg-[#525D6E] shrink-0 mt-[6px] md:mt-[7px]"
|
||||
/>
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
|
|
@ -1352,7 +1351,7 @@ function SourceCard({
|
|||
</ul>
|
||||
|
||||
{(footerLeft || footerRight) && (
|
||||
<div className="mt-auto pt-4 flex items-end justify-between gap-3">
|
||||
<div className="mt-auto flex items-end justify-between gap-3 pt-3 md:pt-4">
|
||||
<div>{footerLeft}</div>
|
||||
<div className="pb-1.5">{footerRight}</div>
|
||||
</div>
|
||||
|
|
@ -1364,10 +1363,10 @@ function SourceCard({
|
|||
function SpaceChip({ name }: { name: string }) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-[#737373]"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-medium text-[#737373] md:gap-1.5 md:text-[11px]"
|
||||
title={`This source will save into the "${name}" space.`}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
|
||||
<span className="text-[9px] uppercase tracking-[0.08em] text-[#525D6E] md:text-[10px]">
|
||||
Saves to
|
||||
</span>
|
||||
<FolderOpen className="size-3 text-[#737373]" />
|
||||
|
|
|
|||
|
|
@ -144,14 +144,14 @@ export function StepTeam({
|
|||
const count = values.invites.length
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="max-w-2xl mx-auto pb-24 md:pb-0">
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
|
||||
className="rounded-[22px] bg-[#1B1F24] p-5 md:p-8"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-start gap-3 md:gap-4">
|
||||
<div
|
||||
className="size-12 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
|
||||
className="size-11 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0 md:size-12"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<Users className="size-5 text-[#fafafa]" />
|
||||
|
|
@ -159,13 +159,13 @@ export function StepTeam({
|
|||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[20px] font-semibold text-[#fafafa]",
|
||||
"text-[19px] font-semibold text-[#fafafa] md:text-[20px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Invite your team
|
||||
</p>
|
||||
<p className="text-[14px] text-[#737373] mt-1 leading-[1.5] font-medium">
|
||||
<p className="text-[13px] text-[#737373] mt-1 leading-[1.45] font-medium md:text-[14px] md:leading-[1.5]">
|
||||
A brain gets sharper as more people contribute. You can also do
|
||||
this later.
|
||||
</p>
|
||||
|
|
@ -177,9 +177,9 @@ export function StepTeam({
|
|||
e.preventDefault()
|
||||
addInvites(draft)
|
||||
}}
|
||||
className="mt-6 flex gap-2"
|
||||
className="mt-5 flex min-w-0 gap-2 md:mt-6"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Mail className="size-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-[#737373]" />
|
||||
<Input
|
||||
value={draft}
|
||||
|
|
@ -191,8 +191,8 @@ export function StepTeam({
|
|||
addInvites(pasted)
|
||||
}
|
||||
}}
|
||||
placeholder={`alex@${domainOrFallback}, sam@${domainOrFallback}, …`}
|
||||
className={cn(inputClass, "pl-10")}
|
||||
placeholder={`alex@${domainOrFallback}`}
|
||||
className={cn(inputClass, "min-w-0 pl-10")}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -200,19 +200,19 @@ export function StepTeam({
|
|||
type="submit"
|
||||
variant="insideOut"
|
||||
disabled={!draft.trim()}
|
||||
className="rounded-full h-12 px-4 text-[13px] font-medium text-[#fafafa]"
|
||||
className="h-12 shrink-0 rounded-full px-3.5 text-[13px] font-medium text-[#fafafa] md:px-4"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-2">
|
||||
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-1 md:pl-2">
|
||||
Paste multiple emails at once — we'll split them for you.
|
||||
</p>
|
||||
|
||||
{count === 0 ? (
|
||||
<div className="mt-6 rounded-[14px] border border-dashed border-[rgba(82,89,102,0.3)] bg-[#14161A]/40 px-5 py-8 text-center">
|
||||
<div className="mt-5 rounded-[14px] border border-dashed border-[rgba(82,89,102,0.3)] bg-[#14161A]/40 px-4 py-7 text-center md:mt-6 md:px-5 md:py-8">
|
||||
<p className="text-[13px] text-[#737373] font-medium">
|
||||
No invites yet.
|
||||
</p>
|
||||
|
|
@ -242,11 +242,11 @@ export function StepTeam({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5 max-h-[280px] overflow-y-auto scrollbar-thin pr-1">
|
||||
<div className="mt-3 space-y-1.5 max-h-[240px] overflow-y-auto scrollbar-thin pr-1 md:max-h-[280px]">
|
||||
{values.invites.map((inv) => (
|
||||
<div
|
||||
key={inv.email}
|
||||
className="flex items-center gap-3 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-3 py-2"
|
||||
className="flex flex-wrap items-center gap-2 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-3 py-2 sm:flex-nowrap md:gap-3"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<div className="size-7 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] flex items-center justify-center shrink-0">
|
||||
|
|
@ -263,7 +263,7 @@ export function StepTeam({
|
|||
setRole(inv.email, r as "admin" | "member")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-7 bg-transparent border border-[rgba(82,89,102,0.2)] rounded-full text-[#A1A1AA] text-[11px] font-medium px-3 shadow-none focus:ring-0">
|
||||
<SelectTrigger className="h-7 w-24 bg-transparent border border-[rgba(82,89,102,0.2)] rounded-full text-[#A1A1AA] text-[11px] font-medium px-3 shadow-none focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#14161A] border-[rgba(82,89,102,0.2)] rounded-[12px]">
|
||||
|
|
@ -295,12 +295,12 @@ export function StepTeam({
|
|||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
|
||||
<div className="mt-5 flex items-center justify-end gap-3 border-t border-white/[0.06] pt-4 md:mt-6 md:gap-[22px] md:pt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip ?? onContinue}
|
||||
disabled={submitting}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
|
||||
className="text-[#737373] font-medium text-[13px] transition-colors hover:text-[#999] disabled:opacity-50 md:text-[14px]"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
|
|
@ -308,7 +308,7 @@ export function StepTeam({
|
|||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
disabled={submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
className="rounded-full px-4 py-2 text-[13px] font-medium text-[#fafafa] md:px-5 md:py-[10px]"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"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"
|
||||
|
|
@ -9,26 +8,19 @@ import {
|
|||
CalendarClock,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
FilePlus2,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Info,
|
||||
LifeBuoy,
|
||||
ListTodo,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageCircleQuestion,
|
||||
Plus,
|
||||
Radar,
|
||||
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,
|
||||
|
|
@ -167,7 +159,7 @@ const PRESETS: Preset[] = [
|
|||
{
|
||||
id: "standup",
|
||||
category: "team",
|
||||
label: "Daily standup",
|
||||
label: "Morning checkup",
|
||||
description:
|
||||
"Shipped work, decisions, blockers & open questions from the last 24h.",
|
||||
icon: CalendarClock,
|
||||
|
|
@ -179,7 +171,7 @@ const PRESETS: Preset[] = [
|
|||
{
|
||||
id: "weekly-recap",
|
||||
category: "team",
|
||||
label: "Weekly team recap",
|
||||
label: "Company progress",
|
||||
description:
|
||||
"Decisions, shipped work & unresolved threads from the past week.",
|
||||
icon: CalendarClock,
|
||||
|
|
@ -236,6 +228,18 @@ const PRESETS: Preset[] = [
|
|||
frequency: "daily",
|
||||
time: "09:00",
|
||||
},
|
||||
{
|
||||
id: "competitor-check",
|
||||
category: "product",
|
||||
label: "Competitor check",
|
||||
description: "What competitors shipped, announced, or changed this week.",
|
||||
icon: Radar,
|
||||
prompt:
|
||||
"Check what our competitors shipped, announced, or changed recently — launches, pricing changes, and anything the team should react to.",
|
||||
frequency: "weekly",
|
||||
weekday: 1,
|
||||
time: "09:00",
|
||||
},
|
||||
{
|
||||
id: "release-notes",
|
||||
category: "product",
|
||||
|
|
@ -273,38 +277,12 @@ function sortPresets(connected: Set<string>): Preset[] {
|
|||
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<Category>()
|
||||
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 (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
|
|
@ -318,9 +296,6 @@ 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,
|
||||
|
|
@ -830,7 +805,7 @@ function AutomationRow({
|
|||
)
|
||||
}
|
||||
|
||||
function PresetTile({
|
||||
function PresetCard({
|
||||
preset,
|
||||
onPick,
|
||||
}: {
|
||||
|
|
@ -844,19 +819,31 @@ function PresetTile({
|
|||
onClick={onPick}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-center gap-3 rounded-[10px] border border-white/[0.06] bg-[#14161A] px-3 py-2.5 text-left transition-colors hover:border-white/[0.12] hover:bg-[#171A1F]",
|
||||
"flex min-w-0 cursor-pointer flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 text-left",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors hover:bg-[#171A1F]",
|
||||
)}
|
||||
title={preset.description}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-[#9A9A9A]" />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[13px] font-medium text-[#FAFAFA]">
|
||||
{preset.label}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-[#6B6B6B]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
<Icon className="size-4 text-[#9A9A9A]" />
|
||||
</div>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p className="truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]">
|
||||
{preset.label}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]">
|
||||
{preset.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<span className="text-[12px] font-medium text-[#737373]">
|
||||
{cadenceLabel(preset)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[12px] font-medium text-[#8B929E]">
|
||||
Use template
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -873,17 +860,6 @@ export default function CompanyBrainAutomations() {
|
|||
const removeDraft = (key: number) =>
|
||||
setDrafts((d) => d.filter((x) => x.key !== key))
|
||||
|
||||
const roleQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "role"],
|
||||
queryFn: async () =>
|
||||
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||
staleTime: 60_000,
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
const isAdmin = ["owner", "admin"].includes(
|
||||
(roleQuery.data ?? "").toLowerCase(),
|
||||
)
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "list", org?.id],
|
||||
queryFn: async () => {
|
||||
|
|
@ -935,71 +911,12 @@ export default function CompanyBrainAutomations() {
|
|||
queryKey: ["company-brain-automations", "list"],
|
||||
})
|
||||
}
|
||||
const showGallery = automations.length === 0 && drafts.length === 0
|
||||
const usedTitles = new Set(automations.map((a) => a.title))
|
||||
const availablePresets = presets.filter((p) => !usedTitles.has(p.label))
|
||||
const hasList = automations.length > 0 || drafts.length > 0
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 px-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle>Channel automations</SectionTitle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-full border border-white/10 px-3 text-[12px] font-medium text-[#9A9A9A] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New automation
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"min-w-[240px] rounded-xl border-white/[0.08] bg-[#1B1F24] p-1.5",
|
||||
)}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => addDraft(emptyDraft())}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<FilePlus2 className="size-4 text-[#737373]" />
|
||||
Blank automation
|
||||
</DropdownMenuItem>
|
||||
{presets.map((p) => {
|
||||
const Icon = p.icon
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
onClick={() => addDraft(presetToDraft(p))}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Icon className="size-4 text-[#737373]" />
|
||||
{p.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-center gap-1.5 text-[12px] text-[#6B6B6B]",
|
||||
)}
|
||||
>
|
||||
<Lock className="size-3 shrink-0" />
|
||||
Read-only scheduled summaries posted to a channel.{" "}
|
||||
{isAdmin
|
||||
? "You manage all across the org."
|
||||
: "You manage the ones you create."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{automations.map((a) =>
|
||||
openId === a.id ? (
|
||||
|
|
@ -1044,40 +961,37 @@ export default function CompanyBrainAutomations() {
|
|||
/>
|
||||
))}
|
||||
|
||||
{showGallery ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#6B6B6B]")}
|
||||
>
|
||||
Start from a template:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{galleryPresets(presets).map((p) => (
|
||||
<PresetTile
|
||||
key={p.id}
|
||||
preset={p}
|
||||
onPick={() => addDraft(presetToDraft(p))}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDraft(emptyDraft())}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-center justify-center gap-2 rounded-[10px] border border-dashed border-white/[0.1] bg-transparent px-3 py-2.5 text-[13px] text-[#9A9A9A] transition-colors hover:border-white/20 hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Start from scratch
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className={cn(dmSans125ClassName(), "text-[11px] text-[#5A5A5A]")}
|
||||
>
|
||||
More templates in the New automation menu.
|
||||
</p>
|
||||
</div>
|
||||
{hasList ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"pt-2 text-[12px] font-medium text-[#6B6B6B]",
|
||||
)}
|
||||
>
|
||||
Templates
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{availablePresets.map((p) => (
|
||||
<PresetCard
|
||||
key={p.id}
|
||||
preset={p}
|
||||
onPick={() => addDraft(presetToDraft(p))}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDraft(emptyDraft())}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
|
||||
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New automation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Loader2, Lock } from "lucide-react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { ChevronDown, Loader2, Plus, XIcon } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@ui/components/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
|
|
@ -33,7 +45,6 @@ type ConnRow = {
|
|||
userId: string | null
|
||||
}
|
||||
type SlackStatus = { connected: boolean; teamName: string | null }
|
||||
type Scope = "org" | "user"
|
||||
|
||||
function titleCase(s: string) {
|
||||
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
|
|
@ -48,28 +59,20 @@ function slugifyMcpName(value: string) {
|
|||
.slice(0, 63)
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
children,
|
||||
href,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-full border border-[#1E293B] bg-[#0D121A] px-4 h-9",
|
||||
"text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#1E293B]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
const pillLinkClass = cn(
|
||||
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
||||
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
)
|
||||
|
||||
function StatusDot({ connected }: { connected: boolean }) {
|
||||
function ScopeChip({
|
||||
label,
|
||||
connected,
|
||||
}: {
|
||||
label: string
|
||||
connected: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -84,19 +87,22 @@ function StatusDot({ connected }: { connected: boolean }) {
|
|||
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
|
||||
)}
|
||||
/>
|
||||
{connected ? "Connected" : "Not connected"}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const menuItemClass =
|
||||
"gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
|
||||
function AppCard({
|
||||
name,
|
||||
subtitle,
|
||||
icon,
|
||||
connected,
|
||||
canConnect,
|
||||
canDisconnect,
|
||||
lockedHint,
|
||||
userConnected,
|
||||
orgConnected,
|
||||
isAdmin,
|
||||
personalOnly,
|
||||
busy,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
|
|
@ -104,16 +110,20 @@ function AppCard({
|
|||
name: string
|
||||
subtitle: string
|
||||
icon: React.ReactNode
|
||||
connected: boolean
|
||||
canConnect: boolean
|
||||
canDisconnect: boolean
|
||||
lockedHint?: string
|
||||
userConnected: boolean
|
||||
orgConnected: boolean
|
||||
isAdmin: boolean
|
||||
personalOnly?: boolean
|
||||
busy: boolean
|
||||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
onConnect: (shared: boolean) => void
|
||||
onDisconnect: (shared: boolean) => void
|
||||
}) {
|
||||
const anyConnected = userConnected || orgConnected
|
||||
const showOrgChip = !personalOnly && (orgConnected || isAdmin)
|
||||
const adminMenu = isAdmin && !personalOnly
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[152px] min-w-0 flex-col justify-between gap-4 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
{icon}
|
||||
|
|
@ -138,67 +148,154 @@ function AppCard({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<StatusDot connected={connected} />
|
||||
{connected && canDisconnect ? (
|
||||
<PillButton onClick={onDisconnect} disabled={busy}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{personalOnly || !anyConnected ? (
|
||||
<ScopeChip
|
||||
label={userConnected ? "Connected" : "Not connected"}
|
||||
connected={userConnected}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ScopeChip label="You" connected={userConnected} />
|
||||
{showOrgChip ? (
|
||||
<ScopeChip label="Workspace" connected={orgConnected} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{adminMenu ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
||||
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{anyConnected ? "Manage" : "Connect"}
|
||||
<ChevronDown className="size-3.5 text-[#737373]" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"min-w-[220px] rounded-xl border border-white/[0.08] p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className={menuItemClass}
|
||||
onClick={() =>
|
||||
userConnected ? onDisconnect(false) : onConnect(false)
|
||||
}
|
||||
>
|
||||
{userConnected ? "Disconnect my account" : "Connect my account"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={menuItemClass}
|
||||
onClick={() =>
|
||||
orgConnected ? onDisconnect(true) : onConnect(true)
|
||||
}
|
||||
>
|
||||
{orgConnected
|
||||
? "Disconnect workspace"
|
||||
: "Connect for workspace"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : userConnected ? (
|
||||
<PillButton onClick={() => onDisconnect(false)} disabled={busy}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Disconnect
|
||||
</PillButton>
|
||||
) : (
|
||||
!connected &&
|
||||
(canConnect ? (
|
||||
<PillButton onClick={onConnect} disabled={busy}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Connect
|
||||
</PillButton>
|
||||
) : lockedHint ? (
|
||||
<span className="flex items-center gap-1 text-[12px] font-medium text-[#737373]">
|
||||
<Lock className="size-3" />
|
||||
{lockedHint}
|
||||
</span>
|
||||
) : null)
|
||||
) : personalOnly ? null : (
|
||||
<PillButton onClick={() => onConnect(false)} disabled={busy}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Connect
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScopeToggle({
|
||||
scope,
|
||||
onChange,
|
||||
function SlackCard({
|
||||
status,
|
||||
isAdmin,
|
||||
installHref,
|
||||
}: {
|
||||
scope: Scope
|
||||
onChange: (s: Scope) => void
|
||||
status: SlackStatus | null
|
||||
isAdmin: boolean
|
||||
installHref: string
|
||||
}) {
|
||||
const items: { id: Scope; label: string }[] = [
|
||||
{ id: "org", label: "Organization" },
|
||||
{ id: "user", label: "Personal" },
|
||||
]
|
||||
const connected = status?.connected ?? false
|
||||
return (
|
||||
<div className="inline-flex rounded-full border border-[#1E293B] bg-[#0D121A] p-1">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => onChange(it.id)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"rounded-full px-4 h-8 text-[13px] font-medium transition-colors",
|
||||
scope === it.id
|
||||
? "bg-[#1E293B] text-[#FAFAFA]"
|
||||
: "text-[#737373] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
<SlackMark className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Slack
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Messaging
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<ScopeChip
|
||||
label={
|
||||
connected
|
||||
? status?.teamName
|
||||
? `Workspace · ${status.teamName}`
|
||||
: "Workspace"
|
||||
: "Not connected"
|
||||
}
|
||||
connected={connected}
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<a
|
||||
href={installHref}
|
||||
className={cn(dmSans125ClassName(), pillLinkClass)}
|
||||
>
|
||||
{connected ? "Reconnect" : "Connect"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowSkeleton() {
|
||||
return (
|
||||
<div className="min-h-[152px] rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
||||
<div className="space-y-2">
|
||||
|
|
@ -206,7 +303,7 @@ function RowSkeleton() {
|
|||
<div className="h-2.5 w-32 animate-pulse rounded bg-[#1c1f24]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
|
||||
<div className="mt-5 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -219,19 +316,11 @@ export default function CompanyBrainConnections() {
|
|||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<Scope>("user")
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [customName, setCustomName] = useState("")
|
||||
const [customServerUrl, setCustomServerUrl] = useState("")
|
||||
|
||||
const roleQuery = useQuery({
|
||||
queryKey: ["company-brain-connections", "role"],
|
||||
queryFn: async () =>
|
||||
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||
staleTime: 60_000,
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
const role = (roleQuery.data ?? "").toLowerCase()
|
||||
const isAdmin = role === "owner" || role === "admin"
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [catRes, connRes, slackRes] = await Promise.all([
|
||||
|
|
@ -388,8 +477,12 @@ export default function CompanyBrainConnections() {
|
|||
}
|
||||
if (data.authUrl) {
|
||||
window.open(data.authUrl, "_blank", "noopener")
|
||||
setCustomOpen(false)
|
||||
setCustomName("")
|
||||
setCustomServerUrl("")
|
||||
} else if (data.ok) {
|
||||
toast.success(`${slug} connected.`)
|
||||
setCustomOpen(false)
|
||||
setCustomName("")
|
||||
setCustomServerUrl("")
|
||||
await load()
|
||||
|
|
@ -461,43 +554,9 @@ export default function CompanyBrainConnections() {
|
|||
!catalogSlugs.has(row.serverSlug),
|
||||
)
|
||||
: []
|
||||
const shared = scope === "org"
|
||||
const description = shared
|
||||
? "Connected by admins. Used for reads when you haven't connected your own."
|
||||
: "Your personal accounts, used for your actions and your reads."
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<ScopeToggle scope={scope} onChange={setScope} />
|
||||
{slackStatus?.connected && slackStatus.teamName ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"ml-auto text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Slack · {slackStatus.teamName}
|
||||
</p>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<SecondaryButton href={`${BACKEND}/brain/slack/oauth/install`}>
|
||||
<SlackMark className="size-4" />
|
||||
Reconnect Slack
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"px-1 text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{loading ? (
|
||||
<>
|
||||
<RowSkeleton />
|
||||
|
|
@ -506,100 +565,128 @@ export default function CompanyBrainConnections() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{!shared && isStaff ? (
|
||||
<form
|
||||
onSubmit={connectCustom}
|
||||
className="flex min-h-[152px] min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Custom MCP server
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 line-clamp-2 text-[12px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Add a personal OAuth MCP server by URL.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={customName}
|
||||
onChange={(event) => setCustomName(event.target.value)}
|
||||
placeholder="Name"
|
||||
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<input
|
||||
value={customServerUrl}
|
||||
onChange={(event) => setCustomServerUrl(event.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<div className="flex justify-end border-[#1E293B]/50 border-t pt-3">
|
||||
<PillButton
|
||||
type="submit"
|
||||
disabled={busy?.startsWith("custom:") ?? false}
|
||||
>
|
||||
{busy?.startsWith("custom:") && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
Connect
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<SlackCard
|
||||
status={slackStatus}
|
||||
isAdmin={isAdmin}
|
||||
installHref={`${BACKEND}/brain/slack/oauth/install`}
|
||||
/>
|
||||
{apps.map((entry) => (
|
||||
<AppCard
|
||||
key={`${scope}-${entry.slug}`}
|
||||
key={entry.slug}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name)}
|
||||
connected={isConnected(entry.slug, shared)}
|
||||
canConnect={shared ? isAdmin : true}
|
||||
canDisconnect={shared ? isAdmin : true}
|
||||
lockedHint={shared ? "Admin only" : undefined}
|
||||
busy={busy === `${entry.slug}:${scope}`}
|
||||
onConnect={() => connect(entry, shared)}
|
||||
onDisconnect={() => disconnect(entry, shared)}
|
||||
userConnected={isConnected(entry.slug, false)}
|
||||
orgConnected={isConnected(entry.slug, true)}
|
||||
isAdmin={isAdmin}
|
||||
busy={busy?.startsWith(`${entry.slug}:`) ?? false}
|
||||
onConnect={(shared) => connect(entry, shared)}
|
||||
onDisconnect={(shared) => disconnect(entry, shared)}
|
||||
/>
|
||||
))}
|
||||
{!shared &&
|
||||
customRows.map((row) => (
|
||||
<AppCard
|
||||
key={`custom-${row.serverSlug}`}
|
||||
name={titleCase(row.serverSlug.replace(/-/g, " "))}
|
||||
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
|
||||
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
|
||||
connected
|
||||
canConnect={false}
|
||||
canDisconnect
|
||||
busy={busy === `${row.serverSlug}:user`}
|
||||
onConnect={() => {}}
|
||||
onDisconnect={() =>
|
||||
disconnect(
|
||||
{
|
||||
slug: row.serverSlug,
|
||||
name: titleCase(row.serverSlug.replace(/-/g, " ")),
|
||||
category: "Custom OAuth MCP",
|
||||
authType: "oauth",
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{customRows.map((row) => (
|
||||
<AppCard
|
||||
key={`custom-${row.serverSlug}`}
|
||||
name={titleCase(row.serverSlug.replace(/-/g, " "))}
|
||||
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
|
||||
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
|
||||
userConnected
|
||||
orgConnected={false}
|
||||
isAdmin={false}
|
||||
personalOnly
|
||||
busy={busy === `${row.serverSlug}:user`}
|
||||
onConnect={() => {}}
|
||||
onDisconnect={() =>
|
||||
disconnect(
|
||||
{
|
||||
slug: row.serverSlug,
|
||||
name: titleCase(row.serverSlug.replace(/-/g, " ")),
|
||||
category: "Custom OAuth MCP",
|
||||
authType: "oauth",
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{isStaff ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomOpen(true)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
|
||||
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add custom MCP
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={customOpen} onOpenChange={setCustomOpen}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[440px]! flex flex-col gap-4 rounded-[22px] border-none bg-[#1B1F24] p-4",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<DialogHeader className="flex-1 space-y-1 pl-1">
|
||||
<DialogTitle className="font-semibold text-[#FAFAFA]">
|
||||
Custom MCP server
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] font-medium leading-[1.35] text-[#737373]">
|
||||
Add a personal OAuth MCP server by URL.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<DialogPrimitive.Close
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A] transition-opacity hover:opacity-100 focus:outline-hidden"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4 text-[#737373]" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<form onSubmit={connectCustom} className="flex flex-col gap-2">
|
||||
<input
|
||||
value={customName}
|
||||
onChange={(event) => setCustomName(event.target.value)}
|
||||
placeholder="Name"
|
||||
className="h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<input
|
||||
value={customServerUrl}
|
||||
onChange={(event) => setCustomServerUrl(event.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
className="h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<div className="flex justify-end pt-2">
|
||||
<PillButton
|
||||
type="submit"
|
||||
disabled={busy?.startsWith("custom:") ?? false}
|
||||
>
|
||||
{busy?.startsWith("custom:") && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
Connect
|
||||
</PillButton>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
537
apps/web/components/settings/company-brain-models.tsx
Normal file
537
apps/web/components/settings/company-brain-models.tsx
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Check, ChevronDown, Loader2, Lock } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select"
|
||||
import {
|
||||
type BrainModelConfig,
|
||||
type BrainModelRole,
|
||||
type BrainReasoningEffort,
|
||||
type BrainReasoningKey,
|
||||
useBrainModels,
|
||||
useUpdateBrainModels,
|
||||
} from "@/hooks/use-brain-models"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { PillButton } from "../integrations/install-steps"
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
"claude-sonnet-5": "Sonnet 5",
|
||||
"claude-opus-4.8": "Opus 4.8",
|
||||
"claude-sonnet-4.6": "Sonnet 4.6",
|
||||
"claude-haiku-4.5": "Haiku 4.5",
|
||||
"grok-4.5": "Grok 4.5",
|
||||
"grok-4.3": "Grok 4.3",
|
||||
"grok-4-fast": "Grok 4 Fast",
|
||||
"gpt-5.6": "GPT-5.6",
|
||||
"gpt-5.5": "GPT-5.5",
|
||||
}
|
||||
|
||||
const labelFor = (id: string) => MODEL_LABELS[id] ?? id
|
||||
|
||||
// One-line personality tags so non-experts can tell models apart.
|
||||
const MODEL_TAGS: Record<string, string> = {
|
||||
"claude-sonnet-5": "balanced",
|
||||
"claude-opus-4.8": "most capable, slower",
|
||||
"claude-sonnet-4.6": "balanced",
|
||||
"claude-haiku-4.5": "fast and light",
|
||||
"grok-4.5": "sharp all-rounder",
|
||||
"grok-4.3": "capable",
|
||||
"grok-4-fast": "fastest",
|
||||
"gpt-5.6": "capable",
|
||||
"gpt-5.5": "capable",
|
||||
}
|
||||
|
||||
const EFFORT_LABELS: Record<BrainReasoningEffort, string> = {
|
||||
low: "Low",
|
||||
medium: "Medium",
|
||||
high: "High",
|
||||
xhigh: "Extra high",
|
||||
}
|
||||
|
||||
const ROWS: {
|
||||
role: BrainModelRole
|
||||
effortKey: BrainReasoningKey
|
||||
title: string
|
||||
help: string
|
||||
effortHelp: string
|
||||
}[] = [
|
||||
{
|
||||
role: "main",
|
||||
effortKey: "mainEffort",
|
||||
title: "Answers",
|
||||
help: "Writes the replies your brain sends in Slack.",
|
||||
effortHelp: "Deeper thinking gives better answers but takes longer.",
|
||||
},
|
||||
{
|
||||
role: "triage",
|
||||
effortKey: "triageEffort",
|
||||
title: "When to reply",
|
||||
help: "Decides whether and how the brain responds to a message.",
|
||||
effortHelp:
|
||||
"Deeper thinking routes messages more carefully but takes longer.",
|
||||
},
|
||||
{
|
||||
role: "research",
|
||||
effortKey: "researchEffort",
|
||||
title: "Web research",
|
||||
help: "Looks things up on the web when researching your company.",
|
||||
effortHelp: "Deeper research per web search, at the cost of speed.",
|
||||
},
|
||||
]
|
||||
|
||||
type FullConfig = Required<BrainModelConfig>
|
||||
|
||||
type PresetDef = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
build: (
|
||||
defaults: BrainModelConfig,
|
||||
choices: { main: string[] } & Partial<
|
||||
Record<BrainReasoningKey, BrainReasoningEffort[]>
|
||||
>,
|
||||
) => FullConfig
|
||||
}
|
||||
|
||||
const pickEffort = (
|
||||
options: BrainReasoningEffort[] | undefined,
|
||||
wanted: BrainReasoningEffort,
|
||||
fallback: BrainReasoningEffort,
|
||||
): BrainReasoningEffort =>
|
||||
!options || options.length === 0 || options.includes(wanted)
|
||||
? wanted
|
||||
: fallback
|
||||
|
||||
const PRESETS: PresetDef[] = [
|
||||
{
|
||||
id: "fast",
|
||||
label: "Fastest",
|
||||
description: "Snappy replies, lighter on credits. Best for quick lookups.",
|
||||
build: (defaults, choices) => ({
|
||||
main: choices.main.includes("grok-4-fast")
|
||||
? "grok-4-fast"
|
||||
: defaults.main,
|
||||
triage: defaults.triage,
|
||||
research: defaults.research,
|
||||
mainEffort: pickEffort(choices.mainEffort, "low", "low"),
|
||||
triageEffort: pickEffort(choices.triageEffort, "low", "low"),
|
||||
researchEffort: pickEffort(choices.researchEffort, "low", "low"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "balanced",
|
||||
label: "Balanced",
|
||||
description: "Our recommended mix of speed and answer quality.",
|
||||
build: (defaults) => ({
|
||||
main: defaults.main,
|
||||
triage: defaults.triage,
|
||||
research: defaults.research,
|
||||
mainEffort: defaults.mainEffort ?? "high",
|
||||
triageEffort: defaults.triageEffort ?? "low",
|
||||
researchEffort: defaults.researchEffort ?? "high",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "thorough",
|
||||
label: "Most thorough",
|
||||
description: "Deepest answers and research. Slower, uses more credits.",
|
||||
build: (defaults, choices) => ({
|
||||
main: defaults.main,
|
||||
triage: defaults.triage,
|
||||
research: defaults.research,
|
||||
mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"),
|
||||
triageEffort: pickEffort(choices.triageEffort, "medium", "low"),
|
||||
researchEffort: pickEffort(choices.researchEffort, "xhigh", "high"),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const CONFIG_KEYS = [
|
||||
"main",
|
||||
"triage",
|
||||
"research",
|
||||
"mainEffort",
|
||||
"triageEffort",
|
||||
"researchEffort",
|
||||
] as const
|
||||
|
||||
const extraHighIsBounded = (model: string): boolean =>
|
||||
model.startsWith("grok-") || model.startsWith("gpt-")
|
||||
|
||||
const controlClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
|
||||
)
|
||||
const fieldLabel = cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] font-medium uppercase tracking-[0.06em] text-[#5B6675]",
|
||||
)
|
||||
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"
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompanyBrainModels({
|
||||
showHeading = true,
|
||||
}: {
|
||||
showHeading?: boolean
|
||||
}) {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
|
||||
const modelsQuery = useBrainModels(isCompanyBrain)
|
||||
const update = useUpdateBrainModels()
|
||||
|
||||
const [draft, setDraft] = useState<Partial<BrainModelConfig>>({})
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const resolved = modelsQuery.data?.resolved
|
||||
const defaults = modelsQuery.data?.defaults
|
||||
const choices = modelsQuery.data?.choices
|
||||
|
||||
const valueFor = (role: BrainModelRole): string =>
|
||||
draft[role] ?? resolved?.[role] ?? ""
|
||||
const effortFor = (key: BrainReasoningKey): BrainReasoningEffort | "" =>
|
||||
draft[key] ?? resolved?.[key] ?? defaults?.[key] ?? ""
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!resolved) return false
|
||||
return ROWS.some(({ role, effortKey }) => {
|
||||
const modelChanged =
|
||||
draft[role] !== undefined && draft[role] !== resolved[role]
|
||||
const effortChanged =
|
||||
draft[effortKey] !== undefined &&
|
||||
draft[effortKey] !== resolved[effortKey]
|
||||
return modelChanged || effortChanged
|
||||
})
|
||||
}, [draft, resolved])
|
||||
|
||||
const presets = useMemo(() => {
|
||||
if (!defaults || !choices) return []
|
||||
return PRESETS.map((p) => ({ ...p, config: p.build(defaults, choices) }))
|
||||
}, [defaults, choices])
|
||||
|
||||
// Preset whose full config matches what's currently on screen (draft over saved).
|
||||
const activePresetId = useMemo(() => {
|
||||
if (!resolved) return null
|
||||
const current: Record<string, string | undefined> = {}
|
||||
for (const key of CONFIG_KEYS) {
|
||||
current[key] = draft[key] ?? resolved[key] ?? defaults?.[key]
|
||||
}
|
||||
return (
|
||||
presets.find((p) =>
|
||||
CONFIG_KEYS.every((key) => current[key] === p.config[key]),
|
||||
)?.id ?? null
|
||||
)
|
||||
}, [presets, draft, resolved, defaults])
|
||||
|
||||
if (!isCompanyBrain) return null
|
||||
|
||||
const disabled = !isAdmin || modelsQuery.isLoading || update.isPending
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 px-1">
|
||||
{showHeading ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SectionTitle>Models</SectionTitle>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]")}
|
||||
>
|
||||
Choose which models Company Brain uses. Applies to this organization
|
||||
only.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{modelsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-[13px] text-[#9A9A9A]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
) : modelsQuery.isError ? (
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-red-400")}>
|
||||
Couldn't load models.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{presets.map((preset) => {
|
||||
const isActive = preset.id === activePresetId
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => {
|
||||
if (!resolved) return
|
||||
setDraft({ ...preset.config })
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-w-0 cursor-pointer flex-col gap-1.5 rounded-xl p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
isActive
|
||||
? "bg-[#10161f] ring-1 ring-[#2261CA]/45"
|
||||
: "hover:bg-[#171A1F]",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]">
|
||||
{preset.label}
|
||||
{preset.id === "balanced" ? (
|
||||
<span className="ml-1.5 text-[11px] font-medium text-[#737B87]">
|
||||
Recommended
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check className="size-4 shrink-0 text-[#6BB0FF]" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
{preset.description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex w-fit cursor-pointer items-center gap-1.5 rounded-full px-1 py-1 text-[12px] font-medium text-[#8B929E] transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 transition-transform",
|
||||
(advancedOpen || activePresetId === null) && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
Advanced
|
||||
{activePresetId === null ? (
|
||||
<span className="text-[11px] text-[#5F6673]">
|
||||
· custom settings in use
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{advancedOpen || activePresetId === null ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ROWS.map(({ role, effortKey, title, help, effortHelp }) => {
|
||||
const options = choices?.[role] ?? []
|
||||
const current = valueFor(role)
|
||||
const effortOptions = choices?.[effortKey] ?? []
|
||||
const currentEffort = effortFor(effortKey)
|
||||
const isDefault =
|
||||
defaults?.[role] === current &&
|
||||
(effortOptions.length === 0 ||
|
||||
defaults?.[effortKey] === currentEffort)
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex min-w-0 flex-col gap-5 rounded-xl bg-[#14161A] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1.5 min-h-[2lh] text-[12px] font-medium leading-[1.55] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{help}
|
||||
</p>
|
||||
</div>
|
||||
{isDefault ? (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"shrink-0 rounded-full border border-white/[0.08] px-2 py-0.5 text-[10px] font-medium text-[#737B87]",
|
||||
)}
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={fieldLabel}>Model</span>
|
||||
<Select
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({ ...d, [role]: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={controlClass}>
|
||||
<SelectValue placeholder="Select a model…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={selectContentClass}>
|
||||
{options.map((id) => (
|
||||
<SelectItem
|
||||
key={id}
|
||||
value={id}
|
||||
className={selectItemClass}
|
||||
>
|
||||
{labelFor(id)}
|
||||
{MODEL_TAGS[id] ? (
|
||||
<span className="text-[#737B87]">
|
||||
{" "}
|
||||
· {MODEL_TAGS[id]}
|
||||
</span>
|
||||
) : null}
|
||||
{defaults?.[role] === id ? " (default)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{effortOptions.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={fieldLabel}>Thinking depth</span>
|
||||
<div className="flex w-full items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
|
||||
{effortOptions.map((effort) => {
|
||||
const isOn = currentEffort === effort
|
||||
return (
|
||||
<button
|
||||
key={effort}
|
||||
type="button"
|
||||
aria-pressed={isOn}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
setDraft((currentDraft) => ({
|
||||
...currentDraft,
|
||||
[effortKey]: effort,
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-7 min-w-0 flex-1 cursor-pointer rounded-full px-1 text-[11.5px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isOn
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#8B929E] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{EFFORT_LABELS[effort]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-1.5 text-[10px] font-medium text-[#4A5260]">
|
||||
<span>Faster</span>
|
||||
<span>Smarter</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] leading-[1.5] text-[#5F6673]",
|
||||
)}
|
||||
>
|
||||
{effortHelp}
|
||||
</p>
|
||||
{currentEffort === "xhigh" &&
|
||||
extraHighIsBounded(current) ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] text-amber-300/80",
|
||||
)}
|
||||
>
|
||||
Extra high maps to High for this model provider.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isAdmin ? (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[#737373]">
|
||||
<Lock className="size-3.5" />
|
||||
Only organization admins can change these.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{dirty ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft({})}
|
||||
disabled={update.isPending}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 rounded-full px-3 text-[13px] font-medium text-[#8B929E] transition-colors hover:text-[#FAFAFA] disabled:opacity-45",
|
||||
)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
) : null}
|
||||
<PillButton
|
||||
disabled={disabled || !dirty}
|
||||
onClick={() => {
|
||||
const patch: Partial<BrainModelConfig> = {}
|
||||
for (const { role, effortKey } of ROWS) {
|
||||
if (draft[role] && draft[role] !== resolved?.[role]) {
|
||||
patch[role] = draft[role]
|
||||
}
|
||||
if (
|
||||
draft[effortKey] &&
|
||||
draft[effortKey] !== resolved?.[effortKey]
|
||||
) {
|
||||
patch[effortKey] = draft[effortKey]
|
||||
}
|
||||
}
|
||||
update.mutate(patch, { onSuccess: () => setDraft({}) })
|
||||
}}
|
||||
>
|
||||
{update.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</PillButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,9 +10,6 @@ import Account from "@/components/settings/account"
|
|||
import Billing from "@/components/settings/billing"
|
||||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
|
||||
import Proactiveness from "@/components/settings/proactiveness"
|
||||
import Support from "@/components/settings/support"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
|
@ -54,8 +51,6 @@ export const TABS = [
|
|||
"billing",
|
||||
"integrations",
|
||||
"connections",
|
||||
"company-brain",
|
||||
"proactiveness",
|
||||
"support",
|
||||
] as const
|
||||
export type SettingsTab = (typeof TABS)[number]
|
||||
|
|
@ -92,18 +87,6 @@ const NAV_ITEMS: NavItem[] = [
|
|||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "company-brain",
|
||||
label: "Company Brain",
|
||||
description: "Connect apps to your brain — org and personal",
|
||||
icon: <Building2 className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "proactiveness",
|
||||
label: "Proactiveness",
|
||||
description: "Scheduled digests and unprompted actions",
|
||||
icon: <ProactivenessIcon className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
|
|
@ -166,7 +149,7 @@ export function SettingsContent({
|
|||
const { user, org, organizations, setActiveOrg, clearActiveOrg } = useAuth()
|
||||
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
// Company Brain orgs manage tools inside Company Brain; hide the generic tabs.
|
||||
// Company Brain orgs manage tools in the Configure view; hide the generic tabs.
|
||||
const navItems = isCompanyBrain
|
||||
? NAV_ITEMS.filter(
|
||||
(item) => item.id !== "integrations" && item.id !== "connections",
|
||||
|
|
@ -498,8 +481,6 @@ export function SettingsContent({
|
|||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "company-brain" && <CompanyBrainConnections />}
|
||||
{activeTab === "proactiveness" && <Proactiveness />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ import { useRouter } from "next/navigation"
|
|||
import {
|
||||
LogOut,
|
||||
Settings,
|
||||
Settings2,
|
||||
RotateCcw,
|
||||
HelpCircle,
|
||||
LifeBuoy,
|
||||
Building2,
|
||||
Sun,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
|
@ -26,7 +26,6 @@ import { dmSansClassName } from "@/lib/fonts"
|
|||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
|
||||
|
|
@ -165,20 +164,13 @@ export function UserProfileMenu({
|
|||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openSettings("company-brain")}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<Building2 className="size-4 text-[#737373]" />
|
||||
Company Brain
|
||||
</DropdownMenuItem>
|
||||
{isCompanyBrain ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => openSettings("proactiveness")}
|
||||
onClick={() => void setViewMode("configure")}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<ProactivenessIcon className="size-4 text-[#737373]" />
|
||||
Proactiveness
|
||||
<Settings2 className="size-4 text-[#737373]" />
|
||||
Configure
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isCompanyBrain ? (
|
||||
|
|
|
|||
68
apps/web/hooks/use-brain-models.ts
Normal file
68
apps/web/hooks/use-brain-models.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const BASE = `${BACKEND}/brain/models`
|
||||
|
||||
export type BrainModelRole = "main" | "triage" | "research"
|
||||
export type BrainReasoningEffort = "low" | "medium" | "high" | "xhigh"
|
||||
export type BrainReasoningKey = "mainEffort" | "triageEffort" | "researchEffort"
|
||||
|
||||
export type BrainModelConfig = Record<BrainModelRole, string> &
|
||||
Partial<Record<BrainReasoningKey, BrainReasoningEffort>>
|
||||
|
||||
export type BrainModelsResponse = {
|
||||
resolved: BrainModelConfig
|
||||
defaults: BrainModelConfig
|
||||
choices: Record<BrainModelRole, string[]> &
|
||||
Partial<Record<BrainReasoningKey, BrainReasoningEffort[]>>
|
||||
}
|
||||
|
||||
export function useBrainModels(enabled: boolean) {
|
||||
const { org } = useAuth()
|
||||
return useQuery({
|
||||
queryKey: ["brain", "models", org?.id],
|
||||
queryFn: async (): Promise<BrainModelsResponse> => {
|
||||
const res = await fetch(`${BASE}/`, { credentials: "include" })
|
||||
if (!res.ok) throw new Error("Failed to load models")
|
||||
return res.json()
|
||||
},
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateBrainModels() {
|
||||
const { org } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (patch: Partial<BrainModelConfig>) => {
|
||||
const res = await fetch(`${BASE}/`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-App-Source": "nova" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
if (res.status === 403)
|
||||
throw new Error("Only admins can change brain models.")
|
||||
if (!res.ok) {
|
||||
const b = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
throw new Error(b.message ?? b.error ?? "Failed to save models")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["brain", "models", org?.id],
|
||||
})
|
||||
toast.success("Brain models saved")
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save models"),
|
||||
})
|
||||
}
|
||||
|
|
@ -390,6 +390,7 @@ export function useDocumentMutations({
|
|||
urls: string[]
|
||||
project: string
|
||||
}): Promise<{ success: number; failed: number }> => {
|
||||
const entityContext = await resolveEntityContext(project)
|
||||
let success = 0
|
||||
let failed = 0
|
||||
|
||||
|
|
@ -400,7 +401,7 @@ export function useDocumentMutations({
|
|||
documents: chunk.map((url) => ({
|
||||
content: url,
|
||||
containerTags: [project],
|
||||
entityContext,
|
||||
...(entityContext !== undefined ? { entityContext } : {}),
|
||||
metadata: { sm_source: "consumer" },
|
||||
})),
|
||||
},
|
||||
|
|
|
|||
19
apps/web/hooks/use-org-member-role.ts
Normal file
19
apps/web/hooks/use-org-member-role.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
// Shared active-member role for the current org. Single queryKey so the
|
||||
// company-brain settings sections dedupe the getActiveMember call.
|
||||
export function useOrgMemberRole(enabled = true) {
|
||||
const { org } = useAuth()
|
||||
const query = useQuery({
|
||||
queryKey: ["org", "member-role", org?.id],
|
||||
queryFn: async () =>
|
||||
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||
staleTime: 60_000,
|
||||
enabled: enabled && !!org?.id,
|
||||
})
|
||||
const role = (query.data ?? "").toLowerCase()
|
||||
const isAdmin = role === "owner" || role === "admin"
|
||||
return { role, isAdmin, query }
|
||||
}
|
||||
|
|
@ -222,14 +222,7 @@ export const analytics = {
|
|||
|
||||
// settings / spaces / docs analytics
|
||||
settingsTabChanged: (props: {
|
||||
tab:
|
||||
| "account"
|
||||
| "billing"
|
||||
| "integrations"
|
||||
| "connections"
|
||||
| "company-brain"
|
||||
| "proactiveness"
|
||||
| "support"
|
||||
tab: "account" | "billing" | "integrations" | "connections" | "support"
|
||||
}) => safeCapture("settings_tab_changed", props),
|
||||
|
||||
spaceCreated: () => safeCapture("space_created"),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const viewLiterals = [
|
|||
"graph",
|
||||
"list",
|
||||
"integrations",
|
||||
"configure",
|
||||
"chat",
|
||||
"digests",
|
||||
// Integration sub-views — each card is its own view
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -332,7 +332,7 @@
|
|||
},
|
||||
"packages/tools": {
|
||||
"name": "@supermemory/tools",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.25",
|
||||
"@ai-sdk/openai": "^2.0.23",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ following the same pattern as the built-in Mem0 integration.
|
|||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from agent_framework import BaseContextProvider
|
||||
try:
|
||||
from agent_framework import BaseContextProvider
|
||||
except ImportError:
|
||||
# Renamed in agent-framework-core 1.0.0 stable; the interface is
|
||||
# unchanged (source_id __init__, before_run/after_run hooks with
|
||||
# identical keyword-only signatures).
|
||||
from agent_framework import ContextProvider as BaseContextProvider
|
||||
|
||||
from .connection import AgentSupermemory
|
||||
from .utils import (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@supermemory/tools",
|
||||
"type": "module",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
|||
// can be exercised deterministically without any network access. We only need
|
||||
// `search.execute` to return a single document with known multi-line content.
|
||||
const searchExecute = vi.fn()
|
||||
const addMock = vi.fn()
|
||||
|
||||
vi.mock("supermemory", () => {
|
||||
return {
|
||||
default: class MockSupermemory {
|
||||
search = { execute: searchExecute }
|
||||
add = vi.fn()
|
||||
add = addMock
|
||||
memories = { forget: vi.fn() }
|
||||
},
|
||||
}
|
||||
|
|
@ -83,3 +84,100 @@ describe("ClaudeMemoryTool view_range", () => {
|
|||
expect(result.content).not.toContain("line5")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ClaudeMemoryTool exact-file matching", () => {
|
||||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.mockReset()
|
||||
addMock.mockReset()
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
||||
it("view finds the exact file even when a neighbour ranks first", async () => {
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
{ documentId: "memories_notes_txt", content: FILE_CONTENT },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "view",
|
||||
path: FILE_PATH,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.content).toContain("line1")
|
||||
expect(result.content).not.toContain("backup stuff")
|
||||
})
|
||||
|
||||
it("view reports not-found instead of returning a different file", async () => {
|
||||
// Semantic search can surface a similarly-named file; that must not
|
||||
// be served as the requested one.
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "view",
|
||||
path: FILE_PATH,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("File not found")
|
||||
})
|
||||
|
||||
it("str_replace refuses to modify a different file than requested", async () => {
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "str_replace",
|
||||
path: FILE_PATH,
|
||||
old_str: "backup",
|
||||
new_str: "primary",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(addMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("ClaudeMemoryTool str_replace replacement literalness", () => {
|
||||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.mockReset()
|
||||
addMock.mockReset()
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [{ documentId: "memories_notes_txt", content: FILE_CONTENT }],
|
||||
})
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
||||
it.each([
|
||||
"$&",
|
||||
"$'",
|
||||
"$`",
|
||||
"$$",
|
||||
])("stores %s literally instead of expanding it as a replacement pattern", async (dollarSequence) => {
|
||||
const result = await tool.handleCommand({
|
||||
command: "str_replace",
|
||||
path: FILE_PATH,
|
||||
old_str: "line3",
|
||||
new_str: `price is ${dollarSequence} today`,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(addMock).toHaveBeenCalledTimes(1)
|
||||
const stored = addMock.mock.calls[0]?.[0]?.content as string
|
||||
expect(stored).toContain(`price is ${dollarSequence} today`)
|
||||
expect(stored).not.toContain("line3")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -262,29 +262,21 @@ export class ClaudeMemoryTool {
|
|||
viewRange?: [number, number],
|
||||
): Promise<MemoryResponse> {
|
||||
try {
|
||||
const normalizedId = this.normalizePathToCustomId(filePath)
|
||||
|
||||
const response = await this.client.search.execute({
|
||||
q: normalizedId,
|
||||
containerTags: this.containerTags,
|
||||
limit: 1,
|
||||
includeFullDocs: true,
|
||||
})
|
||||
|
||||
// Try to find exact match by customId
|
||||
const exactMatch = response.results?.find(
|
||||
(r) => r.documentId === normalizedId,
|
||||
)
|
||||
const document = exactMatch || response.results?.[0]
|
||||
|
||||
if (!document) {
|
||||
// Same lookup as every mutating command: limit 5 so the exact
|
||||
// customId match is findable among semantic near-neighbours.
|
||||
// With the old limit of 1, a similarly-named file ranking first
|
||||
// made this return the wrong file's contents as a success.
|
||||
const readResult = await this.getFileDocument(filePath)
|
||||
if (!readResult.success || !readResult.document) {
|
||||
return {
|
||||
success: false,
|
||||
error: `File not found: ${filePath}`,
|
||||
error: readResult.error || `File not found: ${filePath}`,
|
||||
}
|
||||
}
|
||||
|
||||
let content = document.content || ""
|
||||
const document = readResult.document
|
||||
|
||||
let content: string = document.raw || document.content || ""
|
||||
|
||||
// Apply line range if specified
|
||||
if (viewRange) {
|
||||
|
|
@ -393,8 +385,10 @@ export class ClaudeMemoryTool {
|
|||
}
|
||||
}
|
||||
|
||||
// Replace the string
|
||||
const newContent = originalContent.replace(oldStr, newStr)
|
||||
// Replace the string. The function replacer keeps `$` sequences
|
||||
// in the replacement literal — a bare string here would expand
|
||||
// patterns like $&, $', and $` and silently corrupt the file.
|
||||
const newContent = originalContent.replace(oldStr, () => newStr)
|
||||
|
||||
// Update the document
|
||||
const normalizedId = this.normalizePathToCustomId(filePath)
|
||||
|
|
@ -590,11 +584,12 @@ export class ClaudeMemoryTool {
|
|||
includeFullDocs: true,
|
||||
})
|
||||
|
||||
// Try to find exact match by customId first
|
||||
const exactMatch = response.results?.find(
|
||||
// Only accept the exact customId match. Falling back to the top
|
||||
// semantic hit would let callers read — and worse, modify or
|
||||
// delete — a different file than the one they asked for.
|
||||
const document = response.results?.find(
|
||||
(r) => r.documentId === normalizedId,
|
||||
)
|
||||
const document = exactMatch || response.results?.[0]
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ const addMemoryTool = async (
|
|||
* @param options.customId - Optional conversation ID to group messages for contextual memory generation
|
||||
* @param options.verbose - Enable detailed logging of memory operations (default: false)
|
||||
* @param options.mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) (default: "profile")
|
||||
* @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "never")
|
||||
* @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "always")
|
||||
* @returns Object with `wrapClient` and `createClient` methods
|
||||
* @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue