diff --git a/backend/alembic/versions/2026_04_04_2100_c4d5e6f7a8b9_add_artifacts_to_commits.py b/backend/alembic/versions/2026_04_04_2100_c4d5e6f7a8b9_add_artifacts_to_commits.py new file mode 100644 index 0000000..84c09ef --- /dev/null +++ b/backend/alembic/versions/2026_04_04_2100_c4d5e6f7a8b9_add_artifacts_to_commits.py @@ -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') diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index 7ee1210..44f4729 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -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) diff --git a/backend/app/api/routes/lineage.py b/backend/app/api/routes/lineage.py index f5b7e9a..301e5c9 100644 --- a/backend/app/api/routes/lineage.py +++ b/backend/app/api/routes/lineage.py @@ -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( diff --git a/backend/app/db/models.py b/backend/app/db/models.py index e8dce02..23acbb5 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 64a6e30..1d2bde8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 { return requestV4('/chat/commit', { method: 'POST', diff --git a/frontend/src/pages/ChatWorkspacePage.tsx b/frontend/src/pages/ChatWorkspacePage.tsx index d10adf5..d185912 100644 --- a/frontend/src/pages/ChatWorkspacePage.tsx +++ b/frontend/src/pages/ChatWorkspacePage.tsx @@ -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 && ( - +
+ {onAddArtifact && ( + + )} + +
)} {!isUser && turn.model && ( @@ -224,6 +239,29 @@ function RestoreDivider({ label }: { label: string }) { ); } +function ArtifactPreview({ artifact }: { artifact: Artifact }) { + const [expanded, setExpanded] = useState(false); + return ( +
+ + {expanded && ( +
+          {artifact.content}
+        
+ )} +
+ ); +} + interface CommitModalProps { repoId: string; sessionId: string; @@ -232,9 +270,17 @@ interface CommitModalProps { providerStatus: Record | 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(initialArtifacts ?? []); const [loading, setLoading] = useState(false); const [drafting, setDrafting] = useState(false); const [err, setErr] = useState(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, /> + + {/* Artifacts */} + {artifacts.length > 0 && ( +
+

Artifacts

+ {artifacts.map((art, idx) => ( +
+
+ + setArtifacts(prev => prev.map((a, i) => i === idx ? { ...a, label: e.target.value } : a))} + /> + +
+