feat(notes): wire backend-backed note workspace slice
This commit is contained in:
parent
418ecaacc5
commit
5995b6c725
@ -24,6 +24,23 @@ type NoteListResponse = {
|
|||||||
items: NoteDoc[];
|
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[]) {
|
function mapWorkspaceNames(workspaces: MobileWorkspace[]) {
|
||||||
return new Map(workspaces.map((workspace) => [workspace.id, workspace.name]));
|
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));
|
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(
|
export async function updateNote(
|
||||||
id: string,
|
id: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
|||||||
@ -45,9 +45,13 @@ export default function CaptureScreen() {
|
|||||||
textAlignVertical="top"
|
textAlignVertical="top"
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
saveDraft(title, body);
|
const didSave = await saveDraft(activeWorkspaceId, title, body);
|
||||||
setSaved(true);
|
setSaved(didSave);
|
||||||
|
if (!didSave) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setBody('');
|
setBody('');
|
||||||
}}
|
}}
|
||||||
@ -55,7 +59,7 @@ export default function CaptureScreen() {
|
|||||||
>
|
>
|
||||||
<Text style={styles.buttonText}>Save draft</Text>
|
<Text style={styles.buttonText}>Save draft</Text>
|
||||||
</Pressable>
|
</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}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.cardTitle}>Offline queue readiness</Text>
|
<Text style={styles.cardTitle}>Offline queue readiness</Text>
|
||||||
<Text style={styles.cardBody}>Queue capacity: {OFFLINE_QUEUE_MAX_SIZE} items</Text>
|
<Text style={styles.cardBody}>Queue capacity: {OFFLINE_QUEUE_MAX_SIZE} items</Text>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
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 = {
|
export type NotesState = {
|
||||||
notes: MobileNote[];
|
notes: MobileNote[];
|
||||||
@ -7,7 +7,7 @@ export type NotesState = {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
openNote: (id: string) => 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>;
|
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);
|
const note = await getNote(id, current.workspaceId);
|
||||||
set({ selectedNote: note, isLoading: false });
|
set({ selectedNote: note, isLoading: false });
|
||||||
},
|
},
|
||||||
saveDraft(title: string, body: string) {
|
async saveDraft(workspaceId: string | null, title: string, body: string) {
|
||||||
const draft: MobileNote = {
|
if (!workspaceId) {
|
||||||
id: `draft-${Date.now()}`,
|
return false;
|
||||||
workspaceId: 'drafts',
|
}
|
||||||
title: title.trim() || 'Untitled draft',
|
|
||||||
body,
|
|
||||||
workspaceName: 'Drafts',
|
|
||||||
status: 'draft',
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
async updateNote(id: string, title: string, body: string) {
|
||||||
const nextTitle = title.trim() || 'Untitled draft';
|
const nextTitle = title.trim() || 'Untitled draft';
|
||||||
|
|||||||
@ -3,29 +3,78 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
|
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";
|
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
||||||
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
|
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
|
||||||
|
const [pendingReviewCount, setPendingReviewCount] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [nextNotes, nextWorkspaces] = await Promise.all([
|
const [nextNotes, nextWorkspaces, nextApprovalQueue] = await Promise.all([
|
||||||
listNoteSummaries(),
|
listNoteSummaries(),
|
||||||
listWorkspaceSummaries(),
|
listWorkspaceSummaries(),
|
||||||
|
listApprovalQueue(),
|
||||||
]);
|
]);
|
||||||
setNotes(nextNotes);
|
setNotes(nextNotes);
|
||||||
setWorkspaces(nextWorkspaces);
|
setWorkspaces(nextWorkspaces);
|
||||||
|
setPendingReviewCount(nextApprovalQueue.length);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Unable to load dashboard data");
|
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);
|
const recentNotes = notes.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -45,7 +94,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="surface-card" style={{ padding: "var(--ml-space-5)" }}>
|
<div className="surface-card" style={{ padding: "var(--ml-space-5)" }}>
|
||||||
<div style={{ color: "var(--ml-text-secondary)" }}>Pending review surfaces</div>
|
<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>
|
</div>
|
||||||
</section>
|
</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)" }}>
|
<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={{ fontSize: "var(--ml-fs-xl)", fontWeight: 700 }}>Saved views</div>
|
||||||
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
<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)" }}>
|
<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" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
|
||||||
<strong>{view.name}</strong>
|
<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)" }}>
|
<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={{ fontSize: "var(--ml-fs-xl)", fontWeight: 700 }}>Operator workflows</div>
|
||||||
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
<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)" }}>
|
<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" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
|
||||||
<strong>{workflow.name}</strong>
|
<strong>{workflow.name}</strong>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { AgentTimeline } from "@/components/AgentTimeline";
|
import { AgentTimeline } from "@/components/AgentTimeline";
|
||||||
import { ProposalReviewCard } from "@/components/ProposalReviewCard";
|
import { ProposalReviewCard } from "@/components/ProposalReviewCard";
|
||||||
import { mockOperatorWorkflows } from "@/lib/mock-data";
|
|
||||||
import { listAgentTimeline, listApprovalQueue } from "@/lib/review-client";
|
import { listAgentTimeline, listApprovalQueue } from "@/lib/review-client";
|
||||||
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
|
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
|
||||||
|
|
||||||
@ -29,6 +28,24 @@ export default function ReviewsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const featuredProposal = useMemo(() => approvalQueue[0] ?? null, [approvalQueue]);
|
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 (
|
return (
|
||||||
<AppShell
|
<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)" }}>
|
<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={{ fontWeight: 700 }}>Operator workflows</div>
|
||||||
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
<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)" }}>
|
<div key={workflow.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
|
||||||
<strong>{workflow.name}</strong>
|
<strong>{workflow.name}</strong>
|
||||||
<span style={{ color: "var(--ml-text-secondary)" }}>Owner: {workflow.owner}</span>
|
<span style={{ color: "var(--ml-text-secondary)" }}>Owner: {workflow.owner}</span>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import Link from "next/link";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { listNoteSummaries } from "@/lib/notes-client";
|
import { listNoteSummaries } from "@/lib/notes-client";
|
||||||
import { mockSavedViews } from "@/lib/mock-data";
|
|
||||||
import type { NoteSummary } from "@/lib/types";
|
import type { NoteSummary } from "@/lib/types";
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
@ -35,6 +34,25 @@ export default function SearchPage() {
|
|||||||
);
|
);
|
||||||
}, [notes, query]);
|
}, [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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Search"
|
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)" }}>
|
<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={{ fontWeight: 700 }}>Saved searches</div>
|
||||||
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
||||||
{mockSavedViews
|
{savedViews.map((view) => (
|
||||||
.filter((view) => view.scope === "search")
|
<div key={view.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
|
||||||
.map((view) => (
|
<strong>{view.name}</strong>
|
||||||
<div key={view.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
|
<span style={{ color: "var(--ml-text-secondary)" }}>{view.query}</span>
|
||||||
<strong>{view.name}</strong>
|
<span className="badge">{view.resultCount} results</span>
|
||||||
<span style={{ color: "var(--ml-text-secondary)" }}>{view.query}</span>
|
</div>
|
||||||
<span className="badge">{view.resultCount} results</span>
|
))}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "var(--ml-space-2)" }}>
|
<div style={{ display: "grid", gap: "var(--ml-space-2)" }}>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import Link from "next/link";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
|
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
|
||||||
import { mockSavedViews } from "@/lib/mock-data";
|
|
||||||
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||||
|
|
||||||
export default function WorkspacesPage() {
|
export default function WorkspacesPage() {
|
||||||
@ -38,6 +37,21 @@ export default function WorkspacesPage() {
|
|||||||
[notes, workspaces],
|
[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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Workspaces"
|
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)" }}>
|
<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={{ fontWeight: 700 }}>Saved views</div>
|
||||||
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
||||||
{mockSavedViews
|
{savedViews.map((view) => (
|
||||||
.filter((view) => view.scope === "workspace")
|
<div key={view.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
|
||||||
.map((view) => (
|
<strong>{view.name}</strong>
|
||||||
<div key={view.id} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
|
<span style={{ color: "var(--ml-text-secondary)" }}>{view.description}</span>
|
||||||
<strong>{view.name}</strong>
|
<span className="badge">{view.resultCount} results</span>
|
||||||
<span style={{ color: "var(--ml-text-secondary)" }}>{view.description}</span>
|
</div>
|
||||||
<span className="badge">{view.resultCount} results</span>
|
))}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { createApiClient } from "@bytelyst/api-client";
|
import { createApiClient } from "@bytelyst/api-client";
|
||||||
import { extractSuggestedTasks } from "@/lib/extraction-client";
|
import { extractSuggestedTasks } from "@/lib/extraction-client";
|
||||||
import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config";
|
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 = {
|
type NoteDoc = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -78,6 +78,18 @@ type NoteAgentActionListResponse = {
|
|||||||
items: NoteAgentActionDoc[];
|
items: NoteAgentActionDoc[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NoteRelationshipDoc = {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
fromNoteId: string;
|
||||||
|
toNoteId: string;
|
||||||
|
relationshipType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NoteRelationshipListResponse = {
|
||||||
|
items: NoteRelationshipDoc[];
|
||||||
|
};
|
||||||
|
|
||||||
function getAccessToken(): string | null {
|
function getAccessToken(): string | null {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return null;
|
return null;
|
||||||
@ -100,6 +112,10 @@ function buildWorkspaceMap(workspaces: WorkspaceDoc[]) {
|
|||||||
return new Map(workspaces.map((workspace) => [workspace.id, workspace]));
|
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 {
|
function toNoteSummary(note: NoteDoc): NoteSummary {
|
||||||
return {
|
return {
|
||||||
id: note.id,
|
id: note.id,
|
||||||
@ -176,6 +192,38 @@ function toReviewState(
|
|||||||
return "none";
|
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[]> {
|
export async function listWorkspaceSummaries(): Promise<WorkspaceSummary[]> {
|
||||||
const api = createNotesApiClient();
|
const api = createNotesApiClient();
|
||||||
const [workspaceResponse, noteResponse] = await Promise.all([
|
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 workspaceMap = buildWorkspaceMap(workspaceResponse.items);
|
||||||
|
const noteMap = buildNoteMap(noteResponse.items);
|
||||||
const workspace = workspaceMap.get(note.workspaceId);
|
const workspace = workspaceMap.get(note.workspaceId);
|
||||||
const [taskResponse, artifactResponse, actionResponse] = await Promise.all([
|
const [taskResponse, artifactResponse, actionResponse] = await Promise.all([
|
||||||
api.fetch<NoteTaskListResponse>(
|
api.fetch<NoteTaskListResponse>(
|
||||||
@ -223,6 +272,19 @@ export async function getNoteDetail(noteId: string): Promise<NoteDetail | null>
|
|||||||
`/note-agent-actions?workspaceId=${encodeURIComponent(note.workspaceId)}¬eId=${encodeURIComponent(note.id)}`
|
`/note-agent-actions?workspaceId=${encodeURIComponent(note.workspaceId)}¬eId=${encodeURIComponent(note.id)}`
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
let relationshipResponse: NoteRelationshipListResponse = { items: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchedRelationships = await api.fetch<NoteRelationshipListResponse>(
|
||||||
|
`/note-relationships?workspaceId=${encodeURIComponent(note.workspaceId)}¬eId=${encodeURIComponent(note.id)}`
|
||||||
|
);
|
||||||
|
relationshipResponse =
|
||||||
|
fetchedRelationships && Array.isArray(fetchedRelationships.items)
|
||||||
|
? fetchedRelationships
|
||||||
|
: { items: [] };
|
||||||
|
} catch {
|
||||||
|
relationshipResponse = { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const extractedTasks = await extractSuggestedTasks(note.body).catch(() => []);
|
const extractedTasks = await extractSuggestedTasks(note.body).catch(() => []);
|
||||||
const existingTaskTitles = new Set(taskResponse.items.map((task) => task.title.trim().toLowerCase()));
|
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
|
const timeline = actionResponse.items
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
.map(toTimelineItem);
|
.map(toTimelineItem);
|
||||||
|
const linkedNotes = toLinkedNotes(note.id, relationshipResponse.items, noteMap);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...toNoteSummary(note),
|
...toNoteSummary(note),
|
||||||
@ -245,7 +308,7 @@ export async function getNoteDetail(noteId: string): Promise<NoteDetail | null>
|
|||||||
taskCount: tasks.length,
|
taskCount: tasks.length,
|
||||||
artifactCount: artifacts.length,
|
artifactCount: artifacts.length,
|
||||||
},
|
},
|
||||||
linkedNotes: [],
|
linkedNotes,
|
||||||
tasks,
|
tasks,
|
||||||
artifacts,
|
artifacts,
|
||||||
timeline,
|
timeline,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user