feat(mobile): add workspace-scoped utility flows

This commit is contained in:
saravanakumardb1 2026-03-10 09:09:14 -07:00
parent c517375e39
commit 90558a5537
7 changed files with 172 additions and 34 deletions

View File

@ -27,17 +27,17 @@ Stack: React Native + Expo + TypeScript
# Phase M2 — Product Utility
- [ ] Lightweight editing
- [x] Lightweight editing
- [ ] Notification handling
- [ ] Offline queue / sync polish
- [ ] Approvals inbox if in MVP scope
- [ ] Basic artifact metadata viewing if in MVP scope
- [x] Approvals inbox if in MVP scope
- [x] Basic artifact metadata viewing if in MVP scope
# Phase M3 — Agent / Approval Utility
- [ ] Approval/reject actions
- [ ] Lightweight agent activity feed
- [ ] Mobile-friendly review surfaces for simple actions
- [x] Approval/reject actions
- [x] Lightweight agent activity feed
- [x] Mobile-friendly review surfaces for simple actions
# Phase M4 — Hardening
@ -57,7 +57,7 @@ Stack: React Native + Expo + TypeScript
# Done When
- [ ] Mobile is useful for capture and retrieval
- [ ] Mobile supports lightweight editing and approvals for MVP
- [x] Mobile supports lightweight editing and approvals for MVP
- [ ] Mobile does not try to duplicate dense web-only workflows
# Progress Notes
@ -65,22 +65,24 @@ Stack: React Native + Expo + TypeScript
- 2026-03-10 — Mobile workstream moved from draft-only planning into initial implementation.
- `learning_ai_notes/mobile/` was scaffolded from scratch with Expo + TypeScript.
- Root app config now exists: `package.json`, `app.json`, `tsconfig.json`, `index.ts`, `babel.config.js`.
- Expo Router shell now exists with auth entry, tab layout, home/recent notes, search, quick capture, inbox placeholder, and note detail routes.
- Expo Router shell now exists with auth entry, tab layout, home/recent notes, search, quick capture, inbox, and note detail routes.
- Shared mobile bootstrap files now exist for:
- `@bytelyst/auth-client`
- `@bytelyst/platform-client`
- `@bytelyst/offline-queue`
- token-based theme wiring via `@bytelyst/design-tokens/tokens.json`
- Zustand-backed auth, notes, and workspace stores were added for first-pass mobile state management.
- Home/search/capture now use active workspace context for more useful mobile browsing.
- Note detail now supports lightweight local editing plus basic artifact metadata viewing.
- Inbox now supports simple approval/reject flows and a lightweight activity feed.
- Mobile currently uses provisional product config and fallback note/workspace data until product identity and backend contract details are finalized.
# Open Questions
- What is the final canonical `productId` for ByteLyst Agentic Notes?
- What is the final product backend port for the notes service?
- Should the current provisional bootstrap values (`productId: bytelyst-notes`, backend port `4016`) now be treated as final?
- What are the final iOS bundle identifier, Android package name, and URL scheme/domain values?
- Should mobile notes access go directly to the product backend for note CRUD while auth remains on `platform-service`?
- Is approvals inbox definitely in MVP scope, or should it remain a deferred Phase M2/M3 item?
- Should the current local approval/activity model map directly to backend `note-agent-actions`, or stay as a mobile-specific condensed surface?
# Blockers
@ -90,9 +92,6 @@ Stack: React Native + Expo + TypeScript
# Deferred
- Lightweight editing beyond scaffold-level draft save
- Real notification handling
- Offline queue flush/retry integration against live APIs
- Approval actions and lightweight agent activity feed
- Artifact metadata viewing
- Smoke tests and compile verification after dependency install

View File

@ -1,18 +1,26 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useNotesStore } from '../../store/notes-store';
import type { MobileWorkspace } from '../../api/workspaces';
import { useNotesStore, type NotesState } from '../../store/notes-store';
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
import { colors } from '../../theme';
export default function CaptureScreen() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [saved, setSaved] = useState(false);
const saveDraft = useNotesStore((state) => state.saveDraft);
const saveDraft = useNotesStore((state: NotesState) => state.saveDraft);
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
const activeWorkspaceName =
workspaces.find((workspace: MobileWorkspace) => workspace.id === activeWorkspaceId)?.name ?? 'Drafts';
return (
<View style={styles.container}>
<Text style={styles.title}>Quick capture</Text>
<Text style={styles.subtitle}>Create a lightweight mobile draft. Offline queue wiring comes in the next batch.</Text>
<Text style={styles.subtitle}>
Create a lightweight mobile draft in {activeWorkspaceName}. Offline queue wiring comes in a later batch.
</Text>
<TextInput
value={title}
onChangeText={(value: string) => {
@ -87,7 +95,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
buttonText: {
color: '#EFF4FF',
color: colors.textPrimary,
fontWeight: '700',
},
saved: {

View File

@ -1,9 +1,10 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useInboxStore, type ApprovalItem, type InboxState } from '../../store/inbox-store';
import { useInboxStore, type ActivityItem, type ApprovalItem, type InboxState } from '../../store/inbox-store';
import { colors } from '../../theme';
export default function InboxScreen() {
const approvals = useInboxStore((state: InboxState) => state.approvals);
const activity = useInboxStore((state: InboxState) => state.activity);
const approve = useInboxStore((state: InboxState) => state.approve);
const reject = useInboxStore((state: InboxState) => state.reject);
@ -26,6 +27,16 @@ export default function InboxScreen() {
</View>
</View>
))}
<View style={styles.feedSection}>
<Text style={styles.feedTitle}>Recent activity</Text>
{activity.map((item: ActivityItem) => (
<View key={item.id} style={styles.feedCard}>
<Text style={styles.feedKind}>{item.kind}</Text>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardBody}>{item.summary}</Text>
</View>
))}
</View>
</View>
);
}
@ -94,4 +105,26 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontWeight: '600',
},
feedSection: {
gap: 10,
},
feedTitle: {
color: colors.textPrimary,
fontSize: 18,
fontWeight: '700',
},
feedCard: {
backgroundColor: colors.bgElevated,
borderRadius: 14,
borderWidth: 1,
borderColor: colors.borderDefault,
padding: 14,
gap: 6,
},
feedKind: {
color: colors.accentSecondary,
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
},
});

View File

@ -1,20 +1,30 @@
import { useMemo } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { useNotesStore } from '../../store/notes-store';
import type { MobileNote } from '../../api/notes';
import { useNotesStore, type NotesState } from '../../store/notes-store';
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
import type { MobileWorkspace } from '../../api/workspaces';
import { colors } from '../../theme';
export default function HomeScreen() {
const notes = useNotesStore((state) => state.notes);
const notes = useNotesStore((state: NotesState) => state.notes);
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
const setActiveWorkspace = useWorkspaceStore((state: WorkspaceState) => state.setActiveWorkspace);
const activeWorkspaceName =
workspaces.find((workspace: MobileWorkspace) => workspace.id === activeWorkspaceId)?.name ?? null;
const recentNotes = useMemo(
() =>
notes.map((note) => ({
id: note.id,
title: note.title,
workspace: note.workspaceName,
preview: note.body,
})),
[notes]
notes
.filter((note: MobileNote) => (activeWorkspaceName ? note.workspaceName === activeWorkspaceName : true))
.map((note: MobileNote) => ({
id: note.id,
title: note.title,
workspace: note.workspaceName,
preview: note.body,
})),
[activeWorkspaceName, notes]
);
return (
@ -22,8 +32,25 @@ export default function HomeScreen() {
<Text style={styles.eyebrow}>Recent notes</Text>
<Text style={styles.title}>Capture and retrieve fast</Text>
<Text style={styles.subtitle}>Mobile MVP focuses on recent notes, quick capture, search, and lightweight review.</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.workspaceRow}>
{workspaces.map((workspace: MobileWorkspace) => {
const isActive = workspace.id === activeWorkspaceId;
{recentNotes.map((note) => (
return (
<Pressable
key={workspace.id}
onPress={() => setActiveWorkspace(workspace.id)}
style={[styles.workspaceChip, isActive ? styles.workspaceChipActive : null]}
>
<Text style={[styles.workspaceChipText, isActive ? styles.workspaceChipTextActive : null]}>
{workspace.name}
</Text>
</Pressable>
);
})}
</ScrollView>
{recentNotes.map((note: (typeof recentNotes)[number]) => (
<Pressable key={note.id} onPress={() => router.push(`/note/${note.id}`)} style={styles.card}>
<View style={styles.badgeRow}>
<Text style={styles.badge}>{note.workspace}</Text>
@ -61,6 +88,29 @@ const styles = StyleSheet.create({
lineHeight: 22,
marginBottom: 8,
},
workspaceRow: {
gap: 10,
paddingBottom: 6,
},
workspaceChip: {
borderWidth: 1,
borderColor: colors.borderDefault,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: colors.surfaceCard,
},
workspaceChipActive: {
backgroundColor: colors.accentPrimary,
borderColor: colors.accentPrimary,
},
workspaceChipText: {
color: colors.textSecondary,
fontWeight: '600',
},
workspaceChipTextActive: {
color: colors.textPrimary,
},
card: {
backgroundColor: colors.surfaceCard,
borderWidth: 1,
@ -74,7 +124,7 @@ const styles = StyleSheet.create({
},
badge: {
color: colors.accentPrimary,
backgroundColor: '#14203A',
backgroundColor: colors.bgElevated,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,

View File

@ -2,25 +2,41 @@ import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { router } from 'expo-router';
import type { MobileNote } from '../../api/notes';
import type { MobileWorkspace } from '../../api/workspaces';
import { useNotesStore, type NotesState } from '../../store/notes-store';
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
import { colors } from '../../theme';
export default function SearchScreen() {
const [query, setQuery] = useState('');
const notes = useNotesStore((state: NotesState) => state.notes);
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
const activeWorkspaceName =
workspaces.find((workspace: MobileWorkspace) => workspace.id === activeWorkspaceId)?.name ?? null;
const results = useMemo(() => {
const scopedNotes = notes.filter((note: MobileNote) =>
activeWorkspaceName ? note.workspaceName === activeWorkspaceName : true
);
if (!query.trim()) {
return notes;
return scopedNotes;
}
const normalized = query.toLowerCase();
return notes.filter((note: MobileNote) => note.title.toLowerCase().includes(normalized));
}, [notes, query]);
return scopedNotes.filter(
(note: MobileNote) =>
note.title.toLowerCase().includes(normalized) ||
note.body.toLowerCase().includes(normalized) ||
note.workspaceName.toLowerCase().includes(normalized)
);
}, [activeWorkspaceName, notes, query]);
return (
<View style={styles.container}>
<Text style={styles.title}>Search</Text>
<Text style={styles.scope}>Scope: {activeWorkspaceName ?? 'All workspaces'}</Text>
<TextInput
value={query}
onChangeText={setQuery}
@ -61,6 +77,10 @@ const styles = StyleSheet.create({
color: colors.textPrimary,
backgroundColor: colors.surfaceCard,
},
scope: {
color: colors.textSecondary,
fontSize: 14,
},
list: {
gap: 10,
},

View File

@ -7,8 +7,16 @@ export type ApprovalItem = {
status: 'pending' | 'approved' | 'rejected';
};
export type ActivityItem = {
id: string;
title: string;
summary: string;
kind: 'note' | 'task' | 'agent';
};
export type InboxState = {
approvals: ApprovalItem[];
activity: ActivityItem[];
approve: (id: string) => void;
reject: (id: string) => void;
};
@ -28,6 +36,26 @@ export const useInboxStore = create<InboxState>((set) => ({
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) {
set((state) => ({
approvals: state.approvals.map((item) =>

View File

@ -1,7 +1,7 @@
import { create } from 'zustand';
import { listWorkspaces, type MobileWorkspace } from '../api/workspaces';
type WorkspaceState = {
export type WorkspaceState = {
workspaces: MobileWorkspace[];
activeWorkspaceId: string | null;
hydrate: () => Promise<void>;