feat(web): back review surfaces with backend data

This commit is contained in:
saravanakumardb1 2026-03-10 12:35:16 -07:00
parent 57d38762f0
commit 8bf0bb5452
8 changed files with 313 additions and 25 deletions

View File

@ -10,7 +10,6 @@ import { TaskReviewPanel } from "@/components/TaskReviewPanel";
import { ArtifactPanel } from "@/components/ArtifactPanel";
import { AgentTimeline } from "@/components/AgentTimeline";
import { getNoteDetail } from "@/lib/notes-client";
import { mockAgentTimeline } from "@/lib/review-data";
import type { NoteDetail } from "@/lib/types";
export default function NoteDetailPage() {
@ -57,7 +56,7 @@ export default function NoteDetailPage() {
<LinkedNotesPanel linkedNotes={note.linkedNotes} />
<TaskReviewPanel tasks={note.tasks} />
<ArtifactPanel artifacts={note.artifacts} />
<AgentTimeline items={mockAgentTimeline} />
<AgentTimeline items={note.timeline} />
</aside>
</div>
</AppShell>

View File

@ -1,10 +1,35 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { AgentTimeline } from "@/components/AgentTimeline";
import { ProposalReviewCard } from "@/components/ProposalReviewCard";
import { mockOperatorWorkflows } from "@/lib/mock-data";
import { mockAgentTimeline, mockApprovalQueue } from "@/lib/review-data";
import { listAgentTimeline, listApprovalQueue } from "@/lib/review-client";
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
export default function ReviewsPage() {
const [approvalQueue, setApprovalQueue] = useState<ApprovalQueueItem[]>([]);
const [timeline, setTimeline] = useState<AgentTimelineItem[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const [nextQueue, nextTimeline] = await Promise.all([
listApprovalQueue(),
listAgentTimeline(),
]);
setApprovalQueue(nextQueue);
setTimeline(nextTimeline);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load review queue");
}
})();
}, []);
const featuredProposal = useMemo(() => approvalQueue[0] ?? null, [approvalQueue]);
return (
<AppShell
title="Agent review"
@ -38,8 +63,9 @@ export default function ReviewsPage() {
<span className="badge">owner:any</span>
</div>
</div>
{error ? <div style={{ color: "var(--ml-text-secondary)" }}>{error}</div> : null}
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{mockApprovalQueue.map((item) => (
{approvalQueue.map((item) => (
<div key={item.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", alignItems: "center", flexWrap: "wrap" }}>
<div style={{ display: "grid", gap: 4 }}>
<strong>{item.title}</strong>
@ -55,13 +81,15 @@ export default function ReviewsPage() {
</section>
</section>
<ProposalReviewCard
title="Summary rewrite proposal"
before={"The note lists required launch items but mixes current scope with later operational work."}
after={"The note now separates must-ship launch items from explicitly deferred operational follow-ups, improving review clarity for both humans and agents."}
/>
{featuredProposal ? (
<ProposalReviewCard
title={featuredProposal.title}
before={featuredProposal.before ?? "No prior summary captured."}
after={featuredProposal.after ?? "No proposed change summary captured yet."}
/>
) : null}
<AgentTimeline items={mockAgentTimeline} />
<AgentTimeline items={timeline} />
</AppShell>
);
}

View File

@ -1,11 +1,4 @@
export interface AgentTimelineItem {
id: string;
actor: string;
action: string;
timestamp: string;
status: "draft" | "proposed" | "approved" | "rejected" | "applied";
summary: string;
}
import type { AgentTimelineItem } from "@/lib/types";
export function AgentTimeline({ items }: { items: AgentTimelineItem[] }) {
return (

View File

@ -1,4 +1,5 @@
import type {
AgentTimelineItem,
NoteDetail,
NoteSummary,
OperatorWorkflowSummary,
@ -136,6 +137,33 @@ export const mockOperatorWorkflows: OperatorWorkflowSummary[] = [
},
];
const mockTimeline: AgentTimelineItem[] = [
{
id: "event-1",
actor: "Research Agent",
action: "Proposed metadata enrichment",
timestamp: "2026-03-10 08:11",
status: "proposed",
summary: "Suggested new tags, source metadata, and a research confidence note.",
},
{
id: "event-2",
actor: "Workflow Agent",
action: "Extracted task candidates",
timestamp: "2026-03-10 08:08",
status: "draft",
summary: "Produced three task candidates linked to the current note body.",
},
{
id: "event-3",
actor: "Operator",
action: "Approved editorial summary",
timestamp: "2026-03-10 08:03",
status: "approved",
summary: "Accepted a summary rewrite and preserved original source wording in history.",
},
];
export const mockNoteDetails: Record<string, NoteDetail> = {
"note-prd-cutline": {
...mockNotes[0],
@ -160,6 +188,7 @@ export const mockNoteDetails: Record<string, NoteDetail> = {
{ id: "artifact-prd", name: "PRD v2.0", type: "document", status: "ready" },
{ id: "artifact-roadmap", name: "Roadmap execution sheet", type: "document", status: "ready" },
],
timeline: mockTimeline,
},
"note-web-shell": {
...mockNotes[1],
@ -181,6 +210,7 @@ export const mockNoteDetails: Record<string, NoteDetail> = {
artifacts: [
{ id: "artifact-shell", name: "UI scaffold", type: "ui", status: "processing" },
],
timeline: mockTimeline,
},
};

View File

@ -1,6 +1,6 @@
import { createApiClient } from "@bytelyst/api-client";
import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config";
import type { NoteDetail, NoteSummary, WorkspaceSummary } from "@/lib/types";
import type { AgentTimelineItem, ArtifactSummary, NoteDetail, NoteSummary, NoteTask, WorkspaceSummary } from "@/lib/types";
type NoteDoc = {
id: string;
@ -32,6 +32,48 @@ type WorkspaceListResponse = {
items: WorkspaceDoc[];
};
type NoteTaskDoc = {
id: string;
noteId: string;
title: string;
description?: string;
status: "open" | "in_progress" | "completed" | "canceled";
source: "manual" | "extracted";
};
type NoteArtifactDoc = {
id: string;
noteId: string;
artifactType: "file" | "summary" | "extraction" | "citation" | "export";
title: string;
description?: string;
};
type NoteAgentActionDoc = {
id: string;
noteId: string;
actorId: string;
actorType: "agent" | "human";
actionType: "create" | "update" | "summarize" | "extract_tasks" | "attach_citation";
state: "draft" | "proposed" | "approved" | "rejected" | "applied";
reason?: string;
beforeSummary?: string;
afterSummary?: string;
updatedAt: string;
};
type NoteTaskListResponse = {
items: NoteTaskDoc[];
};
type NoteArtifactListResponse = {
items: NoteArtifactDoc[];
};
type NoteAgentActionListResponse = {
items: NoteAgentActionDoc[];
};
function getAccessToken(): string | null {
if (typeof window === "undefined") {
return null;
@ -83,6 +125,50 @@ function toWorkspaceSummary(workspace: WorkspaceDoc, notes: NoteDoc[]): Workspac
};
}
function toNoteTask(task: NoteTaskDoc): NoteTask {
return {
id: task.id,
title: task.title,
status:
task.status === "open"
? "todo"
: task.status === "completed"
? "done"
: "in_progress",
source: task.source === "extracted" ? "agent" : "manual",
};
}
function toArtifactSummary(artifact: NoteArtifactDoc): ArtifactSummary {
return {
id: artifact.id,
name: artifact.title,
type: artifact.artifactType,
status: artifact.description ? "ready" : "processing",
};
}
function toTimelineItem(action: NoteAgentActionDoc): AgentTimelineItem {
return {
id: action.id,
actor: action.actorId,
action: `${action.actorType} ${action.actionType.replaceAll("_", " ")}`,
timestamp: action.updatedAt,
status: action.state,
summary: action.afterSummary ?? action.reason ?? action.actionType,
};
}
function toReviewState(
status: AgentTimelineItem["status"] | undefined
): NoteDetail["metadata"]["reviewState"] {
if (status === "proposed" || status === "approved" || status === "rejected") {
return status;
}
return "none";
}
export async function listWorkspaceSummaries(): Promise<WorkspaceSummary[]> {
const api = createNotesApiClient();
const [workspaceResponse, noteResponse] = await Promise.all([
@ -119,6 +205,23 @@ export async function getNoteDetail(noteId: string): Promise<NoteDetail | null>
const workspaceMap = buildWorkspaceMap(workspaceResponse.items);
const workspace = workspaceMap.get(note.workspaceId);
const [taskResponse, artifactResponse, actionResponse] = await Promise.all([
api.fetch<NoteTaskListResponse>(
`/note-tasks?workspaceId=${encodeURIComponent(note.workspaceId)}&noteId=${encodeURIComponent(note.id)}`
),
api.fetch<NoteArtifactListResponse>(
`/note-artifacts?workspaceId=${encodeURIComponent(note.workspaceId)}&noteId=${encodeURIComponent(note.id)}`
),
api.fetch<NoteAgentActionListResponse>(
`/note-agent-actions?workspaceId=${encodeURIComponent(note.workspaceId)}&noteId=${encodeURIComponent(note.id)}`
),
]);
const tasks = taskResponse.items.map(toNoteTask);
const artifacts = artifactResponse.items.map(toArtifactSummary);
const timeline = actionResponse.items
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(toTimelineItem);
return {
...toNoteSummary(note),
@ -126,12 +229,13 @@ export async function getNoteDetail(noteId: string): Promise<NoteDetail | null>
metadata: {
owner: workspace?.members.find((member) => member.role === "owner")?.userId ?? note.updatedBy,
source: note.sourceType ?? "manual",
reviewState: "none",
taskCount: 0,
artifactCount: 0,
reviewState: toReviewState(timeline[0]?.status),
taskCount: tasks.length,
artifactCount: artifacts.length,
},
linkedNotes: [],
tasks: [],
artifacts: [],
tasks,
artifacts,
timeline,
};
}

View File

@ -0,0 +1,112 @@
import { createApiClient } from "@bytelyst/api-client";
import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config";
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
import { listWorkspaceSummaries } from "@/lib/notes-client";
type NoteAgentActionDoc = {
id: string;
workspaceId: string;
noteId: string;
actorId: string;
actorType: "agent" | "human";
toolName: string;
actionType: "create" | "update" | "summarize" | "extract_tasks" | "attach_citation";
state: "draft" | "proposed" | "approved" | "rejected" | "applied";
reason?: string;
beforeSummary?: string;
afterSummary?: string;
updatedAt: string;
};
type NoteAgentActionListResponse = {
items: NoteAgentActionDoc[];
};
function getAccessToken(): string | null {
if (typeof window === "undefined") {
return null;
}
return localStorage.getItem(`${PRODUCT_ID}_access_token`);
}
function createNotesApiClient() {
return createApiClient({
baseUrl: NOTES_API_URL,
getToken: getAccessToken,
defaultHeaders: {
"x-product-id": PRODUCT_ID,
},
});
}
function toSeverity(actionType: NoteAgentActionDoc["actionType"]): ApprovalQueueItem["severity"] {
if (actionType === "update" || actionType === "extract_tasks") {
return "medium";
}
if (actionType === "attach_citation") {
return "low";
}
return "high";
}
function toTimelineItem(action: NoteAgentActionDoc): AgentTimelineItem {
return {
id: action.id,
actor: action.actorId,
action: `${action.actorType} ${action.actionType.replaceAll("_", " ")}`,
timestamp: action.updatedAt,
status: action.state,
summary: action.afterSummary ?? action.reason ?? action.toolName,
};
}
function toApprovalQueueItem(action: NoteAgentActionDoc): ApprovalQueueItem {
return {
id: action.id,
title: action.afterSummary ?? action.reason ?? `${action.actionType} proposal`,
owner: action.actorId,
severity: toSeverity(action.actionType),
status: action.state,
noteId: action.noteId,
workspaceId: action.workspaceId,
before: action.beforeSummary,
after: action.afterSummary,
};
}
async function listAgentActionsForWorkspace(workspaceId: string): Promise<NoteAgentActionDoc[]> {
const api = createNotesApiClient();
const response = await api.fetch<NoteAgentActionListResponse>(
`/note-agent-actions?workspaceId=${encodeURIComponent(workspaceId)}`
);
return response.items;
}
export async function listApprovalQueue(): Promise<ApprovalQueueItem[]> {
const workspaces = await listWorkspaceSummaries();
const actionGroups = await Promise.all(
workspaces.map((workspace) => listAgentActionsForWorkspace(workspace.id))
);
return actionGroups
.flat()
.filter((action) => action.state === "draft" || action.state === "proposed")
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(toApprovalQueueItem);
}
export async function listAgentTimeline(noteId?: string): Promise<AgentTimelineItem[]> {
const workspaces = await listWorkspaceSummaries();
const actionGroups = await Promise.all(
workspaces.map((workspace) => listAgentActionsForWorkspace(workspace.id))
);
return actionGroups
.flat()
.filter((action) => (noteId ? action.noteId === noteId : true))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(toTimelineItem);
}

View File

@ -1,4 +1,4 @@
import type { AgentTimelineItem } from "@/components/AgentTimeline";
import type { AgentTimelineItem } from "@/lib/types";
export const mockAgentTimeline: AgentTimelineItem[] = [
{

View File

@ -70,6 +70,28 @@ export interface NoteDetail extends NoteSummary {
linkedNotes: LinkedNote[];
tasks: NoteTask[];
artifacts: ArtifactSummary[];
timeline: AgentTimelineItem[];
}
export interface AgentTimelineItem {
id: string;
actor: string;
action: string;
timestamp: string;
status: "draft" | "proposed" | "approved" | "rejected" | "applied";
summary: string;
}
export interface ApprovalQueueItem {
id: string;
title: string;
owner: string;
severity: "low" | "medium" | "high";
status: "draft" | "proposed" | "approved" | "rejected" | "applied";
noteId: string;
workspaceId: string;
before?: string;
after?: string;
}
export interface ProductUser {