claude-code-kanban 2.2.0-rc.6 → 2.2.0-rc.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.2.0-rc.6",
3
+ "version": "2.2.0-rc.8",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
- "dev": "node server.js --open",
11
+ "dev": "node --watch server.js",
12
12
  "test": "node --test test/contracts.test.js",
13
13
  "test:hooks": "bash tests/test-agent-spy.sh",
14
14
  "validate:schemas": "node test/validate-live-schemas.js",
package/public/app.js CHANGED
@@ -114,7 +114,8 @@ let lastTasksHash = '';
114
114
  //#region DATA_FETCHING
115
115
  async function fetchSessions() {
116
116
  try {
117
- const pinnedParam = pinnedSessionIds.size > 0 ? `&pinned=${[...pinnedSessionIds].join(',')}` : '';
117
+ const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
118
+ const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
118
119
  const [newSessions, newTasks] = await Promise.all([
119
120
  fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}`).then((r) => r.json()),
120
121
  fetch('/api/tasks/all').then((r) => r.json()),
@@ -435,6 +436,10 @@ async function fetchTasks(sessionId) {
435
436
  if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
436
437
  if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
437
438
  closeScratchpad();
439
+ if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
440
+ revealedPlanSessionId = null;
441
+ renderSessions();
442
+ }
438
443
  currentSessionId = sessionId;
439
444
  currentPins = loadPins(sessionId);
440
445
  ownerFilter = '';
@@ -500,9 +505,7 @@ async function fetchProjectView(projectPath) {
500
505
  if (msgPinned) msgPinned.innerHTML = '';
501
506
  const projectSessions = sessions.filter((s) => s.project === projectPath);
502
507
  currentProjectSessionIds = projectSessions.map((s) => s.id);
503
- const activeSessionIds = projectSessions
504
- .filter((s) => isSessionActive(s) || pinnedSessionIds.has(s.id))
505
- .map((s) => s.id);
508
+ const activeSessionIds = projectSessions.filter((s) => isSessionActive(s) || isAnyPinned(s.id)).map((s) => s.id);
506
509
 
507
510
  const encoded = btoa(projectPath);
508
511
  const [tasksResult, agentResults] = await Promise.all([
@@ -551,9 +554,7 @@ async function fetchProjectView(projectPath) {
551
554
  async function refreshProjectAgents() {
552
555
  if (!currentProjectPath) return;
553
556
  const projectSessions = sessions.filter((s) => s.project === currentProjectPath);
554
- const activeSessionIds = projectSessions
555
- .filter((s) => isSessionActive(s) || pinnedSessionIds.has(s.id))
556
- .map((s) => s.id);
557
+ const activeSessionIds = projectSessions.filter((s) => isSessionActive(s) || isAnyPinned(s.id)).map((s) => s.id);
557
558
  const agentResults = await Promise.all(
558
559
  activeSessionIds.map((id) =>
559
560
  fetch(`/api/sessions/${id}/agents`)
@@ -758,7 +759,6 @@ function renderPinnedSection() {
758
759
  <div class="msg-body"><div class="msg-text">${escapeHtml(p.tool || '')}${toolDetail}</div><div class="msg-time">${formatDate(p.timestamp)}</div></div>${pinnedAgentLogBtn}${unpin}
759
760
  </div>`;
760
761
  } else if (p.type === 'agent') {
761
- const agentClick = `onclick="showAgentModal('${escapeHtml(p.agentId)}')" style="cursor:pointer"`;
762
762
  const agentLogBtn = agentLogButton(p.agentId);
763
763
  const msgTrunc = p.lastMessage
764
764
  ? escapeHtml(
@@ -768,7 +768,7 @@ function renderPinnedSection() {
768
768
  )
769
769
  : '';
770
770
  const agentDetail = msgTrunc ? ` <span style="color:var(--text-muted)">${msgTrunc}</span>` : '';
771
- return `<div class="msg-item msg-tool" ${agentClick}>
771
+ return `<div class="msg-item msg-tool" ${click}>
772
772
  ${MSG_ICON_TOOL}
773
773
  <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}
774
774
  </div>`;
@@ -1019,27 +1019,8 @@ function showPinnedMsgDetail(pinIdx) {
1019
1019
  }
1020
1020
  currentMsgDetailIdx = null;
1021
1021
  currentPinDetailId = pin.id;
1022
+ _renderPinToDetail(pin);
1022
1023
  const body = document.getElementById('msg-detail-body');
1023
- const agentBtn = document.getElementById('msg-detail-agent-btn');
1024
- if (pin.type === 'tool_use') {
1025
- document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
1026
- const fullText = pin.fullDetail || pin.detail || '';
1027
- const pinParamsHtml = renderToolParamsHtml(pin.params);
1028
- const pinResultHtml = renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull);
1029
- const pinDetailEscaped = escapeHtml(fullText);
1030
- const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
1031
- body.innerHTML =
1032
- (fullText ? `<pre class="msg-detail-pre">${pinDetailRendered}</pre>` : '<em>No details</em>') +
1033
- pinParamsHtml +
1034
- pinResultHtml;
1035
- agentBtn.style.display = 'none';
1036
- } else {
1037
- const text = stripAnsi(pin.fullText || pin.text || '');
1038
- document.getElementById('msg-detail-title').textContent = pin.type === 'assistant' ? 'Claude' : 'User';
1039
- agentBtn.style.display = 'none';
1040
- body.innerHTML = renderMarkdown(text);
1041
- }
1042
- document.getElementById('msg-detail-meta').textContent = formatDate(pin.timestamp);
1043
1024
  const pinModal = document.getElementById('msg-detail-modal').querySelector('.modal');
1044
1025
  autoSizeModal(pinModal, body);
1045
1026
  const pinBtn = document.getElementById('msg-detail-pin-btn');
@@ -1073,6 +1054,7 @@ function togglePinnedCollapse() {
1073
1054
 
1074
1055
  //#region PINNING
1075
1056
  let pinnedSessionIds = new Set();
1057
+ let stickySessionIds = new Set();
1076
1058
 
1077
1059
  function loadPinnedSessions() {
1078
1060
  try {
@@ -1082,18 +1064,72 @@ function loadPinnedSessions() {
1082
1064
  }
1083
1065
  }
1084
1066
 
1067
+ function loadStickySessions() {
1068
+ try {
1069
+ return new Set(JSON.parse(localStorage.getItem('sticky-sessions')) || []);
1070
+ } catch {
1071
+ return new Set();
1072
+ }
1073
+ }
1074
+
1085
1075
  function savePinnedSessions() {
1086
1076
  localStorage.setItem('pinned-sessions', JSON.stringify([...pinnedSessionIds]));
1077
+ localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
1087
1078
  }
1088
1079
 
1080
+ // unpinned → pinned → sticky → unpinned
1089
1081
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1090
1082
  function toggleSessionPin(sessionId) {
1091
- if (pinnedSessionIds.has(sessionId)) pinnedSessionIds.delete(sessionId);
1092
- else pinnedSessionIds.add(sessionId);
1083
+ if (stickySessionIds.has(sessionId)) {
1084
+ stickySessionIds.delete(sessionId);
1085
+ pinnedSessionIds.delete(sessionId);
1086
+ } else if (pinnedSessionIds.has(sessionId)) {
1087
+ pinnedSessionIds.delete(sessionId);
1088
+ stickySessionIds.add(sessionId);
1089
+ } else {
1090
+ pinnedSessionIds.add(sessionId);
1091
+ }
1093
1092
  savePinnedSessions();
1094
1093
  renderSessions();
1095
1094
  }
1096
1095
 
1096
+ function getSessionPinState(sessionId) {
1097
+ if (stickySessionIds.has(sessionId)) return 'sticky';
1098
+ if (pinnedSessionIds.has(sessionId)) return 'pinned';
1099
+ return 'none';
1100
+ }
1101
+
1102
+ function isAnyPinned(sessionId) {
1103
+ return pinnedSessionIds.has(sessionId) || stickySessionIds.has(sessionId);
1104
+ }
1105
+
1106
+ function _renderPinToDetail(pin) {
1107
+ const body = document.getElementById('msg-detail-body');
1108
+ const agentBtn = document.getElementById('msg-detail-agent-btn');
1109
+ agentBtn.style.display = 'none';
1110
+ if (pin.type === 'tool_use') {
1111
+ document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
1112
+ const fullText = pin.fullDetail || pin.detail || '';
1113
+ const pinParamsHtml = renderToolParamsHtml(pin.params);
1114
+ const pinResultHtml = renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull);
1115
+ const pinDetailEscaped = escapeHtml(fullText);
1116
+ const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
1117
+ body.innerHTML =
1118
+ (fullText ? `<pre class="msg-detail-pre">${pinDetailRendered}</pre>` : '<em>No details</em>') +
1119
+ pinParamsHtml +
1120
+ pinResultHtml;
1121
+ } else if (pin.type === 'agent') {
1122
+ document.getElementById('msg-detail-title').textContent = pin.agentType || 'Agent';
1123
+ const lastMsg = stripAnsi(pin.lastMessage || '');
1124
+ body.innerHTML = lastMsg ? renderMarkdown(lastMsg) : '<em>No agent message</em>';
1125
+ } else {
1126
+ const text = stripAnsi(pin.fullText || pin.text || '');
1127
+ document.getElementById('msg-detail-title').textContent = pin.type === 'assistant' ? 'Claude' : 'User';
1128
+ body.innerHTML = renderMarkdown(text);
1129
+ }
1130
+ document.getElementById('msg-detail-meta').textContent = formatDate(pin.timestamp);
1131
+ }
1132
+
1097
1133
  const SESSION_PIN_SVG = PIN_SVG.replace('width="14" height="14"', 'width="12" height="12"');
1098
1134
 
1099
1135
  //#endregion
@@ -1737,6 +1773,22 @@ function closeAgentModal() {
1737
1773
  //#endregion
1738
1774
 
1739
1775
  //#region RENDERING
1776
+ let revealedPlanSessionId = null;
1777
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1778
+ function revealPlanSession(planSessionId) {
1779
+ if (revealedPlanSessionId === planSessionId) {
1780
+ revealedPlanSessionId = null;
1781
+ } else {
1782
+ revealedPlanSessionId = planSessionId;
1783
+ }
1784
+ renderSessions();
1785
+ if (revealedPlanSessionId) {
1786
+ fetchTasks(planSessionId);
1787
+ const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(planSessionId)}"]`);
1788
+ if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1789
+ }
1790
+ }
1791
+
1740
1792
  async function showAllTasks() {
1741
1793
  try {
1742
1794
  viewMode = 'all';
@@ -1810,16 +1862,9 @@ function renderSessions() {
1810
1862
  if (isActive) activeSessionIds.add(s.id);
1811
1863
  return isActive;
1812
1864
  });
1813
- // Include plan sessions whose implementation is active
1814
- const planSessions = sessions.filter(
1815
- (s) =>
1816
- s.planImplementationSessionId &&
1817
- activeSessionIds.has(s.planImplementationSessionId) &&
1818
- !activeSessionIds.has(s.id),
1819
- );
1820
- if (planSessions.length) {
1821
- filteredSessions = filteredSessions.concat(planSessions);
1822
- filteredSessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
1865
+ if (revealedPlanSessionId && !filteredSessions.some((s) => s.id === revealedPlanSessionId)) {
1866
+ const planSession = sessions.find((s) => s.id === revealedPlanSessionId);
1867
+ if (planSession) filteredSessions.push(planSession);
1823
1868
  }
1824
1869
  }
1825
1870
  if (filterProject) {
@@ -1850,10 +1895,10 @@ function renderSessions() {
1850
1895
  });
1851
1896
  }
1852
1897
 
1853
- // Always include pinned sessions even if they don't match filters
1854
- if (pinnedSessionIds.size > 0 && !searchQuery) {
1898
+ // Always include pinned/sticky sessions even if they don't match filters
1899
+ if ((pinnedSessionIds.size > 0 || stickySessionIds.size > 0) && !searchQuery) {
1855
1900
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
1856
- const missingPinned = sessions.filter((s) => pinnedSessionIds.has(s.id) && !filteredIds.has(s.id));
1901
+ const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id));
1857
1902
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
1858
1903
  }
1859
1904
 
@@ -1907,11 +1952,13 @@ function renderSessions() {
1907
1952
  const isTeam = session.isTeam;
1908
1953
  const memberCount = session.memberCount || 0;
1909
1954
 
1910
- const isSessionPinned = pinnedSessionIds.has(session.id);
1955
+ const pinState = getSessionPinState(session.id);
1956
+ const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
1957
+ const pinTitle = pinState === 'sticky' ? 'Unpin' : pinState === 'pinned' ? 'Sticky pin' : 'Pin';
1911
1958
  const showCtx = !!session.contextStatus;
1912
1959
  return `
1913
1960
  <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}">
1914
- <span class="session-pin-btn${isSessionPinned ? ' pinned' : ''}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${isSessionPinned ? 'Unpin' : 'Pin'} session">${SESSION_PIN_SVG}</span>
1961
+ <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${SESSION_PIN_SVG}</span>
1915
1962
  <div class="session-name">${escapeHtml(primaryName)}</div>
1916
1963
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
1917
1964
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
@@ -1923,7 +1970,7 @@ function renderSessions() {
1923
1970
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
1924
1971
  ${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>` : ''}
1925
1972
  ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
1926
- ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to view plan session" onclick="event.stopPropagation(); fetchTasks('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
1973
+ ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
1927
1974
  ${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
1928
1975
  ${isLive ? '<span class="pulse"></span>' : ''}
1929
1976
  </span>
@@ -1951,10 +1998,14 @@ function renderSessions() {
1951
1998
  const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
1952
1999
  const renderGroupSessions = (sessions, pinKey) => {
1953
2000
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
1954
- const gPinned = sessions.filter((s) => pinnedSessionIds.has(s.id));
2001
+ const gPinned = sessions.filter((s) => pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id));
1955
2002
  if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
1956
- const gUnpinned = sessions.filter((s) => !pinnedSessionIds.has(s.id));
2003
+ const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
2004
+ const gUnpinned = sessions.filter(
2005
+ (s) => !pinnedSessionIds.has(s.id) || isSessionActive(s) || stickySessionIds.has(s.id),
2006
+ );
1957
2007
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
2008
+ if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
1958
2009
  return (
1959
2010
  '<div class="pinned-sub-section">' +
1960
2011
  '<div class="pinned-sub-header' +
@@ -1965,21 +2016,23 @@ function renderSessions() {
1965
2016
  '<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>' +
1966
2017
  '<span class="pinned-sub-label">Pinned</span>' +
1967
2018
  '<span class="group-count">' +
1968
- gPinned.length +
2019
+ gIdlePinned.length +
1969
2020
  '</span>' +
1970
2021
  '<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">&times;</span>' +
1971
2022
  '</div>' +
1972
2023
  '<div class="pinned-sub-items' +
1973
2024
  (pinCollapsed ? ' collapsed' : '') +
1974
2025
  '">' +
1975
- gPinned.map(renderSessionCard).join('') +
2026
+ gIdlePinned.map(renderSessionCard).join('') +
1976
2027
  '</div>' +
1977
2028
  '</div>' +
1978
2029
  gUnpinned.map(renderSessionCard).join('')
1979
2030
  );
1980
2031
  };
1981
- if (!groupPinned && pinnedSessionIds.size > 0) {
1982
- const pinSort = (a, b) => (pinnedSessionIds.has(b.id) ? 1 : 0) - (pinnedSessionIds.has(a.id) ? 1 : 0);
2032
+ if (!groupPinned && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2033
+ const pinWeight = (s) =>
2034
+ stickySessionIds.has(s.id) ? 2 : pinnedSessionIds.has(s.id) && !isSessionActive(s) ? 1 : 0;
2035
+ const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
1983
2036
  for (const [, arr] of groups) arr.sort(pinSort);
1984
2037
  ungrouped.sort(pinSort);
1985
2038
  }
@@ -2054,19 +2107,28 @@ function renderSessions() {
2054
2107
 
2055
2108
  sessionsList.innerHTML = html;
2056
2109
  } else {
2057
- const pinned = filteredSessions.filter((s) => pinnedSessionIds.has(s.id));
2058
- const rest = filteredSessions.filter((s) => !pinnedSessionIds.has(s.id));
2110
+ const sticky = filteredSessions.filter((s) => stickySessionIds.has(s.id));
2111
+ const idlePinned = filteredSessions.filter((s) => pinnedSessionIds.has(s.id) && !isSessionActive(s));
2112
+ const rest = filteredSessions.filter(
2113
+ (s) =>
2114
+ (!pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id)) ||
2115
+ (pinnedSessionIds.has(s.id) && isSessionActive(s)),
2116
+ );
2059
2117
  let html = '';
2060
- if (pinned.length > 0) {
2061
- const isCollapsed = collapsedProjectGroups.has('__pinned__');
2118
+ if (sticky.length > 0) {
2119
+ html += sticky.map(renderSessionCard).join('');
2120
+ }
2121
+ const isCollapsed = collapsedProjectGroups.has('__pinned__');
2122
+ const hasPinned = pinnedSessionIds.size > 0 && filteredSessions.some((s) => pinnedSessionIds.has(s.id));
2123
+ if (idlePinned.length > 0 || (hasPinned && isCollapsed)) {
2062
2124
  html += `
2063
2125
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__pinned__">
2064
2126
  <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>
2065
2127
  <span class="group-name">Pinned</span>
2066
- <span class="group-count">${pinned.length}</span>
2128
+ <span class="group-count">${idlePinned.length}</span>
2067
2129
  </div>
2068
2130
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
2069
- ${pinned.map(renderSessionCard).join('')}
2131
+ ${idlePinned.map(renderSessionCard).join('')}
2070
2132
  </div>
2071
2133
  `;
2072
2134
  }
@@ -3031,7 +3093,10 @@ const _scratchpadModal = document.getElementById('scratchpad-modal');
3031
3093
  const _scratchpadTextarea = document.getElementById('scratchpad-textarea');
3032
3094
  const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
3033
3095
 
3096
+ let _scratchpadKeyOverride = null;
3097
+
3034
3098
  function _scratchpadKey() {
3099
+ if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
3035
3100
  if (currentSessionId) return `scratchpad-${currentSessionId}`;
3036
3101
  if (currentProjectPath) return `scratchpad-project:${currentProjectPath}`;
3037
3102
  return null;
@@ -3045,7 +3110,8 @@ function toggleScratchpad() {
3045
3110
  }
3046
3111
  }
3047
3112
 
3048
- function showScratchpad() {
3113
+ function showScratchpad(keyOverride) {
3114
+ _scratchpadKeyOverride = keyOverride || null;
3049
3115
  const key = _scratchpadKey();
3050
3116
  if (!key) return;
3051
3117
  _scratchpadTextarea.value = localStorage.getItem(key) || '';
@@ -3060,13 +3126,19 @@ function closeScratchpad() {
3060
3126
  _scratchpadSaveTimer = null;
3061
3127
  }
3062
3128
  saveScratchpad();
3129
+ _scratchpadKeyOverride = null;
3063
3130
  _scratchpadModal.classList.remove('visible');
3064
3131
  }
3065
3132
 
3066
3133
  function saveScratchpad() {
3067
3134
  const key = _scratchpadKey();
3068
3135
  if (!key) return;
3069
- localStorage.setItem(key, _scratchpadTextarea.value);
3136
+ const val = _scratchpadTextarea.value;
3137
+ if (val.trim()) {
3138
+ localStorage.setItem(key, val);
3139
+ } else {
3140
+ localStorage.removeItem(key);
3141
+ }
3070
3142
  }
3071
3143
 
3072
3144
  _scratchpadTextarea.addEventListener('input', () => {
@@ -3080,6 +3152,367 @@ _scratchpadTextarea.addEventListener('input', () => {
3080
3152
 
3081
3153
  //#endregion
3082
3154
 
3155
+ //#region STORAGE_MANAGER
3156
+
3157
+ function _getStorageTotalSize() {
3158
+ let bytes = 0;
3159
+ for (let i = 0; i < localStorage.length; i++) {
3160
+ const k = localStorage.key(i);
3161
+ bytes += k.length + localStorage.getItem(k).length;
3162
+ }
3163
+ return bytes * 2; // UTF-16
3164
+ }
3165
+
3166
+ function _updateStorageTotal() {
3167
+ const el = document.getElementById('storage-total');
3168
+ if (el) el.textContent = `${(_getStorageTotalSize() / 1024).toFixed(1)} KB`;
3169
+ }
3170
+
3171
+ function _getKnownSessionIds() {
3172
+ return new Set(sessions.map((s) => s.id));
3173
+ }
3174
+
3175
+ function _sessionLabel(session, id) {
3176
+ return session ? escapeHtml(session.name || session.slug || id.slice(0, 12)) : escapeHtml(id.slice(0, 12));
3177
+ }
3178
+
3179
+ function _groupByProject(sessionIds) {
3180
+ const sessionMap = new Map(sessions.map((s) => [s.id, s]));
3181
+ const groups = new Map();
3182
+ const orphans = [];
3183
+ for (const id of sessionIds) {
3184
+ const session = sessionMap.get(id);
3185
+ if (!session) {
3186
+ orphans.push({ id, session: null });
3187
+ continue;
3188
+ }
3189
+ const project = session.project || '(no project)';
3190
+ if (!groups.has(project)) groups.set(project, []);
3191
+ groups.get(project).push({ id, session });
3192
+ }
3193
+ return { groups, orphans };
3194
+ }
3195
+
3196
+ function _projectLabel(project) {
3197
+ if (project === '(no project)') return '(no project)';
3198
+ return project.split(/[/\\]/).pop() || project;
3199
+ }
3200
+
3201
+ function _escapeForJsAttr(str) {
3202
+ const jsEscaped = str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
3203
+ return escapeHtml(jsEscaped);
3204
+ }
3205
+
3206
+ function _renderProjectGroup(label, meta, innerHtml) {
3207
+ return `<div class="storage-project-group">
3208
+ <div class="storage-project-header">
3209
+ <span>${label}</span>
3210
+ <span class="storage-item-meta">${meta}</span>
3211
+ </div>
3212
+ <div class="storage-session-group">${innerHtml}</div>
3213
+ </div>`;
3214
+ }
3215
+
3216
+ function _renderOrphanGroup(count, innerHtml) {
3217
+ return _renderProjectGroup('Orphaned', `<span class="storage-item-badge orphan">${count}</span>`, innerHtml);
3218
+ }
3219
+
3220
+ function showStorageManager() {
3221
+ _updateStorageTotal();
3222
+ _updateOrphanedCount();
3223
+ document.querySelectorAll('.storage-tab').forEach((t) => {
3224
+ t.classList.toggle('active', t.dataset.tab === 'sessions');
3225
+ });
3226
+ _renderStorageTab();
3227
+ document.getElementById('storage-modal').classList.add('visible');
3228
+ }
3229
+
3230
+ function closeStorageManager() {
3231
+ document.getElementById('storage-modal').classList.remove('visible');
3232
+ }
3233
+
3234
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
3235
+ function switchStorageTab(tab) {
3236
+ document.querySelectorAll('.storage-tab').forEach((t) => {
3237
+ t.classList.toggle('active', t.dataset.tab === tab);
3238
+ });
3239
+ _renderStorageTab();
3240
+ }
3241
+
3242
+ function _renderStorageTab() {
3243
+ const body = document.getElementById('storage-modal-body');
3244
+ const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
3245
+ if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
3246
+ else if (tab === 'scratchpads') body.innerHTML = _renderStorageScratchpads();
3247
+ }
3248
+
3249
+ function _renderStorageSessions() {
3250
+ const pinnedIds = [...new Set([...pinnedSessionIds, ...stickySessionIds])];
3251
+
3252
+ const msgMap = new Map();
3253
+ for (let i = 0; i < localStorage.length; i++) {
3254
+ const key = localStorage.key(i);
3255
+ if (!key.startsWith('pinned-messages-')) continue;
3256
+ const sid = key.slice('pinned-messages-'.length);
3257
+ try {
3258
+ const pins = JSON.parse(localStorage.getItem(key)) || [];
3259
+ if (pins.length) msgMap.set(sid, { pins, key });
3260
+ } catch {}
3261
+ }
3262
+
3263
+ const allIds = [...new Set([...pinnedIds, ...msgMap.keys()])];
3264
+ if (!allIds.length) return '<div class="storage-empty">No pinned sessions or messages</div>';
3265
+ const { groups, orphans } = _groupByProject(allIds);
3266
+
3267
+ function renderMessageItems(id) {
3268
+ const g = msgMap.get(id);
3269
+ if (!g) return '';
3270
+ const eid = escapeHtml(id);
3271
+ const header = `<div class="storage-group-header" style="padding-left:12px;">
3272
+ <span>${g.pins.length} pinned message${g.pins.length > 1 ? 's' : ''}</span>
3273
+ <div class="storage-item-actions">
3274
+ <button class="danger" onclick="_storageClearSessionPins('${eid}')">Clear All</button>
3275
+ </div>
3276
+ </div>`;
3277
+ const items = g.pins
3278
+ .map((p) => {
3279
+ const type = escapeHtml(p.type || '?');
3280
+ const text = escapeHtml((p.text || p.tool || p.agentType || '').slice(0, 60));
3281
+ const pinId = _escapeForJsAttr(p.id || '');
3282
+ const sid = _escapeForJsAttr(id);
3283
+ return `<div class="storage-item storage-item-clickable" style="padding-left:24px;" onclick="_storagePreviewPin('${sid}','${pinId}')">
3284
+ <span class="storage-item-badge">${type}</span>
3285
+ <span class="storage-item-id">${text}</span>
3286
+ <span class="storage-item-meta">${formatDate(p.timestamp)}</span>
3287
+ <div class="storage-item-actions">
3288
+ <button onclick="event.stopPropagation();_storagePreviewPin('${sid}','${pinId}')">View</button>
3289
+ <button class="danger" onclick="event.stopPropagation();_storageUnpinMessage('${sid}','${pinId}')">Unpin</button>
3290
+ </div>
3291
+ </div>`;
3292
+ })
3293
+ .join('');
3294
+ return header + items;
3295
+ }
3296
+
3297
+ function renderSessionItem({ id, session }) {
3298
+ const isPinned = isAnyPinned(id);
3299
+ const eid = escapeHtml(id);
3300
+ const actions = isPinned
3301
+ ? `<button onclick="_storageViewSession('${eid}')">View</button>
3302
+ <button class="danger" onclick="_storageUnpinSession('${eid}')">Unpin</button>`
3303
+ : `<button onclick="_storageViewSession('${eid}')">View</button>`;
3304
+ return `<div class="storage-group-header">
3305
+ <span>${_sessionLabel(session, id)}</span>
3306
+ <div class="storage-item-actions">${actions}</div>
3307
+ </div>${renderMessageItems(id)}`;
3308
+ }
3309
+
3310
+ let html = '';
3311
+ for (const [project, items] of groups) {
3312
+ const count = items.length;
3313
+ html += _renderProjectGroup(
3314
+ escapeHtml(_projectLabel(project)),
3315
+ `${count} session${count > 1 ? 's' : ''}`,
3316
+ items.map(renderSessionItem).join(''),
3317
+ );
3318
+ }
3319
+ if (orphans.length) {
3320
+ html += _renderOrphanGroup(orphans.length, orphans.map(renderSessionItem).join(''));
3321
+ }
3322
+ return html;
3323
+ }
3324
+
3325
+ function _storageViewSession(id) {
3326
+ closeStorageManager();
3327
+ fetchTasks(id);
3328
+ }
3329
+
3330
+ function _storageUnpinSession(id) {
3331
+ pinnedSessionIds.delete(id);
3332
+ stickySessionIds.delete(id);
3333
+ savePinnedSessions();
3334
+ renderSessions();
3335
+ _renderStorageTab();
3336
+ _updateStorageTotal();
3337
+ }
3338
+
3339
+ function _storageClearSessionPins(sessionId) {
3340
+ localStorage.removeItem(`pinned-messages-${sessionId}`);
3341
+ if (currentSessionId === sessionId) {
3342
+ currentPins = [];
3343
+ const el = document.getElementById('message-panel-pinned');
3344
+ if (el) el.innerHTML = '';
3345
+ }
3346
+ _renderStorageTab();
3347
+ _updateStorageTotal();
3348
+ }
3349
+
3350
+ function _storageUnpinMessage(sessionId, pinId) {
3351
+ const key = `pinned-messages-${sessionId}`;
3352
+ try {
3353
+ const pins = JSON.parse(localStorage.getItem(key)) || [];
3354
+ const idx = pins.findIndex((p) => p.id === pinId);
3355
+ if (idx < 0) return;
3356
+ pins.splice(idx, 1);
3357
+ if (pins.length) localStorage.setItem(key, JSON.stringify(pins));
3358
+ else localStorage.removeItem(key);
3359
+ if (currentSessionId === sessionId) {
3360
+ currentPins = pins;
3361
+ const el = document.getElementById('message-panel-pinned');
3362
+ if (el) el.innerHTML = renderPinnedSection();
3363
+ }
3364
+ } catch {}
3365
+ _renderStorageTab();
3366
+ _updateStorageTotal();
3367
+ }
3368
+
3369
+ function _renderStorageScratchpads() {
3370
+ const allItems = [];
3371
+ for (let i = 0; i < localStorage.length; i++) {
3372
+ const key = localStorage.key(i);
3373
+ if (!key.startsWith('scratchpad-')) continue;
3374
+ const val = localStorage.getItem(key) || '';
3375
+ const isProject = key.startsWith('scratchpad-project:');
3376
+ const id = isProject ? key.slice('scratchpad-project:'.length) : key.slice('scratchpad-'.length);
3377
+ allItems.push({ key, id, isProject, chars: val.length });
3378
+ }
3379
+ if (!allItems.length) return '<div class="storage-empty">No scratchpads</div>';
3380
+
3381
+ const projectItems = allItems.filter((i) => i.isProject);
3382
+ const sessionItems = allItems.filter((i) => !i.isProject);
3383
+ const sessionIds = sessionItems.map((i) => i.id);
3384
+ const { groups: projectGroups, orphans } = _groupByProject(sessionIds);
3385
+ const scratchBySession = new Map(sessionItems.map((i) => [i.id, i]));
3386
+
3387
+ function renderScratchItem(item) {
3388
+ const session = !item.isProject ? sessions.find((s) => s.id === item.id) : null;
3389
+ const typeBadge = item.isProject
3390
+ ? '<span class="storage-item-badge">project</span>'
3391
+ : '<span class="storage-item-badge">session</span>';
3392
+ const jsKey = _escapeForJsAttr(item.key);
3393
+ const label = item.isProject ? escapeHtml(_projectLabel(item.id)) : _sessionLabel(session, item.id);
3394
+ return `<div class="storage-item">
3395
+ <span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
3396
+ ${typeBadge}
3397
+ <span class="storage-item-meta">${item.chars} chars</span>
3398
+ <div class="storage-item-actions">
3399
+ <button onclick="_storagePreviewScratchpad('${jsKey}')">View</button>
3400
+ <button class="danger" onclick="_storageDeleteScratchpad('${jsKey}')">Delete</button>
3401
+ </div>
3402
+ </div>`;
3403
+ }
3404
+
3405
+ let html = '';
3406
+
3407
+ if (projectItems.length) {
3408
+ html += _renderProjectGroup(
3409
+ 'Project Scratchpads',
3410
+ `${projectItems.length}`,
3411
+ projectItems.map(renderScratchItem).join(''),
3412
+ );
3413
+ }
3414
+
3415
+ for (const [project, items] of projectGroups) {
3416
+ const matching = items.map((i) => scratchBySession.get(i.id)).filter(Boolean);
3417
+ if (!matching.length) continue;
3418
+ html += _renderProjectGroup(
3419
+ escapeHtml(_projectLabel(project)),
3420
+ `${matching.length} scratchpad${matching.length > 1 ? 's' : ''}`,
3421
+ matching.map(renderScratchItem).join(''),
3422
+ );
3423
+ }
3424
+
3425
+ if (orphans.length) {
3426
+ const orphanItems = orphans.map((i) => scratchBySession.get(i.id)).filter(Boolean);
3427
+ if (orphanItems.length) {
3428
+ html += _renderOrphanGroup(orphanItems.length, orphanItems.map(renderScratchItem).join(''));
3429
+ }
3430
+ }
3431
+ return html;
3432
+ }
3433
+
3434
+ function _storagePreviewScratchpad(key) {
3435
+ closeStorageManager();
3436
+ showScratchpad(key);
3437
+ }
3438
+
3439
+ function _storagePreviewPin(sessionId, pinId) {
3440
+ closeStorageManager();
3441
+ const key = `pinned-messages-${sessionId}`;
3442
+ try {
3443
+ const pins = JSON.parse(localStorage.getItem(key)) || [];
3444
+ const pin = pins.find((p) => p.id === pinId);
3445
+ if (!pin) return;
3446
+ document.getElementById('msg-detail-pin-btn').style.display = 'none';
3447
+ currentMsgDetailIdx = null;
3448
+ currentPinDetailId = null;
3449
+ _renderPinToDetail(pin);
3450
+ document.getElementById('msg-detail-modal').classList.add('visible');
3451
+ } catch (e) {
3452
+ console.error('_storagePreviewPin error:', e);
3453
+ }
3454
+ }
3455
+
3456
+ function _storageDeleteScratchpad(key) {
3457
+ localStorage.removeItem(key);
3458
+ _renderStorageTab();
3459
+ _updateStorageTotal();
3460
+ }
3461
+
3462
+ function _findOrphanedKeys() {
3463
+ const known = _getKnownSessionIds();
3464
+ if (!known.size) return [];
3465
+ const orphaned = [];
3466
+ for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
3467
+ for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
3468
+ for (let i = 0; i < localStorage.length; i++) {
3469
+ const key = localStorage.key(i);
3470
+ if (key.startsWith('pinned-messages-')) {
3471
+ if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
3472
+ } else if (key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:')) {
3473
+ if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
3474
+ }
3475
+ }
3476
+ return orphaned;
3477
+ }
3478
+
3479
+ function _updateOrphanedCount() {
3480
+ const btn = document.getElementById('storage-cleanup-btn');
3481
+ if (!btn) return;
3482
+ const count = _findOrphanedKeys().length;
3483
+ btn.textContent = count ? `Clean Orphaned (${count})` : 'Clean Orphaned';
3484
+ }
3485
+
3486
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
3487
+ function cleanupOrphanedStorage() {
3488
+ if (!sessions.length) {
3489
+ showToast('Sessions not loaded yet — try again after they appear');
3490
+ return;
3491
+ }
3492
+ const orphaned = _findOrphanedKeys();
3493
+ let pinsChanged = false;
3494
+ for (const key of orphaned) {
3495
+ if (key.startsWith('__pinned__')) {
3496
+ pinnedSessionIds.delete(key.slice('__pinned__'.length));
3497
+ pinsChanged = true;
3498
+ } else if (key.startsWith('__sticky__')) {
3499
+ stickySessionIds.delete(key.slice('__sticky__'.length));
3500
+ pinsChanged = true;
3501
+ } else {
3502
+ localStorage.removeItem(key);
3503
+ }
3504
+ }
3505
+ if (pinsChanged) savePinnedSessions();
3506
+ const removed = orphaned.length;
3507
+
3508
+ showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
3509
+ renderSessions();
3510
+ _renderStorageTab();
3511
+ _updateStorageTotal();
3512
+ _updateOrphanedCount();
3513
+ }
3514
+ //#endregion
3515
+
3083
3516
  //#region KEYBOARD_SHORTCUTS
3084
3517
  function matchKey(e, ...keys) {
3085
3518
  if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false;
@@ -3151,6 +3584,11 @@ document.addEventListener('keydown', (e) => {
3151
3584
  }
3152
3585
  return;
3153
3586
  }
3587
+ if (e.code === 'KeyS' && e.shiftKey) {
3588
+ e.preventDefault();
3589
+ showStorageManager();
3590
+ return;
3591
+ }
3154
3592
 
3155
3593
  // Tab toggles focus zone
3156
3594
  if (e.key === 'Tab') {
@@ -4098,7 +4536,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
4098
4536
  } else {
4099
4537
  html += `<span style="${plainStyle}" title="${copyVal}">${escapeHtml(value)}</span>`;
4100
4538
  }
4101
- const jsCopyVal = copyVal.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
4539
+ const jsCopyVal = _escapeForJsAttr(copyVal);
4102
4540
  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>`;
4103
4541
  });
4104
4542
  html += `</div>`;
@@ -4376,6 +4814,7 @@ searchQuery = urlState.search || '';
4376
4814
 
4377
4815
  loadPreferences();
4378
4816
  pinnedSessionIds = loadPinnedSessions();
4817
+ stickySessionIds = loadStickySessions();
4379
4818
  setupEventSource();
4380
4819
 
4381
4820
  if (urlState.search) {
package/public/index.html CHANGED
@@ -161,6 +161,11 @@
161
161
  <path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
162
162
  </svg>
163
163
  </button>
164
+ <button class="icon-btn" onclick="showStorageManager()" title="Storage manager (Shift+S)" aria-label="Storage manager">
165
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
166
+ <ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
167
+ </svg>
168
+ </button>
164
169
  <button id="theme-toggle" class="icon-btn" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
165
170
  <svg id="theme-icon-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
166
171
  <path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
@@ -390,6 +395,10 @@
390
395
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">N</kbd></td>
391
396
  <td style="padding: 4px 0; color: var(--text-primary);">Toggle scratchpad</td>
392
397
  </tr>
398
+ <tr>
399
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+S</kbd></td>
400
+ <td style="padding: 4px 0; color: var(--text-primary);">Storage manager</td>
401
+ </tr>
393
402
  <tr>
394
403
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">J/K</kbd></td>
395
404
  <td style="padding: 4px 0; color: var(--text-primary);">Navigate messages in detail modal</td>
@@ -406,7 +415,7 @@
406
415
 
407
416
  <!-- Delete Confirmation Modal -->
408
417
  <div id="delete-confirm-modal" class="modal-overlay" onclick="closeDeleteConfirmModal()">
409
- <div class="modal" onclick="event.stopPropagation()" style="max-width: 400px;">
418
+ <div class="modal modal-sm" onclick="event.stopPropagation()">
410
419
  <div class="modal-header">
411
420
  <h3 class="modal-title">Delete Task</h3>
412
421
  <button class="modal-close" aria-label="Close dialog" onclick="closeDeleteConfirmModal()">
@@ -427,7 +436,7 @@
427
436
 
428
437
  <!-- Delete All Session Tasks Confirmation Modal -->
429
438
  <div id="delete-session-tasks-modal" class="modal-overlay" onclick="closeDeleteSessionTasksModal()">
430
- <div class="modal" onclick="event.stopPropagation()" style="max-width: 500px;">
439
+ <div class="modal" onclick="event.stopPropagation()">
431
440
  <div class="modal-header">
432
441
  <h3 class="modal-title">Delete All Tasks</h3>
433
442
  <button class="modal-close" aria-label="Close dialog" onclick="closeDeleteSessionTasksModal()">
@@ -449,7 +458,7 @@
449
458
 
450
459
  <!-- Delete Result Modal -->
451
460
  <div id="delete-result-modal" class="modal-overlay" onclick="closeDeleteResultModal()">
452
- <div class="modal" onclick="event.stopPropagation()" style="max-width: 500px;">
461
+ <div class="modal" onclick="event.stopPropagation()">
453
462
  <div class="modal-header">
454
463
  <h3 class="modal-title">Deletion Result</h3>
455
464
  <button class="modal-close" aria-label="Close dialog" onclick="closeDeleteResultModal()">
@@ -517,7 +526,7 @@
517
526
 
518
527
  <!-- Blocked Task Warning Modal -->
519
528
  <div id="blocked-task-modal" class="modal-overlay" onclick="closeBlockedTaskModal()">
520
- <div class="modal" onclick="event.stopPropagation()" style="max-width: 450px;">
529
+ <div class="modal modal-sm" onclick="event.stopPropagation()">
521
530
  <div class="modal-header">
522
531
  <h3 class="modal-title" style="display: flex; align-items: center; gap: 8px;">
523
532
  <svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" style="width: 20px; height: 20px;">
@@ -593,6 +602,29 @@
593
602
  </div>
594
603
  </div>
595
604
 
605
+ <div id="storage-modal" class="modal-overlay" onclick="closeStorageManager()">
606
+ <div class="modal storage-modal" onclick="event.stopPropagation()">
607
+ <div class="modal-header">
608
+ <h3 class="modal-title">Storage Manager</h3>
609
+ <div style="display:flex;align-items:center;gap:8px;">
610
+ <span id="storage-total" class="storage-total"></span>
611
+ <button class="modal-close" aria-label="Close dialog" onclick="closeStorageManager()">
612
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
613
+ </button>
614
+ </div>
615
+ </div>
616
+ <div class="storage-tabs">
617
+ <button class="storage-tab active" data-tab="sessions" onclick="switchStorageTab('sessions')">Sessions</button>
618
+ <button class="storage-tab" data-tab="scratchpads" onclick="switchStorageTab('scratchpads')">Scratchpads</button>
619
+ </div>
620
+ <div class="modal-body" id="storage-modal-body" style="overflow-y:auto;min-height:200px;max-height:60vh;padding-top:16px;padding-right:8px;"></div>
621
+ <div class="modal-footer">
622
+ <button id="storage-cleanup-btn" class="btn btn-secondary" onclick="cleanupOrphanedStorage()">Clean Orphaned</button>
623
+ <button class="btn btn-primary" onclick="closeStorageManager()">Close</button>
624
+ </div>
625
+ </div>
626
+ </div>
627
+
596
628
  <div id="toast" class="toast"></div>
597
629
  </body>
598
630
  </html>
package/public/style.css CHANGED
@@ -568,6 +568,11 @@ body::before {
568
568
  text-overflow: ellipsis;
569
569
  }
570
570
 
571
+ .session-item.plan-reveal {
572
+ outline: 1.5px solid var(--plan);
573
+ background: var(--plan-dim);
574
+ }
575
+
571
576
  .session-progress {
572
577
  display: flex;
573
578
  align-items: center;
@@ -1962,6 +1967,14 @@ body::before {
1962
1967
  fill: var(--accent);
1963
1968
  stroke: var(--accent);
1964
1969
  }
1970
+ .session-pin-btn.sticky {
1971
+ opacity: 1;
1972
+ color: var(--warning);
1973
+ }
1974
+ .session-pin-btn.sticky svg {
1975
+ fill: var(--warning);
1976
+ stroke: var(--warning);
1977
+ }
1965
1978
  .pinned-sessions-divider {
1966
1979
  height: 1px;
1967
1980
  margin: 4px 8px;
@@ -2570,10 +2583,10 @@ body.light .msg-assistant .msg-text {
2570
2583
  }
2571
2584
 
2572
2585
  .modal.fullscreen {
2573
- width: 76vw;
2574
- max-width: 76vw;
2575
- height: 85vh;
2576
- max-height: 85vh;
2586
+ width: 90vw;
2587
+ max-width: 90vw;
2588
+ height: 92vh;
2589
+ max-height: 92vh;
2577
2590
  }
2578
2591
 
2579
2592
  .modal.plan-modal {
@@ -2584,6 +2597,17 @@ body.light .msg-assistant .msg-text {
2584
2597
  flex-direction: column;
2585
2598
  }
2586
2599
 
2600
+ .modal.plan-modal.fullscreen {
2601
+ width: 90vw;
2602
+ max-width: 90vw;
2603
+ height: 92vh;
2604
+ max-height: 92vh;
2605
+ }
2606
+
2607
+ .modal-sm {
2608
+ max-width: 440px;
2609
+ }
2610
+
2587
2611
  @keyframes live-border-pulse {
2588
2612
  0%,
2589
2613
  100% {
@@ -2822,6 +2846,196 @@ select.form-input option:checked {
2822
2846
  }
2823
2847
  /* #endregion */
2824
2848
 
2849
+ /* #region STORAGE_MANAGER */
2850
+ .storage-modal {
2851
+ width: 90%;
2852
+ max-width: 860px;
2853
+ max-height: 85vh;
2854
+ display: flex;
2855
+ flex-direction: column;
2856
+ }
2857
+
2858
+ .storage-project-group {
2859
+ margin-bottom: 12px;
2860
+ }
2861
+
2862
+ .storage-project-header {
2863
+ font-size: 12px;
2864
+ font-weight: 600;
2865
+ color: var(--text-secondary);
2866
+ padding: 8px 10px;
2867
+ background: var(--bg-elevated);
2868
+ border-radius: 6px;
2869
+ margin-bottom: 4px;
2870
+ display: flex;
2871
+ justify-content: space-between;
2872
+ align-items: center;
2873
+ }
2874
+
2875
+ .storage-project-header .storage-item-meta {
2876
+ font-weight: 400;
2877
+ }
2878
+
2879
+ .storage-session-group {
2880
+ padding-left: 12px;
2881
+ border-left: 2px solid color-mix(in srgb, var(--border) 60%, transparent);
2882
+ margin-left: 8px;
2883
+ margin-bottom: 8px;
2884
+ }
2885
+
2886
+ .storage-total {
2887
+ font-size: 11px;
2888
+ color: var(--text-muted);
2889
+ font-family: var(--mono);
2890
+ }
2891
+
2892
+ .storage-tabs {
2893
+ display: flex;
2894
+ gap: 0;
2895
+ border-bottom: 1px solid var(--border);
2896
+ margin: -4px -24px 0;
2897
+ padding: 0 24px;
2898
+ flex-shrink: 0;
2899
+ }
2900
+
2901
+ .storage-tab {
2902
+ background: none;
2903
+ border: none;
2904
+ color: var(--text-secondary);
2905
+ padding: 8px 16px;
2906
+ font-size: 13px;
2907
+ font-family: var(--mono);
2908
+ cursor: pointer;
2909
+ border-bottom: 2px solid transparent;
2910
+ transition:
2911
+ color 0.15s,
2912
+ border-color 0.15s;
2913
+ }
2914
+
2915
+ .storage-tab:hover {
2916
+ color: var(--text-primary);
2917
+ }
2918
+
2919
+ .storage-tab.active {
2920
+ color: var(--accent);
2921
+ border-bottom-color: var(--accent);
2922
+ }
2923
+
2924
+ .storage-item {
2925
+ display: flex;
2926
+ align-items: center;
2927
+ justify-content: space-between;
2928
+ padding: 8px 0;
2929
+ border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
2930
+ gap: 8px;
2931
+ }
2932
+
2933
+ .storage-item:last-child {
2934
+ border-bottom: none;
2935
+ }
2936
+
2937
+ .storage-item-clickable {
2938
+ cursor: pointer;
2939
+ }
2940
+
2941
+ .storage-item-clickable:hover {
2942
+ background: var(--bg-hover);
2943
+ border-radius: 4px;
2944
+ }
2945
+
2946
+ .storage-item-id {
2947
+ font-family: var(--mono);
2948
+ font-size: 12px;
2949
+ color: var(--text-secondary);
2950
+ overflow: hidden;
2951
+ text-overflow: ellipsis;
2952
+ white-space: nowrap;
2953
+ flex: 1;
2954
+ min-width: 0;
2955
+ }
2956
+
2957
+ .storage-item-meta {
2958
+ font-size: 11px;
2959
+ color: var(--text-muted);
2960
+ flex-shrink: 0;
2961
+ white-space: nowrap;
2962
+ }
2963
+
2964
+ .storage-item-badge {
2965
+ font-size: 10px;
2966
+ padding: 2px 6px;
2967
+ border-radius: 4px;
2968
+ background: var(--bg-hover);
2969
+ color: var(--text-secondary);
2970
+ flex-shrink: 0;
2971
+ font-family: var(--mono);
2972
+ }
2973
+
2974
+ .storage-item-badge.orphan {
2975
+ background: rgba(239, 68, 68, 0.15);
2976
+ color: #ef4444;
2977
+ }
2978
+
2979
+ .storage-item-badge.pinned {
2980
+ background: color-mix(in srgb, var(--accent) 15%, transparent);
2981
+ color: var(--accent);
2982
+ }
2983
+
2984
+ .storage-item-badge.sticky {
2985
+ background: rgba(234, 179, 8, 0.15);
2986
+ color: #eab308;
2987
+ }
2988
+
2989
+ .storage-item-actions {
2990
+ display: flex;
2991
+ gap: 4px;
2992
+ flex-shrink: 0;
2993
+ }
2994
+
2995
+ .storage-item-actions button {
2996
+ background: none;
2997
+ border: 1px solid var(--border);
2998
+ border-radius: 4px;
2999
+ color: var(--text-secondary);
3000
+ padding: 2px 8px;
3001
+ font-size: 11px;
3002
+ cursor: pointer;
3003
+ font-family: var(--mono);
3004
+ transition: all 0.15s;
3005
+ }
3006
+
3007
+ .storage-item-actions button:hover {
3008
+ background: var(--bg-hover);
3009
+ color: var(--text-primary);
3010
+ }
3011
+
3012
+ .storage-item-actions button.danger:hover {
3013
+ background: rgba(239, 68, 68, 0.15);
3014
+ color: #ef4444;
3015
+ }
3016
+
3017
+ .storage-group-header {
3018
+ font-size: 12px;
3019
+ color: var(--text-muted);
3020
+ padding: 12px 0 4px;
3021
+ font-weight: 600;
3022
+ display: flex;
3023
+ justify-content: space-between;
3024
+ align-items: center;
3025
+ }
3026
+
3027
+ .storage-group-header:first-child {
3028
+ padding-top: 0;
3029
+ }
3030
+
3031
+ .storage-empty {
3032
+ text-align: center;
3033
+ padding: 32px 0;
3034
+ color: var(--text-muted);
3035
+ font-size: 13px;
3036
+ }
3037
+ /* #endregion */
3038
+
2825
3039
  /* #region A11Y */
2826
3040
  .skip-link {
2827
3041
  position: absolute;