Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 144 additions & 5 deletions apps/web/components/add-document/connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,28 @@ import type { ConnectionResponseSchema } from "@repo/validation/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import { Check, Loader, Trash2, Zap } from "lucide-react"
import { Check, ChevronDown, Loader, Trash2, Zap } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"

type GDriveSyncScope = "scoped" | "full"

const GDRIVE_SCOPE_LABELS: Record<GDriveSyncScope, string> = {
scoped: "Files & Folders",
full: "Whole Drive",
}

type Connection = z.infer<typeof ConnectionResponseSchema>

type ConnectorProvider = "google-drive" | "notion" | "onedrive"
Expand Down Expand Up @@ -54,6 +67,8 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
const isProUser = hasActivePlan(autumn.customer?.products, "api_pro")
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
const [gdriveSyncScope, setGdriveSyncScope] =
useState<GDriveSyncScope>("scoped")
const [isUpgrading, setIsUpgrading] = useState(false)
const [removeDialog, setRemoveDialog] = useState<{
open: boolean
Expand Down Expand Up @@ -114,7 +129,13 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {

// Connect mutation
const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => {
mutationFn: async ({
provider,
syncScope,
}: {
provider: ConnectorProvider
syncScope?: GDriveSyncScope
}) => {
if (!canAddConnection && !isProUser) {
throw new Error(
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
Expand All @@ -126,6 +147,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
body: {
redirectUrl: window.location.href,
containerTags: [selectedProject],
metadata:
provider === "google-drive" && syncScope === "full"
? { syncScope: "full" }
: undefined,
},
})

Expand Down Expand Up @@ -180,7 +205,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {

const handleConnect = (provider: ConnectorProvider) => {
setConnectingProvider(provider)
addConnectionMutation.mutate(provider)
addConnectionMutation.mutate({
provider,
syncScope: provider === "google-drive" ? gdriveSyncScope : undefined,
})
}

const handleDisconnect = (connection: Connection) => {
Expand Down Expand Up @@ -215,7 +243,66 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
const isConnecting =
connectingProvider === provider ||
(addConnectionMutation.isPending &&
addConnectionMutation.variables === provider)
addConnectionMutation.variables?.provider === provider)

if (provider === "google-drive") {
return (
<div
key={provider}
className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] flex overflow-hidden"
>
<button
type="button"
onClick={() => handleConnect("google-drive")}
disabled={
!isProUser ||
isConnecting ||
addConnectionMutation.isPending
}
className="flex-1 py-3 flex items-center justify-center gap-2 hover:bg-[#1B1F24] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon className="w-6 h-6 text-[#737373]" />
<p className="text-[14px] font-medium">{config.title}</p>
{isConnecting && (
<Loader className="h-4 w-4 animate-spin text-[#4BA0FA]" />
)}
</button>
<div className="w-px bg-[rgba(82,89,102,0.4)] self-stretch my-2" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="w-8 flex items-center justify-center hover:bg-[#1B1F24] transition-colors"
>
<ChevronDown className="w-3 h-3 text-[#737373]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{(
Object.entries(GDRIVE_SCOPE_LABELS) as [
GDriveSyncScope,
string,
][]
).map(([scope, label]) => (
<DropdownMenuItem
key={scope}
onClick={(e) => {
e.stopPropagation()
setGdriveSyncScope(scope)
}}
className="flex items-center justify-between"
>
{label}
{gdriveSyncScope === scope && (
<Check className="w-3 h-3 text-[#4BA0FA]" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

return (
<button
Expand Down Expand Up @@ -249,7 +336,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
const isConnecting =
connectingProvider === provider ||
(addConnectionMutation.isPending &&
addConnectionMutation.variables === provider)
addConnectionMutation.variables?.provider === provider)

return (
<div
Expand Down Expand Up @@ -285,6 +372,58 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
>
<Trash2 className="h-4 w-4" />
</Button>
) : provider === "google-drive" ? (
<div className="flex items-center rounded-md overflow-hidden">
<button
type="button"
onClick={() => handleConnect("google-drive")}
disabled={
!isProUser ||
isConnecting ||
addConnectionMutation.isPending
}
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 h-8 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isConnecting ? (
<Loader className="h-4 w-4 animate-spin" />
) : (
"Connect"
)}
</button>
<div className="w-px h-5 bg-black/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 px-1.5 h-8 flex items-center transition-colors"
>
<ChevronDown className="w-3 h-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{(
Object.entries(GDRIVE_SCOPE_LABELS) as [
GDriveSyncScope,
string,
][]
).map(([scope, label]) => (
<DropdownMenuItem
key={scope}
onClick={(e) => {
e.stopPropagation()
setGdriveSyncScope(scope)
}}
className="flex items-center justify-between"
>
{label}
{gdriveSyncScope === scope && (
<Check className="w-3 h-3 text-[#4BA0FA]" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<Button
onClick={() =>
Expand Down
127 changes: 120 additions & 7 deletions apps/web/components/chat/message/agent-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,30 @@ import { useState } from "react"
import type { UIMessage } from "@ai-sdk/react"
import { Streamdown } from "streamdown"
import {
BookOpenIcon,
ChevronDownIcon,
ChevronRightIcon,
Loader2,
SearchIcon,
GlobeIcon,
PlusIcon,
BookOpenIcon,
ClockIcon,
GlobeIcon,
ListIcon,
XCircleIcon,
Loader2,
PlusIcon,
SearchIcon,
TerminalIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"

const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
searchMemories: { label: "Search Memories", icon: SearchIcon },
bash: { label: "Memory", icon: TerminalIcon },
web_search: { label: "Web search", icon: GlobeIcon },
google_search: { label: "Google search", icon: GlobeIcon },
// legacy tool names kept for existing persisted messages
searchMemories: { label: "Search Memories", icon: SearchIcon },
addMemory: { label: "Add Memory", icon: PlusIcon },
fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon },
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
Expand Down Expand Up @@ -95,9 +98,119 @@ function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) {
)
}

function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const isLoading =
part.state === "input-streaming" || part.state === "input-available"
const isDone = part.state === "output-available"
const isError = part.state === "error" || part.state === "output-error"

const cmd =
part.input && typeof part.input === "object" && "cmd" in part.input
? String((part.input as { cmd: string }).cmd)
: undefined

const output =
isDone && part.output && typeof part.output === "object"
? (part.output as { stdout?: string; stderr?: string; exitCode?: number })
: undefined

const hasOutput =
output &&
((output.stdout && output.stdout.length > 0) ||
(output.stderr && output.stderr.length > 0))
const errorText = part.errorText
const hasExpandable = hasOutput || (isError && errorText)

return (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden font-mono">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
expanded && hasExpandable && "border-b border-[#1E2128]",
)}
>
{isLoading ? (
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
) : (
<TerminalIcon
className={cn(
"size-3 shrink-0",
isDone
? output?.exitCode === 0
? "text-emerald-400"
: "text-amber-400"
: isError
? "text-red-400"
: "text-white/50",
)}
/>
)}
<span className={cn("text-white/50", isLoading && "text-blue-400/60")}>
$
</span>
<span
className={cn(
"flex-1 text-left truncate",
isDone
? output?.exitCode === 0
? "text-emerald-300"
: "text-amber-300"
: isLoading
? "text-blue-300"
: isError
? "text-red-300"
: "text-white/70",
)}
>
{cmd ?? "..."}
</span>
{isLoading && (
<span className="text-white/30 shrink-0">running...</span>
)}
{isDone && !hasOutput && (
<span className="text-white/30 shrink-0">done</span>
)}
{isError && <span className="text-red-400/60 shrink-0">error</span>}
{hasExpandable &&
(expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
))}
</button>

{expanded && (hasOutput || (isError && errorText)) && (
<div className="px-3 py-2 space-y-1">
{output?.stdout && output.stdout.length > 0 && (
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-48 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{output.stdout}
</pre>
)}
{output?.stderr && output.stderr.length > 0 && (
<pre className="text-amber-300/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{output.stderr}
</pre>
)}
{isError && errorText && (
<pre className="text-red-300/90 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{errorText}
</pre>
)}
</div>
)}
</div>
)
}

function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const toolName = part.type.replace("tool-", "")
if (toolName === "bash") {
return <BashToolDisplay part={part} />
}
const meta =
TOOL_META[toolName] ??
(isWebSearchToolName(toolName)
Expand Down
Loading