12 KiB
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
- Zero duplicated rendering code — Single source of truth for how sessions display
- Automatic feature parity — Any card change propagates to modal without extra work
- Preserve modal behaviors — Backdrop, escape key, animations, scroll lock
- 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 <SessionCard enlarged={true} />.
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:
limitprop 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
// 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
export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
// Closing animation state
// Body scroll lock
// Escape key handler
return html`
<div class="backdrop...">
<${SessionCard}
session=${session}
conversation=${conversations[session.session_id] || []}
onFetchConversation=${onFetchConversation}
onRespond=${onRespond}
onDismiss=${onDismiss}
onClick=${() => {}}
enlarged=${true}
/>
</div>
`;
}
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:
// 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
const [sending, setSending] = useState(false);
const handleSubmit = async (e) => {
if (sending) return;
setSending(true);
try {
await onRespond(...);
} finally {
setSending(false);
}
};
// In render:
<button disabled=${sending}>
${sending ? 'Sending...' : 'Send'}
</button>
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):
conversationLoadingstate — was only passed to ModalrefreshConversationcallback — was only used by Modal's custom send handler
Modified:
respondToSessionnow 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
- Card displays: status dot, status badge, agent badge, cwd, context usage, messages, input
- Modal displays: identical to card, just larger
- Card limits to 20 messages
- Modal shows all messages
- Smart scroll works in both views
- "Sending..." feedback works in both views
- Escape closes modal
- Click outside closes modal
- Entrance/exit animations work
- 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
enlargedprop (but avoid this — keep views identical) - To change message limit: Modify the
limitprop value in SessionCard's ChatMessages call
10. Lessons Learned
- Composition > Duplication — When two UIs show the same data, compose them from shared components
- Props for variations — A single boolean prop is often sufficient for "same thing, different context"
- Identify the actual differences — Modal needed only: backdrop, escape key, scroll lock, animations. Everything else was false complexity.
- Feature drift is inevitable — Duplicated code guarantees divergence over time. Only shared code stays in sync.