5407 lines
218 KiB
HTML
5407 lines
218 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Claude Code Analytics - Chats</title>
|
||
<style>
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
:root {
|
||
/* Terminal theme - black and orange */
|
||
--bg-primary: #0d1117;
|
||
--bg-secondary: #161b22;
|
||
--bg-tertiary: #21262d;
|
||
--text-primary: #ffffff;
|
||
--text-secondary: #8b949e;
|
||
--text-accent: #ff6b35;
|
||
--text-success: #3fb950;
|
||
--text-warning: #d29922;
|
||
--text-error: #da3633;
|
||
--border-primary: #30363d;
|
||
--border-secondary: #40464e;
|
||
--shadow: rgba(0, 0, 0, 0.5);
|
||
|
||
/* Terminal specific colors */
|
||
--terminal-orange: #ff6b35;
|
||
--terminal-orange-hover: #ff8659;
|
||
--terminal-dark: #0d1117;
|
||
--terminal-gray: #21262d;
|
||
|
||
/* Message bubble colors */
|
||
--message-received: #cc5500; /* Warm orange for received messages */
|
||
--message-sent: #1e7e34; /* Darker green for sent messages */
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Mobile-first chat app layout */
|
||
.chat-app {
|
||
display: flex;
|
||
height: 100vh;
|
||
position: relative;
|
||
}
|
||
|
||
/* Mobile sidebar (conversations list) */
|
||
.chat-sidebar {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: var(--bg-primary);
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
z-index: 100;
|
||
}
|
||
|
||
/* Chat sidebar header */
|
||
.chat-header {
|
||
background: var(--bg-secondary);
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: between;
|
||
min-height: 64px;
|
||
}
|
||
|
||
.chat-header-content {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
}
|
||
|
||
.chat-header-content .header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
flex: 1;
|
||
}
|
||
|
||
.chat-header-content .header-center {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.chat-header-content .header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
flex: 1;
|
||
}
|
||
|
||
.chat-title {
|
||
font-size: 1.2rem;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.header-btn {
|
||
background: rgba(255, 107, 53, 0.1);
|
||
border: 1px solid rgba(255, 107, 53, 0.2);
|
||
color: var(--terminal-orange);
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
padding: 6px 12px;
|
||
border-radius: 8px;
|
||
transition: all 0.2s ease;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.header-btn:hover {
|
||
background: rgba(255, 107, 53, 0.15);
|
||
border-color: rgba(255, 107, 53, 0.3);
|
||
color: var(--terminal-orange);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 4px rgba(255, 107, 53, 0.2);
|
||
}
|
||
|
||
.header-btn:active {
|
||
transform: translateY(0);
|
||
box-shadow: 0 1px 2px rgba(255, 107, 53, 0.2);
|
||
}
|
||
|
||
.header-btn.back-btn {
|
||
background: rgba(255, 107, 53, 0.15);
|
||
border: 1px solid rgba(255, 107, 53, 0.3);
|
||
color: var(--terminal-orange);
|
||
font-size: 1.2rem;
|
||
font-weight: 700;
|
||
padding: 8px 12px;
|
||
min-width: 40px;
|
||
text-align: center;
|
||
}
|
||
|
||
.header-btn.back-btn:hover {
|
||
background: rgba(255, 107, 53, 0.2);
|
||
border-color: rgba(255, 107, 53, 0.4);
|
||
}
|
||
|
||
/* Search bar */
|
||
.chat-search {
|
||
padding: 16px 20px;
|
||
background: var(--bg-primary);
|
||
border-bottom: 1px solid var(--border-primary);
|
||
}
|
||
|
||
.search-input {
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-primary);
|
||
border-radius: 24px;
|
||
color: var(--text-primary);
|
||
font-size: 1rem;
|
||
outline: none;
|
||
transition: border-color 0.2s ease;
|
||
}
|
||
|
||
.search-input:focus {
|
||
border-color: var(--terminal-orange);
|
||
}
|
||
|
||
.search-input::placeholder {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* Advanced Search Panel - Telegram Style */
|
||
.search-panel-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 1000;
|
||
display: none;
|
||
animation: fadeIn 0.2s ease;
|
||
}
|
||
|
||
.search-panel-overlay.active {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
padding-top: 10vh;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
.search-panel {
|
||
background: var(--bg-secondary);
|
||
border-radius: 12px;
|
||
width: 90%;
|
||
max-width: 600px;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||
animation: slideDown 0.3s ease;
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-20px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.search-panel-header {
|
||
padding: 20px;
|
||
border-bottom: 1px solid var(--border-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--bg-secondary);
|
||
z-index: 10;
|
||
}
|
||
|
||
.search-panel-title {
|
||
font-size: 1.25rem;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.search-panel-close {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-secondary);
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.search-panel-close:hover {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.search-panel-body {
|
||
padding: 20px;
|
||
}
|
||
|
||
.search-filter-group {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.search-filter-label {
|
||
display: block;
|
||
color: var(--text-secondary);
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
margin-bottom: 8px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.search-filter-input {
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-primary);
|
||
border-radius: 8px;
|
||
color: var(--text-primary);
|
||
font-size: 1rem;
|
||
outline: none;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.search-filter-input:focus {
|
||
border-color: var(--terminal-orange);
|
||
background: var(--bg-primary);
|
||
}
|
||
|
||
.search-filter-input::placeholder {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.search-filter-date-range {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.search-filter-date-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.search-filter-date-label {
|
||
font-size: 0.75rem;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.search-panel-actions {
|
||
padding: 16px 20px;
|
||
border-top: 1px solid var(--border-primary);
|
||
display: flex;
|
||
gap: 12px;
|
||
position: sticky;
|
||
bottom: 0;
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.search-btn {
|
||
flex: 1;
|
||
padding: 14px 24px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.search-btn-primary {
|
||
background: var(--terminal-orange);
|
||
color: white;
|
||
}
|
||
|
||
.search-btn-primary:hover {
|
||
background: var(--terminal-orange-hover);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
|
||
}
|
||
|
||
.search-btn-secondary {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.search-btn-secondary:hover {
|
||
background: var(--border-secondary);
|
||
}
|
||
|
||
.search-btn:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.search-filter-icon {
|
||
display: inline-block;
|
||
margin-right: 6px;
|
||
}
|
||
|
||
.advanced-search-toggle {
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-primary);
|
||
color: var(--terminal-orange);
|
||
padding: 8px 12px;
|
||
border-radius: 20px;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
margin-left: 8px;
|
||
transition: all 0.2s ease;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.advanced-search-toggle:hover {
|
||
background: var(--terminal-orange);
|
||
color: white;
|
||
border-color: var(--terminal-orange);
|
||
}
|
||
|
||
.search-results-info {
|
||
background: var(--bg-tertiary);
|
||
border-radius: 8px;
|
||
padding: 12px 16px;
|
||
margin-bottom: 16px;
|
||
color: var(--text-secondary);
|
||
font-size: 0.875rem;
|
||
display: none;
|
||
}
|
||
|
||
.search-results-info.active {
|
||
display: block;
|
||
}
|
||
|
||
.search-results-count {
|
||
color: var(--terminal-orange);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.clear-filters-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--terminal-orange);
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
text-decoration: underline;
|
||
padding: 0;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.clear-filters-btn:hover {
|
||
color: var(--terminal-orange-hover);
|
||
}
|
||
|
||
.filter-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-secondary);
|
||
border-radius: 12px;
|
||
padding: 4px 10px;
|
||
font-size: 0.75rem;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.filter-tag-icon {
|
||
font-size: 0.7rem;
|
||
}
|
||
|
||
.filter-tag-label {
|
||
color: var(--text-secondary);
|
||
margin-right: 2px;
|
||
}
|
||
|
||
.filter-tag-value {
|
||
color: var(--terminal-orange);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Folder Browser */
|
||
.folder-browser-wrapper {
|
||
position: relative;
|
||
}
|
||
|
||
.folder-browser-toggle {
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
background: var(--terminal-orange);
|
||
border: none;
|
||
color: white;
|
||
padding: 6px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
transition: all 0.2s ease;
|
||
z-index: 1;
|
||
}
|
||
|
||
.folder-browser-toggle:hover {
|
||
background: var(--terminal-orange-hover);
|
||
transform: translateY(-50%) scale(1.05);
|
||
}
|
||
|
||
.folder-browser-dropdown {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
left: 0;
|
||
right: 0;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border-secondary);
|
||
border-radius: 8px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
z-index: 100;
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||
display: none;
|
||
}
|
||
|
||
.folder-browser-dropdown.active {
|
||
display: block;
|
||
animation: slideDown 0.2s ease;
|
||
}
|
||
|
||
.folder-browser-header {
|
||
padding: 12px 16px;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border-primary);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.folder-browser-search {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-primary);
|
||
border-radius: 6px;
|
||
color: var(--text-primary);
|
||
font-size: 0.875rem;
|
||
outline: none;
|
||
}
|
||
|
||
.folder-browser-search:focus {
|
||
border-color: var(--terminal-orange);
|
||
}
|
||
|
||
.folder-browser-list {
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.folder-item {
|
||
padding: 12px 16px;
|
||
cursor: pointer;
|
||
transition: background 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
border-bottom: 1px solid var(--border-primary);
|
||
}
|
||
|
||
.folder-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.folder-item:hover {
|
||
background: var(--bg-tertiary);
|
||
}
|
||
|
||
.folder-item.selected {
|
||
background: var(--terminal-orange);
|
||
color: white;
|
||
}
|
||
|
||
.folder-icon {
|
||
font-size: 1.2rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.folder-path {
|
||
flex: 1;
|
||
font-size: 0.875rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.folder-item.selected .folder-path {
|
||
color: white;
|
||
}
|
||
|
||
.folder-count {
|
||
font-size: 0.75rem;
|
||
color: var(--text-secondary);
|
||
background: var(--bg-tertiary);
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.folder-item.selected .folder-count {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
color: white;
|
||
}
|
||
|
||
.folder-browser-empty {
|
||
padding: 24px 16px;
|
||
text-align: center;
|
||
color: var(--text-secondary);
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
/* In-Conversation Search */
|
||
.chat-search-bar {
|
||
display: none;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border-primary);
|
||
padding: 12px 16px;
|
||
align-items: center;
|
||
gap: 12px;
|
||
animation: slideDown 0.2s ease;
|
||
}
|
||
|
||
.chat-search-bar.active {
|
||
display: flex;
|
||
}
|
||
|
||
.chat-search-input-wrapper {
|
||
flex: 1;
|
||
position: relative;
|
||
}
|
||
|
||
.chat-search-input {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-primary);
|
||
border-radius: 6px;
|
||
color: var(--text-primary);
|
||
font-size: 0.875rem;
|
||
outline: none;
|
||
}
|
||
|
||
.chat-search-input:focus {
|
||
border-color: var(--terminal-orange);
|
||
}
|
||
|
||
.chat-search-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chat-search-counter {
|
||
font-size: 0.875rem;
|
||
color: var(--text-secondary);
|
||
white-space: nowrap;
|
||
min-width: 60px;
|
||
text-align: center;
|
||
}
|
||
|
||
.chat-search-counter.has-results {
|
||
color: var(--terminal-orange);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.chat-search-nav-btn {
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-primary);
|
||
color: var(--text-primary);
|
||
padding: 6px 10px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.chat-search-nav-btn:hover:not(:disabled) {
|
||
background: var(--terminal-orange);
|
||
border-color: var(--terminal-orange);
|
||
color: white;
|
||
}
|
||
|
||
.chat-search-nav-btn:disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.chat-search-close-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-secondary);
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 1.2rem;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.chat-search-close-btn:hover {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* Message highlight */
|
||
.message-highlight {
|
||
background: var(--terminal-orange);
|
||
color: white;
|
||
padding: 2px 4px;
|
||
border-radius: 3px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.message-current-highlight {
|
||
background: #ffaa00;
|
||
color: white;
|
||
padding: 2px 4px;
|
||
border-radius: 3px;
|
||
font-weight: 600;
|
||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.6);
|
||
}
|
||
|
||
/* Action buttons group - unified design with orange accent */
|
||
.action-buttons-group {
|
||
display: inline-flex;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
border: 2px solid rgba(255, 170, 0, 0.4);
|
||
background: rgba(255, 170, 0, 0.08);
|
||
box-shadow: 0 2px 8px rgba(255, 170, 0, 0.15);
|
||
}
|
||
|
||
.action-btn {
|
||
background: transparent;
|
||
border: none;
|
||
border-right: 1px solid rgba(255, 170, 0, 0.2);
|
||
color: var(--text-primary);
|
||
padding: 10px 18px;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.action-btn:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.action-btn svg {
|
||
width: 15px;
|
||
height: 15px;
|
||
transition: all 0.2s ease;
|
||
stroke: var(--text-primary);
|
||
}
|
||
|
||
.action-btn:hover {
|
||
background: rgba(255, 170, 0, 0.12);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.action-btn.resume-btn:hover {
|
||
background: rgba(63, 185, 80, 0.15);
|
||
color: rgba(63, 185, 80, 1);
|
||
}
|
||
|
||
.action-btn.resume-btn:hover svg {
|
||
stroke: rgba(63, 185, 80, 1);
|
||
}
|
||
|
||
.action-btn.download-btn:hover {
|
||
background: rgba(59, 130, 246, 0.15);
|
||
color: rgba(59, 130, 246, 1);
|
||
}
|
||
|
||
.action-btn.download-btn:hover svg {
|
||
stroke: rgba(59, 130, 246, 1);
|
||
}
|
||
|
||
.action-btn.search-btn:hover {
|
||
background: rgba(251, 146, 60, 0.15);
|
||
color: rgba(251, 146, 60, 1);
|
||
}
|
||
|
||
.action-btn.search-btn:hover svg {
|
||
stroke: rgba(251, 146, 60, 1);
|
||
}
|
||
|
||
.action-btn.search-btn.active {
|
||
background: rgba(251, 146, 60, 0.2);
|
||
color: rgba(251, 146, 60, 1);
|
||
}
|
||
|
||
.action-btn.search-btn.active svg {
|
||
stroke: rgba(251, 146, 60, 1);
|
||
}
|
||
|
||
.action-btn.analytics-btn:hover {
|
||
background: rgba(139, 92, 246, 0.15);
|
||
color: rgba(139, 92, 246, 1);
|
||
}
|
||
|
||
.action-btn.analytics-btn:hover svg {
|
||
stroke: rgba(139, 92, 246, 1);
|
||
}
|
||
|
||
/* Conversations list */
|
||
.conversations-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
background: var(--bg-primary);
|
||
}
|
||
|
||
.conversation-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border-primary);
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease;
|
||
position: relative;
|
||
}
|
||
|
||
.conversation-item:hover {
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.conversation-item.active {
|
||
background: var(--bg-secondary);
|
||
border-left: 4px solid var(--terminal-orange);
|
||
}
|
||
|
||
.conversation-avatar {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
background: var(--terminal-orange);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.2rem;
|
||
margin-right: 16px;
|
||
flex-shrink: 0;
|
||
color: var(--bg-primary);
|
||
font-weight: bold;
|
||
}
|
||
|
||
.conversation-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.conversation-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.conversation-name {
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
font-size: 1rem;
|
||
text-overflow: ellipsis;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.conversation-time {
|
||
color: var(--text-secondary);
|
||
font-size: 0.8rem;
|
||
flex-shrink: 0;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.conversation-preview {
|
||
color: var(--text-secondary);
|
||
font-size: 0.9rem;
|
||
text-overflow: ellipsis;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.conversation-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* Project grouping styles */
|
||
.project-group {
|
||
border-bottom: 1px solid var(--border-secondary);
|
||
}
|
||
|
||
.project-header {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border-primary);
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.project-header:hover {
|
||
background: var(--bg-tertiary);
|
||
}
|
||
|
||
.project-avatar {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 8px;
|
||
background: linear-gradient(135deg, var(--terminal-orange) 0%, #e67e22 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.2rem;
|
||
margin-right: 12px;
|
||
flex-shrink: 0;
|
||
color: var(--bg-primary);
|
||
font-weight: bold;
|
||
}
|
||
|
||
.project-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.project-name {
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
font-size: 1rem;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.project-count {
|
||
color: var(--text-secondary);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.project-toggle {
|
||
margin-left: 8px;
|
||
color: var(--text-secondary);
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.toggle-icon {
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.toggle-icon.expanded {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
/* Smaller avatar for conversations within groups */
|
||
.conversation-avatar-small {
|
||
width: 36px;
|
||
height: 36px;
|
||
font-size: 0.85rem;
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* Indent conversations within project groups */
|
||
.project-group .conversation-item {
|
||
padding-left: 36px;
|
||
}
|
||
|
||
.conversation-state {
|
||
font-size: 0.7rem;
|
||
padding: 3px 8px;
|
||
border-radius: 12px;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
letter-spacing: 0.5px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
/* Working states - Green (Claude is actively working) */
|
||
.state-working {
|
||
background: rgba(40, 167, 69, 0.15);
|
||
color: #28a745;
|
||
border: 1px solid rgba(40, 167, 69, 0.3);
|
||
animation: pulse-working 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse-working {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.7; }
|
||
}
|
||
|
||
/* Active states - Blue/Cyan (Recent activity, ready) */
|
||
.state-active {
|
||
background: rgba(23, 162, 184, 0.15);
|
||
color: #17a2b8;
|
||
border: 1px solid rgba(23, 162, 184, 0.3);
|
||
}
|
||
|
||
/* Waiting/Responding states - Orange (Waiting for input/response) */
|
||
.state-waiting {
|
||
background: rgba(255, 193, 7, 0.15);
|
||
color: #ffc107;
|
||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||
}
|
||
|
||
.state-responding {
|
||
background: rgba(255, 152, 0, 0.15);
|
||
color: #ff9800;
|
||
border: 1px solid rgba(255, 152, 0, 0.3);
|
||
}
|
||
|
||
/* Typing states - Purple (User interaction) */
|
||
.state-typing {
|
||
background: rgba(156, 39, 176, 0.15);
|
||
color: #9c27b0;
|
||
border: 1px solid rgba(156, 39, 176, 0.3);
|
||
}
|
||
|
||
/* Idle states - Gray (Inactive but not old) */
|
||
.state-idle {
|
||
background: rgba(108, 117, 125, 0.15);
|
||
color: #6c757d;
|
||
border: 1px solid rgba(108, 117, 125, 0.3);
|
||
}
|
||
|
||
/* Inactive states - Red/Gray (Old conversations) */
|
||
.state-inactive {
|
||
background: rgba(134, 142, 150, 0.1);
|
||
color: #868e96;
|
||
border: 1px solid rgba(134, 142, 150, 0.2);
|
||
}
|
||
|
||
/* Special states */
|
||
.state-recent {
|
||
background: rgba(0, 123, 255, 0.15);
|
||
color: #007bff;
|
||
border: 1px solid rgba(0, 123, 255, 0.3);
|
||
}
|
||
|
||
.state-finishing {
|
||
background: rgba(40, 167, 69, 0.2);
|
||
color: #28a745;
|
||
border: 1px solid rgba(40, 167, 69, 0.4);
|
||
}
|
||
|
||
.state-empty {
|
||
background: rgba(184, 188, 200, 0.1);
|
||
color: var(--text-secondary);
|
||
border: 1px solid rgba(184, 188, 200, 0.2);
|
||
}
|
||
|
||
/* Hover effects */
|
||
.conversation-state:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.message-count {
|
||
background: rgba(255, 107, 53, 0.2);
|
||
color: var(--terminal-orange);
|
||
border: 1px solid rgba(255, 107, 53, 0.3);
|
||
border-radius: 10px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
padding: 2px 6px;
|
||
min-width: 18px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* Message styles - matching AgentsPage.js format */
|
||
.messages-list {
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.message {
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-width: 85%;
|
||
}
|
||
|
||
.message-user {
|
||
align-self: flex-end;
|
||
}
|
||
|
||
.message-assistant {
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.message-bubble {
|
||
position: relative;
|
||
}
|
||
|
||
.message-user .message-bubble .message-content {
|
||
background: var(--message-sent);
|
||
color: white;
|
||
padding: 12px 16px;
|
||
border-radius: 18px 18px 4px 18px;
|
||
font-size: 1rem;
|
||
line-height: 1.4;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.message-assistant .message-bubble .message-content {
|
||
background: var(--terminal-dark);
|
||
color: var(--text-primary);
|
||
padding: 12px 16px;
|
||
border: 2px solid var(--message-received);
|
||
border-radius: 8px;
|
||
font-size: 1rem;
|
||
line-height: 1.4;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.message-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
font-size: 0.7rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.message-user .message-meta {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.message-assistant .message-meta {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.message-time {
|
||
font-size: 0.7rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.tool-indicator {
|
||
color: var(--terminal-orange);
|
||
padding: 2px 6px;
|
||
border-radius: 10px;
|
||
font-size: 0.6rem;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.token-indicator {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-secondary);
|
||
padding: 2px 6px;
|
||
border-radius: 8px;
|
||
font-size: 0.6rem;
|
||
}
|
||
|
||
/* Code formatting in messages */
|
||
.message-content pre {
|
||
background: var(--terminal-dark);
|
||
color: #ffab70;
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
overflow-x: auto;
|
||
margin: 8px 0;
|
||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||
font-size: 0.9rem;
|
||
border: 1px solid var(--border-primary);
|
||
}
|
||
|
||
.message-content code {
|
||
background: var(--terminal-gray);
|
||
color: #ffab70;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.message-content pre code {
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.message-content strong {
|
||
font-weight: 600;
|
||
color: #ffab70;
|
||
}
|
||
|
||
.message-content em {
|
||
font-style: italic;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* Expandable message styles */
|
||
.expandable-message {
|
||
width: 100%;
|
||
}
|
||
|
||
.message-expand-link {
|
||
color: var(--terminal-orange);
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
margin-top: 8px;
|
||
padding: 4px 0;
|
||
border-top: 1px solid rgba(255, 107, 53, 0.3);
|
||
text-align: center;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.message-expand-link:hover {
|
||
color: var(--terminal-orange-hover);
|
||
background: rgba(255, 107, 53, 0.1);
|
||
}
|
||
|
||
.message-collapse-link {
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
margin-top: 8px;
|
||
padding: 4px 0;
|
||
border-top: 1px solid var(--border-primary);
|
||
text-align: center;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.message-collapse-link:hover {
|
||
color: var(--text-primary);
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
/* Tool call and result styles */
|
||
.tool-call {
|
||
margin: 8px 0;
|
||
padding: 8px 0;
|
||
background: none;
|
||
}
|
||
|
||
.tool-summary {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 0.9rem;
|
||
color: var(--text-primary);
|
||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
word-wrap: break-word;
|
||
word-break: break-all;
|
||
overflow-wrap: break-word;
|
||
max-width: 100%;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.tool-summary:hover {
|
||
background: rgba(255, 107, 53, 0.1);
|
||
padding: 2px 4px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.tool-bullet {
|
||
color: var(--terminal-orange);
|
||
font-weight: bold;
|
||
}
|
||
|
||
.tool-name {
|
||
font-weight: bold;
|
||
color: var(--terminal-orange);
|
||
word-wrap: break-word;
|
||
word-break: break-all;
|
||
overflow-wrap: break-word;
|
||
}
|
||
|
||
.tool-summary-text {
|
||
word-wrap: break-word;
|
||
word-break: break-all;
|
||
overflow-wrap: break-word;
|
||
flex-shrink: 1;
|
||
}
|
||
|
||
.tool-expand-note {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 4px;
|
||
font-size: 0.8rem;
|
||
color: var(--text-secondary);
|
||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.tool-expand-note:hover {
|
||
color: var(--terminal-orange);
|
||
}
|
||
|
||
.tool-branch {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.tool-note {
|
||
font-style: italic;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.tool-parameters {
|
||
margin-top: 8px;
|
||
border-top: 1px solid var(--border-primary);
|
||
padding-top: 8px;
|
||
}
|
||
|
||
.tool-parameters-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 0.8rem;
|
||
color: var(--text-secondary);
|
||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.tool-param-title {
|
||
font-weight: bold;
|
||
}
|
||
|
||
.tool-parameters-content {
|
||
background: var(--terminal-dark);
|
||
color: var(--terminal-orange);
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
margin: 0;
|
||
border: 1px solid var(--border-primary);
|
||
word-wrap: break-word;
|
||
word-break: break-all;
|
||
overflow-wrap: break-word;
|
||
max-width: 100%;
|
||
overflow-x: auto;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.tool-result {
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-primary);
|
||
border-radius: 8px;
|
||
margin: 8px 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.tool-result-header {
|
||
background: transparent;
|
||
color: var(--text-primary);
|
||
padding: 8px 12px;
|
||
font-weight: bold;
|
||
font-size: 0.9rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tool-result-content {
|
||
padding: 12px;
|
||
}
|
||
|
||
.tool-result-content pre {
|
||
background: var(--terminal-dark);
|
||
color: var(--text-primary);
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
/* Expandable tool result styles */
|
||
.expandable-tool-result {
|
||
width: 100%;
|
||
}
|
||
|
||
.tool-result-expand-link {
|
||
color: var(--terminal-orange);
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
margin-top: 8px;
|
||
padding: 6px 12px;
|
||
border-top: 1px solid rgba(255, 107, 53, 0.3);
|
||
text-align: center;
|
||
transition: all 0.2s ease;
|
||
background: rgba(255, 107, 53, 0.05);
|
||
border-radius: 0 0 4px 4px;
|
||
}
|
||
|
||
.tool-result-expand-link:hover {
|
||
color: var(--terminal-orange-hover);
|
||
background: rgba(255, 107, 53, 0.1);
|
||
}
|
||
|
||
.tool-result-collapse-link {
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-size: 0.7rem;
|
||
margin-top: 8px;
|
||
padding: 4px 8px;
|
||
border-top: 1px solid var(--border-primary);
|
||
text-align: center;
|
||
transition: all 0.2s ease;
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
|
||
.tool-result-collapse-link:hover {
|
||
color: var(--text-primary);
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
/* Loading and error states */
|
||
.messages-loading {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 40px 20px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.messages-loading .loading-spinner {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.no-messages-found {
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.no-messages-icon {
|
||
font-size: 3rem;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.error-loading-messages {
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
color: var(--text-error);
|
||
}
|
||
|
||
.error-icon {
|
||
font-size: 3rem;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.retry-messages {
|
||
background: var(--terminal-orange);
|
||
color: white;
|
||
border: none;
|
||
padding: 10px 20px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-weight: bold;
|
||
margin-top: 12px;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.retry-messages:hover {
|
||
background: var(--terminal-orange-hover);
|
||
}
|
||
|
||
/* Chat view (initially hidden) */
|
||
.chat-view {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: var(--bg-primary);
|
||
position: absolute;
|
||
top: 0;
|
||
left: 100%;
|
||
z-index: 200;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: left 0.3s ease;
|
||
}
|
||
|
||
.chat-view.active {
|
||
left: 0;
|
||
}
|
||
|
||
/* Chat view header */
|
||
.chat-view-header {
|
||
background: var(--bg-secondary);
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
min-height: 64px;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
flex: 1;
|
||
}
|
||
|
||
.header-center {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
gap: 8px;
|
||
}
|
||
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
flex: 1;
|
||
gap: 12px;
|
||
}
|
||
|
||
.chat-view-back {
|
||
margin-right: 16px;
|
||
}
|
||
|
||
.chat-view-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.chat-view-title {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin: 0;
|
||
}
|
||
|
||
.chat-view-subtitle {
|
||
font-size: 0.8rem;
|
||
color: var(--text-secondary);
|
||
margin: 0;
|
||
}
|
||
|
||
/* Tools toggle switch */
|
||
.tools-toggle {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
opacity: 0;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.chat-view.conversation-selected .tools-toggle {
|
||
opacity: 1;
|
||
}
|
||
|
||
.tools-toggle-label {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
user-select: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.toggle-switch {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 44px;
|
||
height: 24px;
|
||
}
|
||
|
||
.toggle-switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.toggle-slider {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: #555;
|
||
transition: 0.3s;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.toggle-slider:before {
|
||
position: absolute;
|
||
content: "";
|
||
height: 18px;
|
||
width: 18px;
|
||
left: 3px;
|
||
bottom: 3px;
|
||
background-color: white;
|
||
transition: 0.3s;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.toggle-switch input:checked + .toggle-slider {
|
||
background-color: #cc5500;
|
||
}
|
||
|
||
.toggle-switch input:checked + .toggle-slider:before {
|
||
transform: translateX(20px);
|
||
}
|
||
|
||
.toggle-switch input:hover + .toggle-slider {
|
||
background-color: #666;
|
||
}
|
||
|
||
.toggle-switch input:checked:hover + .toggle-slider {
|
||
background-color: #e66600;
|
||
}
|
||
|
||
/* Hide complete assistant messages with tools when toggle is off */
|
||
.chat-view:not(.show-tools) .message-assistant.has-tools {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Messages area */
|
||
.chat-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
padding-bottom: 80px; /* Space for status footer */
|
||
background: var(--bg-primary);
|
||
}
|
||
|
||
/* Loading states */
|
||
.loading-spinner {
|
||
border: 2px solid var(--border-primary);
|
||
border-top: 2px solid var(--terminal-orange);
|
||
border-radius: 50%;
|
||
width: 24px;
|
||
height: 24px;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
|
||
.no-conversations {
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* Status Footer */
|
||
.status-footer {
|
||
background: var(--bg-primary);
|
||
border-top: 1px solid var(--border-primary);
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
min-height: 50px;
|
||
position: sticky;
|
||
bottom: 0;
|
||
backdrop-filter: blur(10px);
|
||
z-index: 10;
|
||
}
|
||
|
||
.status-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: var(--text-secondary);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.status-dot.ready {
|
||
background: #28a745;
|
||
box-shadow: 0 0 6px rgba(40, 167, 69, 0.4);
|
||
}
|
||
|
||
.status-dot.working {
|
||
background: var(--terminal-orange);
|
||
box-shadow: 0 0 6px rgba(255, 107, 53, 0.4);
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
.status-dot.waiting {
|
||
background: #ffc107;
|
||
box-shadow: 0 0 6px rgba(255, 193, 7, 0.4);
|
||
}
|
||
|
||
.status-dot.error {
|
||
background: #dc3545;
|
||
box-shadow: 0 0 6px rgba(220, 53, 69, 0.4);
|
||
}
|
||
|
||
.status-dot.idle {
|
||
background: var(--text-secondary);
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
50% {
|
||
opacity: 0.7;
|
||
transform: scale(1.1);
|
||
}
|
||
}
|
||
|
||
.status-text {
|
||
color: var(--text-primary);
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.status-details {
|
||
color: var(--text-secondary);
|
||
font-size: 0.8rem;
|
||
text-align: right;
|
||
}
|
||
|
||
/* Hide status footer when no conversation is selected */
|
||
.chat-view:not(.conversation-selected) .status-footer {
|
||
display: none;
|
||
}
|
||
|
||
.no-conversations-icon {
|
||
font-size: 3rem;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* Desktop: side-by-side layout */
|
||
@media (min-width: 769px) {
|
||
.chat-sidebar {
|
||
width: 360px;
|
||
min-width: 360px;
|
||
border-right: 1px solid var(--border-primary);
|
||
}
|
||
|
||
.chat-view {
|
||
position: relative;
|
||
left: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.chat-view .chat-view-back {
|
||
display: none;
|
||
}
|
||
|
||
.conversation-item.active {
|
||
background: var(--bg-tertiary);
|
||
}
|
||
}
|
||
/* Modal styles */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
display: none;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.modal-overlay.show {
|
||
display: flex;
|
||
}
|
||
|
||
.modal {
|
||
background: var(--bg-secondary);
|
||
border-radius: 12px;
|
||
border: 1px solid var(--border-primary);
|
||
padding: 24px;
|
||
max-width: 600px;
|
||
width: 95%;
|
||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||
animation: modalSlideIn 0.3s ease-out;
|
||
}
|
||
|
||
@keyframes modalSlideIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-20px) scale(0.95);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.modal-icon {
|
||
font-size: 1.5rem;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 1.2rem;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.modal-description {
|
||
color: var(--text-secondary);
|
||
font-size: 0.9rem;
|
||
margin-bottom: 20px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.modal-command {
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-primary);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||
font-size: 0.85rem;
|
||
color: var(--terminal-orange);
|
||
word-break: break-all;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.modal-btn {
|
||
background: rgba(255, 107, 53, 0.1);
|
||
border: 1px solid rgba(255, 107, 53, 0.2);
|
||
color: var(--terminal-orange);
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
padding: 8px 16px;
|
||
border-radius: 8px;
|
||
transition: all 0.2s ease;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.modal-btn:hover {
|
||
background: rgba(255, 107, 53, 0.15);
|
||
border-color: rgba(255, 107, 53, 0.3);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 4px rgba(255, 107, 53, 0.2);
|
||
}
|
||
|
||
.modal-btn:active {
|
||
transform: translateY(0);
|
||
box-shadow: 0 1px 2px rgba(255, 107, 53, 0.2);
|
||
}
|
||
|
||
.modal-btn.primary {
|
||
background: var(--terminal-orange);
|
||
color: var(--bg-primary);
|
||
}
|
||
|
||
.modal-btn.primary:hover {
|
||
background: var(--terminal-orange-hover);
|
||
color: var(--bg-primary);
|
||
}
|
||
|
||
.modal-btn.secondary {
|
||
background: transparent;
|
||
border-color: var(--border-primary);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.modal-btn.secondary:hover {
|
||
background: var(--bg-tertiary);
|
||
border-color: var(--border-secondary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* Analytics modal */
|
||
.analytics-modal {
|
||
max-width: 900px;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.analytics-body {
|
||
padding: 20px;
|
||
}
|
||
|
||
.analytics-loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
}
|
||
|
||
.analytics-loading p {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.analytics-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.analytics-stat {
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
border-left: 3px solid;
|
||
}
|
||
|
||
.analytics-stat-label {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.analytics-stat-value {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.analytics-stat.purple { background: rgba(139, 92, 246, 0.1); border-color: #8b5cf6; }
|
||
.analytics-stat.purple .analytics-stat-value { color: #8b5cf6; }
|
||
.analytics-stat.blue { background: rgba(59, 130, 246, 0.1); border-color: #3b82f6; }
|
||
.analytics-stat.blue .analytics-stat-value { color: #3b82f6; }
|
||
.analytics-stat.green { background: rgba(16, 185, 129, 0.1); border-color: #10b981; }
|
||
.analytics-stat.green .analytics-stat-value { color: #10b981; }
|
||
.analytics-stat.orange { background: rgba(234, 88, 12, 0.1); border-color: #ea580c; }
|
||
.analytics-stat.orange .analytics-stat-value { color: #ea580c; }
|
||
.analytics-stat.amber { background: rgba(251, 146, 60, 0.1); border-color: #fb923c; }
|
||
.analytics-stat.amber .analytics-stat-value { color: #fb923c; }
|
||
.analytics-stat.violet { background: rgba(168, 85, 247, 0.1); border-color: #a855f7; }
|
||
.analytics-stat.violet .analytics-stat-value { color: #a855f7; }
|
||
|
||
.analytics-section {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.analytics-section-title {
|
||
margin: 0 0 12px 0;
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.analytics-two-col {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.analytics-detail-label {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.analytics-detail-value {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.analytics-detail-value-sm {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.analytics-detail-sub {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.analytics-cost-divider {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.analytics-cost-label {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.analytics-cost-value {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #10b981;
|
||
}
|
||
|
||
.analytics-progress-bar {
|
||
height: 8px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
}
|
||
|
||
.analytics-wait-bar {
|
||
background: linear-gradient(90deg, #3b82f6, #2563eb);
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.analytics-user-bar {
|
||
background: linear-gradient(90deg, #10b981, #059669);
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.analytics-models-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.analytics-tools-list {
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.analytics-model-mono {
|
||
font-family: monospace;
|
||
}
|
||
|
||
.color-blue { color: #3b82f6; }
|
||
.color-purple { color: #8b5cf6; }
|
||
.color-orange { color: #fb923c; }
|
||
.color-green { color: #10b981; }
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="chat-app" id="chatApp">
|
||
<!-- Conversations List (Mobile Sidebar) -->
|
||
<div class="chat-sidebar" id="chatSidebar">
|
||
<!-- Header -->
|
||
<div class="chat-header">
|
||
<div class="chat-header-content">
|
||
<div class="header-left">
|
||
<h1 class="chat-title">Claude Code Chats</h1>
|
||
</div>
|
||
<div class="header-center">
|
||
<button class="header-btn" id="githubBtn" title="Star on GitHub" onclick="window.open('https://github.com/davila7/claude-code-templates', '_blank')">
|
||
⭐️ on Github
|
||
</button>
|
||
</div>
|
||
<div class="header-right">
|
||
<button class="header-btn" id="refreshBtn" title="Refresh">
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search -->
|
||
<div class="chat-search">
|
||
<div style="display: flex; align-items: center;">
|
||
<input
|
||
type="text"
|
||
class="search-input"
|
||
placeholder="Search conversations..."
|
||
id="searchInput"
|
||
style="flex: 1;"
|
||
/>
|
||
<button class="advanced-search-toggle" id="advancedSearchToggle">
|
||
<span>🔍</span>
|
||
<span>Advanced</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Search Results Info -->
|
||
<div class="search-results-info" id="searchResultsInfo">
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>Found <span class="search-results-count" id="searchResultsCount">0</span> conversations</span>
|
||
<button class="clear-filters-btn" id="clearFiltersBtn">Clear filters</button>
|
||
</div>
|
||
<div id="appliedFilters" style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Conversations List -->
|
||
<div class="conversations-list" id="conversationsList">
|
||
<div class="loading-spinner" style="margin: 40px auto;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Advanced Search Panel Overlay -->
|
||
<div class="search-panel-overlay" id="searchPanelOverlay">
|
||
<div class="search-panel">
|
||
<!-- Panel Header -->
|
||
<div class="search-panel-header">
|
||
<h3 class="search-panel-title">
|
||
<span class="search-filter-icon">🔍</span>
|
||
Advanced Search
|
||
</h3>
|
||
<button class="search-panel-close" id="searchPanelClose">✕</button>
|
||
</div>
|
||
|
||
<!-- Panel Body -->
|
||
<div class="search-panel-body">
|
||
<!-- Quick Search -->
|
||
<div class="search-filter-group">
|
||
<label class="search-filter-label">
|
||
<span class="search-filter-icon">💬</span>
|
||
Quick Search
|
||
</label>
|
||
<input
|
||
type="text"
|
||
class="search-filter-input"
|
||
placeholder="Search by conversation ID or filename..."
|
||
id="filterQuery"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Working Directory Filter -->
|
||
<div class="search-filter-group">
|
||
<label class="search-filter-label">
|
||
<span class="search-filter-icon">📁</span>
|
||
Working Directory
|
||
</label>
|
||
<div class="folder-browser-wrapper">
|
||
<input
|
||
type="text"
|
||
class="search-filter-input"
|
||
placeholder="e.g., /Users/name/Projects/MyProject"
|
||
id="filterWorkingDirectory"
|
||
style="padding-right: 100px;"
|
||
/>
|
||
<button class="folder-browser-toggle" id="folderBrowserToggle">
|
||
📂 Browse
|
||
</button>
|
||
|
||
<!-- Folder Browser Dropdown -->
|
||
<div class="folder-browser-dropdown" id="folderBrowserDropdown">
|
||
<div class="folder-browser-header">
|
||
<input
|
||
type="text"
|
||
class="folder-browser-search"
|
||
placeholder="Filter directories..."
|
||
id="folderBrowserSearch"
|
||
/>
|
||
</div>
|
||
<div class="folder-browser-list" id="folderBrowserList">
|
||
<div class="folder-browser-empty">
|
||
Loading directories...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Date Range Filter -->
|
||
<div class="search-filter-group">
|
||
<label class="search-filter-label">
|
||
<span class="search-filter-icon">📅</span>
|
||
Date Range
|
||
</label>
|
||
<div class="search-filter-date-range">
|
||
<div class="search-filter-date-wrapper">
|
||
<span class="search-filter-date-label">From</span>
|
||
<input
|
||
type="date"
|
||
class="search-filter-input"
|
||
id="filterDateFrom"
|
||
/>
|
||
</div>
|
||
<div class="search-filter-date-wrapper">
|
||
<span class="search-filter-date-label">To</span>
|
||
<input
|
||
type="date"
|
||
class="search-filter-input"
|
||
id="filterDateTo"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content Search -->
|
||
<div class="search-filter-group">
|
||
<label class="search-filter-label">
|
||
<span class="search-filter-icon">🔎</span>
|
||
Search in Messages
|
||
</label>
|
||
<input
|
||
type="text"
|
||
class="search-filter-input"
|
||
placeholder="Search within conversation content..."
|
||
id="filterContentSearch"
|
||
/>
|
||
<small style="color: var(--text-secondary); font-size: 0.75rem; margin-top: 4px; display: block;">
|
||
⚠️ This may take longer for large conversations
|
||
</small>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Panel Actions -->
|
||
<div class="search-panel-actions">
|
||
<button class="search-btn search-btn-secondary" id="searchPanelReset">
|
||
Reset
|
||
</button>
|
||
<button class="search-btn search-btn-primary" id="searchPanelApply">
|
||
Search
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chat View -->
|
||
<div class="chat-view" id="chatView">
|
||
<div class="chat-view-header">
|
||
<div class="header-left">
|
||
<button class="header-btn back-btn chat-view-back" id="backToList">
|
||
←
|
||
</button>
|
||
<div class="chat-view-info">
|
||
<h2 class="chat-view-title" id="chatViewTitle">Select a session</h2>
|
||
<p class="chat-view-subtitle" id="chatViewSubtitle"></p>
|
||
</div>
|
||
</div>
|
||
<div class="header-center">
|
||
<div class="action-buttons-group" style="display: none;" id="actionButtonsGroup">
|
||
<button class="action-btn resume-btn" id="resumeConversation" onclick="resumeConversationWithClaude()">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||
</svg>
|
||
<span>Resume</span>
|
||
</button>
|
||
<button class="action-btn download-btn" id="downloadConversation" onclick="downloadConversation()">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||
<polyline points="7 10 12 15 17 10"></polyline>
|
||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||
</svg>
|
||
<span>Download</span>
|
||
</button>
|
||
<button class="action-btn search-btn" id="chatSearchToggle">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="11" cy="11" r="8"></circle>
|
||
<path d="m21 21-4.35-4.35"></path>
|
||
</svg>
|
||
<span>Search</span>
|
||
</button>
|
||
<button class="action-btn analytics-btn" id="showAnalytics" onclick="showAnalyticsModal()">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="18" y1="20" x2="18" y2="10"></line>
|
||
<line x1="12" y1="20" x2="12" y2="4"></line>
|
||
<line x1="6" y1="20" x2="6" y2="14"></line>
|
||
</svg>
|
||
<span>Analytics</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<div class="tools-toggle" id="toolsToggle">
|
||
<span class="tools-toggle-label" onclick="document.getElementById('showToolsSwitch').click()">Show Tools</span>
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" id="showToolsSwitch" checked>
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- In-Conversation Search Bar -->
|
||
<div class="chat-search-bar" id="chatSearchBar">
|
||
<div class="chat-search-input-wrapper">
|
||
<input
|
||
type="text"
|
||
class="chat-search-input"
|
||
placeholder="Search in this conversation..."
|
||
id="chatSearchInput"
|
||
/>
|
||
</div>
|
||
<div class="chat-search-controls">
|
||
<span class="chat-search-counter" id="chatSearchCounter">0/0</span>
|
||
<button class="chat-search-nav-btn" id="chatSearchPrev" title="Previous match (↑)">
|
||
↑
|
||
</button>
|
||
<button class="chat-search-nav-btn" id="chatSearchNext" title="Next match (↓)">
|
||
↓
|
||
</button>
|
||
<button class="chat-search-close-btn" id="chatSearchClose">
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chat-messages" id="chatMessages">
|
||
<div class="no-conversations">
|
||
<div class="no-conversations-icon">💬</div>
|
||
<h3>No conversation selected</h3>
|
||
<p>Choose a conversation from the list to view messages</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Status Footer -->
|
||
<div class="status-footer" id="statusFooter">
|
||
<div class="status-indicator">
|
||
<div class="status-dot" id="statusDot"></div>
|
||
<span class="status-text" id="statusText">Ready</span>
|
||
</div>
|
||
<div class="status-details" id="statusDetails"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Resume Conversation Modal -->
|
||
<div class="modal-overlay" id="resumeModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<span class="modal-icon">▶️</span>
|
||
<h3 class="modal-title">Resume Conversation</h3>
|
||
</div>
|
||
<p class="modal-description">
|
||
To resume this conversation in Claude Code, copy and run the following command in your terminal:
|
||
</p>
|
||
<div class="modal-command" id="modalCommand">
|
||
<!-- Command will be inserted here -->
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="modal-btn secondary" onclick="closeResumeModal()">Cancel</button>
|
||
<button class="modal-btn primary" id="copyCommandBtn" onclick="copyModalCommand()">Copy Command</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Download Context Modal -->
|
||
<div class="modal-overlay" id="downloadModal">
|
||
<div class="modal">
|
||
<div class="modal-header" style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);">
|
||
<span class="modal-icon">📥</span>
|
||
<h3 class="modal-title">Download Conversation Context</h3>
|
||
</div>
|
||
<div style="padding: 20px;">
|
||
<p class="modal-description" style="margin-bottom: 16px; line-height: 1.5;">
|
||
Download this conversation as a <strong>Markdown context file</strong> that Claude Code can read to continue your work.
|
||
</p>
|
||
|
||
<div style="background: rgba(59, 130, 246, 0.08); border-left: 3px solid #3b82f6; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
|
||
<h4 style="color: #3b82f6; margin: 0 0 8px 0; font-size: 14px;">📋 What's included</h4>
|
||
<p style="margin: 0; font-size: 13px; color: var(--text-secondary); line-height: 1.4;">
|
||
Full conversation history (last <strong>100 messages</strong>) formatted for Claude Code to understand
|
||
</p>
|
||
</div>
|
||
|
||
<div style="background: rgba(16, 185, 129, 0.08); border-left: 3px solid #10b981; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
|
||
<h4 style="color: #10b981; margin: 0 0 8px 0; font-size: 14px;">💡 How to continue the conversation</h4>
|
||
<p style="margin: 0 0 12px 0; font-size: 13px; color: var(--text-secondary); line-height: 1.4;">
|
||
After downloading, use one of these commands to load the context:
|
||
</p>
|
||
|
||
<div style="margin-bottom: 10px;">
|
||
<p style="margin: 0 0 4px 0; font-size: 12px; font-weight: 600; color: #10b981;">Option 1: Start with context loaded</p>
|
||
<code style="display: block; background: rgba(0,0,0,0.5); padding: 8px; border-radius: 4px; font-size: 12px; color: #e5e7eb; overflow-x: auto; white-space: nowrap;">claude "read @context-file.md and continue"</code>
|
||
</div>
|
||
|
||
<div>
|
||
<p style="margin: 0 0 4px 0; font-size: 12px; font-weight: 600; color: #10b981;">Option 2: In interactive mode</p>
|
||
<code style="display: block; background: rgba(0,0,0,0.5); padding: 8px; border-radius: 4px; font-size: 12px; color: #e5e7eb; overflow-x: auto; white-space: nowrap;">claude</code>
|
||
<code style="display: block; background: rgba(0,0,0,0.5); padding: 8px; border-radius: 4px; font-size: 12px; color: #e5e7eb; overflow-x: auto; white-space: nowrap; margin-top: 4px;">> @context-file.md continue with the task</code>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="background: rgba(100, 116, 139, 0.08); border-left: 3px solid #64748b; padding: 12px; border-radius: 6px;">
|
||
<p style="margin: 0; font-size: 12px; color: var(--text-secondary); line-height: 1.4;">
|
||
💾 <strong>Tip:</strong> Save the downloaded file in your project directory for easy access
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button class="modal-btn secondary" onclick="closeDownloadModal()">Cancel</button>
|
||
<button class="modal-btn primary" onclick="proceedWithDownload()" style="background: #3b82f6;">
|
||
Download Context File
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Analytics Modal -->
|
||
<div class="modal-overlay" id="analyticsModal">
|
||
<div class="modal analytics-modal">
|
||
<div class="modal-header">
|
||
<h3 class="modal-title">Session Analytics (Beta)</h3>
|
||
</div>
|
||
<div class="analytics-body">
|
||
<!-- Loading State -->
|
||
<div id="analyticsLoading" class="analytics-loading">
|
||
<p>Loading analytics data...</p>
|
||
</div>
|
||
|
||
<!-- Analytics Content (hidden initially) -->
|
||
<div id="analyticsContent" style="display: none;">
|
||
<!-- Overview Stats - 3 columns -->
|
||
<div class="analytics-grid">
|
||
<div class="analytics-stat purple">
|
||
<div class="analytics-stat-label">Total Messages</div>
|
||
<div class="analytics-stat-value" id="analyticsMessageCount">-</div>
|
||
</div>
|
||
<div class="analytics-stat blue">
|
||
<div class="analytics-stat-label">Total Tokens</div>
|
||
<div class="analytics-stat-value" id="analyticsTotalTokens">-</div>
|
||
</div>
|
||
<div class="analytics-stat green">
|
||
<div class="analytics-stat-label">Tool Calls</div>
|
||
<div class="analytics-stat-value" id="analyticsToolCalls">-</div>
|
||
</div>
|
||
<div class="analytics-stat orange">
|
||
<div class="analytics-stat-label">Tools Used</div>
|
||
<div class="analytics-stat-value" id="analyticsUniqueTools">-</div>
|
||
</div>
|
||
<div class="analytics-stat amber">
|
||
<div class="analytics-stat-label">Models Used</div>
|
||
<div class="analytics-stat-value" id="analyticsUniqueModels">-</div>
|
||
</div>
|
||
<div class="analytics-stat violet">
|
||
<div class="analytics-stat-label">Cache Efficiency</div>
|
||
<div class="analytics-stat-value" id="analyticsCacheEfficiency">-</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Token Usage Breakdown -->
|
||
<div class="analytics-section">
|
||
<h4 class="analytics-section-title">Token Usage Breakdown</h4>
|
||
<div class="analytics-two-col">
|
||
<div>
|
||
<div class="analytics-detail-label">Input Tokens</div>
|
||
<div class="analytics-detail-value color-blue" id="analyticsInputTokens">-</div>
|
||
</div>
|
||
<div>
|
||
<div class="analytics-detail-label">Output Tokens</div>
|
||
<div class="analytics-detail-value color-purple" id="analyticsOutputTokens">-</div>
|
||
</div>
|
||
<div>
|
||
<div class="analytics-detail-label">Cache Creation</div>
|
||
<div class="analytics-detail-value color-orange" id="analyticsCacheCreation">-</div>
|
||
</div>
|
||
<div>
|
||
<div class="analytics-detail-label">Cache Reads</div>
|
||
<div class="analytics-detail-value color-green" id="analyticsCacheReads">-</div>
|
||
</div>
|
||
</div>
|
||
<div class="analytics-cost-divider">
|
||
<div class="analytics-cost-label">Cost Estimate (USD)</div>
|
||
<div class="analytics-cost-value" id="analyticsCostEstimate">$0.00</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Session Timeline -->
|
||
<div class="analytics-section">
|
||
<h4 class="analytics-section-title">Session Timeline</h4>
|
||
<div class="analytics-two-col">
|
||
<div>
|
||
<div class="analytics-detail-label">Started</div>
|
||
<div class="analytics-detail-value-sm" id="analyticsStartTime">-</div>
|
||
</div>
|
||
<div>
|
||
<div class="analytics-detail-label">Last Activity</div>
|
||
<div class="analytics-detail-value-sm" id="analyticsLastActivity">-</div>
|
||
</div>
|
||
<div>
|
||
<div class="analytics-detail-label">Duration</div>
|
||
<div class="analytics-detail-value-sm" id="analyticsDuration">-</div>
|
||
</div>
|
||
<div>
|
||
<div class="analytics-detail-label">Status</div>
|
||
<div class="analytics-detail-value-sm" id="analyticsStatus">-</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Time Breakdown -->
|
||
<div class="analytics-section">
|
||
<h4 class="analytics-section-title">Time Breakdown</h4>
|
||
<div class="analytics-two-col" style="margin-bottom: 12px;">
|
||
<div>
|
||
<div class="analytics-detail-label">Claude Executing</div>
|
||
<div class="analytics-detail-value color-blue" id="analyticsWaitTime">-</div>
|
||
<div class="analytics-detail-sub" id="analyticsWaitPercent">-</div>
|
||
</div>
|
||
<div>
|
||
<div class="analytics-detail-label">User Conversing</div>
|
||
<div class="analytics-detail-value color-green" id="analyticsUserTime">-</div>
|
||
<div class="analytics-detail-sub" id="analyticsUserPercent">-</div>
|
||
</div>
|
||
</div>
|
||
<div class="analytics-progress-bar">
|
||
<div id="analyticsWaitBar" class="analytics-wait-bar"></div>
|
||
<div id="analyticsUserBar" class="analytics-user-bar"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Model Information -->
|
||
<div class="analytics-section">
|
||
<h4 class="analytics-section-title">Model Information</h4>
|
||
<div class="analytics-two-col">
|
||
<div>
|
||
<div class="analytics-detail-label">Primary Model</div>
|
||
<div class="analytics-detail-value-sm analytics-model-mono" id="analyticsPrimaryModel">-</div>
|
||
</div>
|
||
<div>
|
||
<div class="analytics-detail-label">Service Tier</div>
|
||
<div class="analytics-detail-value-sm" id="analyticsServiceTier">-</div>
|
||
</div>
|
||
</div>
|
||
<div id="analyticsAllModels" style="display: none; margin-top: 12px;">
|
||
<div class="analytics-detail-label" style="margin-bottom: 6px;">All Models Used</div>
|
||
<div id="analyticsModelsList" class="analytics-models-list">
|
||
<!-- Models will be populated here -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tool Usage -->
|
||
<div class="analytics-section">
|
||
<h4 class="analytics-section-title">Tool Usage</h4>
|
||
<div id="analyticsToolsList" class="analytics-tools-list">
|
||
<!-- Tool usage will be populated here -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Components Used -->
|
||
<div id="analyticsComponentsSection" class="analytics-section" style="display: none;">
|
||
<h4 class="analytics-section-title">Components Used</h4>
|
||
<div id="analyticsComponentsContent">
|
||
<!-- Components will be populated here -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button class="modal-btn secondary" onclick="closeAnalyticsModal()">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Import WebSocket and Data Services -->
|
||
<script src="services/WebSocketService.js"></script>
|
||
<script src="services/DataService.js"></script>
|
||
<script src="services/StateService.js"></script>
|
||
|
||
<script>
|
||
function escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.appendChild(document.createTextNode(str));
|
||
return div.innerHTML;
|
||
}
|
||
|
||
class ChatsMobileApp {
|
||
constructor() {
|
||
this.conversations = [];
|
||
this.selectedConversationId = null;
|
||
this.loadedMessages = new Map(); // Cache messages by conversation ID (stores paginated data)
|
||
|
||
// Pagination state for messages
|
||
this.messagesPagination = {
|
||
currentPage: 0,
|
||
limit: 15, // Load 15 messages per page
|
||
hasMore: true,
|
||
isLoading: false,
|
||
conversationId: null
|
||
};
|
||
|
||
// Message scroll listener reference
|
||
this.messagesScrollListener = null;
|
||
|
||
// Tools visibility state
|
||
this.showTools = true;
|
||
|
||
// Auto-scroll state tracking
|
||
this.isUserScrolling = false;
|
||
this.autoScrollEnabled = true;
|
||
this.scrollThreshold = 100; // pixels from bottom to consider "at bottom"
|
||
this.userScrollTimeout = null;
|
||
|
||
// Initialize services for real-time updates
|
||
this.webSocketService = new WebSocketService();
|
||
this.stateService = new StateService();
|
||
this.dataService = new DataService(this.webSocketService);
|
||
|
||
this.init();
|
||
}
|
||
|
||
async init() {
|
||
this.bindEvents();
|
||
this.setupRealTimeUpdates();
|
||
this.loadToolsPreference();
|
||
await this.loadConversations();
|
||
}
|
||
|
||
bindEvents() {
|
||
// Refresh conversations
|
||
document.getElementById('refreshBtn').addEventListener('click', () => {
|
||
this.loadConversations();
|
||
});
|
||
|
||
// Back to conversations list
|
||
document.getElementById('backToList').addEventListener('click', () => {
|
||
this.showConversationsList();
|
||
});
|
||
|
||
// Search functionality
|
||
const searchInput = document.getElementById('searchInput');
|
||
searchInput.addEventListener('input', (e) => {
|
||
this.filterConversations(e.target.value);
|
||
});
|
||
|
||
// Advanced search toggle
|
||
document.getElementById('advancedSearchToggle').addEventListener('click', () => {
|
||
this.openAdvancedSearch();
|
||
});
|
||
|
||
// Advanced search panel close
|
||
document.getElementById('searchPanelClose').addEventListener('click', () => {
|
||
this.closeAdvancedSearch();
|
||
});
|
||
|
||
// Close panel on overlay click
|
||
document.getElementById('searchPanelOverlay').addEventListener('click', (e) => {
|
||
if (e.target.id === 'searchPanelOverlay') {
|
||
this.closeAdvancedSearch();
|
||
}
|
||
});
|
||
|
||
// Advanced search apply
|
||
document.getElementById('searchPanelApply').addEventListener('click', () => {
|
||
this.applyAdvancedSearch();
|
||
});
|
||
|
||
// Advanced search reset
|
||
document.getElementById('searchPanelReset').addEventListener('click', () => {
|
||
this.resetAdvancedSearch();
|
||
});
|
||
|
||
// Clear filters button
|
||
document.getElementById('clearFiltersBtn').addEventListener('click', () => {
|
||
this.clearAllFilters();
|
||
});
|
||
|
||
// Folder browser toggle
|
||
document.getElementById('folderBrowserToggle').addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.toggleFolderBrowser();
|
||
});
|
||
|
||
// Folder browser search
|
||
document.getElementById('folderBrowserSearch').addEventListener('input', (e) => {
|
||
this.filterFolderBrowserList(e.target.value);
|
||
});
|
||
|
||
// Close folder browser when clicking outside
|
||
document.addEventListener('click', (e) => {
|
||
const dropdown = document.getElementById('folderBrowserDropdown');
|
||
const toggle = document.getElementById('folderBrowserToggle');
|
||
const wrapper = document.querySelector('.folder-browser-wrapper');
|
||
|
||
if (dropdown.classList.contains('active') &&
|
||
!wrapper.contains(e.target)) {
|
||
dropdown.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// In-conversation search toggle
|
||
document.getElementById('chatSearchToggle').addEventListener('click', () => {
|
||
this.toggleChatSearch();
|
||
});
|
||
|
||
// In-conversation search input (only search with 3+ characters)
|
||
document.getElementById('chatSearchInput').addEventListener('input', (e) => {
|
||
const query = e.target.value.trim();
|
||
if (query.length >= 3) {
|
||
this.performChatSearch(query);
|
||
} else if (query.length === 0) {
|
||
// Clear search when input is empty
|
||
this.clearChatSearch();
|
||
} else {
|
||
// Show message that minimum 3 characters are required
|
||
this.updateSearchCounter(0, 0, true);
|
||
}
|
||
});
|
||
|
||
// In-conversation search navigation
|
||
document.getElementById('chatSearchPrev').addEventListener('click', () => {
|
||
this.navigateSearchResults('prev');
|
||
});
|
||
|
||
document.getElementById('chatSearchNext').addEventListener('click', () => {
|
||
this.navigateSearchResults('next');
|
||
});
|
||
|
||
// In-conversation search close
|
||
document.getElementById('chatSearchClose').addEventListener('click', () => {
|
||
this.closeChatSearch();
|
||
});
|
||
|
||
// Keyboard shortcuts for search
|
||
document.getElementById('chatSearchInput').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (e.shiftKey) {
|
||
this.navigateSearchResults('prev');
|
||
} else {
|
||
this.navigateSearchResults('next');
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
this.closeChatSearch();
|
||
}
|
||
});
|
||
|
||
// Show Tools toggle functionality
|
||
const showToolsSwitch = document.getElementById('showToolsSwitch');
|
||
showToolsSwitch.addEventListener('change', (e) => {
|
||
this.toggleTools(e.target.checked);
|
||
});
|
||
}
|
||
|
||
setupRealTimeUpdates() {
|
||
console.log('🔧 Setting up real-time updates...');
|
||
|
||
// Subscribe to DataService events for real-time updates
|
||
this.dataService.addEventListener((type, data) => {
|
||
console.log('📡 DataService event:', type, data);
|
||
if (type === 'new_message') {
|
||
console.log('🔄 WebSocket: New message received', { conversationId: data.conversationId });
|
||
this.handleNewMessage(data.conversationId, data.message, data.metadata);
|
||
}
|
||
});
|
||
|
||
// Try to connect WebSocket
|
||
try {
|
||
console.log('🌐 Attempting WebSocket connection...');
|
||
this.webSocketService.connect();
|
||
|
||
// Log WebSocket connection status
|
||
setTimeout(() => {
|
||
const status = this.webSocketService.getStatus();
|
||
console.log('🔗 WebSocket status:', status);
|
||
}, 2000);
|
||
} catch (error) {
|
||
console.warn('WebSocket connection failed, using fallback polling:', error);
|
||
}
|
||
}
|
||
|
||
async loadConversations() {
|
||
const conversationsList = document.getElementById('conversationsList');
|
||
|
||
try {
|
||
conversationsList.innerHTML = '<div class="loading-spinner" style="margin: 40px auto;"></div>';
|
||
|
||
// Fetch conversations and states simultaneously (like AgentsPage.js)
|
||
const [conversationsResponse, statesResponse] = await Promise.all([
|
||
fetch('/api/conversations'),
|
||
fetch('/api/conversation-state') // Use singular like AgentsPage.js
|
||
]);
|
||
|
||
if (!conversationsResponse.ok) {
|
||
throw new Error(`HTTP error! status: ${conversationsResponse.status}`);
|
||
}
|
||
|
||
const conversationsData = await conversationsResponse.json();
|
||
this.conversations = conversationsData.conversations || [];
|
||
|
||
// Get conversation states (like AgentsPage.js)
|
||
let states = {};
|
||
if (statesResponse.ok) {
|
||
const statesData = await statesResponse.json();
|
||
states = statesData.activeStates || {};
|
||
console.log('📊 Loaded conversation states:', Object.keys(states).length, 'conversations');
|
||
} else {
|
||
console.warn('Could not load conversation states:', statesResponse.status);
|
||
}
|
||
|
||
this.renderConversations(this.conversations, states);
|
||
|
||
} catch (error) {
|
||
console.error('Error loading conversations:', error);
|
||
conversationsList.innerHTML = `
|
||
<div class="no-conversations">
|
||
<div class="no-conversations-icon">⚠️</div>
|
||
<h3>Error loading conversations</h3>
|
||
<p>${escapeHtml(error.message)}</p>
|
||
<button onclick="location.reload()" style="margin-top: 12px; padding: 8px 16px; background: var(--text-accent); color: white; border: none; border-radius: 4px; cursor: pointer;">Retry</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
renderConversations(conversations, states = {}) {
|
||
const conversationsList = document.getElementById('conversationsList');
|
||
|
||
if (conversations.length === 0) {
|
||
conversationsList.innerHTML = `
|
||
<div class="no-conversations">
|
||
<div class="no-conversations-icon">💬</div>
|
||
<h3>No sessions found</h3>
|
||
<p>Start a session with Claude Code to see it here</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Group conversations by project
|
||
const groupedByProject = conversations.reduce((groups, conv) => {
|
||
const projectName = conv.project || 'Unknown Project';
|
||
if (!groups[projectName]) {
|
||
groups[projectName] = [];
|
||
}
|
||
groups[projectName].push(conv);
|
||
return groups;
|
||
}, {});
|
||
|
||
// Sort projects alphabetically
|
||
const sortedProjects = Object.keys(groupedByProject).sort();
|
||
|
||
// Initialize expanded state if not exists
|
||
if (!this.expandedProjects) {
|
||
this.expandedProjects = new Set(); // All collapsed by default
|
||
}
|
||
|
||
// Render grouped conversations
|
||
conversationsList.innerHTML = sortedProjects.map(projectName => {
|
||
const projectConversations = groupedByProject[projectName];
|
||
const isExpanded = this.expandedProjects.has(projectName);
|
||
const firstLetter = projectName.charAt(0).toUpperCase();
|
||
const conversationCount = projectConversations.length;
|
||
|
||
// Render conversations for this project
|
||
const conversationsHTML = projectConversations.map(conv => {
|
||
const state = states[conv.id] || 'inactive';
|
||
const stateClass = this.getStateClass(state);
|
||
const stateLabel = this.getStateLabel(state);
|
||
|
||
const lastActivity = this.formatRelativeTime(new Date(conv.lastModified));
|
||
const messageCount = conv.messageCount || 0;
|
||
const conversationId = conv.id.slice(-8);
|
||
|
||
return `
|
||
<div class="conversation-item" data-conversation-id="${conv.id}" style="display: ${isExpanded ? 'flex' : 'none'}">
|
||
<div class="conversation-avatar conversation-avatar-small">
|
||
${conversationId.substring(0, 2).toUpperCase()}
|
||
</div>
|
||
<div class="conversation-content">
|
||
<div class="conversation-header">
|
||
<div class="conversation-name">Session ${conversationId}</div>
|
||
<div class="conversation-time">${lastActivity}</div>
|
||
</div>
|
||
<div class="conversation-meta">
|
||
<span class="conversation-state ${stateClass}">${stateLabel}</span>
|
||
${messageCount > 0 ? `<span class="message-count">${messageCount}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="project-group" data-project="${projectName}">
|
||
<div class="project-header" data-project="${projectName}">
|
||
<div class="project-avatar">
|
||
${firstLetter}
|
||
</div>
|
||
<div class="project-info">
|
||
<div class="project-name">${projectName}</div>
|
||
<div class="project-count">${conversationCount} session${conversationCount !== 1 ? 's' : ''}</div>
|
||
</div>
|
||
<div class="project-toggle">
|
||
<svg class="toggle-icon ${isExpanded ? 'expanded' : ''}" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
${conversationsHTML}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Bind project toggle events
|
||
conversationsList.querySelectorAll('.project-header').forEach(header => {
|
||
header.addEventListener('click', (e) => {
|
||
const projectName = header.dataset.project;
|
||
this.toggleProject(projectName);
|
||
});
|
||
});
|
||
|
||
// Bind conversation click events
|
||
conversationsList.querySelectorAll('.conversation-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const conversationId = item.dataset.conversationId;
|
||
this.selectConversation(conversationId);
|
||
});
|
||
});
|
||
}
|
||
|
||
toggleProject(projectName) {
|
||
if (this.expandedProjects.has(projectName)) {
|
||
this.expandedProjects.delete(projectName);
|
||
} else {
|
||
this.expandedProjects.add(projectName);
|
||
}
|
||
|
||
// Re-render conversations with current states
|
||
this.renderConversations(this.conversations, this.conversationStates);
|
||
}
|
||
|
||
selectConversation(conversationId) {
|
||
this.selectedConversationId = conversationId;
|
||
|
||
// Update active state
|
||
document.querySelectorAll('.conversation-item').forEach(item => {
|
||
item.classList.toggle('active', item.dataset.conversationId === conversationId);
|
||
});
|
||
|
||
// Show chat view
|
||
this.showChatView(conversationId);
|
||
}
|
||
|
||
showChatView(conversationId) {
|
||
const conversation = this.conversations.find(conv => conv.id === conversationId);
|
||
if (!conversation) return;
|
||
|
||
const chatView = document.getElementById('chatView');
|
||
const chatViewTitle = document.getElementById('chatViewTitle');
|
||
const chatViewSubtitle = document.getElementById('chatViewSubtitle');
|
||
|
||
// Update chat view header
|
||
const projectName = conversation.project || 'Unknown Project';
|
||
const convId = conversation.id.slice(-8);
|
||
chatViewTitle.textContent = projectName;
|
||
chatViewSubtitle.textContent = `Session ${convId}`;
|
||
|
||
// Show chat view with animation
|
||
chatView.classList.add('active');
|
||
|
||
// Apply tools visibility state
|
||
if (this.showTools) {
|
||
chatView.classList.add('show-tools');
|
||
} else {
|
||
chatView.classList.remove('show-tools');
|
||
}
|
||
|
||
// Show action buttons group
|
||
const actionButtonsGroup = document.getElementById('actionButtonsGroup');
|
||
actionButtonsGroup.style.display = 'inline-flex';
|
||
|
||
const resumeBtn = document.getElementById('resumeConversation');
|
||
const downloadBtn = document.getElementById('downloadConversation');
|
||
const analyticsBtn = document.getElementById('showAnalytics');
|
||
resumeBtn.setAttribute('data-conversation-id', conversationId);
|
||
downloadBtn.setAttribute('data-conversation-id', conversationId);
|
||
analyticsBtn.setAttribute('data-conversation-id', conversationId);
|
||
|
||
// Load messages (placeholder for now)
|
||
this.loadChatMessages(conversationId);
|
||
}
|
||
|
||
showConversationsList() {
|
||
const chatView = document.getElementById('chatView');
|
||
chatView.classList.remove('active');
|
||
chatView.classList.remove('conversation-selected'); // Hide status footer
|
||
this.selectedConversationId = null;
|
||
|
||
// Clean up scroll tracking when leaving conversation
|
||
this.removeScrollTracking();
|
||
|
||
// Hide action buttons group
|
||
const actionButtonsGroup = document.getElementById('actionButtonsGroup');
|
||
actionButtonsGroup.style.display = 'none';
|
||
|
||
// Remove active state from conversations
|
||
document.querySelectorAll('.conversation-item').forEach(item => {
|
||
item.classList.remove('active');
|
||
});
|
||
|
||
// Reset status
|
||
this.updateStatus('idle', 'Ready', '');
|
||
}
|
||
|
||
async loadChatMessages(conversationId) {
|
||
const chatView = document.getElementById('chatView');
|
||
|
||
// Mark conversation as selected and show status footer
|
||
chatView.classList.add('conversation-selected');
|
||
|
||
// Reset pagination for new conversation
|
||
this.messagesPagination = {
|
||
currentPage: 0,
|
||
limit: 15,
|
||
hasMore: true,
|
||
isLoading: false,
|
||
conversationId: conversationId
|
||
};
|
||
|
||
// Clear cached messages for this conversation
|
||
this.loadedMessages.delete(conversationId);
|
||
|
||
// Load first page of messages
|
||
await this.loadMoreMessages(conversationId, true);
|
||
}
|
||
|
||
/**
|
||
* Load more messages (for infinite scroll)
|
||
* @param {string} conversationId - Conversation ID
|
||
* @param {boolean} isInitialLoad - Whether this is the initial load
|
||
*/
|
||
async loadMoreMessages(conversationId, isInitialLoad = false) {
|
||
const chatMessages = document.getElementById('chatMessages');
|
||
if (!chatMessages) return;
|
||
|
||
// Prevent concurrent loading
|
||
if (this.messagesPagination.isLoading || !this.messagesPagination.hasMore) {
|
||
return;
|
||
}
|
||
|
||
// Ensure we're loading for the correct conversation
|
||
if (this.messagesPagination.conversationId !== conversationId) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.messagesPagination.isLoading = true;
|
||
|
||
if (isInitialLoad) {
|
||
// Show loading state for initial load
|
||
chatMessages.innerHTML = `
|
||
<div class="messages-loading">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading messages...</span>
|
||
</div>
|
||
`;
|
||
// Update status while loading
|
||
this.updateStatus('working', 'Loading conversation...', 'Fetching messages from server');
|
||
} else {
|
||
// Show loading indicator at top for infinite scroll
|
||
this.showMessagesLoadingIndicator(true);
|
||
}
|
||
|
||
// Fetch paginated messages from the server
|
||
const response = await fetch(`/api/conversations/${conversationId}/messages?page=${this.messagesPagination.currentPage}&limit=${this.messagesPagination.limit}`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const messagesData = await response.json();
|
||
|
||
if (messagesData && messagesData.messages) {
|
||
// Update pagination state - handle both paginated and non-paginated responses
|
||
if (messagesData.pagination) {
|
||
// Paginated response
|
||
this.messagesPagination.hasMore = messagesData.pagination.hasMore;
|
||
this.messagesPagination.currentPage = messagesData.pagination.page + 1;
|
||
} else {
|
||
// Non-paginated response (fallback) - treat as complete data
|
||
this.messagesPagination.hasMore = false;
|
||
this.messagesPagination.currentPage = 1;
|
||
}
|
||
|
||
// Get existing messages or initialize
|
||
let existingMessages = this.loadedMessages.get(conversationId) || [];
|
||
|
||
if (isInitialLoad) {
|
||
// For initial load, replace all messages (newest messages first)
|
||
existingMessages = messagesData.messages;
|
||
// Render all messages for initial load
|
||
this.renderCachedMessages(existingMessages, false);
|
||
} else {
|
||
// For infinite scroll, prepend older messages to the beginning
|
||
existingMessages = [...messagesData.messages, ...existingMessages];
|
||
// Render ONLY the new messages (not all accumulated messages)
|
||
this.renderCachedMessages(messagesData.messages, true);
|
||
}
|
||
|
||
// Cache the combined messages
|
||
this.loadedMessages.set(conversationId, existingMessages);
|
||
|
||
// Setup scroll listener for infinite scroll (only on initial load)
|
||
if (isInitialLoad) {
|
||
this.setupMessagesScrollListener(conversationId);
|
||
// Setup intelligent scroll tracking for chat behavior
|
||
this.setupScrollTracking();
|
||
// Enable auto-scroll for initial load
|
||
this.autoScrollEnabled = true;
|
||
// Scroll to bottom for initial load
|
||
this.scrollToBottom();
|
||
// Update status based on conversation state
|
||
this.analyzeConversationStatus(existingMessages);
|
||
}
|
||
|
||
} else if (isInitialLoad) {
|
||
chatMessages.innerHTML = `
|
||
<div class="no-messages-found">
|
||
<div class="no-messages-icon">💭</div>
|
||
<h4>No messages found</h4>
|
||
<p>This conversation has no messages or they could not be loaded.</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error loading messages:', error);
|
||
|
||
if (isInitialLoad) {
|
||
this.updateStatus('error', 'Error loading conversation', error.message);
|
||
chatMessages.innerHTML = `
|
||
<div class="error-loading-messages">
|
||
<div class="error-icon">⚠️</div>
|
||
<h4>Error loading messages</h4>
|
||
<p>${escapeHtml(error.message)}</p>
|
||
<button class="retry-messages">Retry</button>
|
||
</div>
|
||
`;
|
||
|
||
// Add retry functionality
|
||
const retryBtn = chatMessages.querySelector('.retry-messages');
|
||
if (retryBtn) {
|
||
retryBtn.addEventListener('click', () => {
|
||
this.loadChatMessages(conversationId);
|
||
});
|
||
}
|
||
}
|
||
} finally {
|
||
this.messagesPagination.isLoading = false;
|
||
if (!isInitialLoad) {
|
||
this.showMessagesLoadingIndicator(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
renderCachedMessages(messages, prepend = false) {
|
||
const chatMessages = document.getElementById('chatMessages');
|
||
if (!chatMessages) return;
|
||
|
||
if (prepend) {
|
||
// For infinite scroll, prepend messages to existing content
|
||
let existingMessagesDiv = chatMessages.querySelector('.messages-list');
|
||
|
||
if (!existingMessagesDiv) {
|
||
// Create messages div if it doesn't exist
|
||
existingMessagesDiv = document.createElement('div');
|
||
existingMessagesDiv.className = 'messages-list';
|
||
chatMessages.appendChild(existingMessagesDiv);
|
||
}
|
||
|
||
// Remember scroll position to maintain scroll position after prepending
|
||
const scrollTop = chatMessages.scrollTop;
|
||
const scrollHeight = chatMessages.scrollHeight;
|
||
|
||
// Prepend new messages
|
||
// Calculate message indices (considering we're prepending older messages)
|
||
const startIndex = this.messagesPagination.currentPage * this.messagesPagination.limit;
|
||
const newMessagesHTML = messages.map((msg, idx) =>
|
||
this.renderMessage(msg, startIndex + idx)
|
||
).join('');
|
||
existingMessagesDiv.innerHTML = newMessagesHTML + existingMessagesDiv.innerHTML;
|
||
|
||
// Restore scroll position (account for new content)
|
||
const newScrollHeight = chatMessages.scrollHeight;
|
||
const scrollDiff = newScrollHeight - scrollHeight;
|
||
chatMessages.scrollTop = scrollTop + scrollDiff;
|
||
|
||
} else {
|
||
// Normal render (initial load or replace all)
|
||
const messageHTML = `
|
||
<div class="messages-list">
|
||
${messages.map((msg, idx) => this.renderMessage(msg, idx)).join('')}
|
||
</div>
|
||
`;
|
||
|
||
chatMessages.innerHTML = messageHTML;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show/hide messages loading indicator
|
||
* @param {boolean} show - Whether to show the indicator
|
||
*/
|
||
showMessagesLoadingIndicator(show) {
|
||
const chatMessages = document.getElementById('chatMessages');
|
||
if (!chatMessages) return;
|
||
|
||
let indicator = chatMessages.querySelector('.messages-loading-indicator');
|
||
if (!indicator) {
|
||
// Create the loading indicator if it doesn't exist
|
||
indicator = document.createElement('div');
|
||
indicator.className = 'messages-loading-indicator';
|
||
indicator.innerHTML = `
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading older messages...</span>
|
||
`;
|
||
indicator.style.cssText = `
|
||
display: none;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 15px;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 8px;
|
||
margin-bottom: 10px;
|
||
gap: 10px;
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
`;
|
||
chatMessages.insertBefore(indicator, chatMessages.firstChild);
|
||
}
|
||
|
||
indicator.style.display = show ? 'flex' : 'none';
|
||
}
|
||
|
||
/**
|
||
* Setup scroll listener for infinite scroll in messages
|
||
* @param {string} conversationId - Current conversation ID
|
||
*/
|
||
setupMessagesScrollListener(conversationId) {
|
||
const chatMessages = document.getElementById('chatMessages');
|
||
if (!chatMessages) return;
|
||
|
||
// Remove existing listener if any
|
||
if (this.messagesScrollListener) {
|
||
chatMessages.removeEventListener('scroll', this.messagesScrollListener);
|
||
}
|
||
|
||
// Create new listener
|
||
this.messagesScrollListener = () => {
|
||
// Check if we've scrolled near the top (for loading older messages)
|
||
const scrollTop = chatMessages.scrollTop;
|
||
const threshold = 100; // pixels from top
|
||
|
||
if (scrollTop <= threshold && this.messagesPagination.hasMore && !this.messagesPagination.isLoading) {
|
||
this.loadMoreMessages(conversationId, false);
|
||
}
|
||
};
|
||
|
||
// Add listener
|
||
chatMessages.addEventListener('scroll', this.messagesScrollListener);
|
||
}
|
||
|
||
renderMessage(message, messageIndex) {
|
||
const timestamp = this.formatRelativeTime(new Date(message.timestamp));
|
||
const fullTimestamp = new Date(message.timestamp).toLocaleString();
|
||
const isUser = message.role === 'user' && !message.isCompactSummary;
|
||
|
||
// Detect if message contains tools (either in content or as correlated toolResults)
|
||
const hasToolsInContent = Array.isArray(message.content) &&
|
||
message.content.some(block => block.type === 'tool_use');
|
||
const hasCorrelatedTools = message.toolResults && message.toolResults.length > 0;
|
||
const hasTools = hasToolsInContent || hasCorrelatedTools;
|
||
|
||
// Debug logging for tool detection
|
||
if (hasTools) {
|
||
console.log('🔧 Rendering message with tools', {
|
||
messageId: message.id,
|
||
role: message.role,
|
||
hasToolsInContent,
|
||
hasCorrelatedTools,
|
||
toolResultsCount: message.toolResults?.length || 0,
|
||
contentType: Array.isArray(message.content) ? 'array' : typeof message.content,
|
||
willHaveHasToolsClass: !isUser && hasTools
|
||
});
|
||
}
|
||
|
||
const toolCount = hasToolsInContent ?
|
||
message.content.filter(block => block.type === 'tool_use').length :
|
||
(hasCorrelatedTools ? message.toolResults.length : 0);
|
||
|
||
// Add has-tools class to assistant messages that contain tools
|
||
const hasToolsClass = (!isUser && hasTools) ? ' has-tools' : '';
|
||
|
||
// Add message index for search functionality
|
||
const messageIndexAttr = messageIndex !== undefined ? ` data-message-index="${messageIndex}"` : '';
|
||
|
||
return `
|
||
<div class="message message-${isUser ? 'user' : 'assistant'}${hasToolsClass}" data-message-id="${message.id || ''}"${messageIndexAttr}>
|
||
<div class="message-bubble">
|
||
<div class="message-content">
|
||
${this.formatMessageContent(message.content, message)}
|
||
</div>
|
||
<div class="message-meta">
|
||
<span class="message-time" title="${fullTimestamp}">${timestamp}</span>
|
||
${hasTools ? `<span class="tool-indicator">🔧 ${toolCount}</span>` : ''}
|
||
${message.usage && (message.usage.input_tokens > 0 || message.usage.output_tokens > 0) ? `
|
||
<span class="token-indicator">
|
||
In: ${message.usage.input_tokens} • Out: ${message.usage.output_tokens}
|
||
</span>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
formatMessageContent(content, message = null) {
|
||
let result = '';
|
||
|
||
// Handle different content formats
|
||
if (Array.isArray(content)) {
|
||
// Assistant messages with content blocks
|
||
content.forEach((block, index) => {
|
||
if (block.type === 'text') {
|
||
result += this.formatTextContent(block.text);
|
||
} else if (block.type === 'tool_use') {
|
||
result += this.formatToolCall(block);
|
||
} else if (block.type === 'tool_result') {
|
||
result += this.formatToolResult(block);
|
||
}
|
||
});
|
||
} else if (typeof content === 'string') {
|
||
// Simple text content
|
||
result = this.formatTextContent(content);
|
||
} else if (typeof content === 'object' && content.text) {
|
||
// Object with text property
|
||
result = this.formatTextContent(content.text);
|
||
}
|
||
|
||
// Handle correlated tool results from ConversationAnalyzer
|
||
if (message && message.toolResults && message.toolResults.length > 0) {
|
||
console.log('🔧 Formatting tool results for message', {
|
||
messageId: message.id,
|
||
toolResultsCount: message.toolResults.length,
|
||
toolResults: message.toolResults.map(tr => ({ tool_use_id: tr.tool_use_id, contentLength: tr.content?.length }))
|
||
});
|
||
message.toolResults.forEach(toolResult => {
|
||
result += this.formatToolResult(toolResult);
|
||
});
|
||
}
|
||
|
||
return result || '<em>Empty message</em>';
|
||
}
|
||
|
||
formatTextContent(text) {
|
||
if (!text) return '';
|
||
|
||
// Check if text is very long (more than 10 lines or 800 characters)
|
||
const lines = text.split('\n');
|
||
const isLongText = lines.length > 10 || text.length > 800;
|
||
|
||
let processedText = text;
|
||
let expandableContent = '';
|
||
|
||
if (isLongText) {
|
||
// Take first 5 lines or 400 characters, whichever comes first
|
||
const previewLines = lines.slice(0, 5);
|
||
const previewText = previewLines.join('\n');
|
||
const remainingLines = lines.length - 5;
|
||
|
||
if (previewText.length > 400) {
|
||
processedText = text.substring(0, 400) + '...';
|
||
expandableContent = text.substring(400);
|
||
} else {
|
||
processedText = previewText;
|
||
expandableContent = lines.slice(5).join('\n');
|
||
}
|
||
|
||
// Create unique ID for this message
|
||
const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||
|
||
// Format the preview text
|
||
const formattedPreview = processedText
|
||
.replace(/```(\w+)?\n([\s\S]+?)\n```/g, '<pre><code class="$1">$2</code></pre>')
|
||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||
.replace(/\n/g, '<br>');
|
||
|
||
// Format the hidden content
|
||
const formattedHidden = expandableContent
|
||
.replace(/```(\w+)?\n([\s\S]+?)\n```/g, '<pre><code class="$1">$2</code></pre>')
|
||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||
.replace(/\n/g, '<br>');
|
||
|
||
return `
|
||
<div class="expandable-message">
|
||
<div class="message-preview">${formattedPreview}</div>
|
||
<div class="message-expand-link" onclick="this.parentNode.querySelector('.message-preview').style.display='none'; this.parentNode.querySelector('.message-full').style.display='block'; this.style.display='none';">
|
||
+ see ${remainingLines > 0 ? remainingLines + ' more lines' : 'more'}
|
||
</div>
|
||
<div class="message-full" style="display: none;">
|
||
${formattedPreview}<br>${formattedHidden}
|
||
<div class="message-collapse-link" onclick="this.parentNode.style.display='none'; this.parentNode.parentNode.querySelector('.message-preview').style.display='block'; this.parentNode.parentNode.querySelector('.message-expand-link').style.display='block';">
|
||
- show less
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Basic markdown-like formatting for normal length messages
|
||
return text
|
||
.replace(/```(\w+)?\n([\s\S]+?)\n```/g, '<pre><code class="$1">$2</code></pre>')
|
||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||
.replace(/\n/g, '<br>');
|
||
}
|
||
|
||
formatToolCall(toolCall) {
|
||
const toolName = toolCall.name || 'Tool';
|
||
const input = toolCall.input || {};
|
||
|
||
// Create a readable summary of the tool call
|
||
let toolSummary = '';
|
||
if (toolName === 'Read') {
|
||
const filePath = input.file_path || input.path || '';
|
||
const fileName = filePath.split('/').pop() || filePath;
|
||
const limit = input.limit ? ` ${input.limit} lines` : '';
|
||
toolSummary = `${fileName}${limit ? ` (${limit})` : ''}`;
|
||
} else if (toolName === 'Edit' || toolName === 'MultiEdit') {
|
||
const filePath = input.file_path || input.path || '';
|
||
const fileName = filePath.split('/').pop() || filePath;
|
||
toolSummary = fileName;
|
||
} else if (toolName === 'Bash') {
|
||
const command = input.command || '';
|
||
toolSummary = command.length > 50 ? command.substring(0, 50) + '...' : command;
|
||
} else if (toolName === 'Grep') {
|
||
const pattern = input.pattern || '';
|
||
const path = input.path ? ` in ${input.path.split('/').pop()}` : '';
|
||
toolSummary = `"${pattern}"${path}`;
|
||
} else if (toolName === 'Write') {
|
||
const filePath = input.file_path || input.path || '';
|
||
const fileName = filePath.split('/').pop() || filePath;
|
||
toolSummary = fileName;
|
||
} else {
|
||
// Generic fallback
|
||
const firstKey = Object.keys(input)[0];
|
||
if (firstKey) {
|
||
const value = input[firstKey];
|
||
if (typeof value === 'string') {
|
||
toolSummary = value.length > 30 ? value.substring(0, 30) + '...' : value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create escaped JSON for parameters
|
||
const inputContent = JSON.stringify(input, null, 2);
|
||
const escapedInput = this.escapeHtml(inputContent);
|
||
|
||
return `
|
||
<div class="tool-call">
|
||
<div class="tool-summary" onclick="
|
||
const toolCall = this.parentNode;
|
||
const expandNote = toolCall.querySelector('.tool-expand-note');
|
||
const parameters = toolCall.querySelector('.tool-parameters');
|
||
|
||
if (parameters.style.display === 'none') {
|
||
expandNote.style.display = 'none';
|
||
parameters.style.display = 'block';
|
||
} else {
|
||
expandNote.style.display = 'flex';
|
||
parameters.style.display = 'none';
|
||
}
|
||
">
|
||
<span class="tool-bullet">⏺</span>
|
||
<span class="tool-name">${toolName}</span>${toolSummary ? `<span class="tool-summary-text">(${toolSummary})</span>` : ''}
|
||
</div>
|
||
<div class="tool-expand-note" onclick="this.parentNode.querySelector('.tool-summary').click();">
|
||
<span class="tool-branch">⎿</span>
|
||
<span class="tool-note">Click to expand parameters</span>
|
||
</div>
|
||
<div class="tool-parameters" style="display: none;">
|
||
<div class="tool-parameters-header">
|
||
<span class="tool-branch">⎿</span>
|
||
<span class="tool-param-title">Parameters:</span>
|
||
</div>
|
||
<pre class="tool-parameters-content"><code>${escapedInput}</code></pre>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
formatToolResult(toolResult) {
|
||
// Properly escape HTML content
|
||
const content = typeof toolResult.content === 'string'
|
||
? toolResult.content
|
||
: JSON.stringify(toolResult.content, null, 2);
|
||
|
||
const escapedContent = this.escapeHtml(content);
|
||
|
||
// Check if content is long (more than 5 lines or 300 characters)
|
||
const lines = content.split('\n');
|
||
const isLongContent = lines.length > 5 || content.length > 300;
|
||
|
||
if (isLongContent) {
|
||
// Show collapsed version for long content
|
||
const previewLines = lines.slice(0, 3);
|
||
const previewContent = previewLines.join('\n');
|
||
const remainingLines = lines.length - 3;
|
||
const escapedPreview = this.escapeHtml(previewContent);
|
||
|
||
const messageId = 'tool_result_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||
|
||
return `
|
||
<div class="tool-result">
|
||
<div class="tool-result-header">
|
||
<span>Tool Result</span>
|
||
</div>
|
||
<div class="tool-result-content">
|
||
<div class="expandable-tool-result">
|
||
<div class="tool-result-preview">
|
||
<pre><code>${escapedPreview}</code></pre>
|
||
</div>
|
||
<div class="tool-result-expand-link" onclick="this.parentNode.querySelector('.tool-result-preview').style.display='none'; this.parentNode.querySelector('.tool-result-full').style.display='block'; this.style.display='none';">
|
||
+ see ${remainingLines} more lines
|
||
</div>
|
||
<div class="tool-result-full" style="display: none;">
|
||
<pre><code>${escapedContent}</code></pre>
|
||
<div class="tool-result-collapse-link" onclick="this.parentNode.style.display='none'; this.parentNode.parentNode.querySelector('.tool-result-preview').style.display='block'; this.parentNode.parentNode.querySelector('.tool-result-expand-link').style.display='block';">
|
||
- show less
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Regular format for short content
|
||
return `
|
||
<div class="tool-result">
|
||
<div class="tool-result-header">
|
||
<span>Tool Result</span>
|
||
</div>
|
||
<div class="tool-result-content">
|
||
<pre><code>${escapedContent}</code></pre>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Check if user is near the bottom of the chat
|
||
* @returns {boolean} True if user is near the bottom
|
||
*/
|
||
isNearBottom() {
|
||
const chatMessages = document.getElementById('chatMessages');
|
||
if (!chatMessages) return false;
|
||
|
||
const scrollTop = chatMessages.scrollTop;
|
||
const scrollHeight = chatMessages.scrollHeight;
|
||
const clientHeight = chatMessages.clientHeight;
|
||
|
||
// Consider "near bottom" if within scrollThreshold pixels
|
||
return scrollHeight - scrollTop - clientHeight <= this.scrollThreshold;
|
||
}
|
||
|
||
/**
|
||
* Smart scroll to bottom with chat logic
|
||
* Only scrolls if user is near bottom or auto-scroll is enabled
|
||
*/
|
||
scrollToBottom() {
|
||
const chatMessages = document.getElementById('chatMessages');
|
||
if (!chatMessages) return;
|
||
|
||
// Always scroll on initial load or if user is near bottom
|
||
if (this.autoScrollEnabled || this.isNearBottom()) {
|
||
console.log('📱 Auto-scrolling to bottom', {
|
||
autoScrollEnabled: this.autoScrollEnabled,
|
||
isNearBottom: this.isNearBottom(),
|
||
userScrolling: this.isUserScrolling
|
||
});
|
||
|
||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||
} else {
|
||
console.log('📱 Skipping auto-scroll (user viewing older messages)', {
|
||
scrollTop: chatMessages.scrollTop,
|
||
scrollHeight: chatMessages.scrollHeight,
|
||
isNearBottom: this.isNearBottom()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setup scroll tracking for intelligent auto-scroll
|
||
*/
|
||
setupScrollTracking() {
|
||
const chatMessages = document.getElementById('chatMessages');
|
||
if (!chatMessages || this.scrollListener) return;
|
||
|
||
this.scrollListener = () => {
|
||
// Clear previous timeout
|
||
if (this.userScrollTimeout) {
|
||
clearTimeout(this.userScrollTimeout);
|
||
}
|
||
|
||
// Mark as user scrolling
|
||
this.isUserScrolling = true;
|
||
|
||
// Check if user scrolled back to bottom
|
||
if (this.isNearBottom()) {
|
||
this.autoScrollEnabled = true;
|
||
console.log('📱 User scrolled to bottom, re-enabling auto-scroll');
|
||
} else {
|
||
this.autoScrollEnabled = false;
|
||
}
|
||
|
||
// Reset user scrolling flag after a delay
|
||
this.userScrollTimeout = setTimeout(() => {
|
||
this.isUserScrolling = false;
|
||
}, 1000);
|
||
};
|
||
|
||
chatMessages.addEventListener('scroll', this.scrollListener, { passive: true });
|
||
console.log('📱 Scroll tracking enabled for intelligent auto-scroll');
|
||
}
|
||
|
||
/**
|
||
* Remove scroll tracking
|
||
*/
|
||
removeScrollTracking() {
|
||
const chatMessages = document.getElementById('chatMessages');
|
||
if (chatMessages && this.scrollListener) {
|
||
chatMessages.removeEventListener('scroll', this.scrollListener);
|
||
this.scrollListener = null;
|
||
}
|
||
|
||
if (this.userScrollTimeout) {
|
||
clearTimeout(this.userScrollTimeout);
|
||
this.userScrollTimeout = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Escape HTML characters to prevent double-encoding issues
|
||
* @param {string} text - Text to escape
|
||
* @returns {string} Escaped text
|
||
*/
|
||
escapeHtml(text) {
|
||
if (typeof text !== 'string') return text;
|
||
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
|
||
|
||
filterConversations(searchTerm) {
|
||
const term = searchTerm.toLowerCase().trim();
|
||
const projectGroups = document.querySelectorAll('.project-group');
|
||
|
||
if (!term) {
|
||
// If search is empty, collapse all projects and reset
|
||
this.expandedProjects.clear(); // Close all projects
|
||
|
||
projectGroups.forEach(group => {
|
||
group.style.display = 'block';
|
||
|
||
// Hide all conversations
|
||
const conversations = group.querySelectorAll('.conversation-item');
|
||
conversations.forEach(conv => {
|
||
conv.style.display = 'none';
|
||
});
|
||
|
||
// Update toggle icon to collapsed state
|
||
const toggleIcon = group.querySelector('.toggle-icon');
|
||
if (toggleIcon) {
|
||
toggleIcon.classList.remove('expanded');
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Filter projects and conversations
|
||
projectGroups.forEach(group => {
|
||
const projectName = group.dataset.project;
|
||
const projectNameLower = projectName.toLowerCase();
|
||
const conversations = group.querySelectorAll('.conversation-item');
|
||
|
||
let hasMatchingConversation = false;
|
||
|
||
// Check each conversation in this project
|
||
conversations.forEach(item => {
|
||
const nameElement = item.querySelector('.conversation-name');
|
||
const name = nameElement ? nameElement.textContent.toLowerCase() : '';
|
||
const matches = name.includes(term) || projectNameLower.includes(term);
|
||
|
||
if (matches) {
|
||
item.style.display = 'flex';
|
||
hasMatchingConversation = true;
|
||
} else {
|
||
item.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Show project group if it has matching conversations or if project name matches
|
||
if (hasMatchingConversation || projectNameLower.includes(term)) {
|
||
group.style.display = 'block';
|
||
// Auto-expand project when searching
|
||
if (!this.expandedProjects.has(projectName)) {
|
||
this.expandedProjects.add(projectName);
|
||
// Update toggle icon
|
||
const toggleIcon = group.querySelector('.toggle-icon');
|
||
if (toggleIcon) {
|
||
toggleIcon.classList.add('expanded');
|
||
}
|
||
}
|
||
} else {
|
||
group.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Advanced Search Methods
|
||
openAdvancedSearch() {
|
||
const overlay = document.getElementById('searchPanelOverlay');
|
||
overlay.classList.add('active');
|
||
document.body.style.overflow = 'hidden'; // Prevent background scrolling
|
||
}
|
||
|
||
closeAdvancedSearch() {
|
||
const overlay = document.getElementById('searchPanelOverlay');
|
||
overlay.classList.remove('active');
|
||
document.body.style.overflow = ''; // Restore scrolling
|
||
}
|
||
|
||
async applyAdvancedSearch() {
|
||
const conversationsList = document.getElementById('conversationsList');
|
||
const searchResultsInfo = document.getElementById('searchResultsInfo');
|
||
const searchResultsCount = document.getElementById('searchResultsCount');
|
||
const appliedFiltersContainer = document.getElementById('appliedFilters');
|
||
|
||
try {
|
||
// Show loading state
|
||
conversationsList.innerHTML = '<div class="loading-spinner" style="margin: 40px auto;"></div>';
|
||
|
||
// Get filter values
|
||
const filters = {
|
||
query: document.getElementById('filterQuery').value.trim(),
|
||
workingDirectory: document.getElementById('filterWorkingDirectory').value.trim(),
|
||
dateFrom: document.getElementById('filterDateFrom').value,
|
||
dateTo: document.getElementById('filterDateTo').value,
|
||
contentSearch: document.getElementById('filterContentSearch').value.trim()
|
||
};
|
||
|
||
// Call the search API
|
||
const response = await fetch('/api/search', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(filters)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Search failed: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Update conversations with search results
|
||
this.conversations = data.results || [];
|
||
|
||
// Get conversation states for the results
|
||
const statesResponse = await fetch('/api/conversation-state');
|
||
let states = {};
|
||
if (statesResponse.ok) {
|
||
const statesData = await statesResponse.json();
|
||
states = statesData.activeStates || {};
|
||
}
|
||
|
||
// Render search results
|
||
this.renderConversations(this.conversations, states);
|
||
|
||
// Show search results info with applied filters
|
||
searchResultsCount.textContent = data.count;
|
||
this.renderAppliedFilters(filters, appliedFiltersContainer);
|
||
searchResultsInfo.classList.add('active');
|
||
|
||
// Close the search panel
|
||
this.closeAdvancedSearch();
|
||
|
||
console.log('🔍 Search completed:', data.count, 'results found');
|
||
} catch (error) {
|
||
console.error('Error performing advanced search:', error);
|
||
conversationsList.innerHTML = `
|
||
<div class="no-conversations">
|
||
<div class="no-conversations-icon">⚠️</div>
|
||
<h3>Search Error</h3>
|
||
<p>${escapeHtml(error.message)}</p>
|
||
<button onclick="location.reload()" style="margin-top: 12px; padding: 8px 16px; background: var(--text-accent); color: white; border: none; border-radius: 4px; cursor: pointer;">Retry</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
renderAppliedFilters(filters, container) {
|
||
container.innerHTML = '';
|
||
|
||
const filterTags = [];
|
||
|
||
// Quick search filter
|
||
if (filters.query) {
|
||
filterTags.push({
|
||
icon: '💬',
|
||
label: 'Search',
|
||
value: filters.query
|
||
});
|
||
}
|
||
|
||
// Working directory filter
|
||
if (filters.workingDirectory) {
|
||
// Shorten the path for display
|
||
const shortPath = filters.workingDirectory.length > 30
|
||
? '...' + filters.workingDirectory.slice(-30)
|
||
: filters.workingDirectory;
|
||
filterTags.push({
|
||
icon: '📁',
|
||
label: 'Directory',
|
||
value: shortPath
|
||
});
|
||
}
|
||
|
||
// Date range filter
|
||
if (filters.dateFrom || filters.dateTo) {
|
||
let dateValue = '';
|
||
if (filters.dateFrom && filters.dateTo) {
|
||
dateValue = `${this.formatDate(filters.dateFrom)} → ${this.formatDate(filters.dateTo)}`;
|
||
} else if (filters.dateFrom) {
|
||
dateValue = `From ${this.formatDate(filters.dateFrom)}`;
|
||
} else if (filters.dateTo) {
|
||
dateValue = `Until ${this.formatDate(filters.dateTo)}`;
|
||
}
|
||
filterTags.push({
|
||
icon: '📅',
|
||
label: 'Date',
|
||
value: dateValue
|
||
});
|
||
}
|
||
|
||
// Content search filter
|
||
if (filters.contentSearch) {
|
||
filterTags.push({
|
||
icon: '🔎',
|
||
label: 'Content',
|
||
value: filters.contentSearch
|
||
});
|
||
}
|
||
|
||
// Render filter tags
|
||
filterTags.forEach(tag => {
|
||
const tagElement = document.createElement('div');
|
||
tagElement.className = 'filter-tag';
|
||
tagElement.innerHTML = `
|
||
<span class="filter-tag-icon">${tag.icon}</span>
|
||
<span class="filter-tag-label">${tag.label}:</span>
|
||
<span class="filter-tag-value">${tag.value}</span>
|
||
`;
|
||
container.appendChild(tagElement);
|
||
});
|
||
}
|
||
|
||
formatDate(dateString) {
|
||
const date = new Date(dateString);
|
||
const options = { month: 'short', day: 'numeric', year: 'numeric' };
|
||
return date.toLocaleDateString('en-US', options);
|
||
}
|
||
|
||
// Folder Browser Methods
|
||
async toggleFolderBrowser() {
|
||
const dropdown = document.getElementById('folderBrowserDropdown');
|
||
const isActive = dropdown.classList.contains('active');
|
||
|
||
if (isActive) {
|
||
dropdown.classList.remove('active');
|
||
} else {
|
||
// Load directories if not loaded yet
|
||
if (!this.directories) {
|
||
await this.loadDirectories();
|
||
}
|
||
dropdown.classList.add('active');
|
||
}
|
||
}
|
||
|
||
async loadDirectories() {
|
||
const listContainer = document.getElementById('folderBrowserList');
|
||
|
||
try {
|
||
listContainer.innerHTML = '<div class="folder-browser-empty">Loading directories...</div>';
|
||
|
||
const response = await fetch('/api/directories');
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load directories');
|
||
}
|
||
|
||
const data = await response.json();
|
||
this.directories = data.directories || [];
|
||
|
||
// Count conversations per directory
|
||
this.directoryCounts = {};
|
||
this.conversations.forEach(conv => {
|
||
if (conv.project) {
|
||
this.directoryCounts[conv.project] = (this.directoryCounts[conv.project] || 0) + 1;
|
||
}
|
||
});
|
||
|
||
this.renderFolderBrowserList(this.directories);
|
||
} catch (error) {
|
||
console.error('Error loading directories:', error);
|
||
listContainer.innerHTML = '<div class="folder-browser-empty">Error loading directories</div>';
|
||
}
|
||
}
|
||
|
||
renderFolderBrowserList(directories) {
|
||
const listContainer = document.getElementById('folderBrowserList');
|
||
const currentValue = document.getElementById('filterWorkingDirectory').value;
|
||
|
||
if (directories.length === 0) {
|
||
listContainer.innerHTML = '<div class="folder-browser-empty">No directories found</div>';
|
||
return;
|
||
}
|
||
|
||
listContainer.innerHTML = directories.map(dir => {
|
||
const count = this.directoryCounts[dir] || 0;
|
||
const isSelected = dir === currentValue;
|
||
|
||
return `
|
||
<div class="folder-item ${isSelected ? 'selected' : ''}" data-path="${this.escapeHtml(dir)}">
|
||
<span class="folder-icon">📁</span>
|
||
<span class="folder-path" title="${this.escapeHtml(dir)}">${this.escapeHtml(dir)}</span>
|
||
<span class="folder-count">${count}</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Add click handlers to folder items
|
||
listContainer.querySelectorAll('.folder-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const path = item.getAttribute('data-path');
|
||
this.selectDirectory(path);
|
||
});
|
||
});
|
||
}
|
||
|
||
selectDirectory(path) {
|
||
const input = document.getElementById('filterWorkingDirectory');
|
||
const dropdown = document.getElementById('folderBrowserDropdown');
|
||
|
||
input.value = path;
|
||
dropdown.classList.remove('active');
|
||
|
||
// Update UI to show selected folder
|
||
this.renderFolderBrowserList(this.directories);
|
||
}
|
||
|
||
filterFolderBrowserList(searchTerm) {
|
||
if (!this.directories) return;
|
||
|
||
const term = searchTerm.toLowerCase();
|
||
const filtered = this.directories.filter(dir =>
|
||
dir.toLowerCase().includes(term)
|
||
);
|
||
|
||
this.renderFolderBrowserList(filtered);
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// In-Conversation Search Methods
|
||
toggleChatSearch() {
|
||
const searchBar = document.getElementById('chatSearchBar');
|
||
const toggle = document.getElementById('chatSearchToggle');
|
||
const isActive = searchBar.classList.contains('active');
|
||
|
||
if (isActive) {
|
||
this.closeChatSearch();
|
||
} else {
|
||
searchBar.classList.add('active');
|
||
toggle.classList.add('active');
|
||
document.getElementById('chatSearchInput').focus();
|
||
}
|
||
}
|
||
|
||
closeChatSearch() {
|
||
const searchBar = document.getElementById('chatSearchBar');
|
||
const toggle = document.getElementById('chatSearchToggle');
|
||
const input = document.getElementById('chatSearchInput');
|
||
|
||
searchBar.classList.remove('active');
|
||
toggle.classList.remove('active');
|
||
input.value = '';
|
||
|
||
// Clear search state
|
||
this.chatSearchMatches = [];
|
||
this.chatSearchCurrentIndex = -1;
|
||
this.updateSearchCounter();
|
||
this.clearSearchHighlights();
|
||
}
|
||
|
||
clearChatSearch() {
|
||
// Clear search results but keep search bar open
|
||
this.chatSearchMatches = [];
|
||
this.chatSearchCurrentIndex = -1;
|
||
this.updateSearchCounter();
|
||
this.clearSearchHighlights();
|
||
}
|
||
|
||
async performChatSearch(query) {
|
||
if (!this.selectedConversationId || !query || query.trim().length === 0) {
|
||
this.chatSearchMatches = [];
|
||
this.chatSearchCurrentIndex = -1;
|
||
this.updateSearchCounter();
|
||
this.clearSearchHighlights();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/conversations/${this.selectedConversationId}/search`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ query: query.trim() })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Search failed');
|
||
}
|
||
|
||
const data = await response.json();
|
||
this.chatSearchMatches = data.matches || [];
|
||
this.chatSearchCurrentIndex = this.chatSearchMatches.length > 0 ? 0 : -1;
|
||
|
||
console.log('🔍 Search results:', {
|
||
query: query,
|
||
totalMatches: this.chatSearchMatches.length,
|
||
matches: this.chatSearchMatches
|
||
});
|
||
|
||
this.updateSearchCounter();
|
||
this.highlightSearchResults(query);
|
||
|
||
// Navigate to first match
|
||
if (this.chatSearchMatches.length > 0) {
|
||
await this.scrollToSearchMatch(0);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error searching in conversation:', error);
|
||
}
|
||
}
|
||
|
||
async navigateSearchResults(direction) {
|
||
if (this.chatSearchMatches.length === 0) return;
|
||
|
||
if (direction === 'next') {
|
||
this.chatSearchCurrentIndex = (this.chatSearchCurrentIndex + 1) % this.chatSearchMatches.length;
|
||
} else {
|
||
this.chatSearchCurrentIndex = (this.chatSearchCurrentIndex - 1 + this.chatSearchMatches.length) % this.chatSearchMatches.length;
|
||
}
|
||
|
||
this.updateSearchCounter();
|
||
await this.scrollToSearchMatch(this.chatSearchCurrentIndex);
|
||
}
|
||
|
||
async scrollToSearchMatch(index) {
|
||
if (index < 0 || index >= this.chatSearchMatches.length) return;
|
||
|
||
const match = this.chatSearchMatches[index];
|
||
const messageId = match.messageId;
|
||
const messageIndex = match.messageIndex;
|
||
|
||
// Try to find the message by ID first (more reliable)
|
||
let messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
|
||
|
||
// Fallback to index if ID doesn't work
|
||
if (!messageElement) {
|
||
messageElement = document.querySelector(`[data-message-index="${messageIndex}"]`);
|
||
}
|
||
|
||
if (messageElement) {
|
||
// Message is already in DOM, just scroll to it
|
||
this.scrollToMessage(messageElement);
|
||
this.highlightCurrentMatchById(messageId);
|
||
} else {
|
||
// Message not loaded yet - need to load more messages
|
||
// This handles the infinite scroll case
|
||
await this.loadMessageById(messageId, messageIndex);
|
||
}
|
||
}
|
||
|
||
async loadMessageById(messageId, messageIndex) {
|
||
// Keep loading more messages until we find the target message by ID
|
||
let attempts = 0;
|
||
const maxAttempts = 10; // Prevent infinite loops
|
||
|
||
while (attempts < maxAttempts) {
|
||
// Try to find by ID first
|
||
let messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
|
||
|
||
if (messageElement) {
|
||
// Found the message!
|
||
this.scrollToMessage(messageElement);
|
||
this.highlightCurrentMatchById(messageId);
|
||
return;
|
||
}
|
||
|
||
// Try to load more messages
|
||
if (this.messagesPagination.hasMore && !this.messagesPagination.isLoading) {
|
||
console.log(`🔄 Loading more messages to find message ${messageId}...`);
|
||
await this.loadMoreMessages(this.selectedConversationId, false);
|
||
attempts++;
|
||
|
||
// Wait a bit for messages to render
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
} else {
|
||
// No more messages to load or already loading
|
||
console.warn('Could not find message with ID', messageId, 'after', attempts, 'attempts');
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
scrollToMessage(messageElement) {
|
||
if (!messageElement) {
|
||
console.warn('No message element to scroll to');
|
||
return;
|
||
}
|
||
|
||
const messagesContainer = document.getElementById('chatMessages');
|
||
if (!messagesContainer) return;
|
||
|
||
// Get the absolute position of the message within the scrollable container
|
||
const containerTop = messagesContainer.getBoundingClientRect().top;
|
||
const messageTop = messageElement.getBoundingClientRect().top;
|
||
const containerHeight = messagesContainer.clientHeight;
|
||
const messageHeight = messageElement.offsetHeight;
|
||
|
||
// Calculate the current scroll position
|
||
const currentScroll = messagesContainer.scrollTop;
|
||
|
||
// Calculate the offset from the top of the container
|
||
const messageOffsetFromTop = messageTop - containerTop;
|
||
|
||
// Calculate target scroll position to center the message
|
||
// We want the message to be in the middle of the visible area
|
||
const targetScroll = currentScroll + messageOffsetFromTop - (containerHeight / 2) + (messageHeight / 2);
|
||
|
||
// Scroll to the target position
|
||
messagesContainer.scrollTo({
|
||
top: Math.max(0, targetScroll), // Don't scroll to negative values
|
||
behavior: 'smooth'
|
||
});
|
||
|
||
// Add a brief highlight animation to make it more visible
|
||
messageElement.style.transition = 'background-color 0.3s ease';
|
||
const originalBg = messageElement.style.backgroundColor;
|
||
messageElement.style.backgroundColor = 'rgba(255, 107, 53, 0.1)';
|
||
|
||
setTimeout(() => {
|
||
messageElement.style.backgroundColor = originalBg;
|
||
setTimeout(() => {
|
||
messageElement.style.transition = '';
|
||
}, 300);
|
||
}, 600);
|
||
}
|
||
|
||
highlightSearchResults(query) {
|
||
this.clearSearchHighlights();
|
||
const searchTerm = query.toLowerCase();
|
||
const messageElements = document.querySelectorAll('.message-content');
|
||
|
||
messageElements.forEach(element => {
|
||
const text = element.textContent;
|
||
if (text.toLowerCase().includes(searchTerm)) {
|
||
const highlightedHTML = this.highlightText(element.innerHTML, query);
|
||
element.innerHTML = highlightedHTML;
|
||
}
|
||
});
|
||
}
|
||
|
||
highlightText(html, searchTerm) {
|
||
const text = document.createElement('div');
|
||
text.innerHTML = html;
|
||
const textContent = text.textContent;
|
||
|
||
if (!textContent.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||
return html;
|
||
}
|
||
|
||
// Use regex to find all occurrences (case-insensitive)
|
||
const regex = new RegExp(`(${this.escapeRegex(searchTerm)})`, 'gi');
|
||
const newHTML = textContent.replace(regex, '<span class="message-highlight">$1</span>');
|
||
|
||
return newHTML;
|
||
}
|
||
|
||
highlightCurrentMatch(messageIndex) {
|
||
// Remove previous current highlight
|
||
document.querySelectorAll('.message-current-highlight').forEach(el => {
|
||
el.classList.remove('message-current-highlight');
|
||
el.classList.add('message-highlight');
|
||
});
|
||
|
||
// Add current highlight to the current match
|
||
const messageElement = document.querySelector(`[data-message-index="${messageIndex}"]`);
|
||
if (messageElement) {
|
||
const firstHighlight = messageElement.querySelector('.message-highlight');
|
||
if (firstHighlight) {
|
||
firstHighlight.classList.remove('message-highlight');
|
||
firstHighlight.classList.add('message-current-highlight');
|
||
|
||
// Scroll the highlight into view within the message
|
||
// This ensures the actual highlighted text is visible
|
||
setTimeout(() => {
|
||
firstHighlight.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'center',
|
||
inline: 'nearest'
|
||
});
|
||
}, 100);
|
||
}
|
||
}
|
||
}
|
||
|
||
highlightCurrentMatchById(messageId) {
|
||
// Remove previous current highlight
|
||
document.querySelectorAll('.message-current-highlight').forEach(el => {
|
||
el.classList.remove('message-current-highlight');
|
||
el.classList.add('message-highlight');
|
||
});
|
||
|
||
// Add current highlight to the current match (find by message ID)
|
||
const messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
|
||
if (messageElement) {
|
||
const firstHighlight = messageElement.querySelector('.message-highlight');
|
||
if (firstHighlight) {
|
||
firstHighlight.classList.remove('message-highlight');
|
||
firstHighlight.classList.add('message-current-highlight');
|
||
|
||
// Scroll the highlight into view within the message
|
||
// This ensures the actual highlighted text is visible
|
||
setTimeout(() => {
|
||
firstHighlight.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'center',
|
||
inline: 'nearest'
|
||
});
|
||
}, 100);
|
||
}
|
||
}
|
||
}
|
||
|
||
clearSearchHighlights() {
|
||
document.querySelectorAll('.message-highlight, .message-current-highlight').forEach(el => {
|
||
const parent = el.parentNode;
|
||
parent.replaceChild(document.createTextNode(el.textContent), el);
|
||
parent.normalize(); // Merge adjacent text nodes
|
||
});
|
||
}
|
||
|
||
updateSearchCounter(currentIndex = null, totalMatches = null, showMinCharsMessage = false) {
|
||
const counter = document.getElementById('chatSearchCounter');
|
||
const prevBtn = document.getElementById('chatSearchPrev');
|
||
const nextBtn = document.getElementById('chatSearchNext');
|
||
|
||
// Use provided values or fall back to instance values
|
||
const current = currentIndex !== null ? currentIndex : this.chatSearchCurrentIndex;
|
||
const total = totalMatches !== null ? totalMatches : this.chatSearchMatches.length;
|
||
|
||
console.log('📊 Updating counter:', {
|
||
currentIndex: current,
|
||
totalMatches: total,
|
||
showMinCharsMessage: showMinCharsMessage
|
||
});
|
||
|
||
if (showMinCharsMessage) {
|
||
counter.textContent = 'Min 3 chars';
|
||
counter.classList.remove('has-results');
|
||
prevBtn.disabled = true;
|
||
nextBtn.disabled = true;
|
||
} else if (total === 0) {
|
||
counter.textContent = '0/0';
|
||
counter.classList.remove('has-results');
|
||
prevBtn.disabled = true;
|
||
nextBtn.disabled = true;
|
||
} else {
|
||
counter.textContent = `${current + 1}/${total}`;
|
||
counter.classList.add('has-results');
|
||
prevBtn.disabled = false;
|
||
nextBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async getTotalMessageCount(conversationId) {
|
||
const conversation = this.conversations.find(c => c.id === conversationId);
|
||
return conversation ? conversation.messageCount : 0;
|
||
}
|
||
|
||
escapeRegex(string) {
|
||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
resetAdvancedSearch() {
|
||
// Clear all filter inputs
|
||
document.getElementById('filterQuery').value = '';
|
||
document.getElementById('filterWorkingDirectory').value = '';
|
||
document.getElementById('filterDateFrom').value = '';
|
||
document.getElementById('filterDateTo').value = '';
|
||
document.getElementById('filterContentSearch').value = '';
|
||
}
|
||
|
||
async clearAllFilters() {
|
||
const searchResultsInfo = document.getElementById('searchResultsInfo');
|
||
const searchInput = document.getElementById('searchInput');
|
||
|
||
// Clear simple search
|
||
searchInput.value = '';
|
||
|
||
// Clear advanced search filters
|
||
this.resetAdvancedSearch();
|
||
|
||
// Reset expanded projects state (close all projects)
|
||
if (this.expandedProjects) {
|
||
this.expandedProjects.clear();
|
||
}
|
||
|
||
// Hide search results info
|
||
searchResultsInfo.classList.remove('active');
|
||
|
||
// Reload all conversations
|
||
await this.loadConversations();
|
||
}
|
||
|
||
getStateLabel(state) {
|
||
// Handle all possible states from StateCalculator with icons
|
||
const stateLabels = {
|
||
// Basic states
|
||
'active': '● Active',
|
||
'idle': '◐ Idle',
|
||
'inactive': '○ Inactive',
|
||
|
||
// Detailed states from StateCalculator
|
||
'Claude Code working...': '⚡ Working',
|
||
'Claude Code finishing...': '✓ Finishing',
|
||
'Active session': '● Active',
|
||
'Active conversation': '● Active',
|
||
'Recently active': '◉ Recent',
|
||
'Awaiting user input...': '⏳ Waiting',
|
||
'Awaiting response...': '💭 Responding',
|
||
'User typing...': '⌨️ Typing',
|
||
'Waiting for input...': '⏳ Waiting',
|
||
'No messages': '○ Empty',
|
||
|
||
// Fallback for exact matches
|
||
'Claude Code working': '⚡ Working',
|
||
'Claude Code': '⚡ Working',
|
||
'working': '⚡ Working',
|
||
'working...': '⚡ Working',
|
||
'recent': '◉ Recent',
|
||
'waiting': '⏳ Waiting'
|
||
};
|
||
|
||
// Try exact match first
|
||
if (stateLabels[state]) {
|
||
return stateLabels[state];
|
||
}
|
||
|
||
// Try partial matches for complex states with icons
|
||
const stateLower = state.toLowerCase();
|
||
if (stateLower.includes('working') || stateLower.includes('claude')) {
|
||
return '⚡ Working';
|
||
}
|
||
if (stateLower.includes('active')) {
|
||
return '● Active';
|
||
}
|
||
if (stateLower.includes('recent')) {
|
||
return '◉ Recent';
|
||
}
|
||
if (stateLower.includes('waiting') || stateLower.includes('awaiting')) {
|
||
return '⏳ Waiting';
|
||
}
|
||
if (stateLower.includes('responding')) {
|
||
return '💭 Responding';
|
||
}
|
||
if (stateLower.includes('typing')) {
|
||
return '⌨️ Typing';
|
||
}
|
||
if (stateLower.includes('idle')) {
|
||
return '◐ Idle';
|
||
}
|
||
if (stateLower.includes('inactive')) {
|
||
return '○ Inactive';
|
||
}
|
||
|
||
// Return the state as-is if we can't categorize it
|
||
return state || 'Unknown';
|
||
}
|
||
|
||
getStateClass(state) {
|
||
// Generate appropriate CSS class based on state
|
||
const stateLower = (state || '').toLowerCase();
|
||
|
||
if (stateLower.includes('working') || stateLower.includes('claude')) {
|
||
return 'state-working';
|
||
}
|
||
if (stateLower.includes('active') || stateLower.includes('recent')) {
|
||
return 'state-active';
|
||
}
|
||
if (stateLower.includes('waiting') || stateLower.includes('awaiting') || stateLower.includes('responding')) {
|
||
return 'state-waiting';
|
||
}
|
||
if (stateLower.includes('typing')) {
|
||
return 'state-typing';
|
||
}
|
||
if (stateLower.includes('idle')) {
|
||
return 'state-idle';
|
||
}
|
||
if (stateLower.includes('inactive')) {
|
||
return 'state-inactive';
|
||
}
|
||
|
||
// Fallback to basic classification
|
||
return `state-${state.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z-]/g, '')}`;
|
||
}
|
||
|
||
formatRelativeTime(date) {
|
||
if (!date) return '';
|
||
|
||
const now = new Date();
|
||
const diff = now.getTime() - date.getTime();
|
||
const minutes = Math.floor(diff / 60000);
|
||
const hours = Math.floor(minutes / 60);
|
||
const days = Math.floor(hours / 24);
|
||
const weeks = Math.floor(days / 7);
|
||
const months = Math.floor(days / 30);
|
||
|
||
if (minutes < 1) return 'Just now';
|
||
if (minutes < 60) return `${minutes}m ago`;
|
||
if (hours < 24) return `${hours}h ago`;
|
||
if (days < 7) return `${days}d ago`;
|
||
if (weeks < 4) return `${weeks}w ago`;
|
||
if (months < 12) return `${months}mo ago`;
|
||
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
/**
|
||
* Handle new message received via WebSocket
|
||
* @param {string} conversationId - Conversation ID that received new message
|
||
* @param {Object} message - New message object
|
||
* @param {Object} metadata - Additional metadata
|
||
*/
|
||
handleNewMessage(conversationId, message, metadata) {
|
||
console.log('🔄 Processing new message', {
|
||
conversationId,
|
||
role: message?.role,
|
||
selectedConversationId: this.selectedConversationId,
|
||
hasToolResults: !!(message?.toolResults && message.toolResults.length > 0),
|
||
toolResultsCount: message?.toolResults?.length || 0,
|
||
hasToolsInContent: Array.isArray(message?.content) && message.content.some(block => block.type === 'tool_use'),
|
||
contentType: Array.isArray(message?.content) ? 'array' : typeof message?.content,
|
||
metadata: metadata
|
||
});
|
||
|
||
// Update message cache for this conversation
|
||
const existingMessages = this.loadedMessages.get(conversationId) || [];
|
||
|
||
// Check if we already have this message (ONLY by ID - no timestamp fallback)
|
||
// This ensures we only merge/replace the exact same message, never different ones
|
||
// CRITICAL: Only match if both messages have valid, non-null IDs
|
||
const existingIndex = existingMessages.findIndex(msg =>
|
||
msg.id && message.id && msg.id === message.id
|
||
);
|
||
|
||
console.log('🔍 WebSocket message processing:', {
|
||
messageId: message.id,
|
||
messageIdValid: !!message.id,
|
||
existingIndex,
|
||
totalExisting: existingMessages.length,
|
||
hasToolResults: !!(message.toolResults && message.toolResults.length > 0),
|
||
messageRole: message.role,
|
||
contentType: Array.isArray(message.content) ? `array(${message.content.length})` : typeof message.content,
|
||
existingIds: existingMessages.map(m => m.id).slice(-3) // Last 3 IDs for debugging
|
||
});
|
||
|
||
let updatedMessages;
|
||
if (existingIndex >= 0) {
|
||
const existingMessage = existingMessages[existingIndex];
|
||
|
||
// CRITICAL PROTECTION: If existing message is text and was rendered in DOM, NEVER replace it
|
||
const existingIsText = !existingMessage.toolResults || existingMessage.toolResults.length === 0;
|
||
const newIsText = !message.toolResults || message.toolResults.length === 0;
|
||
|
||
// Check if this message is currently rendered in the DOM
|
||
const isRenderedInDOM = this.isMessageRenderedInDOM(existingMessage);
|
||
|
||
if (existingIsText && isRenderedInDOM) {
|
||
// This is a text message already in DOM - NEVER replace it, always add new message
|
||
console.log('🛡️ PROTECTING text message already rendered in DOM', {
|
||
messageId: message.id,
|
||
existingType: 'TEXT',
|
||
newType: newIsText ? 'TEXT' : 'TOOL',
|
||
reason: 'TEXT_MESSAGE_IN_DOM_PROTECTED',
|
||
action: 'ADD_AS_NEW'
|
||
});
|
||
|
||
// Add as new message instead of replacing
|
||
updatedMessages = [...existingMessages, message];
|
||
} else if (existingIsText && !newIsText) {
|
||
// Existing is text, new has tools - DON'T REPLACE, they're different messages
|
||
console.log('⚠️ PREVENTING text message replacement with tool message', {
|
||
messageId: message.id,
|
||
existingType: 'TEXT',
|
||
newType: 'TOOL',
|
||
reason: 'DIFFERENT_MESSAGE_TYPES_DONT_REPLACE'
|
||
});
|
||
|
||
// Add as new message instead
|
||
updatedMessages = [...existingMessages, message];
|
||
} else if (!existingIsText && newIsText) {
|
||
// Existing has tools, new is text - DON'T REPLACE, they're different messages
|
||
console.log('⚠️ PREVENTING tool message replacement with text message', {
|
||
messageId: message.id,
|
||
existingType: 'TOOL',
|
||
newType: 'TEXT',
|
||
reason: 'DIFFERENT_MESSAGE_TYPES_DONT_REPLACE'
|
||
});
|
||
|
||
// Add as new message instead
|
||
updatedMessages = [...existingMessages, message];
|
||
} else {
|
||
// Same type of message - safe to replace ONLY if not a protected text message
|
||
console.log('🔄 REPLACING existing message with server-correlated version', {
|
||
messageId: message.id,
|
||
existingToolResults: existingMessage.toolResults?.length || 0,
|
||
newToolResults: message.toolResults?.length || 0,
|
||
messageType: newIsText ? 'TEXT' : 'TOOL',
|
||
reason: 'SERVER_CORRELATION_AUTHORITATIVE'
|
||
});
|
||
|
||
updatedMessages = [...existingMessages];
|
||
updatedMessages[existingIndex] = message;
|
||
}
|
||
} else {
|
||
// Add new message to the end
|
||
console.log('➕ Adding new message from WebSocket', {
|
||
messageId: message.id,
|
||
toolResults: message.toolResults?.length || 0,
|
||
hasContent: !!(message.content && (typeof message.content === 'string' || Array.isArray(message.content))),
|
||
messageType: (!message.toolResults || message.toolResults.length === 0) ? 'TEXT' : 'TOOL'
|
||
});
|
||
updatedMessages = [...existingMessages, message];
|
||
}
|
||
|
||
this.loadedMessages.set(conversationId, updatedMessages);
|
||
|
||
// If this conversation is currently selected, update the messages view immediately
|
||
if (this.selectedConversationId === conversationId) {
|
||
console.log('💬 Updating messages view in real-time');
|
||
this.renderCachedMessages(updatedMessages);
|
||
|
||
// Smart scroll behavior: scroll to bottom for new messages
|
||
// or when user is already near the bottom
|
||
if (existingIndex === -1) {
|
||
// This is a completely new message
|
||
console.log('📱 New message received, checking auto-scroll...');
|
||
this.scrollToBottom();
|
||
} else if (this.isNearBottom()) {
|
||
// This is an update to existing message and user is near bottom
|
||
console.log('📱 Message updated, user near bottom, scrolling...');
|
||
this.scrollToBottom();
|
||
} else {
|
||
console.log('📱 Message updated, user viewing older messages, not scrolling');
|
||
}
|
||
|
||
// Update status based on the message
|
||
this.analyzeConversationStatus(updatedMessages);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update status footer
|
||
* @param {string} status - Status type: 'ready', 'working', 'waiting', 'error', 'idle'
|
||
* @param {string} text - Status text
|
||
* @param {string} details - Optional details text
|
||
*/
|
||
updateStatus(status, text, details = '') {
|
||
const statusDot = document.getElementById('statusDot');
|
||
const statusText = document.getElementById('statusText');
|
||
const statusDetails = document.getElementById('statusDetails');
|
||
|
||
if (!statusDot || !statusText || !statusDetails) return;
|
||
|
||
// Remove all status classes
|
||
statusDot.className = 'status-dot';
|
||
|
||
// Add new status class
|
||
statusDot.classList.add(status);
|
||
|
||
// Update text
|
||
statusText.textContent = text;
|
||
statusDetails.textContent = details;
|
||
}
|
||
|
||
/**
|
||
* Analyze conversation messages to determine current status
|
||
* @param {Array} messages - Array of messages
|
||
*/
|
||
analyzeConversationStatus(messages) {
|
||
if (!messages || messages.length === 0) {
|
||
this.updateStatus('idle', 'No messages', '');
|
||
return;
|
||
}
|
||
|
||
const lastMessage = messages[messages.length - 1];
|
||
const secondLastMessage = messages.length > 1 ? messages[messages.length - 2] : null;
|
||
const now = new Date();
|
||
const messageTime = new Date(lastMessage.timestamp);
|
||
const timeDiff = (now - messageTime) / 1000 / 60; // minutes ago
|
||
|
||
// Analyze message patterns to determine status
|
||
if (lastMessage.role === 'user') {
|
||
// Last message is from user - Claude should be working
|
||
if (timeDiff < 2) {
|
||
this.updateStatus('working', 'Claude Code is working...', 'Processing your request');
|
||
} else if (timeDiff < 10) {
|
||
this.updateStatus('working', 'Claude Code is thinking...', `Started ${Math.round(timeDiff)}m ago`);
|
||
} else {
|
||
this.updateStatus('waiting', 'Waiting for response', `User input ${Math.round(timeDiff)}m ago`);
|
||
}
|
||
} else if (lastMessage.role === 'assistant') {
|
||
// Check if assistant is using tools
|
||
const hasTools = (Array.isArray(lastMessage.content) &&
|
||
lastMessage.content.some(block => block.type === 'tool_use')) ||
|
||
(lastMessage.toolResults && lastMessage.toolResults.length > 0);
|
||
|
||
if (hasTools) {
|
||
// Assistant used tools - might still be working
|
||
if (timeDiff < 1) {
|
||
this.updateStatus('working', 'Claude Code executing tools...', 'Running commands');
|
||
} else if (timeDiff < 5) {
|
||
this.updateStatus('working', 'Processing tool results...', `Tools executed ${Math.round(timeDiff)}m ago`);
|
||
} else {
|
||
this.updateStatus('ready', 'Tools completed', `Waiting for user input`);
|
||
}
|
||
} else {
|
||
// Regular assistant message
|
||
if (timeDiff < 5) {
|
||
this.updateStatus('ready', 'Claude Code ready', 'Waiting for user input');
|
||
} else if (timeDiff < 60) {
|
||
this.updateStatus('ready', 'Ready', `Response sent ${Math.round(timeDiff)}m ago`);
|
||
} else if (timeDiff < 1440) { // 24 hours
|
||
const hours = Math.round(timeDiff / 60);
|
||
this.updateStatus('idle', 'Conversation idle', `Last activity ${hours}h ago`);
|
||
} else {
|
||
const days = Math.round(timeDiff / 1440);
|
||
this.updateStatus('idle', 'Conversation inactive', `Last activity ${days}d ago`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Special handling for sequences of messages
|
||
if (messages.length >= 2) {
|
||
const recentMessages = messages.slice(-3); // Last 3 messages
|
||
const hasRecentTools = recentMessages.some(msg =>
|
||
(Array.isArray(msg.content) && msg.content.some(block => block.type === 'tool_use')) ||
|
||
(msg.toolResults && msg.toolResults.length > 0)
|
||
);
|
||
|
||
// If there's been recent tool activity, show more specific status
|
||
if (hasRecentTools && lastMessage.role === 'assistant' && timeDiff < 2) {
|
||
const toolCount = recentMessages.reduce((count, msg) => {
|
||
const contentTools = Array.isArray(msg.content) ?
|
||
msg.content.filter(block => block.type === 'tool_use').length : 0;
|
||
const resultTools = msg.toolResults ? msg.toolResults.length : 0;
|
||
return count + contentTools + resultTools;
|
||
}, 0);
|
||
|
||
this.updateStatus('working', 'Processing multiple tools...', `${toolCount} tools in progress`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Determine if two messages should be merged or replaced
|
||
* @param {Object} existingMessage - The existing message in cache
|
||
* @param {Object} newMessage - The new message from WebSocket
|
||
* @returns {boolean} True if should merge, false if should replace
|
||
*/
|
||
shouldMergeMessages(existingMessage, newMessage) {
|
||
// SIMPLE RULE: Only merge if it's the EXACT same message getting tool results added
|
||
// This mirrors server behavior: text messages stay separate, tools stay separate
|
||
|
||
// If messages have different IDs, they are DIFFERENT messages - never merge
|
||
if (existingMessage.id !== newMessage.id) {
|
||
console.log('🤔 shouldMergeMessages decision:', {
|
||
existingId: existingMessage.id,
|
||
newId: newMessage.id,
|
||
decision: 'NEVER_MERGE_DIFFERENT_IDS',
|
||
reason: 'DIFFERENT_MESSAGE_IDS'
|
||
});
|
||
return false; // Different messages - never merge
|
||
}
|
||
|
||
// If IDs are the same, check if this is just adding tool results to the same message
|
||
const existingToolResults = existingMessage.toolResults?.length || 0;
|
||
const newToolResults = newMessage.toolResults?.length || 0;
|
||
|
||
// If new message has more tool results for the SAME message ID, we can replace
|
||
if (newToolResults > existingToolResults) {
|
||
console.log('🤔 shouldMergeMessages decision:', {
|
||
messageId: newMessage.id,
|
||
existingToolResults,
|
||
newToolResults,
|
||
decision: 'REPLACE_SAME_ID_MORE_TOOLS',
|
||
reason: 'MORE_TOOLS_SAME_MESSAGE'
|
||
});
|
||
return false; // Replace with more complete version of same message
|
||
}
|
||
|
||
// If they're the same message but new one has less or equal tools, keep existing
|
||
console.log('🤔 shouldMergeMessages decision:', {
|
||
messageId: newMessage.id,
|
||
existingToolResults,
|
||
newToolResults,
|
||
decision: 'KEEP_EXISTING',
|
||
reason: 'SAME_MESSAGE_NO_NEW_TOOLS'
|
||
});
|
||
return true; // Keep existing (merge, but existing wins)
|
||
}
|
||
|
||
/**
|
||
* Merge two message objects, preserving content and adding tool results
|
||
* @param {Object} existingMessage - The existing message in cache
|
||
* @param {Object} newMessage - The new message from WebSocket
|
||
* @returns {Object} Merged message object
|
||
*/
|
||
mergeMessages(existingMessage, newMessage) {
|
||
// Start with the existing message as base
|
||
const mergedMessage = { ...existingMessage };
|
||
|
||
// Always use the latest timestamp, role, and metadata from new message
|
||
mergedMessage.timestamp = newMessage.timestamp || existingMessage.timestamp;
|
||
mergedMessage.role = newMessage.role || existingMessage.role;
|
||
mergedMessage.id = newMessage.id || existingMessage.id;
|
||
mergedMessage.model = newMessage.model || existingMessage.model;
|
||
mergedMessage.usage = newMessage.usage || existingMessage.usage;
|
||
|
||
// Merge content intelligently
|
||
if (existingMessage.content && newMessage.content) {
|
||
// Both have content - need to merge intelligently
|
||
const existingIsArray = Array.isArray(existingMessage.content);
|
||
const newIsArray = Array.isArray(newMessage.content);
|
||
|
||
if (existingIsArray && newIsArray) {
|
||
// Both are arrays - combine unique content blocks
|
||
const combinedContent = [...existingMessage.content];
|
||
for (const newBlock of newMessage.content) {
|
||
const existsAlready = combinedContent.some(block =>
|
||
block.type === newBlock.type &&
|
||
(block.type === 'text' ? block.text === newBlock.text :
|
||
block.type === 'tool_use' ? block.id === newBlock.id :
|
||
JSON.stringify(block) === JSON.stringify(newBlock))
|
||
);
|
||
if (!existsAlready) {
|
||
combinedContent.push(newBlock);
|
||
}
|
||
}
|
||
mergedMessage.content = combinedContent;
|
||
} else if (existingIsArray) {
|
||
// Existing is array, new is string - keep array (more complete)
|
||
mergedMessage.content = existingMessage.content;
|
||
} else if (newIsArray) {
|
||
// New is array, existing is string - use array (more complete)
|
||
mergedMessage.content = newMessage.content;
|
||
} else {
|
||
// Both strings - use the longer one
|
||
mergedMessage.content = existingMessage.content.length >= newMessage.content.length ?
|
||
existingMessage.content : newMessage.content;
|
||
}
|
||
} else if (newMessage.content) {
|
||
mergedMessage.content = newMessage.content;
|
||
} else if (existingMessage.content) {
|
||
mergedMessage.content = existingMessage.content;
|
||
}
|
||
|
||
// Merge tool results - use the most complete set
|
||
const existingToolResults = existingMessage.toolResults || [];
|
||
const newToolResults = newMessage.toolResults || [];
|
||
|
||
if (newToolResults.length > existingToolResults.length) {
|
||
// New message has more tool results
|
||
mergedMessage.toolResults = newToolResults;
|
||
} else if (existingToolResults.length > 0) {
|
||
// Keep existing tool results if they're more complete
|
||
mergedMessage.toolResults = existingToolResults;
|
||
} else {
|
||
// Use whatever we have
|
||
mergedMessage.toolResults = newToolResults.length > 0 ? newToolResults : existingToolResults;
|
||
}
|
||
|
||
// Preserve any other fields that might be important
|
||
mergedMessage.isCompactSummary = newMessage.isCompactSummary || existingMessage.isCompactSummary;
|
||
mergedMessage.uuid = newMessage.uuid || existingMessage.uuid;
|
||
mergedMessage.type = newMessage.type || existingMessage.type;
|
||
|
||
console.log('🔀 Message merge result', {
|
||
messageId: mergedMessage.id,
|
||
existingContentType: Array.isArray(existingMessage.content) ? `array(${existingMessage.content.length})` : typeof existingMessage.content,
|
||
newContentType: Array.isArray(newMessage.content) ? `array(${newMessage.content.length})` : typeof newMessage.content,
|
||
finalContentType: Array.isArray(mergedMessage.content) ? `array(${mergedMessage.content.length})` : typeof mergedMessage.content,
|
||
existingToolResults: existingMessage.toolResults?.length || 0,
|
||
newToolResults: newMessage.toolResults?.length || 0,
|
||
finalToolResults: mergedMessage.toolResults?.length || 0,
|
||
mergeStrategy: existingMessage.content && newMessage.content ? 'MERGED_CONTENT' :
|
||
existingMessage.content ? 'EXISTING_CONTENT' : 'NEW_CONTENT'
|
||
});
|
||
|
||
return mergedMessage;
|
||
}
|
||
|
||
/**
|
||
* Load tools preference from localStorage
|
||
*/
|
||
loadToolsPreference() {
|
||
const saved = localStorage.getItem('showTools');
|
||
const showTools = saved !== null ? saved === 'true' : true; // Default to true
|
||
|
||
this.showTools = showTools;
|
||
const chatView = document.getElementById('chatView');
|
||
const showToolsSwitch = document.getElementById('showToolsSwitch');
|
||
|
||
// Update switch state
|
||
showToolsSwitch.checked = showTools;
|
||
|
||
// Update CSS class
|
||
if (showTools) {
|
||
chatView.classList.add('show-tools');
|
||
} else {
|
||
chatView.classList.remove('show-tools');
|
||
}
|
||
|
||
console.log('🔧 Tools preference loaded:', showTools ? 'ON' : 'OFF');
|
||
}
|
||
|
||
/**
|
||
* Check if a message is currently rendered in the DOM
|
||
* @param {Object} message - The message to check
|
||
* @returns {boolean} True if message is rendered in DOM
|
||
*/
|
||
isMessageRenderedInDOM(message) {
|
||
if (!message || !message.id) return false;
|
||
|
||
const chatMessages = document.querySelector('.chat-messages');
|
||
if (!chatMessages) return false;
|
||
|
||
// Look for message element with matching data-message-id
|
||
const messageElement = chatMessages.querySelector(`[data-message-id="${message.id}"]`);
|
||
if (messageElement) {
|
||
console.log('🔍 DOM check: Found message in DOM', {
|
||
messageId: message.id,
|
||
hasTools: messageElement.classList.contains('has-tools'),
|
||
isText: !messageElement.classList.contains('has-tools')
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// Additional check: look for text content in DOM if message has no tools
|
||
const isTextMessage = !message.toolResults || message.toolResults.length === 0;
|
||
if (isTextMessage && message.content) {
|
||
const contentText = typeof message.content === 'string' ?
|
||
message.content :
|
||
(Array.isArray(message.content) ?
|
||
message.content.filter(block => block.type === 'text').map(block => block.text).join(' ') :
|
||
''
|
||
);
|
||
|
||
if (contentText && contentText.length > 10) {
|
||
// Check if this text content appears in any message bubble
|
||
const messageElements = chatMessages.querySelectorAll('.message .message-content');
|
||
for (const element of messageElements) {
|
||
if (element.textContent.includes(contentText.substring(0, 50))) {
|
||
console.log('🔍 DOM check: Found text message by content', {
|
||
messageId: message.id,
|
||
contentPreview: contentText.substring(0, 50)
|
||
});
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('🔍 DOM check: Message NOT found in DOM', {
|
||
messageId: message.id,
|
||
isText: isTextMessage,
|
||
hasContent: !!message.content
|
||
});
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Toggle tools visibility
|
||
* @param {boolean} show - Whether to show tools
|
||
*/
|
||
toggleTools(show) {
|
||
this.showTools = show;
|
||
const chatView = document.getElementById('chatView');
|
||
|
||
if (show) {
|
||
chatView.classList.add('show-tools');
|
||
} else {
|
||
chatView.classList.remove('show-tools');
|
||
}
|
||
|
||
console.log('🔧 Tools visibility toggled:', show ? 'ON' : 'OFF');
|
||
console.log('🔧 Messages with .has-tools class will be:', show ? 'VISIBLE' : 'HIDDEN');
|
||
|
||
// Store preference in localStorage
|
||
localStorage.setItem('showTools', show);
|
||
}
|
||
}
|
||
|
||
// Global function to resume conversation with Claude Code
|
||
function resumeConversationWithClaude() {
|
||
const resumeBtn = document.getElementById('resumeConversation');
|
||
const conversationId = resumeBtn.getAttribute('data-conversation-id');
|
||
|
||
if (!conversationId) {
|
||
console.error('No conversation ID found');
|
||
return;
|
||
}
|
||
|
||
// Show modal with command
|
||
showResumeModal(conversationId);
|
||
}
|
||
|
||
function showResumeModal(conversationId) {
|
||
const modal = document.getElementById('resumeModal');
|
||
const modalCommand = document.getElementById('modalCommand');
|
||
const command = `claude --resume ${conversationId}`;
|
||
|
||
// Set the command in the modal
|
||
modalCommand.textContent = command;
|
||
modalCommand.setAttribute('data-command', command);
|
||
|
||
// Show the modal
|
||
modal.classList.add('show');
|
||
|
||
// Close modal when clicking outside
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
closeResumeModal();
|
||
}
|
||
});
|
||
|
||
console.log('📋 Resume modal opened for conversation:', conversationId);
|
||
}
|
||
|
||
function closeResumeModal() {
|
||
const modal = document.getElementById('resumeModal');
|
||
modal.classList.remove('show');
|
||
}
|
||
|
||
function copyModalCommand() {
|
||
const modalCommand = document.getElementById('modalCommand');
|
||
const copyBtn = document.getElementById('copyCommandBtn');
|
||
const command = modalCommand.getAttribute('data-command');
|
||
|
||
if (!command) {
|
||
console.error('No command found to copy');
|
||
return;
|
||
}
|
||
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(command).then(() => {
|
||
// Show success feedback
|
||
const originalText = copyBtn.textContent;
|
||
copyBtn.textContent = '✅ Copied!';
|
||
copyBtn.style.backgroundColor = 'rgba(63, 185, 80, 0.8)';
|
||
copyBtn.style.borderColor = 'rgba(63, 185, 80, 1)';
|
||
|
||
setTimeout(() => {
|
||
copyBtn.textContent = originalText;
|
||
copyBtn.style.backgroundColor = '';
|
||
copyBtn.style.borderColor = '';
|
||
closeResumeModal();
|
||
}, 1500);
|
||
|
||
console.log('📋 Command copied to clipboard:', command);
|
||
}).catch(err => {
|
||
console.error('Failed to copy to clipboard:', err);
|
||
fallbackCopy(command);
|
||
});
|
||
} else {
|
||
// Fallback for browsers without clipboard API
|
||
fallbackCopy(command);
|
||
}
|
||
}
|
||
|
||
function fallbackCopy(command) {
|
||
// Create a temporary text area to select and copy
|
||
const tempTextArea = document.createElement('textarea');
|
||
tempTextArea.value = command;
|
||
tempTextArea.style.position = 'fixed';
|
||
tempTextArea.style.left = '-9999px';
|
||
document.body.appendChild(tempTextArea);
|
||
tempTextArea.select();
|
||
|
||
try {
|
||
document.execCommand('copy');
|
||
const copyBtn = document.getElementById('copyCommandBtn');
|
||
const originalText = copyBtn.textContent;
|
||
copyBtn.textContent = '✅ Copied!';
|
||
copyBtn.style.backgroundColor = 'rgba(63, 185, 80, 0.8)';
|
||
|
||
setTimeout(() => {
|
||
copyBtn.textContent = originalText;
|
||
copyBtn.style.backgroundColor = '';
|
||
closeResumeModal();
|
||
}, 1500);
|
||
|
||
console.log('📋 Command copied using fallback method:', command);
|
||
} catch (err) {
|
||
console.error('Fallback copy failed:', err);
|
||
alert(`Please copy this command manually:\n\n${command}`);
|
||
} finally {
|
||
document.body.removeChild(tempTextArea);
|
||
}
|
||
}
|
||
|
||
// Download conversation context functions
|
||
function downloadConversation() {
|
||
const downloadBtn = document.getElementById('downloadConversation');
|
||
const conversationId = downloadBtn.getAttribute('data-conversation-id');
|
||
|
||
if (!conversationId) {
|
||
console.error('No conversation ID found');
|
||
return;
|
||
}
|
||
|
||
console.log('📥 Opening download modal for conversation:', conversationId);
|
||
|
||
// Show download modal
|
||
const downloadModal = document.getElementById('downloadModal');
|
||
downloadModal.classList.add('show');
|
||
|
||
// Close modal when clicking outside
|
||
downloadModal.addEventListener('click', (e) => {
|
||
if (e.target === downloadModal) {
|
||
closeDownloadModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
function closeDownloadModal() {
|
||
const downloadModal = document.getElementById('downloadModal');
|
||
downloadModal.classList.remove('show');
|
||
}
|
||
|
||
async function proceedWithDownload() {
|
||
// Close modal
|
||
closeDownloadModal();
|
||
|
||
const downloadBtn = document.getElementById('downloadConversation');
|
||
const conversationId = downloadBtn.getAttribute('data-conversation-id');
|
||
|
||
if (!conversationId) {
|
||
console.error('No conversation ID found');
|
||
return;
|
||
}
|
||
|
||
console.log('📥 Downloading context for conversation:', conversationId);
|
||
|
||
try {
|
||
// Call API to export the conversation as markdown
|
||
const response = await fetch(`/api/conversations/${conversationId}/download`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to export session: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
console.log('✅ Session exported successfully:', data);
|
||
|
||
// Create a blob from the markdown content
|
||
const blob = new Blob([data.markdown], { type: 'text/markdown;charset=utf-8' });
|
||
|
||
// Create a temporary download link
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = data.filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
|
||
// Cleanup
|
||
window.URL.revokeObjectURL(url);
|
||
document.body.removeChild(a);
|
||
|
||
// Show success notification
|
||
console.log(`📥 Downloaded: ${data.filename}`);
|
||
if (data.wasLimited) {
|
||
console.log(`⚠️ Exported ${data.messageCount} of ${data.totalMessageCount} messages`);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Failed to download context:', error);
|
||
alert(`Failed to download context: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// Analytics Modal Functions
|
||
async function showAnalyticsModal() {
|
||
const analyticsBtn = document.getElementById('showAnalytics');
|
||
const conversationId = analyticsBtn.getAttribute('data-conversation-id');
|
||
|
||
if (!conversationId) {
|
||
console.error('No conversation ID found');
|
||
return;
|
||
}
|
||
|
||
console.log('📊 Opening analytics modal for conversation:', conversationId);
|
||
|
||
// Show analytics modal
|
||
const analyticsModal = document.getElementById('analyticsModal');
|
||
analyticsModal.classList.add('show');
|
||
|
||
// Reset to loading state
|
||
document.getElementById('analyticsLoading').style.display = 'block';
|
||
document.getElementById('analyticsContent').style.display = 'none';
|
||
|
||
// Close modal when clicking outside
|
||
analyticsModal.addEventListener('click', (e) => {
|
||
if (e.target === analyticsModal) {
|
||
closeAnalyticsModal();
|
||
}
|
||
});
|
||
|
||
// Fetch analytics data
|
||
try {
|
||
const response = await fetch(`/api/conversations/${conversationId}/analytics`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch analytics: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log('✅ Analytics fetched:', data);
|
||
|
||
// Populate modal with analytics data
|
||
populateAnalyticsModal(data.analytics);
|
||
|
||
// Hide loading, show content
|
||
document.getElementById('analyticsLoading').style.display = 'none';
|
||
document.getElementById('analyticsContent').style.display = 'block';
|
||
|
||
} catch (error) {
|
||
console.error('❌ Failed to fetch analytics:', error);
|
||
document.getElementById('analyticsLoading').innerHTML =
|
||
`<p style="color: #ef4444;">Failed to load analytics: ${escapeHtml(error.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function closeAnalyticsModal() {
|
||
const analyticsModal = document.getElementById('analyticsModal');
|
||
analyticsModal.classList.remove('show');
|
||
}
|
||
|
||
function populateAnalyticsModal(analytics) {
|
||
console.log('📊 Populating analytics modal with data:', analytics);
|
||
|
||
// Overview stats - with safe fallbacks
|
||
document.getElementById('analyticsMessageCount').textContent = (analytics.messageCount || 0).toLocaleString();
|
||
document.getElementById('analyticsTotalTokens').textContent = (analytics.totalTokens || 0).toLocaleString();
|
||
document.getElementById('analyticsToolCalls').textContent = (analytics.toolCalls || 0).toLocaleString();
|
||
document.getElementById('analyticsCacheEfficiency').textContent = analytics.cacheEfficiency || '0%';
|
||
|
||
// New stats: unique tools and models
|
||
const uniqueTools = analytics.toolUsage?.uniqueTools || 0;
|
||
const uniqueModels = (analytics.modelInfo?.modelUsage && Array.isArray(analytics.modelInfo.modelUsage))
|
||
? analytics.modelInfo.modelUsage.length
|
||
: 0;
|
||
|
||
console.log('📊 Unique tools:', uniqueTools, 'Unique models:', uniqueModels);
|
||
document.getElementById('analyticsUniqueTools').textContent = uniqueTools;
|
||
document.getElementById('analyticsUniqueModels').textContent = uniqueModels;
|
||
|
||
// Token breakdown
|
||
document.getElementById('analyticsInputTokens').textContent = analytics.tokenUsage.inputTokens.toLocaleString();
|
||
document.getElementById('analyticsOutputTokens').textContent = analytics.tokenUsage.outputTokens.toLocaleString();
|
||
document.getElementById('analyticsCacheCreation').textContent = analytics.tokenUsage.cacheCreationTokens.toLocaleString();
|
||
document.getElementById('analyticsCacheReads').textContent = analytics.tokenUsage.cacheReadTokens.toLocaleString();
|
||
|
||
// Cost estimate
|
||
document.getElementById('analyticsCostEstimate').textContent = `$${analytics.costEstimate.total}`;
|
||
|
||
// Model info
|
||
document.getElementById('analyticsPrimaryModel').textContent = analytics.modelInfo.primaryModel;
|
||
document.getElementById('analyticsServiceTier').textContent = analytics.modelInfo.serviceTier;
|
||
|
||
// Show all models if multiple were used with usage percentages
|
||
const allModelsSection = document.getElementById('analyticsAllModels');
|
||
if (analytics.modelInfo.hasMultipleModels && analytics.modelInfo.modelUsage && analytics.modelInfo.modelUsage.length > 1) {
|
||
const modelsListHTML = analytics.modelInfo.modelUsage
|
||
.map(usage => `
|
||
<span style="
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 10px;
|
||
background: rgba(139, 92, 246, 0.15);
|
||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-family: monospace;
|
||
color: var(--text-primary);
|
||
">
|
||
<span>${usage.model}</span>
|
||
<span style="
|
||
padding: 2px 6px;
|
||
background: rgba(139, 92, 246, 0.3);
|
||
border-radius: 3px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
color: #e9d5ff;
|
||
">${usage.count} msgs (${usage.percentage}%)</span>
|
||
</span>
|
||
`)
|
||
.join('');
|
||
document.getElementById('analyticsModelsList').innerHTML = modelsListHTML;
|
||
allModelsSection.style.display = 'block';
|
||
} else {
|
||
allModelsSection.style.display = 'none';
|
||
}
|
||
|
||
// Tool usage breakdown
|
||
const toolsList = document.getElementById('analyticsToolsList');
|
||
if (Object.keys(analytics.toolUsage.breakdown).length > 0) {
|
||
const toolsHTML = Object.entries(analytics.toolUsage.breakdown)
|
||
.sort((a, b) => b[1] - a[1]) // Sort by usage count
|
||
.map(([tool, count]) => {
|
||
const percentage = Math.round((count / analytics.toolUsage.totalCalls) * 100);
|
||
return `
|
||
<div style="margin-bottom: 10px; padding: 8px; background: rgba(255,255,255,0.03); border-radius: 6px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||
<span style="font-size: 13px; font-weight: 600; color: var(--text-primary);">${tool}</span>
|
||
<span style="font-size: 12px; color: var(--text-secondary);">${count} calls (${percentage}%)</span>
|
||
</div>
|
||
<div style="height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden;">
|
||
<div style="height: 100%; width: ${percentage}%; background: linear-gradient(90deg, #10b981, #059669); transition: width 0.3s ease;"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
})
|
||
.join('');
|
||
toolsList.innerHTML = toolsHTML;
|
||
} else {
|
||
toolsList.innerHTML = '<p style="color: var(--text-secondary); font-size: 13px; text-align: center; padding: 20px;">No tool usage detected in this session</p>';
|
||
}
|
||
|
||
// Timeline
|
||
const formatDate = (isoString) => {
|
||
if (!isoString) return 'N/A';
|
||
const date = new Date(isoString);
|
||
return date.toLocaleString('en-US', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
};
|
||
|
||
document.getElementById('analyticsStartTime').textContent = formatDate(analytics.timeline.startTime);
|
||
document.getElementById('analyticsLastActivity').textContent = formatDate(analytics.timeline.endTime);
|
||
document.getElementById('analyticsDuration').textContent = analytics.timeline.duration;
|
||
document.getElementById('analyticsStatus').textContent = analytics.timeline.status.charAt(0).toUpperCase() + analytics.timeline.status.slice(1);
|
||
|
||
// Time breakdown
|
||
if (analytics.timeBreakdown && analytics.timeBreakdown.totalWaitTime) {
|
||
document.getElementById('analyticsWaitTime').textContent = analytics.timeBreakdown.totalWaitTime || '0s';
|
||
document.getElementById('analyticsUserTime').textContent = analytics.timeBreakdown.totalUserTime || '0s';
|
||
document.getElementById('analyticsWaitPercent').textContent = `${analytics.timeBreakdown.waitTimePercent || 0}% of iteration time`;
|
||
document.getElementById('analyticsUserPercent').textContent = `${analytics.timeBreakdown.userTimePercent || 0}% of iteration time`;
|
||
|
||
// Update progress bars
|
||
document.getElementById('analyticsWaitBar').style.width = `${analytics.timeBreakdown.waitTimePercent || 0}%`;
|
||
document.getElementById('analyticsUserBar').style.width = `${analytics.timeBreakdown.userTimePercent || 0}%`;
|
||
} else {
|
||
// No timing data available
|
||
document.getElementById('analyticsWaitTime').textContent = 'N/A';
|
||
document.getElementById('analyticsUserTime').textContent = 'N/A';
|
||
document.getElementById('analyticsWaitPercent').textContent = 'No data';
|
||
document.getElementById('analyticsUserPercent').textContent = 'No data';
|
||
document.getElementById('analyticsWaitBar').style.width = '0%';
|
||
document.getElementById('analyticsUserBar').style.width = '0%';
|
||
}
|
||
|
||
// Components used (agents, commands, skills)
|
||
if (analytics.componentsUsed) {
|
||
const hasComponents = analytics.componentsUsed.totalAgents > 0 ||
|
||
analytics.componentsUsed.totalCommands > 0 ||
|
||
analytics.componentsUsed.totalSkills > 0;
|
||
|
||
if (hasComponents) {
|
||
let componentsHTML = '';
|
||
|
||
// Agents
|
||
if (analytics.componentsUsed.agents.length > 0) {
|
||
componentsHTML += '<div style="margin-bottom: 12px;">';
|
||
componentsHTML += '<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; font-weight: 600;">Agents</div>';
|
||
componentsHTML += '<div style="display: flex; flex-wrap: wrap; gap: 6px;">';
|
||
analytics.componentsUsed.agents.forEach(agent => {
|
||
componentsHTML += `
|
||
<span style="
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
background: rgba(139, 92, 246, 0.15);
|
||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
color: var(--text-primary);
|
||
">
|
||
<span>${agent.type}</span>
|
||
<span style="color: var(--text-secondary); font-size: 11px;">×${agent.count}</span>
|
||
</span>
|
||
`;
|
||
});
|
||
componentsHTML += '</div></div>';
|
||
}
|
||
|
||
// Slash Commands
|
||
if (analytics.componentsUsed.slashCommands.length > 0) {
|
||
componentsHTML += '<div style="margin-bottom: 12px;">';
|
||
componentsHTML += '<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; font-weight: 600;">Slash Commands</div>';
|
||
componentsHTML += '<div style="display: flex; flex-wrap: wrap; gap: 6px;">';
|
||
analytics.componentsUsed.slashCommands.forEach(cmd => {
|
||
componentsHTML += `
|
||
<span style="
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
background: rgba(59, 130, 246, 0.15);
|
||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-family: monospace;
|
||
color: var(--text-primary);
|
||
">
|
||
<span>${cmd.name}</span>
|
||
<span style="color: var(--text-secondary); font-size: 11px;">×${cmd.count}</span>
|
||
</span>
|
||
`;
|
||
});
|
||
componentsHTML += '</div></div>';
|
||
}
|
||
|
||
// Skills
|
||
if (analytics.componentsUsed.skills.length > 0) {
|
||
componentsHTML += '<div>';
|
||
componentsHTML += '<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; font-weight: 600;">Skills</div>';
|
||
componentsHTML += '<div style="display: flex; flex-wrap: wrap; gap: 6px;">';
|
||
analytics.componentsUsed.skills.forEach(skill => {
|
||
componentsHTML += `
|
||
<span style="
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
background: rgba(16, 185, 129, 0.15);
|
||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
color: var(--text-primary);
|
||
">
|
||
<span>${skill.name}</span>
|
||
<span style="color: var(--text-secondary); font-size: 11px;">×${skill.count}</span>
|
||
</span>
|
||
`;
|
||
});
|
||
componentsHTML += '</div></div>';
|
||
}
|
||
|
||
document.getElementById('analyticsComponentsContent').innerHTML = componentsHTML;
|
||
document.getElementById('analyticsComponentsSection').style.display = 'block';
|
||
} else {
|
||
document.getElementById('analyticsComponentsSection').style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Initialize the app
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
new ChatsMobileApp();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |