Files
cameleer-server/docs/superpowers/plans/2026-04-16-runtime-compact-view.md
hsiegeln 23d24487d1 docs: add runtime compact view implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:32:14 +02:00

22 KiB

Runtime Compact View 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: Add a compact/collapsed view mode to the AgentHealth runtime dashboard where each app is a small health-tinted card, expandable inline to the full agent table.

Architecture: Add viewMode and expandedApps state to AgentHealth. A CompactAppCard inline component renders the collapsed card. The existing GroupCard+DataTable renders expanded apps. A view toggle in the stat strip switches modes. All CSS uses design system variables with color-mix() for health tinting.

Tech Stack: React, CSS Modules, lucide-react, @cameleer/design-system components, localStorage


Task 1: Add compact card CSS classes

Files:

  • Modify: ui/src/pages/AgentHealth/AgentHealth.module.css

  • Step 1: Add compact grid class

Append to AgentHealth.module.css after the .groupGridSingle block (line 94):

/* Compact view grid */
.compactGrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 10px;
  margin-bottom: 20px;
}
  • Step 2: Add compact card base and health variant classes

Append to AgentHealth.module.css:

/* Compact app card */
.compactCard {
  background: color-mix(in srgb, var(--card-accent) 8%, var(--bg-raised));
  border: 1px solid color-mix(in srgb, var(--card-accent) 25%, transparent);
  border-left: 3px solid var(--card-accent);
  border-radius: var(--radius-md);
  padding: 10px 12px;
  cursor: pointer;
  transition: background 150ms ease;
}

.compactCard:hover {
  background: color-mix(in srgb, var(--card-accent) 12%, var(--bg-raised));
}

.compactCardSuccess { --card-accent: var(--success); }
.compactCardWarning { --card-accent: var(--warning); }
.compactCardError   { --card-accent: var(--error); }

.compactCardHeader {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 6px;
}

.compactCardName {
  font-size: 13px;
  font-weight: 700;
  color: var(--text-primary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.compactCardChevron {
  color: var(--text-muted);
  flex-shrink: 0;
}

.compactCardMeta {
  display: flex;
  justify-content: space-between;
  font-size: 11px;
}

.compactCardLive {
  font-weight: 600;
  color: var(--card-accent);
}

.compactCardHeartbeat {
  color: var(--text-muted);
}

.compactCardHeartbeatWarn {
  color: var(--card-accent);
}
  • Step 3: Add expanded-in-compact-grid span class

Append to AgentHealth.module.css:

/* Expanded card inside compact grid */
.compactGridExpanded {
  grid-column: span 2;
}
  • Step 4: Add animation classes

Append to AgentHealth.module.css:

/* Expand/collapse animation wrapper */
.expandWrapper {
  overflow: hidden;
  transition: max-height 200ms ease, opacity 150ms ease;
}

.expandWrapperCollapsed {
  max-height: 0;
  opacity: 0;
}

.expandWrapperExpanded {
  max-height: 1000px;
  opacity: 1;
}
  • Step 5: Add view toggle classes and update stat strip

Append to AgentHealth.module.css:

/* View mode toggle */
.viewToggle {
  display: flex;
  align-items: center;
  gap: 4px;
}

.viewToggleBtn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-subtle);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition: background 150ms ease, color 150ms ease;
}

.viewToggleBtn:hover {
  color: var(--text-primary);
}

.viewToggleBtnActive {
  background: var(--running);
  border-color: var(--running);
  color: #fff;
}
  • Step 6: Update stat strip grid to add auto column

In AgentHealth.module.css, change the .statStrip rule from:

.statStrip {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 10px;
  margin-bottom: 16px;
}

to:

.statStrip {
  display: grid;
  grid-template-columns: repeat(5, 1fr) auto;
  gap: 10px;
  margin-bottom: 16px;
}
  • Step 7: Add collapse button class for expanded cards

Append to AgentHealth.module.css:

/* Collapse button in expanded GroupCard header */
.collapseBtn {
  background: transparent;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  padding: 2px 4px;
  border-radius: var(--radius-sm);
  line-height: 1;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
}

.collapseBtn:hover {
  color: var(--text-primary);
}
  • Step 8: Commit
git add ui/src/pages/AgentHealth/AgentHealth.module.css
git commit -m "style: add compact view CSS classes for runtime dashboard"

Task 2: Add view mode state and toggle to AgentHealth

Files:

  • Modify: ui/src/pages/AgentHealth/AgentHealth.tsx:1-3 (imports)

  • Modify: ui/src/pages/AgentHealth/AgentHealth.tsx:100-120 (state)

  • Modify: ui/src/pages/AgentHealth/AgentHealth.tsx:339-402 (stat strip JSX)

  • Step 1: Add lucide-react imports

