Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 2 deletions apps/web/src/components/app/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
import { MacroMcpSetupModal } from '@app/features/integrations/mcp-setup/MacroMcpSetupModal';
import { Paywall } from '@app/features/paywall/Paywall';
import { PropertyEditorModal } from '@app/features/property/editor/PropertyEditorModal';
import { CreateReminderModal } from '@app/features/reminders/CreateReminderModal';
import { ReminderComposerModal } from '@app/features/reminders/ReminderComposerModal';
import { useOnboardingV4Flag } from '@app/features/setup/flow/useOnboardingV4Flag';
import { GlobalShareModal } from '@app/features/sharing/global-share-modal/GlobalShareModal';
import { IosShareSheet } from '@app/features/sharing/ios-share-sheet/IosShareSheet';
Expand Down Expand Up @@ -463,7 +463,7 @@ function LayoutInner(props: RouteSectionProps) {
key={ENABLE_REMINDERS_FLAG}
enabledOverride={ENABLE_REMINDERS_OVERRIDE}
>
<CreateReminderModal />
<ReminderComposerModal />
</ShowFeatureFlag>
<Show when={isAddInboxDialogOpen()}>
<AddInboxDialog />
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/features/next-soup/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { makeCopyEntityIdAction } from './make-copy-entity-id-action';
export { makeCopyLinkAction } from './make-copy-link-action';
export { makeCreateReminderAction } from './make-create-reminder-action';
export { makeDeleteAction } from './make-delete-action';
export { makeEditReminderAction } from './make-edit-reminder-action';
export { makeFavoriteAction } from './make-favorite-action';
export { makeHideCompanyAction } from './make-hide-company-action';
export { makeMarkDoneAction } from './make-mark-done-action';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { EntityData, ReminderEntity } from '@entity';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
openReminderEditor: vi.fn(),
remindersEnabled: true,
}));

vi.mock('@app/features/reminders/reminder-composer', () => ({
openReminderEditor: mocks.openReminderEditor,
}));

// Spread the original so the other flags in this module keep working; only the
// reminders gate is driven by the tests. Under vitest MODE is not
// 'development', so the real ENABLE_REMINDERS would resolve to false.
vi.mock('@core/constant/featureFlags', async (importOriginal) => ({
...(await importOriginal<typeof import('@core/constant/featureFlags')>()),
ENABLE_REMINDERS: () => mocks.remindersEnabled,
}));

import { makeEditReminderAction } from './make-edit-reminder-action';

const NEXT_RUN = '2026-08-09T09:00:00.000Z';

const reminder = (overrides: Partial<ReminderEntity> = {}) =>
({
type: 'reminder',
id: 'rem-1',
name: 'Chase the contract',
description: 'Chase the contract',
ownerId: '',
scheduleType: 'once',
nextRunAt: NEXT_RUN,
enabled: true,
...overrides,
}) as ReminderEntity;

const entity = (type: EntityData['type'], id = 'e1') =>
({ type, id, name: 'Thing' }) as EntityData;

describe('makeEditReminderAction', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.remindersEnabled = true;
});

it('can run for a one-shot reminder', () => {
expect(makeEditReminderAction().canExecute(reminder())).toBe(true);
});

it('cannot run for anything that is not a reminder', () => {
const { canExecute } = makeEditReminderAction();

expect(canExecute(entity('document'))).toBe(false);
expect(canExecute(entity('email'))).toBe(false);
});

// The composer only speaks one-shot schedules, so editing a recurring
// reminder through it would quietly turn a cron into a single firing.
it('cannot run for a recurring reminder', () => {
const recurring = reminder({
scheduleType: 'recurring',
cron: '0 0 9 * * *',
timezone: 'UTC',
});

expect(makeEditReminderAction().canExecute(recurring)).toBe(false);
});

it('opens the editor with the reminder as the row knows it', () => {
makeEditReminderAction().execute([reminder()]);

expect(mocks.openReminderEditor).toHaveBeenCalledWith({
id: 'rem-1',
description: 'Chase the contract',
remindAt: new Date(NEXT_RUN),
completed: false,
});
});

// A reschedule has to clear the done flag, so the editor needs to know the
// reminder was completed — see reminderEditPatch.
it('reports a completed reminder as completed', () => {
makeEditReminderAction().execute([
reminder({ completedAt: '2026-08-08T10:00:00.000Z' }),
]);

expect(mocks.openReminderEditor).toHaveBeenCalledWith(
expect.objectContaining({ completed: true })
);
});

it('does nothing for an empty selection', () => {
makeEditReminderAction().execute([]);

expect(mocks.openReminderEditor).not.toHaveBeenCalled();
});

// The editor asks about one reminder's time, so a multi-select is not a
// batch — the menu only offers it for a single row.
it('uses only the first entity of a multi-selection', () => {
const first = reminder();

makeEditReminderAction().execute([first, reminder({ id: 'rem-2' })]);

expect(mocks.openReminderEditor).toHaveBeenCalledOnce();
expect(mocks.openReminderEditor).toHaveBeenCalledWith(
expect.objectContaining({ id: 'rem-1' })
);
});

