Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/agent-forge/src/components/formatTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,17 @@ export function formatTime(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toTimeString().slice(0, 8);
}

/**
* `just now` / `5m ago` / `2h ago` / `3d ago` for an ISO timestamp, or ''
* when it doesn't parse (same buckets as `fmtAge` in observe/format).
*/
export function relativeTime(iso: string, now: number = Date.now()): string {
const at = Date.parse(iso);
if (Number.isNaN(at)) return '';
const ageMs = now - at;
if (ageMs < 60_000) return 'just now';
if (ageMs < 3_600_000) return `${Math.round(ageMs / 60_000)}m ago`;
if (ageMs < 86_400_000) return `${Math.round(ageMs / 3_600_000)}h ago`;
return `${Math.round(ageMs / 86_400_000)}d ago`;
}
176 changes: 176 additions & 0 deletions apps/agent-forge/src/screens/agentDetail/VersionsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { useCallback, useState, type JSX } from 'react';
import { Flag, Pencil, PlusCircle, Rocket, RotateCcw, type LucideIcon } from 'lucide-react';

import { errorMessage } from '../../components/errorMessage';
import { relativeTime } from '../../components/formatTime';
import { runtimeClient } from '../../runtime/runtimeClient';
import { useToast } from '../../ui/Toast';
import { EmptyState } from '../../ui/EmptyState';
import { SkeletonRows } from '../../ui/Skeleton';
import type { VersionKind, VersionListItem } from '../../../shared/wireTypes';
import { useAgentDetail } from './AgentDetailContext';
import { fieldLabel, kindLabel, SOURCE_LABELS, summaryPending } from './versionLabels';
import { useVersions } from './useVersions';
import './versions.css';

/** The timeline dot's icon per version kind (14px, lucide). */
const KIND_ICONS: Record<VersionKind, LucideIcon> = {
create: PlusCircle,
update: Pencil,
restore: RotateCcw,
baseline: Flag,
publish: Rocket,
};

/** Field chips shown before the `+N` overflow chip. */
const MAX_FIELD_CHIPS = 4;

/**
* One timeline row (L01): number, kind/source, the "Current" pill on the
* newest item, relative time, the summary (skeleton while the summary
* pass is still writing it, the baseline hint for external edits), the
* quoted request text and the changed-field chips. `version-actions`
* stays empty - L02/L03/L14b add Changes/Restore/Publish buttons there.
*/
function VersionItem({ item, isCurrent }: { item: VersionListItem; isCurrent: boolean }): JSX.Element {
const Icon = KIND_ICONS[item.kind];
const pending = summaryPending(item);
const shown = item.changedFields.slice(0, MAX_FIELD_CHIPS);
const hidden = item.changedFields.length - shown.length;
return (
<li className="version-item" data-version={item.version} data-kind={item.kind} data-current={isCurrent ? 'true' : undefined}>
<span className="version-dot" aria-hidden="true">
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
</span>
<div className="version-body">
<div className="version-head">
<span className="version-number">v{item.version}</span>
<span className="version-kind">{kindLabel(item)}</span>
<span className="version-source">{SOURCE_LABELS[item.source]}</span>
{isCurrent && (
<span className="status-pill pill-info">
<span className="dot" aria-hidden="true" />
Current
</span>
)}
<time className="version-time" dateTime={item.createdAt} title={new Date(item.createdAt).toLocaleString()}>
{relativeTime(item.createdAt)}
</time>
</div>
{item.kind === 'baseline' ? (
<p className="version-summary version-baseline">Recorded before the file was edited outside Studio.</p>
) : pending ? (
<>
<span className="skeleton version-summary-skeleton" aria-hidden="true" />
<span className="visually-hidden">Writing a summary</span>
</>
) : item.summary !== null ? (
<p className="version-summary">{item.summary}</p>
) : null}
{item.requestText !== null && <blockquote className="version-request">“{item.requestText}”</blockquote>}
{item.changedFields.length > 0 && (
<ul className="version-fields" aria-label="Changed fields">
{shown.map((field) => (
<li key={field} className="chip">
{fieldLabel(field)}
</li>
))}
{hidden > 0 && <li className="chip">+{hidden}</li>}
</ul>
)}
<div className="version-actions" />
</div>
</li>
);
}

/**
* The Versions tab (L01, PLAN §11 `/agents/:id/versions`): every saved
* version newest-first on a timeline rail, refreshed live by `useVersions`
* (`version.summary` events fill skeletons, `agent.changed` re-fetches),
* plus the explicit "Save version" button. Reads the agent id from the
* detail context like the other tab bodies.
*/
export function VersionsTab(): JSX.Element {
const { record } = useAgentDetail();
const { items, error, refresh } = useVersions(record.id);
const toast = useToast();
const [saving, setSaving] = useState(false);

const saveVersion = useCallback(async () => {
setSaving(true);
try {
const { item, created } = await runtimeClient.saveVersion(record.id);
toast.show({
message: created ? `Saved v${item.version}` : `Nothing changed since v${item.version}`,
tone: created ? 'success' : 'info',
});
await refresh();
} catch (err) {
toast.show({ message: errorMessage(err), tone: 'error' });
} finally {
setSaving(false);
}
}, [record.id, refresh, toast]);

const saveButton = (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void saveVersion()}
disabled={saving}
aria-busy={saving || undefined}
>
Save version
</button>
);

return (
<section className="versions" aria-labelledby="versions-title">
<header className="versions-header">
<div>
<h2 id="versions-title" className="versions-title">
Versions
</h2>
<p className="versions-hint">Every save, restore and import is kept here. Restoring writes a new version.</p>
</div>
{saveButton}
</header>
{error !== undefined ? (
<EmptyState
title="Couldn't load versions"
hint={error}
action={
<button type="button" className="btn" onClick={() => void refresh()}>
Retry
</button>
}
/>
) : items === undefined ? (
<SkeletonRows rows={5} />
) : items.length === 0 ? (
<EmptyState
title="No versions yet"
hint="Save a version to start the history."
action={
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => void saveVersion()}
disabled={saving}
aria-busy={saving || undefined}
>
Save version
</button>
}
/>
) : (
<ol className="version-timeline">
{items.map((item, index) => (
<VersionItem key={item.version} item={item} isCurrent={index === 0} />
))}
</ol>
)}
</section>
);
}
Loading
Loading