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));