taskplane 0.28.6 → 0.28.7

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.
@@ -616,7 +616,13 @@ function renderSummary(batch) {
616
616
  const isCurrent = i === waveIdx && (batch.phase === "executing" || batch.phase === "merging");
617
617
  const isMergingChip = i === waveIdx && batch.phase === "merging";
618
618
  const cls = isDone ? "done" : isMergingChip ? "current merging" : isCurrent ? "current" : "";
619
- wavesHtml += `<span class="wave-chip ${cls}">W${i + 1} [${taskIds.join(", ")}]</span>`;
619
+ // #484: Show lane parallelization within each wave. Group taskIds by
620
+ // their assigned lane: tasks on the same lane render with `→` (serial),
621
+ // tasks on different lanes render with ` | ` (parallel). Tooltip shows
622
+ // the expanded lane breakdown.
623
+ const { compact, tooltip } = formatWaveLaneBreakdown(taskIds, batch.lanes || [], i + 1);
624
+ const titleAttr = tooltip ? ` title="${escapeHtml(tooltip)}"` : "";
625
+ wavesHtml += `<span class="wave-chip ${cls}"${titleAttr}>W${i + 1} [${compact}]</span>`;
620
626
  });
621
627
  $summaryWaves.innerHTML = wavesHtml;
622
628
  } else {
@@ -624,6 +630,84 @@ function renderSummary(batch) {
624
630
  }
625
631
  }
626
632
 
633
+ /**
634
+ * Compute lane parallelization for a wave's tasks (#484).
635
+ *
636
+ * Returns:
637
+ * - `compact`: a string like "TP-165 → TP-166 | TP-168 | TP-167" suitable
638
+ * for the wave-chip body. Tasks on the same lane are joined by ` → `
639
+ * (in lane execution order, not just appearance order); separate lanes
640
+ * are joined by ` | `.
641
+ * - `tooltip`: a multi-line string like
642
+ * "W1\n L1: TP-165 → TP-166\n L2: TP-168\n L3: TP-167" suitable for
643
+ * the chip's `title` attribute. Empty string when no lane data is
644
+ * available (future waves not yet provisioned).
645
+ *
646
+ * When lane data is missing for one or more tasks (e.g., the wave has not
647
+ * yet been provisioned, or task hasn't been assigned), unassigned tasks
648
+ * are shown with the previous flat formatting and no tooltip is generated
649
+ * — this preserves backward compatibility with future-wave display.
650
+ */
651
+ function formatWaveLaneBreakdown(taskIds, lanes, waveNumber) {
652
+ if (!Array.isArray(taskIds) || taskIds.length === 0) {
653
+ return { compact: "", tooltip: "" };
654
+ }
655
+ // Build taskId → laneNumber map for the lanes that have any of these tasks.
656
+ const taskToLane = new Map();
657
+ for (const lane of lanes) {
658
+ if (!lane || !Array.isArray(lane.taskIds)) continue;
659
+ for (const tid of lane.taskIds) {
660
+ // First lane to claim a task wins (lanes shouldn't overlap, but be defensive).
661
+ if (!taskToLane.has(tid)) taskToLane.set(tid, lane.laneNumber);
662
+ }
663
+ }
664
+ // If no task in this wave has lane data, fall back to flat display.
665
+ const hasAnyLaneData = taskIds.some((t) => taskToLane.has(t));
666
+ if (!hasAnyLaneData) {
667
+ return { compact: taskIds.join(", "), tooltip: "" };
668
+ }
669
+ // Group taskIds by lane. Preserve appearance order across lanes.
670
+ // Within a lane, preserve the lane's own taskIds order so `→` reflects
671
+ // execution order, not the order taskIds happens to appear here.
672
+ const laneOrder = []; // lane numbers in order they first appear in taskIds
673
+ const laneToTasks = new Map(); // laneNumber → ordered taskIds for this wave
674
+ const unassigned = []; // taskIds not in any lane (shouldn't happen but be defensive)
675
+ for (const tid of taskIds) {
676
+ const ln = taskToLane.get(tid);
677
+ if (ln === undefined) {
678
+ unassigned.push(tid);
679
+ continue;
680
+ }
681
+ if (!laneToTasks.has(ln)) {
682
+ laneToTasks.set(ln, []);
683
+ laneOrder.push(ln);
684
+ }
685
+ laneToTasks.get(ln).push(tid);
686
+ }
687
+ // Sort each lane's tasks by their position in the lane.taskIds array
688
+ // so `→` always reflects execution order on that lane.
689
+ for (const ln of laneOrder) {
690
+ const lane = lanes.find((l) => l && l.laneNumber === ln);
691
+ if (lane && Array.isArray(lane.taskIds)) {
692
+ const orderIndex = new Map(lane.taskIds.map((t, idx) => [t, idx]));
693
+ laneToTasks.get(ln).sort((a, b) => (orderIndex.get(a) ?? 0) - (orderIndex.get(b) ?? 0));
694
+ }
695
+ }
696
+ // Build compact representation.
697
+ const laneSegments = laneOrder.map((ln) => laneToTasks.get(ln).join(" → "));
698
+ if (unassigned.length > 0) laneSegments.push(unassigned.join(", "));
699
+ const compact = laneSegments.join(" | ");
700
+ // Build tooltip representation.
701
+ const tooltipLines = [`W${waveNumber}`];
702
+ for (const ln of laneOrder) {
703
+ tooltipLines.push(` L${ln}: ${laneToTasks.get(ln).join(" → ")}`);
704
+ }
705
+ if (unassigned.length > 0) {
706
+ tooltipLines.push(` (unassigned): ${unassigned.join(", ")}`);
707
+ }
708
+ return { compact, tooltip: tooltipLines.join("\n") };
709
+ }
710
+
627
711
  // ─── Render: Lanes + Tasks (integrated) ─────────────────────────────────────
628
712
 
629
713
  function renderLanesTasks(batch, sessions) {
@@ -858,11 +942,16 @@ function renderLanesTasks(batch, sessions) {
858
942
  ? `<button class="viewer-eye-btn${isViewingStatus ? ' active' : ''}" onclick="viewStatusMd('${escapeHtml(task.taskId)}')" title="View STATUS.md">👁</button>`
859
943
  : '';
860
944
 
945
+ // #485: Show task title (from PROMPT.md `# Task: <ID> - <title>`) under
946
+ // the task-id when available. Falls back to just the ID when missing.
947
+ const titleHtml = task.taskTitle
948
+ ? `<div class="task-title-subtitle">${escapeHtml(task.taskTitle)}</div>`
949
+ : "";
861
950
  html += `
862
951
  <div class="task-row">
863
952
  <span class="task-icon"><span class="status-dot ${task.status}"></span></span>
864
953
  <span class="task-actions">${eyeHtml}</span>
865
- <span class="task-id status-${task.status}">${escapeHtml(task.taskId)}${showRepos ? repoBadgeHtml(tRepo, "repo-badge-task") : ""}</span>
954
+ <span class="task-id status-${task.status}"><div class="task-id-line">${escapeHtml(task.taskId)}${showRepos ? repoBadgeHtml(tRepo, "repo-badge-task") : ""}</div>${titleHtml}</span>
866
955
  <span><span class="status-badge status-${task.status}"><span class="status-dot ${task.status}"></span> ${task.status}</span></span>
867
956
  <span class="task-duration">${dur}</span>
868
957
  <span>${progressHtml}</span>
@@ -625,6 +625,28 @@ body {
625
625
  font-family: var(--font-mono);
626
626
  font-weight: 600;
627
627
  font-size: 0.85rem;
628
+ /* #485: task-id may now contain a stacked task-id-line + task-title-subtitle */
629
+ display: flex;
630
+ flex-direction: column;
631
+ gap: 2px;
632
+ min-width: 0; /* allow inner ellipsis to work */
633
+ }
634
+
635
+ .task-id-line {
636
+ /* The original TP-XXX line; preserves the previous single-line look */
637
+ white-space: nowrap;
638
+ }
639
+
640
+ .task-title-subtitle {
641
+ /* #485: human-readable task title under the ID */
642
+ font-family: var(--font-sans, inherit);
643
+ font-weight: 400;
644
+ font-size: 0.72rem;
645
+ color: var(--text-muted);
646
+ white-space: nowrap;
647
+ overflow: hidden;
648
+ text-overflow: ellipsis;
649
+ max-width: 100%;
628
650
  }
629
651
 
630
652
  .task-duration {
@@ -160,6 +160,52 @@ function resolveTaskFolder(task, state) {
160
160
  return task.taskFolder;
161
161
  }
162
162
 
163
+ /**
164
+ * Read the task title from a task folder's PROMPT.md.
165
+ *
166
+ * Extracts the human-readable title from the first `# Task:` heading. The
167
+ * heading format is `# Task: <ID> - <title>` per the create-taskplane-task
168
+ * skill's prompt template. Returns null when PROMPT.md is missing, the
169
+ * pattern doesn't match, or any read error occurs.
170
+ *
171
+ * Cached per-folder for the lifetime of this server process: PROMPT.md is
172
+ * immutable above the `---` divider so the title never changes mid-batch.
173
+ * Cache is keyed by absolute task folder path. (#485)
174
+ */
175
+ const _taskTitleCache = new Map();
176
+ function parseTaskTitle(taskFolder) {
177
+ if (!taskFolder) return null;
178
+ const cacheKey = path.resolve(taskFolder);
179
+ if (_taskTitleCache.has(cacheKey)) return _taskTitleCache.get(cacheKey);
180
+
181
+ // Look at both the canonical folder and the archive fallback (matches
182
+ // parseStatusMd's two-candidate strategy).
183
+ const candidates = [taskFolder];
184
+ const taskId = path.basename(taskFolder);
185
+ const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
186
+ if (archiveBase !== taskFolder) candidates.push(archiveBase);
187
+
188
+ for (const folder of candidates) {
189
+ const promptPath = path.join(folder, "PROMPT.md");
190
+ try {
191
+ const content = fs.readFileSync(promptPath, "utf-8");
192
+ // Match `# Task: <ID> - <title>` (the canonical first-line heading).
193
+ const match = content.match(/^# Task:\s*\S+\s*[-\u2014\u2013]\s*(.+?)\s*$/m);
194
+ if (match) {
195
+ const title = match[1].trim();
196
+ _taskTitleCache.set(cacheKey, title);
197
+ return title;
198
+ }
199
+ } catch {
200
+ // PROMPT.md missing or unreadable in this candidate — try the next.
201
+ continue;
202
+ }
203
+ }
204
+ // Cache the negative result too, so we don't re-attempt on every poll.
205
+ _taskTitleCache.set(cacheKey, null);
206
+ return null;
207
+ }
208
+
163
209
  function parseStatusMd(taskFolder) {
164
210
  const candidates = [taskFolder];
165
211
  const taskId = path.basename(taskFolder);
@@ -1102,13 +1148,17 @@ function buildDashboardState() {
1102
1148
  const tasks = (state.tasks || []).map((task) => {
1103
1149
  const effectiveFolder = resolveTaskFolder(task, state);
1104
1150
  let statusData = null;
1151
+ let taskTitle = null;
1105
1152
  if (effectiveFolder) {
1106
1153
  statusData = parseStatusMd(effectiveFolder);
1154
+ // #485: read the human-readable title from PROMPT.md once, surface
1155
+ // alongside taskId so the dashboard can show it as a subtitle.
1156
+ taskTitle = parseTaskTitle(effectiveFolder);
1107
1157
  }
1108
1158
  if (!task.doneFileFound && effectiveFolder) {
1109
1159
  task.doneFileFound = checkDoneFile(effectiveFolder);
1110
1160
  }
1111
- return { ...task, statusData };
1161
+ return { ...task, statusData, taskTitle };
1112
1162
  });
1113
1163
 
1114
1164
  // TP-107: Load Runtime V2 data when available
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.28.6",
3
+ "version": "0.28.7",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",