57 KiB
| name | description | category |
|---|---|---|
| bricks-widget-development | Bricks widget creation patterns: layout, flexbox, events, build.sh, pitfalls | software-development |
Bricks Widget Development
How to create new Bricks widgets for the Sage platform. Patterns extracted from DimensionFilter, SearchBar, and DataViewer development.
Widget Skeleton
Every widget:
- Source file in
bricks/bricks/<widget_name>.js - Register via
bricks.Factory.register('WidgetName', bricks.WidgetName) - Add to
build.shSOURCES list - Rebuild:
cd bricks/bricks && ./build.sh - CSS (if needed) in
bricks/bricks/css/bricks.css
var bricks = window.bricks || {};
bricks.MyWidget = class extends bricks.VBox {
constructor(opts){
opts.width = opts.width || '100%';
opts.height = opts.height || '100%';
super(opts);
schedule_once(this.build_all.bind(this), 0.1);
}
async build_all(){
// Build subwidgets and add them via this.add_widget()
// Load async data
}
};
bricks.Factory.register('MyWidget', bricks.MyWidget);
🔴 Custom-widget option names MUST NOT collide with method names
opts_set_style() (widget.js ~line 202) copies EVERY option key onto the instance:
this[okeys[k]] = this.opts[okeys[k]]. So an option whose name matches a prototype
method silently SHADOWS that method with a string/number, and the next this.method()
call throws TypeError: this.<name> is not a function.
Real failure (ResourceBrowser, 2026-08-12): the option tree_width: "280px" collided
with the method tree_width(). On build, opts_set_style set this.tree_width = "280px"
(string), so build_body's this.tree_width() threw TypeError: this.tree_width is not a function
→ the whole widget rendered empty. Fix: rename the METHOD to get_tree_width()
(reads this.opts.tree_width || '280px'), never collide option names with method names.
Rule when authoring a composite widget: if you add an option foo, do NOT also define a
method foo(). Prefix getter methods (get_foo()) or rename the option. Verify after build:
grep -c "get_tree_width" ../dist/bricks.js should be non-zero and the served /bricks/bricks.js
must match (the bundle is served from dist/bricks.js; browser may cache it — use a
cache-busting query on navigation or confirm the byte-size changed).
🔴 Composite widget: buildBind pollutes your option descriptors — strip tools/binds before clone_descriptor
Real failure (ResourceBrowser, 2026-08-13): a composite widget whose options carry a
tools (or binds) array builds those sub-widgets once at startup (build_tools_row →
widgetBuild). bricks.buildBind (bricks.js ~line 210) MUTATES each bind descriptor:
desc.event_widget = widget — a live widget reference. Since this.browser_options.tools[i]
IS that same descriptor object (not a copy), it now holds a Button whose
dom_element.bricks_widget points back to itself → circular reference.
Later the widget calls clone_descriptor(this.browser_options) =
JSON.parse(JSON.stringify(desc)) → throws TypeError: Converting circular structure to JSON
→ the browser panel renders empty (browser_panel.children === 0, current_id already set but
no content).
Fix — strip the polluted keys BEFORE cloning:
var browser_copy = bricks.extend({}, this.browser_options); // shallow copy
delete browser_copy.tools; // drop the polluted array
var desc = this.clone_descriptor(browser_copy); // now stringify is safe
Apply the same delete copy.tools treatment to tree_options in build_body for symmetry.
Rule: any descriptor you will later JSON.stringify-clone must never be the same object you
handed to widgetBuild (which mutates it via baseURI and event_widget).
Diagnosis signature: JSON.stringify circular-structure stack pointing at
bricks.Button → dom_element → bricks_widget; and the target panel stays empty while the
sub-widget build itself logged idset= successfully.
🔴 Declarative confirmation: the conform field on any bind (NO inline confirm())
Every bind descriptor accepts a conform option. When present, universal_handler
(bricks.js) builds a bricks.Conform popup and only runs the action handler AFTER the user
clicks "confirm" — no window.confirm(), no extra JS. This is the bricks-idiomatic way to
satisfy "删除前让客户确认一下".
{
"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"conform": {
"title": "删除确认",
"message": "确认删除选中的文件?",
"conform": {"label": "删除"}, // overrides the confirm button label
"discard": {"label": "取消"} // overrides the cancel button label
},
"script": "/* runs only after confirm */"
}
conform.title/conform.message→ PopupWindow title +Conformmessage (Conform readsopts.message).conform.conform/conform.discard→ override the twoIconTextBartool labels (defaults are i18n'Conform'/'Discard').- The whole
conformobject is passed verbatim as theConformwidget's options (widgetBuild({widgettype:'Conform', options:desc.conform})), thenconform_widget.bind('conformed', handler). - Works with
script,urlwidget, and any otheractiontype— the confirmation wraps the handler uniformly.
🔴 Drag-drop file upload: bricks.Droppable + filedrop event
bricks.Droppable (draggable.js) accepts OS file drops and dispatches a filedrop event.
This is the ready-made upload zone — no manual dragover/drop listeners needed.
{
"widgettype": "Droppable",
"options": {"accepts": ["*"], "padding": "10px"},
"subwidgets": [{"widgettype": "Text", "options": {"text": "📥 拖拽文件到此处上传", "halign": "middle"}}],
"binds": [{"wid": "self", "event": "filedrop", "actiontype": "script", "target": "self",
"script": "var fs=event.params.files; ..."}]
}
event.params.files=[{name, size, type, file}]wherefileis the nativeFileobject.accepts: ['*']accepts everything ('*'.replace('*','.*')→'.*'matches any MIME).- In the script, read the file via
FileReader.readAsDataURL(f)to get a base64data:URI for the backend (see form-encoded note below).
🔴 fetch→DSPY POST: use URLSearchParams (form-encoded), NOT a JSON body
Verified (2026-08-13): a DSPY reading params_kw.get('filename') does not receive fields
sent as a JSON body (Content-Type: application/json). params_kw came back empty (→ "文件名无效").
Sending the same fields as application/x-www-form-urlencoded (via URLSearchParams or --data-urlencode)
did populate params_kw. For raw file bytes use FormData (multipart → ahserver auto-saves to
FileStorage; see references/agentinput-formdata-multipart.md).
// ✅ RIGHT — form-encoded (works with params_kw)
var body = new URLSearchParams();
body.append('id', folder_id);
body.append('filename', f.name);
body.append('filedata', b64); // base64 data: URI from FileReader
var resp = await fetch(upload_url, {method:'POST', body: body}); // no Content-Type header
var rj = await resp.json();
// ❌ WRONG — JSON body does NOT populate params_kw
fetch(url, {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({id:..., filename:..., filedata:...})});
Full workspace file-browser (ResourceBrowser + tree + upload/open/delete tools + confirm +
selectable file rows): references/resourcebrowser-file-upload-tools.md.
"打开/预览文件" dispatch — text→Wterm+.xterm vi-edit, media→VideoPlayer/AudioPlayer/Image,
office/pdf→download: DSPY returns web.FileResponse (stream + Content-Disposition: attachment),
.xterm returns SSH cmdargs:['vi', path], websocket_url() (not entire_url()) for the ws_url,
config.json processors + nginx /wss/ wiring. Full detail:
references/file-open-vi-media-download.md.
Layout: CSS Flexbox
Critical: Bricks widgets use inline-block layout by default. flexGrow, flex, and flexShrink have NO effect unless the parent has display: flex.
For proper height fill in VBox:
// In constructor or build method:
this.dom_element.style.display = 'flex';
this.dom_element.style.flexDirection = 'column';
For children to fill remaining space:
// Child that should expand:
child.dom_element.style.flex = '1';
child.dom_element.style.overflow = 'hidden';
// Child that should keep fixed size:
child.dom_element.style.flexShrink = '0';
DynamicColumn & Card Grid
// Column layout for card grids
"DynamicColumn": {
"col_cwidth": 25, // character width per column
"col_cgap": 1 // gap between columns
}
// Each card MUST match the column width:
"VBox": {
"cwidth": 25, // MUST equal col_cwidth
"cheight": 16 // fixed height
}
Pitfall: If cwidth != col_cwidth, cards overflow or shrink unexpectedly.
Pitfall: width: "100%" does NOT work in DynamicColumn — use cwidth.
SearchBar Events
SearchBar dispatches search event on Enter or clear:
// Event handler receives {keyword: "search text"}
this.searchbar.bind('search', this.search_handle.bind(this));
// In handler:
search_handle(event){
var d = event.params || {};
var keyword = d.keyword || '';
}
Data Loading with HttpJson
var jc = new bricks.HttpJson();
var data = await jc.get(this.opts.data_url);
// Expects: {rows: [...]} or {data: [...], total: N}
Pitfall: Don't use bricks.jc global — create new HttpJson() instance for proper session handling.
urlwidget for Dynamic Content Areas
// Build a urlwidget that loads content from a server URL:
var desc = {
widgettype: 'urlwidget',
options: {
url: 'https://...',
params: {key: value}, // added as query params
method: 'GET'
}
};
var w = await bricks.widgetBuild(desc, this);
if (w) {
container.add_widget(w);
}
Pitfall: bricks.widgetBuild is async — must await. Calling without await in a synchronous script bind causes silent failure (widget never renders).
🔴 widgetBuild(desc, target) RETURNS the widget — it does NOT insert it into target. The second arg is only the scope for id/context resolution. After widgetBuild, you MUST target.add_widget(returned_widget) (or clear_widgets() first for a re-render). Calling bricks.widgetBuild(d, fp) alone and expecting fp to show content is the classic "panel stays empty" bug — the widget object is built (you'll see idset= logs and no JS error) but fp.dom_element.innerHTML stays 0 because nothing was attached. Correct re-render pattern:
fp.clear_widgets(); // or fp.dom_element.innerHTML = '';
var w = await bricks.widgetBuild(d, fp); // build with fp as scope
if (w) fp.add_widget(w); // ← attach it — this is the step that renders
This is exactly what ResourceBrowser.render_browser() does internally.
🔴 PITFALL: new bricks.urlwidget(...) does NOT work. urlwidget is NOT a registered Bricks class — bricks.Factory.get('urlwidget') returns null. It's handled as a special case inside widgetBuild (line 2271 in bricks.js: while (klassname == 'urlwidget') fetches and expands). Always use bricks.widgetBuild({widgettype:'urlwidget', options:{url:'...'}}, parent) — never new bricks.urlwidget(...). Using new causes TypeError: bricks.urlwidget is not a constructor, crashing the entire script bind.
Real failure (cockpit project button): new bricks.urlwidget({url:'/api/task_detail.dspy?...',method:'GET'}) in an inline script caused the whole button click handler to silently fail. Fix: removed the urlwidget line and built task cards with plain new bricks.VBox() / new bricks.Text() widgets instead.
🔴 TabPanel 动态 tab(菜单项驱动 + 去重 + 立即显示)
原生 bricks.TabPanel 的动态 tab 支持是坏的,三处要修/覆盖:add_tab() 调了不存在的
this.add_removeable(实为 toolbar.add_removable,且 createTool 内部已处理)→ TypeError;
show_tabcontent() 里 cur_tab_name = name(name 未定义);add_tab 不把 desc push 进
opts.items(导致 show_tabcontent 遍历不到动态 tab)。
去重判断用 opts.items(不是 content_buffer)——未登录时 urlwidget 请求 401、content
构建失败,content_buffer 存不进,按它判重会误判"不存在"而重复新增。
🔴 HttpJson.httpcall 遇 401 会 await 等登录框销毁(withLoginInfo 里
await new Promise(r => login_window.bind('destroy', r)))——若 open_tab 里 await widgetBuild
会卡死,tab 不切换不高亮("还需再点 tab")。修复:open_tab 同步返回(先切 cur_tab +
toolbar.click 高亮),content 交 _load_tab_content 异步加载。
🔴 Html widget 内嵌 <script> 不执行(innerHTML),且 </script> 会提前终止 HTML shell
外层 <script>(整页变原始 JSON)。JS 扩展放独立 .js 文件(wwwroot/index_tab.js),靠
get_js_files() 自动加载;根目录 .js 会被 RBAC 拦 401,需在 rp.json any 加路径。
🔴 Menu 项驱动 tab 必须用 opts.script(不能用 opts.url)——menu_clicked 对带 url 的
菜单项执行 t.clear_widgets() 清空 TabPanel 整个结构。opts.script 里 this = target(TabPanel)。
完整 open_tab 扩展代码 + 验证要点:references/tabpanel-dynamic-tabs.md。
CRUD Toolbar Binds: urlwidget+PopupWindow with native row data passthrough
When adding a tool button to a CRUD list's toolbar that calls a DSPY endpoint with the selected row's data, use actiontype: "urlwidget" with target: "PopupWindow". The row data is natively passed to the urlwidget — no params_mapping, no ${field}$ needed.
{
"wid": "self",
"event": "my_tool",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {"title": "...", "cwidth": 16, "cheight": 10},
"options": {
"url": "{{entire_url('../api/my_action.dspy')}}"
}
}
DSPY reads the row's native id field:
row_id = params_kw.get('id', '')
🔴 Debug-first rule: When a toolbar tool's DSPY receives wrong/missing params, do NOT iterate on bind configs blindly. First ask the user to paste the actual POST data from the browser Network tab (Form Data / Query String Parameters). The row data tells you exactly which fields are available and what names to use. Guessing wastes iterations — every real failure in this pattern was solved by looking at the raw POST data.
params_mapping pitfall: Only use params_mapping when the DSPY needs a DIFFERENT parameter name. The mapping target must NOT conflict with existing row field names — the row's native field takes precedence and overwrites the mapping (e.g., row has marketing_id → mapping id→marketing_id → DSPY gets row's marketing_id, not id).
See references/crud-toolbar-binds.md for the full pattern.
JSON in Script Binds
When embedding scripts in JSON strings, use single quotes inside:
{
"script": "var url='{{base}}?param=value';"
}
Pitfall: url:"..." with double quotes breaks JSON parsing because \\\" in Jinja2 renders as unescaped \" in the output.
Pitfall: location.href does full page navigation — user sees model_plaza.ui standalone, not in workspace. Use urlwidget actiontype or internal getWidgetById(app_root) pattern instead.
🔴 JSON Script Unicode Escaping: never multi-pass patch bare \uXXXX
When .ui JSON files contain Chinese text as \\uXXXX escape sequences inside
inline JS script strings, each round-trip through the patch tool adds another
layer of backslash escaping, silently corrupting the JS. Use Python
json.dumps(obj, ensure_ascii=False) for final writes. Diagnosis and full fix
in references/json-script-unicode-pitfall.md.
Widget Registration & Build
Two registration paths exist:
Path A — Monolithic (legacy, for bricks-core widgets):
# 1. Add source to build.sh SOURCES list
# 2. Rebuild
cd bricks/bricks && ./build.sh
# 3. Verify in dist
grep "MyWidget" dist/bricks.js
Path B — jsfiles() auto-discovery (recommended for app-level widgets):
Put your .js file in any wwwroot subdirectory (NOT under /bricks/). The ahserver.get_js_files() scans all website paths recursively for *.js files and excludes /bricks-prefixed paths. These are injected into header.tmpl via {% for myjs in jsfiles() %} as synchronous <script src="..."> tags, loaded BEFORE bricks App initializes — so Factory.register() runs before any widget rendering.
# Just place the file — no build step needed:
wwwroot/
pipeline-sdlc/
agent_input.js # auto-discovered, auto-loaded by jsfiles()
Key contract: widget class must call bricks.Factory.register('WidgetName', bricks.WidgetName) at file scope. No build.sh modification needed.
See references/agent-input-implementation.md for full example (AgentInput: TextFiles + UiCode model selector composite).
Theme Support
Widgets automatically inherit theme via CSS variables. Set CSS classes:
this.set_css('my-widget-root'); // class for theme-aware styling
Define in bricks.css:
.my-widget-root {
background: var(--sage-bg, #fff);
color: var(--sage-text, #333);
}
[data-theme="dark"] .my-widget-root {
background: var(--sage-bg-dark, #1e1e2e);
color: var(--sage-text-dark, #e0e0e0);
}
Configurable Param Names
When a widget passes parameters to a downstream URL, make param names configurable:
// In refresh handler:
var hp = this.opts.h_param_name || 'h_id'; // default if not set
var vp = this.opts.v_param_name || 'v_id';
var params = {};
if (selected_id != 'all') params[hp] = selected_id; // skip 'all'
Usage in JSON:
{
"widgettype": "MyWidget",
"options": {
"h_param_name": "catelogid",
"v_param_name": "providerid"
}
}
🔴 Tree Widget: idField/textField/entire_url — key parameters
Tree widget uses these options (NOT valueField, NOT parentField):
| Option | Default | Purpose |
|---|---|---|
idField |
'id' |
Node unique identifier field from data |
textField |
'text' |
Display text field from data |
is_leafField |
'is_leaf' |
Whether node is a leaf (no children) |
dataurl |
— | URL to fetch tree data (MUST use entire_url()) |
cfontsize |
— | Font size multiplier for node text |
Data format (flat list): {id, parentid, label, is_leaf, path} — use relative paths as id, store full path in separate field.
Tree event bind — the event is node_selected (NOT selected). Verified in tree.js: node_click_handle → node_selected(node, true) → this.dispatch('node_selected', d) where d = bricks.extend(node.user_data, {selected: flag}). So the handler's event.params IS the node's user_data (carries id, text, etc.), NOT a TreeNode. Bind with tree.bind('node_selected', function(event){ var id = event.params.id; ... }). To make node click select-only (never unselect on re-click of the same node), pass select_only: true in Tree options — node_click_handle only nulls selected_node when !this.opts.select_only. Older docs/examples below that call tree.bind('selected', …) refer to the same event; use node_selected.
Pitfall: Without entire_url(), dataurl is relative and Tree silently fails to load.
Pitfall: valueField does NOT exist on Tree — use idField.
Pitfall: parentField does NOT exist on Tree.
Pitfall: Left panel width: use fixed "width": "280px", not percentages (unreliable in Bricks HBox).
Pitfall: PopupWindow resizebox (2nd child, 30×30px) is only a resize handle — content lives in content_box (1st child).
🔴 Pitfall: Tree binds on subwidgets inside PopupWindow JSON are SILENTLY ignored. When a Tree (or any child widget) with a binds array is created as a subwidget of a PopupWindow via widgetBuild or DSPY JSON, the binds are NOT registered — tree.sel_handlers stays undefined. PopupWindow's add_widget calls content_w.add_widget() without processing the child's binds.
Workaround — button-script pattern (reliable): Create PopupWindow + Tree + VScrollPanel directly in a Button's inline JS script. Call tree.bind('selected', handler) after adding it to the layout. This is the proven working pattern for workspace/pipeline tree+panel UIs.
var pw = new bricks.PopupWindow({title:'工作空间', width:'85%', height:'80%', auto_open:true});
var hb = new bricks.HBox({height:'100%'}); pw.content_w.add_widget(hb);
var lb = new bricks.VBox({width:'280px'}); hb.add_widget(lb);
var tree = new bricks.Tree({dataurl:'...', textField:'label', idField:'id', id:'ws_tree'});
lb.add_widget(tree);
var fp = new bricks.VScrollPanel({css:'filler', id:'ws_files'}); hb.add_widget(fp);
tree.bind('selected', function(){
var nid = tree.selected_node.user_data.id;
fetch('...?id='+encodeURIComponent(nid)).then(r=>r.json()).then(function(d){
fp.clear(); bricks.widgetBuild(d, fp);
});
});
🔴 Pitfall: buildScriptHandler does NOT bind this to the source widget. Scripts receive params and event as function arguments. Inside a bind script, always use bricks.getWidgetById('id', bricks.app) — never this.selected_node or this.xxx.
🔴 Pitfall: event_params and params_mapping do NOT work for Tree selected events. The Tree dispatches selected with the TreeNode as event data. event_params: ["id"] tries to extract rtdata.id (TreeNode.id), but the node's id is at user_data.id. Similarly, params_mapping: {"id": "id"} maps rtdata.id → URL param, hitting the same wrong level. Neither pattern triggers a network request from a Tree selected bind. The ONLY reliable way is tree.bind('selected', fn) called from inline JS, where fn reads tree.selected_node.user_data.id directly.
🔴 Pitfall: widgetBuild of PopupWindow from DSPY JSON places subwidgets in bricks.app. When calling bricks.widgetBuild({widgettype:'PopupWindow', subwidgets:[...]}, bricks.app), the PopupWindow floats correctly but subwidgets (Tree, buttons, etc.) are built into the document body alongside the popup, not inside it. The pattern above (building subwidgets with add_widget after PopupWindow construction) avoids this.
Full workspace popup pattern (Tree + file panel): references/workspace-tree-popup-pattern.md.
Composite tree+browser widget (ResourceBrowser — tree selection drives a re-rendered browser panel, with corrected node_selected event + select_only): references/resourcebrowser-composite-pattern.md.
Tree leaf-arrow async race (Svg.set_url stale-fetch token fix), folder-icon expand/collapse bugs (open-folder.svg typo + this.icon_url + 'expand' vs 'open'), and the "inject console.log + bump ?v= to get definitive evidence" debugging discipline: references/tree-leaf-arrow-svg-race.md.
Text widget default halign='center' (set halign:'left' for any non-centered label) and bricks.js cache-busting via ?v= in header.tmpl: references/text-halign-and-cache-busting.md.
🔴 Form field pre-fill: value not defaultValue
Bricks Form fields accept a value key for initial/pre-filled values. defaultValue is silently ignored — the field stays empty.
// ❌ WRONG — field stays blank
{"name": "myfield", "uitype": "str", "defaultValue": "pre-filled text"}
// ✅ RIGHT — field shows the value
{"name": "myfield", "uitype": "str", "value": "pre-filled text"}
Source: form.js line 133 reads f.value from the field descriptor. There is no defaultValue lookup anywhere in the Form build loop.
Real failure: rename_kb_form.dspy used defaultValue on name/description fields — the popup showed empty inputs instead of the current KB name. Fix (commit e2d17b6): change to value.
uitype: check vs checkbox — Single Toggle vs Multi-Checkbox Group
⚠️ PITFALL: uitype: "checkbox" (UiCheckBox) and uitype: "check" (UiCheck) are DIFFERENT widgets with different required options. Using the wrong one causes Cannot read properties of undefined (reading 'length') at UiCheckBox.build_checkboxs().
| uitype | Class | Use case | Required | Optional |
|---|---|---|---|---|
"check" |
UiCheck |
Single boolean toggle | name, value (bool) |
label |
"checkbox" |
UiCheckBox |
Multi-checkbox group | name, data (array) |
valueField, textField, value (array), multicheck |
data array format: [{value: "...", text: "..."}, ...] — each item is one checkbox.
Single toggle (RIGHT):
{"name": "regenerate", "uitype": "check", "value": false}
Single toggle (WRONG — crashes):
{"name": "regenerate", "uitype": "checkbox"}
→ build_checkboxs() reads data.length but data is undefined (no data option provided). This is the most common form of this error — a single yes/no field mistakenly typed as "checkbox".
Multi-checkbox group (RIGHT):
{"name": "perms", "uitype": "checkbox", "data": [
{"value": "read", "text": "Read"},
{"value": "write", "text": "Write"}
], "multicheck": true}
🔴 .dspy vs .ui — template syntax is file-type specific
.ui files are processed by the bui Jinja2 template engine. .dspy files are executed as Python code. Syntax that works in one silently fails in the other:
| Syntax | .ui |
.dspy |
|---|---|---|
{{entire_url('./path')}} |
✅ Jinja evaluates | ❌ literal string → 403 |
entire_url('./path') |
❌ not evaluated | ✅ Python function |
{{params_kw.kb_id}} |
✅ Jinja variable | ❌ literal string |
params_kw.get('kb_id','') |
❌ not evaluated | ✅ Python code |
Real failure: rename_kb_form.dspy used "url": "{{entire_url('./rename_kb.dspy')}}" — the Jinja {{...}} was treated as a literal string, producing a 403 on form submit. Fix: "url": entire_url('./rename_kb.dspy') (commit 5de0a87).
Rule: .dspy files use raw Python. .ui files use Jinja2 {{...}}. Never mix the two.
🔴 USER MANDATE: prefer urlwidget/PopupWindow over inline JS scripts
User correction (explicit): "少用js好吗,这个功能应该简单到一个actiontype: urlwidget的一个事件处理,加一个confirm内容,删除前让客户确认一下,你不要总想写js来实现,bricks不喜欢"
When building confirmation flows (delete, rename, etc.), use pure urlwidget chains:
- Confirm popup → separate
.dspyform opened viaPopupWindowwithactiontype: "urlwidget" - Confirm action →
Buttonwithactiontype: "urlwidget"targetingapp.rag_main_contentwithmode: "replace"→ calls the actual handler.dspy - Cancel → minimal
scripttodismiss()+destroy()the popup (unavoidable — bricks has no built-in "close popup" action)
Anti-pattern (DO NOT USE): Inline JS confirm() → fetch() → window.location.href. This causes:
- String escaping nightmares (json.dumps → outer json.dumps re-escape → AsyncFunction SyntaxError)
\nin script strings →\\nafter outer json.dumps- Hard to debug; violates bricks' declarative design philosophy
When JS IS unavoidable (popup close, stopPropagation), keep scripts to ONE line with no server-data embedding.
🔴 Popup close-before-action: dismiss popup THEN navigate
PopUpWindows attach to document.body — replacing app.rag_main_content does NOT close them. Confirm/submit buttons MUST explicitly dismiss the popup before navigating. See references/dspy-popup-close-pattern.md for the exact script pattern, the clear_widgets() PopupWindow pitfall (destroys title bar, page becomes unresponsive), and real failure cases (rename 403, delete 500, widgettype null).
🔴 Bricks options["data-*"] → NOT rendered as DOM attribute
Pitfall: Setting data-sel (or any data-* key) in Bricks widget options does NOT translate to a DOM data-sel attribute on the rendered element. The value exists in Bricks' internal option store but is invisible to JavaScript getAttribute() / querySelector('[data-sel]').
Symptoms:
document.querySelectorAll('[data-sel]')returns empty even thoughoptions: {"data-sel": "1"}was set- Click handlers reading
el.getAttribute('data-sel')always getnullon first interaction - Pre-checked items lose their state on re-render because the DOM attribute never existed
Fix: Use visual indicators (text content, CSS class, background color) as the source of truth, not DOM data attributes set through Bricks options.
// ❌ WRONG — data-sel set in options is NOT a DOM attribute
"submit_js": "var rows=document.querySelectorAll('#panel [data-sel]');"
"click_handler": "var on=el.getAttribute('data-sel')==='1';"
// ✅ RIGHT — read state from visual indicator (checkbox text ☑/☐)
"submit_js": "var rows=document.querySelectorAll('#panel [id^=tagrow_]');"
"rows.forEach(function(r){var chk=r.querySelector('[id^=tagchk_]');"
"if(chk&&chk.textContent==='☑'){/* checked */}});"
"click_handler": "var c=el.querySelector('[id^=tagchk_]');"
"var on=c&&c.textContent==='☑';"
"if(c)c.textContent=on?'☐':'☑';"
"el.setAttribute('data-sel',on?'0':'1');"
Note: After the click handler sets data-sel via setAttribute(), subsequent reads work — but only after first interaction. The initial state from options never becomes a DOM attribute.
Real failure (2026-08-07 ragserver): Tag form popup checked rows showed ☑ but submit_js used [data-sel] selector → found zero rows → saved empty tag list. Fix: changed selector to [id^=tagrow_], read state from ☑/☐ text content.
Tag-chip multi-select filter: Dynamic clickable tag chips for search filtering with AND logic via media_tags. Full pattern + backend DSPY in references/tag-chip-filter-pattern.md.
Pitfall: Using location.reload() in a popup form's submit button script destroys the entire page state and often races the save request — the reload fires before the server commits, so the user sees the page refresh but the data never changed.
Correct pattern (from ragserver tag_form.dspy fix):
fetch()the save endpoint- Close the popup:
pw.dismiss(); pw.destroy() - AJAX-refresh the parent panel: re-fetch the panel's DSPY URL and call
bricks.widgetBuild()to rebuild in-place
// submit_js in popup form
"fetch(save_url).then(function(r){return r.json()}).then(function(d){"
// 1. close popup
"var pw=null;var w=self;while(w){if(w instanceof bricks.PopupWindow||w instanceof bricks.Popup){pw=w;break};w=w.parent};"
"if(pw){pw.dismiss();pw.destroy()};"
// 2. AJAX refresh parent panel — NOT location.reload()
"var mp=document.querySelector('#media_card_panel');"
"if(mp&&mp.bricks_widget){fetch(panel_url).then(function(r2){return r2.json()}).then(function(d2){"
"var mw=mp.bricks_widget;mw.clear_widgets();"
"bricks.widgetBuild(d2,mw).then(function(nw){if(nw)mw.add_widget(nw)})"
"}).catch(function(){})};"
"}).catch(function(){alert('网络错误')});"
Key: The parent panel must have a stable DOM id (e.g. #media_card_panel) and its bricks widget reference accessible via .bricks_widget. Then clear_widgets() + widgetBuild() + add_widget() replaces the content without touching the rest of the page.
Contrast with existing Form→urlwidget→render pattern: The "DSPY Returns Widget Descriptor with Auto-Close" pattern above uses Form's native submit + urlwidget actiontype — that's the preferred approach when the form IS a Bricks Form widget. The fetch() + panel-refresh pattern here applies when the submit is a plain Button with inline script (popup content built from DSPY-generated JSON, not a Bricks Form instance).
🔴 Card-with-buttons click pattern: event.stopPropagation()
When a card VBox has a click bind (navigate to detail) AND contains action buttons (edit, delete), button clicks bubble up and trigger the card click. Fix: add a script bind with event.stopPropagation() BEFORE the main action bind on each button.
// Card click: navigate to detail
{"wid": "self", "event": "click", "actiontype": "urlwidget", ...}
// Button: stopPropagation + popup
"binds": [
{"wid": "self", "event": "click", "actiontype": "script",
"script": "event.stopPropagation()"},
{"wid": "self", "event": "click", "actiontype": "urlwidget", ...}
]
Multiple binds on the same wid+event execute in order. The first stops propagation to parent (card), the second performs the button's action.
🔴 JS strings from server data: use json.dumps() for safe embedding
When embedding server-side strings (KB names, file names) into inline JavaScript, never concatenate with + — special characters (quotes, backslashes, newlines) break JS syntax.
# ❌ DANGEROUS — name with single quote breaks JS
"script": "if(!confirm('删除「" + namestr + "」?'))return;"
# ✅ SAFE — json.dumps escapes all special chars
## Form 取值/赋值陷阱(高频踩坑)
**`bricks.Form.getValue()` 返回 `FormData` 对象,不是普通对象!** 用 `v.field` 访问会得到 `undefined`,导致所有字段传空值(后端报"格式非法"/"缺少 xxx")。
- 正确取值:`f._getValue()` → 返回 `{name: value}` 普通对象(form.js 第 330 行,遍历 name_inputs 调每个 input 的 getValue 合并)。
- 错误取值:`f.getValue()` → 返回 `get_formdata()` = `new FormData()`(form.js 第 352/388 行),`.field` 是 undefined。
**`bricks.Form`(FormBase)没有 `setValue(整个对象)` 方法**,`setValue(name, value)` 是 `bricks.InlineForm` 才有的。给 Form 加载数据要遍历 `name_inputs`:
```javascript
// 赋值(加载数据)
var env = d.env || {};
for (var k in env) {
if (f.name_inputs && f.name_inputs[k]) f.name_inputs[k].setValue(env[k]);
}
// 取值(提交/读值)
var v = f._getValue(); // {mode: 'local', remote_host: '...', ...}
每个 input(UiType)的 getValue() 返回 {name: value},setValue(v) 设置 this.value。UiCode(code 下拉)继承 UiType,getValue 返回 {name: 选中的 value}。
"script": "if(!confirm(" + json.dumps("删除「" + namestr + "」?\n\n不可恢复!") + "))return;"
**🔥 CRITICAL: Never embed `\n` (literal newlines) in script strings.** The outer `return json.dumps(...)` serializes the script value — literal `\n` becomes `\\n`. When the browser's `new AsyncFunction(body)` receives a script body containing `\\n` (backslash-n, not a real newline), it throws `SyntaxError: Invalid or unexpected token`. This is a brick-wall error with no workaround — inline JS scripts with confirm dialogs must NOT use multi-line strings.
**Fix**: Use PopupWindow confirm pattern (see above) instead of inline `confirm()`. If inline confirm IS required, keep the message to a single line with NO `\n` characters — use spaces or dashes as separators.
`sor.R()` row attributes use `.name` accessor; assign to a Python variable first, then pass through `json.dumps()` for JS embedding.
### 🔴 UiCode standalone vs Form wrapper — NO nested Forms
**PITFALL (user correction):** Using `new bricks.Form({fields: [{uitype:'code',...}]})` inside a composite VBox widget creates nested Forms — the outer page may already have a Form context, producing invalid HTML and unpredictable behavior.
**Rule:** Use `new bricks.UiCode({...})` directly, never wrap it in `bricks.Form`. `UiCode` is a standalone `<select>` input that extends `bricks.UiType`, supports `dataurl` for remote options, and does NOT create a form element.
```javascript
// ❌ WRONG — Form inside potentially-another-Form
this.model_selector = new bricks.Form({
name: 'model_selector', cols: 1,
fields: [{name: 'model_id', uitype: 'code', dataurl: '...'}]
});
// ✅ RIGHT — standalone UiCode, no form element
this.model_selector = new bricks.UiCode({
name: 'model_id',
valueField: 'model_id',
textField: 'model_id_text',
dataurl: '...',
cwidth: 12
});
UiCode.get_value() returns the raw selected value (string), unlike Form.getValue() which returns a dict. So after switching from Form to UiCode, update any value reads from mv.model_id to mv directly — or use .get_value() for the string, getValue() for the dict (UiCode inherits both paths from UiType).
🔴 Pipeline DevOps Intent: natural language → git command extraction
When the pipeline cockpit_chat devops intent handler receives Chinese natural language like
"克隆仓库 git@... 到本地", it must extract the actual git clone command. Full pattern and regex in
references/pipeline-devops-intent.md.
Rule: one yes/no toggle → "check". List of options → "checkbox" with data array.
See references/uicheck-vs-uicheckbox.md for bricks.js source code of both widgets and the exact error call chain.
🔴 uitype:'submit' does NOT exist in Bricks Form
Bricks Form supports str, hide, tel, date, int, float, check, checkbox, email, file, image, code, text, password, audiorecorder. There is NO uitype:'submit' — using it produces no visible element and no error.
For submit triggers in inline-JS Forms: use a separate bricks.Button that calls form.submit(), or use setTimeout + DOM change listener for auto-submit (see Form Picker Popup section below).
Real failure: cockpit project picker — {uitype:'submit', label:'确定'} in Form fields produced no button. Fix (commit 0f39feb): removed it, switched to separate Button + form.submit().
API Naming: snake_case Only
Bricks widget methods use snake_case, never camelCase:
| WRONG (crashes) | RIGHT |
|---|---|
widget.setText(...) |
widget.set_text(...) |
widget.setValue(...) |
widget.set_value(...) |
widget.getValue(...) |
widget.get_value(...) |
This applies to all bricks widgets including bricks.Text, form fields, etc.
🔴 CRITICAL — Button has NO set_text() or setText(). Button stores its label as a child bricks.Text widget in this.text_w. To update a button's displayed text:
// ❌ WRONG — Button has no set_text() method
button.setText('new label');
button.set_text('new label');
// ✅ RIGHT — access the internal Text widget
button.text_w.set_text('new label');
Source: bricks.Button (line 7535) opts_setup() creates new bricks.Text({otext: this.opts.label, ...}) and stores it as this.text_w. Failed 3 times this session — setText → set_text → text_w.set_text.
UiType getValue() returns an OBJECT — use resultValue() for URL params
⚠️ PITFALL (real failure, ragserver search.ui): UiType.getValue() (bricks/input.js, base class of UiCode/UiText dropdowns & inputs) returns the form-data object (e.g. {kb_id: "41c9f1cd45e0"}), NOT the raw string. Concatenating it into a URL in a script bind produces kb_id=%5Bobject%20Object%5D — the backend queries a bogus collection and returns 0 results forever, with zero JS errors.
Rule: in script binds that build URLs or need the raw selected value, use resultValue():
// WRONG — object stringified into URL → [object Object]
"script": "...var kb_id=kb?kb.getValue()||'':'all';..."
// RIGHT — raw string value
"script": "...var kb=bricks.getWidgetById('kb_selector',this);var kb_id=(kb&&kb.resultValue())||'all';..."
For dropdowns whose data items are {value: id, text: name}, resultValue() returns the item's value (id). Diagnosis signature: server log shows kb_id='%5Bobject%20Object%5D' (URL-decoded = [object Object]).
Icon Paths: bricks_resource()
Use bricks_resource('imgs/xxx.svg') for built-in icon paths (not hardcoded URLs):
🔴 Pipeline UI: Button as Clickable Text (css:"link") + datawidget
Pattern: Use Button with css:"link" for clickable context labels that dynamically update from an API. Pair with hidden datawidget that loads JSON and uses datascript to drive Button label updates via button.text_w.set_text() (NOT setText()/set_text() — see references/button-text-w-pitfall.md).
{
"widgettype": "Button",
"id": "project_label",
"options": {"label": "项目:加载中...", "css": "link", "action": "click"},
"binds": [
{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": "var d=bricks.getWidgetById('context_data',bricks.app).getValue();..."}
]
},
{
"widgettype": "datawidget",
"id": "context_data",
"options": {
"dataurl": "/api/cockpit_context.dspy", "method": "GET",
"datascript": "(function(d){var pl=bricks.getWidgetById('project_label',bricks.app);if(pl)pl.setText(d.name?'项目:'+d.name:'项目:--');return d;})"
}
}
Key contracts: button.text_w.set_text(newLabel) updates display (Button does NOT have set_text() — use its inner text_w Text widget). datawidget.getValue() returns the JSON object. datascript runs once on page load and returns what getValue() exposes.
⚠️ PITFALL: datawidget is NOT a real Bricks widget class. It's not registered in Factory — widgetBuild('datawidget') returns null. The datascript runs via bui/Jinja2 server-side processing, not bricks widget lifecycle. datawidget.render({}) does NOT re-fetch data (unlike urlwidget.render({})). For runtime updates, manually call button.text_w.set_text(...) + datawidget.setValue(data) from the fetch callback. Also: Button has no set_text() — use button.text_w.set_text().
🔴 Pipeline UI: PopupWindow with Inline Content
var box = new bricks.VBox();
box.add_widget(new bricks.Text({text: '名称:'+name, cfontsize: 1.1, fontWeight: 'bold'}));
box.add_widget(new bricks.Text({text: 'ID:'+id, cfontsize: 0.85, color: '#64748b'}));
var pw = new bricks.PopupWindow({title: '详情', cwidth: 36, cheight: 14, auto_open: true});
pw.content_w.add_widget(box);
Pitfall: Don't use urlwidget for simple detail display — inline widgets are lighter and don't need external pages.
🔴 Form Picker Popup: auto-submit on dropdown change (no button)
User correction (explicit): "确定按钮删除,用form的submit事件" — no button, select triggers submit.
⚠️ PITFALL: binds and uitype:'submit' do NOT work in inline-JS Form constructors. Bricks Form binds array and {uitype:'submit'} field type only work in declarative .ui JSON (processed by bui/Jinja2 engine). When creating Forms programmatically in script binds, actiontype:'urlwidget' on submit events is silently ignored and uitype:'submit' produces no visible button.
Working pattern — setTimeout + DOM change listener on the <select>:
See also references/cockpit-context-bar-pattern.md for the redesigned UiCode selector + task button pattern that avoids this pitfall entirely.
Working pattern — setTimeout + DOM change listener on the <select>:
⚠️ PITFALL: Bricks PopupWindow CSS class is .popup, NOT .popup-window. document.querySelector('.popup-window select') silently returns null — the select element exists but under .popup, not .popup-window. Use the PopupWindow reference directly: pw.content_w.dom_element.querySelector('select').
var pw = new bricks.PopupWindow({title:'选择项目', cwidth:32, cheight:16, auto_open:true});
var form = new bricks.Form({
name: 'picker', cols: 1,
fields: [{name: 'select_id', uitype: 'code', label: '项目', cwidth: 26,
dataurl: '/api/picker.dspy', valueField: 'value', textField: 'text'}]
});
var vb = new bricks.VBox({padding: '12px', gap: '12px'});
vb.add_widget(new bricks.Text({text: '请选择一个项目:', cfontsize: 0.95, color: '#64748b'}));
vb.add_widget(form);
pw.content_w.add_widget(vb);
// Auto-submit on selection: wait for Bricks to render the <select>, then attach change listener
setTimeout(function() {
var sel = pw.content_w.dom_element.querySelector('select');
if (sel) sel.addEventListener('change', function() {
var v = sel.value;
if (!v) return;
fetch('/api/save.dspy', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'id=' + encodeURIComponent(v)
}).then(function(r) { return r.json(); }).then(function(r) {
if (r.success) {
pw.destroy();
// Update button labels directly — datawidget.render() does not re-fetch
var pl = bricks.getWidgetById('project_label', bricks.app);
if (pl && pl.text_w) pl.text_w.set_text('📁 当前项目:' + r.project_name);
// Also update datawidget cache
var cd = bricks.getWidgetById('context_data', bricks.app);
if (cd) cd.setValue(r);
}
});
});
}, 500);
Key elements:
setTimeout(fn, 500)— delays until Bricks UI has rendered the<select>into DOM; 500ms is a safe windowpw.content_w.dom_element.querySelector('select')— finds the select inside the popup using the PopupWindow reference (NOT.popup-windowclass — that doesn't exist)addEventListener('change', ...)— fires when user picks from dropdown; no button neededpw.destroy()— closes popup via the closure variable (captured in script scope)- Button label update via
pl.text_w.set_text(...)— Button's inner Text child;datawidget.render()does NOT re-fetch data\n6.datawidget.setValue(r)— updates cached data sogetValue()returns fresh data next time\n7. Response is plain JSON{"success": true, ...}— NOT a widget descriptor
Why setTimeout is needed: new bricks.Form(...) starts an async build process for the code dropdown (fetching dataurl options). The <select> element won't exist in the DOM synchronously after the constructor call. setTimeout gives Bricks' internal rendering a chance to complete.
Contrast with declarative .ui: In .ui JSON files, binds and actiontype:'urlwidget' on Form submit DO work because the bui/Jinja2 engine processes them before the JS runtime. The pitfalls only apply to inline JS new bricks.Form(...) calls inside script binds.
🔴 DSPY save endpoint: return plain JSON, NOT widget descriptor
When a picker popup calls a save DSPY via fetch() (see Form Picker Popup section above), the DSPY should return plain JSON {"success": true, ...}. The fetch().then() callback in the inline JS handles popup close + context refresh — the DSPY does NOT need to return a widget descriptor.
DSPY pattern (cockpit_context_update.dspy):
return json.dumps({
"success": True,
"project_id": new_pid,
"project_name": pname,
# ... other fields as needed
}, ensure_ascii=False)
Why NOT widget descriptor: The inline JS setTimeout + addEventListener('change') pattern uses pw.destroy() via closure — the popup reference pw is already captured in the script scope. DOM traversal via CSS class (like .popup) is fragile and unnecessary when the JS already has a direct reference to the popup.
🔴 AgentInput 表单提交:必须用 FormData (multipart)
症状:file_paths=[],文件从未到达后端。
根因:.ui 脚本用 application/x-www-form-urlencoded 发送 fetch —— 浏览器 File 对象无法嵌入 URL-encoded 字符串。
修复:使用 FormData + 不设 Content-Type header(浏览器自动 multipart):
var fd = new FormData();
fd.append('message_text', p);
fd.append('model_id', mid);
fd.append('iteration_id', iid);
fd.append('project_id', pid);
files.forEach(function(f){ fd.append('file_paths', f, f.name); });
fetch(url, {method:'POST', body: fd}).then(...)
ahserver 自动接收 multipart,文件存到 {workdir}/files/,路径写入 params_kw['file_paths']。完整说明:references/agentinput-formdata-multipart.md。
症状:AgentInput 提交后 URL 中 model_id=[object Object],后台收到 '[object Object]' 而非实际模型 ID。
根因:bricks.UiCode.getValue() 可能返回对象 {model_id: "xxx"} 而非裸字符串。encodeURIComponent({...}) → [object Object]。
修复(双层):
AgentInput 内部 (agent_input.js):
var mv = this.model_selector.getValue();
var mid = '';
if (typeof mv === 'object' && mv !== null) {
mid = mv.model_id || mv.value || '';
} else {
mid = mv || '';
}
.ui 脚本第二层兜底:
var mid = params.model_id || '';
if (typeof mid === 'object' && mid !== null) mid = mid.model_id || '';
🔴 AgentInput 文件序列化:File 对象 → JSON → [{}]
症状:file_paths=[{}] 或 [null]。
根因:params.add_files 是浏览器 File 对象数组,JSON.stringify([fileObj]) → [{}]。
修复:提取 .name 再序列化:
var fnames = files.map(function(f){return (f&&f.name)||''}).filter(Boolean);
JSON.stringify(fnames)
🔴 iteration_id 读取:context_data 代替不存在的 widget
症状:请求中 iteration_id= 始终为空。
根因:脚本读取不存在的 current_iteration_id widget → null → ''。
修复:从已有的 context_data datawidget 读取:
var ctx = bricks.getWidgetById('context_data', bricks.app);
var cd = ctx ? ctx.getValue() : {};
var iid = cd.iteration_id || '';
var pid = cd.project_id || ''; // 同时传 project_id
🔴 AgentInput 提交后文件列表 UI 残留
症状:提交后文件标签仍显示。
修复:input_finished() 末尾显式清理:
this.add_files = [];
this.filesbar.clear_widgets();
this.filesbar.hide();
**场景**: 对话式 UI,TextFiles 输入区内嵌模型选择下拉框,整合为一个统一的 AgentInput 控件——不需要单独一行。
**结构** (JSON .ui):
```json
{
"widgettype": "VBox",
"id": "agent_input",
"options": {"bgcolor": "#fff", "border": "1px solid #e2e8f0",
"borderTop": "none", "borderRadius": "0 0 8px 8px", "padding": "0"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {"padding": "6px 14px 2px 14px", "gap": "6px", "alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "\ud83e\udd16", "cfontsize": 0.8}},
{"widgettype": "Form", "id": "input_model_selector", "options": {
"name": "input_model_selector", "cols": 1,
"fields": [{"name": "model_id", "uitype": "code", "label": "",
"placeholder": "选择模型", "cwidth": 12,
"dataurl": "/api/cockpit_model_options.dspy",
"valueField": "model_id", "textField": "model_id_text"}]}},
{"widgettype": "Filler"},
{"widgettype": "Button", "options": {"label": "配置", "css": "small"}, "binds": [...]}
]
},
{
"widgettype": "TextFiles", "id": "chat_input",
"options": {"bgcolor": "transparent", "padding": "6px 14px 10px 14px"},
"binds": [...] }
]
}
脚本中读取所选模型 (TextFiles inputed bind):
var ms = bricks.getWidgetById('input_model_selector', bricks.app);
if (ms) { var v = ms.getValue(); mid = (v || {}).model_id || ''; }
Pitfall: Form.getValue() 返回 dict {model_id: "xxx"},不是裸字符串。取值用 .model_id。
对比原反模式: 模型选择放页面顶部独立一行 → 和输入区分开降低可用性。AgentInput 把 UiCode 嵌在 TextFiles 上方薄行内,视觉上是一个整体控件。
Icon Paths: bricks_resource() — original, see above
// Built-in icons
bricks_resource('imgs/llm.svg') // LLM/agent icon
bricks_resource('imgs/chat-user.svg') // user avatar
bricks_resource('imgs/agent.svg') // agent avatar
bricks_resource('imgs/input.svg') // input/edit icon
bricks_resource('imgs/add.svg') // add/plus icon
Firefox/Chrome Compatibility
- Use
dom_element.style.flex = '1'notset_style('flexGrow', '1')— inline style works cross-browser - Use
dom_element.style.overflow = 'hidden'for scroll containers
getWidgetById: Always Pass from_widget
⚠️ PITFALL — new bricks.js API (no default fallback): The current bricks.getWidgetById(idset, from_widget) at line 2680 does NOT default from_widget to bricks.Body (unlike the old getWidgetByIdOld). Calling with only an ID crashes at line 2691: fromw.dom_element where fromw is undefined.
Rule: every script that calls getWidgetById MUST pass a second argument.
Search strategy:
- Global search:
bricks.getWidgetById('xxx', bricks.app)— finds anywhere in the tree - Subtree:
bricks.getWidgetById('xxx', containerWidget)— finds children of container - Self: target
"self"→thisIS the widget you want; access directly, don't usegetWidgetById
Sibling access is tricky — downward=true only finds children, closest (- prefix) only finds ancestors. To find a sibling, search from a common ancestor (usually bricks.app).
Fix pattern in .ui script binds:
// WRONG — crashes with "Cannot read properties of undefined (reading 'dom_element')"
"script": "var w=bricks.getWidgetById('chat_scroll');if(w)w.render({});"
// RIGHT — explicit from_widget
"script": "var w=bricks.getWidgetById('chat_scroll',bricks.app);if(w)w.render({});"
真实失败案例(ragserver search.ui,静默兜底最危险): bind script 里 bricks.getWidgetById('kb_selector', this) 想取兄弟控件(kb_selector 与 this=search_bar 同在 HBox 内)——querySelector 只搜后代 → 返回 null → 代码兜底 kb?kb.getValue()||'':'all' 静默把 kb_id='all' 发给后端 → VDB 没有叫 'all' 的 collection → 永远"共 0 条结果",全程无任何 JS 报错。特征:后端用正确参数 curl 有结果,用户页面永远空。确诊法:后端 dspy 顶部加 info(params_kw) 日志,用户复现一次,日志里 kb_id 是兜底值即确诊。修前端用 bricks.getWidgetById('kb_selector', bricks.app) 从共同祖先找。
VScrollPanel has no .render() — use add_widget() for dynamic content
Scroll areas: filler chain, NEVER fixed height (user rule)
User correction (explicit): result/content scroll areas must NOT use fixed heights — no "height":"65vh", no cheight, no px. Rule: "下面的控件用 filler,在 filler 中放 VScrollPanel". Using a fixed height got rejected in review; the filler chain is the only accepted pattern.
Why it works: VScrollPanel constructor FORCES width/height='100%' + overflow:'auto' — a height option on the panel itself is pointless; the PARENT supplies height. CSS .filler { flex:1; overflow:hidden } makes children expand to fill remaining flex space.
Known-good structure:
root VBox "height":"100%", "css":"filler" ← viewport height source
├ header/input controls (natural height)
└ VBox "css":"filler" ← fills rest, NO height key
└ VScrollPanel id=results, "css":"filler" ← scrolls, NO height key
- Height chain must be unbroken to a viewport-height ancestor (app root
height:100%→ filler HBox → filler content area e.g.app.rag_main_content). Any ancestor without height collapses the whole chain — nothing scrolls. - A .ui loaded via urlwidget into a filler parent needs
"height":"100%","css":"filler"on its root so the chain continues. - Production precedent: ragserver
detail.uifile_list_panel.
Deployed .ui verification gotcha: server wraps .ui in a bricks HTML shell — json.loads(curl_output) fails. Extract first: re.search(r'"widget"\s*:\s*(\{.*?\})\s*\n?\s*\};', html, re.S). Detail + framework source notes: bricks-framework skill references/vscrollpanel-filler-layout.md.
VScrollPanel has no .render() — use add_widget() for dynamic content
⚠️ PITFALL: VScrollPanel does NOT have a .render() method. Only urlwidget supports
.render({}) for re-fetching content from its URL endpoint. Calling .render() on a
VScrollPanel crashes: TypeError: c.render is not a function.
For chat-like UIs (TextFiles input → append bubbles → scroll), use add_widget() on the
VScrollPanel to dynamically append message bubbles. The canonical reference implementation is
~/repos/bricks/bricks/agent.js — bricks.AgentIO class, where:
msg_box = VScrollPanel— scrollable message containershow_input(params)→msg_box.add_widget(userBubble)— instant user messagemodel.set_inputed(params)→msg_box.add_widget(AgentOutput)→ POST → stream updates
For .ui JSON script binds (no class context), replicate inline:
// Append user bubble to chat_scroll VScrollPanel
var chat = bricks.getWidgetById('chat_scroll', bricks.app);
var ub = new bricks.HBox({width:'100%'});
var um = new bricks.VBox({width:'85%', alignSelf:'flex-end', bgcolor:'#dbeafe',
borderRadius:'12px', padding:'12px 16px', marginBottom:'10px', gap:'4px'});
um.add_widget(new bricks.Text({text:'你', cfontsize:0.75, color:'#2563eb', fontWeight:'bold'}));
um.add_widget(new bricks.Text({text:prompt, cfontsize:0.95, color:'#1e293b', whiteSpace:'pre-wrap'}));
ub.add_widget(new bricks.VBox({css:'filler'}));
ub.add_widget(um);
ub.add_widget(new bricks.Svg({rate:2, url:bricks_resource('imgs/chat-user.svg')}));
chat.add_widget(ub);