feat(notes): wire mobile inbox review flow
This commit is contained in:
parent
650c061dba
commit
8f14698a42
126
mobile/src/api/note-agent-actions.ts
Normal file
126
mobile/src/api/note-agent-actions.ts
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user