From c69acaf5355f9a2a4c191aa87e596a537d135d45 Mon Sep 17 00:00:00 2001 From: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:13:04 +0300 Subject: [PATCH] Keep a hidden widget's slot when the shown cards are reordered A reorder wrote only the cards on screen as the whole widget_order, and the server replaces that list, so a widget whose skill was off lost its place and came back at the end, against the DESIGN "Widgets board" promise. mergeWidgetOrder (web/modules/widget_reorder.js) now applies the reorder of the shown cards onto the full stored order: every slot holding a shown key takes the next shown key, keys not on screen keep their slots, and shown keys the stored order lacks take new slots at its end (stored [A, H, B], shown [B, A] -> [B, H, A]). "Not on screen" is the only signal; no catalogue call. A card the stored order does not hold also kept the server's listing order (sorted by key), so a widget appearing mid-session landed before the cards already shown when its key sorted first. The list sync now passes the window's shown sequence to sortTabsByWidgetOrder: unknown keys keep their shown place and a new key joins the end. DESIGN states both, and the honest edge: the order is stored when the owner moves a card, so after a reload widgets added since the last move follow the stored ones alphabetically. widgets.js stays at its cap (1599 here). Review round 1 findings 2 and 3 (scope MAJOR/MINOR, Grok and Fable MINOR, Astra MINOR). --- docs/DESIGN.md | 9 ++++--- tests/test_widgets_ui_static.py | 7 ++++++ web/modules/widget_reorder.js | 31 ++++++++++++++++++++--- web/modules/widgets.js | 13 +++++----- web/tests/widget_reorder.test.js | 43 +++++++++++++++++++++++++++++++- 5 files changed, 89 insertions(+), 14 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f0c8f8f1a..7da372848 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1195,9 +1195,12 @@ widget's content belongs to its author. the order the owner set with a card's move handle; a card that does not fit the rest of a row starts the next one. Nothing is measured to place a card, so a card whose content grows only makes its own row taller and never sends - a neighbour to another column. A new widget joins the end of the order. A - widget whose skill is off is not drawn; its place in the order and its width - stay stored for its return, and nothing is reserved meanwhile. + a neighbour to another column. A new widget joins the end of the order; the + order is stored when the owner moves a card, so after a window reload the + widgets added since the last move follow the stored ones alphabetically. A + widget whose skill is off is not drawn; its place in the order (moving the + other cards keeps its slot) and its width stay stored for its return, and + nothing is reserved meanwhile. - **Gaps belong to the owner.** A short card beside a tall one leaves space under it (cards align to the top of their row), and widths that do not fill a row leave the rest of it empty. The owner closes a gap by choosing widths; diff --git a/tests/test_widgets_ui_static.py b/tests/test_widgets_ui_static.py index 0c5c93d5a..70eaf7677 100644 --- a/tests/test_widgets_ui_static.py +++ b/tests/test_widgets_ui_static.py @@ -600,6 +600,13 @@ def test_widgets_card_order_is_owner_ui_preference(): assert "event.key === 'ArrowUp'" in reorder assert "apiClient.uiPreferences()" in source assert "apiClient.saveUiPreferences({ widget_order: normalized })" in source + # A reorder of the shown cards is merged into the stored order (a card that + # is off keeps its slot); a card the order lacks keeps its shown place and a + # new one joins the end (docs/DESIGN.md "Widgets board"). + assert "export function mergeWidgetOrder(stored, shown)" in reorder + assert "const normalized = mergeWidgetOrder(uiPreferences.widget_order, order);" in source + assert "export function sortTabsByWidgetOrder(tabs, order, shown = [])" in reorder + assert "uiPreferences.widget_order, currentWidgetOrder()," in source assert "export function moveWidgetKey(order, key, toIndex)" in reorder assert "export function bindWidgetCardReorder(list, currentOrder, onOrderChange)" in reorder assert "bindWidgetCardReorder(list, currentWidgetOrder, persistWidgetOrder);" in source diff --git a/web/modules/widget_reorder.js b/web/modules/widget_reorder.js index 96b0dcc91..18f14af8d 100644 --- a/web/modules/widget_reorder.js +++ b/web/modules/widget_reorder.js @@ -1,5 +1,6 @@ /* Widgets card arrangement by the owner: the `widget_order` preference applied - to the card list, the pure key-order move behind a reorder, the drag / + to the card list, the pure key-order move behind a reorder and its merge + into the stored order (a card not on screen keeps its slot), the drag / keyboard reorder handles, and the card widths (`createWidgetWidths`: the card menu, the edge handle and its keys over `ui_preferences.widget_size`). Nothing here moves an
: the board (web/modules/widget_grid.js) @@ -28,8 +29,15 @@ export function normalizeWidgetOrder(value) { }); } -export function sortTabsByWidgetOrder(tabs, order) { - const rank = new Map(normalizeWidgetOrder(order).map((key, idx) => [key, idx])); +/** + * The cards in the owner's order. A card the order does not hold yet keeps the + * place this window last showed it in (`shown`), and a card new to both joins + * the end, so a widget that appears never lands at its listing place (the + * server lists by key) ahead of the cards already on screen. + */ +export function sortTabsByWidgetOrder(tabs, order, shown = []) { + const known = normalizeWidgetOrder([...normalizeWidgetOrder(order), ...normalizeWidgetOrder(shown)]); + const rank = new Map(known.map((key, idx) => [key, idx])); return tabs.map((tab, originalIndex) => ({ tab, originalIndex })).sort((a, b) => { const aRank = rank.has(widgetKey(a.tab)) ? rank.get(widgetKey(a.tab)) : Number.MAX_SAFE_INTEGER; const bRank = rank.has(widgetKey(b.tab)) ? rank.get(widgetKey(b.tab)) : Number.MAX_SAFE_INTEGER; @@ -38,6 +46,23 @@ export function sortTabsByWidgetOrder(tabs, order) { }).map((item) => item.tab); } +/** + * The stored order after the owner rearranged the shown cards into `shown`: + * every slot that holds a shown key takes the next key of `shown`, so a key + * not on screen (its skill is off) keeps its slot for its return; shown keys + * the stored order lacks take new slots at its end. Stored `[A, H, B]` with + * `[B, A]` shown becomes `[B, H, A]`. "Not on screen" is the only signal. + */ +export function mergeWidgetOrder(stored, shown) { + const visible = normalizeWidgetOrder(shown); + const onScreen = new Set(visible); + const kept = normalizeWidgetOrder(stored); + const keptKeys = new Set(kept); + let next = 0; + return [...kept, ...visible.filter((key) => !keptKeys.has(key))] + .map((key) => (onScreen.has(key) ? visible[next++] : key)); +} + /** * Pure key-order move: `key` leaves its slot and re-enters at `toIndex` * (clamped to the list). A drop onto another card passes that card's index, diff --git a/web/modules/widgets.js b/web/modules/widgets.js index 530f193e2..c6f509bea 100644 --- a/web/modules/widgets.js +++ b/web/modules/widgets.js @@ -25,7 +25,7 @@ import { WIDGET_START_MODES, withWidgetStartMode, } from './widget_card.js'; -import { bindWidgetCardReorder, createWidgetWidths, normalizeWidgetOrder, sortTabsByWidgetOrder } from './widget_reorder.js'; +import { bindWidgetCardReorder, createWidgetWidths, mergeWidgetOrder, normalizeWidgetOrder, sortTabsByWidgetOrder } from './widget_reorder.js'; import { apiClient, apiFetch, @@ -1388,7 +1388,7 @@ export function initWidgets(ctx = {}) { } const tabs = sortTabsByWidgetOrder( Array.isArray(data.ui_tabs) ? data.ui_tabs : [], - uiPreferences.widget_order, + uiPreferences.widget_order, currentWidgetOrder(), ); const signature = widgetTabsSignature(tabs); if (hasCards() && tabs.length) { @@ -1458,14 +1458,13 @@ export function initWidgets(ctx = {}) { } } - // A reorder (handle drag / keys) hands over the next key order: remember it, + // A reorder (handle drag / keys) hands over the next order of the shown cards: + // merge it into the stored order (a card not on screen keeps its slot), // re-sort the last good list, relayout in place, persist. No node moves. function persistWidgetOrder(order) { - const normalized = normalizeWidgetOrder(order); + const normalized = mergeWidgetOrder(uiPreferences.widget_order, order); uiPreferences = { ...uiPreferences, widget_order: normalized }; - if (lastTabs) { - lastTabs = sortTabsByWidgetOrder(lastTabs, normalized); - } + if (lastTabs) lastTabs = sortTabsByWidgetOrder(lastTabs, normalized); relayout(); apiClient.saveUiPreferences({ widget_order: normalized }).catch((err) => { console.warn('Failed to save widget order', err); diff --git a/web/tests/widget_reorder.test.js b/web/tests/widget_reorder.test.js index 523a7387a..bd97c0fe4 100644 --- a/web/tests/widget_reorder.test.js +++ b/web/tests/widget_reorder.test.js @@ -2,7 +2,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { createWidgetWidths, moveWidgetKey, normalizeWidgetOrder, sortTabsByWidgetOrder } from '../modules/widget_reorder.js'; +import { + createWidgetWidths, mergeWidgetOrder, moveWidgetKey, normalizeWidgetOrder, sortTabsByWidgetOrder, +} from '../modules/widget_reorder.js'; // Widgets lifecycle phase 3: a reorder is a pure move in the KEY order; the // handles never move an
(a moved