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

643 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```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