);
+export const ScrollPositionPreserver = () => null;
+
export type ConversationScrollButtonProps = ComponentProps
;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
- const { isAtBottom, scrollToBottom } = useStickToBottomContext();
+ const { isAtBottom, scrollToBottom } = useConversationContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
@@ -199,16 +292,16 @@ export const ConversationScrollButton = ({
!isAtBottom && (
)
);
diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx
index d9453aa1..18af8b0e 100644
--- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx
+++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx
@@ -16,8 +16,8 @@ import {
WrenchIcon,
XCircleIcon,
} from "lucide-react";
-import type { ComponentProps, ReactNode } from "react";
-import { isValidElement } from "react";
+import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
+
const formatToolValue = (value: unknown) => {
if (typeof value === "string") return value;
try {
@@ -37,7 +37,7 @@ const ToolCode = ({
}) => (
@@ -129,64 +129,90 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
/>
);
-export type ToolInputProps = ComponentProps<"div"> & {
+/* ── Tabbed content (Parameters / Result) ────────────────────────── */
+
+export type ToolTabbedContentProps = {
input: ToolUIPart["input"];
-};
-
-export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
-
-);
-
-export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolUIPart["output"];
- errorText: ToolUIPart["errorText"];
+ errorText?: ToolUIPart["errorText"];
};
-export const ToolOutput = ({
- className,
+export const ToolTabbedContent = ({
+ input,
output,
errorText,
- ...props
-}: ToolOutputProps) => {
- if (!(output || errorText)) {
- return null;
- }
+}: ToolTabbedContentProps) => {
+ const [activeTab, setActiveTab] = useState<"parameters" | "result">("parameters");
+ const hasOutput = output != null || !!errorText;
- let Output = {output as ReactNode}
;
-
- if (typeof output === "object" && !isValidElement(output)) {
- Output = ;
- } else if (typeof output === "string") {
- Output = ;
+ let OutputNode: ReactNode = null;
+ if (errorText) {
+ OutputNode = ;
+ } else if (output != null) {
+ if (typeof output === "object" && !isValidElement(output)) {
+ OutputNode = ;
+ } else if (typeof output === "string") {
+ OutputNode = ;
+ } else {
+ OutputNode = {output as ReactNode}
;
+ }
}
return (
-
-
- {errorText ? "Error" : "Result"}
-
-
- {errorText && (
-
- {errorText}
+
+ {/* Tabs */}
+
+
+
+
+
+ {/* Tab content */}
+
+ {activeTab === "parameters" && (
+
+
+
+ )}
+ {activeTab === "result" && (
+
+ {hasOutput ? (
+
+ {OutputNode}
+
+ ) : (
+
(pending...)
+ )}
)}
- {Output}
);
};
+
diff --git a/apps/x/apps/renderer/src/components/bases-view.tsx b/apps/x/apps/renderer/src/components/bases-view.tsx
index 7462f5b5..a68eb360 100644
--- a/apps/x/apps/renderer/src/components/bases-view.tsx
+++ b/apps/x/apps/renderer/src/components/bases-view.tsx
@@ -1,9 +1,16 @@
import * as React from 'react'
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
-import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save } from 'lucide-react'
+import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, Pencil, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from '@/components/ui/context-menu'
import {
Dialog,
DialogContent,
@@ -91,6 +98,12 @@ type BasesViewProps = {
externalSearch?: string
/** Called after the external search has been consumed (applied to internal state). */
onExternalSearchConsumed?: () => void
+ /** Actions for context menu */
+ actions?: {
+ rename: (oldPath: string, newName: string, isDir: boolean) => Promise
+ remove: (path: string) => Promise
+ copyPath: (path: string) => void
+ }
}
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
@@ -140,13 +153,15 @@ function getSortValue(note: NoteEntry, column: string): string | number {
if (column === 'mtimeMs') return note.mtimeMs
const v = note.fields[column]
if (!v) return ''
+ if (column === 'last_update' || column === 'first_met') {
+ const s = Array.isArray(v) ? v[0] ?? '' : v
+ const ms = Date.parse(s)
+ return isNaN(ms) ? 0 : ms
+ }
return Array.isArray(v) ? v[0] ?? '' : v
}
-const isBuiltin = (col: string): col is BuiltinColumn =>
- (BUILTIN_COLUMNS as readonly string[]).includes(col)
-
-export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed }: BasesViewProps) {
+export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed, actions }: BasesViewProps) {
// Build notes instantly from tree
const notes = useMemo(() => {
return collectFiles(tree).map((f) => ({
@@ -655,22 +670,15 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul
{pageNotes.map((note) => (
- onSelectNote(note.path)}
- >
- {visibleColumns.map((col) => (
- |
-
- |
- ))}
-
+ note={note}
+ visibleColumns={visibleColumns}
+ filters={filters}
+ toggleFilter={toggleFilter}
+ onSelectNote={onSelectNote}
+ actions={actions}
+ />
))}
{pageNotes.length === 0 && (
@@ -773,6 +781,17 @@ function CellRenderer({
return {formatDate(note.mtimeMs)}
}
+ // Date-like frontmatter columns — render like Last Modified
+ if (column === 'last_update' || column === 'first_met') {
+ const value = note.fields[column]
+ if (!value || Array.isArray(value)) return null
+ const ms = Date.parse(value)
+ if (!isNaN(ms)) {
+ return {formatDate(ms)}
+ }
+ return {value}
+ }
+
// Frontmatter column
const value = note.fields[column]
if (!value) return null
@@ -804,6 +823,116 @@ function CellRenderer({
)
}
+function NoteRow({
+ note,
+ visibleColumns,
+ filters,
+ toggleFilter,
+ onSelectNote,
+ actions,
+}: {
+ note: NoteEntry
+ visibleColumns: string[]
+ filters: ActiveFilter[]
+ toggleFilter: (category: string, value: string) => void
+ onSelectNote: (path: string) => void
+ actions?: BasesViewProps['actions']
+}) {
+ const [isRenaming, setIsRenaming] = useState(false)
+ const [newName, setNewName] = useState('')
+ const isSubmittingRef = useRef(false)
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ if (isRenaming) inputRef.current?.focus()
+ }, [isRenaming])
+
+ const baseName = note.name
+ const handleRenameSubmit = useCallback(async () => {
+ if (isSubmittingRef.current) return
+ const trimmed = newName.trim()
+ if (!trimmed || trimmed === baseName) {
+ setIsRenaming(false)
+ return
+ }
+ isSubmittingRef.current = true
+ try {
+ await actions?.rename(note.path, trimmed, false)
+ } catch {
+ // ignore
+ }
+ setIsRenaming(false)
+ isSubmittingRef.current = false
+ }, [newName, baseName, actions, note.path])
+
+ const handleCopyPath = useCallback(() => {
+ actions?.copyPath(note.path)
+ }, [actions, note.path])
+
+ const handleDelete = useCallback(() => {
+ void actions?.remove(note.path)
+ }, [actions, note.path])
+
+ const row = (
+ onSelectNote(note.path)}
+ >
+ {visibleColumns.map((col) => (
+ |
+ {col === 'name' && isRenaming ? (
+ setNewName(e.target.value)}
+ onBlur={() => void handleRenameSubmit()}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') void handleRenameSubmit()
+ if (e.key === 'Escape') setIsRenaming(false)
+ }}
+ onClick={(e) => e.stopPropagation()}
+ className="w-full bg-transparent text-sm font-medium outline-none ring-1 ring-ring rounded px-1"
+ />
+ ) : (
+
+ )}
+ |
+ ))}
+
+ )
+
+ if (!actions) return row
+
+ return (
+
+
+ {row}
+
+
+
+
+ Copy Path
+
+
+ { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
+
+ Rename
+
+
+
+ Delete
+
+
+
+ )
+}
+
function CategoryBadge({
category,
value,
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
index 057550b2..03ab3f94 100644
--- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
+++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
@@ -66,10 +66,11 @@ const providerDisplayNames: Record = {
openrouter: 'OpenRouter',
aigateway: 'AI Gateway',
'openai-compatible': 'OpenAI-Compatible',
+ rowboat: 'Rowboat',
}
interface ConfiguredModel {
- flavor: string
+ flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
model: string
apiKey?: string
baseURL?: string
@@ -156,51 +157,103 @@ function ChatInputInner({
const [activeModelKey, setActiveModelKey] = useState('')
const [searchEnabled, setSearchEnabled] = useState(false)
const [searchAvailable, setSearchAvailable] = useState(false)
+ const [isRowboatConnected, setIsRowboatConnected] = useState(false)
- // Load model config from disk (on mount and whenever tab becomes active)
+ // Check Rowboat sign-in state
+ useEffect(() => {
+ window.ipc.invoke('oauth:getState', null).then((result) => {
+ setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
+ }).catch(() => setIsRowboatConnected(false))
+ }, [isActive])
+
+ // Update sign-in state when OAuth events fire
+ useEffect(() => {
+ const cleanup = window.ipc.on('oauth:didConnect', () => {
+ window.ipc.invoke('oauth:getState', null).then((result) => {
+ setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
+ }).catch(() => setIsRowboatConnected(false))
+ })
+ return cleanup
+ }, [])
+
+ // Load model config (gateway when signed in, local config when BYOK)
const loadModelConfig = useCallback(async () => {
try {
- const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
- const parsed = JSON.parse(result.data)
- const models: ConfiguredModel[] = []
- if (parsed?.providers) {
- for (const [flavor, entry] of Object.entries(parsed.providers)) {
- const e = entry as Record
- const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
- const singleModel = typeof e.model === 'string' ? e.model : ''
- const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
- for (const model of allModels) {
- if (model) {
- models.push({
- flavor,
- model,
- apiKey: (e.apiKey as string) || undefined,
- baseURL: (e.baseURL as string) || undefined,
- headers: (e.headers as Record) || undefined,
- knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
- })
+ if (isRowboatConnected) {
+ // Fetch gateway models
+ const listResult = await window.ipc.invoke('models:list', null)
+ const rowboatProvider = listResult.providers?.find(
+ (p: { id: string }) => p.id === 'rowboat'
+ )
+ const models: ConfiguredModel[] = (rowboatProvider?.models || []).map(
+ (m: { id: string }) => ({ flavor: 'rowboat', model: m.id })
+ )
+
+ // Read current default from config
+ let defaultModel = ''
+ try {
+ const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
+ const parsed = JSON.parse(result.data)
+ defaultModel = parsed?.model || ''
+ } catch { /* no config yet */ }
+
+ if (defaultModel) {
+ models.sort((a, b) => {
+ if (a.model === defaultModel) return -1
+ if (b.model === defaultModel) return 1
+ return 0
+ })
+ }
+
+ setConfiguredModels(models)
+ const activeKey = defaultModel
+ ? `rowboat/${defaultModel}`
+ : models[0] ? `rowboat/${models[0].model}` : ''
+ if (activeKey) setActiveModelKey(activeKey)
+ } else {
+ // BYOK: read from local models.json
+ const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
+ const parsed = JSON.parse(result.data)
+ const models: ConfiguredModel[] = []
+ if (parsed?.providers) {
+ for (const [flavor, entry] of Object.entries(parsed.providers)) {
+ const e = entry as Record
+ const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
+ const singleModel = typeof e.model === 'string' ? e.model : ''
+ const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
+ for (const model of allModels) {
+ if (model) {
+ models.push({
+ flavor: flavor as ConfiguredModel['flavor'],
+ model,
+ apiKey: (e.apiKey as string) || undefined,
+ baseURL: (e.baseURL as string) || undefined,
+ headers: (e.headers as Record) || undefined,
+ knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
+ })
+ }
}
}
}
- }
- const defaultKey = parsed?.provider?.flavor && parsed?.model
- ? `${parsed.provider.flavor}/${parsed.model}`
- : ''
- models.sort((a, b) => {
- const aKey = `${a.flavor}/${a.model}`
- const bKey = `${b.flavor}/${b.model}`
- if (aKey === defaultKey) return -1
- if (bKey === defaultKey) return 1
- return 0
- })
- setConfiguredModels(models)
- if (defaultKey) {
- setActiveModelKey(defaultKey)
+ const defaultKey = parsed?.provider?.flavor && parsed?.model
+ ? `${parsed.provider.flavor}/${parsed.model}`
+ : ''
+ models.sort((a, b) => {
+ const aKey = `${a.flavor}/${a.model}`
+ const bKey = `${b.flavor}/${b.model}`
+ if (aKey === defaultKey) return -1
+ if (bKey === defaultKey) return 1
+ return 0
+ })
+ setConfiguredModels(models)
+ if (defaultKey) {
+ setActiveModelKey(defaultKey)
+ }
}
} catch {
// No config yet
}
- }, [])
+ }, [isRowboatConnected])
useEffect(() => {
loadModelConfig()
@@ -213,47 +266,54 @@ function ChatInputInner({
return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig])
- // Check search tool availability (brave or exa)
+ // Check search tool availability (exa or signed-in via gateway)
useEffect(() => {
const checkSearch = async () => {
+ if (isRowboatConnected) {
+ setSearchAvailable(true)
+ return
+ }
let available = false
try {
- const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/brave-search.json' })
+ const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' })
const config = JSON.parse(raw.data)
if (config.apiKey) available = true
} catch { /* not configured */ }
- if (!available) {
- try {
- const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' })
- const config = JSON.parse(raw.data)
- if (config.apiKey) available = true
- } catch { /* not configured */ }
- }
setSearchAvailable(available)
}
checkSearch()
- }, [isActive])
+ }, [isActive, isRowboatConnected])
const handleModelChange = useCallback(async (key: string) => {
const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
if (!entry) return
setActiveModelKey(key)
- // Collect all models for this provider so the full list is preserved
- const providerModels = configuredModels
- .filter((m) => m.flavor === entry.flavor)
- .map((m) => m.model)
+
try {
- await window.ipc.invoke('models:saveConfig', {
- provider: {
- flavor: entry.flavor,
- apiKey: entry.apiKey,
- baseURL: entry.baseURL,
- headers: entry.headers,
- },
- model: entry.model,
- models: providerModels,
- knowledgeGraphModel: entry.knowledgeGraphModel,
- })
+ if (entry.flavor === 'rowboat') {
+ // Gateway model — save with valid Zod flavor, no credentials
+ await window.ipc.invoke('models:saveConfig', {
+ provider: { flavor: 'openrouter' as const },
+ model: entry.model,
+ knowledgeGraphModel: entry.knowledgeGraphModel,
+ })
+ } else {
+ // BYOK — preserve full provider config
+ const providerModels = configuredModels
+ .filter((m) => m.flavor === entry.flavor)
+ .map((m) => m.model)
+ await window.ipc.invoke('models:saveConfig', {
+ provider: {
+ flavor: entry.flavor,
+ apiKey: entry.apiKey,
+ baseURL: entry.baseURL,
+ headers: entry.headers,
+ },
+ model: entry.model,
+ models: providerModels,
+ knowledgeGraphModel: entry.knowledgeGraphModel,
+ })
+ }
} catch {
toast.error('Failed to switch model')
}
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
index ac7f23be..64b1e843 100644
--- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx
+++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
@@ -8,7 +8,7 @@ import {
Conversation,
ConversationContent,
ConversationEmptyState,
- ScrollPositionPreserver,
+ ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import {
Message,
@@ -16,8 +16,9 @@ import {
MessageResponse,
} from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer'
-import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
+import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
+import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions'
@@ -29,11 +30,14 @@ import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { wikiLabel } from '@/lib/wiki-links'
import {
+ type ChatViewportAnchorState,
type ChatTabViewState,
type ConversationItem,
type PermissionResponse,
createEmptyChatTabViewState,
getWebSearchCardData,
+ getComposioConnectCardData,
+ getToolDisplayName,
isChatMessage,
isErrorMessage,
isToolCall,
@@ -87,6 +91,7 @@ interface ChatSidebarProps {
conversation: ConversationItem[]
currentAssistantMessage: string
chatTabStates?: Record
+ viewportAnchors?: Record
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
@@ -121,6 +126,7 @@ interface ChatSidebarProps {
ttsMode?: 'summary' | 'full'
onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => void
+ onComposioConnected?: (toolkitSlug: string) => void
}
export function ChatSidebar({
@@ -138,6 +144,7 @@ export function ChatSidebar({
conversation,
currentAssistantMessage,
chatTabStates = {},
+ viewportAnchors = {},
isProcessing,
isStopping,
onStop,
@@ -171,6 +178,7 @@ export function ChatSidebar({
ttsMode,
onToggleTts,
onTtsModeChange,
+ onComposioConnected,
}: ChatSidebarProps) {
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false)
@@ -284,7 +292,7 @@ export function ChatSidebar({
if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
return (
-
+
@@ -296,7 +304,7 @@ export function ChatSidebar({
}
const { message, files } = parseAttachedFiles(item.content)
return (
-
+
{files.length > 0 && (
@@ -316,7 +324,7 @@ export function ChatSidebar({
)
}
return (
-
+
{item.content}
@@ -337,6 +345,21 @@ export function ChatSidebar({
/>
)
}
+ const composioConnectData = getComposioConnectCardData(item)
+ if (composioConnectData) {
+ if (composioConnectData.hidden) return null
+ return (
+
+ )
+ }
+ const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
@@ -346,10 +369,9 @@ export function ChatSidebar({
open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
>
-
+
-
- {output !== null ? : null}
+
)
@@ -357,7 +379,7 @@ export function ChatSidebar({
if (isErrorMessage(item)) {
return (
-
+
{item.message}
@@ -466,9 +488,12 @@ export function ChatSidebar({
)}
data-chat-tab-panel={tab.id}
aria-hidden={!isActive}
- >
-
-
+ >
+
{!tabHasConversation ? (
@@ -526,10 +551,11 @@ export function ChatSidebar({
)}
>
- )}
-
-
-
+ )}
+
+
+
+
)
})}
diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx
index fe1d58ae..e28f662e 100644
--- a/apps/x/apps/renderer/src/components/connectors-popover.tsx
+++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx
@@ -1,8 +1,8 @@
"use client"
import * as React from "react"
-import { useState, useEffect, useCallback } from "react"
-import { AlertTriangle, Loader2, Mic, Mail, MessageSquare, User } from "lucide-react"
+import { useState } from "react"
+import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react"
import {
Popover,
@@ -18,367 +18,40 @@ import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
-import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
-import { toast } from "sonner"
-
-interface ProviderState {
- isConnected: boolean
- isLoading: boolean
- isConnecting: boolean
-}
-
-interface ProviderStatus {
- error?: string
-}
+import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
+import { useConnectors } from "@/hooks/useConnectors"
interface ConnectorsPopoverProps {
children: React.ReactNode
tooltip?: string
open?: boolean
onOpenChange?: (open: boolean) => void
+ mode?: "all" | "unconnected"
}
-export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange }: ConnectorsPopoverProps) {
+export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange, mode = "all" }: ConnectorsPopoverProps) {
const [openInternal, setOpenInternal] = useState(false)
const isControlled = typeof openProp === "boolean"
const open = isControlled ? openProp : openInternal
const setOpen = onOpenChange ?? setOpenInternal
- const [providers, setProviders] = useState([])
- const [providersLoading, setProvidersLoading] = useState(true)
- const [providerStates, setProviderStates] = useState>({})
- const [providerStatus, setProviderStatus] = useState>({})
- const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
- const [googleClientIdDescription, setGoogleClientIdDescription] = useState(undefined)
- // Granola state
- const [granolaEnabled, setGranolaEnabled] = useState(false)
- const [granolaLoading, setGranolaLoading] = useState(true)
+ const c = useConnectors(open)
- // Slack state (agent-slack CLI)
- const [slackEnabled, setSlackEnabled] = useState(false)
- const [slackLoading, setSlackLoading] = useState(true)
- const [slackWorkspaces, setSlackWorkspaces] = useState>([])
- const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([])
- const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set())
- const [slackPickerOpen, setSlackPickerOpen] = useState(false)
- const [slackDiscovering, setSlackDiscovering] = useState(false)
- const [slackDiscoverError, setSlackDiscoverError] = useState(null)
-
- // Load available providers on mount
- useEffect(() => {
- async function loadProviders() {
- try {
- setProvidersLoading(true)
- const result = await window.ipc.invoke('oauth:list-providers', null)
- setProviders(result.providers || [])
- } catch (error) {
- console.error('Failed to get available providers:', error)
- setProviders([])
- } finally {
- setProvidersLoading(false)
- }
- }
- loadProviders()
- }, [])
-
- // Load Granola config
- const refreshGranolaConfig = useCallback(async () => {
- try {
- setGranolaLoading(true)
- const result = await window.ipc.invoke('granola:getConfig', null)
- setGranolaEnabled(result.enabled)
- } catch (error) {
- console.error('Failed to load Granola config:', error)
- setGranolaEnabled(false)
- } finally {
- setGranolaLoading(false)
- }
- }, [])
-
- // Update Granola config
- const handleGranolaToggle = useCallback(async (enabled: boolean) => {
- try {
- setGranolaLoading(true)
- await window.ipc.invoke('granola:setConfig', { enabled })
- setGranolaEnabled(enabled)
- toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled')
- } catch (error) {
- console.error('Failed to update Granola config:', error)
- toast.error('Failed to update Granola sync settings')
- } finally {
- setGranolaLoading(false)
- }
- }, [])
-
- // Load Slack config
- const refreshSlackConfig = useCallback(async () => {
- try {
- setSlackLoading(true)
- const result = await window.ipc.invoke('slack:getConfig', null)
- setSlackEnabled(result.enabled)
- setSlackWorkspaces(result.workspaces || [])
- } catch (error) {
- console.error('Failed to load Slack config:', error)
- setSlackEnabled(false)
- setSlackWorkspaces([])
- } finally {
- setSlackLoading(false)
- }
- }, [])
-
- // Enable Slack: discover workspaces
- const handleSlackEnable = useCallback(async () => {
- setSlackDiscovering(true)
- setSlackDiscoverError(null)
- try {
- const result = await window.ipc.invoke('slack:listWorkspaces', null)
- if (result.error || result.workspaces.length === 0) {
- setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
- setSlackAvailableWorkspaces([])
- setSlackPickerOpen(true)
- } else {
- setSlackAvailableWorkspaces(result.workspaces)
- setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
- setSlackPickerOpen(true)
- }
- } catch (error) {
- console.error('Failed to discover Slack workspaces:', error)
- setSlackDiscoverError('Failed to discover Slack workspaces')
- setSlackPickerOpen(true)
- } finally {
- setSlackDiscovering(false)
- }
- }, [])
-
- // Save selected Slack workspaces
- const handleSlackSaveWorkspaces = useCallback(async () => {
- const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
- try {
- setSlackLoading(true)
- await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
- setSlackEnabled(true)
- setSlackWorkspaces(selected)
- setSlackPickerOpen(false)
- toast.success('Slack enabled')
- } catch (error) {
- console.error('Failed to save Slack config:', error)
- toast.error('Failed to save Slack settings')
- } finally {
- setSlackLoading(false)
- }
- }, [slackAvailableWorkspaces, slackSelectedUrls])
-
- // Disable Slack
- const handleSlackDisable = useCallback(async () => {
- try {
- setSlackLoading(true)
- await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
- setSlackEnabled(false)
- setSlackWorkspaces([])
- setSlackPickerOpen(false)
- toast.success('Slack disabled')
- } catch (error) {
- console.error('Failed to update Slack config:', error)
- toast.error('Failed to update Slack settings')
- } finally {
- setSlackLoading(false)
- }
- }, [])
-
- // Check connection status for all providers
- const refreshAllStatuses = useCallback(async () => {
- // Refresh Granola
- refreshGranolaConfig()
-
- // Refresh Slack config
- refreshSlackConfig()
-
- // Refresh OAuth providers
- if (providers.length === 0) return
-
- const newStates: Record = {}
-
- try {
- const result = await window.ipc.invoke('oauth:getState', null)
- const config = result.config || {}
- const statusMap: Record = {}
-
- for (const provider of providers) {
- const providerConfig = config[provider]
- newStates[provider] = {
- isConnected: providerConfig?.connected ?? false,
- isLoading: false,
- isConnecting: false,
- }
- if (providerConfig?.error) {
- statusMap[provider] = { error: providerConfig.error }
- }
- }
-
- setProviderStatus(statusMap)
- } catch (error) {
- console.error('Failed to check connection statuses:', error)
- for (const provider of providers) {
- newStates[provider] = {
- isConnected: false,
- isLoading: false,
- isConnecting: false,
- }
- }
- setProviderStatus({})
- }
-
- setProviderStates(newStates)
- }, [providers, refreshGranolaConfig, refreshSlackConfig])
-
- // Refresh statuses when popover opens or providers list changes
- useEffect(() => {
- if (open) {
- refreshAllStatuses()
- }
- }, [open, providers, refreshAllStatuses])
-
- // Listen for OAuth completion events
- useEffect(() => {
- const cleanup = window.ipc.on('oauth:didConnect', (event) => {
- const { provider, success, error } = event
-
- setProviderStates(prev => ({
- ...prev,
- [provider]: {
- isConnected: success,
- isLoading: false,
- isConnecting: false,
- }
- }))
-
- if (success) {
- const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
- // Show detailed message for Google and Fireflies (includes sync info)
- if (provider === 'google' || provider === 'fireflies-ai') {
- toast.success(`Connected to ${displayName}`, {
- description: 'Syncing your data in the background. This may take a few minutes before changes appear.',
- duration: 8000,
- })
- } else {
- toast.success(`Connected to ${displayName}`)
- }
- // Refresh status to ensure consistency
- refreshAllStatuses()
- } else {
- toast.error(error || `Failed to connect to ${provider}`)
- }
- })
-
- return cleanup
- }, [refreshAllStatuses])
-
- const startConnect = useCallback(async (provider: string, clientId?: string) => {
- setProviderStates(prev => ({
- ...prev,
- [provider]: { ...prev[provider], isConnecting: true }
- }))
-
- try {
- const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
-
- if (result.success) {
- // OAuth flow started - keep isConnecting state, wait for event
- // Event listener will handle the actual completion
- } else {
- // Immediate failure (e.g., couldn't start flow)
- toast.error(result.error || `Failed to connect to ${provider}`)
- setProviderStates(prev => ({
- ...prev,
- [provider]: { ...prev[provider], isConnecting: false }
- }))
- }
- } catch (error) {
- console.error('Failed to connect:', error)
- toast.error(`Failed to connect to ${provider}`)
- setProviderStates(prev => ({
- ...prev,
- [provider]: { ...prev[provider], isConnecting: false }
- }))
- }
- }, [])
-
- // Connect to a provider
- const handleConnect = useCallback(async (provider: string) => {
- if (provider === 'google') {
- setGoogleClientIdDescription(undefined)
- const existingClientId = getGoogleClientId()
- if (!existingClientId) {
- setGoogleClientIdOpen(true)
- return
- }
- await startConnect(provider, existingClientId)
- return
- }
-
- await startConnect(provider)
- }, [startConnect])
-
- const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
- setGoogleClientId(clientId)
- setGoogleClientIdOpen(false)
- setGoogleClientIdDescription(undefined)
- startConnect('google', clientId)
- }, [startConnect])
-
- // Disconnect from a provider
- const handleDisconnect = useCallback(async (provider: string) => {
- setProviderStates(prev => ({
- ...prev,
- [provider]: { ...prev[provider], isLoading: true }
- }))
-
- try {
- const result = await window.ipc.invoke('oauth:disconnect', { provider })
-
- if (result.success) {
- if (provider === 'google') {
- clearGoogleClientId()
- }
- const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
- toast.success(`Disconnected from ${displayName}`)
- setProviderStates(prev => ({
- ...prev,
- [provider]: {
- isConnected: false,
- isLoading: false,
- isConnecting: false,
- }
- }))
- } else {
- toast.error(`Failed to disconnect from ${provider}`)
- setProviderStates(prev => ({
- ...prev,
- [provider]: { ...prev[provider], isLoading: false }
- }))
- }
- } catch (error) {
- console.error('Failed to disconnect:', error)
- toast.error(`Failed to disconnect from ${provider}`)
- setProviderStates(prev => ({
- ...prev,
- [provider]: { ...prev[provider], isLoading: false }
- }))
- }
- }, [])
-
- const hasProviderError = Object.values(providerStatus).some(
- (status) => Boolean(status?.error)
- )
+ const isUnconnectedMode = mode === "unconnected"
// Helper to render an OAuth provider row
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
- const state = providerStates[provider] || {
+ const state = c.providerStates[provider] || {
isConnected: false,
isLoading: true,
isConnecting: false,
}
- const needsReconnect = Boolean(providerStatus[provider]?.error)
+ const needsReconnect = Boolean(c.providerStatus[provider]?.error)
+
+ // In unconnected mode, skip connected providers (unless they need reconnect)
+ if (isUnconnectedMode && state.isConnected && !needsReconnect && !state.isLoading) {
+ return null
+ }
return (
{
if (provider === 'google') {
- setGoogleClientIdDescription(
+ c.setGoogleClientIdDescription(
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
)
- setGoogleClientIdOpen(true)
+ c.setGoogleClientIdOpen(true)
return
}
- startConnect(provider)
+ c.startConnect(provider)
}}
className="h-7 px-2 text-xs"
>
@@ -425,23 +98,23 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
) : (
)}
@@ -450,19 +123,57 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
)
}
+ // Check if Gmail is unconnected (for filtering in unconnected mode)
+ const isGmailUnconnected = c.useComposioForGoogle ? !c.gmailConnected && !c.gmailLoading : true
+ const isGoogleCalendarUnconnected = c.useComposioForGoogleCalendar ? !c.googleCalendarConnected && !c.googleCalendarLoading : true
+ const isGranolaUnconnected = !c.granolaEnabled && !c.granolaLoading
+ const isSlackUnconnected = !c.slackEnabled && !c.slackLoading
+
+ // For unconnected mode, check if there's anything to show
+ const hasUnconnectedEmailCalendar = (() => {
+ if (!isUnconnectedMode) return true
+ if (c.useComposioForGoogle && isGmailUnconnected) return true
+ if (c.useComposioForGoogleCalendar && isGoogleCalendarUnconnected) return true
+ if (!c.useComposioForGoogle && c.providers.includes('google')) {
+ const googleState = c.providerStates['google']
+ if (!googleState?.isConnected || c.providerStatus['google']?.error) return true
+ }
+ return false
+ })()
+
+ const hasUnconnectedMeetingNotes = (() => {
+ if (!isUnconnectedMode) return true
+ if (isGranolaUnconnected) return true
+ if (c.providers.includes('fireflies-ai')) {
+ const firefliesState = c.providerStates['fireflies-ai']
+ if (!firefliesState?.isConnected || c.providerStatus['fireflies-ai']?.error) return true
+ }
+ return false
+ })()
+
+ const hasUnconnectedSlack = !isUnconnectedMode || isSlackUnconnected
+
+ const isRowboatUnconnected = (() => {
+ if (!c.providers.includes('rowboat')) return false
+ const rowboatState = c.providerStates['rowboat']
+ return !rowboatState?.isConnected || rowboatState?.isLoading
+ })()
+
+ const allConnected = isUnconnectedMode && !isRowboatUnconnected && !hasUnconnectedEmailCalendar && !hasUnconnectedMeetingNotes && !hasUnconnectedSlack
+
return (
<>
{
- setGoogleClientIdOpen(nextOpen)
+ c.setGoogleClientIdOpen(nextOpen)
if (!nextOpen) {
- setGoogleClientIdDescription(undefined)
+ c.setGoogleClientIdDescription(undefined)
}
}}
- onSubmit={handleGoogleClientIdSubmit}
- isSubmitting={providerStates.google?.isConnecting ?? false}
- description={googleClientIdDescription}
+ onSubmit={c.handleGoogleClientIdSubmit}
+ isSubmitting={c.providerStates.google?.isConnecting ?? false}
+ description={c.googleClientIdDescription}
/>
{tooltip ? (
@@ -489,169 +200,296 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
>
- Connected accounts
- {hasProviderError && (
+ {isUnconnectedMode ? "Connect Accounts" : "Connected accounts"}
+ {!isUnconnectedMode && c.hasProviderError && (
)}
- Connect accounts to sync data
+ {isUnconnectedMode ? "Add new account connections" : "Connect accounts to sync data"}
- {providersLoading ? (
+ {c.providersLoading ? (
+ ) : allConnected ? (
+
+
All accounts connected
+
+ Manage your connections in Settings
+
+
) : (
<>
- {/* Rowboat Account */}
- {providers.includes('rowboat') && (
+ {/* Rowboat Account - show in "all" mode always, or in "unconnected" mode only when not connected */}
+ {c.providers.includes('rowboat') && (() => {
+ const rowboatState = c.providerStates['rowboat']
+ const isRowboatConnected = rowboatState?.isConnected && !rowboatState?.isLoading
+ if (isUnconnectedMode && isRowboatConnected) return null
+ return (
+ <>
+
+ Account
+
+ {renderOAuthProvider('rowboat', 'Rowboat',
, 'Log in to your Rowboat account')}
+
+ >
+ )
+ })()}
+
+ {/* Email & Calendar Section */}
+ {(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && hasUnconnectedEmailCalendar && (
<>
- Account
-
- {renderOAuthProvider('rowboat', 'Rowboat',
, 'Connect your Rowboat account')}
-
- >
- )}
-
- {/* Email & Calendar Section - Google */}
- {providers.includes('google') && (
- <>
-
- Email & Calendar
-
- {renderOAuthProvider('google', 'Google',
, 'Sync emails and calendar')}
-
- >
- )}
-
- {/* Meeting Notes Section - Granola & Fireflies */}
-
- Meeting Notes
-
-
- {/* Granola */}
-
-
-
-
-
-
- Granola
-
- Local meeting notes
+
+ Email & Calendar
-
-
- {granolaLoading && (
-
+ {c.useComposioForGoogle ? (
+ // In unconnected mode, only show if not connected
+ (!isUnconnectedMode || isGmailUnconnected) ? (
+
+
+
+
+
+
+ Gmail
+ {c.gmailLoading ? (
+ Checking...
+ ) : (
+
+ Sync emails
+
+ )}
+
+
+
+ {c.gmailLoading ? (
+
+ ) : c.gmailConnected ? (
+
+ ) : (
+
+ )}
+
+
+ ) : null
+ ) : (
+ renderOAuthProvider('google', 'Google',
, 'Sync emails and calendar')
)}
-
-
-
-
- {/* Fireflies */}
- {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies',
, 'AI meeting transcripts')}
-
-
-
- {/* Team Communication Section - Slack */}
-
- Team Communication
-
-
- {/* Slack */}
-
-
-
-
-
+ {c.useComposioForGoogleCalendar && (!isUnconnectedMode || isGoogleCalendarUnconnected) && (
+
+
+
+
+
+
+ Google Calendar
+ {c.googleCalendarLoading ? (
+ Checking...
+ ) : (
+
+ Sync calendar events
+
+ )}
+
+
+
+ {c.googleCalendarLoading ? (
+
+ ) : c.googleCalendarConnected ? (
+
+ ) : (
+
+ )}
+
-
-
Slack
- {slackEnabled && slackWorkspaces.length > 0 ? (
-
- {slackWorkspaces.map(w => w.name).join(', ')}
-
- ) : (
-
- Send messages and view channels
-
- )}
+ )}
+
+ >
+ )}
+
+ {/* Meeting Notes Section */}
+ {hasUnconnectedMeetingNotes && (
+ <>
+
+ Meeting Notes
+
+
+ {/* Granola - show in unconnected mode only if not enabled */}
+ {(!isUnconnectedMode || isGranolaUnconnected) && (
+
+
+
+
+
+
+ Granola
+
+ Local meeting notes
+
+
+
+
+ {c.granolaLoading && (
+
+ )}
+
+
+ )}
+
+ {/* Fireflies */}
+ {c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies',
, 'AI meeting transcripts')}
+
+
+ >
+ )}
+
+ {/* Team Communication Section */}
+ {hasUnconnectedSlack && (
+ <>
+
+ Team Communication
-
- {(slackLoading || slackDiscovering) && (
-
- )}
- {slackEnabled ? (
-
handleSlackDisable()}
- disabled={slackLoading}
- />
- ) : (
-
+
+
+
+
+
+
+
+
+ Slack
+ {c.slackEnabled && c.slackWorkspaces.length > 0 ? (
+
+ {c.slackWorkspaces.map(w => w.name).join(', ')}
+
+ ) : (
+
+ Send messages and view channels
+
+ )}
+
+
+
+ {(c.slackLoading || c.slackDiscovering) && (
+
+ )}
+ {c.slackEnabled ? (
+ c.handleSlackDisable()}
+ disabled={c.slackLoading}
+ />
+ ) : (
+
+ )}
+
+
+ {c.slackPickerOpen && (
+
+ {c.slackDiscoverError ? (
+
{c.slackDiscoverError}
+ ) : (
+ <>
+ {c.slackAvailableWorkspaces.map(w => (
+
+ ))}
+
+ >
+ )}
+
)}
-
- {slackPickerOpen && (
-
- {slackDiscoverError ? (
-
{slackDiscoverError}
- ) : (
- <>
- {slackAvailableWorkspaces.map(w => (
-
- ))}
-
- >
- )}
-
- )}
-
+ >
+ )}
>
)}
+
>
)
}
diff --git a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx
index 280d45f1..0ceb2c76 100644
--- a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx
+++ b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx
@@ -59,7 +59,7 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
})
}, [])
- const commitField = useCallback((index: number) => {
+ const commitField = useCallback((_index: number) => {
setFields(prev => {
commit(prev)
return prev
diff --git a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx
index c4df07a2..3ef536d9 100644
--- a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx
+++ b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx
@@ -47,19 +47,37 @@ export function GoogleClientIdModal({
return (