@unbrained/pm-web 2026.6.4 → 2026.6.13-2

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.
@@ -6,6 +6,7 @@ import { api } from '../api.js';
6
6
  import { escHtml } from '../utils.js';
7
7
  import { toast } from '../components/toast.js';
8
8
  import { showModal, hideModal, createModal, confirmDialog } from '../components/modals.js';
9
+ import { buildPlanExecutionSnapshot, buildPlanAgentBrief, buildNextStepPrompt, type AnalyzedPlanStep, type PlanExecutionSnapshot } from './plan-execution.js';
9
10
 
10
11
  // ─── Types ───────────────────────────────────────────────────
11
12
 
@@ -39,6 +40,8 @@ type PlanData = {
39
40
  // ─── State ───────────────────────────────────────────────────
40
41
 
41
42
  let currentPlanId: string | null = null;
43
+ let currentPlanData: PlanData | null = null;
44
+ let currentExecutionSnapshot: PlanExecutionSnapshot | null = null;
42
45
 
43
46
  // ─── Helpers ─────────────────────────────────────────────────
44
47
 
@@ -60,10 +63,96 @@ function stepStatusBadge(status?: string): string {
60
63
  return `<span style="font-size:11px;padding:2px 7px;border-radius:4px;background:color-mix(in srgb,${color} 18%,transparent);color:${color};font-weight:600;letter-spacing:.3px">${escHtml(s)}</span>`;
61
64
  }
62
65
 
63
- function renderStepRow(step: PlanStep, planId: string): string {
66
+ function metricBadge(label: string, value: string, color: string): string {
67
+ return `
68
+ <div style="padding:8px 10px;border-radius:8px;background:color-mix(in srgb,${color} 14%, var(--bg-card));border:1px solid color-mix(in srgb,${color} 28%, var(--border));min-width:95px">
69
+ <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.4px">${escHtml(label)}</div>
70
+ <div style="font-size:14px;font-weight:600;color:${color};margin-top:2px">${escHtml(value)}</div>
71
+ </div>`;
72
+ }
73
+
74
+ function renderDependencyHint(analyzed?: AnalyzedPlanStep): string {
75
+ if (!analyzed || analyzed.dependsOn.length === 0 || analyzed.isDone || analyzed.isBlocked) return '';
76
+
77
+ if (analyzed.incompleteDependencies.length === 0 && analyzed.unresolvedDependencies.length === 0) {
78
+ return `<div style="font-size:12px;color:var(--text-muted);margin-top:3px">Dependencies complete: ${escHtml(analyzed.dependsOn.join(', '))}</div>`;
79
+ }
80
+
81
+ const blockers = [
82
+ ...analyzed.incompleteDependencies,
83
+ ...analyzed.unresolvedDependencies.map(dep => `${dep} (missing)`),
84
+ ];
85
+ return `<div style="font-size:12px;color:var(--warning,#f59e0b);margin-top:3px">Waiting on: ${escHtml(blockers.join(', '))}</div>`;
86
+ }
87
+
88
+ function renderExecutionFocus(planId: string, snapshot: PlanExecutionSnapshot): string {
89
+ const next = snapshot.nextReadyStep;
90
+ const waitingPreview = snapshot.waitingSteps.slice(0, 2).map(step => {
91
+ const blockers = [
92
+ ...step.incompleteDependencies,
93
+ ...step.unresolvedDependencies.map(dep => `${dep} (missing)`),
94
+ ];
95
+ return `
96
+ <li style="font-size:12px;color:var(--text-secondary);line-height:1.5">
97
+ <span style="font-family:'JetBrains Mono',monospace;color:var(--text-muted)">[${escHtml(step.ref)}]</span>
98
+ ${escHtml(step.title)} - waiting on ${escHtml(blockers.join(', '))}
99
+ </li>`;
100
+ }).join('');
101
+ const blockedPreview = snapshot.blockedStepDetails.slice(0, 2).map(step => `
102
+ <li style="font-size:12px;color:var(--text-secondary);line-height:1.5">
103
+ <span style="font-family:'JetBrains Mono',monospace;color:var(--text-muted)">[${escHtml(step.ref)}]</span>
104
+ ${escHtml(step.title)}
105
+ ${step.blockedReason ? `<span style="color:var(--status-blocked)"> - ${escHtml(step.blockedReason)}</span>` : ''}
106
+ </li>`).join('');
107
+
108
+ return `
109
+ <div style="margin-bottom:12px;padding:12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated)">
110
+ <div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;align-items:flex-start">
111
+ <div>
112
+ <div style="font-size:13px;font-weight:600">Execution Focus</div>
113
+ <div style="font-size:12px;color:var(--text-muted);margin-top:2px">Dependency-aware summary to guide agent execution.</div>
114
+ </div>
115
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
116
+ <button class="btn btn-ghost btn-sm" onclick="window.__app.copyPlanAgentBrief('${escHtml(planId)}')" title="Copy dependency and status summary">Copy agent brief</button>
117
+ ${next ? `<button class="btn btn-secondary btn-sm" onclick="window.__app.copyPlanNextStepPrompt('${escHtml(planId)}','${escHtml(next.ref)}')" title="Copy prompt for the next ready step">Copy next-step prompt</button>` : ''}
118
+ </div>
119
+ </div>
120
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
121
+ ${metricBadge('Complete', `${snapshot.completedSteps}/${snapshot.totalSteps}`, 'var(--status-closed)')}
122
+ ${metricBadge('Ready', String(snapshot.readySteps.length), 'var(--accent)')}
123
+ ${metricBadge('Waiting', String(snapshot.waitingSteps.length), 'var(--warning,#f59e0b)')}
124
+ ${metricBadge('Blocked', String(snapshot.blockedStepDetails.length), 'var(--status-blocked)')}
125
+ </div>
126
+ ${next
127
+ ? `<div style="margin-top:10px;font-size:12px;color:var(--text-secondary)">Next ready step: <span style="font-family:'JetBrains Mono',monospace;color:var(--text-muted)">[${escHtml(next.ref)}]</span> <strong>${escHtml(next.title)}</strong></div>`
128
+ : '<div style="margin-top:10px;font-size:12px;color:var(--text-muted)">No ready steps right now. Resolve blockers or dependencies to continue.</div>'}
129
+ ${waitingPreview
130
+ ? `<div style="margin-top:8px">
131
+ <div style="font-size:12px;font-weight:600;color:var(--text-secondary)">Waiting queue</div>
132
+ <ul style="margin:6px 0 0 16px;padding:0;display:flex;flex-direction:column;gap:4px">${waitingPreview}</ul>
133
+ ${snapshot.waitingSteps.length > 2 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:4px">+ ${snapshot.waitingSteps.length - 2} more waiting step(s)</div>` : ''}
134
+ </div>`
135
+ : ''}
136
+ ${blockedPreview
137
+ ? `<div style="margin-top:8px">
138
+ <div style="font-size:12px;font-weight:600;color:var(--status-blocked)">Blocked steps</div>
139
+ <ul style="margin:6px 0 0 16px;padding:0;display:flex;flex-direction:column;gap:4px">${blockedPreview}</ul>
140
+ ${snapshot.blockedStepDetails.length > 2 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:4px">+ ${snapshot.blockedStepDetails.length - 2} more blocked step(s)</div>` : ''}
141
+ </div>`
142
+ : ''}
143
+ </div>`;
144
+ }
145
+
146
+ function renderStepRow(step: PlanStep, planId: string, analyzed?: AnalyzedPlanStep): string {
64
147
  const ref = stepRef(step);
65
148
  const isDone = ['done', 'completed'].includes((step.status || '').toLowerCase());
66
149
  const isBlocked = (step.status || '').toLowerCase() === 'blocked';
150
+ const isWaiting = !!analyzed && !analyzed.isDone && !analyzed.isBlocked
151
+ && (analyzed.incompleteDependencies.length > 0 || analyzed.unresolvedDependencies.length > 0);
152
+ const promptTitle = isBlocked
153
+ ? 'Copy blocker-resolution prompt'
154
+ : (isWaiting ? 'Copy dependency-resolution prompt' : 'Copy execution prompt');
155
+ const dependencyHint = renderDependencyHint(analyzed);
67
156
  return `
68
157
  <div class="plan-step-row" data-step-ref="${escHtml(ref)}">
69
158
  <div style="flex:1;min-width:0">
@@ -73,9 +162,11 @@ function renderStepRow(step: PlanStep, planId: string): string {
73
162
  ${stepStatusBadge(step.status)}
74
163
  </div>
75
164
  ${step.description ? `<div style="font-size:12px;color:var(--text-muted);margin-top:3px">${escHtml(step.description)}</div>` : ''}
165
+ ${dependencyHint}
76
166
  ${isBlocked && (step.blockedReason || step.blocked_reason) ? `<div style="font-size:12px;color:var(--status-blocked);margin-top:3px">Blocked: ${escHtml(step.blockedReason || step.blocked_reason || '')}</div>` : ''}
77
167
  </div>
78
168
  <div style="display:flex;gap:6px;flex-shrink:0">
169
+ ${!isDone ? `<button class="btn btn-ghost btn-sm" onclick="window.__app.copyPlanNextStepPrompt('${escHtml(planId)}','${escHtml(ref)}')" title="${escHtml(promptTitle)}" ${isBlocked ? 'style="color:var(--status-blocked)"' : ''}>⧉</button>` : ''}
79
170
  ${!isDone ? `<button class="btn btn-ghost btn-sm" onclick="window.__app.planCompleteStep('${escHtml(planId)}','${escHtml(ref)}')" title="Mark complete">✓</button>` : ''}
80
171
  ${!isBlocked && !isDone ? `<button class="btn btn-ghost btn-sm" onclick="window.__app.planBlockStepPrompt('${escHtml(planId)}','${escHtml(ref)}')" title="Block">⊘</button>` : ''}
81
172
  <button class="btn btn-ghost btn-sm" onclick="window.__app.planRemoveStep('${escHtml(planId)}','${escHtml(ref)}')" title="Remove" style="color:var(--danger,#f87171)">✕</button>
@@ -112,6 +203,9 @@ export function renderPlanView(): string {
112
203
  export async function initPlanView(): Promise<void> {
113
204
  const el = document.getElementById('content-plan');
114
205
  if (!el) return;
206
+ currentPlanId = null;
207
+ currentPlanData = null;
208
+ currentExecutionSnapshot = null;
115
209
  if (!state.currentProject) {
116
210
  el.innerHTML = '<div class="empty-state"><div class="empty-state-text">No project selected</div></div>';
117
211
  return;
@@ -134,6 +228,9 @@ async function loadPlanList(): Promise<void> {
134
228
  if (subEl) subEl.textContent = state.currentProject.name;
135
229
 
136
230
  if (items.length === 0) {
231
+ currentPlanId = null;
232
+ currentPlanData = null;
233
+ currentExecutionSnapshot = null;
137
234
  listEl.innerHTML = '<div class="empty-state" style="padding:16px"><div class="empty-state-text">No plans yet</div></div>';
138
235
  return;
139
236
  }
@@ -172,38 +269,144 @@ export async function openPlanDetail(planId: string): Promise<void> {
172
269
  const data = await api('GET', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}`) as any;
173
270
  const plan = (data.plan || data) as PlanData;
174
271
  const steps = plan.steps || [];
272
+ const snapshot = buildPlanExecutionSnapshot(steps);
273
+ currentPlanData = plan;
274
+ currentExecutionSnapshot = snapshot;
175
275
 
176
276
  const isApproved = !!(plan.approvedAt || plan.approved_at);
177
277
 
178
- detailEl.innerHTML = `
179
- <div class="card">
180
- <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
181
- <div>
182
- <div class="card-title">${escHtml(plan.title || planId)}</div>
183
- <div style="font-size:12px;color:var(--text-muted);margin-top:2px;font-family:'JetBrains Mono',monospace">${escHtml(plan.id || planId)}</div>
184
- </div>
185
- <div style="display:flex;gap:8px;flex-wrap:wrap">
186
- ${!isApproved ? `<button class="btn btn-secondary btn-sm" onclick="window.__app.planApprove('${escHtml(planId)}')">✓ Approve</button>` : '<span style="font-size:12px;color:var(--status-closed)">✓ Approved</span>'}
187
- <button class="btn btn-primary btn-sm" onclick="window.__app.planMaterializePrompt('${escHtml(planId)}')">⇗ Materialize</button>
188
- <button class="btn btn-ghost btn-sm" onclick="window.__app.planEditPrompt('${escHtml(planId)}','${escHtml(plan.title||'')}')" title="Edit plan">✎</button>
189
- <button class="btn btn-ghost btn-sm" style="color:var(--danger,#f87171)" onclick="window.__app.planDeletePrompt('${escHtml(planId)}')" title="Delete plan">✕</button>
190
- </div>
191
- </div>
192
- <div class="card-body">
193
- ${plan.description ? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">${escHtml(plan.description)}</div>` : ''}
194
- ${plan.scope ? `<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Scope: ${escHtml(plan.scope)}</div>` : ''}
195
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
196
- <div style="font-size:13px;font-weight:600">Steps (${steps.length})</div>
197
- <button class="btn btn-ghost btn-sm" onclick="window.__app.openAddStepModal('${escHtml(planId)}')">+ Add Step</button>
198
- </div>
199
- <div id="plan-steps-list">
200
- ${steps.length === 0
201
- ? '<div style="font-size:13px;color:var(--text-muted);padding:8px 0">No steps yet. Add the first step to get started.</div>'
202
- : steps.map(s => renderStepRow(s, planId)).join('')}
203
- </div>
204
- </div>
205
- </div>`;
278
+ const card = document.createElement('div');
279
+ card.className = 'card';
280
+
281
+ const header = document.createElement('div');
282
+ header.className = 'card-header';
283
+ header.style.display = 'flex';
284
+ header.style.alignItems = 'center';
285
+ header.style.justifyContent = 'space-between';
286
+ header.style.flexWrap = 'wrap';
287
+ header.style.gap = '8px';
288
+
289
+ const heading = document.createElement('div');
290
+ const titleEl = document.createElement('div');
291
+ titleEl.className = 'card-title';
292
+ titleEl.textContent = plan.title || planId;
293
+ const idEl = document.createElement('div');
294
+ idEl.style.fontSize = '12px';
295
+ idEl.style.color = 'var(--text-muted)';
296
+ idEl.style.marginTop = '2px';
297
+ idEl.style.fontFamily = "'JetBrains Mono',monospace";
298
+ idEl.textContent = plan.id || planId;
299
+ heading.append(titleEl, idEl);
300
+
301
+ const actions = document.createElement('div');
302
+ actions.style.display = 'flex';
303
+ actions.style.gap = '8px';
304
+ actions.style.flexWrap = 'wrap';
305
+
306
+ if (!isApproved) {
307
+ const approveBtn = document.createElement('button');
308
+ approveBtn.className = 'btn btn-secondary btn-sm';
309
+ approveBtn.textContent = '✓ Approve';
310
+ approveBtn.addEventListener('click', () => { void planApprove(planId); });
311
+ actions.appendChild(approveBtn);
312
+ } else {
313
+ const approvedLabel = document.createElement('span');
314
+ approvedLabel.style.fontSize = '12px';
315
+ approvedLabel.style.color = 'var(--status-closed)';
316
+ approvedLabel.textContent = '✓ Approved';
317
+ actions.appendChild(approvedLabel);
318
+ }
319
+
320
+ const materializeBtn = document.createElement('button');
321
+ materializeBtn.className = 'btn btn-primary btn-sm';
322
+ materializeBtn.textContent = '⇗ Materialize';
323
+ materializeBtn.addEventListener('click', () => planMaterializePrompt(planId));
324
+ actions.appendChild(materializeBtn);
325
+
326
+ const editBtn = document.createElement('button');
327
+ editBtn.className = 'btn btn-ghost btn-sm';
328
+ editBtn.title = 'Edit plan';
329
+ editBtn.textContent = '✎';
330
+ editBtn.addEventListener('click', () => planEditPrompt(planId, plan.title || ''));
331
+ actions.appendChild(editBtn);
332
+
333
+ const deleteBtn = document.createElement('button');
334
+ deleteBtn.className = 'btn btn-ghost btn-sm';
335
+ deleteBtn.title = 'Delete plan';
336
+ deleteBtn.style.color = 'var(--danger,#f87171)';
337
+ deleteBtn.textContent = '✕';
338
+ deleteBtn.addEventListener('click', () => planDeletePrompt(planId));
339
+ actions.appendChild(deleteBtn);
340
+
341
+ header.append(heading, actions);
342
+
343
+ const body = document.createElement('div');
344
+ body.className = 'card-body';
345
+
346
+ if (plan.description) {
347
+ const descEl = document.createElement('div');
348
+ descEl.style.fontSize = '13px';
349
+ descEl.style.color = 'var(--text-secondary)';
350
+ descEl.style.marginBottom = '12px';
351
+ descEl.textContent = plan.description;
352
+ body.appendChild(descEl);
353
+ }
354
+
355
+ if (plan.scope) {
356
+ const scopeEl = document.createElement('div');
357
+ scopeEl.style.fontSize = '12px';
358
+ scopeEl.style.color = 'var(--text-muted)';
359
+ scopeEl.style.marginBottom = '8px';
360
+ scopeEl.textContent = `Scope: ${plan.scope}`;
361
+ body.appendChild(scopeEl);
362
+ }
363
+
364
+ const focusTemplate = document.createElement('template');
365
+ focusTemplate.innerHTML = renderExecutionFocus(planId, snapshot);
366
+ body.appendChild(focusTemplate.content);
367
+
368
+ const stepsHeader = document.createElement('div');
369
+ stepsHeader.style.display = 'flex';
370
+ stepsHeader.style.alignItems = 'center';
371
+ stepsHeader.style.justifyContent = 'space-between';
372
+ stepsHeader.style.marginBottom = '8px';
373
+
374
+ const stepsTitle = document.createElement('div');
375
+ stepsTitle.style.fontSize = '13px';
376
+ stepsTitle.style.fontWeight = '600';
377
+ stepsTitle.textContent = `Steps (${steps.length})`;
378
+
379
+ const addStepBtn = document.createElement('button');
380
+ addStepBtn.className = 'btn btn-ghost btn-sm';
381
+ addStepBtn.textContent = '+ Add Step';
382
+ addStepBtn.addEventListener('click', () => openAddStepModal(planId));
383
+
384
+ stepsHeader.append(stepsTitle, addStepBtn);
385
+ body.appendChild(stepsHeader);
386
+
387
+ const stepsList = document.createElement('div');
388
+ stepsList.id = 'plan-steps-list';
389
+ if (steps.length === 0) {
390
+ const emptySteps = document.createElement('div');
391
+ emptySteps.style.fontSize = '13px';
392
+ emptySteps.style.color = 'var(--text-muted)';
393
+ emptySteps.style.padding = '8px 0';
394
+ emptySteps.textContent = 'No steps yet. Add the first step to get started.';
395
+ stepsList.appendChild(emptySteps);
396
+ } else {
397
+ steps.forEach((step, index) => {
398
+ const rowTemplate = document.createElement('template');
399
+ rowTemplate.innerHTML = renderStepRow(step, planId, snapshot.allSteps[index]);
400
+ stepsList.appendChild(rowTemplate.content);
401
+ });
402
+ }
403
+ body.appendChild(stepsList);
404
+
405
+ card.append(header, body);
406
+ detailEl.replaceChildren(card);
206
407
  } catch(err: unknown) {
408
+ currentPlanData = null;
409
+ currentExecutionSnapshot = null;
207
410
  detailEl.innerHTML = `<div class="empty-state"><div class="empty-state-text">Error: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
208
411
  }
209
412
  }
@@ -434,6 +637,65 @@ export async function submitMaterializePlan(planId: string): Promise<void> {
434
637
  }
435
638
  }
