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.
- package/README.md +397 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3815 -0
- package/dist/index.js.map +1 -0
- package/dist/studio-bridge.d.ts +2 -0
- package/dist/studio-bridge.js +1985 -0
- package/dist/studio-bridge.js.map +1 -0
- package/dist/studio-web/planpilot-todo-bar.d.ts +33 -0
- package/dist/studio-web/planpilot-todo-bar.js +704 -0
- package/dist/studio-web/planpilot-todo-bar.js.map +1 -0
- package/dist/studio.manifest.json +968 -0
- package/package.json +6 -1
- package/src/index.ts +583 -26
- package/src/lib/config.ts +436 -0
- package/src/prompt.ts +7 -1
- package/src/studio/bridge.ts +746 -0
- package/src/studio-web/main.ts +1030 -0
- package/src/studio-web/planpilot-todo-bar.ts +974 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
type PlanStatus = "todo" | "done"
|
|
4
|
+
type StepStatus = "todo" | "done"
|
|
5
|
+
type GoalStatus = "todo" | "done"
|
|
6
|
+
|
|
7
|
+
type PlanRow = {
|
|
8
|
+
id: number
|
|
9
|
+
title: string
|
|
10
|
+
content: string
|
|
11
|
+
status: PlanStatus
|
|
12
|
+
comment: string | null
|
|
13
|
+
updated_at: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type StepRow = {
|
|
17
|
+
id: number
|
|
18
|
+
plan_id: number
|
|
19
|
+
content: string
|
|
20
|
+
status: StepStatus
|
|
21
|
+
executor: "ai" | "human"
|
|
22
|
+
sort_order: number
|
|
23
|
+
comment: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type GoalRow = {
|
|
27
|
+
id: number
|
|
28
|
+
step_id: number
|
|
29
|
+
content: string
|
|
30
|
+
status: GoalStatus
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ActivePlan = {
|
|
34
|
+
plan_id: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type StepDetail = {
|
|
38
|
+
step: StepRow
|
|
39
|
+
goals: GoalRow[]
|
|
40
|
+
wait?: { until: number; reason?: string } | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RuntimeSnapshot = {
|
|
44
|
+
paused: boolean
|
|
45
|
+
activePlan: ActivePlan | null
|
|
46
|
+
nextStep: StepDetail | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type PlanDetail = {
|
|
50
|
+
plan: PlanRow
|
|
51
|
+
steps: StepRow[]
|
|
52
|
+
goals: Array<{ stepId: number; goals: GoalRow[] }>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ActionError = {
|
|
56
|
+
code?: string
|
|
57
|
+
message?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ActionEnvelope<T> = {
|
|
61
|
+
ok: boolean
|
|
62
|
+
data?: T
|
|
63
|
+
error?: ActionError
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type AppState = {
|
|
67
|
+
pluginId: string
|
|
68
|
+
context: Record<string, string>
|
|
69
|
+
plans: PlanRow[]
|
|
70
|
+
runtime: RuntimeSnapshot | null
|
|
71
|
+
selectedPlanId: number | null
|
|
72
|
+
selectedPlan: PlanDetail | null
|
|
73
|
+
loading: boolean
|
|
74
|
+
busyAction: string | null
|
|
75
|
+
message: string
|
|
76
|
+
eventStatus: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const FALLBACK_PLUGIN_ID = "opencode-planpilot"
|
|
80
|
+
const REFRESH_DEBOUNCE_MS = 200
|
|
81
|
+
|
|
82
|
+
type UiLocale = "en-US" | "zh-CN"
|
|
83
|
+
|
|
84
|
+
type UiStrings = {
|
|
85
|
+
unknown: string
|
|
86
|
+
waitingUntil: (time: string, reason: string) => string
|
|
87
|
+
stepsDone: (done: number, total: number) => string
|
|
88
|
+
runtimePaused: string
|
|
89
|
+
runtimeActive: string
|
|
90
|
+
none: string
|
|
91
|
+
runtimeStatus: string
|
|
92
|
+
state: string
|
|
93
|
+
activePlan: string
|
|
94
|
+
nextStep: string
|
|
95
|
+
refresh: string
|
|
96
|
+
resume: string
|
|
97
|
+
pause: string
|
|
98
|
+
deactivate: string
|
|
99
|
+
events: string
|
|
100
|
+
noPlansCreateOne: string
|
|
101
|
+
activeBadge: string
|
|
102
|
+
planDetail: string
|
|
103
|
+
selectPlanHint: string
|
|
104
|
+
noGoals: string
|
|
105
|
+
done: string
|
|
106
|
+
executor: string
|
|
107
|
+
addGoalPlaceholder: string
|
|
108
|
+
addGoal: string
|
|
109
|
+
title: string
|
|
110
|
+
status: string
|
|
111
|
+
progress: string
|
|
112
|
+
goals: string
|
|
113
|
+
reactivate: string
|
|
114
|
+
activate: string
|
|
115
|
+
markPlanDone: string
|
|
116
|
+
steps: string
|
|
117
|
+
newStepContentPlaceholder: string
|
|
118
|
+
optionalGoalsPlaceholder: string
|
|
119
|
+
addStep: string
|
|
120
|
+
noStepsYet: string
|
|
121
|
+
createPlanTree: string
|
|
122
|
+
planTitlePlaceholder: string
|
|
123
|
+
planSummaryPlaceholder: string
|
|
124
|
+
inlineStepsPlaceholder: string
|
|
125
|
+
createTree: string
|
|
126
|
+
loading: string
|
|
127
|
+
sidebarTitle: string
|
|
128
|
+
planList: string
|
|
129
|
+
connecting: string
|
|
130
|
+
live: string
|
|
131
|
+
reconnecting: string
|
|
132
|
+
eventError: string
|
|
133
|
+
invalidActionResponse: (action: string) => string
|
|
134
|
+
actionFailed: (action: string, detail: string) => string
|
|
135
|
+
runtimePausedMessage: string
|
|
136
|
+
runtimeResumedMessage: string
|
|
137
|
+
planDeactivatedMessage: string
|
|
138
|
+
planActivatedMessage: (id: number) => string
|
|
139
|
+
planDoneMessage: (id: number) => string
|
|
140
|
+
stepDoneMessage: (id: number) => string
|
|
141
|
+
goalDoneMessage: (id: number) => string
|
|
142
|
+
planTreeRequiredError: string
|
|
143
|
+
planTreeCreatedMessage: string
|
|
144
|
+
stepContentRequiredError: string
|
|
145
|
+
stepAddedMessage: string
|
|
146
|
+
goalContentRequiredError: string
|
|
147
|
+
goalAddedMessage: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const UI_STRINGS: Record<UiLocale, UiStrings> = {
|
|
151
|
+
"en-US": {
|
|
152
|
+
unknown: "unknown",
|
|
153
|
+
waitingUntil: (time, reason) => `Waiting until ${time}${reason}`,
|
|
154
|
+
stepsDone: (done, total) => `${done}/${total} steps done`,
|
|
155
|
+
runtimePaused: "Paused",
|
|
156
|
+
runtimeActive: "Active",
|
|
157
|
+
none: "None",
|
|
158
|
+
runtimeStatus: "Runtime status",
|
|
159
|
+
state: "State",
|
|
160
|
+
activePlan: "Active plan",
|
|
161
|
+
nextStep: "Next step",
|
|
162
|
+
refresh: "Refresh",
|
|
163
|
+
resume: "Resume",
|
|
164
|
+
pause: "Pause",
|
|
165
|
+
deactivate: "Deactivate",
|
|
166
|
+
events: "Events",
|
|
167
|
+
noPlansCreateOne: "No plans yet. Create one below.",
|
|
168
|
+
activeBadge: "active",
|
|
169
|
+
planDetail: "Plan detail",
|
|
170
|
+
selectPlanHint: "Select a plan to inspect details.",
|
|
171
|
+
noGoals: "No goals",
|
|
172
|
+
done: "Done",
|
|
173
|
+
executor: "executor",
|
|
174
|
+
addGoalPlaceholder: "Add goal",
|
|
175
|
+
addGoal: "Add goal",
|
|
176
|
+
title: "Title",
|
|
177
|
+
status: "Status",
|
|
178
|
+
progress: "Progress",
|
|
179
|
+
goals: "Goals",
|
|
180
|
+
reactivate: "Re-activate",
|
|
181
|
+
activate: "Activate",
|
|
182
|
+
markPlanDone: "Mark plan done",
|
|
183
|
+
steps: "Steps",
|
|
184
|
+
newStepContentPlaceholder: "New step content",
|
|
185
|
+
optionalGoalsPlaceholder: "Optional goals, one per line",
|
|
186
|
+
addStep: "Add step",
|
|
187
|
+
noStepsYet: "No steps yet.",
|
|
188
|
+
createPlanTree: "Create plan tree",
|
|
189
|
+
planTitlePlaceholder: "Plan title",
|
|
190
|
+
planSummaryPlaceholder: "Plan summary",
|
|
191
|
+
inlineStepsPlaceholder: "One step per line. Use :: goal A | goal B for inline goals",
|
|
192
|
+
createTree: "Create tree",
|
|
193
|
+
loading: "Loading...",
|
|
194
|
+
sidebarTitle: "Planpilot sidebar",
|
|
195
|
+
planList: "Plan list",
|
|
196
|
+
connecting: "connecting",
|
|
197
|
+
live: "live",
|
|
198
|
+
reconnecting: "reconnecting",
|
|
199
|
+
eventError: "Plugin event error",
|
|
200
|
+
invalidActionResponse: (action) => `Invalid action response for '${action}'`,
|
|
201
|
+
actionFailed: (action, detail) => `Action '${action}' failed: ${detail}`,
|
|
202
|
+
runtimePausedMessage: "Runtime paused",
|
|
203
|
+
runtimeResumedMessage: "Runtime resumed",
|
|
204
|
+
planDeactivatedMessage: "Plan deactivated",
|
|
205
|
+
planActivatedMessage: (id) => `Plan ${id} activated`,
|
|
206
|
+
planDoneMessage: (id) => `Plan ${id} marked done`,
|
|
207
|
+
stepDoneMessage: (id) => `Step ${id} marked done`,
|
|
208
|
+
goalDoneMessage: (id) => `Goal ${id} marked done`,
|
|
209
|
+
planTreeRequiredError: "Plan title, content, and at least one step are required",
|
|
210
|
+
planTreeCreatedMessage: "Plan tree created",
|
|
211
|
+
stepContentRequiredError: "Step content is required",
|
|
212
|
+
stepAddedMessage: "Step added",
|
|
213
|
+
goalContentRequiredError: "Goal content is required",
|
|
214
|
+
goalAddedMessage: "Goal added",
|
|
215
|
+
},
|
|
216
|
+
"zh-CN": {
|
|
217
|
+
unknown: "未知",
|
|
218
|
+
waitingUntil: (time, reason) => `等待至 ${time}${reason}`,
|
|
219
|
+
stepsDone: (done, total) => `已完成步骤 ${done}/${total}`,
|
|
220
|
+
runtimePaused: "已暂停",
|
|
221
|
+
runtimeActive: "运行中",
|
|
222
|
+
none: "无",
|
|
223
|
+
runtimeStatus: "运行时状态",
|
|
224
|
+
state: "状态",
|
|
225
|
+
activePlan: "当前计划",
|
|
226
|
+
nextStep: "下一步",
|
|
227
|
+
refresh: "刷新",
|
|
228
|
+
resume: "继续",
|
|
229
|
+
pause: "暂停",
|
|
230
|
+
deactivate: "取消激活",
|
|
231
|
+
events: "事件",
|
|
232
|
+
noPlansCreateOne: "还没有计划。请在下方创建。",
|
|
233
|
+
activeBadge: "进行中",
|
|
234
|
+
planDetail: "计划详情",
|
|
235
|
+
selectPlanHint: "选择一个计划以查看详情。",
|
|
236
|
+
noGoals: "无目标",
|
|
237
|
+
done: "完成",
|
|
238
|
+
executor: "执行者",
|
|
239
|
+
addGoalPlaceholder: "添加目标",
|
|
240
|
+
addGoal: "添加目标",
|
|
241
|
+
title: "标题",
|
|
242
|
+
status: "状态",
|
|
243
|
+
progress: "进度",
|
|
244
|
+
goals: "目标数",
|
|
245
|
+
reactivate: "重新激活",
|
|
246
|
+
activate: "激活",
|
|
247
|
+
markPlanDone: "标记计划完成",
|
|
248
|
+
steps: "步骤",
|
|
249
|
+
newStepContentPlaceholder: "新步骤内容",
|
|
250
|
+
optionalGoalsPlaceholder: "可选目标,每行一个",
|
|
251
|
+
addStep: "添加步骤",
|
|
252
|
+
noStepsYet: "暂无步骤。",
|
|
253
|
+
createPlanTree: "创建计划树",
|
|
254
|
+
planTitlePlaceholder: "计划标题",
|
|
255
|
+
planSummaryPlaceholder: "计划摘要",
|
|
256
|
+
inlineStepsPlaceholder: "每行一个步骤。使用 :: 目标A | 目标B 添加内联目标",
|
|
257
|
+
createTree: "创建计划树",
|
|
258
|
+
loading: "加载中...",
|
|
259
|
+
sidebarTitle: "Planpilot 侧边栏",
|
|
260
|
+
planList: "计划列表",
|
|
261
|
+
connecting: "连接中",
|
|
262
|
+
live: "已连接",
|
|
263
|
+
reconnecting: "重连中",
|
|
264
|
+
eventError: "插件事件错误",
|
|
265
|
+
invalidActionResponse: (action) => `动作 '${action}' 的响应无效`,
|
|
266
|
+
actionFailed: (action, detail) => `动作 '${action}' 失败: ${detail}`,
|
|
267
|
+
runtimePausedMessage: "运行时已暂停",
|
|
268
|
+
runtimeResumedMessage: "运行时已继续",
|
|
269
|
+
planDeactivatedMessage: "计划已取消激活",
|
|
270
|
+
planActivatedMessage: (id) => `计划 ${id} 已激活`,
|
|
271
|
+
planDoneMessage: (id) => `计划 ${id} 已标记完成`,
|
|
272
|
+
stepDoneMessage: (id) => `步骤 ${id} 已标记完成`,
|
|
273
|
+
goalDoneMessage: (id) => `目标 ${id} 已标记完成`,
|
|
274
|
+
planTreeRequiredError: "计划标题、内容以及至少一个步骤为必填项",
|
|
275
|
+
planTreeCreatedMessage: "计划树已创建",
|
|
276
|
+
stepContentRequiredError: "步骤内容为必填项",
|
|
277
|
+
stepAddedMessage: "步骤已添加",
|
|
278
|
+
goalContentRequiredError: "目标内容为必填项",
|
|
279
|
+
goalAddedMessage: "目标已添加",
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeLocale(value: string | null | undefined): UiLocale {
|
|
284
|
+
const normalized = String(value || "").trim().toLowerCase()
|
|
285
|
+
if (normalized.startsWith("zh")) return "zh-CN"
|
|
286
|
+
return "en-US"
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function detectLocale(): UiLocale {
|
|
290
|
+
const params = new URLSearchParams(window.location.search)
|
|
291
|
+
return normalizeLocale(params.get("locale") || params.get("lang"))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const locale = detectLocale()
|
|
295
|
+
const t = UI_STRINGS[locale]
|
|
296
|
+
|
|
297
|
+
const state: AppState = {
|
|
298
|
+
pluginId: detectPluginId(),
|
|
299
|
+
context: detectContext(),
|
|
300
|
+
plans: [],
|
|
301
|
+
runtime: null,
|
|
302
|
+
selectedPlanId: null,
|
|
303
|
+
selectedPlan: null,
|
|
304
|
+
loading: true,
|
|
305
|
+
busyAction: null,
|
|
306
|
+
message: "",
|
|
307
|
+
eventStatus: t.connecting,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let refreshTimer = 0
|
|
311
|
+
|
|
312
|
+
function detectPluginId(): string {
|
|
313
|
+
const fromSearch = new URLSearchParams(window.location.search).get("pluginId")
|
|
314
|
+
if (fromSearch && fromSearch.trim()) return fromSearch.trim()
|
|
315
|
+
|
|
316
|
+
const match = window.location.pathname.match(/\/api\/plugins\/([^/]+)\/assets\//)
|
|
317
|
+
if (match && match[1]) {
|
|
318
|
+
try {
|
|
319
|
+
return decodeURIComponent(match[1])
|
|
320
|
+
} catch {
|
|
321
|
+
return match[1]
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return FALLBACK_PLUGIN_ID
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function detectContext(): Record<string, string> {
|
|
328
|
+
const params = new URLSearchParams(window.location.search)
|
|
329
|
+
const context: Record<string, string> = {}
|
|
330
|
+
const sessionId = params.get("sessionId") || params.get("sessionID")
|
|
331
|
+
const cwd = params.get("cwd") || params.get("directory")
|
|
332
|
+
if (sessionId && sessionId.trim()) context.sessionId = sessionId.trim()
|
|
333
|
+
if (cwd && cwd.trim()) context.cwd = cwd.trim()
|
|
334
|
+
return context
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function goalsForStep(detail: PlanDetail, stepId: number): GoalRow[] {
|
|
338
|
+
const found = detail.goals.find((entry) => entry.stepId === stepId)
|
|
339
|
+
return found ? found.goals : []
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function statusTag(status: PlanStatus | StepStatus | GoalStatus): string {
|
|
343
|
+
return status === "done" ? "done" : "todo"
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function statusLabel(status: PlanStatus | StepStatus | GoalStatus): string {
|
|
347
|
+
if (status === "done") return t.done
|
|
348
|
+
return locale === "zh-CN" ? "待办" : "todo"
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function activePlanId(): number | null {
|
|
352
|
+
return state.runtime?.activePlan?.plan_id ?? null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function planById(id: number | null): PlanRow | undefined {
|
|
356
|
+
if (id === null) return undefined
|
|
357
|
+
return state.plans.find((plan) => plan.id === id)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function formatWait(wait: StepDetail["wait"]): string {
|
|
361
|
+
if (!wait || typeof wait.until !== "number") return ""
|
|
362
|
+
const date = new Date(wait.until)
|
|
363
|
+
const time = Number.isNaN(date.getTime()) ? t.unknown : date.toLocaleString(locale)
|
|
364
|
+
const reason = wait.reason ? ` - ${escapeHtml(wait.reason)}` : ""
|
|
365
|
+
return t.waitingUntil(escapeHtml(time), reason)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function escapeHtml(value: string): string {
|
|
369
|
+
return value
|
|
370
|
+
.replaceAll("&", "&")
|
|
371
|
+
.replaceAll("<", "<")
|
|
372
|
+
.replaceAll(">", ">")
|
|
373
|
+
.replaceAll('"', """)
|
|
374
|
+
.replaceAll("'", "'")
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function selectedPlanGoalCount(detail: PlanDetail): number {
|
|
378
|
+
return detail.goals.reduce((count, entry) => count + entry.goals.length, 0)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function planProgressLabel(detail: PlanDetail): string {
|
|
382
|
+
const doneSteps = detail.steps.filter((step) => step.status === "done").length
|
|
383
|
+
return t.stepsDone(doneSteps, detail.steps.length)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function parseTreeSteps(input: string): Array<{ content: string; executor: "ai" | "human"; goals: string[] }> {
|
|
387
|
+
const lines = input
|
|
388
|
+
.split("\n")
|
|
389
|
+
.map((line) => line.trim())
|
|
390
|
+
.filter((line) => line.length > 0)
|
|
391
|
+
|
|
392
|
+
const steps: Array<{ content: string; executor: "ai" | "human"; goals: string[] }> = []
|
|
393
|
+
for (const line of lines) {
|
|
394
|
+
const parts = line.split("::")
|
|
395
|
+
const content = parts[0]?.trim() ?? ""
|
|
396
|
+
if (!content) continue
|
|
397
|
+
const rawGoals = (parts[1] ?? "")
|
|
398
|
+
.split("|")
|
|
399
|
+
.map((goal) => goal.trim())
|
|
400
|
+
.filter((goal) => goal.length > 0)
|
|
401
|
+
steps.push({ content, executor: "ai", goals: rawGoals })
|
|
402
|
+
}
|
|
403
|
+
return steps
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function invokeAction<T>(action: string, payload: unknown = null): Promise<T> {
|
|
407
|
+
const response = await fetch(`/api/plugins/${encodeURIComponent(state.pluginId)}/action`, {
|
|
408
|
+
method: "POST",
|
|
409
|
+
headers: {
|
|
410
|
+
"content-type": "application/json",
|
|
411
|
+
},
|
|
412
|
+
body: JSON.stringify({
|
|
413
|
+
action,
|
|
414
|
+
payload,
|
|
415
|
+
context: state.context,
|
|
416
|
+
}),
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
let envelope: ActionEnvelope<T>
|
|
420
|
+
try {
|
|
421
|
+
envelope = (await response.json()) as ActionEnvelope<T>
|
|
422
|
+
} catch {
|
|
423
|
+
throw new Error(t.invalidActionResponse(action))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!response.ok || !envelope.ok) {
|
|
427
|
+
const detail = envelope.error?.message || envelope.error?.code || `HTTP ${response.status}`
|
|
428
|
+
throw new Error(t.actionFailed(action, detail))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return envelope.data as T
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function loadBaseState(): Promise<void> {
|
|
435
|
+
const [runtime, plans] = await Promise.all([
|
|
436
|
+
invokeAction<RuntimeSnapshot>("runtime.snapshot"),
|
|
437
|
+
invokeAction<PlanRow[]>("plan.list"),
|
|
438
|
+
])
|
|
439
|
+
state.runtime = runtime
|
|
440
|
+
state.plans = plans
|
|
441
|
+
|
|
442
|
+
const activeId = runtime.activePlan?.plan_id ?? null
|
|
443
|
+
if (state.selectedPlanId === null) {
|
|
444
|
+
state.selectedPlanId = activeId ?? (plans[0]?.id ?? null)
|
|
445
|
+
} else if (!plans.some((plan) => plan.id === state.selectedPlanId)) {
|
|
446
|
+
state.selectedPlanId = activeId ?? (plans[0]?.id ?? null)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function loadSelectedPlan(): Promise<void> {
|
|
451
|
+
if (state.selectedPlanId === null) {
|
|
452
|
+
state.selectedPlan = null
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
state.selectedPlan = await invokeAction<PlanDetail>("plan.get", { id: state.selectedPlanId })
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function refreshAll(): Promise<void> {
|
|
459
|
+
state.loading = true
|
|
460
|
+
render()
|
|
461
|
+
try {
|
|
462
|
+
await loadBaseState()
|
|
463
|
+
await loadSelectedPlan()
|
|
464
|
+
state.message = ""
|
|
465
|
+
} catch (error) {
|
|
466
|
+
state.message = error instanceof Error ? error.message : String(error)
|
|
467
|
+
} finally {
|
|
468
|
+
state.loading = false
|
|
469
|
+
render()
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function scheduleRefresh(): void {
|
|
474
|
+
if (refreshTimer) window.clearTimeout(refreshTimer)
|
|
475
|
+
refreshTimer = window.setTimeout(() => {
|
|
476
|
+
refreshTimer = 0
|
|
477
|
+
void refreshAll()
|
|
478
|
+
}, REFRESH_DEBOUNCE_MS)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function runAction(action: string, payload: unknown, successMessage: string): Promise<void> {
|
|
482
|
+
state.busyAction = action
|
|
483
|
+
state.message = ""
|
|
484
|
+
render()
|
|
485
|
+
try {
|
|
486
|
+
await invokeAction<unknown>(action, payload)
|
|
487
|
+
state.message = successMessage
|
|
488
|
+
await refreshAll()
|
|
489
|
+
} catch (error) {
|
|
490
|
+
state.message = error instanceof Error ? error.message : String(error)
|
|
491
|
+
render()
|
|
492
|
+
} finally {
|
|
493
|
+
state.busyAction = null
|
|
494
|
+
render()
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function bindUiHandlers(root: HTMLElement): void {
|
|
499
|
+
root.querySelectorAll<HTMLButtonElement>("[data-plan-select]").forEach((button) => {
|
|
500
|
+
button.addEventListener("click", () => {
|
|
501
|
+
const planId = Number(button.dataset.planSelect)
|
|
502
|
+
if (!Number.isFinite(planId)) return
|
|
503
|
+
state.selectedPlanId = planId
|
|
504
|
+
void loadSelectedPlan().then(render).catch((error) => {
|
|
505
|
+
state.message = error instanceof Error ? error.message : String(error)
|
|
506
|
+
render()
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
const refreshBtn = root.querySelector<HTMLButtonElement>("[data-action='refresh']")
|
|
512
|
+
if (refreshBtn) {
|
|
513
|
+
refreshBtn.addEventListener("click", () => {
|
|
514
|
+
void refreshAll()
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const pauseBtn = root.querySelector<HTMLButtonElement>("[data-action='pause']")
|
|
519
|
+
if (pauseBtn) {
|
|
520
|
+
pauseBtn.addEventListener("click", () => {
|
|
521
|
+
void runAction("runtime.pause", null, t.runtimePausedMessage)
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const resumeBtn = root.querySelector<HTMLButtonElement>("[data-action='resume']")
|
|
526
|
+
if (resumeBtn) {
|
|
527
|
+
resumeBtn.addEventListener("click", () => {
|
|
528
|
+
void runAction("runtime.resume", null, t.runtimeResumedMessage)
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const deactivateBtn = root.querySelector<HTMLButtonElement>("[data-action='deactivate']")
|
|
533
|
+
if (deactivateBtn) {
|
|
534
|
+
deactivateBtn.addEventListener("click", () => {
|
|
535
|
+
void runAction("plan.deactivate", null, t.planDeactivatedMessage)
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
root.querySelectorAll<HTMLButtonElement>("[data-plan-activate]").forEach((button) => {
|
|
540
|
+
button.addEventListener("click", () => {
|
|
541
|
+
const id = Number(button.dataset.planActivate)
|
|
542
|
+
if (!Number.isFinite(id)) return
|
|
543
|
+
void runAction("plan.activate", { id, force: true }, t.planActivatedMessage(id))
|
|
544
|
+
})
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
root.querySelectorAll<HTMLButtonElement>("[data-plan-done]").forEach((button) => {
|
|
548
|
+
button.addEventListener("click", () => {
|
|
549
|
+
const id = Number(button.dataset.planDone)
|
|
550
|
+
if (!Number.isFinite(id)) return
|
|
551
|
+
void runAction("plan.done", { id }, t.planDoneMessage(id))
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
root.querySelectorAll<HTMLButtonElement>("[data-step-done]").forEach((button) => {
|
|
556
|
+
button.addEventListener("click", () => {
|
|
557
|
+
const id = Number(button.dataset.stepDone)
|
|
558
|
+
if (!Number.isFinite(id)) return
|
|
559
|
+
void runAction("step.done", { id }, t.stepDoneMessage(id))
|
|
560
|
+
})
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
root.querySelectorAll<HTMLButtonElement>("[data-goal-done]").forEach((button) => {
|
|
564
|
+
button.addEventListener("click", () => {
|
|
565
|
+
const id = Number(button.dataset.goalDone)
|
|
566
|
+
if (!Number.isFinite(id)) return
|
|
567
|
+
void runAction("goal.done", { id }, t.goalDoneMessage(id))
|
|
568
|
+
})
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
const createForm = root.querySelector<HTMLFormElement>("[data-form='create-plan-tree']")
|
|
572
|
+
if (createForm) {
|
|
573
|
+
createForm.addEventListener("submit", (event) => {
|
|
574
|
+
event.preventDefault()
|
|
575
|
+
const titleInput = createForm.querySelector<HTMLInputElement>("[name='title']")
|
|
576
|
+
const contentInput = createForm.querySelector<HTMLTextAreaElement>("[name='content']")
|
|
577
|
+
const stepsInput = createForm.querySelector<HTMLTextAreaElement>("[name='steps']")
|
|
578
|
+
const title = titleInput?.value.trim() ?? ""
|
|
579
|
+
const content = contentInput?.value.trim() ?? ""
|
|
580
|
+
const stepsText = stepsInput?.value ?? ""
|
|
581
|
+
const steps = parseTreeSteps(stepsText)
|
|
582
|
+
if (!title || !content || steps.length === 0) {
|
|
583
|
+
state.message = t.planTreeRequiredError
|
|
584
|
+
render()
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
void runAction(
|
|
589
|
+
"plan.createTree",
|
|
590
|
+
{ title, content, steps },
|
|
591
|
+
t.planTreeCreatedMessage,
|
|
592
|
+
).then(() => {
|
|
593
|
+
createForm.reset()
|
|
594
|
+
})
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const addStepForm = root.querySelector<HTMLFormElement>("[data-form='add-step']")
|
|
599
|
+
if (addStepForm) {
|
|
600
|
+
addStepForm.addEventListener("submit", (event) => {
|
|
601
|
+
event.preventDefault()
|
|
602
|
+
const planId = Number(addStepForm.dataset.planId)
|
|
603
|
+
const contentInput = addStepForm.querySelector<HTMLInputElement>("[name='content']")
|
|
604
|
+
const goalsInput = addStepForm.querySelector<HTMLTextAreaElement>("[name='goals']")
|
|
605
|
+
const content = contentInput?.value.trim() ?? ""
|
|
606
|
+
const goals = (goalsInput?.value ?? "")
|
|
607
|
+
.split("\n")
|
|
608
|
+
.map((goal) => goal.trim())
|
|
609
|
+
.filter((goal) => goal.length > 0)
|
|
610
|
+
if (!Number.isFinite(planId) || !content) {
|
|
611
|
+
state.message = t.stepContentRequiredError
|
|
612
|
+
render()
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
void runAction("step.addTree", { planId, content, executor: "ai", goals }, t.stepAddedMessage).then(() => {
|
|
616
|
+
addStepForm.reset()
|
|
617
|
+
})
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
root.querySelectorAll<HTMLFormElement>("[data-form='add-goal']").forEach((form) => {
|
|
622
|
+
form.addEventListener("submit", (event) => {
|
|
623
|
+
event.preventDefault()
|
|
624
|
+
const stepId = Number(form.dataset.stepId)
|
|
625
|
+
const contentInput = form.querySelector<HTMLInputElement>("[name='goalContent']")
|
|
626
|
+
const content = contentInput?.value.trim() ?? ""
|
|
627
|
+
if (!Number.isFinite(stepId) || !content) {
|
|
628
|
+
state.message = t.goalContentRequiredError
|
|
629
|
+
render()
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
void runAction("goal.add", { stepId, content }, t.goalAddedMessage).then(() => {
|
|
633
|
+
form.reset()
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function renderRuntimeCard(): string {
|
|
640
|
+
const runtime = state.runtime
|
|
641
|
+
const isPaused = runtime?.paused ? t.runtimePaused : t.runtimeActive
|
|
642
|
+
const activeId = runtime?.activePlan?.plan_id ?? null
|
|
643
|
+
const activePlan = planById(activeId)
|
|
644
|
+
const nextStep = runtime?.nextStep?.step
|
|
645
|
+
const waitText = runtime?.nextStep ? formatWait(runtime.nextStep.wait ?? null) : ""
|
|
646
|
+
const actionBusy = state.busyAction !== null
|
|
647
|
+
|
|
648
|
+
return `
|
|
649
|
+
<section class="card">
|
|
650
|
+
<h2>${t.runtimeStatus}</h2>
|
|
651
|
+
<div class="runtime-line"><span class="label">${t.state}</span><strong>${escapeHtml(isPaused)}</strong></div>
|
|
652
|
+
<div class="runtime-line"><span class="label">${t.activePlan}</span><span>${activePlan ? `#${activePlan.id} ${escapeHtml(activePlan.title)}` : t.none}</span></div>
|
|
653
|
+
<div class="runtime-line"><span class="label">${t.nextStep}</span><span>${nextStep ? `#${nextStep.id} ${escapeHtml(nextStep.content)}` : t.none}</span></div>
|
|
654
|
+
${waitText ? `<div class="note">${waitText}</div>` : ""}
|
|
655
|
+
<div class="row-actions">
|
|
656
|
+
<button data-action="refresh" ${actionBusy ? "disabled" : ""}>${t.refresh}</button>
|
|
657
|
+
${runtime?.paused ? `<button data-action="resume" ${actionBusy ? "disabled" : ""}>${t.resume}</button>` : `<button data-action="pause" ${actionBusy ? "disabled" : ""}>${t.pause}</button>`}
|
|
658
|
+
<button data-action="deactivate" ${actionBusy ? "disabled" : ""}>${t.deactivate}</button>
|
|
659
|
+
</div>
|
|
660
|
+
<div class="small">${t.events}: ${escapeHtml(state.eventStatus)}</div>
|
|
661
|
+
</section>
|
|
662
|
+
`
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function renderPlanList(): string {
|
|
666
|
+
if (state.plans.length === 0) {
|
|
667
|
+
return `<div class="empty">${t.noPlansCreateOne}</div>`
|
|
668
|
+
}
|
|
669
|
+
return state.plans
|
|
670
|
+
.map((plan) => {
|
|
671
|
+
const isSelected = state.selectedPlanId === plan.id
|
|
672
|
+
const isActive = activePlanId() === plan.id
|
|
673
|
+
return `
|
|
674
|
+
<button class="plan-item ${isSelected ? "selected" : ""}" data-plan-select="${plan.id}">
|
|
675
|
+
<span class="plan-title">#${plan.id} ${escapeHtml(plan.title)}</span>
|
|
676
|
+
<span class="plan-meta">
|
|
677
|
+
<span class="pill ${statusTag(plan.status)}">${statusLabel(plan.status)}</span>
|
|
678
|
+
${isActive ? `<span class="pill active">${t.activeBadge}</span>` : ""}
|
|
679
|
+
</span>
|
|
680
|
+
</button>
|
|
681
|
+
`
|
|
682
|
+
})
|
|
683
|
+
.join("")
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function renderPlanDetail(): string {
|
|
687
|
+
const detail = state.selectedPlan
|
|
688
|
+
if (!detail) {
|
|
689
|
+
return `<section class="card"><h2>${t.planDetail}</h2><div class="empty">${t.selectPlanHint}</div></section>`
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const stepRows = detail.steps
|
|
693
|
+
.map((step) => {
|
|
694
|
+
const goals = goalsForStep(detail, step.id)
|
|
695
|
+
const goalItems = goals.length
|
|
696
|
+
? goals
|
|
697
|
+
.map(
|
|
698
|
+
(goal) => `
|
|
699
|
+
<li>
|
|
700
|
+
<span class="goal-text">#${goal.id} ${escapeHtml(goal.content)}</span>
|
|
701
|
+
<span class="pill ${statusTag(goal.status)}">${statusLabel(goal.status)}</span>
|
|
702
|
+
${goal.status === "todo" ? `<button data-goal-done="${goal.id}" ${state.busyAction ? "disabled" : ""}>${t.done}</button>` : ""}
|
|
703
|
+
</li>
|
|
704
|
+
`,
|
|
705
|
+
)
|
|
706
|
+
.join("")
|
|
707
|
+
: `<li class="empty">${t.noGoals}</li>`
|
|
708
|
+
|
|
709
|
+
return `
|
|
710
|
+
<article class="step-card">
|
|
711
|
+
<div class="step-head">
|
|
712
|
+
<div>
|
|
713
|
+
<strong>#${step.id}</strong> ${escapeHtml(step.content)}
|
|
714
|
+
<div class="small">${t.executor}: ${escapeHtml(step.executor)}</div>
|
|
715
|
+
</div>
|
|
716
|
+
<div class="row-actions">
|
|
717
|
+
<span class="pill ${statusTag(step.status)}">${statusLabel(step.status)}</span>
|
|
718
|
+
${step.status === "todo" ? `<button data-step-done="${step.id}" ${state.busyAction ? "disabled" : ""}>${t.done}</button>` : ""}
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
<ul class="goal-list">${goalItems}</ul>
|
|
722
|
+
<form class="inline-form" data-form="add-goal" data-step-id="${step.id}">
|
|
723
|
+
<input name="goalContent" type="text" placeholder="${t.addGoalPlaceholder}" />
|
|
724
|
+
<button type="submit" ${state.busyAction ? "disabled" : ""}>${t.addGoal}</button>
|
|
725
|
+
</form>
|
|
726
|
+
</article>
|
|
727
|
+
`
|
|
728
|
+
})
|
|
729
|
+
.join("")
|
|
730
|
+
|
|
731
|
+
const isActive = activePlanId() === detail.plan.id
|
|
732
|
+
return `
|
|
733
|
+
<section class="card">
|
|
734
|
+
<h2>${t.planDetail}</h2>
|
|
735
|
+
<div class="runtime-line"><span class="label">${t.title}</span><strong>${escapeHtml(detail.plan.title)}</strong></div>
|
|
736
|
+
<div class="runtime-line"><span class="label">${t.status}</span><span class="pill ${statusTag(detail.plan.status)}">${statusLabel(detail.plan.status)}</span></div>
|
|
737
|
+
<div class="runtime-line"><span class="label">${t.progress}</span><span>${escapeHtml(planProgressLabel(detail))}</span></div>
|
|
738
|
+
<div class="runtime-line"><span class="label">${t.goals}</span><span>${selectedPlanGoalCount(detail)}</span></div>
|
|
739
|
+
<p class="content">${escapeHtml(detail.plan.content)}</p>
|
|
740
|
+
<div class="row-actions">
|
|
741
|
+
<button data-plan-activate="${detail.plan.id}" ${state.busyAction ? "disabled" : ""}>${isActive ? t.reactivate : t.activate}</button>
|
|
742
|
+
<button data-plan-done="${detail.plan.id}" ${state.busyAction ? "disabled" : ""}>${t.markPlanDone}</button>
|
|
743
|
+
</div>
|
|
744
|
+
</section>
|
|
745
|
+
<section class="card">
|
|
746
|
+
<h2>${t.steps}</h2>
|
|
747
|
+
<form class="stack-form" data-form="add-step" data-plan-id="${detail.plan.id}">
|
|
748
|
+
<input name="content" type="text" placeholder="${t.newStepContentPlaceholder}" />
|
|
749
|
+
<textarea name="goals" rows="3" placeholder="${t.optionalGoalsPlaceholder}"></textarea>
|
|
750
|
+
<button type="submit" ${state.busyAction ? "disabled" : ""}>${t.addStep}</button>
|
|
751
|
+
</form>
|
|
752
|
+
${stepRows || `<div class="empty">${t.noStepsYet}</div>`}
|
|
753
|
+
</section>
|
|
754
|
+
`
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function renderCreateForm(): string {
|
|
758
|
+
return `
|
|
759
|
+
<section class="card">
|
|
760
|
+
<h2>${t.createPlanTree}</h2>
|
|
761
|
+
<form class="stack-form" data-form="create-plan-tree">
|
|
762
|
+
<input name="title" type="text" placeholder="${t.planTitlePlaceholder}" />
|
|
763
|
+
<textarea name="content" rows="3" placeholder="${t.planSummaryPlaceholder}"></textarea>
|
|
764
|
+
<textarea name="steps" rows="4" placeholder="${t.inlineStepsPlaceholder}"></textarea>
|
|
765
|
+
<button type="submit" ${state.busyAction ? "disabled" : ""}>${t.createTree}</button>
|
|
766
|
+
</form>
|
|
767
|
+
</section>
|
|
768
|
+
`
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function renderStyles(): string {
|
|
772
|
+
return `
|
|
773
|
+
<style>
|
|
774
|
+
:root {
|
|
775
|
+
color-scheme: light;
|
|
776
|
+
--bg: #f4f6ef;
|
|
777
|
+
--card: #ffffff;
|
|
778
|
+
--ink: #1f2a2a;
|
|
779
|
+
--muted: #5c6767;
|
|
780
|
+
--line: #d5d9d3;
|
|
781
|
+
--ok: #1f7a52;
|
|
782
|
+
--todo: #b55f2d;
|
|
783
|
+
--active: #125ea9;
|
|
784
|
+
}
|
|
785
|
+
* { box-sizing: border-box; }
|
|
786
|
+
body {
|
|
787
|
+
margin: 0;
|
|
788
|
+
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
|
789
|
+
color: var(--ink);
|
|
790
|
+
background: radial-gradient(circle at top left, #fafcf5, var(--bg));
|
|
791
|
+
}
|
|
792
|
+
#app {
|
|
793
|
+
padding: 10px;
|
|
794
|
+
display: grid;
|
|
795
|
+
gap: 10px;
|
|
796
|
+
}
|
|
797
|
+
.card {
|
|
798
|
+
background: var(--card);
|
|
799
|
+
border: 1px solid var(--line);
|
|
800
|
+
border-radius: 12px;
|
|
801
|
+
padding: 10px;
|
|
802
|
+
}
|
|
803
|
+
h1, h2 {
|
|
804
|
+
margin: 0 0 8px;
|
|
805
|
+
font-size: 14px;
|
|
806
|
+
}
|
|
807
|
+
.small {
|
|
808
|
+
color: var(--muted);
|
|
809
|
+
font-size: 11px;
|
|
810
|
+
}
|
|
811
|
+
.note {
|
|
812
|
+
margin: 6px 0;
|
|
813
|
+
color: var(--muted);
|
|
814
|
+
font-size: 12px;
|
|
815
|
+
}
|
|
816
|
+
.runtime-line {
|
|
817
|
+
display: flex;
|
|
818
|
+
gap: 8px;
|
|
819
|
+
justify-content: space-between;
|
|
820
|
+
margin-bottom: 6px;
|
|
821
|
+
font-size: 12px;
|
|
822
|
+
}
|
|
823
|
+
.label {
|
|
824
|
+
color: var(--muted);
|
|
825
|
+
}
|
|
826
|
+
.row-actions {
|
|
827
|
+
display: flex;
|
|
828
|
+
gap: 6px;
|
|
829
|
+
flex-wrap: wrap;
|
|
830
|
+
}
|
|
831
|
+
button {
|
|
832
|
+
border: 1px solid var(--line);
|
|
833
|
+
border-radius: 8px;
|
|
834
|
+
background: #f9faf7;
|
|
835
|
+
color: var(--ink);
|
|
836
|
+
padding: 5px 8px;
|
|
837
|
+
font-size: 12px;
|
|
838
|
+
cursor: pointer;
|
|
839
|
+
}
|
|
840
|
+
button:disabled {
|
|
841
|
+
opacity: 0.6;
|
|
842
|
+
cursor: default;
|
|
843
|
+
}
|
|
844
|
+
.pill {
|
|
845
|
+
display: inline-flex;
|
|
846
|
+
align-items: center;
|
|
847
|
+
border-radius: 999px;
|
|
848
|
+
padding: 2px 7px;
|
|
849
|
+
font-size: 11px;
|
|
850
|
+
border: 1px solid var(--line);
|
|
851
|
+
}
|
|
852
|
+
.pill.done {
|
|
853
|
+
color: var(--ok);
|
|
854
|
+
border-color: color-mix(in srgb, var(--ok), white 65%);
|
|
855
|
+
}
|
|
856
|
+
.pill.todo {
|
|
857
|
+
color: var(--todo);
|
|
858
|
+
border-color: color-mix(in srgb, var(--todo), white 65%);
|
|
859
|
+
}
|
|
860
|
+
.pill.active {
|
|
861
|
+
color: var(--active);
|
|
862
|
+
border-color: color-mix(in srgb, var(--active), white 65%);
|
|
863
|
+
}
|
|
864
|
+
.plan-item {
|
|
865
|
+
width: 100%;
|
|
866
|
+
text-align: left;
|
|
867
|
+
display: flex;
|
|
868
|
+
justify-content: space-between;
|
|
869
|
+
align-items: center;
|
|
870
|
+
margin-bottom: 6px;
|
|
871
|
+
}
|
|
872
|
+
.plan-item.selected {
|
|
873
|
+
border-color: var(--active);
|
|
874
|
+
background: #f1f7ff;
|
|
875
|
+
}
|
|
876
|
+
.plan-title {
|
|
877
|
+
overflow: hidden;
|
|
878
|
+
text-overflow: ellipsis;
|
|
879
|
+
white-space: nowrap;
|
|
880
|
+
max-width: 72%;
|
|
881
|
+
}
|
|
882
|
+
.plan-meta {
|
|
883
|
+
display: inline-flex;
|
|
884
|
+
gap: 4px;
|
|
885
|
+
}
|
|
886
|
+
.content {
|
|
887
|
+
white-space: pre-wrap;
|
|
888
|
+
font-size: 12px;
|
|
889
|
+
margin: 6px 0 8px;
|
|
890
|
+
}
|
|
891
|
+
.stack-form,
|
|
892
|
+
.inline-form {
|
|
893
|
+
display: grid;
|
|
894
|
+
gap: 6px;
|
|
895
|
+
}
|
|
896
|
+
.inline-form {
|
|
897
|
+
grid-template-columns: 1fr auto;
|
|
898
|
+
}
|
|
899
|
+
input,
|
|
900
|
+
textarea {
|
|
901
|
+
width: 100%;
|
|
902
|
+
border: 1px solid var(--line);
|
|
903
|
+
border-radius: 8px;
|
|
904
|
+
padding: 6px;
|
|
905
|
+
font: inherit;
|
|
906
|
+
font-size: 12px;
|
|
907
|
+
}
|
|
908
|
+
.step-card {
|
|
909
|
+
border: 1px solid var(--line);
|
|
910
|
+
border-radius: 10px;
|
|
911
|
+
padding: 8px;
|
|
912
|
+
margin-top: 8px;
|
|
913
|
+
}
|
|
914
|
+
.step-head {
|
|
915
|
+
display: flex;
|
|
916
|
+
justify-content: space-between;
|
|
917
|
+
gap: 8px;
|
|
918
|
+
}
|
|
919
|
+
.goal-list {
|
|
920
|
+
margin: 8px 0;
|
|
921
|
+
padding-left: 16px;
|
|
922
|
+
display: grid;
|
|
923
|
+
gap: 4px;
|
|
924
|
+
}
|
|
925
|
+
.goal-list li {
|
|
926
|
+
display: grid;
|
|
927
|
+
grid-template-columns: 1fr auto auto;
|
|
928
|
+
gap: 6px;
|
|
929
|
+
align-items: center;
|
|
930
|
+
}
|
|
931
|
+
.goal-text {
|
|
932
|
+
overflow: hidden;
|
|
933
|
+
text-overflow: ellipsis;
|
|
934
|
+
white-space: nowrap;
|
|
935
|
+
}
|
|
936
|
+
.empty {
|
|
937
|
+
color: var(--muted);
|
|
938
|
+
font-size: 12px;
|
|
939
|
+
}
|
|
940
|
+
.message {
|
|
941
|
+
font-size: 12px;
|
|
942
|
+
color: #8a2f19;
|
|
943
|
+
}
|
|
944
|
+
@media (min-width: 900px) {
|
|
945
|
+
#app {
|
|
946
|
+
grid-template-columns: 300px 1fr;
|
|
947
|
+
align-items: start;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
</style>
|
|
951
|
+
`
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function render(): void {
|
|
955
|
+
const root = document.getElementById("app")
|
|
956
|
+
if (!root) return
|
|
957
|
+
|
|
958
|
+
const loadingText = state.loading ? `<div class="small">${t.loading}</div>` : ""
|
|
959
|
+
const messageText = state.message ? `<div class="message">${escapeHtml(state.message)}</div>` : ""
|
|
960
|
+
|
|
961
|
+
root.innerHTML = `
|
|
962
|
+
${renderStyles()}
|
|
963
|
+
<section>
|
|
964
|
+
<div class="card">
|
|
965
|
+
<h1>${t.sidebarTitle}</h1>
|
|
966
|
+
${loadingText}
|
|
967
|
+
${messageText}
|
|
968
|
+
</div>
|
|
969
|
+
${renderRuntimeCard()}
|
|
970
|
+
<section class="card">
|
|
971
|
+
<h2>${t.planList}</h2>
|
|
972
|
+
${renderPlanList()}
|
|
973
|
+
</section>
|
|
974
|
+
${renderCreateForm()}
|
|
975
|
+
</section>
|
|
976
|
+
<section>
|
|
977
|
+
${renderPlanDetail()}
|
|
978
|
+
</section>
|
|
979
|
+
`
|
|
980
|
+
|
|
981
|
+
bindUiHandlers(root)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function subscribeEvents(): () => void {
|
|
985
|
+
const source = new EventSource(`/api/plugins/${encodeURIComponent(state.pluginId)}/events`)
|
|
986
|
+
|
|
987
|
+
const onChange = () => {
|
|
988
|
+
state.eventStatus = t.live
|
|
989
|
+
scheduleRefresh()
|
|
990
|
+
render()
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
source.onopen = () => {
|
|
994
|
+
state.eventStatus = t.live
|
|
995
|
+
render()
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
source.addEventListener("plugin.event", onChange)
|
|
999
|
+
source.addEventListener("planpilot.runtime.changed", onChange)
|
|
1000
|
+
source.onmessage = onChange
|
|
1001
|
+
source.addEventListener("heartbeat", () => {
|
|
1002
|
+
state.eventStatus = t.live
|
|
1003
|
+
render()
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
source.addEventListener("plugin.error", (event) => {
|
|
1007
|
+
const data = event instanceof MessageEvent ? String(event.data || "") : t.eventError
|
|
1008
|
+
state.message = data.length > 300 ? `${data.slice(0, 300)}...` : data
|
|
1009
|
+
state.eventStatus = t.eventError
|
|
1010
|
+
render()
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
source.onerror = () => {
|
|
1014
|
+
state.eventStatus = t.reconnecting
|
|
1015
|
+
render()
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return () => {
|
|
1019
|
+
source.close()
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
void (async () => {
|
|
1024
|
+
render()
|
|
1025
|
+
await refreshAll()
|
|
1026
|
+
const stop = subscribeEvents()
|
|
1027
|
+
window.addEventListener("beforeunload", () => {
|
|
1028
|
+
stop()
|
|
1029
|
+
})
|
|
1030
|
+
})()
|