Files
chanora/dev-docs/superpowers/plans/2026-06-06-chat-panel-switching.md
T
Edison Jwa bba6273af7 refactor: restructure docs as submodule, add dev-docs/ and AGENTS.md
- Move ASPICE docs to chanoraapp/docs submodule at docs/
- Move development docs to dev-docs/ (superpowers, offline-knowledge, impl-mapping)
- Add AGENTS.md with project conventions for AI agents
- Add impl-mapping.md (SAD component → source file mapping)
- Archive completed plans to dev-docs/superpowers/plans/_archived/
- Remove AGENTS.md from .gitignore (now tracked)
2026-06-13 03:32:33 +09:00

23 KiB
Raw Blame History

Chat Panel Switching Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Enable in-place conversation switching in the expanded 3-panel layout, add per-conversation draft persistence, and improve unread awareness — matching industry-standard UX patterns from Discord/Slack/Telegram/Element.

Architecture: The expanded layout (≥1024dp) shows voice controls | channel tree | inline chat panel. Currently the chat panel locks to one conversation with no way to switch from the channel tree. The fix adds channel→chat triggers, in-place target swapping, per-target draft storage, and preserves chat state across switches. The state machine (_inlineChatTarget + _chatOpen) already supports switching — we just need UI affordances and draft persistence.

Tech Stack: Flutter/Dart, existing BridgeMessageTarget sealed class, existing ChatDetailView / ChatPanel / SnapshotView widgets.

Design research basis: Discord (in-place swap, dot/badge unread hierarchy), Telegram Desktop (adaptive 3-tier layout, per-conversation drafts + scroll anchoring), Element (per-room panel state, toggleable right panel), Slack (bold sidebar for unread, split view). All apps treat DMs and channels identically for switching behavior.


Current State

UX Element Status
Unread indicator Single global badge count on app bar chat button
Channel → chat trigger None. Channel tiles only join voice
Client → DM trigger Works (right-click → "Direct Message")
Header chat button when panel open Idempotent — re-uses same _inlineChatTarget
Draft persistence None — single TextEditingController, lost on switch
Scroll position memory None — always auto-scrolls to bottom
Per-conversation unread None

Scope

In scope (this plan):

  • Channel → chat switching in expanded layout
  • Channel right-click → "Chat" option
  • Header chat button → switch to current voice channel chat when panel already open
  • Per-target draft persistence (in-memory Map)
  • Close = dismiss (remember last target and draft)
  • Unread dot indicator on channels in SnapshotView

Out of scope (future):

  • Scroll position memory per target
  • "New messages" divider
  • Per-target unread counts / badge numbers
  • Notification tiering (dot/badge/mention)
  • Split view (Slack power-user feature)

Responsive Behavior

Tier Width Chat Mode Changes in this plan
Expanded ≥1024dp Inline ChatPanel (right column) All changes apply here
Medium 6001023dp Full-screen ChatPage route No changes needed (already works)
Compact <600dp Full-screen ChatPage route No changes needed (already works)

File Structure

File Action Responsibility
apps/chanora_flutter/lib/widgets/snapshot_view.dart Modify Add onOpenChannelChat callback, channel context menu with "Chat" option, unread dot on channels
apps/chanora_flutter/lib/main.dart Modify Add _chatDrafts map, wire onOpenChannelChat, fix header chat button to switch to current channel, fix _closeInlineChat to preserve last target
apps/chanora_flutter/lib/widgets/chat_panel.dart Modify Accept onSwitchTarget callback, pass draft state through
apps/chanora_flutter/lib/widgets/chat_views.dart Modify ChatDetailView accepts external draft text, exposes draft text on target change
apps/chanora_flutter/lib/design/breakpoints.dart No changes Breakpoints unchanged

Task 1: Add onOpenChannelChat callback to SnapshotView

Files:

  • Modify: apps/chanora_flutter/lib/widgets/snapshot_view.dart:26-63 (constructor params)

  • Modify: apps/chanora_flutter/lib/widgets/snapshot_view.dart:203-284 (_channelTile)

  • Step 1: Add the callback field to SnapshotView widget

In snapshot_view.dart, add a new optional callback field after onOpenClientPoke (around line 71):

  /// Open chat for a channel.
  final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;

