- 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)
23 KiB
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
SnapshotViewwidget
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
_lastDismissedTargetfield
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
onOpenChannelChatinSnapshotViewconstructor
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(_onOpenChatand_closeInlineChat) -
Step 1: Update
_onOpenChatto save current draft and restore new target's draft
Replace the _onOpenChat method (lines 1628-1676) with logic that:
- Saves the current
_inlineChatTargetdraft before switching - Restores the new target's draft (if any)
- 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
_saveCurrentDraftand_draftKeyForTargethelper 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
_closeInlineChatto 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(ChatDetailViewconstructor + state) -
Modify:
apps/chanora_flutter/lib/widgets/chat_panel.dart:12-75(ChatPanelconstructor + 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
_saveCurrentDraftinmain.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 analyzeon 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:
-
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.
-
Channel right-click → Chat: Right-click a channel → "Open chat". Chat panel should switch to that channel's chat without joining voice.
-
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.
-
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".
-
Close and reopen: Close the chat panel. Click the header chat button. It should reopen to the last conversation with the draft intact.
-
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.
-
Medium/compact unchanged: Narrow the window below 1024dp. Open chat. It should still push a full-screen route as before. No regressions.
-
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)usesBigInt— matcheschannel.idtype_chatDraftsusesStringkeys from_draftKeyForTarget— consistentonOpenChannelChatcallback typeValueChanged<rust.BridgeChannel>?— matches widget patternunreadChannelIdsusesSet<BigInt>— matches channel ID type