From 95a68b21540a8dc6e3fb8e9340ba0dec1f953b70 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Mon, 18 May 2026 07:43:10 +0530 Subject: [PATCH 1/7] feat(dashboard): add DispatchPrefill type, FacetRow type, fetchFacets API, and dispatch telemetry events Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/lib/api.ts | 15 +++++++++++++- dashboard/src/lib/telemetry.ts | 15 ++++++++++++++ dashboard/src/lib/types.ts | 37 ++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index c965bacf..be502d68 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -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'; @@ -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; diff --git a/dashboard/src/lib/telemetry.ts b/dashboard/src/lib/telemetry.ts index d1d81f57..decc993d 100644 --- a/dashboard/src/lib/telemetry.ts +++ b/dashboard/src/lib/telemetry.ts @@ -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') diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index e4877d41..8792ed59 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -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; From cc8275423133053109b7db21ecb812a7db86c190 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Mon, 18 May 2026 07:43:15 +0530 Subject: [PATCH 2/7] feat(dashboard): add buildDispatchPrefill utility and useDispatchDiscovery hook buildDispatchPrefill maps session character to format and extracts top-3 patterns and user-actionable friction points into contextMarkdown. useDispatchDiscovery wraps localStorage for callout dismissed and dispatch opened state. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/hooks/useDispatchDiscovery.ts | 23 +++++++++++ dashboard/src/lib/buildDispatchPrefill.ts | 45 +++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 dashboard/src/hooks/useDispatchDiscovery.ts create mode 100644 dashboard/src/lib/buildDispatchPrefill.ts diff --git a/dashboard/src/hooks/useDispatchDiscovery.ts b/dashboard/src/hooks/useDispatchDiscovery.ts new file mode 100644 index 00000000..9957429f --- /dev/null +++ b/dashboard/src/hooks/useDispatchDiscovery.ts @@ -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 }; +} diff --git a/dashboard/src/lib/buildDispatchPrefill.ts b/dashboard/src/lib/buildDispatchPrefill.ts new file mode 100644 index 00000000..4e59acd8 --- /dev/null +++ b/dashboard/src/lib/buildDispatchPrefill.ts @@ -0,0 +1,45 @@ +import type { Session, FacetRow, FrictionPoint, EffectivePattern, DispatchPrefill } from '@/lib/types'; +import { parseJsonField } from '@/lib/types'; + +const FORMAT_MAP: Partial> = { + 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(facetRow.effective_patterns, []); + const friction = parseJsonField(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'), + }; +} From 617ec94c7a272ae9f9e3087745a46730edf51785 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Mon, 18 May 2026 07:43:21 +0530 Subject: [PATCH 3/7] feat(dashboard): add DispatchEntryButton and DispatchDiscoveryCallout components DispatchEntryButton renders for qualifying session types (feature_build, deep_focus, bug_hunt, refactor) and is absent entirely when session type doesn't qualify or facets are not loaded. DispatchDiscoveryCallout is a dismissible full-width strip with fade-out transition, firing PostHog telemetry on dismiss with the via property. Co-Authored-By: Claude Sonnet 4.6 --- .../insights/DispatchDiscoveryCallout.tsx | 59 +++++++++++++++++++ .../insights/DispatchEntryButton.tsx | 24 ++++++++ 2 files changed, 83 insertions(+) create mode 100644 dashboard/src/components/insights/DispatchDiscoveryCallout.tsx create mode 100644 dashboard/src/components/insights/DispatchEntryButton.tsx diff --git a/dashboard/src/components/insights/DispatchDiscoveryCallout.tsx b/dashboard/src/components/insights/DispatchDiscoveryCallout.tsx new file mode 100644 index 00000000..87aa5d43 --- /dev/null +++ b/dashboard/src/components/insights/DispatchDiscoveryCallout.tsx @@ -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 ( +
+
+ +
+

Turn this session into a writeup

+

+ Your patterns and friction points are ready — generate a blog post or LinkedIn writeup in one click. +

+
+ + +
+ +
+ +
+
+ ); +} diff --git a/dashboard/src/components/insights/DispatchEntryButton.tsx b/dashboard/src/components/insights/DispatchEntryButton.tsx new file mode 100644 index 00000000..160a6cc2 --- /dev/null +++ b/dashboard/src/components/insights/DispatchEntryButton.tsx @@ -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(['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 ( + + ); +} From 2da41fc762b6667feb0cb3274eef48a862ca9bb6 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Mon, 18 May 2026 07:43:27 +0530 Subject: [PATCH 4/7] feat(dashboard): add prefill support to DispatchDrawer Accepts optional prefill?: DispatchPrefill. When present, pre-fills title input (with autoFocus + select), pre-selects format, and populates context textarea. Shows a header subtitle "Drafting from {title}" and a "Reset to defaults" ghost button that appears only after the user edits the textarea. Co-Authored-By: Claude Sonnet 4.6 --- .../components/dispatch/DispatchDrawer.tsx | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/dashboard/src/components/dispatch/DispatchDrawer.tsx b/dashboard/src/components/dispatch/DispatchDrawer.tsx index 86d5b76f..5de0bc1c 100644 --- a/dashboard/src/components/dispatch/DispatchDrawer.tsx +++ b/dashboard/src/components/dispatch/DispatchDrawer.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { useMutation } from '@tanstack/react-query'; import { DndContext, @@ -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 }[] = [ @@ -109,6 +109,7 @@ interface DispatchDrawerProps { selectedInsights: Insight[]; onReorder: (insights: Insight[]) => void; onRemove: (id: string) => void; + prefill?: DispatchPrefill; } export function DispatchDrawer({ @@ -117,13 +118,38 @@ export function DispatchDrawer({ selectedInsights, onReorder, onRemove, + prefill, }: DispatchDrawerProps) { const [context, setContext] = useState(''); + const [contextEdited, setContextEdited] = useState(false); const [format, setFormat] = useState('blog'); const [tone, setTone] = useState('technical'); const [includeSessionBackground, setIncludeSessionBackground] = useState(false); const [result, setResult] = useState(null); const [overlayOpen, setOverlayOpen] = useState(false); + const titleInputRef = useRef(null); + const [titleValue, setTitleValue] = useState(''); + + // When drawer opens with a prefill, apply it + useEffect(() => { + if (open && prefill) { + setContext(prefill.contextMarkdown); + setContextEdited(false); + setFormat(prefill.format); + setTitleValue(prefill.title); + // Select all text in title input on next frame + requestAnimationFrame(() => { + if (titleInputRef.current) { + titleInputRef.current.focus(); + titleInputRef.current.select(); + } + }); + } + if (!open) { + setContextEdited(false); + setTitleValue(''); + } + }, [open, prefill]); const mutation = useMutation({ mutationFn: generateDispatch, @@ -162,6 +188,7 @@ export function DispatchDrawer({ setFormat('blog'); setTone('technical'); setContext(''); + setContextEdited(false); setIncludeSessionBackground(false); } @@ -178,11 +205,29 @@ export function DispatchDrawer({ Create Post - Curate insights and context, then generate a publishable post. + {prefill + ? `Drafting from ${prefill.title}` + : 'Curate insights and context, then generate a publishable post.'}
+ {/* Title input — only shown when prefill provides session context */} + {prefill && ( +
+ + setTitleValue(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + /> +
+ )} + {/* Selected insights with drag-to-reorder */}

@@ -231,7 +276,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" />

@@ -242,6 +287,16 @@ export function DispatchDrawer({ {context.length}/500
+ {prefill && contextEdited && ( + + )}
{/* Format selector */} From 2aa336a6e9b115b2b9748dae97defda8200f8462 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Mon, 18 May 2026 07:43:33 +0530 Subject: [PATCH 5/7] feat(dashboard): wire DispatchEntryButton + DiscoveryCallout + prefill into InsightsPage Fetches raw facets for the last 30d, computes the primary qualifying session (most recent feature_build/deep_focus/bug_hunt/refactor session with facets), and wires: - DispatchEntryButton in header (absent when no qualifying session or facets) - DispatchDiscoveryCallout below header (hidden after dismiss or once drawer opened) - DispatchDrawer receives prefill built from session title + facets - Telemetry: callout_shown, opened_from_insights events Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/pages/InsightsPage.tsx | 96 ++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 11 deletions(-) diff --git a/dashboard/src/pages/InsightsPage.tsx b/dashboard/src/pages/InsightsPage.tsx index 465d8434..7d1afb6e 100644 --- a/dashboard/src/pages/InsightsPage.tsx +++ b/dashboard/src/pages/InsightsPage.tsx @@ -1,11 +1,14 @@ -import { useMemo, useState, useCallback } from 'react'; +import { useMemo, useState, useCallback, useEffect } from 'react'; import { format } from 'date-fns'; import { useSearchParams, Link } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; import { useInsights } from '@/hooks/useInsights'; import { useSessions } from '@/hooks/useSessions'; import { useFilterParams } from '@/hooks/useFilterParams'; import { useProjects } from '@/hooks/useProjects'; +import { useDispatchDiscovery } from '@/hooks/useDispatchDiscovery'; import { buildPatternGroups } from '@/lib/pattern-grouping'; +import { buildDispatchPrefill } from '@/lib/buildDispatchPrefill'; import { InsightListItem } from '@/components/insights/InsightListItem'; // PromptQualityCard still used in SessionDetailPanel; on this page prompt_quality // insights render inline via InsightListItem → PromptQualityContent. @@ -27,7 +30,7 @@ import { import { Sparkles, SearchX, X, FileText, GitCommit, BookOpen, Target } from 'lucide-react'; import { getDateGroup, sortDateGroups } from '@/lib/utils'; import { INSIGHT_TYPE_LABELS } from '@/lib/constants/colors'; -import type { Insight, InsightType } from '@/lib/types'; +import type { Insight, InsightType, DispatchPrefill, SessionCharacter } from '@/lib/types'; import { InsightTypePills } from '@/components/filters/InsightTypePills'; import { SaveFilterPopover } from '@/components/filters/SaveFilterPopover'; import { SavedFiltersDropdown } from '@/components/filters/SavedFiltersDropdown'; @@ -36,9 +39,15 @@ import { useSavedFilters } from '@/hooks/useSavedFilters'; import { LlmNudgeBanner } from '@/components/LlmNudgeBanner'; import { DispatchDrawer } from '@/components/dispatch/DispatchDrawer'; import { FloatingActionBar } from '@/components/dispatch/FloatingActionBar'; +import { DispatchEntryButton } from '@/components/insights/DispatchEntryButton'; +import { DispatchDiscoveryCallout } from '@/components/insights/DispatchDiscoveryCallout'; +import { fetchFacets } from '@/lib/api'; +import { captureDispatchCalloutShown, captureDispatchOpenedFromInsights } from '@/lib/telemetry'; const INSIGHT_TYPES: InsightType[] = ['summary', 'decision', 'learning', 'technique', 'prompt_quality']; +const QUALIFYING_SESSION_TYPES = new Set(['feature_build', 'deep_focus', 'bug_hunt', 'refactor']); + const TYPE_SECTION_ICONS: Record = { summary: { icon: FileText, color: 'text-purple-500' }, decision: { icon: GitCommit, color: 'text-blue-500' }, @@ -77,6 +86,10 @@ export default function InsightsPage() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [selectedInsights, setSelectedInsights] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); + const [dispatchPrefill, setDispatchPrefill] = useState(undefined); + + // Dispatch discovery: callout + opened tracking + const { shouldShowCallout, markCalloutDismissed, markDispatchOpened } = useDispatchDiscovery(); const handleToggleSelect = useCallback((insight: Insight) => { setSelectedIds((prev) => { @@ -126,8 +139,55 @@ export default function InsightsPage() { // limit: 500 matches Analytics page pattern; server default is 50 which would silently miss sessions. const { data: allSessions = [] } = useSessions({ limit: 500 }); + // Fetch raw facets to power DispatchEntryButton prefill + const { data: facetsData } = useQuery({ + queryKey: ['facets', 'list'], + queryFn: () => fetchFacets({ period: '30d' }), + staleTime: 60_000, + }); + + // Build a map of session_id → facet row for prefill lookup + const facetsBySessionId = useMemo(() => { + const map = new Map['facets'][0]>(); + for (const f of (facetsData?.facets ?? [])) { + map.set(f.session_id, f); + } + return map; + }, [facetsData]); + + // Primary qualifying session: most recent session with a qualifying character that has facets + const primarySession = useMemo(() => { + const qualifying = allSessions.filter( + (s) => s.session_character && QUALIFYING_SESSION_TYPES.has(s.session_character as SessionCharacter) && facetsBySessionId.has(s.id) + ); + if (qualifying.length === 0) return null; + return qualifying.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime())[0]; + }, [allSessions, facetsBySessionId]); + const allInsightIds = useMemo(() => new Set(insights.map((i) => i.id)), [insights]); + // Fire callout_shown telemetry once when callout becomes visible + useEffect(() => { + if (shouldShowCallout && primarySession) { + captureDispatchCalloutShown(); + } + }, [shouldShowCallout, primarySession]); + + function openDispatchWithPrefill() { + if (!primarySession) return; + const facetRow = facetsBySessionId.get(primarySession.id); + if (!facetRow) return; + const prefill = buildDispatchPrefill(primarySession, facetRow); + setDispatchPrefill(prefill); + setDrawerOpen(true); + markDispatchOpened(); + captureDispatchOpenedFromInsights(primarySession.session_character); + } + + function handleCalloutDismiss() { + markCalloutDismissed(); + } + // Map session_id → source_tool for client-side source filtering on Insights const sessionSourceMap = useMemo(() => { const map = new Map(); @@ -238,14 +298,21 @@ export default function InsightsPage() {
{/* Sticky header: title + filters */}
-
-

Insights

- {!isLoading && ( -

- {filtered.length} insight{filtered.length !== 1 ? 's' : ''} - {hasFilters ? ' matching filters' : ''} -

- )} +
+
+

Insights

+ {!isLoading && ( +

+ {filtered.length} insight{filtered.length !== 1 ? 's' : ''} + {hasFilters ? ' matching filters' : ''} +

+ )} +
+
{/* Pattern filter banner */} @@ -331,6 +398,12 @@ export default function InsightsPage() { {/* Scrollable content */}
+ {shouldShowCallout && primarySession && facetsBySessionId.has(primarySession.id) && ( + { openDispatchWithPrefill(); }} + onDismiss={handleCalloutDismiss} + /> + )} {isError && !isLoading ? ( @@ -422,7 +495,7 @@ export default function InsightsPage() { setDrawerOpen(true)} + onOpen={() => { setDispatchPrefill(undefined); setDrawerOpen(true); }} />
); From 3cd6a30940f7bc58025f4e92ff1b32e292e8549f Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Mon, 18 May 2026 19:13:18 +0530 Subject: [PATCH 6/7] =?UTF-8?q?fix(dispatch):=20round-2=20review=20fixes?= =?UTF-8?q?=20=E2=80=94=20remove=20title=20input,=20auto-select=20insights?= =?UTF-8?q?,=20reset=20context=20on=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed editable Post title input + titleValue state + titleInputRef + RAF focus code (server DispatchRequest has no title field; subtitle already shows session context) - openDispatchWithPrefill() now calls setSelectedInsights/setSelectedIds with insights from the qualifying session so canGenerate passes immediately on entry from InsightsPage - useEffect(!open) branch now resets context to match handleClose — both close paths leave the drawer in an identical clean state Co-Authored-By: Claude Sonnet 4.6 --- .../components/dispatch/DispatchDrawer.tsx | 32 ++----------------- dashboard/src/pages/InsightsPage.tsx | 8 +++++ 2 files changed, 11 insertions(+), 29 deletions(-) diff --git a/dashboard/src/components/dispatch/DispatchDrawer.tsx b/dashboard/src/components/dispatch/DispatchDrawer.tsx index 5de0bc1c..334da595 100644 --- a/dashboard/src/components/dispatch/DispatchDrawer.tsx +++ b/dashboard/src/components/dispatch/DispatchDrawer.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useMutation } from '@tanstack/react-query'; import { DndContext, @@ -127,27 +127,17 @@ export function DispatchDrawer({ const [includeSessionBackground, setIncludeSessionBackground] = useState(false); const [result, setResult] = useState(null); const [overlayOpen, setOverlayOpen] = useState(false); - const titleInputRef = useRef(null); - const [titleValue, setTitleValue] = useState(''); - // When drawer opens with a prefill, apply it + // 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); - setTitleValue(prefill.title); - // Select all text in title input on next frame - requestAnimationFrame(() => { - if (titleInputRef.current) { - titleInputRef.current.focus(); - titleInputRef.current.select(); - } - }); } if (!open) { + setContext(''); setContextEdited(false); - setTitleValue(''); } }, [open, prefill]); @@ -212,22 +202,6 @@ export function DispatchDrawer({
- {/* Title input — only shown when prefill provides session context */} - {prefill && ( -
- - setTitleValue(e.target.value)} - className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" - /> -
- )} - {/* Selected insights with drag-to-reorder */}

diff --git a/dashboard/src/pages/InsightsPage.tsx b/dashboard/src/pages/InsightsPage.tsx index 7d1afb6e..d0d5b2f9 100644 --- a/dashboard/src/pages/InsightsPage.tsx +++ b/dashboard/src/pages/InsightsPage.tsx @@ -178,6 +178,14 @@ export default function InsightsPage() { const facetRow = facetsBySessionId.get(primarySession.id); if (!facetRow) return; const prefill = buildDispatchPrefill(primarySession, facetRow); + + // Auto-select insights from this session so canGenerate passes on entry + const sessionInsights = insights + .filter((i) => i.session_id === primarySession.id) + .slice(0, MAX_DISPATCH_INSIGHTS); + setSelectedInsights(sessionInsights); + setSelectedIds(new Set(sessionInsights.map((i) => i.id))); + setDispatchPrefill(prefill); setDrawerOpen(true); markDispatchOpened(); From 272ce840eb057520eae1b38697a852971d5a78b8 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Mon, 18 May 2026 19:21:49 +0530 Subject: [PATCH 7/7] =?UTF-8?q?fix(dispatch):=20tighten=20qualifying=20ses?= =?UTF-8?q?sion=20filter=20=E2=80=94=20require=20=E2=89=A53=20insights=20a?= =?UTF-8?q?nd=20non-empty=20prefill=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/pages/InsightsPage.tsx | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/dashboard/src/pages/InsightsPage.tsx b/dashboard/src/pages/InsightsPage.tsx index d0d5b2f9..1050ffc9 100644 --- a/dashboard/src/pages/InsightsPage.tsx +++ b/dashboard/src/pages/InsightsPage.tsx @@ -30,7 +30,8 @@ import { import { Sparkles, SearchX, X, FileText, GitCommit, BookOpen, Target } from 'lucide-react'; import { getDateGroup, sortDateGroups } from '@/lib/utils'; import { INSIGHT_TYPE_LABELS } from '@/lib/constants/colors'; -import type { Insight, InsightType, DispatchPrefill, SessionCharacter } from '@/lib/types'; +import { parseJsonField } from '@/lib/types'; +import type { Insight, InsightType, DispatchPrefill, SessionCharacter, EffectivePattern, FrictionPoint } from '@/lib/types'; import { InsightTypePills } from '@/components/filters/InsightTypePills'; import { SaveFilterPopover } from '@/components/filters/SaveFilterPopover'; import { SavedFiltersDropdown } from '@/components/filters/SavedFiltersDropdown'; @@ -155,14 +156,27 @@ export default function InsightsPage() { return map; }, [facetsData]); - // Primary qualifying session: most recent session with a qualifying character that has facets + // Primary qualifying session: most recent session with a qualifying character that has facets, + // at least 3 insights (so canGenerate can be satisfied after auto-select), and non-empty + // prefill content (so contextMarkdown won't be empty when the drawer opens). const primarySession = useMemo(() => { - const qualifying = allSessions.filter( - (s) => s.session_character && QUALIFYING_SESSION_TYPES.has(s.session_character as SessionCharacter) && facetsBySessionId.has(s.id) - ); + const qualifying = allSessions.filter((s) => { + if (!s.session_character || !QUALIFYING_SESSION_TYPES.has(s.session_character as SessionCharacter)) return false; + const facetRow = facetsBySessionId.get(s.id); + if (!facetRow) return false; + // Require ≥3 insights so canGenerate can be satisfied after auto-select + const sessionInsightCount = insights.filter((i) => i.session_id === s.id).length; + if (sessionInsightCount < 3) return false; + // Require non-empty prefill content so contextMarkdown isn't empty + const patterns = parseJsonField(facetRow.effective_patterns, []); + const friction = parseJsonField(facetRow.friction_points, []).filter( + (f) => f.attribution === 'user-actionable' + ); + return patterns.length > 0 || friction.length > 0; + }); if (qualifying.length === 0) return null; return qualifying.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime())[0]; - }, [allSessions, facetsBySessionId]); + }, [allSessions, facetsBySessionId, insights]); const allInsightIds = useMemo(() => new Set(insights.map((i) => i.id)), [insights]);