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 .gitignore
test/config-form.analyze.telegram.test.ts test/config-form.analyze.telegram.test.ts
ui/src/ui/theme-variants.browser.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__
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png ui/src/ui/views/__screenshots__
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png ui/.vitest-attachments
docs/superpowers

View File

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

View File

@ -670,6 +670,18 @@
max-width: 300px; 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 { .chat-controls__thinking {
display: flex; display: flex;
align-items: center; align-items: center;
@ -760,6 +772,10 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.chat-controls__model select {
max-width: 320px;
}
.chat-controls__thinking { .chat-controls__thinking {
display: flex; display: flex;
align-items: center; align-items: center;
@ -812,6 +828,10 @@
.chat-controls__session { .chat-controls__session {
min-width: 120px; min-width: 120px;
} }
.chat-controls__model {
min-width: 150px;
}
} }
/* Chat loading skeleton */ /* Chat loading skeleton */

View File

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

View File

@ -2,61 +2,131 @@
Mobile Layout 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) { @media (max-width: 1100px) {
.shell, .shell,
.shell--nav-collapsed { .shell--nav-collapsed {
grid-template-columns: minmax(0, 1fr); 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: grid-template-areas:
"topbar" "topbar"
"nav"
"content"; "content";
} }
.shell--chat-focus { .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,
.shell--nav-collapsed .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; 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,
.sidebar--collapsed { .sidebar--collapsed {
width: auto; width: 100%;
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
flex-direction: row; flex-direction: column;
align-items: center; align-items: stretch;
border-right: none; border-right: none;
} }
.sidebar-header, .sidebar-shell,
.sidebar--collapsed .sidebar-header { .sidebar--collapsed .sidebar-shell {
justify-content: flex-start; padding: 18px 16px 14px;
padding: 8px 10px; border-radius: 0;
flex: 0 0 auto;
} }
.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; 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-nav,
.sidebar--collapsed .sidebar-nav { .sidebar--collapsed .sidebar-nav {
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: block;
flex-direction: row; padding: 0;
flex-wrap: nowrap; overflow-x: hidden;
gap: 8px; overflow-y: auto;
padding: 8px 10px 8px 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;
} }
@ -65,29 +135,36 @@
display: none; display: none;
} }
.nav-group, .nav-section,
.nav-group__items, .sidebar--collapsed .nav-section {
.sidebar--collapsed .nav-group, display: grid;
.sidebar--collapsed .nav-group__items { margin-bottom: 16px;
display: contents;
} }
.nav-group { .sidebar-nav .nav-section__label,
margin-bottom: 0; .sidebar--collapsed .nav-section__label {
} display: flex;
.sidebar-nav .nav-group__label {
display: none;
} }
.nav-item, .nav-item,
.sidebar--collapsed .nav-item { .sidebar--collapsed .nav-item {
margin: 0; margin: 0;
padding: 8px 14px; min-height: 40px;
padding: 0 12px;
font-size: 13px; font-size: 13px;
border-radius: var(--radius-md); border-radius: 12px;
white-space: nowrap; white-space: nowrap;
flex: 0 0 auto; 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, .sidebar--collapsed .nav-item--active::before,
@ -95,14 +172,53 @@
content: none; content: none;
} }
.sidebar-footer, .sidebar--collapsed .nav-item__text,
.sidebar--collapsed .sidebar-footer { .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; 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 */ /* Mobile-specific styles */
@media (max-width: 600px) { @media (max-width: 768px) {
.shell { .shell {
--shell-pad: 8px; --shell-pad: 8px;
--shell-gap: 8px; --shell-gap: 8px;
@ -111,24 +227,40 @@
/* Topbar */ /* Topbar */
.topbar { .topbar {
padding: 10px 12px; padding: 10px 12px;
gap: 8px; min-height: auto;
flex-direction: row; }
.topnav-shell {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; gap: 10px;
align-items: center;
} }
.brand { .topnav-shell__actions {
flex: 1;
min-width: 0; min-width: 0;
flex: 1 1 auto;
justify-content: space-between;
gap: 10px;
align-items: stretch;
} }
.brand-title { .topnav-shell__content {
font-size: 14px; order: 3;
width: 100%;
} }
.brand-sub { .topbar-nav-toggle {
display: none; 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 { .topbar-status {
@ -137,6 +269,15 @@
flex-wrap: nowrap; flex-wrap: nowrap;
} }
.topbar-search {
min-width: 0;
flex: 1;
}
.topbar-theme-mode {
flex-shrink: 0;
}
.topbar-status .pill { .topbar-status .pill {
padding: 4px 8px; padding: 4px 8px;
font-size: 11px; font-size: 11px;
@ -151,25 +292,23 @@
display: none; display: none;
} }
.shell-nav { .shell-nav,
border-bottom-width: 0; .shell--nav-collapsed .shell-nav {
width: min(92vw, 320px);
} }
.sidebar-header { .shell--nav-collapsed:not(.shell--nav-drawer-open) .shell-nav {
padding: 6px 8px; width: 78px;
} }
.sidebar-nav { .sidebar-shell,
gap: 6px; .sidebar--collapsed .sidebar-shell {
padding: 6px 8px 6px 0; padding: 16px 14px 12px;
} }
.nav-item { .nav-item,
padding: 6px 10px; .sidebar--collapsed .nav-item {
font-size: 12px; font-size: 12px;
border-radius: var(--radius-md);
white-space: nowrap;
flex-shrink: 0;
} }
/* Content */ /* Content */
@ -177,6 +316,19 @@
display: none; 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 { .content {
padding: 4px 4px 16px; padding: 4px 4px 16px;
gap: 12px; gap: 12px;

View File

@ -1,7 +1,7 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { function makeHost(overrides?: Partial<ChatHost>): ChatHost {
return { return {
@ -19,7 +19,11 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
basePath: "", basePath: "",
hello: null, hello: null,
chatAvatarUrl: null, chatAvatarUrl: null,
chatModelOverrides: {},
chatModelsLoading: false,
chatModelCatalog: [],
refreshSessionsAfterChat: new Set<string>(), refreshSessionsAfterChat: new Set<string>(),
updateComplete: Promise.resolve(),
...overrides, ...overrides,
}; };
} }
@ -63,3 +67,55 @@ describe("refreshChatAvatar", () => {
expect(host.chatAvatarUrl).toBeNull(); 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 { executeSlashCommand } from "./chat/slash-command-executor.ts";
import { parseSlashCommand } from "./chat/slash-commands.ts"; import { parseSlashCommand } from "./chat/slash-commands.ts";
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts"; import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts";
import { loadModels } from "./controllers/models.ts";
import { loadSessions } from "./controllers/sessions.ts"; import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import { normalizeBasePath } from "./navigation.ts"; import { normalizeBasePath } from "./navigation.ts";
import type { ModelCatalogEntry } from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts"; import { generateUUID } from "./uuid.ts";
@ -27,6 +29,10 @@ export type ChatHost = {
basePath: string; basePath: string;
hello: GatewayHelloOk | null; hello: GatewayHelloOk | null;
chatAvatarUrl: string | null; chatAvatarUrl: string | null;
chatModelOverrides: Record<string, string | null>;
chatModelsLoading: boolean;
chatModelCatalog: ModelCatalogEntry[];
updateComplete?: Promise<unknown>;
refreshSessionsAfterChat: Set<string>; refreshSessionsAfterChat: Set<string>;
/** Callback for slash-command side effects that need app-level access. */ /** Callback for slash-command side effects that need app-level access. */
onSlashAction?: (action: string) => void; onSlashAction?: (action: string) => void;
@ -295,12 +301,20 @@ async function dispatchSlashCommand(
return; 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) { if (result.content) {
injectCommandResult(host, 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") { if (result.action === "refresh") {
await refreshChat(host); await refreshChat(host);
} }
@ -341,16 +355,31 @@ export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: bool
loadSessions(host as unknown as OpenClawApp, { loadSessions(host as unknown as OpenClawApp, {
activeMinutes: 0, activeMinutes: 0,
limit: 0, limit: 0,
includeGlobal: false, includeGlobal: true,
includeUnknown: false, includeUnknown: true,
}), }),
refreshChatAvatar(host), refreshChatAvatar(host),
refreshChatModels(host),
]); ]);
if (opts?.scheduleScroll !== false) { if (opts?.scheduleScroll !== false) {
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]); 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; export const flushChatQueueForEvent = flushChatQueue;
type SessionDefaultsSnapshot = { type SessionDefaultsSnapshot = {

View File

@ -12,7 +12,7 @@ import { icons } from "./icons.ts";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts";
import type { ThemeMode, ThemeName } from "./theme.ts"; import type { ThemeMode, ThemeName } from "./theme.ts";
import type { SessionsListResult } from "./types.ts"; import type { ModelCatalogEntry, SessionsListResult } from "./types.ts";
type SessionDefaultsSnapshot = { type SessionDefaultsSnapshot = {
mainSessionKey?: string; 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 href = pathForTab(tab, state.basePath);
const isActive = state.tab === tab; const isActive = state.tab === tab;
const collapsed = state.settings.navCollapsed; const collapsed = opts?.collapsed ?? state.settings.navCollapsed;
return html` return html`
<a <a
href=${href} href=${href}
@ -128,6 +128,7 @@ function renderCronFilterIcon(hiddenCount: number) {
export function renderChatSessionSelect(state: AppViewState) { export function renderChatSessionSelect(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult); const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const modelSelect = renderChatModelSelect(state);
return html` return html`
<div class="chat-controls__session-row"> <div class="chat-controls__session-row">
<label class="field chat-controls__session"> <label class="field chat-controls__session">
@ -159,6 +160,7 @@ export function renderChatSessionSelect(state: AppViewState) {
)} )}
</select> </select>
</label> </label>
${modelSelect}
</div> </div>
`; `;
} }
@ -316,11 +318,139 @@ async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], { await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0, activeMinutes: 0,
limit: 0, limit: 0,
includeGlobal: false, includeGlobal: true,
includeUnknown: false, 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 ────────────────────────────── */ /* ── Channel display labels ────────────────────────────── */
const CHANNEL_LABELS: Record<string, string> = { const CHANNEL_LABELS: Record<string, string> = {
bluebubbles: "iMessage", bluebubbles: "iMessage",
@ -504,6 +634,9 @@ export function resolveSessionOptionGroups(
}; };
for (const row of rows) { for (const row of rows) {
if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) {
continue;
}
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) { if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
continue; continue;
} }

View File

@ -264,33 +264,6 @@ type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number];
type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number]; type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number];
type AiAgentsSectionKey = (typeof AI_AGENTS_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 { function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? []; const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey); const parsed = parseAgentSessionKey(state.sessionKey);
@ -330,6 +303,8 @@ export function renderApp(state: AppViewState) {
const chatDisabledReason = state.connected ? null : t("chat.disconnected"); const chatDisabledReason = state.connected ? null : t("chat.disconnected");
const isChat = state.tab === "chat"; const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); 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 showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const assistantAvatarUrl = resolveAssistantAvatarUrl(state); const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
@ -423,144 +398,164 @@ export function renderApp(state: AppViewState) {
}, },
})} })}
<div <div
class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}" class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${navCollapsed ? "shell--nav-collapsed" : ""} ${navDrawerOpen ? "shell--nav-drawer-open" : ""} ${state.onboarding ? "shell--onboarding" : ""}"
style="--shell-nav-width: ${state.settings.navWidth}px"
> >
<button
type="button"
class="shell-nav-backdrop"
aria-label="${t("nav.collapse")}"
@click=${() => {
state.navDrawerOpen = false;
}}
></button>
<header class="topbar"> <header class="topbar">
<dashboard-header .tab=${state.tab}></dashboard-header> <div class="topnav-shell">
<button <button
class="topbar-search" type="button"
@click=${() => { class="topbar-nav-toggle"
state.paletteOpen = !state.paletteOpen; @click=${() => {
}} state.navDrawerOpen = !navDrawerOpen;
title="Search or jump to… (⌘K)" }}
aria-label="Open command palette" title="${navDrawerOpen ? t("nav.collapse") : t("nav.expand")}"
> aria-label="${navDrawerOpen ? t("nav.collapse") : t("nav.expand")}"
<span class="topbar-search__label">${t("common.search")}</span> aria-expanded=${navDrawerOpen}
<kbd class="topbar-search__kbd">K</kbd> >
</button> <span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.menu}</span>
<div class="topbar-status"> </button>
${renderTopbarThemeModeToggle(state)} <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> </div>
</header> </header>
<div class="shell-nav"> <div class="shell-nav">
<aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}"> <aside class="sidebar ${navCollapsed ? "sidebar--collapsed" : ""}">
<div class="sidebar-header"> <div class="sidebar-shell">
${ <div class="sidebar-shell__header">
state.settings.navCollapsed <div class="sidebar-brand">
? 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" : ""}">
${ ${
!state.settings.navCollapsed navCollapsed
? html` ? nothing
<button : html`
class="nav-group__label" <img class="sidebar-brand__logo" src="${agentLogoUrl(basePath)}" alt="OpenClaw" />
@click=${() => { <span class="sidebar-brand__copy">
const next = { ...state.settings.navGroupsCollapsed }; <span class="sidebar-brand__eyebrow">${t("nav.control")}</span>
next[group.label] = !isGroupCollapsed; <span class="sidebar-brand__title">OpenClaw</span>
state.applySettings({ </span>
...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
} }
<div class="nav-group__items">
${group.tabs.map((tab) => renderTab(state, tab))}
</div>
</div> </div>
`; <button
})} type="button"
</nav> 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"> return html`
<div class="sidebar-footer__docs-block"> <section class="nav-section ${!showItems ? "nav-section--collapsed" : ""}">
<a ${
class="nav-item nav-item--external" !navCollapsed
href="https://docs.openclaw.ai" ? html`
target=${EXTERNAL_LINK_TARGET} <button
rel=${buildExternalLinkRel()} class="nav-section__label"
title="${t("common.docs")} (opens in new tab)" @click=${() => {
> const next = { ...state.settings.navGroupsCollapsed };
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span> next[group.label] = !isGroupCollapsed;
${ state.applySettings({
!state.settings.navCollapsed ...state.settings,
? html` navGroupsCollapsed: next,
<span class="nav-item__text">${t("common.docs")}</span> });
<span class="nav-item__external-icon">${icons.externalLink}</span> }}
` aria-expanded=${showItems}
: nothing >
} <span class="nav-section__label-text">${t(`nav.${group.label}`)}</span>
</a> <span class="nav-section__chevron">
${(() => { ${showItems ? icons.chevronDown : icons.chevronRight}
const version = state.hello?.server?.version ?? ""; </span>
return version </button>
? html` `
<div class="sidebar-version" title=${`v${version}`}> : nothing
${ }
!state.settings.navCollapsed <div class="nav-section__items">
? html`<span class="sidebar-version__text">v${version}</span>` ${group.tabs.map((tab) => renderTab(state, tab, { collapsed: navCollapsed }))}
: html` </div>
<span class="sidebar-version__dot"></span> </section>
` `;
} })}
</div> </nav>
` </div>
: nothing; <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>
</div> </aside>
</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
}
</div> </div>
<main class="content ${isChat ? "content--chat" : ""}"> <main class="content ${isChat ? "content--chat" : ""}">
${ ${

View File

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

View File

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

View File

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

View File

@ -33,6 +33,10 @@ export type SlashCommandResult = {
| "clear" | "clear"
| "toggle-focus" | "toggle-focus"
| "navigate-usage"; | "navigate-usage";
/** Optional session-level directive changes that the caller should mirror locally. */
sessionPatch?: {
model?: string | null;
};
}; };
export async function executeSlashCommand( export async function executeSlashCommand(
@ -141,7 +145,11 @@ async function executeModel(
try { try {
await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); 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) { } catch (err) {
return { content: `Failed to set model: ${String(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"); 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 () => { it("resets to the main session when opening chat from sidebar navigation", async () => {
const app = mountApp("/sessions?session=agent:main:subagent:task-123"); const app = mountApp("/sessions?session=agent:main:subagent:task-123");
await app.updateComplete; 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 () => { it("auto-scrolls chat history to the latest message", async () => {
const app = mountApp("/chat"); const app = mountApp("/chat");
await app.updateComplete; await app.updateComplete;

View File

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

View File

@ -2,6 +2,10 @@
import { render } from "lit"; import { render } from "lit";
import { describe, expect, it, vi } from "vitest"; 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 type { SessionsListResult } from "../types.ts";
import { renderChat, type ChatProps } from "./chat.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 { function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
return { return {
sessionKey: "main", sessionKey: "main",
@ -376,4 +478,173 @@ describe("chat view", () => {
expect(senderLabels).toContain("Iris"); expect(senderLabels).toContain("Iris");
expect(senderLabels).toContain("Joaquin De Rojas"); 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"); 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", () => { it("renders top tabs for root and available sections", () => {
const container = document.createElement("div"); const container = document.createElement("div");
render( render(

View File

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

View File

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