feat(notes): wire mobile inbox review flow

This commit is contained in:
saravanakumardb1 2026-03-10 16:02:08 -07:00
parent 650c061dba
commit 8f14698a42
4 changed files with 200 additions and 44 deletions

View File

@ -0,0 +1,126 @@
import { getApiClient } from './client';
import { listWorkspaces } from './workspaces';
export type MobileApprovalItem = {
id: string;
workspaceId: string;
noteId: string;
title: string;
summary: string;
status: 'pending' | 'approved' | 'rejected';
};
export type MobileActivityItem = {
id: string;
title: string;
summary: string;
kind: 'note' | 'task' | 'agent';
};
type NoteAgentActionDoc = {
id: string;
workspaceId: string;
noteId: string;
actorId: string;
actionType: 'create' | 'update' | 'summarize' | 'extract_tasks' | 'attach_citation';
state: 'draft' | 'proposed' | 'approved' | 'rejected' | 'applied';
reason?: string;
afterSummary?: string;
updatedAt: string;
};
type NoteAgentActionListResponse = {
items: NoteAgentActionDoc[];
};
function toApprovalStatus(action: NoteAgentActionDoc): MobileApprovalItem['status'] {
if (action.state === 'approved' || action.state === 'applied') {
return 'approved';
}
if (action.state === 'rejected') {
return 'rejected';
}
return 'pending';
}
function toApprovalItem(action: NoteAgentActionDoc): MobileApprovalItem {
return {
id: action.id,
workspaceId: action.workspaceId,
noteId: action.noteId,
title: action.afterSummary ?? action.reason ?? `${action.actionType} proposal`,
summary: action.reason ?? action.afterSummary ?? `Agent proposed a ${action.actionType.replaceAll('_', ' ')} change.`,
status: toApprovalStatus(action),
};
}
function toActivityKind(actionType: NoteAgentActionDoc['actionType']): MobileActivityItem['kind'] {
if (actionType === 'extract_tasks') {
return 'task';
}
if (actionType === 'attach_citation') {
return 'note';
}
return 'agent';
}
function toActivityItem(action: NoteAgentActionDoc): MobileActivityItem {
return {
id: action.id,
title: action.afterSummary ?? `${action.actionType.replaceAll('_', ' ')} update`,
summary: action.reason ?? action.afterSummary ?? `State: ${action.state}`,
kind: toActivityKind(action.actionType),
};
}
async function listAgentActionsForWorkspace(workspaceId: string): Promise<NoteAgentActionDoc[]> {
const response = await getApiClient().fetch<NoteAgentActionListResponse>(
`/note-agent-actions?workspaceId=${encodeURIComponent(workspaceId)}`,
);
return response.items;
}
export async function listApprovalQueue(): Promise<MobileApprovalItem[]> {
const workspaces = await listWorkspaces();
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(toApprovalItem);
}
export async function listActivityFeed(): Promise<MobileActivityItem[]> {
const workspaces = await listWorkspaces();
const actionGroups = await Promise.all(workspaces.map((workspace) => listAgentActionsForWorkspace(workspace.id)));
return actionGroups
.flat()
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, 10)
.map(toActivityItem);
}
export async function updateApprovalState(
id: string,
workspaceId: string,
state: 'approved' | 'rejected',
): Promise<MobileApprovalItem> {
const updated = await getApiClient().fetch<NoteAgentActionDoc>(
`/note-agent-actions/${encodeURIComponent(id)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{
method: 'PATCH',
body: JSON.stringify({
state,
reviewedAt: new Date().toISOString(),
}),
},
);
return toApprovalItem(updated);
}

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useInboxStore, type ActivityItem, type ApprovalItem, type InboxState } from '../../store/inbox-store';
import { colors } from '../../theme';
@ -5,28 +6,38 @@ import { colors } from '../../theme';
export default function InboxScreen() {
const approvals = useInboxStore((state: InboxState) => state.approvals);
const activity = useInboxStore((state: InboxState) => state.activity);
const isLoading = useInboxStore((state: InboxState) => state.isLoading);
const hydrate = useInboxStore((state: InboxState) => state.hydrate);
const approve = useInboxStore((state: InboxState) => state.approve);
const reject = useInboxStore((state: InboxState) => state.reject);
useEffect(() => {
void hydrate();
}, [hydrate]);
return (
<View style={styles.container}>
<Text style={styles.title}>Inbox</Text>
<Text style={styles.body}>Review simple agent actions and approve or reject them from mobile.</Text>
{isLoading ? <Text style={styles.empty}>Loading approvals</Text> : null}
{approvals.map((item: ApprovalItem) => (
<View key={item.id} style={styles.card}>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardBody}>{item.summary}</Text>
<Text style={styles.status}>Status: {item.status}</Text>
<View style={styles.actionRow}>
<Pressable style={styles.approveButton} onPress={() => approve(item.id)}>
<Pressable style={styles.approveButton} onPress={() => void approve(item.id)}>
<Text style={styles.approveText}>Approve</Text>
</Pressable>
<Pressable style={styles.rejectButton} onPress={() => reject(item.id)}>
<Pressable style={styles.rejectButton} onPress={() => void reject(item.id)}>
<Text style={styles.rejectText}>Reject</Text>
</Pressable>
</View>
</View>
))}
{!isLoading && approvals.length === 0 ? (
<Text style={styles.empty}>No approval items are waiting right now.</Text>
) : null}
<View style={styles.feedSection}>
<Text style={styles.feedTitle}>Recent activity</Text>
{activity.map((item: ActivityItem) => (
@ -36,6 +47,9 @@ export default function InboxScreen() {
<Text style={styles.cardBody}>{item.summary}</Text>
</View>
))}
{!isLoading && activity.length === 0 ? (
<Text style={styles.empty}>No recent agent activity yet.</Text>
) : null}
</View>
</View>
);
@ -58,6 +72,10 @@ const styles = StyleSheet.create({
fontSize: 15,
lineHeight: 21,
},
empty: {
color: colors.textSecondary,
fontSize: 14,
},
card: {
backgroundColor: colors.surfaceCard,
borderRadius: 16,

View File

@ -2,11 +2,13 @@ import { useEffect } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useAuthStore, type AuthState } from '../store/auth-store';
import { useInboxStore, type InboxState } from '../store/inbox-store';
import { useNotesStore, type NotesState } from '../store/notes-store';
import { useWorkspaceStore, type WorkspaceState } from '../store/workspace-store';
export default function RootLayout() {
const bootstrapAuth = useAuthStore((state: AuthState) => state.bootstrap);
const hydrateInbox = useInboxStore((state: InboxState) => state.hydrate);
const hydrateNotes = useNotesStore((state: NotesState) => state.hydrate);
const hydrateWorkspaces = useWorkspaceStore((state: WorkspaceState) => state.hydrate);
@ -14,7 +16,8 @@ export default function RootLayout() {
void bootstrapAuth();
void hydrateNotes();
void hydrateWorkspaces();
}, [bootstrapAuth, hydrateNotes, hydrateWorkspaces]);
void hydrateInbox();
}, [bootstrapAuth, hydrateInbox, hydrateNotes, hydrateWorkspaces]);
return (
<>

View File

@ -1,7 +1,16 @@
import { create } from 'zustand';
import {
listActivityFeed,
listApprovalQueue,
updateApprovalState,
type MobileActivityItem,
type MobileApprovalItem,
} from '../api/note-agent-actions';
export type ApprovalItem = {
id: string;
workspaceId: string;
noteId: string;
title: string;
summary: string;
status: 'pending' | 'approved' | 'rejected';
@ -17,56 +26,56 @@ export type ActivityItem = {
export type InboxState = {
approvals: ApprovalItem[];
activity: ActivityItem[];
approve: (id: string) => void;
reject: (id: string) => void;
isLoading: boolean;
hydrate: () => Promise<void>;
approve: (id: string) => Promise<void>;
reject: (id: string) => Promise<void>;
};
export const useInboxStore = create<InboxState>((set) => ({
approvals: [
{
id: 'approval-1',
title: 'Apply note merge suggestion',
summary: 'Agent proposed merging two near-duplicate product planning notes.',
status: 'pending',
},
{
id: 'approval-2',
title: 'Create linked task from note',
summary: 'Agent extracted a task from a meeting note and queued it for approval.',
status: 'pending',
},
],
activity: [
{
id: 'activity-1',
title: 'Summary refreshed',
summary: 'Agent updated a note summary after recent edits.',
kind: 'agent',
},
{
id: 'activity-2',
title: 'Task extracted',
summary: 'A follow-up task was extracted from a meeting note.',
kind: 'task',
},
{
id: 'activity-3',
title: 'Related note linked',
summary: 'A planning note was linked to a roadmap note.',
kind: 'note',
},
],
approve(id: string) {
function toApprovalItem(item: MobileApprovalItem): ApprovalItem {
return item;
}
function toActivityItem(item: MobileActivityItem): ActivityItem {
return item;
}
export const useInboxStore = create<InboxState>((set, get) => ({
approvals: [],
activity: [],
isLoading: false,
async hydrate() {
set({ isLoading: true });
const [approvals, activity] = await Promise.all([listApprovalQueue(), listActivityFeed()]);
set({
approvals: approvals.map(toApprovalItem),
activity: activity.map(toActivityItem),
isLoading: false,
});
},
async approve(id: string) {
const current = get().approvals.find((item) => item.id === id);
if (!current) {
return;
}
const updated = await updateApprovalState(id, current.workspaceId, 'approved');
set((state) => ({
approvals: state.approvals.map((item) =>
item.id === id ? { ...item, status: 'approved' } : item,
item.id === id ? toApprovalItem(updated) : item,
),
}));
},
reject(id: string) {
async reject(id: string) {
const current = get().approvals.find((item) => item.id === id);
if (!current) {
return;
}
const updated = await updateApprovalState(id, current.workspaceId, 'rejected');
set((state) => ({
approvals: state.approvals.map((item) =>
item.id === id ? { ...item, status: 'rejected' } : item,
item.id === id ? toApprovalItem(updated) : item,
),
}));
},