Skip to content

Comments

feat: real-time channel list updates via WebSocket#1449

Merged
btucker merged 3 commits intomainfrom
feat/channel-list-ws-event
Feb 24, 2026
Merged

feat: real-time channel list updates via WebSocket#1449
btucker merged 3 commits intomainfrom
feat/channel-list-ws-event

Conversation

@btucker
Copy link
Owner

@btucker btucker commented Feb 23, 2026

Summary

  • Adds a channel_list_changed WebSocket event broadcast on channel create/archive/unarchive/rename, so web clients update their sidebar immediately instead of requiring a page refresh
  • Web app handles the new event by re-fetching the full channel list (respects the archived channels toggle)
  • TUI refreshes its channel list immediately after /channel create

Test plan

  • cargo build — clean compile
  • cargo clippy --all-targets --all-features -- -D warnings — no warnings
  • cargo test — all tests pass
  • Manual: start daemon + web app, create a channel via CLI (midtown channel create test-ch), verify it appears in web sidebar without refresh
  • Manual: archive a channel via CLI, verify it disappears from web sidebar
  • Manual: unarchive a channel via CLI, verify it reappears in web sidebar
  • Manual: create a channel via web UI, verify it appears immediately

🤖 Generated with Claude Code

Channel create/archive/unarchive/rename now broadcast a
`channel_list_changed` WebSocket event so web clients update their
sidebar immediately instead of requiring a page refresh.

Also refreshes the TUI channel list after `/channel create`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@btucker
Copy link
Owner Author

btucker commented Feb 23, 2026

Code review

Found 4 issues:

  1. TUI /channel create calls Channel::new directly, bypassing the daemon RPC handler, so web clients never receive the channel_list_changed broadcast when channels are created from the TUI. The PR correctly adds refresh_available_channels() to update the TUI sidebar, but WebSocket clients are left out of sync.

// Create the channel (this is idempotent - Channel::new creates the file if it doesn't exist)
match midtown::Channel::new(&base_dir, channel_name) {
Ok(_) => {
// Switch to the newly created channel and load its messages
self.selected_channel = channel_name.to_string();
self.load_channel_messages();
self.refresh_available_channels();
true
}
Err(_) => false,

Test suggestion: Integration test that creates a channel via TUI's create_channel(), then asserts a channel_list_changed event was received on the WebSocket broadcast channel.

  1. effects.rs channel mutation paths (Effect::CreateChannel, Effect::ArchiveChannel, Effect::MergeChannels) do not call broadcast_web_update. The RPC handlers in rpc_channel.rs now broadcast on create/archive/unarchive/rename, but the effects system -- which is the primary autonomous path for the daemon's rules engine -- still mutates channels without notifying WebSocket clients. The comment at rpc_channel.rs:390 explicitly states "This mirrors the cleanup in Effect::ArchiveChannel (effects.rs)" -- the PR breaks this mirroring by adding broadcast to the RPC path but not the effect path.
  • Effect::CreateChannel:

    midtown/src/daemon/effects.rs

    Lines 1713 to 1722 in bc01247

    Effect::CreateChannel {
    name,
    initial_tasks,
    } => {
    // Create the channel JSONL file
    let base_dir = crate::paths::projects_dir_for_repo(&state.repo_name);
    if let Err(e) = crate::channel::Channel::create(&base_dir, &name) {
    warn!("Failed to create channel '{}': {}", name, e);
    } else {
    info!("Created channel '{}'", name);
  • Effect::ArchiveChannel:

    midtown/src/daemon/effects.rs

    Lines 1792 to 1813 in bc01247

    Effect::ArchiveChannel { name } => {
    // Archive the channel by using Channel::archive()
    let base_dir = crate::paths::projects_dir_for_repo(&state.repo_name);
    // Idempotency guard: check if the channel directory still exists before
    // archiving. This effect may fire repeatedly (once per TaskDispatchTick)
    // because completed tasks still reference the channel name. Without this
    // guard, Channel::new() would recreate the channel directory,
    // and archive() would then overwrite the real archived data — destroying
    // all channel history.
    let channel_dir = base_dir.join("channels").join(&name);
    if !channel_dir.exists() {
    debug!("Channel '{}' already archived, skipping", name);
    continue;
    }
    match crate::channel::Channel::new(&base_dir, &name) {
    Ok(channel) => {
    if let Err(e) = channel.archive() {
    warn!("Failed to archive channel '{}': {}", name, e);
    } else {
    info!("Archived channel '{}'", name);
  • Effect::MergeChannels archive step:

    midtown/src/daemon/effects.rs

    Lines 1920 to 1936 in bc01247

    // Archive the source channel using Channel::archive().
    // Idempotency guard: only archive if the active directory still exists.
    // Without this, a duplicate merge could recreate the channel via Channel::new()
    // and then archive() would overwrite the real archived data.
    let from_channel_dir = base_dir.join("channels").join(&from);
    if from_channel_dir.exists() {
    if let Err(e) = from_channel.archive() {
    warn!(
    "Failed to archive source channel '{}' after merge: {}",
    from, e
    );
    } else {
    info!(
    "Merged channel '{}' into '{}' and archived source",
    from, into
    );
    }
  • Mirror comment:
    // Shut down the channel lead session (if running) and clean up state.
    // This mirrors the cleanup in Effect::ArchiveChannel (effects.rs).
    let lead_session_name = crate::launch::channel_lead_session_name(name);

Test suggestion: Unit test that executes Effect::CreateChannel and Effect::ArchiveChannel against a DaemonState with a web_updates_tx subscriber, then asserts channel_list_changed events are received.

  1. docs/architecture.md not updated for new WebUpdate::ChannelListChanged variant and data flow (CLAUDE.md says "When reviewing PRs, check whether the changes should be reflected in docs/architecture.md. New modules, changed data flows, new state fields, and altered decision logic should all be documented."). The architecture doc already documents other WebUpdate variants (UniversalItems, CoworkerQuestion) but has no mention of channel list change events.

Test suggestion: N/A (docs issue).

  1. Both handle_channel_create (RPC) and api_channels_create (web) broadcast channel_list_changed("created", ...) on any Ok result from Channel::create, but Channel::create is idempotent -- it succeeds even when the channel already exists. This means a "created" event fires when nothing actually changed, causing unnecessary fetchChannels round-trips in web clients.
  • RPC path:
    Ok(_) => {
    state.broadcast_web_update(crate::web::channel_list_changed("created", name));
    Response::success(
  • Web path with idempotency comment:

    midtown/src/web.rs

    Lines 476 to 489 in bc01247

    // Create the channel (idempotent - returns existing channel if it already exists)
    let base_dir = crate::paths::projects_dir_for_repo(&state.config.repo);
    Channel::create(base_dir, channel_name).map_err(|e| {
    error!("Failed to create channel '{}': {}", channel_name, e);
    (
    StatusCode::INTERNAL_SERVER_ERROR,
    axum::Json(serde_json::json!({ "error": "Failed to create channel" })),
    )
    })?;
    info!("Created channel '{}'", channel_name);
    let _ = state
    .updates_tx
    .send(channel_list_changed("created", channel_name));

Test suggestion: Test that calls Channel::create twice for the same channel name and asserts only the first call produces a channel_list_changed broadcast.

🌃 Co-built with Midtown

Consolidate the dual session tracking system (headless_sessions +
sessions) into a single SessionRecord-based store. Every session now
gets a UUID at spawn time (including Codex, which previously had no
ID until init), eliminating the race window where sessions existed in
headless_sessions but not in sessions.

Key changes:
- Remove HeadlessSessionInfo lookups; all handlers use SessionRecord
  via name_to_session reverse map
- Pre-assign provisional UUIDs for Codex sessions (migrated on init)
- Add crash-loop prevention: cooldown for recently-recovered sessions
  in pending-task resume path
- Clear task binding on failed-resume to prevent dispatch crash-loops
- Enrich SessionRecord with runtime fields (last_active, purpose, pid,
  channel, provider, platform, profile) at spawn time
- Mark legacy backfill functions #[cfg(test)] (no longer needed at
  startup since all sessions get records from spawn)
- Simplify tests with SessionRecord Default derive

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@codecov
Copy link

codecov bot commented Feb 23, 2026

Codecov Report

❌ Patch coverage is 44.17476% with 230 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.32%. Comparing base (512033b) to head (6af6bff).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
src/daemon/rpc_session.rs 17.85% 92 Missing ⚠️
src/daemon/effects.rs 0.00% 49 Missing ⚠️
src/daemon/mod.rs 66.07% 38 Missing ⚠️
src/daemon/state.rs 46.87% 34 Missing ⚠️
src/bin/midtown/cli/chat/app.rs 0.00% 11 Missing ⚠️
src/web.rs 50.00% 6 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1449      +/-   ##
==========================================
- Coverage   67.33%   67.32%   -0.02%     
==========================================
  Files         101      101              
  Lines       52284    52536     +252     
==========================================
+ Hits        35204    35368     +164     
- Misses      17080    17168      +88     
Files with missing lines Coverage Δ
src/bin/midtown/cli/session.rs 42.53% <ø> (+0.27%) ⬆️
src/daemon/dispatch.rs 79.87% <100.00%> (+0.05%) ⬆️
src/daemon/rpc_channel.rs 81.29% <100.00%> (+1.19%) ⬆️
src/daemon/sessions.rs 56.43% <100.00%> (-0.85%) ⬇️
src/daemon/startup.rs 54.40% <100.00%> (+0.26%) ⬆️
src/launch.rs 97.42% <ø> (+<0.01%) ⬆️
src/platform.rs 100.00% <ø> (ø)
src/web.rs 14.69% <50.00%> (+0.37%) ⬆️
src/bin/midtown/cli/chat/app.rs 64.76% <0.00%> (-0.09%) ⬇️
src/daemon/state.rs 57.38% <46.87%> (-6.01%) ⬇️
... and 3 more

... and 13 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- TUI: create channels via daemon RPC so web clients get notified;
  fall back to direct filesystem if daemon unavailable
- Effects system: broadcast channel_list_changed from CreateChannel,
  ArchiveChannel, and MergeChannels effects
- Idempotent create: check already_exists before broadcasting in
  RPC, REST, and effects paths to avoid spurious events
- docs/architecture.md: document ChannelListChanged event flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@btucker btucker merged commit 909db36 into main Feb 24, 2026
15 checks passed
@btucker btucker deleted the feat/channel-list-ws-event branch February 24, 2026 03:04
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.

1 participant