diff --git a/packages/app/e2e/settings/settings-memory.spec.ts b/packages/app/e2e/settings/settings-memory.spec.ts new file mode 100644 index 000000000..0a36ab0d5 --- /dev/null +++ b/packages/app/e2e/settings/settings-memory.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from "../fixtures" +import { closeSettingsPanel, openSettings, withSession } from "../actions" + +test("@smoke memory settings exposes the raw MEMORY.md controls", async ({ page, project }) => { + await project.open() + await project.sdk.memory.reset() + + const settings = await openSettings(page) + await settings.getByRole("tab", { name: "Memory" }).click() + + await expect(settings.getByRole("heading", { level: 2, name: "Memory" })).toBeVisible() + await expect(settings.getByText("Enable memory")).toBeVisible() + await expect(settings.getByText("Raw MEMORY.md", { exact: true })).toBeVisible() + + const raw = settings.locator('[data-action="settings-memory-raw"]') + await expect(raw).toBeVisible() + await expect(raw).toHaveValue(/# PawWork Memory/) + await expect(raw).toHaveValue(/## Profile/) + await expect(raw).toHaveValue(/## Archive/) + + const next = [ + "# PawWork Memory", + "", + "## Profile", + "- e2e memory settings profile", + "", + "## Archive", + "", + ].join("\n") + + await raw.fill(next) + await settings.getByRole("button", { name: "Save" }).click() + + await expect + .poll(async () => { + const state = await project.sdk.memory.get().then((response) => response.data as { content?: string }) + return state.content ?? "" + }) + .toContain("e2e memory settings profile") +}) + +test("idle session shows memory review and saves redacted entries", async ({ page, project }) => { + await project.open() + await project.sdk.memory.reset() + await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: false } }) + + await withSession(project.sdk, "memory review e2e", async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) + + const review = page.locator('[data-component="session-memory-review"]') + await expect(review).toBeVisible() + await expect(review.getByText("Remember this session?")).toBeVisible() + + await review.locator('[data-action="session-memory-review-text"]').fill("Remember sk-test-secret from this session") + await review.getByRole("button", { name: "Save memory" }).click() + + await expect(review).toBeHidden() + await expect + .poll(async () => { + const state = await project.sdk.memory.get().then((response) => response.data as { content?: string }) + return state.content ?? "" + }) + .toContain("[REDACTED]") + + const state = await project.sdk.memory.get().then((response) => response.data as { content?: string }) + expect(state.content ?? "").not.toContain("sk-test-secret") + }) +}) + +test("disabled memory hides the idle session review surface", async ({ page, project }) => { + await project.open() + await project.sdk.memory.reset() + await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: true } }) + try { + await project.sdk.memory.acceptProposal({ memoryProposalInput: { text: "Should not be saved.", scope: "project" } }) + throw new Error("expected disabled memory proposal to be blocked") + } catch (error) { + expect((error as { error?: string }).error).toBe("memory_disabled") + } + + try { + await withSession(project.sdk, "disabled memory review e2e", async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) + + await expect(page.locator('[data-component="session-memory-review"]')).toBeHidden() + }) + } finally { + await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: false } }) + } +}) + +test("turning memory off hides an already visible session review surface", async ({ page, project }) => { + await project.open() + await project.sdk.memory.reset() + await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: false } }) + + await withSession(project.sdk, "memory review toggle e2e", async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) + + const review = page.locator('[data-component="session-memory-review"]') + await expect(review).toBeVisible() + + const settings = await openSettings(page) + await settings.getByRole("tab", { name: "Memory" }).click() + await settings.locator('[data-slot="switch-control"]').click() + await closeSettingsPanel(page, settings) + + await expect(review).toBeHidden() + }) +}) diff --git a/packages/app/src/components/memory/memory-state-sync.ts b/packages/app/src/components/memory/memory-state-sync.ts new file mode 100644 index 000000000..82a275eca --- /dev/null +++ b/packages/app/src/components/memory/memory-state-sync.ts @@ -0,0 +1,9 @@ +import { createSignal } from "solid-js" + +const [memoryStateVersion, setMemoryStateVersion] = createSignal(0) + +export { memoryStateVersion } + +export function notifyMemoryStateChanged() { + setMemoryStateVersion((version) => version + 1) +} diff --git a/packages/app/src/components/memory/session-memory-review.tsx b/packages/app/src/components/memory/session-memory-review.tsx new file mode 100644 index 000000000..ba8fbf9ca --- /dev/null +++ b/packages/app/src/components/memory/session-memory-review.tsx @@ -0,0 +1,136 @@ +import { Button } from "@opencode-ai/ui/button" +import { showToast } from "@opencode-ai/ui/toast" +import { createEffect, createResource, createSignal, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { memoryStateVersion } from "./memory-state-sync" + +type MemoryState = { + disabled?: boolean + status?: "ok" | "safe_mode" +} + +type MemoryError = { + error?: string + reason?: string +} + +const errorMessage = (error: unknown, fallback: string) => (error instanceof Error ? error.message : fallback) + +const memoryError = (value: unknown): MemoryError | undefined => { + if (!value || typeof value !== "object") return undefined + if ("error" in value || "reason" in value) return value as MemoryError + if ("data" in value) return memoryError((value as { data?: unknown }).data) + return undefined +} + +export function SessionMemoryReview(props: { sessionID?: string; visible: boolean }) { + const language = useLanguage() + const sdk = useSDK() + const [draft, setDraft] = createSignal("") + const [dismissed, setDismissed] = createSignal(false) + const [saving, setSaving] = createSignal(false) + const [reviewState] = createResource( + () => (props.visible && props.sessionID ? { sessionID: props.sessionID, version: memoryStateVersion() } : undefined), + async () => { + const result = await sdk.client.memory.reviewState() + return (result.data ?? {}) as MemoryState + }, + ) + + const show = () => props.visible && !dismissed() && reviewState.latest?.disabled === false && reviewState.latest?.status === "ok" + + createEffect(() => { + props.sessionID + setDraft("") + setDismissed(false) + }) + + const accept = async () => { + const text = draft().trim() + if (!text || saving()) return + setSaving(true) + try { + const result = await sdk.client.memory.acceptProposal({ memoryProposalInput: { text, scope: "project" } }) + const blocked = memoryError(result.error) + if (blocked?.error === "memory_disabled" || blocked?.error === "memory_safe_mode") { + setDraft("") + setDismissed(true) + showToast({ + variant: "subtle", + title: language.t("memory.review.blocked"), + description: language.t(blocked.error === "memory_disabled" ? "memory.review.blockedDisabled" : "memory.review.blockedSafeMode"), + }) + return + } + const state = (result.data ?? {}) as MemoryState + if (state.disabled || state.status === "safe_mode") { + setDraft("") + setDismissed(true) + showToast({ + variant: "subtle", + title: language.t("memory.review.blocked"), + description: language.t(state.disabled ? "memory.review.blockedDisabled" : "memory.review.blockedSafeMode"), + }) + return + } + setDraft("") + setDismissed(true) + showToast({ + variant: "success", + title: language.t("memory.review.saved"), + description: language.t("memory.review.savedDescription"), + }) + } catch (error) { + const blocked = memoryError(error) + if (blocked?.error === "memory_disabled" || blocked?.error === "memory_safe_mode") { + setDraft("") + setDismissed(true) + showToast({ + variant: "subtle", + title: language.t("memory.review.blocked"), + description: language.t(blocked.error === "memory_disabled" ? "memory.review.blockedDisabled" : "memory.review.blockedSafeMode"), + }) + return + } + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: errorMessage(error, language.t("common.requestFailed")), + }) + } finally { + setSaving(false) + } + } + + return ( + +
+
{language.t("memory.review.title")}
+