mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 03:31:36 +00:00
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:
@@ -35,6 +35,7 @@ import { AgentDetailScreen } from "./src/screens/AgentDetailScreen";
|
|||||||
import { AnswerScreen } from "./src/screens/AnswerScreen";
|
import { AnswerScreen } from "./src/screens/AnswerScreen";
|
||||||
import { SpawnScreen } from "./src/screens/SpawnScreen";
|
import { SpawnScreen } from "./src/screens/SpawnScreen";
|
||||||
import { SchedulesScreen } from "./src/screens/SchedulesScreen";
|
import { SchedulesScreen } from "./src/screens/SchedulesScreen";
|
||||||
|
import { MemoryScreen } from "./src/screens/MemoryScreen";
|
||||||
import { LogScreen } from "./src/screens/LogScreen";
|
import { LogScreen } from "./src/screens/LogScreen";
|
||||||
import { SettingsScreen } from "./src/screens/SettingsScreen";
|
import { SettingsScreen } from "./src/screens/SettingsScreen";
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ function Router() {
|
|||||||
answer: AnswerScreen,
|
answer: AnswerScreen,
|
||||||
spawn: SpawnScreen,
|
spawn: SpawnScreen,
|
||||||
schedules: SchedulesScreen,
|
schedules: SchedulesScreen,
|
||||||
|
memory: MemoryScreen,
|
||||||
log: LogScreen,
|
log: LogScreen,
|
||||||
settings: SettingsScreen,
|
settings: SettingsScreen,
|
||||||
}[screen];
|
}[screen];
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useAppState, type Screen } from "../state/AppState";
|
|||||||
const TABS: { key: Screen; label: string; icon: IconName }[] = [
|
const TABS: { key: Screen; label: string; icon: IconName }[] = [
|
||||||
{ key: "fleet", label: "Fleet", icon: "home" },
|
{ key: "fleet", label: "Fleet", icon: "home" },
|
||||||
{ key: "schedules", label: "Schedules", icon: "clock" },
|
{ key: "schedules", label: "Schedules", icon: "clock" },
|
||||||
|
{ key: "memory", label: "Memory", icon: "brain" },
|
||||||
{ key: "log", label: "Log", icon: "file" },
|
{ key: "log", label: "Log", icon: "file" },
|
||||||
{ key: "settings", label: "Settings", icon: "settings" },
|
{ key: "settings", label: "Settings", icon: "settings" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type Checkmark,
|
type Checkmark,
|
||||||
type ClaudeModel,
|
type ClaudeModel,
|
||||||
type LogEntry,
|
type LogEntry,
|
||||||
|
type MemoryGraph,
|
||||||
type Project,
|
type Project,
|
||||||
type Schedule,
|
type Schedule,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
@@ -41,6 +42,7 @@ export type Screen =
|
|||||||
| "answer"
|
| "answer"
|
||||||
| "spawn"
|
| "spawn"
|
||||||
| "schedules"
|
| "schedules"
|
||||||
|
| "memory"
|
||||||
| "log"
|
| "log"
|
||||||
| "settings";
|
| "settings";
|
||||||
|
|
||||||
@@ -103,6 +105,9 @@ interface AppStateValue {
|
|||||||
models: ClaudeModel[];
|
models: ClaudeModel[];
|
||||||
/* Recurring agent spawns, across all projects. */
|
/* Recurring agent spawns, across all projects. */
|
||||||
schedules: Schedule[];
|
schedules: Schedule[];
|
||||||
|
/* The agent-memory note graph; null until the memory screen first loads it. */
|
||||||
|
memory: MemoryGraph | null;
|
||||||
|
memoryError: string | null;
|
||||||
waiting: WaitingItem[];
|
waiting: WaitingItem[];
|
||||||
recent: RecentItem[];
|
recent: RecentItem[];
|
||||||
counts: { running: number; waiting: number; done: number };
|
counts: { running: number; waiting: number; done: number };
|
||||||
@@ -405,6 +410,29 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return rows;
|
return rows;
|
||||||
}, [agentsByProject, logsByAgent]);
|
}, [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 ----------------------------------------
|
// ---- Selected agent's run events ----------------------------------------
|
||||||
// Cursor-paged poll (after_id = largest id seen) on a 3s cadence, active only
|
// 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
|
// 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,
|
projects,
|
||||||
models,
|
models,
|
||||||
schedules,
|
schedules,
|
||||||
|
memory,
|
||||||
|
memoryError,
|
||||||
waiting,
|
waiting,
|
||||||
recent,
|
recent,
|
||||||
counts,
|
counts,
|
||||||
@@ -624,6 +654,8 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
projects,
|
projects,
|
||||||
models,
|
models,
|
||||||
schedules,
|
schedules,
|
||||||
|
memory,
|
||||||
|
memoryError,
|
||||||
waiting,
|
waiting,
|
||||||
recent,
|
recent,
|
||||||
counts,
|
counts,
|
||||||
|
|||||||
Reference in New Issue
Block a user