Files
cameleer-server/docs/superpowers/plans/2026-04-23-checkpoints-grid-row.md
2026-04-23 16:54:42 +02:00

36 KiB
Raw Blame History

Checkpoints Grid Row 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: Move the Checkpoints section into the Identity & Artifact config grid as an in-grid row, localize the Deployed-column sub-line, and remove the now-redundant History disclosure from the Deployment tab.

Architecture: CheckpointsTable is rewritten to return a React.Fragment whose children become direct grid cells inside IdentitySection's .configGrid. Label + trigger occupy one grid row; when opened, a second grid row spans both columns for the full-width table. IdentitySection gains a checkpointsSlot prop (distinct from children, which continues to host the portal-rendered drawer). The Deployed cell's ISO sub-line is replaced by new Date(iso).toLocaleString() inline. HistoryDisclosure is deleted — the checkpoints table + drawer now cover its job.

Tech Stack: React 18 + TypeScript, CSS modules, Vitest + React Testing Library.

Spec: docs/superpowers/specs/2026-04-23-checkpoints-grid-row-design.md


Files touched (summary)

Path Change
ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx Add checkpointsSlot?: ReactNode prop; render inside .configGrid after the JAR row
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx Rewrite as React.Fragment (label span + trigger cell + optional full-width row); swap ISO sub-line for toLocaleString()
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx Update expand() helper (button name) + add locale sub-line assertion
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css Add .checkpointsTriggerCell, .checkpointsTrigger, .checkpointsTableFullRow; remove obsolete classes
ui/src/pages/AppsTab/AppDeploymentPage/index.tsx Route CheckpointsTable via checkpointsSlot; drawer stays in children
ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx Remove HistoryDisclosure import + render
ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx Delete

Commands — run from ui/

  • Test one file: npx vitest run <relative-path>
  • All tests: npx vitest run
  • Typecheck: npm run typecheck

Task 1: IdentitySection accepts checkpointsSlot

Why: Give the parent page a way to inject the checkpoints row inside the grid, separate from children (which still hosts the drawer portal).

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx

  • Step 1.1: Add the prop to the interface

Open ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx. Find IdentitySectionProps (around line 18-29):

interface IdentitySectionProps {
  mode: 'net-new' | 'deployed';
  environment: Environment;
  app: App | null;
  currentVersion: AppVersion | null;
  name: string;
  onNameChange: (next: string) => void;
  stagedJar: File | null;
  onStagedJarChange: (file: File | null) => void;
  deploying: boolean;
  children?: ReactNode;
}

Add the checkpointsSlot field:

interface IdentitySectionProps {
  mode: 'net-new' | 'deployed';
  environment: Environment;
  app: App | null;
  currentVersion: AppVersion | null;
  name: string;
  onNameChange: (next: string) => void;
  stagedJar: File | null;
  onStagedJarChange: (file: File | null) => void;
  deploying: boolean;
  checkpointsSlot?: ReactNode;
  children?: ReactNode;
}
  • Step 1.2: Destructure checkpointsSlot + render inside the grid

Find the function signature:

export function IdentitySection({
  mode, environment, app, currentVersion,
  name, onNameChange, stagedJar, onStagedJarChange, deploying, children,
}: IdentitySectionProps) {

Add checkpointsSlot to the destructure list:

export function IdentitySection({
  mode, environment, app, currentVersion,
  name, onNameChange, stagedJar, onStagedJarChange, deploying,
  checkpointsSlot, children,
}: IdentitySectionProps) {

Then find the closing of the Application JAR row, which looks like:

        <span className={styles.configLabel}>Application JAR</span>
        <div className={styles.fileRow}>
          <input
            ref={fileInputRef}
            type="file"
            accept=".jar"
            className={styles.visuallyHidden}
            onChange={(e) => onStagedJarChange(e.target.files?.[0] ?? null)}
          />
          <Button
            size="sm"
            variant="secondary"
            type="button"
            onClick={() => fileInputRef.current?.click()}
            disabled={deploying}
          >
            {currentVersion ? 'Change JAR' : 'Select JAR'}
          </Button>
          {stagedJar && (
            <span className={styles.stagedJar}>
              staged: {stagedJar.name} ({formatBytes(stagedJar.size)})
            </span>
          )}
        </div>
      </div>
      {children}
    </div>
  );
}