In AgentHealth.tsx, change line 3 from:

import { ExternalLink, RefreshCw, Pencil } from 'lucide-react';

to:

import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown } from 'lucide-react';
  • Step 2: Add view mode state

In AgentHealth.tsx, after the const [confirmDismissOpen, setConfirmDismissOpen] = useState(false); line (line 116), add:

  const [viewMode, setViewMode] = useState<'compact' | 'expanded'>(() => {
    const saved = localStorage.getItem('cameleer:runtime:viewMode');
    return saved === 'expanded' ? 'expanded' : 'compact';
  });
  const [expandedApps, setExpandedApps] = useState<Set<string>>(new Set());

  const toggleViewMode = useCallback((mode: 'compact' | 'expanded') => {
    setViewMode(mode);
    setExpandedApps(new Set());
    localStorage.setItem('cameleer:runtime:viewMode', mode);
  }, []);

  const toggleAppExpanded = useCallback((appId: string) => {
    setExpandedApps((prev) => {
      const next = new Set(prev);
      if (next.has(appId)) next.delete(appId);
      else next.add(appId);
      return next;
    });
  }, []);
  • Step 3: Add view toggle to stat strip

In AgentHealth.tsx, inside the stat strip <div className={styles.statStrip}>, after the last <StatCard> (the "Dead" one ending around line 401), add:

        <div className={styles.viewToggle}>
          <button
            className={`${styles.viewToggleBtn} ${viewMode === 'compact' ? styles.viewToggleBtnActive : ''}`}
            onClick={() => toggleViewMode('compact')}
            title="Compact view"
          >
            <LayoutGrid size={14} />
          </button>
          <button
            className={`${styles.viewToggleBtn} ${viewMode === 'expanded' ? styles.viewToggleBtnActive : ''}`}
            onClick={() => toggleViewMode('expanded')}
            title="Expanded view"
          >
            <List size={14} />
          </button>
        </div>
  • Step 4: Verify the dev server compiles without errors

Run: cd ui && npx vite build --mode development 2>&1 | tail -5 Expected: build succeeds (the toggle renders but has no visual effect on the grid yet)

  • Step 5: Commit
git add ui/src/pages/AgentHealth/AgentHealth.tsx
git commit -m "feat: add view mode state and toggle to runtime dashboard"

Task 3: Add CompactAppCard component and compact grid rendering

Files:

  • Modify: ui/src/pages/AgentHealth/AgentHealth.tsx

  • Step 1: Add lastHeartbeat helper to helpers section

In AgentHealth.tsx, after the appHealth() function (after line 81), add:

function latestHeartbeat(group: AppGroup): string | undefined {
  let latest: string | undefined;
  for (const inst of group.instances) {
    if (inst.lastHeartbeat && (!latest || inst.lastHeartbeat > latest)) {
      latest = inst.lastHeartbeat;
    }
  }
  return latest;
}
  • Step 2: Add CompactAppCard component

In AgentHealth.tsx, after the LOG_SOURCE_ITEMS constant (after line 97), add:

function CompactAppCard({ group, onExpand }: { group: AppGroup; onExpand: () => void }) {
  const health = appHealth(group);
  const heartbeat = latestHeartbeat(group);
  const isHealthy = health === 'success';

  const variantClass =
    health === 'success' ? styles.compactCardSuccess
    : health === 'warning' ? styles.compactCardWarning
    : styles.compactCardError;

  return (
    <div
      className={`${styles.compactCard} ${variantClass}`}
      onClick={onExpand}
      role="button"
      tabIndex={0}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onExpand(); }}
    >
      <div className={styles.compactCardHeader}>
        <span className={styles.compactCardName}>{group.appId}</span>
        <ChevronRight size={14} className={styles.compactCardChevron} />
      </div>
      <div className={styles.compactCardMeta}>
        <span className={styles.compactCardLive}>
          {group.liveCount}/{group.instances.length} live
        </span>
        <span className={isHealthy ? styles.compactCardHeartbeat : styles.compactCardHeartbeatWarn}>
          {heartbeat ? timeAgo(heartbeat) : '\u2014'}
        </span>
      </div>
    </div>
  );
}
  • Step 3: Replace the group cards grid with conditional rendering

In AgentHealth.tsx, replace the entire group cards grid block (lines 515-569):

      {/* Group cards grid */}
      <div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
        {groups.map((group) => (
          <GroupCard
            ...
          </GroupCard>
        ))}
      </div>

