# Card/Modal Unification Plan **Status:** Implemented **Date:** 2026-02-26 **Author:** Claude + Taylor --- ## Executive Summary Unify SessionCard and Modal into a single component with an `enlarged` prop, eliminating 165 lines of duplicated code and ensuring feature parity across both views. --- ## 1. Problem Statement ### 1.1 What's Broken The AMC dashboard displays agent sessions as cards in a grid. Clicking a card opens a "modal" for a larger, focused view. These two views evolved independently, creating: | Issue | Impact | |-------|--------| | **Duplicated rendering logic** | Modal.js reimplemented header, chat, input from scratch (227 lines) | | **Feature drift** | Card had context usage display; modal didn't. Modal had timestamps; card didn't. | | **Maintenance burden** | Every card change required parallel modal changes (often forgotten) | | **Inconsistent UX** | Users see different information depending on view | ### 1.2 Why This Matters The modal's purpose is simple: **show an enlarged view with more screen space for content**. It should not be a separate implementation with different features. Users clicking a card expect to see *the same thing, bigger* — not a different interface. ### 1.3 Root Cause The modal was originally built as a separate component because it needed: - Backdrop blur with click-outside-to-close - Escape key handling - Body scroll lock - Entrance/exit animations These concerns led developers to copy-paste card internals into the modal rather than compose them. --- ## 2. Goals and Non-Goals ### 2.1 Goals 1. **Zero duplicated rendering code** — Single source of truth for how sessions display 2. **Automatic feature parity** — Any card change propagates to modal without extra work 3. **Preserve modal behaviors** — Backdrop, escape key, animations, scroll lock 4. **Add missing features to both views** — Smart scroll, sending state feedback ### 2.2 Non-Goals - Changing the visual design of either view - Adding new features beyond parity + smart scroll + sending state - Refactoring other components --- ## 3. User Workflows ### 3.1 Current User Journey ``` User sees session cards in grid │ ├─► Card shows: status, agent, cwd, context usage, last 20 messages, input │ └─► User clicks card │ └─► Modal opens with DIFFERENT layout: - Combined status badge (dot inside) - No context usage - All messages with timestamps - Different input implementation - Keyboard hints shown ``` ### 3.2 Target User Journey ``` User sees session cards in grid │ ├─► Card shows: status, agent, cwd, context usage, last 20 messages, input │ └─► User clicks card │ └─► Modal opens with SAME card, just bigger: - Identical header layout - Context usage visible - All messages (not limited to 20) - Same input components - Same everything, more space ``` ### 3.3 User Benefits | Benefit | Rationale | |---------|-----------| | **Cognitive consistency** | Same information architecture in both views reduces learning curve | | **Trust** | No features "hiding" in one view or the other | | **Predictability** | Click = zoom, not "different interface" | --- ## 4. Design Decisions ### 4.1 Architecture: Shared Component with Prop **Decision:** Add `enlarged` prop to SessionCard. Modal renders ``. **Alternatives Considered:** | Alternative | Rejected Because | |-------------|------------------| | Modal wraps Card with CSS transform | Breaks layout, accessibility issues, can't change message limit | | Higher-order component | Unnecessary complexity for single boolean difference | | Render props pattern | Overkill, harder to read | | Separate "CardContent" extracted | Still requires prop to control limit, might as well be on SessionCard | **Rationale:** A single boolean prop is the simplest solution that achieves all goals. The `enlarged` prop controls exactly two things: container sizing and message limit. Everything else is identical. --- ### 4.2 Message Limit: Card 20, Enlarged All **Decision:** Card shows last 20 messages. Enlarged view shows all. **Rationale:** - Cards in a grid need bounded height for visual consistency - 20 messages is enough context without overwhelming the card - Enlarged view exists specifically to see more — no artificial limit makes sense - Implementation: `limit` prop on ChatMessages (20 default, null for unlimited) --- ### 4.3 Header Layout: Keep Card's Multi-Row Style **Decision:** Use the card's multi-row header layout for both views. **Modal had:** Single row with combined status badge (dot inside badge) **Card had:** Multi-row with separate dot, status badge, agent badge, cwd badge, context usage **Rationale:** - Card layout shows more information (context usage was missing from modal) - Multi-row handles overflow gracefully with `flex-wrap` - Consistent with the "modal = bigger card" philosophy --- ### 4.4 Spacing: Keep Tighter (Card Style) **Decision:** Use card's tighter spacing (`px-4 py-3`, `space-y-2.5`) for both views. **Modal had:** Roomier spacing (`px-5 py-4`, `space-y-4`) **Rationale:** - Tighter spacing is more information-dense - Enlarged view gains space from larger container, not wider margins - Consistent visual rhythm between views --- ### 4.5 Empty State Text: "No messages to show" **Decision:** Standardize on "No messages to show" (neither original). **Card had:** "No messages yet" **Modal had:** "No conversation messages" **Rationale:** "No messages to show" is neutral and accurate — doesn't imply timing ("yet") or specific terminology ("conversation"). --- ## 5. Implementation Details ### 5.1 SessionCard.js Changes ``` BEFORE: SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss }) AFTER: SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false }) ``` **New behaviors controlled by `enlarged`:** | Aspect | `enlarged=false` (card) | `enlarged=true` (modal) | |--------|-------------------------|-------------------------| | Container classes | `h-[850px] w-[600px] cursor-pointer hover:...` | `max-w-5xl max-h-[90vh]` | | Click handler | `onClick(session)` | `undefined` (no-op) | | Message limit | 20 | null (all) | **New feature: Smart scroll tracking** ```js // Track if user is at bottom const wasAtBottomRef = useRef(true); // On scroll, update tracking const handleScroll = () => { wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50; }; // On new messages, only scroll if user was at bottom if (hasNewMessages && wasAtBottomRef.current) { el.scrollTop = el.scrollHeight; } ``` **Rationale:** Users reading history shouldn't be yanked to bottom when new messages arrive. Only auto-scroll if they were already at the bottom (watching live updates). --- ### 5.2 Modal.js Changes **Before:** 227 lines reimplementing header, chat, input, scroll, state management **After:** 62 lines — backdrop wrapper only ```js export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) { // Closing animation state // Body scroll lock // Escape key handler return html`
<${SessionCard} session=${session} conversation=${conversations[session.session_id] || []} onFetchConversation=${onFetchConversation} onRespond=${onRespond} onDismiss=${onDismiss} onClick=${() => {}} enlarged=${true} />
`; } ``` **Preserved behaviors:** - Backdrop blur (`bg-[#02050d]/84 backdrop-blur-sm`) - Click outside to close - Escape key handler - Body scroll lock (`document.body.style.overflow = 'hidden'`) - Entrance/exit animations (CSS classes) --- ### 5.3 ChatMessages.js Changes ``` BEFORE: ChatMessages({ messages, status }) AFTER: ChatMessages({ messages, status, limit = 20 }) ``` **Logic change:** ```js // Before: always slice to 20 const displayMessages = allDisplayMessages.slice(-20); // After: respect limit prop const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages; ``` --- ### 5.4 SimpleInput.js / QuestionBlock.js Changes **New feature: Sending state feedback** ```js const [sending, setSending] = useState(false); const handleSubmit = async (e) => { if (sending) return; setSending(true); try { await onRespond(...); } finally { setSending(false); } }; // In render: ``` **Rationale:** Users need feedback that their message is being sent. Without this, they might click multiple times or think the UI is broken. --- ### 5.5 App.js Changes **Removed (unused after refactor):** - `conversationLoading` state — was only passed to Modal - `refreshConversation` callback — was only used by Modal's custom send handler **Modified:** - `respondToSession` now refreshes conversation immediately after successful send - Modal receives same props as SessionCard (onRespond, onFetchConversation, onDismiss) --- ## 6. Dependency Graph ``` App.js │ ├─► SessionCard (in grid) │ ├─► ChatMessages (limit=20) │ │ └─► MessageBubble │ ├─► QuestionBlock (with sending state) │ │ └─► OptionButton │ └─► SimpleInput (with sending state) │ └─► Modal (backdrop wrapper) └─► SessionCard (enlarged=true) ├─► ChatMessages (limit=null) │ └─► MessageBubble ├─► QuestionBlock (with sending state) │ └─► OptionButton └─► SimpleInput (with sending state) ``` **Key insight:** Modal no longer has its own rendering tree. It delegates entirely to SessionCard. --- ## 7. Metrics | Metric | Before | After | Change | |--------|--------|-------|--------| | Modal.js lines | 227 | 62 | -73% | | Total duplicated code | ~180 lines | 0 | -100% | | Features requiring dual maintenance | All | None | -100% | | Prop surface area (Modal) | 6 custom | 6 same as card | Aligned | --- ## 8. Verification Checklist - [x] Card displays: status dot, status badge, agent badge, cwd, context usage, messages, input - [x] Modal displays: identical to card, just larger - [x] Card limits to 20 messages - [x] Modal shows all messages - [x] Smart scroll works in both views - [x] "Sending..." feedback works in both views - [x] Escape closes modal - [x] Click outside closes modal - [x] Entrance/exit animations work - [x] Body scroll locked when modal open --- ## 9. Future Considerations ### 9.1 Potential Enhancements | Enhancement | Rationale | Blocked By | |-------------|-----------|------------| | Keyboard navigation in card grid | Accessibility | None | | Resize modal dynamically | User preference | None | | Pin modal to side (split view) | Power user workflow | Design decision needed | ### 9.2 Maintenance Notes - **Any SessionCard change** automatically applies to modal view - **To add modal-only behavior**: Check `enlarged` prop (but avoid this — keep views identical) - **To change message limit**: Modify the `limit` prop value in SessionCard's ChatMessages call --- ## 10. Lessons Learned 1. **Composition > Duplication** — When two UIs show the same data, compose them from shared components 2. **Props for variations** — A single boolean prop is often sufficient for "same thing, different context" 3. **Identify the actual differences** — Modal needed only: backdrop, escape key, scroll lock, animations. Everything else was false complexity. 4. **Feature drift is inevitable** — Duplicated code guarantees divergence over time. Only shared code stays in sync.