From e6587a67b771594b8c883055675c1bd3585ebbee Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 22 May 2026 15:47:03 +0530 Subject: [PATCH] settings connections tab, denser sidebar, and help in settings Merge Connected Accounts and Tools Library into a single Connections tab, tighten sidebar spacing, move Help into Settings, and refine the Workspace view. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/x/apps/renderer/src/App.tsx | 5 + .../renderer/src/components/help-popover.tsx | 100 ------ .../src/components/settings-dialog.tsx | 129 ++++++-- .../settings/connected-accounts-settings.tsx | 24 +- .../src/components/sidebar-content.tsx | 59 ++-- .../renderer/src/components/ui/sidebar.tsx | 6 +- .../src/components/workspace-view.tsx | 293 +++++++++++++++++- 7 files changed, 435 insertions(+), 181 deletions(-) delete mode 100644 apps/x/apps/renderer/src/components/help-popover.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e29d3ea9..1d0d9076 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5314,6 +5314,11 @@ function App() { navigateToFile(path)} onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }} /> diff --git a/apps/x/apps/renderer/src/components/help-popover.tsx b/apps/x/apps/renderer/src/components/help-popover.tsx deleted file mode 100644 index 3ff08a5c..00000000 --- a/apps/x/apps/renderer/src/components/help-popover.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client" - -import * as React from "react" -import { useState } from "react" -import { MessageCircle } from "lucide-react" - -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { Button } from "@/components/ui/button" - -interface HelpPopoverProps { - children: React.ReactNode - tooltip?: string -} - -export function HelpPopover({ children, tooltip }: HelpPopoverProps) { - const [open, setOpen] = useState(false) - - const handleDiscordClick = () => { - window.open("https://discord.com/invite/wajrgmJQ6b", "_blank") - } - - return ( - - {tooltip ? ( - - - - {children} - - - - {tooltip} - - - ) : ( - - {children} - - )} - -
-

Help & Support

-

- Get help from our community -

