Add artifact-aware checkpoints

Checkpoints can now hold attached artifacts — text content captured
from chat messages or added manually. Artifacts are included in
prompt context when a checkpoint is active, grounding reasoning in
actual content rather than just summaries. Add capture button on
messages, artifact management in checkpoint modal, and collapsible
artifact display in checkpoint detail.
This commit is contained in:
Himanshu Dongre 2026-04-04 21:35:41 +05:30
parent 2f155e8dcc
commit d01a180975
7 changed files with 212 additions and 16 deletions

View file

@ -0,0 +1,30 @@
"""add_artifacts_to_commits
Revision ID: c4d5e6f7a8b9
Revises: b3c4d5e6f7a8
Create Date: 2026-04-04 21:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
# revision identifiers, used by Alembic.
revision: str = 'c4d5e6f7a8b9'
down_revision: Union[str, None] = 'b3c4d5e6f7a8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'commits',
sa.Column('artifacts', JSONB(), nullable=False, server_default='[]'),
)
def downgrade() -> None:
op.drop_column('commits', 'artifacts')

View file

@ -154,6 +154,17 @@ def build_prompt_from_checkpoints(checkpoints: list[CommitModel], recent_message
for t in ckpt.tasks:
lines.append(f"- {t}")
lines.append("")
if ckpt.artifacts:
lines.append(f"{label} Attached Artifacts:")
for art in ckpt.artifacts:
art_label = art.get('label', 'Untitled') if isinstance(art, dict) else 'Untitled'
art_content = art.get('content', '') if isinstance(art, dict) else str(art)
# Cap each artifact at 2000 chars to manage prompt size
if len(art_content) > 2000:
art_content = art_content[:2000] + "\n[… truncated]"
lines.append(f"\n[{art_label}]:")
lines.append(art_content)
lines.append("")
if recent_messages:
lines.append("Recent Conversation:")
@ -260,6 +271,7 @@ class ManualCommitRequest(BaseModel):
tasks: list[str] = Field(default_factory=list)
open_questions: list[str] = Field(default_factory=list)
entities: list[str] = Field(default_factory=list)
artifacts: list[dict] = Field(default_factory=list)
class CommitResponse(BaseModel):
@ -276,6 +288,7 @@ class CommitResponse(BaseModel):
tasks: list
open_questions: list
entities: list
artifacts: list
created_at: datetime
model_config = {"from_attributes": True}
@ -633,6 +646,7 @@ def manual_commit(payload: ManualCommitRequest, db: Session = Depends(get_db)):
tasks=payload.tasks,
open_questions=payload.open_questions,
entities=payload.entities,
artifacts=payload.artifacts,
metadata_={"session_id": str(session_id)},
)
db.add(commit)

View file

@ -111,6 +111,7 @@ class CheckpointDetail(BaseModel):
assumptions: list
tasks: list
open_questions: list
artifacts: list
class CheckpointDiff(BaseModel):
@ -153,6 +154,7 @@ class ReachableCheckpoint(BaseModel):
tasks: list
open_questions: list
entities: list
artifacts: list
context_blob: dict
raw_source_text: Optional[str]
metadata_: dict = Field(serialization_alias="metadata")
@ -325,6 +327,7 @@ def compare_checkpoints(a_id: uuid.UUID, b_id: uuid.UUID, db: Session = Depends(
assumptions=[_extract_text(a) for a in (c.assumptions or [])],
tasks=[_extract_text(t) for t in (c.tasks or [])],
open_questions=[_extract_text(q) for q in (c.open_questions or [])],
artifacts=c.artifacts or [],
)
return CompareResponse(

View file

@ -173,6 +173,7 @@ class CommitModel(Base):
tasks: Mapped[dict] = mapped_column(JSONB, default=list)
open_questions: Mapped[dict] = mapped_column(JSONB, default=list)
entities: Mapped[dict] = mapped_column(JSONB, default=list)
artifacts: Mapped[dict] = mapped_column(JSONB, default=list)
context_blob: Mapped[dict] = mapped_column(JSONB, default=dict)
raw_source_text: Mapped[str | None] = mapped_column(Text, nullable=True)

View file

@ -254,6 +254,7 @@ export async function createChatCommit(payload: {
tasks?: string[];
open_questions?: string[];
entities?: string[];
artifacts?: { id: string; type: string; label: string; content: string }[];
}): Promise<import('../types').Commit> {
return requestV4<import('../types').Commit>('/chat/commit', {
method: 'POST',

View file

@ -22,7 +22,7 @@ import {
getSessionCheckpoints,
reviewCheckpoint,
} from '../api/client';
import type { ChatSession, Commit, CompareResponse, CheckpointReviewResponse, HeadState, TurnEvent, Repo, ProviderStatus } from '../types';
import type { Artifact, ChatSession, Commit, CompareResponse, CheckpointReviewResponse, HeadState, TurnEvent, Repo, ProviderStatus } from '../types';
import {
Check,
Copy,
@ -38,6 +38,10 @@ import {
Plus,
FolderInput,
RotateCcw,
Paperclip,
X,
ChevronDown,
ChevronRight,
} from 'lucide-react';
// ── Provider / model config ───────────────────────────────────────────────────
@ -146,7 +150,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
return nodes;
}
function MessageBubble({ turn, dimmed }: { turn: TurnEvent; dimmed?: boolean }) {
function MessageBubble({ turn, dimmed, onAddArtifact }: { turn: TurnEvent; dimmed?: boolean; onAddArtifact?: (content: string) => void }) {
const [copied, setCopied] = useState(false);
const isUser = turn.role === 'user';
@ -171,14 +175,25 @@ function MessageBubble({ turn, dimmed }: { turn: TurnEvent; dimmed?: boolean })
}
{!isUser && (
<button
onClick={handleCopy}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-800"
>
{copied
? <Check className="w-3 h-3 text-green-400" />
: <Copy className="w-3 h-3 text-gray-500" />}
</button>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5">
{onAddArtifact && (
<button
onClick={() => onAddArtifact(turn.content)}
title="Add as artifact"
className="p-1 rounded hover:bg-gray-800"
>
<Paperclip className="w-3 h-3 text-gray-500" />
</button>
)}
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-gray-800"
>
{copied
? <Check className="w-3 h-3 text-green-400" />
: <Copy className="w-3 h-3 text-gray-500" />}
</button>
</div>
)}
{!isUser && turn.model && (
@ -224,6 +239,29 @@ function RestoreDivider({ label }: { label: string }) {
);
}
function ArtifactPreview({ artifact }: { artifact: Artifact }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border border-gray-800/50 rounded px-2 py-1.5 bg-zinc-900/30">
<button
onClick={() => setExpanded(e => !e)}
className="flex items-center gap-1.5 w-full text-left"
>
{expanded
? <ChevronDown className="w-3 h-3 text-gray-600 flex-shrink-0" />
: <ChevronRight className="w-3 h-3 text-gray-600 flex-shrink-0" />}
<Paperclip className="w-2.5 h-2.5 text-gray-600 flex-shrink-0" />
<span className="text-[11px] text-gray-300 truncate">{artifact.label || 'Untitled'}</span>
</button>
{expanded && (
<pre className="mt-1.5 text-[10px] text-gray-500 font-mono whitespace-pre-wrap leading-relaxed max-h-32 overflow-y-auto border-t border-gray-800/50 pt-1.5">
{artifact.content}
</pre>
)}
</div>
);
}
interface CommitModalProps {
repoId: string;
sessionId: string;
@ -232,9 +270,17 @@ interface CommitModalProps {
providerStatus: Record<string, ProviderStatus> | null;
mountedCheckpointId?: string | null;
mountedAtSeq?: number | null;
initialArtifacts?: Artifact[];
}
function CommitModal({ repoId, sessionId, onClose, onCommitted, providerStatus, mountedCheckpointId, mountedAtSeq }: CommitModalProps) {
function _makeLabel(content: string): string {
// Generate a readable label: first sentence or first 50 chars
const firstLine = content.split('\n')[0].trim();
if (firstLine.length <= 50) return firstLine;
return firstLine.slice(0, 47) + '…';
}
function CommitModal({ repoId, sessionId, onClose, onCommitted, providerStatus, mountedCheckpointId, mountedAtSeq, initialArtifacts }: CommitModalProps) {
const [msg, setMsg] = useState('');
const [summary, setSummary] = useState('');
const [obj, setObj] = useState('');
@ -243,6 +289,7 @@ function CommitModal({ repoId, sessionId, onClose, onCommitted, providerStatus,
const [assumptions, setAssumptions] = useState('');
const [openQuestions, setOpenQuestions] = useState('');
const [entities, setEntities] = useState('');
const [artifacts, setArtifacts] = useState<Artifact[]>(initialArtifacts ?? []);
const [loading, setLoading] = useState(false);
const [drafting, setDrafting] = useState(false);
const [err, setErr] = useState<string | null>(null);
@ -296,6 +343,7 @@ function CommitModal({ repoId, sessionId, onClose, onCommitted, providerStatus,
assumptions: parseLines(assumptions),
open_questions: parseLines(openQuestions),
entities: parseLines(entities),
artifacts: artifacts.filter(a => a.content.trim()),
});
onCommitted(commit);
} catch (e: any) {
@ -433,6 +481,47 @@ function CommitModal({ repoId, sessionId, onClose, onCommitted, providerStatus,
/>
</div>
</div>
{/* Artifacts */}
{artifacts.length > 0 && (
<div className="space-y-2">
<p className="text-xs uppercase tracking-wider text-gray-500">Artifacts</p>
{artifacts.map((art, idx) => (
<div key={art.id} className="border border-gray-800 rounded-lg p-3 space-y-2 bg-zinc-900/30">
<div className="flex items-center gap-2">
<Paperclip className="w-3 h-3 text-gray-600 flex-shrink-0" />
<input
className="flex-1 bg-transparent border-b border-gray-800 text-sm text-white outline-none focus:border-gray-500 transition-colors py-0.5"
placeholder="Artifact label"
value={art.label}
onChange={e => setArtifacts(prev => prev.map((a, i) => i === idx ? { ...a, label: e.target.value } : a))}
/>
<button
onClick={() => setArtifacts(prev => prev.filter((_, i) => i !== idx))}
className="text-gray-600 hover:text-red-400 transition-colors p-0.5"
title="Remove artifact"
>
<X className="w-3 h-3" />
</button>
</div>
<textarea
className="w-full bg-zinc-900/50 border border-gray-800 rounded px-2 py-1.5 text-xs text-gray-300 outline-none focus:border-gray-500 transition-colors resize-none font-mono"
rows={3}
placeholder="Artifact content…"
value={art.content}
onChange={e => setArtifacts(prev => prev.map((a, i) => i === idx ? { ...a, content: e.target.value } : a))}
/>
</div>
))}
</div>
)}
<button
type="button"
onClick={() => setArtifacts(prev => [...prev, { id: crypto.randomUUID().slice(0, 8), type: 'text', label: '', content: '' }])}
className="text-[11px] text-gray-600 hover:text-gray-400 transition-colors flex items-center gap-1"
>
<Plus className="w-3 h-3" /> Add artifact
</button>
</div>
<div className="px-5 py-4 border-t border-gray-800">
@ -525,6 +614,18 @@ function CheckpointDetailPanel({ commit, onClose, providerStatus }: { commit: Co
{rows(commit.open_questions ?? [], 'Open Questions')}
{rows(commit.entities ?? [], 'Entities')}
{/* Artifacts — secondary, collapsible */}
{(commit.artifacts ?? []).length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-600 mb-1">Artifacts ({commit.artifacts.length})</p>
<div className="space-y-1">
{commit.artifacts.map((art: Artifact, i: number) => (
<ArtifactPreview key={art.id || i} artifact={art} />
))}
</div>
</div>
)}
{/* Review checkpoint */}
<div className="border-t border-gray-800 pt-3">
<button
@ -949,6 +1050,28 @@ function MemorySpacePanel({
{onlyB.map((d, i) => <div key={i} className="text-[11px] text-green-300 px-1.5 py-0.5 bg-green-900/10 rounded mb-0.5 truncate">{d}</div>)}
</div>
))}
{/* Artifact labels — lightweight, no content diff */}
{((compareResult.checkpoint_a.artifacts ?? []).length > 0 || (compareResult.checkpoint_b.artifacts ?? []).length > 0) && (
<div>
<p className="text-[9px] uppercase tracking-wider text-gray-600 mb-1">Artifacts</p>
<div className="grid grid-cols-2 gap-2 text-[10px]">
<div className="space-y-0.5">
{(compareResult.checkpoint_a.artifacts ?? []).map((a: Artifact, i: number) => (
<div key={i} className="text-gray-400 flex items-center gap-1 truncate">
<Paperclip className="w-2.5 h-2.5 flex-shrink-0" />{a.label || 'Untitled'}
</div>
))}
</div>
<div className="space-y-0.5">
{(compareResult.checkpoint_b.artifacts ?? []).map((a: Artifact, i: number) => (
<div key={i} className="text-gray-400 flex items-center gap-1 truncate">
<Paperclip className="w-2.5 h-2.5 flex-shrink-0" />{a.label || 'Untitled'}
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
@ -1146,6 +1269,20 @@ export function ChatWorkspacePage() {
const [mountedAtSeq, setMountedAtSeq] = useState<number | null>(null);
const [showHistoryPanel, setShowHistoryPanel] = useState(false);
const [forkTarget, setForkTarget] = useState<Commit | null>(null);
// Artifacts captured from messages, passed to CommitModal when opening
const [pendingArtifacts, setPendingArtifacts] = useState<Artifact[]>([]);
const handleAddArtifact = (content: string) => {
if (!repo) { setShowAttachModal(true); return; }
const artifact: Artifact = {
id: crypto.randomUUID().slice(0, 8),
type: 'text',
label: _makeLabel(content),
content,
};
setPendingArtifacts(prev => [...prev, artifact]);
setShowCommitModal(true);
};
// The checkpoint this session was forked from (permanent identity for forked sessions)
const [forkSourceCommit, setForkSourceCommit] = useState<Commit | null>(null);
// Increment after a checkpoint is created so MemorySpacePanel re-fetches its list
@ -1386,11 +1523,12 @@ export function ChatWorkspacePage() {
<CommitModal
repoId={repo?.id!}
sessionId={session.id}
onClose={() => setShowCommitModal(false)}
onCommitted={handleCommitted}
onClose={() => { setShowCommitModal(false); setPendingArtifacts([]); }}
onCommitted={(commit) => { handleCommitted(commit); setPendingArtifacts([]); }}
providerStatus={providerStatus}
mountedCheckpointId={mountedCheckpointId}
mountedAtSeq={mountedAtSeq}
initialArtifacts={pendingArtifacts}
/>
)}
@ -1689,7 +1827,7 @@ export function ChatWorkspacePage() {
{(() => {
const isRestored = mountedCheckpointId !== null && mountedAtSeq !== null;
if (!isRestored) {
return turns.map(t => <MessageBubble key={t.id} turn={t} />);
return turns.map(t => <MessageBubble key={t.id} turn={t} onAddArtifact={handleAddArtifact} />);
}
const cutoff = mountedAtSeq ?? -1;
@ -1699,7 +1837,7 @@ export function ChatWorkspacePage() {
return (
<>
{preTurns.map(t => <MessageBubble key={t.id} turn={t} dimmed />)}
{preTurns.map(t => <MessageBubble key={t.id} turn={t} dimmed onAddArtifact={handleAddArtifact} />)}
<RestoreDivider label={dividerLabel} />
{postTurns.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center space-y-2">
@ -1711,7 +1849,7 @@ export function ChatWorkspacePage() {
<>
{postTurns.map((t, i) => (
<React.Fragment key={t.id}>
<MessageBubble turn={t} />
<MessageBubble turn={t} onAddArtifact={handleAddArtifact} />
{i === 1 && postTurns.length <= 3 && t.role === 'assistant' && (
<div className="flex justify-start mb-4 ml-1">
<span className="text-[10px] text-amber-600/50 italic">

View file

@ -91,6 +91,13 @@ export interface Repo {
updated_at: string;
}
export interface Artifact {
id: string;
type: string;
label: string;
content: string;
}
export interface Commit {
id: string;
repo_id: string;
@ -107,6 +114,7 @@ export interface Commit {
tasks: string[];
open_questions: string[];
entities: string[];
artifacts: Artifact[];
context_blob: Record<string, unknown>;
raw_source_text: string | null;
metadata: Record<string, unknown>;
@ -219,6 +227,7 @@ export interface CheckpointDetail {
assumptions: string[];
tasks: string[];
open_questions: string[];
artifacts: Artifact[];
}
export interface CheckpointDiff {