From deb17c95e4de14c26f4e88c273913b7a7cd5d1bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:13:50 +0000 Subject: [PATCH] feat(app): memory screen - browse the agent note graph on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Memory tab loads /memory/graph when opened and lists the distilled notes newest-first with kind filter chips (fact/decision/gotcha/runbook); tapping a note expands its body, tags, and both directions of its links, resolved to note titles. Read-only by design — authoring stays with the web dashboard's admin surface and the agents' own MCP server. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48 --- app/App.tsx | 2 + app/src/components/TabBar.tsx | 1 + app/src/screens/MemoryScreen.tsx | 185 +++++++++++++++++++++++++++++++ app/src/state/AppState.tsx | 32 ++++++ 4 files changed, 220 insertions(+) create mode 100644 app/src/screens/MemoryScreen.tsx diff --git a/app/App.tsx b/app/App.tsx index c373e89..93d836b 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -35,6 +35,7 @@ import { AgentDetailScreen } from "./src/screens/AgentDetailScreen"; import { AnswerScreen } from "./src/screens/AnswerScreen"; import { SpawnScreen } from "./src/screens/SpawnScreen"; import { SchedulesScreen } from "./src/screens/SchedulesScreen"; +import { MemoryScreen } from "./src/screens/MemoryScreen"; import { LogScreen } from "./src/screens/LogScreen"; import { SettingsScreen } from "./src/screens/SettingsScreen"; @@ -48,6 +49,7 @@ function Router() { answer: AnswerScreen, spawn: SpawnScreen, schedules: SchedulesScreen, + memory: MemoryScreen, log: LogScreen, settings: SettingsScreen, }[screen]; diff --git a/app/src/components/TabBar.tsx b/app/src/components/TabBar.tsx index 5daaf4a..c2d7a9b 100644 --- a/app/src/components/TabBar.tsx +++ b/app/src/components/TabBar.tsx @@ -15,6 +15,7 @@ import { useAppState, type Screen } from "../state/AppState"; const TABS: { key: Screen; label: string; icon: IconName }[] = [ { key: "fleet", label: "Fleet", icon: "home" }, { key: "schedules", label: "Schedules", icon: "clock" }, + { key: "memory", label: "Memory", icon: "brain" }, { key: "log", label: "Log", icon: "file" }, { key: "settings", label: "Settings", icon: "settings" }, ]; diff --git a/app/src/screens/MemoryScreen.tsx b/app/src/screens/MemoryScreen.tsx new file mode 100644 index 0000000..01ff747 --- /dev/null +++ b/app/src/screens/MemoryScreen.tsx @@ -0,0 +1,185 @@ +import React, { useMemo, useState } from "react"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Badge } from "../components/Badge"; +import { Chip } from "../components/Chip"; +import { TabBar } from "../components/TabBar"; +import { Card, Divider, Mono, SectionLabel } from "../components/primitives"; +import { useAppState, type BadgeTone } from "../state/AppState"; +import { timeAgo } from "../api/format"; +import type { MemoryNote } from "../api/client"; + +/** + * Memory — a read view over the agent-memory note graph (the web dashboard's + * Memory page). Notes are the distilled facts/decisions/gotchas/runbooks agents + * leave for each other; tapping a note expands its body, tags, and links. + * Authoring stays on the web dashboard (admin token) and in the agents' own + * MCP server — the phone is for looking things up. + */ + +const KIND_FILTERS = ["all", "fact", "decision", "gotcha", "runbook"]; + +const KIND_TONES: Record = { + fact: "neutral", + decision: "positive", + gotcha: "danger", + runbook: "warning", +}; + +export function MemoryScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { memory, memoryError } = useAppState(); + + const [kind, setKind] = useState("all"); + const [openId, setOpenId] = useState(null); + + const notes = useMemo(() => { + const all = memory?.notes ?? []; + const inKind = kind === "all" ? all : all.filter((n) => n.kind === kind); + return [...inKind].sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + }, [memory, kind]); + + const byId = useMemo( + () => new Map((memory?.notes ?? []).map((n) => [n.id, n])), + [memory], + ); + + /* Both directions of the graph for one note: outgoing "relation → title" and + * incoming "title → relation". */ + const linksFor = (note: MemoryNote): { key: string; label: string }[] => { + const links = memory?.links ?? []; + const out: { key: string; label: string }[] = []; + for (const l of links) { + if (l.src_note_id === note.id) { + out.push({ key: `o${l.id}`, label: `${l.relation} → ${title(byId, l.dst_note_id)}` }); + } else if (l.dst_note_id === note.id) { + out.push({ key: `i${l.id}`, label: `${title(byId, l.src_note_id)} → ${l.relation}` }); + } + } + return out; + }; + + return ( + + + + + Memory + + Notes agents distill for every future run. Edit them from the web + dashboard. + + + {KIND_FILTERS.map((k) => ( + setKind(k)} + /> + ))} + + + + + {memoryError ? ( + {memoryError} + ) : memory === null ? ( + Loading… + ) : notes.length === 0 ? ( + + {kind === "all" ? "No notes yet." : `No ${kind} notes yet.`} + + ) : ( + <> + + {`${notes.length} note${notes.length === 1 ? "" : "s"}`} + + + {notes.map((n, i) => { + const open = openId === n.id; + const links = open ? linksFor(n) : []; + return ( + + {i > 0 && } + setOpenId(open ? null : n.id)} + > + + {n.kind} + + {n.title} + + + + {n.project_id ?? "global"} · updated {timeAgo(n.updated_at)} ago + + + {open ? ( + + + {n.body} + + {n.tags && n.tags.length > 0 ? ( + + {n.tags.map((t) => `#${t}`).join(" ")} + + ) : null} + {links.length > 0 ? ( + + Links + {links.map((l) => ( + + {l.label} + + ))} + + ) : null} + + ) : null} + + + ); + })} + + + )} + + + + + ); +} + +function title(byId: Map, id: number): string { + return byId.get(id)?.title ?? `note #${id}`; +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 14 }, + filters: { flexDirection: "row", gap: 8, marginTop: 14, paddingRight: 20 }, + body: { paddingHorizontal: 20, paddingBottom: 24 }, + row: { paddingVertical: 12, paddingHorizontal: 16 }, + titleRow: { flexDirection: "row", alignItems: "center", gap: 8 }, +}); diff --git a/app/src/state/AppState.tsx b/app/src/state/AppState.tsx index ea146f6..c1a1c0f 100644 --- a/app/src/state/AppState.tsx +++ b/app/src/state/AppState.tsx @@ -17,6 +17,7 @@ import { type Checkmark, type ClaudeModel, type LogEntry, + type MemoryGraph, type Project, type Schedule, } from "../api/client"; @@ -41,6 +42,7 @@ export type Screen = | "answer" | "spawn" | "schedules" + | "memory" | "log" | "settings"; @@ -103,6 +105,9 @@ interface AppStateValue { models: ClaudeModel[]; /* Recurring agent spawns, across all projects. */ schedules: Schedule[]; + /* The agent-memory note graph; null until the memory screen first loads it. */ + memory: MemoryGraph | null; + memoryError: string | null; waiting: WaitingItem[]; recent: RecentItem[]; counts: { running: number; waiting: number; done: number }; @@ -405,6 +410,29 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { return rows; }, [agentsByProject, logsByAgent]); + // ---- Agent memory -------------------------------------------------------- + // The note graph is loaded when the memory screen opens (and re-fetched on each + // open) rather than on the fleet poll — it changes slowly and can be large. + const [memory, setMemory] = useState(null); + const [memoryError, setMemoryError] = useState(null); + useEffect(() => { + if (!client || screen !== "memory") return; + let stale = false; + setMemoryError(null); + client + .api("/memory/graph") + .then((g) => { + if (!stale) setMemory(g); + }) + .catch((e) => { + if (e instanceof AuthError) return; // handled by onUnauthorized + if (!stale) setMemoryError(errMessage(e)); + }); + return () => { + stale = true; + }; + }, [client, screen]); + // ---- Selected agent's run events ---------------------------------------- // Cursor-paged poll (after_id = largest id seen) on a 3s cadence, active only // while the detail or answer screen is showing an agent. Selection change resets @@ -595,6 +623,8 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { projects, models, schedules, + memory, + memoryError, waiting, recent, counts, @@ -624,6 +654,8 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { projects, models, schedules, + memory, + memoryError, waiting, recent, counts,