opencode-planpilot 0.2.3 → 0.2.4

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.
@@ -0,0 +1,974 @@
1
+ /// <reference lib="dom" />
2
+
3
+ type JsonPrimitive = string | number | boolean | null
4
+ type JsonObject = { [k: string]: JsonValue }
5
+ type JsonValue = JsonPrimitive | JsonObject | JsonValue[]
6
+
7
+ type HostApi = {
8
+ invokeAction: (action: string, payload?: JsonValue, context?: JsonValue) => Promise<JsonValue>
9
+ subscribeEvents: (handlers: {
10
+ onEvent?: (evt: { type: string; data: JsonValue; lastEventId?: string }) => void
11
+ onError?: (err: Event) => void
12
+ }) => () => void
13
+ }
14
+
15
+ type LayoutApi = {
16
+ setReservePx: (px: number) => void
17
+ }
18
+
19
+ export type StudioMountOptions = {
20
+ pluginId: string
21
+ surface: string
22
+ title?: string
23
+ context: Record<string, string>
24
+ host: HostApi
25
+ layout?: LayoutApi
26
+ close?: () => void
27
+ }
28
+
29
+ type PlanStatus = "todo" | "done"
30
+ type StepStatus = "todo" | "done"
31
+ type GoalStatus = "todo" | "done"
32
+
33
+ type PlanRow = {
34
+ id: number
35
+ title: string
36
+ content: string
37
+ status: PlanStatus
38
+ comment: string | null
39
+ last_session_id: string | null
40
+ updated_at: number
41
+ }
42
+
43
+ type StepRow = {
44
+ id: number
45
+ plan_id: number
46
+ content: string
47
+ status: StepStatus
48
+ executor: "ai" | "human"
49
+ sort_order: number
50
+ comment: string | null
51
+ }
52
+
53
+ type GoalRow = {
54
+ id: number
55
+ step_id: number
56
+ content: string
57
+ status: GoalStatus
58
+ }
59
+
60
+ type RuntimeStepDetail = {
61
+ step: StepRow
62
+ goals: GoalRow[]
63
+ wait?: { until: number; reason?: string } | null
64
+ }
65
+
66
+ type RuntimeSnapshot = {
67
+ paused: boolean
68
+ activePlan: { plan_id: number } | null
69
+ nextStep: RuntimeStepDetail | null
70
+ }
71
+
72
+ type PlanDetail = {
73
+ plan: PlanRow
74
+ steps: StepRow[]
75
+ goals: Array<{ stepId: number; goals: GoalRow[] }>
76
+ }
77
+
78
+ type State = {
79
+ sessionId: string
80
+ loading: boolean
81
+ busy: boolean
82
+ error: string | null
83
+ showOtherPlans: boolean
84
+ collapsed: boolean
85
+ viewedPlanId: number
86
+ goalsExpandedByStepId: Record<string, boolean>
87
+ runtime: RuntimeSnapshot | null
88
+ activePlanDetail: PlanDetail | null
89
+ sessionPlans: PlanRow[]
90
+ }
91
+
92
+ type UiLocale = "en-US" | "zh-CN"
93
+
94
+ type UiStrings = {
95
+ viewingSuffix: string
96
+ loading: string
97
+ noPlans: string
98
+ checkingPlanStatus: string
99
+ noPlansFoundForSession: string
100
+ noSelectedPlanHint: string
101
+ planDetailUnavailable: string
102
+ showPlan: string
103
+ noPlansInSession: string
104
+ active: string
105
+ noStepsYet: string
106
+ waiting: string
107
+ paused: string
108
+ updating: string
109
+ refresh: string
110
+ planList: string
111
+ close: string
112
+ collapse: string
113
+ error: string
114
+ }
115
+
116
+ const UI_STRINGS: Record<UiLocale, UiStrings> = {
117
+ "en-US": {
118
+ viewingSuffix: "(viewing)",
119
+ loading: "Loading...",
120
+ noPlans: "No plans",
121
+ checkingPlanStatus: "Checking plan status...",
122
+ noPlansFoundForSession: "No plans found for this session yet.",
123
+ noSelectedPlanHint: "No plan is selected right now. Open Plan List to review a recent plan.",
124
+ planDetailUnavailable: "Plan detail is unavailable",
125
+ showPlan: "Show plan",
126
+ noPlansInSession: "No plans in this session.",
127
+ active: "Active",
128
+ noStepsYet: "This plan has no steps yet.",
129
+ waiting: "Waiting",
130
+ paused: "Paused",
131
+ updating: "Updating...",
132
+ refresh: "Refresh",
133
+ planList: "Plan List",
134
+ close: "Close",
135
+ collapse: "Collapse",
136
+ error: "Error",
137
+ },
138
+ "zh-CN": {
139
+ viewingSuffix: "(查看中)",
140
+ loading: "加载中...",
141
+ noPlans: "暂无计划",
142
+ checkingPlanStatus: "正在检查计划状态...",
143
+ noPlansFoundForSession: "此会话中还没有计划。",
144
+ noSelectedPlanHint: "当前未选择计划。打开计划列表查看最近的计划。",
145
+ planDetailUnavailable: "计划详情不可用",
146
+ showPlan: "显示计划",
147
+ noPlansInSession: "此会话中暂无计划。",
148
+ active: "进行中",
149
+ noStepsYet: "该计划还没有步骤。",
150
+ waiting: "等待中",
151
+ paused: "已暂停",
152
+ updating: "更新中...",
153
+ refresh: "刷新",
154
+ planList: "计划列表",
155
+ close: "关闭",
156
+ collapse: "收起",
157
+ error: "错误",
158
+ },
159
+ }
160
+
161
+ function normalizeLocale(value: string | undefined | null): UiLocale {
162
+ const normalized = String(value || "").trim().toLowerCase()
163
+ if (!normalized) return "en-US"
164
+ if (normalized.startsWith("zh")) return "zh-CN"
165
+ return "en-US"
166
+ }
167
+
168
+ function asObject(value: JsonValue | undefined | null): Record<string, JsonValue> {
169
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {}
170
+ return value as Record<string, JsonValue>
171
+ }
172
+
173
+ function asArray(value: JsonValue | undefined | null): JsonValue[] {
174
+ return Array.isArray(value) ? value : []
175
+ }
176
+
177
+ function toNumber(value: JsonValue | undefined, fallback = 0): number {
178
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback
179
+ return Math.trunc(value)
180
+ }
181
+
182
+ function toStringValue(value: JsonValue | undefined, fallback = ""): string {
183
+ if (typeof value !== "string") return fallback
184
+ const trimmed = value.trim()
185
+ return trimmed || fallback
186
+ }
187
+
188
+ function parseGoal(value: JsonValue): GoalRow | null {
189
+ const obj = asObject(value)
190
+ const id = toNumber(obj.id)
191
+ const stepId = toNumber(obj.step_id)
192
+ const content = toStringValue(obj.content)
193
+ if (!id || !stepId || !content) return null
194
+ return {
195
+ id,
196
+ step_id: stepId,
197
+ content,
198
+ status: toStringValue(obj.status) === "done" ? "done" : "todo",
199
+ }
200
+ }
201
+
202
+ function parseStep(value: JsonValue): StepRow | null {
203
+ const obj = asObject(value)
204
+ const id = toNumber(obj.id)
205
+ const planId = toNumber(obj.plan_id)
206
+ const content = toStringValue(obj.content)
207
+ if (!id || !planId || !content) return null
208
+ return {
209
+ id,
210
+ plan_id: planId,
211
+ content,
212
+ status: toStringValue(obj.status) === "done" ? "done" : "todo",
213
+ executor: toStringValue(obj.executor) === "human" ? "human" : "ai",
214
+ sort_order: toNumber(obj.sort_order),
215
+ comment: typeof obj.comment === "string" ? obj.comment : null,
216
+ }
217
+ }
218
+
219
+ function parsePlan(value: JsonValue): PlanRow | null {
220
+ const obj = asObject(value)
221
+ const id = toNumber(obj.id)
222
+ const title = toStringValue(obj.title)
223
+ if (!id || !title) return null
224
+ return {
225
+ id,
226
+ title,
227
+ content: toStringValue(obj.content),
228
+ status: toStringValue(obj.status) === "done" ? "done" : "todo",
229
+ comment: typeof obj.comment === "string" ? obj.comment : null,
230
+ last_session_id: typeof obj.last_session_id === "string" ? obj.last_session_id : null,
231
+ updated_at: toNumber(obj.updated_at),
232
+ }
233
+ }
234
+
235
+ function parseRuntime(value: JsonValue): RuntimeSnapshot | null {
236
+ const obj = asObject(value)
237
+ const activeObj = asObject(obj.activePlan)
238
+ const nextObj = asObject(obj.nextStep)
239
+ const nextStepRow = parseStep(nextObj.step as JsonValue)
240
+ const nextGoals = asArray(nextObj.goals)
241
+ .map(parseGoal)
242
+ .filter((goal): goal is GoalRow => !!goal)
243
+ const waitObj = asObject(nextObj.wait)
244
+
245
+ const nextStepDetail: RuntimeStepDetail | null = nextStepRow
246
+ ? {
247
+ step: nextStepRow,
248
+ goals: nextGoals,
249
+ wait: waitObj.until
250
+ ? {
251
+ until: toNumber(waitObj.until),
252
+ reason: toStringValue(waitObj.reason) || undefined,
253
+ }
254
+ : null,
255
+ }
256
+ : null
257
+
258
+ return {
259
+ paused: obj.paused === true,
260
+ activePlan: activeObj.plan_id ? { plan_id: toNumber(activeObj.plan_id) } : null,
261
+ nextStep: nextStepDetail,
262
+ }
263
+ }
264
+
265
+ function parsePlanDetail(value: JsonValue): PlanDetail | null {
266
+ const obj = asObject(value)
267
+ const plan = parsePlan(obj.plan as JsonValue)
268
+ if (!plan) return null
269
+
270
+ const steps = asArray(obj.steps)
271
+ .map(parseStep)
272
+ .filter((step): step is StepRow => !!step)
273
+ const goals = asArray(obj.goals)
274
+ .map((entry) => {
275
+ const e = asObject(entry)
276
+ const stepId = toNumber(e.stepId)
277
+ if (!stepId) return null
278
+ const list = asArray(e.goals)
279
+ .map(parseGoal)
280
+ .filter((goal): goal is GoalRow => !!goal)
281
+ return { stepId, goals: list }
282
+ })
283
+ .filter((entry): entry is { stepId: number; goals: GoalRow[] } => !!entry)
284
+
285
+ return { plan, steps, goals }
286
+ }
287
+
288
+ function formattedWait(stepDetail: RuntimeStepDetail | null, locale: UiLocale): string {
289
+ const wait = stepDetail?.wait
290
+ if (!wait?.until) return ""
291
+ const when = new Date(wait.until)
292
+ if (Number.isNaN(when.getTime())) return ""
293
+ const time = when.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
294
+ return wait.reason ? `${time} (${wait.reason})` : time
295
+ }
296
+
297
+ function summarizePlanContent(content: string): string {
298
+ const normalized = String(content || "").replace(/\s+/g, " ").trim()
299
+ if (!normalized) return ""
300
+ if (normalized.length <= 92) return normalized
301
+ return `${normalized.slice(0, 92)}...`
302
+ }
303
+
304
+ function htmlEscape(value: string): string {
305
+ return String(value)
306
+ .replaceAll("&", "&amp;")
307
+ .replaceAll("<", "&lt;")
308
+ .replaceAll(">", "&gt;")
309
+ .replaceAll('"', "&quot;")
310
+ .replaceAll("'", "&#39;")
311
+ }
312
+
313
+ function iconSvg(name: string, className: string): string {
314
+ // Minimal inline icons (keep bundle tiny; exact glyph parity is not required).
315
+ const common = `class="${className}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"`
316
+ if (name === "check") {
317
+ return `<svg ${common}><path d="M20 6 9 17l-5-5"/></svg>`
318
+ }
319
+ if (name === "chev") {
320
+ return `<svg ${common}><path d="m6 9 6 6 6-6"/></svg>`
321
+ }
322
+ if (name === "refresh") {
323
+ return `<svg ${common}><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>`
324
+ }
325
+ if (name === "list") {
326
+ return `<svg ${common}><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>`
327
+ }
328
+ if (name === "hide") {
329
+ return `<svg ${common}><path d="M3 3l18 18"/><path d="M10.58 10.58A3 3 0 0 0 12 15a3 3 0 0 0 2.42-4.42"/><path d="M9.88 5.09A10.4 10.4 0 0 1 12 5c7 0 10 7 10 7a18 18 0 0 1-3.2 4.2"/><path d="M6.1 6.1A18.5 18.5 0 0 0 2 12s3 7 10 7a10.7 10.7 0 0 0 3.1-.4"/></svg>`
330
+ }
331
+ if (name === "x") {
332
+ return `<svg ${common}><path d="m6 6 12 12"/><path d="m18 6-12 12"/></svg>`
333
+ }
334
+ if (name === "clock") {
335
+ return `<svg ${common}><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
336
+ }
337
+ if (name === "stack") {
338
+ return `<svg ${common}><path d="M12 2 2 7l10 5 10-5-10-5Z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/></svg>`
339
+ }
340
+ return ""
341
+ }
342
+
343
+ export function mount(el: HTMLElement, opts: StudioMountOptions) {
344
+ const sessionId = String(opts.context?.sessionId || "").trim()
345
+ const locale = normalizeLocale(opts.context?.locale || opts.context?.lang)
346
+ const t = UI_STRINGS[locale]
347
+ const hostManagedMode = opts.context?.studioOverlayMode === "host-menu"
348
+
349
+ const state: State = {
350
+ sessionId,
351
+ loading: false,
352
+ busy: false,
353
+ error: null,
354
+ showOtherPlans: false,
355
+ collapsed: !hostManagedMode,
356
+ viewedPlanId: 0,
357
+ goalsExpandedByStepId: {},
358
+ runtime: null,
359
+ activePlanDetail: null,
360
+ sessionPlans: [],
361
+ }
362
+
363
+ let refreshTimer: number | null = null
364
+ let stopEvents: (() => void) | null = null
365
+ let reserveObserver: ResizeObserver | null = null
366
+ let reserveRaf = 0
367
+
368
+ function setReservePx(px: number) {
369
+ if (!opts.layout) return
370
+ opts.layout.setReservePx(px)
371
+ }
372
+
373
+ function computeReserve(): number {
374
+ const rect = el.getBoundingClientRect()
375
+ if (!Number.isFinite(rect.height) || rect.height <= 0) return 0
376
+ // Chat host positions the overlay with `bottom-2`.
377
+ const bottomGap = 8
378
+ return Math.max(0, Math.ceil(rect.height + bottomGap))
379
+ }
380
+
381
+ function scheduleReserveUpdate() {
382
+ if (!opts.layout) return
383
+ if (reserveRaf) return
384
+ reserveRaf = window.requestAnimationFrame(() => {
385
+ reserveRaf = 0
386
+ if (!state.sessionId) {
387
+ setReservePx(0)
388
+ return
389
+ }
390
+ setReservePx(computeReserve())
391
+ })
392
+ }
393
+
394
+ function isVisible(): boolean {
395
+ return Boolean(state.sessionId)
396
+ }
397
+
398
+ function activePlan(): PlanRow | null {
399
+ return state.activePlanDetail?.plan ?? null
400
+ }
401
+
402
+ function activeRuntimePlanId(): number {
403
+ return state.runtime?.activePlan?.plan_id ?? 0
404
+ }
405
+
406
+ function activePlanId(): number {
407
+ return activePlan()?.id ?? 0
408
+ }
409
+
410
+ function orderedSteps(detail: PlanDetail): StepRow[] {
411
+ return [...detail.steps].sort((a, b) => {
412
+ if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
413
+ return a.id - b.id
414
+ })
415
+ }
416
+
417
+ function goalsForStepId(detail: PlanDetail, stepId: number): GoalRow[] {
418
+ const entry = detail.goals.find((g) => g.stepId === stepId)
419
+ const list = entry?.goals ?? []
420
+ return [...list].sort((a, b) => a.id - b.id)
421
+ }
422
+
423
+ function isGoalsExpanded(stepId: number): boolean {
424
+ return state.goalsExpandedByStepId[String(stepId)] === true
425
+ }
426
+
427
+ function toggleGoalsExpanded(stepId: number) {
428
+ const stepsScrollEl = el.querySelector<HTMLElement>("[data-pp-scroll=\"steps\"]")
429
+ const prevStepsScrollTop = stepsScrollEl ? stepsScrollEl.scrollTop : null
430
+
431
+ const key = String(stepId)
432
+ state.goalsExpandedByStepId = {
433
+ ...state.goalsExpandedByStepId,
434
+ [key]: !(state.goalsExpandedByStepId[key] === true),
435
+ }
436
+ render()
437
+
438
+ if (prevStepsScrollTop !== null) {
439
+ const restore = () => {
440
+ const nextStepsScrollEl = el.querySelector<HTMLElement>("[data-pp-scroll=\"steps\"]")
441
+ if (nextStepsScrollEl) nextStepsScrollEl.scrollTop = prevStepsScrollTop
442
+ }
443
+ restore()
444
+ window.requestAnimationFrame(restore)
445
+ }
446
+
447
+ scheduleReserveUpdate()
448
+ }
449
+
450
+ function headerLabel(): string {
451
+ const plan = activePlan()
452
+ if (plan) return `#${plan.id} ${plan.title}`
453
+ if (state.activePlanDetail?.plan) {
454
+ const p = state.activePlanDetail.plan
455
+ return `#${p.id} ${p.title} ${t.viewingSuffix}`
456
+ }
457
+ if (state.loading) return t.loading
458
+ return t.noPlans
459
+ }
460
+
461
+ function planContent(): string {
462
+ const content = state.activePlanDetail?.plan.content
463
+ return String(content || "").trim()
464
+ }
465
+
466
+ function fallbackStatusMessage(): string {
467
+ if (state.loading && !state.activePlanDetail) return t.checkingPlanStatus
468
+ if (!state.activePlanDetail) {
469
+ if (state.sessionPlans.length === 0) {
470
+ return t.noPlansFoundForSession
471
+ }
472
+ return t.noSelectedPlanHint
473
+ }
474
+ return ""
475
+ }
476
+
477
+ async function invoke(action: string, payload: JsonValue = null): Promise<JsonValue> {
478
+ return await opts.host.invokeAction(action, payload, null)
479
+ }
480
+
481
+ async function refreshAll() {
482
+ if (!isVisible()) {
483
+ state.runtime = null
484
+ state.activePlanDetail = null
485
+ state.sessionPlans = []
486
+ state.error = null
487
+ state.loading = false
488
+ render()
489
+ setReservePx(0)
490
+ return
491
+ }
492
+
493
+ state.loading = true
494
+ state.error = null
495
+ render()
496
+ scheduleReserveUpdate()
497
+
498
+ try {
499
+ const [runtimeRaw, activeRaw, plansRaw] = await Promise.all([
500
+ invoke("runtime.snapshot"),
501
+ invoke("plan.active"),
502
+ invoke("plan.list", {}),
503
+ ])
504
+
505
+ state.runtime = parseRuntime(runtimeRaw)
506
+
507
+ const activeObj = asObject(activeRaw)
508
+ const activeDetail = parsePlanDetail(activeObj.detail as JsonValue)
509
+
510
+ const parsedPlans = asArray(plansRaw)
511
+ .map(parsePlan)
512
+ .filter((plan): plan is PlanRow => !!plan)
513
+ .filter((plan) => plan.last_session_id === state.sessionId)
514
+ .sort((a, b) => {
515
+ if (a.updated_at !== b.updated_at) return b.updated_at - a.updated_at
516
+ return b.id - a.id
517
+ })
518
+ state.sessionPlans = parsedPlans
519
+
520
+ const planIdSet = new Set(parsedPlans.map((p) => p.id))
521
+ if (state.viewedPlanId > 0 && !planIdSet.has(state.viewedPlanId)) {
522
+ state.viewedPlanId = 0
523
+ }
524
+
525
+ const fallbackRecentPlanId = parsedPlans[0]?.id ?? 0
526
+ const targetPlanId =
527
+ state.viewedPlanId > 0
528
+ ? state.viewedPlanId
529
+ : (activeDetail?.plan?.id ?? 0) || fallbackRecentPlanId
530
+
531
+ if (!targetPlanId) {
532
+ state.activePlanDetail = null
533
+ } else if (activeDetail && activeDetail.plan.id === targetPlanId) {
534
+ state.activePlanDetail = activeDetail
535
+ } else {
536
+ const detailRaw = await invoke("plan.get", { id: targetPlanId } as unknown as JsonValue)
537
+ state.activePlanDetail = parsePlanDetail(detailRaw)
538
+ }
539
+ } catch (error) {
540
+ state.error = error instanceof Error ? error.message : String(error)
541
+ state.runtime = null
542
+ state.activePlanDetail = null
543
+ state.sessionPlans = []
544
+ } finally {
545
+ state.loading = false
546
+ render()
547
+ scheduleReserveUpdate()
548
+ }
549
+ }
550
+
551
+ function scheduleRefresh(delayMs = 120) {
552
+ if (refreshTimer) window.clearTimeout(refreshTimer)
553
+ refreshTimer = window.setTimeout(() => {
554
+ refreshTimer = null
555
+ void refreshAll()
556
+ }, Math.max(0, Math.floor(delayMs)))
557
+ }
558
+
559
+ function toggleCollapsed() {
560
+ if (hostManagedMode) return
561
+ state.collapsed = !state.collapsed
562
+ if (state.collapsed) {
563
+ state.showOtherPlans = false
564
+ render()
565
+ scheduleReserveUpdate()
566
+ } else {
567
+ void refreshAll()
568
+ }
569
+ }
570
+
571
+ async function openPlan(planId: number) {
572
+ if (!planId || state.busy) return
573
+ state.busy = true
574
+ state.error = null
575
+ render()
576
+ scheduleReserveUpdate()
577
+ try {
578
+ state.showOtherPlans = false
579
+ const detailRaw = await invoke("plan.get", { id: planId } as unknown as JsonValue)
580
+ const detail = parsePlanDetail(detailRaw)
581
+ if (!detail) throw new Error(t.planDetailUnavailable)
582
+ state.viewedPlanId = planId
583
+ state.activePlanDetail = detail
584
+ } catch (error) {
585
+ state.error = error instanceof Error ? error.message : String(error)
586
+ } finally {
587
+ state.busy = false
588
+ render()
589
+ scheduleReserveUpdate()
590
+ }
591
+ }
592
+
593
+ function renderCollapsedButton(): string {
594
+ return `
595
+ <div class="pointer-events-auto p-1">
596
+ <button
597
+ type="button"
598
+ data-pp-action="toggle"
599
+ class="h-9 w-9 rounded-full shadow-md border border-border/50 bg-background/80 backdrop-blur hover:bg-background transition-all inline-flex items-center justify-center"
600
+ aria-label="${t.showPlan}"
601
+ title="${t.showPlan}"
602
+ ${state.busy ? "disabled" : ""}
603
+ >
604
+ ${iconSvg("list", "h-5 w-5 text-muted-foreground")}
605
+ </button>
606
+ </div>
607
+ `
608
+ }
609
+
610
+ function renderPlanList(): string {
611
+ if (state.sessionPlans.length === 0) {
612
+ return `<div class="px-2 py-2 text-center text-xs text-muted-foreground">${t.noPlansInSession}</div>`
613
+ }
614
+
615
+ const activeId = activePlanId()
616
+ const runtimeActiveId = activeRuntimePlanId()
617
+ const rows = state.sessionPlans
618
+ .map((plan) => {
619
+ const isActive = plan.id === activeId
620
+ const isRuntimeActive = plan.id === runtimeActiveId
621
+ const title = summarizePlanContent(plan.content) || `#${plan.id} ${plan.title}`
622
+ const badge = isRuntimeActive
623
+ ? `<span class="shrink-0 text-[10px] px-1 rounded bg-emerald-500/15 text-emerald-700">${t.active}</span>`
624
+ : ""
625
+ const statusIcon =
626
+ plan.status === "done"
627
+ ? iconSvg("check", "h-4 w-4 shrink-0 text-emerald-700/70")
628
+ : iconSvg("stack", "h-4 w-4 shrink-0 text-muted-foreground/70")
629
+
630
+ return `
631
+ <button
632
+ type="button"
633
+ data-pp-plan-open="${plan.id}"
634
+ class="w-full h-8 text-left flex items-center gap-2 rounded-md border border-transparent px-2 transition-colors ${
635
+ isActive ? "bg-primary/5 border-primary/15" : "hover:bg-muted/30"
636
+ }"
637
+ ${state.busy ? "disabled" : ""}
638
+ title="${htmlEscape(title)}"
639
+ >
640
+ <div class="min-w-0 flex-1 flex items-center gap-1.5">
641
+ <span class="min-w-0 text-xs font-medium truncate">#${plan.id} ${htmlEscape(plan.title)}</span>
642
+ ${badge}
643
+ </div>
644
+ ${statusIcon}
645
+ </button>
646
+ `
647
+ })
648
+ .join("")
649
+
650
+ return `<div class="overflow-y-auto overscroll-contain flex-1 min-h-0" style="max-height: 170px"><div class="flex flex-col gap-[2px]">${rows}</div></div>`
651
+ }
652
+
653
+ function renderSteps(detail: PlanDetail): string {
654
+ const steps = orderedSteps(detail)
655
+ if (!steps.length) {
656
+ return `<div class="py-2 text-center text-xs text-muted-foreground italic leading-relaxed">${t.noStepsYet}</div>`
657
+ }
658
+
659
+ const runtimeNextStepId =
660
+ state.runtime?.activePlan?.plan_id === detail.plan.id ? state.runtime?.nextStep?.step.id ?? 0 : 0
661
+ const derivedNextStepId = runtimeNextStepId
662
+ ? 0
663
+ : steps
664
+ .filter((step) => step.status !== "done")
665
+ .sort((a, b) => {
666
+ if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
667
+ return a.id - b.id
668
+ })[0]?.id ?? 0
669
+ const nextStepId = runtimeNextStepId || derivedNextStepId
670
+ const nextWaitLabel = runtimeNextStepId ? formattedWait(state.runtime?.nextStep ?? null, locale) : ""
671
+
672
+ const items = steps
673
+ .map((step) => {
674
+ const goals = goalsForStepId(detail, step.id)
675
+ const isNext = step.id === nextStepId
676
+ const rowClass = isNext
677
+ ? "bg-primary/5 border-primary/15"
678
+ : step.status === "done"
679
+ ? "bg-muted/20 border-border/30 hover:bg-muted/25"
680
+ : "hover:bg-muted/30"
681
+
682
+ const waitIcon =
683
+ isNext && nextWaitLabel
684
+ ? `<span title="${htmlEscape(`${t.waiting}: ${nextWaitLabel}`)}" aria-label="${t.waiting}">${iconSvg(
685
+ "clock",
686
+ "h-4 w-4 text-amber-600/70",
687
+ )}</span>`
688
+ : ""
689
+ const doneIcon =
690
+ step.status === "done" ? iconSvg("check", "h-4 w-4 text-emerald-700/70") : ""
691
+ const arrow = goals.length
692
+ ? `<span class="${isGoalsExpanded(step.id) ? "" : "-rotate-90"} transition-transform">${iconSvg(
693
+ "chev",
694
+ "h-4 w-4 text-muted-foreground/60",
695
+ )}</span>`
696
+ : ""
697
+
698
+ const goalsBlock =
699
+ goals.length && isGoalsExpanded(step.id)
700
+ ? `
701
+ <div class="pb-1 pl-7 pr-1.5">
702
+ <div class="space-y-0.5">
703
+ ${goals
704
+ .map((goal) => {
705
+ const line = goal.status === "done" ? "bg-emerald-600/40" : "bg-muted-foreground/35"
706
+ const goalDone =
707
+ goal.status === "done" ? iconSvg("check", "mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-700/70") : ""
708
+ return `
709
+ <div class="flex items-start gap-2">
710
+ <span class="mt-2 h-px w-2 shrink-0 ${line}" aria-hidden="true"></span>
711
+ <span class="min-w-0 flex-1 text-[11px] text-muted-foreground leading-snug break-words whitespace-pre-wrap ${
712
+ goal.status === "done" ? "opacity-75" : ""
713
+ }">${htmlEscape(goal.content)}</span>
714
+ ${goalDone}
715
+ </div>
716
+ `
717
+ })
718
+ .join("")}
719
+ </div>
720
+ </div>
721
+ `
722
+ : ""
723
+
724
+ const clickableAttrs = goals.length
725
+ ? `data-pp-step-toggle="${step.id}" role="button" tabindex="0" aria-expanded="${isGoalsExpanded(step.id)}"`
726
+ : ""
727
+
728
+ return `
729
+ <li class="rounded-md border border-transparent transition-colors ${rowClass}">
730
+ <div
731
+ class="flex items-center gap-2 h-9 px-1.5 ${goals.length ? "cursor-pointer" : ""}"
732
+ ${clickableAttrs}
733
+ title="${htmlEscape(step.content)}"
734
+ >
735
+ <div class="min-w-0 flex-1">
736
+ <div class="text-[13px] leading-[1.1] truncate ${step.status === "done" ? "text-muted-foreground opacity-75" : "text-foreground"}">
737
+ ${htmlEscape(step.content)}
738
+ </div>
739
+ </div>
740
+ <div class="flex items-center gap-1 shrink-0">
741
+ ${waitIcon}
742
+ ${doneIcon}
743
+ ${arrow}
744
+ </div>
745
+ </div>
746
+ ${goalsBlock}
747
+ </li>
748
+ `
749
+ })
750
+ .join("")
751
+
752
+ return `<div data-pp-scroll="steps" class="overflow-y-auto overscroll-contain flex-1 min-h-0" style="max-height: 200px"><ol class="flex flex-col gap-[2px]">${items}</ol></div>`
753
+ }
754
+
755
+ function renderExpandedPanel(): string {
756
+ const detail = state.activePlanDetail
757
+ const content = planContent()
758
+ const paused = state.runtime?.paused === true
759
+
760
+ const main =
761
+ detail === null
762
+ ? `<div class="py-2 text-center text-xs text-muted-foreground italic leading-relaxed">${htmlEscape(
763
+ fallbackStatusMessage(),
764
+ )}</div>`
765
+ : state.showOtherPlans
766
+ ? `
767
+ <div class="flex flex-col gap-1 min-h-0">
768
+ ${renderPlanList()}
769
+ </div>
770
+ `
771
+ : `
772
+ <div class="flex flex-col gap-1.5 min-h-0">
773
+ ${
774
+ content
775
+ ? `<div class="text-[10px] text-muted-foreground leading-snug whitespace-pre-wrap break-words max-h-12 overflow-hidden" title="${htmlEscape(
776
+ content,
777
+ )}">${htmlEscape(content)}</div>`
778
+ : ""
779
+ }
780
+ ${paused ? `<div class="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground"><span class="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">${t.paused}</span></div>` : ""}
781
+ ${renderSteps(detail)}
782
+ </div>
783
+ `
784
+
785
+ const errorBlock = state.error
786
+ ? `<div class="rounded bg-destructive/10 px-2 py-1.5 text-xs text-destructive flex items-start gap-2"><span class="font-bold">${t.error}:</span> ${htmlEscape(
787
+ state.error,
788
+ )}</div>`
789
+ : ""
790
+
791
+ const header = `
792
+ <div class="flex items-center border-b border-border/30 bg-muted/20 gap-2 px-2 py-1">
793
+ <div class="flex items-center gap-1.5 min-w-0 flex-1">
794
+ <span class="truncate text-[11px] font-semibold text-foreground/90 select-none cursor-default" title="${htmlEscape(
795
+ headerLabel(),
796
+ )}">${htmlEscape(headerLabel())}</span>
797
+ ${detail && detail.plan.status === "done" ? iconSvg("check", "h-3.5 w-3.5 shrink-0 text-emerald-700/70") : ""}
798
+ ${state.loading ? `<span class="animate-pulse text-[10px] text-muted-foreground">${t.updating}</span>` : ""}
799
+ </div>
800
+ <div class="flex items-center gap-0.5">
801
+ <button
802
+ type="button"
803
+ data-pp-action="refresh"
804
+ class="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted/40"
805
+ title="${t.refresh}"
806
+ aria-label="${t.refresh}"
807
+ ${state.busy ? "disabled" : ""}
808
+ >
809
+ ${iconSvg("refresh", "h-3.5 w-3.5 text-muted-foreground/70")}
810
+ </button>
811
+ <button
812
+ type="button"
813
+ data-pp-action="toggleList"
814
+ class="h-7 w-7 inline-flex items-center justify-center rounded ${state.showOtherPlans ? "bg-muted/40" : "hover:bg-muted/40"}"
815
+ title="${t.planList}"
816
+ aria-label="${t.planList}"
817
+ aria-pressed="${state.showOtherPlans}"
818
+ >
819
+ ${iconSvg("list", "h-3.5 w-3.5")}
820
+ </button>
821
+ ${
822
+ hostManagedMode
823
+ ? `<div class="mx-1 h-3 w-px bg-border/50"></div>
824
+ <button
825
+ type="button"
826
+ data-pp-action="close"
827
+ class="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted/40"
828
+ title="${t.close}"
829
+ aria-label="${t.close}"
830
+ >
831
+ ${iconSvg("x", "h-3.5 w-3.5 text-muted-foreground/70")}
832
+ </button>`
833
+ : `<div class="mx-1 h-3 w-px bg-border/50"></div>
834
+ <button
835
+ type="button"
836
+ data-pp-action="toggle"
837
+ class="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted/40"
838
+ title="${t.collapse}"
839
+ aria-label="${t.collapse}"
840
+ >
841
+ ${iconSvg("hide", "h-3.5 w-3.5 text-muted-foreground/70")}
842
+ </button>`
843
+ }
844
+ </div>
845
+ </div>
846
+ `
847
+
848
+ return `
849
+ <section class="pointer-events-auto w-full rounded-lg border border-border/60 bg-background/95 shadow-xl backdrop-blur-md overflow-hidden transition-all duration-300 ease-in-out flex flex-col max-h-[50vh]">
850
+ ${header}
851
+ <div class="overflow-hidden flex-1 min-h-0 flex flex-col p-1.5 gap-1.5">
852
+ ${main}
853
+ ${errorBlock}
854
+ </div>
855
+ </section>
856
+ `
857
+ }
858
+
859
+ function render() {
860
+ if (!isVisible()) {
861
+ el.innerHTML = ""
862
+ setReservePx(0)
863
+ return
864
+ }
865
+
866
+ const body = hostManagedMode ? renderExpandedPanel() : state.collapsed ? renderCollapsedButton() : renderExpandedPanel()
867
+ const containerClass = hostManagedMode ? "pointer-events-none w-full" : "pointer-events-none w-full flex justify-end"
868
+ el.innerHTML = `<div class="${containerClass}">${body}</div>`
869
+ }
870
+
871
+ function handleClick(event: MouseEvent) {
872
+ const target = event.target as Element | null
873
+ if (!target) return
874
+
875
+ const actionEl = target.closest<HTMLElement>("[data-pp-action]")
876
+ if (actionEl) {
877
+ const action = String(actionEl.dataset.ppAction || "").trim()
878
+ if (action === "toggle") {
879
+ toggleCollapsed()
880
+ return
881
+ }
882
+ if (action === "toggleList") {
883
+ state.showOtherPlans = !state.showOtherPlans
884
+ render()
885
+ scheduleReserveUpdate()
886
+ return
887
+ }
888
+ if (action === "refresh") {
889
+ scheduleRefresh(0)
890
+ return
891
+ }
892
+ if (action === "close") {
893
+ opts.close?.()
894
+ return
895
+ }
896
+ return
897
+ }
898
+
899
+ const planEl = target.closest<HTMLElement>("[data-pp-plan-open]")
900
+ if (planEl) {
901
+ const id = Number(planEl.dataset.ppPlanOpen)
902
+ if (Number.isFinite(id) && id > 0) {
903
+ void openPlan(Math.trunc(id))
904
+ }
905
+ return
906
+ }
907
+
908
+ const stepEl = target.closest<HTMLElement>("[data-pp-step-toggle]")
909
+ if (stepEl) {
910
+ const id = Number(stepEl.dataset.ppStepToggle)
911
+ if (Number.isFinite(id) && id > 0) {
912
+ toggleGoalsExpanded(Math.trunc(id))
913
+ }
914
+ }
915
+ }
916
+
917
+ function handleKeydown(event: KeyboardEvent) {
918
+ const target = event.target as Element | null
919
+ if (!target) return
920
+ if (event.key !== "Enter" && event.key !== " ") return
921
+ const stepEl = target.closest<HTMLElement>("[data-pp-step-toggle]")
922
+ if (!stepEl) return
923
+ event.preventDefault()
924
+ const id = Number(stepEl.dataset.ppStepToggle)
925
+ if (Number.isFinite(id) && id > 0) {
926
+ toggleGoalsExpanded(Math.trunc(id))
927
+ }
928
+ }
929
+
930
+ function startEvents() {
931
+ stopEvents?.()
932
+ stopEvents = opts.host.subscribeEvents({
933
+ onEvent: () => scheduleRefresh(90),
934
+ onError: () => {
935
+ // Keep UI stable; host SSE will reconnect.
936
+ },
937
+ })
938
+ }
939
+
940
+ // Mount lifecycle.
941
+ el.addEventListener("click", handleClick)
942
+ el.addEventListener("keydown", handleKeydown)
943
+
944
+ if (typeof ResizeObserver !== "undefined" && opts.layout) {
945
+ reserveObserver = new ResizeObserver(() => scheduleReserveUpdate())
946
+ reserveObserver.observe(el)
947
+ }
948
+
949
+ render()
950
+ scheduleReserveUpdate()
951
+ startEvents()
952
+ void refreshAll()
953
+
954
+ return {
955
+ unmount() {
956
+ stopEvents?.()
957
+ stopEvents = null
958
+ if (refreshTimer) {
959
+ window.clearTimeout(refreshTimer)
960
+ refreshTimer = null
961
+ }
962
+ reserveObserver?.disconnect()
963
+ reserveObserver = null
964
+ if (reserveRaf) {
965
+ window.cancelAnimationFrame(reserveRaf)
966
+ reserveRaf = 0
967
+ }
968
+ el.removeEventListener("click", handleClick)
969
+ el.removeEventListener("keydown", handleKeydown)
970
+ el.innerHTML = ""
971
+ setReservePx(0)
972
+ },
973
+ }
974
+ }