with:

      {/* Group cards grid */}
      {viewMode === 'expanded' || isFullWidth ? (
        <div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
          {groups.map((group) => (
            <GroupCard
              key={group.appId}
              title={group.appId}
              accent={appHealth(group)}
              headerRight={
                <Badge
                  label={`${group.liveCount}/${group.instances.length} LIVE`}
                  color={appHealth(group)}
                  variant="filled"
                />
              }
              meta={
                <div className={styles.groupMeta}>
                  <span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
                  <span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
                  <span>
                    <StatusDot
                      variant={
                        appHealth(group) === 'success'
                          ? 'live'
                          : appHealth(group) === 'warning'
                            ? 'stale'
                            : 'dead'
                      }
                    />
                  </span>
                </div>
              }
              footer={
                group.deadCount > 0 ? (
                  <div className={styles.alertBanner}>
                    <span className={styles.alertIcon}>&#9888;</span>
                    <span>
                      Single point of failure &mdash;{' '}
                      {group.deadCount === group.instances.length
                        ? 'no redundancy'
                        : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
                    </span>
                  </div>
                ) : undefined
              }
            >
              <DataTable<AgentInstance & { id: string }>
                columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
                data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
                onRowClick={handleInstanceClick}
                pageSize={50}
                flush
              />
            </GroupCard>
          ))}
        </div>
      ) : (
        <div className={styles.compactGrid}>
          {groups.map((group) =>
            expandedApps.has(group.appId) ? (
              <div key={group.appId} className={styles.compactGridExpanded}>
                <GroupCard
                  title={group.appId}
                  accent={appHealth(group)}
                  headerRight={
                    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                      <Badge
                        label={`${group.liveCount}/${group.instances.length} LIVE`}
                        color={appHealth(group)}
                        variant="filled"
                      />
                      <button
                        className={styles.collapseBtn}
                        onClick={() => toggleAppExpanded(group.appId)}
                        title="Collapse"
                      >
                        <ChevronDown size={14} />
                      </button>
                    </div>
                  }
                  meta={
                    <div className={styles.groupMeta}>
                      <span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
                      <span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
                      <span>
                        <StatusDot
                          variant={
                            appHealth(group) === 'success'
                              ? 'live'
                              : appHealth(group) === 'warning'
                                ? 'stale'
                                : 'dead'
                          }
                        />
                      </span>
                    </div>
                  }
                  footer={
                    group.deadCount > 0 ? (
                      <div className={styles.alertBanner}>
                        <span className={styles.alertIcon}>&#9888;</span>
                        <span>
                          Single point of failure &mdash;{' '}
                          {group.deadCount === group.instances.length
                            ? 'no redundancy'
                            : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
                        </span>
                      </div>
                    ) : undefined
                  }
                >
                  <DataTable<AgentInstance & { id: string }>
                    columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
                    data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
                    onRowClick={handleInstanceClick}
                    pageSize={50}
                    flush
                  />
                </GroupCard>
              </div>
            ) : (
              <CompactAppCard
                key={group.appId}
                group={group}
                onExpand={() => toggleAppExpanded(group.appId)}
              />
            ),
          )}
        </div>
      )}
  • Step 4: Verify the dev server compiles without errors

Run: cd ui && npx vite build --mode development 2>&1 | tail -5 Expected: build succeeds

  • Step 5: Commit
git add ui/src/pages/AgentHealth/AgentHealth.tsx
git commit -m "feat: add compact app cards with inline expand to runtime dashboard"

Task 4: Add expand/collapse animation for single card toggle

Files:

  • Modify: ui/src/pages/AgentHealth/AgentHealth.tsx

  • Step 1: Add useRef and useEffect to imports

In AgentHealth.tsx, change line 1 from:

import { useState, useMemo, useCallback } from 'react';

to:

import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
  • Step 2: Add animating state to track which apps are transitioning

In AgentHealth.tsx, after the toggleAppExpanded callback, add:

  const [animatingApps, setAnimatingApps] = useState<Map<string, 'expanding' | 'collapsing'>>(new Map());
  const animationTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());

  const animateToggle = useCallback((appIdToToggle: string) => {
    // Clear any existing timer for this app
    const existing = animationTimers.current.get(appIdToToggle);
    if (existing) clearTimeout(existing);

    const isCurrentlyExpanded = expandedApps.has(appIdToToggle);

    if (isCurrentlyExpanded) {
      // Collapsing: start animation, then remove from expandedApps after transition
      setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'collapsing'));
      const timer = setTimeout(() => {
        setExpandedApps((prev) => {
          const next = new Set(prev);
          next.delete(appIdToToggle);
          return next;
        });
        setAnimatingApps((prev) => {
          const next = new Map(prev);
          next.delete(appIdToToggle);
          return next;
        });
        animationTimers.current.delete(appIdToToggle);
      }, 200);
      animationTimers.current.set(appIdToToggle, timer);
    } else {
      // Expanding: add to expandedApps immediately, animate in
      setExpandedApps((prev) => new Set(prev).add(appIdToToggle));
      setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'expanding'));
      // Use requestAnimationFrame to ensure the collapsed state renders first
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          setAnimatingApps((prev) => {
            const next = new Map(prev);
            next.delete(appIdToToggle);
            return next;
          });
        });
      });
    }
  }, [expandedApps]);

  // Cleanup timers on unmount
  useEffect(() => {
    return () => {
      animationTimers.current.forEach((timer) => clearTimeout(timer));
    };
  }, []);
  • Step 3: Update compact grid to use animateToggle and animation classes

