claude-code-kanban 2.2.0-rc.9 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -30,9 +30,23 @@ let selectedSessionKbId = null;
30
30
  let sessionJustSelected = false;
31
31
  let agentLogMode = null;
32
32
  let agentLogSSE = null;
33
+ let msgHasMore = false;
34
+ let msgLoadingMore = false;
35
+ let msgUserScrolledUp = false;
36
+ const MSG_MAX_LOADED = 200;
33
37
  let currentProjectPath = null;
34
38
  let currentProjectSessionIds = [];
35
39
 
40
+ function resetMessageScrollState() {
41
+ msgUserScrolledUp = false;
42
+ msgHasMore = false;
43
+ msgLoadingMore = false;
44
+ currentMessages = [];
45
+ lastMessagesHash = '';
46
+ const btn = document.getElementById('msg-jump-latest');
47
+ if (btn) btn.style.display = 'none';
48
+ }
49
+
36
50
  function getUrlState() {
37
51
  const params = new URLSearchParams(window.location.search);
38
52
  return {
@@ -77,6 +91,7 @@ function resetState() {
77
91
  currentSessionId = null;
78
92
  currentProjectPath = null;
79
93
  currentProjectSessionIds = [];
94
+ resetMessageScrollState();
80
95
  const searchInput = document.getElementById('search-input');
81
96
  if (searchInput) searchInput.value = '';
82
97
  document.getElementById('search-clear-btn')?.classList.remove('visible');
@@ -116,6 +131,7 @@ async function fetchSessions() {
116
131
  try {
117
132
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
118
133
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
134
+ if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
119
135
  const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
120
136
  const [newSessions, newTasks] = await Promise.all([
121
137
  fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}`).then((r) => r.json()),
@@ -342,7 +358,7 @@ function fuzzyMatch(text, query) {
342
358
  if (text.includes(query)) return true;
343
359
 
344
360
  // Split by common delimiters to search in individual words
345
- const words = text.split(/[\s\-_/.]+/);
361
+ const words = text.split(/[\s\-_/.\\]+/);
346
362
 
347
363
  // Check if query matches start of any word
348
364
  for (const word of words) {
@@ -440,10 +456,13 @@ async function fetchTasks(sessionId) {
440
456
  if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
441
457
  revealedPlanSessionId = null;
442
458
  }
459
+ if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
460
+ revealedStorageSessionId = null;
461
+ }
443
462
  currentSessionId = sessionId;
444
463
  currentPins = loadPins(sessionId);
445
464
  ownerFilter = '';
446
- lastMessagesHash = '';
465
+ resetMessageScrollState();
447
466
  for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
448
467
  for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
449
468
  sessionJustSelected = true;
@@ -613,6 +632,7 @@ async function viewAgentLog(agentId) {
613
632
  const shortId = resolvedId.length > 8 ? resolvedId.slice(0, 8) : resolvedId;
614
633
  const agentSessionId = agent._sourceSessionId || currentSessionId;
615
634
  agentLogMode = { agentId: resolvedId, sessionId: agentSessionId, agentType: agent.type || 'unknown' };
635
+ resetMessageScrollState();
616
636
  closeAgentModal();
617
637
  document.getElementById('message-toggle')?.style.removeProperty('display');
618
638
  if (!messagePanelOpen) toggleMessagePanel();
@@ -650,7 +670,7 @@ function exitAgentLogMode() {
650
670
  }
651
671
  const header = document.querySelector('.message-panel-header h3');
652
672
  if (header) header.textContent = 'Session Log';
653
- lastMessagesHash = '';
673
+ resetMessageScrollState();
654
674
  if (currentSessionId) fetchMessages(currentSessionId);
655
675
  }
656
676
 
@@ -682,9 +702,6 @@ async function fetchMessages(sessionId) {
682
702
  const res = await fetch(`/api/sessions/${sessionId}/messages?limit=15`);
683
703
  if (!res.ok) return;
684
704
  const data = await res.json();
685
- const hash = JSON.stringify(data.messages);
686
- if (hash === lastMessagesHash) return;
687
- lastMessagesHash = hash;
688
705
  let agentEnriched = false;
689
706
  for (const m of data.messages) {
690
707
  if (m.agentId && m.agentPrompt) {
@@ -697,16 +714,75 @@ async function fetchMessages(sessionId) {
697
714
  }
698
715
  if (agentEnriched) renderAgentFooter();
699
716
  if (agentLogMode) return;
700
- currentMessages = data.messages;
701
- if (messagePanelOpen) renderMessages(data.messages);
702
- if (msgDetailFollowLatest && data.messages.length) {
703
- showMsgDetail(data.messages.length - 1);
717
+
718
+ if (!msgUserScrolledUp) {
719
+ const hash = JSON.stringify(data.messages);
720
+ if (hash === lastMessagesHash) return;
721
+ lastMessagesHash = hash;
722
+ msgHasMore = data.hasMore !== false;
723
+ currentMessages = data.messages;
724
+ if (messagePanelOpen) renderMessages(data.messages);
725
+ } else {
726
+ if (data.messages.length && currentMessages.length) {
727
+ const lastKnown = currentMessages[currentMessages.length - 1].timestamp;
728
+ const newMsgs = data.messages.filter((m) => m.timestamp > lastKnown);
729
+ if (newMsgs.length) {
730
+ currentMessages = [...currentMessages, ...newMsgs];
731
+ if (currentMessages.length > MSG_MAX_LOADED) {
732
+ currentMessages = currentMessages.slice(-MSG_MAX_LOADED);
733
+ msgHasMore = true;
734
+ }
735
+ if (messagePanelOpen) renderMessages(currentMessages);
736
+ }
737
+ }
738
+ }
739
+
740
+ if (msgDetailFollowLatest && currentMessages.length) {
741
+ showMsgDetail(currentMessages.length - 1);
704
742
  }
705
743
  } catch (e) {
706
744
  console.error('[fetchMessages]', e);
707
745
  }
708
746
  }
709
747
 
748
+ async function loadOlderMessages() {
749
+ if (agentLogMode || msgLoadingMore || !msgHasMore || !currentMessages.length) return;
750
+ msgLoadingMore = true;
751
+ const container = document.getElementById('message-panel-content');
752
+ const loader = document.createElement('div');
753
+ loader.className = 'msg-loading-more';
754
+ loader.textContent = 'Loading...';
755
+ container.prepend(loader);
756
+ try {
757
+ const before = currentMessages[0].timestamp;
758
+ const res = await fetch(`/api/sessions/${currentSessionId}/messages?limit=15&before=${encodeURIComponent(before)}`);
759
+ if (!res.ok) return;
760
+ const data = await res.json();
761
+ msgHasMore = data.hasMore && data.messages.length > 0;
762
+ if (data.messages.length) {
763
+ loader.remove();
764
+ const prevHeight = container.scrollHeight;
765
+ currentMessages = [...data.messages, ...currentMessages];
766
+ if (currentMessages.length > MSG_MAX_LOADED) {
767
+ currentMessages = currentMessages.slice(0, MSG_MAX_LOADED);
768
+ }
769
+ renderMessages(currentMessages);
770
+ container.scrollTop = container.scrollHeight - prevHeight;
771
+ }
772
+ } catch (e) {
773
+ console.error('[loadOlderMessages]', e);
774
+ } finally {
775
+ if (loader.parentNode) loader.remove();
776
+ requestAnimationFrame(() => {
777
+ msgLoadingMore = false;
778
+ // Chain auto-load if content still doesn't overflow
779
+ if (msgHasMore && currentMessages.length < MSG_MAX_LOADED && container.scrollHeight <= container.clientHeight) {
780
+ loadOlderMessages();
781
+ }
782
+ });
783
+ }
784
+ }
785
+
710
786
  function parseCommandMessage(text) {
711
787
  const nameMatch = text.match(/<command-name>([^<]+)<\/command-name>/);
712
788
  if (nameMatch) return nameMatch[1].trim();
@@ -752,10 +828,10 @@ function renderPinnedSection() {
752
828
  <div class="msg-body"><div class="msg-text">${escapeHtml(cleanMessageText(p.text || ''))}</div><div class="msg-time">${formatDate(p.timestamp)}</div></div>${unpin}
753
829
  </div>`;
754
830
  } else if (p.type === 'tool_use') {
755
- const toolDetail = p.detail ? ` <span style="color:var(--text-muted)">${escapeHtml(p.detail)}</span>` : '';
756
- const pinnedAgentLogBtn = p.tool === 'Agent' && p.agentId ? agentLogButton(p.agentId) : '';
831
+ const toolDetail = getToolDetail(p.tool, p.params, p.detail);
832
+ const pinnedAgentLogBtn = resolveAgentLogBtn(p);
757
833
  return `<div class="msg-item msg-tool" ${click}>
758
- ${MSG_ICON_TOOL}
834
+ ${getToolIcon(p.tool)}
759
835
  <div class="msg-body"><div class="msg-text">${escapeHtml(p.tool || '')}${toolDetail}</div><div class="msg-time">${formatDate(p.timestamp)}</div></div>${pinnedAgentLogBtn}${unpin}
760
836
  </div>`;
761
837
  } else if (p.type === 'agent') {
@@ -786,96 +862,163 @@ function renderPinnedSection() {
786
862
  </div>`;
787
863
  }
788
864
 
789
- function renderMessages(messages) {
790
- const container = document.getElementById('message-panel-content');
791
- const pinnedContainer = document.getElementById('message-panel-pinned');
792
- pinnedContainer.innerHTML = agentLogMode ? '' : renderPinnedSection();
793
- if (!messages.length) {
794
- container.innerHTML = '<div class="msg-empty">No messages found for this session</div>';
795
- return;
796
- }
797
- const msgsHtml = messages
798
- .map((m, i) => {
799
- const pinBtn = renderMsgPinBtn(m, i);
800
- const clickable = `onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
801
- if (m.type === 'user') {
802
- if (m.systemLabel) {
803
- return `<div class="msg-item msg-system" ${clickable}>
804
- ${MSG_ICON_SYSTEM}
805
- <div class="msg-body"><div class="msg-text"><code>${escapeHtml(m.systemLabel)}</code></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
806
- </div>`;
807
- }
865
+ function resolveAgentLogBtn(m) {
866
+ if (m.tool === 'Agent' && m.agentId) return agentLogButton(m.agentId);
867
+ if (m.tool === 'SendMessage' && m.params?.to) {
868
+ const recipient = currentAgents.find((a) => (a.type || a.name) === m.params.to);
869
+ if (recipient) return agentLogButton(recipient.agentId);
870
+ }
871
+ return '';
872
+ }
873
+
874
+ function toolGroupKey(m) {
875
+ return m.type === 'tool_use' ? `${m.tool}\0${m.detail || ''}` : null;
876
+ }
877
+
878
+ function renderToolItem(m, i, compact) {
879
+ const toolDetail = getToolDetail(m.tool, m.params, m.detail);
880
+ const agentLink =
881
+ m.tool === 'Agent' && m.agentId
882
+ ? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
883
+ : '';
884
+ const agentLogBtn = resolveAgentLogBtn(m);
885
+ const recipientColor = m.tool === 'SendMessage' && m.params?.to ? resolveNamedColor(teamColorMap[m.params.to]) : null;
886
+ const borderStyle = recipientColor ? `border-left:3px solid ${recipientColor.color};` : '';
887
+ const compactClass = compact ? ' msg-tool-grouped' : '';
888
+ const combinedStyle = `style="${borderStyle}cursor:pointer"`;
889
+ const itemClickAttr =
890
+ m.tool === 'Agent' && m.agentId
891
+ ? `onclick="showAgentModal('${escapeHtml(m.agentId)}')" ${combinedStyle}`
892
+ : `onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" ${combinedStyle}`;
893
+ const pinBtn = renderMsgPinBtn(m, i);
894
+ return `<div class="msg-item msg-tool${compactClass}" ${itemClickAttr}>
895
+ ${getToolIcon(m.tool)}
896
+ <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
897
+ </div>`;
898
+ }
899
+
900
+ function renderMessageList(messages) {
901
+ const parts = [];
902
+ let i = 0;
903
+ while (i < messages.length) {
904
+ const m = messages[i];
905
+
906
+ if (m.type === 'tool_use') {
907
+ const key = toolGroupKey(m);
908
+ let runEnd = i + 1;
909
+ while (runEnd < messages.length && toolGroupKey(messages[runEnd]) === key) runEnd++;
910
+ const count = runEnd - i;
911
+
912
+ if (count >= 2) {
913
+ const first = messages[i];
914
+ const last = messages[runEnd - 1];
915
+ const toolDetail = getToolDetail(first.tool, first.params, first.detail);
916
+ const gid = `tool-group-${i}`;
917
+ const timeRange = `${formatDate(first.timestamp)} – ${formatDate(last.timestamp)}`;
918
+ const grpAgentLogBtn = resolveAgentLogBtn(first);
919
+ const grpPinBtn = renderMsgPinBtn(first, i);
920
+ parts.push(`<div class="msg-tool-group">
921
+ <div class="msg-item msg-tool msg-tool-group-header" onclick="toggleToolGroup('${gid}')" style="cursor:pointer">
922
+ ${getToolIcon(first.tool)}
923
+ <div class="msg-body"><div class="msg-text">${escapeHtml(first.tool)}${toolDetail}<span class="tool-count-badge">×${count}</span></div><div class="msg-time">${timeRange}</div></div>${grpAgentLogBtn}${grpPinBtn}
924
+ </div>
925
+ <div class="msg-tool-group-items" id="${gid}">${Array.from({ length: count }, (_, j) => renderToolItem(messages[i + j], i + j, true)).join('')}</div>
926
+ </div>`);
927
+ i = runEnd;
928
+ continue;
929
+ }
930
+
931
+ parts.push(renderToolItem(m, i, false));
932
+ i++;
933
+ continue;
934
+ }
935
+
936
+ const clickable = `onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
937
+ const pinBtn = renderMsgPinBtn(m, i);
938
+ if (m.type === 'user') {
939
+ if (m.systemLabel) {
940
+ parts.push(`<div class="msg-item msg-system" ${clickable}>
941
+ ${MSG_ICON_SYSTEM}
942
+ <div class="msg-body"><div class="msg-text"><code>${escapeHtml(m.systemLabel)}</code></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
943
+ </div>`);
944
+ } else {
808
945
  const cmd = parseCommandMessage(m.text);
809
946
  const displayText = cmd ? cmd : escapeHtml(cleanMessageText(m.text));
810
947
  const isCmd = !!cmd;
811
- return `<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
948
+ parts.push(`<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
812
949
  ${MSG_ICON_USER}
813
950
  <div class="msg-body"><div class="msg-text">${isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
814
- </div>`;
815
- } else if (m.type === 'assistant') {
816
- return `<div class="msg-item msg-assistant" ${clickable}>
817
- ${MSG_ICON_ASSISTANT}
818
- <div class="msg-body"><div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div><div class="msg-time">${m.model ? `${escapeHtml(m.model)} · ` : ''}${formatDate(m.timestamp)}</div></div>${pinBtn}
819
- </div>`;
820
- } else if (m.type === 'tool_use') {
821
- const toolDetail = m.detail ? ` <span style="color:var(--text-muted)">${escapeHtml(m.detail)}</span>` : '';
822
- const agentLink =
823
- m.tool === 'Agent' && m.agentId
824
- ? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
825
- : '';
826
- let agentLogBtn = '';
827
- if (m.tool === 'Agent' && m.agentId) {
828
- agentLogBtn = agentLogButton(m.agentId);
829
- } else if (m.tool === 'SendMessage' && m.params?.to) {
830
- const recipient = currentAgents.find((a) => (a.type || a.name) === m.params.to);
831
- if (recipient) agentLogBtn = agentLogButton(recipient.agentId);
832
- }
833
- const recipientColor =
834
- m.tool === 'SendMessage' && m.params?.to ? resolveNamedColor(teamColorMap[m.params.to]) : null;
835
- const borderStyle = recipientColor ? `border-left:3px solid ${recipientColor.color};` : '';
836
- const combinedStyle = `style="${borderStyle}cursor:pointer"`;
837
- const itemClickAttr =
838
- m.tool === 'Agent' && m.agentId
839
- ? `onclick="showAgentModal('${escapeHtml(m.agentId)}')" ${combinedStyle}`
840
- : `onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" ${combinedStyle}`;
841
- return `<div class="msg-item msg-tool" ${itemClickAttr}>
842
- ${MSG_ICON_TOOL}
843
- <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
844
- </div>`;
845
- } else if (m.type === 'teammate') {
846
- if (m.teammateId && m.color && !teamColorMap[m.teammateId]) teamColorMap[m.teammateId] = m.color;
847
- const tmColor = m.color ? resolveNamedColor(m.color)?.color || m.color : '';
848
- const nameSpan = `<span class="teammate-name" style="${tmColor ? `color:${escapeHtml(tmColor)}` : ''}">${escapeHtml(m.teammateId || 'teammate')}</span>`;
849
- let tmLookupName = m.teammateId;
850
- if (m.teammateId === 'system' && m.protocolType === 'teammate_terminated' && m.protocolData?.message) {
851
- const shutMatch = m.protocolData.message.match(/^(.+?) has shut down/);
852
- if (shutMatch) tmLookupName = shutMatch[1];
853
- }
854
- const tmAgent = tmLookupName ? currentAgents.find((a) => (a.type || a.name) === tmLookupName) : null;
855
- const tmLogBtn = tmAgent ? agentLogButton(tmAgent.agentId) : '';
856
- if (m.isIdle) {
857
- return `<div class="msg-item msg-teammate msg-idle" ${clickable}>
858
- ${MSG_ICON_IDLE}
859
- <div class="msg-body"><div class="msg-text">${nameSpan} <span class="idle-label">${escapeHtml(m.protocolLabel || 'idle')}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${tmLogBtn}
860
- </div>`;
861
- }
862
- if (m.isProtocol) {
863
- return `<div class="msg-item msg-teammate msg-protocol" ${clickable}>
864
- ${MSG_ICON_TEAMMATE}
865
- <div class="msg-body"><div class="msg-text">${nameSpan} <span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${tmLogBtn}
866
- </div>`;
867
- }
951
+ </div>`);
952
+ }
953
+ } else if (m.type === 'assistant') {
954
+ parts.push(`<div class="msg-item msg-assistant" ${clickable}>
955
+ ${MSG_ICON_ASSISTANT}
956
+ <div class="msg-body"><div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div><div class="msg-time">${m.model ? `${escapeHtml(m.model)} · ` : ''}${formatDate(m.timestamp)}</div></div>${pinBtn}
957
+ </div>`);
958
+ } else if (m.type === 'teammate') {
959
+ if (m.teammateId && m.color && !teamColorMap[m.teammateId]) teamColorMap[m.teammateId] = m.color;
960
+ const tmColor = m.color ? resolveNamedColor(m.color)?.color || m.color : '';
961
+ const nameSpan = `<span class="teammate-name" style="${tmColor ? `color:${escapeHtml(tmColor)}` : ''}">${escapeHtml(m.teammateId || 'teammate')}</span>`;
962
+ let tmLookupName = m.teammateId;
963
+ if (m.teammateId === 'system' && m.protocolType === 'teammate_terminated' && m.protocolData?.message) {
964
+ const shutMatch = m.protocolData.message.match(/^(.+?) has shut down/);
965
+ if (shutMatch) tmLookupName = shutMatch[1];
966
+ }
967
+ const tmAgent = tmLookupName ? currentAgents.find((a) => (a.type || a.name) === tmLookupName) : null;
968
+ const tmLogBtn = tmAgent ? agentLogButton(tmAgent.agentId) : '';
969
+ if (m.isIdle) {
970
+ parts.push(`<div class="msg-item msg-teammate msg-idle" ${clickable}>
971
+ ${MSG_ICON_IDLE}
972
+ <div class="msg-body"><div class="msg-text">${nameSpan} <span class="idle-label">${escapeHtml(m.protocolLabel || 'idle')}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${tmLogBtn}
973
+ </div>`);
974
+ } else if (m.isProtocol) {
975
+ parts.push(`<div class="msg-item msg-teammate msg-protocol" ${clickable}>
976
+ ${MSG_ICON_TEAMMATE}
977
+ <div class="msg-body"><div class="msg-text">${nameSpan} <span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${tmLogBtn}
978
+ </div>`);
979
+ } else {
868
980
  const summaryText = m.summary ? escapeHtml(m.summary) : escapeHtml((m.text || '').slice(0, 80));
869
- return `<div class="msg-item msg-teammate" ${clickable}>
981
+ parts.push(`<div class="msg-item msg-teammate" ${clickable}>
870
982
  ${MSG_ICON_TEAMMATE}
871
983
  <div class="msg-body"><div class="msg-text">${nameSpan} ${summaryText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${tmLogBtn}${pinBtn}
872
- </div>`;
984
+ </div>`);
873
985
  }
874
- return '';
875
- })
876
- .join('');
877
- container.innerHTML = msgsHtml;
878
- container.scrollTop = container.scrollHeight;
986
+ }
987
+ i++;
988
+ }
989
+ return parts.join('');
990
+ }
991
+
992
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
993
+ function toggleToolGroup(id) {
994
+ const el = document.getElementById(id);
995
+ if (el) el.classList.toggle('show');
996
+ }
997
+
998
+ function renderMessages(messages) {
999
+ const container = document.getElementById('message-panel-content');
1000
+ const pinnedContainer = document.getElementById('message-panel-pinned');
1001
+ pinnedContainer.innerHTML = agentLogMode ? '' : renderPinnedSection();
1002
+ if (!messages.length) {
1003
+ container.innerHTML = '<div class="msg-empty">No messages found for this session</div>';
1004
+ return;
1005
+ }
1006
+ const msgsHtml = renderMessageList(messages);
1007
+ const limitBanner =
1008
+ currentMessages.length >= MSG_MAX_LOADED
1009
+ ? `<div class="msg-limit-banner">Showing last ${MSG_MAX_LOADED} messages</div>`
1010
+ : '';
1011
+ container.innerHTML = limitBanner + msgsHtml;
1012
+ if (!msgUserScrolledUp) container.scrollTop = container.scrollHeight;
1013
+ // Auto-load more if content doesn't overflow yet
1014
+ if (
1015
+ msgHasMore &&
1016
+ !msgLoadingMore &&
1017
+ currentMessages.length < MSG_MAX_LOADED &&
1018
+ container.scrollHeight <= container.clientHeight
1019
+ ) {
1020
+ loadOlderMessages();
1021
+ }
879
1022
  }
880
1023
 
881
1024
  let currentMsgDetailIdx = null;
@@ -897,6 +1040,40 @@ const MSG_ICON_TEAMMATE =
897
1040
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
898
1041
  const MSG_ICON_IDLE =
899
1042
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6"/></svg>';
1043
+ const ICON_TASK =
1044
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
1045
+ const ICON_WEB =
1046
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
1047
+ const TOOL_ICONS = {
1048
+ Bash: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="18" rx="2"/><polyline points="7 10 10 13 7 16"/><line x1="13" y1="16" x2="17" y2="16"/></svg>',
1049
+ Read: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
1050
+ Write:
1051
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>',
1052
+ Edit: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
1053
+ Glob: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><circle cx="14" cy="14" r="3"/><line x1="16.5" y1="16.5" x2="19" y2="19"/></svg>',
1054
+ Grep: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
1055
+ Agent: MSG_ICON_TEAMMATE,
1056
+ SendMessage:
1057
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
1058
+ TaskCreate: ICON_TASK,
1059
+ TaskUpdate: ICON_TASK,
1060
+ TaskGet: ICON_TASK,
1061
+ TaskList: ICON_TASK,
1062
+ ToolSearch:
1063
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
1064
+ AskUserQuestion:
1065
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
1066
+ Skill:
1067
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
1068
+ WebFetch: ICON_WEB,
1069
+ WebSearch: ICON_WEB,
1070
+ NotebookEdit:
1071
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>',
1072
+ LSP: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
1073
+ };
1074
+ function getToolIcon(toolName) {
1075
+ return TOOL_ICONS[toolName] || MSG_ICON_TOOL;
1076
+ }
900
1077
  const AGENT_LOG_ICON =
901
1078
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
902
1079
  function agentLogButton(agentId) {
@@ -1077,17 +1254,26 @@ function savePinnedSessions() {
1077
1254
  localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
1078
1255
  }
1079
1256
 
1080
- // unpinned → pinned → sticky → unpinned
1081
1257
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1082
1258
  function toggleSessionPin(sessionId) {
1259
+ if (pinnedSessionIds.has(sessionId)) {
1260
+ pinnedSessionIds.delete(sessionId);
1261
+ stickySessionIds.delete(sessionId);
1262
+ } else {
1263
+ pinnedSessionIds.add(sessionId);
1264
+ }
1265
+ savePinnedSessions();
1266
+ renderSessions();
1267
+ }
1268
+
1269
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1270
+ function toggleSessionSticky(sessionId) {
1083
1271
  if (stickySessionIds.has(sessionId)) {
1084
1272
  stickySessionIds.delete(sessionId);
1085
1273
  pinnedSessionIds.delete(sessionId);
1086
- } else if (pinnedSessionIds.has(sessionId)) {
1087
- pinnedSessionIds.delete(sessionId);
1088
- stickySessionIds.add(sessionId);
1089
1274
  } else {
1090
1275
  pinnedSessionIds.add(sessionId);
1276
+ stickySessionIds.add(sessionId);
1091
1277
  }
1092
1278
  savePinnedSessions();
1093
1279
  renderSessions();
@@ -1184,7 +1370,7 @@ function showMsgDetail(idx) {
1184
1370
  const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
1185
1371
  mainHtml = `${descHtml}<pre class="msg-detail-pre">${detailRendered}</pre>`;
1186
1372
  } else {
1187
- mainHtml = '<em>No details</em>';
1373
+ mainHtml = TASK_TOOLS.has(m.tool) ? '' : '<em>No details</em>';
1188
1374
  }
1189
1375
  body.innerHTML = mainHtml + toolParamsHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1190
1376
  } else if (m.type === 'teammate') {
@@ -1199,14 +1385,25 @@ function showMsgDetail(idx) {
1199
1385
  body.innerHTML = renderMarkdown(text);
1200
1386
  }
1201
1387
  } else {
1202
- const text = stripAnsi(m.fullText || m.text);
1388
+ const rawText = stripAnsi(m.fullText || m.text);
1389
+ const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
1203
1390
  document.getElementById('msg-detail-title').textContent =
1204
1391
  m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
1205
1392
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
1206
1393
  if (m.compactSummary) {
1207
1394
  body.innerHTML = renderMarkdown(m.compactSummary);
1395
+ } else if (cmd) {
1396
+ const argsMatch = rawText.match(/<command-args>([^<]*)<\/command-args>/);
1397
+ const args = argsMatch?.[1].trim() ? argsMatch[1].trim() : null;
1398
+ const cleanBody = rawText
1399
+ .replace(/<command-[^>]+>[\s\S]*?<\/command-[^>]+>/g, '')
1400
+ .replace(/<local-command-[^>]+>[\s\S]*?<\/local-command-[^>]+>/g, '')
1401
+ .trim();
1402
+ let cmdHtml = `<code>${escapeHtml(cmd)}${args ? ` ${escapeHtml(args)}` : ''}</code>`;
1403
+ if (cleanBody) cmdHtml += `<div style="margin-top:10px">${renderMarkdown(cleanBody)}</div>`;
1404
+ body.innerHTML = cmdHtml;
1208
1405
  } else {
1209
- body.innerHTML = renderMarkdown(text);
1406
+ body.innerHTML = renderMarkdown(rawText);
1210
1407
  }
1211
1408
  }
1212
1409
  const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
@@ -1318,6 +1515,32 @@ function renderProtocolDetail(data) {
1318
1515
  }
1319
1516
 
1320
1517
  const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']);
1518
+ const TASK_STATUS_COLORS = {
1519
+ pending: 'var(--text-muted)',
1520
+ in_progress: 'var(--info)',
1521
+ completed: 'var(--success)',
1522
+ deleted: 'var(--danger)',
1523
+ };
1524
+ function formatTaskStatusBadge(status) {
1525
+ const color = TASK_STATUS_COLORS[status] || 'var(--text-muted)';
1526
+ return `<span style="color:${color};font-weight:600;text-transform:uppercase;font-size:0.85em">${escapeHtml(status)}</span>`;
1527
+ }
1528
+ function formatTaskToolDetail(params) {
1529
+ if (!params) return '';
1530
+ const parts = [];
1531
+ if (params.taskId) {
1532
+ const id = String(params.taskId).replace(/^#/, '');
1533
+ parts.push(`<span style="color:var(--text-muted)">#${escapeHtml(id)}</span>`);
1534
+ }
1535
+ if (params.status) parts.push(formatTaskStatusBadge(params.status));
1536
+ if (params.subject) parts.push(`<span style="color:var(--text-secondary)">${escapeHtml(params.subject)}</span>`);
1537
+ return parts.length ? ` ${parts.join(' ')}` : '';
1538
+ }
1539
+ function getToolDetail(tool, params, detail) {
1540
+ if (TASK_TOOLS.has(tool)) return formatTaskToolDetail(params);
1541
+ if (detail) return ` <span style="color:var(--text-muted)">${escapeHtml(detail)}</span>`;
1542
+ return '';
1543
+ }
1321
1544
  function renderTaskResult(toolResult) {
1322
1545
  if (!toolResult) return '';
1323
1546
  const lines = toolResult.trim().split('\n');
@@ -1330,17 +1553,9 @@ function renderTaskResult(toolResult) {
1330
1553
  const title = fields.find(([k]) => /^Task/.test(k));
1331
1554
  const status = fields.find(([k]) => k === 'Status');
1332
1555
  const rest = fields.filter(([k]) => !/^Task/.test(k) && k !== 'Status');
1333
- const statusColors = {
1334
- pending: 'var(--text-muted)',
1335
- in_progress: 'var(--info)',
1336
- completed: 'var(--success)',
1337
- deleted: 'var(--danger)',
1338
- };
1339
- const sc = status ? statusColors[status[1]] || 'var(--text-muted)' : '';
1340
1556
  let html = '<div class="protocol-detail">';
1341
1557
  if (title) html += `<span class="protocol-type-badge">${escapeHtml(title[1])}</span>`;
1342
- if (status)
1343
- html += `<span style="display:inline-block;font-size:10px;font-weight:600;color:${sc};text-transform:uppercase;margin-bottom:6px">${escapeHtml(status[1])}</span>`;
1558
+ if (status) html += `<span style="display:inline-block;margin-bottom:6px">${formatTaskStatusBadge(status[1])}</span>`;
1344
1559
  if (rest.length) {
1345
1560
  html += '<div class="protocol-fields">';
1346
1561
  for (const [k, v] of rest) {
@@ -1447,6 +1662,13 @@ function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
1447
1662
  }
1448
1663
 
1449
1664
  function autoSizeModal(modal, body) {
1665
+ modal.style.maxWidth = '';
1666
+ modal.classList.remove('has-mermaid');
1667
+ const hasMermaid = body.querySelector('pre.mermaid') !== null;
1668
+ if (hasMermaid) {
1669
+ modal.classList.add('has-mermaid');
1670
+ return;
1671
+ }
1450
1672
  const hasTable = body.querySelector('table') !== null;
1451
1673
  const hasPre = body.querySelector('pre') !== null;
1452
1674
  const desired = hasTable ? 1100 : body.textContent.length > 2000 || hasPre ? 960 : 860;
@@ -1539,8 +1761,9 @@ function renderAgentFooter() {
1539
1761
  // or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
1540
1762
  const byType = {};
1541
1763
  for (const a of agents) {
1542
- if (!byType[a.type]) byType[a.type] = [];
1543
- byType[a.type].push(a);
1764
+ const groupKey = a.agentName || a.type;
1765
+ if (!byType[groupKey]) byType[groupKey] = [];
1766
+ byType[groupKey].push(a);
1544
1767
  }
1545
1768
  const filtered = [];
1546
1769
  for (const group of Object.values(byType)) {
@@ -1614,10 +1837,15 @@ function renderAgentFooter() {
1614
1837
  const colonIdx = rawType.indexOf(':');
1615
1838
  const typeNs = colonIdx > 0 ? rawType.substring(0, colonIdx + 1) : '';
1616
1839
  const typeName = colonIdx > 0 ? rawType.substring(colonIdx + 1) : rawType;
1840
+ const agentNameVal = a.agentName || null;
1841
+ const nameColor = agentNameVal ? getOwnerColor(agentNameVal) : null;
1842
+ const nameBadgeHtml = nameColor
1843
+ ? `<span class="task-owner-badge task-owner-badge--compact" style="background:${nameColor.bg};color:${nameColor.color}">${escapeHtml(agentNameVal)}</span>`
1844
+ : '';
1617
1845
  const agentColor = resolveNamedColor(a.color);
1618
1846
  const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
1619
1847
  return `<div class="agent-card"${colorStyle} onclick="showAgentModal('${a.agentId}')">
1620
- <div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span></div>
1848
+ <div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
1621
1849
  <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
1622
1850
  ${msgHtml}
1623
1851
  </div>`;
@@ -1717,13 +1945,21 @@ function showAgentModal(agentId) {
1717
1945
  const elapsed = stopped && started ? stopped.getTime() - started.getTime() : started ? now - started.getTime() : 0;
1718
1946
 
1719
1947
  const statusDot = `<span class="agent-dot ${agent.status}" style="display:inline-block;vertical-align:middle;margin-right:6px;"></span>`;
1720
- title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}`;
1948
+ const modalNameLabel = agent.agentName ? ` · ${escapeHtml(agent.agentName)}` : '';
1949
+ title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}${modalNameLabel}`;
1721
1950
 
1722
1951
  const rows = [
1723
1952
  ['Status', agent.status],
1724
1953
  ['Agent ID', `<code style="font-size:12px;color:var(--text-tertiary)">${escapeHtml(agent.agentId)}</code>`],
1725
1954
  ['Duration', formatDuration(elapsed)],
1726
1955
  ];
1956
+ if (agent.agentName) {
1957
+ const ownerColor = getOwnerColor(agent.agentName);
1958
+ rows.push([
1959
+ 'Owner',
1960
+ `<span class="task-owner-badge" style="background:${ownerColor.bg};color:${ownerColor.color}">${escapeHtml(agent.agentName)}</span>`,
1961
+ ]);
1962
+ }
1727
1963
  if (agent.model)
1728
1964
  rows.push(['Model', `<code style="font-size:12px;color:var(--text-tertiary)">${escapeHtml(agent.model)}</code>`]);
1729
1965
  if (started) rows.push(['Started', started.toLocaleTimeString()]);
@@ -1774,6 +2010,7 @@ function closeAgentModal() {
1774
2010
 
1775
2011
  //#region RENDERING
1776
2012
  let revealedPlanSessionId = null;
2013
+ let revealedStorageSessionId = null;
1777
2014
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1778
2015
  async function revealPlanSession(planSessionId) {
1779
2016
  if (revealedPlanSessionId === planSessionId) {
@@ -1868,6 +2105,10 @@ function renderSessions() {
1868
2105
  const planSession = sessions.find((s) => s.id === revealedPlanSessionId);
1869
2106
  if (planSession) filteredSessions.push(planSession);
1870
2107
  }
2108
+ if (revealedStorageSessionId && !filteredSessions.some((s) => s.id === revealedStorageSessionId)) {
2109
+ const storageSession = sessions.find((s) => s.id === revealedStorageSessionId);
2110
+ if (storageSession) filteredSessions.push(storageSession);
2111
+ }
1871
2112
  }
1872
2113
  if (filterProject) {
1873
2114
  filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project));
@@ -1875,30 +2116,34 @@ function renderSessions() {
1875
2116
 
1876
2117
  // Apply search filter
1877
2118
  if (searchQuery) {
1878
- filteredSessions = filteredSessions.filter((session) => {
1879
- // Search in session name and ID
1880
- if (session.name && fuzzyMatch(session.name, searchQuery)) return true;
1881
- if (session.id && fuzzyMatch(session.id, searchQuery)) return true;
1882
-
1883
- // Search in project path
1884
- if (session.project && fuzzyMatch(session.project, searchQuery)) return true;
1885
-
1886
- // Search in description
1887
- if (session.description && fuzzyMatch(session.description, searchQuery)) return true;
1888
-
1889
- // Search in tasks for this session
1890
- const sessionTasks = allTasksCache.filter((t) => t.sessionId === session.id);
1891
- return sessionTasks.some(
1892
- (task) =>
1893
- (task.subject && fuzzyMatch(task.subject, searchQuery)) ||
1894
- (task.description && fuzzyMatch(task.description, searchQuery)) ||
1895
- (task.activeForm && fuzzyMatch(task.activeForm, searchQuery)),
1896
- );
1897
- });
2119
+ const taskMatchIds = new Set();
2120
+ for (const t of allTasksCache) {
2121
+ if (
2122
+ (t.subject && fuzzyMatch(t.subject, searchQuery)) ||
2123
+ (t.description && fuzzyMatch(t.description, searchQuery)) ||
2124
+ (t.activeForm && fuzzyMatch(t.activeForm, searchQuery))
2125
+ )
2126
+ taskMatchIds.add(t.sessionId);
2127
+ }
2128
+ const matchesSearch = (s) =>
2129
+ (s.name && fuzzyMatch(s.name, searchQuery)) ||
2130
+ (s.id && fuzzyMatch(s.id, searchQuery)) ||
2131
+ (s.project && fuzzyMatch(s.project, searchQuery)) ||
2132
+ (s.description && fuzzyMatch(s.description, searchQuery)) ||
2133
+ taskMatchIds.has(s.id);
2134
+
2135
+ filteredSessions = filteredSessions.filter(matchesSearch);
2136
+
2137
+ // Re-add pinned/sticky sessions that match the query but were excluded by active filter
2138
+ if (pinnedSessionIds.size > 0 || stickySessionIds.size > 0) {
2139
+ const filteredIds = new Set(filteredSessions.map((s) => s.id));
2140
+ const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id) && matchesSearch(s));
2141
+ if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
2142
+ }
1898
2143
  }
1899
2144
 
1900
- // Always include pinned/sticky sessions even if they don't match filters
1901
- if ((pinnedSessionIds.size > 0 || stickySessionIds.size > 0) && !searchQuery) {
2145
+ // Include pinned/sticky sessions even if they don't match active/recent filter
2146
+ if (!searchQuery && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
1902
2147
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
1903
2148
  const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id));
1904
2149
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
@@ -1956,7 +2201,7 @@ function renderSessions() {
1956
2201
 
1957
2202
  const pinState = getSessionPinState(session.id);
1958
2203
  const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
1959
- const pinTitle = pinState === 'sticky' ? 'Unpin' : pinState === 'pinned' ? 'Sticky pin' : 'Pin';
2204
+ const pinTitle = pinState === 'pinned' || pinState === 'sticky' ? 'Unpin' : 'Pin';
1960
2205
  const showCtx = !!session.contextStatus;
1961
2206
  return `
1962
2207
  <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${!session.hasRecentLog && !session.inProgress && !session.hasWaitingForUser ? 'stale' : ''} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
@@ -3324,9 +3569,16 @@ function _renderStorageSessions() {
3324
3569
  return html;
3325
3570
  }
3326
3571
 
3327
- function _storageViewSession(id) {
3572
+ async function _storageViewSession(id) {
3328
3573
  closeStorageManager();
3329
- fetchTasks(id);
3574
+ revealedStorageSessionId = id;
3575
+ if (!sessions.some((s) => s.id === id)) {
3576
+ lastSessionsHash = '';
3577
+ await fetchSessions();
3578
+ }
3579
+ await fetchTasks(id);
3580
+ const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(id)}"]`);
3581
+ if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
3330
3582
  }
3331
3583
 
3332
3584
  function _storageUnpinSession(id) {
@@ -4025,6 +4277,33 @@ function renderMarkdown(text) {
4025
4277
  return `<pre style="white-space:pre-wrap;margin:0;">${escapeHtml(text)}</pre>`;
4026
4278
  }
4027
4279
 
4280
+ function isLightTheme() {
4281
+ const saved = localStorage.getItem('theme');
4282
+ return (
4283
+ document.body.classList.contains('light') || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches)
4284
+ );
4285
+ }
4286
+
4287
+ function getMermaidTheme() {
4288
+ return isLightTheme() ? 'default' : 'dark';
4289
+ }
4290
+
4291
+ function initMermaidBlocks(container) {
4292
+ if (typeof mermaid === 'undefined') return;
4293
+ const blocks = (container || document).querySelectorAll('pre.mermaid:not([data-processed])');
4294
+ if (blocks.length) mermaid.run({ nodes: [...blocks] });
4295
+ }
4296
+
4297
+ function reinitMermaidTheme() {
4298
+ if (typeof mermaid === 'undefined') return;
4299
+ mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
4300
+ document.querySelectorAll('pre.mermaid[data-processed]').forEach((el) => {
4301
+ el.removeAttribute('data-processed');
4302
+ el.innerHTML = escapeHtml(el.getAttribute('data-original') || '');
4303
+ });
4304
+ initMermaidBlocks();
4305
+ }
4306
+
4028
4307
  const _agentTabTexts = {};
4029
4308
 
4030
4309
  function renderAgentTabs(promptHtml, responseHtml, promptText, responseText) {
@@ -4288,22 +4567,21 @@ function toggleTheme() {
4288
4567
  updateThemeIcon();
4289
4568
  updateThemeColor(!isCurrentlyLight);
4290
4569
  syncHljsTheme();
4570
+ reinitMermaidTheme();
4291
4571
  }
4292
4572
 
4293
4573
  function syncHljsTheme() {
4294
- const isLight = document.body.classList.contains('light');
4295
- const dark = document.getElementById('hljs-theme-dark');
4296
- const light = document.getElementById('hljs-theme-light');
4297
- if (dark) dark.disabled = isLight;
4298
- if (light) light.disabled = !isLight;
4574
+ const light = isLightTheme();
4575
+ const dark$ = document.getElementById('hljs-theme-dark');
4576
+ const light$ = document.getElementById('hljs-theme-light');
4577
+ if (dark$) dark$.disabled = light;
4578
+ if (light$) light$.disabled = !light;
4299
4579
  }
4300
4580
 
4301
4581
  function updateThemeIcon() {
4302
- const saved = localStorage.getItem('theme');
4303
- const isLight =
4304
- document.body.classList.contains('light') || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches);
4305
- document.getElementById('theme-icon-dark').style.display = isLight ? 'none' : 'block';
4306
- document.getElementById('theme-icon-light').style.display = isLight ? 'block' : 'none';
4582
+ const light = isLightTheme();
4583
+ document.getElementById('theme-icon-dark').style.display = light ? 'none' : 'block';
4584
+ document.getElementById('theme-icon-light').style.display = light ? 'block' : 'none';
4307
4585
  }
4308
4586
 
4309
4587
  function loadTheme() {
@@ -4477,8 +4755,20 @@ async function showSessionInfoModal(sessionId) {
4477
4755
  showInfoModal(session, teamConfig, tasks, planContent);
4478
4756
  }
4479
4757
 
4758
+ let _infoModalSessionId = null;
4480
4759
  let _pendingPlanContent = null;
4481
4760
 
4761
+ function updateStickyBtnState() {
4762
+ const stickyBtn = document.getElementById('session-info-sticky-btn');
4763
+ if (!stickyBtn || !_infoModalSessionId) return;
4764
+ const isSticky = stickySessionIds.has(_infoModalSessionId);
4765
+ stickyBtn.style.display = '';
4766
+ stickyBtn.classList.toggle('active', isSticky);
4767
+ stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show at top';
4768
+ const svg = stickyBtn.querySelector('svg');
4769
+ if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
4770
+ }
4771
+
4482
4772
  function showInfoModal(session, teamConfig, tasks, planContent) {
4483
4773
  const modal = document.getElementById('team-modal');
4484
4774
  const titleEl = document.getElementById('team-modal-title');
@@ -4616,6 +4906,8 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
4616
4906
  }
4617
4907
 
4618
4908
  bodyEl.innerHTML = html;
4909
+ _infoModalSessionId = session.id;
4910
+ updateStickyBtnState();
4619
4911
  modal.classList.add('visible');
4620
4912
 
4621
4913
  const keyHandler = (e) => {
@@ -4778,6 +5070,9 @@ document.addEventListener('DOMContentLoaded', () => {
4778
5070
  if (typeof marked !== 'undefined' && typeof hljs !== 'undefined') {
4779
5071
  const renderer = new marked.Renderer();
4780
5072
  renderer.code = ({ text, lang }) => {
5073
+ if (lang === 'mermaid') {
5074
+ return `<pre class="mermaid" data-original="${escapeHtml(text)}">${escapeHtml(text)}</pre>`;
5075
+ }
4781
5076
  let highlighted;
4782
5077
  if (lang && hljs.getLanguage(lang)) {
4783
5078
  highlighted = hljs.highlight(text, { language: lang }).value;
@@ -4788,6 +5083,20 @@ document.addEventListener('DOMContentLoaded', () => {
4788
5083
  };
4789
5084
  marked.use({ renderer });
4790
5085
  }
5086
+
5087
+ if (typeof mermaid !== 'undefined') {
5088
+ mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
5089
+ let mermaidPending = false;
5090
+ const mo = new MutationObserver(() => {
5091
+ if (mermaidPending) return;
5092
+ mermaidPending = true;
5093
+ queueMicrotask(() => {
5094
+ mermaidPending = false;
5095
+ initMermaidBlocks();
5096
+ });
5097
+ });
5098
+ mo.observe(document.body, { childList: true, subtree: true });
5099
+ }
4791
5100
  });
4792
5101
 
4793
5102
  loadSidebarState();
@@ -4800,6 +5109,42 @@ initSidebarResize();
4800
5109
  loadPanelWidths();
4801
5110
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
4802
5111
  initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
5112
+
5113
+ const msgContentEl = document.getElementById('message-panel-content');
5114
+ const jumpLatestBtn = document.createElement('button');
5115
+ jumpLatestBtn.id = 'msg-jump-latest';
5116
+ jumpLatestBtn.className = 'msg-jump-latest';
5117
+ jumpLatestBtn.style.display = 'none';
5118
+ jumpLatestBtn.textContent = '\u2193 Latest';
5119
+ jumpLatestBtn.onclick = function () {
5120
+ msgContentEl.scrollTop = msgContentEl.scrollHeight;
5121
+ msgUserScrolledUp = false;
5122
+ this.style.display = 'none';
5123
+ };
5124
+ msgContentEl.parentElement.appendChild(jumpLatestBtn);
5125
+
5126
+ let msgScrollThrottled = false;
5127
+ msgContentEl.addEventListener('scroll', () => {
5128
+ if (msgScrollThrottled) return;
5129
+ msgScrollThrottled = true;
5130
+ requestAnimationFrame(() => {
5131
+ msgScrollThrottled = false;
5132
+ const el = msgContentEl;
5133
+ if (el.scrollTop === 0 && msgHasMore && !msgLoadingMore) {
5134
+ loadOlderMessages();
5135
+ }
5136
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
5137
+ msgUserScrolledUp = !nearBottom;
5138
+ jumpLatestBtn.style.display = msgUserScrolledUp ? '' : 'none';
5139
+ });
5140
+ });
5141
+ // Load older messages on wheel-up when content doesn't overflow
5142
+ msgContentEl.addEventListener('wheel', function (e) {
5143
+ if (e.deltaY < 0 && this.scrollTop === 0 && msgHasMore && !msgLoadingMore) {
5144
+ loadOlderMessages();
5145
+ }
5146
+ });
5147
+
4803
5148
  fetch('/api/version')
4804
5149
  .then((r) => r.json())
4805
5150
  .then((d) => {
@@ -4838,6 +5183,10 @@ fetchSessions().then(async () => {
4838
5183
  }
4839
5184
  if (urlState.messages && currentSessionId) {
4840
5185
  toggleMessagePanel();
5186
+ // Re-render after panel layout settles so scroll dimensions are correct
5187
+ requestAnimationFrame(() => {
5188
+ if (currentMessages.length) renderMessages(currentMessages);
5189
+ });
4841
5190
  }
4842
5191
  });
4843
5192