claude-code-kanban 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/public/index.html +571 -118
  3. package/server.js +155 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
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": {
package/public/index.html CHANGED
@@ -13,6 +13,9 @@
13
13
  </style>
14
14
  <script defer src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
15
15
  <script defer src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
16
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css" id="hljs-theme-dark">
17
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css" id="hljs-theme-light" disabled>
18
+ <script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js"></script>
16
19
  <style>
17
20
  :root {
18
21
  --bg-deep: #101114;
@@ -877,8 +880,8 @@
877
880
  }
878
881
 
879
882
  .detail-close {
880
- width: 28px;
881
- height: 28px;
883
+ width: 34px;
884
+ height: 34px;
882
885
  display: flex;
883
886
  align-items: center;
884
887
  justify-content: center;
@@ -896,8 +899,8 @@
896
899
  }
897
900
 
898
901
  .detail-close svg {
899
- width: 16px;
900
- height: 16px;
902
+ width: 20px;
903
+ height: 20px;
901
904
  }
902
905
 
903
906
  .detail-content {
@@ -933,6 +936,34 @@
933
936
  font-family: var(--serif);
934
937
  font-size: 22px;
935
938
  line-height: 1.4;
939
+ cursor: pointer;
940
+ padding: 2px 4px;
941
+ margin: -2px -4px;
942
+ border-radius: 4px;
943
+ border: 1px solid transparent;
944
+ transition: border-color 0.15s ease;
945
+ }
946
+
947
+ .detail-title:hover {
948
+ border-color: var(--border);
949
+ }
950
+
951
+ .detail-title-input {
952
+ font-family: var(--serif);
953
+ font-size: 22px;
954
+ line-height: 1.4;
955
+ width: 100%;
956
+ padding: 2px 4px;
957
+ margin: -2px -4px;
958
+ background: var(--bg-elevated);
959
+ border: 1px solid var(--accent);
960
+ border-radius: 4px;
961
+ color: var(--text-primary);
962
+ box-shadow: 0 0 0 2px var(--accent-dim);
963
+ }
964
+
965
+ .detail-title-input:focus {
966
+ outline: none;
936
967
  }
937
968
 
938
969
  .detail-status {
@@ -1008,15 +1039,99 @@
1008
1039
  font-size: 14px;
1009
1040
  line-height: 1.7;
1010
1041
  color: var(--text-secondary);
1042
+ cursor: pointer;
1043
+ padding: 4px 6px;
1044
+ margin: -4px -6px;
1045
+ border-radius: 4px;
1046
+ border: 1px solid transparent;
1047
+ transition: border-color 0.15s ease;
1048
+ }
1049
+
1050
+ .detail-desc:hover {
1051
+ border-color: var(--border);
1052
+ }
1053
+
1054
+ .detail-desc-textarea {
1055
+ width: 100%;
1056
+ min-height: 120px;
1057
+ padding: 8px 10px;
1058
+ margin: -4px -6px;
1059
+ background: var(--bg-elevated);
1060
+ border: 1px solid var(--accent);
1061
+ border-radius: 4px;
1062
+ color: var(--text-primary);
1063
+ font-family: var(--mono);
1064
+ font-size: 13px;
1065
+ line-height: 1.6;
1066
+ resize: vertical;
1067
+ box-shadow: 0 0 0 2px var(--accent-dim);
1068
+ }
1069
+
1070
+ .detail-desc-textarea:focus {
1071
+ outline: none;
1072
+ }
1073
+
1074
+ .edit-actions {
1075
+ display: flex;
1076
+ gap: 8px;
1077
+ justify-content: flex-end;
1078
+ margin-top: 8px;
1079
+ }
1080
+
1081
+ .edit-actions button {
1082
+ padding: 6px 14px;
1083
+ border: none;
1084
+ border-radius: 4px;
1085
+ font-family: var(--mono);
1086
+ font-size: 11px;
1087
+ font-weight: 500;
1088
+ cursor: pointer;
1089
+ transition: all 0.15s ease;
1090
+ }
1091
+
1092
+ .edit-save {
1093
+ background: var(--accent);
1094
+ color: white;
1095
+ }
1096
+
1097
+ .edit-save:hover {
1098
+ filter: brightness(1.1);
1099
+ }
1100
+
1101
+ .edit-cancel {
1102
+ background: var(--bg-elevated);
1103
+ color: var(--text-secondary);
1104
+ border: 1px solid var(--border) !important;
1105
+ }
1106
+
1107
+ .edit-cancel:hover {
1108
+ color: var(--text-primary);
1109
+ }
1110
+
1111
+ .detail-deps {
1112
+ font-size: 14px;
1113
+ line-height: 1.7;
1114
+ color: var(--text-secondary);
1011
1115
  }
1012
1116
 
1013
1117
  .detail-desc pre {
1118
+ border-radius: 6px;
1119
+ overflow: hidden;
1120
+ margin: 12px 0;
1121
+ font-size: 12px;
1122
+ }
1123
+
1124
+ .detail-desc pre code.hljs {
1125
+ padding: 12px;
1126
+ border-radius: 6px;
1127
+ }
1128
+
1129
+ .detail-desc pre code {
1014
1130
  background: var(--bg-elevated);
1015
1131
  padding: 12px;
1016
1132
  border-radius: 6px;
1133
+ display: block;
1017
1134
  overflow-x: auto;
1018
- margin: 12px 0;
1019
- font-size: 12px;
1020
1135
  }
1021
1136
 
1022
1137
  .detail-desc code {
@@ -1026,11 +1141,6 @@
1026
1141
  font-size: 0.9em;
1027
1142
  }
1028
1143
 
1029
- .detail-desc pre code {
1030
- background: transparent;
1031
- padding: 0;
1032
- }
1033
-
1034
1144
  .detail-desc hr {
1035
1145
  border: none;
1036
1146
  border-top: 1px solid var(--border);
@@ -1366,6 +1476,19 @@
1366
1476
  display: flex;
1367
1477
  }
1368
1478
 
1479
+ .plan-modal-overlay {
1480
+ z-index: 10001;
1481
+ background: rgba(0, 0, 0, 0.6);
1482
+ }
1483
+
1484
+ .modal.plan-modal {
1485
+ width: 60vw;
1486
+ max-width: 60vw;
1487
+ max-height: 90vh;
1488
+ display: flex;
1489
+ flex-direction: column;
1490
+ }
1491
+
1369
1492
  .modal {
1370
1493
  background: var(--bg-surface);
1371
1494
  border: 1px solid var(--border);
@@ -1533,6 +1656,11 @@
1533
1656
  border-color: var(--text-muted);
1534
1657
  }
1535
1658
 
1659
+ .btn:focus-visible {
1660
+ outline: 2px solid var(--accent);
1661
+ outline-offset: 2px;
1662
+ }
1663
+
1536
1664
  /* Skip navigation */
