Skip to content

feat(messages): add Slack-style scheduled messages - #3239

Open
sqnico wants to merge 4 commits into
mainfrom
codex/slack-style-scheduled-messages
Open

sqnico wants to merge 4 commits into
mainfrom
codex/slack-style-scheduled-messages

Conversation

@sqnico

@sqnico sqnico commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Why

People need to write a message now and deliver it later, including while Buzz is closed. The modern app has no composer scheduling flow or scheduled queue in Drafts & sent.

What

  • Add a split Send button with Later today, Tomorrow, Monday, and Custom time choices, up to 120 days ahead.
  • Add a calendar and editable time picker, an explicit time zone, and a confirmation with a link to scheduled messages. The draft clears only after durable acceptance.
  • Put Scheduled inside the existing Drafts & sent page. Add edit, reschedule, send-now, save-to-drafts, and delete actions, plus a clock/count in the sidebar.
  • Give Drafts, Scheduled, and Sent their own search labels and retained search text. Search matches message text and destination within the selected tab.

How

The existing message builder prepares formatting, mentions, uploaded attachments, and thread references. The host signs the exact future event and uses one fixed authenticated relay endpoint. A session-owned service verifies queued signatures and preserves revisions and retry IDs.

The sidebar and page share one queue read, subscription, and timer. Scheduled revalidates on entry and shares a jittered 15-minute backstop, including when the queue is empty. Overdue messages and failed reads retry with bounded backoff. The UI reuses Dialog, Calendar, Combobox, Menu, SearchField, and the split-button recipe.

The first commit adds the service and host boundary; the second adds the composer and management UI. No standalone scheduling plugin is needed.

Risk and limitations

  • Requires the companion relay change and its default-off capability. Older relays keep ordinary sending behavior. Enable the bundled Drafts & sent plugin to manage the queue.
  • Messages containing composer chips cannot be scheduled yet. The schedule is refused with the draft intact; ordinary sending still signs the chip metadata.
  • Drafts remain local to this device. Cross-device draft sync and recurring schedules are not included.
  • Draft conversion supports text, formatting, mentions, and root-thread replies. It refuses attachments, nested reply parents, media targets, explicit model/effort settings, and unaddressed slash commands. The original stays scheduled when conversion is refused.
  • Text edits are read-only for queued messages with mention recipients. Save supported messages to drafts to edit recipients in the full composer. Reschedule and send-now remain available; destination and attachment bindings stay fixed.
  • Attachment uploads must finish before scheduling. New uploads were not tested against object storage in the isolated manual environment.
  • Server retries can deliver late; the message keeps its selected signed timestamp. No production deployment or activation is included.

Testing

The running modified macOS app was exercised against an isolated local HTTPS relay during UI iteration. The feature flow was checked in Chromium and WebKit. The branch is rebased onto main at df88b83, retaining signed composer-chip metadata, draft diagnostics, and annotators. The chip-send regression now also covers a refused schedule followed by an ordinary send; the original signing assertions are retained. The author confirmed successful testing on November 10, 2026, before the review fixes below. Those fixes need a new human acceptance pass.

  • pnpm exec playwright test --config tests/browser/playwright.config.mjs scheduled-messages.spec.mjs drafts-in-progress.spec.mjs --project chromium --project webkit --no-deps: representative flows pass in both engines, including refusal/retry, draft preservation/reload, cross-year layout at 100%/150%, Finnish time selection, queue edits, rescheduling, search, and save-to-drafts. Later focused reruns checked the locale fix and visible menu capture.

  • BUZZ_SCHEDULED_TEST_RELAY=<isolated loopback relay> pnpm exec vitest run src/features/relay/scheduled-messages.live.test.ts: passed with verified local HTTPS; delivery completed after the scheduling session closed.

  • An existing team-mentions status test now controls its directory-search debounce instead of racing a real timer under suite load. Assertions and timeouts are unchanged.

  • Focused coverage checks signature/owner boundaries, stable retries, stale revisions, newer composer drafts, delayed queue refresh, tab isolation, and refusal of unsafe draft conversion. All mandatory pre-commit and pre-push checks passed without bypasses, including the related test suite, design guards, and native Clippy.

  • Browser cases added: 5 scenarios per engine. They prove real picker focus/input behavior, rendered geometry, and composer/page wiring. State matrices stay in unit tests. A locale parsing probe reproduced the English-parser failure; canonical-time picker selections now pass in both engines; existing browser cases were retained.

  • Review fixes: the signed HTTPS relay test now also passes exact create retries after delivery and send-now retries without a second revision. Independent review of the fixes found no remaining blockers. The existing 10 browser checks passed in Chromium and WebKit.

  • Post-rebase validation passed: mandatory TypeScript/related-unit, design-system, and native Clippy push gates; the existing scheduling/draft journeys also passed all 10 checks across Chromium and WebKit.

