From 2cedd6b0e3a6302eaea5f53026c1e31aa3a36030 Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Sun, 1 Mar 2026 07:14:53 -0500 Subject: [PATCH 1/4] compare branches --- public/css/branches.css | 239 +++++++++++++++++++++++++++++++++++++ public/css/layout.css | 51 ++++++++ public/index.html | 12 ++ public/js/app.js | 10 ++ public/js/branches.js | 233 +++++++++++++++++++++++++++++++++++- public/js/conversations.js | 52 ++++++++ public/js/render.js | 58 +++++++++ public/sw.js | 2 +- 8 files changed, 654 insertions(+), 3 deletions(-) diff --git a/public/css/branches.css b/public/css/branches.css index 268d39e..d7bc86b 100644 --- a/public/css/branches.css +++ b/public/css/branches.css @@ -353,3 +353,242 @@ font-size: 10px; } } + +/* --- Compare Mode Styles --- */ + +/* Compare mode indicator on branches content */ +.branches-content.compare-mode .branch-item { + cursor: pointer; +} + +.branches-content.compare-mode .branch-item:hover { + border-color: var(--accent); +} + +.branches-content.compare-mode .branch-item.compare-selected { + background: var(--accent-alpha-15); + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-alpha-30); +} + +/* Compare overlay */ +.branches-compare { + position: absolute; + inset: 0; + background: var(--bg); + z-index: 10; + display: flex; + flex-direction: column; +} + +.branches-compare.hidden { + display: none; +} + +.compare-header { + padding: 12px 16px; + padding-top: calc(12px + var(--safe-top)); + background: var(--glass-bg); + backdrop-filter: blur(20px) saturate(1.4); + -webkit-backdrop-filter: blur(20px) saturate(1.4); + border-bottom: 1px solid var(--glass-border); + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + font-size: 15px; +} + +.compare-close-btn { + background: none; + border: none; + font-size: 24px; + color: var(--text-secondary); + cursor: pointer; + padding: 8px 12px; + border-radius: 8px; + line-height: 1; + transition: background 0.15s, color 0.15s; +} + +.compare-close-btn:hover { + background: var(--surface); + color: var(--text); +} + +.compare-body { + flex: 1; + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +.compare-loading, +.compare-error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + padding: 24px; + text-align: center; +} + +.compare-error { + color: var(--danger); +} + +/* Side-by-side view (desktop) */ +.compare-split { + display: grid; + grid-template-columns: 1fr 1fr; + height: 100%; +} + +.compare-column { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + min-width: 0; +} + +.compare-column:last-child { + border-right: none; +} + +.compare-column-header { + padding: 10px 12px; + background: var(--surface); + border-bottom: 1px solid var(--border); + font-weight: 600; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; +} + +.compare-messages { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.compare-msg { + padding: 10px 14px; + margin-bottom: 10px; + border-radius: 10px; + font-size: 13px; + line-height: 1.5; + word-break: break-word; +} + +.compare-msg.user { + background: var(--accent-alpha-15); + color: var(--text); + margin-left: 20%; +} + +.compare-msg.assistant { + background: var(--surface); + color: var(--text); + margin-right: 20%; +} + +.compare-msg.empty { + background: var(--bg-tertiary); + opacity: 0.3; + min-height: 40px; +} + +/* Unified diff view (mobile) */ +.compare-unified-header { + display: flex; + gap: 12px; + padding: 10px 12px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.unified-legend { + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; +} + +.unified-legend.parent { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.unified-legend.fork { + background: var(--accent-alpha-20); + color: var(--accent); +} + +.compare-unified { + padding: 12px; +} + +.unified-row { + display: flex; + gap: 10px; + margin-bottom: 10px; + align-items: flex-start; +} + +.unified-row.fork { + padding-left: 20px; +} + +.unified-source { + width: 32px; + min-width: 32px; + padding: 4px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 600; + text-align: center; + text-transform: uppercase; + flex-shrink: 0; +} + +.unified-source.parent { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.unified-source.fork { + background: var(--accent-alpha-20); + color: var(--accent); +} + +.unified-content { + flex: 1; + padding: 10px 14px; + background: var(--surface); + border-radius: 8px; + font-size: 13px; + line-height: 1.5; + word-break: break-word; + min-width: 0; +} + +.unified-row.fork .unified-content { + background: var(--accent-alpha-10); + border: 1px solid var(--accent-alpha-30); +} + +/* Responsive: mobile uses unified view */ +@media (max-width: 767px) { + .compare-split { + display: none; + } +} + +@media (min-width: 768px) { + .compare-unified-header, + .compare-unified { + display: none; + } +} diff --git a/public/css/layout.css b/public/css/layout.css index d064e37..991a2b1 100644 --- a/public/css/layout.css +++ b/public/css/layout.css @@ -191,6 +191,57 @@ color: var(--accent); } +/* Fork link - shows parent conversation link for forked conversations */ +.chat-fork-link { + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 8px; + margin: -2px 0; + min-height: 28px; + border-radius: 6px; + transition: background 0.15s, color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: min(50vw, 300px); +} + +.chat-fork-link:hover { + background: var(--surface); + color: var(--accent); +} + +.chat-fork-link:active { + background: var(--bg-tertiary); +} + +.chat-fork-link .fork-icon { + color: var(--accent); + margin-right: 2px; +} + +.chat-fork-link.hidden { + display: none; +} + +/* Message highlight animation for jump-to-fork */ +.message-highlight, +.message-wrapper.message-highlight { + animation: message-highlight-pulse 2s ease-out; +} + +@keyframes message-highlight-pulse { + 0% { + box-shadow: 0 0 0 3px var(--accent); + background: var(--accent-alpha-15); + } + 100% { + box-shadow: 0 0 0 0 transparent; + background: transparent; + } +} + .status-dot { width: 10px; height: 10px; diff --git a/public/index.html b/public/index.html index 1debc6f..84389eb 100644 --- a/public/index.html +++ b/public/index.html @@ -124,6 +124,7 @@

Concierge

+ AP @@ -416,8 +417,19 @@

Concierge

Conversation Branches
+
+ +
diff --git a/public/js/app.js b/public/js/app.js index 54390ba..14d312d 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -49,6 +49,7 @@ const messageInput = document.getElementById('message-input'); const inputForm = document.getElementById('input-form'); const sendBtn = document.getElementById('send-btn'); const chatName = document.getElementById('chat-name'); +const chatForkLink = document.getElementById('chat-fork-link'); const chatCwdIndicator = document.getElementById('chat-cwd-indicator'); const chatStatus = document.getElementById('chat-status'); const typingIndicator = document.getElementById('typing-indicator'); @@ -213,6 +214,10 @@ const branchesBtn = document.getElementById('branches-btn'); const branchesView = document.getElementById('branches-view'); const branchesBackBtn = document.getElementById('branches-back-btn'); const branchesContent = document.getElementById('branches-content'); +const branchesCompareBtn = document.getElementById('branches-compare-btn'); +const branchesCompare = document.getElementById('branches-compare'); +const compareCloseBtn = document.getElementById('compare-close-btn'); +const compareBody = document.getElementById('compare-body'); const filesStandaloneView = document.getElementById('files-standalone-view'); const filesStandaloneBackBtn = document.getElementById('files-standalone-back-btn'); const filesStandaloneTitle = document.getElementById('files-standalone-title'); @@ -275,6 +280,7 @@ initConversations({ chatView, conversationList, chatName, + chatForkLink, chatCwdIndicator, loadMoreBtn, contextBar, @@ -457,6 +463,10 @@ initBranches({ branchesView, branchesBackBtn, branchesContent, + branchesCompareBtn, + branchesCompare, + compareCloseBtn, + compareBody, listView, chatView }); diff --git a/public/js/branches.js b/public/js/branches.js index 06a6b90..07740b6 100644 --- a/public/js/branches.js +++ b/public/js/branches.js @@ -1,20 +1,30 @@ // --- Conversation branches visualization --- -import { escapeHtml } from './markdown.js'; -import { haptic, apiFetch } from './utils.js'; +import { escapeHtml, renderMarkdown } from './markdown.js'; +import { haptic, apiFetch, truncate } from './utils.js'; import * as state from './state.js'; // DOM elements (set by init) let branchesView = null; let branchesBackBtn = null; let branchesContent = null; +let branchesCompareBtn = null; +let branchesCompare = null; +let compareCloseBtn = null; +let compareBody = null; // State let _currentTreeData = null; +let _compareMode = false; +let _selectedForCompare = []; export function initBranches(elements) { branchesView = elements.branchesView; branchesBackBtn = elements.branchesBackBtn; branchesContent = elements.branchesContent; + branchesCompareBtn = elements.branchesCompareBtn; + branchesCompare = elements.branchesCompare; + compareCloseBtn = elements.compareCloseBtn; + compareBody = elements.compareBody; if (branchesBackBtn) { branchesBackBtn.addEventListener('click', () => { @@ -22,6 +32,28 @@ export function initBranches(elements) { closeBranchesView(); }); } + + if (branchesCompareBtn) { + branchesCompareBtn.addEventListener('click', () => { + haptic(); + toggleCompareMode(); + }); + } + + if (compareCloseBtn) { + compareCloseBtn.addEventListener('click', () => { + haptic(); + closeCompareView(); + }); + } + + // ESC to close compare view + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && branchesCompare && !branchesCompare.classList.contains('hidden')) { + e.stopPropagation(); + closeCompareView(); + } + }); } export async function loadBranchesTree(conversationId) { @@ -182,6 +214,15 @@ function renderTree(data) { if (e.target.closest('.branch-collapse-btn')) return; const id = item.dataset.id; + + // Handle compare mode selection + if (_compareMode) { + haptic(); + selectBranchForCompare(id); + return; + } + + // Normal navigation if (id && id !== data.currentId) { haptic(); navigateToConversation(id); @@ -249,3 +290,191 @@ export function openBranchesFromChat() { showBranchesView(); loadBranchesTree(currentId); } + +// --- Compare Mode --- + +function toggleCompareMode() { + _compareMode = !_compareMode; + _selectedForCompare = []; + + if (_compareMode) { + branchesCompareBtn?.classList.add('active'); + branchesContent?.classList.add('compare-mode'); + updateCompareHeader('Select two branches to compare'); + } else { + branchesCompareBtn?.classList.remove('active'); + branchesContent?.classList.remove('compare-mode'); + closeCompareView(); + } + + // Re-render tree with selection checkboxes + if (_currentTreeData) { + renderTree(_currentTreeData); + } +} + +function updateCompareHeader(text) { + const header = branchesCompare?.querySelector('.compare-header span'); + if (header) header.textContent = text; +} + +function selectBranchForCompare(id) { + if (_selectedForCompare.includes(id)) { + _selectedForCompare = _selectedForCompare.filter(x => x !== id); + } else if (_selectedForCompare.length < 2) { + _selectedForCompare.push(id); + } + + // Update selection UI + branchesContent?.querySelectorAll('.branch-item').forEach(item => { + const itemId = item.dataset.id; + item.classList.toggle('compare-selected', _selectedForCompare.includes(itemId)); + }); + + // Update header text + if (_selectedForCompare.length === 0) { + updateCompareHeader('Select two branches to compare'); + } else if (_selectedForCompare.length === 1) { + updateCompareHeader('Select one more branch'); + } + + // Load comparison when two selected + if (_selectedForCompare.length === 2) { + loadCompareView(_selectedForCompare[0], _selectedForCompare[1]); + } +} + +async function loadCompareView(idA, idB) { + if (!branchesCompare || !compareBody) return; + + branchesCompare.classList.remove('hidden'); + compareBody.innerHTML = '
Loading conversations...
'; + + try { + const [convA, convB] = await Promise.all([ + apiFetch(`/api/conversations/${idA}`).then(r => r?.json()), + apiFetch(`/api/conversations/${idB}`).then(r => r?.json()) + ]); + + if (!convA || !convB) { + compareBody.innerHTML = '
Failed to load conversations
'; + return; + } + + // Determine fork relationship and which is parent + let parent = convA; + let fork = convB; + let forkIndex = 0; + + if (convB.parentId === idA) { + parent = convA; + fork = convB; + forkIndex = fork.forkIndex || 0; + } else if (convA.parentId === idB) { + parent = convB; + fork = convA; + forkIndex = fork.forkIndex || 0; + } else { + // Find common ancestor - for now just compare from start + forkIndex = 0; + } + + updateCompareHeader(`Comparing from message ${forkIndex + 1}`); + + // Render based on viewport + if (window.innerWidth >= 768) { + renderSideBySide(parent, fork, forkIndex); + } else { + renderUnifiedDiff(parent, fork, forkIndex); + } + } catch (err) { + compareBody.innerHTML = `
Error: ${escapeHtml(err.message)}
`; + } +} + +function renderCompareMessage(msg) { + if (!msg) return ''; + const cls = msg.role === 'user' ? 'compare-msg user' : 'compare-msg assistant'; + const content = msg.role === 'assistant' ? renderMarkdown(msg.text) : escapeHtml(msg.text); + const preview = truncate(msg.text || '', 300); + return `
${content}
`; +} + +function renderSideBySide(parent, fork, forkIndex) { + const parentMsgs = (parent.messages || []).slice(forkIndex); + const forkMsgs = (fork.messages || []).slice(forkIndex); + const maxLen = Math.max(parentMsgs.length, forkMsgs.length); + + let parentHtml = ''; + let forkHtml = ''; + + for (let i = 0; i < maxLen; i++) { + parentHtml += renderCompareMessage(parentMsgs[i]) || '
'; + forkHtml += renderCompareMessage(forkMsgs[i]) || '
'; + } + + compareBody.innerHTML = ` +
+
+
${escapeHtml(parent.name)}
+
${parentHtml}
+
+
+
${escapeHtml(fork.name)}
+
${forkHtml}
+
+
+ `; +} + +function renderUnifiedDiff(parent, fork, forkIndex) { + const parentMsgs = (parent.messages || []).slice(forkIndex); + const forkMsgs = (fork.messages || []).slice(forkIndex); + const maxLen = Math.max(parentMsgs.length, forkMsgs.length); + + let html = ` +
+ Parent: ${escapeHtml(parent.name)} + Fork: ${escapeHtml(fork.name)} +
+
+ `; + + for (let i = 0; i < maxLen; i++) { + const pMsg = parentMsgs[i]; + const fMsg = forkMsgs[i]; + + if (pMsg) { + const content = pMsg.role === 'assistant' ? renderMarkdown(pMsg.text) : escapeHtml(pMsg.text); + html += `
+
${pMsg.role === 'user' ? 'You' : 'AI'}
+
${content}
+
`; + } + if (fMsg) { + const content = fMsg.role === 'assistant' ? renderMarkdown(fMsg.text) : escapeHtml(fMsg.text); + html += `
+
${fMsg.role === 'user' ? 'You' : 'AI'}
+
${content}
+
`; + } + } + + html += '
'; + compareBody.innerHTML = html; +} + +function closeCompareView() { + if (branchesCompare) { + branchesCompare.classList.add('hidden'); + } + _selectedForCompare = []; + _compareMode = false; + branchesCompareBtn?.classList.remove('active'); + branchesContent?.classList.remove('compare-mode'); + + // Clear selection UI + branchesContent?.querySelectorAll('.branch-item.compare-selected').forEach(item => { + item.classList.remove('compare-selected'); + }); +} diff --git a/public/js/conversations.js b/public/js/conversations.js index d7b64e5..45da2e7 100644 --- a/public/js/conversations.js +++ b/public/js/conversations.js @@ -19,6 +19,7 @@ let listView = null; let chatView = null; let conversationList = null; let chatName = null; +let chatForkLink = null; let chatCwdIndicator = null; let loadMoreBtn = null; let contextBar = null; @@ -36,6 +37,7 @@ export function initConversations(elements) { chatView = elements.chatView; conversationList = elements.conversationList; chatName = elements.chatName; + chatForkLink = elements.chatForkLink; chatCwdIndicator = elements.chatCwdIndicator; loadMoreBtn = elements.loadMoreBtn; contextBar = elements.contextBar; @@ -121,6 +123,40 @@ function updateChatCwdIndicator(conv) { chatCwdIndicator.classList.toggle('worktree', isWorktree); } +function updateChatForkLink(conv) { + if (!chatForkLink) return; + + if (!conv?.parentId) { + chatForkLink.classList.add('hidden'); + chatForkLink.innerHTML = ''; + return; + } + + const parent = state.conversations.find(c => c.id === conv.parentId); + const parentName = parent?.name || 'parent'; + const msgNum = (conv.forkIndex ?? 0) + 1; + const truncatedName = parentName.length > 24 ? parentName.slice(0, 24) + '...' : parentName; + + chatForkLink.innerHTML = ` from "${escapeHtml(truncatedName)}" @ msg ${msgNum}`; + chatForkLink.dataset.parentId = conv.parentId; + chatForkLink.dataset.forkIndex = conv.forkIndex ?? 0; + chatForkLink.title = `Jump to "${parentName}" at message ${msgNum}`; + chatForkLink.classList.remove('hidden'); + + // Set up click handler if not already done + if (!chatForkLink.dataset.handlerAttached) { + chatForkLink.dataset.handlerAttached = 'true'; + chatForkLink.addEventListener('click', async () => { + haptic(); + const parentId = chatForkLink.dataset.parentId; + const forkIndex = parseInt(chatForkLink.dataset.forkIndex, 10); + if (parentId) { + await openConversationAtMessage(parentId, forkIndex); + } + }); + } +} + export async function loadConversations() { const conversations = state.conversations; // Show skeletons on first load when list is empty @@ -1004,6 +1040,7 @@ export async function openConversation(id) { chatName.textContent = conv.name; updateChatCwdIndicator(conv); + updateChatForkLink(conv); state.updateStatusDot(conv.status); state.setCurrentProvider(conv.provider || 'claude'); @@ -1044,6 +1081,21 @@ export async function openConversation(id) { state.setThinking(conv.status === 'thinking', conv.thinkingStartTime); } +/** + * Open a conversation and scroll to a specific message. + * Used for jumping to fork points in parent conversations. + * @param {string} id - Conversation ID + * @param {number} messageIndex - Message index to scroll to + */ +export async function openConversationAtMessage(id, messageIndex) { + await openConversation(id); + // Wait for render to complete, then scroll to the message + setTimeout(async () => { + const { scrollToMessage } = await import('./render.js'); + scrollToMessage(messageIndex); + }, 150); +} + export function showChatView() { listView.classList.add('slide-out'); chatView.classList.add('slide-in'); diff --git a/public/js/render.js b/public/js/render.js index a72d45f..51cfea5 100644 --- a/public/js/render.js +++ b/public/js/render.js @@ -762,3 +762,61 @@ export function attachMessageActions() { attachMessageActionsCallback(); } } + +/** + * Scroll to a specific message by index and highlight it. + * @param {number} index - Message index to scroll to + */ +export function scrollToMessage(index) { + const messagesContainer = state.getMessagesContainer(); + if (!messagesContainer) return; + + // Find message by data-index attribute + const target = messagesContainer.querySelector(`.message[data-index="${index}"]`); + if (!target) { + // If message not found, it might not be loaded yet (virtual scrolling) + // Load more messages until we find it + const allMessages = state.getAllMessages(); + if (index < state.getMessagesOffset() && index >= 0 && index < allMessages.length) { + // Need to load earlier messages + // Set offset to include the target message + const newOffset = Math.max(0, index - 10); // Load some context + state.setMessagesOffset(newOffset); + const visible = allMessages.slice(newOffset); + messagesContainer.innerHTML = renderMessageSlice(visible, newOffset); + enhanceCodeBlocks(messagesContainer); + attachTTSHandlers(); + attachTimestampHandlers(); + attachImageHandlers(); + attachRegenHandlers(); + attachCopyMsgHandlers(); + attachMessageActions(); + attachCompressedSectionToggle(); + renderAllReactions(); + // Hide load more button if at start + const loadMoreBtn = state.getLoadMoreBtn(); + if (loadMoreBtn) { + loadMoreBtn.classList.toggle('hidden', newOffset <= 0); + } + // Try again after re-render + setTimeout(() => scrollToMessage(index), 50); + return; + } + return; + } + + // Scroll to the message + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // Add highlight animation + target.classList.add('message-highlight'); + + // Also highlight the wrapper if it exists + const wrapper = target.closest('.message-wrapper'); + if (wrapper) { + wrapper.classList.add('message-highlight'); + setTimeout(() => wrapper.classList.remove('message-highlight'), 2000); + } + + setTimeout(() => target.classList.remove('message-highlight'), 2000); +} diff --git a/public/sw.js b/public/sw.js index 249584d..fa385c9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'concierge-v123'; +const CACHE_NAME = 'concierge-v124'; const STATIC_ASSETS = [ '/', '/index.html', From 444b8a01b827a96795c8c99169e9799819583f98 Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Sun, 1 Mar 2026 07:17:28 -0500 Subject: [PATCH 2/4] jump to parent on mobile --- public/css/layout.css | 29 ++++++++++++++++------ public/index.html | 4 ++++ public/js/conversations.js | 49 ++++++++++++++++++++++---------------- public/js/ui.js | 15 ++++++++++++ public/sw.js | 2 +- 5 files changed, 71 insertions(+), 28 deletions(-) diff --git a/public/css/layout.css b/public/css/layout.css index 991a2b1..6474125 100644 --- a/public/css/layout.css +++ b/public/css/layout.css @@ -192,24 +192,32 @@ } /* Fork link - shows parent conversation link for forked conversations */ +/* Hidden on mobile by default - shown only on desktop */ .chat-fork-link { - font-size: 12px; + display: none; + font-size: 11px; color: var(--text-secondary); cursor: pointer; - padding: 4px 8px; - margin: -2px 0; - min-height: 28px; + padding: 3px 8px; border-radius: 6px; transition: background 0.15s, color 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: min(50vw, 300px); + max-width: min(40vw, 240px); + border: 1px solid var(--border); + background: var(--bg-secondary); +} + +.chat-fork-link:not(.hidden) { + display: flex; + align-items: center; } .chat-fork-link:hover { background: var(--surface); color: var(--accent); + border-color: var(--accent-alpha-50); } .chat-fork-link:active { @@ -218,11 +226,18 @@ .chat-fork-link .fork-icon { color: var(--accent); - margin-right: 2px; + margin-right: 3px; } .chat-fork-link.hidden { - display: none; + display: none !important; +} + +/* Hide fork link on mobile - too cramped */ +@media (max-width: 600px) { + .chat-fork-link { + display: none !important; + } } /* Message highlight animation for jump-to-fork */ diff --git a/public/index.html b/public/index.html index 84389eb..aeae6c0 100644 --- a/public/index.html +++ b/public/index.html @@ -762,6 +762,10 @@

New Conversation

Branches +
Commands & Skills diff --git a/public/js/conversations.js b/public/js/conversations.js index 45da2e7..22e3ba3 100644 --- a/public/js/conversations.js +++ b/public/js/conversations.js @@ -124,11 +124,15 @@ function updateChatCwdIndicator(conv) { } function updateChatForkLink(conv) { - if (!chatForkLink) return; + // Also update the "Jump to Parent" menu item visibility + const chatMoreParent = document.getElementById('chat-more-parent'); if (!conv?.parentId) { - chatForkLink.classList.add('hidden'); - chatForkLink.innerHTML = ''; + if (chatForkLink) { + chatForkLink.classList.add('hidden'); + chatForkLink.innerHTML = ''; + } + if (chatMoreParent) chatMoreParent.classList.add('hidden'); return; } @@ -137,24 +141,29 @@ function updateChatForkLink(conv) { const msgNum = (conv.forkIndex ?? 0) + 1; const truncatedName = parentName.length > 24 ? parentName.slice(0, 24) + '...' : parentName; - chatForkLink.innerHTML = ` from "${escapeHtml(truncatedName)}" @ msg ${msgNum}`; - chatForkLink.dataset.parentId = conv.parentId; - chatForkLink.dataset.forkIndex = conv.forkIndex ?? 0; - chatForkLink.title = `Jump to "${parentName}" at message ${msgNum}`; - chatForkLink.classList.remove('hidden'); - - // Set up click handler if not already done - if (!chatForkLink.dataset.handlerAttached) { - chatForkLink.dataset.handlerAttached = 'true'; - chatForkLink.addEventListener('click', async () => { - haptic(); - const parentId = chatForkLink.dataset.parentId; - const forkIndex = parseInt(chatForkLink.dataset.forkIndex, 10); - if (parentId) { - await openConversationAtMessage(parentId, forkIndex); - } - }); + if (chatForkLink) { + chatForkLink.innerHTML = ` from "${escapeHtml(truncatedName)}" @ msg ${msgNum}`; + chatForkLink.dataset.parentId = conv.parentId; + chatForkLink.dataset.forkIndex = conv.forkIndex ?? 0; + chatForkLink.title = `Jump to "${parentName}" at message ${msgNum}`; + chatForkLink.classList.remove('hidden'); + + // Set up click handler if not already done + if (!chatForkLink.dataset.handlerAttached) { + chatForkLink.dataset.handlerAttached = 'true'; + chatForkLink.addEventListener('click', async () => { + haptic(); + const parentId = chatForkLink.dataset.parentId; + const forkIndex = parseInt(chatForkLink.dataset.forkIndex, 10); + if (parentId) { + await openConversationAtMessage(parentId, forkIndex); + } + }); + } } + + // Show the menu item for mobile access + if (chatMoreParent) chatMoreParent.classList.remove('hidden'); } export async function loadConversations() { diff --git a/public/js/ui.js b/public/js/ui.js index 62efe9d..6170370 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -2259,6 +2259,21 @@ export function setupEventListeners(createConversation) { }); } + const chatMoreParent = document.getElementById('chat-more-parent'); + if (chatMoreParent) { + chatMoreParent.addEventListener('click', async () => { + closeChatMoreMenu(); + haptic(); + const forkLink = document.getElementById('chat-fork-link'); + if (forkLink && forkLink.dataset.parentId) { + const { openConversationAtMessage } = await import('./conversations.js'); + const parentId = forkLink.dataset.parentId; + const forkIndex = parseInt(forkLink.dataset.forkIndex || '0', 10); + openConversationAtMessage(parentId, forkIndex); + } + }); + } + if (chatMoreCapabilities) { chatMoreCapabilities.addEventListener('click', () => { closeChatMoreMenu(); diff --git a/public/sw.js b/public/sw.js index fa385c9..fbce7bf 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'concierge-v124'; +const CACHE_NAME = 'concierge-v125'; const STATIC_ASSETS = [ '/', '/index.html', From 051b2b0ad7091bb6ef8486375ac4fccf8182de91 Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Sun, 1 Mar 2026 07:23:23 -0500 Subject: [PATCH 3/4] consolidate jump to parent --- public/css/layout.css | 49 -------------------------------------- public/index.html | 1 - public/js/app.js | 2 -- public/js/conversations.js | 48 ++++++++++--------------------------- public/js/ui.js | 7 +++--- public/sw.js | 2 +- 6 files changed, 16 insertions(+), 93 deletions(-) diff --git a/public/css/layout.css b/public/css/layout.css index 6474125..b655977 100644 --- a/public/css/layout.css +++ b/public/css/layout.css @@ -191,55 +191,6 @@ color: var(--accent); } -/* Fork link - shows parent conversation link for forked conversations */ -/* Hidden on mobile by default - shown only on desktop */ -.chat-fork-link { - display: none; - font-size: 11px; - color: var(--text-secondary); - cursor: pointer; - padding: 3px 8px; - border-radius: 6px; - transition: background 0.15s, color 0.15s; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: min(40vw, 240px); - border: 1px solid var(--border); - background: var(--bg-secondary); -} - -.chat-fork-link:not(.hidden) { - display: flex; - align-items: center; -} - -.chat-fork-link:hover { - background: var(--surface); - color: var(--accent); - border-color: var(--accent-alpha-50); -} - -.chat-fork-link:active { - background: var(--bg-tertiary); -} - -.chat-fork-link .fork-icon { - color: var(--accent); - margin-right: 3px; -} - -.chat-fork-link.hidden { - display: none !important; -} - -/* Hide fork link on mobile - too cramped */ -@media (max-width: 600px) { - .chat-fork-link { - display: none !important; - } -} - /* Message highlight animation for jump-to-fork */ .message-highlight, .message-wrapper.message-highlight { diff --git a/public/index.html b/public/index.html index aeae6c0..5129de5 100644 --- a/public/index.html +++ b/public/index.html @@ -124,7 +124,6 @@

Concierge

- AP diff --git a/public/js/app.js b/public/js/app.js index 14d312d..41ecce7 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -49,7 +49,6 @@ const messageInput = document.getElementById('message-input'); const inputForm = document.getElementById('input-form'); const sendBtn = document.getElementById('send-btn'); const chatName = document.getElementById('chat-name'); -const chatForkLink = document.getElementById('chat-fork-link'); const chatCwdIndicator = document.getElementById('chat-cwd-indicator'); const chatStatus = document.getElementById('chat-status'); const typingIndicator = document.getElementById('typing-indicator'); @@ -280,7 +279,6 @@ initConversations({ chatView, conversationList, chatName, - chatForkLink, chatCwdIndicator, loadMoreBtn, contextBar, diff --git a/public/js/conversations.js b/public/js/conversations.js index 22e3ba3..7a1ca5b 100644 --- a/public/js/conversations.js +++ b/public/js/conversations.js @@ -19,7 +19,6 @@ let listView = null; let chatView = null; let conversationList = null; let chatName = null; -let chatForkLink = null; let chatCwdIndicator = null; let loadMoreBtn = null; let contextBar = null; @@ -37,7 +36,6 @@ export function initConversations(elements) { chatView = elements.chatView; conversationList = elements.conversationList; chatName = elements.chatName; - chatForkLink = elements.chatForkLink; chatCwdIndicator = elements.chatCwdIndicator; loadMoreBtn = elements.loadMoreBtn; contextBar = elements.contextBar; @@ -123,47 +121,25 @@ function updateChatCwdIndicator(conv) { chatCwdIndicator.classList.toggle('worktree', isWorktree); } +/** + * Update the "Jump to Parent" menu item visibility and data based on conversation's fork status. + * Shows the menu item only for forked conversations. + */ function updateChatForkLink(conv) { - // Also update the "Jump to Parent" menu item visibility const chatMoreParent = document.getElementById('chat-more-parent'); + if (!chatMoreParent) return; if (!conv?.parentId) { - if (chatForkLink) { - chatForkLink.classList.add('hidden'); - chatForkLink.innerHTML = ''; - } - if (chatMoreParent) chatMoreParent.classList.add('hidden'); + chatMoreParent.classList.add('hidden'); + chatMoreParent.dataset.parentId = ''; + chatMoreParent.dataset.forkIndex = ''; return; } - const parent = state.conversations.find(c => c.id === conv.parentId); - const parentName = parent?.name || 'parent'; - const msgNum = (conv.forkIndex ?? 0) + 1; - const truncatedName = parentName.length > 24 ? parentName.slice(0, 24) + '...' : parentName; - - if (chatForkLink) { - chatForkLink.innerHTML = ` from "${escapeHtml(truncatedName)}" @ msg ${msgNum}`; - chatForkLink.dataset.parentId = conv.parentId; - chatForkLink.dataset.forkIndex = conv.forkIndex ?? 0; - chatForkLink.title = `Jump to "${parentName}" at message ${msgNum}`; - chatForkLink.classList.remove('hidden'); - - // Set up click handler if not already done - if (!chatForkLink.dataset.handlerAttached) { - chatForkLink.dataset.handlerAttached = 'true'; - chatForkLink.addEventListener('click', async () => { - haptic(); - const parentId = chatForkLink.dataset.parentId; - const forkIndex = parseInt(chatForkLink.dataset.forkIndex, 10); - if (parentId) { - await openConversationAtMessage(parentId, forkIndex); - } - }); - } - } - - // Show the menu item for mobile access - if (chatMoreParent) chatMoreParent.classList.remove('hidden'); + // Store fork data on the menu item for the click handler + chatMoreParent.dataset.parentId = conv.parentId; + chatMoreParent.dataset.forkIndex = conv.forkIndex ?? 0; + chatMoreParent.classList.remove('hidden'); } export async function loadConversations() { diff --git a/public/js/ui.js b/public/js/ui.js index 6170370..c4be373 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -2264,11 +2264,10 @@ export function setupEventListeners(createConversation) { chatMoreParent.addEventListener('click', async () => { closeChatMoreMenu(); haptic(); - const forkLink = document.getElementById('chat-fork-link'); - if (forkLink && forkLink.dataset.parentId) { + const parentId = chatMoreParent.dataset.parentId; + if (parentId) { const { openConversationAtMessage } = await import('./conversations.js'); - const parentId = forkLink.dataset.parentId; - const forkIndex = parseInt(forkLink.dataset.forkIndex || '0', 10); + const forkIndex = parseInt(chatMoreParent.dataset.forkIndex || '0', 10); openConversationAtMessage(parentId, forkIndex); } }); diff --git a/public/sw.js b/public/sw.js index fbce7bf..3ae385e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'concierge-v125'; +const CACHE_NAME = 'concierge-v126'; const STATIC_ASSETS = [ '/', '/index.html', From 4bd74bf85aee255b6d4a048891c6b00a0448160f Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Sun, 1 Mar 2026 07:39:19 -0500 Subject: [PATCH 4/4] change compression flags --- public/js/conversations.js | 1 + public/js/render.js | 17 +++++++++++++++-- public/sw.js | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/public/js/conversations.js b/public/js/conversations.js index 7a1ca5b..ea6b50e 100644 --- a/public/js/conversations.js +++ b/public/js/conversations.js @@ -1012,6 +1012,7 @@ export async function openConversation(id) { state.setCurrentConversationId(id); state.deleteUnread(id); + state.resetCompressionPromptShown(); // Reset for fresh check in this conversation // Clear any text from previous conversation if (messageInput) messageInput.value = ''; setLoading(chatView, true); diff --git a/public/js/render.js b/public/js/render.js index 51cfea5..a8b716c 100644 --- a/public/js/render.js +++ b/public/js/render.js @@ -446,10 +446,12 @@ export function finalizeMessage(data) { // Import dynamically to avoid circular dependency import('./ui.js').then(ui => { // Calculate cumulative tokens from all messages (since we resume sessions) - const { inputTokens, outputTokens } = ui.calculateCumulativeTokens(state.getAllMessages()); + const allMessages = state.getAllMessages(); + const { inputTokens, outputTokens } = ui.calculateCumulativeTokens(allMessages); ui.updateContextBar(inputTokens, outputTokens, state.getCurrentModel()); // Check if context is near limit (85%) and show compression prompt + // But don't show if conversation was already compressed recently const models = state.getModels(); const modelId = state.getCurrentModel(); const model = models.find(m => m.id === modelId); @@ -457,7 +459,18 @@ export function finalizeMessage(data) { const totalTokens = inputTokens + outputTokens; const pct = (totalTokens / contextLimit) * 100; - if (pct >= 85 && !state.getCompressionPromptShown()) { + // Check if conversation has been compressed + const hasCompression = allMessages.some(m => m.compressionMeta); + + // Only show compression prompt if: + // 1. At 85%+ context + // 2. Haven't shown the prompt this session + // 3. Either never compressed OR at 95%+ (need to compress again urgently) + const shouldPrompt = pct >= 85 && + !state.getCompressionPromptShown() && + (!hasCompression || pct >= 95); + + if (shouldPrompt) { ui.showCompressionPrompt(pct, totalTokens, contextLimit); } }); diff --git a/public/sw.js b/public/sw.js index 3ae385e..ed95f48 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'concierge-v126'; +const CACHE_NAME = 'concierge-v127'; const STATIC_ASSETS = [ '/', '/index.html',