mirror of https://github.com/openclaw/openclaw.git
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:
parent
72b6a11a83
commit
ca414735b9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" : ""}">
|
||||
${
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue