Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e611f5d
docs(web): define frontend feature architecture and skill
synoet Sep 5, 2026
f223ba2
refactor(web): separate email thread, message, and compose features
synoet Sep 5, 2026
7fe4ac7
refactor(web): isolate email body rendering from Solid
synoet Sep 5, 2026
6a319a6
fix(web): restore email compatibility after extraction
synoet Sep 6, 2026
54c34e3
refactor(web): split email compose and thread responsibilities
synoet Sep 6, 2026
0bf23f3
fix(web): verify email workflows with seeded Chrome coverage
synoet Sep 6, 2026
7232618
fix(web): hand focus back to email thread arrow navigation
synoet Sep 6, 2026
5a0baf9
test(web): publish audited email verification recording tools
synoet Sep 6, 2026
d94ed80
test(web): prune email audit scaffolding and redundant coverage
synoet Sep 7, 2026
56cad16
docs(web): remove frontend feature skill pending revision
synoet Sep 7, 2026
7c33df2
refactor(web): untangle email composition and thread capabilities
synoet Sep 7, 2026
7146587
fix(ci): refresh Darwin dependencies and check package sources
synoet Sep 7, 2026
e4cf5c1
fix(web): use explicit email send workflows and preserve write outcomes
synoet Sep 8, 2026
c0729f7
refactor(web): give email controllers narrow capabilities
synoet Sep 8, 2026
7b8c4b2
refactor(web): project email transport data into focused feature models
synoet Sep 8, 2026
197f405
refactor(web): align email capability names and provider ownership
synoet Sep 8, 2026
cf1b8b4
Merge main into synoet/email-restructure
synoet Sep 8, 2026
86e8ef7
fix(nix): use native Darwin hash for merged dependencies
synoet Sep 8, 2026
5fdee46
cleanup
synoet Sep 9, 2026
7144948
more cleanup
synoet Sep 9, 2026
60f47c2
fix
synoet Sep 9, 2026
d007980
Merge main into synoet/email-restructure
synoet Sep 9, 2026
8e9093a
remove useless tests
synoet Sep 9, 2026
45ae6a2
Merge main into synoet/email-restructure
synoet Sep 9, 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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ This file is the shared entry point: `AGENTS.md` symlinks to `CLAUDE.md`. Edit
| --- | --- |
| Rust code, builds, or tests | [Rust development](docs/RUST_DEVELOPMENT.md) |
| SQLx queries, migrations, DB tests, or cache errors | [Database development](docs/DATABASE_DEVELOPMENT.md) |
| Web frontend or email-rendering snapshots | [Web agent guide](apps/web/AGENTS.md) |
| Web frontend | [Web agent guide](apps/web/AGENTS.md) |
| Email body rendering or snapshots | [Standalone renderer](packages/email-renderer/README.md) |
| Running the frontend or backend on a local machine | [Running locally](docs/RUNNING_LOCALLY.md) |
| Working inside Cursor Cloud | [Cursor Cloud](docs/CURSOR_CLOUD.md) |
| Driving the app through a browser | [App agent guide](docs/AGENT_GUIDE/README.md) |
Expand Down
4 changes: 2 additions & 2 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- `bun run lint`: lint with biome
- `bun run format`: format changes with biome
- `bun run knip`: to check for dead code
- Email rendering snapshots (Playwright HTML fixtures, not inbox e2e) live in `src/lib/core/email/tests`. Run `just test-email-rendering`. Add a fixture under `fixtures/` then `just test-email-rendering-update`.
- Email rendering is isolated in `packages/email-renderer` at the repository root. Run `just test-email-rendering` for its Node and Chromium suites. Add fixtures under `packages/email-renderer/tests/fixtures`, run `just test-email-rendering-update`, and review changed images. These are renderer tests, not inbox e2e.

## Verifying a change in a real browser

Expand Down Expand Up @@ -79,7 +79,7 @@ Then trigger the interaction and read `window.__inst.log`. `'1,2,3' → '' → '
- Keep reusable components small, atomic, and decoupled from queries/complex state. Push data-fetching and mutations up to use-case-specific composed components.
- Context should be scoped to a component subtree — Message.Content consuming a MessageContext is fine because the ownership boundary is clear.
- Composed primitives must not depend on use-case-specific context — a RecipientsSelector should never require an EmailComposeContext.
- New features use the layered layout in docs/STYLE_GUIDE.md FE-33 (`core / queries / primitives / components / views` plus an injected `context/`). `src/features/activity` is the reference.
- New features and feature restructures use the layered layout in [docs/FRONTEND_FEATURE_ARCHITECTURE.md](../../docs/FRONTEND_FEATURE_ARCHITECTURE.md), summarized by FE-33 (`core / queries / primitives / components / views` plus an injected `context/`). Keep production wiring in an app-facing entry point and give reactive logic narrow feature-owned contracts. `src/features/activity` illustrates the layers but still has documented composition and contract migration gaps.

## Styling
- Use semantic color tokens, not raw Tailwind color classes.
Expand Down
9 changes: 5 additions & 4 deletions apps/web/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,11 @@ test-watch:
bunx --bun vitest

test-email-rendering:
cd src/lib/core/email/tests && bunx playwright test
bun run --cwd ../../packages/email-renderer test
bun run --cwd ../../packages/email-renderer test:browser

test-email-rendering-update:
cd src/lib/core/email/tests && bunx playwright test --update-snapshots
bun run --cwd ../../packages/email-renderer test:browser --update-snapshots

preview-prod:
MODE=production NODE_ENV=production bun run preview:prod
Expand Down Expand Up @@ -165,10 +166,10 @@ fix-lock:

# Analysis
cycles:
bunx --bun biome lint --only=nursery/noImportCycles
bunx --bun biome lint --only=suspicious/noImportCycles

cycles-ci:
bunx --bun biome lint --changed --only=nursery/noImportCycles
bunx --bun biome lint --changed --only=suspicious/noImportCycles

# Build the GraphQL cache wasm module into src/lib/graphql-cache/wasm
# (gitignored). Unconditional — CI app builds must never skip it (schema or
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"knip": "bunx --bun knip"
},
"dependencies": {
"@macro-inc/email-renderer": "workspace:*",
"@aws-crypto/sha256-js": "^5.2.0",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/app/mailtoComposerHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { parseMailto } from '@app/features/email-compose/core/mailto';
import { globalSplitManager } from '@app/signal/splitLayout';
import { parseMailto } from '@block-email/util/mailto';
import { registerExternalUrlInterceptor } from '@core/util/url';

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ComposeAgentSession } from '@app/features/block-agent/component/Compose
import type { EventEditorInitialValues } from '@app/features/calendar/components/composer/event-form-model';
import type { CalendarEvent } from '@app/features/calendar/types';
import { ChannelsView } from '@app/features/channels-view/channels-view';
import { EmailCompose } from '@app/features/email-compose/email-compose';
import { EmailView } from '@app/features/email-view/email-view';
import { GettingStarted } from '@app/features/getting-started';
import { Home } from '@app/features/home';
Expand All @@ -24,7 +25,6 @@ import { useFeatureFlag, usePosthog } from '@app/lib/analytics/posthog';
import { globalSplitManager } from '@app/signal/splitLayout';
import { EventComposerSplit } from '@block-calendar/components/EventComposerSplit';
import { ChannelCompose } from '@block-channel/component/Compose';
import { EmailCompose } from '@block-email/component/compose/Compose';
import { ComposeSkill } from '@block-md/component/ComposeSkill';
import { ComposeTask } from '@block-md/component/ComposeTask';
import {
Expand Down Expand Up @@ -691,7 +691,7 @@ registerComponent('email-compose', (params) => {
.filter(Boolean);
const draftID =
typeof params.draftID === 'string' ? params.draftID : undefined;
return <EmailCompose draftID={draftID} initialTo={initialTo} />;
return <EmailCompose draftId={draftID} initialTo={initialTo} />;
});
registerComponent('task-compose', (params) => {
usePageViewTracking('task-compose');
Expand Down
156 changes: 156 additions & 0 deletions apps/web/src/features/block-email/EmailBlockAdapter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { AskMacroButton } from '@app/features/chat/ChatWithAgentButton';
import { useEmailThreadState } from '@app/features/email-thread/context/email-thread-state-context';
import { URL_PARAMS } from '@app/features/email-thread/core/location';
import { EmailThread } from '@app/features/email-thread/email-thread';
import { SidePanel } from '@components/app/side-panel';
import { useSplitLayout } from '@components/app/split-layout/layout';
import {
useCanAutofocusSplitContent,
useSplitPanel,
} from '@components/app/split-layout/layoutUtils';
import { TOKENS } from '@core/hotkey/tokens';
import { registerScopeSignalHotkey } from '@core/hotkey/utils';
import { isTouchDevice } from '@core/mobile/isTouchDevice';
import { createMethodRegistration } from '@core/orchestrator';
import {
blockElementSignal,
blockHotkeyScopeSignal,
} from '@core/signal/blockElement';
import { blockHandleSignal } from '@core/signal/load';
import { buildMentionMarkdownString } from '@macro-inc/lexical-core';
import { useSearchParams } from '@solidjs/router';
import {
type Accessor,
createEffect,
createSignal,
onCleanup,
Show,
} from 'solid-js';
import { EmailTaskButton } from './component/EmailTaskButton';
import { ModalsProvider } from './component/ModalsProvider';
import { EmailSidePanelSections } from './component/sidepanel/EmailSidePanelSections';
import { TopBar } from './component/TopBar';
import { registerEmailHotkeys } from './util/emailHotkeys';

export function EmailBlockAdapter(props: {
title: string;
threadId: Accessor<string>;
}) {
const [params] = useSearchParams();
const rawTarget = params[URL_PARAMS.messageId];
const [targetMessageId, setTargetMessageId] = createSignal(
Array.isArray(rawTarget) ? rawTarget[0] : rawTarget
);
const split = useSplitPanel();
const canAutofocus = useCanAutofocusSplitContent();
const { popoverSplit } = useSplitLayout();
const blockElement = blockElementSignal.get;
const hotkeyScope = blockHotkeyScopeSignal.get;
const focusContainer = () => blockElement()?.focus({ preventScroll: true });
let targetTimer: ReturnType<typeof setTimeout> | undefined;
createMethodRegistration(blockHandleSignal.get, {
goToLocationFromParams: (params: Record<string, unknown>) => {
const id = params[URL_PARAMS.messageId];
if (typeof id !== 'string' || !id) return;
clearTimeout(targetTimer);
setTargetMessageId(undefined);
targetTimer = setTimeout(() => setTargetMessageId(id), 0);
},
});
onCleanup(() => clearTimeout(targetTimer));
let focused = false;
createEffect(() => {
if (focused || !canAutofocus || isTouchDevice() || !blockElement()) return;
focusContainer();
focused = true;
});
const createTask = () =>
popoverSplit({
type: 'component',
id: 'task-compose',
params: {
initialTitle:
props.title.length > 70
? `${props.title.slice(0, 70)}...`
: props.title,
initialContent: buildMentionMarkdownString({
type: 'document',
documentId: props.threadId(),
documentName: props.title,
blockName: 'email',
}),
},
});
return (
<EmailThread
title={props.title}
threadId={props.threadId}
host={{
targetMessageId,
focusContainer,
isActive: () => split?.isPanelActive() !== false,
registerKeyboard: (handlers) => {
registerEmailHotkeys(hotkeyScope(), handlers);
registerScopeSignalHotkey(hotkeyScope, {
hotkey: 'enter',
description: 'Reply to message',
keyDownHandler: handlers.activate,
hotkeyToken: TOKENS.block.focus,
hide: true,
});
registerScopeSignalHotkey(hotkeyScope, {
hotkey: 'escape',
description: 'Collapse or unselect message',
keyDownHandler: handlers.cancel,
hotkeyToken: TOKENS.email.cancelReply,
hide: true,
});
},
}}
header={
<TopBar
id={props.threadId()}
title={props.title}
onCreateTask={createTask}
/>
}
actions={<ThreadActions title={props.title} onCreateTask={createTask} />}
frame={(content) => (
<ModalsProvider subject={props.title}>
<SidePanel.Layout>
{content()}
<EmailSidePanelSections
threadId={props.threadId()}
title={props.title}
/>
</SidePanel.Layout>
</ModalsProvider>
)}
/>
);
}

function ThreadActions(props: { title: string; onCreateTask: () => void }) {
const context = useEmailThreadState();
return (
<SidePanel.Section
id="email-ai-actions"
title="Actions"
defaultOpen
order={0}
>
<div class="m-px flex items-center justify-start gap-2">
<Show when={context.thread()?.db_id}>
{(id) => (
<AskMacroButton
entity={{ type: 'email', id: id(), name: props.title }}
/>
)}
</Show>
<Show when={context.thread()?.db_id}>
<EmailTaskButton onClick={props.onCreateTask} />
</Show>
</div>
</SidePanel.Section>
);
}
Loading
Loading