Screenshots use fictional local fixtures and the actual shared UI components.

Schedule a message

Schedule message dialog

Manage scheduled messages

Scheduled tab and search

Scheduled message actions

Bigger picture

Companion relay: block/buzz#9335. Deploy its migration and reservation-aware probes with scheduling off before enabling delivery. This PR uses a branch pushed directly to the upstream repository. Human acceptance of the review fixes and current hosted CI remain pending.

Replaces the fork-based app PR #3207. Review fixes address queue revalidation and safe mention editing. The original review discussion remains available there.

Generated with Codex

@sqnico
sqnico marked this pull request as ready for review November 10, 2026 21:54
@sqnico
sqnico requested a review from a team as a code owner November 10, 2026 21:54
sqnico and others added 4 commits November 12, 2026 13:23
Expose signed, authenticated relay scheduling through the native and browser host boundaries. Preserve exact message bindings and stable retries within the active session.

Co-authored-by: Codex <[email protected]>
Signed-off-by: sqnico <[email protected]>
Schedule from the composer and manage the queue in Drafts & sent. Keep searches scoped to each tab and preserve local drafts through failures and navigation.

Co-authored-by: Codex <[email protected]>
Signed-off-by: sqnico <[email protected]>
Refresh remote schedules when management opens and preserve mention recipients through safe editing. Reuse uncertain send-now requests and renew them after a definite rejection.

Co-authored-by: Codex <[email protected]>
Signed-off-by: sqnico <[email protected]>
Preserve composer chips when scheduled delivery cannot encode their metadata. Keep the draft intact after refusal and verify that an ordinary send still signs the chips.

Co-authored-by: Codex <[email protected]>
Signed-off-by: sqnico <[email protected]>
@sqnico
sqnico force-pushed the codex/slack-style-scheduled-messages branch from aa81f6c to 7c1b002 Compare November 12, 2026 18:39
@kazemek21

Copy link
Copy Markdown
Contributor

Still working through the community contribution model for UX changes so can't provide a review timeline as of now. Additional context can be found here:
buzz://message?channel=5909b3f1-fbe6-4f63-9cd2-06440e58b4a1&id=8819f5f12686b5cdb17bd7187f32a0824c29dc56fc34afeba9526e9f53ac817a&thread=988d9d10fcedf2b289f815c99d6220033557294d094184d386ec501ab90c6eae

@joahg joahg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Comment only. This account isn't approving UI changes, so a design or core-team reviewer is needed. The maintainer note on this PR, that the UX contribution model is still being worked out, also applies. No P0/P1 found; worst is P2.

FOUNDATION: src/features/relay/session.ts has the marker ("One relay session owns reads, local intent, delivery and shared views"). I'm asking a maintainer to sign off on that file. The change moves the createMessages(...) call into messageCommands ahead of the session object and adds scheduledMessages. I checked that every binding it captures is declared above the new position, so there's no TDZ risk.

