ui: mobile navigation drawer, theme variant refinements & skills fix (#45107) thanks @BunsDev

## Summary

- Mobile navigation drawer with slide-over behavior at ≤1100px
- Topnav & sidebar shell restructure with brand eyebrow
- Chat model selection picker with optimistic caching + rollback
- Nav breakpoint gap fix (769–1100px toggle visibility)
- Skills page autofill pollution fix (autocomplete=off)
- Delete confirm popover positioning (left/right by role)
- Effective collapsed state propagation to nav items in drawer mode
- Duplicate CSS selector consolidation
- Session key race condition fixes in async model patching
- 2 new test files + expanded test coverage (23 tests)

Co-authored-by: Nova <nova@openclaw.ai>
This commit is contained in:
Val Alexander 2026-03-13 09:44:05 -05:00 committed by GitHub
parent 72b6a11a83
commit ca414735b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1473 additions and 580 deletions

7
.gitignore vendored
View File

@ -129,6 +129,7 @@ docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
.gitignore
test/config-form.analyze.telegram.test.ts
ui/src/ui/theme-variants.browser.test.ts
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
ui/src/ui/__screenshots__
ui/src/ui/views/__screenshots__
ui/.vitest-attachments
docs/superpowers

View File

@ -401,7 +401,6 @@ img.chat-avatar {
.chat-delete-confirm {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
background: var(--card, #1a1a1a);
border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
border-radius: var(--radius-md, 8px);
@ -412,6 +411,14 @@ img.chat-avatar {
animation: scale-in 0.15s ease-out;
}
.chat-delete-confirm--left {
right: 0;
}
.chat-delete-confirm--right {
left: 0;
}
.chat-delete-confirm__text {
margin: 0 0 8px;
font-size: 13px;

View File

@ -670,6 +670,18 @@
max-width: 300px;
}
.chat-controls__session-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chat-controls__model {
min-width: 170px;
max-width: 320px;
}
.chat-controls__thinking {
display: flex;
align-items: center;
@ -760,6 +772,10 @@
text-overflow: ellipsis;
}
.chat-controls__model select {
max-width: 320px;
}
.chat-controls__thinking {
display: flex;
align-items: center;
@ -812,6 +828,10 @@
.chat-controls__session {
min-width: 120px;
}
.chat-controls__model {
min-width: 150px;
}
}
/* Chat loading skeleton */

View File

@ -5,8 +5,8 @@
.shell {
--shell-pad: 16px;
--shell-gap: 16px;
--shell-nav-width: 220px;
--shell-nav-rail-width: 72px;
--shell-nav-width: 288px;
--shell-nav-rail-width: 78px;
--shell-topbar-height: 52px;
--shell-focus-duration: 200ms;
--shell-focus-ease: var(--ease-out);
@ -15,7 +15,7 @@
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
grid-template-rows: var(--shell-topbar-height) 1fr;
grid-template-areas:
"topbar topbar"
"nav topbar"
"nav content";
gap: 0;
animation: dashboard-enter 0.3s var(--ease-out);
@ -50,6 +50,7 @@
}
.shell--onboarding {
grid-template-columns: 0 minmax(0, 1fr);
grid-template-rows: 0 1fr;
}
@ -57,6 +58,10 @@
display: none;
}
.shell--onboarding .shell-nav {
display: none;
}
.shell--onboarding .content {
padding-top: 0;
}
@ -79,21 +84,42 @@
top: 0;
z-index: 40;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 0 20px;
height: var(--shell-topbar-height);
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--bg) 85%, transparent);
padding: 0 24px;
min-height: 58px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(12px) saturate(1.6);
-webkit-backdrop-filter: blur(12px) saturate(1.6);
}
.topbar-left {
.topnav-shell {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
min-height: var(--shell-topbar-height);
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.topbar-nav-toggle {
display: none;
}
.topnav-shell__actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.topnav-shell__content {
min-width: 0;
flex: 1;
}
.topbar .nav-collapse-toggle {
@ -112,49 +138,36 @@
height: 20px;
}
/* Brand */
.brand {
.topnav-shell .dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.topnav-shell .dashboard-header__breadcrumb {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
font-size: 13px;
}
.brand-logo {
width: 26px;
height: 26px;
flex-shrink: 0;
}
.brand-logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-text {
display: flex;
flex-direction: column;
gap: 0;
}
.brand-title {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
color: var(--text-strong);
}
.brand-sub {
font-size: 9px;
font-weight: 500;
.topnav-shell .dashboard-header__breadcrumb-link,
.topnav-shell .dashboard-header__breadcrumb-sep {
color: var(--muted);
letter-spacing: 0.06em;
text-transform: uppercase;
line-height: 1;
}
/* Topbar status */
.topnav-shell .dashboard-header__breadcrumb-current {
color: var(--text-strong);
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-status {
display: flex;
align-items: center;
@ -188,15 +201,15 @@
font-size: 13px;
}
/* Topbar search trigger */
.topbar-search {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 7px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
min-height: 38px;
padding: 0 14px;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--bg-elevated) 84%, transparent);
color: var(--muted);
font-size: 13px;
cursor: pointer;
@ -204,12 +217,12 @@
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
min-width: 180px;
min-width: 200px;
}
.topbar-search:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
border-color: color-mix(in srgb, var(--border-strong) 90%, transparent);
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
color: var(--text);
}
@ -242,9 +255,9 @@
align-items: center;
gap: 2px;
padding: 3px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--bg-elevated) 70%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--bg-elevated) 78%, transparent);
}
.topbar-theme-mode__btn {
@ -292,19 +305,22 @@
}
/* ===========================================
Navigation Sidebar (shadcn-inspired)
Navigation Sidebar
=========================================== */
/* Sidebar wrapper occupies the "nav" grid area */
.shell-nav {
grid-area: nav;
display: flex;
min-height: 0;
min-height: 100%;
overflow: hidden;
border-right: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
transition: width var(--shell-focus-duration) var(--shell-focus-ease);
}
/* The sidebar panel itself */
.shell-nav-backdrop {
display: none;
}
.sidebar {
display: flex;
flex-direction: column;
@ -312,67 +328,103 @@
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--bg);
background: color-mix(in srgb, var(--bg) 96%, var(--bg-elevated) 4%);
}
:root[data-theme-mode="light"] .sidebar {
background: var(--panel);
background: color-mix(in srgb, var(--panel) 98%, white 2%);
}
.sidebar-shell {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
padding: 14px 14px 12px;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
/* Collapsed: icon-only rail */
.sidebar--collapsed {
width: var(--shell-nav-rail-width);
min-width: var(--shell-nav-rail-width);
flex: 0 0 var(--shell-nav-rail-width);
border-right: 1px solid color-mix(in srgb, var(--border-strong) 72%, transparent);
}
/* Header: brand + collapse toggle */
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 14px 6px;
.sidebar-shell__header,
.sidebar-shell__footer {
flex-shrink: 0;
}
.sidebar--collapsed .sidebar-header {
justify-content: center;
padding: 12px 10px 6px;
.sidebar-shell__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 0;
padding: 0 8px 18px;
}
.sidebar-shell__body {
min-height: 0;
flex: 1;
display: flex;
}
.sidebar-shell__footer {
padding: 12px 8px 0;
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
}
/* Brand lockup */
.sidebar-brand {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
min-width: 0;
}
.sidebar-brand__logo {
width: 22px;
height: 22px;
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 6px;
border-radius: 10px;
box-shadow: 0 8px 18px color-mix(in srgb, black 12%, transparent);
}
.sidebar-brand__copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sidebar-brand__eyebrow {
font-size: 10px;
line-height: 1.1;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--muted);
text-transform: uppercase;
}
.sidebar-brand__title {
font-size: 14px;
font-size: 15px;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.025em;
letter-spacing: -0.03em;
color: var(--text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Scrollable nav body */
.sidebar-nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 8px;
padding: 0;
scrollbar-width: none;
}
@ -380,177 +432,31 @@
display: none;
}
.sidebar--collapsed .sidebar-nav {
padding: 4px 8px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* Collapsed sidebar: centre icons, hide text */
.sidebar--collapsed .nav-group__label {
display: none;
}
.sidebar--collapsed .nav-group {
gap: 4px;
margin-bottom: 0;
}
/* In collapsed sidebar, always show nav items (icon-only) regardless of group collapse state */
.sidebar--collapsed .nav-group--collapsed .nav-group__items {
display: grid;
}
.sidebar--collapsed .nav-item {
justify-content: center;
width: 44px;
height: 42px;
padding: 0;
margin: 0 auto;
border-radius: 16px;
}
.sidebar--collapsed .nav-item__icon {
width: 18px;
height: 18px;
opacity: 0.78;
}
.sidebar--collapsed .nav-item__icon svg {
width: 18px;
height: 18px;
}
.sidebar--collapsed .nav-item__text {
display: none;
}
.sidebar--collapsed .nav-item__external-icon {
display: none;
}
/* Footer: docs link + version */
.sidebar-footer {
flex-shrink: 0;
padding: 8px;
border-top: 1px solid var(--border);
}
.sidebar--collapsed .sidebar-footer {
padding: 12px 8px 10px;
}
.sidebar-footer__docs-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.sidebar--collapsed .sidebar-footer__docs-block {
align-items: center;
gap: 10px;
}
.sidebar--collapsed .sidebar-footer .nav-item {
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
}
.sidebar-version {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
}
.sidebar-version__text {
font-size: 11px;
color: var(--muted);
font-weight: 500;
letter-spacing: 0.02em;
}
.sidebar-version__dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--accent) 78%, white 22%);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent);
opacity: 1;
margin: 0 auto;
}
/* Drag-to-resize handle */
.sidebar-resizer {
width: 3px;
cursor: col-resize;
flex-shrink: 0;
background: transparent;
transition: background var(--duration-fast) ease;
position: relative;
}
.sidebar-resizer::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: transparent;
transition: background var(--duration-fast) ease;
}
.sidebar-resizer:hover::after {
background: var(--accent);
opacity: 0.35;
}
.sidebar-resizer:active::after {
background: var(--accent);
opacity: 0.6;
}
/* Shell-level collapsed / focus overrides */
.shell--nav-collapsed .shell-nav {
width: var(--shell-nav-rail-width);
min-width: var(--shell-nav-rail-width);
}
.shell--chat-focus .shell-nav {
width: 0;
min-width: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
}
/* Nav collapse toggle */
.nav-collapse-toggle {
width: 28px;
height: 28px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
border: 1px solid color-mix(in srgb, var(--border-strong) 68%, transparent);
border-radius: 999px;
cursor: pointer;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease,
color var(--duration-fast) ease;
color var(--duration-fast) ease,
transform var(--duration-fast) ease;
margin-bottom: 0;
color: var(--muted);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent);
}
.nav-collapse-toggle:hover {
background: var(--bg-hover);
background: color-mix(in srgb, var(--bg-hover) 90%, transparent);
border-color: color-mix(in srgb, var(--border-strong) 88%, transparent);
color: var(--text);
transform: translateY(-1px);
}
.nav-collapse-toggle__icon {
@ -572,81 +478,65 @@
stroke-linejoin: round;
}
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
color: inherit;
}
/* Nav groups */
.nav-group {
margin-bottom: 12px;
.nav-section {
display: grid;
gap: 1px;
gap: 6px;
margin-bottom: 16px;
}
.nav-group:last-child {
.nav-section:last-child {
margin-bottom: 0;
}
.nav-group__items {
.nav-section__items {
display: grid;
gap: 1px;
gap: 4px;
}
.nav-group--collapsed .nav-group__items {
.nav-section--collapsed .nav-section__items {
display: none;
}
.nav-group__label {
.nav-section__label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 5px 10px;
font-size: 10px;
font-weight: 600;
color: var(--muted);
margin-bottom: 2px;
padding: 0 12px;
min-height: 28px;
background: transparent;
border: none;
border-radius: 10px;
color: var(--muted);
cursor: pointer;
text-align: left;
text-transform: uppercase;
letter-spacing: 0.06em;
border-radius: var(--radius-sm);
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.nav-group__label:hover {
.nav-section__label:hover {
color: var(--text);
background: var(--bg-hover);
background: color-mix(in srgb, var(--bg-hover) 72%, transparent);
}
.nav-group__label--static {
cursor: default;
.nav-section__label-text {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.nav-group__label--static:hover {
color: var(--muted);
background: transparent;
}
.nav-group__label-text {
flex: 1;
}
.nav-group__chevron {
.nav-section__chevron {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
opacity: 0.5;
transition: transform var(--duration-fast) ease;
}
.nav-group__chevron svg {
.nav-section__chevron svg {
width: 12px;
height: 12px;
stroke: currentColor;
@ -656,19 +546,19 @@
stroke-linejoin: round;
}
.nav-group--collapsed .nav-group__chevron {
.nav-section--collapsed .nav-section__chevron {
transform: rotate(-90deg);
}
/* Nav items */
.nav-item {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 7px 10px;
border-radius: var(--radius-md);
gap: 10px;
min-height: 38px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
@ -677,23 +567,26 @@
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
color var(--duration-fast) ease,
transform var(--duration-fast) ease;
}
.nav-item__icon {
width: 15px;
height: 15px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.6;
transition: opacity var(--duration-fast) ease;
opacity: 0.72;
transition:
opacity var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.nav-item__icon svg {
width: 15px;
height: 15px;
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
@ -703,25 +596,29 @@
.nav-item__text {
font-size: 13px;
font-weight: 450;
font-weight: 550;
white-space: nowrap;
}
.nav-item:hover {
color: var(--text);
background: var(--bg-hover);
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
border-color: color-mix(in srgb, var(--border) 72%, transparent);
text-decoration: none;
}
.nav-item:hover .nav-item__icon {
opacity: 0.9;
opacity: 1;
}
.nav-item.active,
.nav-item--active {
color: var(--text-strong);
background: var(--accent-subtle);
border-color: color-mix(in srgb, var(--accent) 15%, transparent);
background: color-mix(in srgb, var(--accent-subtle) 88%, var(--bg-elevated) 12%);
border-color: color-mix(in srgb, var(--accent) 18%, transparent);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 10%, transparent),
0 12px 24px color-mix(in srgb, black 10%, transparent);
}
.nav-item.active .nav-item__icon,
@ -730,40 +627,171 @@
color: var(--accent);
}
.sidebar--collapsed .sidebar-shell {
padding: 12px 8px 10px;
}
.sidebar--collapsed .sidebar-shell__header {
justify-content: center;
align-items: center;
gap: 0;
padding: 0 2px 16px;
}
.sidebar--collapsed .sidebar-nav {
padding: 0;
}
.sidebar--collapsed .nav-section {
gap: 6px;
margin-bottom: 16px;
}
.sidebar--collapsed .nav-item {
justify-content: center;
width: 44px;
min-height: 44px;
padding: 0;
margin: 0 auto;
border-radius: 16px;
border-color: transparent;
box-shadow: none;
}
.sidebar--collapsed .nav-item__icon {
width: 18px;
height: 18px;
}
.sidebar--collapsed .nav-item__icon svg {
width: 18px;
height: 18px;
}
.sidebar--collapsed .nav-item__text,
.sidebar--collapsed .nav-item__external-icon {
display: none;
}
.sidebar--collapsed .nav-item--active::before,
.sidebar--collapsed .nav-item.active::before {
content: "";
position: absolute;
left: 6px;
top: 11px;
bottom: 11px;
width: 2px;
left: 8px;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 78%, transparent);
background: color-mix(in srgb, #2de3d1 86%, transparent);
box-shadow: 0 0 14px color-mix(in srgb, #2de3d1 34%, transparent);
}
.sidebar--collapsed .nav-item.active,
.sidebar--collapsed .nav-item--active {
background: color-mix(in srgb, var(--accent-subtle) 88%, var(--bg-elevated) 12%);
border-color: color-mix(in srgb, var(--accent) 12%, var(--border) 88%);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent);
background: linear-gradient(
180deg,
color-mix(in srgb, #0b2f34 84%, var(--bg-elevated) 16%) 0%,
color-mix(in srgb, #081f25 90%, var(--bg) 10%) 100%
);
border-color: color-mix(in srgb, #1ed2c2 18%, var(--border) 82%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 8%, transparent),
0 10px 20px color-mix(in srgb, black 18%, transparent);
}
.sidebar--collapsed .nav-collapse-toggle {
width: 44px;
height: 34px;
margin-bottom: 0;
border-color: color-mix(in srgb, var(--border-strong) 74%, transparent);
border-radius: var(--radius-full);
width: 42px;
height: 42px;
border-color: color-mix(in srgb, var(--border) 82%, transparent);
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent),
inset 0 1px 0 color-mix(in srgb, white 8%, transparent),
0 8px 18px color-mix(in srgb, black 16%, transparent);
}
.sidebar--collapsed .nav-collapse-toggle:hover {
border-color: color-mix(in srgb, var(--border-strong) 72%, transparent);
background: color-mix(in srgb, var(--bg-elevated) 96%, transparent);
.sidebar--collapsed .sidebar-brand__logo {
width: 34px;
height: 34px;
border-radius: 12px;
box-shadow:
0 10px 20px color-mix(in srgb, black 20%, transparent),
inset 0 1px 0 color-mix(in srgb, white 10%, transparent);
}
.sidebar-utility-group {
display: grid;
gap: 8px;
}
.sidebar-utility-link {
min-height: 42px;
}
.sidebar-version {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 40px;
padding: 0 12px;
border-radius: 14px;
background: color-mix(in srgb, var(--bg-elevated) 72%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
}
.sidebar-version__label {
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sidebar-version__text {
font-size: 12px;
color: var(--text);
font-weight: 600;
}
.sidebar-version__dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--accent) 78%, white 22%);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent);
opacity: 1;
margin: 0 auto;
}
.sidebar--collapsed .sidebar-shell__footer {
padding: 8px 0 2px;
}
.sidebar--collapsed .sidebar-utility-group {
justify-items: center;
gap: 6px;
}
.sidebar--collapsed .sidebar-version {
width: 44px;
min-height: 44px;
padding: 0;
justify-content: center;
border-radius: 16px;
}
.shell--nav-collapsed .shell-nav {
width: var(--shell-nav-rail-width);
min-width: var(--shell-nav-rail-width);
}
.shell--chat-focus .shell-nav {
width: 0;
min-width: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
border-right-width: 0;
}
.nav-item__external-icon {
@ -955,12 +983,6 @@
"content";
}
.nav-group {
grid-auto-flow: column;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
margin-bottom: 0;
}
.grid-cols-2,
.grid-cols-3 {
grid-template-columns: 1fr;

View File

@ -2,61 +2,131 @@
Mobile Layout
=========================================== */
/* Tablet and smaller: collapse the left nav into a horizontal rail. */
/* Tablet and smaller: switch the left nav to a slide-over drawer. */
@media (max-width: 1100px) {
.shell,
.shell--nav-collapsed {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--shell-topbar-height) auto minmax(0, 1fr);
grid-template-rows: var(--shell-topbar-height) minmax(0, 1fr);
grid-template-areas:
"topbar"
"nav"
"content";
}
.shell--chat-focus {
grid-template-rows: var(--shell-topbar-height) 0 minmax(0, 1fr);
grid-template-rows: var(--shell-topbar-height) minmax(0, 1fr);
}
.shell-nav,
.shell--nav-collapsed .shell-nav {
width: auto;
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 70;
width: min(86vw, 320px);
min-width: 0;
border-bottom: 1px solid var(--border);
border-right: none;
box-shadow: 0 30px 80px color-mix(in srgb, black 40%, transparent);
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
transition:
transform var(--shell-focus-duration) var(--shell-focus-ease),
opacity var(--shell-focus-duration) var(--shell-focus-ease);
}
.shell--nav-collapsed:not(.shell--nav-drawer-open) .shell-nav {
width: var(--shell-nav-rail-width);
transform: translateX(0);
opacity: 1;
pointer-events: auto;
box-shadow: none;
}
.shell--nav-drawer-open .shell-nav,
.shell--nav-collapsed.shell--nav-drawer-open .shell-nav {
transform: translateX(0);
opacity: 1;
pointer-events: auto;
}
.shell-nav-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 65;
border: 0;
background: color-mix(in srgb, black 52%, transparent);
opacity: 0;
pointer-events: none;
transition: opacity var(--shell-focus-duration) var(--shell-focus-ease);
}
.shell--nav-drawer-open .shell-nav-backdrop {
opacity: 1;
pointer-events: auto;
}
/* Show the hamburger toggle at the same breakpoint where the drawer takes over. */
.topbar-nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--bg-elevated) 80%, transparent);
color: var(--muted);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent);
}
.sidebar,
.sidebar--collapsed {
width: auto;
width: 100%;
min-width: 0;
flex: 1 1 auto;
flex-direction: row;
align-items: center;
flex-direction: column;
align-items: stretch;
border-right: none;
}
.sidebar-header,
.sidebar--collapsed .sidebar-header {
justify-content: flex-start;
padding: 8px 10px;
flex: 0 0 auto;
.sidebar-shell,
.sidebar--collapsed .sidebar-shell {
padding: 18px 16px 14px;
border-radius: 0;
}
.sidebar-brand {
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar-shell,
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .sidebar-shell {
padding: 12px 8px 10px;
}
.sidebar-shell__header {
min-height: 0;
padding: 0 4px 16px;
}
.sidebar-shell__header .nav-collapse-toggle {
display: none;
}
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar-shell__header {
justify-content: center;
align-items: center;
gap: 0;
padding: 0 2px 16px;
}
.sidebar-nav,
.sidebar--collapsed .sidebar-nav {
flex: 1 1 auto;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 8px;
padding: 8px 10px 8px 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
display: block;
padding: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
}
@ -65,29 +135,36 @@
display: none;
}
.nav-group,
.nav-group__items,
.sidebar--collapsed .nav-group,
.sidebar--collapsed .nav-group__items {
display: contents;
.nav-section,
.sidebar--collapsed .nav-section {
display: grid;
margin-bottom: 16px;
}
.nav-group {
margin-bottom: 0;
}
.sidebar-nav .nav-group__label {
display: none;
.sidebar-nav .nav-section__label,
.sidebar--collapsed .nav-section__label {
display: flex;
}
.nav-item,
.sidebar--collapsed .nav-item {
margin: 0;
padding: 8px 14px;
min-height: 40px;
padding: 0 12px;
font-size: 13px;
border-radius: var(--radius-md);
border-radius: 12px;
white-space: nowrap;
flex: 0 0 auto;
width: auto;
}
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item {
justify-content: center;
width: 44px;
min-height: 44px;
padding: 0;
margin: 0 auto;
border-radius: 16px;
}
.sidebar--collapsed .nav-item--active::before,
@ -95,14 +172,53 @@
content: none;
}
.sidebar-footer,
.sidebar--collapsed .sidebar-footer {
.sidebar--collapsed .nav-item__text,
.sidebar--collapsed .nav-item__external-icon {
display: inline-flex;
}
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item__text,
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item__external-icon {
display: none;
}
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item--active::before,
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item.active::before {
content: "";
position: absolute;
left: 8px;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 999px;
background: color-mix(in srgb, #2de3d1 86%, transparent);
box-shadow: 0 0 14px color-mix(in srgb, #2de3d1 34%, transparent);
}
.sidebar--collapsed .sidebar-shell__footer {
padding: 12px 8px 0;
}
.sidebar--collapsed .sidebar-version {
width: auto;
min-height: 40px;
padding: 0 12px;
}
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .sidebar-shell__footer {
padding: 8px 0 2px;
}
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .sidebar-version {
width: 44px;
min-height: 44px;
padding: 0;
justify-content: center;
}
}
/* Mobile-specific styles */
@media (max-width: 600px) {
@media (max-width: 768px) {
.shell {
--shell-pad: 8px;
--shell-gap: 8px;
@ -111,24 +227,40 @@
/* Topbar */
.topbar {
padding: 10px 12px;
gap: 8px;
flex-direction: row;
min-height: auto;
}
.topnav-shell {
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.brand {
flex: 1;
.topnav-shell__actions {
min-width: 0;
flex: 1 1 auto;
justify-content: space-between;
gap: 10px;
align-items: stretch;
}
.brand-title {
font-size: 14px;
.topnav-shell__content {
order: 3;
width: 100%;
}
.brand-sub {
display: none;
.topbar-nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--bg-elevated) 80%, transparent);
color: var(--muted);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent);
}
.topbar-status {
@ -137,6 +269,15 @@
flex-wrap: nowrap;
}
.topbar-search {
min-width: 0;
flex: 1;
}
.topbar-theme-mode {
flex-shrink: 0;
}
.topbar-status .pill {
padding: 4px 8px;
font-size: 11px;
@ -151,25 +292,23 @@
display: none;
}
.shell-nav {
border-bottom-width: 0;
.shell-nav,
.shell--nav-collapsed .shell-nav {
width: min(92vw, 320px);
}
.sidebar-header {
padding: 6px 8px;
.shell--nav-collapsed:not(.shell--nav-drawer-open) .shell-nav {
width: 78px;
}
.sidebar-nav {
gap: 6px;
padding: 6px 8px 6px 0;
.sidebar-shell,
.sidebar--collapsed .sidebar-shell {
padding: 16px 14px 12px;
}
.nav-item {
padding: 6px 10px;
.nav-item,
.sidebar--collapsed .nav-item {
font-size: 12px;
border-radius: var(--radius-md);
white-space: nowrap;
flex-shrink: 0;
}
/* Content */
@ -177,6 +316,19 @@
display: none;
}
.content--chat .content-header {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.content--chat .content-header > div:first-child,
.content--chat .page-meta,
.content--chat .chat-controls {
width: 100%;
}
.content {
padding: 4px 4px 16px;
gap: 12px;

View File

@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import { refreshChatAvatar, type ChatHost } from "./app-chat.ts";
import { handleSendChat, refreshChatAvatar, type ChatHost } from "./app-chat.ts";
function makeHost(overrides?: Partial<ChatHost>): ChatHost {
return {
@ -19,7 +19,11 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
basePath: "",
hello: null,
chatAvatarUrl: null,
chatModelOverrides: {},
chatModelsLoading: false,
chatModelCatalog: [],
refreshSessionsAfterChat: new Set<string>(),
updateComplete: Promise.resolve(),
...overrides,
};
}
@ -63,3 +67,55 @@ describe("refreshChatAvatar", () => {
expect(host.chatAvatarUrl).toBeNull();
});
});
describe("handleSendChat", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps slash-command model changes in sync with the chat header cache", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
}) as unknown as typeof fetch,
);
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "sessions.patch") {
return { ok: true, key: "main" };
}
if (method === "chat.history") {
return { messages: [], thinkingLevel: null };
}
if (method === "sessions.list") {
return {
ts: 0,
path: "",
count: 0,
defaults: { model: "gpt-5", contextTokens: null },
sessions: [],
};
}
if (method === "models.list") {
return {
models: [{ id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }],
};
}
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
sessionKey: "main",
chatMessage: "/model gpt-5-mini",
});
await handleSendChat(host);
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: "gpt-5-mini",
});
expect(host.chatModelOverrides.main).toBe("gpt-5-mini");
});
});

View File

@ -6,9 +6,11 @@ import type { OpenClawApp } from "./app.ts";
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
import { parseSlashCommand } from "./chat/slash-commands.ts";
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts";
import { loadModels } from "./controllers/models.ts";
import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import { normalizeBasePath } from "./navigation.ts";
import type { ModelCatalogEntry } from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts";
@ -27,6 +29,10 @@ export type ChatHost = {
basePath: string;
hello: GatewayHelloOk | null;
chatAvatarUrl: string | null;
chatModelOverrides: Record<string, string | null>;
chatModelsLoading: boolean;
chatModelCatalog: ModelCatalogEntry[];
updateComplete?: Promise<unknown>;
refreshSessionsAfterChat: Set<string>;
/** Callback for slash-command side effects that need app-level access. */
onSlashAction?: (action: string) => void;
@ -295,12 +301,20 @@ async function dispatchSlashCommand(
return;
}
const result = await executeSlashCommand(host.client, host.sessionKey, name, args);
const targetSessionKey = host.sessionKey;
const result = await executeSlashCommand(host.client, targetSessionKey, name, args);
if (result.content) {
injectCommandResult(host, result.content);
}
if (result.sessionPatch && "model" in result.sessionPatch) {
host.chatModelOverrides = {
...host.chatModelOverrides,
[targetSessionKey]: result.sessionPatch.model ?? null,
};
}
if (result.action === "refresh") {
await refreshChat(host);
}
@ -341,16 +355,31 @@ export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: bool
loadSessions(host as unknown as OpenClawApp, {
activeMinutes: 0,
limit: 0,
includeGlobal: false,
includeUnknown: false,
includeGlobal: true,
includeUnknown: true,
}),
refreshChatAvatar(host),
refreshChatModels(host),
]);
if (opts?.scheduleScroll !== false) {
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
}
}
async function refreshChatModels(host: ChatHost) {
if (!host.client || !host.connected) {
host.chatModelsLoading = false;
host.chatModelCatalog = [];
return;
}
host.chatModelsLoading = true;
try {
host.chatModelCatalog = await loadModels(host.client);
} finally {
host.chatModelsLoading = false;
}
}
export const flushChatQueueForEvent = flushChatQueue;
type SessionDefaultsSnapshot = {

View File

@ -12,7 +12,7 @@ import { icons } from "./icons.ts";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts";
import type { ThemeMode, ThemeName } from "./theme.ts";
import type { SessionsListResult } from "./types.ts";
import type { ModelCatalogEntry, SessionsListResult } from "./types.ts";
type SessionDefaultsSnapshot = {
mainSessionKey?: string;
@ -49,10 +49,10 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
});
}
export function renderTab(state: AppViewState, tab: Tab) {
export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) {
const href = pathForTab(tab, state.basePath);
const isActive = state.tab === tab;
const collapsed = state.settings.navCollapsed;
const collapsed = opts?.collapsed ?? state.settings.navCollapsed;
return html`
<a
href=${href}
@ -128,6 +128,7 @@ function renderCronFilterIcon(hiddenCount: number) {
export function renderChatSessionSelect(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const modelSelect = renderChatModelSelect(state);
return html`
<div class="chat-controls__session-row">
<label class="field chat-controls__session">
@ -159,6 +160,7 @@ export function renderChatSessionSelect(state: AppViewState) {
)}
</select>
</label>
${modelSelect}
</div>
`;
}
@ -316,11 +318,139 @@ async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0,
limit: 0,
includeGlobal: false,
includeUnknown: false,
includeGlobal: true,
includeUnknown: true,
});
}
function resolveActiveSessionRow(state: AppViewState) {
return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
}
function resolveModelOverrideValue(state: AppViewState): string {
// Prefer the local cache — it reflects in-flight patches before sessionsResult refreshes.
const cached = state.chatModelOverrides[state.sessionKey];
if (typeof cached === "string") {
return cached.trim();
}
// cached === null means explicitly cleared to default.
if (cached === null) {
return "";
}
// No local override recorded yet — fall back to server data.
const activeRow = resolveActiveSessionRow(state);
if (activeRow) {
return typeof activeRow.model === "string" ? activeRow.model.trim() : "";
}
return "";
}
function resolveDefaultModelValue(state: AppViewState): string {
const model = state.sessionsResult?.defaults?.model;
return typeof model === "string" ? model.trim() : "";
}
function buildChatModelOptions(
catalog: ModelCatalogEntry[],
currentOverride: string,
defaultModel: string,
): Array<{ value: string; label: string }> {
const seen = new Set<string>();
const options: Array<{ value: string; label: string }> = [];
const addOption = (value: string, label?: string) => {
const trimmed = value.trim();
if (!trimmed) {
return;
}
const key = trimmed.toLowerCase();
if (seen.has(key)) {
return;
}
seen.add(key);
options.push({ value: trimmed, label: label ?? trimmed });
};
for (const entry of catalog) {
const provider = entry.provider?.trim();
addOption(entry.id, provider ? `${entry.id} · ${provider}` : entry.id);
}
if (currentOverride) {
addOption(currentOverride);
}
if (defaultModel) {
addOption(defaultModel);
}
return options;
}
function renderChatModelSelect(state: AppViewState) {
const currentOverride = resolveModelOverrideValue(state);
const defaultModel = resolveDefaultModelValue(state);
const options = buildChatModelOptions(
state.chatModelCatalog ?? [],
currentOverride,
defaultModel,
);
const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default model";
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled =
!state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client;
return html`
<label class="field chat-controls__session chat-controls__model">
<select
data-chat-model-select="true"
aria-label="Chat model"
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatModel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
async function switchChatModel(state: AppViewState, nextModel: string) {
if (!state.client || !state.connected) {
return;
}
const currentOverride = resolveModelOverrideValue(state);
if (currentOverride === nextModel) {
return;
}
const targetSessionKey = state.sessionKey;
const prevOverride = state.chatModelOverrides[targetSessionKey];
state.lastError = null;
// Write the override cache immediately so the picker stays in sync during the RPC round-trip.
state.chatModelOverrides = {
...state.chatModelOverrides,
[targetSessionKey]: nextModel || null,
};
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
model: nextModel || null,
});
await refreshSessionOptions(state);
} catch (err) {
// Roll back so the picker reflects the actual server model.
state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride };
state.lastError = `Failed to set model: ${String(err)}`;
}
}
/* ── Channel display labels ────────────────────────────── */
const CHANNEL_LABELS: Record<string, string> = {
bluebubbles: "iMessage",
@ -504,6 +634,9 @@ export function resolveSessionOptionGroups(
};
for (const row of rows) {
if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) {
continue;
}
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
continue;
}

