diff --git a/src/app/(dashboard)/WelcomeBannerRight.tsx b/src/app/(dashboard)/WelcomeBannerRight.tsx new file mode 100644 index 00000000..7d827a35 --- /dev/null +++ b/src/app/(dashboard)/WelcomeBannerRight.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { FileText, Sparkles, Users, Target, MessageSquare } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useUser } from '@auth0/nextjs-auth0/client'; +import { motion } from 'framer-motion'; +import CreateSessionInputClient from './CreateSessionInputClient'; +import OnboardingChat from '@/components/OnboardingChat'; +import { + Dialog, + DialogContent, +} from '@/components/ui/dialog'; +import { usePostHog } from 'posthog-js/react'; + +const SKIP_KEY_PREFIX = 'harmonica_onboarding_skipped_'; + +interface WelcomeBannerRightProps { + showOnboarding: boolean; +} + +const CONTEXT_HINTS = [ + { icon: Users, text: 'Your team & participants' }, + { icon: Target, text: 'Goals & decision style' }, + { icon: MessageSquare, text: 'Facilitation preferences' }, +]; + +export default function WelcomeBannerRight({ showOnboarding }: WelcomeBannerRightProps) { + const posthog = usePostHog(); + const [showPrompt, setShowPrompt] = useState(false); + const [showDialog, setShowDialog] = useState(false); + const { user } = useUser(); + const router = useRouter(); + + const skipKey = user?.sub ? `${SKIP_KEY_PREFIX}${user.sub}` : ''; + + useEffect(() => { + if (showOnboarding && skipKey) { + const skipped = localStorage.getItem(skipKey); + if (!skipped) { + setShowPrompt(true); + } + } + }, [showOnboarding, skipKey]); + + if (showPrompt) { + return ( + <> + +

+ Personalize your AI facilitator +

+

+ A quick 2-minute chat so every session starts with the right context. +

+ +
+ {CONTEXT_HINTS.map((hint, i) => ( + + + {hint.text} + + ))} +
+ +
+ + +
+
+ + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + { + setShowDialog(false); + setShowPrompt(false); + router.refresh(); + }} + onSkip={() => { + setShowDialog(false); + }} + embedded + /> + + + + ); + } + + return ( + <> +
+ + + + +
+ + + ); +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 4bdb7ae5..1833df16 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -6,7 +6,6 @@ import { WorkspacesTable } from './workspaces-table'; import * as db from '@/lib/db'; import Link from 'next/link'; import { cache } from 'react'; -import { randomBytes, createHash } from 'crypto'; import ErrorPage from '@/components/Error'; import { getGeneratedMetadata } from 'app/api/metadata'; import { Card, CardContent } from '@/components/ui/card'; @@ -17,6 +16,7 @@ import { getSession } from '@auth0/nextjs-auth0'; import ProjectsGrid from './ProjectsGrid'; import { Textarea } from '@/components/ui/textarea'; import CreateSessionInputClient from './CreateSessionInputClient'; +import WelcomeBannerRight from './WelcomeBannerRight'; export const dynamic = 'force-dynamic'; // getHostSessions is using auth, which can only be done client side export const revalidate = 300; // Revalidate the data every 5 minutes (or on page reload) @@ -30,7 +30,7 @@ const sessionCache = cache(async () => { if (!userId) { console.warn('No user ID found'); - return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false }; + return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false, hasHarmonicaMd: false }; } // Query sessions with permissions check @@ -96,7 +96,8 @@ const sessionCache = cache(async () => { ); const adminApiKeys = await db.getApiKeysForUser(userId); - return { hostSessions, workspacesWithSessions, hasApiKeys: adminApiKeys.length > 0 }; + const harmonicaMd = await db.getUserHarmonicaMd(userId); + return { hostSessions, workspacesWithSessions, hasApiKeys: adminApiKeys.length > 0, hasHarmonicaMd: !!harmonicaMd }; } const hostSessionIds = userResources .filter((r) => r.resource_type === 'SESSION') @@ -131,25 +132,12 @@ const sessionCache = cache(async () => { hostSessions, ); - // Auto-generate default API key for new users - let apiKeys = await db.getApiKeysForUser(userId); - if (hostSessions.length === 0 && workspacesWithSessions.length === 0 && apiKeys.length === 0) { - const rawKey = `hm_live_${randomBytes(16).toString('hex')}`; - const keyHash = createHash('sha256').update(rawKey).digest('hex'); - const keyPrefix = rawKey.slice(0, 12); - await db.createApiKey({ - user_id: userId, - key_hash: keyHash, - key_prefix: keyPrefix, - name: 'Default', - }); - apiKeys = [{ id: '', key_hash: '', key_prefix: keyPrefix, name: 'Default', user_id: userId } as any]; - } - - return { hostSessions, workspacesWithSessions, hasApiKeys: apiKeys.length > 0 }; + const apiKeys = await db.getApiKeysForUser(userId); + const harmonicaMd = await db.getUserHarmonicaMd(userId); + return { hostSessions, workspacesWithSessions, hasApiKeys: apiKeys.length > 0, hasHarmonicaMd: !!harmonicaMd }; } catch (error) { console.error('Failed to fetch host sessions: ', error); - return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false }; + return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false, hasHarmonicaMd: false }; } }); @@ -206,7 +194,7 @@ export default async function Dashboard({ }: { searchParams?: { page?: string }; }) { - const { hostSessions, workspacesWithSessions, hasApiKeys } = await sessionCache(); + const { hostSessions, workspacesWithSessions, hasApiKeys, hasHarmonicaMd } = await sessionCache(); if (!hostSessions) { return ; } @@ -227,16 +215,9 @@ export default async function Dashboard({ {/* Right column */}
-
- - - - -
- +
{/* Main dashboard content */} diff --git a/src/app/api/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts index aa8b000c..1971f9dc 100644 --- a/src/app/api/auth/[auth0]/route.ts +++ b/src/app/api/auth/[auth0]/route.ts @@ -1,3 +1,7 @@ -import { handleAuth } from '@auth0/nextjs-auth0'; +import { handleAuth, handleLogout } from '@auth0/nextjs-auth0'; -export const GET = handleAuth(); \ No newline at end of file +export const GET = handleAuth({ + logout: handleLogout({ + logoutParams: { federated: '' }, + }), +}); \ No newline at end of file diff --git a/src/app/api/llamaUtils.ts b/src/app/api/llamaUtils.ts index 023f9588..eb9d44be 100644 --- a/src/app/api/llamaUtils.ts +++ b/src/app/api/llamaUtils.ts @@ -9,6 +9,8 @@ import { getHostSessionById, updateUserSession, increaseSessionsCount, + getPermissions, + getUserHarmonicaMd, } from '@/lib/db'; import { initializeCrossPollination } from '@/lib/crossPollination'; import { getLLM } from '@/lib/modelConfig'; @@ -196,9 +198,25 @@ export async function handleGenerateAnswer( const basicFacilitationPrompt = await getPromptInstructions( 'BASIC_FACILITATION_PROMPT', ); + + // Fetch the session owner's HARMONICA.md for organizational context + let harmonicaMdBlock = ''; + try { + const perms = await getPermissions(messageData.sessionId, 'SESSION'); + const owner = perms.find(p => p.role === 'owner'); + if (owner?.user_id) { + const md = await getUserHarmonicaMd(owner.user_id); + if (md) { + harmonicaMdBlock = `Organizational Context (HARMONICA.md):\n${md}\n\n`; + } + } + } catch (e) { + console.warn('Failed to fetch HARMONICA.md for session:', e); + } + // Format context data const sessionContext = ` -System Instructions: +${harmonicaMdBlock}System Instructions: ${sessionData?.prompt || basicFacilitationPrompt} Session Information: diff --git a/src/app/api/onboarding/chat/route.ts b/src/app/api/onboarding/chat/route.ts new file mode 100644 index 00000000..4ba19326 --- /dev/null +++ b/src/app/api/onboarding/chat/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '@auth0/nextjs-auth0'; +import { getLLM } from '@/lib/modelConfig'; +import { ChatMessage } from 'llamaindex'; + +export const maxDuration = 120; + +const ONBOARDING_SYSTEM_PROMPT = `You are Harmonica's onboarding assistant. Your job is to have a warm, conversational chat with a new user to learn about their team and goals, then generate a structured HARMONICA.md document that will give context to the AI facilitator in all their future sessions. + +CONVERSATION STRUCTURE: +Ask 3-4 focused questions, ONE at a time. Wait for the user's response before asking the next question. + +1. Start by warmly welcoming them and asking about their team or organization — who are they, what do they do? +2. Ask what they're hoping to achieve with Harmonica — what kind of discussions or decisions do they need help with? +3. Ask about the people who will participate in their sessions — roles, expertise, any group dynamics to be aware of. +4. Ask about their preferences for how the AI should facilitate — tone, structure, what good outcomes look like. + +STYLE: +- Be warm, concise, and genuinely curious +- Use natural follow-up questions based on their answers +- Keep each message short (2-3 sentences max + your question) +- Don't ask multiple questions at once + +GENERATING THE DOCUMENT: +After 3-4 exchanges (when you have enough context), tell the user you'll generate their HARMONICA.md and output it wrapped in tags. Fill in all 8 sections based on what they shared. For sections where you have no information, write a helpful placeholder like "Not yet specified — you can add details here later." + + +# HARMONICA.md + +## About +[Who is this group/org? Brief description] + +## Goals & Strategy +[What they're working towards] + +## Participants +[Who typically participates in sessions] + +## Vocabulary +[Domain-specific terminology, acronyms, jargon] + +## Prior Decisions +[Context about existing decisions or settled questions] + +## Facilitation Preferences +[How the AI should facilitate — tone, structure, style] + +## Constraints +[Decision processes, limits, regulatory requirements] + +## Success Patterns +[What good session outcomes look like for this group] + + +After outputting the document, tell the user they can review and edit each section before saving.`; + +export async function POST(req: Request) { + const session = await getSession(); + if (!session?.user?.sub) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { messages } = await req.json(); + + if (!messages || !Array.isArray(messages)) { + return NextResponse.json({ error: 'Messages array required' }, { status: 400 }); + } + + // Return a hardcoded greeting for the initial call (no LLM needed, instant response) + if (messages.length === 0) { + return NextResponse.json({ + response: "Hey there! Welcome to Harmonica! I'm here to help set up your AI facilitator so it runs great sessions for your team.\n\nFirst things first — tell me about your team or organization. Who are you, and what do you do?", + }); + } + + try { + const llm = getLLM('MAIN', 0.5, 4096); + const formattedMessages: ChatMessage[] = [ + { role: 'system', content: ONBOARDING_SYSTEM_PROMPT }, + ...messages.map((m: { role: string; content: string }) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })), + ]; + + const response = await llm.chat({ + messages: formattedMessages, + distinctId: session.user.sub, + operation: 'onboarding_chat', + }); + + return NextResponse.json({ response }); + } catch (error) { + console.error('Onboarding chat error:', error); + return NextResponse.json( + { error: 'Failed to generate response' }, + { status: 500 }, + ); + } +} diff --git a/src/app/chat/StandaloneChat.tsx b/src/app/chat/StandaloneChat.tsx index 2f428923..ee0acc2f 100644 --- a/src/app/chat/StandaloneChat.tsx +++ b/src/app/chat/StandaloneChat.tsx @@ -15,6 +15,7 @@ import { SessionModal } from '@/components/chat/SessionModal'; import { ChatInterface } from '@/components/chat/ChatInterface'; import { QuestionInfo } from 'app/create/types'; import { usePermissions } from '@/lib/permissions'; +import { usePostHog } from 'posthog-js/react'; const StandaloneChat = () => { const [message, setMessage] = useState({ @@ -24,6 +25,7 @@ Please type your name or "anonymous" if you prefer `, }); + const posthog = usePostHog(); const { user } = useUser(); const searchParams = useSearchParams(); const sessionId = searchParams.get('s'); @@ -78,6 +80,7 @@ Please type your name or "anonymous" if you prefer increaseSessionsCount(sessionId!, 'num_finished'); }) .then(() => { + posthog?.capture('session_completed', { session_id: sessionId }); setIsLoading(false); setUserFinished(true); }); diff --git a/src/app/create/choose-template.tsx b/src/app/create/choose-template.tsx index 51dbabf2..996fd320 100644 --- a/src/app/create/choose-template.tsx +++ b/src/app/create/choose-template.tsx @@ -110,7 +110,7 @@ export default function ChooseTemplate({
Still need help? - Read Beginners Guide + Read Beginners Guide
diff --git a/src/app/create/creationFlow.tsx b/src/app/create/creationFlow.tsx index 1811ea66..9d5f3d85 100644 --- a/src/app/create/creationFlow.tsx +++ b/src/app/create/creationFlow.tsx @@ -21,6 +21,7 @@ import { Step, STEPS } from './types'; import { createPromptContent } from 'app/api/utils'; import { getPromptInstructions } from '@/lib/promptActions'; import { linkSessionsToWorkspace } from '@/lib/workspaceActions'; +import { usePostHog } from 'posthog-js/react'; export const maxDuration = 60; // Hosting function timeout, in seconds @@ -36,6 +37,7 @@ export type VersionedPrompt = { const enabledSteps = [true, false, false]; export default function CreationFlow() { + const posthog = usePostHog(); const route = useRouter(); const searchParams = useSearchParams(); const [isLoading, setIsLoading] = useState(false); @@ -251,6 +253,12 @@ IMPORTANT: const sessionId = sessionIds[0]; await db.setPermission(sessionId, 'owner'); + posthog?.capture('session_created', { + session_id: sessionId, + template_id: templateId || null, + has_template: !!templateId, + }); + // Set cookie const expirationDate = new Date(); expirationDate.setDate(expirationDate.getDate() + 30); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index c9d0e34b..14b9442f 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -20,7 +20,7 @@ export default function Navigation() {
diff --git a/src/app/providers.tsx b/src/app/providers.tsx index f66c2e0f..f39aec1c 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -27,6 +27,10 @@ function InvitationProcessor() { `Checking invitations: IsLoading:${isLoading}; hasUser: ${!!user}`, ); if (!isLoading && user) { + // Skip if account is being deleted — prevents syncCurrentUser from + // re-creating the user record during the deletion→logout window + if (sessionStorage.getItem('account_deleting')) return; + const processInvitations = async () => { try { // processUserInvitations now includes syncCurrentUser internally diff --git a/src/app/settings/ApiKeysTab.tsx b/src/app/settings/ApiKeysTab.tsx index 107942a4..c3b5b195 100644 --- a/src/app/settings/ApiKeysTab.tsx +++ b/src/app/settings/ApiKeysTab.tsx @@ -235,6 +235,43 @@ export default function ApiKeysTab() { + {/* MCP Install Instructions — always visible */} + + +
+ +

Connect to Claude Code

+
+

+ Run this in your terminal to add Harmonica as an MCP server. Replace <YOUR_API_KEY> with your API key. +

+
+
+              {getInstallCommand('')}
+            
+ +
+

+ Works with any MCP-compatible client (Claude Code, Cursor, Windsurf). The full command with your key is shown when you create a new API key. +

+
+
+ {/* Create Key Dialog */} ; + +function parseSections(markdown: string): SectionValues { + const values: SectionValues = {}; + const sectionMap: Record = { + 'about': 'about', + 'goals & strategy': 'goals', + 'goals': 'goals', + 'participants': 'participants', + 'vocabulary': 'vocabulary', + 'prior decisions': 'prior_decisions', + 'prior decisions & context': 'prior_decisions', + 'facilitation preferences': 'facilitation', + 'constraints': 'constraints', + 'success patterns': 'success', + }; + + const lines = markdown.split('\n'); + let currentKey = ''; + + for (const line of lines) { + const headerMatch = line.match(/^##\s+(.+)$/); + if (headerMatch) { + const title = headerMatch[1].trim().toLowerCase(); + currentKey = sectionMap[title] || ''; + continue; + } + if (currentKey && line.trim() !== '# HARMONICA.md') { + values[currentKey] = ((values[currentKey] || '') + '\n' + line).trim(); + } + } + + return values; +} + +function assembleSections(values: SectionValues): string { + const parts = ['# HARMONICA.md', '']; + for (const section of SECTIONS) { + const content = values[section.key]?.trim(); + if (content) { + parts.push(`## ${section.title}`); + parts.push(content); + parts.push(''); + } + } + return parts.join('\n').trim(); +} + +export default function HarmonicaMdTab() { + const [sections, setSections] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [hasContent, setHasContent] = useState(false); + const [showRegenerate, setShowRegenerate] = useState(false); + const [expanded, setExpanded] = useState>({}); + const [error, setError] = useState(null); + + const totalChars = assembleSections(sections).length; + const isOverLimit = totalChars > CHAR_LIMIT; + + useEffect(() => { + loadContent(); + }, []); + + const loadContent = async () => { + setLoading(true); + try { + const content = await fetchHarmonicaMd(); + if (content) { + setSections(parseSections(content)); + setHasContent(true); + // Expand sections that have content + const exp: Record = {}; + const parsed = parseSections(content); + for (const s of SECTIONS) { + if (parsed[s.key]?.trim()) exp[s.key] = true; + } + setExpanded(exp); + } else { + // Expand first 3 sections for new users + setExpanded({ about: true, goals: true, participants: true }); + } + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const handleSave = useCallback(async () => { + const content = assembleSections(sections); + if (isOverLimit) return; + + setSaving(true); + setError(null); + try { + const result = await saveHarmonicaMd(content); + if (result.success) { + setSaved(true); + setHasContent(true); + setTimeout(() => setSaved(false), 2000); + } else { + setError(result.message || 'Failed to save'); + } + } catch (e) { + setError('Failed to save'); + console.error(e); + } finally { + setSaving(false); + } + }, [sections, isOverLimit]); + + const handleRegenerateComplete = () => { + setShowRegenerate(false); + loadContent(); + }; + + const toggleSection = (key: string) => { + setExpanded(prev => ({ ...prev, [key]: !prev[key] })); + }; + + if (loading) { + return ( + + + + + + ); + } + + return ( + <> +
+ + +
+
+ + + HARMONICA.md + + + Persistent context about your organization that the AI facilitator uses in every session. + Like CLAUDE.md for code — but for group facilitation. + +
+ +
+
+ + {SECTIONS.map((section) => { + const isExpanded = expanded[section.key]; + return ( +
+ + {isExpanded && ( +
+

{section.description}

+