Change the closing to render checkpointsSlot INSIDE the grid, just before </div> that closes .configGrid:

        <span className={styles.configLabel}>Application JAR</span>
        <div className={styles.fileRow}>
          <input
            ref={fileInputRef}
            type="file"
            accept=".jar"
            className={styles.visuallyHidden}
            onChange={(e) => onStagedJarChange(e.target.files?.[0] ?? null)}
          />
          <Button
            size="sm"
            variant="secondary"
            type="button"
            onClick={() => fileInputRef.current?.click()}
            disabled={deploying}
          >
            {currentVersion ? 'Change JAR' : 'Select JAR'}
          </Button>
          {stagedJar && (
            <span className={styles.stagedJar}>
              staged: {stagedJar.name} ({formatBytes(stagedJar.size)})
            </span>
          )}
        </div>

        {checkpointsSlot}
      </div>
      {children}
    </div>
  );
}

checkpointsSlot is expected to be a React.Fragment whose inner children are grid-direct cells.

  • Step 1.3: Typecheck

Run (from ui/): npm run typecheck Expected: PASS. (Call sites don't pass checkpointsSlot yet — that's fine because the prop is optional.)

  • Step 1.4: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx
git commit -m "feat(ui): IdentitySection accepts checkpointsSlot rendered inside configGrid"

Task 2: Rewrite CheckpointsTable as grid-fragment + locale sub-line

Why: The table must emit grid-ready children (label + trigger cell + optional full-width row), drop its own wrapping <div>, and localize the Deployed sub-line.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx

  • Step 2.1: Replace the full file contents

Replace the entire contents of ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx with:

import { useState } from 'react';
import { Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
import { timeAgo } from '../../../utils/format-utils';
import styles from './AppDeploymentPage.module.css';

const FALLBACK_CAP = 10;

interface CheckpointsTableProps {
  deployments: Deployment[];
  versions: AppVersion[];
  currentDeploymentId: string | null;
  jarRetentionCount: number | null;
  onSelect: (deploymentId: string) => void;
}

export function CheckpointsTable({
  deployments,
  versions,
  currentDeploymentId,
  jarRetentionCount,
  onSelect,
}: CheckpointsTableProps) {
  const [expanded, setExpanded] = useState(false);
  const [open, setOpen] = useState(false);
  const versionMap = new Map(versions.map((v) => [v.id, v]));

  const checkpoints = deployments
    .filter((d) => d.deployedConfigSnapshot && d.id !== currentDeploymentId)
    .sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));

  if (checkpoints.length === 0) {
    return null;
  }

  const cap = jarRetentionCount && jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
  const visible = expanded ? checkpoints : checkpoints.slice(0, cap);
  const hidden = checkpoints.length - visible.length;

  return (
    <>
      <span className={styles.configLabel}>Checkpoints</span>
      <div className={styles.checkpointsTriggerCell}>
        <button
          type="button"
          className={styles.checkpointsTrigger}
          onClick={() => setOpen((v) => !v)}
          aria-expanded={open}
        >
          <span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
          {open ? 'Collapse' : 'Expand'} ({checkpoints.length})
        </button>
      </div>
      {open && (
        <div className={styles.checkpointsTableFullRow}>
          <div className={styles.checkpointsTable}>
            <table>
              <thead>
                <tr>
                  <th>Version</th>
                  <th>JAR</th>
                  <th>Deployed by</th>
                  <th>Deployed</th>
                  <th>Strategy</th>
                  <th>Outcome</th>
                  <th aria-label="open"></th>
                </tr>
              </thead>
              <tbody>
                {visible.map((d) => {
                  const v = versionMap.get(d.appVersionId);
                  const archived = !v;
                  const strategyLabel =
                    d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
                  return (
                    <tr
                      key={d.id}
                      className={archived ? styles.checkpointArchived : undefined}
                      onClick={() => onSelect(d.id)}
                    >
                      <td>
                        <Badge label={v ? `v${v.version}` : '?'} color="auto" />
                      </td>
                      <td className={styles.jarCell}>
                        {v ? (
                          <span className={styles.jarName}>{v.jarFilename}</span>
                        ) : (
                          <>
                            <span className={styles.jarStrike}>JAR pruned</span>
                            <div className={styles.archivedHint}>archived  JAR pruned</div>
                          </>
                        )}
                      </td>
                      <td>
                        {d.createdBy ?? <span className={styles.muted}></span>}
                      </td>
                      <td>
                        {d.deployedAt && timeAgo(d.deployedAt)}
                        <div className={styles.isoSubline}>
                          {d.deployedAt && new Date(d.deployedAt).toLocaleString()}
                        </div>
                      </td>
                      <td>
                        <span className={styles.strategyPill}>{strategyLabel}</span>
                      </td>
                      <td>
                        <span
                          className={`${styles.outcomePill} ${styles[`outcome-${d.status}` as keyof typeof styles] || ''}`}
                        >
                          {d.status}
                        </span>
                      </td>
                      <td className={styles.chevron}></td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
            {hidden > 0 && !expanded && (
              <button
                type="button"
                className={styles.showOlderBtn}
                onClick={() => setExpanded(true)}
              >
                Show older ({hidden})  archived, postmortem only
              </button>
            )}
          </div>
        </div>
      )}
    </>
  );
}

Key points:

  • Returns <>...</> (React.Fragment). Its children become direct children of the parent .configGrid.

  • Trigger button text: Expand (N) when closed, Collapse (N) when open.

  • .checkpointsTableFullRow wraps the inner .checkpointsTable so the full-width row owns the grid span.

  • The Deployed sub-line uses new Date(d.deployedAt).toLocaleString() instead of the raw ISO.

  • The "Show older" button sits inside the full-width row (unchanged behavior).

  • Step 2.2: Typecheck

Run: npm run typecheck Expected: PASS (no callers pass new props; the component's signature is unchanged).

Note: tests will now FAIL until Task 3 updates them — this is expected. Do not run tests here.

  • Step 2.3: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx
git commit -m "feat(ui): CheckpointsTable emits grid fragment + locale sub-line"

Task 3: Update CheckpointsTable.test.tsx

Why: The trigger button's accessible name changed from /checkpoints \(1\)/i to /expand|collapse/i. Also add a locale sub-line assertion.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx

  • Step 3.1: Replace the full test file

Replace the entire contents of ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx with:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@cameleer/design-system';
import type { ReactNode } from 'react';
import { CheckpointsTable } from './CheckpointsTable';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';

function wrap(ui: ReactNode) {
  return render(<ThemeProvider>{ui}</ThemeProvider>);
}

const v6: AppVersion = {
  id: 'v6id', appId: 'a', version: 6, jarPath: '/j', jarChecksum: 'c',
  jarFilename: 'my-app-1.2.3.jar', jarSizeBytes: 1, detectedRuntimeType: null,
  detectedMainClass: null, uploadedAt: '2026-04-23T10:00:00Z',
};

const stoppedDep: Deployment = {
  id: 'd1', appId: 'a', appVersionId: 'v6id', environmentId: 'e',
  status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
  replicaStates: [{ index: 0, containerId: 'c', containerName: 'n', status: 'STOPPED' }],
  deployStage: null, containerId: null, containerName: null, errorMessage: null,
  deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
  createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
  deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
};

function expand() {
  fireEvent.click(screen.getByRole('button', { name: /expand|collapse/i }));
}

describe('CheckpointsTable', () => {
  it('defaults to collapsed — label + trigger visible, rows hidden', () => {
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expect(screen.getByText('Checkpoints')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /expand \(1\)/i })).toBeInTheDocument();
    expect(screen.queryByText('v6')).toBeNull();
    expect(screen.queryByText('my-app-1.2.3.jar')).toBeNull();
  });

  it('clicking trigger expands to show rows; label flips to Collapse', () => {
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expand();
    expect(screen.getByText('v6')).toBeInTheDocument();
    expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
    expect(screen.getByText('alice')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /collapse \(1\)/i })).toBeInTheDocument();
  });

  it('row click invokes onSelect with deploymentId', () => {
    const onSelect = vi.fn();
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
    expand();
    fireEvent.click(screen.getByText('v6').closest('tr')!);
    expect(onSelect).toHaveBeenCalledWith('d1');
  });

  it('renders em-dash for null createdBy', () => {
    const noActor = { ...stoppedDep, createdBy: null };
    wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expand();
    expect(screen.getByText('—')).toBeInTheDocument();
  });

  it('marks pruned-JAR rows as archived', () => {
    const pruned = { ...stoppedDep, appVersionId: 'unknown' };
    wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expand();
    expect(screen.getByText(/archived/i)).toBeInTheDocument();
  });

  it('renders nothing when there are no checkpoints', () => {
    const { container } = wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
    expect(container).toBeEmptyDOMElement();
  });

  it('caps visible rows at jarRetentionCount and shows expander', () => {
    const many: Deployment[] = Array.from({ length: 10 }, (_, i) => ({
      ...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
    }));
    wrap(<CheckpointsTable deployments={many} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
    expand();
    expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4);
    expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
  });

  it('shows all rows when jarRetentionCount >= total', () => {
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
    expand();
    expect(screen.queryByText(/show older/i)).toBeNull();
  });

  it('falls back to default cap of 10 when jarRetentionCount is 0 or null', () => {
    const fifteen: Deployment[] = Array.from({ length: 15 }, (_, i) => ({
      ...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
    }));
    wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
    expand();
    expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
  });

  it('Deployed sub-line is locale-formatted (not the raw ISO)', () => {
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expand();
    // Raw ISO would contain 'T' and end in 'Z' — localized form must not.
    const raw = '2026-04-23T10:35:00Z';
    expect(screen.queryByText(raw)).toBeNull();
    // The actual sub-line must exist as SOME locale-rendered string near the row.
    // Use the createdBy cell as an anchor and walk to the sibling Deployed cell.
    const row = screen.getByText('alice').closest('tr')!;
    const cells = row.querySelectorAll('td');
    // Column order: Version, JAR, Deployed by, Deployed, Strategy, Outcome, chevron
    const deployedCell = cells[3];
    expect(deployedCell).toBeDefined();
    const text = deployedCell.textContent ?? '';
    expect(text).not.toContain('T10:35');
    expect(text).not.toContain('Z');
    expect(text.length).toBeGreaterThan(0);
  });
});

Key changes:

  • expand() now targets /expand|collapse/i.

  • The default-collapsed test checks for /expand \(1\)/i (trigger shows Expand (1) when closed).

  • A new post-expand assertion on /collapse \(1\)/i confirms the label flip.

  • One new test: Deployed sub-line is locale-formatted (not the raw ISO) — asserts neither 'T10:35' nor 'Z' appears in the Deployed cell's text content, and the cell has content. This avoids depending on the CI's locale while still proving the raw-ISO path is gone.

  • Step 3.2: Run tests — expect pass

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx Expected: PASS (10 tests).

If a test fails because the Deployed sub-line is locale-formatted anchor strategy doesn't find alice (e.g. because the parent still wrapped this in a plain <div> somewhere), STOP and report — the Task 2 rewrite is wrong.

  • Step 3.3: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx
git commit -m "test(ui): CheckpointsTable covers fragment layout + locale sub-line"

Task 4: CSS updates — add new classes, remove obsolete ones

Why: Support the in-grid layout; clean up dead CSS from the retired row-list view and HistoryDisclosure.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css

  • Step 4.1: Add the three new classes

Open ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css. Append at the end of the file (after all existing rules):

/* Checkpoints row — lives inside .configGrid */
.checkpointsTriggerCell {
  display: flex;
  align-items: center;
}

.checkpointsTrigger {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: none;
  border: none;
  padding: 0;
  color: var(--text-primary);
  cursor: pointer;
  font: inherit;
  text-align: left;
}

.checkpointsTrigger:hover {
  color: var(--amber);
}

.checkpointsTableFullRow {
  grid-column: 1 / -1;
  margin-top: 4px;
}
  • Step 4.2: Remove the obsolete .checkpointsSection / .checkpointsHeader / .checkpointsCount block

Find and delete this block (currently near the bottom, added in a prior task):