View File

@ -264,33 +264,6 @@ type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number];
type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number];
type AiAgentsSectionKey = (typeof AI_AGENTS_SECTION_KEYS)[number];
const NAV_WIDTH_MIN = 200;
const NAV_WIDTH_MAX = 400;
function handleNavResizeStart(e: MouseEvent, state: AppViewState) {
e.preventDefault();
const startX = e.clientX;
const startWidth = state.settings.navWidth;
const onMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta)));
state.applySettings({ ...state.settings, navWidth: next });
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey);
@ -330,6 +303,8 @@ export function renderApp(state: AppViewState) {
const chatDisabledReason = state.connected ? null : t("chat.disconnected");
const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
const navDrawerOpen = Boolean(state.navDrawerOpen && !chatFocus && !state.onboarding);
const navCollapsed = Boolean(state.settings.navCollapsed && !navDrawerOpen);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
@ -423,144 +398,164 @@ export function renderApp(state: AppViewState) {
},
})}
<div
class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}"
style="--shell-nav-width: ${state.settings.navWidth}px"
class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${navCollapsed ? "shell--nav-collapsed" : ""} ${navDrawerOpen ? "shell--nav-drawer-open" : ""} ${state.onboarding ? "shell--onboarding" : ""}"
>
<button
type="button"
class="shell-nav-backdrop"
aria-label="${t("nav.collapse")}"
@click=${() => {
state.navDrawerOpen = false;
}}
></button>
<header class="topbar">
<dashboard-header .tab=${state.tab}></dashboard-header>
<button
class="topbar-search"
@click=${() => {
state.paletteOpen = !state.paletteOpen;
}}
title="Search or jump to… (⌘K)"
aria-label="Open command palette"
>
<span class="topbar-search__label">${t("common.search")}</span>
<kbd class="topbar-search__kbd">K</kbd>
</button>
<div class="topbar-status">
${renderTopbarThemeModeToggle(state)}
<div class="topnav-shell">
<button
type="button"
class="topbar-nav-toggle"
@click=${() => {
state.navDrawerOpen = !navDrawerOpen;
}}
title="${navDrawerOpen ? t("nav.collapse") : t("nav.expand")}"
aria-label="${navDrawerOpen ? t("nav.collapse") : t("nav.expand")}"
aria-expanded=${navDrawerOpen}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.menu}</span>
</button>
<div class="topnav-shell__content">
<dashboard-header .tab=${state.tab}></dashboard-header>
</div>
<div class="topnav-shell__actions">
<button
class="topbar-search"
@click=${() => {
state.paletteOpen = !state.paletteOpen;
}}
title="Search or jump to… (⌘K)"
aria-label="Open command palette"
>
<span class="topbar-search__label">${t("common.search")}</span>
<kbd class="topbar-search__kbd">K</kbd>
</button>
<div class="topbar-status">
${renderTopbarThemeModeToggle(state)}
</div>
</div>
</div>
</header>
<div class="shell-nav">
<aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}">
<div class="sidebar-header">
${
state.settings.navCollapsed
? nothing
: html`
<div class="sidebar-brand">
<img class="sidebar-brand__logo" src="${agentLogoUrl(basePath)}" alt="OpenClaw" />
<span class="sidebar-brand__title">OpenClaw</span>
</div>
`
}
<button
type="button"
class="nav-collapse-toggle"
@click=${() =>
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
>
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.menu}</span>
</button>
</div>
<nav class="sidebar-nav">
${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
const showItems = hasActiveTab || !isGroupCollapsed;
return html`
<div class="nav-group ${!showItems ? "nav-group--collapsed" : ""}">
<aside class="sidebar ${navCollapsed ? "sidebar--collapsed" : ""}">
<div class="sidebar-shell">
<div class="sidebar-shell__header">
<div class="sidebar-brand">
${
!state.settings.navCollapsed
? html`
<button
class="nav-group__label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
aria-expanded=${showItems}
>
<span class="nav-group__label-text">${t(`nav.${group.label}`)}</span>
<span class="nav-group__chevron">${showItems ? icons.chevronDown : icons.chevronRight}</span>
</button>
`
: nothing
navCollapsed
? nothing
: html`
<img class="sidebar-brand__logo" src="${agentLogoUrl(basePath)}" alt="OpenClaw" />
<span class="sidebar-brand__copy">
<span class="sidebar-brand__eyebrow">${t("nav.control")}</span>
<span class="sidebar-brand__title">OpenClaw</span>
</span>
`
}
<div class="nav-group__items">
${group.tabs.map((tab) => renderTab(state, tab))}
</div>
</div>
`;
})}
</nav>
<button
type="button"
class="nav-collapse-toggle"
@click=${() =>
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${navCollapsed ? t("nav.expand") : t("nav.collapse")}"
aria-label="${navCollapsed ? t("nav.expand") : t("nav.collapse")}"
>
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.menu}</span>
</button>
</div>
<div class="sidebar-shell__body">
<nav class="sidebar-nav">
${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
const showItems = navCollapsed || hasActiveTab || !isGroupCollapsed;
<div class="sidebar-footer">
<div class="sidebar-footer__docs-block">
<a
class="nav-item nav-item--external"
href="https://docs.openclaw.ai"
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
title="${t("common.docs")} (opens in new tab)"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
${
!state.settings.navCollapsed
? html`
<span class="nav-item__text">${t("common.docs")}</span>
<span class="nav-item__external-icon">${icons.externalLink}</span>
`
: nothing
}
</a>
${(() => {
const version = state.hello?.server?.version ?? "";
return version
? html`
<div class="sidebar-version" title=${`v${version}`}>
${
!state.settings.navCollapsed
? html`<span class="sidebar-version__text">v${version}</span>`
: html`
<span class="sidebar-version__dot"></span>
`
}
</div>
`
: nothing;
})()}
return html`
<section class="nav-section ${!showItems ? "nav-section--collapsed" : ""}">
${
!navCollapsed
? html`
<button
class="nav-section__label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
aria-expanded=${showItems}
>
<span class="nav-section__label-text">${t(`nav.${group.label}`)}</span>
<span class="nav-section__chevron">
${showItems ? icons.chevronDown : icons.chevronRight}
</span>
</button>
`
: nothing
}
<div class="nav-section__items">
${group.tabs.map((tab) => renderTab(state, tab, { collapsed: navCollapsed }))}
</div>
</section>
`;
})}
</nav>
</div>
<div class="sidebar-shell__footer">
<div class="sidebar-utility-group">
<a
class="nav-item nav-item--external sidebar-utility-link"
href="https://docs.openclaw.ai"
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
title="${t("common.docs")} (opens in new tab)"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
${
!navCollapsed
? html`
<span class="nav-item__text">${t("common.docs")}</span>
<span class="nav-item__external-icon">${icons.externalLink}</span>
`
: nothing
}
</a>
${(() => {
const version = state.hello?.server?.version ?? "";
return version
? html`
<div class="sidebar-version" title=${`v${version}`}>
${
!navCollapsed
? html`
<span class="sidebar-version__label">${t("common.version")}</span>
<span class="sidebar-version__text">v${version}</span>
`
: html`
<span class="sidebar-version__dot"></span>
`
}
</div>
`
: nothing;
})()}
</div>
</div>
</div>
</div>
</aside>
${
!state.settings.navCollapsed && !chatFocus
? html`
<div
class="sidebar-resizer"
role="separator"
aria-orientation="vertical"
aria-label="${t("nav.resize")}"
title="${t("nav.resize")}"
@mousedown=${(ev: MouseEvent) => handleNavResizeStart(ev, state)}
></div>
`
: nothing
}
</aside>
</div>
<main class="content ${isChat ? "content--chat" : ""}">
${

View File

@ -71,11 +71,15 @@ export type AppViewState = {
fallbackStatus: FallbackStatus | null;
chatAvatarUrl: string | null;
chatThinkingLevel: string | null;
chatModelOverrides: Record<string, string | null>;
chatModelsLoading: boolean;
chatModelCatalog: ModelCatalogEntry[];
chatQueue: ChatQueueItem[];
chatManualRefreshInFlight: boolean;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
chatNewMessagesBelow: boolean;
navDrawerOpen: boolean;
sidebarOpen: boolean;
sidebarContent: string | null;
sidebarError: string | null;

View File

@ -158,9 +158,13 @@ export class OpenClawApp extends LitElement {
@state() fallbackStatus: FallbackStatus | null = null;
@state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatModelOverrides: Record<string, string | null> = {};
@state() chatModelsLoading = false;
@state() chatModelCatalog: ModelCatalogEntry[] = [];
@state() chatQueue: ChatQueueItem[] = [];
@state() chatAttachments: ChatAttachment[] = [];
@state() chatManualRefreshInFlight = false;
@state() navDrawerOpen = false;
onSlashAction?: (action: string) => void;
@ -541,6 +545,7 @@ export class OpenClawApp extends LitElement {
setTab(next: Tab) {
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
this.navDrawerOpen = false;
}
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {

View File

@ -174,7 +174,11 @@ export function renderMessageGroup(
<span class="chat-group-timestamp">${timestamp}</span>
${renderMessageMeta(meta)}
${normalizedRole === "assistant" && isTtsSupported() ? renderTtsButton(group) : nothing}
${opts.onDelete ? renderDeleteButton(opts.onDelete) : nothing}
${
opts.onDelete
? renderDeleteButton(opts.onDelete, normalizedRole === "user" ? "left" : "right")
: nothing
}
</div>
</div>
</div>
@ -312,6 +316,8 @@ function extractGroupText(group: MessageGroup): string {
const SKIP_DELETE_CONFIRM_KEY = "openclaw:skipDeleteConfirm";
type DeleteConfirmSide = "left" | "right";
function shouldSkipDeleteConfirm(): boolean {
try {
return localStorage.getItem(SKIP_DELETE_CONFIRM_KEY) === "1";
@ -320,7 +326,7 @@ function shouldSkipDeleteConfirm(): boolean {
}
}
function renderDeleteButton(onDelete: () => void) {
function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) {
return html`
<span class="chat-delete-wrap">
<button
@ -340,7 +346,7 @@ function renderDeleteButton(onDelete: () => void) {
return;
}
const popover = document.createElement("div");
popover.className = "chat-delete-confirm";
popover.className = `chat-delete-confirm chat-delete-confirm--${side}`;
popover.innerHTML = `
<p class="chat-delete-confirm__text">Delete this message?</p>
<label class="chat-delete-confirm__remember">

View File

@ -33,6 +33,10 @@ export type SlashCommandResult = {
| "clear"
| "toggle-focus"
| "navigate-usage";
/** Optional session-level directive changes that the caller should mirror locally. */
sessionPatch?: {
model?: string | null;
};
};
export async function executeSlashCommand(
@ -141,7 +145,11 @@ async function executeModel(
try {
await client.request("sessions.patch", { key: sessionKey, model: args.trim() });
return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" };
return {
content: `Model set to \`${args.trim()}\`.`,
action: "refresh",
sessionPatch: { model: args.trim() },
};
} catch (err) {
return { content: `Failed to set model: ${String(err)}` };
}

View File

@ -64,6 +64,85 @@ describe("control UI routing", () => {
expect(window.location.pathname).toBe("/channels");
});
it("renders the refreshed top navigation shell", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(app.querySelector(".topnav-shell")).not.toBeNull();
expect(app.querySelector(".topnav-shell__content")).not.toBeNull();
expect(app.querySelector(".topnav-shell__actions")).not.toBeNull();
expect(app.querySelector(".topnav-shell .brand-title")).toBeNull();
});
it("renders the refreshed sidebar shell structure", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(app.querySelector(".sidebar-shell")).not.toBeNull();
expect(app.querySelector(".sidebar-shell__header")).not.toBeNull();
expect(app.querySelector(".sidebar-shell__body")).not.toBeNull();
expect(app.querySelector(".sidebar-shell__footer")).not.toBeNull();
expect(app.querySelector(".sidebar-brand")).not.toBeNull();
expect(app.querySelector(".sidebar-brand__logo")).not.toBeNull();
expect(app.querySelector(".sidebar-brand__copy")).not.toBeNull();
});
it("does not render a desktop sidebar resizer or inject a custom nav width", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.applySettings({ ...app.settings, navWidth: 360 });
await app.updateComplete;
expect(app.querySelector(".sidebar-resizer")).toBeNull();
const shell = app.querySelector<HTMLElement>(".shell");
expect(shell?.style.getPropertyValue("--shell-nav-width")).toBe("");
});
it("hides section labels in collapsed mode", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.applySettings({ ...app.settings, navCollapsed: true });
await app.updateComplete;
expect(app.querySelector(".nav-section__label")).toBeNull();
expect(app.querySelector(".sidebar-brand__logo")).toBeNull();
});
it("keeps footer utilities available in collapsed mode", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.applySettings({ ...app.settings, navCollapsed: true });
await app.updateComplete;
expect(app.querySelector(".sidebar-shell__footer")).not.toBeNull();
expect(app.querySelector(".sidebar-utility-link")).not.toBeNull();
});
it("keeps the collapsed desktop rail compact", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.applySettings({ ...app.settings, navCollapsed: true });
await app.updateComplete;
const item = app.querySelector<HTMLElement>(".sidebar .nav-item");
const header = app.querySelector<HTMLElement>(".sidebar-shell__header");
expect(item).not.toBeNull();
expect(header).not.toBeNull();
if (!item || !header) {
return;
}
const itemStyles = getComputedStyle(item);
const headerStyles = getComputedStyle(header);
expect(itemStyles.width).toBe("44px");
expect(itemStyles.minHeight).toBe("44px");
expect(headerStyles.justifyContent).toBe("center");
});
it("resets to the main session when opening chat from sidebar navigation", async () => {
const app = mountApp("/sessions?session=agent:main:subagent:task-123");
await app.updateComplete;
@ -107,6 +186,96 @@ describe("control UI routing", () => {
}
});
it("stacks the refreshed top navigation for narrow viewports", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
const shell = app.querySelector<HTMLElement>(".topnav-shell");
const content = app.querySelector<HTMLElement>(".topnav-shell__content");
expect(shell).not.toBeNull();
expect(content).not.toBeNull();
if (!shell || !content) {
return;
}
expect(getComputedStyle(shell).flexWrap).toBe("wrap");
expect(getComputedStyle(content).width).not.toBe("auto");
});
it("keeps the mobile topbar nav toggle visible beside the search row", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
const shell = app.querySelector<HTMLElement>(".topnav-shell");
const toggle = app.querySelector<HTMLElement>(".topbar-nav-toggle");
const actions = app.querySelector<HTMLElement>(".topnav-shell__actions");
expect(shell).not.toBeNull();
expect(toggle).not.toBeNull();
expect(actions).not.toBeNull();
if (!shell || !toggle || !actions) {
return;
}
const shellWidth = parseFloat(getComputedStyle(shell).width);
const toggleWidth = parseFloat(getComputedStyle(toggle).width);
const actionsWidth = parseFloat(getComputedStyle(actions).width);
expect(toggleWidth).toBeGreaterThan(0);
expect(actionsWidth).toBeLessThan(shellWidth);
});
it("opens the mobile sidenav as a drawer from the topbar toggle", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
const toggle = app.querySelector<HTMLButtonElement>(".topbar-nav-toggle");
const shell = app.querySelector<HTMLElement>(".shell");
const nav = app.querySelector<HTMLElement>(".shell-nav");
expect(toggle).not.toBeNull();
expect(shell).not.toBeNull();
expect(nav).not.toBeNull();
if (!toggle || !shell || !nav) {
return;
}
expect(shell.classList.contains("shell--nav-drawer-open")).toBe(false);
toggle.click();
await app.updateComplete;
expect(shell.classList.contains("shell--nav-drawer-open")).toBe(true);
const styles = getComputedStyle(nav);
expect(styles.position).toBe("fixed");
expect(styles.transform).not.toBe("none");
});
it("closes the mobile sidenav drawer after navigation", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
const toggle = app.querySelector<HTMLButtonElement>(".topbar-nav-toggle");
expect(toggle).not.toBeNull();
toggle?.click();
await app.updateComplete;
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
const shell = app.querySelector<HTMLElement>(".shell");
expect(link).not.toBeNull();
expect(shell?.classList.contains("shell--nav-drawer-open")).toBe(true);
link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
await app.updateComplete;
expect(app.tab).toBe("channels");
expect(shell?.classList.contains("shell--nav-drawer-open")).toBe(false);
});
it("auto-scrolls chat history to the latest message", async () => {
const app = mountApp("/chat");
await app.updateComplete;

View File

@ -437,6 +437,8 @@ export function renderAgentSkills(params: {
.value=${params.filter}
@input=${(e: Event) => params.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills"
autocomplete="off"
name="agent-skills-filter"
/>
</label>
<div class="muted">${filtered.length} shown</div>

View File

@ -2,6 +2,10 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderChatSessionSelect } from "../app-render.helpers.ts";
import type { AppViewState } from "../app-view-state.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ModelCatalogEntry } from "../types.ts";
import type { SessionsListResult } from "../types.ts";
import { renderChat, type ChatProps } from "./chat.ts";
@ -15,6 +19,104 @@ function createSessions(): SessionsListResult {
};
}
function createChatHeaderState(
overrides: {
model?: string | null;
models?: ModelCatalogEntry[];
omitSessionFromList?: boolean;
} = {},
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
let currentModel = overrides.model ?? null;
const omitSessionFromList = overrides.omitSessionFromList ?? false;
const catalog = overrides.models ?? [
{ id: "gpt-5", name: "GPT-5", provider: "openai" },
{ id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" },
];
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.patch") {
currentModel = (params.model as string | null | undefined) ?? null;
return { ok: true, key: "main" };
}
if (method === "chat.history") {
return { messages: [], thinkingLevel: null };
}
if (method === "sessions.list") {
return {
ts: 0,
path: "",
count: omitSessionFromList ? 0 : 1,
defaults: { model: "gpt-5", contextTokens: null },
sessions: omitSessionFromList
? []
: [{ key: "main", kind: "direct", updatedAt: null, model: currentModel }],
};
}
if (method === "models.list") {
return { models: catalog };
}
throw new Error(`Unexpected request: ${method}`);
});
const state = {
sessionKey: "main",
connected: true,
sessionsHideCron: true,
sessionsResult: {
ts: 0,
path: "",
count: omitSessionFromList ? 0 : 1,
defaults: { model: "gpt-5", contextTokens: null },
sessions: omitSessionFromList
? []
: [{ key: "main", kind: "direct", updatedAt: null, model: currentModel }],
},
chatModelOverrides: {},
chatModelCatalog: catalog,
chatModelsLoading: false,
client: { request } as unknown as GatewayBrowserClient,
settings: {
gatewayUrl: "",
token: "",
locale: "en",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "dark",
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
chatFocusMode: false,
chatShowThinking: false,
},
chatMessage: "",
chatStream: null,
chatStreamStartedAt: null,
chatRunId: null,
chatQueue: [],
chatMessages: [],
chatLoading: false,
chatThinkingLevel: null,
lastError: null,
chatAvatarUrl: null,
basePath: "",
hello: null,
agentsList: null,
applySettings(next: AppViewState["settings"]) {
state.settings = next;
},
loadAssistantIdentity: vi.fn(),
resetToolStream: vi.fn(),
resetChatScroll: vi.fn(),
} as unknown as AppViewState & {
client: GatewayBrowserClient;
settings: AppViewState["settings"];
};
return { state, request };
}
function flushTasks() {
return new Promise<void>((resolve) => setTimeout(resolve, 0));
}
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
return {
sessionKey: "main",
@ -376,4 +478,173 @@ describe("chat view", () => {
expect(senderLabels).toContain("Iris");
expect(senderLabels).toContain("Joaquin De Rojas");
});
it("opens delete confirm on the left for user messages", () => {
try {
localStorage.removeItem("openclaw:skipDeleteConfirm");
} catch {
/* noop */
}
const container = document.createElement("div");
render(
renderChat(
createProps({
messages: [
{
role: "user",
content: "hello from user",
timestamp: 1000,
},
],
}),
),
container,
);
const deleteButton = container.querySelector<HTMLButtonElement>(
".chat-group.user .chat-group-delete",
);
expect(deleteButton).not.toBeNull();
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const confirm = container.querySelector<HTMLElement>(".chat-group.user .chat-delete-confirm");
expect(confirm).not.toBeNull();
expect(confirm?.classList.contains("chat-delete-confirm--left")).toBe(true);
});
it("opens delete confirm on the right for assistant messages", () => {
try {
localStorage.removeItem("openclaw:skipDeleteConfirm");
} catch {
/* noop */
}
const container = document.createElement("div");
render(
renderChat(
createProps({
messages: [
{
role: "assistant",
content: "hello from assistant",
timestamp: 1000,
},
],
}),
),
container,
);
const deleteButton = container.querySelector<HTMLButtonElement>(
".chat-group.assistant .chat-group-delete",
);
expect(deleteButton).not.toBeNull();
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const confirm = container.querySelector<HTMLElement>(
".chat-group.assistant .chat-delete-confirm",
);
expect(confirm).not.toBeNull();
expect(confirm?.classList.contains("chat-delete-confirm--right")).toBe(true);
});
it("patches the current session model from the chat header picker", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState();
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("");
modelSelect!.value = "gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: "gpt-5-mini",
});
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
vi.unstubAllGlobals();
});
it("clears the session model override back to the default model", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("gpt-5-mini");
modelSelect!.value = "";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: null,
});
expect(state.sessionsResult?.sessions[0]?.model).toBeNull();
vi.unstubAllGlobals();
});
it("disables the chat header model picker while a run is active", () => {
const { state } = createChatHeaderState();
state.chatRunId = "run-123";
state.chatStream = "Working";
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.disabled).toBe(true);
});
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state } = createChatHeaderState({ omitSessionFromList: true });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
modelSelect!.value = "gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
render(renderChatSessionSelect(state), container);
const rerendered = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(rerendered?.value).toBe("gpt-5-mini");
vi.unstubAllGlobals();
});
});

View File

@ -209,6 +209,15 @@ describe("config view", () => {
expect(onSearchChange).toHaveBeenCalledWith("gateway");
});
it("renders the top search icon inside the search input row", () => {
const container = document.createElement("div");
render(renderConfig(baseProps()), container);
const icon = container.querySelector<SVGElement>(".config-search__icon");
expect(icon).not.toBeNull();
expect(icon?.closest(".config-search__input-row")).not.toBeNull();
});
it("renders top tabs for root and available sections", () => {
const container = document.createElement("div");
render(

View File

@ -812,36 +812,38 @@ export function renderConfig(props: ConfigProps) {
formMode === "form"
? html`
<div class="config-search config-search--top">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
.value=${props.searchQuery}
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
.value=${props.searchQuery}
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
</div>
</div>
`
: nothing

View File

@ -61,6 +61,8 @@ export function renderSkills(props: SkillsProps) {
.value=${props.filter}
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills"
autocomplete="off"
name="skills-filter"
/>
</label>
<div class="muted">${filtered.length} shown</div>