Skip to content

feat(webui): K area — memory, AGENTS.md and the personalization tab - #32

Merged
July-X merged 29 commits into
webuifrom
dev-izzy
Oct 6, 2026
Merged

July-X merged 29 commits into
webuifrom
dev-izzy

Conversation

@July-X

@July-X July-X commented Oct 6, 2026

Copy link
Copy Markdown

Change

K 区(记忆与个性化)此前在 WebUI 里不可见:个性化 设置 tab 是禁用的,AGENTS.md、MEMORY.md 和 user.md 只有服务端能读写,WebUI 没有任何入口。这个 PR 把它变成可用,并按桌面版对齐外观。

新增能力

  • 解锁 个性化 tab,一次补齐三块此前不存在的内容:
    • 长期记忆 —— 读取/编辑 MEMORY.md;⋯ 菜单提供「交给会话继续」和「删除记忆」,删除走带确认的对话框;两个记忆开关接真实读写,并发写入时不会把 memory.dailyDigest 之类的相邻字段丢掉。
    • 关于你 —— 读写 user.md 的标记区域,按桌面版的 Nickname: / Occupation: / ## More about you 三字段解析;区域外的运行时追加内容不会被覆盖。
    • 自定义指令 —— 读写 profile 级 AGENTS.md。文件所有权、32KiB 上限和临时文件加改名的原子写都在服务端,UI 只回显 maxBytes,避免面板硬编码上限后接受运行时必然拒绝的文本。
  • 记忆摘要弹窗从「设置页里的一段内联区域」改成独立对话框,打开时自己拉取正文。

与桌面版对齐

  • 弹窗标题栏、⋯ 菜单、底部按钮排布按 asar 转写:按钮 26px、间距 8px、面板 624px、max-height: 90vh。
  • 编辑器三处分化:弹窗用基础样式带边框;关于你 和 自定义指令 走 mavis-personalization-editor 的无边框 + bg_grouped_tertiary + 16px 圆角。
  • 字符计数放在编辑器框内右下角(14px),随内容高度自适应(field-sizing: content),焦点蓝圈用 outline 画在盒子外侧,不占布局也不吃掉 1px 内容。
  • 设置页两个保存按钮改为桌面版的黑色主按钮三态(hover 80% 黑、禁用 opacity: .5)。
  • 删除 关于你 里两个恒为空的输入框 —— 桌面版只在文件层保留 Nickname: / Occupation: 这两个字段,UI 不为它们画控件,之前按文件格式直接生成表单导致界面上多出两个永久空框。

Validation

  • pnpm typecheck:webui — server / client / test 三份 tsconfig 全过,0 error。
  • pnpm build:webui — 208 inputs(server 65 / client 144)。
  • pnpm check:webui-boundary — 208 inputs, 6 rules。
  • pnpm test:webui — 95 files / 1856 tests 全过。
  • pnpm check:source — 4753 files,workspace exports 与 native helper 完整性通过。
  • Performance: basic,未加 perf:full(本 PR 不触碰 performance rules 覆盖的运行时路径)。

NOT RUN / 边界

  • 未做真浏览器实测。 弹窗的圆角、焦点蓝圈、计数位置这几处是照桌面版 asar 和截图逐条对齐的,但本机沙箱不允许 kill 进程,服务端无法重启,因此没有在真实页面上看过最终像素。这一项建议评审时人工确认。
  • 未跑 Windows 契约与 Node 版本兼容矩阵(CI 会跑)。
  • 全部为离线测试,不构成 live-service 或跨平台验收。
  • 与上游 webui(738ae1c)rebase 时产生的冲突均为并集型(两侧各自新增、上游解禁了 代码审查 与 工作树 两个 tab)。上游解禁的那两个予以保留,本 PR 未回退。

Publication and contribution checks

  • I have permission to contribute these changes under the existing licenses applicable to the changed files/packages; imported material and its provenance are identified and existing notices are preserved.
  • No credentials, account data, real user content, internal source history or private review material is included.
  • Added/removed source files were reviewed before regenerating release/public-source.json; new tests are declared in test/vitest-suites.json where applicable.
  • Shared English/Chinese documentation and capability/verification records are updated where applicable. Mock/offline results are not described as live-service acceptance.

Maintainer handoff

Publication scope or license changes (if any): none. No new package, no new runtime dependency, no license or notice change.

Shared-source port: not needed. The change is confined to packages/webui and the two generated contracts (release/public-source.json, test/vitest-suites.json), all produced by the repository's own generators.

Suggested labels: enhancement (change type). The product axis in docs/maintainers.md offers cli / tui / desktop, none of which exist as labels in this repository; this PR is WebUI-only and proposes no product label.

…ditor

K area (记忆与个性化), first of three PRs. The 个性化 tab was
`disabled: true`; it now opens a textarea bound to the profile-wide
AGENTS.md — the same file Turn assembly already reads through
`GlobalInstructions.readForPrompt`, so editing it here changes what the
next turn actually sees.

The v2 `instructions` capability exposes only `listSources`, which returns
paths rather than content and has no write, so the harness holds its own
`GlobalInstructions` over the same dataDir the turn path uses. One file,
one writer, no second layout. The panel renders the server-reported
`maxBytes` instead of re-declaring 32KiB: a client copy would drift the
moment the runtime cap moves, and the drift would surface as the server
rejecting a save the UI had called valid.

A localStorage draft left by the pre-AGENTS.md prototype reaches the user,
but only when the profile file is genuinely absent — seeding it over a live
AGENTS.md would resurrect text the user has already replaced on disk.
`resolveEditorSeed` carries that decision and is pinned directly.

`settings-modal.test.tsx` asserted the tab was disabled; it is rewritten
for the new behaviour rather than left to go red — `account` and
`custom-instructions` are enabled, the other five stay gated.

Scope boundary with the P area: P owns the settings shell (tab list,
groups, search, the remaining `disabled` flags); this PR owns one tab body
and its two transport calls. The only shared line is the `disabled` flag
this PR removes.

