diff --git a/.github/workspace-dep-closures.json b/.github/workspace-dep-closures.json index bb438836fcf..4909410a1cc 100644 --- a/.github/workspace-dep-closures.json +++ b/.github/workspace-dep-closures.json @@ -875,6 +875,7 @@ "crates/reminders", "crates/remote_env_var", "crates/roles_and_permissions", + "crates/routine_suggestions", "crates/routines", "crates/s3_key", "crates/scheduled_action", @@ -11604,6 +11605,101 @@ "crates/roles_and_permissions", "crates/workspace-hack" ], + "routine_suggestions": [ + "crates/activity", + "crates/agent", + "crates/agent_runtime_protocol", + "crates/ai_toolset", + "crates/ai_usage", + "crates/attachment", + "crates/authentication_service_client", + "crates/bot_id", + "crates/bot_token", + "crates/broadcast", + "crates/calendar_events", + "crates/call", + "crates/channel_sender", + "crates/channels", + "crates/connection", + "crates/connection_gateway_client", + "crates/connection_gateway_models", + "crates/contacts", + "crates/cowlike", + "crates/crm", + "crates/database_env_vars", + "crates/database_sql", + "crates/databases", + "crates/document_sub_type", + "crates/email", + "crates/email_api_client", + "crates/email_db_client", + "crates/email_service_client", + "crates/email_utils", + "crates/entity_access", + "crates/entity_access_db_utils", + "crates/entity_access_management", + "crates/entity_mutation", + "crates/filter_ast", + "crates/folds/agent_fold", + "crates/frecency", + "crates/genai_telemetry", + "crates/gmail_client", + "crates/google_token", + "crates/harness_id", + "crates/harness_token", + "crates/initiative", + "crates/invite_email", + "crates/ip_extractor", + "crates/item_filters", + "crates/kafka_util", + "crates/last_online_tracker", + "crates/lexical_client", + "crates/macro_auth", + "crates/macro_authorization", + "crates/macro_aws_config", + "crates/macro_entrypoint", + "crates/macro_env", + "crates/macro_env_var", + "crates/macro_event_broker", + "crates/macro_event_topics", + "crates/macro_service_urls", + "crates/macro_tower_layers", + "crates/macro_user_id", + "crates/macro_uuid", + "crates/maybe_send", + "crates/mention_utils", + "crates/messages", + "crates/model", + "crates/model-entity", + "crates/model-error-response", + "crates/model_file_type", + "crates/model_notifications", + "crates/model_owner", + "crates/model_user", + "crates/models_bulk_upload", + "crates/models_databases", + "crates/models_email", + "crates/models_entity_access_management", + "crates/models_pagination", + "crates/models_permissions", + "crates/models_properties", + "crates/models_sfs", + "crates/non_empty", + "crates/notification", + "crates/notification_state", + "crates/prompt", + "crates/properties", + "crates/rate_limit", + "crates/remote_env_var", + "crates/routine_suggestions", + "crates/share_permission_db_utils", + "crates/sqs_worker", + "crates/static_file_service_client", + "crates/system_properties", + "crates/trigger_context", + "crates/unfurl", + "crates/workspace-hack" + ], "routines": [ "crates/ai_toolset", "crates/bot_id", diff --git a/.sqlx/query-261339e50c4dfc7c940f1104505f1463c5359688c9ea8fd56caa607c8946673b.json b/.sqlx/query-261339e50c4dfc7c940f1104505f1463c5359688c9ea8fd56caa607c8946673b.json new file mode 100644 index 00000000000..f71ecd4a73f --- /dev/null +++ b/.sqlx/query-261339e50c4dfc7c940f1104505f1463c5359688c9ea8fd56caa607c8946673b.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE routine_suggestion_set\n SET status = 'ready', suggestions = $3, generated_at = $4\n WHERE user_id = $1 AND generation_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid", + "Jsonb", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "261339e50c4dfc7c940f1104505f1463c5359688c9ea8fd56caa607c8946673b" +} diff --git a/.sqlx/query-2f982073cae861ed109c0a5600305ce1c85ca877057d859ad2b0e08765bd187d.json b/.sqlx/query-2f982073cae861ed109c0a5600305ce1c85ca877057d859ad2b0e08765bd187d.json new file mode 100644 index 00000000000..8a5210652c8 --- /dev/null +++ b/.sqlx/query-2f982073cae861ed109c0a5600305ce1c85ca877057d859ad2b0e08765bd187d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE routine_suggestion_set\n SET status = 'failed'\n WHERE user_id = $1 AND generation_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "2f982073cae861ed109c0a5600305ce1c85ca877057d859ad2b0e08765bd187d" +} diff --git a/.sqlx/query-c1e006045fc68c8ac6380a6d39f4216452bed1f96ed54425f83494cc829c0dad.json b/.sqlx/query-c1e006045fc68c8ac6380a6d39f4216452bed1f96ed54425f83494cc829c0dad.json new file mode 100644 index 00000000000..82b0047bdb0 --- /dev/null +++ b/.sqlx/query-c1e006045fc68c8ac6380a6d39f4216452bed1f96ed54425f83494cc829c0dad.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO routine_suggestion_set (user_id, status, generation_id, started_at)\n VALUES ($1, 'generating', $2, $3)\n ON CONFLICT (user_id) DO UPDATE\n SET status = 'generating',\n generation_id = EXCLUDED.generation_id,\n started_at = EXCLUDED.started_at\n WHERE routine_suggestion_set.status <> 'generating'\n OR routine_suggestion_set.started_at < $4\n RETURNING generation_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "generation_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c1e006045fc68c8ac6380a6d39f4216452bed1f96ed54425f83494cc829c0dad" +} diff --git a/.sqlx/query-d09ecbcadea9a0be7a470ff48bd7bb8ac0c6dc03c279ed0ae45a3164bfac1827.json b/.sqlx/query-d09ecbcadea9a0be7a470ff48bd7bb8ac0c6dc03c279ed0ae45a3164bfac1827.json new file mode 100644 index 00000000000..0c7a3b6395b --- /dev/null +++ b/.sqlx/query-d09ecbcadea9a0be7a470ff48bd7bb8ac0c6dc03c279ed0ae45a3164bfac1827.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT status, suggestions, started_at, generated_at\n FROM routine_suggestion_set\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "suggestions", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "generated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "d09ecbcadea9a0be7a470ff48bd7bb8ac0c6dc03c279ed0ae45a3164bfac1827" +} diff --git a/.sqlx/query-f239423fdf1f4c657e7e2ec3408427ff554acf834e45633166c158e0c882ff31.json b/.sqlx/query-f239423fdf1f4c657e7e2ec3408427ff554acf834e45633166c158e0c882ff31.json new file mode 100644 index 00000000000..2c3bbe5d098 --- /dev/null +++ b/.sqlx/query-f239423fdf1f4c657e7e2ec3408427ff554acf834e45633166c158e0c882ff31.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, email_address, provider as \"provider: DbUserProvider\",\n sync_generation, grant_generation\n FROM email_links\n WHERE macro_id = $1 AND NOT needs_reauth\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email_address", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "provider: DbUserProvider", + "type_info": { + "Custom": { + "name": "email_user_provider_enum", + "kind": { + "Enum": [ + "GMAIL", + "OUTLOOK" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "sync_generation", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "grant_generation", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "f239423fdf1f4c657e7e2ec3408427ff554acf834e45633166c158e0c882ff31" +} diff --git a/Cargo.lock b/Cargo.lock index 2d20502121a..8cec38bd4af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,6 +647,7 @@ dependencies = [ "rmcp", "roles_and_permissions", "rootcause", + "routine_suggestions", "scheduled_action", "schemars 1.2.1", "search_service_client", @@ -7475,6 +7476,7 @@ version = "0.1.0" dependencies = [ "anyhow", "email", + "macro_tower_layers", "model", "models_email", "reqwest 0.13.4", @@ -16804,6 +16806,35 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "routine_suggestions" +version = "0.1.0" +dependencies = [ + "agent", + "ai_usage", + "axum", + "chrono", + "email_service_client", + "macro_authorization", + "macro_db_migrator", + "macro_user_id", + "macro_uuid", + "models_email", + "rootcause", + "schemars 1.2.1", + "serde", + "serde_json", + "sqlx", + "strum 0.27.2", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "utoipa", + "uuid", + "workspace-hack", +] + [[package]] name = "routines" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 788625eff0f..02ca2a94afb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,6 +111,7 @@ members = [ "crates/pipedream_mcp", "crates/granola_sync", "crates/onboarding", + "crates/routine_suggestions", "crates/ai_engine", "crates/docx_engine", "crates/fig_engine", diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index e9a169d992f..6c883422d6b 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -51,6 +51,10 @@ beside a scrolling editor whose selection runs past its visible bottom (`clipToVisible`), have a backend-free browser regression: `bunx playwright test --config src/lib/core/component/LexicalMarkdown/directive/browser-test/playwright.config.ts`. +Suggested routines (sidebar card, modal, one-click enable, edit before enabling, +and the phone sheet) have a backend-free browser regression: +`bunx playwright test --config src/features/routine-suggestions/browser-test/playwright.config.ts`. + Composer `/` menus list Connections between Skills and Pull requests. Keep native and hosted providers aligned with `mcp_select::select_connections`, prefer native credentials per provider, and preserve editor focus on pointer and touch selection. diff --git a/apps/web/src/components/app/split-layout/componentRegistry.tsx b/apps/web/src/components/app/split-layout/componentRegistry.tsx index fd2c9b11495..439eaf49556 100644 --- a/apps/web/src/components/app/split-layout/componentRegistry.tsx +++ b/apps/web/src/components/app/split-layout/componentRegistry.tsx @@ -439,6 +439,7 @@ registerComponent('routine-compose', (params) => ( ? (params.onCreated as (id: string) => void) : undefined } + initialDraft={params.initialDraft} /> )); registerComponent('document-compose', (params) => { diff --git a/apps/web/src/features/agents-view/components/AgentsSidebar.test.tsx b/apps/web/src/features/agents-view/components/AgentsSidebar.test.tsx index 35e2d9881a4..31f41bce5b9 100644 --- a/apps/web/src/features/agents-view/components/AgentsSidebar.test.tsx +++ b/apps/web/src/features/agents-view/components/AgentsSidebar.test.tsx @@ -21,6 +21,9 @@ import { AgentsSidebar } from './AgentsSidebar'; const openWithSplit = vi.fn(); const unreadFilter = vi.hoisted(() => vi.fn(() => false)); +vi.mock('@app/features/routine-suggestions/routine-suggestions', () => ({ + SidebarRoutineSuggestions: () => null, +})); vi.mock('@components/app/split-layout/layout', () => ({ useSplitLayout: () => ({ openWithSplit }), })); diff --git a/apps/web/src/features/agents-view/components/AgentsSidebar.tsx b/apps/web/src/features/agents-view/components/AgentsSidebar.tsx index d13a8226ce1..cc3c7a06444 100644 --- a/apps/web/src/features/agents-view/components/AgentsSidebar.tsx +++ b/apps/web/src/features/agents-view/components/AgentsSidebar.tsx @@ -12,6 +12,7 @@ import { type EntityActionViewContext, toEntityActionListState, } from '@app/features/next-soup/actions'; +import { SidebarRoutineSuggestions } from '@app/features/routine-suggestions/routine-suggestions'; import { MaybeSoupEntityActionDrawerManager, SoupEntityContextMenu, @@ -288,6 +289,7 @@ export function AgentsSidebar(props: AgentsSidebarProps) { + + diff --git a/apps/web/src/features/calls-view/components/calls-sidebar.tsx b/apps/web/src/features/calls-view/components/calls-sidebar.tsx index 4a07682a2b4..d851218ca59 100644 --- a/apps/web/src/features/calls-view/components/calls-sidebar.tsx +++ b/apps/web/src/features/calls-view/components/calls-sidebar.tsx @@ -6,6 +6,7 @@ import { NewCallButton } from '@app/features/next-soup/soup-view/NewCallButton'; import { useSoupView } from '@app/features/next-soup/soup-view/soup-view-context'; import { useApplyPreset } from '@app/features/next-soup/soup-view/soup-view-tabs'; import { VIEW_TAB_LISTS } from '@app/features/next-soup/soup-view/tab-lists'; +import { SidebarRoutineSuggestions } from '@app/features/routine-suggestions/routine-suggestions'; import { usePreference } from '@app/preferences/use-preference'; import { fireAndForget } from '@core/util/fireAndForget'; import BuildingsIcon from '@icon/system/regular/buildings.svg'; @@ -176,6 +177,7 @@ export function CallsSidebar(props: { onNavigate: () => void }) { + {(tab) => ( diff --git a/apps/web/src/features/channels-view/components/rail/ExpandedChannelsRail.tsx b/apps/web/src/features/channels-view/components/rail/ExpandedChannelsRail.tsx index 541f7b2199b..fa8f2f19f93 100644 --- a/apps/web/src/features/channels-view/components/rail/ExpandedChannelsRail.tsx +++ b/apps/web/src/features/channels-view/components/rail/ExpandedChannelsRail.tsx @@ -1,5 +1,6 @@ import { SearchBar, ViewSidebar } from '@app/components/view-shell'; import { runCreateAction } from '@app/features/command/Launcher'; +import { SidebarRoutineSuggestions } from '@app/features/routine-suggestions/routine-suggestions'; import { SlackImportSidebar } from '@app/features/slack-import/slack-import-sidebar'; import { DEBUG_SETTING_KEYS, useDebugSetting } from '@app/lib/debugSettings'; import { openNewChannelModal } from '@channel/CreateChannelModal'; @@ -422,6 +423,7 @@ function ExpandedHeader(props: { search: ChannelRailSearch }) { + ({ + SidebarRoutineSuggestions: () => null, +})); vi.mock('@app/lib/analytics/posthog', () => ({ useFeatureFlag: () => () => ({ enabled: false, loading: false }), })); diff --git a/apps/web/src/features/crm/components/crm-sidebar.tsx b/apps/web/src/features/crm/components/crm-sidebar.tsx index 9b8aca61fb3..8685cb01bbe 100644 --- a/apps/web/src/features/crm/components/crm-sidebar.tsx +++ b/apps/web/src/features/crm/components/crm-sidebar.tsx @@ -1,4 +1,5 @@ import { CollapsibleSection, ViewSidebar } from '@app/components/view-shell'; +import { SidebarRoutineSuggestions } from '@app/features/routine-suggestions/routine-suggestions'; import { useFeatureFlag } from '@app/lib/analytics/posthog'; import { getIconConfig } from '@core/component/EntityIcon'; import { @@ -101,6 +102,7 @@ export function CrmSidebar(props: { + + diff --git a/apps/web/src/features/email-view/components/EmailSidebar.tsx b/apps/web/src/features/email-view/components/EmailSidebar.tsx index c9fe3c73dc1..b59c8632f3a 100644 --- a/apps/web/src/features/email-view/components/EmailSidebar.tsx +++ b/apps/web/src/features/email-view/components/EmailSidebar.tsx @@ -4,6 +4,7 @@ import { ViewSidebar, } from '@app/components/view-shell'; import { SidebarCreateButton } from '@app/components/view-shell/SidebarCreateButton'; +import { SidebarRoutineSuggestions } from '@app/features/routine-suggestions/routine-suggestions'; import { useFeatureFlag } from '@app/lib/analytics/posthog'; import { useSplitLayout } from '@components/app/split-layout/layout'; import { useSplitPanelOrThrow } from '@components/app/split-layout/layoutUtils'; @@ -226,6 +227,7 @@ export function EmailSidebar() { + diff --git a/apps/web/src/features/onboarding/browser-test/fixture-page.ts b/apps/web/src/features/onboarding/browser-test/fixture-page.ts index bc0bf49a952..e701c4eed82 100644 --- a/apps/web/src/features/onboarding/browser-test/fixture-page.ts +++ b/apps/web/src/features/onboarding/browser-test/fixture-page.ts @@ -34,6 +34,12 @@ export const fakeWorld = (page: Page) => export const fakeEvents = (page: Page) => page.evaluate(() => window.onboardingFixture.events()); +/** Leave the last step with the routines the AI switched on. */ +export async function finishRoutines(page: Page) { + await expect(heading(page, 'Put your busywork on autopilot.')).toBeVisible(); + await page.getByRole('button', { name: /^Turn on \d+ routines?$/ }).click(); +} + export async function expectLanded(page: Page, text: string) { await expect(page.getByTestId('landed')).toHaveText(text); } diff --git a/apps/web/src/features/onboarding/browser-test/onboarding.browser.e2e.ts b/apps/web/src/features/onboarding/browser-test/onboarding.browser.e2e.ts index 0e8bd7422e4..ba0d231bf46 100644 --- a/apps/web/src/features/onboarding/browser-test/onboarding.browser.e2e.ts +++ b/apps/web/src/features/onboarding/browser-test/onboarding.browser.e2e.ts @@ -5,6 +5,7 @@ import { expectLanded, fakeEvents, fakeWorld, + finishRoutines, heading, openFixture, } from './fixture-page'; @@ -60,8 +61,19 @@ test('a new user signs up, connects everything, and starts the trial', async ({ await expect(page.getByRole('textbox')).toHaveCount(0); await page.getByRole('button', { name: 'Start 30 day trial' }).click(); - // Stripe returns on the success leg; the flow waits for the webhook's license. + // Stripe returns on the success leg; the flow waits for the webhook's + // license, then offers routines with the AI's likely keepers switched on. + await expect(heading(page, 'Put your busywork on autopilot.')).toBeVisible(); + await expect( + page.getByRole('heading', { level: 2, name: 'Email automations' }) + ).toBeVisible(); + await expect(page.getByRole('switch', { checked: true })).toHaveCount(3); + await page + .getByRole('switch', { name: 'Turn on Alert me about Acme' }) + .check({ force: true }); + await page.getByRole('button', { name: 'Turn on 4 routines' }).click(); await expectLanded(page, 'Entered Macro at /home'); + expect((await fakeWorld(page)).routines).toHaveLength(4); const world = await fakeWorld(page); expect(world.viewer).toMatchObject({ tutorialComplete: true, @@ -87,6 +99,7 @@ test('a deep link survives the inbox OAuth round-trip', async ({ page }) => { await page.getByRole('button', { name: 'Skip for now' }).click(); await page.getByRole('button', { name: 'Continue with Free' }).last().click(); + await finishRoutines(page); await expectLanded(page, 'Entered Macro at /channel/launch'); }); @@ -182,6 +195,7 @@ test('Free, Pro, and Max appear below the unchanged trial offer', async ({ ).toBe(true); await page.getByRole('button', { name: 'Continue with Free' }).last().click(); + await finishRoutines(page); await expectLanded(page, 'Entered Macro at /md/notes'); expect(await fakeEvents(page)).toContainEqual({ event: 'onboarding_v4_completed', @@ -201,6 +215,7 @@ test('Max checkout returns with a licensed account and no trial', async ({ }); await page.reload(); await page.getByRole('button', { name: 'Continue with Max' }).click(); + await finishRoutines(page); await expectLanded(page, 'Entered Macro at /home'); expect((await fakeWorld(page)).viewer).toMatchObject({ tutorialComplete: true, @@ -279,6 +294,7 @@ test('an invite’s free months replace the trial and keep its promotion', async page.getByRole('button', { name: 'Start 30 day trial' }) ).toHaveCount(0); await page.getByRole('button', { name: 'Claim your free months' }).click(); + await finishRoutines(page); await expectLanded(page, 'Entered Macro at /home'); expect((await fakeWorld(page)).viewer?.licensed).toBe(true); }); @@ -405,6 +421,7 @@ for (const width of [1440, 390]) { 'tools', 'team', 'plan', + 'routines', ]) { await page.evaluate((step) => { localStorage.setItem( @@ -756,6 +773,7 @@ for (const width of [1280, 390]) { page.getByText('30 days free, then', { exact: false }) ).toHaveCount(0); await page.getByRole('button', { name: 'Get Pro', exact: true }).click(); + await finishRoutines(page); await expectLanded(page, 'Entered Macro at /home'); }); } diff --git a/apps/web/src/features/onboarding/context/onboarding-context.tsx b/apps/web/src/features/onboarding/context/onboarding-context.tsx index 40acf3b259e..59c64f2a306 100644 --- a/apps/web/src/features/onboarding/context/onboarding-context.tsx +++ b/apps/web/src/features/onboarding/context/onboarding-context.tsx @@ -1,4 +1,7 @@ import type { PaidPlanTier } from '@app/features/paywall/plans'; +import type { RoutineSuggestionsSource } from '@app/features/routine-suggestions/queries/routine-suggestions'; +import type { ScheduleDraft } from '@app/features/routines/core/draft'; +import type { RoutineTarget } from '@app/features/routines/core/routine-target'; import type { AppEvents } from '@app/lib/analytics/app-events'; import type { AiPlanCatalog } from '@service-auth/ai-billing-types'; import type { GtmInviteOffer } from '@service-auth/generated/schemas/gtmInviteOffer'; @@ -138,6 +141,16 @@ export type OnboardingContext = { * `skipped` records that the user left without going through the steps. */ completeOnboarding(options: { skipped: boolean }): Promise; + /** Starts suggesting routines from the inbox and tools connected so far. */ + warmRoutineSuggestions(): void; + /** The last step's AI-picked routines, and how to turn them on. */ + createRoutineSuggestionsStep(): { + source: RoutineSuggestionsSource; + /** What new routines run with. */ + target: Accessor; + /** Creates the routine, turned on; resolves with its id, rejects on failure. */ + create(draft: ScheduleDraft): Promise; + }; /** Finishes a tutorial flag left behind by a completed onboarding record. */ repairTutorial(): Promise; applyAccent(color: string, viewerId: string): void; diff --git a/apps/web/src/features/onboarding/core/steps.ts b/apps/web/src/features/onboarding/core/steps.ts index 420eccf3356..7443ab44c39 100644 --- a/apps/web/src/features/onboarding/core/steps.ts +++ b/apps/web/src/features/onboarding/core/steps.ts @@ -7,6 +7,7 @@ export const ONBOARDING_STEPS = [ 'tools', 'team', 'plan', + 'routines', ] as const; export type OnboardingStep = (typeof ONBOARDING_STEPS)[number]; diff --git a/apps/web/src/features/onboarding/create-app-onboarding-context.ts b/apps/web/src/features/onboarding/create-app-onboarding-context.ts index 29ca2b29190..846b6376cfc 100644 --- a/apps/web/src/features/onboarding/create-app-onboarding-context.ts +++ b/apps/web/src/features/onboarding/create-app-onboarding-context.ts @@ -1,5 +1,7 @@ import { ROUTER_BASE_CONCAT } from '@app/constants/routerBase'; import { useAnalytics } from '@app/lib/analytics/analytics-context'; +import { useHasPaidAccess } from '@core/auth/license'; +import { defaultModelForPlan } from '@core/component/AI/constant'; import { toast } from '@core/component/Toast/Toast'; import { SERVER_HOSTS } from '@core/constant/servers'; import { deriveIsAuthenticated } from '@core/context/user'; @@ -34,6 +36,12 @@ import { useCreateTeamWithInvitesMutation, useUserTeamsQuery, } from '@queries/team/teams'; +import { + createRoutineSuggestions, + warmRoutineSuggestions, +} from '../routine-suggestions/queries/routine-suggestions'; +import { draftToCreateBody } from '../routines/queries/routine-draft'; +import { useCreateScheduleMutation } from '../routines/queries/schedules'; import { applyWorkspaceAccent } from './apply-workspace-accent'; import type { OnboardingContext, @@ -283,6 +291,26 @@ export function createAppOnboardingContext(): OnboardingContext { await completeTutorial.mutateAsync(); return { t: 'completed' }; }, + warmRoutineSuggestions: () => + fireAndForget(warmRoutineSuggestions(), 'onboarding'), + createRoutineSuggestionsStep: () => { + const create = useCreateScheduleMutation(undefined, 'suggestion'); + const hasPaidAccess = useHasPaidAccess(); + return { + source: createRoutineSuggestions({ + ready: () => viewer().t === 'signed-in', + }), + target: () => ({ + kind: 'model', + model: defaultModelForPlan(hasPaidAccess()), + }), + create: async (draft) => { + const created = await create.mutateAsync(draftToCreateBody(draft)); + if (!created.id) throw new Error('Created routine has no ID'); + return created.id; + }, + }; + }, repairTutorial: async () => { await completeTutorial.mutateAsync(); }, diff --git a/apps/web/src/features/onboarding/primitives/routines-step.ts b/apps/web/src/features/onboarding/primitives/routines-step.ts new file mode 100644 index 00000000000..6220a4fc0b0 --- /dev/null +++ b/apps/web/src/features/onboarding/primitives/routines-step.ts @@ -0,0 +1,87 @@ +import type { RoutineSuggestion } from '@app/features/routine-suggestions/core/suggestions'; +import { + type RoutineSurface, + routineSurfaces, +} from '@app/features/routine-suggestions/core/surfaces'; +import { createSuggestionActions } from '@app/features/routine-suggestions/primitives/suggestion-actions'; +import { createSuggestionSelection } from '@app/features/routine-suggestions/primitives/suggestion-selection'; +import { createMemo, createSignal } from 'solid-js'; +import type { OnboardingContext } from '../context/onboarding-context'; + +type RoutinesStepCapabilities = Pick< + OnboardingContext, + 'createRoutineSuggestionsStep' | 'track' +>; + +export type RoutineGroup = { + surface: RoutineSurface; + suggestions: RoutineSuggestion[]; +}; + +/** + * The last step: every suggested routine grouped by surface, the AI's likely + * keepers switched on, and one action that turns the chosen ones on before + * leaving. Failed creates stay on their rows; continuing again retries only + * those, so nothing is created twice. + */ +export function createRoutinesStep(context: RoutinesStepCapabilities) { + const step = context.createRoutineSuggestionsStep(); + const actions = createSuggestionActions({ + target: step.target, + // A new account has no routines yet. + existingRoutines: () => [], + create: step.create, + }); + const selection = createSuggestionSelection(actions, step.source.suggestions); + const [enabling, setEnabling] = createSignal(false); + const [failed, setFailed] = createSignal(false); + + const groups = createMemo((): RoutineGroup[] => { + const all = step.source.suggestions() ?? []; + return routineSurfaces.flatMap((surface) => { + const suggestions = all.filter( + (suggestion) => suggestion.surface === surface + ); + return suggestions.length ? [{ surface, suggestions }] : []; + }); + }); + + const report = (enabled: readonly RoutineSuggestion[]) => { + const all = step.source.suggestions() ?? []; + context.track('onboarding_v4_routines', { + suggested: all.length, + recommended: all.filter(selection.isDefault).length, + enabled: enabled.length, + recommended_kept: enabled.filter(selection.isDefault).length, + }); + }; + + return { + source: step.source, + actions, + selection, + groups, + enabling, + failed, + /** Turns on the chosen routines, then leaves; stays when one fails. */ + async turnOnAndFinish(onFinish: () => void) { + if (enabling()) return; + setEnabling(true); + setFailed(false); + const enabled = await selection.enableSelected(); + setEnabling(false); + if (!enabled) { + setFailed(true); + return; + } + report(selection.selected()); + onFinish(); + }, + /** Leaves without turning anything on. */ + skip(onFinish: () => void) { + if (enabling()) return; + report([]); + onFinish(); + }, + }; +} diff --git a/apps/web/src/features/onboarding/tests/fake-onboarding-context.ts b/apps/web/src/features/onboarding/tests/fake-onboarding-context.ts index ef59f67ea20..c91b1f32270 100644 --- a/apps/web/src/features/onboarding/tests/fake-onboarding-context.ts +++ b/apps/web/src/features/onboarding/tests/fake-onboarding-context.ts @@ -1,4 +1,9 @@ import type { PaidPlanTier } from '@app/features/paywall/plans'; +import { + toRoutineSuggestions, + type WireSuggestion, +} from '@app/features/routine-suggestions/queries/suggestion-wire'; +import { FIXTURE_ROUTINE_SUGGESTIONS } from '@app/features/routine-suggestions/tests/fixture-suggestions'; import type { AiPlanCatalog } from '@service-auth/ai-billing-types'; import type { ServerResponse } from '@service-cognition/generated/schemas'; import { createSignal } from 'solid-js'; @@ -51,6 +56,10 @@ export type FakeOnboardingWorld = { /** How many refreshes a checkout's webhook takes to land. */ webhookDelayPolls: number; accent: string | undefined; + /** The AI's routine suggestions; null while they are still generating. */ + routineSuggestions: WireSuggestion[] | null; + /** Names of the routines turned on, in order. */ + routines: string[]; /** Capabilities that fail, with the message they report. */ failures: Partial< Record< @@ -62,7 +71,8 @@ export type FakeOnboardingWorld = { | 'planCatalog' | 'trialEligibility' | 'checkout' - | 'completion', + | 'completion' + | 'createRoutine', string > >; @@ -129,6 +139,8 @@ export function defaultFakeWorld(): FakeOnboardingWorld { webhookPollsRemaining: undefined, webhookDelayPolls: 1, accent: undefined, + routineSuggestions: FIXTURE_ROUTINE_SUGGESTIONS, + routines: [], failures: {}, }; } @@ -355,6 +367,30 @@ export function createFakeOnboarding( }); return { t: 'completed' }; }, + warmRoutineSuggestions: () => call('warmRoutineSuggestions'), + createRoutineSuggestionsStep: () => ({ + source: { + suggestions: () => + world.routineSuggestions + ? toRoutineSuggestions( + unwrap(world.routineSuggestions), + 'America/New_York' + ) + : undefined, + generating: () => world.routineSuggestions === null, + error: () => undefined, + refresh: async () => call('refreshRoutineSuggestions'), + }, + target: () => ({ kind: 'model', model: 'fixture/model' }), + create: async (draft) => { + call(`createRoutine:${draft.name}`); + fail('createRoutine'); + update((world) => { + world.routines.push(draft.name); + }); + return `routine-${world.routines.length}`; + }, + }), repairTutorial: async () => { call('repairTutorial'); update((draft) => { diff --git a/apps/web/src/features/onboarding/tests/render.tsx b/apps/web/src/features/onboarding/tests/render.tsx index 09dbfa9b361..737d9911daf 100644 --- a/apps/web/src/features/onboarding/tests/render.tsx +++ b/apps/web/src/features/onboarding/tests/render.tsx @@ -11,6 +11,15 @@ import { export function stubOnboardingBrowser() { vi.stubGlobal('matchMedia', () => ({ matches: true })); HTMLElement.prototype.scrollTo = vi.fn(); + // The routines step's expandable rows measure themselves. + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); } /** Render `ui` under a fake onboarding backend seeded with `world`. */ diff --git a/apps/web/src/features/onboarding/views/onboarding-flow-view.test.tsx b/apps/web/src/features/onboarding/views/onboarding-flow-view.test.tsx index 3b143e5732d..9dcea581c6b 100644 --- a/apps/web/src/features/onboarding/views/onboarding-flow-view.test.tsx +++ b/apps/web/src/features/onboarding/views/onboarding-flow-view.test.tsx @@ -63,6 +63,12 @@ const heading = (name: string | RegExp) => screen.findByRole('heading', { name, level: 1 }); const click = (name: string | RegExp) => fireEvent.click(screen.getByRole('button', { name })); +const ROUTINES_HEADING = 'Put your busywork on autopilot.'; +/** Leaves the routines step with the AI's picks: three in the fixture. */ +const turnOnPickedRoutines = async () => { + await heading(ROUTINES_HEADING); + click('Turn on 3 routines'); +}; describe('onboarding flow', () => { it('walks every step and finishes with Free', async () => { @@ -90,6 +96,8 @@ describe('onboarding flow', () => { click('Connect Linear'); await screen.findByRole('button', { name: 'Connected to Linear' }); click('Continue'); + // Suggestions start generating once inbox and tools are connected. + expect(fake.calls()).toContain('warmRoutineSuggestions'); await heading('Built for teams.'); expect(screen.getByDisplayValue('Acme')).toBeTruthy(); @@ -106,9 +114,35 @@ describe('onboarding flow', () => { screen.getAllByRole('button', { name: 'Continue with Free' }).at(-1)! ); + // The AI's likely keepers start on; the user swaps one for another. + await heading(ROUTINES_HEADING); + expect(navigate).not.toHaveBeenCalled(); + expect( + screen.getByRole('heading', { name: 'Email automations', level: 2 }) + ).toBeTruthy(); + const toggle = (name: string) => + screen.getByRole('switch', { name: `Turn on ${name}` }); + expect(toggle('Follow-ups after every call')).toHaveProperty( + 'checked', + true + ); + expect(toggle('Alert me about Acme')).toHaveProperty('checked', false); + fireEvent.click(toggle('Morning inbox digest')); + fireEvent.click(toggle('Keep the pipeline honest')); + click('Turn on 3 routines'); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/channel/launch') ); + expect(fake.world.routines.toSorted()).toEqual([ + 'Follow-ups after every call', + 'Keep the pipeline honest', + 'Tag invoices and receipts', + ]); + expect(fake.events()).toContainEqual({ + event: 'onboarding_v4_routines', + data: { suggested: 6, recommended: 3, enabled: 3, recommended_kept: 2 }, + }); // Completing flips the viewer to complete; the flow must not leave twice. await new Promise((resolve) => setTimeout(resolve, 0)); expect(navigate).toHaveBeenCalledOnce(); @@ -197,9 +231,14 @@ describe('onboarding flow', () => { world: { webhookPollsRemaining: 1 }, }); await heading('Your workspace is ready.'); - await waitFor(() => expect(navigate).toHaveBeenCalledWith('/home'), { - timeout: 3_000, - }); + await screen.findByRole( + 'heading', + { name: ROUTINES_HEADING, level: 1 }, + { timeout: 3_000 } + ); + expect(navigate).not.toHaveBeenCalled(); + await turnOnPickedRoutines(); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/home')); }); it('keeps a deep-linked step after the link is gone and the session is new', async () => { @@ -255,9 +294,14 @@ describe('onboarding flow', () => { world: { webhookPollsRemaining: 1 }, }); await heading('Your workspace is ready.'); - await waitFor(() => expect(navigate).toHaveBeenCalledWith('/home'), { - timeout: 3_000, - }); + await screen.findByRole( + 'heading', + { name: ROUTINES_HEADING, level: 1 }, + { timeout: 3_000 } + ); + expect(navigate).not.toHaveBeenCalled(); + await turnOnPickedRoutines(); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/home')); }); it('starts hosted checkout from the trial offer', async () => { @@ -339,13 +383,14 @@ describe('onboarding flow', () => { expect( screen.queryByRole('button', { name: 'Continue with Free' }) ).toBeNull(); + await turnOnPickedRoutines(); await waitFor(() => expect(navigate).toHaveBeenCalledExactlyOnceWith('/home') ); expect(redirect).not.toHaveBeenCalled(); }); - it('finishes a licensed member directly on the team step while saving', async () => { + it('takes a licensed member from the team step to routines and finishes while saving', async () => { const { fake, navigate } = setup({ resume: 'team', world: { @@ -364,16 +409,14 @@ describe('onboarding flow', () => { .mockReturnValue(completion.promise); await heading('Built for teams.'); click('Continue'); + await turnOnPickedRoutines(); const opening = await screen.findByRole('button', { name: 'Opening your workspace…', }); expect(opening.hasAttribute('disabled')).toBe(true); - expect( - screen.getByRole('heading', { name: 'Built for teams.' }) - ).toBeTruthy(); expect(screen.queryByText('Your workspace is ready.')).toBeNull(); - expect(readSavedStep(FAKE_VIEWER_ID)).toBe('team'); + expect(readSavedStep(FAKE_VIEWER_ID)).toBe('routines'); expect(save).toHaveBeenCalledOnce(); expect(navigate).not.toHaveBeenCalled(); @@ -383,7 +426,7 @@ describe('onboarding flow', () => { ); }); - it('lets a licensed member retry a failed save from the team step', async () => { + it('retries a failed save without turning routines on twice', async () => { const { fake, navigate } = setup({ resume: 'team', world: { @@ -399,21 +442,64 @@ describe('onboarding flow', () => { }); await heading('Built for teams.'); click('Continue'); + await turnOnPickedRoutines(); await waitFor(() => expect(fake.notifications()).toHaveLength(1)); expect( - screen.getByRole('heading', { name: 'Built for teams.' }) + screen.getByRole('heading', { name: ROUTINES_HEADING }) ).toBeTruthy(); expect( - screen.getByRole('button', { name: 'Continue' }).hasAttribute('disabled') + screen + .getByRole('button', { name: 'Turn on 3 routines' }) + .hasAttribute('disabled') ).toBe(false); expect(navigate).not.toHaveBeenCalled(); fake.update((world) => { delete world.failures.completion; }); - click('Continue'); + click('Turn on 3 routines'); await waitFor(() => expect(navigate).toHaveBeenCalledExactlyOnceWith('/home') ); + expect(fake.world.routines).toHaveLength(3); + }); + + it('keeps the step when a routine fails and retries only that one', async () => { + const { fake, navigate } = setup({ + resume: 'routines', + world: { failures: { createRoutine: 'Server busy' } }, + }); + await turnOnPickedRoutines(); + await screen.findByText(/Some routines couldn't be turned on/); + expect(navigate).not.toHaveBeenCalled(); + expect(fake.world.routines).toEqual([]); + fake.update((world) => { + delete world.failures.createRoutine; + }); + click('Turn on 3 routines'); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/home')); + expect(fake.world.routines).toHaveLength(3); + }); + + it('skips routines, or continues while they are still being picked', async () => { + const first = setup({ resume: 'routines' }); + await heading(ROUTINES_HEADING); + click('Skip for now'); + await waitFor(() => expect(first.navigate).toHaveBeenCalledWith('/home')); + expect(first.fake.world.routines).toEqual([]); + expect(first.fake.events()).toContainEqual({ + event: 'onboarding_v4_routines', + data: { suggested: 6, recommended: 3, enabled: 0, recommended_kept: 0 }, + }); + cleanup(); + + const second = setup({ + resume: 'routines', + world: { routineSuggestions: null }, + }); + await heading(ROUTINES_HEADING); + expect(screen.getByText(/Studying your workspace/)).toBeTruthy(); + click('Continue to workspace'); + await waitFor(() => expect(second.navigate).toHaveBeenCalledWith('/home')); }); it('sends a signed-out visitor to sign in', async () => { diff --git a/apps/web/src/features/onboarding/views/onboarding-flow-view.tsx b/apps/web/src/features/onboarding/views/onboarding-flow-view.tsx index e0b94a9e1a9..a3721bf01f7 100644 --- a/apps/web/src/features/onboarding/views/onboarding-flow-view.tsx +++ b/apps/web/src/features/onboarding/views/onboarding-flow-view.tsx @@ -1,5 +1,12 @@ import { fireAndForget } from '@core/util/fireAndForget'; -import { createEffect, Match, onCleanup, Show, Switch } from 'solid-js'; +import { + createEffect, + createSignal, + Match, + onCleanup, + Show, + Switch, +} from 'solid-js'; import { BypassButton } from '../components/bypass-button'; import { animateStepChange, @@ -22,6 +29,7 @@ import { createOnboardingFlow } from '../primitives/onboarding-flow'; import { EmailStep } from './email-step'; import { PlanComparisonView } from './plan-comparison-view'; import { type InviteOfferSlot, PlanStep } from './plan-step'; +import { RoutinesStep } from './routines-step'; import { StoryStageView } from './story-stage-view'; import { TeamStep } from './team-step'; import { ToolsStep } from './tools-step'; @@ -99,19 +107,35 @@ export function OnboardingFlowView(props: { queueMicrotask(() => focusStepHeading(content)); }); }; + const licensed = () => { + const viewer = context.viewer(); + return viewer.t === 'signed-in' && viewer.viewer.licensed; + }; + // The plan is settled before the routines step, but onboarding completes + // after it; a reload there falls back to what the license says. + const [finishPlan, setFinishPlan] = createSignal<() => Promise>(); + const choosePlan = (finishWith: () => Promise) => { + if (finish.finishing()) return; + setFinishPlan(() => finishWith); + flow.leave('completed'); + goTo('routines'); + }; + const finishOnboarding = () => { + const finishWith = + finishPlan() ?? + (licensed() ? () => finish.finishPremium() : () => finish.finishFree()); + return finishWith(); + }; const advance = (outcome: StepOutcome = 'completed') => { if (finish.finishing()) return; - const next = flow.leave(outcome); - const viewer = context.viewer(); - if ( - step() === 'team' && - viewer.t === 'signed-in' && - viewer.viewer.licensed - ) { - fireAndForget(finish.finishPremium(), 'onboarding'); + const current = step(); + // Inbox and tools are connected now: start picking routines for the last step. + if (current === 'tools') context.warmRoutineSuggestions(); + if (current === 'team' && licensed()) { + choosePlan(() => finish.finishPremium()); return; } - goTo(next); + goTo(flow.leave(outcome)); }; const viewerEmail = () => { const viewer = context.viewer(); @@ -128,6 +152,8 @@ export function OnboardingFlowView(props: { }; const back = () => { const current = step(); + // The plan is settled (and maybe paid for) once routines show. + if (current === 'routines') return undefined; const previous = current && previousOnboardingStep(current); return previous && !finish.finishing() ? () => goTo(previous) : undefined; }; @@ -153,9 +179,7 @@ export function OnboardingFlowView(props: { showPlanOffer() ? ( - fireAndForget(finish.finishFree(), 'onboarding') - } + onContinueFree={() => choosePlan(() => finish.finishFree())} onBackToPro={() => focusStepHeading(content, true)} onStartMax={() => fireAndForget( @@ -215,11 +239,17 @@ export function OnboardingFlowView(props: { 'onboarding' ) } - onContinueFree={() => - fireAndForget(finish.finishFree(), 'onboarding') - } + onContinueFree={() => choosePlan(() => finish.finishFree())} renderInviteOffer={props.renderInviteOffer} - onPremiumPaid={finish.finishPremium} + onPremiumPaid={async (tier) => + choosePlan(() => finish.finishPremium(tier)) + } + /> + + + fireAndForget(finishOnboarding(), 'onboarding')} /> diff --git a/apps/web/src/features/onboarding/views/routines-step.tsx b/apps/web/src/features/onboarding/views/routines-step.tsx new file mode 100644 index 00000000000..a8748e7390a --- /dev/null +++ b/apps/web/src/features/onboarding/views/routines-step.tsx @@ -0,0 +1,130 @@ +import { RoutineSparkGlyph } from '@app/features/routine-suggestions/components/routine-spark-glyph'; +import { RoutineSuggestionList } from '@app/features/routine-suggestions/components/routine-suggestion-list'; +import { + RoutineSuggestionRow, + SuggestionToggle, +} from '@app/features/routine-suggestions/components/routine-suggestion-row'; +import { routineSurfaceCopy } from '@app/features/routine-suggestions/core/surfaces'; +import { suggestionListView } from '@app/features/routine-suggestions/views/suggestion-list-view'; +import { fireAndForget } from '@core/util/fireAndForget'; +import { For, Show } from 'solid-js'; +import { ContinueButton, SkipButton } from '../components/controls'; +import { useOnboardingContext } from '../context/onboarding-context'; +import { createRoutinesStep } from '../primitives/routines-step'; + +/** + * Put busywork on autopilot: every routine the AI suggested, grouped by + * kind, with the ones it expects the user to keep already on. + */ +export function RoutinesStep(props: { finishing: boolean; onFinish(): void }) { + const context = useOnboardingContext(); + const step = createRoutinesStep(context); + const count = () => step.selection.selected().length; + const busy = () => step.enabling() || props.finishing; + const view = () => + suggestionListView({ + suggestions: step.source.suggestions(), + generating: step.source.generating(), + failed: step.source.error() !== undefined, + }); + + return ( +
+
+ +

+ Put your busywork on autopilot. +

+

+ Routines are AI agents that run on their own. We picked these from + your inbox and tools, and switched on the ones we think you'll keep. + Change anything you like. +

+
+
+ fireAndForget(step.source.refresh(), 'onboarding')} + > + {() => ( + + {(group) => ( +
+

+ {routineSurfaceCopy[group.surface].groupLabel} +

+
    + + {(suggestion) => { + const name = () => + step.actions.editsFor(suggestion).title || + suggestion.title; + return ( + + step.actions.setExpanded(suggestion, expanded) + } + onEdit={(edits) => + step.actions.edit(suggestion, edits) + } + action={ + + step.selection.setOn(suggestion, on) + } + /> + } + /> + ); + }} + +
+
+ )} +
+ )} +
+
+ + + + 0 + ? `Turn on ${count()} ${count() === 1 ? 'routine' : 'routines'}` + : 'Continue to workspace' + } + disabled={busy()} + onClick={() => + fireAndForget(step.turnOnAndFinish(props.onFinish), 'onboarding') + } + /> + 0}> + step.skip(props.onFinish)} + /> + +
+ ); +} diff --git a/apps/web/src/features/reviews-view/components/ReviewsSidebar.tsx b/apps/web/src/features/reviews-view/components/ReviewsSidebar.tsx index 02ef2b0d951..575f45fc0dc 100644 --- a/apps/web/src/features/reviews-view/components/ReviewsSidebar.tsx +++ b/apps/web/src/features/reviews-view/components/ReviewsSidebar.tsx @@ -5,6 +5,7 @@ import { ViewSidebar, } from '@app/components/view-shell'; import { ViewCreateMenu } from '@app/features/command/view-create-menu'; +import { SidebarRoutineSuggestions } from '@app/features/routine-suggestions/routine-suggestions'; import { t } from '@app/lib/i18n'; import { globalSplitManager } from '@app/signal/splitLayout'; import { PrOriginIcon } from '@block-pr/component/PrLinks'; @@ -337,6 +338,7 @@ export function ReviewsSidebar(props: {
+ {(scope) => { diff --git a/apps/web/src/features/routine-suggestions/browser-test/playwright.config.ts b/apps/web/src/features/routine-suggestions/browser-test/playwright.config.ts new file mode 100644 index 00000000000..9ca4df78a22 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/browser-test/playwright.config.ts @@ -0,0 +1,26 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from '@playwright/test'; + +const webDirectory = fileURLToPath(new URL('../../../../', import.meta.url)); +const port = process.env.ROUTINE_SUGGESTIONS_TEST_PORT ?? '3051'; +export default defineConfig({ + testDir: fileURLToPath(new URL('.', import.meta.url)), + testMatch: '*.browser.e2e.ts', + workers: 1, + timeout: 60_000, + use: { + baseURL: `http://localhost:${port}`, + headless: true, + viewport: { width: 1280, height: 800 }, + launchOptions: { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }, + }, + webServer: { + command: `MACRO_DEV_HTTPS=false bunx vite --port ${port} --strictPort`, + cwd: webDirectory, + url: `http://localhost:${port}/app/`, + reuseExistingServer: false, + timeout: 60_000, + }, +}); diff --git a/apps/web/src/features/routine-suggestions/browser-test/suggestions.browser.e2e.ts b/apps/web/src/features/routine-suggestions/browser-test/suggestions.browser.e2e.ts new file mode 100644 index 00000000000..891c9a63e23 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/browser-test/suggestions.browser.e2e.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; + +const fixture = + '/src/features/routine-suggestions/browser-test/suggestions.html'; + +test.beforeEach(async ({ page }) => { + await page.routeWebSocket('**', (socket) => socket.close()); + await page.route('https://**', (route) => route.abort()); + await page.route('**/__macro_dev/**', (route) => route.abort()); +}); + +test('enables a suggestion in one click and an edited one after a retry', async ({ + page, +}) => { + await page.goto(fixture, { waitUntil: 'domcontentloaded' }); + const sidebar = page.getByRole('complementary', { name: 'Email sidebar' }); + await sidebar.getByRole('button', { name: /Suggested routines/ }).click(); + const dialog = page.getByRole('dialog', { name: 'Routines for Email' }); + await expect(dialog.getByRole('listitem')).toHaveCount(3); + await expect( + dialog.getByText('When a new email arrives').first() + ).toBeVisible(); + await expect(dialog.getByText('Weekdays at 8:30 AM')).toBeVisible(); + + // The modal hides the page from the accessibility tree; read the probe directly. + const created = page.locator('output[aria-label="Created routines"]'); + // The first create fails; the row says so and Enable stays available. + await dialog + .getByRole('button', { name: 'Enable Tag invoices and receipts' }) + .click(); + await expect(dialog.getByRole('alert')).toContainText('The server is busy.'); + await dialog + .getByRole('button', { name: 'Enable Tag invoices and receipts' }) + .click(); + await expect(created).toHaveText('Tag invoices and receipts'); + await expect(dialog.getByRole('button', { name: 'Enabled' })).toBeVisible(); + + // Expand, rename, and enable the edited routine. + await dialog.getByRole('button', { name: /^Alert me about Acme/ }).click(); + const name = dialog.getByRole('textbox', { name: 'Name' }); + await name.fill('Acme renewal alerts'); + await dialog + .getByRole('button', { name: 'Enable Acme renewal alerts' }) + .click(); + await expect(created).toHaveText( + 'Tag invoices and receipts | Acme renewal alerts' + ); + await expect(name).toBeDisabled(); +}); + +test('opens the full editor from More options and dismisses the card', async ({ + page, +}) => { + await page.goto(fixture, { waitUntil: 'domcontentloaded' }); + const sidebar = page.getByRole('complementary', { name: 'Email sidebar' }); + await sidebar.getByRole('button', { name: /Suggested routines/ }).click(); + const dialog = page.getByRole('dialog', { name: 'Routines for Email' }); + await dialog.getByRole('button', { name: /^Morning inbox digest/ }).click(); + await dialog.getByRole('button', { name: 'More options' }).click(); + await expect( + page.locator('output[aria-label="Created routines"]') + ).toHaveText('edit:Morning inbox digest'); + await page.keyboard.press('Escape'); + await sidebar + .getByRole('button', { name: 'Dismiss suggested routines' }) + .click(); + await expect( + sidebar.getByRole('button', { name: /Suggested routines/ }) + ).toHaveCount(0); +}); + +test('shows the suggestions in a phone sheet', async ({ browser }) => { + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + hasTouch: true, + isMobile: true, + }); + const page = await context.newPage(); + await page.routeWebSocket('**', (socket) => socket.close()); + await page.route('https://**', (route) => route.abort()); + await page.goto(fixture, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: /Suggested routines/ }).click(); + const sheet = page.getByRole('dialog', { name: 'Routines for Email' }); + await expect(sheet.getByRole('listitem')).toHaveCount(3); + await sheet + .getByRole('button', { name: 'Enable Morning inbox digest' }) + .click(); + await expect(sheet.getByRole('alert')).toContainText('The server is busy.'); + await context.close(); +}); diff --git a/apps/web/src/features/routine-suggestions/browser-test/suggestions.html b/apps/web/src/features/routine-suggestions/browser-test/suggestions.html new file mode 100644 index 00000000000..cb23868b991 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/browser-test/suggestions.html @@ -0,0 +1,5 @@ + + + Routine suggestions +
+ diff --git a/apps/web/src/features/routine-suggestions/browser-test/suggestions.tsx b/apps/web/src/features/routine-suggestions/browser-test/suggestions.tsx new file mode 100644 index 00000000000..4ef476f4df3 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/browser-test/suggestions.tsx @@ -0,0 +1,93 @@ +import '@app/index.css'; +import { applyTheme } from '@app/features/theme/utils/themeUtils'; +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { RoutineSuggestionsCard } from '../components/routine-suggestions-card'; +import { RoutineSuggestionsDialog } from '../components/routine-suggestions-dialog'; +import { createSuggestionActions } from '../primitives/suggestion-actions'; +import { toRoutineSuggestions } from '../queries/suggestion-wire'; +import { FIXTURE_ROUTINE_SUGGESTIONS } from '../tests/fixture-suggestions'; +import { + SuggestionRows, + suggestionListView, +} from '../views/suggestion-list-view'; + +// The real card, modal, and rows over the fixture's email suggestions. Creating a +// routine records the draft instead of calling the server; the first create +// fails once so the retry path can be exercised. +const suggestions = toRoutineSuggestions( + FIXTURE_ROUTINE_SUGGESTIONS, + 'America/New_York' +).filter((suggestion) => suggestion.surface === 'email'); +const [created, setCreated] = createSignal([]); +let failNext = true; + +function Fixture() { + const [open, setOpen] = createSignal(false); + const [dismissed, setDismissed] = createSignal(false); + const actions = createSuggestionActions({ + target: () => ({ kind: 'model', model: 'fixture/model' }), + existingRoutines: () => [], + async create(draft) { + await new Promise((resolve) => setTimeout(resolve, 150)); + if (failNext) { + failNext = false; + throw new Error('The server is busy.'); + } + setCreated((names) => [...names, draft.name]); + return `routine-${created().length}`; + }, + customize: (draft) => + setCreated((names) => [...names, `edit:${draft.name}`]), + }); + return ( +
+ + + {created().join(' | ')} + + {}} + onSeeAll={() => setOpen(false)} + > + {}} + onOpenRoutine={() => setOpen(false)} + /> + +
+ ); +} + +applyTheme( + new URLSearchParams(location.search).get('theme') === 'dark' + ? 'Macro Dark' + : 'Macro Light' +); +const root = document.getElementById('root'); +if (root) render(() => , root); diff --git a/apps/web/src/features/routine-suggestions/components/routine-spark-glyph.tsx b/apps/web/src/features/routine-suggestions/components/routine-spark-glyph.tsx new file mode 100644 index 00000000000..d696b01945f --- /dev/null +++ b/apps/web/src/features/routine-suggestions/components/routine-spark-glyph.tsx @@ -0,0 +1,28 @@ +import RepeatIcon from '@icon/system/regular/repeat.svg'; +import { cn } from '@ui'; + +/** + * The suggested-routines mark uses the shared repeat icon on a flat violet tile. + */ +export function RoutineSparkGlyph(props: { + /** `sm` for sidebar rows, `lg` for the modal header. */ + size?: 'sm' | 'md' | 'lg'; + class?: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/features/routine-suggestions/components/routine-suggestion-list.tsx b/apps/web/src/features/routine-suggestions/components/routine-suggestion-list.tsx new file mode 100644 index 00000000000..8fd74a9567a --- /dev/null +++ b/apps/web/src/features/routine-suggestions/components/routine-suggestion-list.tsx @@ -0,0 +1,68 @@ +import { ListSkeleton } from '@app/components/view-shell/ListSkeleton'; +import ArrowsClockwiseIcon from '@icon/system/regular/arrows-clockwise.svg'; +import { Button } from '@ui'; +import { type JSX, Match, Switch } from 'solid-js'; +import type { RoutineSuggestion } from '../core/suggestions'; +import { RoutineSparkGlyph } from './routine-spark-glyph'; + +/** What a suggestion list shows. */ +export type SuggestionListView = + | { kind: 'loading' } + | { kind: 'error' } + | { kind: 'empty' } + | { kind: 'items'; items: RoutineSuggestion[] }; + +/** Loading, error, and empty states around the host's suggestion rows. */ +export function RoutineSuggestionList(props: { + view: SuggestionListView; + /** Said when the list is empty, e.g. "No email routines to suggest yet". */ + emptyLabel: string; + onRetry(): void; + /** Renders the suggestions once there are some. */ + children: (items: RoutineSuggestion[]) => JSX.Element; +}) { + return ( + + + {(items) => props.children(items())} + + +
+

+ Studying your workspace to suggest routines that fit how you work… +

+ + {[0, 1, 2].map(() => ( +
+
+
+ + + +
+
+ ))} + +
+ + + + + +
+ + {props.emptyLabel} +
+
+ + ); +} diff --git a/apps/web/src/features/routine-suggestions/components/routine-suggestion-row.tsx b/apps/web/src/features/routine-suggestions/components/routine-suggestion-row.tsx new file mode 100644 index 00000000000..9dabfb0b703 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/components/routine-suggestion-row.tsx @@ -0,0 +1,214 @@ +import CaretDownIcon from '@icon/system/regular/caret-down.svg'; +import CheckIcon from '@icon/system/regular/check.svg'; +import LightningIcon from '@icon/system/regular/lightning.svg'; +import SlidersIcon from '@icon/system/regular/sliders-horizontal.svg'; +import { Button, cn, TextField, ToggleSwitch } from '@ui'; +import { CollapseTransition } from '@ui/components/CollapseTransition'; +import { createUniqueId, type JSX, Match, Show, Switch } from 'solid-js'; +import type { + RoutineSuggestion, + SuggestionEdits, + SuggestionStatus, +} from '../core/suggestions'; +import { SuggestionIcon } from './suggestion-icon'; + +/** + * One suggested routine: collapsed it reads as a title, a summary, and when + * it runs, beside the host's action (Enable, or a toggle); expanded it lets + * the user edit the name, instructions, and notifications first, or open the + * full routine editor. + */ +export function RoutineSuggestionRow(props: { + suggestion: RoutineSuggestion; + edits: SuggestionEdits; + status: SuggestionStatus; + expanded: boolean; + /** The control on the right, e.g. {@link SuggestionEnableButton}. */ + action: JSX.Element; + /** Shown on pages that mix surfaces, such as Routines. */ + surfaceLabel?: string; + onExpandedChange(expanded: boolean): void; + onEdit(edits: SuggestionEdits): void; + /** Opens the full routine editor; hidden where there is none. */ + onCustomize?: () => void; +}) { + const detailsId = createUniqueId(); + // Edits apply to the routine still to be created. + const locked = () => + props.status.kind === 'enabling' || props.status.kind === 'enabled'; + const edit = (patch: Partial) => + props.onEdit({ ...props.edits, ...patch }); + return ( +
  • +
    + + +
    {props.action}
    +
    + + {(failed) => ( + + )} + + +
    + edit({ title })} + disabled={locked()} + > + + Name + + + + edit({ instructions })} + disabled={locked()} + > + + Instructions + + + +
    + edit({ notify })} + /> + + {(customize) => ( + + )} + +
    +
    +
    +
  • + ); +} + +/** Enable in one click; once enabled, opens the routine. */ +export function SuggestionEnableButton(props: { + name: string; + status: SuggestionStatus; + onEnable(): void; + onOpenRoutine(routineId: string): void; +}) { + return ( + props.onEnable()} + > + {props.status.kind === 'enabling' ? 'Enabling…' : 'Enable'} + + } + > + + {(state) => ( + + )} + + + ); +} + +/** On or off, for lists where the host creates the chosen routines later. */ +export function SuggestionToggle(props: { + name: string; + on: boolean; + status: SuggestionStatus; + onChange(on: boolean): void; +}) { + return ( + + + ); +} diff --git a/apps/web/src/features/routine-suggestions/components/routine-suggestions-card.tsx b/apps/web/src/features/routine-suggestions/components/routine-suggestions-card.tsx new file mode 100644 index 00000000000..4abc56a6667 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/components/routine-suggestions-card.tsx @@ -0,0 +1,56 @@ +import XIcon from '@icon/system/regular/x.svg'; +import { Button, cn } from '@ui'; +import { Show } from 'solid-js'; +import { RoutineSparkGlyph } from './routine-spark-glyph'; + +/** + * The compact "Suggested routines" card at the top of a view sidebar. The + * whole card opens the suggestions; the close button dismisses it. + */ +export function RoutineSuggestionsCard(props: { + /** Names the top suggestion once one exists, else the surface pitch. */ + teaser: string; + /** Suggestions beyond the teased one, shown as "+N more". */ + more: number; + onOpen(trigger: HTMLButtonElement): void; + onDismiss(): void; + class?: string; +}) { + return ( +
    + + +
    + ); +} diff --git a/apps/web/src/features/routine-suggestions/components/routine-suggestions-dialog.tsx b/apps/web/src/features/routine-suggestions/components/routine-suggestions-dialog.tsx new file mode 100644 index 00000000000..2cef82c1df6 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/components/routine-suggestions-dialog.tsx @@ -0,0 +1,73 @@ +import ArrowRightIcon from '@icon/system/regular/arrow-right.svg'; +import ArrowsClockwiseIcon from '@icon/system/regular/arrows-clockwise.svg'; +import XIcon from '@icon/system/regular/x.svg'; +import { Button, Dialog, Panel } from '@ui'; +import type { JSX } from 'solid-js'; +import { RoutineSparkGlyph } from './routine-spark-glyph'; + +/** The suggestions modal: a header, the list, and a footer of actions. */ +export function RoutineSuggestionsDialog(props: { + open: boolean; + onOpenChange(open: boolean): void; + title: string; + refreshing: boolean; + onRefresh(): void; + onSeeAll(): void; + children: JSX.Element; +}) { + return ( + + + +
    +
    + +
    + + {props.title} + + + Picked by AI from your workspace. Enable one in a click, or + open it to edit first. + +
    + + + +
    +
    {props.children}
    +
    + + +
    +
    +
    +
    +
    + ); +} diff --git a/apps/web/src/features/routine-suggestions/components/suggestion-icon.tsx b/apps/web/src/features/routine-suggestions/components/suggestion-icon.tsx new file mode 100644 index 00000000000..4bb454cd82b --- /dev/null +++ b/apps/web/src/features/routine-suggestions/components/suggestion-icon.tsx @@ -0,0 +1,51 @@ +import BellIcon from '@icon/system/regular/bell.svg'; +import CalendarIcon from '@icon/system/regular/calendar-blank.svg'; +import ChartIcon from '@icon/system/regular/chart-bar.svg'; +import ChatIcon from '@icon/system/regular/chat-circle.svg'; +import CheckSquareIcon from '@icon/system/regular/check-square.svg'; +import CodeIcon from '@icon/system/regular/code.svg'; +import EnvelopeIcon from '@icon/system/regular/envelope.svg'; +import FileTextIcon from '@icon/system/regular/file-text.svg'; +import PhoneIcon from '@icon/system/regular/phone.svg'; +import SparkleIcon from '@icon/system/regular/sparkle.svg'; +import TagIcon from '@icon/system/regular/tag.svg'; +import UsersIcon from '@icon/system/regular/users.svg'; +import { cn } from '@ui'; +import { Dynamic } from 'solid-js/web'; +import { match } from 'ts-pattern'; +import type { SuggestionIcon as SuggestionIconName } from '../core/suggestions'; + +function iconFor(name: SuggestionIconName) { + return match(name) + .with('mail', () => EnvelopeIcon) + .with('tag', () => TagIcon) + .with('bell', () => BellIcon) + .with('calendar', () => CalendarIcon) + .with('task', () => CheckSquareIcon) + .with('document', () => FileTextIcon) + .with('chart', () => ChartIcon) + .with('people', () => UsersIcon) + .with('phone', () => PhoneIcon) + .with('code', () => CodeIcon) + .with('chat', () => ChatIcon) + .with('sparkle', () => SparkleIcon) + .exhaustive(); +} + +/** A suggestion's topic icon in a soft violet tile. */ +export function SuggestionIcon(props: { + name: SuggestionIconName; + class?: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/features/routine-suggestions/core/suggestions.test.ts b/apps/web/src/features/routine-suggestions/core/suggestions.test.ts new file mode 100644 index 00000000000..f9ef185d128 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/core/suggestions.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + pickDefaultOn, + type RoutineSuggestion, + suggestionToDraft, +} from './suggestions'; + +function raw(overrides: Partial): RoutineSuggestion { + const surface = overrides.surface ?? 'email'; + const title = overrides.title ?? 'Tag invoices'; + return { + id: JSON.stringify([surface, title.toLowerCase()]), + surface: 'email', + title: 'Tag invoices', + summary: 'Labels invoices as they arrive.', + reason: 'You get invoices from Acme weekly', + instructions: 'Label every invoice from Acme as Finance.', + icon: 'tag', + keepProbability: 0.5, + affectsOthers: false, + notify: false, + trigger: { + id: 'trigger-1', + kind: 'event', + events: ['email.message_received'], + condition: 'Is this email an invoice?', + }, + when: 'When a new email arrives', + ...overrides, + }; +} + +describe('suggestionToDraft', () => { + it('creates an enabled routine that shows its runs when it notifies', () => { + const suggestion = raw({ notify: true }); + const draft = suggestionToDraft(suggestion, { + kind: 'agent', + agentId: '0198c7a3-0000-7000-8000-000000000001', + }); + expect(draft).toMatchObject({ + name: 'Tag invoices', + prompt: 'Label every invoice from Acme as Finance.', + enabled: true, + runVisibility: 'listed', + target: { + kind: 'agent', + agentId: '0198c7a3-0000-7000-8000-000000000001', + }, + }); + expect(draft.triggers?.[0]?.id).not.toBe(suggestion.trigger.id); + }); +}); + +describe('pickDefaultOn', () => { + const titled = (probabilities: number[]) => + probabilities.map((keepProbability, index) => + raw({ + surface: (['email', 'tasks', 'crm'] as const)[index % 3], + title: `Routine ${index}`, + keepProbability, + }) + ); + const titlesOf = ( + suggestions: ReturnType, + ids: ReadonlySet + ) => + suggestions + .filter((suggestion) => ids.has(suggestion.id)) + .map((suggestion) => suggestion.title); + + it('turns on every likely keeper, up to five, most likely first', () => { + const three = titled([0.5, 0.92, 0.81, 0.3, 0.8, 0.79]); + expect(titlesOf(three, pickDefaultOn(three))).toEqual([ + 'Routine 1', + 'Routine 2', + 'Routine 4', + ]); + const many = titled([0.9, 0.95, 0.85, 0.99, 0.81, 0.8, 0.97]); + expect(pickDefaultOn(many).size).toBe(5); + expect(pickDefaultOn(many).has(many[5].id)).toBe(false); + }); + + it('tops up to two with the next most likely when few clear the bar', () => { + const unsure = titled([0.2, 0.6, 0.85, 0.55]); + expect(titlesOf(unsure, pickDefaultOn(unsure))).toEqual([ + 'Routine 1', + 'Routine 2', + ]); + expect(pickDefaultOn(titled([0.4])).size).toBe(1); + expect(pickDefaultOn([]).size).toBe(0); + }); + + it('never turns on a routine that affects other people', () => { + const suggestions = [ + raw({ + title: 'Post updates', + keepProbability: 0.99, + affectsOthers: true, + }), + raw({ title: 'Tag mail', keepProbability: 0.3 }), + raw({ title: 'Digest', keepProbability: 0.2 }), + ]; + expect( + suggestions + .filter((suggestion) => pickDefaultOn(suggestions).has(suggestion.id)) + .map((suggestion) => suggestion.title) + ).toEqual(['Tag mail', 'Digest']); + }); +}); diff --git a/apps/web/src/features/routine-suggestions/core/suggestions.ts b/apps/web/src/features/routine-suggestions/core/suggestions.ts new file mode 100644 index 00000000000..aa00d1fdf5e --- /dev/null +++ b/apps/web/src/features/routine-suggestions/core/suggestions.ts @@ -0,0 +1,102 @@ +import type { ScheduleDraft } from '@app/features/routines/core/draft'; +import { createEmptyDraft } from '@app/features/routines/core/routine-draft'; +import type { RoutineTarget } from '@app/features/routines/core/routine-target'; +import type { RoutineTriggerDraft } from '@app/features/routines/core/routine-triggers'; +import type { SuggestionIcon } from '@service-cognition/generated/schemas/suggestionIcon'; +import type { RoutineSurface } from './surfaces'; + +export type { SuggestionIcon }; + +/** + * Onboarding turns on the suggestions the AI expects the user to keep: those + * at or above this keep probability, at least {@link MIN_DEFAULT_ON} and at + * most {@link MAX_DEFAULT_ON} of them. The server's prompt asks the model to + * calibrate against the same bar. + */ +const DEFAULT_ON_THRESHOLD = 0.8; +const MIN_DEFAULT_ON = 2; +const MAX_DEFAULT_ON = 5; + +/** A suggestion the UI can show and enable as-is. */ +export type RoutineSuggestion = { + /** Stable within one set: surface plus title. */ + id: string; + surface: RoutineSurface; + title: string; + summary: string; + reason: string; + instructions: string; + icon: SuggestionIcon; + /** The AI's estimate, 0-1, that the user keeps it on once enabled for them. */ + keepProbability: number; + /** Sends, posts, shares, or changes what others see; never on by default. */ + affectsOthers: boolean; + notify: boolean; + trigger: RoutineTriggerDraft; + /** The trigger in words, e.g. "When a new email arrives". */ + when: string; +}; + +/** What the user has done with a suggestion so far. */ +export type SuggestionStatus = + | { kind: 'idle' } + | { kind: 'enabling' } + | { kind: 'enabled'; routineId: string } + | { kind: 'failed'; message: string }; + +/** The parts of a suggestion the user can edit before enabling it. */ +export type SuggestionEdits = { + title: string; + instructions: string; + notify: boolean; +}; + +/** The routine a suggestion creates, ready to save or open in the composer. */ +export function suggestionToDraft( + suggestion: Pick< + RoutineSuggestion, + 'title' | 'instructions' | 'trigger' | 'notify' + >, + target: RoutineTarget +): ScheduleDraft { + return { + ...createEmptyDraft(''), + name: suggestion.title, + prompt: suggestion.instructions, + target, + triggers: [{ ...suggestion.trigger, id: crypto.randomUUID() }], + enabled: true, + runVisibility: suggestion.notify ? 'listed' : 'unlisted', + }; +} + +/** + * The suggestions to turn on by default: every one the AI expects the user to + * keep ({@link DEFAULT_ON_THRESHOLD} or more), topped up to + * {@link MIN_DEFAULT_ON} with the next most likely and capped at + * {@link MAX_DEFAULT_ON}, most likely first. Ties keep the AI's order. + * Routines that affect other people are never chosen: their instructions come + * from a model that read third-party mail, so the user switches them on. + */ +export function pickDefaultOn( + suggestions: readonly RoutineSuggestion[] +): ReadonlySet { + const ranked = suggestions + .map((suggestion, index) => ({ suggestion, index })) + .filter(({ suggestion }) => !suggestion.affectsOthers) + .toSorted( + (a, b) => + b.suggestion.keepProbability - a.suggestion.keepProbability || + a.index - b.index + ) + .map(({ suggestion }) => suggestion); + const likely = ranked.filter( + (suggestion) => suggestion.keepProbability >= DEFAULT_ON_THRESHOLD + ).length; + const count = Math.min( + MAX_DEFAULT_ON, + Math.max(MIN_DEFAULT_ON, likely), + ranked.length + ); + return new Set(ranked.slice(0, count).map((suggestion) => suggestion.id)); +} diff --git a/apps/web/src/features/routine-suggestions/core/surfaces.ts b/apps/web/src/features/routine-suggestions/core/surfaces.ts new file mode 100644 index 00000000000..4c3f0447178 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/core/surfaces.ts @@ -0,0 +1,75 @@ +/** + * The view sidebars that show routine suggestions. Each surface names the + * part of Macro its suggestions automate; the server generates suggestions + * for these same keys (`RoutineSurface` in the `routine_suggestions` crate). + */ +export const routineSurfaces = [ + 'email', + 'calendar', + 'tasks', + 'files', + 'crm', + 'chat', + 'calls', + 'reviews', + 'agents', +] as const; + +export type RoutineSurface = (typeof routineSurfaces)[number]; + +export type RoutineSurfaceCopy = { + /** Names the surface in the modal title and filter chips. */ + label: string; + /** Heads the surface's group where suggestions of every surface are listed. */ + groupLabel: string; + /** The sidebar card's one-line pitch. */ + pitch: string; +}; + +export const routineSurfaceCopy: Record = { + email: { + label: 'Email', + groupLabel: 'Email automations', + pitch: 'Let AI triage, tag, and follow up on your mail', + }, + calendar: { + label: 'Calendar', + groupLabel: 'Calendar automations', + pitch: 'Prep for meetings before they start', + }, + tasks: { + label: 'Tasks', + groupLabel: 'Task automations', + pitch: 'Keep tasks moving without the busywork', + }, + files: { + label: 'Files', + groupLabel: 'Document automations', + pitch: 'Keep your documents organized and current', + }, + crm: { + label: 'CRM', + groupLabel: 'CRM automations', + pitch: 'Keep every company and deal in the right place', + }, + chat: { + label: 'Chat', + groupLabel: 'Chat automations', + pitch: 'Never miss what matters in your channels', + }, + calls: { + label: 'Calls', + groupLabel: 'Call automations', + pitch: 'Turn every call into next steps', + }, + reviews: { + label: 'Reviews', + groupLabel: 'Code review automations', + pitch: 'Stay on top of pull requests', + }, + agents: { + label: 'Agents', + groupLabel: 'Workspace automations', + pitch: 'Put your recurring work on autopilot', + }, +}; diff --git a/apps/web/src/features/routine-suggestions/primitives/suggestion-actions.test.ts b/apps/web/src/features/routine-suggestions/primitives/suggestion-actions.test.ts new file mode 100644 index 00000000000..436bfc12df7 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/primitives/suggestion-actions.test.ts @@ -0,0 +1,96 @@ +import { createRoot } from 'solid-js'; +import { describe, expect, it, vi } from 'vitest'; +import { toRoutineSuggestions } from '../queries/suggestion-wire'; +import { createSuggestionActions } from './suggestion-actions'; + +const [suggestion] = toRoutineSuggestions( + [ + { + surface: 'tasks', + title: 'Follow-ups after calls', + summary: 'Creates follow-up tasks when a call ends.', + reason: 'You run many customer calls', + instructions: 'Create a task for each action item from the call.', + icon: 'task', + keepProbability: 0.9, + affectsOthers: false, + notify: false, + trigger: { kind: 'event', event: 'call.ended' }, + }, + ], + 'UTC' +); + +function setup( + create: (name: string) => Promise, + existing = [] as { id: string; name: string }[] +) { + return createRoot(() => + createSuggestionActions({ + target: () => ({ kind: 'model', model: 'anthropic/claude-sonnet-4-5' }), + existingRoutines: () => existing, + create: (draft) => create(draft.name), + customize: vi.fn(), + }) + ); +} + +describe('createSuggestionActions', () => { + it('creates one routine with the edited name however often Enable is pressed', async () => { + let finish: (id: string) => void = () => {}; + const create = vi.fn( + () => new Promise((resolve) => (finish = resolve)) + ); + const actions = setup(create); + actions.edit(suggestion, { + ...actions.editsFor(suggestion), + title: 'Call follow-ups', + }); + const first = actions.enable(suggestion); + const second = actions.enable(suggestion); + expect(actions.statusFor(suggestion)).toEqual({ kind: 'enabling' }); + finish('routine-1'); + await Promise.all([first, second]); + expect(create).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith('Call follow-ups'); + expect(actions.statusFor(suggestion)).toEqual({ + kind: 'enabled', + routineId: 'routine-1', + }); + await actions.enable(suggestion); + expect(create).toHaveBeenCalledTimes(1); + }); + + it('reports a failed enable and allows another try after an edit', async () => { + const create = vi + .fn<(name: string) => Promise>() + .mockRejectedValueOnce(new Error('Server down')) + .mockResolvedValueOnce('routine-2'); + const actions = setup(create); + await actions.enable(suggestion); + expect(actions.statusFor(suggestion)).toEqual({ + kind: 'failed', + message: "Couldn't enable this routine. Server down", + }); + actions.edit(suggestion, actions.editsFor(suggestion)); + expect(actions.statusFor(suggestion)).toEqual({ kind: 'idle' }); + await actions.enable(suggestion); + expect(actions.statusFor(suggestion)).toEqual({ + kind: 'enabled', + routineId: 'routine-2', + }); + }); + + it('reads a suggestion the user already has as enabled', async () => { + const create = vi.fn(async () => 'never'); + const actions = setup(create, [ + { id: 'existing', name: 'follow-ups after calls ' }, + ]); + expect(actions.statusFor(suggestion)).toEqual({ + kind: 'enabled', + routineId: 'existing', + }); + await actions.enable(suggestion); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/routine-suggestions/primitives/suggestion-actions.ts b/apps/web/src/features/routine-suggestions/primitives/suggestion-actions.ts new file mode 100644 index 00000000000..f307602d6b6 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/primitives/suggestion-actions.ts @@ -0,0 +1,102 @@ +import type { ScheduleDraft } from '@app/features/routines/core/draft'; +import { getErrorMessage } from '@app/features/routines/core/routine-draft'; +import type { RoutineTarget } from '@app/features/routines/core/routine-target'; +import { type Accessor, createSignal } from 'solid-js'; +import { + type RoutineSuggestion, + type SuggestionEdits, + type SuggestionStatus, + suggestionToDraft, +} from '../core/suggestions'; + +export type SuggestionActionsDeps = { + /** The model or agent new routines run with. */ + target: Accessor; + /** Existing routines, so suggestions the user already has read as enabled. */ + existingRoutines: Accessor<{ id: string; name: string }[]>; + create(draft: ScheduleDraft): Promise; + /** Opens the full routine editor, prefilled, where the host has one. */ + customize?: (draft: ScheduleDraft) => void; +}; + +/** + * Per-suggestion state for a list of suggestions: what the user edited, + * which row is open, and whether enabling is running, done, or failed. + * Enabling twice never creates two routines. + */ +export function createSuggestionActions(deps: SuggestionActionsDeps) { + const [edits, setEdits] = createSignal>({}); + const [statuses, setStatuses] = createSignal< + Record + >({}); + const [expandedId, setExpandedId] = createSignal(); + + const setStatus = (id: string, status: SuggestionStatus) => + setStatuses((previous) => ({ ...previous, [id]: status })); + + const editsFor = (suggestion: RoutineSuggestion): SuggestionEdits => + edits()[suggestion.id] ?? { + title: suggestion.title, + instructions: suggestion.instructions, + notify: suggestion.notify, + }; + + const statusFor = (suggestion: RoutineSuggestion): SuggestionStatus => { + const own = statuses()[suggestion.id]; + if (own && own.kind !== 'idle') return own; + const name = editsFor(suggestion).title.trim().toLowerCase(); + const existing = deps + .existingRoutines() + .find((routine) => routine.name.trim().toLowerCase() === name); + return existing + ? { kind: 'enabled', routineId: existing.id } + : { kind: 'idle' }; + }; + + const draftFor = (suggestion: RoutineSuggestion) => { + const edited = editsFor(suggestion); + return suggestionToDraft( + { + title: edited.title.trim() || suggestion.title, + instructions: edited.instructions.trim() || suggestion.instructions, + notify: edited.notify, + trigger: suggestion.trigger, + }, + deps.target() + ); + }; + + async function enable(suggestion: RoutineSuggestion) { + const current = statusFor(suggestion).kind; + if (current === 'enabling' || current === 'enabled') return; + setStatus(suggestion.id, { kind: 'enabling' }); + try { + const routineId = await deps.create(draftFor(suggestion)); + setStatus(suggestion.id, { kind: 'enabled', routineId }); + } catch (error) { + setStatus(suggestion.id, { + kind: 'failed', + message: `Couldn't enable this routine. ${getErrorMessage(error)}`, + }); + } + } + + return { + editsFor, + statusFor, + isExpanded: (suggestion: RoutineSuggestion) => + expandedId() === suggestion.id, + setExpanded: (suggestion: RoutineSuggestion, expanded: boolean) => + setExpandedId(expanded ? suggestion.id : undefined), + edit: (suggestion: RoutineSuggestion, next: SuggestionEdits) => { + setEdits((previous) => ({ ...previous, [suggestion.id]: next })); + if (statuses()[suggestion.id]?.kind === 'failed') + setStatus(suggestion.id, { kind: 'idle' }); + }, + enable, + customize: (suggestion: RoutineSuggestion) => + deps.customize?.(draftFor(suggestion)), + }; +} + +export type SuggestionActions = ReturnType; diff --git a/apps/web/src/features/routine-suggestions/primitives/suggestion-selection.ts b/apps/web/src/features/routine-suggestions/primitives/suggestion-selection.ts new file mode 100644 index 00000000000..61be52e5dc4 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/primitives/suggestion-selection.ts @@ -0,0 +1,41 @@ +import { type Accessor, createMemo, createSignal } from 'solid-js'; +import { pickDefaultOn, type RoutineSuggestion } from '../core/suggestions'; +import type { SuggestionActions } from './suggestion-actions'; + +/** + * Which suggestions are switched on in a list the user confirms all at once. + * The AI's likely keepers start on; the user's own toggles win. + */ +export function createSuggestionSelection( + actions: SuggestionActions, + suggestions: Accessor +) { + const defaults = createMemo(() => pickDefaultOn(suggestions() ?? [])); + const [choices, setChoices] = createSignal>({}); + const isOn = (suggestion: RoutineSuggestion) => + choices()[suggestion.id] ?? defaults().has(suggestion.id); + const selected = () => (suggestions() ?? []).filter(isOn); + + return { + isOn, + setOn: (suggestion: RoutineSuggestion, on: boolean) => + setChoices((previous) => ({ ...previous, [suggestion.id]: on })), + selected, + /** Whether the AI switched this one on before the user touched it. */ + isDefault: (suggestion: RoutineSuggestion) => defaults().has(suggestion.id), + /** + * Creates every selected routine not created yet. Resolves true once all + * of them exist; failures stay on their rows, and calling again retries + * only those. + */ + async enableSelected(): Promise { + const chosen = selected(); + await Promise.all(chosen.map(actions.enable)); + return chosen.every( + (suggestion) => actions.statusFor(suggestion).kind === 'enabled' + ); + }, + }; +} + +export type SuggestionSelection = ReturnType; diff --git a/apps/web/src/features/routine-suggestions/queries/keys.ts b/apps/web/src/features/routine-suggestions/queries/keys.ts new file mode 100644 index 00000000000..5faf4b39f83 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/queries/keys.ts @@ -0,0 +1,5 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +export const routineSuggestionKeys = createQueryKeys('routineSuggestions', { + set: null, +}); diff --git a/apps/web/src/features/routine-suggestions/queries/routine-suggestions.test.tsx b/apps/web/src/features/routine-suggestions/queries/routine-suggestions.test.tsx new file mode 100644 index 00000000000..ac423977444 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/queries/routine-suggestions.test.tsx @@ -0,0 +1,119 @@ +import { queryClient } from '@queries/client'; +import type { RoutineSuggestionSet } from '@service-cognition/routine-suggestions'; +import { cleanup, render, waitFor } from '@solidjs/testing-library'; +import { QueryClientProvider } from '@tanstack/solid-query'; +import { okAsync } from 'neverthrow'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FIXTURE_ROUTINE_SUGGESTIONS } from '../tests/fixture-suggestions'; +import { + createRoutineSuggestions, + type RoutineSuggestionsSource, +} from './routine-suggestions'; + +const server = vi.hoisted(() => ({ get: vi.fn(), refresh: vi.fn() })); +vi.mock('@queries/client', async () => { + const { QueryClient } = await import('@tanstack/solid-query'); + return { queryClient: new QueryClient() }; +}); +vi.mock('@service-cognition/routine-suggestions', () => ({ + routineSuggestionsClient: server, +})); + +afterEach(() => { + cleanup(); + queryClient.clear(); + server.get.mockReset(); + server.refresh.mockReset(); +}); + +const [first] = FIXTURE_ROUTINE_SUGGESTIONS; + +function set(overrides: Partial): RoutineSuggestionSet { + return { + status: 'ready', + suggestions: [first], + generatedAt: '2026-10-27T09:00:00Z', + ...overrides, + }; +} + +function mount(): RoutineSuggestionsSource { + let source: RoutineSuggestionsSource | undefined; + function Probe() { + source = createRoutineSuggestions({ ready: () => true }); + return null; + } + render(() => ( + + + + )); + if (!source) throw new Error('source did not mount'); + return source; +} + +describe('createRoutineSuggestions', () => { + it('waits while the first set generates, then shows it', async () => { + server.get + .mockReturnValueOnce( + okAsync( + set({ status: 'generating', suggestions: [], generatedAt: null }) + ) + ) + .mockReturnValue(okAsync(set({}))); + + const source = mount(); + + await waitFor(() => expect(source.generating()).toBe(true)); + expect(source.suggestions()).toBeUndefined(); + expect(source.error()).toBeUndefined(); + await waitFor( + () => + expect(source.suggestions()?.map((s) => s.title)).toEqual([ + first.title, + ]), + { timeout: 5_000 } + ); + expect(source.generating()).toBe(false); + }); + + it('keeps the same rows when a refetch answers with the same set', async () => { + server.get.mockImplementation(() => okAsync(set({}))); + const source = mount(); + await waitFor(() => expect(source.suggestions()).toHaveLength(1)); + const [before] = source.suggestions() ?? []; + + await queryClient.refetchQueries(); + + expect(server.get).toHaveBeenCalledTimes(2); + expect(source.suggestions()?.[0]).toBe(before); + }); + + it('keeps the earlier set visible while a refresh generates', async () => { + server.get.mockReturnValue(okAsync(set({}))); + server.refresh.mockReturnValue(okAsync(set({ status: 'generating' }))); + const source = mount(); + await waitFor(() => expect(source.suggestions()).toHaveLength(1)); + + await source.refresh(); + + expect(source.generating()).toBe(true); + expect(source.suggestions()).toHaveLength(1); + expect(source.error()).toBeUndefined(); + }); + + it('reports a failed generation only when there is nothing to show', async () => { + server.get.mockReturnValue( + okAsync(set({ status: 'failed', suggestions: [], generatedAt: null })) + ); + const failed = mount(); + await waitFor(() => expect(failed.error()).toBeDefined()); + cleanup(); + queryClient.clear(); + + server.get.mockReturnValue(okAsync(set({ status: 'failed' }))); + const stale = mount(); + await waitFor(() => expect(stale.suggestions()).toHaveLength(1)); + expect(stale.error()).toBeUndefined(); + }); +}); diff --git a/apps/web/src/features/routine-suggestions/queries/routine-suggestions.ts b/apps/web/src/features/routine-suggestions/queries/routine-suggestions.ts new file mode 100644 index 00000000000..fc59603b4d2 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/queries/routine-suggestions.ts @@ -0,0 +1,96 @@ +import { getDefaultTimezone } from '@core/util/cron'; +import { throwOnErr } from '@core/util/result'; +import { queryClient } from '@queries/client'; +import { + type RoutineSuggestionSet, + routineSuggestionsClient, +} from '@service-cognition/routine-suggestions'; +import { type Query, queryOptions, useQuery } from '@tanstack/solid-query'; +import { type Accessor, createMemo, createSignal } from 'solid-js'; +import type { RoutineSuggestion } from '../core/suggestions'; +import { routineSuggestionKeys } from './keys'; +import { toRoutineSuggestions } from './suggestion-wire'; + +/** How often to ask again while the server is still generating. */ +const GENERATING_POLL_MS = 3_000; + +function pollWhileGenerating(query: Query) { + return query.state.data?.status === 'generating' ? GENERATING_POLL_MS : false; +} + +function routineSuggestionsQueryOptions(enabled: boolean) { + return queryOptions({ + queryKey: routineSuggestionKeys.set.queryKey, + queryFn: () => throwOnErr(() => routineSuggestionsClient.get()), + enabled, + staleTime: 5 * 60 * 1000, + refetchInterval: pollWhileGenerating, + // FE-49: polls under the open suggestion list; an unchanged answer keeps + // its objects, so rows don't remount and drop focus. + reconcile: 'id', + }); +} + +async function startRefresh() { + const set = await throwOnErr(() => routineSuggestionsClient.refresh()); + // A generating answer turns polling back on. + queryClient.setQueryData(routineSuggestionKeys.set.queryKey, set); + return set; +} + +export type RoutineSuggestionsSource = { + /** Undefined until a result exists; earlier results stay while refreshing. */ + suggestions(): RoutineSuggestion[] | undefined; + /** A generation is running (first time or a refresh). */ + generating(): boolean; + /** Set when there is nothing to show because loading or generating failed. */ + error(): string | undefined; + refresh(): Promise; +}; + +/** The user's routine suggestions, generated and stored by the server. */ +export function createRoutineSuggestions(options: { + /** Whether the user has loaded, so the request carries their identity. */ + ready: Accessor; +}): RoutineSuggestionsSource { + const query = useQuery(() => routineSuggestionsQueryOptions(options.ready())); + const [refreshing, setRefreshing] = createSignal(false); + // FE-48: read data only once the query has settled, and keep it through + // refetch errors. + const set = () => (query.isPending ? undefined : query.data); + const suggestions = createMemo(() => { + const current = set(); + if (!current) return undefined; + if (current.generatedAt == null && current.suggestions.length === 0) { + return undefined; + } + return toRoutineSuggestions(current.suggestions, getDefaultTimezone()); + }); + return { + suggestions, + generating: () => refreshing() || set()?.status === 'generating', + error: () => { + if (suggestions()?.length) return undefined; + if (query.isError && !set()) return query.error.message; + return set()?.status === 'failed' + ? 'Routine suggestions could not be generated.' + : undefined; + }, + refresh: async () => { + setRefreshing(true); + try { + return await startRefresh(); + } finally { + setRefreshing(false); + } + }, + }; +} + +/** + * Starts generating the suggestions onboarding's last step shows, from the + * inbox and tools connected so far, so they are ready by the time it opens. + */ +export async function warmRoutineSuggestions(): Promise { + await startRefresh(); +} diff --git a/apps/web/src/features/routine-suggestions/queries/suggestion-wire.test.ts b/apps/web/src/features/routine-suggestions/queries/suggestion-wire.test.ts new file mode 100644 index 00000000000..611f2b78616 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/queries/suggestion-wire.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { serializeTriggers } from '../../routines/queries/routine-trigger-mapping'; +import { toRoutineSuggestions, type WireSuggestion } from './suggestion-wire'; + +function wire(overrides: Partial): WireSuggestion { + return { + surface: 'email', + title: 'Tag invoices', + summary: 'Labels invoices as they arrive.', + reason: 'You get invoices from Acme weekly', + instructions: 'Label every invoice from Acme as Finance.', + icon: 'tag', + keepProbability: 0.5, + affectsOthers: false, + notify: false, + trigger: { + kind: 'event', + event: 'email.message_received', + condition: 'Is this email an invoice?', + }, + ...overrides, + }; +} + +describe('toRoutineSuggestions', () => { + it('turns event and schedule suggestions into routine triggers', () => { + const [event, weekly, daily, monthly, hourly] = toRoutineSuggestions( + [ + wire({}), + wire({ + surface: 'calendar', + title: 'Morning brief', + trigger: { + kind: 'schedule', + schedule: { + frequency: 'week', + time: '08:30', + weekdays: ['mon', 'fri'], + }, + }, + }), + wire({ + surface: 'chat', + title: 'Channel digest', + trigger: { + kind: 'schedule', + schedule: { frequency: 'day', time: '17:00' }, + }, + }), + wire({ + surface: 'crm', + title: 'Pipeline audit', + trigger: { + kind: 'schedule', + schedule: { frequency: 'month', time: '09:00', dayOfMonth: 2 }, + }, + }), + wire({ + surface: 'agents', + title: 'Watch the queue', + trigger: { kind: 'schedule', schedule: { frequency: 'hour' } }, + }), + ], + 'America/New_York' + ); + expect(serializeTriggers([event.trigger])).toEqual({ + type: 'events', + filters: [ + { + events: ['email.message_received'], + condition: 'Is this email an invoice?', + }, + ], + }); + expect(event.when).toBe('When a new email arrives'); + expect(serializeTriggers([weekly.trigger])).toEqual({ + type: 'cron', + schedule: '0 30 8 * * 2,6', + timezone: 'America/New_York', + }); + expect(serializeTriggers([daily.trigger])).toEqual({ + type: 'cron', + schedule: '0 0 17 * * 1,2,3,4,5,6,7', + timezone: 'America/New_York', + }); + expect(serializeTriggers([monthly.trigger])).toEqual({ + type: 'cron', + schedule: '0 0 9 2 * *', + timezone: 'America/New_York', + }); + expect(hourly.when).toBe('Every hour'); + expect(hourly.trigger).toMatchObject({ + kind: 'schedule', + frequency: 'hour', + timezone: 'America/New_York', + }); + }); + + it('keys each suggestion by surface and title', () => { + const [email, tasks] = toRoutineSuggestions( + [wire({}), wire({ surface: 'tasks', title: 'Tag Invoices' })], + 'UTC' + ); + expect(email.id).toBe('["email","tag invoices"]'); + expect(tasks.id).toBe('["tasks","tag invoices"]'); + }); + + it('skips a trigger shape this build does not know', () => { + const unknown = JSON.parse( + '{"kind":"webhook","url":"https://example.com"}' + ) as WireSuggestion['trigger']; + const decoded = toRoutineSuggestions( + [wire({ title: 'From a newer server', trigger: unknown }), wire({})], + 'UTC' + ); + expect(decoded.map((suggestion) => suggestion.title)).toEqual([ + 'Tag invoices', + ]); + }); +}); diff --git a/apps/web/src/features/routine-suggestions/queries/suggestion-wire.ts b/apps/web/src/features/routine-suggestions/queries/suggestion-wire.ts new file mode 100644 index 00000000000..d3a0ab6451c --- /dev/null +++ b/apps/web/src/features/routine-suggestions/queries/suggestion-wire.ts @@ -0,0 +1,149 @@ +import { + newScheduleTrigger, + type RoutineEventName, + type RoutineTriggerDraft, +} from '@app/features/routines/core/routine-triggers'; +import { describeCron } from '@core/util/cron'; +import type { RoutineSuggestion as WireSuggestion } from '@service-cognition/generated/schemas/routineSuggestion'; +import type { SuggestedEvent } from '@service-cognition/generated/schemas/suggestedEvent'; +import type { SuggestionSchedule } from '@service-cognition/generated/schemas/suggestionSchedule'; +import type { SuggestionTrigger } from '@service-cognition/generated/schemas/suggestionTrigger'; +import type { Weekday } from '@service-cognition/generated/schemas/weekday'; +import { match } from 'ts-pattern'; +import type { RoutineSuggestion } from '../core/suggestions'; + +export type { WireSuggestion }; + +const EVENT_LABELS = { + 'email.message_received': 'When a new email arrives', + 'task.created': 'When a task is created', + 'document.created': 'When a document is created', + 'call.started': 'When a call starts', + 'call.ended': 'When a call ends', +} satisfies Record; + +const CRON_WEEKDAYS: Record = { + sun: '1', + mon: '2', + tue: '3', + wed: '4', + thu: '5', + fri: '6', + sat: '7', +}; + +const EVERY_DAY = ['1', '2', '3', '4', '5', '6', '7']; + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function scheduleTrigger( + schedule: SuggestionSchedule, + timezone: string +): { trigger: RoutineTriggerDraft; when: string } | undefined { + return match(schedule) + .with({ frequency: 'hour' }, () => ({ + trigger: { ...newScheduleTrigger('hour'), timezone }, + when: 'Every hour', + })) + .with({ frequency: 'day' }, ({ time }) => ({ + trigger: { + ...newScheduleTrigger('week'), + time, + daysOfWeek: EVERY_DAY, + timezone, + }, + when: capitalize( + describeCron({ + frequency: 'week', + time, + daysOfWeek: EVERY_DAY, + dayOfMonth: '1', + }) + ), + })) + .with({ frequency: 'week' }, ({ time, weekdays }) => { + const daysOfWeek = weekdays.map((day) => CRON_WEEKDAYS[day]); + return { + trigger: { ...newScheduleTrigger('week'), time, daysOfWeek, timezone }, + when: capitalize( + describeCron({ frequency: 'week', time, daysOfWeek, dayOfMonth: '1' }) + ), + }; + }) + .with({ frequency: 'month' }, ({ time, dayOfMonth }) => { + const day = String(dayOfMonth); + return { + trigger: { + ...newScheduleTrigger('month'), + time, + dayOfMonth: day, + timezone, + }, + when: capitalize( + describeCron({ + frequency: 'month', + time, + daysOfWeek: [], + dayOfMonth: day, + }) + ), + }; + }) + .exhaustive(() => undefined); +} + +function routineTrigger( + trigger: SuggestionTrigger, + timezone: string +): { trigger: RoutineTriggerDraft; when: string } | undefined { + return match(trigger) + .with({ kind: 'event' }, ({ event, condition }) => ({ + trigger: { + id: crypto.randomUUID(), + kind: 'event' as const, + events: [event], + ...(condition ? { condition } : {}), + }, + when: EVENT_LABELS[event], + })) + .with({ kind: 'schedule' }, ({ schedule }) => + scheduleTrigger(schedule, timezone) + ) + .exhaustive(() => undefined); +} + +/** + * The server's suggestions as the UI shows them, with triggers in the + * routine composer's shape and scheduled in `timezone`. The server has + * already validated them; one in a shape this build does not know (a newer + * server) is skipped. + */ +export function toRoutineSuggestions( + suggestions: readonly WireSuggestion[], + timezone: string +): RoutineSuggestion[] { + return suggestions.flatMap((suggestion) => { + const trigger = routineTrigger(suggestion.trigger, timezone); + if (!trigger) return []; + return [ + { + id: JSON.stringify([ + suggestion.surface, + suggestion.title.toLowerCase(), + ]), + surface: suggestion.surface, + title: suggestion.title, + summary: suggestion.summary, + reason: suggestion.reason, + instructions: suggestion.instructions, + icon: suggestion.icon, + keepProbability: suggestion.keepProbability, + affectsOthers: suggestion.affectsOthers, + notify: suggestion.notify, + ...trigger, + }, + ]; + }); +} diff --git a/apps/web/src/features/routine-suggestions/routine-suggestions.tsx b/apps/web/src/features/routine-suggestions/routine-suggestions.tsx new file mode 100644 index 00000000000..e3c9c8e2ac9 --- /dev/null +++ b/apps/web/src/features/routine-suggestions/routine-suggestions.tsx @@ -0,0 +1,238 @@ +import { openAgentsPage } from '@app/features/agents-view/primitives/open-page'; +import { createPromotionVisibility } from '@app/features/sidebar-promotion/sidebar-promotion'; +import { analytics } from '@app/lib/analytics'; +import { useSplitLayout } from '@components/app/split-layout/layout'; +import { useHasPaidAccess } from '@core/auth/license'; +import { defaultModelForPlan } from '@core/component/AI/constant'; +import { useUserId } from '@core/context/user'; +import { fireAndForget } from '@core/util/fireAndForget'; +import { Tabs } from '@ui'; +import { createMemo, createSignal, Show } from 'solid-js'; +import { draftToCreateBody } from '../routines/queries/routine-draft'; +import { + useCreateScheduleMutation, + useSchedulesQuery, +} from '../routines/queries/schedules'; +import { RoutineSuggestionsCard } from './components/routine-suggestions-card'; +import { RoutineSuggestionsDialog } from './components/routine-suggestions-dialog'; +import { + type RoutineSurface, + routineSurfaceCopy, + routineSurfaces, +} from './core/surfaces'; +import { createSuggestionActions } from './primitives/suggestion-actions'; +import { createRoutineSuggestions } from './queries/routine-suggestions'; +import { + SuggestionRows, + suggestionListView, +} from './views/suggestion-list-view'; + +/** Production wiring shared by the sidebar modal and the Routines page. */ +function createProductionSuggestions( + /** Whether the user's routines are needed yet (to mark ones they have). */ + listRoutines: () => boolean, + onLeave: () => void +) { + const layout = useSplitLayout(); + const userId = useUserId(); + const source = createRoutineSuggestions({ + ready: () => userId() !== undefined, + }); + const hasPaidAccess = useHasPaidAccess(); + const routines = useSchedulesQuery(listRoutines); + const create = useCreateScheduleMutation(undefined, 'suggestion'); + const actions = createSuggestionActions({ + target: () => ({ + kind: 'model', + model: defaultModelForPlan(hasPaidAccess()), + }), + existingRoutines: () => + routines.isPending + ? [] + : (routines.data ?? []).flatMap((routine) => + routine.id ? [{ id: routine.id, name: routine.name }] : [] + ), + async create(draft) { + const created = await create.mutateAsync(draftToCreateBody(draft)); + if (!created.id) throw new Error('Created routine has no ID'); + return created.id; + }, + customize(draft) { + onLeave(); + layout.popoverSplit({ + type: 'component', + id: 'routine-compose', + params: { initialDraft: draft }, + }); + }, + }); + return { + source, + actions, + openRoutines: (routineId?: string) => { + onLeave(); + openAgentsPage( + layout, + 'routines', + routineId === undefined ? {} : { routineId } + ); + }, + refresh: () => fireAndForget(source.refresh(), 'routine-suggestions'), + }; +} + +/** + * The "Suggested routines" card at the top of a view sidebar, and the modal + * it opens with AI-picked routines for that surface. The close button hides + * the card on every device. + */ +export function SidebarRoutineSuggestions(props: { + surface: RoutineSurface; + class?: string; +}) { + const visibility = createPromotionVisibility( + () => `routine-suggestions:${props.surface}`, + () => true + ); + return ( + + { + analytics.track('routine_suggestions_dismissed', { + surface: props.surface, + }); + visibility.dismiss(); + }} + /> + + ); +} + +function SidebarRoutineSuggestionsCard(props: { + surface: RoutineSurface; + class?: string; + onDismiss(): void; +}) { + const [open, setOpen] = createSignal(false); + const suggestions = createProductionSuggestions(open, () => setOpen(false)); + const copy = () => routineSurfaceCopy[props.surface]; + const forSurface = createMemo(() => + suggestions.source + .suggestions() + ?.filter((suggestion) => suggestion.surface === props.surface) + ); + const view = () => + suggestionListView({ + suggestions: forSurface(), + generating: suggestions.source.generating(), + failed: suggestions.source.error() !== undefined, + }); + return ( + <> + { + analytics.track('routine_suggestions_opened', { + surface: props.surface, + suggestions: forSurface()?.length ?? 0, + }); + setOpen(true); + }} + onDismiss={props.onDismiss} + /> + suggestions.openRoutines()} + > + + + + ); +} + +type SurfaceFilter = RoutineSurface | 'all'; + +/** "Suggested for you" below the routines list, across every surface. */ +export function RoutineSuggestionsSection() { + const suggestions = createProductionSuggestions( + () => true, + () => {} + ); + const [filter, setFilter] = createSignal('all'); + const all = suggestions.source.suggestions; + const filterOptions = createMemo(() => { + const present = new Set(all()?.map((suggestion) => suggestion.surface)); + return [ + { value: 'all' as const, label: 'All' }, + ...routineSurfaces + .filter((surface) => present.has(surface)) + .map((surface) => ({ + value: surface, + label: routineSurfaceCopy[surface].label, + })), + ]; + }); + const visible = () => { + const current = filter(); + return current === 'all' + ? all() + : all()?.filter((suggestion) => suggestion.surface === current); + }; + return ( +
    +
    +
    +

    + Suggested for you +

    +

    + Routines AI picked from your workspace. Enable one in a click, or + open it to edit first. +

    +
    + 2}> + + +
    + +
    + ); +} diff --git a/apps/web/src/features/routine-suggestions/tests/fixture-suggestions.ts b/apps/web/src/features/routine-suggestions/tests/fixture-suggestions.ts new file mode 100644 index 00000000000..b48aa86d03d --- /dev/null +++ b/apps/web/src/features/routine-suggestions/tests/fixture-suggestions.ts @@ -0,0 +1,108 @@ +import type { WireSuggestion } from '../queries/suggestion-wire'; + +/** + * The server's suggestions for a founder's workspace, for fixtures and fakes: + * three email routines and one or two for other surfaces, with the AI's keep + * estimates putting three of them over the default-on bar. + */ +export const FIXTURE_ROUTINE_SUGGESTIONS: WireSuggestion[] = [ + { + surface: 'email', + title: 'Tag invoices and receipts', + summary: 'Labels every invoice and receipt as Finance when it arrives.', + reason: 'Stripe and AWS bill you every month', + instructions: + 'When an email is an invoice or a receipt, label it Finance and archive it. Note the vendor, amount, and due date in the Finance expenses document.', + icon: 'tag', + keepProbability: 0.86, + affectsOthers: false, + notify: false, + trigger: { + kind: 'event', + event: 'email.message_received', + condition: 'Is this email an invoice or a receipt?', + }, + }, + { + surface: 'email', + title: 'Alert me about Acme', + summary: 'Tells you right away when anyone from Acme writes in.', + reason: 'Acme is your largest open deal', + instructions: + 'When an email comes from someone at Acme or mentions the Acme renewal, summarize it in two sentences and draft a reply for me to review.', + icon: 'bell', + keepProbability: 0.74, + affectsOthers: false, + notify: true, + trigger: { + kind: 'event', + event: 'email.message_received', + condition: 'Is this email from Acme or about the Acme renewal?', + }, + }, + { + surface: 'email', + title: 'Morning inbox digest', + summary: 'A short list of what needs you, every weekday morning.', + reason: 'You get 80+ emails a day', + instructions: + 'Every weekday morning, list the emails from the last day that need my reply, ranked by urgency, with a one-line summary each.', + icon: 'mail', + keepProbability: 0.82, + affectsOthers: false, + notify: true, + trigger: { + kind: 'schedule', + schedule: { + frequency: 'week', + time: '08:30', + weekdays: ['mon', 'tue', 'wed', 'thu', 'fri'], + }, + }, + }, + { + surface: 'calls', + title: 'Follow-ups after every call', + summary: 'Creates tasks for the action items when a call ends.', + reason: 'You run six customer calls a week', + instructions: + 'When a call ends, read its transcript and create a task for me for each action item I own, due when I said.', + icon: 'task', + keepProbability: 0.88, + affectsOthers: false, + notify: false, + trigger: { kind: 'event', event: 'call.ended' }, + }, + { + surface: 'crm', + title: 'Keep the pipeline honest', + summary: 'Checks every deal sits in the right stage, each Friday.', + reason: 'Your Sales pipeline has 40 open deals', + instructions: + 'Every Friday afternoon, review each open deal in the Sales pipeline against its latest emails and calls, and move any deal whose stage is out of date. List what you moved.', + icon: 'chart', + keepProbability: 0.71, + affectsOthers: true, + notify: true, + trigger: { + kind: 'schedule', + schedule: { frequency: 'week', time: '16:00', weekdays: ['fri'] }, + }, + }, + { + surface: 'calendar', + title: 'Meeting prep briefs', + summary: 'A one-page brief on who you are meeting, every morning.', + reason: 'Most of your meetings are with new people', + instructions: + 'Every weekday morning, for each external meeting today, write a short brief on the attendees and their company from email history and the web.', + icon: 'calendar', + keepProbability: 0.64, + affectsOthers: false, + notify: true, + trigger: { + kind: 'schedule', + schedule: { frequency: 'day', time: '07:45' }, + }, + }, +]; diff --git a/apps/web/src/features/routine-suggestions/views/suggestion-list-view.tsx b/apps/web/src/features/routine-suggestions/views/suggestion-list-view.tsx new file mode 100644 index 00000000000..8d30969e9bb --- /dev/null +++ b/apps/web/src/features/routine-suggestions/views/suggestion-list-view.tsx @@ -0,0 +1,82 @@ +import { For } from 'solid-js'; +import { + RoutineSuggestionList, + type SuggestionListView, +} from '../components/routine-suggestion-list'; +import { + RoutineSuggestionRow, + SuggestionEnableButton, +} from '../components/routine-suggestion-row'; +import type { RoutineSuggestion } from '../core/suggestions'; +import { routineSurfaceCopy } from '../core/surfaces'; +import type { SuggestionActions } from '../primitives/suggestion-actions'; + +/** Derives the list state; cached suggestions stay visible through refreshes. */ +export function suggestionListView(input: { + suggestions: RoutineSuggestion[] | undefined; + generating: boolean; + failed: boolean; +}): SuggestionListView { + if (input.suggestions?.length) { + return { kind: 'items', items: input.suggestions }; + } + if (input.generating) return { kind: 'loading' }; + if (input.failed && input.suggestions === undefined) return { kind: 'error' }; + if (input.suggestions === undefined) return { kind: 'loading' }; + return { kind: 'empty' }; +} + +/** A list of suggestion rows wired to their per-row actions. */ +export function SuggestionRows(props: { + view: SuggestionListView; + actions: SuggestionActions; + emptyLabel: string; + /** Label each row with its surface, for lists that mix surfaces. */ + showSurface?: boolean; + onRetry(): void; + onOpenRoutine(routineId: string): void; +}) { + return ( + + {(items) => ( +
      + + {(suggestion) => ( + + props.actions.setExpanded(suggestion, expanded) + } + onEdit={(edits) => props.actions.edit(suggestion, edits)} + onCustomize={() => props.actions.customize(suggestion)} + action={ + props.actions.enable(suggestion)} + onOpenRoutine={props.onOpenRoutine} + /> + } + /> + )} + +
    + )} +
    + ); +} diff --git a/apps/web/src/features/routines/components/routines-list.tsx b/apps/web/src/features/routines/components/routines-list.tsx index 7c636823759..236d1014933 100644 --- a/apps/web/src/features/routines/components/routines-list.tsx +++ b/apps/web/src/features/routines/components/routines-list.tsx @@ -7,7 +7,7 @@ import { ViewCreateButton } from '@app/components/view-shell/ViewCreateButton'; import { isTouchDevice } from '@core/mobile/isTouchDevice'; import ClockIcon from '@icon/system/regular/clock-clockwise.svg'; import { Button } from '@ui'; -import { createSignal, For, Show } from 'solid-js'; +import { createSignal, For, type JSX, Show } from 'solid-js'; import type { RoutineRow } from '../core/types'; import { RoutineListRow, RoutineRowLayout } from './routine-row'; @@ -19,6 +19,8 @@ export function RoutinesList(props: { /** False where the host already titles the page, as Settings does. */ showTitle?: boolean; registerSearchFocus?: (focus: () => void) => void; + /** Scrolls with the list, below it, e.g. suggested routines. */ + footer?: JSX.Element; onCreate: () => void; onOpen: (id: string, history?: boolean) => void; onToggle: (row: RoutineRow) => void; @@ -96,70 +98,70 @@ export function RoutinesList(props: {
    -
    - +
    - Routine - Created by - Status - Runs with - - Enabled - - -
    -
    - - {(row) => ( - - )} - -
    - -
    -
    - -

    - {props.loading - ? 'Loading routines…' - : props.error - ? 'Routines are unavailable' - : search().trim() - ? 'No matching routines' - : 'No routines yet'} -

    - + Routine + Created by + Status + Runs with + + Enabled + + +
    +
    + + {(row) => ( + + )} + +
    + +
    +
    +

    - Create a routine to run an agent on a schedule or a Macro - event. + {props.loading + ? 'Loading routines…' + : props.error + ? 'Routines are unavailable' + : search().trim() + ? 'No matching routines' + : 'No routines yet'}

    - + +

    + Create a routine to run an agent on a schedule or a Macro + event. +

    +
    +
    -
    -
    + +
    + {props.footer}
    ); diff --git a/apps/web/src/features/routines/draft-storage.ts b/apps/web/src/features/routines/draft-storage.ts index e773c6c3535..0c900fc0e84 100644 --- a/apps/web/src/features/routines/draft-storage.ts +++ b/apps/web/src/features/routines/draft-storage.ts @@ -40,6 +40,12 @@ const draftSchema = z ]) .transform((draft): ScheduleDraft => draft); +/** A draft handed over at runtime (split params), if it is well formed. */ +export function parseRoutineDraft(value: unknown): ScheduleDraft | undefined { + const parsed = draftSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; +} + const storedDraftSchema = z.object({ draft: draftSchema, timestamp: z.number().nonnegative(), diff --git a/apps/web/src/features/routines/primitives/routine-composer.ts b/apps/web/src/features/routines/primitives/routine-composer.ts index dfc0a3b78a8..8548c8739a3 100644 --- a/apps/web/src/features/routines/primitives/routine-composer.ts +++ b/apps/web/src/features/routines/primitives/routine-composer.ts @@ -17,9 +17,11 @@ export function createRoutineComposer( storage: RoutineDraftStorage, defaultModel: string, onCreated: (id: string) => void, - connectedApps: Accessor | undefined> + connectedApps: Accessor | undefined>, + /** Starts from this draft, e.g. a suggested routine, instead of a saved one. */ + initialDraft?: ScheduleDraft ) { - const restored = storage.load(); + const restored = initialDraft ?? storage.load(); const [draft, setDraft] = createSignal( restored ? { diff --git a/apps/web/src/features/routines/queries/schedules.ts b/apps/web/src/features/routines/queries/schedules.ts index 61f2bee82b5..0f525b99b7f 100644 --- a/apps/web/src/features/routines/queries/schedules.ts +++ b/apps/web/src/features/routines/queries/schedules.ts @@ -131,8 +131,8 @@ function _invalidateScheduleHistory(scheduleId: string) { export function useCreateScheduleMutation( callbacks?: MutationCallbacks, - /** Whether the user created the routine or duplicated one, for analytics. */ - source: 'new' | 'duplicate' = 'new' + /** How the user created the routine, for analytics. */ + source: 'new' | 'duplicate' | 'suggestion' = 'new' ) { return useMutation(() => ({ mutationFn: async (request: CreateScheduledAction) => diff --git a/apps/web/src/features/routines/routine-creator.tsx b/apps/web/src/features/routines/routine-creator.tsx index 76a43ed0468..d01287138c6 100644 --- a/apps/web/src/features/routines/routine-creator.tsx +++ b/apps/web/src/features/routines/routine-creator.tsx @@ -6,6 +6,7 @@ import { defaultModelForPlan } from '@core/component/AI/constant'; import { clearRoutineComposerDraft, loadRoutineComposerDraft, + parseRoutineDraft, saveRoutineComposerDraft, } from './draft-storage'; import { createRoutineCreatorSource } from './queries/routine-sources'; @@ -17,7 +18,14 @@ import { RoutineCreatorView } from './views/routine-creator'; /** Production composition for the shared task/project creation popover. */ export function RoutineCreator( - props: { onCreated?: (id: string) => void } = {} + props: { + onCreated?: (id: string) => void; + /** + * Prefills the composer, e.g. with a suggested routine. Split params are + * untyped, so it is validated here and ignored when malformed. + */ + initialDraft?: unknown; + } = {} ) { const panel = useSplitPanelOrThrow(); const layout = useSplitLayout(); @@ -27,6 +35,7 @@ export function RoutineCreator( (focusSearch = focus)} + footer={} onCreate={create} onOpen={(id) => open(id)} /> diff --git a/apps/web/src/features/routines/views/routine-creator.tsx b/apps/web/src/features/routines/views/routine-creator.tsx index ce04d94a6ee..b7eefece945 100644 --- a/apps/web/src/features/routines/views/routine-creator.tsx +++ b/apps/web/src/features/routines/views/routine-creator.tsx @@ -9,7 +9,7 @@ import type { RoutineCreatorSource, RoutineDraftStorage, } from '../context/routine-sources'; -import { DEFAULT_RUN_VISIBILITY } from '../core/draft'; +import { DEFAULT_RUN_VISIBILITY, type ScheduleDraft } from '../core/draft'; import { createRoutineComposer } from '../primitives/routine-composer'; export function RoutineCreatorView(props: { @@ -17,6 +17,7 @@ export function RoutineCreatorView(props: { storage: RoutineDraftStorage; slots: RoutineEditorSlots; defaultModel: string; + initialDraft?: ScheduleDraft; onCreated(id: string): void; onClose(): void; }) { @@ -26,7 +27,8 @@ export function RoutineCreatorView(props: { props.storage, props.defaultModel, props.onCreated, - props.slots.createConnectedApps() + props.slots.createConnectedApps(), + props.initialDraft ); return ( void) => void; + footer?: JSX.Element; onCreate(): void; onOpen(id: string): void; }) { @@ -25,6 +26,7 @@ export function RoutinesListView(props: {