[eric] patterns: offer toast with evidence-citing copy, accept opens the created workflow

This commit is contained in:
ciregenz
2026-07-28 11:56:52 -07:00
parent ea93e261b7
commit e0c6f796ff
6 changed files with 210 additions and 1 deletions
@@ -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 (
<Snackbar
open={open && !!suggestion}
autoHideDuration={null}
// Clickaway would kill the offer on the user's first canvas click, before they read it.
onClose={(event, reason) => { if (reason !== 'clickaway') dispatch(hidePatternToast()); }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
icon={false}
severity="info"
sx={{
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
maxWidth: 460,
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
}}
action={
<>
<Button size="small" disabled={accepting} onClick={onCreate} sx={{ color: c.accent.primary, fontWeight: 700, whiteSpace: 'nowrap' }}>
{accepting ? 'Creating...' : 'Create workflow'}
</Button>
<Button size="small" disabled={accepting} onClick={onDecline} sx={{ color: c.text.muted, whiteSpace: 'nowrap' }}>
No thanks
</Button>
<IconButton
size="small"
aria-label="Hide for now"
onClick={() => dispatch(hidePatternToast())}
sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</>
}
>
{suggestion ? `${suggestion.description} ${evidenceLine(suggestion)} Want me to make it a workflow that runs itself?` : ''}
</Alert>
</Snackbar>
);
}
@@ -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<DashboardOverlaysProps> = ({
{/* Launch nudge when a subscription login died while the app was closed */}
<ProviderHealthToast />
{/* Mined-pattern offer: "you do this a lot, want a workflow?" */}
<PatternOfferToast />
</>
);
};
@@ -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
@@ -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<string> => {
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;
+2
View File
@@ -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,
},
@@ -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));