mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-25 22:12:22 +02:00
[eric] patterns: offer toast with evidence-citing copy, accept opens the created workflow
This commit is contained in:
@@ -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;
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user