mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
commit
7835b3b5dd
5 changed files with 167 additions and 61 deletions
|
|
@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
|||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { Button } from './components/ui/button';
|
||||
import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor } from './components/markdown-editor';
|
||||
import { ChatInputBar } from './components/chat-button';
|
||||
|
|
@ -125,6 +125,14 @@ const graphPalette = [
|
|||
{ hue: 0, sat: 72, light: 52 },
|
||||
]
|
||||
|
||||
const MACOS_TRAFFIC_LIGHTS_RESERVED_PX = 16 + 12 * 3 + 8 * 2
|
||||
const TITLEBAR_BUTTON_PX = 32
|
||||
const TITLEBAR_BUTTON_GAP_PX = 4
|
||||
const TITLEBAR_HEADER_GAP_PX = 8
|
||||
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
|
||||
const TITLEBAR_BUTTONS_COLLAPSED = 4
|
||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value))
|
||||
|
||||
|
|
@ -461,61 +469,90 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
|||
return true // both graph
|
||||
}
|
||||
|
||||
/** Traffic light placeholders + toggle button + back/forward nav, fixed next to macOS traffic lights */
|
||||
/** Sidebar toggle + back/forward nav */
|
||||
function FixedSidebarToggle({
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
canNavigateBack,
|
||||
canNavigateForward,
|
||||
onNewChat,
|
||||
leftInsetPx,
|
||||
}: {
|
||||
onNavigateBack: () => void
|
||||
onNavigateForward: () => void
|
||||
canNavigateBack: boolean
|
||||
canNavigateForward: boolean
|
||||
onNewChat: () => void
|
||||
leftInsetPx: number
|
||||
}) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar, state } = useSidebar()
|
||||
const isCollapsed = state === "collapsed"
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
{/* Placeholder dots that show through when traffic lights are hidden (window unfocused) */}
|
||||
<div className="flex items-center gap-2 pl-[13px]">
|
||||
<div className="h-3 w-3 rounded-full bg-border" />
|
||||
<div className="h-3 w-3 rounded-full bg-border" />
|
||||
<div className="h-3 w-3 rounded-full bg-border" />
|
||||
</div>
|
||||
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
|
||||
{/* Sidebar toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebar}
|
||||
className="ml-2.5 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
style={{ marginLeft: TITLEBAR_TOGGLE_MARGIN_LEFT_PX }}
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
<PanelLeftIcon className="size-4" />
|
||||
<PanelLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label="New chat"
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</button>
|
||||
{/* Back / Forward navigation */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateBack}
|
||||
disabled={!canNavigateBack}
|
||||
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateForward}
|
||||
disabled={!canNavigateForward}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Go forward"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</button>
|
||||
{isCollapsed && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateBack}
|
||||
disabled={!canNavigateBack}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateForward}
|
||||
disabled={!canNavigateForward}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Go forward"
|
||||
>
|
||||
<ChevronRightIcon className="size-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Main content header that adjusts padding based on sidebar state */
|
||||
function ContentHeader({ children }: { children: React.ReactNode }) {
|
||||
function ContentHeader({
|
||||
children,
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
canNavigateBack,
|
||||
canNavigateForward,
|
||||
collapsedLeftPaddingPx,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onNavigateBack?: () => void
|
||||
onNavigateForward?: () => void
|
||||
canNavigateBack?: boolean
|
||||
canNavigateForward?: boolean
|
||||
collapsedLeftPaddingPx?: number
|
||||
}) {
|
||||
const { state } = useSidebar()
|
||||
const isCollapsed = state === "collapsed"
|
||||
return (
|
||||
|
|
@ -524,9 +561,35 @@ function ContentHeader({ children }: { children: React.ReactNode }) {
|
|||
"titlebar-drag-region flex h-10 shrink-0 items-center gap-2 border-b border-border px-3 bg-sidebar transition-[padding] duration-200 ease-linear",
|
||||
// When the sidebar is collapsed the content area shifts left, so we need enough left padding
|
||||
// to avoid overlapping the fixed traffic-lights/toggle/back/forward controls.
|
||||
isCollapsed && "pl-[168px]"
|
||||
isCollapsed && !collapsedLeftPaddingPx && "pl-[168px]"
|
||||
)}
|
||||
style={isCollapsed && collapsedLeftPaddingPx ? { paddingLeft: collapsedLeftPaddingPx } : undefined}
|
||||
>
|
||||
{!isCollapsed && onNavigateBack && onNavigateForward ? (
|
||||
<div className="titlebar-no-drag flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateBack}
|
||||
disabled={!canNavigateBack}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateForward}
|
||||
disabled={!canNavigateForward}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Go forward"
|
||||
>
|
||||
<ChevronRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{onNavigateBack && onNavigateForward ? (
|
||||
<div className="titlebar-no-drag self-stretch w-px bg-border/70" aria-hidden="true" />
|
||||
) : null}
|
||||
{children}
|
||||
</header>
|
||||
)
|
||||
|
|
@ -550,6 +613,13 @@ function App() {
|
|||
const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const [graphError, setGraphError] = useState<string | null>(null)
|
||||
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac')
|
||||
const collapsedLeftPaddingPx =
|
||||
(isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0) +
|
||||
TITLEBAR_TOGGLE_MARGIN_LEFT_PX +
|
||||
TITLEBAR_BUTTON_PX * TITLEBAR_BUTTONS_COLLAPSED +
|
||||
TITLEBAR_BUTTON_GAP_PX * TITLEBAR_BUTTON_GAPS_COLLAPSED +
|
||||
TITLEBAR_HEADER_GAP_PX
|
||||
|
||||
// Keep the latest selected path in a ref (avoids stale async updates when switching rapidly)
|
||||
const selectedPathRef = useRef<string | null>(null)
|
||||
|
|
@ -2216,7 +2286,13 @@ function App() {
|
|||
/>
|
||||
<SidebarInset className="overflow-hidden! min-h-0">
|
||||
{/* Header - also serves as titlebar drag region, adjusts padding when sidebar collapsed */}
|
||||
<ContentHeader>
|
||||
<ContentHeader
|
||||
onNavigateBack={() => { void navigateBack() }}
|
||||
onNavigateForward={() => { void navigateForward() }}
|
||||
canNavigateBack={canNavigateBack}
|
||||
canNavigateForward={canNavigateForward}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
>
|
||||
<span className="text-sm font-medium text-muted-foreground flex-1 min-w-0 truncate">
|
||||
{headerTitle}
|
||||
</span>
|
||||
|
|
@ -2249,20 +2325,20 @@ function App() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleCloseFullScreenChat}
|
||||
className="titlebar-no-drag flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
aria-label="Return to file"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
{(selectedPath || isGraphOpen) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsChatSidebarOpen(!isChatSidebarOpen)}
|
||||
className="titlebar-no-drag flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors -mr-1"
|
||||
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors -mr-1"
|
||||
aria-label="Toggle Chat Sidebar"
|
||||
>
|
||||
<PanelRightIcon className="size-4" />
|
||||
<PanelRightIcon className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
</ContentHeader>
|
||||
|
|
@ -2447,6 +2523,8 @@ function App() {
|
|||
onNavigateForward={() => { void navigateForward() }}
|
||||
canNavigateBack={canNavigateBack}
|
||||
canNavigateForward={canNavigateForward}
|
||||
onNewChat={handleNewChat}
|
||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
// Granola state
|
||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
// Composio/Slack state
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
|
|
@ -158,9 +159,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
|
||||
// Preferred default models for each provider
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-5-20251101",
|
||||
}
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
}
|
||||
|
||||
// Initialize default models from catalog
|
||||
useEffect(() => {
|
||||
|
|
@ -445,8 +446,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
startConnect('google', clientId)
|
||||
}, [startConnect])
|
||||
|
||||
// Step indicator component
|
||||
const StepIndicator = () => (
|
||||
// Step indicator
|
||||
const renderStepIndicator = () => (
|
||||
<div className="flex gap-2 justify-center mb-6">
|
||||
{[0, 1, 2].map((step) => (
|
||||
<div
|
||||
|
|
@ -587,9 +588,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
*/
|
||||
|
||||
// Step 0: LLM Setup
|
||||
const LlmSetupStep = () => {
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
const renderLlmSetupStep = () => {
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "Use your OpenAI API key" },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Use your Anthropic API key" },
|
||||
|
|
@ -745,7 +744,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
|
||||
// Step 1: Connect Accounts
|
||||
const AccountConnectionStep = () => (
|
||||
const renderAccountConnectionStep = () => (
|
||||
<div className="flex flex-col">
|
||||
<DialogHeader className="text-center mb-6">
|
||||
<DialogTitle className="text-2xl">Connect Your Accounts</DialogTitle>
|
||||
|
|
@ -796,7 +795,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
)
|
||||
|
||||
// Step 2: Completion
|
||||
const CompletionStep = () => {
|
||||
const renderCompletionStep = () => {
|
||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected
|
||||
|
||||
return (
|
||||
|
|
@ -877,10 +876,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<StepIndicator />
|
||||
{currentStep === 0 && <LlmSetupStep />}
|
||||
{currentStep === 1 && <AccountConnectionStep />}
|
||||
{currentStep === 2 && <CompletionStep />}
|
||||
{renderStepIndicator()}
|
||||
{currentStep === 0 && renderLlmSetupStep()}
|
||||
{currentStep === 1 && renderAccountConnectionStep()}
|
||||
{currentStep === 2 && renderCompletionStep()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: s
|
|||
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-5-20251101",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
}
|
||||
|
||||
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,25 @@ function formatEventTime(ts: string): string {
|
|||
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
}
|
||||
|
||||
function formatRunTime(ts: string): string {
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return ""
|
||||
const now = Date.now()
|
||||
const diffMs = Math.max(0, now - date.getTime())
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const diffWeeks = Math.floor(diffDays / 7)
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
|
||||
if (diffMinutes < 1) return "just now"
|
||||
if (diffMinutes < 60) return `${diffMinutes} m`
|
||||
if (diffHours < 24) return `${diffHours} h`
|
||||
if (diffDays < 7) return `${diffDays} d`
|
||||
if (diffWeeks < 4) return `${diffWeeks} w`
|
||||
return `${Math.max(1, diffMonths)} m`
|
||||
}
|
||||
|
||||
function SyncStatusBar() {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
|
||||
|
|
@ -867,8 +886,7 @@ function Tree({
|
|||
if (isRenaming) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
{isDir ? <Folder className="size-4 shrink-0" /> : <File className="size-4 shrink-0" />}
|
||||
<div className="flex items-center px-2 py-1">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
|
|
@ -905,7 +923,6 @@ function Tree({
|
|||
isActive={isSelected}
|
||||
onClick={() => onSelect(item.path, item.kind)}
|
||||
>
|
||||
<File className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
|
@ -927,7 +944,6 @@ function Tree({
|
|||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<ChevronRight className="transition-transform size-4" />
|
||||
<Folder className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
|
|
@ -1045,11 +1061,16 @@ function TasksSection({
|
|||
isActive={currentRunId === run.id}
|
||||
onClick={() => actions?.onSelectRun(run.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-full items-center gap-2 min-w-0">
|
||||
{processingRunIds?.has(run.id) ? (
|
||||
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||
) : null}
|
||||
<span className="truncate text-sm">{run.title || '(Untitled chat)'}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
|
||||
{run.createdAt ? (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{formatRunTime(run.createdAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
|
|
|||
|
|
@ -39,11 +39,17 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
|
|||
baseURL,
|
||||
headers,
|
||||
});
|
||||
case "ollama":
|
||||
case "ollama": {
|
||||
// ollama-ai-provider-v2 expects baseURL to include /api
|
||||
let ollamaURL = baseURL;
|
||||
if (ollamaURL && !ollamaURL.replace(/\/+$/, '').endsWith('/api')) {
|
||||
ollamaURL = ollamaURL.replace(/\/+$/, '') + '/api';
|
||||
}
|
||||
return createOllama({
|
||||
baseURL,
|
||||
baseURL: ollamaURL,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
case "openai-compatible":
|
||||
return createOpenAICompatible({
|
||||
name: "openai-compatible",
|
||||
|
|
@ -65,10 +71,12 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
|
|||
export async function testModelConnection(
|
||||
providerConfig: z.infer<typeof Provider>,
|
||||
model: string,
|
||||
timeoutMs: number = 8000,
|
||||
timeoutMs?: number,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const isLocal = providerConfig.flavor === "ollama" || providerConfig.flavor === "openai-compatible";
|
||||
const effectiveTimeout = timeoutMs ?? (isLocal ? 60000 : 8000);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const timeout = setTimeout(() => controller.abort(), effectiveTimeout);
|
||||
try {
|
||||
const provider = createProvider(providerConfig);
|
||||
const languageModel = provider.languageModel(model);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue