mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #711 from rowboatlabs/fix/heading-ui-cleanup
UI polish: unified headers, settings redesign, chat management
This commit is contained in:
commit
45d9002a3a
19 changed files with 524 additions and 328 deletions
|
|
@ -1240,10 +1240,14 @@
|
|||
}
|
||||
|
||||
.graph-view {
|
||||
background-color: var(--background);
|
||||
background-color: #f8f8f9;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dark .graph-view {
|
||||
background-color: #0b0b0d;
|
||||
}
|
||||
|
||||
.graph-view::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageCopyButton,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message';
|
||||
import {
|
||||
|
|
@ -5891,14 +5892,17 @@ function App() {
|
|||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{item.content}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
<div className="flex flex-col items-end">
|
||||
<MessageContent>
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{item.content}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
<MessageCopyButton text={item.content} className="mt-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -5906,26 +5910,29 @@ function App() {
|
|||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{files.map((filePath, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full"
|
||||
>
|
||||
@{wikiLabel(filePath)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{message}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
<div className="flex flex-col items-end">
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{files.map((filePath, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full"
|
||||
>
|
||||
@{wikiLabel(filePath)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{message}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
<MessageCopyButton text={message} className="mt-0.5" />
|
||||
</div>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
|
@ -6173,6 +6180,20 @@ function App() {
|
|||
onOpenApps={openAppsView}
|
||||
recentRuns={runs}
|
||||
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||
onRenameRun={(rid, title) => {
|
||||
void window.ipc.invoke('sessions:setTitle', { sessionId: rid, title })
|
||||
.then(() => setRuns((prev) => prev.map((r) => (r.id === rid ? { ...r, title } : r))))
|
||||
.catch((err) => console.error('Failed to rename chat:', err))
|
||||
}}
|
||||
onDeleteRun={(rid) => {
|
||||
void window.ipc.invoke('sessions:delete', { sessionId: rid })
|
||||
.then(() => {
|
||||
setRuns((prev) => prev.filter((r) => r.id !== rid))
|
||||
const openTab = chatTabs.find((t) => t.runId === rid)
|
||||
if (openTab) closeChatTab(openTab.id)
|
||||
})
|
||||
.catch((err) => console.error('Failed to delete chat:', err))
|
||||
}}
|
||||
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||
onOpenEmail={(threadId) => openEmailView(threadId)}
|
||||
onOpenHome={() => void navigateToView({ type: 'home' })}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import {
|
|||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
|
@ -38,6 +40,37 @@ export const Message = ({ className, from, ...props }: MessageProps) => (
|
|||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* Minimal copy-to-clipboard affordance for a message bubble. Invisible until
|
||||
* the surrounding Message (`.group`) is hovered.
|
||||
*/
|
||||
export const MessageCopyButton = ({
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
}}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
|
|
@ -49,7 +82,7 @@ export const MessageContent = ({
|
|||
data-slot="message-content"
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-2xl group-[.is-user]:rounded-tr-md group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ const CARD_CSS = `
|
|||
--ma-pat-opacity:0.05; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.16;
|
||||
--ma-badge-mix:15%; --ma-pill-mix:13%; --ma-tint:20%; --ma-tint-hover:26%;
|
||||
}
|
||||
.ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; }
|
||||
.ma-h1 { font-size:clamp(19px,2.6cqw,24px); font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
|
||||
.ma-inner { max-width:1120px; margin:0 auto; padding:34px 30px 48px; }
|
||||
.ma-h1 { font-size:24px; font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
|
||||
.ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(14px,2cqw,20px); }
|
||||
.ma-tabs { display:flex; gap:6px; margin-bottom:clamp(14px,2cqw,22px); }
|
||||
.ma-tab { border:1px solid var(--ma-border); background:transparent; color:var(--ma-sub); border-radius:999px; padding:5px 14px; font-size:13px; font-weight:600; cursor:pointer; }
|
||||
|
|
|
|||
|
|
@ -466,7 +466,7 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul
|
|||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="shrink-0 border-b border-border px-4 py-2 flex items-center gap-3">
|
||||
<div className="shrink-0 border-b border-border pr-4 py-2 flex items-center gap-3">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||
|
|
@ -884,7 +884,7 @@ function NoteRow({
|
|||
|
||||
const row = (
|
||||
<tr
|
||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
className="border-b border-black/10 dark:border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectNote(note.path)}
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
|
|
|
|||
|
|
@ -1714,22 +1714,20 @@ export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlu
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border px-6 py-5">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
|
||||
<div className="mx-auto w-full max-w-[1120px] shrink-0 px-[30px] pt-[34px] pb-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">Background tasks</h2>
|
||||
</div>
|
||||
<h2 className="text-[24px] font-[650] tracking-[-0.02em] text-[#0d0e11] dark:text-[#f4f5f7]">Background tasks</h2>
|
||||
<Button size="sm" onClick={() => setShowNewDialog(true)}>
|
||||
New task
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[14px] text-black/50 dark:text-white/[0.52]">
|
||||
Persistent agents that fire on a schedule or in response to events. Toggle a task inactive to pause it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="mx-auto h-full w-full max-w-[1120px] px-[30px] pb-12">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
|
|
@ -1826,16 +1824,17 @@ export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlu
|
|||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{isRunning ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 pl-7">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-foreground animate-pulse">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Updating…
|
||||
Updating
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleStop(task.slug)}
|
||||
disabled={isStopping}
|
||||
className="h-auto gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
|
||||
>
|
||||
{isStopping ? <Loader2 className="size-3 animate-spin" /> : <Square className="size-3" />}
|
||||
Stop
|
||||
|
|
@ -1866,6 +1865,7 @@ export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlu
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewTaskDialog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Bot, Mail, Sparkles, Telescope } from 'lucide-react'
|
||||
import { ArrowRight, BookOpen, Mail, Zap } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ToolConnectionsCard } from '@/components/tool-connections-card'
|
||||
|
|
@ -12,53 +12,55 @@ interface ChatEmptyStateProps {
|
|||
|
||||
const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [
|
||||
{ icon: Mail, title: 'Draft a reply', sub: 'to an email', prompt: "Let's draft a reply to [name]'s email" },
|
||||
{ icon: Bot, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' },
|
||||
{ icon: Telescope, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' },
|
||||
{ icon: Zap, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' },
|
||||
{ icon: BookOpen, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Empty-state body for the chat surface: greeting and starter action cards.
|
||||
* Shown in both the side-pane copilot and full-screen chat.
|
||||
* Shown in both the side-pane copilot and full-screen chat; the side pane
|
||||
* (`wide` unset) uses slightly smaller type to fit the narrow column.
|
||||
*/
|
||||
export function ChatEmptyState({
|
||||
onPickPrompt,
|
||||
wide = false,
|
||||
}: ChatEmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('mx-auto flex w-full flex-col gap-6 px-2 py-6', wide ? 'max-w-2xl' : 'max-w-md')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-border bg-background text-foreground">
|
||||
<Sparkles className="size-[17px]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-tight">What are we working on?</div>
|
||||
<div className="text-xs text-muted-foreground">Ask anything, or start with a suggestion.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('mx-auto flex w-full flex-col gap-5 py-6', wide ? 'max-w-4xl px-4' : 'max-w-md px-2')}>
|
||||
<div>
|
||||
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Get started
|
||||
<div className={cn('font-semibold tracking-tight', wide ? 'text-2xl' : 'text-lg')}>
|
||||
What are we working on?
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{SUGGESTED_ACTIONS.map((action) => (
|
||||
<button
|
||||
key={action.title}
|
||||
type="button"
|
||||
onClick={() => onPickPrompt(action.prompt)}
|
||||
className="flex items-start gap-2.5 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<action.icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.8px] font-medium">{action.title}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{action.sub}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className={cn('mt-1 text-muted-foreground', wide ? 'text-[15px]' : 'text-[13px]')}>
|
||||
Ask anything, or start with a suggestion.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolConnectionsCard />
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
{SUGGESTED_ACTIONS.map((action, i) => (
|
||||
<button
|
||||
key={action.title}
|
||||
type="button"
|
||||
onClick={() => onPickPrompt(action.prompt)}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-1.5 text-left transition-colors hover:bg-accent/50',
|
||||
wide ? 'px-3.5 py-3' : 'px-3 py-2.5',
|
||||
i > 0 && 'border-t border-border/60',
|
||||
)}
|
||||
>
|
||||
<action.icon className={cn('mr-2 shrink-0 text-foreground/80', wide ? 'size-4' : 'size-3.5')} strokeWidth={1.75} />
|
||||
<span className={cn('shrink-0 font-medium text-foreground', wide ? 'text-sm' : 'text-[13px]')}>
|
||||
{action.title}
|
||||
</span>
|
||||
<span className={cn('truncate text-muted-foreground', wide ? 'text-[13px]' : 'text-[12px]')}>
|
||||
{action.sub}
|
||||
</span>
|
||||
<ArrowRight className={cn('ml-auto shrink-0 text-muted-foreground/50 transition-colors group-hover:text-foreground', wide ? 'size-3.5' : 'size-3')} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ToolConnectionsCard compact={!wide} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plus, Video } from 'lucide-react'
|
||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { ToolConnectionsCard } from '@/components/tool-connections-card'
|
||||
import { SlackIcon } from '@/components/onboarding/provider-icons'
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
|
|
@ -331,15 +332,12 @@ export function HomeView({
|
|||
|
||||
{/* Up-next hero */}
|
||||
{nextEvent && (
|
||||
<div className="flex items-center gap-[18px] rounded-xl bg-foreground p-[18px] text-background">
|
||||
<div className="flex size-[52px] shrink-0 items-center justify-center rounded-xl bg-background/10">
|
||||
<Mic className="size-[22px]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-[18px] rounded-xl bg-foreground px-5 py-[18px] text-background">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 text-[11px] uppercase tracking-wide text-background/55">
|
||||
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-background/50">
|
||||
Up next · {nextEvent.isAllDay ? 'today' : relativeFromNow(nextEvent.start)}
|
||||
</div>
|
||||
<div className="mb-0.5 truncate text-[17px] font-medium">{nextEvent.summary}</div>
|
||||
<div className="mb-0.5 truncate text-[17px] font-semibold">{nextEvent.summary}</div>
|
||||
<div className="truncate text-[13px] text-background/70">
|
||||
{nextEvent.isAllDay ? 'All day' : `${timeOfDay(nextEvent.start)}${nextEvent.end ? ` – ${timeOfDay(nextEvent.end)}` : ''}`}
|
||||
{nextEvent.location ? ` · ${nextEvent.location}` : ''}
|
||||
|
|
@ -432,7 +430,7 @@ export function HomeView({
|
|||
{slackEnabled && (
|
||||
<div className={CARD}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<MessageSquare className="size-[15px]" />
|
||||
<SlackIcon className="size-[15px]" />
|
||||
<span className="text-sm font-medium">Slack</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">Latest messages</span>
|
||||
|
|
@ -548,9 +546,6 @@ export function HomeView({
|
|||
onClick={onOpenChat}
|
||||
className="flex items-center gap-3.5 rounded-xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground">
|
||||
<MessageSquare className="size-[15px]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-[13.5px] leading-snug">
|
||||
<span className="font-medium">Ask anything</span>
|
||||
<span className="text-muted-foreground"> — create presentations, do research, collaborate on docs.</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
|
|
@ -70,20 +69,6 @@ const HIDDEN_PATHS = new Set(['knowledge/Meetings', 'knowledge/Workspace'])
|
|||
|
||||
// Theme-aware accent palette for folder avatars — colored letter on a faint
|
||||
// tint of the same hue. Mirrors the design's six-colour rotation.
|
||||
const AVATAR_PALETTE = [
|
||||
'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400',
|
||||
'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
'bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||
'bg-rose-500/10 text-rose-600 dark:text-rose-400',
|
||||
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
'bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||
] as const
|
||||
|
||||
function avatarClass(name: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0
|
||||
return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]
|
||||
}
|
||||
|
||||
function isMarkdown(node: TreeNode): boolean {
|
||||
return node.kind === 'file' && node.name.toLowerCase().endsWith('.md')
|
||||
|
|
@ -203,11 +188,11 @@ export function KnowledgeView({
|
|||
const currentFolder = folderPath ? findNode(tree, folderPath) : null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
|
||||
<div className="mx-auto w-full max-w-[1120px] shrink-0 flex items-start justify-between gap-4 px-[30px] pt-[34px] pb-5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Brain</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<h1 className="text-[24px] font-[650] tracking-[-0.02em] text-[#0d0e11] dark:text-[#f4f5f7]">Brain</h1>
|
||||
<p className="mt-1 text-[14px] text-black/50 dark:text-white/[0.52]">
|
||||
{totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '}
|
||||
{folders.length === 1 ? 'folder' : 'folders'}
|
||||
</p>
|
||||
|
|
@ -242,12 +227,12 @@ export function KnowledgeView({
|
|||
{graphContent}
|
||||
</div>
|
||||
) : mode === 'basis' ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="mx-auto flex w-full max-w-[1120px] flex-1 min-h-0 flex-col overflow-hidden px-[30px] pb-6">
|
||||
{basisContent}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-3xl px-8 py-6">
|
||||
<div className="mx-auto w-full max-w-[1120px] px-[30px] py-6">
|
||||
{currentFolder ? (
|
||||
<FolderDetail
|
||||
folder={currentFolder}
|
||||
|
|
@ -261,11 +246,11 @@ export function KnowledgeView({
|
|||
/>
|
||||
) : (
|
||||
<>
|
||||
<SectionHeader label={`Folders · ${folders.length}`} aside="Sorted by name" />
|
||||
<SectionHeader label={`Folders · ${folders.length}`} />
|
||||
{folders.length === 0 ? (
|
||||
<EmptyState text="No folders yet." />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="overflow-hidden rounded-xl border border-black/15 dark:border-border">
|
||||
{folders.map((node, i) => (
|
||||
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
|
||||
<FolderCard
|
||||
|
|
@ -285,7 +270,7 @@ export function KnowledgeView({
|
|||
{looseNotes.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<SectionHeader label={`Loose notes · ${looseNotes.length}`} />
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="overflow-hidden rounded-xl border border-black/15 dark:border-border">
|
||||
{looseNotes.map((node, i) => (
|
||||
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
|
||||
<ItemRow
|
||||
|
|
@ -398,9 +383,9 @@ function QuickAction({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
className="inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<Icon className="size-4" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
|
|
@ -409,7 +394,7 @@ function QuickAction({
|
|||
function SectionHeader({ label, aside }: { label: string; aside?: string }) {
|
||||
return (
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{aside && <span className="text-xs text-muted-foreground">{aside}</span>}
|
||||
|
|
@ -425,20 +410,6 @@ function EmptyState({ text }: { text: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function FolderAvatar({ name, className }: { name: string; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md text-[13px] font-bold',
|
||||
avatarClass(name),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{name.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderCard({
|
||||
node,
|
||||
actions,
|
||||
|
|
@ -472,9 +443,8 @@ function FolderCard({
|
|||
onOpenFolder(node.path)
|
||||
}
|
||||
}}
|
||||
className="group flex w-full cursor-pointer items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
className="group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<FolderAvatar name={node.name} className="mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{renameActive ? (
|
||||
<RenameField
|
||||
|
|
@ -489,28 +459,32 @@ function FolderCard({
|
|||
{node.name}
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{count} {count === 1 ? 'note' : 'notes'}
|
||||
<div className="mt-0.5 flex min-w-0 items-baseline gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="shrink-0">
|
||||
{count} {count === 1 ? 'note' : 'notes'}
|
||||
</span>
|
||||
{peek.length > 0 && (
|
||||
<span className="truncate text-muted-foreground/70">
|
||||
{peek.map((n) => (
|
||||
<span key={n.path}>
|
||||
<span className="text-muted-foreground/40">{' · '}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenNote(n.path)
|
||||
}}
|
||||
className="transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
{displayName(n)}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{peek.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{peek.map((n) => (
|
||||
<button
|
||||
key={n.path}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenNote(n.path)
|
||||
}}
|
||||
className="max-w-[200px] truncate rounded-full border border-border/60 bg-muted px-2.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{displayName(n)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 pt-1">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{modified}
|
||||
</span>
|
||||
|
|
@ -565,17 +539,6 @@ function FolderDetail({
|
|||
return (
|
||||
<>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
|
||||
onNavigate(parent)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(null)}
|
||||
|
|
@ -605,7 +568,7 @@ function FolderDetail({
|
|||
{items.length === 0 ? (
|
||||
<EmptyState text="This folder is empty." />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="overflow-hidden rounded-xl border border-black/15 dark:border-border">
|
||||
{items.map((node, i) => (
|
||||
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
|
||||
<ItemRow
|
||||
|
|
@ -665,13 +628,6 @@ function ItemRow({
|
|||
}}
|
||||
className="group flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
{isDir ? (
|
||||
<FolderAvatar name={node.name} />
|
||||
) : (
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{renameActive ? (
|
||||
<RenameField
|
||||
|
|
@ -682,16 +638,21 @@ function ItemRow({
|
|||
onDone={onClearRename}
|
||||
/>
|
||||
) : (
|
||||
<span className="block truncate text-sm text-foreground">{displayName(node)}</span>
|
||||
)}
|
||||
{isDir && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{count} {count === 1 ? 'note' : 'notes'}
|
||||
</div>
|
||||
<span className="block truncate text-sm font-semibold text-foreground">
|
||||
{displayName(node)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{isDir && (
|
||||
<>
|
||||
<span className="whitespace-nowrap">
|
||||
{count} {count === 1 ? 'note' : 'notes'}
|
||||
</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="tabular-nums whitespace-nowrap">
|
||||
{modified}
|
||||
</span>
|
||||
{isDir && (
|
||||
|
|
|
|||
|
|
@ -711,8 +711,8 @@ function UpcomingEvents({ onOpenNote }: { onOpenNote: (path: string) => void })
|
|||
const todayKey = localDateKey(now)
|
||||
|
||||
return (
|
||||
<section className="border-b border-border/60 px-6 pb-6 pt-5">
|
||||
<div className="mx-auto w-full max-w-[760px]">
|
||||
<section className="border-b border-border/60 pb-6 pt-5">
|
||||
<div className="w-full">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<Calendar className="size-4 text-muted-foreground" />
|
||||
|
|
@ -1243,13 +1243,10 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
const isRecording = meetingState === 'recording'
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border px-6 py-5">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
|
||||
<div className="mx-auto w-full max-w-[1120px] shrink-0 px-[30px] pt-[34px] pb-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">Meetings</h2>
|
||||
</div>
|
||||
<h2 className="text-[24px] font-[650] tracking-[-0.02em] text-[#0d0e11] dark:text-[#f4f5f7]">Meetings</h2>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
@ -1267,13 +1264,14 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
{meetingSummarizing ? 'Generating notes...' : getMeetingButtonLabel(meetingState)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[14px] text-black/50 dark:text-white/[0.52]">
|
||||
Upcoming events and meeting notes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="mx-auto w-full max-w-[1120px] px-[30px] pb-12">
|
||||
<UpcomingEvents onOpenNote={onOpenNote} />
|
||||
<div className="p-6">
|
||||
<div className="pt-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
|
|
@ -1329,6 +1327,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -75,6 +75,38 @@ export function SlackIcon({ className }: IconProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export function GitHubIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscordIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" fill="#5865F2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WhatsAppIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" fill="#25D366" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TelegramIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" fill="#26A5E4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FirefliesIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
|
|
|
|||
|
|
@ -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, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } 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, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -23,6 +23,7 @@ import { Switch } from "@/components/ui/switch"
|
|||
import { cn } from "@/lib/utils"
|
||||
import { useTheme } from "@/contexts/theme-context"
|
||||
import { toast } from "sonner"
|
||||
import { AnthropicIcon, DiscordIcon, GenericApiIcon, GitHubIcon, GoogleIcon, OllamaIcon, OpenAIIcon, OpenRouterIcon, VercelIcon } from "@/components/onboarding/provider-icons"
|
||||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
|
||||
|
|
@ -112,6 +113,13 @@ const tabs: TabConfig[] = [
|
|||
},
|
||||
]
|
||||
|
||||
/** Sidebar nav grouping: identity first, capabilities, then app-level. */
|
||||
const NAV_SECTIONS: { label: string | null; ids: ConfigTab[] }[] = [
|
||||
{ label: null, ids: ["account", "connections", "mobile"] },
|
||||
{ label: "Configure", ids: ["models", "mcp", "security", "code-mode", "note-tagging"] },
|
||||
{ label: "App", ids: ["appearance", "notifications", "help"] },
|
||||
]
|
||||
|
||||
interface SettingsDialogProps {
|
||||
/** Optional trigger element. Omit when controlling `open` externally. */
|
||||
children?: React.ReactNode
|
||||
|
|
@ -136,9 +144,7 @@ function HelpSettings() {
|
|||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
|
||||
<Bug className="size-4 text-destructive" />
|
||||
</div>
|
||||
<GitHubIcon className="size-5 shrink-0" />
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">Report a bug</span>
|
||||
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
|
||||
|
|
@ -149,9 +155,7 @@ function HelpSettings() {
|
|||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
onClick={() => window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
|
||||
<MessageCircle className="size-4 text-white" />
|
||||
</div>
|
||||
<DiscordIcon className="size-5 shrink-0" />
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">Join our Discord</span>
|
||||
<span className="text-xs text-muted-foreground">Chat with the community</span>
|
||||
|
|
@ -162,9 +166,7 @@ function HelpSettings() {
|
|||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
onClick={() => window.open("mailto:contact@rowboatlabs.com", "_blank")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<Mail className="size-5 shrink-0" />
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">Contact us</span>
|
||||
<span className="text-xs text-muted-foreground">contact@rowboatlabs.com</span>
|
||||
|
|
@ -313,17 +315,17 @@ interface LlmModelOption {
|
|||
release_date?: string
|
||||
}
|
||||
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "GPT models" },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models" },
|
||||
{ id: "google", name: "Gemini", description: "Google AI Studio" },
|
||||
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally" },
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "GPT models", icon: OpenAIIcon },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models", icon: AnthropicIcon },
|
||||
{ id: "google", name: "Gemini", description: "Google AI Studio", icon: GoogleIcon },
|
||||
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally", icon: OllamaIcon },
|
||||
]
|
||||
|
||||
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key" },
|
||||
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway" },
|
||||
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API" },
|
||||
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", icon: OpenRouterIcon },
|
||||
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway", icon: VercelIcon },
|
||||
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API", icon: GenericApiIcon },
|
||||
]
|
||||
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
|
|
@ -647,7 +649,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
}
|
||||
}, [defaultProvider, providerConfigs, rowboatConnected])
|
||||
|
||||
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => {
|
||||
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
|
||||
const isDefault = defaultProvider === p.id
|
||||
const isSelected = provider === p.id
|
||||
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
|
||||
|
|
@ -665,7 +667,8 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
: "border-border hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p.icon className="size-4 shrink-0" />
|
||||
<span className="text-sm font-medium">{p.name}</span>
|
||||
{isDefault && !rowboatConnected && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
|
||||
|
|
@ -1869,7 +1872,11 @@ function AgentStatusRow({
|
|||
const ready = installed && status?.signedIn
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
|
||||
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
||||
{agent === 'claude' ? (
|
||||
<AnthropicIcon className="size-5 shrink-0" />
|
||||
) : (
|
||||
<OpenAIIcon className="size-5 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
|
||||
|
|
@ -2323,34 +2330,47 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<div className="flex h-full overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="w-48 border-r bg-muted/30 p-2 flex flex-col">
|
||||
<div className="px-2 py-3 mb-2">
|
||||
<h2 className="font-semibold text-sm">Settings</h2>
|
||||
<div className="px-2 pt-3.5 pb-3 mb-2">
|
||||
<h2 className="font-semibold text-base tracking-tight">Settings</h2>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-2 rounded-md text-sm transition-colors text-left",
|
||||
activeTab === tab.id
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="size-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<nav className="flex flex-col">
|
||||
{NAV_SECTIONS.map((section) => {
|
||||
const sectionTabs = visibleTabs.filter((tab) => section.ids.includes(tab.id))
|
||||
if (sectionTabs.length === 0) return null
|
||||
return (
|
||||
<div key={section.label ?? "main"} className="flex flex-col gap-0.5">
|
||||
{section.label ? (
|
||||
<div className="px-2 pb-1 pt-4 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
{section.label}
|
||||
</div>
|
||||
) : null}
|
||||
{sectionTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors text-left",
|
||||
activeTab === tab.id
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="size-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<h3 className="font-medium text-sm">{activeTabConfig.label}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<div className="px-6 pb-4 pt-5">
|
||||
<h3 className="text-lg font-semibold tracking-tight">{activeTabConfig.label}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{activeTab === "models" && rowboatConnected
|
||||
? "Select your default models"
|
||||
: activeTabConfig.description}
|
||||
|
|
@ -2358,7 +2378,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 px-6 pb-5 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connections" ? (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react"
|
||||
import { Loader2, Calendar } from "lucide-react"
|
||||
import { FirefliesIcon, GoogleIcon, SlackIcon } from "@/components/onboarding/provider-icons"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
|
|
@ -47,9 +47,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
{icon}
|
||||
</div>
|
||||
{icon}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">{displayName}</span>
|
||||
{state.isLoading ? (
|
||||
|
|
@ -145,9 +143,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
{c.useComposioForGoogle ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<GoogleIcon className="size-5 shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Gmail</span>
|
||||
{c.gmailLoading ? (
|
||||
|
|
@ -189,14 +185,12 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
||||
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <GoogleIcon className="size-5" />, 'Sync emails and calendar')
|
||||
)}
|
||||
{c.useComposioForGoogleCalendar && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Calendar className="size-4" />
|
||||
</div>
|
||||
<Calendar className="size-5 shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Google Calendar</span>
|
||||
{c.googleCalendarLoading ? (
|
||||
|
|
@ -238,7 +232,6 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -252,14 +245,13 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
</div>
|
||||
|
||||
{/* Fireflies */}
|
||||
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
{renderOAuthProvider('fireflies-ai', 'Fireflies', <FirefliesIcon className="size-5" />, 'AI meeting transcripts')}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Team Communication Section */}
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="px-3 pt-1 pb-0.5">
|
||||
<div className="px-3 pt-3 pb-0.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Team Communication
|
||||
</span>
|
||||
|
|
@ -267,9 +259,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<SlackIcon className="size-5 shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{c.slackLoading ? (
|
||||
|
|
@ -408,8 +398,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
{/* Knowledge Sources Section */}
|
||||
{c.slackEnabled && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="px-3 pt-1 pb-0.5">
|
||||
<div className="px-3 pt-3 pb-0.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Knowledge Sources
|
||||
</span>
|
||||
|
|
@ -417,9 +406,7 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<SlackIcon className="size-5 shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack to knowledge</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type { z } from "zod"
|
||||
import { Coffee, Loader2, MessageCircle, Send, Smartphone } from "lucide-react"
|
||||
import { Coffee, Loader2, Smartphone } from "lucide-react"
|
||||
import { TelegramIcon, WhatsAppIcon } from "@/components/onboarding/provider-icons"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "sonner"
|
||||
import type { ChannelsConfig, ChannelsStatus } from "@x/shared/src/channels.js"
|
||||
|
|
@ -104,9 +104,7 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean })
|
|||
{/* Caffeinate */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Coffee className="size-4" />
|
||||
</div>
|
||||
<Coffee className="size-5 shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Caffeinate</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
@ -131,15 +129,12 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean })
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* WhatsApp */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageCircle className="size-4" />
|
||||
</div>
|
||||
<WhatsAppIcon className="size-5 shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">WhatsApp</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
@ -228,15 +223,12 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean })
|
|||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Telegram */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Send className="size-4" />
|
||||
</div>
|
||||
<TelegramIcon className="size-5 shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Telegram</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import {
|
|||
Home,
|
||||
LayoutGrid,
|
||||
Mic,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Pin,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
Plug,
|
||||
LoaderIcon,
|
||||
Mail,
|
||||
|
|
@ -61,6 +65,12 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
|
|
@ -127,6 +137,8 @@ type ServiceEventType = z.infer<typeof ServiceEvent>
|
|||
|
||||
const MAX_SYNC_EVENTS = 1000
|
||||
const RUN_STALE_MS = 2 * 60 * 60 * 1000
|
||||
const PINNED_CHATS_STORAGE_KEY = 'x:pinned-chats'
|
||||
const MAX_PINNED_CHATS = 3
|
||||
|
||||
const SERVICE_LABELS: Record<string, string> = {
|
||||
gmail: "Syncing Gmail",
|
||||
|
|
@ -171,6 +183,10 @@ type SidebarContentPanelProps = {
|
|||
onOpenAgent?: (slug: string) => void
|
||||
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
|
||||
onOpenRun?: (runId: string) => void
|
||||
/** Persist a custom chat title (sessions:setTitle) and refresh the runs list. */
|
||||
onRenameRun?: (runId: string, title: string) => void
|
||||
/** Delete the chat's session (sessions:delete) and refresh the runs list. */
|
||||
onDeleteRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
onOpenEmail?: (threadId?: string) => void
|
||||
onOpenHome?: () => void
|
||||
|
|
@ -422,6 +438,8 @@ export function SidebarContentPanel({
|
|||
onOpenApps,
|
||||
recentRuns = [],
|
||||
onOpenRun,
|
||||
onRenameRun,
|
||||
onDeleteRun,
|
||||
onOpenChatHistory,
|
||||
onOpenEmail,
|
||||
onOpenHome,
|
||||
|
|
@ -548,16 +566,54 @@ export function SidebarContentPanel({
|
|||
.slice(0, 10)
|
||||
}, [tree])
|
||||
|
||||
// Chats: the 5 most recently modified chats, newest first.
|
||||
// Pinned chats: a per-machine UI preference, persisted in localStorage.
|
||||
const [pinnedChatIds, setPinnedChatIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PINNED_CHATS_STORAGE_KEY)
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const toggleChatPin = useCallback((chatId: string) => {
|
||||
const isPinned = pinnedChatIds.includes(chatId)
|
||||
if (!isPinned && pinnedChatIds.length >= MAX_PINNED_CHATS) {
|
||||
toast(`You can pin up to ${MAX_PINNED_CHATS} chats`, 'error')
|
||||
return
|
||||
}
|
||||
const next = isPinned ? pinnedChatIds.filter((id) => id !== chatId) : [...pinnedChatIds, chatId]
|
||||
try {
|
||||
window.localStorage.setItem(PINNED_CHATS_STORAGE_KEY, JSON.stringify(next))
|
||||
} catch { /* ignore */ }
|
||||
setPinnedChatIds(next)
|
||||
}, [pinnedChatIds])
|
||||
|
||||
// Chats: pinned first, then the most recently modified, 10 rows total.
|
||||
const recentChats = React.useMemo(() => {
|
||||
const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => {
|
||||
const ms = new Date(r.modifiedAt ?? r.createdAt).getTime()
|
||||
return Number.isFinite(ms) ? ms : 0
|
||||
}
|
||||
return [...recentRuns]
|
||||
.sort((a, b) => chatRecency(b) - chatRecency(a))
|
||||
.slice(0, 10)
|
||||
}, [recentRuns])
|
||||
const sorted = [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a))
|
||||
const pinned = sorted.filter((r) => pinnedChatIds.includes(r.id))
|
||||
const rest = sorted.filter((r) => !pinnedChatIds.includes(r.id))
|
||||
return [...pinned, ...rest.slice(0, Math.max(0, 10 - pinned.length))]
|
||||
}, [recentRuns, pinnedChatIds])
|
||||
|
||||
// Chat pending delete confirmation, if any.
|
||||
const [deleteChatTarget, setDeleteChatTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
|
||||
// Inline chat rename: which row is editing and its draft text.
|
||||
const [renamingChatId, setRenamingChatId] = useState<string | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
const commitChatRename = useCallback((chatId: string) => {
|
||||
const title = renameDraft.trim()
|
||||
const current = recentChats.find((c) => c.id === chatId)
|
||||
setRenamingChatId(null)
|
||||
if (!title || title === (current?.title ?? '')) return
|
||||
onRenameRun?.(chatId, title)
|
||||
}, [renameDraft, recentChats, onRenameRun])
|
||||
|
||||
// Workspace count for the Workspaces sublabel — top-level dir children of
|
||||
// knowledge/Workspace (matches WorkspaceView's root listing).
|
||||
|
|
@ -737,9 +793,16 @@ export function SidebarContentPanel({
|
|||
{/* Top spacer to clear the traffic lights + fixed toggle row */}
|
||||
<div className="h-8" />
|
||||
{/* Quick actions */}
|
||||
<div className="titlebar-no-drag flex items-center gap-1.5 px-3 pb-2">
|
||||
<div className="titlebar-no-drag flex items-center gap-1 pl-3 pr-6 pb-2">
|
||||
{onNewChat && (
|
||||
<ActionButton icon={SquarePen} label="New chat" onClick={onNewChat} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-md border border-sidebar-border text-[13px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
<SquarePen className="size-3.5" />
|
||||
New chat
|
||||
</button>
|
||||
)}
|
||||
<ActionButton icon={FilePlus} label="New note" onClick={() => knowledgeActions.createNote()} />
|
||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} variant="action" />
|
||||
|
|
@ -764,9 +827,9 @@ export function SidebarContentPanel({
|
|||
data-tour-id="nav-email"
|
||||
isActive={activeNav === 'email'}
|
||||
onClick={() => onOpenEmail?.()}
|
||||
className={previewEmail ? 'h-auto py-1.5' : undefined}
|
||||
className={previewEmail ? 'h-auto items-start py-1.5' : undefined}
|
||||
>
|
||||
<Mail className="size-4 shrink-0" />
|
||||
<Mail className={cn('size-4 shrink-0', previewEmail && 'mt-0.5')} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">Email</span>
|
||||
{previewEmail && (
|
||||
|
|
@ -782,14 +845,22 @@ export function SidebarContentPanel({
|
|||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{codeModeEnabled && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton data-tour-id="nav-code" isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<Code2 className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Code</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-meetings"
|
||||
isActive={activeNav === 'meetings'}
|
||||
onClick={onOpenMeetings}
|
||||
className={meetingSublabel ? 'h-auto py-1.5' : undefined}
|
||||
className={meetingSublabel ? 'h-auto items-start py-1.5' : undefined}
|
||||
>
|
||||
<Mic className={cn('size-4 shrink-0', meetingIsRecording && 'text-red-500')} />
|
||||
<Mic className={cn('size-4 shrink-0', meetingSublabel && 'mt-1', meetingIsRecording && 'text-red-500')} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">Meetings</span>
|
||||
{meetingSublabel && (
|
||||
|
|
@ -861,22 +932,14 @@ export function SidebarContentPanel({
|
|||
</div>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
{codeModeEnabled && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton data-tour-id="nav-code" isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<Code2 className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Code</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-knowledge"
|
||||
isActive={activeNav === 'knowledge'}
|
||||
onClick={() => knowledgeActions.openKnowledgeView()}
|
||||
className={knowledgeUpdatedLabel ? 'h-auto py-1.5' : undefined}
|
||||
className={knowledgeUpdatedLabel ? 'h-auto items-start py-1.5' : undefined}
|
||||
>
|
||||
<FileText className="size-4 shrink-0" />
|
||||
<FileText className={cn('size-4 shrink-0', knowledgeUpdatedLabel && 'mt-0.5')} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">Brain</span>
|
||||
{knowledgeUpdatedLabel && (
|
||||
|
|
@ -890,16 +953,26 @@ export function SidebarContentPanel({
|
|||
<div className="mx-3 my-2 border-t border-sidebar-border" />
|
||||
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-apps"
|
||||
isActive={activeNav === 'apps'}
|
||||
onClick={onOpenApps}
|
||||
>
|
||||
<LayoutGrid className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Apps</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-agents"
|
||||
isActive={activeNav === 'agents'}
|
||||
onClick={onOpenBgTasks}
|
||||
className={bgAgentsLabel ? 'h-auto py-1.5' : undefined}
|
||||
className={bgAgentsLabel ? 'h-auto items-start py-1.5' : undefined}
|
||||
>
|
||||
<Bot className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Bot className={cn('size-4 shrink-0', bgAgentsLabel && 'mt-0.5')} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-muted-foreground">Background agents</span>
|
||||
<span className="truncate">Background agents</span>
|
||||
{bgAgentsLabel && (
|
||||
<span className={cn(
|
||||
'truncate text-[11px]',
|
||||
|
|
@ -911,26 +984,16 @@ export function SidebarContentPanel({
|
|||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-apps"
|
||||
isActive={activeNav === 'apps'}
|
||||
onClick={onOpenApps}
|
||||
>
|
||||
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-muted-foreground">Apps</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-workspaces"
|
||||
isActive={activeNav === 'workspaces'}
|
||||
onClick={() => knowledgeActions.openWorkspaceAt()}
|
||||
className="h-auto py-1.5"
|
||||
className="h-auto items-start py-1.5"
|
||||
>
|
||||
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Folder className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-muted-foreground">Workspaces</span>
|
||||
<span className="truncate">Workspaces</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{workspaceCount === 0 ? 'No workspaces' : `${workspaceCount} workspace${workspaceCount === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
|
|
@ -964,10 +1027,75 @@ export function SidebarContentPanel({
|
|||
<SidebarMenu>
|
||||
{recentChats.map((chat) => (
|
||||
<SidebarMenuItem key={chat.id}>
|
||||
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)}>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
|
||||
</SidebarMenuButton>
|
||||
{renamingChatId === chat.id ? (
|
||||
<div className="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitChatRename(chat.id)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setRenamingChatId(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => commitChatRename(chat.id)}
|
||||
className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)} className={onRenameRun ? 'pr-7' : undefined}>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
|
||||
{pinnedChatIds.includes(chat.id) && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70 transition-opacity group-hover/menu-item:opacity-0" />
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
{onRenameRun && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Chat options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/menu-item:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
<DropdownMenuItem onClick={() => toggleChatPin(chat.id)}>
|
||||
<Pin className="mr-2 size-3.5" />
|
||||
{pinnedChatIds.includes(chat.id) ? 'Unpin' : 'Pin'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setRenameDraft(chat.title || '')
|
||||
setRenamingChatId(chat.id)
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 size-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
{onDeleteRun && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteChatTarget({ id: chat.id, title: chat.title || '(Untitled chat)' })}
|
||||
>
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{onOpenChatHistory && (
|
||||
|
|
@ -986,6 +1114,28 @@ export function SidebarContentPanel({
|
|||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<AlertDialog open={!!deleteChatTarget} onOpenChange={(open) => { if (!open) setDeleteChatTarget(null) }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete chat?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
“{deleteChatTarget?.title}” and its full history will be permanently deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (deleteChatTarget) onDeleteRun?.(deleteChatTarget.id)
|
||||
setDeleteChatTarget(null)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SidebarContent>
|
||||
{/* Billing / upgrade CTA or Log in CTA */}
|
||||
{isRowboatConnected && billing ? (() => {
|
||||
|
|
@ -1398,7 +1548,7 @@ path: ${currentRelativePath}
|
|||
|
||||
if (!hasDeepgramKey) return null
|
||||
|
||||
const actionClass = "flex h-9 flex-1 items-center justify-center rounded-md border border-sidebar-border text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
const actionClass = "flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
const iconClass = "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
|
||||
return (
|
||||
|
|
@ -1432,7 +1582,7 @@ function ActionButton({ icon: Icon, label, onClick }: { icon: typeof Mic; label:
|
|||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className="flex h-9 flex-1 items-center justify-center rounded-md border border-sidebar-border text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowRight, Plug } from 'lucide-react'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -55,7 +55,7 @@ function ToolkitPreviewIcon({
|
|||
)
|
||||
}
|
||||
|
||||
export function ToolConnectionsCard({ className }: { className?: string }) {
|
||||
export function ToolConnectionsCard({ className, compact = false }: { className?: string; compact?: boolean }) {
|
||||
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
|
||||
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
|
||||
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
|
||||
|
|
@ -101,11 +101,8 @@ export function ToolConnectionsCard({ className }: { className?: string }) {
|
|||
<>
|
||||
<div className={cn('rounded-xl border border-border bg-card p-4', className)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
|
||||
<Plug className="size-[14px]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13.5px] leading-snug">
|
||||
<div className={cn('leading-snug', compact ? 'text-[12.5px]' : 'text-[13.5px]')}>
|
||||
<span className="text-muted-foreground">Bring context from and take action in the apps you already use.</span>
|
||||
</div>
|
||||
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
|
||||
|
|
@ -119,7 +116,10 @@ export function ToolConnectionsCard({ className }: { className?: string }) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectionsSettingsOpen(true)}
|
||||
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
|
||||
className={cn(
|
||||
'ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 font-medium text-primary hover:underline',
|
||||
compact ? 'text-[11.5px]' : 'text-[12px]',
|
||||
)}
|
||||
>
|
||||
Connections
|
||||
<ArrowRight className="size-3" />
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ function DialogOverlay({
|
|||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
|||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
Folder as FolderIcon,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Home,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
|
|
@ -365,19 +364,18 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
}, [newName, onCreateWorkspace, resetAddDialog])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
|
||||
<div className="mx-auto flex w-full max-w-[1120px] shrink-0 items-center justify-between gap-3 pl-[22px] pr-[30px] pt-[30px] pb-4">
|
||||
<div className="flex min-w-0 items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(WORKSPACE_ROOT)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors',
|
||||
isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
'inline-flex items-center rounded-md px-2 py-1 transition-colors',
|
||||
isRoot ? 'text-[#0d0e11] dark:text-[#f4f5f7]' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Home className="size-4" />
|
||||
<span className="font-medium">Workspace</span>
|
||||
<span className="text-[24px] font-[650] tracking-[-0.02em]">Workspace</span>
|
||||
</button>
|
||||
{breadcrumbs.map((crumb, idx) => {
|
||||
const isLast = idx === breadcrumbs.length - 1
|
||||
|
|
@ -482,12 +480,13 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div
|
||||
className="relative flex-1 overflow-y-auto px-6 py-6"
|
||||
className="relative flex-1 overflow-y-auto"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="mx-auto h-full w-full max-w-[1120px] px-[30px] py-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
|
||||
<FolderIcon className="size-10 opacity-50" />
|
||||
|
|
@ -594,6 +593,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dropEnabled && isDraggingOver && (
|
||||
<div className="pointer-events-none absolute inset-3 z-10 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5 text-primary">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue