Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7b15784
feat(webui): unlock the personalization tab with a global AGENTS.md e…
Z-starts Oct 5, 2026
df89f01
test(webui): type the shared validation helper over its body
Z-starts Oct 5, 2026
6c814ab
feat(webui): add a long-term memory panel to the personalization tab
Z-starts Oct 5, 2026
edc3bf6
refactor(webui): own AGENTS.md and agent memory storage in one module
Z-starts Oct 5, 2026
bceb251
fix(webui): forward the personalization operations through the servic…
Z-starts Oct 5, 2026
be08f6c
feat(webui): complete the personalization tab with the profile and me…
Z-starts Oct 5, 2026
bfcddd5
fix(webui): give the personalization editors a fixed 300px height
Z-starts Oct 5, 2026
1e4f574
fix(webui): make the memory manager visible and stop switches lying a…
Z-starts Oct 5, 2026
e0f4eda
test(config): pin that a memory switch write cannot drop memory.daily…
Z-starts Oct 6, 2026
5497263
fix(webui): open the memory manager as a dialog that loads its own body
Z-starts Oct 6, 2026
394bfc0
feat(webui): give the memory manager its ⋯ menu — hand off to a chat,…
Z-starts Oct 6, 2026
12c3f8c
feat(webui): give the section hints a real bubble instead of a title
Z-starts Oct 6, 2026
90dbe46
docs(webui): record why the section-hint bubble stays dark in both th…
Z-starts Oct 6, 2026
4e40f49
fix(webui): align the memory summary row and its header controls with…
Z-starts Oct 6, 2026
60f0a71
fix(webui): keep the marked user-profile region intact across a save
Z-starts Oct 6, 2026
abe0bc7
fix(webui): read 关于你 as the three fields the desktop actually stores
Z-starts Oct 6, 2026
76adbe0
fix(webui): give the section save its three states and drop the field…
Z-starts Oct 6, 2026
aeede5e
fix(webui): undo three misreadings of the memory dialog
Z-starts Oct 6, 2026
a8e2861
fix(webui): drop the two empty profile inputs, refuse a disabled save
Z-starts Oct 6, 2026
bcbd192
fix(webui): draw the section save buttons as the desktop's black primary
Z-starts Oct 6, 2026
2b9d7de
fix(webui): transcribe the memory summary dialog off the desktop bundle
Z-starts Oct 6, 2026
d0f1e76
fix(webui): match the memory summary dialog to the desktop capture
Z-starts Oct 6, 2026
b6b0f67
fix(webui): give the memory count its own class, size the editor by c…
Z-starts Oct 6, 2026
d8fe5f1
fix(webui): height:auto so field-sizing can size the memory editor
Z-starts Oct 6, 2026
bd85481
fix(webui): align the dialog controls, paint the count row, clamp the…
Z-starts Oct 6, 2026
8a1efe6
fix(webui): draw the memory editor's focus ring outside the box
Z-starts Oct 6, 2026
8b491c1
fix(webui): restore the count row's bottom corners
Z-starts Oct 6, 2026
8b18b41
fix(webui): type the profile write the way the port declares it
Z-starts Oct 6, 2026
34a136b
test(webui): bring the personalization stubs back onto the split shapes
Z-starts Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/config/test/config-update-preservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,51 @@ describe("valid local config updates", () => {
permissionMode: "default",
});
});

/**
* The memory switches reach the config through `put`, not through the
* `preparedCommitPayload` argument. That argument is a shallow
* `Object.assign`, so `{ memory: { enabled, proactive } }` through it would
* replace the whole `memory` subtree and silently delete `dailyDigest` from
* the user's config — a setting they enabled, with no error and no trace.
*
* This is the only place that failure would be visible: the WebUI reports
* the two booleans it wrote, and it has no reason to look at a sibling key.
*/
it("keeps sibling keys of a subtree when writing through put", async () => {
const source = {
memory: {
enabled: true,
dailyDigest: { enabled: true, customRetention: 7 },
},
};
fs.writeFileSync(configPath, yaml.dump(source));

await updateLocalConfigFile({
field: "memory",
put: { proactive: true },
});

expect(yaml.load(fs.readFileSync(configPath, "utf8"))).toEqual({
memory: {
enabled: true,
dailyDigest: { enabled: true, customRetention: 7 },
proactive: true,
},
});
});

it("refuses a field the whitelist does not carry, without touching the file", async () => {
// The WebUI's operation frame is two booleans, but the writer is shared by
// every caller — the whitelist is what stops a general payload from
// reaching the API-key roots through this same code path.
const source = `minimax_api:\n apiKey: ${secret}\n`;
fs.writeFileSync(configPath, source);

await expect(
updateLocalConfigFile({ field: "minimax_api", put: { apiKey: "other" } }),
).rejects.toThrow(/cannot update config field/u);

expect(fs.readFileSync(configPath, "utf8")).toBe(source);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,25 @@ export interface LocalRuntimeApplication {
setPermissionMode(input: {
mode: "default" | "acceptEdits" | "bypassPermissions" | "auto" | "off";
}): Promise<unknown>;
/**
* The two long-term-memory switches, read and written as their own
* narrow surface rather than as a general config patch.
*
* Optional because a host that predates this surface still has to
* type-check, and every consumer must fail closed with one clear
* message instead of crashing on an absent method — the same rule the
* permission-mode pair already follows. Callers must not treat a missing
* method as "off": absence means "this host cannot answer", which is a
* different statement from either boolean value.
*/
getMemorySettings?(): Promise<{
enabled: boolean;
proactive: boolean;
}>;
setMemorySettings?(input: {
enabled?: boolean;
proactive?: boolean;
}): Promise<{ enabled: boolean; proactive: boolean }>;
};
readonly models?: {
list(input?: { sessionId?: string }): Promise<readonly unknown[]>;
Expand Down
33 changes: 33 additions & 0 deletions packages/local-runtime-v2/src/compat/v1/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,39 @@ function createPeripheralApplication(
readLocalPermissionMode(host.apiHost.configGetter().permissionMode),
setPermissionMode: ({ mode }) =>
host.apiHost.setProcessLocalPermissionMode(mode),
/**
* Reads the two long-term-memory switches with the same defaults the
* config declares (`packages/config/src/config.ts`: `enabled` true,
* `proactive` false). A missing `memory` tree is not an error — it is the
* documented default state, and reporting it as "off" would be a lie the
* settings UI would then write back.
*/
getMemorySettings: async () => {
const memory = host.apiHost.configGetter().memory;
return {
enabled: memory?.enabled !== false,
proactive: memory?.proactive === true,
};
},
/**
* `put` rather than the `preparedCommitPayload` argument: that path does
* a shallow `Object.assign`, so passing `{ memory: { enabled, proactive } }`
* would silently drop `memory.dailyDigest` from the user's config. `put`
* deep-merges into the existing subtree and touches one key per call.
*/
setMemorySettings: async ({ enabled, proactive }) => {
const put: Record<string, boolean> = {};
if (enabled !== undefined) put.enabled = enabled;
if (proactive !== undefined) put.proactive = proactive;
if (Object.keys(put).length > 0) {
await updateLocalConfigFile({ field: "memory", put });
}
const memory = host.apiHost.configGetter().memory;
return {
enabled: memory?.enabled !== false,
proactive: memory?.proactive === true,
};
},
},
backgroundTasks: {
list: async ({
Expand Down
27 changes: 27 additions & 0 deletions packages/local-runtime-v2/src/local/cli-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,33 @@ export class CliService {
).setPermissionMode(input);
}

/**
* The memory switches are optional on the capability, so a host that
* predates the surface must produce one clear failure here rather than a
* `TypeError` from calling `undefined`. Absence means "this host cannot
* answer" — it is deliberately not reported as a boolean value, because
* `false` would read as "the user turned memory off".
*/
getMemorySettings(): Promise<{ enabled: boolean; proactive: boolean }> {
const configuration = this.requireCapability("configuration", "Configuration");
if (!configuration.getMemorySettings)
throw new Error("Runtime host does not expose memory settings");
return configuration.getMemorySettings();
}

setMemorySettings(
input: Parameters<
NonNullable<
NonNullable<LocalRuntimeApplication["configuration"]>["setMemorySettings"]
>
>[0],
): Promise<{ enabled: boolean; proactive: boolean }> {
const configuration = this.requireCapability("configuration", "Configuration");
if (!configuration.setMemorySettings)
throw new Error("Runtime host does not expose memory settings");
return configuration.setMemorySettings(input);
}

listModels(
input?: Parameters<
NonNullable<LocalRuntimeApplication["models"]>["list"]
Expand Down
44 changes: 44 additions & 0 deletions packages/webui/src/client/components/SessionComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ import {
removeWebuiSlashToken,
replaceWebuiSlashToken,
webuiAttachmentLimitError,
webuiTextAttachmentDataUrl,
type WebuiMentionRange,
} from "../projection/composer-interactions.js";
import {
Expand Down Expand Up @@ -616,6 +617,7 @@ export function WebuiComposer({
workspaceDir,
onSelectSession,
onOpenPluginManagement,
seedAttachment,
}: {
readonly sessionId?: string;
readonly sessionStatus?: unknown;
Expand Down Expand Up @@ -661,6 +663,23 @@ export function WebuiComposer({
/** The draft lives on the shell so it survives silent first-session creation. */
readonly draft: string;
readonly onDraftChange: (next: string) => void;
/**
* A file dropped into the composer from outside it — 记忆摘要's 「在会话中创建」
* hands the memory file over this way, because the composer is the only
* surface that can render an attachment chip and the only one that can put it
* on the wire.
*
* One-shot by `token`: the shell keeps the seed around across the view switch
* that opens the composer, so an effect keyed on anything else would re-attach
* the same 74KB file on every re-render of this 3000-line component.
*/
readonly seedAttachment?: {
readonly token: string;
readonly fileName: string;
readonly mimeType: string;
readonly sizeBytes: number;
readonly content: string;
};
/**
* Submitted-input history for ↑ recall (roadmap Module B: 输入历史/草稿).
* Keyed by session on the shell; the composer only walks it with the pure
Expand Down Expand Up @@ -1520,6 +1539,31 @@ export function WebuiComposer({
setAttachmentBusy(false);
}
};
const seededTokenRef = useRef<string>();
useEffect(() => {
const seed = seedAttachment;
if (!seed || seededTokenRef.current === seed.token) return;
// Mark it consumed before anything that can bail. A seed the composer
// refuses has to stay refused; retrying on every attachment change would
// pin the error in place.
seededTokenRef.current = seed.token;
const limitError = webuiAttachmentLimitError(attachments, [{ sizeBytes: seed.sizeBytes }]);
if (limitError) {
setInteractionError(limitError);
return;
}
setAttachments((current) => [
...current,
{
id: `seed-${seed.token}`,
fileName: seed.fileName,
mimeType: seed.mimeType,
sizeBytes: seed.sizeBytes,
dataUrl: webuiTextAttachmentDataUrl(seed.mimeType, seed.content),
kind: "file",
},
]);
}, [attachments, seedAttachment]);
const attachmentWire = attachments.map((attachment) => ({
meta: {
attachmentType: attachment.kind,
Expand Down
Loading
Loading