claude-code-kanban 2.1.0 → 2.2.0-rc.10

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
@@ -21,6 +21,7 @@ let messagePanelOpen = false;
21
21
  let lastMessagesHash = '';
22
22
  let currentMessages = [];
23
23
  let agentDurationInterval = null;
24
+ let agentPollInterval = null;
24
25
  let selectedTaskId = null;
25
26
  let selectedSessionId = null;
26
27
  let focusZone = 'board'; // 'board' | 'sidebar'
@@ -29,6 +30,8 @@ let selectedSessionKbId = null;
29
30
  let sessionJustSelected = false;
30
31
  let agentLogMode = null;
31
32
  let agentLogSSE = null;
33
+ let currentProjectPath = null;
34
+ let currentProjectSessionIds = [];
32
35
 
33
36
  function getUrlState() {
34
37
  const params = new URLSearchParams(window.location.search);
@@ -41,12 +44,14 @@ function getUrlState() {
41
44
  owner: params.get('owner'),
42
45
  search: params.get('search'),
43
46
  messages: params.get('messages') === '1',
47
+ projectView: params.get('projectView'),
44
48
  };
45
49
  }
46
50
 
47
51
  function updateUrl() {
48
52
  const params = new URLSearchParams();
49
53
  if (viewMode === 'all') params.set('view', 'all');
54
+ if (viewMode === 'project' && currentProjectPath) params.set('projectView', btoa(currentProjectPath));
50
55
  if (currentSessionId) params.set('session', currentSessionId);
51
56
  if (sessionFilter !== 'active') params.set('filter', sessionFilter);
52
57
  if (sessionLimit !== '20') params.set('limit', sessionLimit);
@@ -70,6 +75,8 @@ function resetState() {
70
75
  viewMode = 'all';
71
76
  if (agentLogMode) exitAgentLogMode();
72
77
  currentSessionId = null;
78
+ currentProjectPath = null;
79
+ currentProjectSessionIds = [];
73
80
  const searchInput = document.getElementById('search-input');
74
81
  if (searchInput) searchInput.value = '';
75
82
  document.getElementById('search-clear-btn')?.classList.remove('visible');
@@ -106,9 +113,11 @@ let lastTasksHash = '';
106
113
 
107
114
  //#region DATA_FETCHING
108
115
  async function fetchSessions() {
109
- console.log('[fetchSessions] Starting...');
110
116
  try {
111
- const pinnedParam = pinnedSessionIds.size > 0 ? `&pinned=${[...pinnedSessionIds].join(',')}` : '';
117
+ const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
118
+ if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
119
+ if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
120
+ const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
112
121
  const [newSessions, newTasks] = await Promise.all([
113
122
  fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}`).then((r) => r.json()),
114
123
  fetch('/api/tasks/all').then((r) => r.json()),
@@ -117,7 +126,6 @@ async function fetchSessions() {
117
126
  const sessionsHash = JSON.stringify(newSessions);
118
127
  const tasksHash = JSON.stringify(newTasks);
119
128
  if (sessionsHash === lastSessionsHash && tasksHash === lastTasksHash) {
120
- console.log('[fetchSessions] No changes, skipping render');
121
129
  return;
122
130
  }
123
131
  lastSessionsHash = sessionsHash;
@@ -125,9 +133,7 @@ async function fetchSessions() {
125
133
 
126
134
  sessions = newSessions;
127
135
  allTasksCache = newTasks;
128
- console.log('[fetchSessions] Sessions loaded:', sessions.length);
129
136
  renderSessions();
130
- console.log('[fetchSessions] Render complete');
131
137
  renderLiveUpdatesFromCache();
132
138
  } catch (error) {
133
139
  console.error('Failed to fetch sessions:', error);
@@ -410,6 +416,7 @@ let lastCurrentTasksHash = '';
410
416
  async function fetchTasks(sessionId) {
411
417
  try {
412
418
  viewMode = 'session';
419
+ document.getElementById('message-toggle')?.style.removeProperty('display');
413
420
  const res = await fetch(`/api/sessions/${sessionId}`);
414
421
 
415
422
  let newTasks;
@@ -423,7 +430,6 @@ async function fetchTasks(sessionId) {
423
430
 
424
431
  const hash = JSON.stringify(newTasks);
425
432
  if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
426
- console.log('[fetchTasks] No changes, skipping render');
427
433
  return;
428
434
  }
429
435
  lastCurrentTasksHash = hash;
@@ -432,6 +438,12 @@ async function fetchTasks(sessionId) {
432
438
  if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
433
439
  if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
434
440
  closeScratchpad();
441
+ if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
442
+ revealedPlanSessionId = null;
443
+ }
444
+ if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
445
+ revealedStorageSessionId = null;
446
+ }
435
447
  currentSessionId = sessionId;
436
448
  currentPins = loadPins(sessionId);
437
449
  ownerFilter = '';
@@ -483,6 +495,101 @@ async function fetchAgents(sessionId) {
483
495
  }
484
496
  }
485
497
 
498
+ async function fetchProjectView(projectPath) {
499
+ viewMode = 'project';
500
+ currentProjectPath = projectPath;
501
+ currentSessionId = null;
502
+ currentMessages = [];
503
+ lastMessagesHash = '';
504
+ if (messagePanelOpen) toggleMessagePanel();
505
+ document.getElementById('message-toggle')?.style.setProperty('display', 'none');
506
+ const msgContent = document.getElementById('message-panel-content');
507
+ if (msgContent) msgContent.innerHTML = '';
508
+ const msgPinned = document.getElementById('message-panel-pinned');
509
+ if (msgPinned) msgPinned.innerHTML = '';
510
+ const projectSessions = sessions.filter((s) => s.project === projectPath);
511
+ currentProjectSessionIds = projectSessions.map((s) => s.id);
512
+ const activeSessionIds = projectSessions.filter((s) => isSessionActive(s) || isAnyPinned(s.id)).map((s) => s.id);
513
+
514
+ const encoded = btoa(projectPath);
515
+ const [tasksResult, agentResults] = await Promise.all([
516
+ fetch(`/api/projects/${encodeURIComponent(encoded)}/tasks`)
517
+ .then((r) => r.json())
518
+ .catch((e) => {
519
+ console.error('[fetchProjectView] tasks:', e);
520
+ return [];
521
+ }),
522
+ Promise.all(
523
+ activeSessionIds.map((id) =>
524
+ fetch(`/api/sessions/${id}/agents`)
525
+ .then((r) => r.json())
526
+ .catch(() => ({ agents: [] })),
527
+ ),
528
+ ),
529
+ ]);
530
+ currentTasks = tasksResult;
531
+ const seen = new Set();
532
+ currentAgents = [];
533
+ const mergedColors = {};
534
+ let mergedWaiting = null;
535
+ for (let i = 0; i < agentResults.length; i++) {
536
+ const r = agentResults[i];
537
+ const sid = activeSessionIds[i];
538
+ const agents = r.agents || (Array.isArray(r) ? r : []);
539
+ for (const a of agents) {
540
+ if (a.agentId && !seen.has(a.agentId)) {
541
+ seen.add(a.agentId);
542
+ a._sourceSessionId = sid;
543
+ currentAgents.push(a);
544
+ }
545
+ }
546
+ if (r.teamColors) Object.assign(mergedColors, r.teamColors);
547
+ if (r.waitingForUser && !mergedWaiting) mergedWaiting = r.waitingForUser;
548
+ }
549
+ currentWaiting = mergedWaiting;
550
+ Object.assign(teamColorMap, mergedColors);
551
+
552
+ renderProjectView();
553
+ renderAgentFooter();
554
+ renderKanban();
555
+ updateUrl();
556
+ }
557
+
558
+ async function refreshProjectAgents() {
559
+ if (!currentProjectPath) return;
560
+ const projectSessions = sessions.filter((s) => s.project === currentProjectPath);
561
+ const activeSessionIds = projectSessions.filter((s) => isSessionActive(s) || isAnyPinned(s.id)).map((s) => s.id);
562
+ const agentResults = await Promise.all(
563
+ activeSessionIds.map((id) =>
564
+ fetch(`/api/sessions/${id}/agents`)
565
+ .then((r) => r.json())
566
+ .catch(() => ({ agents: [] })),
567
+ ),
568
+ );
569
+ const seen = new Set();
570
+ currentAgents = [];
571
+ let mergedWaiting = null;
572
+ for (let i = 0; i < agentResults.length; i++) {
573
+ const r = agentResults[i];
574
+ const sid = activeSessionIds[i];
575
+ const agents = r.agents || (Array.isArray(r) ? r : []);
576
+ for (const a of agents) {
577
+ if (a.agentId && !seen.has(a.agentId)) {
578
+ seen.add(a.agentId);
579
+ a._sourceSessionId = sid;
580
+ currentAgents.push(a);
581
+ }
582
+ }
583
+ if (r.teamColors) Object.assign(teamColorMap, r.teamColors);
584
+ if (r.waitingForUser && !mergedWaiting) mergedWaiting = r.waitingForUser;
585
+ }
586
+ currentWaiting = mergedWaiting;
587
+ const hash = JSON.stringify({ agents: currentAgents, waiting: currentWaiting });
588
+ if (hash === lastAgentsHash) return;
589
+ lastAgentsHash = hash;
590
+ renderAgentFooter();
591
+ }
592
+
486
593
  //#endregion
487
594
 
488
595
  //#region MESSAGE_PANEL
@@ -500,15 +607,18 @@ function toggleMessagePanel() {
500
607
 
501
608
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
502
609
  async function viewAgentLog(agentId) {
503
- let agent = currentAgents.find((a) => a.agentId === agentId);
610
+ let agent = findAgentById(agentId);
504
611
  if (!agent && currentSessionId) {
505
612
  await fetchAgents(currentSessionId);
506
- agent = currentAgents.find((a) => a.agentId === agentId);
613
+ agent = findAgentById(agentId);
507
614
  }
508
615
  if (!agent) return;
509
- const shortId = agentId.length > 8 ? agentId.slice(0, 8) : agentId;
510
- agentLogMode = { agentId, sessionId: currentSessionId, agentType: agent.type || 'unknown' };
616
+ const resolvedId = agent.agentId;
617
+ const shortId = resolvedId.length > 8 ? resolvedId.slice(0, 8) : resolvedId;
618
+ const agentSessionId = agent._sourceSessionId || currentSessionId;
619
+ agentLogMode = { agentId: resolvedId, sessionId: agentSessionId, agentType: agent.type || 'unknown' };
511
620
  closeAgentModal();
621
+ document.getElementById('message-toggle')?.style.removeProperty('display');
512
622
  if (!messagePanelOpen) toggleMessagePanel();
513
623
  const header = document.querySelector('.message-panel-header h3');
514
624
  if (header) {
@@ -519,9 +629,9 @@ async function viewAgentLog(agentId) {
519
629
  agentLogSSE.close();
520
630
  agentLogSSE = null;
521
631
  }
522
- agentLogSSE = new EventSource(`/api/sessions/${agentLogMode.sessionId}/agents/${agentId}/messages/stream`);
632
+ agentLogSSE = new EventSource(`/api/sessions/${agentLogMode.sessionId}/agents/${resolvedId}/messages/stream`);
523
633
  agentLogSSE.addEventListener('agent-log-update', (e) => {
524
- if (!agentLogMode || agentLogMode.agentId !== agentId) return;
634
+ if (!agentLogMode || agentLogMode.agentId !== resolvedId) return;
525
635
  try {
526
636
  const data = JSON.parse(e.data);
527
637
  currentMessages = data.messages;
@@ -537,6 +647,11 @@ function exitAgentLogMode() {
537
647
  agentLogSSE.close();
538
648
  agentLogSSE = null;
539
649
  }
650
+ if (viewMode === 'project') {
651
+ if (messagePanelOpen) toggleMessagePanel();
652
+ document.getElementById('message-toggle')?.style.setProperty('display', 'none');
653
+ return;
654
+ }
540
655
  const header = document.querySelector('.message-panel-header h3');
541
656
  if (header) header.textContent = 'Session Log';
542
657
  lastMessagesHash = '';
@@ -648,17 +763,16 @@ function renderPinnedSection() {
648
763
  <div class="msg-body"><div class="msg-text">${escapeHtml(p.tool || '')}${toolDetail}</div><div class="msg-time">${formatDate(p.timestamp)}</div></div>${pinnedAgentLogBtn}${unpin}
649
764
  </div>`;
650
765
  } else if (p.type === 'agent') {
651
- const agentClick = `onclick="showAgentModal('${escapeHtml(p.agentId)}')" style="cursor:pointer"`;
652
766
  const agentLogBtn = agentLogButton(p.agentId);
653
767
  const msgTrunc = p.lastMessage
654
768
  ? escapeHtml(
655
- stripAnsi(p.lastMessage.trim())
769
+ stripAnsi(stripTeammateWrapper(p.lastMessage.trim()))
656
770
  .replace(/[\r\n]+/g, ' ')
657
771
  .slice(0, 60),
658
772
  )
659
773
  : '';
660
774
  const agentDetail = msgTrunc ? ` <span style="color:var(--text-muted)">${msgTrunc}</span>` : '';
661
- return `<div class="msg-item msg-tool" ${agentClick}>
775
+ return `<div class="msg-item msg-tool" ${click}>
662
776
  ${MSG_ICON_TOOL}
663
777
  <div class="msg-body"><div class="msg-text">${escapeHtml(p.agentType || 'Agent')}${agentDetail}</div><div class="msg-time">${formatDate(p.timestamp)}</div></div>${agentLogBtn}${unpin}
664
778
  </div>`;
@@ -909,27 +1023,8 @@ function showPinnedMsgDetail(pinIdx) {
909
1023
  }
910
1024
  currentMsgDetailIdx = null;
911
1025
  currentPinDetailId = pin.id;
1026
+ _renderPinToDetail(pin);
912
1027
  const body = document.getElementById('msg-detail-body');
913
- const agentBtn = document.getElementById('msg-detail-agent-btn');
914
- if (pin.type === 'tool_use') {
915
- document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
916
- const fullText = pin.fullDetail || pin.detail || '';
917
- const pinParamsHtml = renderToolParamsHtml(pin.params);
918
- const pinResultHtml = renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull);
919
- const pinDetailEscaped = escapeHtml(fullText);
920
- const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
921
- body.innerHTML =
922
- (fullText ? `<pre class="msg-detail-pre">${pinDetailRendered}</pre>` : '<em>No details</em>') +
923
- pinParamsHtml +
924
- pinResultHtml;
925
- agentBtn.style.display = 'none';
926
- } else {
927
- const text = stripAnsi(pin.fullText || pin.text || '');
928
- document.getElementById('msg-detail-title').textContent = pin.type === 'assistant' ? 'Claude' : 'User';
929
- agentBtn.style.display = 'none';
930
- body.innerHTML = renderMarkdown(text);
931
- }
932
- document.getElementById('msg-detail-meta').textContent = formatDate(pin.timestamp);
933
1028
  const pinModal = document.getElementById('msg-detail-modal').querySelector('.modal');
934
1029
  autoSizeModal(pinModal, body);
935
1030
  const pinBtn = document.getElementById('msg-detail-pin-btn');
@@ -963,6 +1058,7 @@ function togglePinnedCollapse() {
963
1058
 
964
1059
  //#region PINNING
965
1060
  let pinnedSessionIds = new Set();
1061
+ let stickySessionIds = new Set();
966
1062
 
967
1063
  function loadPinnedSessions() {
968
1064
  try {
@@ -972,18 +1068,72 @@ function loadPinnedSessions() {
972
1068
  }
973
1069
  }
974
1070
 
1071
+ function loadStickySessions() {
1072
+ try {
1073
+ return new Set(JSON.parse(localStorage.getItem('sticky-sessions')) || []);
1074
+ } catch {
1075
+ return new Set();
1076
+ }
1077
+ }
1078
+
975
1079
  function savePinnedSessions() {
976
1080
  localStorage.setItem('pinned-sessions', JSON.stringify([...pinnedSessionIds]));
1081
+ localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
977
1082
  }
978
1083
 
1084
+ // unpinned → pinned → sticky → unpinned
979
1085
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
980
1086
  function toggleSessionPin(sessionId) {
981
- if (pinnedSessionIds.has(sessionId)) pinnedSessionIds.delete(sessionId);
982
- else pinnedSessionIds.add(sessionId);
1087
+ if (stickySessionIds.has(sessionId)) {
1088
+ stickySessionIds.delete(sessionId);
1089
+ pinnedSessionIds.delete(sessionId);
1090
+ } else if (pinnedSessionIds.has(sessionId)) {
1091
+ pinnedSessionIds.delete(sessionId);
1092
+ stickySessionIds.add(sessionId);
1093
+ } else {
1094
+ pinnedSessionIds.add(sessionId);
1095
+ }
983
1096
  savePinnedSessions();
984
1097
  renderSessions();
985
1098
  }
986
1099
 
1100
+ function getSessionPinState(sessionId) {
1101
+ if (stickySessionIds.has(sessionId)) return 'sticky';
1102
+ if (pinnedSessionIds.has(sessionId)) return 'pinned';
1103
+ return 'none';
1104
+ }
1105
+
1106
+ function isAnyPinned(sessionId) {
1107
+ return pinnedSessionIds.has(sessionId) || stickySessionIds.has(sessionId);
1108
+ }
1109
+
1110
+ function _renderPinToDetail(pin) {
1111
+ const body = document.getElementById('msg-detail-body');
1112
+ const agentBtn = document.getElementById('msg-detail-agent-btn');
1113
+ agentBtn.style.display = 'none';
1114
+ if (pin.type === 'tool_use') {
1115
+ document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
1116
+ const fullText = pin.fullDetail || pin.detail || '';
1117
+ const pinParamsHtml = renderToolParamsHtml(pin.params);
1118
+ const pinResultHtml = renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull);
1119
+ const pinDetailEscaped = escapeHtml(fullText);
1120
+ const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
1121
+ body.innerHTML =
1122
+ (fullText ? `<pre class="msg-detail-pre">${pinDetailRendered}</pre>` : '<em>No details</em>') +
1123
+ pinParamsHtml +
1124
+ pinResultHtml;
1125
+ } else if (pin.type === 'agent') {
1126
+ document.getElementById('msg-detail-title').textContent = pin.agentType || 'Agent';
1127
+ const lastMsg = stripAnsi(pin.lastMessage || '');
1128
+ body.innerHTML = lastMsg ? renderMarkdown(lastMsg) : '<em>No agent message</em>';
1129
+ } else {
1130
+ const text = stripAnsi(pin.fullText || pin.text || '');
1131
+ document.getElementById('msg-detail-title').textContent = pin.type === 'assistant' ? 'Claude' : 'User';
1132
+ body.innerHTML = renderMarkdown(text);
1133
+ }
1134
+ document.getElementById('msg-detail-meta').textContent = formatDate(pin.timestamp);
1135
+ }
1136
+
987
1137
  const SESSION_PIN_SVG = PIN_SVG.replace('width="14" height="14"', 'width="12" height="12"');
988
1138
 
989
1139
  //#endregion
@@ -1400,14 +1550,17 @@ function renderAgentFooter() {
1400
1550
  for (const group of Object.values(byType)) {
1401
1551
  group.sort((a, b) => new Date(a.startedAt || 0) - new Date(b.startedAt || 0));
1402
1552
  filtered.push(group[0]);
1553
+ let maxStop = group[0].stoppedAt ? new Date(group[0].stoppedAt).getTime() : Infinity;
1403
1554
  for (let i = 1; i < group.length; i++) {
1404
- const prev = group[i - 1];
1405
- const prevStop = prev.stoppedAt ? new Date(prev.stoppedAt).getTime() : Infinity;
1406
- const curStart = new Date(group[i].startedAt || 0).getTime();
1407
- const overlapped = curStart < prevStop;
1408
- const reSpawn = curStart - prevStop > 30000;
1409
- const isActive = group[i].status === 'active' || group[i].status === 'idle';
1410
- if (overlapped || reSpawn || isActive) filtered.push(group[i]);
1555
+ const cur = group[i];
1556
+ const hasContent = cur.prompt || cur.lastMessage;
1557
+ const curStart = new Date(cur.startedAt || 0).getTime();
1558
+ const overlapped = curStart < maxStop;
1559
+ const reSpawn = curStart - maxStop > 30000;
1560
+ const isActive = cur.status === 'active' || cur.status === 'idle';
1561
+ if (overlapped || reSpawn || isActive || hasContent) filtered.push(cur);
1562
+ const curStop = cur.stoppedAt ? new Date(cur.stoppedAt).getTime() : Infinity;
1563
+ if (curStop > maxStop) maxStop = curStop;
1411
1564
  }
1412
1565
  }
1413
1566
  // Sort: active/idle first, then by updatedAt desc
@@ -1426,6 +1579,8 @@ function renderAgentFooter() {
1426
1579
  footer.classList.remove('visible');
1427
1580
  clearInterval(agentDurationInterval);
1428
1581
  agentDurationInterval = null;
1582
+ clearInterval(agentPollInterval);
1583
+ agentPollInterval = null;
1429
1584
  return;
1430
1585
  }
1431
1586
 
@@ -1454,7 +1609,7 @@ function renderAgentFooter() {
1454
1609
  : a.status === 'idle'
1455
1610
  ? `idle · ${formatDuration(elapsed)}`
1456
1611
  : `active · ${formatDuration(elapsed)}`;
1457
- const promptTrimmed = stripAnsi((a.prompt || '').trim()).replace(/[\r\n]+/g, ' ');
1612
+ const promptTrimmed = stripAnsi(stripTeammateWrapper((a.prompt || '').trim())).replace(/[\r\n]+/g, ' ');
1458
1613
  const promptTrunc = promptTrimmed.length > 60 ? `${promptTrimmed.substring(0, 60)}…` : promptTrimmed;
1459
1614
  const msgHtml = promptTrunc
1460
1615
  ? `<div class="agent-message" title="${escapeHtml(promptTrimmed)}">${escapeHtml(promptTrunc)}</div>`
@@ -1476,8 +1631,19 @@ function renderAgentFooter() {
1476
1631
  clearInterval(agentDurationInterval);
1477
1632
  if (visible.some((a) => a.status === 'active' || a.status === 'idle')) {
1478
1633
  agentDurationInterval = setInterval(() => renderAgentFooter(), 1000);
1634
+ if (!agentPollInterval) {
1635
+ agentPollInterval = setInterval(() => {
1636
+ if (viewMode === 'project' && currentProjectPath) {
1637
+ refreshProjectAgents();
1638
+ } else if (currentSessionId) {
1639
+ fetchAgents(currentSessionId);
1640
+ }
1641
+ }, 3000);
1642
+ }
1479
1643
  } else {
1480
1644
  agentDurationInterval = setInterval(() => renderAgentFooter(), 10000);
1645
+ clearInterval(agentPollInterval);
1646
+ agentPollInterval = null;
1481
1647
  }
1482
1648
  }
1483
1649
 
@@ -1531,9 +1697,19 @@ async function dismissAgent(agentId) {
1531
1697
  }
1532
1698
  }
1533
1699
 
1700
+ function findAgentById(agentId) {
1701
+ let agent = currentAgents.find((a) => a.agentId === agentId);
1702
+ if (!agent) {
1703
+ const atIdx = agentId.indexOf('@');
1704
+ const memberName = atIdx > 0 ? agentId.substring(0, atIdx) : null;
1705
+ if (memberName) agent = currentAgents.find((a) => a.type === memberName);
1706
+ }
1707
+ return agent || null;
1708
+ }
1709
+
1534
1710
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1535
1711
  function showAgentModal(agentId) {
1536
- const agent = currentAgents.find((a) => a.agentId === agentId);
1712
+ const agent = findAgentById(agentId);
1537
1713
  if (!agent) return;
1538
1714
  currentAgentModalId = agentId;
1539
1715
  const modal = document.getElementById('agent-modal');
@@ -1552,6 +1728,8 @@ function showAgentModal(agentId) {
1552
1728
  ['Agent ID', `<code style="font-size:12px;color:var(--text-tertiary)">${escapeHtml(agent.agentId)}</code>`],
1553
1729
  ['Duration', formatDuration(elapsed)],
1554
1730
  ];
1731
+ if (agent.model)
1732
+ rows.push(['Model', `<code style="font-size:12px;color:var(--text-tertiary)">${escapeHtml(agent.model)}</code>`]);
1555
1733
  if (started) rows.push(['Started', started.toLocaleTimeString()]);
1556
1734
  if (stopped) rows.push(['Stopped', stopped.toLocaleTimeString()]);
1557
1735
 
@@ -1567,7 +1745,7 @@ function showAgentModal(agentId) {
1567
1745
  .join('') +
1568
1746
  `</table>`;
1569
1747
 
1570
- const promptText = agentMsg?.agentPrompt || agent.prompt || null;
1748
+ const promptText = stripTeammateWrapper(agentMsg?.agentPrompt || agent.prompt || null);
1571
1749
  const responseText = agent.lastMessage ? stripAnsi(agent.lastMessage.trim()) : null;
1572
1750
  _agentModalPromptText = promptText;
1573
1751
  _agentModalResponseText = responseText;
@@ -1599,6 +1777,25 @@ function closeAgentModal() {
1599
1777
  //#endregion
1600
1778
 
1601
1779
  //#region RENDERING
1780
+ let revealedPlanSessionId = null;
1781
+ let revealedStorageSessionId = null;
1782
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1783
+ async function revealPlanSession(planSessionId) {
1784
+ if (revealedPlanSessionId === planSessionId) {
1785
+ revealedPlanSessionId = null;
1786
+ renderSessions();
1787
+ return;
1788
+ }
1789
+ revealedPlanSessionId = planSessionId;
1790
+ if (!sessions.some((s) => s.id === planSessionId)) {
1791
+ lastSessionsHash = '';
1792
+ await fetchSessions();
1793
+ }
1794
+ await fetchTasks(planSessionId);
1795
+ const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(planSessionId)}"]`);
1796
+ if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1797
+ }
1798
+
1602
1799
  async function showAllTasks() {
1603
1800
  try {
1604
1801
  viewMode = 'all';
@@ -1659,32 +1856,26 @@ function renderSessions() {
1659
1856
  let filteredSessions = sessions;
1660
1857
  if (sessionFilter === 'active') {
1661
1858
  const ACTIVE_PLAN_MS = 15 * 60 * 1000;
1662
- const RECENTLY_MODIFIED_MS = 5 * 60 * 1000;
1663
1859
  const now = Date.now();
1664
1860
  const activeSessionIds = new Set();
1665
1861
  filteredSessions = filteredSessions.filter((s) => {
1666
1862
  const isActive =
1667
1863
  s.hasMessages &&
1668
- (s.pending > 0 ||
1669
- s.inProgress > 0 ||
1864
+ ((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
1670
1865
  s.hasActiveAgents ||
1671
1866
  s.hasWaitingForUser ||
1672
1867
  s.hasRecentLog ||
1673
- (s.hasPlan && !s.planImplementationSessionId && now - new Date(s.modifiedAt).getTime() <= ACTIVE_PLAN_MS) ||
1674
- now - new Date(s.modifiedAt).getTime() <= RECENTLY_MODIFIED_MS);
1868
+ (s.hasPlan && !s.planImplementationSessionId && now - new Date(s.modifiedAt).getTime() <= ACTIVE_PLAN_MS));
1675
1869
  if (isActive) activeSessionIds.add(s.id);
1676
1870
  return isActive;
1677
1871
  });
1678
- // Include plan sessions whose implementation is active
1679
- const planSessions = sessions.filter(
1680
- (s) =>
1681
- s.planImplementationSessionId &&
1682
- activeSessionIds.has(s.planImplementationSessionId) &&
1683
- !activeSessionIds.has(s.id),
1684
- );
1685
- if (planSessions.length) {
1686
- filteredSessions = filteredSessions.concat(planSessions);
1687
- filteredSessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
1872
+ if (revealedPlanSessionId && !filteredSessions.some((s) => s.id === revealedPlanSessionId)) {
1873
+ const planSession = sessions.find((s) => s.id === revealedPlanSessionId);
1874
+ if (planSession) filteredSessions.push(planSession);
1875
+ }
1876
+ if (revealedStorageSessionId && !filteredSessions.some((s) => s.id === revealedStorageSessionId)) {
1877
+ const storageSession = sessions.find((s) => s.id === revealedStorageSessionId);
1878
+ if (storageSession) filteredSessions.push(storageSession);
1688
1879
  }
1689
1880
  }
1690
1881
  if (filterProject) {
@@ -1715,10 +1906,10 @@ function renderSessions() {
1715
1906
  });
1716
1907
  }
1717
1908
 
1718
- // Always include pinned sessions even if they don't match filters
1719
- if (pinnedSessionIds.size > 0 && !searchQuery) {
1909
+ // Always include pinned/sticky sessions even if they don't match filters
1910
+ if ((pinnedSessionIds.size > 0 || stickySessionIds.size > 0) && !searchQuery) {
1720
1911
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
1721
- const missingPinned = sessions.filter((s) => pinnedSessionIds.has(s.id) && !filteredIds.has(s.id));
1912
+ const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id));
1722
1913
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
1723
1914
  }
1724
1915
 
@@ -1772,11 +1963,13 @@ function renderSessions() {
1772
1963
  const isTeam = session.isTeam;
1773
1964
  const memberCount = session.memberCount || 0;
1774
1965
 
1775
- const isSessionPinned = pinnedSessionIds.has(session.id);
1966
+ const pinState = getSessionPinState(session.id);
1967
+ const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
1968
+ const pinTitle = pinState === 'sticky' ? 'Unpin' : pinState === 'pinned' ? 'Sticky pin' : 'Pin';
1776
1969
  const showCtx = !!session.contextStatus;
1777
1970
  return `
1778
1971
  <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}">
1779
- <span class="session-pin-btn${isSessionPinned ? ' pinned' : ''}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${isSessionPinned ? 'Unpin' : 'Pin'} session">${SESSION_PIN_SVG}</span>
1972
+ <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${SESSION_PIN_SVG}</span>
1780
1973
  <div class="session-name">${escapeHtml(primaryName)}</div>
1781
1974
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
1782
1975
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
@@ -1784,10 +1977,11 @@ function renderSessions() {
1784
1977
  <div class="session-progress">
1785
1978
  <span class="session-indicators">
1786
1979
  ${isTeam ? `<span class="team-badge" title="${memberCount} team members"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>${memberCount}</span>` : ''}
1980
+ ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>` : ''}
1787
1981
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
1788
1982
  ${session.hasPlan ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
1789
1983
  ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
1790
- ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to view plan session" onclick="event.stopPropagation(); fetchTasks('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
1984
+ ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
1791
1985
  ${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
1792
1986
  ${isLive ? '<span class="pulse"></span>' : ''}
1793
1987
  </span>
@@ -1812,8 +2006,44 @@ function renderSessions() {
1812
2006
  ungrouped.push(session);
1813
2007
  }
1814
2008
  }
1815
- if (pinnedSessionIds.size > 0) {
1816
- const pinSort = (a, b) => (pinnedSessionIds.has(b.id) ? 1 : 0) - (pinnedSessionIds.has(a.id) ? 1 : 0);
2009
+ const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
2010
+ const renderGroupSessions = (sessions, pinKey) => {
2011
+ if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
2012
+ const gPinned = sessions.filter((s) => pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id));
2013
+ if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
2014
+ const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
2015
+ const gUnpinned = sessions.filter(
2016
+ (s) => !pinnedSessionIds.has(s.id) || isSessionActive(s) || stickySessionIds.has(s.id),
2017
+ );
2018
+ const pinCollapsed = collapsedProjectGroups.has(pinKey);
2019
+ if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
2020
+ return (
2021
+ '<div class="pinned-sub-section">' +
2022
+ '<div class="pinned-sub-header' +
2023
+ (pinCollapsed ? ' collapsed' : '') +
2024
+ '" data-group-path="' +
2025
+ escapeHtml(pinKey) +
2026
+ '">' +
2027
+ '<svg class="group-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
2028
+ '<span class="pinned-sub-label">Pinned</span>' +
2029
+ '<span class="group-count">' +
2030
+ gIdlePinned.length +
2031
+ '</span>' +
2032
+ '<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">&times;</span>' +
2033
+ '</div>' +
2034
+ '<div class="pinned-sub-items' +
2035
+ (pinCollapsed ? ' collapsed' : '') +
2036
+ '">' +
2037
+ gIdlePinned.map(renderSessionCard).join('') +
2038
+ '</div>' +
2039
+ '</div>' +
2040
+ gUnpinned.map(renderSessionCard).join('')
2041
+ );
2042
+ };
2043
+ if (!groupPinned && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2044
+ const pinWeight = (s) =>
2045
+ stickySessionIds.has(s.id) ? 2 : pinnedSessionIds.has(s.id) && !isSessionActive(s) ? 1 : 0;
2046
+ const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
1817
2047
  for (const [, arr] of groups) arr.sort(pinSort);
1818
2048
  ungrouped.sort(pinSort);
1819
2049
  }
@@ -1833,6 +2063,15 @@ function renderSessions() {
1833
2063
  const sortedGroups = stableGroupOrder.map((p) => [p, groups.get(p)]);
1834
2064
 
1835
2065
  let html = '';
2066
+ if (!groupPinned && pinnedSessionIds.size > 0) {
2067
+ const hasPinnedInView = filteredSessions.some((s) => pinnedSessionIds.has(s.id));
2068
+ if (hasPinnedInView) {
2069
+ html += `<div class="pinned-regroup-banner">
2070
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="3"/><path d="M5 10l7-7 7 7"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
2071
+ Group pinned sessions
2072
+ </div>`;
2073
+ }
2074
+ }
1836
2075
  for (const [projectPath, projectSessions] of sortedGroups) {
1837
2076
  const folderName = projectPath.split(/[/\\]/).pop();
1838
2077
  const isCollapsed = collapsedProjectGroups.has(projectPath);
@@ -1850,13 +2089,13 @@ function renderSessions() {
1850
2089
  <svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
1851
2090
  <span class="group-name">${escapeHtml(folderName)}</span>
1852
2091
  <span class="group-count">${projectSessions.length}</span>
1853
- <span class="group-path-toggle" data-group-action="toggle-path" title="Show full path">
1854
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>
2092
+ <span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
2093
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
1855
2094
  </span>
1856
2095
  </div>
1857
2096
  <div class="project-group-breadcrumb" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
1858
2097
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
1859
- ${projectSessions.map(renderSessionCard).join('')}
2098
+ ${renderGroupSessions(projectSessions, `__pinned_${projectPath}__`)}
1860
2099
  </div>
1861
2100
  `;
1862
2101
  }
@@ -1870,7 +2109,7 @@ function renderSessions() {
1870
2109
  <span class="group-count">${ungrouped.length}</span>
1871
2110
  </div>
1872
2111
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
1873
- ${ungrouped.map(renderSessionCard).join('')}
2112
+ ${renderGroupSessions(ungrouped, '__pinned___ungrouped__')}
1874
2113
  </div>
1875
2114
  `;
1876
2115
  } else {
@@ -1879,19 +2118,28 @@ function renderSessions() {
1879
2118
 
1880
2119
  sessionsList.innerHTML = html;
1881
2120
  } else {
1882
- const pinned = filteredSessions.filter((s) => pinnedSessionIds.has(s.id));
1883
- const rest = filteredSessions.filter((s) => !pinnedSessionIds.has(s.id));
2121
+ const sticky = filteredSessions.filter((s) => stickySessionIds.has(s.id));
2122
+ const idlePinned = filteredSessions.filter((s) => pinnedSessionIds.has(s.id) && !isSessionActive(s));
2123
+ const rest = filteredSessions.filter(
2124
+ (s) =>
2125
+ (!pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id)) ||
2126
+ (pinnedSessionIds.has(s.id) && isSessionActive(s)),
2127
+ );
1884
2128
  let html = '';
1885
- if (pinned.length > 0) {
1886
- const isCollapsed = collapsedProjectGroups.has('__pinned__');
2129
+ if (sticky.length > 0) {
2130
+ html += sticky.map(renderSessionCard).join('');
2131
+ }
2132
+ const isCollapsed = collapsedProjectGroups.has('__pinned__');
2133
+ const hasPinned = pinnedSessionIds.size > 0 && filteredSessions.some((s) => pinnedSessionIds.has(s.id));
2134
+ if (idlePinned.length > 0 || (hasPinned && isCollapsed)) {
1887
2135
  html += `
1888
2136
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__pinned__">
1889
2137
  <svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
1890
2138
  <span class="group-name">Pinned</span>
1891
- <span class="group-count">${pinned.length}</span>
2139
+ <span class="group-count">${idlePinned.length}</span>
1892
2140
  </div>
1893
2141
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
1894
- ${pinned.map(renderSessionCard).join('')}
2142
+ ${idlePinned.map(renderSessionCard).join('')}
1895
2143
  </div>
1896
2144
  `;
1897
2145
  }
@@ -1969,12 +2217,37 @@ function renderSession() {
1969
2217
  renderSessions();
1970
2218
  }
1971
2219
 
2220
+ function renderProjectView() {
2221
+ noSession.style.display = 'none';
2222
+ sessionView.classList.add('visible');
2223
+
2224
+ const folderName = currentProjectPath ? currentProjectPath.split(/[/\\]/).pop() : 'Project';
2225
+ sessionTitle.textContent = folderName;
2226
+
2227
+ const metaParts = [`${currentProjectSessionIds.length} sessions`, `${currentTasks.length} tasks`];
2228
+ if (currentProjectPath) metaParts.push(currentProjectPath);
2229
+ sessionMeta.textContent = metaParts.join(' · ');
2230
+
2231
+ const completed = currentTasks.filter((t) => t.status === 'completed').length;
2232
+ const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
2233
+
2234
+ progressPercent.textContent = `${percent}%`;
2235
+ progressBar.style.width = `${percent}%`;
2236
+ const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
2237
+ progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
2238
+
2239
+ updateOwnerFilter();
2240
+ renderKanban();
2241
+ renderSessions();
2242
+ }
2243
+
1972
2244
  function renderTaskCard(task) {
1973
2245
  const isBlocked = task.blockedBy && task.blockedBy.length > 0;
1974
- const taskId = viewMode === 'all' ? `${task.sessionId?.slice(0, 4)}-${task.id}` : task.id;
2246
+ const useSlug = viewMode === 'all' || viewMode === 'project';
2247
+ const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
1975
2248
  const sessionLabel = viewMode === 'all' && task.sessionName ? task.sessionName : null;
1976
2249
  const statusClass = task.status.replace('_', '-');
1977
- const actualSessionId = task.sessionId || currentSessionId;
2250
+ const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
1978
2251
 
1979
2252
  return `
1980
2253
  <div
@@ -2112,7 +2385,9 @@ async function onColumnDrop(e) {
2112
2385
  return;
2113
2386
  }
2114
2387
  const { taskId, sessionId } = data;
2115
- const task = currentTasks.find((t) => t.id === taskId && (t.sessionId || currentSessionId) === sessionId);
2388
+ const task = currentTasks.find(
2389
+ (t) => t.id === taskId && (t._taskDir === sessionId || (t.sessionId || currentSessionId) === sessionId),
2390
+ );
2116
2391
  if (!task || task.status === newStatus) return;
2117
2392
  try {
2118
2393
  const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
@@ -2150,7 +2425,10 @@ function getSelectedCardInfo() {
2150
2425
  for (let ci = 0; ci < COLUMNS.length; ci++) {
2151
2426
  const cards = Array.from(COLUMNS[ci].el.querySelectorAll('.task-card'));
2152
2427
  for (let i = 0; i < cards.length; i++) {
2153
- if (cards[i].dataset.taskId === selectedTaskId) {
2428
+ if (
2429
+ cards[i].dataset.taskId === selectedTaskId &&
2430
+ (!selectedSessionId || cards[i].dataset.sessionId === selectedSessionId)
2431
+ ) {
2154
2432
  return { colIndex: ci, cardIndex: i, card: cards[i] };
2155
2433
  }
2156
2434
  }
@@ -2200,8 +2478,9 @@ function getKbId(el) {
2200
2478
  }
2201
2479
 
2202
2480
  function getGroupSessionsContainer(header) {
2481
+ const cls = header.classList.contains('pinned-sub-header') ? 'pinned-sub-items' : 'project-group-sessions';
2203
2482
  let el = header.nextElementSibling;
2204
- while (el && !el.classList.contains('project-group-sessions')) el = el.nextElementSibling;
2483
+ while (el && !el.classList.contains(cls)) el = el.nextElementSibling;
2205
2484
  return el;
2206
2485
  }
2207
2486
 
@@ -2213,7 +2492,10 @@ function getNavigableItems() {
2213
2492
  if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
2214
2493
  const container = getGroupSessionsContainer(el);
2215
2494
  if (container) {
2216
- for (const s of container.querySelectorAll('.session-item')) items.push(s);
2495
+ for (const s of container.querySelectorAll('.session-item')) {
2496
+ if (s.closest('.pinned-sub-items.collapsed')) continue;
2497
+ items.push(s);
2498
+ }
2217
2499
  }
2218
2500
  }
2219
2501
  } else if (el.classList.contains('session-item')) {
@@ -2410,7 +2692,9 @@ function getAvailableTasksOptions(currentTaskId = null) {
2410
2692
 
2411
2693
  //#region TASK_DETAIL
2412
2694
  async function showTaskDetail(taskId, sessionId = null) {
2413
- let task = currentTasks.find((t) => t.id === taskId && (!sessionId || t.sessionId === sessionId));
2695
+ let task = currentTasks.find(
2696
+ (t) => t.id === taskId && (!sessionId || t.sessionId === sessionId || t._taskDir === sessionId),
2697
+ );
2414
2698
 
2415
2699
  // If task not found in currentTasks, fetch it from the session
2416
2700
  if (!task && sessionId && sessionId !== 'undefined') {
@@ -2502,20 +2786,25 @@ async function showTaskDetail(taskId, sessionId = null) {
2502
2786
  </div>
2503
2787
  `;
2504
2788
 
2505
- // Setup button handlers
2789
+ // Setup button handlers (read-only in project view)
2506
2790
  const deleteBtn = document.getElementById('delete-task-btn');
2507
- deleteBtn.style.display = '';
2508
- deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
2791
+ const isProjectView = viewMode === 'project';
2792
+ deleteBtn.style.display = isProjectView ? 'none' : '';
2793
+ if (!isProjectView) deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
2509
2794
 
2510
- // Setup inline editing
2511
- const titleEl = detailContent.querySelector('.detail-title');
2512
- if (titleEl) {
2513
- titleEl.onclick = () => editTitle(titleEl, task, actualSessionId);
2514
- }
2795
+ const noteSection = detailContent.querySelector('.note-section');
2796
+ if (noteSection && isProjectView) noteSection.style.display = 'none';
2797
+
2798
+ if (!isProjectView) {
2799
+ const titleEl = detailContent.querySelector('.detail-title');
2800
+ if (titleEl) {
2801
+ titleEl.onclick = () => editTitle(titleEl, task, actualSessionId);
2802
+ }
2515
2803
 
2516
- const descEl = detailContent.querySelector('.detail-desc');
2517
- if (descEl) {
2518
- descEl.onclick = () => editDescription(descEl, task, actualSessionId);
2804
+ const descEl = detailContent.querySelector('.detail-desc');
2805
+ if (descEl) {
2806
+ descEl.onclick = () => editDescription(descEl, task, actualSessionId);
2807
+ }
2519
2808
  }
2520
2809
  }
2521
2810
 
@@ -2815,6 +3104,15 @@ const _scratchpadModal = document.getElementById('scratchpad-modal');
2815
3104
  const _scratchpadTextarea = document.getElementById('scratchpad-textarea');
2816
3105
  const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
2817
3106
 
3107
+ let _scratchpadKeyOverride = null;
3108
+
3109
+ function _scratchpadKey() {
3110
+ if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
3111
+ if (currentSessionId) return `scratchpad-${currentSessionId}`;
3112
+ if (currentProjectPath) return `scratchpad-project:${currentProjectPath}`;
3113
+ return null;
3114
+ }
3115
+
2818
3116
  function toggleScratchpad() {
2819
3117
  if (_scratchpadModal.classList.contains('visible')) {
2820
3118
  closeScratchpad();
@@ -2823,9 +3121,11 @@ function toggleScratchpad() {
2823
3121
  }
2824
3122
  }
2825
3123
 
2826
- function showScratchpad() {
2827
- if (!currentSessionId) return;
2828
- _scratchpadTextarea.value = localStorage.getItem(`scratchpad-${currentSessionId}`) || '';
3124
+ function showScratchpad(keyOverride) {
3125
+ _scratchpadKeyOverride = keyOverride || null;
3126
+ const key = _scratchpadKey();
3127
+ if (!key) return;
3128
+ _scratchpadTextarea.value = localStorage.getItem(key) || '';
2829
3129
  _scratchpadCharcount.textContent = `${_scratchpadTextarea.value.length} chars`;
2830
3130
  _scratchpadModal.classList.add('visible');
2831
3131
  _scratchpadTextarea.focus();
@@ -2837,12 +3137,19 @@ function closeScratchpad() {
2837
3137
  _scratchpadSaveTimer = null;
2838
3138
  }
2839
3139
  saveScratchpad();
3140
+ _scratchpadKeyOverride = null;
2840
3141
  _scratchpadModal.classList.remove('visible');
2841
3142
  }
2842
3143
 
2843
3144
  function saveScratchpad() {
2844
- if (!currentSessionId) return;
2845
- localStorage.setItem(`scratchpad-${currentSessionId}`, _scratchpadTextarea.value);
3145
+ const key = _scratchpadKey();
3146
+ if (!key) return;
3147
+ const val = _scratchpadTextarea.value;
3148
+ if (val.trim()) {
3149
+ localStorage.setItem(key, val);
3150
+ } else {
3151
+ localStorage.removeItem(key);
3152
+ }
2846
3153
  }
2847
3154
 
2848
3155
  _scratchpadTextarea.addEventListener('input', () => {
@@ -2856,6 +3163,374 @@ _scratchpadTextarea.addEventListener('input', () => {
2856
3163
 
2857
3164
  //#endregion
2858
3165
 
3166
+ //#region STORAGE_MANAGER
3167
+
3168
+ function _getStorageTotalSize() {
3169
+ let bytes = 0;
3170
+ for (let i = 0; i < localStorage.length; i++) {
3171
+ const k = localStorage.key(i);
3172
+ bytes += k.length + localStorage.getItem(k).length;
3173
+ }
3174
+ return bytes * 2; // UTF-16
3175
+ }
3176
+
3177
+ function _updateStorageTotal() {
3178
+ const el = document.getElementById('storage-total');
3179
+ if (el) el.textContent = `${(_getStorageTotalSize() / 1024).toFixed(1)} KB`;
3180
+ }
3181
+
3182
+ function _getKnownSessionIds() {
3183
+ return new Set(sessions.map((s) => s.id));
3184
+ }
3185
+
3186
+ function _sessionLabel(session, id) {
3187
+ return session ? escapeHtml(session.name || session.slug || id.slice(0, 12)) : escapeHtml(id.slice(0, 12));
3188
+ }
3189
+
3190
+ function _groupByProject(sessionIds) {
3191
+ const sessionMap = new Map(sessions.map((s) => [s.id, s]));
3192
+ const groups = new Map();
3193
+ const orphans = [];
3194
+ for (const id of sessionIds) {
3195
+ const session = sessionMap.get(id);
3196
+ if (!session) {
3197
+ orphans.push({ id, session: null });
3198
+ continue;
3199
+ }
3200
+ const project = session.project || '(no project)';
3201
+ if (!groups.has(project)) groups.set(project, []);
3202
+ groups.get(project).push({ id, session });
3203
+ }
3204
+ return { groups, orphans };
3205
+ }
3206
+
3207
+ function _projectLabel(project) {
3208
+ if (project === '(no project)') return '(no project)';
3209
+ return project.split(/[/\\]/).pop() || project;
3210
+ }
3211
+
3212
+ function _escapeForJsAttr(str) {
3213
+ const jsEscaped = str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
3214
+ return escapeHtml(jsEscaped);
3215
+ }
3216
+
3217
+ function _renderProjectGroup(label, meta, innerHtml) {
3218
+ return `<div class="storage-project-group">
3219
+ <div class="storage-project-header">
3220
+ <span>${label}</span>
3221
+ <span class="storage-item-meta">${meta}</span>
3222
+ </div>
3223
+ <div class="storage-session-group">${innerHtml}</div>
3224
+ </div>`;
3225
+ }
3226
+
3227
+ function _renderOrphanGroup(count, innerHtml) {
3228
+ return _renderProjectGroup('Orphaned', `<span class="storage-item-badge orphan">${count}</span>`, innerHtml);
3229
+ }
3230
+
3231
+ function showStorageManager() {
3232
+ _updateStorageTotal();
3233
+ _updateOrphanedCount();
3234
+ document.querySelectorAll('.storage-tab').forEach((t) => {
3235
+ t.classList.toggle('active', t.dataset.tab === 'sessions');
3236
+ });
3237
+ _renderStorageTab();
3238
+ document.getElementById('storage-modal').classList.add('visible');
3239
+ }
3240
+
3241
+ function closeStorageManager() {
3242
+ document.getElementById('storage-modal').classList.remove('visible');
3243
+ }
3244
+
3245
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
3246
+ function switchStorageTab(tab) {
3247
+ document.querySelectorAll('.storage-tab').forEach((t) => {
3248
+ t.classList.toggle('active', t.dataset.tab === tab);
3249
+ });
3250
+ _renderStorageTab();
3251
+ }
3252
+
3253
+ function _renderStorageTab() {
3254
+ const body = document.getElementById('storage-modal-body');
3255
+ const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
3256
+ if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
3257
+ else if (tab === 'scratchpads') body.innerHTML = _renderStorageScratchpads();
3258
+ }
3259
+
3260
+ function _renderStorageSessions() {
3261
+ const pinnedIds = [...new Set([...pinnedSessionIds, ...stickySessionIds])];
3262
+
3263
+ const msgMap = new Map();
3264
+ for (let i = 0; i < localStorage.length; i++) {
3265
+ const key = localStorage.key(i);
3266
+ if (!key.startsWith('pinned-messages-')) continue;
3267
+ const sid = key.slice('pinned-messages-'.length);
3268
+ try {
3269
+ const pins = JSON.parse(localStorage.getItem(key)) || [];
3270
+ if (pins.length) msgMap.set(sid, { pins, key });
3271
+ } catch {}
3272
+ }
3273
+
3274
+ const allIds = [...new Set([...pinnedIds, ...msgMap.keys()])];
3275
+ if (!allIds.length) return '<div class="storage-empty">No pinned sessions or messages</div>';
3276
+ const { groups, orphans } = _groupByProject(allIds);
3277
+
3278
+ function renderMessageItems(id) {
3279
+ const g = msgMap.get(id);
3280
+ if (!g) return '';
3281
+ const eid = escapeHtml(id);
3282
+ const header = `<div class="storage-group-header" style="padding-left:12px;">
3283
+ <span>${g.pins.length} pinned message${g.pins.length > 1 ? 's' : ''}</span>
3284
+ <div class="storage-item-actions">
3285
+ <button class="danger" onclick="_storageClearSessionPins('${eid}')">Clear All</button>
3286
+ </div>
3287
+ </div>`;
3288
+ const items = g.pins
3289
+ .map((p) => {
3290
+ const type = escapeHtml(p.type || '?');
3291
+ const text = escapeHtml((p.text || p.tool || p.agentType || '').slice(0, 60));
3292
+ const pinId = _escapeForJsAttr(p.id || '');
3293
+ const sid = _escapeForJsAttr(id);
3294
+ return `<div class="storage-item storage-item-clickable" style="padding-left:24px;" onclick="_storagePreviewPin('${sid}','${pinId}')">
3295
+ <span class="storage-item-badge">${type}</span>
3296
+ <span class="storage-item-id">${text}</span>
3297
+ <span class="storage-item-meta">${formatDate(p.timestamp)}</span>
3298
+ <div class="storage-item-actions">
3299
+ <button onclick="event.stopPropagation();_storagePreviewPin('${sid}','${pinId}')">View</button>
3300
+ <button class="danger" onclick="event.stopPropagation();_storageUnpinMessage('${sid}','${pinId}')">Unpin</button>
3301
+ </div>
3302
+ </div>`;
3303
+ })
3304
+ .join('');
3305
+ return header + items;
3306
+ }
3307
+
3308
+ function renderSessionItem({ id, session }) {
3309
+ const isPinned = isAnyPinned(id);
3310
+ const eid = escapeHtml(id);
3311
+ const actions = isPinned
3312
+ ? `<button onclick="_storageViewSession('${eid}')">View</button>
3313
+ <button class="danger" onclick="_storageUnpinSession('${eid}')">Unpin</button>`
3314
+ : `<button onclick="_storageViewSession('${eid}')">View</button>`;
3315
+ return `<div class="storage-group-header">
3316
+ <span>${_sessionLabel(session, id)}</span>
3317
+ <div class="storage-item-actions">${actions}</div>
3318
+ </div>${renderMessageItems(id)}`;
3319
+ }
3320
+
3321
+ let html = '';
3322
+ for (const [project, items] of groups) {
3323
+ const count = items.length;
3324
+ html += _renderProjectGroup(
3325
+ escapeHtml(_projectLabel(project)),
3326
+ `${count} session${count > 1 ? 's' : ''}`,
3327
+ items.map(renderSessionItem).join(''),
3328
+ );
3329
+ }
3330
+ if (orphans.length) {
3331
+ html += _renderOrphanGroup(orphans.length, orphans.map(renderSessionItem).join(''));
3332
+ }
3333
+ return html;
3334
+ }
3335
+
3336
+ async function _storageViewSession(id) {
3337
+ closeStorageManager();
3338
+ revealedStorageSessionId = id;
3339
+ if (!sessions.some((s) => s.id === id)) {
3340
+ lastSessionsHash = '';
3341
+ await fetchSessions();
3342
+ }
3343
+ await fetchTasks(id);
3344
+ const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(id)}"]`);
3345
+ if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
3346
+ }
3347
+
3348
+ function _storageUnpinSession(id) {
3349
+ pinnedSessionIds.delete(id);
3350
+ stickySessionIds.delete(id);
3351
+ savePinnedSessions();
3352
+ renderSessions();
3353
+ _renderStorageTab();
3354
+ _updateStorageTotal();
3355
+ }
3356
+
3357
+ function _storageClearSessionPins(sessionId) {
3358
+ localStorage.removeItem(`pinned-messages-${sessionId}`);
3359
+ if (currentSessionId === sessionId) {
3360
+ currentPins = [];
3361
+ const el = document.getElementById('message-panel-pinned');
3362
+ if (el) el.innerHTML = '';
3363
+ }
3364
+ _renderStorageTab();
3365
+ _updateStorageTotal();
3366
+ }
3367
+
3368
+ function _storageUnpinMessage(sessionId, pinId) {
3369
+ const key = `pinned-messages-${sessionId}`;
3370
+ try {
3371
+ const pins = JSON.parse(localStorage.getItem(key)) || [];
3372
+ const idx = pins.findIndex((p) => p.id === pinId);
3373
+ if (idx < 0) return;
3374
+ pins.splice(idx, 1);
3375
+ if (pins.length) localStorage.setItem(key, JSON.stringify(pins));
3376
+ else localStorage.removeItem(key);
3377
+ if (currentSessionId === sessionId) {
3378
+ currentPins = pins;
3379
+ const el = document.getElementById('message-panel-pinned');
3380
+ if (el) el.innerHTML = renderPinnedSection();
3381
+ }
3382
+ } catch {}
3383
+ _renderStorageTab();
3384
+ _updateStorageTotal();
3385
+ }
3386
+
3387
+ function _renderStorageScratchpads() {
3388
+ const allItems = [];
3389
+ for (let i = 0; i < localStorage.length; i++) {
3390
+ const key = localStorage.key(i);
3391
+ if (!key.startsWith('scratchpad-')) continue;
3392
+ const val = localStorage.getItem(key) || '';
3393
+ const isProject = key.startsWith('scratchpad-project:');
3394
+ const id = isProject ? key.slice('scratchpad-project:'.length) : key.slice('scratchpad-'.length);
3395
+ allItems.push({ key, id, isProject, chars: val.length });
3396
+ }
3397
+ if (!allItems.length) return '<div class="storage-empty">No scratchpads</div>';
3398
+
3399
+ const projectItems = allItems.filter((i) => i.isProject);
3400
+ const sessionItems = allItems.filter((i) => !i.isProject);
3401
+ const sessionIds = sessionItems.map((i) => i.id);
3402
+ const { groups: projectGroups, orphans } = _groupByProject(sessionIds);
3403
+ const scratchBySession = new Map(sessionItems.map((i) => [i.id, i]));
3404
+
3405
+ function renderScratchItem(item) {
3406
+ const session = !item.isProject ? sessions.find((s) => s.id === item.id) : null;
3407
+ const typeBadge = item.isProject
3408
+ ? '<span class="storage-item-badge">project</span>'
3409
+ : '<span class="storage-item-badge">session</span>';
3410
+ const jsKey = _escapeForJsAttr(item.key);
3411
+ const label = item.isProject ? escapeHtml(_projectLabel(item.id)) : _sessionLabel(session, item.id);
3412
+ return `<div class="storage-item">
3413
+ <span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
3414
+ ${typeBadge}
3415
+ <span class="storage-item-meta">${item.chars} chars</span>
3416
+ <div class="storage-item-actions">
3417
+ <button onclick="_storagePreviewScratchpad('${jsKey}')">View</button>
3418
+ <button class="danger" onclick="_storageDeleteScratchpad('${jsKey}')">Delete</button>
3419
+ </div>
3420
+ </div>`;
3421
+ }
3422
+
3423
+ let html = '';
3424
+
3425
+ if (projectItems.length) {
3426
+ html += _renderProjectGroup(
3427
+ 'Project Scratchpads',
3428
+ `${projectItems.length}`,
3429
+ projectItems.map(renderScratchItem).join(''),
3430
+ );
3431
+ }
3432
+
3433
+ for (const [project, items] of projectGroups) {
3434
+ const matching = items.map((i) => scratchBySession.get(i.id)).filter(Boolean);
3435
+ if (!matching.length) continue;
3436
+ html += _renderProjectGroup(
3437
+ escapeHtml(_projectLabel(project)),
3438
+ `${matching.length} scratchpad${matching.length > 1 ? 's' : ''}`,
3439
+ matching.map(renderScratchItem).join(''),
3440
+ );
3441
+ }
3442
+
3443
+ if (orphans.length) {
3444
+ const orphanItems = orphans.map((i) => scratchBySession.get(i.id)).filter(Boolean);
3445
+ if (orphanItems.length) {
3446
+ html += _renderOrphanGroup(orphanItems.length, orphanItems.map(renderScratchItem).join(''));
3447
+ }
3448
+ }
3449
+ return html;
3450
+ }
3451
+
3452
+ function _storagePreviewScratchpad(key) {
3453
+ closeStorageManager();
3454
+ showScratchpad(key);
3455
+ }
3456
+
3457
+ function _storagePreviewPin(sessionId, pinId) {
3458
+ closeStorageManager();
3459
+ const key = `pinned-messages-${sessionId}`;
3460
+ try {
3461
+ const pins = JSON.parse(localStorage.getItem(key)) || [];
3462
+ const pin = pins.find((p) => p.id === pinId);
3463
+ if (!pin) return;
3464
+ document.getElementById('msg-detail-pin-btn').style.display = 'none';
3465
+ currentMsgDetailIdx = null;
3466
+ currentPinDetailId = null;
3467
+ _renderPinToDetail(pin);
3468
+ document.getElementById('msg-detail-modal').classList.add('visible');
3469
+ } catch (e) {
3470
+ console.error('_storagePreviewPin error:', e);
3471
+ }
3472
+ }
3473
+
3474
+ function _storageDeleteScratchpad(key) {
3475
+ localStorage.removeItem(key);
3476
+ _renderStorageTab();
3477
+ _updateStorageTotal();
3478
+ }
3479
+
3480
+ function _findOrphanedKeys() {
3481
+ const known = _getKnownSessionIds();
3482
+ if (!known.size) return [];
3483
+ const orphaned = [];
3484
+ for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
3485
+ for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
3486
+ for (let i = 0; i < localStorage.length; i++) {
3487
+ const key = localStorage.key(i);
3488
+ if (key.startsWith('pinned-messages-')) {
3489
+ if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
3490
+ } else if (key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:')) {
3491
+ if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
3492
+ }
3493
+ }
3494
+ return orphaned;
3495
+ }
3496
+
3497
+ function _updateOrphanedCount() {
3498
+ const btn = document.getElementById('storage-cleanup-btn');
3499
+ if (!btn) return;
3500
+ const count = _findOrphanedKeys().length;
3501
+ btn.textContent = count ? `Clean Orphaned (${count})` : 'Clean Orphaned';
3502
+ }
3503
+
3504
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
3505
+ function cleanupOrphanedStorage() {
3506
+ if (!sessions.length) {
3507
+ showToast('Sessions not loaded yet — try again after they appear');
3508
+ return;
3509
+ }
3510
+ const orphaned = _findOrphanedKeys();
3511
+ let pinsChanged = false;
3512
+ for (const key of orphaned) {
3513
+ if (key.startsWith('__pinned__')) {
3514
+ pinnedSessionIds.delete(key.slice('__pinned__'.length));
3515
+ pinsChanged = true;
3516
+ } else if (key.startsWith('__sticky__')) {
3517
+ stickySessionIds.delete(key.slice('__sticky__'.length));
3518
+ pinsChanged = true;
3519
+ } else {
3520
+ localStorage.removeItem(key);
3521
+ }
3522
+ }
3523
+ if (pinsChanged) savePinnedSessions();
3524
+ const removed = orphaned.length;
3525
+
3526
+ showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
3527
+ renderSessions();
3528
+ _renderStorageTab();
3529
+ _updateStorageTotal();
3530
+ _updateOrphanedCount();
3531
+ }
3532
+ //#endregion
3533
+
2859
3534
  //#region KEYBOARD_SHORTCUTS
2860
3535
  function matchKey(e, ...keys) {
2861
3536
  if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false;
@@ -2927,6 +3602,11 @@ document.addEventListener('keydown', (e) => {
2927
3602
  }
2928
3603
  return;
2929
3604
  }
3605
+ if (e.code === 'KeyS' && e.shiftKey) {
3606
+ e.preventDefault();
3607
+ showStorageManager();
3608
+ return;
3609
+ }
2930
3610
 
2931
3611
  // Tab toggles focus zone
2932
3612
  if (e.key === 'Tab') {
@@ -3101,7 +3781,9 @@ function setupEventSource() {
3101
3781
 
3102
3782
  let taskRefreshTimer = null;
3103
3783
  let metadataRefreshTimer = null;
3784
+ let agentRefreshTimer = null;
3104
3785
  const pendingTaskSessionIds = new Set();
3786
+ const pendingAgentSessionIds = new Set();
3105
3787
 
3106
3788
  function debouncedRefresh(sessionId, isMetadata) {
3107
3789
  if (isMetadata) {
@@ -3122,6 +3804,9 @@ function setupEventSource() {
3122
3804
  currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
3123
3805
  renderAllTasks();
3124
3806
  renderLiveUpdatesFromCache();
3807
+ } else if (viewMode === 'project' && currentProjectPath) {
3808
+ const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
3809
+ if (hasUpdate) fetchProjectView(currentProjectPath);
3125
3810
  } else if (currentSessionId && pendingTaskSessionIds.has(currentSessionId)) {
3126
3811
  fetchTasks(currentSessionId);
3127
3812
  }
@@ -3132,7 +3817,6 @@ function setupEventSource() {
3132
3817
 
3133
3818
  eventSource.onmessage = (event) => {
3134
3819
  const data = JSON.parse(event.data);
3135
- console.log('[SSE] Event received:', data);
3136
3820
  if (data.type === 'update' || data.type === 'metadata-update') {
3137
3821
  if (data.type === 'metadata-update') projectsCacheDirty = true;
3138
3822
  debouncedRefresh(data.sessionId, data.type === 'metadata-update');
@@ -3143,10 +3827,17 @@ function setupEventSource() {
3143
3827
  }
3144
3828
 
3145
3829
  if (data.type === 'agent-update') {
3146
- fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
3147
- if (currentSessionId && data.sessionId === currentSessionId) {
3148
- fetchAgents(currentSessionId);
3149
- }
3830
+ pendingAgentSessionIds.add(data.sessionId);
3831
+ clearTimeout(agentRefreshTimer);
3832
+ agentRefreshTimer = setTimeout(() => {
3833
+ fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
3834
+ if (viewMode === 'project' && currentProjectSessionIds.some((id) => pendingAgentSessionIds.has(id))) {
3835
+ refreshProjectAgents();
3836
+ } else if (currentSessionId && pendingAgentSessionIds.has(currentSessionId)) {
3837
+ fetchAgents(currentSessionId);
3838
+ }
3839
+ pendingAgentSessionIds.clear();
3840
+ }, 500);
3150
3841
  }
3151
3842
 
3152
3843
  if (data.type === 'context-update') {
@@ -3154,7 +3845,6 @@ function setupEventSource() {
3154
3845
  }
3155
3846
 
3156
3847
  if (data.type === 'team-update') {
3157
- console.log('[SSE] Team update:', data.teamName);
3158
3848
  debouncedRefresh(data.teamName, false);
3159
3849
  }
3160
3850
  };
@@ -3312,6 +4002,10 @@ function renderContextDetail(raw) {
3312
4002
  //#endregion
3313
4003
 
3314
4004
  //#region UTILS
4005
+ function isSessionActive(s) {
4006
+ return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
4007
+ }
4008
+
3315
4009
  function formatDate(dateStr) {
3316
4010
  const date = new Date(dateStr);
3317
4011
  const now = new Date();
@@ -3328,6 +4022,12 @@ function stripAnsi(text) {
3328
4022
  return typeof text === 'string' ? text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') : text;
3329
4023
  }
3330
4024
 
4025
+ function stripTeammateWrapper(text) {
4026
+ if (typeof text !== 'string') return text;
4027
+ const match = text.match(/^<teammate-message[^>]*>\n?([\s\S]*?)(?:<\/teammate-message>\s*)?$/);
4028
+ return match ? match[1].trim() : text;
4029
+ }
4030
+
3331
4031
  function escapeHtml(text) {
3332
4032
  const div = document.createElement('div');
3333
4033
  div.textContent = text;
@@ -3494,6 +4194,33 @@ document.addEventListener('click', (e) => {
3494
4194
  return;
3495
4195
  }
3496
4196
 
4197
+ const projectBtn = e.target.closest('.project-view-btn');
4198
+ if (projectBtn) {
4199
+ e.stopPropagation();
4200
+ const projectPath = projectBtn.dataset.projectPath;
4201
+ if (projectPath) fetchProjectView(projectPath);
4202
+ return;
4203
+ }
4204
+
4205
+ if (e.target.closest('.pinned-ungroup-btn')) {
4206
+ e.stopPropagation();
4207
+ localStorage.setItem('groupPinnedSessions', 'false');
4208
+ renderSessions();
4209
+ return;
4210
+ }
4211
+
4212
+ if (e.target.closest('.pinned-regroup-banner')) {
4213
+ localStorage.setItem('groupPinnedSessions', 'true');
4214
+ renderSessions();
4215
+ return;
4216
+ }
4217
+
4218
+ const pinnedSubHeader = e.target.closest('.pinned-sub-header');
4219
+ if (pinnedSubHeader) {
4220
+ setGroupCollapsed(pinnedSubHeader, !collapsedProjectGroups.has(pinnedSubHeader.dataset.groupPath));
4221
+ return;
4222
+ }
4223
+
3497
4224
  const header = e.target.closest('.project-group-header');
3498
4225
  if (header) {
3499
4226
  setGroupCollapsed(header, !collapsedProjectGroups.has(header.dataset.groupPath));
@@ -3731,8 +4458,9 @@ async function showSessionInfoModal(sessionId) {
3731
4458
  // Fetch team config
3732
4459
  let teamConfig = null;
3733
4460
  if (session.isTeam) {
4461
+ const teamId = session.teamName || sessionId;
3734
4462
  promises.push(
3735
- fetch(`/api/teams/${sessionId}`)
4463
+ fetch(`/api/teams/${teamId}`)
3736
4464
  .then((r) => (r.ok ? r.json() : null))
3737
4465
  .catch(() => null)
3738
4466
  .then((data) => {
@@ -3804,6 +4532,9 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
3804
4532
  if (session.tasksDir) {
3805
4533
  infoRows.push(['Tasks Dir', session.tasksDir, { openPath: session.tasksDir }]);
3806
4534
  }
4535
+ if (session.sharedTaskList) {
4536
+ infoRows.push(['Shared Tasks', session.sharedTaskList]);
4537
+ }
3807
4538
  if (teamConfig?.configPath) {
3808
4539
  const configDir = teamConfig.configPath.replace(/[/\\][^/\\]+$/, '');
3809
4540
  infoRows.push(['Team Config', teamConfig.configPath, { openPath: configDir, openFile: teamConfig.configPath }]);
@@ -3823,7 +4554,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
3823
4554
  } else {
3824
4555
  html += `<span style="${plainStyle}" title="${copyVal}">${escapeHtml(value)}</span>`;
3825
4556
  }
3826
- const jsCopyVal = copyVal.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
4557
+ const jsCopyVal = _escapeForJsAttr(copyVal);
3827
4558
  html += `<button onclick="navigator.clipboard.writeText('${jsCopyVal}'); this.textContent='✓'; setTimeout(() => this.textContent='Copy', 1000)" style="padding: 2px 8px; font-size: 11px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; color: var(--text-secondary); cursor: pointer; white-space: nowrap;">Copy</button>`;
3828
4559
  });
3829
4560
  html += `</div>`;
@@ -4101,6 +4832,7 @@ searchQuery = urlState.search || '';
4101
4832
 
4102
4833
  loadPreferences();
4103
4834
  pinnedSessionIds = loadPinnedSessions();
4835
+ stickySessionIds = loadStickySessions();
4104
4836
  setupEventSource();
4105
4837
 
4106
4838
  if (urlState.search) {
@@ -4109,7 +4841,13 @@ if (urlState.search) {
4109
4841
  }
4110
4842
 
4111
4843
  fetchSessions().then(async () => {
4112
- if (urlState.session) {
4844
+ if (urlState.projectView) {
4845
+ try {
4846
+ await fetchProjectView(atob(urlState.projectView));
4847
+ } catch (_) {
4848
+ showAllTasks();
4849
+ }
4850
+ } else if (urlState.session) {
4113
4851
  await fetchTasks(urlState.session);
4114
4852
  } else {
4115
4853
  showAllTasks();
@@ -4127,7 +4865,13 @@ window.addEventListener('popstate', () => {
4127
4865
  ownerFilter = s.owner || '';
4128
4866
  searchQuery = s.search || '';
4129
4867
  loadPreferences();
4130
- if (s.session) fetchTasks(s.session);
4868
+ if (s.projectView) {
4869
+ try {
4870
+ fetchProjectView(atob(s.projectView));
4871
+ } catch (_) {
4872
+ showAllTasks();
4873
+ }
4874
+ } else if (s.session) fetchTasks(s.session);
4131
4875
  else showAllTasks();
4132
4876
  if (s.messages !== messagePanelOpen) toggleMessagePanel();
4133
4877
  });