feat(app): memory screen - browse the agent note graph on mobile

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
This commit is contained in:
Claude
2026-08-13 13:13:50 +00:00
parent 31de11c07c
commit deb17c95e4
4 changed files with 220 additions and 0 deletions
+2
View File
@@ -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];
+1
View File
@@ -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" },
];
+185
View File
@@ -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<string, BadgeTone> = {
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<number | null>(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 (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<View style={styles.header}>
<Text style={[text.h3, { color: colors.textHeading }]}>Memory</Text>
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4 }]}>
Notes agents distill for every future run. Edit them from the web
dashboard.
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filters}
>
{KIND_FILTERS.map((k) => (
<Chip
key={k}
label={k === "all" ? "All" : k}
selected={kind === k}
onPress={() => setKind(k)}
/>
))}
</ScrollView>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={styles.body}
showsVerticalScrollIndicator={false}
>
{memoryError ? (
<Text style={[text.bodySm, { color: colors.danger }]}>{memoryError}</Text>
) : memory === null ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>Loading</Text>
) : notes.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
{kind === "all" ? "No notes yet." : `No ${kind} notes yet.`}
</Text>
) : (
<>
<SectionLabel style={{ marginBottom: 8 }}>
{`${notes.length} note${notes.length === 1 ? "" : "s"}`}
</SectionLabel>
<Card>
{notes.map((n, i) => {
const open = openId === n.id;
const links = open ? linksFor(n) : [];
return (
<View key={n.id}>
{i > 0 && <Divider />}
<Pressable
style={styles.row}
onPress={() => setOpenId(open ? null : n.id)}
>
<View style={styles.titleRow}>
<Badge tone={KIND_TONES[n.kind] ?? "neutral"}>{n.kind}</Badge>
<Text
numberOfLines={open ? undefined : 1}
style={[text.bodySm, { color: colors.textHeading, flex: 1 }]}
>
{n.title}
</Text>
</View>
<Text style={[text.caption, { color: colors.textMuted, marginTop: 4 }]}>
{n.project_id ?? "global"} · updated {timeAgo(n.updated_at)} ago
</Text>
{open ? (
<View style={{ marginTop: 10, gap: 8 }}>
<Text style={[text.bodySm, { color: colors.textBody }]}>
{n.body}
</Text>
{n.tags && n.tags.length > 0 ? (
<Mono style={{ fontSize: 12, color: colors.textMuted }}>
{n.tags.map((t) => `#${t}`).join(" ")}
</Mono>
) : null}
{links.length > 0 ? (
<View style={{ gap: 4 }}>
<SectionLabel>Links</SectionLabel>
{links.map((l) => (
<Mono
key={l.key}
style={{ fontSize: 12, color: colors.textMuted }}
>
{l.label}
</Mono>
))}
</View>
) : null}
</View>
) : null}
</Pressable>
</View>
);
})}
</Card>
</>
)}
</ScrollView>
<TabBar active="memory" />
</View>
);
}
function title(byId: Map<number, MemoryNote>, 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 },
});
+32
View File
@@ -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<MemoryGraph | null>(null);
const [memoryError, setMemoryError] = useState<string | null>(null);
useEffect(() => {
if (!client || screen !== "memory") return;
let stale = false;
setMemoryError(null);
client
.api<MemoryGraph>("/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,