/* Collapsible Checkpoints header */
.checkpointsSection {
  display: flex;
  flex-direction: column;
}

.checkpointsHeader {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: none;
  border: none;
  padding: 8px 0;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
  cursor: pointer;
  text-align: left;
}

.checkpointsHeader:hover {
  color: var(--amber);
}

.checkpointsChevron {
  color: var(--text-muted);
  font-size: 11px;
  width: 12px;
  text-align: center;
}

.checkpointsCount {
  color: var(--text-muted);
  font-weight: 400;
}

Important: After deleting that block, re-add .checkpointsChevron (still used by the trigger button) as a standalone rule at the end of the file:

.checkpointsChevron {
  color: var(--text-muted);
  font-size: 11px;
  width: 12px;
  text-align: center;
}
  • Step 4.3: Remove retired row-list + history classes

Still in the same file, find and delete these rules (left over from the retired Checkpoints.tsx row-list and the soon-to-be-deleted HistoryDisclosure):

.checkpointsRow {
  margin-top: 8px;
}

.disclosureToggle {
  background: none;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  font-size: 13px;
  padding: 4px 0;
}

.checkpointList {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 6px 0 0 12px;
}

.checkpointRow {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
}

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

.checkpointArchived {
  color: var(--warning);
  font-size: 12px;
}

Do NOT delete .checkpointsTable tr.checkpointArchived { opacity: 0.55; } — that variant IS still used by the table body.

Also find and delete:

/* HistoryDisclosure */
.historyRow { margin-top: 16px; }
  • Step 4.4: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 4.5: Run table tests to confirm no missing-class regressions

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx Expected: PASS (10 tests).

  • Step 4.6: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "refactor(ui): checkpoints in-grid styles + drop retired row-list/history CSS"

Task 5: Wire CheckpointsTable through checkpointsSlot in the page

Why: Move the render from children into the new slot so the table ends up inside the grid; keep the drawer in children.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/index.tsx

  • Step 5.1: Update the <IdentitySection> render

Open ui/src/pages/AppsTab/AppDeploymentPage/index.tsx. Find the <IdentitySection> JSX (around lines 437-474):

      <IdentitySection
        mode={mode}
        environment={env}
        app={app}
        currentVersion={currentVersion}
        name={name}
        onNameChange={setName}
        stagedJar={stagedJar}
        onStagedJarChange={setStagedJar}
        deploying={deploymentInProgress}
      >
        {app && (
          <>
            <CheckpointsTable
              deployments={deployments}
              versions={versions}
              currentDeploymentId={currentDeployment?.id ?? null}
              jarRetentionCount={jarRetentionCount}
              onSelect={setSelectedCheckpointId}
            />
            {selectedDep && (
              <CheckpointDetailDrawer
                open
                onClose={() => setSelectedCheckpointId(null)}
                deployment={selectedDep}
                version={selectedDepVersion}
                appSlug={app.slug}
                envSlug={selectedEnv ?? ''}
                currentForm={form}
                onRestore={(deploymentId) => {
                  handleRestore(deploymentId);
                  setSelectedCheckpointId(null);
                }}
              />
            )}
          </>
        )}
      </IdentitySection>

Replace with:

      <IdentitySection
        mode={mode}
        environment={env}
        app={app}
        currentVersion={currentVersion}
        name={name}
        onNameChange={setName}
        stagedJar={stagedJar}
        onStagedJarChange={setStagedJar}
        deploying={deploymentInProgress}
        checkpointsSlot={
          app ? (
            <CheckpointsTable
              deployments={deployments}
              versions={versions}
              currentDeploymentId={currentDeployment?.id ?? null}
              jarRetentionCount={jarRetentionCount}
              onSelect={setSelectedCheckpointId}
            />
          ) : undefined
        }
      >
        {app && selectedDep && (
          <CheckpointDetailDrawer
            open
            onClose={() => setSelectedCheckpointId(null)}
            deployment={selectedDep}
            version={selectedDepVersion}
            appSlug={app.slug}
            envSlug={selectedEnv ?? ''}
            currentForm={form}
            onRestore={(deploymentId) => {
              handleRestore(deploymentId);
              setSelectedCheckpointId(null);
            }}
          />
        )}
      </IdentitySection>

