diff --git a/frontend/src/app/components/overlays/PatternOfferToast.tsx b/frontend/src/app/components/overlays/PatternOfferToast.tsx new file mode 100644 index 00000000..5c6da082 --- /dev/null +++ b/frontend/src/app/components/overlays/PatternOfferToast.tsx @@ -0,0 +1,103 @@ +// Bottom-left nudge for a mined behavior pattern: "you do this a lot, want a workflow?". +// Cites real evidence (count + rhythm) because users know they waste time on SOMETHING +// but usually can't name it. Create makes a real workflow and opens it for review; +// "No thanks" dismisses the pattern permanently; the X just hides it for now. + +import React from 'react'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { + PatternSuggestion, + acceptPatternSuggestion, + dismissPatternSuggestion, + hidePatternToast, +} from '@/shared/state/patternsSlice'; +import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; + +const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +function formatHour(hour: number): string { + if (hour === 0) return 'midnight'; + if (hour === 12) return 'noon'; + return hour < 12 ? `${hour}am` : `${hour - 12}pm`; +} + +function evidenceLine(s: PatternSuggestion): string { + const base = `Noticed ${s.evidence_count} times this month`; + if (s.cadence.kind === 'weekly' && s.cadence.on_days.length > 0) { + return `${base}, usually ${DAY_NAMES[s.cadence.on_days[0]]}s around ${formatHour(s.cadence.hour)}.`; + } + if (s.cadence.kind === 'daily') { + return `${base}, most days around ${formatHour(s.cadence.hour)}.`; + } + return `${base}.`; +} + +export default function PatternOfferToast() { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const open = useAppSelector((s) => s.patterns.toastOpen); + const accepting = useAppSelector((s) => s.patterns.accepting); + const suggestion = useAppSelector((s) => s.patterns.suggestions[0]); + + const onCreate = React.useCallback(async () => { + if (!suggestion) return; + try { + const res = await dispatch(acceptPatternSuggestion(suggestion.id)).unwrap(); + dispatch(openWorkflowsApp({ workflowId: res.workflow.id })); + } catch { + // Accept failed server-side; leave the toast up so the user can retry. + } + }, [dispatch, suggestion]); + + const onDecline = React.useCallback(() => { + if (suggestion) dispatch(dismissPatternSuggestion(suggestion.id)); + }, [dispatch, suggestion]); + + return ( + { if (reason !== 'clickaway') dispatch(hidePatternToast()); }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + + + + dispatch(hidePatternToast())} + sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }} + > + + + + } + > + {suggestion ? `${suggestion.description} ${evidenceLine(suggestion)} Want me to make it a workflow that runs itself?` : ''} + + + ); +} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 350e9b3e..e4e8e5e7 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -7,6 +7,7 @@ import DirectionHints from '../controls/DirectionHints'; import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast'; import MissedRunsToast from '@/app/pages/Workflows/MissedRunsToast'; import ProviderHealthToast from '@/app/components/overlays/ProviderHealthToast'; +import PatternOfferToast from '@/app/components/overlays/PatternOfferToast'; import type { AgentSession } from '@/shared/state/agentsSlice'; import type { CardPosition, @@ -156,6 +157,9 @@ const DashboardOverlays: React.FC = ({ {/* Launch nudge when a subscription login died while the app was closed */} + + {/* Mined-pattern offer: "you do this a lot, want a workflow?" */} + ); }; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index d2d65512..ae8da0c3 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -26,6 +26,7 @@ import { generateDashboardName } from '@/shared/state/dashboardsSlice'; import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/workflowsSlice'; import { fetchMissedRuns } from '@/shared/state/missedRunsSlice'; import { fetchProviderHealth } from '@/shared/state/subscriptionsSlice'; +import { fetchPatternSuggestions } from '@/shared/state/patternsSlice'; import { dashboardWs } from '@/shared/ws/WebSocketManager'; import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; import { getKeepAliveBrowserIds } from '@/shared/browserFocus'; @@ -89,7 +90,9 @@ export function useDashboardLifecycle({ if (res.skipped) setTimeout(() => { dispatch(fetchProviderHealth()); }, 45_000); } catch { /* probe is best-effort; silence on failure */ } }, 12_000); - return () => clearTimeout(t); + // Mined-pattern offers ride the same once-per-launch gate, staggered after the health pill so nudges don't stack; the ws patterns:suggestions_updated case covers a miner pass finishing later. + const tPatterns = setTimeout(() => { dispatch(fetchPatternSuggestions()); }, 20_000); + return () => { clearTimeout(t); clearTimeout(tPatterns); }; }, [isActive, dispatch]); // Track dashboard engagement time diff --git a/frontend/src/shared/state/patternsSlice.ts b/frontend/src/shared/state/patternsSlice.ts new file mode 100644 index 00000000..f2a64996 --- /dev/null +++ b/frontend/src/shared/state/patternsSlice.ts @@ -0,0 +1,91 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { API_BASE } from '@/shared/config'; + +export interface PatternCadence { + kind: 'weekly' | 'daily' | 'irregular'; + on_days: number[]; + hour: number; +} + +export interface PatternSuggestion { + id: string; + description: string; + evidence_count: number; + first_seen: string | null; + last_seen: string | null; + cadence: PatternCadence; + workflow_title: string; + workflow_steps: string[]; +} + +interface PatternsState { + suggestions: PatternSuggestion[]; + toastOpen: boolean; + accepting: boolean; +} + +const initialState: PatternsState = { + suggestions: [], + toastOpen: false, + accepting: false, +}; + +export const fetchPatternSuggestions = createAsyncThunk( + 'patterns/fetch', + async (): Promise<{ suggestions: PatternSuggestion[] }> => { + const r = await fetch(`${API_BASE}/patterns/suggestions`); + return (await r.json()) as { suggestions: PatternSuggestion[] }; + }, +); + +export const acceptPatternSuggestion = createAsyncThunk( + 'patterns/accept', + async (suggestionId: string): Promise<{ workflow: { id: string } }> => { + const r = await fetch(`${API_BASE}/patterns/suggestions/${suggestionId}/accept`, { method: 'POST' }); + if (!r.ok) throw new Error(`accept failed: ${r.status}`); + return (await r.json()) as { workflow: { id: string } }; + }, +); + +export const dismissPatternSuggestion = createAsyncThunk( + 'patterns/dismiss', + async (suggestionId: string): Promise => { + await fetch(`${API_BASE}/patterns/suggestions/${suggestionId}/dismiss`, { method: 'POST' }); + return suggestionId; + }, +); + +const patternsSlice = createSlice({ + name: 'patterns', + initialState, + reducers: { + hidePatternToast(state) { + state.toastOpen = false; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetchPatternSuggestions.fulfilled, (state, action) => { + state.suggestions = action.payload.suggestions ?? []; + state.toastOpen = state.suggestions.length > 0; + }); + builder.addCase(acceptPatternSuggestion.pending, (state) => { + state.accepting = true; + }); + builder.addCase(acceptPatternSuggestion.fulfilled, (state, action) => { + state.accepting = false; + state.suggestions = state.suggestions.filter((s) => s.id !== action.meta.arg); + // The user acted; don't immediately push the next offer in their face. + state.toastOpen = false; + }); + builder.addCase(acceptPatternSuggestion.rejected, (state) => { + state.accepting = false; + }); + builder.addCase(dismissPatternSuggestion.fulfilled, (state, action) => { + state.suggestions = state.suggestions.filter((s) => s.id !== action.payload); + state.toastOpen = state.suggestions.length > 0; + }); + }, +}); + +export const { hidePatternToast } = patternsSlice.actions; +export default patternsSlice.reducer; diff --git a/frontend/src/shared/state/store.ts b/frontend/src/shared/state/store.ts index 2b8b1345..5311d7bc 100644 --- a/frontend/src/shared/state/store.ts +++ b/frontend/src/shared/state/store.ts @@ -17,6 +17,7 @@ import interactionReducer from './interactionSlice'; import subscriptionsReducer from './subscriptionsSlice'; import workflowsReducer from './workflowsSlice'; import missedRunsReducer from './missedRunsSlice'; +import patternsReducer from './patternsSlice'; import onboardingProgressReducer from '@/shared/state/onboardingProgressSlice'; import onboardingV3Reducer from '@/shared/state/onboardingV3Slice'; @@ -40,6 +41,7 @@ export const store = configureStore({ subscriptions: subscriptionsReducer, workflows: workflowsReducer, missedRuns: missedRunsReducer, + patterns: patternsReducer, onboardingProgress: onboardingProgressReducer, onboardingV3: onboardingV3Reducer, }, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 320262f0..2895fdee 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -33,6 +33,7 @@ import { upsertOutput } from '../state/outputsSlice'; import { fetchSettings } from '../state/settingsSlice'; import { displaySessionName } from '../state/sessionDisplay'; import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice'; +import { fetchPatternSuggestions } from '../state/patternsSlice'; import { stepsSignature } from '@/app/pages/Workflows/scheduleUtils'; import { getAuthToken } from '../config'; import { notifyAgentCompletion } from '../notifications'; @@ -756,6 +757,11 @@ class WebSocketManager { } break; + case 'patterns:suggestions_updated': + // The miner just found something; refetch so the offer can appear this launch, not next. + store.dispatch(fetchPatternSuggestions()); + break; + case 'workflow:deleted': if (data.workflow_id) { store.dispatch(removeWorkflow(data.workflow_id));