项目文件夹

文件
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 行
218 KiB
HTML

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<!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>