Files
amc/plans/card-modal-unification.md
2026-02-26 11:39:39 -05:00

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

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

// 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):

  • 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

  • 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 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.