1537
1665
  .skip-link {
1538
1666
  position: absolute;
@@ -1848,11 +1976,18 @@
1848
1976
  <aside id="detail-panel" class="detail-panel">
1849
1977
  <header class="detail-header">
1850
1978
  <h3>Task Details</h3>
1851
- <button id="close-detail" class="detail-close" aria-label="Close detail panel">
1852
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1853
- <path d="M6 18L18 6M6 6l12 12"/>
1854
- </svg>
1855
- </button>
1979
+ <div style="display: flex; gap: 8px; align-items: center;">
1980
+ <button id="delete-task-btn" class="icon-btn" title="Delete task (D)" aria-label="Delete task" style="color: #ef4444; border-color: #ef4444; display: none;">
1981
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1982
+ <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
1983
+ </svg>
1984
+ </button>
1985
+ <button id="close-detail" class="detail-close" aria-label="Close detail panel">
1986
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1987
+ <path d="M6 18L18 6M6 6l12 12"/>
1988
+ </svg>
1989
+ </button>
1990
+ </div>
1856
1991
  </header>
1857
1992
  <div id="detail-content" class="detail-content"></div>
1858
1993
  </aside>
@@ -1866,7 +2001,8 @@
1866
2001
  let viewMode = 'session';
1867
2002
  let sessionFilter = 'active';
1868
2003
  let sessionLimit = '20';
1869
- let filterProject = null; // null = all projects, or project path to filter
2004
+ let filterProject = '__recent__'; // null = all, '__recent__' = last 24h, or project path
2005
+ let recentProjects = new Set();
1870
2006
  let searchQuery = ''; // Search query for fuzzy search
1871
2007
  let allTasksCache = []; // Cache all tasks for search
1872
2008
  let bulkDeleteSessionId = null; // Track session for bulk delete
@@ -1895,7 +2031,7 @@
1895
2031
  if (currentSessionId) params.set('session', currentSessionId);
1896
2032
  if (sessionFilter !== 'active') params.set('filter', sessionFilter);
1897
2033
  if (sessionLimit !== '20') params.set('limit', sessionLimit);
1898
- if (filterProject) params.set('project', filterProject);
2034
+ if (filterProject && filterProject !== '__recent__') params.set('project', filterProject);
1899
2035
  if (ownerFilter) params.set('owner', ownerFilter);
1900
2036
  if (searchQuery) params.set('search', searchQuery);
1901
2037
  const qs = params.toString();
@@ -1907,7 +2043,7 @@
1907
2043
  history.replaceState(null, '', window.location.pathname);
1908
2044
  sessionFilter = 'active';
1909
2045
  sessionLimit = '20';
1910
- filterProject = null;
2046
+ filterProject = '__recent__';
1911
2047
  ownerFilter = '';
1912
2048
  searchQuery = '';
1913
2049
  viewMode = 'all';
@@ -2192,7 +2328,7 @@
2192
2328
  const allTasks = await res.json();
2193
2329
  let activeTasks = allTasks.filter(t => t.status === 'in_progress');
2194
2330
  if (filterProject) {
2195
- activeTasks = activeTasks.filter(t => t.project === filterProject);
2331
+ activeTasks = activeTasks.filter(t => matchesProjectFilter(t.project));
2196
2332
  }
2197
2333
  renderLiveUpdates(activeTasks);
2198
2334
  } catch (error) {
@@ -2270,7 +2406,7 @@
2270
2406
  const res = await fetch('/api/tasks/all');
2271
2407
  let tasks = await res.json();
2272
2408
  if (filterProject) {
2273
- tasks = tasks.filter(t => t.project === filterProject);
2409
+ tasks = tasks.filter(t => matchesProjectFilter(t.project));
2274
2410
  }
2275
2411
  currentTasks = tasks;
2276
2412
  updateUrl();
@@ -2290,9 +2426,10 @@
2290
2426
  const completed = currentTasks.filter(t => t.status === 'completed').length;
2291
2427
  const percent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
2292
2428
 
2293
- const projectName = filterProject ? filterProject.split('/').pop() : null;
2294
- sessionTitle.textContent = filterProject ? `Tasks: ${projectName}` : 'All Tasks';
2295
- sessionMeta.textContent = filterProject
2429
+ const isFiltered = filterProject && filterProject !== '__recent__';
2430
+ const projectName = isFiltered ? filterProject.split(/[/\\]/).pop() : null;
2431
+ sessionTitle.textContent = isFiltered ? `Tasks: ${projectName}` : (filterProject === '__recent__' ? 'Recent Tasks' : 'All Tasks');
2432
+ sessionMeta.textContent = isFiltered
2296
2433
  ? `${totalTasks} tasks in this project`
2297
2434
  : `${totalTasks} tasks across ${sessions.length} sessions`;
2298
2435
  progressPercent.textContent = `${percent}%`;
@@ -2310,7 +2447,7 @@
2310
2447
  filteredSessions = filteredSessions.filter(s => s.pending > 0 || s.inProgress > 0);
2311
2448
  }
2312
2449
  if (filterProject) {
2313
- filteredSessions = filteredSessions.filter(s => s.project === filterProject);
2450
+ filteredSessions = filteredSessions.filter(s => matchesProjectFilter(s.project));
2314
2451
  }
2315
2452
 
2316
2453
  // Apply search filter
@@ -2389,14 +2526,14 @@
2389
2526
  const memberCount = session.memberCount || 0;
2390
2527
 
2391
2528
  return `
2392
- <button onclick="fetchTasks('${session.id}')" class="session-item ${isActive ? 'active' : ''}" title="${tooltip}">
2529
+ <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''}" title="${tooltip}">
2393
2530
  <div class="session-name">${escapeHtml(primaryName)}</div>
2394
2531
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
2395
2532
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
2396
2533
  <div class="session-progress">
2397
2534
  <span class="session-indicators">
2398
2535
  ${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>` : ''}
2399
- ${isTeam ? `<span class="team-info-btn" onclick="event.stopPropagation(); showTeamModalForSession('${session.id}')" title="View team info">ℹ</span>` : ''}
2536
+ ${(isTeam || session.project) ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2400
2537
  ${hasInProgress ? '<span class="pulse"></span>' : ''}
2401
2538
  </span>
2402
2539
  <div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
@@ -2530,7 +2667,7 @@
2530
2667
  const card = document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`)
2531
2668
  || document.querySelector(`.task-card[data-task-id="${selectedTaskId}"]`);
2532
2669
  if (card) {
2533
- card.classList.add('selected');
2670
+ if (focusZone === 'board') card.classList.add('selected');
2534
2671
  } else {
2535
2672
  selectedTaskId = null;
2536
2673
  selectedSessionId = null;
@@ -2660,13 +2797,20 @@
2660
2797
  selectSessionByIndex(selectedSessionIdx);
2661
2798
  }
2662
2799
  } else {
2800
+ // Session changed while in sidebar — reset stale selection
2801
+ if (selectedSessionId && selectedSessionId !== currentSessionId) {
2802
+ selectedTaskId = null;
2803
+ selectedSessionId = null;
2804
+ }
2663
2805
  if (selectedTaskId) {
2664
- const card = document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`)
2665
- || document.querySelector(`.task-card[data-task-id="${selectedTaskId}"]`);
2806
+ const card = document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`);
2666
2807
  if (card) card.classList.add('selected');
2667
2808
  } else {
2668
2809
  navigateVertical(1);
2669
2810
  }
2811
+ if (selectedTaskId && detailPanel.classList.contains('visible')) {
2812
+ showTaskDetail(selectedTaskId, selectedSessionId);
2813
+ }
2670
2814
  }
2671
2815
  }
2672
2816
 
@@ -2738,19 +2882,8 @@
2738
2882
 
2739
2883
  detailContent.innerHTML = `
