Repository navigation
Merged
Conversation
…ms and state round-trip
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.
Summary
Interactive components in replies were static:
deeix-uiprops could only carry data, a value the user changed never reached the model, and a broken node rendered as a blank box. Anything that needed animation, a 3D sketch or a flow field had no home at all, and a suggested follow-up was sent verbatim on click.1. A block format that can compute and remember (
packages/core+apps/web). Blocks gainstate,computedandchildrennext to the existing props, plusbind,visibleand actions (set,ask,copy). Values are{expression}templates evaluated locally — nevereval, a whitelisted interpreter with 30 functions and implicitx/i/rowfor per-point chart series,wherefilters and formula/scene primitives (formularenders "expression = substitution = result" and generates its own LaTeX from the parse tree;scenedraws shapes from the same values). Newpackages/core/src/ui-block/holds the engine:partial-json.ts(any prefix of the JSON is readable, and it reports what was closed and where the text was cut),expression.ts,template.ts,points.ts,primitives.ts,fence.ts, all covered by Node tests including "every streaming prefix of the samples parses and renders".apps/web/shared/lib/safe-expression.tsis replaced by the core interpreter (calculator and function-plot use it through the same numeric interface). Rendering is streaming-tolerant (a node appears when its own closing brace arrives, leaf by leaf, path-keyed so inputs keep focus), and failures are bounded: unknown components and invalid props say so instead of disappearing, a broken value shows "—", and node- and block-level error boundaries keep the rest of the message alive. Dev builds list compile and evaluation problems under the block.2. State round-trip (front end, backend, prompt). Only the keys the user changed are stored, per
(message, block id, state key), debounced for a second and flushed withkeepalivewhen the page is hidden; the current values also travel with the next send, so a change cannot race the debounce.PUT /api/v1/conversations/:id/messages/:message_id/ui-statepersists them (new tablechat_message_ui_states, one row per message and block, hard delete, message row locked before the block count so the 32-block cap holds under concurrency), and the next request injects the current values for the blocks on the visible branch into the dynamic context as a<ui_state>block — capped at 8 blocks and 4 KB, escaped, and labelled as user-entered data rather than instructions, so it never enters the prefix cache. Fork, delete, export, temporary chats and edits are handled; share pages stay read-only.3. Inline interactive illustrations (
deeix-widget). A second kind of fence whose body is a self-contained HTML fragment the model writes and the host runs in the sandbox that already backs artifacts and custom components (sandbox="allow-scripts", no same origin,SANDBOX_CSP: no network, no forms, all permissions denied). It runs only once its fence is closed (a half-written script would only throw) while the rest of the message keeps streaming, is keyed by its source and titled from<title>/first heading, shares the per-block state store and the next-turn injection, caps at 200 KB (source shown instead) and 900 px, offers "view source" and "open in the side panel", and degrades to the source after 5 s where the host CSP forbids inline scripts (the desktop shell). A block'saskaction became a small composer prefilled with the suggestion: the user edits it, sends with the button or Enter, and a reset button brings the suggestion back — nothing is sent from a click the host cannot attribute to the user.4. Prompt. The component catalog now has a v2 (
catalogPromptHeaderLive) that describesstate/children/formula/data/actions/primitives, the<widget>section, and a rewritten<when>(use / avoid / choose / followup / check) that prefers the lightest expression (catalog component → panel → widget). v1 clients keep the previous catalog byte for byte: the client declaresuiBlockVersionwith each send (absent or 1, 2; other values 400), and the routing line is only rewritten for v2. Examples, the function table and the primitive table live inpackages/coretestdataand are read by the Go tests, so prompt and engine cannot drift.5. Desktop (macOS): browser-style close shortcuts. The Tauri shell now builds its own menu: ⌘W closes the tab that is showing (the last tab closes the window instead, because closing it would forget its server) and ⇧⌘W closes the window, with File following the tab count as Safari's does. Tauri's default menu bound ⌘W to Close Window, which hid the whole tab container — the behaviour users reported as "the app quit". Two upstream details forced the shape: the predefined Close Window item has a hard-coded accelerator and no setter, and a
Cmd+Shift+Waccelerator string becomes the unshifted key equivalentw, which AppKit never matches for ⇧⌘W, so the installed item is corrected through AppKit. Other platforms have no menu bar and are unchanged.Change type
Affected areas
Verification
pnpm --filter @deeix/core test— 119 passed (streaming prefixes of every sample, expressions, templates, points, fences, widget keys).pnpm --filter @deeix/core check,pnpm --filter @deeix/web check(Biome,tsc, architecture rules) — clean.pnpm api:check— Swagger andtypes.generated.tsare up to date.go test ./internal/application/uicomponent/... ./internal/application/conversation/...— ok (UI state service, repository, handler and injection tests, catalog/example contract tests, prompt tests).cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test --libinapps/desktop/src-tauri— clean, 37 passed.<ui_state>on the next turn; a configuration switch and a timeline drag write state back to the host; the side panel and the inline view stay in sync; copy turns a reply into readable text and tables with the current values; a widget plays its animation and falls back to source when inline scripts are blocked; the.stableillustration no longer shifts its controls when the caption goes from one line to two.NSMenu.performActionForItemAtIndex:, the same path a click or key press takes) and confirmed the File items, their key equivalents, the tab-count rewrite, that ⌘W closes the active tab, and that the last tab leaves the window hidden rather than destroyed. AppKit matching was checked separately againstNSMenu.performKeyEquivalent.Screenshots, API examples, or logs
PUT /api/v1/conversations/…/messages/…/ui-statewith{"blocks": {"loan": {"amount": 300, "years": 20}}}; response returns the full block state of that message.[probe] 2 tabs: item 0 "Close Tab" key="w" mask=0x100000,item 1 "Close Window" key="W" mask=0x120000,firing item 0 → [tabs] close tab-2,1 tab again: item 0 "Close Window" key="w", window hidden after the close request.Configuration, migration, and compatibility notes
chat_message_ui_states, registered withschema.Models()and created by the existing startup migration path; new endpoint plus DTOs, with Swagger andpackages/api-contract/src/types.generated.tsregenerated (pnpm api:checkpasses).uiBlockVersionon send requests is optional; absent or1keeps the previous catalog and behaviour, so older clients and their prompt caches are unaffected. They see v2 blocks as an unknown fence and fall back to the source.{"component", "id", "props": …}blocks still parse, and stored state is keyed per block.script-src 'self'is inherited by the sandboxsrcdoc; serving the sandbox document from a custom protocol with its own CSP is the follow-up.uiStateis added to message list, pagination, export, edit and temporary-chat responses; no other response envelope changes.Documentation
docs/ARCHITECTURE.mdrecords the deliberate exception thatui-blocklives inpackages/corewhile only the web consumes it (pure logic, Node tests, contract shared with the backend prompt), andpackages/core/README.mddocuments the module and itstestdata. The model-facing format is documented in the catalog prompt itself, which the tests above check. Design notes live indocs/rules/GenUI/Plan.md, which.gitignorekeeps out of the repository on purpose.Security and privacy
No secrets, tokens, credentials, local config, or personal data are included.
User data access remains scoped by authenticated user context unless an admin-only path explicitly requires broader access.
Security-sensitive behavior was reviewed, including authentication, authorization, provider routing, file processing, billing, and admin APIs where relevant.
Model-authored HTML and script now runs inline in the sandbox that already backs artifacts and custom components:
allow-scriptsonly, no same origin, no network, no forms, every permission denied, a 200 KB cap, and the source is always viewable.ui-stateonly accepts assistant messages of the current conversation owned by the caller, refuses messages that are still generating, and validates block ids, state names, depth and size (message.invalidUiState,message.ui_state_target_invalid, both localised).Injected state is user-entered data: XML-escaped, capped at 8 blocks and 4 KB, passed in the dynamic context outside the cached prefix, and explicitly labelled as values rather than instructions; prompt traces record its source.
askonly prefills the composer (2000 characters, throttled): the host cannot attribute a click inside a sandbox to the user, so the user is the one who sends, and the text they send is the text they can see.Checklist
.pycfiles,.envfiles, and local storage data are not committed.