Relay without scheduling: Solid. buzz-scheduled-messages-v1 is read from NIP-11 supported_extensions in both the native and broker transports, matching what block/buzz#9335 advertises. When it's absent, session.scheduledMessages is undefined, so the composer shows the plain Send button and there's no Scheduled tab. The Rust allowlist only adds POST /scheduled-messages. Support is read once at connect, so a relay that toggles the flag needs a reconnect. P3.

Signing at schedule time: Yes. The client signs the exact kind-9 event with created_at = sendAt (future) and the relay stores it. Content and the p, e, imeta and emoji tags are therefore frozen. The client checks write access only at schedule time. Per the relay PR, membership and ingest checks re-run at delivery, so a removed member's message should land as failed rather than post. A late delivery keeps its past timestamp. Edits re-sign client-side, so mention recipients can't change (the UI makes those read-only) and a text edit can leave emoji tags stale. P3.

Edit and cancel:

  • Revision checks, send-now re-signing on a 400, and save-to-drafts (local save first, then cancel, with a clear message if cancel is unconfirmed) are handled well.
  • Editing text on a row whose time has passed fails with "Choose a future time within 120 days." (inline). P2.
  • A lost create response followed by an identical new schedule reuses the old UUID and dedupes. Rare, P3.

Timezone and DST: All local-time via Date. parseScheduleTime round-trips, so a nonexistent spring-forward time is rejected rather than shifted, but the message is the generic future-time error. The ambiguous fall-back hour silently picks the first occurrence. Tests only use Nov 10, with no DST fixtures. P3.

Other: The list verifies signatures and viewer ownership, renders content and error as text, and caps responses. The client's 356 matches the relay's 256+100. The relay's P2s (bad row aborting the sweep, internal errors retrying forever, rows kept forever) are relay-side. The client surfaces failed rows but gives no per-row reason beyond relay text. The Drafts & sent plugin is opt-in, so someone can schedule a message without a way to manage it. At 3.8k lines across 38 files, the scope checkpoint and diff budget in AGENTS.md may warrant splitting service and host from UI.

CI is green apart from the skipped Windows job. I ran nothing locally. Not reviewed: the Rust native signing path beyond the allowlist, browser-host broker tests, CSS and accessibility, GIF and chat-card handling in the scheduled path, and the full composer diff beyond the send path.

if (connected && !liveWasConnected) recovery?.retryNow(liveRecovery);
liveWasConnected = connected;
});
const messageCommands = createMessages(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 FOUNDATION file (session.ts, line 3): needs a maintainer's sign-off. The change moves createMessages(...) ahead of session and adds scheduledMessages; I found no TDZ problem.

);
// Preserve attachment and thread bindings. Mentioned messages may only
// change time here; the full composer owns recipient editing.
const event = await signed(input, {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Editing text on a row whose time has passed (failed, overdue, or due while the dialog is open) fails here: signed() requires sendAt > now, so the user sees "Choose a future time within 120 days." for a text edit. Consider a clearer message or prompting for a new time.

type Template = Pick<EventTemplate, "kind" | "content" | "tags">;
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

class ScheduledRequestError extends Error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Every non-409 status (400, 403, 404, 5xx) becomes "Could not reach scheduled delivery", so someone who lost access can't tell that from an outage. Consider mapping 4xx to the relay's reason.

matt2e added a commit that referenced this pull request Nov 14, 2026
…es (#3442)

## Why

Two browser journeys fail intermittently in CI for the same underlying
reason. Both jump the page clock far enough to expire the live stream's
heartbeat, and the stream restart that follows was not accounted for.

| Test | Sightings |
|---|---|
| `agent-outcomes.spec.mjs`, "a remote failure shares its expandable
history and preserves its message anchor" | WebKit 1/18, Browser
journeys: [10 Nov,
main](https://github.com/block/buzz-app/actions/runs/42923245792/job/130206998334),
[10 Nov,
PR](https://github.com/block/buzz-app/actions/runs/42920092542/job/130193154955),
[11 Nov,
PR](https://github.com/block/buzz-app/actions/runs/43110752944/job/130826317616),
[11 Nov,
main](https://github.com/block/buzz-app/actions/runs/42952461094/job/130300041605)
|
| `relay-recovery.spec.mjs`, "relay reads recover automatically after
the network returns" | WebKit 5/18, Browser journeys: [10 Nov, PR,
passed on
re-run](https://github.com/block/buzz-app/actions/runs/42918607668/job/130187892464)
|

## Root cause

The live broker stream arms a 45 s heartbeat on the page clock. The
broker's 15 s keepalive runs on real time. A page-clock jump longer than
45 s therefore always expires the heartbeat, and the page aborts and
restarts its stream. `presence.spec.mjs` already records this hazard.

**`agent-outcomes`** pauses the page clock, freezes wall time, and runs
120 s of browser time. Two failures follow from the restart:

- In three of the four jobs the test published its signed replies before
the restarted stream had re-issued a channel REQ. The policy relay
rejected the publish with "event must traverse an explicit production
channel REQ". In each trace the publish came about 0.9 s after the clock
resumed, and the new stream opened 0.2 to 0.4 s later.
- In the fourth job (11 Nov, main) the 120 s jump took 85.6 s of real
time instead of the usual 4 to 12 s. The still-frozen wall clock drifted
more than 60 s from the broker, so read-state signing answered 400.

**`relay-recovery`** fast-forwards the page clock 30 s twice. The second
jump expires the heartbeat. In the same jump, session recovery's
scheduled retry posts `/stream-retry` with the old stream id. The broker
has already handled the stream close and answers 404 "Live stream no
longer available". The app drops the error for a superseded generation,
but the browser still logs "Failed to load resource … 404". The fixture
only excused retired-stream 404s for `/stream-observer` and
`/stream-priority`, so the unexplained-console-error check failed the
test. The trace shows the fast-forward, the 404 ten milliseconds later,
and the new stream 0.7 s after that.

Neither is a product defect. The restart only happens under a fake-clock
jump, and the app already ignores the late control response.

## Fix

- **`agent-outcomes.spec.mjs`**: re-anchor the page wall clock to real
time right after resuming, so signed read-state events follow the
broker's clock again. Then poll for the primary/alpha route before
publishing, mirroring the readiness wait the test already does at
startup.
- **`fixture.mjs`**: widen the retired-stream 404 match to the six
broker control routes that answer 404 once a stream is retired:
observer, priority, retry, interests, artifact-sync and
artifact-sync-close. The exact `retiredStreams.has(streamId)` guard
stays, so a 404 for a live or unknown stream still fails. Presence
controls answer 200 for a missing stream and stay out of the list.

No timeouts, retries or assertions were relaxed. No browser cases were
added or removed.

## Evidence

Local runs on Linux, WebKit, `bin/pnpm test:browser:ci --project webkit
--no-deps --workers=1 --repeat-each=8 --reporter=list`.

| Scope | Result |
|---|---|
| `agent-outcomes.spec.mjs -g "a remote failure shares"` | 8/8 passed,
17.8 to 20.2 s each |
| `relay-recovery.spec.mjs relay-refusal.spec.mjs -g "relay reads
recover automatically\|an upload is the first request"` | 16/16 passed |

## Not reproduced, not run

- The late `/stream-retry` 404 did not reproduce locally. No retired
controls were recorded in the local evidence, so the widened fixture
branch is only exercised under CI timing. The Flake check lane on this
PR is the real test of it.
- The unchanged `agent-outcomes` test also passed 8/8 locally, with two
or three stream restarts during each jump. The local run guards against
regression rather than reproducing the CI ordering.
- The read-state re-anchor addresses a failure seen once, when the jump
ran unusually slowly. That slowness was not reproduced.
- Chromium was not run for these two changes.
- No human has tested this yet, so `buzz-review-completed` is not set.

## Overlap with open PRs

Several open PRs also edit `tests/browser/fixture.mjs` (for example
#3332, #3239, #3186 and #3093). None touches the retired-stream match,
and a trial merge of each against this change is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: mtoohey <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants