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
|
.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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,11 +398,34 @@ 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">
|
||||||
|
<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>
|
<dashboard-header .tab=${state.tab}></dashboard-header>
|
||||||
|
</div>
|
||||||
|
<div class="topnav-shell__actions">
|
||||||
<button
|
<button
|
||||||
class="topbar-search"
|
class="topbar-search"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
|
|
@ -442,20 +440,26 @@ export function renderApp(state: AppViewState) {
|
||||||
<div class="topbar-status">
|
<div class="topbar-status">
|
||||||
${renderTopbarThemeModeToggle(state)}
|
${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">
|
||||||
|
<div class="sidebar-brand">
|
||||||
${
|
${
|
||||||
state.settings.navCollapsed
|
navCollapsed
|
||||||
? nothing
|
? nothing
|
||||||
: html`
|
: html`
|
||||||
<div class="sidebar-brand">
|
|
||||||
<img class="sidebar-brand__logo" src="${agentLogoUrl(basePath)}" alt="OpenClaw" />
|
<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 class="sidebar-brand__title">OpenClaw</span>
|
||||||
</div>
|
</span>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="nav-collapse-toggle"
|
class="nav-collapse-toggle"
|
||||||
|
|
@ -464,27 +468,26 @@ export function renderApp(state: AppViewState) {
|
||||||
...state.settings,
|
...state.settings,
|
||||||
navCollapsed: !state.settings.navCollapsed,
|
navCollapsed: !state.settings.navCollapsed,
|
||||||
})}
|
})}
|
||||||
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
title="${navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
||||||
aria-label="${state.settings.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>
|
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.menu}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sidebar-shell__body">
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
${TAB_GROUPS.map((group) => {
|
${TAB_GROUPS.map((group) => {
|
||||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||||
const showItems = hasActiveTab || !isGroupCollapsed;
|
const showItems = navCollapsed || hasActiveTab || !isGroupCollapsed;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="nav-group ${!showItems ? "nav-group--collapsed" : ""}">
|
<section class="nav-section ${!showItems ? "nav-section--collapsed" : ""}">
|
||||||
${
|
${
|
||||||
!state.settings.navCollapsed
|
!navCollapsed
|
||||||
? html`
|
? html`
|
||||||
<button
|
<button
|
||||||
class="nav-group__label"
|
class="nav-section__label"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = { ...state.settings.navGroupsCollapsed };
|
const next = { ...state.settings.navGroupsCollapsed };
|
||||||
next[group.label] = !isGroupCollapsed;
|
next[group.label] = !isGroupCollapsed;
|
||||||
|
|
@ -495,24 +498,26 @@ export function renderApp(state: AppViewState) {
|
||||||
}}
|
}}
|
||||||
aria-expanded=${showItems}
|
aria-expanded=${showItems}
|
||||||
>
|
>
|
||||||
<span class="nav-group__label-text">${t(`nav.${group.label}`)}</span>
|
<span class="nav-section__label-text">${t(`nav.${group.label}`)}</span>
|
||||||
<span class="nav-group__chevron">${showItems ? icons.chevronDown : icons.chevronRight}</span>
|
<span class="nav-section__chevron">
|
||||||
|
${showItems ? icons.chevronDown : icons.chevronRight}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
`
|
`
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
<div class="nav-group__items">
|
<div class="nav-section__items">
|
||||||
${group.tabs.map((tab) => renderTab(state, tab))}
|
${group.tabs.map((tab) => renderTab(state, tab, { collapsed: navCollapsed }))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-shell__footer">
|
||||||
<div class="sidebar-footer__docs-block">
|
<div class="sidebar-utility-group">
|
||||||
<a
|
<a
|
||||||
class="nav-item nav-item--external"
|
class="nav-item nav-item--external sidebar-utility-link"
|
||||||
href="https://docs.openclaw.ai"
|
href="https://docs.openclaw.ai"
|
||||||
target=${EXTERNAL_LINK_TARGET}
|
target=${EXTERNAL_LINK_TARGET}
|
||||||
rel=${buildExternalLinkRel()}
|
rel=${buildExternalLinkRel()}
|
||||||
|
|
@ -520,7 +525,7 @@ export function renderApp(state: AppViewState) {
|
||||||
>
|
>
|
||||||
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
|
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
|
||||||
${
|
${
|
||||||
!state.settings.navCollapsed
|
!navCollapsed
|
||||||
? html`
|
? html`
|
||||||
<span class="nav-item__text">${t("common.docs")}</span>
|
<span class="nav-item__text">${t("common.docs")}</span>
|
||||||
<span class="nav-item__external-icon">${icons.externalLink}</span>
|
<span class="nav-item__external-icon">${icons.externalLink}</span>
|
||||||
|
|
@ -534,8 +539,11 @@ export function renderApp(state: AppViewState) {
|
||||||
? html`
|
? html`
|
||||||
<div class="sidebar-version" title=${`v${version}`}>
|
<div class="sidebar-version" title=${`v${version}`}>
|
||||||
${
|
${
|
||||||
!state.settings.navCollapsed
|
!navCollapsed
|
||||||
? html`<span class="sidebar-version__text">v${version}</span>`
|
? html`
|
||||||
|
<span class="sidebar-version__label">${t("common.version")}</span>
|
||||||
|
<span class="sidebar-version__text">v${version}</span>
|
||||||
|
`
|
||||||
: html`
|
: html`
|
||||||
<span class="sidebar-version__dot"></span>
|
<span class="sidebar-version__dot"></span>
|
||||||
`
|
`
|
||||||
|
|
@ -546,21 +554,8 @@ export function renderApp(state: AppViewState) {
|
||||||
})()}
|
})()}
|
||||||
</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" : ""}">
|
||||||
${
|
${
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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]) {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)}` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -812,6 +812,7 @@ 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">
|
||||||
|
<div class="config-search__input-row">
|
||||||
<svg
|
<svg
|
||||||
class="config-search__icon"
|
class="config-search__icon"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|
@ -843,6 +844,7 @@ export function renderConfig(props: ConfigProps) {
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue