feat(mobile): add workspace-scoped utility flows
This commit is contained in:
parent
c517375e39
commit
90558a5537
@ -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
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user