The 30 red tests reported by the webui vitest suite are baseline reds,
not introduced here — confirmed by a differential run in a clean
origin/webui worktree with a bidirectional failing-file diff showing no
new red. Root cause is `TypeError: resolved?.getItem is not a function`
at composer-history.ts:157, unrelated to this area.
The global-instructions test helper was pinned to the write operation's
type, so the two read-side cases that pass the get operation did not
compile: `WebuiOperation<Record<string, never>>` is not assignable to
`WebuiOperation<{ content: string }>` because an index signature does not
satisfy a required property.

`typecheck:webui-full` catches this and the narrow `typecheck:webui` does
not, because only tsconfig.test-full.json covers the read-side cases. The
failure was invisible in the previous run: that loop reported `$?` after a
`pnpm | tail` pipeline, so the gate printed `exit=0` directly under
`ELIFECYCLE Command failed with exit code 2`, and `tail -6` cut the actual
diagnostic off the top of the log.

Make the helper generic over the body type so it states the contract it
actually relies on -- a `validate` returning the invalid-body result --
rather than one operation's body shape.

No production change: the read and write operations were already correct.
K area, second of three PRs. PR-1 opened the 个性化 tab with a global
AGENTS.md editor; this adds the 长期记忆 section next to it.

agent memory is five separate stores — main, topics, daily, summary and
archive — each with its own lifecycle. Only `agents/<name>/memory/MEMORY.md`
lands here. Topics need name validation and a description encoding, daily has
TTL archiving, and summary is capped at 4KB; all three belong with
`LocalMemoryFacade`, which the WebUI does not depend on.

That 4KB cap is the trap this avoids. The desktop's 记忆概要 modal renders a
68KB document, so that panel is reading main memory, not
`writeMemorySummary` — a textarea wired to the summary would reject exactly
the content the modal exists to display.

It lives in `@mavis/local-runtime` (v1), which `packages/webui` does not
depend on, and v2 has no memory surface to call instead. Every facade method
also takes `agentName` as a required first argument, and a user editing
"the memory" from a settings modal is not editing a session's agent, so
there is no session to attribute it to. This therefore mirrors the runtime's
storage contract — same path layout, the same `assertSafeAgentName` guard,
the same 0600-temp-file-then-rename write — rather than inventing a second
one. `WEBUI_DEFAULT_AGENT_NAME` matches the TUI constant and a test pins the
literal so the two cannot drift.

A live main file runs past the runtime's 64KB cleanup threshold — the profile
this was built against holds 110KB. The section renders size, mtime and path
from a bodyless read and fetches the text only when the user asks to edit
it, so opening the settings tab does not ship a large payload and build a
large DOM for a row of metadata.

`WebuiAgentMemoryView` lives in `client/contracts.ts`, not beside the
implementation. `src/server/agent-memory.ts` imports `node:fs`, and
`tsconfig.client.json` compiles with `"types": []` — a browser-side program
has no node globals. `server/port.ts` sidesteps this by being pure
`import type`; anything that actually touches the filesystem cannot.

`test/unit/agent-memory.test.ts` covers the storage contract against a real
temp data dir: absent-file reads, bodyless vs full reads over the 64KB
threshold, round-trip, delete-on-blank plus its idempotence, a failed write
leaving prior content intact, and the traversal guard. Three cases were added
to `personalization-settings.test.tsx` for the panel: the first paint has no
textarea, an absent transport is declared rather than faked, and a read-only
surface disables loading.

The 30 red tests in the webui vitest suite are baseline reds, not introduced
here — a differential run in a clean origin/webui worktree showed no new
red. `test:release-tools` cannot run in this sandbox (it writes a git hook
under a path the sandbox denies) and fails identically on the baseline.
check:webui-boundary failed with 8 'not an allowed WebUI build entry' violations. Root cause: host.ts imported GlobalInstructions from @mavis/local-runtime-v2/turn-system, which is a barrel (export * from persistence/global-instructions.js). Taking one class through a barrel pulled the whole turn-system dependency tree into the WebUI build graph.

server/agent-memory.ts becomes server/profile-files.ts and now owns both <dataDir>/AGENTS.md and <dataDir>/agents/<name>/memory/MEMORY.md. It mirrors the runtime's storage contract - same paths, same name guard, same 32KiB cap, same temp-file-then-rename - rather than adding a second file layout. host.ts drops the lazy GlobalInstructions singleton and its three helpers.

The two values copied out of the runtime, GLOBAL_INSTRUCTIONS_MAX_BYTES and WEBUI_DEFAULT_AGENT_NAME, are now pinned by test so a runtime-side change fails here instead of silently disagreeing.

Fixes a real bug from 76c7591: WebuiAgentMemoryError carried no code, so the case asserting code AGENT_MEMORY_UNAVAILABLE was already red. The error class now carries a code (AGENT_MEMORY_UNAVAILABLE, GLOBAL_INSTRUCTIONS_UNAVAILABLE, GLOBAL_INSTRUCTIONS_TOO_LARGE). These do not reach the client verbatim - operation-dispatch forwards a thrown code only when it is a member of WebuiErrorCode and collapses everything else to harness_error.

Moves WebuiGlobalInstructionsView from server/port.ts to client/contracts.ts next to WebuiAgentMemoryView. Both live on the client side of the seam because the implementation imports node:fs and the client tsconfig compiles with types: [].

Tests: the AGENTS.md file contract moves out of global-instructions-operation.test.ts, which imported the runtime class, and into profile-files.test.ts next to the implementation that owns it. Adds a byte-vs-character cap case, since a half-cap Chinese document is three times that in UTF-8.
…e projection

The four operations (getGlobalInstructions / setGlobalInstructions / getAgentMemory / setAgentMemory) were added to WebuiHarnessPort, to createOperationHandlers and to the client transport, but never to the hand-written projection object in service.ts that builds the operation registry. The handler layer therefore read undefined and threw 'runtime host does not expose ... reads', so both the AGENTS.md editor and the long-term memory panel were dead on arrival.

This is not a compile error and not any existing test failure. webui-host-shape-invariant.test.ts calls createOperationRegistry(port) directly, which skips the very layer that broke, and no gate boots the service. Both features shipped with twenty green gates behind them.

Adds webui-personalization-wiring.test.ts, which boots a real WebuiService and drives the four operations over a real WebSocket, so the layer that actually fails is the one under test. Verified the guard is not vacuous: renaming the getAgentMemory forwarding line turns exactly one case red, with no compile error.
…mory switches

The tab carried 自定义指令 and a standalone 长期记忆 editor. The desktop surface it mirrors has three blocks, and two of them were missing.

关于你 reads and writes the marked region of user.md, not the file. The runtime appends mem-append-reason entries to that same file, and the <user_profile> prompt block is built from the text between the two personalization markers. Editing the whole file would show the user entries the collector wrote, and let them change text the model never reads. A blank write clears the region without deleting the file, because the entries in it are not the WebUI's to remove. A file carrying only one marker is refused rather than repaired: any guess about where the region ends drops whatever sits after it.

记忆 becomes the three rows the desktop ships — 记忆, 主动记忆, and a 记忆摘要 row whose 管理 button opens the manager. The body stays closed until it is asked for, because a live main file runs past the runtime's 64KB cleanup threshold.

The two switches go through the runtime's own configuration capability as { field: "memory", put: {...} }. Not the preparedCommitPayload argument: that path is a shallow Object.assign, so passing a memory subtree would silently drop memory.dailyDigest from the user's config. The operation frame carries two booleans and nothing else — the config file holds plaintext API keys under other roots and the runtime masks them on read, so a general payload could write the masks back over the real keys. An absent capability is reported as a gap, never as enabled: false.

The marker format is owned outside this repository: nothing here writes those markers, and LocalPromptMemoryReader has no implementation in this tree. Both literals are therefore pinned by profile-files.test.ts against the same strings, alongside the existing pins for the AGENTS.md cap, the default agent name and the injection ceiling.

K-3 (个性化设置 — theme and behaviour customisation across the general tabs) is untouched and stays unclaimed: it belongs to the P area. The tab label is deliberately left as 个性化, so this entry must not be read as closing that roadmap row.
The editors were sized by min-height alone, so their height depended on the content already in the file: a 3.5KB AGENTS.md rendered as a wall of text while an empty profile rendered as an empty wall, and the two editors sat at visibly different heights for no reason. A textarea that is neither empty nor short is the case that actually needs a predictable box.

height rather than min-height, with min-height kept as the floor so the drag cannot go below it. resize: vertical stays: the browser writes a dragged size back as an inline style, so the user's own height survives until they reload, and the next open starts from the fixed height again.

Verified in the browser against the built stylesheet: both editors measure 300px and the resize grip is present.
…bout their state

Two defects in the 记忆 block, both found by clicking it rather than by reading it.

管理 opened the manager correctly and the panel really appeared — below the bottom of the settings scroll container, in the last section of the tab. The button looked dead. The manager now scrolls itself into view on open with block: nearest, which moves the panel the minimum distance and leaves an already-visible one where it is.

The switches rolled their optimistic value back only when a previous state existed. On a host where the first read failed there was no previous state, so a failed write left the switch showing the value it never wrote. Restoring "unknown" is the honest answer there, and a switch with no baseline is not one the user can reason about, so both switches now stay disabled until a read gives them one.

Found while the server was still running a build that predates these operations, which is also why the error read setMemorySettings rather than getMemorySettings: both failures share one error slot and the last one wins.
…Digest

The switches reach the config through { field: "memory", put: {...} }. The alternative in that same function is the preparedCommitPayload argument, which is a shallow Object.assign: sending { memory: { enabled, proactive } } through it would replace the whole memory subtree and delete dailyDigest from the user's config, with no error and nothing for the WebUI to notice.

The WebUI reports back the two booleans it wrote and has no reason to look at a sibling key, so this failure would surface as a digest the user enabled quietly switching off. The WebUI suite cannot see it either: the projection tests drive a fake port, and the profile-file tests never touch config.

Also asserts the whitelist still refuses a field the caller is not meant to reach. The operation frame is two booleans, but the writer is shared, so the whitelist is the thing that actually stops a general payload from arriving through this path.
管理 was an inline expansion in the last block of the tab, so it opened
below the fold: the panel really did expand, just where the user could
not see it, and the row read as a dead button. It is a dialog now.

Portalled to document.body rather than nested in the panel, because the
settings shell is a z-index:100 fixed overlay. Anything with a position
and a z-index is a stacking context, so a dialog rendered inside it can
never paint above the shell however large its own z-index is. Same
reason UserMenu portals the settings modal itself out of the rail.

The body loads on open, matching the desktop, which opens 记忆摘要
straight into the editor. A second click just to start reading is a step
with nothing behind it.

That load is reached through a ref and its effect is keyed on nothing.
Keyed on getAgentMemory, a parent handing down a fresh closure would
re-run it after every keystroke and replace the text being typed — the
editor would look like it was refreshing and the edit would be silently
gone. A load that can fire twice per open is worse than no load at all.

Escape now closes it, and it did not before. The handler sat on the
portalled surface, which is not a DOM descendant of 管理, so with focus
still on that button the key never reached it; measured dead in the
browser before the fix. Escape is on the document now, focus moves in on
open and returns to 管理 on close, and the role and aria-modal moved onto
the element that actually holds focus.

The footer follows the desktop: 更新于 <stamp> with 取消 / 保存, plus the
character count. The stamp is assembled by hand rather than through
toLocaleString so its shape does not follow the host locale.

Not pinned by a unit test, deliberately: this package runs
environment:"node" with no DOM, and the browser suite is a Linux-only
gate whose fixture still answers {} for these four operations. The
dialog behaviour is verified in the browser against the real profile
instead — open, auto-load, edit, four seconds without the draft being
reverted, close discarding it, and the file unchanged on disk. Only the
timestamp formatting is pinned here; both of its failure modes were
checked to turn the matching case red.
… or delete

The desktop's 记忆摘要 header carries a ⋯ menu, and both of its items
already had a real path here; neither had a name.

删除记忆 is a blank write, not a new capability. `writeAgentMemory` treats
empty content as "remove the file" — the runtime's own write contract does
the same — so the item is a named shortcut for what the editor could
already do by emptying the textarea and saving. That is also the reason
it gets a confirmation: the path it takes is the one that unlinks a
117KB file of accumulated lessons with nothing to undo it from. The
desktop's screenshot shows no confirmation step, so this one is a
deliberate addition, not a transcription.

在会话中创建 hands the file to a conversation instead. It is a
capability of the shell, not the transport — the memory is already
client-side and what is missing is a composer to put it in — so it rides
as a callback beside `getSigninPanel` rather than joining the
`Pick<WebuiTransport, …>` surface. The menu keeps ownership of its own
modal and dismisses it on the way out; leaving it parked over the home
composer would hide the composer the hand-off just filled.

Two things that had to be got right rather than written:

- The hand-off prompt is written from an effect, after the home surface
  settles. Drafts are stored per session, and `startNewTask` clears the
  *old* session's slot before the selection changes, so writing the line
  inline would type it into a slot the user never sees whenever they
  started from inside a session.
- The seed attachment is encoded UTF-8 first. `btoa` throws above U+00FF
  and the one file this carries is almost entirely Chinese, so the
  obvious implementation fails on the real input and on nothing else.

The seed is one-shot by token: the shell keeps it across the view switch
that opens the composer, so an effect keyed on anything else re-attaches
74KB on every re-render.

Escape and the scrim shed the menu before the dialog. A menu is a layer
above its dialog, and dismissing both at once would throw away the draft
behind them.

Verified in the browser against the real profile: menu, confirmation
quoting the live size, and the hand-off landing on the home composer with
the `MD` / `MEMORY.md` chip and the line already typed. Delete was
confirmed up to its own confirm button and no further.

`webuiTextAttachmentDataUrl`, `formatMemorySize` and the hand-off prompt
are unit-pinned; both failure modes were checked to turn the matching
case red. The menu's own contents stay browser-only — they live behind
state, and this package runs `environment:"node"` with no DOM.
The ⓘ next to 自定义指令 / 关于你 / 记忆 was a <span role="img" title>:
the browser drew its own surface, about a second late, with no arrow. The
desktop hangs a dark bubble with an arrow above the glyph, and a native
`title` cannot be made to look like that.

The bubble reuses the shape the shell already carries twice
(`.webui-signin-credits-tooltip`, `.webui-context-usage-label`) rather than
introducing a third design, and the 6px radius, 8/12 padding and 18px line
height measured off the desktop screenshots are the same three numbers those
two already use.

Two things this deliberately does NOT copy from its siblings: the max-width
is 250, not their 340. All three desktop bubbles were measured and the
two-line and three-line ones both wrap at exactly 250, which is where
`width: max-content` stops, so the 340 on the siblings is a pre-existing
mismatch against the desktop. It is left alone here because those two
surfaces have no screenshot to re-measure against and guessing at them is not
this change's job. And the hint is a real <button> now, not a role="img"
span, so it is reachable by keyboard; the bubble is aria-hidden because
aria-label already reads as the control's name.

The three strings are the desktop's own wording, replacing text that
described the mechanism instead of the effect ("注入每个会话的
<user_profile>…") — a note to whoever debugs the injection, not a line that
tells a user what the field is for. They live in one exported constant so the
markup, the test and the two surfaces cannot drift apart.

Verified: 1735 webui tests, boundary, artifact, status-contract and smoke
green; all three bubbles hovered in the real panel, the three-line one still
clearing the `overflow: auto` panel edge with its top corners intact.
test:capabilities and test:byok fail identically at HEAD in this sandbox
(TUI watcher timing; byok's 90s budget against 24 CLI spawns at ~3.4s each).
…emes

The bubble's `rgb(0 0 0 / 85%)` / `#fff` is hardcoded while every other surface
around it is token-driven, which reads like an oversight. It is not one, and
this comment is here so the next reader does not "fix" it.

The desktop draws these hints with antd's `Tooltip`. Its colours come from the
`colorBgSpotlight` and `colorTextLightSolid` global tokens, documented defaults
`rgba(0,0,0,0.85)` and `#fff`, and the desktop ships no override for either:
across the whole packaged app the string `colorBgSpotlight` appears twice, both
inside the antd chunk. Those tokens are theme-independent, and the desktop
renders a black bubble under both `.light` and `.dark` — verified by looking at
it in each. The same source also explains the 6px radius and the 250px max
width: they are antd's `borderRadius` and Tooltip `maxWidth` defaults, not
measurements taken off a screenshot.

So binding this bubble to a token would invert it in one theme and diverge from
the desktop, which is a fidelity regression that looks like a cleanup.

Comment only: no declaration changed, and the built stylesheet is unchanged.
Gates: build:webui, check:webui-boundary, test:webui.
… the desktop

Three alignment defects, all measured off the desktop capture rather than
guessed.

The 记忆摘要 row showed `123240 字节` between the description and 管理. The
desktop has nothing there — the column names the owning product. The count was
also a second, differently-scaled reading of the same file, since the manager
already reports characters. Dropping it removed the only reason the settings
pane read the memory document on open: `getAgentMemory` fired a full read just
to measure it, and the byte count was what the `onChanged` callback existed to
refresh. Both go with it, so opening 个性化 no longer pulls a 74KB file it does
not display.

The manager's ⋯ and × were unstyled bare buttons. `.webui-settings-icon-button`
has no rule anywhere in the source, so both were sized by the UA's default
padding and sat 4px apart, reading as one blob. The desktop draws ⋯ as a 26×26
tile already filled before hover, keeps × transparent, and puts the two ink
centres 34px apart at the same height. `gap: 8px` turns that centre distance
into geometry: 26 + 8 + 26 with the ink inset inside each box.

The character count sat in flow under the text, where `space-between` with a
single child collapses to left alignment. The desktop draws it inside the
editor's bottom-right corner, 15px off the right border and 14px off the
bottom, so it is absolutely positioned against a now-relative editor.

Verified in the browser at DPR 2: tile measures 26×26 CSS, the two ink centres
differ by 0.0px vertically, and the centre distance is 35.8px against the
desktop's 34. Hover deepens to `tertiary_press` rather than `tertiary_selected`
because in the light theme the latter two are the same `--opacity_black_1_4`,
which would have made the hover state indistinguishable from rest.
`locateUserProfileRegion` captures everything up to and including the opening
marker, then rebuilds as `before + body + after`. The newline that followed the
marker belonged to neither half, so every save glued the profile body straight
onto `<!-- ... -->`. Three saves of unchanged content produced three different
files.

Rebuilding explicitly restores the separator, keeps a single trailing newline
when there is a body and none when the region is cleared, and is byte-idempotent
across repeated saves. Pinned by four byte-level tests; reverting the fix turns
three of them red, and the fourth guards the cleared case that behaved the same
before and after.

Also in this batch, both measured against the desktop:

删除记忆 now confirms in its own 480px modal rather than inline. An inline block
cannot dim what is behind it, and the desktop dims the editor around a centred
dialog. Escape unwinds one layer at a time, and focus returns to the control
that opened it. The copy carries no byte count, matching the desktop, so the
`formatMemorySize` helper went with it.

The sign-in credits tooltip goes from 340 to 250, the antd default the desktop
resolves to. The two 220s stay: under `white-space: nowrap` with
`width: max-content` and no overflow, `max-width` cannot take effect, and the
comment now says so instead of leaving a declaration that reads as if it works.
The panel showed a filled profile where the desktop shows an empty one, and the
byte count said it had 6 characters. The region was not a blob of prose: the
desktop writes a fixed skeleton of `Nickname: `, `Occupation: ` and a
`## More about you` heading, and reads each label back off whichever line
carries it. An untouched file therefore has both labels present with nothing
after either colon, and every field parses to "" — while this WebUI handed the
whole skeleton to one textarea, so the user was looking at the file's own
structure and reading it as content they had written.

Splitting it into `nickname`, `occupation` and `moreAbout` and giving each its
own control is the fix; the read and the write are both transcribed from the
desktop's own bundle rather than approximated. Its normaliser collapses
whitespace runs in the two label fields, its `filter(Boolean)` applies to the
three outer parts only — so the blank line and the heading survive inside the
block and an empty profile still writes the full skeleton — and its write
verifies the file it just produced.

Both halves of the region split excluding their marker. Carrying the start
marker in `before` printed it twice per save; carrying the end marker in
`after` appended another one, growing the file 34 bytes per click. Neither is
visible in the panel, and the idempotence test is what caught both.

The cap is now checked on the composed file rather than on one field, and the
operation requires all three — defaulting a missing one to "" would silently
erase a value the user never touched. Verified byte-for-byte against the
desktop's compose across four shapes, including a region with neighbours on
both sides.
… labels

Two things the desktop has that this panel did not.

The save button was live on open. It checked that a write was possible and
that the draft fit the cap, but never whether the draft differed from what the
server returned, so a save rewrote the file byte-for-byte on a form nobody had
touched. It now compares the three fields field-by-field against the server's
own answer — which becomes the new baseline after each save, so the button goes
quiet again instead of staying live on a file that already matches. Extracted
as `isProfileSaveable` because `renderToStaticMarkup` paints one frame and
never resolves the read: every assertion made through it sees the pre-load
state, where the button is disabled for an unrelated reason and would stay
green with the check deleted.

The disabled state also faded the whole control. `.webui-mavis-button:disabled`
uses `opacity`, which washes the fill out along with the label. Measured off the
desktop, both states draw the identical `#f3f3f1` fill and differ only in the
label: `#202020` live, `#868686` not. Scoped to a new class rather than
changing the shared rule — the dialog footers genuinely do want the faded fill,
and one of them is where the desktop's own confirm was measured. The live
state's hover deepens the fill; the disabled state gets none, because a control
that cannot act must not look like one that can.

The three profile fields lose their visible labels. A label column beside each
one pushed the inputs left of the card they sit in, and the desktop draws them
bare. The names move to `aria-label`, so each control is still addressable by a
screen reader without occupying a column.

Also applied to 自定义指令, which already had the dirty check and was drawing
its disabled state the washed-out way for the same reason.
All three come from measuring the wrong capture rather than from a
misunderstanding of the layout.

The filled tile on ⋯ was a hover state. The light desktop capture had the
pointer parked on that button, so the 26x26 #f1f1ef square read as the rest
state. The dark capture of the same dialog shows both glyphs bare on the
panel, with ink centres 24px apart at the same height. A `:first-child` rule
had pinned ⋯ permanently, which is why it read as selected next to a
transparent x. The fill is now hover-only for both, and the geometry follows
the dark capture: 20px boxes, 4px gap, 6px radius.

The controls sat 16px further in than the text below them because
`.webui-personalization-header` indents its children by 16px while the editor
is a *sibling* of that header, not a child. A negative margin on the manager's
header cancels the inset without moving the title, which shares the row.

The character count was absolutely positioned in the editor's bottom-right.
That matched the desktop horizontally but laid the digits over the last line
of text with the textarea's own background showing through. It is back in
flow as a full-width band with its own background.

The hover test asserts no non-hover fill exists across *any* rule matching
these controls rather than against the one selector this bug arrived on --
`:first-child` is only the shape it happened to take.

Gates: build:webui, check:webui-boundary, test:webui (89 files, 1751 tests).
The desktop's `personalization-profile-section` renders a single TextArea
bound to `moreAbout`. `nickname` and `occupation` exist only in the file
layer, where the reader and the writer still parse and re-emit their
`Nickname: ` / `Occupation: ` lines so an existing profile is not truncated
on the next save. The desktop draws no control for either.

We rendered inputs for both, which is why the section showed two permanently
empty rectangles above the one field that is actually editable: the user's
own `user.md` has both labels with nothing after the colon. The file layer is
unchanged and still round-trips them -- the byte-level diff against the
desktop's compose was correct, and it stays correct. What was wrong was
concluding from a three-field file format that the UI had three fields.

The three now-dead `> input` rules went with them.

A disabled 保存 also refused the pointer as a plain arrow. The desktop carries
`opacity:.5;cursor:not-allowed` on `.mavis-button.disabled`; our shared
`.webui-mavis-button:disabled` pins `cursor: default` and won the cascade.
Restated on the scoped rule rather than dropped from the shared one, because
`default` is the right cursor for a dialog button that closes.

Gates: build:webui, check:webui-boundary, test:webui (89 files, 1751 tests).
Both personalization headers pass `variant: "black"` in the desktop bundle --
the 关于你 header's `action` and 自定义指令's save are identical down to the
geometry overrides. We were drawing them as `gray`, a light fill. That is the
记忆摘要「管理」button's variant, not theirs.

`black` is `bg_interaction_primary_default` with `text_label_primary_default`,
and its hover is `bg_interaction_primary_hover` (80% black); the dark theme
fades the whole control to .8 instead of restating the fill. The call site also
overrides the shared 36x80 base down to 76x30 with no padding and centred text,
which is now transcribed rather than measured.

The disabled state needed no colour of its own, and the bundle has no
`.mavis-button.black.disabled` to copy: `.mavis-button.disabled{opacity:.5;
cursor:not-allowed}` is the whole rule. So a disabled 保存 keeps the black fill
and the white label and goes half-transparent. Only `gray` overrides its own
disabled fill, which is why the old grey version could keep one -- and why the
`#868686` label and the `secondary_default` fill are gone rather than restated.

This replaces the earlier reasoning, which concluded from a capture that both
states shared a `#f3f3f1` fill. That measurement was taken from a button whose
variant was never established; the bundle distinguishes `gray` from `black`
unambiguously, and the dark-theme opacity rule only exists for the dark fills.

Gates: build:webui, check:webui-boundary, test:webui (89 files, 1751 tests).
Four reported problems, all read off the dialog's JSX and CSS rather than off
a capture. The capture that motivated the first attempt turned out to be of a
build that predates the change it was being used to check.

The dialog's last row is `flex justify-between` -- timestamp left, 取消/保存
right -- with no divider; the gap is the divider. We drew a `border-top`. The
same row pairs `variant:"gray"` with `variant:"black"`, so the committing
action is the loud one; both buttons were grey. Spacing is 20px above the row
and 16px between the buttons.

The character count is not painted. The desktop widens antd's count suffix to
`width:100%;justify-content:flex-end` so the digits land on the editor's own
surface. Our second grey band next to a `--bg_grouped_tertiary` editor drew two
shades meeting at the bottom edge, which read as a seam. The band is now
unstyled, which keeps it a full row and puts it back on one surface with the
text it counts.

The header controls are `size-[26px]`, `rounded-lg`, `gap-2`, with a rest fill
of `bg_interaction_tertiary_default`. That token is `--opacity_black_1_0`, so
the fill is declared rather than left off -- transparent here, not transparent
in a theme that raises it. A previous pass took them to 20px on a 4px gap from
a dark capture whose ink centres were 24px apart; a centre distance cannot pin
a box size without the glyph inset, and the bundle states the box outright.

Three editors, two looks, and the split is the class the call site carries. The
dialog uses the base `.mavis-textarea` and keeps a border, taking `!rounded-xl`
from the call site; 关于你 and 自定义指令 both add `mavis-personalization-editor`,
which zeroes the border and fills with `--bg_grouped_tertiary` at 16px. All
three lose the manual resize, as `.mavis-textarea textarea{resize:none
!important}` does on the desktop -- it outranks even the inline `resize:vertical`
one call site passes.

Gates: build:webui, check:webui-boundary, test:webui (89 files, 1755 tests).
Measured off the two screenshots at 1:1 rather than inferred.

The save button was live on open. The desktop's predicate is
`!loading && !saving && draft !== baseline && draft.length > 0`; ours had no
baseline to compare against, so the control offered a write that rewrites the
file byte-for-byte. Lifted into `isMemorySaveable` so it is testable without an
effect flush, with the baseline set on load, on a successful write (the server's
answer is the new baseline, in case it normalised anything) and on a delete.

The count was outside the field. The desktop's antd `showCount` widens the
count suffix to `width:100%` and right-aligns it inside the editor, so the
digits sit 14px from the right border and 14px above the bottom. Ours floated
over the panel, then became a full-width sibling row *below* the editor -- still
outside the control it counts. It is now absolute inside, with 28px of reserved
bottom padding so the last line of text cannot scroll underneath. The desktop
reserves nothing and overlaps once the text scrolls; that is a defect worth not
copying.

The focus edge is `--blue_500`. The bundle says
`.mavis-textarea:focus{border-color:var(--border_heavy)}`, but the control
renders a 1px `#0077d9` edge while focused, which is this app's `blue_500` and
antd's themed `colorPrimary`. The pixel won: a rule that no longer matches what
the control draws is not worth copying. Hover still deepens to `--border_heavy`.

Test coverage note: the predicate and the button's on-open disabled state are
pinned, but replacing the predicate at the call site with a laxer one would not
fail any test here -- `renderToStaticMarkup` runs no effects, so nothing can
drive the button into its enabled state. Recorded in the test itself rather than
left as a silent gap.

Gates: build:webui, check:webui-boundary, test:webui (89 files, 1757 tests).
…ontent

The count rendered bottom-left, inside padding, on top of the text -- the same
shape it has in the report. The cause was a shared class, not the offsets.

`.webui-personalization-meta` is the 「path · size」 row and carries
`width: 100%`, `display: flex` and `padding: 8px 12px`. The count rode on it
behind a scoped override, and a scoped rule only wins the properties it
declares: the width survived. `position: absolute` + `width: 100%` +
`right: 14px` puts the box's left edge at -14px, and `space-between` then
aligned the digits to that edge. Same mistake as the save button's missing
`cursor` two commits ago, and the fix is the same: declare what you rely on, or
stop inheriting. The count is a one-off control, so it gets its own class.

The dialog was 239px tall against the desktop's 593. The desktop sizes the
field by content -- `autoSize: {minRows: 13, maxRows: 24}` at `!leading-6` --
and marks it `shrink-0`. Ours had `flex: 1` inside a surface carrying only a
`max-height`, so no definite height existed to distribute and the field sat on
its own `min-height: 240px` no matter how much text it held.

`field-sizing: content` is the native equivalent of antd's autoSize: the
browser measures the content and drives `height`, no script. Unsupported, the
field keeps `min-height` and the dialog stays usable, so the fallback is the
old behaviour rather than a broken one. `flex: none` because the height is the
content's, not the leftover space.

The face changes with it, and had to: the desktop runs this field at
`!text-sm !leading-6` in the UI face, and we were on 12px monospace -- which is
also why a "13 row" floor was not 312px here. Measured off the desktop dialog:
24px between baselines, 13 rows + 8px top padding + the 28px the count needs is
the floor, 24 rows the ceiling.

Gates: build:webui, check:webui-boundary, test:webui (89 files, 1759 tests).
The dialog was still 347px. `field-sizing: content` had never taken effect,
so the height came from `min-height` and the desktop's 595px was never in
reach -- the property was simply ignored, with nothing in devtools and no
error anywhere.

It only applies when `height` is `auto`. The shared
`.webui-personalization-textarea` rule still declares `height: 300px`, and a
scoped rule that does not restate a property inherits it -- the same cascade
trap as the save button's `cursor` and the count's `width`, and the third time
in this file. `height: auto` is now declared, and asserted: a silently inert
declaration is exactly the kind of thing that should have a test pinning it.

This also settles the second half. With a dead `field-sizing`, the box was
347px of scrollable text with the count absolutely positioned 8px off the
bottom, so the last line ran under the digits. Now the box caps at 612px and
the 28px reserved bottom padding is a band the scroll cannot enter, which is
what the desktop gets from antd subtracting the count suffix's height out of
autoSize.

On the 17px the two figures disagree by: 612 against the measured 595 is the
28px count band plus its top padding, less what the desktop's own 8px suffix
padding would have used. The desktop lets the last visible line be cut in half
by the count; the band is what stops that, and it is the one place this
deliberately does not copy.

Gates: build:webui, check:webui-boundary, test:webui (89 files, 1759 tests).
… editors

Three items, each measured off the desktop capture the annotations point at.

The header controls were 25px inboard of the editor's right border where the
desktop has 8px. The fix replaces `margin-right: -16px` with
`padding-right: 0`: the header is a stretched flex item, so its box and padding
do not resolve the way a block's would, and the negative margin still left the
controls ~19px short. Zeroing the padding puts their right edge on the surface's
content edge, which is the editor's edge, by construction rather than by
arithmetic.

The count now spans the full reserved band and paints it. The 28px the editor
keeps as `padding-bottom` has to read as the count's row; left unpainted it was
a gap the summary text showed through. The two 28px values are the same band
and have to move together.

The two settings-page editors were 300px tall and free to grow. The desktop
passes `minHeight: 144, maxHeight: 200` in the style object, and with
`field-sizing: content` alongside `height: auto` those two numbers become the
whole rule -- the field grows with its text and stops at 200px, which is how
antd resolves a textarea carrying min/max and no autoSize. The dialog keeps its
own 13-to-24-row clamp on top.

Gates: build:webui, check:webui-boundary, test:webui (89 files, 1759 tests).
The blue was the border, which means it cost the content 1px and left the text
sitting right against the ring. It is an `outline` now: painted on the border
edge, taking no layout, so the content box is identical focused and unfocused
and the ring sits outside it. `border-color` returns to its resting value for
the same reason. Hover still deepens the border itself to `--border_heavy`.

The blue itself is unchanged and still measured rather than copied: 1px
`#0077d9` off the desktop dialog, which is this app's `--blue_500` and antd's
themed `colorPrimary` -- not the `--border_heavy` the bundle's
`.mavis-textarea:focus` rule names.

The test asserts `outline` and `border-color` as a pair, plus a negative on the
border, because the two are the same decision: either the ring is outside and
the border is the resting colour, or it is the border and the content loses a
pixel. Checking one without the other would let a future edit satisfy half of
it. A `box-shadow` ring is the tempting third option and is asserted against
too -- it also takes no layout, but it paints over the padding and would cover
the count row's edges.

Gates: build:webui, check:webui-boundary, test:webui (89 files, 1759 tests).
The character count band is `position: absolute`, which also places it in a
later painting phase than the static textarea, so its opaque surface covered
the editor's 12px corner curve and the bottom corners read as right angles.

The radius is the editor's own `--radius_12`, not a value picked to look
right. `.webui-memory-manager-editor` has no padding, no border, and the
textarea carries no margin, so `right/bottom/left: 0` puts the band's box
exactly on the editor's border box: the outer radii are concentric and no 1px
inset arithmetic is needed.

Two comments in the same block had been falsified by later commits and had
started describing code that no longer exists -- one still explained a
`bottom: 8px` offset that is gone, and one claimed the border itself turns
`--blue_500` on focus, which stopped holding when the focus ring moved to an
outline. Both corrected, since a stale comment here is what a later reader
would trust over the rule.

Left alone, deliberately: the band's bottom edge is still flush with the
editor's, so the 1px bottom border stays covered across the full width. The
desktop puts its count in antd's `.ant-input-suffix`, inside the bordered
box, so that border reads all the way around. Closing the gap means insetting
the band by 1px with 11px corners, which moves the already-measured 14px inset
to 15px, so it is recorded in the CSS rather than changed unmeasured.

The new assertion pairs with the editor's existing radius assertion, and was
checked against a reverted file: dropping the declaration fails it, and the
file restores byte-for-byte.
`pnpm typecheck:webui` was never part of the loop this branch was built on --
the three gates were `build:webui`, `check:webui-boundary` and `test:webui`,
and vitest transpiles without checking types. Rebasing onto `webui` and
running the compiler for the first time surfaced four errors that had been
sitting in the tree the whole time.

Two of them are one mistake. When the profile region split out of a single
`content` string into its three real fields, `port.ts` was updated to take
`WebuiUserProfileFields` and so was `profile-files.ts`, but the client-side
transport surface kept declaring `setUserProfile(request: { content: string })`
in all three places. The panel was therefore calling the operation with a shape
the server never accepted, and the only thing that noticed was the compiler.

`profile-files.ts` used `WebuiUserProfileFields` in three signatures without
importing it. That survived a long time because the name appears only in type
positions: esbuild strips it, so the runtime was correct and every test passed.

The fourth is a narrowing the compiler cannot see through. `isMemorySaveable`
already rules an undefined draft out, but it returns a plain boolean, so
`draft` reached `setAgentMemory({ content: draft })` as `string | undefined`.

Also drops a duplicate `provider.js` export line that the rebase merge left
behind; `build:webui` caught it as `Multiple exports with the same name`.

Verified: `typecheck:webui` clean on all three projects (server, client, test),
`build:webui` 208 inputs, `check:webui-boundary` 208 inputs / 6 rules,
`test:webui` 95 files / 1856 tests.
@July-X July-X added the enhancement New feature or request label Oct 6, 2026
CI caught what the local loop could not. `verify (ubuntu-latest)` failed at
`typecheck:webui-full` -- the fourth tsconfig, the one that compiles the full
test tree rather than the curated subset. It was never part of the gates this
branch was iterated against, and vitest transpiles without checking types, so
two rounds of type errors passed locally and still went out in the PR.

Six stubs in `personalization-settings.test.tsx` were still returning the
pre-split shapes:

- four memory stubs omit `agentName`, which `WebuiAgentMemoryView` requires;
  the existing correct stub at line 366 shows the field is not optional.
- two profile stubs return a single `content` string, but
  `WebuiUserProfileView` extends `WebuiUserProfileFields`, so it needs
  `nickname`, `occupation` and `moreAbout`.

Both are the same class of drift as the source-level errors fixed in the
previous commit: the data layer split into real fields, one declaration layer
was updated, and the layer that tests it was not. The stubs stayed valid at
runtime because nothing in the render path reads `agentName` or the profile
fields on the malformed path -- so the assertions kept passing while the
types said the stubs were lying.

Local gate list widened to cover the gap: all four webui tsconfigs
(server / client / test / test-full), plus `build:webui`,
`check:webui-boundary`, `test:webui` and `check:source`.
@July-X
July-X merged commit 41f2edc into webui Oct 6, 2026
8 checks passed
@July-X
July-X deleted the dev-izzy branch October 6, 2026 10:45
antianqi added a commit that referenced this pull request Oct 7, 2026
…nect

Every dev-mode upgrade and API call was rejected with 403 Forbidden Origin,
and the shell sat on "WebUI connection failed". Sessions and account state came
back empty because nothing had ever connected: the websocket handshake never
completed.

`isAllowedOrigin` requires the origin's port to equal the bound port, which is
correct for the packaged server — ADR 0004 has it serve the built client from
the same listener, so same-origin is the right rule there. The Vite dev server
necessarily listens on a different port (5173 against 8787), so every request
the browser made carried an origin the check rejected.

Measured, before this change:

    ws://127.0.0.1:8787/ws  Origin http://127.0.0.1:8787  ->  OPEN
    ws://127.0.0.1:5173/ws  Origin http://127.0.0.1:5173  ->  ERROR

After: both OPEN.

The port check is skipped only in dev, and only after the checks above it have
already held — the origin must still be http or https and its host must still be
`127.0.0.1` or `localhost`. A page on another machine still cannot reach the
service.

This is a port of another contributor's uncommitted work on this file, which I
had to drop while syncing the base: their patch no longer applies, because
PR #29 added 153 lines to `service.ts` and PR #32 moved the other two files in
the same change set. The reasoning and the shape are theirs, carried over
rather than rewritten; if they would rather land it themselves, this commit
should be dropped in favour of theirs.

Validation
- pnpm typecheck:webui-full                  exit 0
- run-vitest-suite.mjs webui    10 failed | 1917 passed | 4 skipped

The 10 failures are the unchanged pre-existing Windows baseline. The browser
suite's own harness does not exercise the dev proxy path, so it neither caught
this nor proves the fix; the handshake measurement above is what does.

Not covered here: `vite.config.ts` and `build-webui-styles.mjs` carried related
edits in the same dropped patch. Those address dev-server packaging rather than
the handshake, and neither was needed to restore the connection.
antianqi added a commit that referenced this pull request Oct 9, 2026
…nect

Every dev-mode upgrade and API call was rejected with 403 Forbidden Origin,
and the shell sat on "WebUI connection failed". Sessions and account state came
back empty because nothing had ever connected: the websocket handshake never
completed.

`isAllowedOrigin` requires the origin's port to equal the bound port, which is
correct for the packaged server — ADR 0004 has it serve the built client from
the same listener, so same-origin is the right rule there. The Vite dev server
necessarily listens on a different port (5173 against 8787), so every request
the browser made carried an origin the check rejected.

Measured, before this change:

    ws://127.0.0.1:8787/ws  Origin http://127.0.0.1:8787  ->  OPEN
    ws://127.0.0.1:5173/ws  Origin http://127.0.0.1:5173  ->  ERROR

After: both OPEN.

The port check is skipped only in dev, and only after the checks above it have
already held — the origin must still be http or https and its host must still be
`127.0.0.1` or `localhost`. A page on another machine still cannot reach the
service.

This is a port of another contributor's uncommitted work on this file, which I
had to drop while syncing the base: their patch no longer applies, because
PR #29 added 153 lines to `service.ts` and PR #32 moved the other two files in
the same change set. The reasoning and the shape are theirs, carried over
rather than rewritten; if they would rather land it themselves, this commit
should be dropped in favour of theirs.

Validation
- pnpm typecheck:webui-full                  exit 0
- run-vitest-suite.mjs webui    10 failed | 1917 passed | 4 skipped

The 10 failures are the unchanged pre-existing Windows baseline. The browser
suite's own harness does not exercise the dev proxy path, so it neither caught
this nor proves the fix; the handshake measurement above is what does.

Not covered here: `vite.config.ts` and `build-webui-styles.mjs` carried related
edits in the same dropped patch. Those address dev-server packaging rather than
the handshake, and neither was needed to restore the connection.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants