feat(notes): wire backend-backed note workspace slice

This commit is contained in:
saravanakumardb1 2026-03-10 15:53:57 -07:00
parent 418ecaacc5
commit 5995b6c725
8 changed files with 251 additions and 46 deletions

View File

@ -24,6 +24,23 @@ type NoteListResponse = {
items: NoteDoc[];
};
type CreateNoteInput = {
id: string;
workspaceId: string;
title: string;
body: string;
tags?: string[];
links?: string[];
};
function generateNoteId(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return `note-${Date.now()}`;
}
function mapWorkspaceNames(workspaces: MobileWorkspace[]) {
return new Map(workspaces.map((workspace) => [workspace.id, workspace.name]));
}
@ -59,6 +76,32 @@ export async function getNote(id: string, workspaceId: string): Promise<MobileNo
return toMobileNote(note, mapWorkspaceNames(workspaces));
}
export async function createNote(
workspaceId: string,
title: string,
body: string,
): Promise<MobileNote> {
const trimmedTitle = title.trim() || 'Untitled draft';
const payload: CreateNoteInput = {
id: generateNoteId(),
workspaceId,
title: trimmedTitle,
body,
tags: [],
links: [],
};
const [created, workspaces] = await Promise.all([
getApiClient().fetch<NoteDoc>('/notes', {
method: 'POST',
body: JSON.stringify(payload),
}),
listWorkspaces(),
]);
return toMobileNote(created, mapWorkspaceNames(workspaces));
}
export async function updateNote(
id: string,
workspaceId: string,

View File

@ -45,9 +45,13 @@ export default function CaptureScreen() {
textAlignVertical="top"
/>
<Pressable
onPress={() => {
saveDraft(title, body);
setSaved(true);
onPress={async () => {
const didSave = await saveDraft(activeWorkspaceId, title, body);
setSaved(didSave);
if (!didSave) {
return;
}
setTitle('');
setBody('');
}}
@ -55,7 +59,7 @@ export default function CaptureScreen() {
>
<Text style={styles.buttonText}>Save draft</Text>
</Pressable>
{saved ? <Text style={styles.saved}>Draft saved locally in this scaffold step.</Text> : null}
{saved ? <Text style={styles.saved}>Draft saved to the product backend.</Text> : null}
<View style={styles.card}>
<Text style={styles.cardTitle}>Offline queue readiness</Text>
<Text style={styles.cardBody}>Queue capacity: {OFFLINE_QUEUE_MAX_SIZE} items</Text>

View File

@ -1,5 +1,5 @@
import { create } from 'zustand';
import { getNote, listNotes, updateNote as persistNote, type MobileNote } from '../api/notes';
import { createNote as persistNewNote, getNote, listNotes, updateNote as persistNote, type MobileNote } from '../api/notes';
export type NotesState = {
notes: MobileNote[];
@ -7,7 +7,7 @@ export type NotesState = {
isLoading: boolean;
hydrate: () => Promise<void>;
openNote: (id: string) => Promise<void>;
saveDraft: (title: string, body: string) => void;
saveDraft: (workspaceId: string | null, title: string, body: string) => Promise<boolean>;
updateNote: (id: string, title: string, body: string) => Promise<void>;
};
@ -31,18 +31,19 @@ export const useNotesStore = create<NotesState>((set, get) => ({
const note = await getNote(id, current.workspaceId);
set({ selectedNote: note, isLoading: false });
},
saveDraft(title: string, body: string) {
const draft: MobileNote = {
id: `draft-${Date.now()}`,
workspaceId: 'drafts',
title: title.trim() || 'Untitled draft',
body,
workspaceName: 'Drafts',
status: 'draft',
updatedAt: new Date().toISOString(),
};
async saveDraft(workspaceId: string | null, title: string, body: string) {
if (!workspaceId) {
return false;
}
set({ notes: [draft, ...get().notes], selectedNote: draft });
set({ isLoading: true });
const created = await persistNewNote(workspaceId, title, body);
set({
notes: [created, ...get().notes],
selectedNote: created,
isLoading: false,
});
return true;
},
async updateNote(id: string, title: string, body: string) {
const nextTitle = title.trim() || 'Untitled draft';

View File

@ -3,29 +3,78 @@
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
import { mockOperatorWorkflows, mockSavedViews } from "@/lib/mock-data";
import { listApprovalQueue } from "@/lib/review-client";
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
export default function DashboardPage() {
const [notes, setNotes] = useState<NoteSummary[]>([]);
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
const [pendingReviewCount, setPendingReviewCount] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const [nextNotes, nextWorkspaces] = await Promise.all([
const [nextNotes, nextWorkspaces, nextApprovalQueue] = await Promise.all([
listNoteSummaries(),
listWorkspaceSummaries(),
listApprovalQueue(),
]);
setNotes(nextNotes);
setWorkspaces(nextWorkspaces);
setPendingReviewCount(nextApprovalQueue.length);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load dashboard data");
}
})();
}, []);
const savedViews = [
{
id: "workspace-all",
name: "All workspaces",
scope: "workspace",
description: "Current workspace inventory derived from backend-backed workspace data.",
query: "visibility:any sort:updated",
resultCount: workspaces.length,
},
{
id: "draft-notes",
name: "Draft notes",
scope: "search",
description: "Draft notes currently tracked across the active knowledge base.",
query: "status:draft",
resultCount: notes.filter((note) => note.status === "draft").length,
},
{
id: "pending-review",
name: "Pending review",
scope: "review",
description: "Current agent-mediated items awaiting review.",
query: "state:draft|proposed",
resultCount: pendingReviewCount,
},
];
const operatorWorkflows = [
{
id: "workflow-approvals",
name: "Approval triage",
owner: "Operator",
queueCount: pendingReviewCount,
sla: "< 4h",
status: pendingReviewCount > 3 ? "at_risk" : "healthy",
},
{
id: "workflow-workspaces",
name: "Workspace coverage",
owner: "Knowledge Ops",
queueCount: workspaces.length,
sla: "< 1d",
status: workspaces.length > 5 ? "at_risk" : "healthy",
},
] as const;
const recentNotes = notes.slice(0, 3);
return (
@ -45,7 +94,7 @@ export default function DashboardPage() {
</div>
<div className="surface-card" style={{ padding: "var(--ml-space-5)" }}>
<div style={{ color: "var(--ml-text-secondary)" }}>Pending review surfaces</div>
<div style={{ fontSize: "var(--ml-fs-3xl)", fontWeight: 700, marginTop: "var(--ml-space-2)" }}>2</div>
<div style={{ fontSize: "var(--ml-fs-3xl)", fontWeight: 700, marginTop: "var(--ml-space-2)" }}>{pendingReviewCount}</div>
</div>
</section>
@ -53,7 +102,7 @@ export default function DashboardPage() {
<section className="surface-card" style={{ padding: "var(--ml-space-6)", display: "grid", gap: "var(--ml-space-4)" }}>
<div style={{ fontSize: "var(--ml-fs-xl)", fontWeight: 700 }}>Saved views</div>
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{mockSavedViews.map((view) => (
{savedViews.map((view) => (
<article key={view.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
<strong>{view.name}</strong>
@ -72,7 +121,7 @@ export default function DashboardPage() {
<section className="surface-card" style={{ padding: "var(--ml-space-6)", display: "grid", gap: "var(--ml-space-4)" }}>
<div style={{ fontSize: "var(--ml-fs-xl)", fontWeight: 700 }}>Operator workflows</div>
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{mockOperatorWorkflows.map((workflow) => (
{operatorWorkflows.map((workflow) => (
<article key={workflow.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
<strong>{workflow.name}</strong>

View File

@ -4,7 +4,6 @@ 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 { listAgentTimeline, listApprovalQueue } from "@/lib/review-client";
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
@ -29,6 +28,24 @@ export default function ReviewsPage() {
}, []);
const featuredProposal = useMemo(() => approvalQueue[0] ?? null, [approvalQueue]);
const operatorWorkflows = [
{
id: "workflow-approvals",
name: "Approval triage",
owner: "Operator",
queueCount: approvalQueue.length,
sla: "< 4h",
status: approvalQueue.length > 3 ? "at_risk" : "healthy",
},
{
id: "workflow-agent-activity",
name: "Agent activity review",
owner: "Knowledge Ops",
queueCount: timeline.length,
sla: "< 1d",
status: timeline.length > 6 ? "at_risk" : "healthy",
},
] as const;
return (
<AppShell
@ -40,7 +57,7 @@ export default function ReviewsPage() {
<aside className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>
<div style={{ fontWeight: 700 }}>Operator workflows</div>
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{mockOperatorWorkflows.map((workflow) => (
{operatorWorkflows.map((workflow) => (
<div key={workflow.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
<strong>{workflow.name}</strong>
<span style={{ color: "var(--ml-text-secondary)" }}>Owner: {workflow.owner}</span>

View File

@ -4,7 +4,6 @@ import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { listNoteSummaries } from "@/lib/notes-client";
import { mockSavedViews } from "@/lib/mock-data";
import type { NoteSummary } from "@/lib/types";
export default function SearchPage() {
@ -35,6 +34,25 @@ export default function SearchPage() {
);
}, [notes, query]);
const savedViews = [
{
id: "search-launch-readiness",
name: "Launch readiness",
query: "tag:launch tag:mvp status:active",
resultCount: notes.filter(
(note) =>
note.status === "active" &&
note.tags.some((tag) => tag === "launch" || tag === "mvp")
).length,
},
{
id: "search-drafts",
name: "Draft notes",
query: "status:draft",
resultCount: notes.filter((note) => note.status === "draft").length,
},
];
return (
<AppShell
title="Search"
@ -45,15 +63,13 @@ export default function SearchPage() {
<aside className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>
<div style={{ fontWeight: 700 }}>Saved searches</div>
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{mockSavedViews
.filter((view) => view.scope === "search")
.map((view) => (
<div key={view.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
<strong>{view.name}</strong>
<span style={{ color: "var(--ml-text-secondary)" }}>{view.query}</span>
<span className="badge">{view.resultCount} results</span>
</div>
))}
{savedViews.map((view) => (
<div key={view.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
<strong>{view.name}</strong>
<span style={{ color: "var(--ml-text-secondary)" }}>{view.query}</span>
<span className="badge">{view.resultCount} results</span>
</div>
))}
</div>
<div style={{ display: "grid", gap: "var(--ml-space-2)" }}>

View File

@ -4,7 +4,6 @@ import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
import { mockSavedViews } from "@/lib/mock-data";
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
export default function WorkspacesPage() {
@ -38,6 +37,21 @@ export default function WorkspacesPage() {
[notes, workspaces],
);
const savedViews = [
{
id: "workspace-all",
name: "All workspaces",
description: "Current backend-backed workspace inventory.",
resultCount: workspaces.length,
},
{
id: "workspace-shared",
name: "Shared workspaces",
description: "Workspaces with more than one member.",
resultCount: workspaces.filter((workspace) => workspace.visibility === "shared").length,
},
];
return (
<AppShell
title="Workspaces"
@ -48,15 +62,13 @@ export default function WorkspacesPage() {
<aside className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>
<div style={{ fontWeight: 700 }}>Saved views</div>
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{mockSavedViews
.filter((view) => view.scope === "workspace")
.map((view) => (
<div key={view.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
<strong>{view.name}</strong>
<span style={{ color: "var(--ml-text-secondary)" }}>{view.description}</span>
<span className="badge">{view.resultCount} results</span>
</div>
))}
{savedViews.map((view) => (
<div key={view.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
<strong>{view.name}</strong>
<span style={{ color: "var(--ml-text-secondary)" }}>{view.description}</span>
<span className="badge">{view.resultCount} results</span>
</div>
))}
</div>
</aside>

View File

@ -1,7 +1,7 @@
import { createApiClient } from "@bytelyst/api-client";
import { extractSuggestedTasks } from "@/lib/extraction-client";
import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config";
import type { AgentTimelineItem, ArtifactSummary, NoteDetail, NoteSummary, NoteTask, WorkspaceSummary } from "@/lib/types";
import type { AgentTimelineItem, ArtifactSummary, LinkedNote, NoteDetail, NoteSummary, NoteTask, WorkspaceSummary } from "@/lib/types";
type NoteDoc = {
id: string;
@ -78,6 +78,18 @@ type NoteAgentActionListResponse = {
items: NoteAgentActionDoc[];
};
type NoteRelationshipDoc = {
id: string;
workspaceId: string;
fromNoteId: string;
toNoteId: string;
relationshipType: string;
};
type NoteRelationshipListResponse = {
items: NoteRelationshipDoc[];
};
function getAccessToken(): string | null {
if (typeof window === "undefined") {
return null;
@ -100,6 +112,10 @@ function buildWorkspaceMap(workspaces: WorkspaceDoc[]) {
return new Map(workspaces.map((workspace) => [workspace.id, workspace]));
}
function buildNoteMap(notes: NoteDoc[]) {
return new Map(notes.map((note) => [note.id, note]));
}
function toNoteSummary(note: NoteDoc): NoteSummary {
return {
id: note.id,
@ -176,6 +192,38 @@ function toReviewState(
return "none";
}
function toLinkedNotes(
noteId: string,
relationships: NoteRelationshipDoc[],
noteMap: Map<string, NoteDoc>,
): LinkedNote[] {
return relationships.flatMap((relationship) => {
const relatedNoteId =
relationship.fromNoteId === noteId
? relationship.toNoteId
: relationship.toNoteId === noteId
? relationship.fromNoteId
: null;
if (!relatedNoteId) {
return [];
}
const relatedNote = noteMap.get(relatedNoteId);
if (!relatedNote) {
return [];
}
return [
{
id: relatedNote.id,
title: relatedNote.title,
relationship: relationship.relationshipType,
},
];
});
}
export async function listWorkspaceSummaries(): Promise<WorkspaceSummary[]> {
const api = createNotesApiClient();
const [workspaceResponse, noteResponse] = await Promise.all([
@ -211,6 +259,7 @@ export async function getNoteDetail(noteId: string): Promise<NoteDetail | null>
}
const workspaceMap = buildWorkspaceMap(workspaceResponse.items);
const noteMap = buildNoteMap(noteResponse.items);
const workspace = workspaceMap.get(note.workspaceId);
const [taskResponse, artifactResponse, actionResponse] = await Promise.all([
api.fetch<NoteTaskListResponse>(
@ -223,6 +272,19 @@ export async function getNoteDetail(noteId: string): Promise<NoteDetail | null>
`/note-agent-actions?workspaceId=${encodeURIComponent(note.workspaceId)}&noteId=${encodeURIComponent(note.id)}`
),
]);
let relationshipResponse: NoteRelationshipListResponse = { items: [] };
try {
const fetchedRelationships = await api.fetch<NoteRelationshipListResponse>(
`/note-relationships?workspaceId=${encodeURIComponent(note.workspaceId)}&noteId=${encodeURIComponent(note.id)}`
);
relationshipResponse =
fetchedRelationships && Array.isArray(fetchedRelationships.items)
? fetchedRelationships
: { items: [] };
} catch {
relationshipResponse = { items: [] };
}
const extractedTasks = await extractSuggestedTasks(note.body).catch(() => []);
const existingTaskTitles = new Set(taskResponse.items.map((task) => task.title.trim().toLowerCase()));
@ -234,6 +296,7 @@ export async function getNoteDetail(noteId: string): Promise<NoteDetail | null>
const timeline = actionResponse.items
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(toTimelineItem);
const linkedNotes = toLinkedNotes(note.id, relationshipResponse.items, noteMap);
return {
...toNoteSummary(note),
@ -245,7 +308,7 @@ export async function getNoteDetail(noteId: string): Promise<NoteDetail | null>
taskCount: tasks.length,
artifactCount: artifacts.length,
},
linkedNotes: [],
linkedNotes,
tasks,
artifacts,
timeline,