# 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<rust.BridgeChannel>? 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<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**

```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<String, String> _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<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`:

```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<String>? 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<String>? 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<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:

```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<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:

```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<rust.BridgeChannel>?` — matches widget pattern
- `unreadChannelIds` uses `Set<BigInt>` — matches channel ID type