Key difference: <CheckpointsTable> moves into the new checkpointsSlot prop; the drawer stays as the only children.

  • Step 5.2: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 5.3: Run all deployment page tests

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage Expected: PASS.

  • Step 5.4: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
git commit -m "refactor(ui): route CheckpointsTable via IdentitySection.checkpointsSlot"

Task 6: Remove HistoryDisclosure from the Deployment tab

Why: The checkpoints table + drawer now cover the same information; History is dead weight.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx

  • Delete: ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx

  • Step 6.1: Remove the import and render from DeploymentTab.tsx

Open ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx. It currently looks like:

import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { DeploymentProgress } from '../../../../components/DeploymentProgress';
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
import { EmptyState } from '@cameleer/design-system';
import { StatusCard } from './StatusCard';
import { HistoryDisclosure } from './HistoryDisclosure';
import styles from '../AppDeploymentPage.module.css';

interface Props {
  deployments: Deployment[];
  versions: AppVersion[];
  appSlug: string;
  envSlug: string;
  externalUrl: string;
}

export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl }: Props) {
  const latest = deployments
    .slice()
    .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;

  if (!latest) {
    return <EmptyState title="No deployments yet" description="Save your configuration and click Redeploy to launch." />;
  }

  const version = versions.find((v) => v.id === latest.appVersionId) ?? null;

  return (
    <div className={styles.deploymentTab}>
      <StatusCard
        deployment={latest}
        version={version}
        externalUrl={externalUrl}
      />
      {latest.status === 'STARTING' && (
        <DeploymentProgress currentStage={latest.deployStage} failed={false} />
      )}
      {latest.status === 'FAILED' && (
        <DeploymentProgress currentStage={latest.deployStage} failed />
      )}
      <StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug}
                       className={styles.logFill} />
      <HistoryDisclosure deployments={deployments} versions={versions}
                         appSlug={appSlug} envSlug={envSlug} />
    </div>
  );
}

Replace with:

import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { DeploymentProgress } from '../../../../components/DeploymentProgress';
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
import { EmptyState } from '@cameleer/design-system';
import { StatusCard } from './StatusCard';
import styles from '../AppDeploymentPage.module.css';

interface Props {
  deployments: Deployment[];
  versions: AppVersion[];
  appSlug: string;
  envSlug: string;
  externalUrl: string;
}

export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl }: Props) {
  const latest = deployments
    .slice()
    .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;

  if (!latest) {
    return <EmptyState title="No deployments yet" description="Save your configuration and click Redeploy to launch." />;
  }

  const version = versions.find((v) => v.id === latest.appVersionId) ?? null;

  return (
    <div className={styles.deploymentTab}>
      <StatusCard
        deployment={latest}
        version={version}
        externalUrl={externalUrl}
      />
      {latest.status === 'STARTING' && (
        <DeploymentProgress currentStage={latest.deployStage} failed={false} />
      )}
      {latest.status === 'FAILED' && (
        <DeploymentProgress currentStage={latest.deployStage} failed />
      )}
      <StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug}
                       className={styles.logFill} />
    </div>
  );
}

Changes: remove the import { HistoryDisclosure } line and the <HistoryDisclosure ... /> JSX render at the bottom. Note that versions is still used (line with const version = ...), so keep the versions prop in the destructure.

  • Step 6.2: Delete HistoryDisclosure.tsx

Delete the file:

rm ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx
  • Step 6.3: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 6.4: Run deployment page tests

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage Expected: PASS.

  • Step 6.5: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx
git commit -m "ui(deploy): remove redundant HistoryDisclosure from Deployment tab"

Task 7: Final verification + rules + reindex

Why: Confirm the full suite is green; update .claude/rules/ui.md so future sessions know Checkpoints lives inside the Identity grid and History is gone.

Files:

  • Modify: .claude/rules/ui.md

  • Step 7.1: Run the entire UI test suite

Run (from ui/): npx vitest run Expected: PASS.

  • Step 7.2: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 7.3: Update .claude/rules/ui.md