436
639
 
640
+ async function copyTextWithFallback(modalTitle: string, text: string, successMessage: string): Promise<void> {
641
+ if (navigator.clipboard?.writeText) {
642
+ try {
643
+ await navigator.clipboard.writeText(text);
644
+ toast(successMessage, 'success');
645
+ return;
646
+ } catch {
647
+ // Fall through to manual-copy modal.
648
+ }
649
+ }
650
+
651
+ const modalId = 'plan-copy-fallback-modal';
652
+ createModal(modalId, modalTitle, `
653
+ <p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Clipboard access is unavailable in this browser context. Copy manually:</p>
654
+ <textarea class="form-textarea" id="plan-copy-fallback-text" rows="12" spellcheck="false"></textarea>`,
655
+ `<button class="btn btn-primary" onclick="window.__app.hideModal('${modalId}')">Close</button>`);
656
+ showModal(modalId);
657
+ const el = document.getElementById('plan-copy-fallback-text') as HTMLTextAreaElement | null;
658
+ if (el) {
659
+ el.value = text;
660
+ el.focus();
661
+ el.select();
662
+ }
663
+ toast('Clipboard blocked. Opened manual copy panel.', 'info');
664
+ }
665
+
666
+ function getCurrentPlanContext(planId: string): { plan: PlanData; snapshot: PlanExecutionSnapshot } | null {
667
+ const activeId = currentPlanData?.id || currentPlanId;
668
+ if (!currentPlanData || !currentExecutionSnapshot || activeId !== planId) {
669
+ toast('Open this plan first to generate prompts', 'info');
670
+ return null;
671
+ }
672
+ return { plan: currentPlanData, snapshot: currentExecutionSnapshot };
673
+ }
674
+
675
+ export async function copyPlanAgentBrief(planId: string): Promise<void> {
676
+ const ctx = getCurrentPlanContext(planId);
677
+ if (!ctx) return;
678
+ const brief = buildPlanAgentBrief(ctx.plan, ctx.snapshot);
679
+ await copyTextWithFallback('Plan Agent Brief', brief, 'Agent brief copied');
680
+ }
681
+
682
+ export async function copyPlanNextStepPrompt(planId: string, stepRef?: string): Promise<void> {
683
+ const ctx = getCurrentPlanContext(planId);
684
+ if (!ctx) return;
685
+
686
+ if (stepRef && !ctx.snapshot.stepByRef[stepRef]) {
687
+ toast(`Step ${stepRef} not found`, 'error');
688
+ return;
689
+ }
690
+ if (!stepRef && !ctx.snapshot.nextReadyStep) {
691
+ toast('No ready step available right now', 'info');
692
+ return;
693
+ }
694
+
695
+ const prompt = buildNextStepPrompt(ctx.plan, ctx.snapshot, stepRef);
696
+ await copyTextWithFallback('Next Step Prompt', prompt, 'Next-step prompt copied');
697
+ }
698
+
437
699
  export function planEditPrompt(planId: string, currentTitle: string): void {
438
700
  createModal('edit-plan-modal', 'Edit Plan', `
439
701
  <div class="form-group">
@@ -481,6 +743,8 @@ export function planDeletePrompt(planId: string): void {
481
743
  await api('DELETE', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}`, {});
482
744
  toast('Plan deleted', 'success');
483
745
  currentPlanId = null;
746
+ currentPlanData = null;
747
+ currentExecutionSnapshot = null;
484
748
  const detailEl = document.getElementById('plan-detail-panel');
485
749
  if (detailEl) detailEl.innerHTML = '<div class="empty-state"><div class="empty-state-text">Select a plan to view its steps</div></div>';
486
750
  await loadPlanList();