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 { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { useInboxStore, type ActivityItem, type ApprovalItem, type InboxState } from '../../store/inbox-store';
|
import { useInboxStore, type ActivityItem, type ApprovalItem, type InboxState } from '../../store/inbox-store';
|
||||||
import { colors } from '../../theme';
|
import { colors } from '../../theme';
|
||||||
@ -5,28 +6,38 @@ import { colors } from '../../theme';
|
|||||||
export default function InboxScreen() {
|
export default function InboxScreen() {
|
||||||
const approvals = useInboxStore((state: InboxState) => state.approvals);
|
const approvals = useInboxStore((state: InboxState) => state.approvals);
|
||||||
const activity = useInboxStore((state: InboxState) => state.activity);
|
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 approve = useInboxStore((state: InboxState) => state.approve);
|
||||||
const reject = useInboxStore((state: InboxState) => state.reject);
|
const reject = useInboxStore((state: InboxState) => state.reject);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void hydrate();
|
||||||
|
}, [hydrate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.title}>Inbox</Text>
|
<Text style={styles.title}>Inbox</Text>
|
||||||
<Text style={styles.body}>Review simple agent actions and approve or reject them from mobile.</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) => (
|
{approvals.map((item: ApprovalItem) => (
|
||||||
<View key={item.id} style={styles.card}>
|
<View key={item.id} style={styles.card}>
|
||||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||||
<Text style={styles.cardBody}>{item.summary}</Text>
|
<Text style={styles.cardBody}>{item.summary}</Text>
|
||||||
<Text style={styles.status}>Status: {item.status}</Text>
|
<Text style={styles.status}>Status: {item.status}</Text>
|
||||||
<View style={styles.actionRow}>
|
<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>
|
<Text style={styles.approveText}>Approve</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.rejectButton} onPress={() => reject(item.id)}>
|
<Pressable style={styles.rejectButton} onPress={() => void reject(item.id)}>
|
||||||
<Text style={styles.rejectText}>Reject</Text>
|
<Text style={styles.rejectText}>Reject</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
{!isLoading && approvals.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>No approval items are waiting right now.</Text>
|
||||||
|
) : null}
|
||||||
<View style={styles.feedSection}>
|
<View style={styles.feedSection}>
|
||||||
<Text style={styles.feedTitle}>Recent activity</Text>
|
<Text style={styles.feedTitle}>Recent activity</Text>
|
||||||
{activity.map((item: ActivityItem) => (
|
{activity.map((item: ActivityItem) => (
|
||||||
@ -36,6 +47,9 @@ export default function InboxScreen() {
|
|||||||
<Text style={styles.cardBody}>{item.summary}</Text>
|
<Text style={styles.cardBody}>{item.summary}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
{!isLoading && activity.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>No recent agent activity yet.</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -58,6 +72,10 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
lineHeight: 21,
|
lineHeight: 21,
|
||||||
},
|
},
|
||||||
|
empty: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: colors.surfaceCard,
|
backgroundColor: colors.surfaceCard,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
|
|||||||
@ -2,11 +2,13 @@ import { useEffect } from 'react';
|
|||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useAuthStore, type AuthState } from '../store/auth-store';
|
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 { useNotesStore, type NotesState } from '../store/notes-store';
|
||||||
import { useWorkspaceStore, type WorkspaceState } from '../store/workspace-store';
|
import { useWorkspaceStore, type WorkspaceState } from '../store/workspace-store';
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const bootstrapAuth = useAuthStore((state: AuthState) => state.bootstrap);
|
const bootstrapAuth = useAuthStore((state: AuthState) => state.bootstrap);
|
||||||
|
const hydrateInbox = useInboxStore((state: InboxState) => state.hydrate);
|
||||||
const hydrateNotes = useNotesStore((state: NotesState) => state.hydrate);
|
const hydrateNotes = useNotesStore((state: NotesState) => state.hydrate);
|
||||||
const hydrateWorkspaces = useWorkspaceStore((state: WorkspaceState) => state.hydrate);
|
const hydrateWorkspaces = useWorkspaceStore((state: WorkspaceState) => state.hydrate);
|
||||||
|
|
||||||
@ -14,7 +16,8 @@ export default function RootLayout() {
|
|||||||
void bootstrapAuth();
|
void bootstrapAuth();
|
||||||
void hydrateNotes();
|
void hydrateNotes();
|
||||||
void hydrateWorkspaces();
|
void hydrateWorkspaces();
|
||||||
}, [bootstrapAuth, hydrateNotes, hydrateWorkspaces]);
|
void hydrateInbox();
|
||||||
|
}, [bootstrapAuth, hydrateInbox, hydrateNotes, hydrateWorkspaces]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import {
|
||||||
|
listActivityFeed,
|
||||||
|
listApprovalQueue,
|
||||||
|
updateApprovalState,
|
||||||
|
type MobileActivityItem,
|
||||||
|
type MobileApprovalItem,
|
||||||
|
} from '../api/note-agent-actions';
|
||||||
|
|
||||||
export type ApprovalItem = {
|
export type ApprovalItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
noteId: string;
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
status: 'pending' | 'approved' | 'rejected';
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
@ -17,56 +26,56 @@ export type ActivityItem = {
|
|||||||
export type InboxState = {
|
export type InboxState = {
|
||||||
approvals: ApprovalItem[];
|
approvals: ApprovalItem[];
|
||||||
activity: ActivityItem[];
|
activity: ActivityItem[];
|
||||||
approve: (id: string) => void;
|
isLoading: boolean;
|
||||||
reject: (id: string) => void;
|
hydrate: () => Promise<void>;
|
||||||
|
approve: (id: string) => Promise<void>;
|
||||||
|
reject: (id: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useInboxStore = create<InboxState>((set) => ({
|
function toApprovalItem(item: MobileApprovalItem): ApprovalItem {
|
||||||
approvals: [
|
return item;
|
||||||
{
|
}
|
||||||
id: 'approval-1',
|
|
||||||
title: 'Apply note merge suggestion',
|
function toActivityItem(item: MobileActivityItem): ActivityItem {
|
||||||
summary: 'Agent proposed merging two near-duplicate product planning notes.',
|
return item;
|
||||||
status: 'pending',
|
}
|
||||||
},
|
|
||||||
{
|
export const useInboxStore = create<InboxState>((set, get) => ({
|
||||||
id: 'approval-2',
|
approvals: [],
|
||||||
title: 'Create linked task from note',
|
activity: [],
|
||||||
summary: 'Agent extracted a task from a meeting note and queued it for approval.',
|
isLoading: false,
|
||||||
status: 'pending',
|
async hydrate() {
|
||||||
},
|
set({ isLoading: true });
|
||||||
],
|
const [approvals, activity] = await Promise.all([listApprovalQueue(), listActivityFeed()]);
|
||||||
activity: [
|
set({
|
||||||
{
|
approvals: approvals.map(toApprovalItem),
|
||||||
id: 'activity-1',
|
activity: activity.map(toActivityItem),
|
||||||
title: 'Summary refreshed',
|
isLoading: false,
|
||||||
summary: 'Agent updated a note summary after recent edits.',
|
});
|
||||||
kind: 'agent',
|
},
|
||||||
},
|
async approve(id: string) {
|
||||||
{
|
const current = get().approvals.find((item) => item.id === id);
|
||||||
id: 'activity-2',
|
if (!current) {
|
||||||
title: 'Task extracted',
|
return;
|
||||||
summary: 'A follow-up task was extracted from a meeting note.',
|
}
|
||||||
kind: 'task',
|
|
||||||
},
|
const updated = await updateApprovalState(id, current.workspaceId, 'approved');
|
||||||
{
|
|
||||||
id: 'activity-3',
|
|
||||||
title: 'Related note linked',
|
|
||||||
summary: 'A planning note was linked to a roadmap note.',
|
|
||||||
kind: 'note',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
approve(id: string) {
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
approvals: state.approvals.map((item) =>
|
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) => ({
|
set((state) => ({
|
||||||
approvals: state.approvals.map((item) =>
|
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