-
-
- -
-
- - Terms of Service - - · - - Privacy Policy - -
-
-
- ) -} diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index a1554a56..3a93e1c5 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle } from "lucide-react" import { Dialog, @@ -11,6 +11,7 @@ import { } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" import { Select, SelectContent, @@ -25,7 +26,7 @@ import { toast } from "sonner" import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" -type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging" +type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "help" interface TabConfig { id: ConfigTab @@ -43,10 +44,10 @@ const tabs: TabConfig[] = [ description: "Manage your Rowboat account", }, { - id: "connected-accounts", - label: "Connected Accounts", + id: "connections", + label: "Connections", icon: Plug, - description: "Manage connected services", + description: "Manage accounts and tools", }, { id: "models", @@ -75,12 +76,6 @@ const tabs: TabConfig[] = [ icon: Palette, description: "Customize the look and feel", }, - { - id: "tools", - label: "Tools Library", - icon: Wrench, - description: "Browse and enable toolkits", - }, { id: "note-tagging", label: "Note Tagging", @@ -88,10 +83,80 @@ const tabs: TabConfig[] = [ path: "config/tags.json", description: "Configure tags for notes and emails", }, + { + id: "help", + label: "Help", + icon: HelpCircle, + description: "Get help and support", + }, ] interface SettingsDialogProps { - children: React.ReactNode + /** Optional trigger element. Omit when controlling `open` externally. */ + children?: React.ReactNode + /** Tab to open on when the dialog is shown. Defaults to "account". */ + defaultTab?: ConfigTab + /** Controlled open state. When provided, the dialog is fully controlled. */ + open?: boolean + onOpenChange?: (open: boolean) => void +} + +// --- Help & Support tab --- + +function HelpSettings() { + return ( +
+
+

Help & Support

+

Get help from our community

+
+ + +
+ + Terms of Service + + · + + Privacy Policy + +
+
+ ) } // --- Theme option for Appearance tab --- @@ -1572,9 +1637,14 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { // --- Main Settings Dialog --- -export function SettingsDialog({ children }: SettingsDialogProps) { - const [open, setOpen] = useState(false) - const [activeTab, setActiveTab] = useState("account") +export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) { + const [internalOpen, setInternalOpen] = useState(false) + const open = controlledOpen ?? internalOpen + const setOpen = useCallback((next: boolean) => { + if (onOpenChange) onOpenChange(next) + else setInternalOpen(next) + }, [onOpenChange]) + const [activeTab, setActiveTab] = useState(defaultTab) const [content, setContent] = useState("") const [originalContent, setOriginalContent] = useState("") const [loading, setLoading] = useState(false) @@ -1582,6 +1652,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const [error, setError] = useState(null) const [rowboatConnected, setRowboatConnected] = useState(false) + // Reset to the requested default tab each time the dialog is opened + useEffect(() => { + if (open) setActiveTab(defaultTab) + }, [open, defaultTab]) + // Check if user is signed in to Rowboat useEffect(() => { if (!open) return @@ -1607,7 +1682,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) { } const loadConfig = useCallback(async (tab: ConfigTab) => { - if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return + if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help") return const tabConfig = tabs.find((t) => t.id === tab)! if (!tabConfig.path) return setLoading(true) @@ -1673,7 +1748,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) { return ( - {children} + {children && {children}} @@ -1715,11 +1790,21 @@ export function SettingsDialog({ children }: SettingsDialogProps) { {/* Content */} -
+
{activeTab === "account" ? ( - ) : activeTab === "connected-accounts" ? ( - + ) : activeTab === "connections" ? ( +
+
+

Primary accounts

+ +
+ +
+

Library

+ +
+
) : activeTab === "models" ? ( rowboatConnected ? @@ -1728,8 +1813,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) { ) : activeTab === "appearance" ? ( - ) : activeTab === "tools" ? ( - + ) : activeTab === "help" ? ( + ) : loading ? (
Loading... diff --git a/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx index 8b90f619..e0c0b900 100644 --- a/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx @@ -26,10 +26,10 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti return (
-
-
+
+
{icon}
@@ -119,15 +119,15 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti {/* Email & Calendar Section */} {(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && ( <> -
+
Email & Calendar
{c.useComposioForGoogle ? ( -
-
-
+
+
+
@@ -174,9 +174,9 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti c.providers.includes('google') && renderOAuthProvider('google', 'Google', , 'Sync emails and calendar') )} {c.useComposioForGoogleCalendar && ( -
-
-
+
+
+
@@ -220,14 +220,14 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
)} - + )} {/* Meeting Notes Section */} {c.providers.includes('fireflies-ai') && ( <> -
+
Meeting Notes diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 5c437468..3053abc8 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -13,7 +13,6 @@ import { FolderPlus, Globe, AlertTriangle, - HelpCircle, Home, Mic, SearchIcon, @@ -79,8 +78,6 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu" import { cn } from "@/lib/utils" -import { ConnectorsPopover } from "@/components/connectors-popover" -import { HelpPopover } from "@/components/help-popover" import { SettingsDialog } from "@/components/settings-dialog" import { toast } from "@/lib/toast" import { formatRelativeTime as formatRunTime } from "@/lib/relative-time" @@ -456,8 +453,8 @@ export function SidebarContentPanel({ }: SidebarContentPanelProps) { const [hasOauthError, setHasOauthError] = useState(false) const [showOauthAlert, setShowOauthAlert] = useState(true) - const [connectorsOpen, setConnectorsOpen] = useState(false) - const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false) + const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false) + const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false) const connectorsButtonRef = useRef(null) const [isRowboatConnected, setIsRowboatConnected] = useState(false) const [loggingIn, setLoggingIn] = useState(false) @@ -575,11 +572,11 @@ export function SidebarContentPanel({ setConnectorsOpen(true)} + onOpenConnectors={() => setConnectionsSettingsOpen(true)} /> setConnectorsOpen(true)} + onOpenConnectors={() => setConnectionsSettingsOpen(true)} recordingState={meetingRecordingState ?? 'idle'} recordingSource={recordingMeetingSource ?? null} onToggleRecording={onToggleMeetingRecording} @@ -645,15 +642,14 @@ export function SidebarContentPanel({
- - - + {hasOauthError && ( { event.preventDefault() - if (openConnectorsAfterClose) { - setOpenConnectorsAfterClose(false) - setConnectorsOpen(true) + if (openConnectionsAfterClose) { + setOpenConnectionsAfterClose(false) + setConnectionsSettingsOpen(true) } connectorsButtonRef.current?.focus() }} @@ -696,7 +692,7 @@ export function SidebarContentPanel({ { - setOpenConnectorsAfterClose(true) + setOpenConnectionsAfterClose(true) setShowOauthAlert(false) }} > @@ -713,14 +709,13 @@ export function SidebarContentPanel({ Settings - - -
+ @@ -1005,7 +1000,7 @@ function KnowledgeSection({ -
+
Notes
@@ -1078,7 +1073,7 @@ export function WorkspaceSection({ return ( -
+
Workspace
@@ -1319,7 +1314,7 @@ function EmailSidebarSection({ return ( -
+
Email
@@ -1469,7 +1464,7 @@ function MeetingsSidebarSection({ return ( -
+
Meetings
@@ -1608,7 +1603,7 @@ function TasksSidebarSection({ return ( -
+
Tasks
@@ -1666,7 +1661,7 @@ function TasksSection({ return ( -
+
Chat history
diff --git a/apps/x/apps/renderer/src/components/ui/sidebar.tsx b/apps/x/apps/renderer/src/components/ui/sidebar.tsx index 9c413735..ffe40957 100644 --- a/apps/x/apps/renderer/src/components/ui/sidebar.tsx +++ b/apps/x/apps/renderer/src/components/ui/sidebar.tsx @@ -380,7 +380,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { data-slot="sidebar-content" data-sidebar="content" className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + "flex min-h-0 flex-1 flex-col gap-1 overflow-auto group-data-[collapsible=icon]:overflow-hidden", className )} {...props} @@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
) @@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
    ) diff --git a/apps/x/apps/renderer/src/components/workspace-view.tsx b/apps/x/apps/renderer/src/components/workspace-view.tsx index 4001140c..6cbd1075 100644 --- a/apps/x/apps/renderer/src/components/workspace-view.tsx +++ b/apps/x/apps/renderer/src/components/workspace-view.tsx @@ -1,7 +1,33 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { ChevronRight, File as FileIcon, Folder as FolderIcon, Home, Plus } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ChevronRight, + Copy, + File as FileIcon, + FilePlus, + Folder as FolderIcon, + FolderOpen, + FolderPlus, + Home, + Pencil, + Plus, + Trash2, + UploadCloud, +} from 'lucide-react' import { Button } from '@/components/ui/button' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { Dialog, DialogContent, @@ -11,6 +37,7 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' +import { toast } from '@/lib/toast' import { cn } from '@/lib/utils' const WORKSPACE_ROOT = 'knowledge/Workspace' @@ -22,13 +49,28 @@ interface TreeNode { children?: TreeNode[] } +type WorkspaceActions = { + remove: (path: string) => Promise + copyPath: (path: string) => void + revealInFileManager: (path: string, isDir: boolean) => void +} + type WorkspaceViewProps = { tree: TreeNode[] initialPath?: string | null + actions: WorkspaceActions onOpenNote: (path: string) => void onCreateWorkspace: (name: string) => Promise } +function getFileManagerName(): string { + if (typeof navigator === 'undefined') return 'File Manager' + const platform = navigator.platform.toLowerCase() + if (platform.includes('mac')) return 'Finder' + if (platform.includes('win')) return 'Explorer' + return 'File Manager' +} + function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null { if (!nodes) return null for (const node of nodes) { @@ -46,18 +88,51 @@ function countChildren(node: TreeNode | null): number { return node.children.length } -export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { +async function uniqueChildPath(parent: string, name: string): Promise { + const dot = name.lastIndexOf('.') + const base = dot > 0 ? name.slice(0, dot) : name + const ext = dot > 0 ? name.slice(dot) : '' + let candidate = `${parent}/${name}` + let i = 1 + while ((await window.ipc.invoke('workspace:exists', { path: candidate })).exists) { + candidate = `${parent}/${base} (${i})${ext}` + i += 1 + } + return candidate +} + +function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = reader.result as string + resolve(result.split(',')[1] ?? '') + } + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + +export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { const [currentPath, setCurrentPath] = useState(initialPath || WORKSPACE_ROOT) const [addOpen, setAddOpen] = useState(false) const [newName, setNewName] = useState('') const [creating, setCreating] = useState(false) const [error, setError] = useState(null) + const [renameTarget, setRenameTarget] = useState(null) + const [renameValue, setRenameValue] = useState('') + const [isDraggingOver, setIsDraggingOver] = useState(false) + const [uploading, setUploading] = useState(false) + const dragDepthRef = useRef(0) + const filesInputRef = useRef(null) + const folderInputRef = useRef(null) useEffect(() => { if (initialPath) setCurrentPath(initialPath) }, [initialPath]) const isRoot = currentPath === WORKSPACE_ROOT + const fileManagerName = getFileManagerName() const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath]) @@ -83,15 +158,109 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace const handleItemClick = useCallback( (item: TreeNode) => { + if (renameTarget) return if (item.kind === 'dir') { setCurrentPath(item.path) } else { onOpenNote(item.path) } }, - [onOpenNote], + [onOpenNote, renameTarget], ) + const beginRename = useCallback((item: TreeNode) => { + setRenameTarget(item.path) + setRenameValue(item.name) + }, []) + + const commitRename = useCallback(async () => { + if (!renameTarget) return + const node = items.find((i) => i.path === renameTarget) + const trimmed = renameValue.trim() + setRenameTarget(null) + if (!node || !trimmed || trimmed === node.name || trimmed.includes('/')) return + const parent = renameTarget.slice(0, renameTarget.lastIndexOf('/')) + try { + await window.ipc.invoke('workspace:rename', { from: renameTarget, to: `${parent}/${trimmed}` }) + toast('Renamed', 'success') + } catch { + toast('Failed to rename', 'error') + } + }, [renameTarget, renameValue, items]) + + const handleDelete = useCallback(async (item: TreeNode) => { + try { + await actions.remove(item.path) + toast('Moved to trash', 'success') + } catch { + toast('Failed to delete', 'error') + } + }, [actions]) + + const uploadFiles = useCallback(async (files: FileList | File[], preserveStructure = false) => { + const list = Array.from(files) + if (list.length === 0) return + setUploading(true) + try { + for (const file of list) { + const data = await readFileAsBase64(file) + const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath + const target = preserveStructure && rel + ? `${currentPath}/${rel}` + : await uniqueChildPath(currentPath, file.name) + await window.ipc.invoke('workspace:writeFile', { + path: target, + data, + opts: { encoding: 'base64', mkdirp: true }, + }) + } + toast(list.length === 1 ? 'Added' : `${list.length} items added`, 'success') + } catch (err) { + console.error('Failed to add files:', err) + toast('Failed to add', 'error') + } finally { + setUploading(false) + } + }, [currentPath]) + + // Drag-and-drop (only inside a workspace folder, not at the root grid). + // stopPropagation keeps the drop from also reaching the copilot's + // document-level drop listener when it lands on the workspace area. + const dropEnabled = !isRoot + const handleDragEnter = useCallback((e: React.DragEvent) => { + if (!dropEnabled) return + if (!Array.from(e.dataTransfer.types).includes('Files')) return + e.preventDefault() + e.stopPropagation() + dragDepthRef.current += 1 + setIsDraggingOver(true) + }, [dropEnabled]) + const handleDragOver = useCallback((e: React.DragEvent) => { + if (!dropEnabled) return + if (!Array.from(e.dataTransfer.types).includes('Files')) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + }, [dropEnabled]) + const handleDragLeave = useCallback((e: React.DragEvent) => { + if (!dropEnabled) return + e.preventDefault() + e.stopPropagation() + dragDepthRef.current -= 1 + if (dragDepthRef.current <= 0) { + dragDepthRef.current = 0 + setIsDraggingOver(false) + } + }, [dropEnabled]) + const handleDrop = useCallback((e: React.DragEvent) => { + if (!dropEnabled) return + e.preventDefault() + e.stopPropagation() + dragDepthRef.current = 0 + setIsDraggingOver(false) + if (e.dataTransfer.files?.length) void uploadFiles(e.dataTransfer.files) + }, [dropEnabled, uploadFiles]) + const resetAddDialog = useCallback(() => { setNewName('') setError(null) @@ -157,22 +326,70 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace ) })}
- {isRoot && ( + {isRoot ? ( + ) : ( + + + + + + filesInputRef.current?.click()}> + + Add files… + + folderInputRef.current?.click()}> + + Add folder… + + + )}
+ { + if (e.target.files?.length) void uploadFiles(e.target.files, false) + e.target.value = '' + }} + /> + { + if (e.target.files?.length) void uploadFiles(e.target.files, true) + e.target.value = '' + }} + /> -
+
{items.length === 0 ? (
{isRoot ? 'No workspaces yet. Create one to get started.' - : 'This folder is empty.'} + : 'This folder is empty. Drag files in or use New note / New folder.'}
{isRoot && ( ) + return ( + + {card} + + beginRename(item)}> + + Rename + + { actions.copyPath(item.path); toast('Path copied', 'success') }}> + + Copy Path + + actions.revealInFileManager(item.path, item.kind === 'dir')}> + + Show in {fileManagerName} + + + void handleDelete(item)}> + + Delete + + + + ) })}
)} + + {dropEnabled && isDraggingOver && ( +
+ + Drop files to add to this folder +
+ )} + {uploading && ( +
+ Adding files… +
+ )}