diff --git a/docs/superpowers/plans/2026-04-23-checkpoints-grid-row.md b/docs/superpowers/plans/2026-04-23-checkpoints-grid-row.md new file mode 100644 index 00000000..94ac2620 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-checkpoints-grid-row.md @@ -0,0 +1,1016 @@ +# 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 ` +- **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 + Application JAR +
+ onStagedJarChange(e.target.files?.[0] ?? null)} + /> + + {stagedJar && ( + + staged: {stagedJar.name} ({formatBytes(stagedJar.size)}) + + )} +
+ + {children} + + ); +} +``` + +Change the closing to render `checkpointsSlot` INSIDE the grid, just before `` that closes `.configGrid`: + +```tsx + Application JAR +
+ onStagedJarChange(e.target.files?.[0] ?? null)} + /> + + {stagedJar && ( + + staged: {stagedJar.name} ({formatBytes(stagedJar.size)}) + + )} +
+ + {checkpointsSlot} + + {children} + + ); +} +``` + +`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 `
`, 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 ( + <> + Checkpoints +
+ +
+ {open && ( +
+
+ + + + + + + + + + + + + + {visible.map((d) => { + const v = versionMap.get(d.appVersionId); + const archived = !v; + const strategyLabel = + d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling'; + return ( + onSelect(d.id)} + > + + + + + + + + + ); + })} + +
VersionJARDeployed byDeployedStrategyOutcome
+ + + {v ? ( + {v.jarFilename} + ) : ( + <> + JAR pruned +
archived — JAR pruned
+ + )} +
+ {d.createdBy ?? } + + {d.deployedAt && timeAgo(d.deployedAt)} +
+ {d.deployedAt && new Date(d.deployedAt).toLocaleString()} +
+
+ {strategyLabel} + + + {d.status} + +
+ {hidden > 0 && !expanded && ( + + )} +
+
+ )} + + ); +} +``` + +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({ui}); +} + +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( {}} />); + 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( {}} />); + 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(); + 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( {}} />); + expand(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('marks pruned-JAR rows as archived', () => { + const pruned = { ...stoppedDep, appVersionId: 'unknown' }; + wrap( {}} />); + expand(); + expect(screen.getByText(/archived/i)).toBeInTheDocument(); + }); + + it('renders nothing when there are no checkpoints', () => { + const { container } = wrap( {}} />); + 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( {}} />); + expand(); + expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4); + expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument(); + }); + + it('shows all rows when jarRetentionCount >= total', () => { + wrap( {}} />); + 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( {}} />); + expand(); + expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument(); + }); + + it('Deployed sub-line is locale-formatted (not the raw ISO)', () => { + wrap( {}} />); + 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 `
` 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 `` render** + +Open `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx`. Find the `` JSX (around lines 437-474): + +```tsx + + {app && ( + <> + + {selectedDep && ( + setSelectedCheckpointId(null)} + deployment={selectedDep} + version={selectedDepVersion} + appSlug={app.slug} + envSlug={selectedEnv ?? ''} + currentForm={form} + onRestore={(deploymentId) => { + handleRestore(deploymentId); + setSelectedCheckpointId(null); + }} + /> + )} + + )} + +``` + +Replace with: + +```tsx + + ) : undefined + } + > + {app && selectedDep && ( + setSelectedCheckpointId(null)} + deployment={selectedDep} + version={selectedDepVersion} + appSlug={app.slug} + envSlug={selectedEnv ?? ''} + currentForm={form} + onRestore={(deploymentId) => { + handleRestore(deploymentId); + setSelectedCheckpointId(null); + }} + /> + )} + +``` + +Key difference: `` 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 ; + } + + const version = versions.find((v) => v.id === latest.appVersionId) ?? null; + + return ( +
+ + {latest.status === 'STARTING' && ( + + )} + {latest.status === 'FAILED' && ( + + )} + + +
+ ); +} +``` + +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 ; + } + + const version = versions.find((v) => v.id === latest.appVersionId) ?? null; + + return ( +
+ + {latest.status === 'STARTING' && ( + + )} + {latest.status === 'FAILED' && ( + + )} + +
+ ); +} +``` + +Changes: remove the `import { HistoryDisclosure }` line and the `` 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.