Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2090a40
feat: add HARMONICA.md — persistent org context for AI facilitator
zhiganov Feb 21, 2026
a77ea44
fix: stop overwriting user display name on login
zhiganov Feb 21, 2026
89e0dcc
fix: show DB display name in navigation instead of Auth0 name
zhiganov Feb 21, 2026
04d572f
fix: show onboarding as CTA + dialog instead of embedded chat
zhiganov Feb 21, 2026
35be769
design: refined onboarding CTA with warm card and staggered hints
zhiganov Feb 21, 2026
cc62341
fix: use window.location for logout redirect after account deletion
zhiganov Feb 21, 2026
f83ef99
fix: remove useless auto-generate API key, show install command in ba…
zhiganov Feb 21, 2026
19729fa
feat: add persistent MCP install instructions in API Keys settings tab
zhiganov Feb 21, 2026
767274f
fix: delete API keys and session ratings before user account deletion
zhiganov Feb 21, 2026
a12941e
fix: make localStorage dismiss keys user-specific
zhiganov Feb 21, 2026
274ec11
fix: hardcode onboarding greeting and fix squished logo
zhiganov Feb 21, 2026
049bcb4
fix: hide redundant 'Skip for now' inside onboarding dialog
zhiganov Feb 21, 2026
1911096
fix: prevent accidental onboarding dialog closure
zhiganov Feb 21, 2026
75e9300
fix: replace old Notion help center links with help.harmonica.chat
zhiganov Feb 21, 2026
eaf1013
feat: add PostHog funnel events for pirate metrics (HAR-97)
zhiganov Feb 21, 2026
8d5c226
fix: type-to-confirm account deletion + prevent user re-creation race
zhiganov Feb 21, 2026
135ab6e
fix: enable federated logout so Google shows account picker on re-login
zhiganov Feb 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions src/app/(dashboard)/WelcomeBannerRight.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="rounded-xl bg-white/60 backdrop-blur-sm border border-amber-200/60 p-5 shadow-sm"
>
<p className="text-[15px] font-medium text-zinc-800 mb-1">
Personalize your AI facilitator
</p>
<p className="text-sm text-zinc-500 mb-4">
A quick 2-minute chat so every session starts with the right context.
</p>

<div className="space-y-2 mb-5">
{CONTEXT_HINTS.map((hint, i) => (
<motion.div
key={hint.text}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.15 + i * 0.1 }}
className="flex items-center gap-2.5 text-sm text-zinc-600"
>
<hint.icon className="h-3.5 w-3.5 text-amber-600/70 shrink-0" />
{hint.text}
</motion.div>
))}
</div>

<div className="flex items-center gap-3">
<Button
onClick={() => setShowDialog(true)}
className="bg-zinc-900 hover:bg-zinc-800 text-white shadow-sm"
size="sm"
>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
Get started
</Button>
<button
onClick={() => {
posthog?.capture('onboarding_skipped');
if (skipKey) localStorage.setItem(skipKey, '1');
setShowPrompt(false);
}}
className="text-xs text-zinc-400 hover:text-zinc-600 transition-colors"
>
Skip for now
</button>
</div>
</motion.div>

<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent
className="sm:max-w-2xl max-h-[80vh] overflow-y-auto"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<OnboardingChat
onComplete={() => {
setShowDialog(false);
setShowPrompt(false);
router.refresh();
}}
onSkip={() => {
setShowDialog(false);
}}
embedded
/>
</DialogContent>
</Dialog>
</>
);
}

return (
<>
<div className="flex items-center justify-between mb-2">
<label htmlFor="dashboard-objective" className="block text-base font-medium">What do you want to find out?</label>
<Link href="/templates">
<Button variant="ghost" size="sm">
<FileText className="w-4 h-4" />
Templates
</Button>
</Link>
</div>
<CreateSessionInputClient />
</>
);
}
43 changes: 12 additions & 31 deletions src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 };
}
});

Expand Down Expand Up @@ -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 <ErrorPage title={''} message={''} />;
}
Expand All @@ -227,16 +215,9 @@ export default async function Dashboard({
</div>
{/* Right column */}
<div className="flex-1 flex flex-col justify-center">
<div className="flex items-center justify-between mb-2">
<label htmlFor="dashboard-objective" className="block text-base font-medium">What do you want to find out?</label>
<Link href="/templates">
<Button variant="ghost" size="sm">
<FileText className="w-4 h-4" />
Templates
</Button>
</Link>
</div>
<CreateSessionInputClient />
<WelcomeBannerRight
showOnboarding={hostSessions.length === 0 && !hasHarmonicaMd}
/>
</div>
</div>
{/* Main dashboard content */}
Expand Down
8 changes: 6 additions & 2 deletions src/app/api/auth/[auth0]/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { handleAuth } from '@auth0/nextjs-auth0';
import { handleAuth, handleLogout } from '@auth0/nextjs-auth0';

export const GET = handleAuth();
export const GET = handleAuth({
logout: handleLogout({
logoutParams: { federated: '' },
}),
});
20 changes: 19 additions & 1 deletion src/app/api/llamaUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
getHostSessionById,
updateUserSession,
increaseSessionsCount,
getPermissions,
getUserHarmonicaMd,
} from '@/lib/db';
import { initializeCrossPollination } from '@/lib/crossPollination';
import { getLLM } from '@/lib/modelConfig';
Expand Down Expand Up @@ -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:
Expand Down
100 changes: 100 additions & 0 deletions src/app/api/onboarding/chat/route.ts
Original file line number Diff line number Diff line change
@@ -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 <HARMONICA_MD> 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>
# 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]
</HARMONICA_MD>

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 },
);
}
}
Loading