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

1017 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```tsx
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:
```tsx
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:
```tsx
export function IdentitySection({
mode, environment, app, currentVersion,
name, onNameChange, stagedJar, onStagedJarChange, deploying, children,
}: IdentitySectionProps) {
```
Add `checkpointsSlot` to the destructure list:
```tsx
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:
```tsx
<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`:
```tsx
<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**
```bash
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:
```tsx
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**
```bash
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:
```tsx
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**
```bash
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):
```css
/* 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):
```css
/* 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:
```css
.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`):
```css
.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:
```css
/* 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**
```bash
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):
```tsx
<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:
```tsx
<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**
```bash
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:
```tsx
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:
```tsx
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:
```bash
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**
```bash
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**
```bash
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.