# 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** | 600–1023dp | 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): ```dart /// Open chat for a channel. final ValueChanged? onOpenChannelChat; ``` Update the constructor to include it (around line 32, after `onOpenClientPoke`): ```dart 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 ```dart return InkWell( onTap: onTap, onLongPress: widget.onOpenChannelChat != null ? () => widget.onOpenChannelChat!(channel) : null, child: PopupMenuButton( 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** ```bash 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`): ```dart /// Per-target draft text. Populated when switching away from a conversation /// so the user's unfinished message is preserved. final Map _chatDrafts = {}; ``` - [ ] **Step 2: Add `_lastDismissedTarget` field** Add a new state field to remember the last dismissed target so reopening returns to it: ```dart /// 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: ```dart 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** ```bash 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 ```dart Future _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`: ```dart /// 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): ```dart 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** ```bash 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): ```dart /// 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? onDraftChanged; ``` - [ ] **Step 2: Implement draft restore in `_ChatDetailViewState`** In `_ChatDetailViewState` (line 1100), add `initState` and `didUpdateWidget` to handle drafts: ```dart @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: ```dart /// External draft text to restore in the chat detail view. final String? restoredDraft; /// Called when the draft text changes. final ValueChanged? onDraftChanged; ``` Pass them through in `build()` where `ChatDetailView` is constructed (line 58): ```dart 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: ```dart 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: ```dart 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** ```bash 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): ```dart /// Set of channel IDs that have unread chat messages. final Set 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: ```dart // 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: ```dart /// Channel IDs that have unread chat messages (used for dot indicators). Set get _unreadChannelIds { if (_chatOpen) return const {}; final ids = {}; 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: ```dart unreadChannelIds: _unreadChannelIds, ``` - [ ] **Step 5: Run `flutter analyze`** Run: `cd apps/chanora_flutter && flutter analyze` Expected: No new errors - [ ] **Step 6: Commit** ```bash 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?` — matches widget pattern - `unreadChannelIds` uses `Set` — matches channel ID type