From 1acbd6aea048f16a53c7a6f0498bace1a1f5f84c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 20:20:06 +0100 Subject: [PATCH] refactor(ui): split chat renderers and styles --- ui/src/styles/chat.css | 795 +----------------------------- ui/src/styles/chat/grouped.css | 143 ++++++ ui/src/styles/chat/layout.css | 253 ++++++++++ ui/src/styles/chat/legacy.css | 37 ++ ui/src/styles/chat/sidebar.css | 118 +++++ ui/src/styles/chat/text.css | 72 +++ ui/src/styles/chat/tool-cards.css | 167 +++++++ ui/src/ui/chat/grouped-render.ts | 166 +++++++ ui/src/ui/chat/legacy-render.ts | 121 +++++ ui/src/ui/chat/message-extract.ts | 86 ++++ ui/src/ui/chat/tool-cards.ts | 200 ++++++++ ui/src/ui/chat/tool-helpers.ts | 5 +- ui/src/ui/views/chat.ts | 544 +------------------- 13 files changed, 1380 insertions(+), 1327 deletions(-) create mode 100644 ui/src/styles/chat/grouped.css create mode 100644 ui/src/styles/chat/layout.css create mode 100644 ui/src/styles/chat/legacy.css create mode 100644 ui/src/styles/chat/sidebar.css create mode 100644 ui/src/styles/chat/text.css create mode 100644 ui/src/styles/chat/tool-cards.css create mode 100644 ui/src/ui/chat/grouped-render.ts create mode 100644 ui/src/ui/chat/legacy-render.ts create mode 100644 ui/src/ui/chat/message-extract.ts create mode 100644 ui/src/ui/chat/tool-cards.ts diff --git a/ui/src/styles/chat.css b/ui/src/styles/chat.css index 694bd110d..b23ea3eaa 100644 --- a/ui/src/styles/chat.css +++ b/ui/src/styles/chat.css @@ -1,789 +1,6 @@ -/* ============================================= - CHAT CARD LAYOUT - Flex container with sticky compose - ============================================= */ - -/* Main chat card - flex column layout, transparent background */ -.chat { - position: relative; - display: flex; - flex-direction: column; - flex: 1 1 0; - height: 100%; - min-height: 0; /* Allow flex shrinking */ - overflow: hidden; - background: transparent !important; - border: none !important; - box-shadow: none !important; -} - -/* Chat header - fixed at top, transparent */ -.chat-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - flex-wrap: nowrap; - flex-shrink: 0; - padding-bottom: 12px; - margin-bottom: 12px; - background: transparent; -} - -.chat-header__left { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; - min-width: 0; -} - -.chat-header__right { - display: flex; - align-items: center; - gap: 8px; -} - -.chat-session { - min-width: 180px; -} - -/* Chat thread - scrollable middle section, transparent */ -.chat-thread { - flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */ - overflow-y: auto; - overflow-x: hidden; - padding: 12px; - margin: 0 -12px; - min-height: 0; /* Allow shrinking for flex scroll behavior */ - border-radius: 12px; - background: transparent; -} - -/* Focus mode exit button */ -.chat-focus-exit { - position: absolute; - top: 12px; - right: 12px; - z-index: 100; - width: 32px; - height: 32px; - border-radius: 50%; - border: 1px solid var(--border); - background: var(--panel); - color: var(--muted); - font-size: 20px; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background 150ms ease-out, color 150ms ease-out, border-color 150ms ease-out; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); -} - -.chat-focus-exit:hover { - background: var(--panel-strong); - color: var(--text); - border-color: var(--accent); -} - -/* Chat compose - sticky at bottom */ -.chat-compose { - position: sticky; - bottom: 0; - flex-shrink: 0; - display: flex; - align-items: flex-end; - gap: 12px; - margin-top: auto; /* Push to bottom of flex container */ - padding: 16px 0 4px; - background: linear-gradient(to bottom, transparent, var(--bg) 20%); - z-index: 10; -} - -.chat-compose__field { - flex: 1 1 auto; - min-width: 0; -} - -/* Hide the "Message" label - keep textarea only */ -.chat-compose__field > span { - display: none; -} - -/* Override .field textarea min-height (180px) from components.css */ -.chat-compose .chat-compose__field textarea { - width: 100%; - min-height: 36px; - max-height: 150px; - padding: 8px 12px; - border-radius: 10px; - resize: vertical; - white-space: pre-wrap; - font-family: var(--font-body); - font-size: 14px; - line-height: 1.45; -} - -.chat-compose__field textarea:disabled { - opacity: 0.7; - cursor: not-allowed; -} - -.chat-compose__actions { - flex-shrink: 0; - display: flex; - align-items: stretch; -} - -.chat-compose .chat-compose__actions .btn { - padding: 8px 16px; - font-size: 13px; - min-height: 36px; - white-space: nowrap; -} - -/* Chat controls - moved to content-header area, left aligned */ -.chat-controls { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 12px; - flex-wrap: wrap; -} - -.chat-controls__session { - min-width: 140px; -} - -.chat-controls__thinking { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; -} - -/* Icon button style */ -.btn--icon { - padding: 8px !important; - min-width: 36px; - height: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.06); -} - -/* Controls separator */ -.chat-controls__separator { - color: rgba(255, 255, 255, 0.4); - font-size: 18px; - margin: 0 8px; - font-weight: 300; -} - -:root[data-theme="light"] .chat-controls__separator { - color: rgba(16, 24, 40, 0.3); -} - -.btn--icon:hover { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.2); -} - -/* Light theme icon button overrides */ -:root[data-theme="light"] .btn--icon { - background: rgba(255, 255, 255, 0.9); - border-color: rgba(16, 24, 40, 0.2); - box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); - color: rgba(16, 24, 40, 0.7); -} - -:root[data-theme="light"] .btn--icon:hover { - background: rgba(255, 255, 255, 1); - border-color: rgba(16, 24, 40, 0.3); - color: rgba(16, 24, 40, 0.9); -} - -.btn--icon svg { - display: block; -} - -.chat-controls__session select { - padding: 6px 10px; - font-size: 13px; -} - -.chat-controls__thinking { - display: flex; - align-items: center; - gap: 4px; - font-size: 12px; - padding: 4px 10px; - background: rgba(255, 255, 255, 0.04); - border-radius: 6px; - border: 1px solid var(--border); -} - -/* Light theme thinking indicator override */ -:root[data-theme="light"] .chat-controls__thinking { - background: rgba(255, 255, 255, 0.9); - border-color: rgba(16, 24, 40, 0.15); -} - -@media (max-width: 640px) { - .chat-session { - min-width: 140px; - } - - .chat-compose { - grid-template-columns: 1fr; - } - - .chat-controls { - flex-wrap: wrap; - gap: 8px; - } - - .chat-controls__session { - min-width: 120px; - } -} - -/* ============================================= - LEGACY CHAT LINE LAYOUT (non-grouped) - ============================================= */ - -.chat-line { - display: flex; - margin-bottom: 12px; -} - -.chat-line.user { - justify-content: flex-end; -} - -.chat-line.assistant, -.chat-line.other { - justify-content: flex-start; -} - -.chat-msg { - display: grid; - gap: 6px; - max-width: min(900px, 95%); -} - -.chat-line.user .chat-msg { - justify-items: end; -} - -.chat-stamp { - font-size: 11px; - color: var(--muted); -} - -.chat-line.user .chat-stamp { - text-align: right; -} - -/* ============================================= - CHAT TEXT STYLING - ============================================= */ - -.chat-text { - font-size: 14px; - line-height: 1.5; - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-text :where(p, ul, ol, pre, blockquote, table) { - margin: 0; -} - -.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote) { - margin-top: 0.75em; -} - -.chat-text :where(ul, ol) { - padding-left: 1.5em; -} - -.chat-text :where(li + li) { - margin-top: 0.25em; -} - -.chat-text :where(a) { - color: var(--accent); - text-decoration: underline; - text-underline-offset: 2px; -} - -.chat-text :where(a:hover) { - opacity: 0.8; -} - -.chat-text :where(code) { - font-family: var(--mono); - font-size: 0.9em; -} - -.chat-text :where(:not(pre) > code) { - background: rgba(0, 0, 0, 0.15); - padding: 0.15em 0.4em; - border-radius: 4px; -} - -.chat-text :where(pre) { - background: rgba(0, 0, 0, 0.15); - border-radius: 6px; - padding: 10px 12px; - overflow-x: auto; -} - -.chat-text :where(pre code) { - background: none; - padding: 0; -} - -.chat-text :where(blockquote) { - border-left: 3px solid var(--border); - padding-left: 12px; - color: var(--muted); -} - -.chat-text :where(hr) { - border: none; - border-top: 1px solid var(--border); - margin: 1em 0; -} - -/* ============================================= - GROUPED CHAT LAYOUT (Slack-style) - ============================================= */ - -/* Chat Group Layout - default (assistant/other on left) */ -.chat-group { - display: flex; - gap: 12px; - align-items: flex-start; - margin-bottom: 16px; - margin-left: 16px; - margin-right: 16px; -} - -/* User messages on right */ -.chat-group.user { - flex-direction: row-reverse; - justify-content: flex-start; -} - -.chat-group-messages { - display: flex; - flex-direction: column; - gap: 2px; - max-width: min(900px, calc(100% - 60px)); -} - -/* User messages align content right */ -.chat-group.user .chat-group-messages { - align-items: flex-end; -} - -.chat-group.user .chat-group-footer { - justify-content: flex-end; -} - -/* Footer at bottom of message group (role + time) */ -.chat-group-footer { - display: flex; - gap: 8px; - align-items: baseline; - margin-top: 6px; -} - -.chat-sender-name { - font-weight: 500; - font-size: 12px; - color: var(--muted); -} - -.chat-group-timestamp { - font-size: 11px; - color: var(--muted); - opacity: 0.7; -} - -/* Avatar Styles */ -.chat-avatar { - width: 40px; - height: 40px; - border-radius: 8px; - background: var(--panel-strong); - display: grid; - place-items: center; - font-weight: 600; - font-size: 14px; - flex-shrink: 0; - align-self: flex-end; /* Align with last message in group */ - margin-bottom: 4px; /* Optical alignment */ -} - -.chat-avatar.user { - background: rgba(245, 159, 74, 0.2); - color: rgba(245, 159, 74, 1); -} - -.chat-avatar.assistant { - background: rgba(52, 199, 183, 0.2); - color: rgba(52, 199, 183, 1); -} - -.chat-avatar.other { - background: rgba(150, 150, 150, 0.2); - color: rgba(150, 150, 150, 1); -} - -/* Minimal Bubble Design - dynamic width based on content */ -.chat-bubble { - display: inline-block; - border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.12); - border-radius: 12px; - padding: 10px 14px; - box-shadow: none; - transition: background 150ms ease-out, border-color 150ms ease-out; - max-width: 100%; - word-wrap: break-word; -} - -.chat-bubble:hover { - background: rgba(0, 0, 0, 0.18); -} - -/* User bubbles have different styling */ -.chat-group.user .chat-bubble { - background: rgba(245, 159, 74, 0.15); - border-color: rgba(245, 159, 74, 0.3); -} - -.chat-group.user .chat-bubble:hover { - background: rgba(245, 159, 74, 0.22); -} - -/* Streaming animation */ -.chat-bubble.streaming { - animation: pulsing-border 1.5s ease-out infinite; -} - -@keyframes pulsing-border { - 0%, 100% { - border-color: var(--border); - } - 50% { - border-color: var(--accent); - } -} - -/* Fade-in animation for new messages */ -.chat-bubble.fade-in { - animation: fade-in 200ms ease-out; -} - -@keyframes fade-in { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Tool Card Styles */ -.chat-tool-card { - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; - margin-top: 8px; - transition: border-color 150ms ease-out, background 150ms ease-out; - /* Fixed max-height to ensure cards don't expand too much */ - max-height: 120px; - overflow: hidden; -} - -.chat-tool-card:hover { - border-color: var(--accent); - background: rgba(0, 0, 0, 0.06); -} - -/* First tool card in a group - no top margin */ -.chat-tool-card:first-child { - margin-top: 0; -} - -.chat-tool-card--clickable { - cursor: pointer; -} - -.chat-tool-card--clickable:focus { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* Header with title and chevron */ -.chat-tool-card__header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; -} - -.chat-tool-card__title { - display: inline-flex; - align-items: center; - gap: 6px; - font-weight: 600; - font-size: 13px; - line-height: 1.2; -} - -.chat-tool-card__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - font-size: 14px; - line-height: 1; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - vertical-align: middle; - flex-shrink: 0; -} - -/* "View >" action link */ -.chat-tool-card__action { - font-size: 12px; - color: var(--accent); - opacity: 0.8; - transition: opacity 150ms ease-out; -} - -.chat-tool-card--clickable:hover .chat-tool-card__action { - opacity: 1; -} - -/* Status indicator for completed/empty results */ -.chat-tool-card__status { - font-size: 14px; - color: var(--ok); -} - -.chat-tool-card__status-text { - font-size: 11px; - margin-top: 4px; -} - -.chat-tool-card__detail { - font-size: 12px; - color: var(--muted); - margin-top: 4px; -} - -/* Collapsed preview - fixed height with truncation */ -.chat-tool-card__preview { - font-size: 11px; - color: var(--muted); - margin-top: 8px; - padding: 8px 10px; - background: rgba(0, 0, 0, 0.08); - border-radius: 6px; - white-space: pre-wrap; - overflow: hidden; - max-height: 44px; - line-height: 1.4; - border: 1px solid rgba(255, 255, 255, 0.04); -} - -.chat-tool-card--clickable:hover .chat-tool-card__preview { - background: rgba(0, 0, 0, 0.12); - border-color: rgba(255, 255, 255, 0.08); -} - -/* Short inline output */ -.chat-tool-card__inline { - font-size: 11px; - color: var(--text); - margin-top: 6px; - padding: 6px 8px; - background: rgba(0, 0, 0, 0.06); - border-radius: 4px; - white-space: pre-wrap; - word-break: break-word; -} - -/* Reading Indicator */ -.chat-reading-indicator { - background: transparent; - border: 1px solid var(--border); - padding: 12px; - display: inline-flex; -} - -.chat-reading-indicator__dots { - display: flex; - gap: 6px; - align-items: center; -} - -.chat-reading-indicator__dots span { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--muted); - animation: reading-pulse 1.4s ease-in-out infinite; -} - -.chat-reading-indicator__dots span:nth-child(1) { - animation-delay: 0s; -} - -.chat-reading-indicator__dots span:nth-child(2) { - animation-delay: 0.2s; -} - -.chat-reading-indicator__dots span:nth-child(3) { - animation-delay: 0.4s; -} - -@keyframes reading-pulse { - 0%, 60%, 100% { - opacity: 0.3; - transform: scale(0.8); - } - 30% { - opacity: 1; - transform: scale(1); - } -} - -/* Split View Layout */ -.chat-split-container { - display: flex; - gap: 0; - flex: 1; - min-height: 0; - height: 100%; -} - -.chat-main { - min-width: 400px; - display: flex; - flex-direction: column; - overflow: hidden; - /* Smooth transition when sidebar opens/closes */ - transition: flex 250ms ease-out; -} - -.chat-sidebar { - flex: 1; - min-width: 300px; - border-left: 1px solid var(--border); - display: flex; - flex-direction: column; - overflow: hidden; - animation: slide-in 200ms ease-out; -} - -@keyframes slide-in { - from { - opacity: 0; - transform: translateX(20px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -/* Sidebar Panel */ -.sidebar-panel { - display: flex; - flex-direction: column; - height: 100%; - background: var(--panel); -} - -.sidebar-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - border-bottom: 1px solid var(--border); - flex-shrink: 0; - position: sticky; - top: 0; - z-index: 10; - background: var(--panel); -} - -/* Smaller close button for sidebar */ -.sidebar-header .btn { - padding: 4px 8px; - font-size: 14px; - min-width: auto; - line-height: 1; -} - -.sidebar-title { - font-weight: 600; - font-size: 14px; -} - -.sidebar-content { - flex: 1; - overflow: auto; - padding: 16px; -} - -.sidebar-markdown { - font-size: 14px; - line-height: 1.5; -} - -.sidebar-markdown pre { - background: rgba(0, 0, 0, 0.12); - border-radius: 4px; - padding: 12px; - overflow-x: auto; -} - -.sidebar-markdown code { - font-family: var(--mono); - font-size: 13px; -} - -/* Mobile: Full-screen modal */ -@media (max-width: 768px) { - .chat-split-container { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 1000; - } - - .chat-main { - display: none; /* Hide chat on mobile when sidebar open */ - } - - .chat-sidebar { - width: 100%; - min-width: 0; - border-left: none; - } -} +@import "./chat/layout.css"; +@import "./chat/legacy.css"; +@import "./chat/text.css"; +@import "./chat/grouped.css"; +@import "./chat/tool-cards.css"; +@import "./chat/sidebar.css"; diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css new file mode 100644 index 000000000..879a0c8bc --- /dev/null +++ b/ui/src/styles/chat/grouped.css @@ -0,0 +1,143 @@ +/* ============================================= + GROUPED CHAT LAYOUT (Slack-style) + ============================================= */ + +/* Chat Group Layout - default (assistant/other on left) */ +.chat-group { + display: flex; + gap: 12px; + align-items: flex-start; + margin-bottom: 16px; + margin-left: 16px; + margin-right: 16px; +} + +/* User messages on right */ +.chat-group.user { + flex-direction: row-reverse; + justify-content: flex-start; +} + +.chat-group-messages { + display: flex; + flex-direction: column; + gap: 2px; + max-width: min(900px, calc(100% - 60px)); +} + +/* User messages align content right */ +.chat-group.user .chat-group-messages { + align-items: flex-end; +} + +.chat-group.user .chat-group-footer { + justify-content: flex-end; +} + +/* Footer at bottom of message group (role + time) */ +.chat-group-footer { + display: flex; + gap: 8px; + align-items: baseline; + margin-top: 6px; +} + +.chat-sender-name { + font-weight: 500; + font-size: 12px; + color: var(--muted); +} + +.chat-group-timestamp { + font-size: 11px; + color: var(--muted); + opacity: 0.7; +} + +/* Avatar Styles */ +.chat-avatar { + width: 40px; + height: 40px; + border-radius: 8px; + background: var(--panel-strong); + display: grid; + place-items: center; + font-weight: 600; + font-size: 14px; + flex-shrink: 0; + align-self: flex-end; /* Align with last message in group */ + margin-bottom: 4px; /* Optical alignment */ +} + +.chat-avatar.user { + background: rgba(245, 159, 74, 0.2); + color: rgba(245, 159, 74, 1); +} + +.chat-avatar.assistant { + background: rgba(52, 199, 183, 0.2); + color: rgba(52, 199, 183, 1); +} + +.chat-avatar.other { + background: rgba(150, 150, 150, 0.2); + color: rgba(150, 150, 150, 1); +} + +/* Minimal Bubble Design - dynamic width based on content */ +.chat-bubble { + display: inline-block; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); + border-radius: 12px; + padding: 10px 14px; + box-shadow: none; + transition: background 150ms ease-out, border-color 150ms ease-out; + max-width: 100%; + word-wrap: break-word; +} + +.chat-bubble:hover { + background: rgba(0, 0, 0, 0.18); +} + +/* User bubbles have different styling */ +.chat-group.user .chat-bubble { + background: rgba(245, 159, 74, 0.15); + border-color: rgba(245, 159, 74, 0.3); +} + +.chat-group.user .chat-bubble:hover { + background: rgba(245, 159, 74, 0.22); +} + +/* Streaming animation */ +.chat-bubble.streaming { + animation: pulsing-border 1.5s ease-out infinite; +} + +@keyframes pulsing-border { + 0%, 100% { + border-color: var(--border); + } + 50% { + border-color: var(--accent); + } +} + +/* Fade-in animation for new messages */ +.chat-bubble.fade-in { + animation: fade-in 200ms ease-out; +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css new file mode 100644 index 000000000..a416d92cd --- /dev/null +++ b/ui/src/styles/chat/layout.css @@ -0,0 +1,253 @@ +/* ============================================= + CHAT CARD LAYOUT - Flex container with sticky compose + ============================================= */ + +/* Main chat card - flex column layout, transparent background */ +.chat { + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 0; + height: 100%; + min-height: 0; /* Allow flex shrinking */ + overflow: hidden; + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +/* Chat header - fixed at top, transparent */ +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: nowrap; + flex-shrink: 0; + padding-bottom: 12px; + margin-bottom: 12px; + background: transparent; +} + +.chat-header__left { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + min-width: 0; +} + +.chat-header__right { + display: flex; + align-items: center; + gap: 8px; +} + +.chat-session { + min-width: 180px; +} + +/* Chat thread - scrollable middle section, transparent */ +.chat-thread { + flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */ + overflow-y: auto; + overflow-x: hidden; + padding: 12px; + margin: 0 -12px; + min-height: 0; /* Allow shrinking for flex scroll behavior */ + border-radius: 12px; + background: transparent; +} + +/* Focus mode exit button */ +.chat-focus-exit { + position: absolute; + top: 12px; + right: 12px; + z-index: 100; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--panel); + color: var(--muted); + font-size: 20px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 150ms ease-out, color 150ms ease-out, border-color 150ms ease-out; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.chat-focus-exit:hover { + background: var(--panel-strong); + color: var(--text); + border-color: var(--accent); +} + +/* Chat compose - sticky at bottom */ +.chat-compose { + position: sticky; + bottom: 0; + flex-shrink: 0; + display: flex; + align-items: flex-end; + gap: 12px; + margin-top: auto; /* Push to bottom of flex container */ + padding: 16px 0 4px; + background: linear-gradient(to bottom, transparent, var(--bg) 20%); + z-index: 10; +} + +.chat-compose__field { + flex: 1 1 auto; + min-width: 0; +} + +/* Hide the "Message" label - keep textarea only */ +.chat-compose__field > span { + display: none; +} + +/* Override .field textarea min-height (180px) from components.css */ +.chat-compose .chat-compose__field textarea { + width: 100%; + min-height: 36px; + max-height: 150px; + padding: 8px 12px; + border-radius: 10px; + resize: vertical; + white-space: pre-wrap; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; +} + +.chat-compose__field textarea:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.chat-compose__actions { + flex-shrink: 0; + display: flex; + align-items: stretch; +} + +.chat-compose .chat-compose__actions .btn { + padding: 8px 16px; + font-size: 13px; + min-height: 36px; + white-space: nowrap; +} + +/* Chat controls - moved to content-header area, left aligned */ +.chat-controls { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.chat-controls__session { + min-width: 140px; +} + +.chat-controls__thinking { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +/* Icon button style */ +.btn--icon { + padding: 8px !important; + min-width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.06); +} + +/* Controls separator */ +.chat-controls__separator { + color: rgba(255, 255, 255, 0.4); + font-size: 18px; + margin: 0 8px; + font-weight: 300; +} + +:root[data-theme="light"] .chat-controls__separator { + color: rgba(16, 24, 40, 0.3); +} + +.btn--icon:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); +} + +/* Light theme icon button overrides */ +:root[data-theme="light"] .btn--icon { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(16, 24, 40, 0.2); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); + color: rgba(16, 24, 40, 0.7); +} + +:root[data-theme="light"] .btn--icon:hover { + background: rgba(255, 255, 255, 1); + border-color: rgba(16, 24, 40, 0.3); + color: rgba(16, 24, 40, 0.9); +} + +.btn--icon svg { + display: block; +} + +.chat-controls__session select { + padding: 6px 10px; + font-size: 13px; +} + +.chat-controls__thinking { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + border: 1px solid var(--border); +} + +/* Light theme thinking indicator override */ +:root[data-theme="light"] .chat-controls__thinking { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(16, 24, 40, 0.15); +} + +@media (max-width: 640px) { + .chat-session { + min-width: 140px; + } + + .chat-compose { + grid-template-columns: 1fr; + } + + .chat-controls { + flex-wrap: wrap; + gap: 8px; + } + + .chat-controls__session { + min-width: 120px; + } +} + diff --git a/ui/src/styles/chat/legacy.css b/ui/src/styles/chat/legacy.css new file mode 100644 index 000000000..90601d0ca --- /dev/null +++ b/ui/src/styles/chat/legacy.css @@ -0,0 +1,37 @@ +/* ============================================= + LEGACY CHAT LINE LAYOUT (non-grouped) + ============================================= */ + +.chat-line { + display: flex; + margin-bottom: 12px; +} + +.chat-line.user { + justify-content: flex-end; +} + +.chat-line.assistant, +.chat-line.other { + justify-content: flex-start; +} + +.chat-msg { + display: grid; + gap: 6px; + max-width: min(900px, 95%); +} + +.chat-line.user .chat-msg { + justify-items: end; +} + +.chat-stamp { + font-size: 11px; + color: var(--muted); +} + +.chat-line.user .chat-stamp { + text-align: right; +} + diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css new file mode 100644 index 000000000..af7006778 --- /dev/null +++ b/ui/src/styles/chat/sidebar.css @@ -0,0 +1,118 @@ +/* Split View Layout */ +.chat-split-container { + display: flex; + gap: 0; + flex: 1; + min-height: 0; + height: 100%; +} + +.chat-main { + min-width: 400px; + display: flex; + flex-direction: column; + overflow: hidden; + /* Smooth transition when sidebar opens/closes */ + transition: flex 250ms ease-out; +} + +.chat-sidebar { + flex: 1; + min-width: 300px; + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + animation: slide-in 200ms ease-out; +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Sidebar Panel */ +.sidebar-panel { + display: flex; + flex-direction: column; + height: 100%; + background: var(--panel); +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 10; + background: var(--panel); +} + +/* Smaller close button for sidebar */ +.sidebar-header .btn { + padding: 4px 8px; + font-size: 14px; + min-width: auto; + line-height: 1; +} + +.sidebar-title { + font-weight: 600; + font-size: 14px; +} + +.sidebar-content { + flex: 1; + overflow: auto; + padding: 16px; +} + +.sidebar-markdown { + font-size: 14px; + line-height: 1.5; +} + +.sidebar-markdown pre { + background: rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 12px; + overflow-x: auto; +} + +.sidebar-markdown code { + font-family: var(--mono); + font-size: 13px; +} + +/* Mobile: Full-screen modal */ +@media (max-width: 768px) { + .chat-split-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + } + + .chat-main { + display: none; /* Hide chat on mobile when sidebar open */ + } + + .chat-sidebar { + width: 100%; + min-width: 0; + border-left: none; + } +} + diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css new file mode 100644 index 000000000..5e42b258d --- /dev/null +++ b/ui/src/styles/chat/text.css @@ -0,0 +1,72 @@ +/* ============================================= + CHAT TEXT STYLING + ============================================= */ + +.chat-text { + font-size: 14px; + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.chat-text :where(p, ul, ol, pre, blockquote, table) { + margin: 0; +} + +.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote) { + margin-top: 0.75em; +} + +.chat-text :where(ul, ol) { + padding-left: 1.5em; +} + +.chat-text :where(li + li) { + margin-top: 0.25em; +} + +.chat-text :where(a) { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.chat-text :where(a:hover) { + opacity: 0.8; +} + +.chat-text :where(code) { + font-family: var(--mono); + font-size: 0.9em; +} + +.chat-text :where(:not(pre) > code) { + background: rgba(0, 0, 0, 0.15); + padding: 0.15em 0.4em; + border-radius: 4px; +} + +.chat-text :where(pre) { + background: rgba(0, 0, 0, 0.15); + border-radius: 6px; + padding: 10px 12px; + overflow-x: auto; +} + +.chat-text :where(pre code) { + background: none; + padding: 0; +} + +.chat-text :where(blockquote) { + border-left: 3px solid var(--border); + padding-left: 12px; + color: var(--muted); +} + +.chat-text :where(hr) { + border: none; + border-top: 1px solid var(--border); + margin: 1em 0; +} + diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css new file mode 100644 index 000000000..d6998a35c --- /dev/null +++ b/ui/src/styles/chat/tool-cards.css @@ -0,0 +1,167 @@ +/* Tool Card Styles */ +.chat-tool-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + margin-top: 8px; + transition: border-color 150ms ease-out, background 150ms ease-out; + /* Fixed max-height to ensure cards don't expand too much */ + max-height: 120px; + overflow: hidden; +} + +.chat-tool-card:hover { + border-color: var(--accent); + background: rgba(0, 0, 0, 0.06); +} + +/* First tool card in a group - no top margin */ +.chat-tool-card:first-child { + margin-top: 0; +} + +.chat-tool-card--clickable { + cursor: pointer; +} + +.chat-tool-card--clickable:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Header with title and chevron */ +.chat-tool-card__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.chat-tool-card__title { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + font-size: 13px; + line-height: 1.2; +} + +.chat-tool-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 14px; + line-height: 1; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; + vertical-align: middle; + flex-shrink: 0; +} + +/* "View >" action link */ +.chat-tool-card__action { + font-size: 12px; + color: var(--accent); + opacity: 0.8; + transition: opacity 150ms ease-out; +} + +.chat-tool-card--clickable:hover .chat-tool-card__action { + opacity: 1; +} + +/* Status indicator for completed/empty results */ +.chat-tool-card__status { + font-size: 14px; + color: var(--ok); +} + +.chat-tool-card__status-text { + font-size: 11px; + margin-top: 4px; +} + +.chat-tool-card__detail { + font-size: 12px; + color: var(--muted); + margin-top: 4px; +} + +/* Collapsed preview - fixed height with truncation */ +.chat-tool-card__preview { + font-size: 11px; + color: var(--muted); + margin-top: 8px; + padding: 8px 10px; + background: rgba(0, 0, 0, 0.08); + border-radius: 6px; + white-space: pre-wrap; + overflow: hidden; + max-height: 44px; + line-height: 1.4; + border: 1px solid rgba(255, 255, 255, 0.04); +} + +.chat-tool-card--clickable:hover .chat-tool-card__preview { + background: rgba(0, 0, 0, 0.12); + border-color: rgba(255, 255, 255, 0.08); +} + +/* Short inline output */ +.chat-tool-card__inline { + font-size: 11px; + color: var(--text); + margin-top: 6px; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.06); + border-radius: 4px; + white-space: pre-wrap; + word-break: break-word; +} + +/* Reading Indicator */ +.chat-reading-indicator { + background: transparent; + border: 1px solid var(--border); + padding: 12px; + display: inline-flex; +} + +.chat-reading-indicator__dots { + display: flex; + gap: 6px; + align-items: center; +} + +.chat-reading-indicator__dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--muted); + animation: reading-pulse 1.4s ease-in-out infinite; +} + +.chat-reading-indicator__dots span:nth-child(1) { + animation-delay: 0s; +} + +.chat-reading-indicator__dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.chat-reading-indicator__dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes reading-pulse { + 0%, 60%, 100% { + opacity: 0.3; + transform: scale(0.8); + } + 30% { + opacity: 1; + transform: scale(1); + } +} + diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts new file mode 100644 index 000000000..146d5d8e6 --- /dev/null +++ b/ui/src/ui/chat/grouped-render.ts @@ -0,0 +1,166 @@ +import { html, nothing } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + +import { toSanitizedMarkdownHtml } from "../markdown"; +import type { MessageGroup } from "../types/chat-types"; +import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer"; +import { + extractText, + extractThinking, + formatReasoningMarkdown, +} from "./message-extract"; +import { extractToolCards, renderToolCardSidebar } from "./tool-cards"; + +export function renderReadingIndicatorGroup() { + return html` +
+ ${renderAvatar("assistant")} +
+ +
+
+ `; +} + +export function renderStreamingGroup( + text: string, + startedAt: number, + onOpenSidebar?: (content: string) => void, +) { + const timestamp = new Date(startedAt).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + + return html` +
+ ${renderAvatar("assistant")} +
+ ${renderGroupedMessage( + { + role: "assistant", + content: [{ type: "text", text }], + timestamp: startedAt, + }, + { isStreaming: true, showReasoning: false }, + onOpenSidebar, + )} + +
+
+ `; +} + +export function renderMessageGroup( + group: MessageGroup, + opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean }, +) { + const normalizedRole = normalizeRoleForGrouping(group.role); + const who = + normalizedRole === "user" + ? "You" + : normalizedRole === "assistant" + ? "Assistant" + : normalizedRole; + const roleClass = + normalizedRole === "user" + ? "user" + : normalizedRole === "assistant" + ? "assistant" + : "other"; + const timestamp = new Date(group.timestamp).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + + return html` +
+ ${renderAvatar(group.role)} +
+ ${group.messages.map((item, index) => + renderGroupedMessage( + item.message, + { + isStreaming: + group.isStreaming && index === group.messages.length - 1, + showReasoning: opts.showReasoning, + }, + opts.onOpenSidebar, + ), + )} + +
+
+ `; +} + +function renderAvatar(role: string) { + const normalized = normalizeRoleForGrouping(role); + const initial = normalized === "user" ? "U" : normalized === "assistant" ? "A" : "?"; + const className = normalized === "user" ? "user" : normalized === "assistant" ? "assistant" : "other"; + return html`
${initial}
`; +} + +function renderGroupedMessage( + message: unknown, + opts: { isStreaming: boolean; showReasoning: boolean }, + onOpenSidebar?: (content: string) => void, +) { + const m = message as Record; + const role = typeof m.role === "string" ? m.role : "unknown"; + const isToolResult = + isToolResultMessage(message) || + role.toLowerCase() === "toolresult" || + role.toLowerCase() === "tool_result" || + typeof m.toolCallId === "string" || + typeof m.tool_call_id === "string"; + + const toolCards = extractToolCards(message); + const hasToolCards = toolCards.length > 0; + + const extractedText = extractText(message); + const extractedThinking = + opts.showReasoning && role === "assistant" ? extractThinking(message) : null; + const markdownBase = extractedText?.trim() ? extractedText : null; + const markdown = extractedThinking + ? [formatReasoningMarkdown(extractedThinking), markdownBase] + .filter(Boolean) + .join("\n\n") + : markdownBase; + + const bubbleClasses = [ + "chat-bubble", + opts.isStreaming ? "streaming" : "", + "fade-in", + ] + .filter(Boolean) + .join(" "); + + if (!markdown && hasToolCards && isToolResult) { + return html`${toolCards.map((card) => + renderToolCardSidebar(card, onOpenSidebar), + )}`; + } + + if (!markdown && !hasToolCards) return nothing; + + return html` +
+ ${markdown + ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` + : nothing} + ${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))} +
+ `; +} + diff --git a/ui/src/ui/chat/legacy-render.ts b/ui/src/ui/chat/legacy-render.ts new file mode 100644 index 000000000..735b17c2d --- /dev/null +++ b/ui/src/ui/chat/legacy-render.ts @@ -0,0 +1,121 @@ +import { html, nothing } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + +import { toSanitizedMarkdownHtml } from "../markdown"; +import { + isToolResultMessage, + normalizeRoleForGrouping, +} from "./message-normalizer"; +import { + extractText, + extractThinking, + formatReasoningMarkdown, +} from "./message-extract"; +import { extractToolCards, renderToolCardLegacy } from "./tool-cards"; + +export type LegacyToolOutputProps = { + isToolOutputExpanded?: (id: string) => boolean; + onToolOutputToggle?: (id: string, expanded: boolean) => void; +}; + +export function renderReadingIndicator() { + return html` +
+
+ +
+
+ `; +} + +export function renderMessage( + message: unknown, + props?: LegacyToolOutputProps, + opts?: { streaming?: boolean; showReasoning?: boolean }, +) { + const m = message as Record; + const role = typeof m.role === "string" ? m.role : "unknown"; + const toolCards = extractToolCards(message); + const hasToolCards = toolCards.length > 0; + const isToolResult = + isToolResultMessage(message) || + typeof m.toolCallId === "string" || + typeof m.tool_call_id === "string"; + const extractedText = extractText(message); + const extractedThinking = + opts?.showReasoning && role === "assistant" ? extractThinking(message) : null; + const contentText = typeof m.content === "string" ? m.content : null; + const fallback = hasToolCards ? null : JSON.stringify(message, null, 2); + + const display = + !isToolResult && extractedText?.trim() + ? { kind: "text" as const, value: extractedText } + : !isToolResult && contentText?.trim() + ? { kind: "text" as const, value: contentText } + : !isToolResult && fallback + ? { kind: "json" as const, value: fallback } + : null; + + const markdownBase = + display?.kind === "json" + ? ["```json", display.value, "```"].join("\n") + : (display?.value ?? null); + const markdown = extractedThinking + ? [formatReasoningMarkdown(extractedThinking), markdownBase] + .filter(Boolean) + .join("\n\n") + : markdownBase; + + const timestamp = + typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; + + const normalizedRole = normalizeRoleForGrouping(role); + const klass = + normalizedRole === "assistant" + ? "assistant" + : normalizedRole === "user" + ? "user" + : "other"; + const who = + normalizedRole === "assistant" + ? "Assistant" + : normalizedRole === "user" + ? "You" + : normalizedRole; + + const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; + const toolCardBase = + toolCallId || + (typeof m.id === "string" ? m.id : "") || + (typeof m.messageId === "string" ? m.messageId : "") || + (typeof m.timestamp === "number" ? String(m.timestamp) : "tool-card"); + + return html` +
+
+
+ ${markdown + ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` + : nothing} + ${toolCards.map((card, index) => + renderToolCardLegacy(card, { + id: `${toolCardBase}:${index}`, + expanded: props?.isToolOutputExpanded + ? props.isToolOutputExpanded(`${toolCardBase}:${index}`) + : false, + onToggle: props?.onToolOutputToggle, + }), + )} +
+
+ ${who}${timestamp ? html` · ${timestamp}` : nothing} +
+
+
+ `; +} + diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts new file mode 100644 index 000000000..d08c3258b --- /dev/null +++ b/ui/src/ui/chat/message-extract.ts @@ -0,0 +1,86 @@ +import { stripThinkingTags } from "../format"; + +export function extractText(message: unknown): string | null { + const m = message as Record; + const role = typeof m.role === "string" ? m.role : ""; + const content = m.content; + if (typeof content === "string") { + return role === "assistant" ? stripThinkingTags(content) : content; + } + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + if (parts.length > 0) { + const joined = parts.join("\n"); + return role === "assistant" ? stripThinkingTags(joined) : joined; + } + } + if (typeof m.text === "string") { + return role === "assistant" ? stripThinkingTags(m.text) : m.text; + } + return null; +} + +export function extractThinking(message: unknown): string | null { + const m = message as Record; + const content = m.content; + const parts: string[] = []; + if (Array.isArray(content)) { + for (const p of content) { + const item = p as Record; + if (item.type === "thinking" && typeof item.thinking === "string") { + const cleaned = item.thinking.trim(); + if (cleaned) parts.push(cleaned); + } + } + } + if (parts.length > 0) return parts.join("\n"); + + // Back-compat: older logs may still have tags inside text blocks. + const rawText = extractRawText(message); + if (!rawText) return null; + const matches = [ + ...rawText.matchAll( + /<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi, + ), + ]; + const extracted = matches + .map((m) => (m[1] ?? "").trim()) + .filter(Boolean); + return extracted.length > 0 ? extracted.join("\n") : null; +} + +export function extractRawText(message: unknown): string | null { + const m = message as Record; + const content = m.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + if (parts.length > 0) return parts.join("\n"); + } + if (typeof m.text === "string") return m.text; + return null; +} + +export function formatReasoningMarkdown(text: string): string { + const trimmed = text.trim(); + if (!trimmed) return ""; + const lines = trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => `_${line}_`); + return lines.length ? ["_Reasoning:_", ...lines].join("\n") : ""; +} + diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts new file mode 100644 index 000000000..f725363df --- /dev/null +++ b/ui/src/ui/chat/tool-cards.ts @@ -0,0 +1,200 @@ +import { html, nothing } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + +import { toSanitizedMarkdownHtml } from "../markdown"; +import { formatToolDetail, resolveToolDisplay } from "../tool-display"; +import type { ToolCard } from "../types/chat-types"; +import { TOOL_INLINE_THRESHOLD } from "./constants"; +import { + formatToolOutputForSidebar, + getTruncatedPreview, +} from "./tool-helpers"; +import { isToolResultMessage } from "./message-normalizer"; +import { extractText } from "./message-extract"; + +export function extractToolCards(message: unknown): ToolCard[] { + const m = message as Record; + const content = normalizeContent(m.content); + const cards: ToolCard[] = []; + + for (const item of content) { + const kind = String(item.type ?? "").toLowerCase(); + const isToolCall = + ["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) || + (typeof item.name === "string" && item.arguments != null); + if (isToolCall) { + cards.push({ + kind: "call", + name: (item.name as string) ?? "tool", + args: coerceArgs(item.arguments ?? item.args), + }); + } + } + + for (const item of content) { + const kind = String(item.type ?? "").toLowerCase(); + if (kind !== "toolresult" && kind !== "tool_result") continue; + const text = extractToolText(item); + const name = typeof item.name === "string" ? item.name : "tool"; + cards.push({ kind: "result", name, text }); + } + + if ( + isToolResultMessage(message) && + !cards.some((card) => card.kind === "result") + ) { + const name = + (typeof m.toolName === "string" && m.toolName) || + (typeof m.tool_name === "string" && m.tool_name) || + "tool"; + const text = extractText(message) ?? undefined; + cards.push({ kind: "result", name, text }); + } + + return cards; +} + +export function renderToolCardLegacy( + card: ToolCard, + opts?: { + id: string; + expanded: boolean; + onToggle?: (id: string, expanded: boolean) => void; + }, +) { + const display = resolveToolDisplay({ name: card.name, args: card.args }); + const detail = formatToolDetail(display); + const hasOutput = typeof card.text === "string" && card.text.length > 0; + const expanded = opts?.expanded ?? false; + const id = opts?.id ?? `${card.name}-${Math.random()}`; + return html` +
+
+
+ ${display.emoji} + ${display.label} +
+ ${!hasOutput ? html`` : nothing} +
+ ${detail + ? html`
${detail}
` + : nothing} + ${hasOutput + ? html` +
{ + if (!opts?.onToggle) return; + const target = e.currentTarget as HTMLDetailsElement; + opts.onToggle(id, target.open); + }} + > + + ${expanded ? "Hide output" : "Show output"} + + (${card.text?.length ?? 0} chars) + + + ${expanded + ? html`
+ ${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))} +
` + : nothing} +
+ ` + : nothing} +
+ `; +} + +export function renderToolCardSidebar( + card: ToolCard, + onOpenSidebar?: (content: string) => void, +) { + const display = resolveToolDisplay({ name: card.name, args: card.args }); + const detail = formatToolDetail(display); + const hasText = Boolean(card.text?.trim()); + + const canClick = Boolean(onOpenSidebar); + const handleClick = canClick + ? () => { + if (hasText) { + onOpenSidebar!(formatToolOutputForSidebar(card.text!)); + return; + } + const info = `## ${display.label}\n\n${ + detail ? `**Command:** \`${detail}\`\n\n` : "" + }*No output — tool completed successfully.*`; + onOpenSidebar!(info); + } + : undefined; + + const isShort = hasText && (card.text?.length ?? 0) <= TOOL_INLINE_THRESHOLD; + const showCollapsed = hasText && !isShort; + const showInline = hasText && isShort; + const isEmpty = !hasText; + + return html` +
{ + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + handleClick?.(); + } + : nothing} + > +
+
+ ${display.emoji} + ${display.label} +
+ ${canClick + ? html`${hasText ? "View ›" : "›"}` + : nothing} + ${isEmpty && !canClick ? html`` : nothing} +
+ ${detail + ? html`
${detail}
` + : nothing} + ${isEmpty + ? html`
Completed
` + : nothing} + ${showCollapsed + ? html`
${getTruncatedPreview(card.text!)}
` + : nothing} + ${showInline + ? html`
${card.text}
` + : nothing} +
+ `; +} + +function normalizeContent(content: unknown): Array> { + if (!Array.isArray(content)) return []; + return content.filter(Boolean) as Array>; +} + +function coerceArgs(value: unknown): unknown { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if (!trimmed) return value; + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function extractToolText(item: Record): string | undefined { + if (typeof item.text === "string") return item.text; + if (typeof item.content === "string") return item.content; + return undefined; +} + diff --git a/ui/src/ui/chat/tool-helpers.ts b/ui/src/ui/chat/tool-helpers.ts index 6369b1bd6..f541fce90 100644 --- a/ui/src/ui/chat/tool-helpers.ts +++ b/ui/src/ui/chat/tool-helpers.ts @@ -27,10 +27,11 @@ export function formatToolOutputForSidebar(text: string): string { * Truncates to first N lines or first N characters, whichever is shorter. */ export function getTruncatedPreview(text: string): string { - const lines = text.split("\n").slice(0, PREVIEW_MAX_LINES); + const allLines = text.split("\n"); + const lines = allLines.slice(0, PREVIEW_MAX_LINES); const preview = lines.join("\n"); if (preview.length > PREVIEW_MAX_CHARS) { return preview.slice(0, PREVIEW_MAX_CHARS) + "…"; } - return lines.length < text.split("\n").length ? preview + "…" : preview; + return lines.length < allLines.length ? preview + "…" : preview; } diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 075a35645..272e663a2 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,23 +1,19 @@ import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; - -import { stripThinkingTags } from "../format"; -import { toSanitizedMarkdownHtml } from "../markdown"; -import { formatToolDetail, resolveToolDisplay } from "../tool-display"; import type { SessionsListResult } from "../types"; import type { ChatQueueItem } from "../ui-types"; -import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types"; -import { TOOL_INLINE_THRESHOLD } from "../chat/constants"; -import { - formatToolOutputForSidebar, - getTruncatedPreview, -} from "../chat/tool-helpers"; +import type { ChatItem, MessageGroup } from "../types/chat-types"; import { normalizeMessage, normalizeRoleForGrouping, - isToolResultMessage, } from "../chat/message-normalizer"; +import { extractText } from "../chat/message-extract"; +import { renderMessage, renderReadingIndicator } from "../chat/legacy-render"; +import { + renderMessageGroup, + renderReadingIndicatorGroup, + renderStreamingGroup, +} from "../chat/grouped-render"; import { renderMarkdownSidebar } from "./markdown-sidebar"; import "../components/resizable-divider"; @@ -360,527 +356,3 @@ function fnv1a(input: string): string { } return (hash >>> 0).toString(36); } - -function renderReadingIndicator() { - return html` -
-
- -
-
- `; -} - -function renderReadingIndicatorGroup() { - return html` -
- ${renderAvatar("assistant")} -
- -
-
- `; -} - -function renderMessage( - message: unknown, - props?: Pick, - opts?: { streaming?: boolean; showReasoning?: boolean }, -) { - const m = message as Record; - const role = typeof m.role === "string" ? m.role : "unknown"; - const toolCards = extractToolCards(message); - const hasToolCards = toolCards.length > 0; - const isToolResult = - isToolResultMessage(message) || - typeof m.toolCallId === "string" || - typeof m.tool_call_id === "string"; - const extractedText = extractText(message); - const extractedThinking = - opts?.showReasoning && role === "assistant" ? extractThinking(message) : null; - const contentText = typeof m.content === "string" ? m.content : null; - const fallback = hasToolCards ? null : JSON.stringify(message, null, 2); - - const display = - !isToolResult && extractedText?.trim() - ? { kind: "text" as const, value: extractedText } - : !isToolResult && contentText?.trim() - ? { kind: "text" as const, value: contentText } - : !isToolResult && fallback - ? { kind: "json" as const, value: fallback } - : null; - - const markdownBase = - display?.kind === "json" - ? ["```json", display.value, "```"].join("\n") - : (display?.value ?? null); - const markdown = extractedThinking - ? [formatReasoningMarkdown(extractedThinking), markdownBase] - .filter(Boolean) - .join("\n\n") - : markdownBase; - - const timestamp = - typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; - - const normalizedRole = normalizeRoleForGrouping(role); - const klass = - normalizedRole === "assistant" - ? "assistant" - : normalizedRole === "user" - ? "user" - : "other"; - const who = - normalizedRole === "assistant" - ? "Assistant" - : normalizedRole === "user" - ? "You" - : normalizedRole; - - const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; - const toolCardBase = - toolCallId || - (typeof m.id === "string" ? m.id : "") || - (typeof m.messageId === "string" ? m.messageId : "") || - (typeof m.timestamp === "number" ? String(m.timestamp) : "tool-card"); - - return html` -
-
-
- ${markdown - ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` - : nothing} - ${toolCards.map((card, index) => - renderToolCardLegacy(card, { - id: `${toolCardBase}:${index}`, - expanded: props?.isToolOutputExpanded - ? props.isToolOutputExpanded(`${toolCardBase}:${index}`) - : false, - onToggle: props?.onToolOutputToggle, - }), - )} -
-
- ${who}${timestamp ? html` · ${timestamp}` : nothing} -
-
-
- `; -} - -function extractText(message: unknown): string | null { - const m = message as Record; - const role = typeof m.role === "string" ? m.role : ""; - const content = m.content; - if (typeof content === "string") { - return role === "assistant" ? stripThinkingTags(content) : content; - } - if (Array.isArray(content)) { - const parts = content - .map((p) => { - const item = p as Record; - if (item.type === "text" && typeof item.text === "string") return item.text; - return null; - }) - .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) { - const joined = parts.join("\n"); - return role === "assistant" ? stripThinkingTags(joined) : joined; - } - } - if (typeof m.text === "string") { - return role === "assistant" ? stripThinkingTags(m.text) : m.text; - } - return null; -} - -function extractThinking(message: unknown): string | null { - const m = message as Record; - const content = m.content; - const parts: string[] = []; - if (Array.isArray(content)) { - for (const p of content) { - const item = p as Record; - if (item.type === "thinking" && typeof item.thinking === "string") { - const cleaned = item.thinking.trim(); - if (cleaned) parts.push(cleaned); - } - } - } - if (parts.length > 0) return parts.join("\n"); - - // Back-compat: older logs may still have tags inside text blocks. - const rawText = extractRawText(message); - if (!rawText) return null; - const matches = [ - ...rawText.matchAll( - /<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi, - ), - ]; - const extracted = matches - .map((m) => (m[1] ?? "").trim()) - .filter(Boolean); - return extracted.length > 0 ? extracted.join("\n") : null; -} - -function extractRawText(message: unknown): string | null { - const m = message as Record; - const content = m.content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - const parts = content - .map((p) => { - const item = p as Record; - if (item.type === "text" && typeof item.text === "string") return item.text; - return null; - }) - .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) return parts.join("\n"); - } - if (typeof m.text === "string") return m.text; - return null; -} - -function formatReasoningMarkdown(text: string): string { - const trimmed = text.trim(); - if (!trimmed) return ""; - const lines = trimmed - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => `_${line}_`); - return lines.length ? ["_Reasoning:_", ...lines].join("\n") : ""; -} - -function extractToolCards(message: unknown): ToolCard[] { - const m = message as Record; - const content = normalizeContent(m.content); - const cards: ToolCard[] = []; - - for (const item of content) { - const kind = String(item.type ?? "").toLowerCase(); - const isToolCall = - ["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) || - (typeof item.name === "string" && item.arguments != null); - if (isToolCall) { - cards.push({ - kind: "call", - name: (item.name as string) ?? "tool", - args: coerceArgs(item.arguments ?? item.args), - }); - } - } - - for (const item of content) { - const kind = String(item.type ?? "").toLowerCase(); - if (kind !== "toolresult" && kind !== "tool_result") continue; - const text = extractToolText(item); - const name = typeof item.name === "string" ? item.name : "tool"; - cards.push({ kind: "result", name, text }); - } - - if ( - isToolResultMessage(message) && - !cards.some((card) => card.kind === "result") - ) { - const name = - (typeof m.toolName === "string" && m.toolName) || - (typeof m.tool_name === "string" && m.tool_name) || - "tool"; - const text = extractText(message) ?? undefined; - cards.push({ kind: "result", name, text }); - } - - return cards; -} - -function renderToolCardLegacy( - card: ToolCard, - opts?: { - id: string; - expanded: boolean; - onToggle?: (id: string, expanded: boolean) => void; - }, -) { - const display = resolveToolDisplay({ name: card.name, args: card.args }); - const detail = formatToolDetail(display); - const hasOutput = typeof card.text === "string" && card.text.length > 0; - const expanded = opts?.expanded ?? false; - const id = opts?.id ?? `${card.name}-${Math.random()}`; - return html` -
-
-
- ${display.emoji} - ${display.label} -
- ${!hasOutput ? html`` : nothing} -
- ${detail - ? html`
${detail}
` - : nothing} - ${hasOutput - ? html` -
{ - if (!opts?.onToggle) return; - const target = e.currentTarget as HTMLDetailsElement; - opts.onToggle(id, target.open); - }} - > - - ${expanded ? "Hide output" : "Show output"} - - (${card.text?.length ?? 0} chars) - - - ${expanded - ? html`
- ${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))} -
` - : nothing} -
- ` - : nothing} -
- `; -} - -function renderToolCardSidebar( - card: ToolCard, - onOpenSidebar?: (content: string) => void, -) { - const display = resolveToolDisplay({ name: card.name, args: card.args }); - const detail = formatToolDetail(display); - const hasText = Boolean(card.text?.trim()); - - const canClick = Boolean(onOpenSidebar); - const handleClick = canClick - ? () => { - if (hasText) { - onOpenSidebar!(formatToolOutputForSidebar(card.text!)); - return; - } - const info = `## ${display.label}\n\n${ - detail ? `**Command:** \`${detail}\`\n\n` : "" - }*No output — tool completed successfully.*`; - onOpenSidebar!(info); - } - : undefined; - - const isShort = hasText && (card.text?.length ?? 0) <= TOOL_INLINE_THRESHOLD; - const showCollapsed = hasText && !isShort; - const showInline = hasText && isShort; - const isEmpty = !hasText; - - return html` -
{ - if (e.key !== "Enter" && e.key !== " ") return; - e.preventDefault(); - handleClick?.(); - } - : nothing} - > -
-
- ${display.emoji} - ${display.label} -
- ${canClick - ? html`${hasText ? "View ›" : "›"}` - : nothing} - ${isEmpty && !canClick ? html`` : nothing} -
- ${detail - ? html`
${detail}
` - : nothing} - ${isEmpty - ? html`
Completed
` - : nothing} - ${showCollapsed - ? html`
${getTruncatedPreview(card.text!)}
` - : nothing} - ${showInline - ? html`
${card.text}
` - : nothing} -
- `; -} - -function renderAvatar(role: string) { - const normalized = normalizeRoleForGrouping(role); - const initial = normalized === "user" ? "U" : normalized === "assistant" ? "A" : "?"; - const className = normalized === "user" ? "user" : normalized === "assistant" ? "assistant" : "other"; - return html`
${initial}
`; -} - -function renderStreamingGroup( - text: string, - startedAt: number, - onOpenSidebar?: (content: string) => void, -) { - const timestamp = new Date(startedAt).toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", - }); - - return html` -
- ${renderAvatar("assistant")} -
- ${renderGroupedMessage( - { - role: "assistant", - content: [{ type: "text", text }], - timestamp: startedAt, - }, - { isStreaming: true, showReasoning: false }, - onOpenSidebar, - )} - -
-
- `; -} - -function renderMessageGroup( - group: MessageGroup, - opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean }, -) { - const normalizedRole = normalizeRoleForGrouping(group.role); - const who = - normalizedRole === "user" - ? "You" - : normalizedRole === "assistant" - ? "Assistant" - : normalizedRole; - const roleClass = - normalizedRole === "user" - ? "user" - : normalizedRole === "assistant" - ? "assistant" - : "other"; - const timestamp = new Date(group.timestamp).toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", - }); - - return html` -
- ${renderAvatar(group.role)} -
- ${group.messages.map((item, index) => - renderGroupedMessage( - item.message, - { - isStreaming: - group.isStreaming && index === group.messages.length - 1, - showReasoning: opts.showReasoning, - }, - opts.onOpenSidebar, - ), - )} - -
-
- `; -} - -function renderGroupedMessage( - message: unknown, - opts: { isStreaming: boolean; showReasoning: boolean }, - onOpenSidebar?: (content: string) => void, -) { - const m = message as Record; - const role = typeof m.role === "string" ? m.role : "unknown"; - const isToolResult = - isToolResultMessage(message) || - role.toLowerCase() === "toolresult" || - role.toLowerCase() === "tool_result" || - typeof m.toolCallId === "string" || - typeof m.tool_call_id === "string"; - - const toolCards = extractToolCards(message); - const hasToolCards = toolCards.length > 0; - - const extractedText = extractText(message); - const extractedThinking = - opts.showReasoning && role === "assistant" ? extractThinking(message) : null; - const markdownBase = extractedText?.trim() ? extractedText : null; - const markdown = extractedThinking - ? [formatReasoningMarkdown(extractedThinking), markdownBase] - .filter(Boolean) - .join("\n\n") - : markdownBase; - - const bubbleClasses = [ - "chat-bubble", - opts.isStreaming ? "streaming" : "", - "fade-in", - ] - .filter(Boolean) - .join(" "); - - if (!markdown && hasToolCards && isToolResult) { - return html`${toolCards.map((card) => - renderToolCardSidebar(card, onOpenSidebar), - )}`; - } - - if (!markdown && !hasToolCards) return nothing; - - return html` -
- ${markdown - ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` - : nothing} - ${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))} -
- `; -} - -function normalizeContent(content: unknown): Array> { - if (!Array.isArray(content)) return []; - return content.filter(Boolean) as Array>; -} - -function coerceArgs(value: unknown): unknown { - if (typeof value !== "string") return value; - const trimmed = value.trim(); - if (!trimmed) return value; - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value; - try { - return JSON.parse(trimmed); - } catch { - return value; - } -} - -function extractToolText(item: Record): string | undefined { - if (typeof item.text === "string") return item.text; - if (typeof item.content === "string") return item.content; - return undefined; -}