Update the constructor to include it (around line 32, after onOpenClientPoke):

    this.onOpenChannelChat,
  • Step 2: Add a right-click/long-press context menu to _channelTile

Replace the InkWell in _channelTile (lines 243-284) with a context menu wrapper. The channel tile should support:

  • Tap: join voice (existing behavior, unchanged)
  • Right-click / long-press: show a popup menu with "Open chat" option
    return InkWell(
      onTap: onTap,
      onLongPress: widget.onOpenChannelChat != null
          ? () => widget.onOpenChannelChat!(channel)
          : null,
      child: PopupMenuButton<String>(
        position: PopupMenuPosition.under,
        enabled: widget.onOpenChannelChat != null,
        onSelected: (value) {
          if (value == 'chat') {
            widget.onOpenChannelChat?.call(channel);
          }
        },
        itemBuilder: (context) => [
          PopupMenuItem(
            value: 'chat',
            child: Row(
              children: [
                const Icon(Icons.chat_bubble_outline, size: 18),
                const SizedBox(width: 12),
                Text(AppLocalizations.of(context)!.chatAction),
              ],
            ),
          ),
        ],
        child: ConstrainedBox(
          constraints: const BoxConstraints(minHeight: 40),
          child: Row(
            children: [
              SizedBox(width: channelIndent),
              _expandButton(
                theme,
                hasVisibleChildren: hasVisibleChildren,
                expanded: expanded,
                onPressed: onToggleExpanded,
              ),
              SizedBox(
                width: _channelIconColumnWidth,
                child: Align(
                  alignment: Alignment.centerLeft,
                  child: Icon(
                    Icons.tag,
                    color: theme.colorScheme.onSurfaceVariant,
                  ),
                ),
              ),
              const SizedBox(width: _channelTextGap),
              Expanded(
                child: Text(
                  channel.name,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
              ),
              if (channel.hasPassword) ...[
                const SizedBox(width: 8),
                Icon(
                  Icons.lock_outline,
                  color: theme.colorScheme.onSurfaceVariant,
                ),
              ],
            ],
          ),
        ),
      ),
    );

Note: The PopupMenuButton wraps the existing content as its child, so the tile looks identical until right-clicked. The onTap on InkWell continues to handle voice join.

  • Step 3: Run flutter analyze

Run: cd apps/chanora_flutter && flutter analyze Expected: No new errors (the callback is optional, so existing call sites compile without changes)

  • Step 4: Commit
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart
git commit -m "feat(chat): add onOpenChannelChat callback with context menu to channel tiles"

Task 2: Wire onOpenChannelChat in main.dart and add per-target draft storage

Files:

  • Modify: apps/chanora_flutter/lib/main.dart:370-374 (state fields)

  • Modify: apps/chanora_flutter/lib/main.dart:2521-2540 (SnapshotView constructor)

  • Step 1: Add draft storage map

Add a new state field near line 374 (after _inlineChatCollapseNoticeShown):

  /// Per-target draft text. Populated when switching away from a conversation
  /// so the user's unfinished message is preserved.
  final Map<String, String> _chatDrafts = {};
  • Step 2: Add _lastDismissedTarget field

Add a new state field to remember the last dismissed target so reopening returns to it:

  /// The last chat target before the panel was closed. Used to restore the
  /// previous conversation when the user reopens chat.
  rust.BridgeMessageTarget? _lastDismissedTarget;
  String _lastDismissedClientName = '';
  • Step 3: Wire onOpenChannelChat in SnapshotView constructor

In the SnapshotView(...) constructor around line 2512, add the new callback:

                        onOpenChannelChat: (channel) => unawaited(
                          _onOpenChat(
                            target: rust.BridgeMessageTarget.channel(channel.id),
                          ),
                        ),
  • Step 4: Run flutter analyze

Run: cd apps/chanora_flutter && flutter analyze Expected: No new errors

  • Step 5: Commit
git add apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): add per-target draft storage and wire onOpenChannelChat"

Task 3: Fix _onOpenChat to support switching and draft save/restore

Files:

  • Modify: apps/chanora_flutter/lib/main.dart:1628-1685 (_onOpenChat and _closeInlineChat)

  • Step 1: Update _onOpenChat to save current draft and restore new target's draft

