57 KiB
Raw Blame History

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:

  1. Source file in bricks/bricks/<widget_name>.js
  2. Register via bricks.Factory.register('WidgetName', bricks.WidgetName)
  3. Add to build.sh SOURCES list
  4. Rebuild: cd bricks/bricks && ./build.sh
  5. 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_rowwidgetBuild). 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.Buttondom_elementbricks_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 + Conform message (Conform reads opts.message).
  • conform.conform / conform.discard → override the two IconTextBar tool labels (defaults are i18n 'Conform'/'Discard').
  • The whole conform object is passed verbatim as the Conform widget's options (widgetBuild({widgettype:'Conform', options:desc.conform})), then conform_widget.bind('conformed', handler).
  • Works with script, urlwidget, and any other actiontype — 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}] where file is the native File object.
  • accepts: ['*'] accepts everything ('*'.replace('*','.*')'.*' matches any MIME).
  • In the script, read the file via FileReader.readAsDataURL(f) to get a base64 data: 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 = namename 未定义);add_tab 不把 desc push 进 opts.items(导致 show_tabcontent 遍历不到动态 tab

去重判断用 opts.items(不是 content_buffer——未登录时 urlwidget 请求 401、content 构建失败,content_buffer 存不进,按它判重会误判"不存在"而重复新增。

🔴 HttpJson.httpcall 遇 401 会 await 等登录框销毁withLoginInfoawait new Promise(r => login_window.bind('destroy', r)))——若 open_tabawait 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.scriptthis = 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_handlenode_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:

  1. Confirm popup → separate .dspy form opened via PopupWindow with actiontype: "urlwidget"
  2. Confirm actionButton with actiontype: "urlwidget" targeting app.rag_main_content with mode: "replace" → calls the actual handler .dspy
  3. Cancel → minimal script to dismiss()+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)
  • \n in script strings → \\n after 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 though options: {"data-sel": "1"} was set
  • Click handlers reading el.getAttribute('data-sel') always get null on 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):

  1. fetch() the save endpoint
  2. Close the popup: pw.dismiss(); pw.destroy()
  3. 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: '...', ...}

每个 inputUiTypegetValue() 返回 {name: value}setValue(v) 设置 this.value。UiCodecode 下拉)继承 UiTypegetValue 返回 {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 — setTextset_texttext_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):

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 FactorywidgetBuild('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:

  1. setTimeout(fn, 500) — delays until Bricks UI has rendered the <select> into DOM; 500ms is a safe window
  2. pw.content_w.dom_element.querySelector('select') — finds the select inside the popup using the PopupWindow reference (NOT .popup-window class — that doesn't exist)
  3. addEventListener('change', ...) — fires when user picks from dropdown; no button needed
  4. pw.destroy() — closes popup via the closure variable (captured in script scope)
  5. 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 so getValue() 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();

**场景**: 对话式 UITextFiles 输入区内嵌模型选择下拉框整合为一个统一的 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' not set_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"this IS the widget you want; access directly, don't use getWidgetById

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
  1. 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.
  2. A .ui loaded via urlwidget into a filler parent needs "height":"100%","css":"filler" on its root so the chain continues.
  3. Production precedent: ragserver detail.ui file_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.jsbricks.AgentIO class, where:

  • msg_box = VScrollPanel — scrollable message container
  • show_input(params)msg_box.add_widget(userBubble) — instant user message
  • model.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);