diff --git a/mobile/src/api/note-agent-actions.ts b/mobile/src/api/note-agent-actions.ts new file mode 100644 index 0000000..68d888b --- /dev/null +++ b/mobile/src/api/note-agent-actions.ts @@ -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 { + const response = await getApiClient().fetch( + `/note-agent-actions?workspaceId=${encodeURIComponent(workspaceId)}`, + ); + return response.items; +} + +export async function listApprovalQueue(): Promise { + 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 { + 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 { + const updated = await getApiClient().fetch( + `/note-agent-actions/${encodeURIComponent(id)}?workspaceId=${encodeURIComponent(workspaceId)}`, + { + method: 'PATCH', + body: JSON.stringify({ + state, + reviewedAt: new Date().toISOString(), + }), + }, + ); + + return toApprovalItem(updated); +} diff --git a/mobile/src/app/(tabs)/inbox.tsx b/mobile/src/app/(tabs)/inbox.tsx index dc878ce..2193634 100644 --- a/mobile/src/app/(tabs)/inbox.tsx +++ b/mobile/src/app/(tabs)/inbox.tsx @@ -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 ( Inbox Review simple agent actions and approve or reject them from mobile. + {isLoading ? Loading approvals… : null} {approvals.map((item: ApprovalItem) => ( {item.title} {item.summary} Status: {item.status} - approve(item.id)}> + void approve(item.id)}> Approve - reject(item.id)}> + void reject(item.id)}> Reject ))} + {!isLoading && approvals.length === 0 ? ( + No approval items are waiting right now. + ) : null} Recent activity {activity.map((item: ActivityItem) => ( @@ -36,6 +47,9 @@ export default function InboxScreen() { {item.summary} ))} + {!isLoading && activity.length === 0 ? ( + No recent agent activity yet. + ) : null} ); @@ -58,6 +72,10 @@ const styles = StyleSheet.create({ fontSize: 15, lineHeight: 21, }, + empty: { + color: colors.textSecondary, + fontSize: 14, + }, card: { backgroundColor: colors.surfaceCard, borderRadius: 16, diff --git a/mobile/src/app/_layout.tsx b/mobile/src/app/_layout.tsx index fb63234..d763f7a 100644 --- a/mobile/src/app/_layout.tsx +++ b/mobile/src/app/_layout.tsx @@ -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 ( <> diff --git a/mobile/src/store/inbox-store.ts b/mobile/src/store/inbox-store.ts index 1141d0d..7c1cecc 100644 --- a/mobile/src/store/inbox-store.ts +++ b/mobile/src/store/inbox-store.ts @@ -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; + approve: (id: string) => Promise; + reject: (id: string) => Promise; }; -export const useInboxStore = create((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((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, ), })); },