it('does not open the editor for a non-reminder', () => {
makeEditReminderAction().execute([entity('document')]);

expect(mocks.openReminderEditor).not.toHaveBeenCalled();
});

it('cannot run when the reminders flag is off', () => {
mocks.remindersEnabled = false;

expect(makeEditReminderAction().canExecute(reminder())).toBe(false);
});

// execute re-checks the gate, so a command-menu entry left over from before
// the flag closed cannot still open the editor.
it('does not open the editor when the reminders flag is off', () => {
mocks.remindersEnabled = false;

makeEditReminderAction().execute([reminder()]);

expect(mocks.openReminderEditor).not.toHaveBeenCalled();
});

// The soup context menu and soup command menu both drive actions through
// executeWithSoup, so the action is unreachable from a list without it.
it('opens the editor when driven from a soup list', async () => {
await makeEditReminderAction().executeWithSoup(
[reminder()],
{} as Parameters<
ReturnType<typeof makeEditReminderAction>['executeWithSoup']
>[1]
);

expect(mocks.openReminderEditor).toHaveBeenCalledWith(
expect.objectContaining({ id: 'rem-1' })
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { openReminderEditor } from '@app/features/reminders/reminder-composer';
import { ENABLE_REMINDERS } from '@core/constant/featureFlags';
import type { EntityData } from '@entity';
import type { SoupState } from '../create-soup-state';

/**
* Edit an existing reminder — its description, its time, or both.
*
* `execute` opens the composer prefilled rather than writing anything: both
* answers come from the user, so there is nothing to do until that modal
* resolves. Single-entity only, like creating one.
*
* Recurring reminders are excluded. The composer only speaks one-shot
* schedules, so editing one through it would quietly turn a cron into a single
* firing. Nothing in the product creates a recurring reminder today, so this
* excludes nothing a user can actually reach.
*/
export const makeEditReminderAction = () => {
const canExecute = (entity: EntityData): boolean =>
ENABLE_REMINDERS() &&
entity.type === 'reminder' &&
entity.scheduleType === 'once';

const execute = (entities: EntityData[]) => {
const [entity] = entities;
// Re-checked rather than assumed: a stale command-menu entry could
// otherwise still fire against a row that has since changed.
if (!entity || entity.type !== 'reminder' || !canExecute(entity)) return;

openReminderEditor({
id: entity.id,
description: entity.description,
remindAt: new Date(entity.nextRunAt),
completed: entity.completedAt != null,
});
};

const executeWithSoup = async (entities: EntityData[], _soup: SoupState) => {
// Opening the composer doesn't change the list, so selection and focus are
// left where they are.
execute(entities);
};

return { canExecute, execute, executeWithSoup };
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
makeCopyLinkAction,
makeCreateReminderAction,
makeDeleteAction,
makeEditReminderAction,
makeFavoriteAction,
makeMarkDoneAction,
makeMarkNotDoneAction,
Expand Down Expand Up @@ -88,6 +89,7 @@ export const useEntityActionHotkeys = (

const copyEntityIdAction = makeCopyEntityIdAction();
const createReminderAction = makeCreateReminderAction();
const editReminderAction = makeEditReminderAction();

const shareAction = makeShareAction();

Expand Down Expand Up @@ -302,25 +304,45 @@ export const useEntityActionHotkeys = (
tags: [HotkeyTags.SelectionModification],
}).withGroup(group);

// Rename - 'r'
/**
* Whether 'r' should open the reminder editor rather than rename.
*
* A reminder is never renamable — its name is its description, owned by the
* reminders API — so the two are mutually exclusive and can share the key
* instead of leaving 'r' dead on a reminder row.
*/
const editsReminder = (): boolean => {
const entities = getEntitiesForAction();
return entities.length === 1 && editReminderAction.canExecute(entities[0]);
};

// Rename - 'r'. Edits the reminder instead when the row is one.
registerHotkey({
hotkey: ['r'],
hotkeyToken: TOKENS.entity.action.rename,
scopeId,
description: () => {
if (editsReminder()) return 'Edit reminder';
const count = getEntitiesForAction().length;
return count > 1 ? 'Rename items' : 'Rename item';
},
keyDownHandler: () => {
const entities = getEntitiesForAction();
if (entities.length === 0) return false;

if (editsReminder()) {
editReminderAction.executeWithSoup(entities, soup);
return true;
}

if (!entities.every(renameAction.canExecute)) return false;

renameAction.executeWithSoup(entities, soup);
return true;
},
condition: () => {
if (condition && !condition()) return false;
if (editsReminder()) return true;
const entities = getEntitiesForAction();
return entities.length > 0 && entities.every(renameAction.canExecute);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
makeCopyLinkAction,
makeCreateReminderAction,
makeDeleteAction,
makeEditReminderAction,
makeFavoriteAction,
makeHideCompanyAction,
makeMarkDoneAction,
Expand Down Expand Up @@ -126,6 +127,7 @@ export function createSoupEntityActions(): {
const copyBranchNameAction = makeCopyBranchNameAction();
const copyEntityIdAction = makeCopyEntityIdAction();
const createReminderAction = makeCreateReminderAction();
const editReminderAction = makeEditReminderAction();
const shareAction = makeShareAction();
const blockSenderAction = makeBlockSenderAction();
const markSenderSignalAction = makeMarkSenderSignalAction();
Expand Down Expand Up @@ -291,6 +293,18 @@ export function createSoupEntityActions(): {
});
}

// A reminder never offers Rename — its name is its description, which the
// reminders API owns — so this takes that slot, and the same 'r' key.
// Single-entity only: the editor asks about one reminder's time.
if (entities.length === 1 && editReminderAction.canExecute(entities[0])) {
middleItems.push({
id: 'edit-reminder',
label: 'Edit reminder',
hotkeyToken: TOKENS.entity.action.rename,
onClick: handle(editReminderAction.executeWithSoup),
});
}
Comment on lines +296 to +306

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the rename action eligibility rules for reminder entities.
fd -t f 'make-rename-action.ts' --exec cat -n {}

Repository: macro-inc/macro

Length of output: 2467


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="apps/web/src/features/next-soup/soup-view/create-soup-entity-actions.ts"
echo "### file exists and relevant line range"
wc -l "$file"
sed -n '260,315p' "$file" | nl -ba -v260

echo
echo "### related action definitions/usages in file"
rg -n "renameAction|editReminderAction|create-soup-action|canExecute|executeWithSoup|action" "$file"

Repository: macro-inc/macro

Length of output: 309


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="apps/web/src/features/next-soup/soup-view/create-soup-entity-actions.ts"
echo "### relevant line range"
sed -n '260,315p' "$file" | awk '{printf "%5d\t%s\n", NR+259, $0}'

echo
echo "### related action definitions/usages in file"
rg -n "renameAction|editReminderAction|create-soup-action|canExecute|executeWithSoup|action" "$file"

echo
echo "### imports/top"
sed -n '1,80p' "$file" | awk '{printf "%5d\t%s\n", NR, $0}'

Repository: macro-inc/macro

Length of output: 9523


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "### locate action files by name"
fd -t f 'make-.*action\.ts|actions' apps/web/src/features/next-soup | sort

echo
echo "### rename action import target"
rg -n "export const makeRenameAction|makeRenameAction" apps/web/src/features/next-soup -g '*.ts'

echo
echo "### edit reminder action"
rg -n "export const makeEditReminderAction|makeEditReminderAction|canExecute" apps/web/src/features/next-soup -g '*.ts' -A8 -B8 | head -220

echo
echo "### hotkey mapping around action rename/edit reminder"
rg -n "TOKENS\.entity\.action\.rename|registerHotkey|entity/action/rename|editReminder|reminder" apps/web/src/features apps/web/src/components apps/web/src -g '*.ts' -g '*.tsx' -A10 -B10 | head -300

Repository: macro-inc/macro

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "### make-edit-reminder-action.ts"
sed -n '1,180p' apps/web/src/features/next-soup/actions/make-edit-reminder-action.ts | awk '{printf "%5d\t%s\n", NR, $0}'

echo
echo "### use-entity-action-hotkeys.ts rename/reminder handling"
rg -n "TOKENS\.entity\.action\.rename|renameAction|editReminderAction|remindersActive|reminder" apps/web/src/features/next-soup/actions/use-entity-action-hotkeys.ts -A10 - B10

echo
echo "### simple rename eligibility model"
python3 - <<'PY'
types = ["reminder", "document", "email", "channel", "channel_message", "foreign", "project"]
owned = True
for t in types:
    result = False if t in {"email", "channel_message", "channel_thread", "foreign"} else owned
    if t == "channel":
        result = False
    print(f"{t}: {result}")
PY

Repository: macro-inc/macro

Length of output: 11592


Make Rename and Edit reminder mutually exclusive in the menu

makeRenameAction.canExecute() still returns true for reminder entities, so the Rename item can appear alongside Edit reminder and both use TOKENS.entity.action.rename. Add entity.type === 'reminder' to the rename eligibility or another exclusion that keeps reminder rows from showing the Rename action.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/features/next-soup/soup-view/create-soup-entity-actions.ts`
around lines 296 - 306, Update the Rename action eligibility, specifically the
logic used by makeRenameAction.canExecute(), to exclude entities whose type is
'reminder'. Preserve Rename for all other eligible entity types so reminder rows
show only the existing Edit reminder action.


if (canExecuteAll(favoriteAction.canExecute)) {
const allFavorited = entities.every((entity) =>
favoriteAction.isFavorited(entity)
Expand Down
Loading
Loading