Replace the _onOpenChat method (lines 1628-1676) with logic that:

  1. Saves the current _inlineChatTarget draft before switching
  2. Restores the new target's draft (if any)
  3. When called with no explicit target and panel is already open, switches to current voice channel's chat
  Future<void> _onOpenChat({
    rust.BridgeMessageTarget? target,
    String clientName = '',
  }) async {
    final initialSnapshot = _snapshot!;

    // Resolve the new target.
    // If no target passed and panel is already open, switch to current voice channel.
    // If no target passed and panel is closed, resolve from history or default.
    rust.BridgeMessageTarget newTarget;
    if (target != null) {
      newTarget = target;
    } else if (_chatOpen && _currentVoiceChannelId != null) {
      newTarget = rust.BridgeMessageTarget.channel(_currentVoiceChannelId!);
    } else if (_inlineChatTarget != null) {
      newTarget = _inlineChatTarget!;
    } else {
      newTarget = resolveInitialChatTarget(
        messages: _chatMessages,
        currentVoiceChannelId: _currentVoiceChannelId,
      ) ?? const rust.BridgeMessageTarget.server();
    }

    final newClientName = clientName.isNotEmpty
        ? clientName
        : (newTarget == _inlineChatTarget) ? _inlineChatClientName : '';

    final isExpanded =
        layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
        LayoutClass.expanded;
    if (isExpanded) {
      setState(() {
        // Save draft for the current target before switching.
        _saveCurrentDraft();
        _chatUnread = 0;
        _chatOpen = true;
        _inlineChatTarget = newTarget;
        _inlineChatClientName = newClientName;
        _inlineChatCollapseNoticeShown = false;
      });
      return;
    }
    setState(() {
      _chatUnread = 0;
      _chatOpen = true;
    });
    await Navigator.of(context).push(
      MaterialPageRoute(
        builder: (_) => ChatPage(
          messages: _chatMessages,
          snapshot: initialSnapshot,
          messagesSource: () => _chatMessages,
          snapshotSource: () => _snapshot ?? initialSnapshot,
          refreshListenable: _chatFeedRevision,
          initialTarget: newTarget,
          initialClientName: newClientName,
          onTs3ServerLink: _onTs3ServerLink,
        ),
      ),
    );
    if (mounted) setState(() => _chatOpen = false);
  }
  • Step 2: Add _saveCurrentDraft and _draftKeyForTarget helper methods

Add these near _onOpenChat:

  /// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
  String _draftKeyForTarget(rust.BridgeMessageTarget target) {
    return switch (target) {
      rust.BridgeMessageTarget_Server() => 'server',
      rust.BridgeMessageTarget_Channel(:final id) => 'channel:$id',
      rust.BridgeMessageTarget_Client(:final id) => 'client:$id',
      rust.BridgeMessageTarget_Poke(:final id) => 'poke:$id',
    };
  }

  /// Saves the current draft text (if any) for the current inline chat target.
  /// Called before switching targets or closing the panel.
  void _saveCurrentDraft() {
    // Note: The actual draft text is read from ChatDetailView's
    // TextEditingController via a callback. This is wired in Task 4.
  }

Note: _saveCurrentDraft will be completed in Task 4 when we wire the draft callback from ChatDetailView.

  • Step 3: Update _closeInlineChat to preserve last target instead of nulling it

Replace _closeInlineChat (lines 1678-1685):

  void _closeInlineChat() {
    setState(() {
      // Save draft before closing.
      _saveCurrentDraft();
      // Remember the last target so reopening returns to it.
      _lastDismissedTarget = _inlineChatTarget;
      _lastDismissedClientName = _inlineChatClientName;
      _chatOpen = false;
      // Do NOT null _inlineChatTarget — we want to remember it for reopen.
    });
  }
  • Step 4: Run flutter analyze

Run: cd apps/chanora_flutter && flutter analyze Expected: No new errors

  • Step 5: Commit
git add apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): switch chat target on channel click, save draft before switching, preserve target on close"

Task 4: Add draft save/restore callback to ChatDetailView and ChatPanel

