Repository navigation
Conversation
…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.
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`.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Change
K 区(记忆与个性化)此前在 WebUI 里不可见:
个性化设置 tab 是禁用的,AGENTS.md、MEMORY.md和user.md只有服务端能读写,WebUI 没有任何入口。这个 PR 把它变成可用,并按桌面版对齐外观。新增能力
个性化tab,一次补齐三块此前不存在的内容:MEMORY.md;⋯菜单提供「交给会话继续」和「删除记忆」,删除走带确认的对话框;两个记忆开关接真实读写,并发写入时不会把memory.dailyDigest之类的相邻字段丢掉。user.md的标记区域,按桌面版的Nickname: / Occupation: / ## More about you三字段解析;区域外的运行时追加内容不会被覆盖。AGENTS.md。文件所有权、32KiB 上限和临时文件加改名的原子写都在服务端,UI 只回显maxBytes,避免面板硬编码上限后接受运行时必然拒绝的文本。与桌面版对齐
⋯菜单、底部按钮排布按 asar 转写:按钮 26px、间距 8px、面板 624px、max-height: 90vh。关于你和自定义指令走mavis-personalization-editor的无边框 +bg_grouped_tertiary+ 16px 圆角。field-sizing: content),焦点蓝圈用outline画在盒子外侧,不占布局也不吃掉 1px 内容。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 完整性通过。perf:full(本 PR 不触碰 performance rules 覆盖的运行时路径)。NOT RUN / 边界
kill进程,服务端无法重启,因此没有在真实页面上看过最终像素。这一项建议评审时人工确认。webui(738ae1c)rebase 时产生的冲突均为并集型(两侧各自新增、上游解禁了代码审查与工作树两个 tab)。上游解禁的那两个予以保留,本 PR 未回退。Publication and contribution checks
release/public-source.json; new tests are declared intest/vitest-suites.jsonwhere applicable.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/webuiand 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 indocs/maintainers.mdofferscli/tui/desktop, none of which exist as labels in this repository; this PR is WebUI-only and proposes no product label.