mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
Memory V1: remembered facts, your instructions, one screen
Coworkers remember durable things you tell them and use them in future sessions. One Settings screen lists everything remembered - edit, delete, or stop new saves; standing instructions ride along. Knowledge is session-stable, the save switch is per-message; sqlite gains a summary column via in-place migration.
This commit is contained in:
@@ -8,7 +8,10 @@ import {
|
||||
getSessionMessages,
|
||||
getSessions,
|
||||
announceAutomationsChanged,
|
||||
announceMemoryChanged,
|
||||
connectEvents,
|
||||
deleteMemory,
|
||||
updateMemory,
|
||||
getSettings,
|
||||
getPersonas,
|
||||
getInbox,
|
||||
@@ -189,10 +192,10 @@ export function App() {
|
||||
const [scheduledOpenId, setScheduledOpenId] = useState<string | null>(null);
|
||||
const [gateCreate, setGateCreate] = useState(false);
|
||||
// Which Settings section the full-page Settings surface opens on (§ Settings-as-page).
|
||||
const [settingsTab, setSettingsTab] = useState<"appearance" | "models" | "voice" | "personas">(
|
||||
const [settingsTab, setSettingsTab] = useState<"appearance" | "models" | "voice" | "memory" | "personas">(
|
||||
"appearance",
|
||||
);
|
||||
const openSettings = (tab: "appearance" | "models" | "voice" | "personas" = "appearance") => {
|
||||
const openSettings = (tab: "appearance" | "models" | "voice" | "memory" | "personas" = "appearance") => {
|
||||
setSettingsTab(tab);
|
||||
setSurface("settings");
|
||||
};
|
||||
@@ -689,6 +692,24 @@ export function App() {
|
||||
if (d.model) setModel(d.model);
|
||||
setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]);
|
||||
break;
|
||||
case "memory_saved":
|
||||
// §5.1 save notice — inline in the transcript, where the user is already
|
||||
// looking and where it keeps until they act (a corner toast disappeared
|
||||
// before it could be read or undone — owner-hit 2026-07-28). Summary is the
|
||||
// friendly one-liner; content is the fallback when the model skipped it.
|
||||
setItems((p) => [
|
||||
...p,
|
||||
{
|
||||
kind: "memory",
|
||||
id: Number(d.id),
|
||||
text: String(d.summary || d.content || ""),
|
||||
// Present when an existing memory was edited rather than added — the
|
||||
// notice says so, and Undo restores this text instead of deleting.
|
||||
...(d.previous ? { previous: String(d.previous) } : {}),
|
||||
},
|
||||
]);
|
||||
announceMemoryChanged(); // Settings ▸ Memory, if open, is now stale
|
||||
break;
|
||||
case "interrupted":
|
||||
flushPartialStream();
|
||||
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]);
|
||||
@@ -926,6 +947,18 @@ export function App() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [runToast]);
|
||||
|
||||
// MEMORY-SPEC §5.1: undo a write the transcript just announced. A new memory is
|
||||
// deleted; an EDIT is rolled back to its previous text (deleting there would throw
|
||||
// away whatever the memory already held). The notice confirms in place either way.
|
||||
const undoMemorySave = async (id: number, previous?: string) => {
|
||||
if (previous) await updateMemory(id, previous).catch(() => {});
|
||||
else await deleteMemory(id).catch(() => {});
|
||||
announceMemoryChanged();
|
||||
setItems((p) =>
|
||||
p.map((it) => (it.kind === "memory" && it.id === id ? { ...it, undone: true } : it)),
|
||||
);
|
||||
};
|
||||
|
||||
const openSessionFromInbox = (sid: string, ws: string, ag: string) => selectSession(sid, ws, ag);
|
||||
const selectSession = async (id: string, ws: string, ag: string) => {
|
||||
setSurface("session"); // selecting a conversation always returns to the conversation view
|
||||
@@ -1470,6 +1503,7 @@ export function App() {
|
||||
onApprove={approve}
|
||||
running={running}
|
||||
onRetry={retry}
|
||||
onUndoMemory={(id, previous) => void undoMemorySave(id, previous)}
|
||||
// §33 ref #3: sub-threshold streamed text renders INSIDE the live turn
|
||||
// group (header when collapsed, quiet line when expanded) — never as a
|
||||
// floating paragraph.
|
||||
|
||||
@@ -1256,6 +1256,73 @@ export async function setOnboarded(value: boolean): Promise<{ ok: boolean; onboa
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// -- Memory (MEMORY-SPEC §5.3/§6: the memory screen, user rules, toast Undo) ----
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: number;
|
||||
scope: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MemorySettings {
|
||||
enabled: boolean;
|
||||
user_rules: string;
|
||||
}
|
||||
|
||||
// Fired whenever memory changes from OUTSIDE the memory screen — today the agent
|
||||
// saving or editing one mid-conversation. The screen only loads its list on mount, so
|
||||
// without this it sits there stale and the user reads "Nothing yet" seconds after a
|
||||
// save actually landed (owner-hit 2026-07-28).
|
||||
export const MEMORY_CHANGED = "coworker:memory-changed";
|
||||
export function announceMemoryChanged() {
|
||||
window.dispatchEvent(new CustomEvent(MEMORY_CHANGED));
|
||||
}
|
||||
|
||||
export async function getMemory(): Promise<MemoryEntry[]> {
|
||||
const res = await fetch(`${httpBase()}/v1/memory`);
|
||||
return (await res.json()).memory ?? [];
|
||||
}
|
||||
|
||||
export async function updateMemory(
|
||||
id: number,
|
||||
content: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await fetch(`${httpBase()}/v1/memory/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteMemory(id: number): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await fetch(`${httpBase()}/v1/memory/${id}`, { method: "DELETE" });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteAllMemory(): Promise<{ ok: boolean; deleted: number }> {
|
||||
const res = await fetch(`${httpBase()}/v1/memory`, { method: "DELETE" });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getMemorySettings(): Promise<MemorySettings> {
|
||||
const res = await fetch(`${httpBase()}/v1/memory/settings`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function setMemorySettings(
|
||||
patch: Partial<MemorySettings>,
|
||||
): Promise<MemorySettings> {
|
||||
const res = await fetch(`${httpBase()}/v1/memory/settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// -- model providers (OpenAI, Ollama, …) --------------------------------------
|
||||
export interface ProviderField {
|
||||
key: string;
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
deleteAllMemory,
|
||||
deleteMemory,
|
||||
getMemory,
|
||||
getMemorySettings,
|
||||
setMemorySettings,
|
||||
updateMemory,
|
||||
MEMORY_CHANGED,
|
||||
type MemoryEntry,
|
||||
type MemorySettings,
|
||||
} from "../api";
|
||||
import { Icon } from "./Icon";
|
||||
import { PanelHead } from "./IntegrationsView";
|
||||
import { Toggle } from "./Toggle";
|
||||
|
||||
// MEMORY-SPEC §5.3: the one memory screen. A plain-language list of remembered facts
|
||||
// (edit/delete per row), the on/off toggle, delete-all, and the User Rules textarea —
|
||||
// no scope vocabulary, no markdown, no files. Everything else memory does happens in
|
||||
// chat (toast §5.1, attribution §5.2).
|
||||
const CARD = "rounded-xl2 border border-line bg-panel";
|
||||
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
|
||||
const FIELD_HELP = "text-[12px] text-muted mt-1.5 leading-relaxed";
|
||||
const BTN_ACCENT =
|
||||
"text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shrink-0 disabled:opacity-40";
|
||||
|
||||
export function MemorySection() {
|
||||
const [settings, setSettings] = useState<MemorySettings | null>(null);
|
||||
const [entries, setEntries] = useState<MemoryEntry[] | null>(null);
|
||||
// State-change copy (§5.3): shown under the toggle / list after an action.
|
||||
const [toggleMsg, setToggleMsg] = useState<string | null>(null);
|
||||
const [listMsg, setListMsg] = useState<string | null>(null);
|
||||
|
||||
const refresh = () => {
|
||||
getMemorySettings().then(setSettings).catch(() => setSettings(null));
|
||||
getMemory().then(setEntries).catch(() => setEntries([]));
|
||||
};
|
||||
useEffect(refresh, []);
|
||||
// Stay current while the screen is open: a save/edit landing in a conversation, or
|
||||
// the window regaining focus after one did. Without this the list is a snapshot from
|
||||
// whenever the page mounted — it showed "Nothing yet" seconds after a real save
|
||||
// (owner-hit 2026-07-28), which reads as "it didn't work".
|
||||
useEffect(() => {
|
||||
window.addEventListener(MEMORY_CHANGED, refresh);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
window.removeEventListener(MEMORY_CHANGED, refresh);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleEnabled = async () => {
|
||||
if (!settings) return;
|
||||
const next = await setMemorySettings({ enabled: !settings.enabled });
|
||||
setSettings(next);
|
||||
setToggleMsg(
|
||||
next.enabled
|
||||
? "Details you share from future conversations will be remembered, so I can be more helpful over time."
|
||||
: "I'll stop remembering new things about you. What I already know is kept and still used — delete anything below you'd rather I forget.",
|
||||
);
|
||||
};
|
||||
|
||||
const wipeAll = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Delete everything that's been remembered about you?\n\n" +
|
||||
"This can't be undone. Conversations you already have open still know what " +
|
||||
"they knew — new conversations start with a clean slate.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
await deleteAllMemory();
|
||||
setListMsg(
|
||||
"Everything I remembered has been deleted. New conversations start fresh; ones " +
|
||||
"you already have open still know what they knew when they started.",
|
||||
);
|
||||
refresh();
|
||||
};
|
||||
|
||||
if (!settings || entries === null)
|
||||
return <div className="text-[13px] text-muted">Loading…</div>;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PanelHead
|
||||
title="Memory"
|
||||
sub="Your coworkers can remember useful things about you between conversations. Everything they know is listed here."
|
||||
/>
|
||||
|
||||
{/* On/off — one switch, no other setup (§5.4). */}
|
||||
<div className={CARD + " p-4 mb-4"} data-testid="memory-toggle-card">
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle checked={settings.enabled} onChange={toggleEnabled} title="Remember new things about you" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={FIELD_LABEL}>Remember new things about me</div>
|
||||
<div className="text-[12px] text-muted mt-0.5">
|
||||
Lasting preferences you mention in chat get saved and used in future conversations —
|
||||
you'll see a small note each time, with one-tap Undo. Turning this off stops new
|
||||
saves; anything already below is still used until you delete it.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{toggleMsg && (
|
||||
<div className="text-[12.5px] text-muted mt-3 pt-3 border-t border-line" data-testid="memory-toggle-msg">
|
||||
{toggleMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* What I've learned (§5.3): directly under the toggle that governs it — the off
|
||||
message ("what I already know is kept, delete it below") points here. */}
|
||||
<div className={CARD + " p-4 mb-4"} data-testid="memory-list-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={FIELD_LABEL + " flex-1"}>What I've learned about you</div>
|
||||
{entries.length > 0 && (
|
||||
<button
|
||||
className="text-[12px] text-danger/80 hover:text-danger"
|
||||
data-testid="memory-delete-all"
|
||||
onClick={wipeAll}
|
||||
>
|
||||
Forget everything…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={FIELD_HELP}>
|
||||
Saved automatically from your conversations. Fix anything that's wrong — or delete it.
|
||||
Edits and deletions apply to new conversations; ones you already have open keep what
|
||||
they knew when they started.
|
||||
</div>
|
||||
{listMsg && (
|
||||
<div className="text-[12.5px] text-muted mt-2.5" data-testid="memory-list-msg">
|
||||
{listMsg}
|
||||
</div>
|
||||
)}
|
||||
{entries.length === 0 ? (
|
||||
!listMsg && (
|
||||
<div className="text-[12px] text-muted mt-3" data-testid="memory-empty">
|
||||
Nothing yet. When you mention a lasting preference in chat — or say "remember
|
||||
that…" — it will show up here.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-3 divide-y divide-line">
|
||||
{entries.map((m) => (
|
||||
<MemoryRow key={m.id} entry={m} onChanged={refresh} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Your instructions (§6): user-authored, toggle-independent — so it sits apart
|
||||
from the auto-memory pair above. The agent never edits these. */}
|
||||
<UserRulesCard settings={settings} onSaved={setSettings} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRulesCard({
|
||||
settings,
|
||||
onSaved,
|
||||
}: {
|
||||
settings: MemorySettings;
|
||||
onSaved: (s: MemorySettings) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(settings.user_rules);
|
||||
const [savedMsg, setSavedMsg] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
const next = await setMemorySettings({ user_rules: draft });
|
||||
onSaved(next);
|
||||
setSavedMsg(true);
|
||||
window.setTimeout(() => setSavedMsg(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={CARD + " p-4"} data-testid="user-rules-card">
|
||||
<div className={FIELD_LABEL}>Your instructions</div>
|
||||
<div className={FIELD_HELP}>
|
||||
Your coworkers follow these in every conversation.
|
||||
</div>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={
|
||||
"I use a screen reader — no tables, describe any image\n" +
|
||||
"Use DD-MM-YYYY for dates"
|
||||
}
|
||||
data-testid="user-rules-input"
|
||||
className="w-full mt-2.5 px-3 py-2.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent resize-y leading-relaxed"
|
||||
/>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
className={BTN_ACCENT}
|
||||
onClick={save}
|
||||
disabled={draft === settings.user_rules}
|
||||
data-testid="user-rules-save"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{savedMsg && (
|
||||
<span className="text-[12.5px] text-muted">
|
||||
Saved — applies to new conversations. Ones you already have open keep the
|
||||
instructions they started with.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryRow({ entry, onChanged }: { entry: MemoryEntry; onChanged: () => void }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(entry.content);
|
||||
|
||||
const save = async () => {
|
||||
const text = draft.trim();
|
||||
if (text && text !== entry.content) await updateMemory(entry.id, text);
|
||||
setEditing(false);
|
||||
onChanged();
|
||||
};
|
||||
const remove = async () => {
|
||||
await deleteMemory(entry.id);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
if (editing)
|
||||
return (
|
||||
<div className="py-2.5" data-testid={`memory-edit-${entry.id}`}>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={2}
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent resize-y leading-relaxed"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2.5 mt-1.5">
|
||||
<button className={BTN_ACCENT} onClick={() => void save()}>
|
||||
Save
|
||||
</button>
|
||||
<button className="text-[12.5px] text-muted hover:text-ink" onClick={() => setEditing(false)}>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="py-2.5 flex items-start gap-2.5 group" data-testid={`memory-row-${entry.id}`}>
|
||||
<div className="min-w-0 flex-1 text-[13px] leading-relaxed">{entry.content}</div>
|
||||
<button
|
||||
className="text-faint hover:text-ink shrink-0 mt-0.5"
|
||||
title="Fix this"
|
||||
data-testid={`memory-edit-btn-${entry.id}`}
|
||||
onClick={() => {
|
||||
setDraft(entry.content);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="pencil" size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="text-faint hover:text-danger shrink-0 mt-0.5"
|
||||
title="Delete this memory"
|
||||
data-testid={`memory-delete-${entry.id}`}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { useThemePref } from "../theme";
|
||||
import { Icon } from "./Icon";
|
||||
import { PanelHead } from "./IntegrationsView";
|
||||
import { ModelsTab } from "./ManageTabs";
|
||||
import { MemorySection } from "./MemorySection";
|
||||
import { GalleryModal } from "./GalleryModal";
|
||||
import { PersonasTab } from "./PersonasTab";
|
||||
import { showPersonas } from "../flags";
|
||||
@@ -47,7 +48,7 @@ import { showPersonas } from "../flags";
|
||||
// Models + Personas host the existing tab components inside the page shell (field re-skin to follow).
|
||||
// "appearance" is the General tab's stable key — callers deep-link with it, so the
|
||||
// rename (UX-021) changed only the label. "files" folded into General as a card.
|
||||
type SetTab = "appearance" | "models" | "voice" | "personas";
|
||||
type SetTab = "appearance" | "models" | "voice" | "memory" | "personas";
|
||||
|
||||
const CARD = "rounded-xl2 border border-line bg-panel";
|
||||
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
|
||||
@@ -58,10 +59,11 @@ const BTN_ACCENT = "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shri
|
||||
const BTN_BORDERED =
|
||||
"text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
|
||||
|
||||
const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "sparkle" }[] = [
|
||||
const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "archive" | "sparkle" }[] = [
|
||||
{ key: "appearance", label: "General", icon: "sliders" },
|
||||
{ key: "models", label: "Models", icon: "code" },
|
||||
{ key: "voice", label: "Voice input", icon: "mic" },
|
||||
{ key: "memory", label: "Memory", icon: "archive" },
|
||||
{ key: "personas", label: "Personas", icon: "sparkle" },
|
||||
];
|
||||
|
||||
@@ -122,6 +124,8 @@ export function SettingsView({
|
||||
</section>
|
||||
) : tab === "voice" ? (
|
||||
<VoiceInputSection />
|
||||
) : tab === "memory" ? (
|
||||
<MemorySection />
|
||||
) : (
|
||||
<PersonasSection onOpenPersona={onOpenPersona} />
|
||||
)}
|
||||
|
||||
@@ -180,6 +180,64 @@ describe("bubble hover affordances (FB-005)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// MEMORY-SPEC §5.1 — the save notice lives IN the conversation (a corner toast vanished
|
||||
// before it could be read or undone, owner-hit 2026-07-28) and stays until acted on.
|
||||
describe("memory save notice", () => {
|
||||
it("announces the save inline and offers Undo", () => {
|
||||
const onUndo = vi.fn();
|
||||
render(
|
||||
<Transcript
|
||||
items={[{ kind: "memory", id: 7, text: "prefers short replies" }]}
|
||||
onApprove={vi.fn()}
|
||||
onUndoMemory={onUndo}
|
||||
/>,
|
||||
);
|
||||
const notice = screen.getByTestId("memory-notice");
|
||||
expect(notice.textContent).toContain("I'll remember that");
|
||||
expect(notice.textContent).toContain("prefers short replies");
|
||||
|
||||
fireEvent.click(screen.getByTestId("memory-notice-undo"));
|
||||
// No `previous` on a brand-new save — undo deletes it outright.
|
||||
expect(onUndo).toHaveBeenCalledWith(7, undefined);
|
||||
});
|
||||
|
||||
it("says an existing memory was UPDATED and undoes by restoring its old text", () => {
|
||||
const onUndo = vi.fn();
|
||||
render(
|
||||
<Transcript
|
||||
items={[
|
||||
{
|
||||
kind: "memory",
|
||||
id: 4,
|
||||
text: "diabetic, lactose-free, likes ice cream",
|
||||
previous: "diabetic, lactose-free",
|
||||
},
|
||||
]}
|
||||
onApprove={vi.fn()}
|
||||
onUndoMemory={onUndo}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("memory-notice").textContent).toContain(
|
||||
"I've updated what I remember",
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("memory-notice-undo"));
|
||||
// Undo restores the previous wording rather than deleting the whole memory.
|
||||
expect(onUndo).toHaveBeenCalledWith(4, "diabetic, lactose-free");
|
||||
});
|
||||
|
||||
it("confirms in place once undone, with no Undo left to click", () => {
|
||||
render(
|
||||
<Transcript
|
||||
items={[{ kind: "memory", id: 7, text: "prefers short replies", undone: true }]}
|
||||
onApprove={vi.fn()}
|
||||
onUndoMemory={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("memory-notice-undone").textContent).toContain("forgotten");
|
||||
expect(screen.queryByTestId("memory-notice-undo")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("humanizeTool", () => {
|
||||
it("prefers run_shell's model-written description and keeps the command as the object", () => {
|
||||
const line = humanizeTool("run_shell", { command: "git log --since=yesterday", description: "List yesterday's merges" });
|
||||
|
||||
@@ -311,6 +311,9 @@ interface Props {
|
||||
// Re-run the failed turn (no new user message). Offered only on a retriable notice that
|
||||
// is the transcript tail of an idle session — anywhere else the error is history.
|
||||
onRetry?: () => void;
|
||||
// MEMORY-SPEC §5.1: undo a just-announced write. `previous` (set when the write was
|
||||
// an edit) is the text to restore; without it the memory is deleted.
|
||||
onUndoMemory?: (id: number, previous?: string) => void;
|
||||
}
|
||||
|
||||
// The transcript index whose notice gets the Retry button: the tail error notice, looking
|
||||
@@ -326,7 +329,7 @@ export function retryAnchor(items: Item[]): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function Transcript({ items, running, streamingText, onRetry }: Props) {
|
||||
export function Transcript({ items, running, streamingText, onRetry, onUndoMemory }: Props) {
|
||||
// §33 grouping: a turn = the maximal run of assistant/tool/resolved-approval items between
|
||||
// breakers (user, connector, notices, plan/dir requests…). Trailing assistant texts are the
|
||||
// ANSWER and render as bubbles after the group; interior assistant texts are narration and
|
||||
@@ -455,6 +458,40 @@ export function Transcript({ items, running, streamingText, onRetry }: Props) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// §5.1 save notice: quiet, inline, and it STAYS — the user reads it in place
|
||||
// and can undo whenever they get to it.
|
||||
case "memory":
|
||||
return (
|
||||
<div
|
||||
className="notice flex items-center gap-2 text-left"
|
||||
data-testid="memory-notice"
|
||||
key={bi}
|
||||
>
|
||||
{item.undone ? (
|
||||
<span data-testid="memory-notice-undone">
|
||||
{item.previous ? "Okay — put back the way it was." : "Okay — forgotten."}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">
|
||||
{item.previous ? "I've updated what I remember" : "I'll remember that"}
|
||||
</span>
|
||||
{item.text ? <span className="text-muted"> — {item.text}</span> : null}
|
||||
</span>
|
||||
{onUndoMemory && (
|
||||
<button
|
||||
className="btn ml-auto shrink-0"
|
||||
data-testid="memory-notice-undo"
|
||||
onClick={() => onUndoMemory(item.id, item.previous)}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export type EventType =
|
||||
| "input_rejected"
|
||||
| "interrupted"
|
||||
| "model_changed"
|
||||
| "memory_saved"
|
||||
| "turn_done";
|
||||
|
||||
export interface WsEvent {
|
||||
@@ -117,4 +118,10 @@ export type Item =
|
||||
multi?: boolean;
|
||||
resolved?: string;
|
||||
}
|
||||
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean };
|
||||
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean }
|
||||
// MEMORY-SPEC §5.1: the save notice, inline in the conversation where the user is
|
||||
// already looking (a corner toast vanished before it could be read or undone —
|
||||
// owner-hit 2026-07-28). Stays put. `previous` is set when an existing memory was
|
||||
// EDITED rather than a new one added (the update-don't-duplicate rule sends many
|
||||
// saves that way) — Undo restores that text instead of deleting the memory.
|
||||
| { kind: "memory"; id: number; text: string; previous?: string; undone?: boolean };
|
||||
|
||||
Reference in New Issue
Block a user