In the compact grid section of the JSX, replace the expanded card wrapper and the CompactAppCard's onExpand to use animateToggle instead of toggleAppExpanded.

For the expanded card inside the compact grid, wrap the GroupCard in the animation wrapper:

Change the expanded card branch from:

            expandedApps.has(group.appId) ? (
              <div key={group.appId} className={styles.compactGridExpanded}>
                <GroupCard

to:

            expandedApps.has(group.appId) ? (
              <div
                key={group.appId}
                className={`${styles.compactGridExpanded} ${styles.expandWrapper} ${
                  animatingApps.get(group.appId) === 'expanding'
                    ? styles.expandWrapperCollapsed
                    : animatingApps.get(group.appId) === 'collapsing'
                      ? styles.expandWrapperCollapsed
                      : styles.expandWrapperExpanded
                }`}
              >
                <GroupCard

Update the collapse button onClick:

                        onClick={() => animateToggle(group.appId)}

Update the CompactAppCard's onExpand:

              <CompactAppCard
                key={group.appId}
                group={group}
                onExpand={() => animateToggle(group.appId)}
              />
  • Step 4: Verify build

Run: cd ui && npx vite build --mode development 2>&1 | tail -5 Expected: build succeeds

  • Step 5: Commit
git add ui/src/pages/AgentHealth/AgentHealth.tsx
git commit -m "feat: add expand/collapse animation for compact card toggle"

Task 5: Visual testing and polish

Files:

  • Modify: ui/src/pages/AgentHealth/AgentHealth.tsx (if adjustments needed)

  • Modify: ui/src/pages/AgentHealth/AgentHealth.module.css (if adjustments needed)

  • Step 1: Start the dev server

Run: cd ui && npm run dev

  • Step 2: Test compact view default

Open the runtime dashboard in a browser. Verify:

  • All apps render as compact cards (tinted background, left border, health colors)

  • The view toggle shows the grid icon as active

  • Cards display app name, live count (x/y), last heartbeat

  • Step 3: Test single card expand

Click a compact card. Verify:

  • It expands inline with animation (fade in + grow)

  • The expanded card shows the full agent table (DataTable)

  • It spans 2 columns in the grid

  • A collapse button (ChevronDown) appears in the header

  • Step 4: Test single card collapse

Click the collapse button on an expanded card. Verify:

  • It animates back to compact (fade out + shrink)

  • Returns to compact card form

  • Step 5: Test expand all

Click the list icon in the view toggle. Verify:

  • All cards instantly switch to expanded GroupCards (no animation)

  • The list icon is now active

  • Step 6: Test collapse all

Click the grid icon in the view toggle. Verify:

  • All cards instantly switch to compact cards (no animation)

  • Per-app overrides are cleared

  • Step 7: Test localStorage persistence

Verify:

  • Switch to expanded mode, refresh the page — stays in expanded mode

  • Switch to compact mode, refresh the page — stays in compact mode

  • Step 8: Test app-scoped view (single app route)

Navigate to a specific app route (e.g., /runtime/some-app). Verify:

  • The view toggle still shows but the app-scoped layout uses groupGridSingle (full width)

  • Compact mode is not used when a specific app is selected (isFullWidth)

  • Step 9: Fix any visual issues found

Adjust padding, font sizes, colors, or grid breakpoints as needed. All colors must use CSS variables.

  • Step 10: Commit
git add ui/src/pages/AgentHealth/AgentHealth.tsx ui/src/pages/AgentHealth/AgentHealth.module.css
git commit -m "fix: polish compact view styling after visual testing"

Task 6: Update rules file

Files:

  • Modify: .claude/rules/ui.md

  • Step 1: Add compact view to Runtime description

In .claude/rules/ui.md, change:

- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`)

to:

- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`). AgentHealth supports compact view (dense health-tinted cards) and expanded view (full GroupCard+DataTable per app). View mode persisted to localStorage.
  • Step 2: Commit
git add .claude/rules/ui.md
git commit -m "docs: add compact view to runtime section of ui rules"