2740
2884
  <div class="detail-section">
2741
- <div style="display: flex; justify-content: space-between; align-items: start;">
2742
- <div style="flex: 1;">
2743
- <div class="detail-label">Task #${task.id}</div>
2744
- <h2 class="detail-title">${escapeHtml(task.subject)}</h2>
2745
- </div>
2746
- <div style="display: flex; gap: 8px;">
2747
- <button id="delete-task-btn" class="icon-btn" title="Delete task (D)" aria-label="Delete task" style="color: #ef4444; border-color: #ef4444;">
2748
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2749
- <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
2750
- </svg>
2751
- </button>
2752
- </div>
2753
- </div>
2885
+ <div class="detail-label">Task #${task.id}</div>
2886
+ <h2 class="detail-title">${escapeHtml(task.subject)}</h2>
2754
2887
  </div>
2755
2888
 
2756
2889
  <div class="detail-section" style="display: flex; gap: 12px; align-items: center;">
@@ -2774,7 +2907,7 @@
2774
2907
 
2775
2908
  <div class="detail-section">
2776
2909
  <div class="detail-label">Blocked By</div>
2777
- <div class="detail-desc">
2910
+ <div class="detail-deps">
2778
2911
  ${task.blockedBy && task.blockedBy.length > 0
2779
2912
  ? `<div class="detail-box blocked"><strong>Blocked by:</strong> ${task.blockedBy.map(id => '#' + id).join(', ')}</div>`
2780
2913
  : '<em style="color: var(--text-muted); font-size: 13px;">No dependencies</em>'}
@@ -2783,7 +2916,7 @@
2783
2916
 
2784
2917
  <div class="detail-section">
2785
2918
  <div class="detail-label">Blocks</div>
2786
- <div class="detail-desc">
2919
+ <div class="detail-deps">
2787
2920
  ${task.blocks && task.blocks.length > 0
2788
2921
  ? `<div class="detail-box blocks"><strong>Blocks:</strong> ${task.blocks.map(id => '#' + id).join(', ')}</div>`
2789
2922
  : '<em style="color: var(--text-muted); font-size: 13px;">No tasks blocked</em>'}
