Compare commits

...

5 commits

Author SHA1 Message Date
Arjun
d6b31cc25a fixed onboarding 2026-04-07 17:25:43 +05:30
Arjun
01bc31ce77 default model in logged in 2026-04-07 13:49:01 +05:30
arkml
aea40e632b
Remove slack granola (#465)
* remove native slack and granola

* remove agent-slack instructions

* fix build error
2026-04-07 12:38:19 +05:30
Arjun
ce4e8f620a fix build issue 2026-04-07 11:58:17 +05:30
arkml
470947a59d
posthog analytics (#424)
Improved analytics
2026-04-07 11:37:20 +05:30
17 changed files with 186 additions and 390 deletions

View file

@ -87,6 +87,8 @@ import { toast } from "sonner"
import { useVoiceMode } from '@/hooks/useVoiceMode'
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
import * as analytics from '@/lib/analytics'
type DirEntry = z.infer<typeof workspace.DirEntry>
type RunEventType = z.infer<typeof RunEvent>
@ -624,6 +626,8 @@ function App() {
type ShortcutPane = 'left' | 'right'
type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean }
useAnalyticsIdentity()
// File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileContent, setFileContent] = useState<string>('')
@ -2163,6 +2167,7 @@ function App() {
currentRunId = run.id
newRunCreatedAt = run.createdAt
setRunId(currentRunId)
analytics.chatSessionCreated(currentRunId)
// Update active chat tab's runId to the new run
setChatTabs((prev) => prev.map((tab) => (
tab.id === submitTabId
@ -2223,6 +2228,11 @@ function App() {
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
})
analytics.chatMessageSent({
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
})
} else {
// Legacy path: plain string with optional XML-formatted @mentions.
let formattedMessage = userMessage
@ -2254,6 +2264,11 @@ function App() {
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
})
analytics.chatMessageSent({
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
})
titleSource = formattedMessage
}
@ -4267,6 +4282,7 @@ function App() {
const title = getBaseName(tab.path)
try {
await window.ipc.invoke('export:note', { markdown, format, title })
analytics.noteExported(format)
} catch (err) {
console.error('Export failed:', err)
}

View file

@ -563,7 +563,7 @@ function ChatInputInner({
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<span className="max-w-[150px] truncate">
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || 'Model'}
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'}
</span>
<ChevronDown className="h-3 w-3" />
</button>

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState } from "react"
import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react"
import { AlertTriangle, Loader2, Mic, Mail, Calendar, User } from "lucide-react"
import {
Popover,
@ -15,7 +15,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
@ -126,8 +125,6 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
// Check if Gmail is unconnected (for filtering in unconnected mode)
const isGmailUnconnected = c.useComposioForGoogle ? !c.gmailConnected && !c.gmailLoading : true
const isGoogleCalendarUnconnected = c.useComposioForGoogleCalendar ? !c.googleCalendarConnected && !c.googleCalendarLoading : true
const isGranolaUnconnected = !c.granolaEnabled && !c.granolaLoading
const isSlackUnconnected = !c.slackEnabled && !c.slackLoading
// For unconnected mode, check if there's anything to show
const hasUnconnectedEmailCalendar = (() => {
@ -143,7 +140,6 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
const hasUnconnectedMeetingNotes = (() => {
if (!isUnconnectedMode) return true
if (isGranolaUnconnected) return true
if (c.providers.includes('fireflies-ai')) {
const firefliesState = c.providerStates['fireflies-ai']
if (!firefliesState?.isConnected || c.providerStatus['fireflies-ai']?.error) return true
@ -151,15 +147,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
return false
})()
const hasUnconnectedSlack = !isUnconnectedMode || isSlackUnconnected
const isRowboatUnconnected = (() => {
if (!c.providers.includes('rowboat')) return false
const rowboatState = c.providerStates['rowboat']
return !rowboatState?.isConnected || rowboatState?.isLoading
})()
const allConnected = isUnconnectedMode && !isRowboatUnconnected && !hasUnconnectedEmailCalendar && !hasUnconnectedMeetingNotes && !hasUnconnectedSlack
const allConnected = isUnconnectedMode && !isRowboatUnconnected && !hasUnconnectedEmailCalendar && !hasUnconnectedMeetingNotes
return (
<>
@ -357,128 +351,12 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
<span className="text-xs font-medium text-muted-foreground">Meeting Notes</span>
</div>
{/* Granola - show in unconnected mode only if not enabled */}
{(!isUnconnectedMode || isGranolaUnconnected) && (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mic className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Granola</span>
<span className="text-xs text-muted-foreground truncate">
Local meeting notes
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{c.granolaLoading && (
<Loader2 className="size-3 animate-spin" />
)}
<Switch
checked={c.granolaEnabled}
onCheckedChange={c.handleGranolaToggle}
disabled={c.granolaLoading}
/>
</div>
</div>
)}
{/* Fireflies */}
{c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
<Separator className="my-2" />
</>
)}
{/* Team Communication Section */}
{hasUnconnectedSlack && (
<>
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
</div>
<div className="rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{c.slackEnabled && c.slackWorkspaces.length > 0 ? (
<span className="text-xs text-muted-foreground truncate">
{c.slackWorkspaces.map(w => w.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{(c.slackLoading || c.slackDiscovering) && (
<Loader2 className="size-3 animate-spin" />
)}
{c.slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => c.handleSlackDisable()}
disabled={c.slackLoading}
/>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleSlackEnable}
disabled={c.slackLoading || c.slackDiscovering}
className="h-7 px-2 text-xs"
>
Enable
</Button>
)}
</div>
</div>
{c.slackPickerOpen && (
<div className="mt-2 ml-11 space-y-2">
{c.slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
) : (
<>
{c.slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={c.slackSelectedUrls.has(w.url)}
onChange={(e) => {
c.setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={c.handleSlackSaveWorkspaces}
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
className="h-7 px-3 text-xs"
>
Save
</Button>
</>
)}
</div>
)}
</div>
</>
)}
</>
)}
</div>

View file

@ -48,7 +48,7 @@ const FLOAT_VARIANCE = 2
const FLOAT_SPEED_BASE = 0.0006
const FLOAT_SPEED_VARIANCE = 0.00025
export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: GraphViewProps) {
export function GraphView({ nodes, edges, error, onSelectNode }: GraphViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const positionsRef = useRef<Map<string, NodePosition>>(new Map())
const motionSeedsRef = useRef<Map<string, { phase: number; amplitude: number; speed: number }>>(new Map())

View file

@ -8,8 +8,8 @@ interface CompletionStepProps {
}
export function CompletionStep({ state }: CompletionStepProps) {
const { connectedProviders, granolaEnabled, slackEnabled, gmailConnected, googleCalendarConnected, handleComplete } = state
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected
const { connectedProviders, gmailConnected, googleCalendarConnected, handleComplete } = state
const hasConnections = connectedProviders.length > 0 || gmailConnected || googleCalendarConnected
return (
<div className="flex flex-col items-center justify-center text-center flex-1">
@ -109,28 +109,6 @@ export function CompletionStep({ state }: CompletionStepProps) {
<span>Fireflies (Meeting transcripts)</span>
</motion.div>
)}
{granolaEnabled && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Granola (Local meeting notes)</span>
</motion.div>
)}
{slackEnabled && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.65 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Slack (Team communication)</span>
</motion.div>
)}
</div>
</motion.div>
)}

View file

@ -1,9 +1,8 @@
import { Loader2, CheckCircle2, ArrowLeft, Calendar } from "lucide-react"
import { Loader2, CheckCircle2, ArrowLeft, Calendar, FileText } from "lucide-react"
import { motion } from "motion/react"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { GmailIcon, SlackIcon, FirefliesIcon, GranolaIcon } from "../provider-icons"
import { GmailIcon, FirefliesIcon } from "../provider-icons"
import type { OnboardingState, ProviderState } from "../use-onboarding-state"
interface ConnectAccountsStepProps {
@ -85,11 +84,6 @@ function ProviderCard({
export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
const {
providers, providersLoading, providerStates, handleConnect,
granolaEnabled, granolaLoading, handleGranolaToggle,
slackEnabled, slackLoading, slackWorkspaces, slackAvailableWorkspaces,
slackSelectedUrls, setSlackSelectedUrls, slackPickerOpen,
slackDiscovering, slackDiscoverError,
handleSlackEnable, handleSlackSaveWorkspaces, handleSlackDisable,
useComposioForGoogle, gmailConnected, gmailLoading, gmailConnecting, handleConnectGmail,
useComposioForGoogleCalendar, googleCalendarConnected, googleCalendarLoading, googleCalendarConnecting, handleConnectGoogleCalendar,
handleNext, handleBack,
@ -104,7 +98,7 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
Connect Your Accounts
</h2>
<p className="text-base text-muted-foreground text-center leading-relaxed mb-8">
Connect your accounts to give Rowboat context about your work. You can always add more later.
Rowboat gets smarter the more it knows about your work. Connect your accounts to get started. You can find more tools in Settings.
</p>
{providersLoading ? (
@ -122,7 +116,7 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
{useComposioForGoogle ? (
<ProviderCard
name="Gmail"
description="Sync your email for context-aware assistance"
description="Read emails for context and drafts."
icon={<GmailIcon />}
iconBg="bg-red-500/10"
iconColor="text-red-500"
@ -145,7 +139,7 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
{useComposioForGoogleCalendar && (
<ProviderCard
name="Google Calendar"
description="Sync calendar events for scheduling awareness"
description="Read meetings and your schedule."
icon={<Calendar className="size-5" />}
iconBg="bg-blue-500/10"
iconColor="text-blue-500"
@ -162,29 +156,31 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>
<ProviderCard
name="Granola"
description="Sync your local meeting notes for richer context"
icon={<GranolaIcon />}
iconBg="bg-purple-500/10"
iconColor="text-purple-500"
providerState={{ isConnected: granolaEnabled, isLoading: false, isConnecting: false }}
rightSlot={
<div className="flex items-center gap-2">
{granolaLoading && <Loader2 className="size-3 animate-spin" />}
<Switch
checked={granolaEnabled}
onCheckedChange={handleGranolaToggle}
disabled={granolaLoading}
/>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: cardIndex++ * 0.06 }}
className="flex items-center justify-between gap-4 rounded-xl border border-green-200 bg-green-50/50 dark:border-green-800/50 dark:bg-green-900/10 p-4"
>
<div className="flex items-center gap-3 min-w-0">
<div className="size-10 rounded-lg flex items-center justify-center shrink-0 bg-green-500/10">
<span className="text-green-500"><FileText className="size-5" /></span>
</div>
}
index={cardIndex++}
/>
<div className="min-w-0">
<div className="text-sm font-semibold">Rowboat Meeting Notes</div>
<div className="text-xs text-muted-foreground truncate">Built in. Ready to use.</div>
</div>
</div>
<div className="shrink-0">
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="size-4" />
</div>
</div>
</motion.div>
{providers.includes('fireflies-ai') && (
<ProviderCard
name="Fireflies"
description="Import AI-powered meeting transcripts automatically"
description="Import existing notes."
icon={<FirefliesIcon />}
iconBg="bg-amber-500/10"
iconColor="text-amber-500"
@ -194,83 +190,6 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
/>
)}
</div>
{/* Team Communication */}
<div className="space-y-3">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Team Communication
</span>
<div>
<ProviderCard
name="Slack"
description={
slackEnabled && slackWorkspaces.length > 0
? slackWorkspaces.map(w => w.name).join(', ')
: "Enable Rowboat to understand your team conversations and provide relevant context"
}
icon={<SlackIcon />}
iconBg="bg-emerald-500/10"
iconColor="text-emerald-500"
providerState={{ isConnected: slackEnabled, isLoading: false, isConnecting: false }}
rightSlot={
<div className="flex items-center gap-2">
{(slackLoading || slackDiscovering) && <Loader2 className="size-3 animate-spin" />}
{slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => handleSlackDisable()}
disabled={slackLoading}
/>
) : (
<Button
size="sm"
onClick={handleSlackEnable}
disabled={slackLoading || slackDiscovering}
>
Enable
</Button>
)}
</div>
}
index={cardIndex++}
/>
{slackPickerOpen && (
<div className="mt-2 ml-[3.25rem] space-y-2 pl-4 border-l-2 border-muted">
{slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
) : (
<>
{slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={slackSelectedUrls.has(w.url)}
onChange={(e) => {
setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={handleSlackSaveWorkspaces}
disabled={slackSelectedUrls.size === 0 || slackLoading}
>
Save
</Button>
</>
)}
</div>
)}
</div>
</div>
</div>
)}

View file

@ -104,7 +104,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to all models no API keys needed.
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to leading models. No API keys needed.
</p>
<button
onClick={handleSwitchToRowboat}

View file

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import posthog from 'posthog-js'
import * as analytics from '@/lib/analytics'
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
import {
CommandDialog,
@ -68,6 +70,8 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
.then((res) => {
if (!cancelled) {
setResults(res.results)
analytics.searchExecuted(types)
posthog.people.set_once({ has_used_search: true })
}
})
.catch((err) => {

View file

@ -1,9 +1,8 @@
"use client"
import * as React from "react"
import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react"
import { Loader2, Mic, Mail, Calendar } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
@ -235,129 +234,18 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
)}
{/* Meeting Notes Section */}
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>
</div>
{/* Granola */}
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<Mic className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Granola</span>
<span className="text-xs text-muted-foreground truncate">
Local meeting notes
{c.providers.includes('fireflies-ai') && (
<>
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{c.granolaLoading && (
<Loader2 className="size-3 animate-spin" />
)}
<Switch
checked={c.granolaEnabled}
onCheckedChange={c.handleGranolaToggle}
disabled={c.granolaLoading}
/>
</div>
</div>
{/* Fireflies */}
{c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
<Separator className="my-3" />
{/* Team Communication Section */}
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Team Communication
</span>
</div>
{/* Slack */}
<div className="rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{c.slackEnabled && c.slackWorkspaces.length > 0 ? (
<span className="text-xs text-emerald-600 truncate">
{c.slackWorkspaces.map(w => w.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{(c.slackLoading || c.slackDiscovering) && (
<Loader2 className="size-3 animate-spin" />
)}
{c.slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => c.handleSlackDisable()}
disabled={c.slackLoading}
/>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleSlackEnable}
disabled={c.slackLoading || c.slackDiscovering}
className="h-7 px-3 text-xs"
>
Enable
</Button>
)}
</div>
</div>
{c.slackPickerOpen && (
<div className="mt-2 ml-12 space-y-2">
{c.slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
) : (
<>
{c.slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={c.slackSelectedUrls.has(w.url)}
onChange={(e) => {
c.setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={c.handleSlackSaveWorkspaces}
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
className="h-7 px-3 text-xs"
>
Save
</Button>
</>
)}
</div>
)}
</div>
{/* Fireflies */}
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
</>
)}
</div>
</>
)

View file

@ -29,7 +29,6 @@ export function TabBar<T>({
activeTabId,
getTabTitle,
getTabId,
isProcessing,
onSwitchTab,
onCloseTab,
layout = 'fill',
@ -47,7 +46,6 @@ export function TabBar<T>({
{tabs.map((tab, index) => {
const tabId = getTabId(tab)
const isActive = tabId === activeTabId
const processing = isProcessing?.(tab) ?? false
const title = getTabTitle(tab)
return (

View file

@ -0,0 +1,74 @@
import { useEffect } from 'react'
import posthog from 'posthog-js'
/**
* Identifies the user in PostHog when signed into Rowboat,
* and sets user properties for connected OAuth providers.
* Call once at the App level.
*/
export function useAnalyticsIdentity() {
// On mount: check current OAuth state and identify if signed in
useEffect(() => {
async function init() {
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
// Identify if Rowboat account is connected
const rowboat = config.rowboat
if (rowboat?.connected && rowboat?.userId) {
posthog.identify(rowboat.userId)
}
// Set provider connection flags
const providers = ['gmail', 'calendar', 'slack', 'rowboat']
const props: Record<string, boolean> = { signed_in: !!rowboat?.connected }
for (const p of providers) {
props[`${p}_connected`] = !!config[p]?.connected
}
posthog.people.set(props)
// Count notes for total_notes property
try {
const entries = await window.ipc.invoke('workspace:readdir', { path: '/' })
let totalNotes = 0
if (entries) {
for (const entry of entries) {
if (entry.kind === 'dir') {
try {
const sub = await window.ipc.invoke('workspace:readdir', { path: `/${entry.name}` })
totalNotes += sub?.length ?? 0
} catch {
// skip inaccessible dirs
}
}
}
}
posthog.people.set({ total_notes: totalNotes })
} catch {
// workspace may not be available
}
} catch {
// oauth state unavailable
}
}
init()
}, [])
// Listen for OAuth connect/disconnect events to update identity
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (!event.success) return
// If Rowboat provider connected, identify user
if (event.provider === 'rowboat' && event.userId) {
posthog.identify(event.userId)
posthog.people.set({ signed_in: true })
}
posthog.people.set({ [`${event.provider}_connected`]: true })
})
return cleanup
}, [])
}

View file

@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { toast } from '@/lib/toast';
import posthog from 'posthog-js';
import * as analytics from '@/lib/analytics';
/**
* Hook for managing OAuth connection state for a specific provider
@ -40,6 +42,8 @@ export function useOAuth(provider: string) {
setIsLoading(false);
if (event.success) {
analytics.oauthConnected(provider);
posthog.people.set({ [`${provider}_connected`]: true });
toast(`Successfully connected to ${provider}`, 'success');
// Refresh connection status to ensure consistency
checkConnection();
@ -75,6 +79,8 @@ export function useOAuth(provider: string) {
setIsLoading(true);
const result = await window.ipc.invoke('oauth:disconnect', { provider });
if (result.success) {
analytics.oauthDisconnected(provider);
posthog.people.set({ [`${provider}_connected`]: false });
toast(`Disconnected from ${provider}`, 'success');
setIsConnected(false);
} else {

View file

@ -1,6 +1,8 @@
import { useCallback, useRef, useState } from 'react';
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
import posthog from 'posthog-js';
import * as analytics from '@/lib/analytics';
export type VoiceState = 'idle' | 'connecting' | 'listening';
@ -146,6 +148,8 @@ export function useVoiceMode() {
// Show listening immediately — don't wait for WebSocket
setState('listening');
analytics.voiceInputStarted();
posthog.people.set_once({ has_used_voice: true });
// Kick off mic + WebSocket in parallel, don't await WebSocket
const [stream] = await Promise.all([

View file

@ -0,0 +1,37 @@
import posthog from 'posthog-js'
export function chatSessionCreated(runId: string) {
posthog.capture('chat_session_created', { run_id: runId })
}
export function chatMessageSent(props: {
voiceInput?: boolean
voiceOutput?: string
searchEnabled?: boolean
}) {
posthog.capture('chat_message_sent', {
voice_input: props.voiceInput ?? false,
voice_output: props.voiceOutput ?? false,
search_enabled: props.searchEnabled ?? false,
})
}
export function oauthConnected(provider: string) {
posthog.capture('oauth_connected', { provider })
}
export function oauthDisconnected(provider: string) {
posthog.capture('oauth_disconnected', { provider })
}
export function voiceInputStarted() {
posthog.capture('voice_input_started')
}
export function searchExecuted(types: string[]) {
posthog.capture('search_executed', { types })
}
export function noteExported(format: string) {
posthog.capture('note_exported', { format })
}

View file

@ -70,7 +70,6 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always show message drafts to the user before sending.
## Learning About the User (save-to-memory)

View file

@ -7,7 +7,6 @@ import draftEmailsSkill from "./draft-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
import slackSkill from "./slack/skill.js";
import backgroundAgentsSkill from "./background-agents/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
@ -61,12 +60,6 @@ const definitions: SkillDefinition[] = [
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
content: organizeFilesSkill,
},
{
id: "slack",
title: "Slack Integration",
summary: "Send Slack messages, view channel history, search conversations, find users, and manage team communication.",
content: slackSkill,
},
{
id: "background-agents",
title: "Background Agents",

View file

@ -251,6 +251,7 @@ const ipcSchemas = {
config: z.record(z.string(), z.object({
connected: z.boolean(),
error: z.string().nullable().optional(),
userId: z.string().optional(),
})),
}),
},
@ -267,6 +268,7 @@ const ipcSchemas = {
provider: z.string(),
success: z.boolean(),
error: z.string().optional(),
userId: z.string().optional(),
}),
res: z.null(),
},