Files
wehub-resource-sync bb5c75ce05
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:58 +08:00

5407 lines
218 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>