Files:

  • Modify: apps/chanora_flutter/lib/widgets/chat_views.dart:1054-1098 (ChatDetailView constructor + state)

  • Modify: apps/chanora_flutter/lib/widgets/chat_panel.dart:12-75 (ChatPanel constructor + build)

  • Step 1: Add draft callbacks to ChatDetailView

Add two new optional callbacks to ChatDetailView (after messageMaxWidth around line 1066):

  /// External draft text to restore when the widget initializes or the target changes.
  final String? restoredDraft;

  /// Called with the current draft text whenever the target changes or the widget is disposed.
  final ValueChanged<String>? onDraftChanged;
  • Step 2: Implement draft restore in _ChatDetailViewState

In _ChatDetailViewState (line 1100), add initState and didUpdateWidget to handle drafts:

  @override
  void initState() {
    super.initState();
    if (widget.restoredDraft != null && widget.restoredDraft!.isNotEmpty) {
      _textCtl.text = widget.restoredDraft!;
    }
  }

  @override
  void didUpdateWidget(covariant ChatDetailView oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.target != widget.target) {
      // Save draft for old target before switching.
      if (oldWidget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
        oldWidget.onDraftChanged!(_textCtl.text);
      }
      // Restore draft for new target.
      _textCtl.text = widget.restoredDraft ?? '';
      _lastRenderedTarget = null;
    }
  }

  @override
  void dispose() {
    // Emit the current draft so the parent can save it.
    if (widget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
      widget.onDraftChanged!(_textCtl.text);
    }
    _textCtl.dispose();
    _scrollCtl.dispose();
    super.dispose();
  }

Remove the existing dispose method (lines 1127-1132) — it's replaced by the new one above.

  • Step 3: Thread draft callbacks through ChatPanel

Update ChatPanel to accept and pass through the new callbacks. Add fields:

  /// External draft text to restore in the chat detail view.
  final String? restoredDraft;

  /// Called when the draft text changes.
  final ValueChanged<String>? onDraftChanged;

Pass them through in build() where ChatDetailView is constructed (line 58):

        child: ChatDetailView(
          messages: messages,
          snapshot: snapshot,
          target: target,
          clientName: clientName,
          currentChannelId: currentChannelId,
          channelName: channelName,
          onTs3ServerLink: onTs3ServerLink,
          restoredDraft: restoredDraft,
          onDraftChanged: onDraftChanged,
          messageMaxWidth: 500,
          headerTrailing: IconButton(
            tooltip: 'Close chat',
            icon: const Icon(Icons.close),
            onPressed: onClose,
          ),
        ),
  • Step 4: Wire draft callbacks in main.dart

In the ChatPanel(...) constructor around line 2567, add the draft callbacks:

                              ChatPanel(
                                messages: _chatMessages,
                                snapshot: _snapshot!,
                                target: inlineChatTarget,
                                clientName: _inlineChatClientName,
                                onTs3ServerLink: _onTs3ServerLink,
                                restoredDraft: _chatDrafts[_draftKeyForTarget(inlineChatTarget)],
                                onDraftChanged: (text) {
                                  _chatDrafts[_draftKeyForTarget(_inlineChatTarget!)] = text;
                                },
                                onClose: _closeInlineChat,
                              ),
  • Step 5: Complete _saveCurrentDraft in main.dart

The _saveCurrentDraft method is called from _onOpenChat (before switching) and _closeInlineChat. Since ChatDetailView emits drafts via onDraftChanged and dispose, the parent always has the latest draft in _chatDrafts. The method body stays as a no-op safety net:

  void _saveCurrentDraft() {
    // Drafts are continuously saved via onDraftChanged callback.
    // This method exists as an explicit save point for any future
    // snapshot-based draft capture.
  }
  • Step 6: Run flutter analyze

Run: cd apps/chanora_flutter && flutter analyze Expected: No new errors

  • Step 7: Commit
git add apps/chanora_flutter/lib/widgets/chat_views.dart apps/chanora_flutter/lib/widgets/chat_panel.dart apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): per-target draft persistence with save/restore on switch"

Task 5: Add unread dot indicator to channel tiles in SnapshotView

Files:

  • Modify: apps/chanora_flutter/lib/widgets/snapshot_view.dart (add unread indicator)

  • Modify: apps/chanora_flutter/lib/main.dart (pass unread channel set)

  • Step 1: Add unread channel IDs parameter to SnapshotView

