Skip to content

fix: sync workspace chip immediately on chat switch + searchable sorted dropdown#1464

Closed
JKJameson wants to merge 1 commit intonesquena:masterfrom
JKJameson:fix/chat-switch-workspace
Closed

fix: sync workspace chip immediately on chat switch + searchable sorted dropdown#1464
JKJameson wants to merge 1 commit intonesquena:masterfrom
JKJameson:fix/chat-switch-workspace

Conversation

@JKJameson
Copy link
Copy Markdown
Contributor

Fixes the workspace display lagging behind after switching chats, and adds alphabetical sort + search to the workspace dropdown.

  • sessions.js: Call syncTopbar() immediately after S.session = data.session in loadSession() so the workspace chip label reflects the correct session workspace before async message-loading begins
  • panels.js: Rewrite renderWorkspaceDropdownInto() to sort workspaces alphabetically by name (localeCompare) and add a search input that filters by name or path in real-time; clear button resets filter; "No workspaces found" shown when nothing matches
  • style.css: Add .ws-search-row/.ws-search-input/.ws-search-clear/.ws-no-results styles matching the model dropdown aesthetic
  • api/workspace.py: Sort load_workspaces() output by name before returning so the backend enforces consistent alphabetical order

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Thanks for the PR. The frontend pieces (search input, immediate syncTopbar() after switching) are good ideas. But there's a serious problem with the backend sort that I think needs to block merge:

load_workspaces() alphabetical sort breaks manual drag-to-reorder (#492)

The repo already has a workspace reorder feature — /api/workspaces/reorder (api/routes.py:4644, _handle_workspace_reorder) lets users drag-and-drop their workspaces into a custom order, and that order is persisted to the workspaces.json file. Tests for it live in tests/test_issue492_workspace_reorder.py.

This PR's change to load_workspaces():

return sorted(cleaned, key=lambda w: w.get('name', '').lower()) or [...]

…re-sorts the list alphabetically on every load. Effect: as soon as the user reorders by dragging, their custom order survives until the next page load, then gets clobbered back to alphabetical. The reorder UI silently does nothing user-visible.

The existing test_reorder_changes_order test won't catch this because it mocks load_workspaces() directly, bypassing the sort path.

I think the right call is one of:

  1. Sort only in the dropdown rendering (renderWorkspaceDropdownInto in panels.js), not in load_workspaces(). The PR already does sort there too — [...workspaces].sort(...) on line ~1660 of the diff. Just drop the backend change entirely. The /api/workspaces endpoint would keep returning the user's explicit order, so the management panel (where drag-to-reorder lives) keeps working, while the chip dropdown shows alphabetical for find-ability.

  2. Add a "sort mode" preference ("manual" vs "alphabetical") and respect it in both the dropdown and the management panel. More work, probably overkill for this PR.

I'd suggest option (1) — strip the two sorted(...) calls from api/workspace.py, keep the frontend sort in the dropdown, and the rest of the PR is fine.

Other notes:

  • Missing i18n keyst('ws_search_placeholder') and t('ws_no_results') aren't defined anywhere in static/i18n.js. The || fallbacks in your code (t('ws_search_placeholder')||'Search workspaces…') cover the runtime, but the placeholder/no-results strings will be in English in every locale (es, de, zh, zh-Hant, ru). Worth adding the keys to all six locales — there's a precedent right above your code: model_search_placeholder (line 624 of static/ui.js) is wired into all locales. The existing tests/test_issue492_workspace_reorder.py::test_i18n_keys_present_in_all_locales is the kind of check that would have caught this; consider extending it (or adding a parallel test) for the new keys.

  • CSS / structural concern — the dropdown previously had a single flat list; now it's searchRow + listContainer (with its own overflow-y: auto; max-height: 260px) + divider + footer actions. Two scrollables in a single dropdown can confuse mobile users, and inline style.overflowY / style.maxHeight should probably go in style.css next to .ws-search-row for theme consistency. Non-blocking polish but a class would be cleaner.

  • syncTopbar() immediate call in loadSession — this is fine and matches how the model is handled. ✓

  • The renderList() / filterWs() closure pattern is fine, but noResults is referenced before assignment in filterWs(term) if filterWs is called before renderList() runs. In the actual flow it isn't — renderList() runs before the input event ever fires — but the code reads as if it could be. A nit.

Once the backend sort is removed and the i18n keys are added, this is good to merge. The dropdown UX win is real, just don't let it overwrite #492's reorder.

@JKJameson
Copy link
Copy Markdown
Contributor Author

Alphabetical order with search is an upgrade UX in my opinion, especially if you have hundreds of workspaces.

@JKJameson JKJameson force-pushed the fix/chat-switch-workspace branch from 90ce451 to 6a61ce8 Compare May 2, 2026 11:20
@JKJameson
Copy link
Copy Markdown
Contributor Author

Pushed. Changes...

  • api/workspace.py | Reverted the sorted() calls — load_workspaces() now preserves user-defined order, drag-to-reorder (feat(workspace): drag-to-reorder workspaces in the workspace list #492) is safe
  • static/i18n.js | Added ws_search_placeholder + ws_no_results to all 9 locales
  • static/panels.js | Replaced listContainer.style.* inline styles with className='ws-list-container' + moved noResults creation before filterWs to eliminate the ref-before-set nit
  • static/style.css | Added .ws-list-container class

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Good follow-through, the revert + i18n additions + class refactor address the three blocking points cleanly. On the alphabetical-vs-manual question: dropping the backend sort and keeping it as a frontend dropdown convenience is the right call — drag-to-reorder (#492) keeps working, and 100+-workspace users get find-ability via the new search input regardless of order. Best of both worlds without a sort-mode toggle.

Will let Nathan take final look on merge.

@nesquena-hermes nesquena-hermes added the ux User experience / visual polish label May 2, 2026
@nesquena-hermes
Copy link
Copy Markdown
Collaborator

UX review needed — need before/after screenshots

Tagging ux on this PR. The workspace chip + dropdown is a top-bar UI surface visible at all viewport widths, and the changes (immediate sync, alphabetical sort, in-dropdown search) affect both desktop and mobile layouts.

Before merge we need:

1. Before/after screenshots in the PR body, on both:

  • Desktop (1280px+ width)
  • Mobile (390px width — iPhone reference)

2. Surfaces to capture:

  • Workspace chip in the top bar — closed state, immediately after a chat switch (this is the bug the PR fixes; the screenshot should show the new sync behavior)
  • Workspace dropdown open — showing the alphabetical sort
  • Workspace dropdown with search box — typing a query, filtered list visible
  • Mobile (390px): chip and dropdown both, since dropdown positioning at narrow widths is the most likely break point

3. UX maintainer review: Once screenshots land, our UX reviewer will look at them and confirm the dropdown behavior at the responsive breakpoints.

Code looks reasonable on read-through; this is purely a visual-verification gate. Should be quick once images are up.

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

cc @aronprins — flagging this for UX review once before/after screenshots are posted. Top-bar workspace chip + dropdown changes affecting both desktop and 390px mobile, see screenshot list above.

@aronprins
Copy link
Copy Markdown
Contributor

Awaiting screenshots.

@nesquena nesquena added the hold label May 2, 2026
@JKJameson
Copy link
Copy Markdown
Contributor Author

image

…ace dropdown

- sessions.js: call syncTopbar() immediately after S.session assignment
  in loadSession() so workspace chip label reflects the correct session
  workspace before async message-loading begins

- panels.js: rewrite renderWorkspaceDropdownInto() to sort workspaces
  alphabetically by name (localeCompare) and add a search input that
  filters by name or path in real-time; clear button resets filter;
  'No workspaces found' shown when search matches nothing;
  pre-create noResults element before filterWs to avoid ref-before-set

- style.css: add .ws-search-row/.ws-search-input/.ws-search-clear/
  .ws-list-container/.ws-no-results styles matching the model dropdown;
  list scrolling now uses a .ws-list-container class instead of inline
  style for theme consistency

- i18n.js: add ws_search_placeholder and ws_no_results keys to all nine
  locales (en, ja, ru, es, de, zh, zh-Hant, pt-BR, ko)

NOTE: backend load_workspaces() alphabetical sort was reverted after
review -- it clobbers the drag-to-reorder feature (nesquena#492). Frontend
dropdown sort is preserved.
@JKJameson JKJameson force-pushed the fix/chat-switch-workspace branch from 6a61ce8 to e04b2d2 Compare May 3, 2026 01:28
@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Code-quality review (assuming UX approval clears) ❌ blocker found

Thanks for posting the screenshot, @JKJameson. Stepping through the diff line-by-line + comparing to what the screenshot shows, I found one inverted boolean that's currently shipping the bug live in your screenshot, plus a couple of smaller polish items. Detail below.

🔴 Blocker — noResults display ternary is inverted

static/panels.js:133:

noResults.style.display=visible?'':'none';

Reads as: "if visible items exist, show the noResults message; else hide it." That's exactly backwards — noResults should appear when the filter yields zero matches.

The screenshot you posted is showing this bug live: the search term "exa" has matched two valid workspaces (example1, example2) and the "No workspaces found" empty-state is also rendered below them simultaneously. (At a guess that's why you marked the screenshot for review — but worth being explicit: this is a code defect, not a screenshot framing issue.)

Fix is one character:

-    noResults.style.display=visible?'':'none';
+    noResults.style.display=visible?'none':'';

After the fix:

  • visible > 0 (has matches) → display='none' (hide noResults) ✅
  • visible === 0 (no matches) → display='' (show noResults) ✅

The line above it for .ws-opt items is already correct (opt.style.display=show?'':'none';) — the asymmetry probably caused the brain-bug.

A regression test asserting the visibility relationship would be welcome — at the source level something like:

def test_no_results_shows_only_when_zero_matches():
    src = (REPO / "static" / "panels.js").read_text()
    # Locate filterWs() body
    fn = src[src.index("function filterWs"):]
    fn = fn[:fn.index("\n  }\n") + 4]
    # The noResults visibility line must hide on visible>0
    assert "noResults.style.display=visible?'none':''" in fn, (
        "noResults must hide when matches exist, show when none"
    )

(or asserting both ws-opt + noResults ternaries are mirror images of each other).

Other observations (non-blocking)

1. searchRow.innerHTML=... injects t('ws_search_placeholder') after esc(...) — fine here, but worth a comment

static/panels.js:104:

searchRow.innerHTML=`<input class="ws-search-input" ... placeholder="${esc(t('ws_search_placeholder')||'Search workspaces…')}" ...><button ...>${li('x',10)}</button>`;

esc() is the right call given your i18n keys live in your own static dict so this is purely defensive — but if any of the ws_search_placeholder translations ever come from user-supplied input in the future, the existing esc keeps you safe. ✅ no action needed, just noting that the pattern is correct.

2. renderList() is called once and then never again — could be inlined

static/panels.js:136-148:

function renderList(){
  listContainer.innerHTML='';
  for(const w of sorted){...}
  listContainer.appendChild(noResults);
}
renderList();
filterWs('');

renderList() is only invoked once, immediately after declaration. The function abstraction adds nothing here. Inlining would be fine; leaving as-is is also fine (might come in handy if a future "add workspace" path needs to re-render). Non-blocking either way.

3. listContainer.querySelectorAll('.ws-opt') runs on every keystroke

static/panels.js:125:

For 100+ workspaces (your stated motivation for adding search), this re-queries the DOM on every keystroke. Tiny performance bite, and dataset.name / dataset.path lookups dominate the inner loop anyway. Could be cached:

const opts = listContainer.querySelectorAll('.ws-opt');  // cache once

function filterWs(term){
  // ... use cached opts
}

But: you're already O(N) per keystroke regardless, querying a freshly-rendered DOM, and N for workspaces is small. Not worth blocking. Flagging in case search ever expands to thousands of workspaces.

4. si.addEventListener('input',()=>{ filterWs(si.value); }); — no debounce

For workspace counts under ~500, instantaneous filter-on-input is the right choice — no debounce needed. Just confirming I noticed the absence and it's intentional/correct given the data scale. ✅

5. CSS — .ws-list-container { max-height: 260px; overflow-y: auto; }

Good call. With 50+ workspaces and a search box pinned at the top, this is necessary so the dropdown doesn't grow past the viewport.

6. i18n coverage — all 9 locales ship both new keys ✅

Verified all 9 locale blocks (en, ja, ru, es, de, zh, zh-Hant, pt, ko) ship both ws_search_placeholder and ws_no_results with native translations (not English fallback). Good i18n hygiene.

7. sessions.js:374 immediate syncTopbar()

The if(typeof syncTopbar==='function') syncTopbar(); guard is the correct shape for an early-load race. Mirrors how the model chip is handled. Fixes the chip-stale-on-chat-switch behavior cleanly.

Cross-tool / security check

  • ✅ Webui-only change. No config.yaml, no CLI surface, no agent-side touch points.
  • ✅ No XSS surface — esc() is applied in all the right places (ws-opt-name, ws-opt-path, ws_search_placeholder).
  • dataset.name / dataset.path are read with .toLowerCase() for matching but never re-emitted to the DOM, so no traversal back through user input.
  • ✅ No new endpoints; no auth changes.

Recommendation

Once noResults ternary is flipped, this PR is good to go from a code-quality standpoint — and you've already addressed the previous review's blocker (the load_workspaces alphabetical sort that would have broken drag-to-reorder #492). The other 6 observations above are polish, all non-blocking.

So the merge-queue gate is:

  • Flip ternary on static/panels.js:133 (one character)
  • Optional: regression test pinning the visibility relationship
  • @aronprins UX approval on the screenshot (Nathan: pending your screenshot OK)

Once Nathan signs off on the visual + you push the ternary fix, I'd be comfortable removing hold and routing this to the next batch.

sunnysktsang pushed a commit to sunnysktsang/hermes-webui that referenced this pull request May 3, 2026
…sorbed

CHANGELOG, ROADMAP, TESTING bumped (3936 \u2192 3946).

8 constituent PRs:
- nesquena#1523 (@franksong2702) branch indicator codepoint fix
- nesquena#1519 (@franksong2702) onboarding API-key focus loss fix
- nesquena#1518 (@franksong2702) voice-mode toggle-off recognizer stop
- nesquena#1516 (@franksong2702) YAML newline CSS rules
- nesquena#1517 (@franksong2702) __CACHE_VERSION__ \u2192 __WEBUI_VERSION__ rename
- nesquena#1532 (@ai-ag2026) state.db WebUI session recovery
- nesquena#1525 (@ai-ag2026) stale stream state proactive cleanup
- nesquena#1526 (@ai-ag2026) max_tokens forwarding + OpenRouter quota classifier

Opus MUST-FIX absorbed: sw.js conflict-marker cleanup + regression guard.
Opus SHOULD-FIX deferred to follow-up nesquena#1533 (race in _clear_stale_stream_state).

2 closed as duplicates: nesquena#1528 (identical to nesquena#1517), nesquena#1529 (superseded by nesquena#1516).
1 maintainer-review label: nesquena#1531 (Asunfly stowaway change in force-push).
5 stay on hold: nesquena#1418 nesquena#1464 nesquena#1404 nesquena#1353 nesquena#1311.
nesquena-hermes pushed a commit that referenced this pull request May 4, 2026
… — 4094→4111 tests

- #1586 (Michaelyklam): login asset SW cache exemption
- #1590 (Michaelyklam): hot-apply compact tool activity setting
- #1591 (Michaelyklam): first-turn sidebar visibility (optimistic upserts)
- #1592 (Michaelyklam): turn duration display (Done in 1m 12s) + Opus follow-up (truthy-check on _pending_started_at)
- #1464 (JKJameson, maintainer-augmented): workspace dropdown sort+search+chip-sync (rebased + ternary fix + regression test)

Maintainer-side test fixes in stage:
- tests/test_465_session_branching.py: widen compact() search window 1500→3000
- tests/test_regressions.py: anchor on api('/api/chat/start' instead of comment line

Browser API sanity: 11/11 passed. Live UX verification: vision-confirmed dropdown sort+search+empty-state on test server. Opus advisor: SHIP AS-IS.
@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Shipped in v0.50.290 via release PR #1593 (merged commit 4559163).

Thanks @JKJameson! The workspace dropdown design — search filter + alphabetical sort + immediate chip sync on chat switch — landed clean with one maintainer-side adjustment (the noResults ternary inversion) plus a regression test to lock the visibility relationship as mirror-image opt/noResults ternaries.

A few notes from the merge process:

  • Rebased onto current master before staging (REBASE-DEFAULT rule). Your branch was 124 commits behind v0.50.275 — rebase was clean, no conflicts.
  • Ternary flip on static/panels.js:1683 (visible?'':'none'visible?'none':''). The bug was visible in your own PR-thread screenshot from May 03 — "exa" matched example1 + example2 AND rendered "No workspaces found" alongside the results. The mirror-image of opt.style.display=show?'':'none' is noResults.style.display=visible?'none':''. Co-authored-by: Josh Jameson preserved on the augmentation commit.
  • Regression test at tests/test_issue1464_workspace_dropdown_filter.py pins both ternaries plus a defense-in-depth assertion that they must be mirror images of each other. Future edits can't silently re-invert either one.
  • Live UX verification on a test server with 10 workspaces (alpha/beta/delta/epsilon/eta/gamma/theta/zeta + Home + workspace): dropdown opens with search input, alphabetical sort, "alp" filters down to single alpha, "zzznomatch" shows clean empty-state. Vision-confirmed.
  • Mobile (390px) verification couldn't be captured this session — the CDP origin policy blocked our viewport-emulation injection. Desktop UX gate cleared; mobile responsive verification deferred to @aronprins's hands-on review per our two-stage UX policy.

GitHub didn't auto-close this PR because the rebase changed the merge commit's parent — state=CLOSED, mergedAt=null is the gotcha shape we hit on rebased PRs. Closing manually now; your fix is fully shipped at 4559163.

Genuinely great primary work on this — the search input was the right shape (no debounce needed at the workspace count scale, dataset-attribute-based filtering is fast enough, class-based CSS instead of inline styles, full 9-locale i18n parity). The dropdown's a real upgrade for users with 50+ workspaces. Thanks for the persistence through the review cycle.

pull Bot pushed a commit to JamesWilliam1977/hermes-webui that referenced this pull request May 4, 2026
…c by @JKJameson (maintainer-augmented: ternary fix + regression test)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hold ux User experience / visual polish

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants