Skip to content
37 changes: 33 additions & 4 deletions dashboard/src/components/dispatch/DispatchDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import {
DndContext,
Expand Down Expand Up @@ -31,7 +31,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { generateDispatch } from '@/lib/api';
import { PostOverlay } from './PostOverlay';
import type { Insight } from '@/lib/types';
import type { Insight, DispatchPrefill } from '@/lib/types';
import type { DispatchTone, DispatchFormat, DispatchResponse } from '@/lib/api';

const FORMAT_OPTIONS: { value: DispatchFormat; label: string; description: string }[] = [
Expand Down Expand Up @@ -109,6 +109,7 @@ interface DispatchDrawerProps {
selectedInsights: Insight[];
onReorder: (insights: Insight[]) => void;
onRemove: (id: string) => void;
prefill?: DispatchPrefill;
}

export function DispatchDrawer({
Expand All @@ -117,14 +118,29 @@ export function DispatchDrawer({
selectedInsights,
onReorder,
onRemove,
prefill,
}: DispatchDrawerProps) {
const [context, setContext] = useState('');
const [contextEdited, setContextEdited] = useState(false);
const [format, setFormat] = useState<DispatchFormat>('blog');
const [tone, setTone] = useState<DispatchTone>('technical');
const [includeSessionBackground, setIncludeSessionBackground] = useState(false);
const [result, setResult] = useState<DispatchResponse | null>(null);
const [overlayOpen, setOverlayOpen] = useState(false);

// When drawer opens with a prefill, apply it; when closed, reset transient state
useEffect(() => {
if (open && prefill) {
setContext(prefill.contextMarkdown);
setContextEdited(false);
setFormat(prefill.format);
}
if (!open) {
setContext('');
setContextEdited(false);
}
}, [open, prefill]);

const mutation = useMutation({
mutationFn: generateDispatch,
onSuccess: (data) => { setResult(data); setOverlayOpen(true); },
Expand Down Expand Up @@ -162,6 +178,7 @@ export function DispatchDrawer({
setFormat('blog');
setTone('technical');
setContext('');
setContextEdited(false);
setIncludeSessionBackground(false);
}

Expand All @@ -178,7 +195,9 @@ export function DispatchDrawer({
<SheetHeader className="px-4 py-3 border-b shrink-0">
<SheetTitle>Create Post</SheetTitle>
<SheetDescription>
Curate insights and context, then generate a publishable post.
{prefill
? `Drafting from ${prefill.title}`
: 'Curate insights and context, then generate a publishable post.'}
</SheetDescription>
</SheetHeader>

Expand Down Expand Up @@ -231,7 +250,7 @@ export function DispatchDrawer({
maxLength={500}
placeholder="2-3 sentences framing the narrative. What did you build or discover? Why does it matter?"
value={context}
onChange={(e) => setContext(e.target.value)}
onChange={(e) => { setContext(e.target.value); if (prefill) setContextEdited(true); }}
className="resize-none"
/>
<div className="flex justify-between mt-1">
Expand All @@ -242,6 +261,16 @@ export function DispatchDrawer({
{context.length}/500
</span>
</div>
{prefill && contextEdited && (
<Button
variant="ghost"
size="sm"
className="mt-1 h-7 text-xs text-muted-foreground"
onClick={() => { setContext(prefill.contextMarkdown); setContextEdited(false); }}
>
Reset to defaults
</Button>
)}
</div>

{/* Format selector */}
Expand Down
59 changes: 59 additions & 0 deletions dashboard/src/components/insights/DispatchDiscoveryCallout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { Sparkles, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { captureDispatchCalloutDismissed } from '@/lib/telemetry';

interface DispatchDiscoveryCalloutProps {
onTryIt: () => void;
onDismiss: () => void;
}

export function DispatchDiscoveryCallout({ onTryIt, onDismiss }: DispatchDiscoveryCalloutProps) {
const [fading, setFading] = useState(false);

function dismiss(via: 'x' | 'not_now') {
captureDispatchCalloutDismissed(via);
setFading(true);
setTimeout(() => onDismiss(), 150);
}

function handleTryIt() {
captureDispatchCalloutDismissed('try_it');
setFading(true);
onTryIt();
// Dismiss after a short delay to let drawer open first
setTimeout(() => onDismiss(), 150);
}

return (
<div
className={`rounded-lg border bg-muted/40 px-4 py-3 mb-4 transition-opacity duration-150 ${fading ? 'opacity-0' : 'opacity-100'}`}
>
<div className="flex items-start gap-3">
<Sparkles className="h-4 w-4 text-primary mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Turn this session into a writeup</p>
<p className="text-xs text-muted-foreground mt-0.5">
Your patterns and friction points are ready — generate a blog post or LinkedIn writeup in one click.
</p>
<div className="flex items-center gap-2 mt-2">
<Button size="sm" onClick={handleTryIt}>
Try it
</Button>
<Button size="sm" variant="ghost" onClick={() => dismiss('not_now')}>
Not now
</Button>
</div>

</div>
<button
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => dismiss('x')}
aria-label="Dismiss callout"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}
24 changes: 24 additions & 0 deletions dashboard/src/components/insights/DispatchEntryButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { SessionCharacter } from '@/lib/types';

const QUALIFYING_TYPES = new Set<SessionCharacter>(['feature_build', 'deep_focus', 'bug_hunt', 'refactor']);

interface DispatchEntryButtonProps {
sessionCharacter: SessionCharacter | null | undefined;
facetsLoaded: boolean;
onClick: () => void;
}

export function DispatchEntryButton({ sessionCharacter, facetsLoaded, onClick }: DispatchEntryButtonProps) {
if (!sessionCharacter || !QUALIFYING_TYPES.has(sessionCharacter) || !facetsLoaded) {
return null;
}

return (
<Button variant="outline" size="sm" onClick={onClick}>
<PenLine className="h-4 w-4 mr-1.5" />
Write about this
</Button>
);
}
23 changes: 23 additions & 0 deletions dashboard/src/hooks/useDispatchDiscovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useState, useCallback } from 'react';

const KEY_DISMISSED = 'ci.dispatch.calloutDismissed';
const KEY_OPENED = 'ci.dispatch.opened';

export function useDispatchDiscovery() {
const [dismissed, setDismissed] = useState(() => localStorage.getItem(KEY_DISMISSED) === '1');
const [opened, setOpened] = useState(() => localStorage.getItem(KEY_OPENED) === '1');

const markCalloutDismissed = useCallback(() => {
localStorage.setItem(KEY_DISMISSED, '1');
setDismissed(true);
}, []);

const markDispatchOpened = useCallback(() => {
localStorage.setItem(KEY_OPENED, '1');
setOpened(true);
}, []);

const shouldShowCallout = !dismissed && !opened;

return { shouldShowCallout, markCalloutDismissed, markDispatchOpened };
}
15 changes: 14 additions & 1 deletion dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Base URL is relative in production (SPA served by the same server).
// In Vite dev mode, the proxy forwards /api -> localhost:7890.

import type { Project, Session, Message, Insight, DashboardStats, LLMConfig, ExportTemplate } from '@/lib/types';
import type { Project, Session, Message, Insight, DashboardStats, LLMConfig, ExportTemplate, FacetRow } from '@/lib/types';

const BASE = '/api';

Expand Down Expand Up @@ -313,6 +313,19 @@ export interface FacetAggregation {
totalTokens: number;
}

export function fetchFacets(params?: {
project?: string;
period?: string;
source?: string;
}) {
const q = new URLSearchParams();
if (params?.project) q.set('project', params.project);
if (params?.period) q.set('period', params.period);
if (params?.source) q.set('source', params.source);
const qs = q.toString() ? `?${q.toString()}` : '';
return request<{ facets: FacetRow[]; missingCount: number; totalSessions: number }>(`/facets${qs}`);
}

export function fetchFacetAggregation(params?: {
project?: string;
period?: string;
Expand Down
45 changes: 45 additions & 0 deletions dashboard/src/lib/buildDispatchPrefill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Session, FacetRow, FrictionPoint, EffectivePattern, DispatchPrefill } from '@/lib/types';
import { parseJsonField } from '@/lib/types';

const FORMAT_MAP: Partial<Record<string, DispatchPrefill['format']>> = {
feature_build: 'blog',
bug_hunt: 'linkedin',
refactor: 'blog',
deep_focus: 'blog',
};

// Note: the spec maps bug_hunt→postmortem and refactor/deep_focus→deep-dive, but
// DispatchFormat only has 'blog' | 'linkedin'. Mapping to closest available format.
// feature_build→blog, bug_hunt→linkedin (most punchy), refactor/deep_focus→blog.

export function buildDispatchPrefill(session: Session, facetRow: FacetRow): DispatchPrefill {
const title = session.custom_title ?? session.generated_title ?? 'Untitled Session';
const format = FORMAT_MAP[session.session_character ?? ''] ?? 'blog';

const patterns = parseJsonField<EffectivePattern[]>(facetRow.effective_patterns, []);
const friction = parseJsonField<FrictionPoint[]>(facetRow.friction_points, []);

const topPatterns = patterns.slice(0, 3);
const topFriction = friction
.filter((f) => f.attribution === 'user-actionable')
.slice(0, 3);

const sections: string[] = [];

if (topPatterns.length > 0) {
const lines = topPatterns.map((p) => `- ${p.description}`).join('\n');
sections.push(`## What you learned\n${lines}`);
}

if (topFriction.length > 0) {
const lines = topFriction.map((f) => `- ${f.description}`).join('\n');
sections.push(`## What was hard\n${lines}`);
}

return {
sessionId: session.id,
title,
format,
contextMarkdown: sections.join('\n\n'),
};
}
15 changes: 15 additions & 0 deletions dashboard/src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ export function capturePageView(path: string): void {
}
}

export function captureDispatchCalloutShown(): void {
if (!initialized) return;
try { posthog.capture('dispatch.discovery_callout_shown'); } catch { /* silent */ }
}

export function captureDispatchCalloutDismissed(via: 'x' | 'not_now' | 'try_it'): void {
if (!initialized) return;
try { posthog.capture('dispatch.discovery_callout_dismissed', { via }); } catch { /* silent */ }
}

export function captureDispatchOpenedFromInsights(sessionCharacter: string | null): void {
if (!initialized) return;
try { posthog.capture('dispatch.opened_from_insights', { session_character: sessionCharacter }); } catch { /* silent */ }
}

/**
* Capture the dashboard_loaded event with load time.
* @param page - The route segment (e.g. 'dashboard', 'sessions')
Expand Down
37 changes: 37 additions & 0 deletions dashboard/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,43 @@ export interface InsightMetadata {
potentialMessageReduction?: number;
}

// Raw session_facets row as returned by GET /api/facets
export interface FacetRow {
session_id: string;
outcome_satisfaction: string;
workflow_pattern: string | null;
had_course_correction: number;
course_correction_reason: string | null;
iteration_count: number;
friction_points: string; // JSON-encoded FrictionPoint[]
effective_patterns: string; // JSON-encoded EffectivePattern[]
extracted_at: string;
analysis_version: string;
}

export interface FrictionPoint {
category: string;
attribution?: 'user-actionable' | 'ai-capability' | 'environmental';
description: string;
severity: 'high' | 'medium' | 'low';
resolution: 'resolved' | 'workaround' | 'unresolved';
}

export interface EffectivePattern {
category: string;
description: string;
confidence: number;
driver?: 'user-driven' | 'ai-driven' | 'collaborative';
}

// Prefill data for DispatchDrawer when opened from InsightsPage
export interface DispatchPrefill {
sessionId: string;
title: string;
format: 'blog' | 'linkedin';
contextMarkdown: string;
}

// LLM config from /api/config/llm
export interface LLMConfig {
dashboardPort: number;
Expand Down
Loading
Loading