Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ebcafa5
refactor(sidebar-promotion): share card visibility with other sidebar…
claude Oct 26, 2026
91a80c7
feat(routines): let the routine composer start from a given draft
claude Oct 26, 2026
ab4471d
feat(routines): suggest AI-picked routines in every sidebar
claude Oct 26, 2026
8e60f6a
feat(onboarding): end onboarding with AI-picked routines
claude Oct 26, 2026
60bafde
fix(routines): keep default-on suggestions private and conditions intact
claude Oct 26, 2026
01a13f3
Merge remote-tracking branch 'origin/main' into claude/focused-hopper…
claude Oct 27, 2026
30e4837
feat(email): read the top of a user's inboxes straight from the provider
claude Oct 27, 2026
583b9cc
feat(routines): generate routine suggestions into their own table
claude Oct 27, 2026
e267e90
feat(routines): read routine suggestions from their own endpoint
claude Oct 27, 2026
b44d266
chore(code-mode): rebuild the embedded SDK bundle
claude Oct 27, 2026
afa6671
fix(ai-usage): classify routine suggestions in tests and docs
claude Oct 27, 2026
9f4acd0
Merge remote-tracking branch 'origin/main' into claude/focused-hopper…
claude Oct 27, 2026
ed6827c
Merge remote-tracking branch 'origin/main' into claude/focused-hopper…
claude Oct 27, 2026
376a46f
chore(code-mode): rebuild the SDK bundle with CI's Bun
claude Oct 27, 2026
3ad3268
docs(code-mode): name the Bun that generated bundles must match
claude Oct 27, 2026
85e89a9
Merge remote-tracking branch 'origin/main' into claude/focused-hopper…
claude Oct 27, 2026
80cb60a
Merge remote-tracking branch 'origin/main' into claude/focused-hopper…
claude Oct 28, 2026
981ab3d
fix(routines): keep suggestion rows stable while polling
claude Oct 28, 2026
4c42ce0
Merge remote-tracking branch 'origin/main' into claude/focused-hopper…
claude Oct 28, 2026
2d46999
Merge remote-tracking branch 'origin/main' into claude/focused-hopper…
claude Oct 28, 2026
5a2fe0b
fix(routines): use flat surfaces and shared icons
jbecke Oct 28, 2026
d166444
chore(routines): incorporate current pull request changes
jbecke Oct 28, 2026
a09a029
docs(routines): show flat suggestion surfaces in both themes
jbecke Oct 28, 2026
d5f2163
Merge remote-tracking branch 'origin/main' into claude/focused-hopper…
claude Oct 28, 2026
f461a0b
fix(routines): use the primary button for Enable
claude Oct 28, 2026
3fde18e
Merge remote-tracking branch 'origin/main' into claude/focused-hopper…
claude Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ Composer typeahead menus choosing the side with room for their full list
phone screen and below one near the top, 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`.

Meeting-note creation, linking, optional team access, retry, and unlinking have desktop and
phone browser coverage in `features/calendar-notes`:
`bunx playwright test --config src/features/calendar-notes/browser-test/playwright.config.ts`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ registerComponent('routine-compose', (params) => (
? (params.onCreated as (id: string) => void)
: undefined
}
initialDraft={params.initialDraft}
/>
));
registerComponent('document-compose', (params) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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 }),
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -304,6 +305,7 @@ export function AgentsSidebar(props: AgentsSidebarProps) {
</Show>

<ViewSidebar.Content class="gap-2 overflow-hidden pt-2">
<SidebarRoutineSuggestions surface="agents" />
<Show when={!isTouchDevice()}>
<ViewSidebar.Nav aria-label="Agent tools">
<ViewSidebar.Item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
useUpcomingTeamOoo,
} from '@app/features/calendar/hooks/use-team-ooo';
import { TEAM_OOO_SOURCE_ID } from '@app/features/calendar/types';
import { SidebarRoutineSuggestions } from '@app/features/routine-suggestions/routine-suggestions';
import { CalendarSchedulingActions } from '@app/features/scheduling/calendar-actions';
import { ShowFeatureFlag } from '@app/lib/analytics/posthog';
import { UserIcon } from '@core/component/UserIcon';
Expand Down Expand Up @@ -346,6 +347,7 @@ export function CalendarSidebar() {
</CalendarCreateMenu>
</ViewSidebar.Primary>
<ViewSidebar.Content class="pt-2">
<SidebarRoutineSuggestions surface="calendar" />
<CalendarMiniCalendar />
<Show when={shell.breakpoints.narrow?.()}>
<CalendarSchedulingActions sidebar />
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/calls-view/components/calls-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 '@phosphor/buildings.svg';
Expand Down Expand Up @@ -176,6 +177,7 @@ export function CallsSidebar(props: { onNavigate: () => void }) {
</ViewSidebar.Primary>

<ViewSidebar.Content>
<SidebarRoutineSuggestions surface="calls" />
<ViewSidebar.Nav aria-label="Call views">
<For each={VIEW_TAB_LISTS.calls}>
{(tab) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -420,6 +421,7 @@ function ExpandedHeader(props: { search: ChannelRailSearch }) {
<Show when={!isTouchDevice()}>
<ConversationsCreateMenu presentation="sidebar" />
</Show>
<SidebarRoutineSuggestions surface="chat" />
<ViewSidebar.Toolbar class={!isTouchDevice() ? 'px-0' : undefined}>
<Tabs
aria-label="Chat sidebar views"
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/features/crm/components/crm-sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, expect, it, vi } from 'vitest';
import { CrmSidebar } from './crm-sidebar';

vi.mock('@app/features/routine-suggestions/routine-suggestions', () => ({
SidebarRoutineSuggestions: () => null,
}));
vi.mock('@app/lib/analytics/posthog', () => ({
useFeatureFlag: () => () => ({ enabled: false, loading: false }),
}));
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/crm/components/crm-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 { enableCrmActivity } from '@core/constant/featureFlags';
Expand Down Expand Up @@ -95,6 +96,7 @@ export function CrmSidebar(props: {
</ViewSidebar.Primary>
</header>
<ViewSidebar.Content>
<SidebarRoutineSuggestions surface="crm" />
<CollapsibleSection.Root
open={recordsOpen()}
onOpenChange={setRecordsOpen}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/drive-view/views/drive-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { ConnectorImportSidebar } from '@app/features/connector-import/connector-import';
import { FavoriteContextMenu } from '@app/features/favorites/FavoriteContextMenu';
import { FavoriteIcon } from '@app/features/favorites/FavoriteIcon';
import { SidebarRoutineSuggestions } from '@app/features/routine-suggestions/routine-suggestions';
import { useFavoriteDisplayName } from '@app/util/favorites';
import { fireAndForget } from '@core/util/fireAndForget';
import FolderIcon from '@phosphor/folder.svg';
Expand Down Expand Up @@ -250,6 +251,7 @@ export function DriveSidebar() {
<DriveCreateMenu />
</ViewSidebar.Primary>
<ViewSidebar.Content>
<SidebarRoutineSuggestions surface="files" />
<DriveSidebarContent />
<DriveImportPromotion />
</ViewSidebar.Content>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/email-view/components/EmailSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -218,6 +219,7 @@ export function EmailSidebar() {
</Show>

<ViewSidebar.Content>
<SidebarRoutineSuggestions surface="email" />
<EmailInboxList />

<Show when={isTouchDevice()}>
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/features/onboarding/browser-test/fixture-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
expectLanded,
fakeEvents,
fakeWorld,
finishRoutines,
heading,
openFixture,
} from './fixture-page';
Expand Down Expand Up @@ -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,
Expand All @@ -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');
});

Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -405,6 +421,7 @@ for (const width of [1440, 390]) {
'tools',
'team',
'plan',
'routines',
]) {
await page.evaluate((step) => {
localStorage.setItem(
Expand Down Expand Up @@ -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');
});
}
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/features/onboarding/context/onboarding-context.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -138,6 +141,16 @@ export type OnboardingContext = {
* `skipped` records that the user left without going through the steps.
*/
completeOnboarding(options: { skipped: boolean }): Promise<CompletionResult>;
/** 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<RoutineTarget>;
/** Creates the routine, turned on; resolves with its id, rejects on failure. */
create(draft: ScheduleDraft): Promise<string>;
};
/** Finishes a tutorial flag left behind by a completed onboarding record. */
repairTutorial(): Promise<void>;
applyAccent(color: string, viewerId: string): void;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/features/onboarding/core/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const ONBOARDING_STEPS = [
'tools',
'team',
'plan',
'routines',
] as const;
export type OnboardingStep = (typeof ONBOARDING_STEPS)[number];

Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/features/onboarding/create-app-onboarding-context.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,6 +36,12 @@ import {
useCreateTeamWithInvitesMutation,
useUserTeamsQuery,
} from '@queries/team/teams';
import {
createRoutineSuggestions,
warmOnboardingRoutineSuggestions,
} 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,
Expand Down Expand Up @@ -283,6 +291,27 @@ export function createAppOnboardingContext(): OnboardingContext {
await completeTutorial.mutateAsync();
return { t: 'completed' };
},
warmRoutineSuggestions: () =>
fireAndForget(warmOnboardingRoutineSuggestions(), 'onboarding'),
createRoutineSuggestionsStep: () => {
const create = useCreateScheduleMutation(undefined, 'suggestion');
const hasPaidAccess = useHasPaidAccess();
return {
source: createRoutineSuggestions({
premium: () => false,
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();
},
Expand Down
Loading
Loading