@@ -2800,7 +2933,111 @@
2800
2933
  `;
2801
2934
 
2802
2935
  // Setup button handlers
2803
- document.getElementById('delete-task-btn').onclick = () => deleteTask(task.id, actualSessionId);
2936
+ const deleteBtn = document.getElementById('delete-task-btn');
2937
+ deleteBtn.style.display = '';
2938
+ deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
2939
+
2940
+ // Setup inline editing
2941
+ const titleEl = detailContent.querySelector('.detail-title');
2942
+ if (titleEl) {
2943
+ titleEl.onclick = () => editTitle(titleEl, task, actualSessionId);
2944
+ }
2945
+
2946
+ const descEl = detailContent.querySelector('.detail-desc');
2947
+ if (descEl) {
2948
+ descEl.onclick = () => editDescription(descEl, task, actualSessionId);
2949
+ }
2950
+ }
2951
+
2952
+ function editTitle(titleEl, task, sessionId) {
2953
+ if (titleEl.querySelector('input')) return;
2954
+ const input = document.createElement('input');
2955
+ input.type = 'text';
2956
+ input.className = 'detail-title-input';
2957
+ input.value = task.subject;
2958
+
2959
+ titleEl.replaceWith(input);
2960
+ input.focus();
2961
+ input.select();
2962
+
2963
+ const save = async () => {
2964
+ const val = input.value.trim();
2965
+ if (val && val !== task.subject) {
2966
+ await saveTaskField(task.id, sessionId, 'subject', val);
2967
+ } else {
2968
+ showTaskDetail(task.id, sessionId);
2969
+ }
2970
+ };
2971
+
2972
+ input.onkeydown = (e) => {
2973
+ if (e.key === 'Enter') { e.preventDefault(); save(); }
2974
+ if (e.key === 'Escape') showTaskDetail(task.id, sessionId);
2975
+ };
2976
+ input.onblur = () => save();
2977
+ }
2978
+
2979
+ function editDescription(descEl, task, sessionId) {
2980
+ if (descEl.querySelector('textarea')) return;
2981
+ const wrapper = document.createElement('div');
2982
+ const textarea = document.createElement('textarea');
2983
+ textarea.className = 'detail-desc-textarea';
2984
+ textarea.value = task.description || '';
2985
+ textarea.rows = Math.max(5, (task.description || '').split('\n').length + 2);
2986
+
2987
+ const actions = document.createElement('div');
2988
+ actions.className = 'edit-actions';
2989
+
2990
+ const saveBtn = document.createElement('button');
2991
+ saveBtn.className = 'edit-save';
2992
+ saveBtn.textContent = 'Save';
2993
+
2994
+ const cancelBtn = document.createElement('button');
2995
+ cancelBtn.className = 'edit-cancel';
2996
+ cancelBtn.textContent = 'Cancel';
2997
+
2998
+ actions.append(cancelBtn, saveBtn);
2999
+ wrapper.append(textarea, actions);
3000
+ descEl.replaceWith(wrapper);
3001
+ textarea.focus();
3002
+
3003
+ const save = async () => {
3004
+ const val = textarea.value;
3005
+ if (val !== (task.description || '')) {
3006
+ await saveTaskField(task.id, sessionId, 'description', val);
3007
+ } else {
3008
+ showTaskDetail(task.id, sessionId);
3009
+ }
3010
+ };
3011
+
3012
+ saveBtn.onclick = save;
3013
+ cancelBtn.onclick = () => showTaskDetail(task.id, sessionId);
3014
+ textarea.onkeydown = (e) => {
3015
+ if (e.key === 'Escape') showTaskDetail(task.id, sessionId);
3016
+ };
3017
+ }
3018
+
3019
+ async function saveTaskField(taskId, sessionId, field, value) {
3020
+ try {
3021
+ const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
3022
+ method: 'PUT',
3023
+ headers: { 'Content-Type': 'application/json' },
3024
+ body: JSON.stringify({ [field]: value })
3025
+ });
3026
+
3027
+ if (res.ok) {
3028
+ lastCurrentTasksHash = null;
3029
+ if (viewMode === 'all') {
3030
+ const tasksRes = await fetch('/api/tasks/all');
3031
+ currentTasks = await tasksRes.json();
3032
+ renderKanban();
3033
+ } else {
3034
+ await fetchTasks(sessionId);
3035
+ }
3036
+ showTaskDetail(taskId, sessionId);
3037
+ }
3038
+ } catch (error) {
3039
+ console.error('Failed to update task:', error);
3040
+ }
2804
3041
  }
2805
3042
 
2806
3043
  async function addNote(event, taskId, sessionId) {
@@ -2834,10 +3071,12 @@
2834
3071
 
2835
3072
  function closeDetailPanel() {
2836
3073
  detailPanel.classList.remove('visible');
3074
+ document.getElementById('delete-task-btn').style.display = 'none';
2837
3075
  }
2838
3076
 
2839
3077
  let deleteTaskId = null;
2840
3078
  let deleteSessionId = null;
3079
+ let deleteModalKeyHandler = null;
2841
3080
 
2842
3081
  function showBlockedTaskModal(task) {
2843
3082
  const messageDiv = document.getElementById('blocked-task-message');
@@ -2890,15 +3129,31 @@
2890
3129
  const modal = document.getElementById('delete-confirm-modal');
2891
3130
  modal.classList.add('visible');
2892
3131
 
2893
- // Handle ESC key
2894
- const keyHandler = (e) => {
3132
+ const buttons = [
3133
+ document.getElementById('delete-cancel-btn'),
3134
+ document.getElementById('delete-confirm-btn')
3135
+ ];
3136
+ let focusIdx = 1;
3137
+ buttons[focusIdx].focus();
3138
+
3139
+ deleteModalKeyHandler = (e) => {
2895
3140
  if (e.key === 'Escape') {
2896
3141
  e.preventDefault();
2897
3142
  closeDeleteConfirmModal();
2898
- document.removeEventListener('keydown', keyHandler);
3143
+ } else if (e.key === 'ArrowLeft' || e.key === 'h') {
3144
+ e.preventDefault();
3145
+ focusIdx = 0;
3146
+ buttons[focusIdx].focus();
3147
+ } else if (e.key === 'ArrowRight' || e.key === 'l') {
3148
+ e.preventDefault();
3149
+ focusIdx = 1;
3150
+ buttons[focusIdx].focus();
3151
+ } else if (e.key === 'Enter') {
3152
+ e.preventDefault();
3153
+ buttons[focusIdx].click();
2899
3154
  }
2900
3155
  };
2901
- document.addEventListener('keydown', keyHandler);
3156
+ document.addEventListener('keydown', deleteModalKeyHandler);
2902
3157
  }
2903
3158
 
2904
3159
  function closeDeleteConfirmModal() {
@@ -2906,6 +3161,10 @@
2906
3161
  modal.classList.remove('visible');
2907
3162
  deleteTaskId = null;
2908
3163
  deleteSessionId = null;
3164
+ if (deleteModalKeyHandler) {
3165
+ document.removeEventListener('keydown', deleteModalKeyHandler);
3166
+ deleteModalKeyHandler = null;
3167
+ }
2909
3168
  }
2910
3169
 
2911
3170
  async function confirmDelete() {
@@ -3008,6 +3267,20 @@
3008
3267
  setFocusZone('board');
3009
3268
  return;
3010
3269
  }
3270
+ if (e.key === 'p' || e.key === 'P') {
3271
+ e.preventDefault();
3272
+ const highlighted = sessionsList.querySelector('.session-item.kb-selected');
3273
+ const sid = highlighted?.dataset.sessionId || currentSessionId;
3274
+ if (sid) openPlanForSession(sid);
3275
+ return;
3276
+ }
3277
+ if (e.key === 'i' || e.key === 'I') {
3278
+ e.preventDefault();
3279
+ const highlighted = sessionsList.querySelector('.session-item.kb-selected');
3280
+ const sid = highlighted?.dataset.sessionId || currentSessionId;
3281
+ if (sid) showSessionInfoModal(sid);
3282
+ return;
3283
+ }
3011
3284
  if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
3012
3285
  e.preventDefault();
3013
3286
  showHelpModal();
@@ -3050,18 +3323,23 @@
3050
3323
  closeDetailPanel();
3051
3324
  }
3052
3325
 
3053
- if (detailPanel.classList.contains('visible')) {
3054
- const labelElement = document.querySelector('.detail-label');
3055
- if (!labelElement) return;
3056
- const taskId = labelElement.textContent.match(/\d+/)?.[0];
3057
- if (!taskId) return;
3058
- const task = currentTasks.find(t => t.id === taskId);
3059
- if (!task) return;
3060
- const sessionId = task.sessionId || currentSessionId;
3061
- if (e.key === 'd' || e.key === 'D') {
3062
- e.preventDefault();
3063
- deleteTask(taskId, sessionId);
3064
- }
3326
+ if (e.key === 'p' || e.key === 'P') {
3327
+ e.preventDefault();
3328
+ const sid = selectedSessionId || currentSessionId;
3329
+ if (sid) openPlanForSession(sid);
3330
+ return;
3331
+ }
3332
+
3333
+ if (e.key === 'i' || e.key === 'I') {
3334
+ e.preventDefault();
3335
+ const sid = selectedSessionId || currentSessionId;
3336
+ if (sid) showSessionInfoModal(sid);
3337
+ return;
3338
+ }
3339
+
3340
+ if ((e.key === 'd' || e.key === 'D') && selectedTaskId) {
3341
+ e.preventDefault();
3342
+ deleteTask(selectedTaskId, selectedSessionId || currentSessionId);
3065
3343
  }
3066
3344
 
3067
3345
  if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
@@ -3178,6 +3456,12 @@
3178
3456
  fetchSessions();
3179
3457
  }
3180
3458
 
3459
+ function matchesProjectFilter(project) {
3460
+ if (!filterProject) return true;
3461
+ if (filterProject === '__recent__') return recentProjects.has(project);
3462
+ return project === filterProject;
3463
+ }
3464
+
3181
3465
  function filterByProject(project) {
3182
3466
  filterProject = project || null;
3183
3467
  updateUrl();
@@ -3186,15 +3470,30 @@
3186
3470
  showAllTasks();
3187
3471
  }
3188
3472
 
3189
- function updateProjectDropdown() {
3473
+ async function updateProjectDropdown() {
3190
3474
  const dropdown = document.getElementById('project-filter');
3191
- const projects = [...new Set(sessions.map(s => s.project).filter(Boolean))].sort();
3475
+ let projects;
3476
+ try {
3477
+ const res = await fetch('/api/projects');
3478
+ projects = await res.json();
3479
+ } catch (e) {
3480
+ projects = [...new Set(sessions.map(s => s.project).filter(Boolean))].sort()
3481
+ .map(p => ({ path: p, modifiedAt: null }));
3482
+ }
3483
+
3484
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
3485
+ recentProjects = new Set(
3486
+ projects.filter(p => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff).map(p => p.path)
3487
+ );
3192
3488
 
3193
- dropdown.innerHTML = '<option value="">All Projects</option>' +
3489
+ const recentSelected = filterProject === '__recent__' ? ' selected' : '';
3490
+ dropdown.innerHTML =
3491
+ '<option value="">All Projects</option>' +
3492
+ `<option value="__recent__"${recentSelected}>Recent (24h)</option>` +
3194
3493
  projects.map(p => {
3195
- const name = p.split('/').pop();
3196
- const selected = p === filterProject ? ' selected' : '';
3197
- return `<option value="${p}"${selected} title="${escapeHtml(p)}">${escapeHtml(name)}</option>`;
3494
+ const name = p.path.split(/[/\\]/).pop();
3495
+ const selected = p.path === filterProject ? ' selected' : '';
3496
+ return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
3198
3497
  }).join('');
3199
3498
  }
3200
3499
 
@@ -3210,6 +3509,15 @@
3210
3509
  localStorage.setItem('theme', 'light');
3211
3510
  }
3212
3511
  updateThemeIcon();
3512
+ syncHljsTheme();
3513
+ }
3514
+
3515
+ function syncHljsTheme() {
3516
+ const isLight = document.body.classList.contains('light');
3517
+ const dark = document.getElementById('hljs-theme-dark');
3518
+ const light = document.getElementById('hljs-theme-light');
3519
+ if (dark) dark.disabled = isLight;
3520
+ if (light) light.disabled = !isLight;
3213
3521
  }
3214
3522
 
3215
3523
  function updateThemeIcon() {
@@ -3231,6 +3539,7 @@
3231
3539
  }
3232
3540
  // If no saved preference, system prefers-color-scheme CSS handles it
3233
3541
  updateThemeIcon();
3542
+ syncHljsTheme();
3234
3543
  }
3235
3544
 
3236
3545
  function toggleSidebar() {
@@ -3239,6 +3548,7 @@
3239
3548
  localStorage.setItem('sidebar-collapsed', collapsed);
3240
3549
  if (collapsed) {
3241
3550
  sidebar.style.width = '';
3551
+ if (focusZone === 'sidebar') setFocusZone('board');
3242
3552
  } else {
3243
3553
  const w = getComputedStyle(sidebar).getPropertyValue('--sidebar-width');
3244
3554
  if (w) sidebar.style.width = w;
@@ -3294,68 +3604,127 @@
3294
3604
  document.getElementById('session-limit').value = sessionLimit;
3295
3605
  }
3296
3606
 
3297
- async function showTeamModalForSession(sessionId) {
3607
+ async function showSessionInfoModal(sessionId) {
3298
3608
  const session = sessions.find(s => s.id === sessionId);
3299
- if (!session || !session.isTeam) return;
3300
- try {
3301
- const res = await fetch(`/api/teams/${sessionId}`);
3302
- if (!res.ok) return;
3303
- const teamConfig = await res.json();
3304
- showTeamModal(teamConfig, currentSessionId === sessionId ? currentTasks : []);
3305
- } catch (e) {
3306
- console.error('Failed to fetch team config:', e);
3609
+ if (!session) return;
3610
+
3611
+ const promises = [];
3612
+
3613
+ // Fetch team config
3614
+ let teamConfig = null;
3615
+ if (session.isTeam) {
3616
+ promises.push(
3617
+ fetch(`/api/teams/${sessionId}`).then(r => r.ok ? r.json() : null).catch(() => null)
3618
+ .then(data => { teamConfig = data; })
3619
+ );
3307
3620
  }
3621
+
3622
+ // Fetch plan
3623
+ let planContent = null;
3624
+ promises.push(
3625
+ fetch(`/api/sessions/${sessionId}/plan`).then(r => r.ok ? r.json() : null).catch(() => null)
3626
+ .then(data => { planContent = data?.content || null; })
3627
+ );
3628
+
3629
+ await Promise.all(promises);
3630
+
3631
+ const tasks = currentSessionId === sessionId ? currentTasks : [];
3632
+ _planSessionId = sessionId;
3633
+ showInfoModal(session, teamConfig, tasks, planContent);
3308
3634
  }
3309
3635
 
3310
- function showTeamModal(teamConfig, tasks) {
3636
+ let _pendingPlanContent = null;
3637
+
3638
+ function showInfoModal(session, teamConfig, tasks, planContent) {
3311
3639
  const modal = document.getElementById('team-modal');
3312
3640
  const titleEl = document.getElementById('team-modal-title');
3313
3641
  const bodyEl = document.getElementById('team-modal-body');
3314
3642
 
3315
- titleEl.textContent = `Team: ${teamConfig.team_name || teamConfig.name || 'Unknown'}`;
3643
+ titleEl.textContent = teamConfig
3644
+ ? `Team: ${teamConfig.team_name || teamConfig.name || 'Unknown'}`
3645
+ : (session.name || session.slug || session.id);
3316
3646
 
3317
- const ownerCounts = {};
3318
- tasks.forEach(t => {
3319
- if (t.owner) {
3320
- ownerCounts[t.owner] = (ownerCounts[t.owner] || 0) + 1;
3647
+ let html = '';
3648
+
3649
+ // Session & project details as compact key-value rows
3650
+ const infoRows = [];
3651
+ infoRows.push(['Session', `<span style="font-family: 'IBM Plex Mono', monospace; font-size: 11px; user-select: all;">${escapeHtml(session.id)}</span>`]);
3652
+ if (session.modifiedAt) {
3653
+ const d = new Date(session.modifiedAt);
3654
+ infoRows.push(['Last Modified', `${formatDate(session.modifiedAt)} <span style="font-size: 10px; color: var(--text-tertiary);">(${d.toLocaleString()})</span>`]);
3655
+ }
3656
+ if (session.project) {
3657
+ const projectName = session.project.split(/[/\\]/).pop();
3658
+ infoRows.push(['Project', `${escapeHtml(projectName)}<br><span style="font-size: 10px; color: var(--text-tertiary);">${escapeHtml(session.project)}</span>`]);
3659
+ if (session.gitBranch) {
3660
+ infoRows.push(['Branch', escapeHtml(session.gitBranch)]);
3661
+ }
3662
+ if (session.description) {
3663
+ infoRows.push(['Description', escapeHtml(session.description)]);
3321
3664
  }
3665
+ }
3666
+ html += `<div class="team-modal-meta" style="margin-bottom: 16px; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline;">`;
3667
+ infoRows.forEach(([label, value]) => {
3668
+ html += `<span style="font-weight: 500; color: var(--text-secondary); font-size: 12px; white-space: nowrap;">${label}</span><span>${value}</span>`;
3322
3669
  });
3670
+ html += `</div>`;
3671
+
3672
+ if (planContent) {
3673
+ _pendingPlanContent = planContent;
3674
+ const titleMatch = planContent.match(/^#\s+(.+)$/m);
3675
+ const planTitle = titleMatch ? titleMatch[1].trim() : null;
3676
+ html += `<div onclick="openPlanModal()" style="margin-bottom: 16px; padding: 10px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.15s ease;" onmouseover="this.style.borderColor='var(--accent)';this.style.background='var(--bg-hover)'" onmouseout="this.style.borderColor='var(--border)';this.style.background='var(--bg-elevated)'">
3677
+ <span style="font-size: 14px;">📋</span>
3678
+ <div style="flex: 1; min-width: 0;">
3679
+ <div style="font-size: 11px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">Plan</div>
3680
+ ${planTitle ? `<div style="font-size: 13px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(planTitle)}</div>` : ''}
3681
+ </div>
3682
+ <svg viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2" style="width: 16px; height: 16px; flex-shrink: 0;"><path d="M9 18l6-6-6-6"/></svg>
3683
+ </div>`;
3684
+ }
3323
3685
 
3324
- const members = teamConfig.members || [];
3325
- const description = teamConfig.description || '';
3326
- const lead = members.find(m => m.agentType === 'team-lead' || m.name === 'team-lead');
3686
+ // Team info section
3687
+ if (teamConfig) {
3688
+ const ownerCounts = {};
3689
+ tasks.forEach(t => {
3690
+ if (t.owner) ownerCounts[t.owner] = (ownerCounts[t.owner] || 0) + 1;
3691
+ });
3327
3692
 
3328
- let html = '';
3329
- if (description) {
3330
- html += `<div class="team-modal-desc">"${escapeHtml(description)}"</div>`;
3331
- }
3693
+ const members = teamConfig.members || [];
3694
+ const description = teamConfig.description || '';
3695
+ const lead = members.find(m => m.agentType === 'team-lead' || m.name === 'team-lead');
3332
3696
 
3333
- html += `<div style="font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 10px;">Members (${members.length})</div>`;
3697
+ if (description) {
3698
+ html += `<div class="team-modal-desc">"${escapeHtml(description)}"</div>`;
3699
+ }
3334
3700
 
3335
- members.forEach(member => {
3336
- const taskCount = ownerCounts[member.name] || 0;
3337
- html += `
3338
- <div class="team-member-card">
3339
- <div class="member-name">🟢 ${escapeHtml(member.name)}</div>
3340
- <div class="member-detail">Role: ${escapeHtml(member.agentType || 'unknown')}</div>
3341
- ${member.model ? `<div class="member-detail">Model: ${escapeHtml(member.model)}</div>` : ''}
3342
- <div class="member-tasks">Tasks: ${taskCount} assigned</div>
3343
- </div>
3344
- `;
3345
- });
3701
+ html += `<div style="font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 10px;">Members (${members.length})</div>`;
3346
3702
 
3347
- const metaParts = [];
3348
- if (teamConfig.created_at) {
3349
- metaParts.push(`Created: ${new Date(teamConfig.created_at).toLocaleString()}`);
3350
- }
3351
- if (lead) {
3352
- metaParts.push(`Lead: ${lead.name}`);
3353
- }
3354
- if (teamConfig.working_dir) {
3355
- metaParts.push(`Working dir: ${teamConfig.working_dir}`);
3356
- }
3357
- if (metaParts.length > 0) {
3358
- html += `<div class="team-modal-meta">${metaParts.map(p => escapeHtml(p)).join('<br>')}</div>`;
3703
+ members.forEach(member => {
3704
+ const taskCount = ownerCounts[member.name] || 0;
3705
+ html += `
3706
+ <div class="team-member-card">
3707
+ <div class="member-name">🟢 ${escapeHtml(member.name)}</div>
3708
+ <div class="member-detail">Role: ${escapeHtml(member.agentType || 'unknown')}</div>
3709
+ ${member.model ? `<div class="member-detail">Model: ${escapeHtml(member.model)}</div>` : ''}
3710
+ <div class="member-tasks">Tasks: ${taskCount} assigned</div>
3711
+ </div>
3712
+ `;
3713
+ });
3714
+
3715
+ const metaParts = [];
3716
+ if (teamConfig.created_at) {
3717
+ metaParts.push(`Created: ${new Date(teamConfig.created_at).toLocaleString()}`);
3718
+ }
3719
+ if (lead) {
3720
+ metaParts.push(`Lead: ${lead.name}`);
3721
+ }
3722
+ if (teamConfig.working_dir) {
3723
+ metaParts.push(`Working dir: ${teamConfig.working_dir}`);
3724
+ }
3725
+ if (metaParts.length > 0) {
3726
+ html += `<div class="team-modal-meta">${metaParts.map(p => escapeHtml(p)).join('<br>')}</div>`;
3727
+ }
3359
3728
  }
3360
3729
 
3361
3730
  bodyEl.innerHTML = html;
@@ -3363,6 +3732,7 @@
3363
3732
 
3364
3733
  const keyHandler = (e) => {
3365
3734
  if (e.key === 'Escape') {
3735
+ if (document.getElementById('plan-modal').classList.contains('visible')) return;
3366
3736
  e.preventDefault();
3367
3737
  closeTeamModal();
3368
3738
  document.removeEventListener('keydown', keyHandler);
@@ -3375,6 +3745,45 @@
3375
3745
  document.getElementById('team-modal').classList.remove('visible');
3376
3746
  }
3377
3747
 
3748
+ let _planSessionId = null;
3749
+
3750
+ function openPlanForSession(sid) {
3751
+ fetch(`/api/sessions/${sid}/plan`).then(r => r.ok ? r.json() : null).catch(() => null)
3752
+ .then(data => {
3753
+ if (data?.content) {
3754
+ _pendingPlanContent = data.content;
3755
+ _planSessionId = sid;
3756
+ openPlanModal();
3757
+ }
3758
+ });
3759
+ }
3760
+
3761
+ function openPlanModal() {
3762
+ if (!_pendingPlanContent) return;
3763
+ const body = document.getElementById('plan-modal-body');
3764
+ body.innerHTML = DOMPurify.sanitize(marked.parse(_pendingPlanContent));
3765
+ document.getElementById('plan-modal').classList.add('visible');
3766
+
3767
+ const keyHandler = (e) => {
3768
+ if (e.key === 'Escape') {
3769
+ e.preventDefault();
3770
+ e.stopPropagation();
3771
+ closePlanModal();
3772
+ document.removeEventListener('keydown', keyHandler, true);
3773
+ }
3774
+ };
3775
+ document.addEventListener('keydown', keyHandler, true);
3776
+ }
3777
+
3778
+ function closePlanModal() {
3779
+ document.getElementById('plan-modal').classList.remove('visible');
3780
+ }
3781
+
3782
+ function openPlanInEditor() {
3783
+ if (!_planSessionId) return;
3784
+ fetch(`/api/sessions/${_planSessionId}/plan/open`, { method: 'POST' }).catch(() => {});
3785
+ }
3786
+
3378
3787
  function updateOwnerFilter() {
3379
3788
  const bar = document.getElementById('owner-filter-bar');
3380
3789
  const select = document.getElementById('owner-filter');
@@ -3416,6 +3825,23 @@
3416
3825
 
3417
3826
  // Init
3418
3827
  loadTheme();
3828
+
3829
+ document.addEventListener('DOMContentLoaded', () => {
3830
+ if (typeof marked !== 'undefined' && typeof hljs !== 'undefined') {
3831
+ const renderer = new marked.Renderer();
3832
+ renderer.code = function({ text, lang }) {
3833
+ let highlighted;
3834
+ if (lang && hljs.getLanguage(lang)) {
3835
+ highlighted = hljs.highlight(text, { language: lang }).value;
3836
+ } else {
3837
+ highlighted = hljs.highlightAuto(text).value;
3838
+ }
3839
+ return `<pre><code class="hljs language-${escapeHtml(lang || '')}">${highlighted}</code></pre>`;
3840
+ };
3841
+ marked.use({ renderer });
3842
+ }
3843
+ });
3844
+
3419
3845
  loadSidebarState();
3420
3846
  initSidebarResize();
3421
3847
  fetch('/api/version').then(r => r.json()).then(d => {
@@ -3425,7 +3851,7 @@
3425
3851
  const urlState = getUrlState();
3426
3852
  sessionFilter = urlState.filter || 'active';
3427
3853
  sessionLimit = urlState.limit || '20';
3428
- filterProject = urlState.project || null;
3854
+ filterProject = urlState.project || '__recent__';
3429
3855
  ownerFilter = urlState.owner || '';
3430
3856
  searchQuery = urlState.search || '';
3431
3857
 
@@ -3449,7 +3875,7 @@
3449
3875
  const s = getUrlState();
3450
3876
  sessionFilter = s.filter || 'active';
3451
3877
  sessionLimit = s.limit || '20';
3452
- filterProject = s.project || null;
3878
+ filterProject = s.project || '__recent__';
3453
3879
  ownerFilter = s.owner || '';
3454
3880
  searchQuery = s.search || '';
3455
3881
  loadPreferences();
@@ -3520,6 +3946,14 @@
3520
3946
  <div>
3521
3947
  <h4 style="margin: 0 0 8px 0; color: var(--text-primary); font-size: 14px; font-weight: 600;">Task Actions</h4>
3522
3948
  <table style="width: 100%; font-size: 13px;">
3949
+ <tr>
3950
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">P</kbd></td>
3951
+ <td style="padding: 4px 0; color: var(--text-primary);">Open session plan</td>
3952
+ </tr>
3953
+ <tr>
3954
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">I</kbd></td>
3955
+ <td style="padding: 4px 0; color: var(--text-primary);">Open session info</td>
3956
+ </tr>
3523
3957
  <tr>
3524
3958
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">D</kbd></td>
3525
3959
  <td style="padding: 4px 0; color: var(--text-primary);">Delete selected task</td>
@@ -3549,8 +3983,8 @@
3549
3983
  <p id="delete-confirm-message" style="margin: 0; color: var(--text-primary);"></p>
3550
3984
  </div>
3551
3985
  <div class="modal-footer">
3552
- <button class="btn btn-secondary" onclick="closeDeleteConfirmModal()">Cancel</button>
3553
- <button class="btn btn-primary" onclick="confirmDelete()" style="background: #ef4444; border-color: #ef4444;">Delete</button>
3986
+ <button id="delete-cancel-btn" class="btn btn-secondary" onclick="closeDeleteConfirmModal()">Cancel</button>
3987
+ <button id="delete-confirm-btn" class="btn btn-primary" onclick="confirmDelete()" style="background: #ef4444; border-color: #ef4444;">Delete</button>
3554
3988
  </div>
3555
3989
  </div>
3556
3990
  </div>
@@ -3616,6 +4050,25 @@
3616
4050
  </div>
3617
4051
  </div>
3618
4052
 
4053
+ <!-- Plan Modal (stacked on top of info modal) -->
4054
+ <div id="plan-modal" class="modal-overlay plan-modal-overlay" onclick="closePlanModal()">
4055
+ <div class="modal plan-modal" onclick="event.stopPropagation()">
4056
+ <div class="modal-header">
4057
+ <h3 id="plan-modal-title" class="modal-title">Plan</h3>
4058
+ <button class="btn btn-secondary" style="padding: 4px 10px; font-size: 11px; margin-left: auto; margin-right: 12px;" onclick="openPlanInEditor()">Open in Editor</button>
4059
+ <button class="modal-close" aria-label="Close dialog" onclick="closePlanModal()">
4060
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4061
+ <path d="M18 6L6 18M6 6l12 12"/>
4062
+ </svg>
4063
+ </button>
4064
+ </div>
4065
+ <div id="plan-modal-body" class="modal-body detail-desc" style="overflow-y: auto; flex: 1;"></div>
4066
+ <div class="modal-footer">
4067
+ <button class="btn btn-primary" onclick="closePlanModal()">Close</button>
4068
+ </div>
4069
+ </div>
4070
+ </div>
4071
+
3619
4072
  <!-- Blocked Task Warning Modal -->
3620
4073
  <div id="blocked-task-modal" class="modal-overlay" onclick="closeBlockedTaskModal()">
3621
4074
  <div class="modal" onclick="event.stopPropagation()" style="max-width: 450px;">
package/server.js CHANGED
@@ -31,6 +31,7 @@ const CLAUDE_DIR = getClaudeDir();
31
31
  const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
32
32
  const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
33
33
  const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
34
+ const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
34
35
 
35
36
  function isTeamSession(sessionId) {
36
37
  return existsSync(path.join(TEAMS_DIR, sessionId, 'config.json'));
@@ -143,20 +144,35 @@ function loadSessionMetadata() {
143
144
 
144
145
  // Find all .jsonl files (session logs)
145
146
  const files = readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
147
+ const sessionIds = [];
146
148
 
149
+ // First pass: read all JSONL files
150
+ let resolvedProjectPath = null;
147
151
  for (const file of files) {
148
152
  const sessionId = file.replace('.jsonl', '');
149
153
  const jsonlPath = path.join(projectPath, file);
150
-
151
- // Read customTitle, slug, and actual project path from JSONL
152
154
  const sessionInfo = readSessionInfoFromJsonl(jsonlPath);
153
155
 
156
+ if (sessionInfo.projectPath && !resolvedProjectPath) {
157
+ resolvedProjectPath = sessionInfo.projectPath;
158
+ }
159
+
154
160
  metadata[sessionId] = {
155
161
  customTitle: sessionInfo.customTitle,
156
162
  slug: sessionInfo.slug,
157
163
  project: sessionInfo.projectPath || null,
158
164
  jsonlPath: jsonlPath
159
165
  };
166
+ sessionIds.push(sessionId);
167
+ }
168
+
169
+ // Second pass: fill in missing project paths from siblings
170
+ if (resolvedProjectPath) {
171
+ for (const sid of sessionIds) {
172
+ if (!metadata[sid].project) {
173
+ metadata[sid].project = resolvedProjectPath;
174
+ }
175
+ }
160
176
  }
161
177
 
162
178
  // Also check sessions-index.json for custom names (if /rename was used)
@@ -167,8 +183,15 @@ function loadSessionMetadata() {
167
183
  const entries = indexData.entries || [];
168
184
 
169
185
  for (const entry of entries) {
170
- if (entry.sessionId && metadata[entry.sessionId]) {
171
- // Add other useful fields
186
+ if (entry.sessionId) {
187
+ if (!metadata[entry.sessionId]) {
188
+ metadata[entry.sessionId] = {
189
+ customTitle: null,
190
+ slug: null,
191
+ project: entry.projectPath || null,
192
+ jsonlPath: null
193
+ };
194
+ }
172
195
  metadata[entry.sessionId].description = entry.description || null;
173
196
  metadata[entry.sessionId].gitBranch = entry.gitBranch || null;
174
197
  metadata[entry.sessionId].created = entry.created || null;
@@ -183,6 +206,25 @@ function loadSessionMetadata() {
183
206
  console.error('Error loading session metadata:', e);
184
207
  }
185
208
 
209
+ // For team sessions with no JSONL match, try team config for project path
210
+ if (existsSync(TASKS_DIR)) {
211
+ const taskDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
212
+ .filter(d => d.isDirectory());
213
+ for (const dir of taskDirs) {
214
+ if (!metadata[dir.name]) {
215
+ const teamConfig = loadTeamConfig(dir.name);
216
+ if (teamConfig) {
217
+ metadata[dir.name] = {
218
+ customTitle: null,
219
+ slug: null,
220
+ project: teamConfig.working_dir || null,
221
+ jsonlPath: null
222
+ };
223
+ }
224
+ }
225
+ }
226
+ }
227
+
186
228
  sessionMetadataCache = metadata;
187
229
  lastMetadataRefresh = now;
188
230
  return metadata;
@@ -275,6 +317,32 @@ app.get('/api/sessions', async (req, res) => {
275
317
  }
276
318
  }
277
319
 
320
+ // Add sessions from metadata that don't have task directories
321
+ for (const [sessionId, meta] of Object.entries(metadata)) {
322
+ if (!sessionsMap.has(sessionId)) {
323
+ let modifiedAt = meta.created || null;
324
+ if (!modifiedAt && meta.jsonlPath) {
325
+ try { modifiedAt = statSync(meta.jsonlPath).mtime.toISOString(); } catch (e) {}
326
+ }
327
+ sessionsMap.set(sessionId, {
328
+ id: sessionId,
329
+ name: getSessionDisplayName(sessionId, meta),
330
+ slug: meta.slug || null,
331
+ project: meta.project || null,
332
+ description: meta.description || null,
333
+ gitBranch: meta.gitBranch || null,
334
+ taskCount: 0,
335
+ completed: 0,
336
+ inProgress: 0,
337
+ pending: 0,
338
+ createdAt: meta.created || null,
339
+ modifiedAt: modifiedAt || new Date(0).toISOString(),
340
+ isTeam: false,
341
+ memberCount: 0
342
+ });
343
+ }
344
+ }
345
+
278
346
  // Convert map to array and sort by most recently modified
279
347
  let sessions = Array.from(sessionsMap.values());
280
348
  sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
@@ -291,6 +359,24 @@ app.get('/api/sessions', async (req, res) => {
291
359
  }
292
360
  });
293
361
 
362
+ // API: Get distinct project paths with last-modified timestamps
363
+ app.get('/api/projects', (req, res) => {
364
+ res.setHeader('Cache-Control', 'no-store');
365
+ const metadata = loadSessionMetadata();
366
+ const projectMap = {};
367
+ for (const meta of Object.values(metadata)) {
368
+ if (!meta.project) continue;
369
+ const mtime = meta.jsonlPath ? (() => { try { return statSync(meta.jsonlPath).mtime; } catch (e) { return null; } })() : null;
370
+ if (!projectMap[meta.project] || (mtime && mtime > projectMap[meta.project])) {
371
+ projectMap[meta.project] = mtime;
372
+ }
373
+ }
374
+ const projects = Object.entries(projectMap)
375
+ .map(([path, mtime]) => ({ path, modifiedAt: mtime ? mtime.toISOString() : null }))
376
+ .sort((a, b) => a.path.localeCompare(b.path));
377
+ res.json(projects);
378
+ });
379
+
294
380
  // API: Get tasks for a session
295
381
  app.get('/api/sessions/:sessionId', async (req, res) => {
296
382
  try {
@@ -322,6 +408,45 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
322
408
  }
323
409
  });
324
410
 
411
+ // API: Get session plan
412
+ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
413
+ try {
414
+ const metadata = loadSessionMetadata();
415
+ const meta = metadata[req.params.sessionId];
416
+ const slug = meta?.slug;
417
+ if (!slug) return res.status(404).json({ error: 'No plan found' });
418
+
419
+ const planPath = path.join(PLANS_DIR, `${slug}.md`);
420
+ if (!existsSync(planPath)) return res.status(404).json({ error: 'No plan found' });
421
+
422
+ const content = await fs.readFile(planPath, 'utf8');
423
+ res.json({ content, slug });
424
+ } catch (error) {
425
+ console.error('Error reading plan:', error);
426
+ res.status(500).json({ error: 'Failed to read plan' });
427
+ }
428
+ });
429
+
430
+ // API: Open session plan in VS Code
431
+ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
432
+ try {
433
+ const metadata = loadSessionMetadata();
434
+ const meta = metadata[req.params.sessionId];
435
+ const slug = meta?.slug;
436
+ if (!slug) return res.status(404).json({ error: 'No plan found' });
437
+
438
+ const planPath = path.join(PLANS_DIR, `${slug}.md`);
439
+ if (!existsSync(planPath)) return res.status(404).json({ error: 'No plan found' });
440
+
441
+ const editor = process.env.EDITOR || 'code';
442
+ require('child_process').exec(`${editor} "${planPath}"`);
443
+ res.json({ success: true });
444
+ } catch (error) {
445
+ console.error('Error opening plan in VS Code:', error);
446
+ res.status(500).json({ error: 'Failed to open plan' });
447
+ }
448
+ });
449
+
325
450
  // API: Get team config
326
451
  app.get('/api/teams/:name', (req, res) => {
327
452
  const config = loadTeamConfig(req.params.name);
@@ -407,6 +532,32 @@ app.post('/api/tasks/:sessionId/:taskId/note', async (req, res) => {
407
532
  }
408
533
  });
409
534
 
535
+ // API: Update task fields (subject, description)
536
+ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
537
+ try {
538
+ const { sessionId, taskId } = req.params;
539
+ const { subject, description } = req.body;
540
+
541
+ const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
542
+
543
+ if (!existsSync(taskPath)) {
544
+ return res.status(404).json({ error: 'Task not found' });
545
+ }
546
+
547
+ const task = JSON.parse(await fs.readFile(taskPath, 'utf8'));
548
+
549
+ if (subject !== undefined) task.subject = subject;
550
+ if (description !== undefined) task.description = description;
551
+
552
+ await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
553
+
554
+ res.json({ success: true, task });
555
+ } catch (error) {
556
+ console.error('Error updating task:', error);
557
+ res.status(500).json({ error: 'Failed to update task' });
558
+ }
559
+ });
560
+
410
561
  // API: Delete a task
411
562
  app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
412
563
  try {