From 19fc23c529e748201006c2374f687fb68d96e89c Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 29 Apr 2026 16:59:29 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(react-ui):=20redesign=20chat=20?= =?UTF-8?q?=E2=80=94=20popover=20history,=20focus=20on=20send,=20density?= =?UTF-8?q?=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the persistent 260px conversation sidebar with a Cmd/Ctrl+K popover (ChatsMenu) so the conversation owns the page. Once a chat has at least one message we auto-collapse the global app rail and fade non-essential header chrome; Esc gives the user back the full chrome for the rest of the session. Move Canvas mode and the MCP dropdown into the input wrapper as mode chips — they describe what's armed for the next message and now live where the user composes. The chat header drops to Chats · title · ModelSelector · overflow · settings, and an overflow menu carries admin-only Manage mode along with Info / Edit / Export / Clear. Density pass: tighter header (40px), smaller avatars with the assistant left-border accent doing the work, 88% bubble width, modern field-sizing on the textarea, 32px send/stop buttons. Empty state now surfaces a Recent strip (top 4 non-empty chats) and a Cmd+K hint, replacing the discoverability the persistent sidebar used to provide. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude:claude-opus-4-7 --- core/http/react-ui/src/App.css | 606 +++++++++++++++++- .../src/components/ChatHeaderOverflow.jsx | 119 ++++ .../react-ui/src/components/ChatsMenu.jsx | 266 ++++++++ core/http/react-ui/src/pages/Chat.jsx | 467 ++++++-------- 4 files changed, 1173 insertions(+), 285 deletions(-) create mode 100644 core/http/react-ui/src/components/ChatHeaderOverflow.jsx create mode 100644 core/http/react-ui/src/components/ChatsMenu.jsx diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 71bbda6cb80e..b8ac7fb86998 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -2785,12 +2785,13 @@ select.input { flex: 1; overflow-y: auto; overflow-x: hidden; - padding: var(--spacing-lg) var(--spacing-xl); + padding: var(--spacing-md) var(--spacing-lg); display: flex; flex-direction: column; - gap: var(--spacing-md); + gap: var(--spacing-sm); scrollbar-width: thin; scrollbar-color: var(--color-border-subtle) transparent; + transition: padding-top var(--duration-normal) var(--ease-default); } .chat-messages::-webkit-scrollbar { width: 6px; } .chat-messages::-webkit-scrollbar-track { background: transparent; } @@ -2805,7 +2806,7 @@ select.input { .chat-message { display: flex; gap: var(--spacing-sm); - max-width: 80%; + max-width: 88%; min-width: 0; animation: messageSlideIn 250ms ease-out; } @@ -2820,13 +2821,13 @@ select.input { } .chat-message-avatar { - width: 32px; - height: 32px; - border-radius: var(--radius-md); + width: 28px; + height: 28px; + border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; - font-size: 0.8rem; + font-size: 0.75rem; flex-shrink: 0; } @@ -2835,9 +2836,10 @@ select.input { color: var(--color-primary-text); } +/* Assistant gets the left-border accent on the bubble; the avatar is + visual noise once that accent is in place. */ .chat-message-assistant .chat-message-avatar { - background: var(--color-accent-light); - color: var(--color-accent); + display: none; } .chat-message-bubble { @@ -2853,7 +2855,7 @@ select.input { font-weight: 500; color: var(--color-text-muted); text-transform: uppercase; - letter-spacing: 0.18em; + letter-spacing: 0.14em; padding-left: 2px; } @@ -2862,7 +2864,7 @@ select.input { border: none; border-left: 2px solid var(--color-border-strong); border-radius: 0; - padding: 2px var(--spacing-md); + padding: 0 var(--spacing-md); font-size: var(--text-base); line-height: var(--leading-normal); word-break: break-word; @@ -2874,8 +2876,8 @@ select.input { color: var(--color-text-primary); border: 1px solid var(--color-primary-border); border-radius: 16px 4px 16px 16px; - padding: var(--spacing-md) var(--spacing-lg); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); + padding: var(--spacing-sm) var(--spacing-md); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .chat-message-content pre { @@ -2974,23 +2976,147 @@ select.input { } .chat-input-area { - padding: var(--spacing-sm) var(--spacing-lg); + padding: var(--spacing-xs) var(--spacing-md) var(--spacing-sm); background: var(--color-bg-secondary); border-top: 1px solid var(--color-border-subtle); } .chat-input-wrapper { display: flex; - gap: var(--spacing-sm); - align-items: flex-end; + gap: var(--spacing-xs); + align-items: flex-start; + flex-wrap: wrap; background: var(--color-surface-raised); border: 1px solid var(--color-border-default); - border-radius: var(--radius-lg); + border-radius: var(--radius-md); padding: var(--spacing-xs); transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } + +/* Mode chips (Canvas, MCP) — render at the start of the input wrapper so + the user sees what's armed for the next message. Compact, low-noise + when off; subtly highlighted when on. The MCP trigger inherits the + same look via the nested-selector overrides below. */ +.chat-input-modes { + display: flex; + align-items: center; + gap: var(--spacing-xs); + flex-shrink: 0; +} +.chat-mode-chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + background: transparent; + border: 1px solid var(--color-border-subtle); + border-radius: 999px; + color: var(--color-text-muted); + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: background var(--duration-fast), border-color var(--duration-fast), color var(--duration-fast); +} +.chat-mode-chip:hover { + background: var(--color-bg-hover); + border-color: var(--color-border-default); + color: var(--color-text-secondary); +} +.chat-mode-chip:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} +.chat-mode-chip-on, +.chat-mode-chip-on:hover { + background: var(--color-primary-light); + border-color: var(--color-primary-border); + color: var(--color-primary); +} +.chat-mode-chip i { + font-size: 0.7rem; +} +.chat-mode-chip-label { + line-height: 1; +} +.chat-mode-chip-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + margin-left: 2px; + border-radius: 999px; + background: var(--color-primary); + color: var(--color-primary-text); + font-size: 0.65rem; + font-weight: 600; + cursor: pointer; + line-height: 1; +} +.chat-mode-chip-count:hover { + filter: brightness(1.1); +} + +/* MCP popover sits above the chip when it's anchored to the input row + (otherwise it would drop off-screen). */ +.chat-input-modes .chat-mcp-dropdown-menu { + top: auto; + bottom: calc(100% + 4px); +} + +/* When the MCP dropdown lives inside the modes row, make its trigger + match the chip aesthetic without altering the component itself. */ +.chat-input-modes .chat-mcp-dropdown > .btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + background: transparent; + border: 1px solid var(--color-border-subtle); + border-radius: 999px; + color: var(--color-text-muted); + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + box-shadow: none; + text-transform: none; + letter-spacing: 0; + transition: background var(--duration-fast), border-color var(--duration-fast), color var(--duration-fast); +} +.chat-input-modes .chat-mcp-dropdown > .btn:hover { + background: var(--color-bg-hover); + border-color: var(--color-border-default); + color: var(--color-text-secondary); +} +.chat-input-modes .chat-mcp-dropdown > .btn .chat-mcp-badge { + background: var(--color-primary); + color: var(--color-primary-text); +} +/* Active state when at least one MCP source is selected — UnifiedMCPDropdown + exposes its count via .chat-mcp-badge, so we use :has() to lift the + chip into the "on" look. Falls back to the neutral chip look on browsers + without :has(). */ +.chat-input-modes .chat-mcp-dropdown > .btn:has(.chat-mcp-badge), +.chat-input-modes .chat-mcp-dropdown > .btn:has(.chat-mcp-badge):hover { + background: var(--color-primary-light); + border-color: var(--color-primary-border); + color: var(--color-primary); +} + +@media (max-width: 480px) { + .chat-mode-chip-label { display: none; } + .chat-mode-chip, + .chat-input-modes .chat-mcp-dropdown > .btn { + padding: 0 8px; + } +} .chat-input-wrapper:focus-within { border-color: var(--color-primary); + box-shadow: 0 0 0 1px var(--color-primary-border); } .chat-attach-btn { @@ -3009,15 +3135,17 @@ select.input { background: transparent; color: var(--color-text-primary); border: none; - padding: var(--spacing-xs) var(--spacing-sm); + padding: 6px var(--spacing-sm); font-size: 0.875rem; font-family: inherit; outline: none; resize: none; - min-height: 36px; + min-height: 32px; max-height: 200px; line-height: 1.5; overflow-y: auto; + /* Modern auto-grow — JS auto-grow stays as a fallback for older engines. */ + field-sizing: content; } .chat-input::placeholder { @@ -3034,9 +3162,10 @@ select.input { display: flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; + width: 32px; + height: 32px; flex-shrink: 0; + align-self: flex-start; transition: background var(--duration-fast), transform var(--duration-fast); } @@ -3062,9 +3191,10 @@ select.input { display: flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; + width: 32px; + height: 32px; flex-shrink: 0; + align-self: flex-start; transition: background var(--duration-fast); } .chat-stop-btn:hover { @@ -3201,10 +3331,12 @@ select.input { align-items: center; justify-content: center; flex: 1; - padding: var(--spacing-xl); + padding: var(--spacing-lg) var(--spacing-md); text-align: center; - min-height: 300px; + min-height: 240px; + gap: var(--spacing-md); } +.chat-empty-state > * { margin-top: 0; margin-bottom: 0; } .chat-empty-icon { font-size: 3rem; color: var(--color-border-default); @@ -3272,6 +3404,383 @@ select.input { font-size: 0.625rem; } +/* Recent strip on the empty state — replaces the old persistent + conversation sidebar. Visible only while messages.length === 0. */ +.chat-recent-strip { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + width: 100%; + max-width: 720px; + margin-top: var(--spacing-sm); +} +.chat-recent-strip-label { + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-muted); + text-align: left; +} +.chat-recent-strip-kbd { + font-family: var(--font-mono); + font-size: 0.65rem; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border-subtle); + text-transform: none; + letter-spacing: 0; +} +.chat-recent-strip-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--spacing-sm); +} +.chat-recent-strip-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--color-surface-raised); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + font-family: inherit; + text-align: left; + cursor: pointer; + transition: border-color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); +} +.chat-recent-strip-item:hover { + border-color: var(--color-primary-border); + background: var(--color-surface-raised); + transform: translateY(-1px); +} +.chat-recent-strip-item-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-recent-strip-item-preview { + font-size: 0.75rem; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-recent-strip-item-time { + font-size: 0.6875rem; + color: var(--color-text-tertiary); + margin-top: 2px; +} + +/* ChatsMenu — popover replacing the old persistent history sidebar. */ +.chats-menu { + position: relative; + display: inline-block; + flex-shrink: 0; +} +.chats-menu-trigger { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); +} +.chats-menu-trigger.active { + background: var(--color-primary-light); + border-color: var(--color-primary-border); + color: var(--color-primary); +} +.chats-menu-trigger-label { + font-weight: 500; +} +.chats-menu-trigger-kbd { + display: none; + font-family: var(--font-mono); + font-size: 0.65rem; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: var(--color-bg-tertiary); + color: var(--color-text-muted); + border: 1px solid var(--color-border-subtle); +} +@media (min-width: 640px) { + .chats-menu-trigger-kbd { display: inline-block; } +} +.chats-menu-popover { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 100; + width: 320px; + max-width: calc(100vw - var(--spacing-md) * 2); + background: var(--color-bg-primary); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + max-height: min(70vh, 520px); + animation: dropdownIn 120ms ease-out; +} +.chats-menu-search { + position: relative; + padding: var(--spacing-xs) var(--spacing-sm); + border-bottom: 1px solid var(--color-border-divider); +} +.chats-menu-search-icon { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--color-text-muted); + font-size: 0.75rem; + pointer-events: none; +} +.chats-menu-search-input { + width: 100%; + padding: 6px 26px 6px 28px; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + font-size: 0.8125rem; + color: var(--color-text-primary); + outline: none; + transition: border-color var(--duration-fast); +} +.chats-menu-search-input:focus { + border-color: var(--color-primary-border); +} +.chats-menu-search-clear { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: 0.75rem; + padding: 2px; +} +.chats-menu-list { + flex: 1; + overflow-y: auto; + padding: var(--spacing-xs); + scrollbar-width: thin; +} +.chats-menu-empty { + padding: var(--spacing-md); + text-align: center; + font-size: 0.8125rem; + color: var(--color-text-muted); +} +.chats-menu-item { + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--text-sm); + color: var(--color-text-secondary); + border-left: 2px solid transparent; + transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast); +} +.chats-menu-item.highlighted { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} +.chats-menu-item.active { + color: var(--color-primary); + border-left-color: var(--color-primary); + background: var(--color-primary-light); +} +.chats-menu-item-icon { + font-size: 0.7rem; + margin-top: 4px; + color: var(--color-text-tertiary); + flex-shrink: 0; +} +.chats-menu-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.chats-menu-item-top { + display: flex; + align-items: center; + gap: var(--spacing-xs); +} +.chats-menu-item-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + font-size: 0.8125rem; +} +.chats-menu-item-spin { + margin-right: 6px; + font-size: 0.7rem; + opacity: 0.7; +} +.chats-menu-item-time { + font-size: 0.65rem; + color: var(--color-text-muted); + white-space: nowrap; + flex-shrink: 0; +} +.chats-menu-item-preview { + font-size: 0.7rem; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} +.chats-menu-item-rename { + flex: 1; + font-size: 0.8125rem; + padding: 2px 4px; +} +.chats-menu-item-actions { + display: flex; + gap: 2px; + opacity: 0; + transition: opacity var(--duration-fast); + flex-shrink: 0; +} +.chats-menu-item:hover .chats-menu-item-actions, +.chats-menu-item.highlighted .chats-menu-item-actions { + opacity: 1; +} +.chats-menu-item-actions button { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + padding: 2px 4px; + font-size: 0.7rem; + border-radius: var(--radius-sm); +} +.chats-menu-item-actions button:hover { + color: var(--color-text-primary); + background: var(--color-bg-tertiary); +} +.chats-menu-item-actions .chats-menu-item-delete:hover { + color: var(--color-error); +} +.chats-menu-footer { + display: flex; + gap: var(--spacing-xs); + padding: var(--spacing-xs); + border-top: 1px solid var(--color-border-divider); +} +.chats-menu-new { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); +} + +/* Mobile: popover becomes a full-width drawer anchored under the header. */ +@media (max-width: 639px) { + .chats-menu-popover { + position: fixed; + top: 56px; + left: 0; + right: 0; + width: 100%; + max-width: 100%; + border-radius: 0; + border-left: none; + border-right: none; + max-height: calc(100dvh - 56px); + } +} + +/* Chat header overflow menu */ +.chat-overflow-menu { + position: relative; + display: inline-block; +} +.chat-overflow-trigger.active { + background: var(--color-primary-light); + border-color: var(--color-primary-border); + color: var(--color-primary); +} +.chat-overflow-popover { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 100; + min-width: 220px; + background: var(--color-bg-primary); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: var(--spacing-xs); + animation: dropdownIn 120ms ease-out; +} +.chat-overflow-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + width: 100%; + padding: 6px var(--spacing-sm); + background: none; + border: none; + border-radius: var(--radius-sm); + font: inherit; + font-size: 0.8125rem; + color: var(--color-text-primary); + cursor: pointer; + text-align: left; + transition: background var(--duration-fast), color var(--duration-fast); +} +.chat-overflow-item:hover, +.chat-overflow-item:focus-visible { + background: var(--color-bg-hover); + outline: none; +} +.chat-overflow-item-danger { + color: var(--color-error); +} +.chat-overflow-item-danger:hover { + background: rgba(220, 38, 38, 0.08); +} +.chat-overflow-icon { + font-size: 0.8rem; + width: 16px; + text-align: center; + flex-shrink: 0; + color: var(--color-text-secondary); +} +.chat-overflow-item-danger .chat-overflow-icon { color: var(--color-error); } +.chat-overflow-label { + flex: 1; + min-width: 0; +} +.chat-overflow-check { + width: 16px; + flex-shrink: 0; + font-size: 0.75rem; + color: var(--color-primary); +} +.chat-overflow-sep { + height: 1px; + background: var(--color-border-divider); + margin: 4px 0; +} + /* Activity group (thinking + tools collapsed into one line) */ @keyframes shimmer { 0% { background-position: -200% 0; } @@ -3485,34 +3994,73 @@ select.input { /* Chat header */ .chat-header { - padding: var(--spacing-sm) var(--spacing-md); + padding: var(--spacing-xs) var(--spacing-md); border-bottom: 1px solid var(--color-border-subtle); display: flex; align-items: center; - gap: var(--spacing-sm); + gap: var(--spacing-xs); background: var(--color-bg-secondary); flex-shrink: 0; + min-height: 40px; } .chat-header-title { - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 600; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 200px; + flex: 0 1 auto; + min-width: 0; +} +.chat-header-shield { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: var(--radius-sm); + font-size: 0.75rem; + color: var(--color-accent); + background: var(--color-accent-light); + flex-shrink: 0; } .chat-header-actions { margin-left: auto; display: flex; align-items: center; gap: var(--spacing-xs); + flex-shrink: 0; } .chat-header-actions .btn-secondary.active { background: var(--color-primary-light); border-color: var(--color-primary-border); color: var(--color-primary); } + +/* Focus mode: once a conversation is underway, fade non-essential + header chrome and pull the messages padding tighter. Hover or focus + on the header brings everything back; Esc removes focus mode for the + rest of the session. */ +.chat--focus .chat-header-title, +.chat--focus .chat-header-shield { + opacity: 0.45; + transition: opacity var(--duration-normal) var(--ease-default); +} +.chat--focus .chat-header:hover .chat-header-title, +.chat--focus .chat-header:hover .chat-header-shield, +.chat--focus .chat-header:focus-within .chat-header-title, +.chat--focus .chat-header:focus-within .chat-header-shield { + opacity: 1; +} +.chat--focus .chat-messages { + padding-top: var(--spacing-sm); +} +@media (prefers-reduced-motion: reduce) { + .chat--focus .chat-header-title, + .chat--focus .chat-header-shield, + .chat-messages { transition: none; } +} /* Chat MCP dropdown */ .chat-mcp-dropdown { position: relative; diff --git a/core/http/react-ui/src/components/ChatHeaderOverflow.jsx b/core/http/react-ui/src/components/ChatHeaderOverflow.jsx new file mode 100644 index 000000000000..4db1f9a7d632 --- /dev/null +++ b/core/http/react-ui/src/components/ChatHeaderOverflow.jsx @@ -0,0 +1,119 @@ +import { useState, useEffect, useRef } from 'react' + +export default function ChatHeaderOverflow({ + isAdmin, + hasModel, + manageMode, + onToggleManage, + onShowModelInfo, + onEditModel, + onExport, + onClearHistory, +}) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + const triggerRef = useRef(null) + + useEffect(() => { + if (!open) return + const handleClick = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false) + } + const handleKey = (e) => { + if (e.key === 'Escape') { + e.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + } + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKey) + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKey) + } + }, [open]) + + const close = () => setOpen(false) + + return ( +
+ + + {open && ( +
+ {isAdmin && ( + + )} + + {(isAdmin && hasModel) && ( + <> +
+ + + + )} + + {isAdmin &&
} + + +
+ )} +
+ ) +} diff --git a/core/http/react-ui/src/components/ChatsMenu.jsx b/core/http/react-ui/src/components/ChatsMenu.jsx new file mode 100644 index 000000000000..101ae1a7438e --- /dev/null +++ b/core/http/react-ui/src/components/ChatsMenu.jsx @@ -0,0 +1,266 @@ +import { useState, useEffect, useRef, useCallback, useImperativeHandle, forwardRef } from 'react' +import { relativeTime } from '../utils/format' + +function getLastMessagePreview(chat) { + if (!chat?.history || chat.history.length === 0) return '' + for (let i = chat.history.length - 1; i >= 0; i--) { + const msg = chat.history[i] + if (msg.role === 'user' || msg.role === 'assistant') { + const text = typeof msg.content === 'string' ? msg.content : msg.content?.[0]?.text || '' + return text.slice(0, 60).replace(/\n/g, ' ') + } + } + return '' +} + +const ChatsMenu = forwardRef(function ChatsMenu({ + chats, + activeChatId, + streamingChatId, + onSelect, + onNew, + onDelete, + onDeleteAll, + onRename, +}, ref) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [editingId, setEditingId] = useState(null) + const [editName, setEditName] = useState('') + const [activeIdx, setActiveIdx] = useState(0) + const containerRef = useRef(null) + const searchRef = useRef(null) + const triggerRef = useRef(null) + const listRef = useRef(null) + + useImperativeHandle(ref, () => ({ + open: () => setOpen(true), + close: () => setOpen(false), + toggle: () => setOpen(prev => !prev), + }), []) + + const filtered = search.trim() + ? chats.filter(c => { + const q = search.toLowerCase() + if ((c.name || '').toLowerCase().includes(q)) return true + return c.history?.some(m => { + const t = typeof m.content === 'string' ? m.content : m.content?.[0]?.text || '' + return t.toLowerCase().includes(q) + }) + }) + : chats + + // Click-outside to close + useEffect(() => { + if (!open) return + const handleClick = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open]) + + // Esc closes; focus search on open; reset state on close + useEffect(() => { + if (!open) { + setSearch('') + setEditingId(null) + setActiveIdx(0) + // Restore focus to trigger when closing + triggerRef.current?.focus() + return + } + setActiveIdx(filtered.findIndex(c => c.id === activeChatId)) + // Focus search shortly after open so the popover animation doesn't fight focus + const t = setTimeout(() => searchRef.current?.focus(), 20) + const onKey = (e) => { + if (e.key === 'Escape') { + e.preventDefault() + setOpen(false) + } + } + window.addEventListener('keydown', onKey) + return () => { + clearTimeout(t) + window.removeEventListener('keydown', onKey) + } + }, [open]) + + const handleSelect = useCallback((id) => { + onSelect?.(id) + setOpen(false) + }, [onSelect]) + + const handleNew = useCallback(() => { + onNew?.() + setOpen(false) + }, [onNew]) + + const startRename = (id, name) => { + setEditingId(id) + setEditName(name || '') + } + + const finishRename = () => { + if (editingId && editName.trim()) { + onRename?.(editingId, editName.trim()) + } + setEditingId(null) + } + + const handleListKey = (e) => { + if (filtered.length === 0) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIdx(i => Math.min(i + 1, filtered.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx(i => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + const target = filtered[activeIdx] + if (target) handleSelect(target.id) + } + } + + // Keep highlighted item scrolled into view + useEffect(() => { + if (!open) return + const el = listRef.current?.querySelector(`[data-idx="${activeIdx}"]`) + el?.scrollIntoView({ block: 'nearest' }) + }, [activeIdx, open]) + + return ( +
+ + + {open && ( +
+
+ + { setSearch(e.target.value); setActiveIdx(0) }} + placeholder="Search conversations..." + /> + {search && ( + + )} +
+ +
+ {filtered.length === 0 && ( +
+ {search ? 'No conversations match your search' : 'No conversations yet'} +
+ )} + {filtered.map((chat, idx) => ( +
handleSelect(chat.id)} + onMouseEnter={() => setActiveIdx(idx)} + > + + {editingId === chat.id ? ( + setEditName(e.target.value)} + onBlur={finishRename} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); finishRename() } + if (e.key === 'Escape') { e.preventDefault(); setEditingId(null) } + }} + autoFocus + onClick={(e) => e.stopPropagation()} + /> + ) : ( +
+
+ { e.stopPropagation(); startRename(chat.id, chat.name) }} + > + {streamingChatId === chat.id && ( + + )} + {chat.name} + + {relativeTime(chat.updatedAt)} +
+ + {getLastMessagePreview(chat) || 'No messages yet'} + +
+ )} +
+ + {chats.length > 1 && ( + + )} +
+
+ ))} +
+ +
+ + {chats.length > 1 && ( + + )} +
+
+ )} +
+ ) +}) + +export default ChatsMenu diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 4dbdf15f8fc9..63dc69acd109 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -12,6 +12,8 @@ import MCPAppFrame from '../components/MCPAppFrame' import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown' import { loadClientMCPServers } from '../utils/mcpClientStorage' import ConfirmDialog from '../components/ConfirmDialog' +import ChatsMenu from '../components/ChatsMenu' +import ChatHeaderOverflow from '../components/ChatHeaderOverflow' import { useAuth } from '../context/AuthContext' import { useOperations } from '../hooks/useOperations' import { relativeTime } from '../utils/format' @@ -295,8 +297,6 @@ export default function Chat() { const [input, setInput] = useState('') const [files, setFiles] = useState([]) const [showSettings, setShowSettings] = useState(false) - const [editingName, setEditingName] = useState(null) - const [editName, setEditName] = useState('') const [mcpAvailable, setMcpAvailable] = useState(false) const [mcpServerList, setMcpServerList] = useState([]) const [mcpServersLoading, setMcpServersLoading] = useState(false) @@ -307,10 +307,8 @@ export default function Chat() { const [mcpPromptArgsValues, setMcpPromptArgsValues] = useState({}) const [mcpResourceList, setMcpResourceList] = useState([]) const [mcpResourcesLoading, setMcpResourcesLoading] = useState(false) - const [chatSearch, setChatSearch] = useState('') const [modelInfo, setModelInfo] = useState(null) const [showModelInfo, setShowModelInfo] = useState(false) - const [sidebarOpen, setSidebarOpen] = useState(true) const [canvasMode, setCanvasMode] = useState(false) const [canvasOpen, setCanvasOpen] = useState(false) const [selectedArtifactId, setSelectedArtifactId] = useState(null) @@ -328,6 +326,17 @@ export default function Chat() { const messagesRef = useRef(null) const textareaRef = useRef(null) const stickToBottomRef = useRef(true) + const chatsMenuRef = useRef(null) + + // Focus mode: once a conversation has at least one message we slim the + // surrounding chrome (collapse the global app rail, fade non-essential + // header items). Esc gives the user back the full chrome for the rest of + // this session. + const isInConversation = (activeChat?.history?.length || 0) > 0 + const [focusOverride, setFocusOverride] = useState(false) + const focusActive = isInConversation && !focusOverride + const prevAppCollapseRef = useRef(null) + const focusHintShownRef = useRef(false) const artifacts = useMemo( () => canvasMode ? extractCodeArtifacts(activeChat?.history, 'role', 'assistant') : [], @@ -594,12 +603,61 @@ export default function Chat() { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [activeChat?.history, streamingContent, streamingReasoning, streamingToolCalls]) - // When switching chats, snap to bottom and re-pin. + // When switching chats, snap to bottom and re-pin. Also reset the + // user's focus-mode override — each chat starts fresh. useEffect(() => { stickToBottomRef.current = true messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }) + setFocusOverride(false) }, [activeChat?.id]) + // Auto-collapse the global app rail when a conversation begins, and + // restore the previous collapsed state when the user goes back to an + // empty chat (or overrides focus with Esc). We feed into the existing + // sidebar-collapse event bus so App.jsx needs no awareness of focus mode. + useEffect(() => { + if (focusActive) { + if (prevAppCollapseRef.current === null) { + try { + prevAppCollapseRef.current = localStorage.getItem('localai_sidebar_collapsed') === 'true' + } catch (_) { prevAppCollapseRef.current = false } + } + window.dispatchEvent(new CustomEvent('sidebar-collapse', { detail: { collapsed: true } })) + if (!focusHintShownRef.current) { + focusHintShownRef.current = true + try { + if (!localStorage.getItem('localai_chat_focus_hint_shown')) { + addToast('Focus mode — press Esc to show controls', 'info', 3500) + localStorage.setItem('localai_chat_focus_hint_shown', '1') + } + } catch (_) { /* ignore quota errors */ } + } + } else if (prevAppCollapseRef.current !== null) { + window.dispatchEvent(new CustomEvent('sidebar-collapse', { detail: { collapsed: prevAppCollapseRef.current } })) + prevAppCollapseRef.current = null + } + }, [focusActive, addToast]) + + // Global keybindings: Cmd/Ctrl+K opens the chats menu; Esc exits focus + // mode while it is engaged (without closing any open dialogs first). + useEffect(() => { + const onKey = (e) => { + const isMod = e.metaKey || e.ctrlKey + if (isMod && (e.key === 'k' || e.key === 'K')) { + e.preventDefault() + chatsMenuRef.current?.toggle() + return + } + if (e.key === 'Escape' && focusActive) { + // Don't fight the chats menu / settings drawer / dialogs — they + // each handle their own Esc and stop propagation when open. + setFocusOverride(true) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [focusActive]) + // Highlight code blocks useEffect(() => { if (messagesRef.current) { @@ -747,199 +805,62 @@ export default function Chat() { } } - const startRename = (chatId, currentName) => { - setEditingName(chatId) - setEditName(currentName) - } - - const finishRename = () => { - if (editingName && editName.trim()) { - renameChat(editingName, editName.trim()) - } - setEditingName(null) - } - const copyMessage = (content) => { const text = typeof content === 'string' ? content : content?.[0]?.text || '' navigator.clipboard.writeText(text) addToast('Copied to clipboard', 'success', 2000) } - // Filter chats by search - const filteredChats = chatSearch.trim() - ? chats.filter(c => { - const q = chatSearch.toLowerCase() - if ((c.name || '').toLowerCase().includes(q)) return true - return c.history?.some(m => { - const t = typeof m.content === 'string' ? m.content : m.content?.[0]?.text || '' - return t.toLowerCase().includes(q) - }) - }) - : chats - const contextPercent = getContextUsagePercent() - if (!activeChat) return null + // Recent chats for the empty state — exclude the current chat and any + // empty placeholders, keep the four most recently updated. + const recentChats = chats + .filter(c => c.id !== activeChatId && (c.history?.length || 0) > 0) + .slice(0, 4) + + const promptDeleteAll = () => setConfirmDialog({ + title: 'Delete All Chats', + message: 'Delete all chats? This cannot be undone.', + confirmLabel: 'Delete all', + danger: true, + onConfirm: () => { setConfirmDialog(null); deleteAllChats() }, + }) - return ( -
- {/* Chat sidebar */} -
-
- - -
+ if (!activeChat) return null - {/* Chat search */} -
-
- - setChatSearch(e.target.value)} - placeholder="Search conversations..." - /> - {chatSearch && ( - - )} -
-
- -
- {filteredChats.map(chat => ( -
switchChat(chat.id)} - > - - {editingName === chat.id ? ( - setEditName(e.target.value)} - onBlur={finishRename} - onKeyDown={(e) => e.key === 'Enter' && finishRename()} - autoFocus - onClick={(e) => e.stopPropagation()} - style={{ padding: '2px 4px', fontSize: '0.8125rem' }} - /> - ) : ( -
-
- startRename(chat.id, chat.name)} - > - {streamingChatId === chat.id && } - {chat.name} - - {relativeTime(chat.updatedAt)} -
- - {getLastMessagePreview(chat) || 'No messages yet'} - -
- )} -
- - {chats.length > 1 && ( - - )} -
-
- ))} - {filteredChats.length === 0 && chatSearch && ( -
- No conversations match your search -
- )} -
-
+ const layoutClasses = [ + 'chat-layout', + isInConversation ? 'chat--has-messages' : '', + focusActive ? 'chat--focus' : '', + ].filter(Boolean).join(' ') + return ( +
{/* Chat main area */}
{/* Header */}
- - {activeChat.name} + addChat(activeChat.model)} + onDelete={deleteChat} + onDeleteAll={promptDeleteAll} + onRename={renameChat} + /> {activeChat.localaiAssistant && ( - - Manage mode + + )} - { - const allNames = mcpServerList.map(s => s.name) - const allSelected = allNames.every(n => (activeChat.mcpServers || []).includes(n)) - updateChatSettings(activeChat.id, { mcpServers: allSelected ? [] : allNames }) - }} - onFetchServers={fetchMcpServers} - clientMCPActiveIds={activeChat.clientMCPServers || []} - onClientToggle={handleClientMCPToggle} - onClientAdded={handleClientMCPServerAdded} - onClientRemoved={handleClientMCPServerRemoved} - connectionStatuses={connectionStatuses} - getConnectedTools={getConnectedTools} - promptsAvailable={mcpAvailable} - mcpPromptList={mcpPromptList} - mcpPromptsLoading={mcpPromptsLoading} - onFetchPrompts={fetchMcpPrompts} - onSelectPrompt={handleSelectPrompt} - promptArgsDialog={mcpPromptArgsDialog} - promptArgsValues={mcpPromptArgsValues} - onPromptArgsChange={(name, value) => setMcpPromptArgsValues(prev => ({ ...prev, [name]: value }))} - onPromptArgsSubmit={handleExpandPromptWithArgs} - onPromptArgsCancel={() => setMcpPromptArgsDialog(null)} - resourcesAvailable={mcpAvailable} - mcpResourceList={mcpResourceList} - mcpResourcesLoading={mcpResourcesLoading} - onFetchResources={fetchMcpResources} - selectedResources={activeChat.mcpResources || []} - onToggleResource={toggleMcpResource} - /> + {activeChat.name} updateChatSettings(activeChat.id, { model })} @@ -947,83 +868,22 @@ export default function Chat() { style={{ flex: '1 1 0', minWidth: 120 }} />
- {activeChat.model && isAdmin && ( - <> - - - - )} - - {isAdmin && ( - - )} - {canvasMode && artifacts.length > 0 && !canvasOpen && ( - - )} - - + updateChatSettings(activeChat.id, { localaiAssistant: checked })} + onShowModelInfo={() => setShowModelInfo(prev => !prev)} + onEditModel={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`)} + onExport={() => exportChatAsMarkdown(activeChat)} + onClearHistory={() => clearHistory(activeChat.id)} + /> @@ -1140,9 +1000,6 @@ export default function Chat() {
{activeChat.history.length === 0 && !isStreaming && (
-
- -

{activeChat.localaiAssistant ? 'Manage LocalAI by chatting' : 'Start a conversation'}

{activeChat.localaiAssistant @@ -1163,6 +1020,30 @@ export default function Chat() { ))}

+ {recentChats.length > 0 && ( +
+
+ Recent ⌘K +
+
+ {recentChats.map(chat => ( + + ))} +
+
+ )}
Enter to send Shift+Enter for newline @@ -1332,6 +1213,80 @@ export default function Chat() { {/* Input area */}
+
+ + { + const allNames = mcpServerList.map(s => s.name) + const allSelected = allNames.every(n => (activeChat.mcpServers || []).includes(n)) + updateChatSettings(activeChat.id, { mcpServers: allSelected ? [] : allNames }) + }} + onFetchServers={fetchMcpServers} + clientMCPActiveIds={activeChat.clientMCPServers || []} + onClientToggle={handleClientMCPToggle} + onClientAdded={handleClientMCPServerAdded} + onClientRemoved={handleClientMCPServerRemoved} + connectionStatuses={connectionStatuses} + getConnectedTools={getConnectedTools} + promptsAvailable={mcpAvailable} + mcpPromptList={mcpPromptList} + mcpPromptsLoading={mcpPromptsLoading} + onFetchPrompts={fetchMcpPrompts} + onSelectPrompt={handleSelectPrompt} + promptArgsDialog={mcpPromptArgsDialog} + promptArgsValues={mcpPromptArgsValues} + onPromptArgsChange={(name, value) => setMcpPromptArgsValues(prev => ({ ...prev, [name]: value }))} + onPromptArgsSubmit={handleExpandPromptWithArgs} + onPromptArgsCancel={() => setMcpPromptArgsDialog(null)} + resourcesAvailable={mcpAvailable} + mcpResourceList={mcpResourceList} + mcpResourcesLoading={mcpResourcesLoading} + onFetchResources={fetchMcpResources} + selectedResources={activeChat.mcpResources || []} + onToggleResource={toggleMcpResource} + /> +
- - {open && ( -
- {isAdmin && ( - - )} - - {(isAdmin && hasModel) && ( - <> -
- - - - )} - - {isAdmin &&
} - - -
- )} -
- ) -} diff --git a/core/http/react-ui/src/components/ChatsMenu.jsx b/core/http/react-ui/src/components/ChatsMenu.jsx index 101ae1a7438e..00d4f0e60ed1 100644 --- a/core/http/react-ui/src/components/ChatsMenu.jsx +++ b/core/http/react-ui/src/components/ChatsMenu.jsx @@ -22,6 +22,7 @@ const ChatsMenu = forwardRef(function ChatsMenu({ onDelete, onDeleteAll, onRename, + onExport, }, ref) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') @@ -227,6 +228,15 @@ const ChatsMenu = forwardRef(function ChatsMenu({ > + {(chat.history?.length || 0) > 0 && onExport && ( + + )} {chats.length > 1 && ( + )} +
+ {isAdmin && activeChat.model && ( + + )} + +
{modelInfo.backend &&
Backend{modelInfo.backend}
} @@ -937,6 +941,26 @@ export default function Chat() {
+ {isAdmin && ( +
+
+ + Manage mode + + + Let this chat install models, switch backends, and edit configs by talking to LocalAI. + +
+ +
+ )}