feat(web): back review surfaces with backend data
This commit is contained in:
parent
57d38762f0
commit
8bf0bb5452
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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)}¬eId=${encodeURIComponent(note.id)}`
|
||||
),
|
||||
api.fetch<NoteArtifactListResponse>(
|
||||
`/note-artifacts?workspaceId=${encodeURIComponent(note.workspaceId)}¬eId=${encodeURIComponent(note.id)}`
|
||||
),
|
||||
api.fetch<NoteAgentActionListResponse>(
|
||||
`/note-agent-actions?workspaceId=${encodeURIComponent(note.workspaceId)}¬eId=${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,
|
||||
};
|
||||
}
|
||||
|
||||
112
web/src/lib/review-client.ts
Normal file
112
web/src/lib/review-client.ts
Normal 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);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { AgentTimelineItem } from "@/components/AgentTimeline";
|
||||
import type { AgentTimelineItem } from "@/lib/types";
|
||||
|
||||
export const mockAgentTimeline: AgentTimelineItem[] = [
|
||||
{
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user