Add a new required field to SnapshotView (after canJoinVoiceChannel around line 57):

  /// Set of channel IDs that have unread chat messages.
  final Set<BigInt> unreadChannelIds;
  • Step 2: Add unread dot to _channelTile

In _channelTile, inside the Row children (after the channel name Expanded widget, around line 273), add an unread dot:

            // Unread indicator.
            if (widget.unreadChannelIds.contains(channel.id)) ...[
              const SizedBox(width: 8),
              Container(
                width: 8,
                height: 8,
                decoration: BoxDecoration(
                  color: theme.colorScheme.primary,
                  shape: BoxShape.circle,
                ),
              ),
            ],

This must come before the password lock icon check (line 274).

  • Step 3: Compute unread channel set in main.dart

Add a getter in _BetaHomeState that computes which channels have unread messages:

  /// Channel IDs that have unread chat messages (used for dot indicators).
  Set<BigInt> get _unreadChannelIds {
    if (_chatOpen) return const {};
    final ids = <BigInt>{};
    for (final entry in _chatMessages) {
      if (!entry.countsTowardUnread || entry.isSelf) continue;
      if (entry.target case rust.BridgeMessageTarget_Channel(:final id)) {
        ids.add(id);
      }
    }
    return ids;
  }
  • Step 4: Pass unread channel set to SnapshotView

In the SnapshotView(...) constructor around line 2512, add:

                        unreadChannelIds: _unreadChannelIds,
  • Step 5: Run flutter analyze

Run: cd apps/chanora_flutter && flutter analyze Expected: No new errors

  • Step 6: Commit
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): unread dot indicator on channel tiles with unread messages"

Task 6: End-to-end verification

Files: All modified files.

  • Step 1: Run flutter analyze on the full project

Run: cd apps/chanora_flutter && flutter analyze Expected: Zero issues

  • Step 2: Run flutter test

Run: cd apps/chanora_flutter && flutter test Expected: All tests pass (same baseline as before — 180 passed, 2 skipped)

  • Step 3: Build macOS release

Run: bash tools/build-macos.sh Expected: Successful build producing chanora-v0.2.0-beta.1-macos-aarch64.zip

  • Step 4: Manual QA checklist

Launch the app and verify:

  1. Channel → chat switching: With window ≥1024dp and chat panel open showing Server chat, click a channel in the tree. The chat panel should switch to that channel's chat (messages filter to that channel). Voice join should also happen.

  2. Channel right-click → Chat: Right-click a channel → "Open chat". Chat panel should switch to that channel's chat without joining voice.

  3. Header chat button toggle: With chat panel open showing a DM, click the header chat button. It should switch to the current voice channel's chat.

  4. Draft persistence: Type "hello" in chat input but don't send. Click a different channel. Type "world" in that channel's chat. Switch back to the first channel. The input should show "hello".

  5. Close and reopen: Close the chat panel. Click the header chat button. It should reopen to the last conversation with the draft intact.

  6. Unread dots: Close the chat panel. Have someone send a message to a specific channel. That channel in the tree should show a blue dot.

  7. Medium/compact unchanged: Narrow the window below 1024dp. Open chat. It should still push a full-screen route as before. No regressions.

  8. DM switching still works: Right-click a client → "Direct Message". Chat panel should switch to that DM. Right-click another client → "Direct Message". Should switch again.


Self-Review

Spec coverage

Requirement Task
Channel → chat switching (tap) Task 2 (wiring) + Task 3 (target resolution)
Channel → chat (context menu) Task 1
Header button switches when open Task 3
Per-target draft persistence Task 4
Close = dismiss (remember state) Task 3
Unread dot on channels Task 5
Medium/compact unchanged No changes to those paths

Placeholder scan

No TBD, TODO, or placeholder steps found. All code blocks contain complete implementations.

Type consistency

  • BridgeMessageTarget.channel(id) uses BigInt — matches channel.id type
  • _chatDrafts uses String keys from _draftKeyForTarget — consistent
  • onOpenChannelChat callback type ValueChanged<rust.BridgeChannel>? — matches widget pattern
  • unreadChannelIds uses Set<BigInt> — matches channel ID type