diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index d582084507..8691cc83af 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -354,6 +354,13 @@ export const ACTIONS: UIAction[] = [ { id: "memory.save", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "memory.restore", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "memory.forget", kind: "destructive", target: "entity", proof: "interaction" }, + { id: "memory.proactive", kind: "kernel-mutation", target: "entity", proof: "interaction" }, + { id: "memory.suggest-text", kind: "view", target: "entity", proof: "interaction" }, + { id: "memory.suggest-accept", kind: "kernel-mutation", target: "entity", proof: "interaction" }, + { id: "memory.suggest-save", kind: "kernel-mutation", target: "entity", proof: "interaction" }, + { id: "memory.suggest-edit", kind: "view", target: "entity", proof: "interaction" }, + { id: "memory.suggest-cancel", kind: "view", target: "entity", proof: "interaction" }, + { id: "memory.suggest-dismiss", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "memory.confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "memory.filter", kind: "view", target: "optional", proof: "interaction" }, { id: "memory.search", kind: "view", target: "none", proof: "interaction" }, diff --git a/desktop/frontend-next/src/design/design.css b/desktop/frontend-next/src/design/design.css index 143406e5a7..157ed4ab21 100644 --- a/desktop/frontend-next/src/design/design.css +++ b/desktop/frontend-next/src/design/design.css @@ -18,5 +18,6 @@ @import "./feedback.css" layer(design); @import "./seltools.css" layer(design); @import "./onboarding.css" layer(design); +@import "./suggest.css" layer(design); @import "./controls.css" layer(design); @import "./motion.css" layer(design); diff --git a/desktop/frontend-next/src/design/plan.css b/desktop/frontend-next/src/design/plan.css index b4bddb5ee6..99e5d676af 100644 --- a/desktop/frontend-next/src/design/plan.css +++ b/desktop/frontend-next/src/design/plan.css @@ -63,7 +63,8 @@ border: 1px solid var(--border); border-bottom: 0; border-radius: var(--r-md) var(--r-md) 0 0; background: var(--page); box-shadow: none; } -.compose > .studio-todo[data-open] { background: var(--raised); box-shadow: var(--shadow-float); } +.compose > .studio-todo[data-open] { inset: auto 0 100%; border-color: transparent; border-radius: var(--r-lg) var(--r-lg) 0 0; background: var(--compose-surface, var(--raised)); box-shadow: 0 -3px 12px rgb(25 30 25 / .07); } +.compose > .studio-todo[data-open] > .studio-todo-body { border-bottom: 1px solid var(--hair); } .compose > .studio-todo[data-open] > .studio-todo-sum { display: none; } .compose > .studio-todo .studio-todo-sum { height: 34px; min-height: 34px; gap: 8px; padding: 0 8px 0 12px; border-radius: inherit; diff --git a/desktop/frontend-next/src/design/suggest.css b/desktop/frontend-next/src/design/suggest.css new file mode 100644 index 0000000000..41e1b800dc --- /dev/null +++ b/desktop/frontend-next/src/design/suggest.css @@ -0,0 +1,22 @@ +.msug { display: flex; flex-direction: column; gap: 6px; padding: 2px 4px; } +.msug-item { display: flex; flex-direction: column; gap: 4px; } +.msug-line { display: flex; align-items: center; gap: 8px; min-width: 0; font: var(--w-med) var(--fs-ui) var(--ui); color: var(--muted); } +.msug-line > .studio-icon { flex: none; width: 14px; height: 14px; } +.msug-line > b { min-width: 0; overflow: hidden; color: var(--text); font-weight: var(--w-semi); text-overflow: ellipsis; white-space: nowrap; } +.msug-meta { flex: none; color: var(--faint); font-size: var(--fs-meta); } +.msug-acts { display: flex; align-items: center; gap: 6px; margin-inline-start: 22px; } +.msug-acts > button { height: 28px; padding: 0 14px; border: 1px solid var(--border); border-radius: 7px; background: none; color: var(--text); font: var(--w-med) var(--fs-ui) var(--ui); cursor: pointer; } +.msug-acts > button[data-primary] { border-color: transparent; background: var(--accent); color: var(--on-accent, #fff); } +.msug-acts > button[data-action="memory.suggest-dismiss"] { border-color: transparent; color: var(--muted); } +.msug-acts > button:disabled { opacity: .5; cursor: default; } +.msug-edit { display: flex; flex-direction: column; gap: 6px; margin-inline-start: 22px; } +.msug-edit > textarea { width: 100%; resize: vertical; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); } +.msug-edit .msug-acts { margin-inline-start: 0; } +.memproactive { display: flex; align-items: center; gap: 16px; padding: 10px 0 14px; } +.memproactive > div:first-child { display: flex; flex: 1; flex-direction: column; min-width: 0; } +.memproactive b { color: var(--text); font: var(--w-semi) var(--fs-ui) var(--ui); } +.memproactive span { color: var(--faint); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); } +.memproactive-err { color: var(--danger, #c0392b) !important; } +.memproactive-seg { display: inline-flex; flex: none; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; } +.memproactive-seg > button { padding: 6px 12px; border: 0; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); cursor: pointer; } +.memproactive-seg > button[data-on] { background: var(--overlay); color: var(--text); } diff --git a/desktop/frontend-next/src/i18n/msg/chat.ts b/desktop/frontend-next/src/i18n/msg/chat.ts index c4b1acf0b4..3016ee530a 100644 --- a/desktop/frontend-next/src/i18n/msg/chat.ts +++ b/desktop/frontend-next/src/i18n/msg/chat.ts @@ -446,6 +446,11 @@ export const CHAT: Messages = { "chat.submit_plan": { zh: "提交计划", en: "Submit plan" }, "chat.submitted": { zh: "已提交", en: "Submitted" }, "chat.subtask": { zh: "子任务", en: "Subtask" }, + "chat.suggest_dismiss": { zh: "不用", en: "No thanks" }, + "chat.suggest_edit": { zh: "改一下…", en: "Edit…" }, + "chat.suggest_lead": { zh: "要记住吗", en: "Remember this?" }, + "chat.suggest_remember": { zh: "记住", en: "Remember" }, + "chat.suggest_text": { zh: "要记的内容", en: "What to remember" }, "chat.switch_to_full_access": { zh: "切换全部放行…", en: "Switch to full access…" }, "chat.switching": { zh: "正在切换…", en: "Switching…" }, "chat.take_it_back": { zh: "撤回", en: "Take it back" }, diff --git a/desktop/frontend-next/src/i18n/msg/memory.ts b/desktop/frontend-next/src/i18n/msg/memory.ts index 09d82d59c3..93921e5111 100644 --- a/desktop/frontend-next/src/i18n/msg/memory.ts +++ b/desktop/frontend-next/src/i18n/msg/memory.ts @@ -44,6 +44,11 @@ export const MEMORY: Messages = { "memory.nothing_is_waiting_for_you": { zh: "没有等你确认的记忆。", en: "Nothing is waiting for you to confirm." }, "memory.one_line_summary": { zh: "简要说明", en: "One-line summary" }, "memory.only_the_current_revision_exists": { zh: "仅有当前版本。", en: "Only the current revision exists." }, + "memory.proactive_ask": { zh: "先问我", en: "Ask me" }, + "memory.proactive_auto": { zh: "直接记", en: "Just remember" }, + "memory.proactive_hint": { zh: "看到值得长期记的事时怎么办", en: "What to do when something is worth keeping" }, + "memory.proactive_off": { zh: "不记", en: "Never" }, + "memory.proactive_title": { zh: "模型主动记事", en: "Model remembers on its own" }, "memory.reading_older_revisions": { zh: "正在读取历史版本…", en: "Reading older revisions…" }, "memory.recalled_n_days_ago": { zh: "{n} 天前被想起", en: "Recalled {n} days ago" }, "memory.recalled_today": { zh: "今天被想起", en: "Recalled today" }, diff --git a/desktop/frontend-next/src/port/memory.ts b/desktop/frontend-next/src/port/memory.ts index 4306b548c2..fd04020df9 100644 --- a/desktop/frontend-next/src/port/memory.ts +++ b/desktop/frontend-next/src/port/memory.ts @@ -38,6 +38,30 @@ export interface UnreadableMemory { error: string; } +// What the model may do on its own with memory: nothing, ask first, or write. +export type MemoryProactive = "off" | "ask" | "auto"; + +// A fact the model proposed and the user has not decided on. +export interface MemorySuggestion { + id: string; + name: string; + title?: string; + description: string; + body: string; + type?: string; + scope?: string; +} + +// What the model may propose to keep, and the user's say over it. +export interface MemoryHoldPort { + memoryProactive(): Promise; + setMemoryProactive(mode: MemoryProactive): Promise; + memorySuggestions(): Promise; + /** Saves a held suggestion; fields given replace the model's wording. */ + acceptMemorySuggestion(id: string, edit?: { title?: string; description?: string; body?: string }): Promise; + dismissMemorySuggestion(id: string): Promise; +} + export interface MemoryCatalog { memories: MemoryEntry[]; recallQuery: string; diff --git a/desktop/frontend-next/src/port/mock_memory_hold.ts b/desktop/frontend-next/src/port/mock_memory_hold.ts new file mode 100644 index 0000000000..9b46f32c7e --- /dev/null +++ b/desktop/frontend-next/src/port/mock_memory_hold.ts @@ -0,0 +1,22 @@ +import type { MemoryHoldPort, MemoryProactive, MemorySuggestion } from "./memory"; +import { MockTerminal } from "./mock_terminal"; + +export class MockMemoryHold extends MockTerminal implements MemoryHoldPort { + proactive: MemoryProactive = "ask"; + suggestions: MemorySuggestion[] = []; + async memoryProactive() { + return this.proactive; + } + async setMemoryProactive(mode: MemoryProactive) { + this.proactive = mode; + } + async memorySuggestions() { + return this.suggestions; + } + async acceptMemorySuggestion(id: string) { + this.suggestions = this.suggestions.filter((s) => s.id !== id); + } + async dismissMemorySuggestion(id: string) { + this.suggestions = this.suggestions.filter((s) => s.id !== id); + } +} diff --git a/desktop/frontend-next/src/port/mock_workspace.ts b/desktop/frontend-next/src/port/mock_workspace.ts index b952434691..f9ecfb24e5 100644 --- a/desktop/frontend-next/src/port/mock_workspace.ts +++ b/desktop/frontend-next/src/port/mock_workspace.ts @@ -2,7 +2,7 @@ import { reason } from "../i18n/kernel"; import { HttpError } from "./http_error"; import type { KeptText } from "./attachment"; import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace"; -import { MockTerminal } from "./mock_terminal"; +import { MockMemoryHold } from "./mock_memory_hold"; export function mockWorkspaces(root: string): import("./workspace").WorkspaceInfo { return { current: root, canSwitch: true, canIsolate: true, recents: [ @@ -19,7 +19,7 @@ export function mockWorktreeSources(root: string, branch: string): import("./wor ] }; } -export class MockWorkspace extends MockTerminal { +export class MockWorkspace extends MockMemoryHold { async workspaceFile(path: string, _root?: string): Promise { return { path, content: `// ${path}\n`, revision: "fixture" }; } async workspacePreviewInfo(ref: import("./workspace").FileRef): Promise { const file = await this.workspaceFile(ref.path, ref.root); diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index 38eaa2918b..e8aaec3cf5 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -18,7 +18,7 @@ export type * from "./backup"; export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adjudication"; import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook"; import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp"; -import type { MemoryCatalog, MemoryEdit, MemoryEntry, UnreadableMemory } from "./memory"; +import type { MemoryHoldPort, MemoryCatalog, MemoryEdit, MemoryEntry, UnreadableMemory } from "./memory"; import type { UploadUsagePort, UsageQuery, UsageReport } from "./usage"; export { DEFAULT_USAGE_DAYS } from "./usage"; export type { MemoryEdit } from "./memory"; @@ -181,7 +181,7 @@ export interface TrayPrefs { closeToTray: boolean; } -export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort { +export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort { providerSetup(): Promise; saveProviderKey(apiKey: string): Promise; // The models that answer the job asked about; "chat" when none is named. diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts index f0409e0e79..1541d2a6fe 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -1,6 +1,7 @@ import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface"; import { PLAN_ACTIONS, type PlanAction } from "./session"; import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, UploadReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port"; +import type { MemoryProactive, MemorySuggestion } from "./memory"; import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port"; import { SseTerminal } from "./sse_terminal"; import type { StoragePlan, StorageQuery, StorageState } from "./storage"; @@ -105,6 +106,23 @@ export class SsePort extends SseTerminal implements AgentPort { confirmMemory(name: string) { return this.post("/memory/confirm", { name }); } + async memoryProactive() { + const r = await this.get<{ mode: MemoryProactive }>("/memory/proactive"); + return r.mode; + } + async setMemoryProactive(mode: MemoryProactive) { + await this.post("/memory/proactive", { mode }); + } + async memorySuggestions() { + const r = await this.get<{ suggestions: MemorySuggestion[] }>("/memory/suggestions"); + return r.suggestions ?? []; + } + acceptMemorySuggestion(id: string, edit?: { title?: string; description?: string; body?: string }) { + return this.post("/memory/suggestions/accept", { id, ...edit }); + } + dismissMemorySuggestion(id: string) { + return this.post("/memory/suggestions/dismiss", { id }); + } async memoryRevisions(name: string) { const r = await this.get<{ revisions: MemoryEntry[] }>("/memory/revisions?name=" + encodeURIComponent(name)); return r.revisions ?? []; diff --git a/desktop/frontend-next/src/ui/Memory.tsx b/desktop/frontend-next/src/ui/Memory.tsx index 1e8be3b01c..af8f3ed5a5 100644 --- a/desktop/frontend-next/src/ui/Memory.tsx +++ b/desktop/frontend-next/src/ui/Memory.tsx @@ -1,3 +1,4 @@ +import { MemoryProactiveRow } from "./MemoryProactiveRow"; import { useEffect, useMemo, useRef, useState } from "react"; import { t } from "../i18n"; import { tx } from "../i18n/rich"; @@ -184,6 +185,7 @@ function MemoryPanel({ port }: { port: AgentPort }) { return (
+ {unreadableNotice} {query && (

diff --git a/desktop/frontend-next/src/ui/MemoryProactiveRow.tsx b/desktop/frontend-next/src/ui/MemoryProactiveRow.tsx new file mode 100644 index 0000000000..0a0deda592 --- /dev/null +++ b/desktop/frontend-next/src/ui/MemoryProactiveRow.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; +import { t } from "../i18n"; +import { reason } from "../i18n/kernel"; +import type { AgentPort } from "../port/port"; +import type { MemoryProactive } from "../port/memory"; + +const MODES: [MemoryProactive, string][] = [["off", "memory.proactive_off"], ["ask", "memory.proactive_ask"], ["auto", "memory.proactive_auto"]]; + +// One line at the top of the memory page: what the model does when it sees +// something worth keeping for good. +export function MemoryProactiveRow({ port }: { port: AgentPort }) { + const [mode, setMode] = useState(null); + const [error, setError] = useState(""); + useEffect(() => { + let live = true; + port.memoryProactive().then((m) => { if (live) setMode(m); }).catch((e) => { if (live) setError(reason(e)); }); + return () => { live = false; }; + }, [port]); + const pick = (next: MemoryProactive) => { + const was = mode; + setMode(next); + setError(""); + port.setMemoryProactive(next).catch((e) => { setMode(was); setError(reason(e)); }); + }; + if (mode === null && !error) return null; + return ( +

+
+ {t("memory.proactive_title")} + {t("memory.proactive_hint")} + {error && {error}} +
+
+ {MODES.map(([id, label]) => ( + + ))} +
+
+ ); +} diff --git a/desktop/frontend-next/src/ui/MemorySuggestRow.tsx b/desktop/frontend-next/src/ui/MemorySuggestRow.tsx new file mode 100644 index 0000000000..4251240f45 --- /dev/null +++ b/desktop/frontend-next/src/ui/MemorySuggestRow.tsx @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useState } from "react"; +import { t } from "../i18n"; +import { reason } from "../i18n/kernel"; +import type { AgentPort } from "../port/port"; +import type { MemorySuggestion } from "../port/memory"; +import { StudioIcon } from "./StudioIcon"; + +const SCOPE: Record = { project: "chat.remember_scope_project", global: "chat.remember_scope_global" }; + +// The facts the model proposed to keep, one line each above the composer. +// The kernel holds them until the user decides; the list is read again each +// time a turn ends, which is the only moment a new one can have appeared. +export function MemorySuggestRow({ port, running, onError }: { port: AgentPort; running: boolean; onError: (e: unknown) => void }) { + const [items, setItems] = useState([]); + const [editing, setEditing] = useState(""); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(""); + + useEffect(() => { + if (running) return; + let live = true; + port.memorySuggestions().then((got) => { if (live) setItems(got); }).catch(() => {}); + return () => { live = false; }; + }, [port, running]); + + const settle = useCallback(async (id: string, act: () => Promise) => { + setBusy(id); + try { + await act(); + setItems((was) => was.filter((s) => s.id !== id)); + setEditing(""); + } catch (e) { + onError(new Error(reason(e))); + } finally { + setBusy(""); + } + }, [onError]); + + if (items.length === 0) return null; + return ( +
+ {items.map((s) => ( +
+
+ + {t("chat.suggest_lead")} + {s.title || s.description} + {`· ${t(SCOPE[s.scope ?? "project"] ?? "chat.remember_scope_project")}`} +
+ {editing === s.id ? ( +
+