Open .claude/rules/ui.md and locate the Deployments bullet that begins "Checkpoints render as a collapsible CheckpointsTable…". Replace the full sentence with:

"Checkpoints render as a collapsible CheckpointsTable (default collapsed) inside the Identity & Artifact configGrid as an in-grid row (Checkpoints | ▸ Expand (N)). When expanded, the table spans both grid columns via grid-column: 1 / -1. Columns: Version · JAR (filename) · Deployed by · Deployed (relative + user-locale sub-line via toLocaleString()) · Strategy · Outcome · . Row click opens CheckpointDetailDrawer. Drawer tabs are ordered Config | Logs with Config as the default. Replica filter uses DS Select. Restore lives in the drawer footer. Visible row cap = Environment.jarRetentionCount (default 10 if 0/null); older rows accessible via 'Show older (N)' expander. Currently-running deployment is excluded — represented separately by StatusCard. The empty-checkpoints case returns null (no row)."

Then locate the Deployment-tab bullet that starts "Deployment tab: StatusCard + DeploymentProgress … + HistoryDisclosure." and replace with:

"Deployment tab: StatusCard + DeploymentProgress (during STARTING / FAILED) + flex-grow StartupLogPanel (no fixed maxHeight). Auto-activates when a deploy starts. HistoryDisclosure was retired — per-deployment config and logs live in the Checkpoints drawer. StartupLogPanel header mirrors the Runtime Application Log pattern: title + live/stopped badge + N entries + sort toggle (↑/↓, default desc) + refresh icon (RefreshCw). Sort drives the backend fetch via useStartupLogs(…, sort). Refresh scrolls to the latest edge (top for desc, bottom for asc). Sort + refresh buttons disable while a refetch is in flight."

  • Step 7.4: Commit rules update
git add .claude/rules/ui.md
git commit -m "docs(rules): checkpoints live inside Identity grid; HistoryDisclosure retired"
  • Step 7.5: Run GitNexus re-index

Run (from repo root): npx gitnexus analyze --embeddings Expected: Indexed successfully.

  • Step 7.6: Manual smoke (human-run, report in PR body)

Start npm run dev in ui/ (backend on :8081). Verify:

  • Identity & Artifact section shows Checkpoints | ▸ Expand (N) as the last row. Collapsed on load.
  • Clicking the trigger reveals the full table spanning both grid columns; button flips to ▾ Collapse (N).
  • Deployed column shows 5h ago as primary; sub-line shows a localized date/time (e.g. 4/23/2026, 12:35:00 PM in en-US).
  • Click a row → CheckpointDetailDrawer opens on Config tab (unchanged).
  • App with no checkpoints: no Checkpoints row at all.
  • Deployment tab no longer shows the History disclosure below Startup Logs.

Self-review

Spec coverage:

  • §2.1 Checkpoints row in the grid → Tasks 1, 2, 4, 5 ✓
  • §2.2 Deployed locale sub-line → Task 2 (component change) + Task 3 (test) ✓
  • §2.3 Remove History → Task 6 ✓
  • §3 Page wiring (split slot from children) → Task 5 ✓
  • §4 Files list — every file mentioned has a touch task ✓
  • §5 Testing — Task 3 updates 9 existing tests + adds the locale assertion ✓
  • §6 Non-goals respected: CheckpointDetailDrawer, timeAgo, drawer layout, other callers of timeAgo are untouched ✓

Placeholder scan: No "TBD"/"TODO"/"similar to Task N"/"add appropriate X". Every step has either a concrete code block or an exact shell command. Step 3.2's failure path names what would be wrong and says to STOP and report — concrete guidance, not a placeholder.

Type consistency:

  • checkpointsSlot?: ReactNode appears consistently in Task 1 (prop), Task 5 (caller).
  • .checkpointsChevron is removed as part of a block in Task 4.2 and re-added standalone in the same step — explicit handling, no dangling references. CheckpointsTable in Task 2 references it.
  • .checkpointsTriggerCell, .checkpointsTrigger, .checkpointsTableFullRow are defined once in Task 4.1 and referenced in Task 2.
  • new Date(iso).toLocaleString() pattern appears once in Task 2 and is asserted in Task 3.

No gaps or inconsistencies.